Skip to content

feat(sdk): route collection through a customer-named subdomain (data-domain) - #10

Merged
tashviks merged 7 commits into
mainfrom
tashvik/web-sdk-domain-routing
Aug 27, 2026
Merged

feat(sdk): route collection through a customer-named subdomain (data-domain)#10
tashviks merged 7 commits into
mainfrom
tashvik/web-sdk-domain-routing

Conversation

@tashviks

@tashviks tashviks commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

What

Adds data-domain to the script tag so an integrating customer names their own collection subdomain, and the SDK posts events there instead of to api.linkrunner.io.

<script
  src="https://cdn.linkrunner.io/web/v1/lr.js"
  data-token="YOUR_PROJECT_TOKEN"
  data-domain="lr.your-domain.com"
  defer
></script>

Also available as LinkrunnerConfig.domain and as a domain prop on <LinkrunnerScript>.

Why

Ad blockers match a request by its domain, not its path. Moving off /web/collect bought time against rules matching the word "collect"; it does nothing about a rule written against api.linkrunner.io, and those exist.

Until now the only way onto a first-party host was FIRST_PARTY_ENDPOINTS, the token-keyed table compiled into the bundle. It works without the customer touching their page, but costs a release from us per customer and every entry is a hostname we hand-verified. It does not scale past the one customer in it. This makes it self-serve.

Precedence

Source Wins over Notes
data-endpoint / config.endpoint everything Unchanged. A site proxying through its own origin has a path we don't own.
data-domain / config.domain table, default New. Host only; we build the URL.
FIRST_PARTY_ENDPOINTS[token] default Playo and Meatigo. Now outranked by both, so either can move or revert from their own page. Stays the fallback for customers who cannot ship a page change.
https://api.linkrunner.io/web/ingest

A host, not a URL

The collection path is ours and has already moved once. A customer who writes the full URL into their page is stranded on the old path the next time it moves; one who names only the host follows us for free. normalizeDomain() accepts anything they plausibly paste (scheme, trailing slash, the full endpoint URL, padded whitespace) and rebuilds https://<host>/web/ingest from the new COLLECT_PATH constant.

A value that isn't a hostname is refused, not repaired — a mis-parsed host would take that customer's events to zero while their network tab still showed healthy 2xxs, whereas falling back costs only the blocked visitors. Credentials in the authority are refused for the same reason: stripping them resolves lr.their-site.com@evil.test to evil.test, the one way a wrong host reads as right. An IDN host is punycoded rather than refused, since rejecting it would leave a correctly-configured customer on the blocked default.

Widened fallback — the one behavioural change to existing integrations

shouldFallBack now retries on 0, 403, 404, 405, 502, 503, up from 0, 404, 405.

The rule is checkable rather than a judgement call: POST /web/ingest answers 204, 400, 401, 413, 500 and nothing else (backend controllers/web-collect.ts), so any other status did not come from the collector — nothing was recorded and a retry cannot double-count.

This matters now the host is whatever the customer typed rather than one we verified by hand: their WAF, CDN or auth proxy is in the path and speaks 403/502 where our collector never would. Without it, an event behind a WAF was lost outright while the README promised it wouldn't be.

504 stays excluded alongside the collector's own statuses — a timeout is the one gateway status that can mean the request was processed and only the response was lost. Note the retry re-sends a byte-identical body (same event_id), and the pre-existing status === 0 retry already had this same property, so this extends an accepted trade-off rather than introducing a new one.

Backward compatibility

The bundle is served behind max-age=0, must-revalidate, so every integrated page picks this up on its next load. Verified by differential test against origin/main rather than by reasoning: 8 already-integrated page shapes (bare token, SPA on, the Playo token, data-endpoint absolute, data-endpoint same-origin path, window-config endpoint, a prototype-colliding token, no token at all) x 13 response statuses = 104 scenarios.

  • Payload identical — 79 fields, same values
  • window.lr surface identical_q, _version, identify, track
  • Endpoint selection identical for all 8 legacy shapes
  • 12 differences, all intended: a non-default host answering 403/502/503. Those events were dropped before and are delivered now. Nothing that previously succeeded behaves differently.

Additive-only on the TS side: domain?: string on the config type and the LinkrunnerScript props. Existing call sites compile and render unchanged.

Tests

48 passing, up from 43. New coverage: host resolution from attribute and global config; six paste variants normalising to one URL; six unusable values falling back; credentials refused across three forms; IDN → punycode; data-domain over the table and data-endpoint over data-domain; an invalid value falling to the table rather than past it; each retryable status (403/404/502/503) delivering exactly one event; each collector status (400/401/413/504) not retrying; and the two rejection paths emitting distinguishable console messages.

Release

Version bumped 0.1.140.1.15 across package.json, package-lock.json (root and packages['']) and window.lr._version; the CI verify-build-version step passes locally against both built bundles. Minified bundle 1518515982 bytes (+797, +5.2%).

