feat(session): optional sessionKey resolver for advertiser-scoped state - #60
Open
goeric wants to merge 1 commit into
Open
feat(session): optional sessionKey resolver for advertiser-scoped state#60goeric wants to merge 1 commit into
goeric wants to merge 1 commit into
Conversation
ingest keys stand-down state by the landing hostname with no way to override it, so a merchant's www, checkout and alternate-ccTLD hosts are unrelated sessions and a stand-down opened on one does not survive navigation to another. Add an opt-in sessionKey resolver that controls only which bucket state is filed under. Detection is untouched: advertiserHosts rules and referrer classification still see the real hostname, and both SessionRecord and ExemptionRecord keep holding it, so only the map key changes. upsertSessionRecord and recordSessionExemptions each take the bucket key and the real host as separate arguments so their records agree on what advertiserHost means. The resolver takes the URL rather than Signals. The read paths only ever have a URL — a tab id resolves to a URL and nothing more — so a resolver keying off any other Signals field (selfPatterns, redirectChain, initiator, signalCoverage) is unimplementable on the read path by construction: it would return one key on write and a different key on read. A URL argument makes that unrepresentable rather than merely documented. The resolver must also be deterministic (I7) — webext resolves once to cache the tab's key and ingest resolves again to file state, so a varying key would desynchronise them. The resolved key is normalized the way the write paths normalize hosts — otherwise a mixed-case or trailing-dot key files an exemption under one string and looks it up under another, standing the integrator down against its own traffic. An empty return falls back to the landing hostname, and a resolver that throws fails the navigation closed (session-key-error) rather than rejecting ingest, keeping every error path a decision. shouldStandDown deliberately does not run the resolver — it stays a lookup by whatever key it is given — so resolveSessionKey(url) exposes the same resolution for the read side. The content / webext / url adapters all accept the option, and the webext and content read paths resolve through it, so a tab or location read cannot land on a different bucket than the navigation wrote. When a navigation resolves to no key at all, webext also drops the tab's cached key, so the next read re-resolves and fails closed with it instead of answering out of the previous page's bucket. Default behaviour is unchanged when the option is omitted.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
An opt-in
sessionKey?: (url: string) => string | undefinedonStanddownSession, controlling which key stand-down state is filed under, plus aresolveSessionKey(url)method so the read side can resolve the same key. Plumbed through thewebext,content, andurladapters. Omitted, nothing changes.Why
ingestderives its state key from the landing hostname (hostFromUrl(signals.url)) and the constructor exposes no override, sowww.merchant.example,checkout.merchant.example, and a merchant's alternate ccTLD are three unrelated sessions. A stand-down that forms on the landing host does not survive the user's navigation to checkout.Rewriting
signals.urlbefore ingest is not a workaround — detection matches against the real URL.Approach
The hostname currently does two jobs. This separates them:
detection.advertiserHosts, referrer classificationdetect.ts:40session.tsSessionRecord.advertiserHoststill holds the detected hostname; only the map key changes.upsertSessionRecordandrecordSessionExemptionseach take the key and the host as separate parameters, soclassifyReferrerkeeps suffix-matching against a real host.One consequence worth stating plainly:
Object.keys(state.sessions)[i] === state.sessions[k].advertiserHostno longer holds when a resolver is configured. The key is the bucket; the field is the most recent real host that touched it.Why the resolver takes a URL, not
SignalsAn earlier revision took the full
Signals. That is the wrong contract: on a read path only a URL exists — a tab id resolves to a URL and nothing more — so a resolver keying offselfPatterns,redirectChain,initiator, orsignalCoverageis unimplementable on the read path by construction. With the wider type it would return one key on write and another on read, which is the write-here/read-there failure this option otherwise prevents. Narrowing tourl: stringmakes that unrepresentable instead of merely documented, and the resolver must be deterministic (SPEC.mdI7) becausewebextresolves once to cache and once viaingest.The sharp edge, deliberately
One key groups both stand-down sessions and self-exemptions, and those have opposite safety directions:
I kept one key rather than splitting them. A split (custom key for sessions, exact host for exemptions) looks safer but drops a granted exemption on navigation to the checkout host and stands the integrator down to their own traffic — the documented revenue-leak failure. So the contract is documented instead: return a key no broader than the advertiser whose attribution you control. Same posture as
selfPatternsvalue-matching. The resolved key is normalised (normalizeHost) at the single resolution point, so a resolver returningMerchant.Example.cannot file state under one spelling and look it up under another.A resolver that throws produces a fail-closed decision (
session-key-error), matching howmalformed-policy,invalid-url, andstore-errorare already handled — a user-supplied callback should not be the one path that can reject.Note on the
webextread sideThe largest non-obvious hunk:
tabHostsbecametabStateKeysand caches the resolved key rather than the raw host, andhostForTabbecameurlForTab. Caching the host would guarantee a read-side miss the moment a resolver is configured, sinceingestfiles state under the resolved key. The cache is cleared ononRemoved, ondispose, and whenever resolution yieldsundefined, so a failed resolution cannot leave a previous page's key answering for the current one.Tests
tests/session-key.test.tsplus one end-to-end test per adapter: default behaviour unchanged; session and exemption shared across hosts under one key; a non-normalised key still shares correctly;undefinedand''fall back to the landing hostname; a throwing resolver fails closed rather than rejecting;resolveSessionKeyagrees withingest; the detected host stays on the record and in the audit log; and the stale-cache path.184 tests pass (167 baseline + 17). The
auditharness passes 30/30 against a fresh build.Note
Proposal as much as patch — this is the shape that fits the gap cleanly, but if you would rather solve advertiser identity a different way, say so and I will rework or close it. The adapter plumbing is included because an option only reachable from the core class, when all three adapters construct the session for you, is half a feature — but it is easy to split out if you would rather review the core alone first.
Checklist
npm run typecheck,npm run lint,npm test, andnpm run buildpasspolicies-cite-checkis untouchedSPEC.mdconstructor options updated forsessionKeyandresolveSessionKey; happy to add README coverage if you want it there tooSignals(I2)Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.