feat(sdk): route collection through a customer-named subdomain (data-domain) - #10
Merged
Conversation
…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>
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>
This reverts commit f2eebbf.
This reverts commit e305529.
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>
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
Adds
data-domainto the script tag so an integrating customer names their own collection subdomain, and the SDK posts events there instead of toapi.linkrunner.io.Also available as
LinkrunnerConfig.domainand as adomainprop on<LinkrunnerScript>.Why
Ad blockers match a request by its domain, not its path. Moving off
/web/collectbought time against rules matching the word "collect"; it does nothing about a rule written againstapi.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
data-endpoint/config.endpointdata-domain/config.domainFIRST_PARTY_ENDPOINTS[token]https://api.linkrunner.io/web/ingestA 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 rebuildshttps://<host>/web/ingestfrom the newCOLLECT_PATHconstant.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 resolveslr.their-site.com@evil.testtoevil.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
shouldFallBacknow retries on0, 403, 404, 405, 502, 503, up from0, 404, 405.The rule is checkable rather than a judgement call:
POST /web/ingestanswers204, 400, 401, 413, 500and nothing else (backendcontrollers/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/502where our collector never would. Without it, an event behind a WAF was lost outright while the README promised it wouldn't be.504stays 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 (sameevent_id), and the pre-existingstatus === 0retry 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 againstorigin/mainrather than by reasoning: 8 already-integrated page shapes (bare token, SPA on, the Playo token,data-endpointabsolute,data-endpointsame-origin path, window-config endpoint, a prototype-colliding token, no token at all) x 13 response statuses = 104 scenarios.window.lrsurface identical —_q, _version, identify, track403/502/503. Those events were dropped before and are delivered now. Nothing that previously succeeded behaves differently.Additive-only on the TS side:
domain?: stringon the config type and theLinkrunnerScriptprops. 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-domainover the table anddata-endpointoverdata-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.14→0.1.15acrosspackage.json,package-lock.json(root andpackages['']) andwindow.lr._version; the CIverify-build-versionstep passes locally against both built bundles. Minified bundle15185→15982bytes (+797, +5.2%).Docs PR: linkrunner-labs/docs (companion, adds the
data-domainsection to/sdk/web).Known, not addressed here
src/index.tsstill hardcodes_version: '0.1.9'in the SSR/pre-load stub — pre-existing drift frompackage.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.com→https://app.meatigo.com/web/ingest, keyed to their web SDK token.Prerequisite verified before the entry was written, not after:
204, not the405the GET-only branded-link handler returns when/web/ingestis 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-domaintakes 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:
400unless the body carriestoken(non-empty string) plusevent_typeorevent_nameWEB_SDK_TOKEN; unknown →401. Nothing ingests without a valid one413above 64 KB; every string field truncated at 2048 chars/web/ingest/webreflects any origin by design, so it restricts nothingA 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 the401proves 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-domainputs 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:238readsparsedBody.tokenfor the Kafka partition key andapi_metrics.project_token, and an encrypted envelope has notokenfield, so enabling encryption without fixing that would blank the dimension and hot-partition the topic.🤖 Generated with Claude Code