Skip to main content

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" }
}
}
FieldMeaning
_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.runtimeChannelPins 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": {}
}
FieldNotes
schemaVersionBumped on a breaking shape change. Readers must ignore unknown fields.
environmentThe environment the SDK should consider itself running in. Read this instead of sniffing import.meta.url (§7).
envBaseUrlAlways the env bucket, so the client can fall back without recomputing anything.
sourcerelease-channel or env-bucket. The single field a rollout report counts.
fallbackReasonnull on the happy path; otherwise machine-readable (§4).
configUrlsContent-hashed URLs for the admin-written config objects (§6). Omitted entirely if a hash could not be resolved.
flagsReserved for per-merchant rollout flags. Empty today.
pinnedBySandboxPresent 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.

TriggerDetected byfallbackReasonAutomatic?
Merchant has no runtimeChannelresolverno-channel-pointerAutomatic — this is the default state for every merchant
Merchant not in the routerresolverno-router-entryAutomatic
runtimeKillSwitch: trueresolverkill-switchAdmin action to trigger, automatic to take effect (next resolver response, ~30 s)
Channel name has no version pointerresolverunknown-channelAutomatic
Pointer built for another environmentresolverenvironment-mismatchAutomatic
— checked against the merchant's live environment, not this request'sA 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.comresolverpointer ignored, treated as unknown-channelAutomatic
Resolver unreachable, 5xx, or timeoutSDKno-runtime-block (cached config, else shared branch)Automatic
runtime.environment disagrees with the branch the SDK resolvedSDKno-runtime-blockAutomatic
Critical release script or the stylesheet fails to loadSDKscript-load-failed / script-load-timeout / css-load-failedAutomatic, per page load
Non-critical release script fails to loadSDKrecorded in runtime.assetFallbacks, page not demotedAutomatic, per asset

Two deliberate distinctions:

  • A non-critical module failing does not demote the page. It is retried from the env bucket, but source stays release-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.js failing was demoting the whole page before the check was added.

Rolling back​

SituationActionTime to take effect
One merchant looks wrongPUT /admin/merchants/:id/runtime-channel with {"killSwitch": true} or {"channel": null}Next resolver response, ~30 s
The release itself is badDELETE /admin/runtime-channels/live — every merchant on that channel returns to the env bucket, and the response reports how manyNext resolver response, ~30 s
Resolver itself is brokenNothing to do; the SDK already falls back to env-bucket URLsImmediate

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​

AssetBaseWhy
scripts/*.jsruntime.baseUrlVersioned, immutable when served from a release path
css/finmatch.cssruntime.baseUrlSame
configs/finmatch-ecom-config.jsonenv bucketMutable, env-scoped
configs/finmatch-rate-card.jsonenv bucketMutable; see rate cards overview for the Step 4 consolidation. Not authorised merchants skip this fetch and the shared fallback.
configs/lender-styling.jsonshared (p|t) or env, content-hashed when availableAdmin-written, single-writer
configs/lender-overrides.jsonshared (p|t) or env, content-hashed when availableAdmin-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 moment merchant-api rewrites 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: p and t read the shared objects; s reads its own env-local copies, so hashing shared for s would 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​

EndpointPurpose
GET /admin/runtime-channelsChannel 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/:channelSet 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/:channelRemove a channel; reports how many merchants return to the env bucket. Requires x-internal-auth
PUT /admin/merchants/:id/runtime-channelPer-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/settingsNow 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:

GateWhereWhat it stops
requireInternalAuth on the mutating routesmerchant-apiAn unauthenticated caller setting or deleting a pointer at all
Release-tree allow-listruntime-channels.js validateChannelPointerA pointer at a bucket we do not own, or at a non-releases/ path inside one we do
Release-tree allow-list againSDK isUsableReleaseBaseUrlA 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​

AreaWhere
Runtime block resolution, every fallback reason, sandbox pinning, pointer validationcloud-functions/getMerchantConfig/lib/runtimeBlock.test.js
Content-hash tokens, env-aware object selection, TTL, fail-opencloud-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 headercloud-functions/getMerchantConfig/test/resolver.integration.test.js
Channel pointer validation, per-merchant patches, rollout summarycloud-run/merchant-api/lib/runtime-channels.test.js
Pointer allow-list: foreign buckets, non-release paths, substring versionscloud-run/merchant-api/lib/runtime-channels.test.js
SDK release and config URL gates, including bucket-prefix tricks and finmatch-sharedcloud-functions/getMerchantConfig/test/sdk-url-gate.test.mjs
The client and write-path allow-lists are identicalcloud-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 Chromecloud-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 hopscloud-functions/getMerchantConfig/test/sdk-runtime-trace.mjs