Native iOS SDK — Backend Contracts & Flutter SDK Study Notes
Research for building the native iOS SDK (optolink-ios/, placeholder today).
Sources: optolink-backend/src/ (controllers/DTOs read verbatim), optolink-flutter/ (full lib/ + ios/),
optolink-node/src/ (conventions skim). Frozen snapshot — re-check controllers before coding.
1. SDK-Facing Backend Endpoints
Base URL = the org's resolution domain (e.g. https://links.myapp.com); every endpoint below lives on
that host, not on a separate API origin. The catch-all @Controller(':orgKey') (ResolutionModule) is
registered last in app.module.ts; literal prefixes (match, sdk, links, .well-known, webhooks/*,
health, dev) are safe from shadowing.
| # | Route | Method | Auth | Purpose |
|---|---|---|---|---|
| 1 | /:orgKey/:shortCode | GET | none (public) | Redirect HTML page (browser click entry) |
| 2 | /:orgKey/:shortCode/data | GET | none (public, optional ?deviceId=) | JSON link data for SDK direct opens (Universal Link) |
| 3 | /match | POST | none required; optional X-API-Key header | Deferred deep link matching after install |
| 4 | /sdk/session | POST | Authorization: Bearer opl_sdk_… (CLIENT tier) | DeviceProfile upsert (init hook) |
| 5 | /sdk/identity | POST | Bearer opl_sdk_… | Bind device → end user (setIdentity) |
| 6 | /sdk/identity/clear | POST | Bearer opl_sdk_… | Unbind device (clearIdentity) |
| 7 | /sdk/events | POST | Bearer opl_sdk_… | Custom app event (trackEvent) |
| 8 | /links, /links/:id, /links/:id/qr.svg, /links/:id/qr.png | GET/POST/PATCH/DELETE | Bearer opl_api_… (SERVER tier) | Link CRUD + QR — server SDK only, not for iOS SDK |
| 9 | /.well-known/apple-app-site-association | GET | none | AASA — Universal Links enablement (served per Host) |
| 10 | /dev/deferred/simulate | POST | none (NODE_ENV !== 'production' only) | Deferred-flow simulator for dev |
1.1 GET /:orgKey/:shortCode/data (direct open JSON)
orgKey: exactly 4 alphanumeric chars (ParseOrgKeyPipe:/^[A-Za-z0-9]\{4}$/), else 400.- Query:
deviceId(optional) — fire-and-forget DeviceProfile upsert. - Response 200:
\{ "linkId": string, "path": string, "params": object }(params = link→template merge, per-link wins). - Side effect: full click-track (fire-and-forget) — the SDK must NOT additionally report the click.
- Rate limit: 500/min per IP (
rl:resolve).
1.2 POST /match (deferred matching)
- Headers:
Content-Type: application/json. Auth NOT enforced by the guard; SDK MAY sendX-API-Key: <key>so that no-match drop-offs are still attributed to the org (audit recordssdk:<first8>). Flutter sendsX-API-Keyhere (andX-OptoLink-SDKversion header — backend never reads it, but useful for ops). - Rate limit: 30/min per IP (anti brute-force token enumeration). Audited via
AuditInterceptor. - Request body (
MatchRequestDto, all fields optional):Field Type Constraints Notes clipboardTokenstring ≤100 opl_…token written by the redirect page (P1)installReferrerstring ≤100 Parsed Play referrer click token (Android-only; iOS omits) deviceIdstring 8–100 SDK-generated stable UUID; upserts DeviceProfile on every attempt platformstring — "iOS"(must match ua-parser's platform label)osVersionstring — e.g. "17.4.1"deviceModelstring — e.g. "iPhone16,1"languagestring — e.g. "en-US"(hyphen format)timezonestring — IANA, e.g. "America/New_York"Unknown fields are rejected (whitelist validation) — send only these. - Response 200, matched:
\{ "matched": true, "matchMethod": "CLIPBOARD"|"INSTALL_REFERRER"|"FINGERPRINT_EXACT"|"FINGERPRINT_SCORED"|"IP_FUZZY", "matchConfidence": "exact"|"high"|"medium"|"low", "matchScore"?: number (only when scored), "linkId": string, "path": string, "params": object } - Response 200, no match:
\{ "matched": false, "matchMethod": "NONE" } - 4xx never retried; 5xx/network retried with exponential backoff (Flutter: 1s, 2s, 4s; maxRetries=2; timeout 10s).
1.3 /sdk/* (identity + telemetry)
All: Authorization: Bearer opl_sdk_… (CLIENT tier — a SERVER opl_api_ key gets 403; wrong/missing key →
401; unknown deviceId → 404). Rate limits: session/identity 60/min (rl:sdk), events 600/min (rl:sdk-events).
Bodies are whitelist-validated; only documented fields accepted.
POST /sdk/session— body\{ deviceId (required, 8–100), platform?, osVersion?, deviceModel? }→ 200\{ "ok": true, "deviceId", "firstSeenAt", "lastSeenAt" }. Call unconditionally on init when no/matchfired this launch.POST /sdk/identity— body\{ deviceId, externalId (1–255), platform?, osVersion?, deviceModel? }→ binding state. 404 if device never had a session.POST /sdk/identity/clear— body\{ deviceId }→ binding closed.POST /sdk/events— body\{ deviceId, eventName (1–100), properties? (JSON object, ≤ 10 240 bytes serialized — **rejected**, never truncated) }→ 200 recorded. Explicit instrumentation only; SDK never auto-fires.
1.4 Error envelope (all guarded endpoints)
NestJS global filter shape — matches optolink-node's ErrorEnvelope:
\{ "message": string, "details"?: [\{ "message", "field"? }], "requestId"? } (400 validation has details;
429 body message parses as Retry after N second(s)).
2. Match/Fingerprint Contract (what the backend actually scores)
Token storage (browser click → Redis MatchRecord), match-store.service.ts:
matchWindowTTL =link.matchWindow ?? template.matchWindow ?? 24hours (1–720 allowed). Records are keyed by hashed IP; per-IP caps: 50 fingerprint candidates, 20 fuzzy candidates.- Clipboard token format:
opl_+ 22 base64url chars (randomBytes(16)). After a token match the key is kept aliveGRACE_SECONDS = 30(SDK retry tolerance), then gone.
Priority chain (first hit wins): P1 clipboard → P1.5 install referrer → P2 decomposed-fingerprint exact (no raw UA: ip + platform + osVersion + deviceModel all equal) → P2.5 weighted scoring → P3 IP-only fuzzy.
P2.5 scoring (SCORE_WEIGHTS / threshold 65, max 30+10+15+8+10+10+12+9 ≈ 104):
| Component | Points |
|---|---|
| IP exact | 30 |
| platform match | 10 |
| osVersion exact | 15 (major-only: 8) |
| language match | 10 |
| timezone match | 10 |
| deviceModel match | 12 |
| recency bonus | <5min:+9, <30min:+7, <60min:+5, <6h:+3, <12h:+1 |
- Score ≥ 65 = match; confidence: ≥85
high, ≥70medium, elselow. P2 exact →high; P3 →low. - Anti-false-positive: if > 3 candidates on the same IP score ≥ 65 → reject all (shared-Network guard).
- Max 30 match attempts/min/IP; clipboard token match is "exact" confidence; matched records get a 30s grace TTL so duplicate/retried calls within 30s still return the match.
- Implication for iOS:
language,timezone,osVersion,deviceModelmust be collected in browser-equivalent formats (hyphen locale, IANA tz,systemVersion,machine) or P2/P2.5 degrade to IP-only.
3. Universal Links / AASA (backend side)
GET /.well-known/apple-app-site-association (per Host header):
\{ applinks: \{ apps: [], details: [\{ appIDs: ["<teamId>.<bundleId>"], components: [\{ "/": "/<orgKey>/*" }] }] }, webcredentials: \{ apps: [...] } }. Aggregated across all ACTIVE orgs on the platform default domain;
single-org on custom domains; orgs without teamId are skipped (never publish invalid appIDs);
Cache-Control: max-age=3600. iOS SDK must handle /\{orgKey}/\{shortCode} opens landing in-app → call
endpoint 1.2 (/data) for JSON, never the HTML redirect.
4. Flutter SDK — Dart API & iOS side, file by file
pubspec.yaml v1.2.0. Deps: app_links ≥6.4 (Universal/App Links + schemes), http, shared_preferences,
device_info_plus, flutter_timezone. Pure-Dart plugin; no platform channel for iOS logic.
| File | Role | iOS-relevant mechanics |
|---|---|---|
lib/src/optolink.dart | OptoLink.initialize(config) singleton; onLink broadcast stream (replays deferred result to first subscriber); getInitialLink(); resolveDeferredLink(); setIdentity/clearIdentity/trackEvent; _registerSession() fallback when no match fired | Regex ^/([a-z0-9]\{4})/([^/]+)/?$ extracts shortCode only when path orgKey == configured orgKey; then GETs /data?deviceId=… and wraps result with matchMethod: DIRECT |
lib/src/config.dart | OptoLinkConfig(apiKey, orgKey, baseUrl, timeout=10s, maxRetries=2, clipboardEnabled=true, logger) | clipboardEnabled=false avoids the iOS 16+ paste banner |
lib/src/match_client.dart | All HTTP: POST /match (X-API-Key header), /sdk/* (Authorization: Bearer — deliberate difference), GET /\{orgKey}/\{code}/data; retries w/ backoff on 5xx/network only; X-OptoLink-SDK: flutter/1.0.0 header | Errors never throw to caller — null/false + logger callback (warning/error levels) |
lib/src/deferred_link_handler.dart | First-launch-only orchestration: parallel clipboard+referrer read → fingerprint → /match → mark done (regardless of result) → clear clipboard | The whole deferred flow lives here, in Dart |
lib/src/clipboard_reader.dart | Reads opl_-prefixed token (≤100 chars) via Clipboard.getData; clears after use | Uses Flutter services → UIPasteboard under the hood; paste banner applies |
lib/src/device_id_store.dart | UUIDv4 in SharedPreferences (iOS UserDefaults), key optolink_device_id; lost on reinstall → re-triggers deferred flow | |
lib/src/fingerprint_collector.dart | \{platform: "iOS", osVersion: systemVersion, deviceModel: utsname.machine, language: localeName ('_'→'-'), timezone: flutter_timezone} — screen size deliberately excluded (CSS-vs-physical px mismatch) | device_info_plus IosDeviceInfo, flutter_timezone |
lib/src/first_launch_detector.dart | Bool flag in SharedPreferences optolink_first_launch_done | |
lib/src/install_referrer_reader.dart | Method-channel to Android InstallReferrerClient; returns null on iOS by design | |
lib/src/deep_link_handler.dart | app_links package: getInitialLink() (cold) + uriLinkStream (warm) | Covers iOS Universal Links + custom schemes via the package |
lib/src/link_data.dart | OptoLinkData \{ linkId?, path, params?, matchType, confidence(enum exact/high/medium/low), isDeferred }; parses /match + /data responses |
The iOS folder — what the "native" side actually contains
ios/Classes/OptolinkFlutterPlugin.swift— 19 lines, a stub. Registers method channelcom.optomatica.optolink_flutter/channel,handle()returnsFlutterMethodNotImplementedfor everything. Comment: Dart guards referrer behindPlatform.isAndroid; iOS never calls native methods; notImplemented surfaces accidental calls loudly.ios/optolink_flutter.podspec— iOS 13.0+, Swift 5, no third-party pods, no privacy manifest (commented-outPrivacyInfo.xcprivacyplaceholder), excludes i386 simulator slice.
Conclusion: the Flutter plugin implements ZERO iOS functionality natively. On iOS everything is done from
Dart through pub packages: Universal Links (app_links → UIApplication/scene delegate callbacks), clipboard
(UIPasteboard via flutter services), device info (Sysctl/utsname via device_info_plus), timezone
(NSTimeZone via flutter_timezone), persistence (UserDefaults via shared_preferences).
iOS-relevant gaps inherited from that architecture
- No native Universal-Link integration code —
app_linksowns scene-connection/swizzle; a native SDK must implementNSUserActivity/UIScenecontinuationUserActivity(or SwiftUI.onOpenURL) itself, plus 冷-start handoff. - iOS paste banner (iOS 16+) fires on first-launch clipboard read; only mitigation today is a config flag.
No Support-PPL
/copy-linkdetection, noUIPasteboarddetection APIs (iOS 16.1detectPatterns) used. - No install-referrer equivalent is possible on iOS — P1.5 is Android-only; iOS matching relies on clipboard (P1) + fingerprint (P2/P2.5) + IP (P3) only.
- Flutter's
X-OptoLink-SDKheader is a hard-codedflutter/1.0.0string — a native SDK should send a truthfulios/<version>value. shared_preferences-stored deviceId is invisible to a native SDK — if both SDKs ever run in one app (Flutter host + native module), they would mint different deviceIds; a shared Keychain/app-group location would fix that (not needed for standalone native SDK).
5. Auth Model (how an app is identified)
- Keys are org-scoped, fixed-purpose, prefix-indexed (
auth/services/api-key-hash.service.ts):opl_sdk_…→CLIENTtier →/sdk/*endpoints (and optionally/matchattribution).opl_api_…→SERVERtier →/linksCRUD. Server keys on client endpoints = 403 (and vice versa).
- Guard:
ApiKeyAuthGuard—Authorization: Bearer <raw>; lookup by key prefix, hash-verify (hashedKeystored, never the raw key), org must beACTIVE, key not revoked/expired. Superseded keys keep working for a 24h grace window after regeneration. /matchhas no guard (public, rate-limited 30/min/IP) — theX-API-Keyheader there is optional, only used to attribute no-match drop-offs (audit idsdk:<first8>)./sdk/*+/linksuseAuthorization: Bearer; Flutter sendsX-API-Keyonly on/matchand/data— replicate this exact split in the iOS SDK.- Consequence: an iOS app embeds its CLIENT key (
opl_sdk_…) in the binary — it's a public-by-design identifier; rate limits + tier + org status are the controls. Never ship anopl_api_key in an app.
6. Conventions worth carrying into the native iOS SDK
From the Flutter SDK (behavioral parity):
- Naming:
initialize(config)(async, once, singleton),onLinkstream,getInitialLink(),resolveDeferredLink(),setIdentity(externalId),clearIdentity(),trackEvent(name, properties), data type namedOptoLinkDatawithlinkId/path/params/matchType/confidence/isDeferred. - Config:
apiKey,orgKey,baseUrl,timeout(10s default),maxRetries(2),clipboardEnabled, logger callback with warning/error levels. - Retry policy: exponential backoff 1s/2s/4s on network + 5xx only; never retry 4xx;
false/nilreturns instead of thrown errors; failures surfaced via logger. - Send
X-OptoLink-SDK: ios/<x.y.z>header; verify orgKey in incoming URLs before hitting/data; deviceId = UUIDv4 persisted across launches, wiped on reinstall (do NOT persist in Keychain unless intentionally surviving reinstall — that would break deferred re-match); first-launch flag semantics identical to Flutter (mark done even when match fails). - Fingerprint values: platform
"iOS",osVersion=UIDevice.systemVersion,deviceModel=utsname.machine(e.g.iPhone16,1),language= hyphenated locale,timezone= IANA identifier.
From optolink-node (error/naming conventions):
- Two error types only:
OptoLinkError(status,message,details[],requestId,retryAfterSecondsparsed from 429 body,raw) and connection-error variant (status 0). No per-status subclasses. → Swift equivalent: oneOptoLinkErrorenum/struct withstatus,requestId,details,retryAfter, plus a.connectioncase. - Client classes grouped as resources (
client.links.create/list/update/delete/…); constructor validates apiKey eagerly (TypeErrorif empty); trailing slash stripped from baseUrl. - Parse
Retry after N second(s)from 429 message into structuredretryAfterSeconds.
Swift-native additions to decide (not settled anywhere yet): Availability (iOS 13 floor like the podspec),
privacy manifest (PrivacyInfo.xcprivacy — required reason APIs for UserDefaults; pasteboard usage),
Swift concurrency (async/await + AsyncStream for onLink), URLSession with structured concurrency
retries, App Groups/Keychain only if Flutter-coexistence matters.
7. Open questions for the native SDK build
- Clipboard on iOS: keep Flutter behavior (read on first launch, accept banner) vs iOS 16.1+
UIPasteboard.detectPatterns(no banner) — needs a product decision. - Deferred flow trigger: Flutter blocks inside
initialize(); native Swift should probably make the match call async-delivered viaonLinkto avoid blocking app startup on a 10s-timeout network call. matchMethodresponse uses SCREAMING_SNAKE (FINGERPRINT_SCORED), Flutter exposes it verbatim asmatchTypewhile normalizingDIRECTlocally — native SDK should map to a Swift enum, documenting the raw-string passthrough.- Whether the iOS SDK should call
/sdk/sessionfromapplication:didFinishLaunchingAND scene activation (Flutter only does it when no match fired) — keep parity: once per launch, only when no/matchand no/datacall happened.