Collector Ingest Trust Model
:::caution Pre-release This is a pre-1.0 library (v0.1.1) — API may change without notice. :::
Read this before registering a second site in one deployment. It is about the limit of the collector's origin-claim mechanism, not just how it works.
The collector attributes every event to a site_id, and that site_id comes
from exactly one place: the request's Origin, looked up in the site
registry. The request body has no site_id field — a hand-rolled client that
sends one anyway is ignored.
The chain
Implemented across
packages/journey-recorder/src/server/with-token-middleware.ts,
token.ts,
site-registry.ts, and
collect-handler.ts:
- Mint —
withJourneyToken()readsOrigin, looks it up in the site registry, and refuses (403, nothing signed) if no site claims it. Otherwise it signs a 15-minute token carrying that origin as a claim. - Collect —
createCollectHandler()looks the request origin up again (an installer can misconfigure the middleware matcher, and a token can outlive a site's removal from the registry), verifies the token against that site's secrets, and requires the token'soriginclaim to equal the origin the request presents.
Each step's failure is distinguishable: 403 forbidden_origin means "no site
owns this origin"; 401 invalid_token means "the token is absent, expired, or
minted for somewhere else".
What that stops
- A body claiming another tenant's
site_id. - An origin belonging to no registered site — at mint and at collect.
- A token minted for site A replayed against site B's origin.
- Page script lying about its origin —
Originis browser-controlled and cannot be set from JavaScript.
Collectively: a confused client. A staging subdomain nobody registered, a snippet copy-pasted from another property, a misconfigured install, a hand-rolled beacon. That is the realistic failure mode, and it is fully covered.
What it does not stop
An adversarial client that is not a browser:
curl -X POST https://collector.example/api/journey/collect \
-H 'Origin: https://a-registered-site.example' ...
gets a token minted for that origin, and its events are attributed to that site. Nothing in an unauthenticated first-party beacon endpoint can distinguish that from a real browser: any credential capable of proving otherwise would have to be shipped to the browser, where it stops being a credential. GA4, Plausible, and Fathom all share this property — it is a property of the problem, not a defect in this implementation.
The consequence is bounded to event injection into a site you already know
the origin of. It does not grant read access to anything — reads go
through apps/agent-api, which is separately authenticated.
The mitigation is topology, not cryptography
The approved deployment shape is one app instance per site: the
collector, its middleware, and its registry are deployed alongside the site
they collect for, and the registry holds exactly one site (apps/demo and
the E2E fixtures are wired this way). In that shape, "forge a registered
origin" has no other tenant to reach — the only origin an attacker can claim
is the one whose own data they would be polluting, the same thing they could
do by driving a real browser at the page.
Cross-tenant forgery only becomes reachable in a deployment whose registry
holds more than one site. A shared multi-site collector is a legitimate
architecture, so createStaticSiteResolver does not refuse to build one — it
console.warns at boot, naming the tradeoff, so it cannot be entered
silently.
If a shared collector needs real tenant isolation, the origin claim is not enough on its own — that requires authenticated ingest (a per-site server-issued credential that never reaches the browser), a different design than the one this package implements.
Per-site ingest secrets
SiteConfig.tokenSecrets overrides the collector-wide tokenSecrets for one
site. Be clear about what this buys: not protection from origin
forgery — the collector holds every configured site's secret and signs with
whichever the claimed origin selects. What it buys is blast radius on the
secret: a leaked or rotated key invalidates one site's live tokens instead
of every site's. Both fields are ordered [current, ...rotatedOut] — every
entry verifies, the first signs. See Installation & Environment
Variables for the rotation workflow.