Skip to main content

Device identity & match attribution

Scope: the backend write path that turns SDK traffic into DeviceProfile and LinkMatch rows. The read side (aggregations, MAU, template performance) is Analytics architecture; the wire contracts the SDKs code against are backlog B-018 (../sdk-contracts/).

Source-checked against optolink-backend @ 29f8589519ac85445ed0598c1b9cfc125a9dd6f2, 2026-10-06 (src/identity/sdk.controller.ts, src/identity/dto/sdk.dto.ts, src/resolution/match-persistence.service.ts, src/resolution/match.controller.ts, src/resolution/resolution.controller.ts, src/rate-limiting/rate-limit.decorator.ts).

Device identity — /sdk/*​

src/identity/sdk.controller.ts: ApiKeyAuthGuard + RequireApiKeyTier(CLIENT) — a valid SERVER-tier key is 403 here, not 401. Rate limit RateLimitPresets.sdk (60/min). The SDK calls /sdk/session from its init hook on every app open that didn't go through /match or a direct link open, so every device gets a DeviceProfile even with zero link interaction.

MethodPathSuccessKey rejections
POST/sdk/session200 {ok, deviceId, firstSeenAt, lastSeenAt} — device upsert; repeat calls refresh lastSeenAt401 no/invalid key; 400 deviceId shorter than 8 or longer than 100 chars
POST/sdk/identity200 binding state (rebound: false = same-user no-op); re-binding to a different user closes the prior binding404 unknown deviceId (session first); 401
POST/sdk/identity/clear200 — binding closed, device anonymous again404 unknown deviceId

Audit asymmetry (deliberate): setIdentity/clearIdentity write AuditLog rows (SET_IDENTITY/CLEAR_IDENTITY actions on DeviceProfile); /sdk/session is not audited — it's high-volume telemetry, like click tracking.

Match attribution — /match persistence (P6-003)​

/match (src/resolution/match.controller.ts, rate limit 30/min, rl:match) persists every attempt as a LinkMatch row via MatchPersistenceService:

  • Matched rows carry clickEventId — resolved via the click's sessionToken embedded in the Redis MatchRecord — plus deviceProfileId (skipped, but the row still persists, when the SDK sent no deviceId).
  • No-match rows persist only when the org is resolvable: the SDK's X-API-Key header (soft lookup — an invalid key is ignored, not a 401) identifies the org so drop-offs are visible. No key + no matched org → no row.
  • An anonymous /match with no resolvable org creates no device row.
  • /data clicks never get a sessionToken — only redirect-page clicks do, so /data-sourced matches carry clickEventId: null by construction.
  • Optional deviceId in the /match body (and ?deviceId= on /:orgKey/:shortCode/data) upserts the device fire-and-forget — it never blocks the response.

Rate limits on this path​

SurfacePresetBudget
/matchMATCH_LIMIT (rl:match)30/min per IP
/sdk/session, /sdk/identity, /sdk/identity/clearsdk (rl:sdk)60/min
/sdk/events ingestionsdkEvents (rl:sdk-events)600/min, keyed per API key

Client hints & IP attribution (click side)​

  • The non-bot redirect response emits Accept-CH: Sec-CH-Timezone (src/resolution/resolution.controller.ts) so repeat browsers volunteer the timezone hint used by deferred fingerprinting.
  • Client hints are opt-in per origin — the FIRST click on a domain still stores timezone '', and the canonical click→install→open deferred journey IS a first click, so P2 exact stays unreachable for genuinely new visitors; it works from the second visit onward (hint persisted origin-wide). Critical-CH (request replay) would fix first-click capture but double-fires click tracking — declined (t008 spec decision, 2026-09-24).
  • IP attribution is spoof-resistant: trust proxy is 1 (src/common/proxy-trust.ts) — req.ip is the entry the directly-connected proxy appended to X-Forwarded-For; client-supplied leftmost entries are ignored. Express depth-1 semantics: a SINGLE XFF entry IS req.ip (only entry = router-appended); two entries = rightmost wins. The 30/min rate limit and the P3 IP-fuzzy bucket key on this value (under the pre-t008 trust proxy = true both were header-bypassable).

Source-checked against optolink-backend @ 29f8589, 2026-10-06 (resolution.controller.ts, proxy-trust.ts). The per-origin first-click hint behavior is recorded (live-verified 2026-09-22) — browser protocol behavior, not provable from this repo.

Verification recipe (local backend; live-proven 2026-09-22, re-verify on next use):

UA='Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36'
# 1. opt-in response carries the header:
curl -s -o /dev/null -D - localhost:3000/demo/welcome1 -A "$UA" -H 'Accept-Language: en-US' | grep -i accept-ch
# 2. hint-aware repeat click (browser opted in on first visit):
curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/demo/welcome1 -A "$UA" \
-H 'Accept-Language: en-US' -H 'Sec-CH-Timezone: America/New_York'
# 3. SDK-shaped match → FINGERPRINT_EXACT / high (201):
curl -s -X POST localhost:3000/match -H 'Content-Type: application/json' \
-d '{"platform":"Android","osVersion":"14","deviceModel":"SM-S918B","language":"en-US","timezone":"America/New_York"}'

Runtime-testing gotchas​

Recorded against earlier SHAs (P6-002/P6-003 gates, 2026-09); the mechanics above are re-verified in code at the SHA above. Re-verify live on next use.

  • The e2e suite runs against optolink_test (.env.test) — a new migration must be applied to BOTH databases: DATABASE_URL=postgresql://postgres:…@localhost:5432/optolink_test?schema=public pnpm prisma migrate deploy. Symptom of skipping it: The column "ClickEvent.sessionToken" does not exist in the current database while unit tests pass.
  • No-match curls need a clean match store. A prior redirect click from 127.0.0.1 leaves fp:* Redis records (TTL = the link's matchWindow) and a signal-free /match will IP-FUZZY-match instead of missing. Clear first: redis-cli --scan --pattern 'fp:*' | xargs -r redis-cli del (and clip:*).
  • IP-bucket discipline: match-store records are keyed by source IP, so localhost, 127.0.0.1, and [::1] are three different buckets. Probe redirect pages only via the literal you intend (127.0.0.1); use [::1] as a never-clicked bucket for deterministic no-match scenarios (the Android live-e2e suite in optolink-android/docs/test.md is built on this).
  • orgKey is exactly 4 alphanumeric chars (ParseOrgKeyPipe, src/common/pipes/parse-org-key.pipe.ts — it must not shadow static routes like /.well-known/apple-app-site-association). Test orgs: node -e 'console.log(require("crypto").randomBytes(2).toString("hex"))' (4 hex chars); org123 400s Invalid orgKey.
  • Asserting the deferred payload on the redirect page: the deep-link path only renders inside scheme/intent URLs, which appear only when the org's AppConfig sets a uriScheme. The always-present assertion target is the hidden clipboard input: id="cbt" value="…" (src/resolution/redirect-page.service.ts) — non-empty means the clipboard token survived, including the template-inherited case.
  • Seeded demo data is funnel-correlated: ~80% of seeded clicks carry a token and all derived matches carry clickEventId — don't treat those nulls as bugs in fresh fixtures.