Skip to content

feat(sdk): first-party collection, fetch-first transport, optional payload encryption - #9

Merged
RathodDarshil merged 6 commits into
mainfrom
tashvik/first-party-collection
Aug 21, 2026
Merged

feat(sdk): first-party collection, fetch-first transport, optional payload encryption#9
RathodDarshil merged 6 commits into
mainfrom
tashvik/first-party-collection

Conversation

@tashviks

Copy link
Copy Markdown
Contributor

Releases 0.1.14. Three related changes, all from the same finding: ad blockers match a request by its domain, so the /web/ingest rename bought time but cannot outrun a rule written against api.linkrunner.io.

What the measurement showed

On a live Playo page with a mainstream blocker installed:

Destination Transport Cleartext Encrypted
api.linkrunner.io sendBeacon cancelled cancelled
app.playo.co (CNAME to the same server) sendBeacon went through went through
api.linkrunner.io fetch went through went through

Two things fall out of that, and they're the first two changes below.

1. Fetch-first transport

sendBeacon is demoted to a fallback. The beacon was cancelled while a fetch carrying the same bytes to the same URL in the same second went through and got a real response — filter lists match the beacon/ping resource type separately, and ours is matched. Every event sent by beacon on those browsers was being dropped.

The usual argument for beacon-first is surviving page unload. It doesn't apply here: keepalive gives fetch the same guarantee, and this SDK has no unload handler — events fire on page view and explicit track() calls, during normal page life. fetch also reports a status, which is what makes a misconfigured first-party proxy visible instead of silent.

This is the fix that needs no infrastructure. It recovers blocked events on the current domain today.

2. First-party collection

New scriptSrc prop on LinkrunnerScript so the bundle can be served from the customer's own origin too — an endpoint on their domain fed by a script on ours only moves the failure one step earlier.

README documents both routes: a reverse proxy on their own origin (strongest — a same-origin path has nothing to uncloak) and a CNAME'd subdomain (one DNS record, but uBO on Firefox and Brave resolve the chain and would find us at the end of it). Includes Next.js / nginx / Cloudflare Worker snippets, the visitor-IP contract, and how to verify.

3. Payload encryption (off by default)

ECIES over P-256: ephemeral key pair per page load, ECDH to our public key, HKDF-SHA256, AES-256-GCM with a fresh IV per event. Only a public key ships in the bundle.

Both key constants are empty, and empty means cleartext exactly as before — there is deliberately no placeholder, since a wrong key would produce envelopes nothing can decrypt and lose events silently.

It is documented as what it is — payload confidentiality from TLS-terminating hops — and explicitly not as an anti-blocking measure. Cleartext and encrypted were indistinguishable in every cell above, because the request is cancelled before a body exists.

Costs, measured on a real 3.2 KB page view: +1.8 KB bundle, one ECDH per page load (not per event), payload grows ~39% (3,195 B → 4,430 B), and your own devtools show an opaque envelope.

Also

npm run dev:collector — a zero-dependency local harness serving the demo page, the SDK unbuilt from src/core.js, and a decrypting endpoint, so the whole loop runs without a backend. Includes an on-page "On the wire" panel showing the exact bytes sent.

Testing

npm test22 passing, verified over 8 consecutive runs.

test/payload-encryption.test.js loads the real shipped core.js, lets it seal a genuine page view, and decrypts it with the same primitives the Node collector uses — proving the two implementations interoperate rather than each agreeing with itself. Also fixed a flake: async waits were bounded by a fixed iteration count, which loses races against ECDH keygen under load; they're deadline-based now.

Merge order — please read

Merging this to main fires deploy-cdn.yml and browsers pick up the new bundle. The transport change is safe on its own. If you also intend to point a customer at their own subdomain, the Caddy route must be live first or those requests 405:

  1. https://github.com/linkrunner-labs/ops/pull/468 — Caddy route for customer domains
  2. https://github.com/linkrunner-labs/linkrunner-backend/pull/977 — envelope + visitor IP
  3. This PR

🤖 Generated with Claude Code

…yload encryption

Three related changes, all driven by the same problem: ad blockers match a
request by its DOMAIN, so the /web/ingest rename bought time but cannot outrun a
rule written against api.linkrunner.io.

FIRST-PARTY COLLECTION
`scriptSrc` on LinkrunnerScript so the bundle can be served from the customer's
own origin too. An endpoint on their domain fed by a script on ours only moves
the failure one step earlier. README documents both routes: a reverse proxy on
their origin (strongest — nothing to uncloak) and a CNAME'd subdomain (one DNS
record, but uBO on Firefox and Brave resolve the chain).

FETCH-FIRST TRANSPORT
sendBeacon is demoted to a fallback. Measured on a live customer page with a
mainstream blocker installed: a beacon to the collector was cancelled while a
fetch carrying the same bytes to the same url in the same second went through
and got a real response. Filter lists match the beacon/ping resource type
separately. The usual argument for beacon-first — surviving unload — does not
apply: keepalive gives fetch the same guarantee and this SDK has no unload
handler. fetch also reports a status, which is what makes a misconfigured
first-party proxy visible instead of silent.

