Storefront runtime resolution contract
How a storefront decides which asset tree to load and which config URLs to fetch, and what happens when any of it fails.
Implemented by cloud-functions/getMerchantConfig,
cloud-run/merchant-api, and the canonical SDK
(finmatch-finance-marketing-assets/s/scripts/finmatch-sdk.js).
1. Principle: resolve-then-fetch, never redirect
The merchant snippet's URL is burned into merchant pages and never changes. The SDK's own URL never changes. What changes is the base URL the SDK derives asset URLs from, and that value now comes from the resolver instead of being computed solely from the environment name.
inline snippet (fixed URL)
└─ finmatch-sdk.js (fixed URL, max-age=300)
└─ GET getmerchantconfig?merchantID=… ← the only mutable hop
returns { …merchantConfig, environment, runtime: { baseUrl, … } }
└─ SDK constructs every asset URL client-side from runtime.baseUrl
scripts → <baseUrl>/scripts/*.js
css → <baseUrl>/css/finmatch.css
configs → env / shared buckets (never the release path)
No request gains a redirect hop: the client is told where to fetch from and then fetches directly. This is verified in the SDK trace harness, which asserts zero redirects across every request in every scenario.
2. Where the pointers live
merchant-router.json in MERCHANT_ROUTER_BUCKET, whose single writer is
merchant-api. There is deliberately no mutable pointer object in an asset
bucket, because CI writes those buckets and routing state must have one
writer (source of truth §4).
{
"_runtimeChannels": {
"updatedAt": "2026-08-24T13:00:00Z",
"updatedBy": "james@finmatch.io",
"channels": {
"live": {
"version": "p-20260824-757fb7c2",
"baseUrl": "https://storage.googleapis.com/finmatch-p/releases/p-20260824-757fb7c2",
"environment": "p"
},
"beta": { "version": "p-20260825-abcd1234", "baseUrl": "…", "environment": "p", "label": "Beta" }
}
},
"FM-4821-6395-2746": {
"environment": "p",
"runtimeChannel": "live",
"sandbox": { "enabled": true, "env": "t", "loadRuntime": true, "runtimeChannel": "beta" }
}
}
| Field | Meaning |
|---|---|
_runtimeChannels.channels.<name> | Channel → version pointer. Channel names are a closed set (live, beta) so an operator typo cannot create a channel nobody resolves. |
runtimeChannel (per merchant) | The rollout unit. Absent by default, so a merchant behaves exactly as before until it is set. |
runtimeKillSwitch (per merchant) | Forces the env bucket regardless of channel. |
sandbox.runtimeChannel | Pins a preview session to a different version from the merchant's live shoppers. Only applies when sandbox.loadRuntime is true. |
Keys beginning with _ are router configuration, not merchants. Existing
consumers already skipped versionStamp, version and _meta; that filter is
now startsWith('_') in admin/js/monitoring.js, admin/js/merchants.js and
cloud-run/partner-api/lib/cache.js so channel state can never be counted as
a phantom merchant.
3. The runtime block
Additive field on the getMerchantConfig response. Always present, always
carries a usable baseUrl.
"runtime": {
"schemaVersion": 1,
"environment": "p",
"envBaseUrl": "https://storage.googleapis.com/finmatch-p",
"channel": "live",
"version": "p-20260824-757fb7c2",
"baseUrl": "https://storage.googleapis.com/finmatch-p/releases/p-20260824-757fb7c2",
"source": "release-channel",
"fallbackReason": null,
"configUrls": {
"lenderStyling": "https://storage.googleapis.com/finmatch-shared/configs/lender-styling.json?h=f2ba04d3bd40",
"lenderOverrides": "https://storage.googleapis.com/finmatch-shared/configs/lender-overrides.json?h=44a9cfa669fa"
},
"flags": {}
}
| Field | Notes |
|---|---|
schemaVersion | Bumped on a breaking shape change. Readers must ignore unknown fields. |
environment | The environment the SDK should consider itself running in. Read this instead of sniffing import.meta.url (§7). |
envBaseUrl | Always the env bucket, so the client can fall back without recomputing anything. |
source | release-channel or env-bucket. The single field a rollout report counts. |
fallbackReason | null on the happy path; otherwise machine-readable (§4). |
configUrls | Content-hashed URLs for the admin-written config objects (§6). Omitted entirely if a hash could not be resolved. |
flags | Reserved for per-merchant rollout flags. Empty today. |
pinnedBySandbox | Present only when a preview session pinned a different channel. |
A runtime block is not returned when the merchant is SDK-disabled: that
403 short-circuits before runtime resolution, as before.
4. Fallback matrix
Every fallback is either resolver-side (visible in the response and in the
function logs) or client-side (visible on window.finmatch.runtime). Nothing
fails silently, and nothing requires a deploy to recover.
| Trigger | Detected by | fallbackReason | Automatic? |
|---|---|---|---|
Merchant has no runtimeChannel | resolver | no-channel-pointer | Automatic — this is the default state for every merchant |
| Merchant not in the router | resolver | no-router-entry | Automatic |
runtimeKillSwitch: true | resolver | kill-switch | Admin action to trigger, automatic to take effect (next resolver response, ~30 s) |
| Channel name has no version pointer | resolver | unknown-channel | Automatic |
| Pointer built for another environment | resolver | environment-mismatch | Automatic |
| — checked against the merchant's live environment, not this request's | A pointer's environment records which branch the release was built from. During a sandbox preview that loads the Test build, config comes from t while the release being previewed is a normal p build, so comparing against the request's environment would reject exactly the case channel pinning exists to serve | ||
Pointer missing a version / base URL, or a base URL outside storage.googleapis.com | resolver | pointer ignored, treated as unknown-channel | Automatic |
| Resolver unreachable, 5xx, or timeout | SDK | no-runtime-block (cached config, else shared branch) | Automatic |
runtime.environment disagrees with the branch the SDK resolved | SDK | no-runtime-block | Automatic |
| Critical release script or the stylesheet fails to load | SDK | script-load-failed / script-load-timeout / css-load-failed | Automatic, per page load |
| Non-critical release script fails to load | SDK | recorded in runtime.assetFallbacks, page not demoted | Automatic, per asset |
Two deliberate distinctions:
- A non-critical module failing does not demote the page. It is retried
from the env bucket, but
sourcestaysrelease-channel, because one flaky asset is not evidence of a bad release and demoting would corrupt the rollout percentage. - Assets that were never on the release base never trigger a fallback.
Several assets are hardcoded to other buckets (
analytics/analytics.js, lender logos, shared configs). The SDK only rewrites a failed URL when it actually starts with the pinned release base. This was caught by the trace harness:analytics.jsfailing was demoting the whole page before the check was added.
Rolling back
| Situation | Action | Time to take effect |
|---|---|---|
| One merchant looks wrong | PUT /admin/merchants/:id/runtime-channel with {"killSwitch": true} or {"channel": null} | Next resolver response, ~30 s |
| The release itself is bad | DELETE /admin/runtime-channels/live — every merchant on that channel returns to the env bucket, and the response reports how many | Next resolver response, ~30 s |
| Resolver itself is broken | Nothing to do; the SDK already falls back to env-bucket URLs | Immediate |
No deploy, no cache purge, and no change to the burned-in snippet in any of these cases.
The runnable commands for each of these, including authentication and how to verify the fallback took effect, are in the Egress Operations Runbook. The kill switch was exercised against production on 26 Aug 2026.
5. What the SDK loads from where
| Asset | Base | Why |
|---|---|---|
scripts/*.js | runtime.baseUrl | Versioned, immutable when served from a release path |
css/finmatch.css | runtime.baseUrl | Same |
configs/finmatch-ecom-config.json | env bucket | Mutable, env-scoped |
configs/finmatch-rate-card.json | env bucket | Mutable; see rate cards overview for the Step 4 consolidation. Not authorised merchants skip this fetch and the shared fallback. |
configs/lender-styling.json | shared (p|t) or env, content-hashed when available | Admin-written, single-writer |
configs/lender-overrides.json | shared (p|t) or env, content-hashed when available | Admin-written, single-writer |
A release never contains config. finmatch.bustCache() reflects this split:
scripts and CSS are busted at the active asset base, configs at the env base.
The SDK publishes the resolved state so env-branch modules and support staff can read it:
window.finmatch.runtime = {
environment, branch, channel, version, baseUrl, envBaseUrl,
source, fallbackReason, configUrls, flags, assetFallbacks
}
6. Content-hashed config URLs (implemented)
lender-styling.json and lender-overrides.json are written by merchant-api
at a stable URL, which historically forced a choice between an hour of
staleness and busting the cache on every render (the busters removed in Step
1). The resolver now hands the client a URL that changes when the content
changes:
- The hash is the object's own GCS
md5Hash, converted to a 12-character hex token. No download, no second source of truth, and it moves the momentmerchant-apirewrites the object. - Cached in-process for 30 s, so a traffic burst costs at most one metadata read per object.
- Fail-open: if metadata is unavailable the URL is omitted and the SDK uses the plain URL it derives itself.
- Env-aware:
pandtread the shared objects;sreads its own env-local copies, so hashing shared forswould hand it a hash for bytes it never fetches.
Because the resolver response itself is revalidated on every page load
(max-age=0, s-maxage=30 + ETag/304), an admin edit reaches storefronts in
roughly 30–60 s regardless of how long the config object is cached for.
Since done: the header change landed on 25 Aug 2026. merchant-api
applies public, max-age=31536000, immutable to the finmatch-shared copies of
lender-styling.json and lender-overrides.json on write, decided inside
lib/jsonStore.js so no call site can forget it, and credit-products-api does
the same for the shared rate card. The env-scoped copies in finmatch-t and
finmatch-s keep the bucket default, because storefront modules still fetch
those bare. See Egress Operations Runbook.
7. Env-branch modules must stop sniffing import.meta.url
Seven sites in scripts/finance-container.js and
scripts/finmatch-finance-app.js on finmatch-p decide production-only
behaviour from their own URL
(asset delivery §3). Under a release path
that answer changes, and two of the sites additionally resolve config
relatively to the module URL, which points at a non-existent object.
window.finmatch.runtime.environment and runtime.configUrls exist to
replace them. Until the env-branch modules prefer those values, a merchant
must not be pointed at a release base whose path does not contain its
environment segment. gs://finmatch-p/releases/<id>/… happens to satisfy
that by coincidence; a dedicated finmatch-releases bucket would not. See
versioned asset releases §8.
8. Merchant-scoped rate-card slice
getMerchantConfig does not return a merchant-scoped rate-card payload
today. Storefronts still dual-read the env object then the shared catalogue
(see Rate cards overview).
The proposed runtime.rateCard shape is recorded on the unlisted
SSOT consolidation plan
(inventory and sign-off only). Do not treat that JSON as live resolver
output.
9. Admin / API endpoints
| Endpoint | Purpose |
|---|---|
GET /admin/runtime-channels | Channel pointers plus the rollout summary: merchants on each channel, on the env bucket, kill-switched, on an unknown channel, and percentOnReleaseChannel |
PUT /admin/runtime-channels/:channel | Set a channel's version pointer. Requires x-internal-auth. The base URL must be a releases/<version> tree in finmatch-p, -s or -t, and the version must be the whole release segment, so a copy-pasted previous release cannot be published under a new version number |
DELETE /admin/runtime-channels/:channel | Remove a channel; reports how many merchants return to the env bucket. Requires x-internal-auth |
PUT /admin/merchants/:id/runtime-channel | Per-merchant {channel, killSwitch}. Refuses a channel with no version pointer, so a rollout cannot silently resolve to the env bucket and look healthy |
PATCH /admin/sandbox/:id/settings | Now also accepts runtimeChannel to pin a preview session to a different version |
Who may write a pointer, and why it is guarded twice
A channel pointer is a code-execution primitive: every storefront on the channel loads and runs whatever sits under the base URL, on pages that take card details. So the write path is constrained at three places, and each one is sufficient on its own:
| Gate | Where | What it stops |
|---|---|---|
requireInternalAuth on the mutating routes | merchant-api | An unauthenticated caller setting or deleting a pointer at all |
| Release-tree allow-list | runtime-channels.js validateChannelPointer | A pointer at a bucket we do not own, or at a non-releases/ path inside one we do |
| Release-tree allow-list again | SDK isUsableReleaseBaseUrl | A pointer already written by some other route, or before the allow-list existed, from actually being executed |
Both allow-lists are the same three buckets — finmatch-p, -s, -t — and they
have to be. A client gate that is wider than the write path is not a last line
of defence, it is a hole with a comment above it claiming otherwise. The release
bucket set is deliberately narrower than the set of buckets we serve assets from:
finmatch-shared holds the repo mirror and the admin-written configs and is never
a release target, so an allow-list derived from the asset buckets would make
finmatch-shared/releases/<version> executable. sdk-url-gate.test.mjs compares
the two lists directly and fails if they ever diverge.
Config URLs are checked separately by isUsableConfigUrl, which requires only
that the bucket is one of ours — finmatch-shared is legitimate there, because
that is where lender-styling.json and the shared rate card live.
The third matters most: the client is what runs the code, so server-side
validation alone would leave any already-written pointer live. Config URLs get
the weaker isUsableConfigUrl check — same bucket-ownership requirement, no
release-path requirement, because they are fetched as JSON rather than executed.
An earlier revision of this design constrained the base URL only to
https://storage.googleapis.com/, which every public bucket on the planet
satisfies, and left the mutating routes unguarded. Both were fixed before any
merchant was assigned to a channel. The regression cases are listed below.
10. What is verified
| Area | Where |
|---|---|
| Runtime block resolution, every fallback reason, sandbox pinning, pointer validation | cloud-functions/getMerchantConfig/lib/runtimeBlock.test.js |
| Content-hash tokens, env-aware object selection, TTL, fail-open | cloud-functions/getMerchantConfig/lib/configHashes.test.js |
Full handler path with a stubbed Storage: runtime block, sandbox vs live shopper, ETag/304 across a version bump, sdkEnabled: false short-circuit, no Location header | cloud-functions/getMerchantConfig/test/resolver.integration.test.js |
| Channel pointer validation, per-merchant patches, rollout summary | cloud-run/merchant-api/lib/runtime-channels.test.js |
| Pointer allow-list: foreign buckets, non-release paths, substring versions | cloud-run/merchant-api/lib/runtime-channels.test.js |
SDK release and config URL gates, including bucket-prefix tricks and finmatch-shared | cloud-functions/getMerchantConfig/test/sdk-url-gate.test.mjs |
| The client and write-path allow-lists are identical | cloud-functions/getMerchantConfig/test/sdk-url-gate.test.mjs |
| A hostile pointer, and a pointer at a non-release bucket we own, both fall back and are never requested, in real Chrome | cloud-functions/getMerchantConfig/test/sdk-runtime-trace.mjs |
| SDK end-to-end in Chrome: which base served scripts and CSS, content-hashed config fetches, automatic fallback when a release 404s, sandbox pinned to another version, zero redirect hops | cloud-functions/getMerchantConfig/test/sdk-runtime-trace.mjs |