Docs PR: linkrunner-labs/docs (companion, adds the data-domain section to /sdk/web).

Known, not addressed here

src/index.ts still hardcodes _version: '0.1.9' in the SSR/pre-load stub — pre-existing drift from package.json, untouched to keep this diff focused. CI only checks the built bundles, so it doesn't fail the build.


Added after review started: Meatigo (project 341) mapped in the table

app.meatigo.comhttps://app.meatigo.com/web/ingest, keyed to their web SDK token.

Prerequisite verified before the entry was written, not after:

$ dig +short app.meatigo.com CNAME
api.linkrunner.io.

$ curl -i -X OPTIONS -H 'Origin: https://www.meatigo.com' \
    -H 'Access-Control-Request-Method: POST' https://app.meatigo.com/web/ingest
HTTP/2 204
access-control-allow-origin: https://www.meatigo.com
access-control-allow-methods: GET,POST,OPTIONS

204, not the 405 the GET-only branded-link handler returns when /web/ingest is not routed on a host (the LIN-1998 shape). Same setup as Playo: their existing branded-link host, no page change on their side, picked up on the next load.

Both entries are now pinned by test. A typo in a token or a host is otherwise invisible — that customer's events quietly start taking the fallback path to api.linkrunner.io, which in a network tab looks like a healthy retry rather than a mistake.

Policy comment reworded. It read "do not add an entry for a customer who can change their script tag", which this commit would break as written. The real distinction is cost, not permission: an entry costs a release from us and pins a hostname we have to keep true, where data-domain takes effect on the customer's next deploy and cannot go stale on our side. The table stays the fallback for when a page change is not available.

Tests now 50.

Also added: a "What the collector checks" section in the README

Documentation only, no behaviour change. Nothing in the repo stated the endpoint's actual posture.

The endpoint validates, it does not authenticate, and the distinction is easy to miss because the validation looks like access control:

Check Behaviour
Token present 400 unless the body carries token (non-empty string) plus event_type or event_name
Token known Looked up as a WEB_SDK_TOKEN; unknown → 401. Nothing ingests without a valid one
Size 413 above 64 KB; every string field truncated at 2048 chars
Rate None — no rate limiter on /web/ingest
Origin CORS on /web reflects any origin by design, so it restricts nothing

A token really is required — but it is public by design, sitting in data-token="..." in every integrator's page source. Anyone who reads the HTML can post events with it, so the 401 proves a token exists, not that the sender is who they claim. There is also no auth middleware on the route; the check lives inside the handler.

Payload encryption would not change this: the SDK holds only a public key, so a sealed envelope is confidential, not attributable. The section ends where it should — an event that has to be trustworthy goes server-to-server from a backend holding a secret.

None of this is introduced or altered by this PR. It is written down because data-domain puts more of these requests onto hosts customers control, which makes the posture worth stating explicitly. The missing rate limiter is pre-existing and already tracked in LIN-2517.

LIN-2572 tracks turning on payload encryption, which is built on both sides but ships dark. One finding recorded there is worth flagging to anyone reviewing this area: metrics.middleware.ts:238 reads parsedBody.token for the Kafka partition key and api_metrics.project_token, and an encrypted envelope has no token field, so enabling encryption without fixing that would blank the dimension and hot-partition the topic.

🤖 Generated with Claude Code

…domain)

Ad blockers match a request by its DOMAIN. Moving off /web/collect bought time
against rules matching the word "collect"; it does nothing about a rule written
against api.linkrunner.io, and those exist. The only request that survives one
is a request to a host the site itself owns.

Until now the only way to get a customer onto their own host was
FIRST_PARTY_ENDPOINTS, a token-keyed table compiled into the bundle. That works
without the customer touching their page, but it costs a release from us per
customer and every entry is a hostname we hand-verified. It does not scale past
the one customer in it.

This adds `data-domain` (and LinkrunnerConfig.domain, and a `domain` prop on
LinkrunnerScript). The customer CNAMEs a subdomain to api.linkrunner.io, names
it on the script tag, and the beacon is first-party from that page load on.

WHY A HOST AND NOT A URL

The collection path is ours and has already moved once. A customer who writes
the full URL into their page is stranded on the old path the next time it moves;
one who names only the host follows us for free. normalizeDomain() therefore
accepts anything they plausibly paste - a scheme, a trailing slash, the full
endpoint URL, padded whitespace - and rebuilds https://<host>/web/ingest from
the new COLLECT_PATH constant.

A value that is not a hostname is refused rather than repaired, and the
resolution falls through to whatever it would have used before. A mis-parsed
host would take that customer's events to zero while their network tab still
showed healthy 2xx responses; falling back costs only the blocked visitors.
Credentials in the authority are refused for the same reason: stripping them
resolves lr.their-site.com@evil.test to evil.test, which is the one way a wrong
host reads as right. An internationalised host is converted to punycode instead
of refused, since rejecting it would leave a correctly configured customer on
the blocked default.

