Skip to content

merge: combine data-domain routing (#10) and last-touch source persistence (#7) - #11

Merged
tashviks merged 10 commits into
mainfrom
integration/domain-routing-and-last-touch-source
Aug 27, 2026
Merged

merge: combine data-domain routing (#10) and last-touch source persistence (#7)#11
tashviks merged 10 commits into
mainfrom
integration/domain-routing-and-last-touch-source

Conversation

@tashviks

Copy link
Copy Markdown
Contributor

What

An integration branch that merges the two open web SDK PRs so they can be reviewed, tested and released together:

Both branched off main independently. This branch is main + 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 bump package.json. Merging them separately into main means 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.json

main is at 0.1.14. #7 was cut before that release and bumps to 0.1.13; #10 bumps to 0.1.15.

Resolved by taking main's 0.1.14 in the #7 merge, then letting #10's bump carry through. The branch ships 0.1.15, which is correct — #7's 0.1.13 was stale, not a competing intent, and #10 already bumped package-lock.json (root and packages['']) and window.lr._version in the same commit, so the three stay consistent.

src/core.js auto-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:

$ npm test
ℹ tests 53
ℹ pass 53
ℹ fail 0

$ npm run build
ESM/CJS/DTS ⚡️ Build success
terser src/core.js -o dist/lr.min.js  ✓

53 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 (sessionStorage write 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

samiksha-shreya and others added 10 commits August 18, 2026 20:33
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>
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
tashviks merged commit 4d7f75f 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