Skip to main content

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:

  1. MintwithJourneyToken() reads Origin, 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.
  2. CollectcreateCollectHandler() 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's origin claim 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 — Origin is 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.