PRECEDENCE

  endpoint  full URL or same-origin path. Unchanged, still on top: a site
            proxying through its own origin has a path we do not own.
  domain    the host the customer CNAME'd to us. New.
  table     FIRST_PARTY_ENDPOINTS. Now outranked by both, so Playo can move or
            revert themselves without waiting on a bundle release, and the
            table is documented as closed to new entries.
  default   api.linkrunner.io.

WIDENED FALLBACK

shouldFallBack now retries on 0, 403, 404, 405, 502 and 503, up from 0, 404 and
405. The rule behind the set is checkable rather than a judgement call: POST
/web/ingest answers 204, 400, 401, 413 or 500 and nothing else, so any other
status did not come from the collector, nothing was recorded, and a retry
cannot double-count.

This matters now that the host is whatever the customer put in their script tag
rather than one we verified by hand. Their WAF, CDN or auth proxy is in the path
and speaks 403 and 502 where our collector never would; without this, an event
behind a WAF was lost outright while the README promised it would not be.

504 stays excluded alongside the collector's own statuses: a timeout is the one
gateway status that can mean the request was processed and only the response was
lost.

BACKWARD COMPATIBILITY

Verified by differential test against origin/main: 8 already-integrated page
shapes (bare token, SPA on, the Playo token, data-endpoint absolute and
same-origin, window-config endpoint, a prototype-colliding token, no token at
all) x 13 response statuses. Payload identical, 79 fields. window.lr surface
identical. Endpoint selection identical for every one of them.

The only behavioural differences are the 12 scenarios where a non-default host
answered 403, 502 or 503: those events were dropped before and are delivered
now. Nothing that previously succeeded behaves differently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread test/endpoint-selection.test.js Fixed
tashviks and others added 6 commits August 25, 2026 10:46
CodeQL flagged `credentials[0].indexOf(DEFAULT) !== -1` as
js/incomplete-url-substring-sanitization. The query is looking for a URL being
validated by substring, which is a real bug when it happens; here the haystack
is a console message rather than a URL, so nothing was being sanitized.

Matching the message in position is the better assertion regardless: it pins
that the message ENDS by naming where the events are going, which is the part
that makes it actionable, instead of merely containing the string somewhere.

No behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
app.meatigo.com is already CNAME'd to api.linkrunner.io and already serves the
collector: OPTIONS /web/ingest answered 204 with access-control-allow-origin on
2026-08-25, not the 405 the GET-only branded-link handler returns when the route
is missing. That check is the hard prerequisite for a table entry, so it ran
before this was written rather than after.

Same shape as the Playo entry: their existing branded-link host, no page change
on their side, picked up on the next load because the bundle is served behind a
max-age=0 alias.

Also reworded the table's policy comment. It said "do not add an entry for a
customer who can change their script tag", which reads as a rule this commit
breaks. The real distinction is cost, not permission: an entry costs a release
from us and pins a hostname we have to keep true, where data-domain takes effect
on the customer's next deploy and cannot go stale on our side. The table stays
the fallback for when a page change is not available.

Both entries are now pinned by test. A typo in a token or a host is otherwise
invisible - that customer's events just quietly start taking the fallback path
to api.linkrunner.io, which looks like a healthy retry rather than a mistake.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The endpoint validates, it does not authenticate, and nothing said so. The two
are easy to conflate: a token IS required (400 without one, 401 if unknown), so
it reads like an access control until you notice the token is public in every
integrator's page source. Anyone who reads your HTML can post events with it.

Documents the rest of the posture in the same place rather than leaving it to be
rediscovered: no auth middleware on the route, CORS on /web reflects any origin
by design so it restricts nothing, 64 KB per request, 2048-char truncation per
string field, and no rate limiter.

None of this is new or changed by this branch. It is written down because
data-domain puts more of these requests on hosts customers control, and the
first question a security review asks about a public collector is exactly this.

Ends where it should: an event that has to be trustworthy goes server-to-server
from a backend holding a secret, not from a browser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Option 2 described the whole setup in prose, so the four things you actually
have to do - register the subdomain, add the CNAME, name it on the tag, check it
serves the collector - were mixed in with the reasoning about why. Someone
integrating had to extract the checklist themselves.

Same four steps, now numbered, with the two pieces that were missing:

- the LinkrunnerConfig form alongside the script tag and the Next.js prop, so
  all three ways of setting it are in one place
- the Network tab check after the curl preflight, and what two requests per
  event means when you see it

Says why registering the subdomain matters rather than treating it as
bookkeeping: the certificate is only issued for subdomains registered against
the project, so skipping it leaves the host without one.

No behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tashviks added a commit that referenced this pull request Aug 27, 2026
…g-and-last-touch-source

merge: combine data-domain routing (#10) and last-touch source persistence (#7)
@tashviks
tashviks merged commit 2e7b927 into main Aug 27, 2026
5 checks passed
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