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.
| Method | Path | Success | Key rejections |
|---|---|---|---|
| POST | /sdk/session | 200 {ok, deviceId, firstSeenAt, lastSeenAt} — device upsert; repeat calls refresh lastSeenAt | 401 no/invalid key; 400 deviceId shorter than 8 or longer than 100 chars |
| POST | /sdk/identity | 200 binding state (rebound: false = same-user no-op); re-binding to a different user closes the prior binding | 404 unknown deviceId (session first); 401 |
| POST | /sdk/identity/clear | 200 — binding closed, device anonymous again | 404 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'ssessionTokenembedded in the Redis MatchRecord — plusdeviceProfileId(skipped, but the row still persists, when the SDK sent nodeviceId). - No-match rows persist only when the org is resolvable: the SDK's
X-API-Keyheader (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
/matchwith no resolvable org creates no device row. /dataclicks never get asessionToken— only redirect-page clicks do, so/data-sourced matches carryclickEventId: nullby construction.- Optional
deviceIdin the/matchbody (and?deviceId=on/:orgKey/:shortCode/data) upserts the device fire-and-forget — it never blocks the response.
Rate limits on this path
| Surface | Preset | Budget |
|---|---|---|
/match | MATCH_LIMIT (rl:match) | 30/min per IP |
/sdk/session, /sdk/identity, /sdk/identity/clear | sdk (rl:sdk) | 60/min |
/sdk/events ingestion | sdkEvents (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 proxyis1(src/common/proxy-trust.ts) —req.ipis 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 ISreq.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-t008trust proxy = trueboth 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 databasewhile 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'smatchWindow) and a signal-free/matchwill IP-FUZZY-match instead of missing. Clear first:redis-cli --scan --pattern 'fp:*' | xargs -r redis-cli del(andclip:*). - 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 inoptolink-android/docs/test.mdis built on this). orgKeyis 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);org123400sInvalid orgKey.- Asserting the deferred payload on the redirect page: the deep-link
pathonly renders inside scheme/intent URLs, which appear only when the org's AppConfig sets auriScheme. 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.