merge: combine data-domain routing (#10) and last-touch source persistence (#7) - #11
Merged
tashviks merged 10 commits intoAug 27, 2026
Merged
Conversation
The SDK recomputes the last-touch traffic source on every event and kept it only in the outgoing payload. Contexts that can only READ storage could not reach it, notably a Shopify custom pixel: it runs sandboxed on the checkout page and cannot call into the SDK, so it had to re-derive the classification by hand and could not reproduce the referrer-based branch at all. Store it in sessionStorage as lr_ts_type / lr_ts_name, scoped to match the lr_utm_* mirror so it expires with the session like the rest of last-touch data. The lr_lt record still handles carrying last-touch across a fresh-tab return. Verified end to end against a real Shopify checkout: without this the pixel falls back to first touch, so a shopper who arrives from an ad and returns organically has the purchase credited to the wrong channel. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…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>
# Conflicts: # package.json
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
An integration branch that merges the two open web SDK PRs so they can be reviewed, tested and released together:
feat(sdk): route collection through a customer-named subdomain (data-domain)(tashvik/web-sdk-domain-routing)feat(sdk): persist last-touch traffic source for storage-only readers(samiksha/shopify-last-touch-source)Both branched off
mainindependently. This branch ismain+ a merge of each, with no new source changes of its own beyond one conflict resolution.Why
The two touch the same file (
src/core.js) and both bumppackage.json. Merging them separately intomainmeans whichever lands second resolves the conflict under review pressure, and neither PR's test suite has ever run against the other's code. This branch resolves it once, up front, and proves the combined result green before either lands.Conflict resolution — one, in
package.jsonmainis at0.1.14. #7 was cut before that release and bumps to0.1.13; #10 bumps to0.1.15.Resolved by taking
main's0.1.14in the #7 merge, then letting #10's bump carry through. The branch ships0.1.15, which is correct — #7's0.1.13was stale, not a competing intent, and #10 already bumpedpackage-lock.json(root andpackages['']) andwindow.lr._versionin the same commit, so the three stay consistent.src/core.jsauto-merged cleanly in both directions — the two changes are in different regions of the file and do not interact.Verification
Both changesets are present and functional in the merged tree:
setLastTouchTrafficSource(feat(sdk): persist last-touch traffic source for storage-only readers #7) atsrc/core.js:734, called atsrc/core.js:1047data-domainrouting,normalizeDomain()and the widenedshouldFallBack(feat(sdk): route collection through a customer-named subdomain (data-domain) #10) intact53 passing — #10's 50 plus #7's 3, no losses and no cross-interference. The build produces both bundles.
Review notes
Both PRs have been reviewed on their own branches; this branch adds no logic to review. What is worth a second look is the version resolution above and the fact that #7's persistence now runs on pages whose collection host comes from
data-domain— those are independent code paths (sessionStoragewrite vs. endpoint selection), so no interaction is expected, and the combined suite agrees.Merging this closes out both #10 and #7.
🤖 Generated with Claude Code