PAYLOAD ENCRYPTION (off by default)
ECIES over P-256: ephemeral key per page load, ECDH to our public key,
HKDF-SHA256, AES-256-GCM with a fresh IV per event. Only a public key ships in
the bundle. Both key constants are empty here, and empty means cleartext exactly
as before — there is deliberately no placeholder, since a wrong key would
produce envelopes nothing can decrypt and lose events silently.

It is documented as what it is: payload confidentiality from TLS-terminating
hops between the browser and us. It does NOT help with ad blockers, which cancel
the request before a body exists — tested three ways, cleartext and encrypted
were indistinguishable in every case.

Also: a local dev collector (npm run dev:collector) that serves the demo page,
the SDK unbuilt from src/core.js, and a decrypting endpoint, so the whole loop
runs without a backend.

Releases 0.1.14.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tashviks
tashviks force-pushed the tashvik/first-party-collection branch from 405dff8 to f2c572c Compare August 20, 2026 11:58
tashviks and others added 3 commits August 20, 2026 17:57
Moving to a CNAME'd subdomain meant a site changed two things — the script src
and the endpoint — and the second is the one that gets forgotten. Forgetting it
looks exactly like working: the bundle loads first-party, the beacon keeps going
to api.linkrunner.io, and only the network tab says so.

When the script is served from /web/v1/ on a host that is not our CDN, the
collector is on that same host by construction, so the endpoint is derived from
it. One attribute to change instead of two.

Matched on the full /web/v1/ path rather than the host, because a site
reverse-proxying the bundle at a path of its own choosing has chosen its
collector path too and guessing /web/ingest there would be wrong. Our CDN is
excluded explicitly — it serves the bundle and no collector. Explicit config
still wins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Removed for two reasons, either of which is sufficient.

It could silently break an existing customer. Derivation fired whenever the
bundle was served from /web/v1/ on any host that is not our CDN — so a customer
self-hosting at that path would have had their events redirected to
<their-host>/web/ingest and 404'd, with nothing logged anywhere. The payload
carries no SDK version, so there is no query that tells us whether anyone is in
that position. Low probability, unmeasurable, silent data loss.

And it bought nothing. The justification was "one attribute instead of two",
but that compared against a strawman: without a bundle proxy the customer only
ever needed one attribute either way — set data-endpoint, or change src. The
derivation only saves anything if the script is ALSO served first-party, and
the companion ops change no longer does that, because the bundle is measurably
not blocked.

scriptSrc stays: a customer proxying the bundle on their own origin (Option 1)
still needs it. What goes is the inference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…serving

data-endpoint lets a site collect from a host it owns, which is the only thing
that survives an ad blocker's rule against our domain. The hazard is that a
CNAME and a certificate look like success while the route behind them is not
live: the LB hands an unmatched path to the branded-link handler, which is
GET-only and answers 405 to both the preflight and the POST. Measured today,
api.linkrunner.io/web/ingest answers 400 with a 204 preflight while an unrouted
customer host answers 405 with no CORS headers at all.

Without a retry, setting data-endpoint one deploy too early takes a site's
events to zero, and nothing in the payload says why. So a first-party endpoint
that fails outright, or answers 404 or 405, is retried once against the default.

400 and 5xx are deliberately excluded. Both come from our own backend, which
either host reaches: a 400 would be rejected identically on the retry, and a 5xx
may already have enqueued the event, so retrying would double-count it. The
retry cannot itself retry -- shouldFallBack is false once the endpoint IS the
default, which also stops a hard outage becoming a resend loop from every page
running the bundle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tashviks
tashviks force-pushed the tashvik/first-party-collection branch from 5a94e12 to 2d231bb Compare August 20, 2026 17:56
tashviks and others added 2 commits August 20, 2026 23:29
…arty table

data-endpoint is the better mechanism and it already exists, but using it means
the customer edits their page, and until they do they keep posting to a domain
the blocklists match. The table moves them with no change on their side, because
the bundle is served from our CDN behind a max-age=0, must-revalidate alias.

Precedence is window config, then data-endpoint, then the table, then the
default, so a customer in the table can still move or revert themselves without
waiting on a release. The lookup is typeof-guarded: a token of 'constructor' or
'toString' would otherwise resolve up the prototype chain to a function and be
handed to fetch as a URL.

Seeds it with Playo, whose app.playo.co is already CNAME'd here with a valid
certificate. The token is not a secret -- it sits in the page source of every
page they serve -- so carrying it here exposes nothing new.

Ship ops#468 first. A CNAME and a certificate are not enough to serve the
collector: until the route is live the LB hands /web/ingest to the branded-link
handler, which is GET-only and answers 405 to both the preflight and the POST.
The retry added in the previous commit is what keeps that from costing events.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@RathodDarshil
RathodDarshil merged commit db0c7b6 into main Aug 21, 2026
5 checks passed
@RathodDarshil
RathodDarshil deleted the tashvik/first-party-collection branch August 21, 2026 10:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants