Skip to content

Commit 8fde9a6

Browse files
chore(source): update CHANGELOG.md supabase/functions/_shared/pricing-shadow.ts supabase/functions/config/index.ts
1 parent cb6ce83 commit 8fde9a6

6 files changed

Lines changed: 248 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,22 @@
6363
dead, since the SDK cannot mint an `idToken` itself.
6464
26 localised strings, new paywall-state test tags, and 13 Roborazzi goldens each paired with a
6565
named semantic assertion so no golden can be captured from a blank surface.
66+
- **093** constrains `paycraft_price_shadow_deltas.served_provenance` / `shadow_provenance` to the
67+
provenance vocabulary. 092 constrained `platform` but not these, and the edge function cast a
68+
client-supplied `X-PayCraft-Country-Provenance` header past the type system — so arbitrary text
69+
reached the table an operator reads before authorising a price cut-over. The edge now whitelists
70+
the header as well; this is the column-level half.
71+
- **Price-chain corrections (Stage A).** `resolveServed` no longer honours `?country=`/`x-country`.
72+
It had been doing so on the stated assumption that an override was already priced pre-deploy —
73+
it was not (pre-deploy read only `apiKey` and priced unconditionally off `Accept-Language`), so
74+
any request carrying an override was priced differently after the deploy than before. That is the
75+
one thing D11's staging exists to prevent. The override now lives only in the shadow chain.
76+
Also: the SDK arm is native-gated BEFORE the provenance header is consulted, so a web caller
77+
sending `X-PayCraft-Country-Provenance: storefront` can no longer be priced off its own
78+
Accept-Language; provider filtering and the response `locale` follow the country actually priced
79+
on rather than `Accept-Language`; and the edge's geo-header order and ISO-2/`XX` validation now
80+
match `dashboard/lib/customer-geo.ts`, which resolved five inputs differently between the two
81+
chains.
6682
- **091 (security)** revokes `anon`/`PUBLIC` EXECUTE on `tenant_products_upsert`,
6783
`tenant_pricing_upsert`, `tenant_products_delete`, `sync_event_emit`,
6884
`tenant_providers_set_account_label` and `_tenant_products_upsert_core`. Migration 084's

supabase/functions/_shared/pricing-shadow.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,14 @@ const NATIVE: ReadonlySet<Platform> = new Set<Platform>(["android", "ios"]);
6868
*/
6969
function sdkSignal(i: PriceInputs): Resolved | null {
7070
if (!i.sdkCountry) return null;
71+
// The NATIVE gate is checked BEFORE the provenance header, and the order is load-bearing.
72+
// Previously an explicit `sdkProvenance` short-circuited it, so a web client sending
73+
// `X-PayCraft-Platform: web` with `X-PayCraft-Country-Provenance: storefront` reached the SDK arm
74+
// and was priced off its own Accept-Language — the exact thing AC-15 says web must never do.
75+
// There is no storefront on the web, so no header a client sends can make one exist.
76+
if (!NATIVE.has(i.platform)) return null;
7177
if (i.sdkProvenance) return { country: i.sdkCountry, provenance: i.sdkProvenance };
72-
if (NATIVE.has(i.platform)) return { country: i.sdkCountry, provenance: "device" };
73-
return null;
78+
return { country: i.sdkCountry, provenance: "device" };
7479
}
7580

7681
/**
@@ -90,15 +95,20 @@ export function resolveShadow(i: PriceInputs): Resolved {
9095
}
9196

9297
/**
93-
* The OLD chain, preserved byte-for-byte for D11 Stage A: price off Accept-Language alone.
98+
* The OLD chain, preserved byte-for-byte for D11 Stage A: price off Accept-Language ALONE.
9499
*
95-
* An override was already honoured upstream before this phase, so it stays in the served chain —
96-
* excluding it here would make Stage A change behaviour, which is the one thing Stage A must not do.
100+
* CORRECTED — this previously honoured `overrideCountry`, on the stated assumption that an override
101+
* "was already honoured upstream before this phase". That assumption was wrong and was never
102+
* checked: pre-deploy `config/index.ts` read only `apiKey` from the query string and passed
103+
* `p_locale: localeCountry` unconditionally. There was no override path anywhere in the priced
104+
* request. Honouring one here meant any request carrying `?country=` or `x-country` was priced
105+
* DIFFERENTLY than before the deploy — which is precisely the thing Stage A exists to guarantee
106+
* cannot happen, and it would have happened silently.
107+
*
108+
* The override still exists, in `resolveShadow`, where it can only affect a price after the Stage B
109+
* cut-over that a human has to authorise.
97110
*/
98111
export function resolveServed(i: PriceInputs): Resolved {
99-
if (i.overrideCountry) {
100-
return { country: i.overrideCountry.toUpperCase(), provenance: "override" };
101-
}
102112
return { country: i.localeCountry.toUpperCase(), provenance: "locale" };
103113
}
104114

supabase/functions/config/index.ts

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,26 @@ export async function handleConfigRequest(req: Request): Promise<Response> {
9797
// it below the store storefront and above the device locale — one consistent signal on every
9898
// platform, including web/desktop where no store storefront exists. Null when the edge did not
9999
// attach a header (local dev / unknown host); the SDK degrades to device/locale in that case.
100-
const geoCountry =
101-
(req.headers.get("x-vercel-ip-country") ??
102-
req.headers.get("cf-ipcountry") ??
103-
req.headers.get("cloudfront-viewer-country"))?.trim()?.toUpperCase() || null
100+
// Header order and validation are deliberately IDENTICAL to dashboard/lib/customer-geo.ts.
101+
// They were not, and the two chains disagreed on five real inputs — a buyer priced one way in the
102+
// app and another way through web checkout (AC-19). Cloudflare first because that is the runtime
103+
// this deploys on; the ISO-2 shape check and the "XX" rejection are the dashboard's, which were
104+
// the stricter and more correct of the two: "XX" is the CDN's *unknown-country* placeholder, so
105+
// treating it as a country prices someone in a country that does not exist, and a 3-letter code
106+
// is not an ISO-3166-alpha-2 value at all.
107+
const GEO_HEADERS = [
108+
"cf-ipcountry",
109+
"x-vercel-ip-country",
110+
"cloudfront-viewer-country",
111+
"x-country",
112+
"x-geo-country",
113+
] as const
114+
const ISO2 = /^[A-Z]{2}$/
115+
let geoCountry: string | null = null
116+
for (const h of GEO_HEADERS) {
117+
const raw = req.headers.get(h)?.trim()?.toUpperCase()
118+
if (raw && ISO2.test(raw) && raw !== "XX") { geoCountry = raw; break }
119+
}
104120
const geoSource = geoCountry ? "SERVER_IP_GEO" : "ABSENT"
105121

106122
// Caller platform (SDK sends X-PayCraft-Platform: ios|android|desktop|web). Drives per-platform
@@ -117,15 +133,31 @@ export async function handleConfigRequest(req: Request): Promise<Response> {
117133
// A real browser has no such header, so its Accept-Language stays a language preference and the
118134
// shadow chain falls through to server geo — which is the actual revenue fix (a US buyer whose
119135
// browser prefers fr-FR is currently billed in EUR).
120-
const overrideCountry =
121-
(new URL(req.url).searchParams.get("country") ??
122-
req.headers.get("x-country"))?.trim()?.toUpperCase() || null
123-
const sdkProvenanceHeader =
136+
// `?country=` ONLY. `x-country` used to be an override alias here while the dashboard treated it
137+
// as a geo header — the same request resolved with a different PROVENANCE depending on which
138+
// entry point served it. An override should be something a caller states explicitly in the URL,
139+
// not a header a CDN might inject on its behalf. It is a geo header on both sides now.
140+
const overrideRaw = new URL(req.url).searchParams.get("country")?.trim()?.toUpperCase() || null
141+
const overrideCountry = overrideRaw && ISO2.test(overrideRaw) && overrideRaw !== "XX"
142+
? overrideRaw
143+
: null
144+
// Whitelisted, not cast. This value is client-supplied and flows into every product row AND into
145+
// paycraft_price_shadow_deltas — the table an operator reads before authorising the Stage B
146+
// cut-over. An `as never` cast let arbitrary text through, so the audit trail the release
147+
// decision rests on was shapeable by any caller.
148+
const PROVENANCE_VALUES = [
149+
"override", "storefront", "server_geo", "device", "locale", "default",
150+
] as const
151+
const rawProvenance =
124152
(req.headers.get("x-paycraft-country-provenance") ?? "").trim().toLowerCase() || null
153+
const sdkProvenanceHeader =
154+
rawProvenance && (PROVENANCE_VALUES as readonly string[]).includes(rawProvenance)
155+
? rawProvenance
156+
: null
125157
const priceInputs = {
126158
overrideCountry,
127159
sdkCountry: callerPlatform ? localeCountry : null,
128-
sdkProvenance: (sdkProvenanceHeader as never) ?? null,
160+
sdkProvenance: sdkProvenanceHeader as never,
129161
geoCountry,
130162
localeCountry,
131163
platform: (callerPlatform ?? "unknown") as never,
@@ -341,7 +373,10 @@ export async function handleConfigRequest(req: Request): Promise<Response> {
341373
}) => {
342374
const localeOk = !pr.supported_locales ||
343375
pr.supported_locales.length === 0 ||
344-
pr.supported_locales.includes(localeCountry)
376+
// effectiveCountry, not localeCountry: after cut-over a buyer priced on server_geo (say IN)
377+
// would otherwise only be offered providers whose supported_locales carry their
378+
// Accept-Language country (say US) — priced in one country, unable to pay in it.
379+
pr.supported_locales.includes(effectiveCountry.country)
345380
const bySku = isTestMode ? pr.test_payment_links : pr.live_payment_links
346381
const linksOk = !!bySku && Object.values(bySku).some(perCurrency =>
347382
!!perCurrency && Object.keys(perCurrency).length > 0
@@ -435,7 +470,9 @@ export async function handleConfigRequest(req: Request): Promise<Response> {
435470
products: pricedProducts,
436471
providers: orderedProviders,
437472
paywall: paywallWithLegal,
438-
locale: localeCountry,
473+
// The country actually priced on. Emitting the raw Accept-Language country here would make
474+
// SuiteConfig.locale disagree with served_country the moment cut-over lands.
475+
locale: effectiveCountry.country,
439476
geo_country: geoCountry,
440477
geo_source: geoSource,
441478
served_country: effectiveCountry.country,
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
-- 093_shadow_delta_provenance_check.sql — constrain the provenance columns.
2+
--
3+
-- 092 put a CHECK on `platform` but none on the two provenance columns, and the edge function cast
4+
-- a client-supplied `X-PayCraft-Country-Provenance` header past the type system with `as never`.
5+
-- Arbitrary text therefore reached the table an operator reads before authorising the Stage B
6+
-- price cut-over — an audit trail any caller could shape.
7+
--
8+
-- The edge function now whitelists the header. This is the second half: defence at the column, so
9+
-- the guarantee does not depend on one caller remembering to validate. Belt and braces is right
10+
-- here because the cost of a bad row is a release decision made on false evidence.
11+
--
12+
-- Idempotent per PayCraft migration policy: constraint creation is guarded on pg_constraint.
13+
14+
-- Any pre-existing out-of-vocabulary rows are normalised rather than blocking the migration; they
15+
-- came from the unvalidated path and carry no trustworthy meaning.
16+
UPDATE paycraft_price_shadow_deltas
17+
SET served_provenance = 'default'
18+
WHERE served_provenance NOT IN ('override','storefront','server_geo','device','locale','default');
19+
UPDATE paycraft_price_shadow_deltas
20+
SET shadow_provenance = 'default'
21+
WHERE shadow_provenance NOT IN ('override','storefront','server_geo','device','locale','default');
22+
23+
DO $$
24+
BEGIN
25+
IF NOT EXISTS (
26+
SELECT 1 FROM pg_constraint WHERE conname = 'shadow_delta_served_provenance_valid'
27+
) THEN
28+
ALTER TABLE paycraft_price_shadow_deltas
29+
ADD CONSTRAINT shadow_delta_served_provenance_valid
30+
CHECK (served_provenance IN ('override','storefront','server_geo','device','locale','default'));
31+
END IF;
32+
IF NOT EXISTS (
33+
SELECT 1 FROM pg_constraint WHERE conname = 'shadow_delta_shadow_provenance_valid'
34+
) THEN
35+
ALTER TABLE paycraft_price_shadow_deltas
36+
ADD CONSTRAINT shadow_delta_shadow_provenance_valid
37+
CHECK (shadow_provenance IN ('override','storefront','server_geo','device','locale','default'));
38+
END IF;
39+
END $$;

tests/fixtures/paycraft-foundation-canary/green/cross-chain-equivalence/equivalence_test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,33 @@ function mockReq(url: string, headers: Record<string, string>): never {
2222
} as never;
2323
}
2424

25+
26+
/**
27+
* The edge's header→geo resolution, mirroring supabase/functions/config/index.ts.
28+
*
29+
* These fixtures previously hand-fed `geoCountry` into resolveShadow, which meant the header
30+
* PARSING — the half the two chains actually disagreed on — was never compared at all. Five real
31+
* disagreements hid behind that: inverted header priority, `x-country` treated as an override on
32+
* one side and geo on the other, `x-geo-country` honoured on one side only, and the CDN's "XX"
33+
* unknown-country placeholder plus 3-letter codes accepted as countries by the edge.
34+
*/
35+
const GEO_HEADERS = [
36+
"cf-ipcountry",
37+
"x-vercel-ip-country",
38+
"cloudfront-viewer-country",
39+
"x-country",
40+
"x-geo-country",
41+
] as const;
42+
const ISO2 = /^[A-Z]{2}$/;
43+
44+
function edgeGeoFromHeaders(headers: Record<string, string>): string | null {
45+
for (const h of GEO_HEADERS) {
46+
const raw = headers[h]?.trim()?.toUpperCase();
47+
if (raw && ISO2.test(raw) && raw !== "XX") return raw;
48+
}
49+
return null;
50+
}
51+
2552
interface Fixture {
2653
name: string;
2754
query: string;
@@ -91,3 +118,56 @@ Deno.test("AC-19 no signal at all: dashboard falls to merchant default, edge to
91118
});
92119
assertEquals(edge, { country: "FR", provenance: "locale" });
93120
});
121+
122+
// ── The five disagreements an adversarial audit found, now pinned ────────────────────────────
123+
const adversarial: Array<{ name: string; headers: Record<string, string>; merchant: string | null }> = [
124+
{
125+
name: "both cf-ipcountry and x-vercel-ip-country present — priority order must agree",
126+
headers: { "cf-ipcountry": "IN", "x-vercel-ip-country": "US" },
127+
merchant: "GB",
128+
},
129+
{
130+
name: "x-country alone — geo on BOTH sides, never an override on one",
131+
headers: { "x-country": "DE" },
132+
merchant: "GB",
133+
},
134+
{
135+
name: "x-geo-country alone — honoured on both sides, not just the dashboard",
136+
headers: { "x-geo-country": "BR" },
137+
merchant: "GB",
138+
},
139+
{
140+
name: "XX is the CDN unknown-country placeholder, not a country",
141+
headers: { "cf-ipcountry": "XX" },
142+
merchant: "GB",
143+
},
144+
{
145+
name: "a 3-letter code is not ISO-3166-alpha-2",
146+
headers: { "cf-ipcountry": "USA" },
147+
merchant: "GB",
148+
},
149+
];
150+
151+
for (const c of adversarial) {
152+
Deno.test(`AC-19 cross-chain: ${c.name}`, () => {
153+
const dash = detectCustomerCountryWithProvenance(
154+
mockReq("https://x/api/checkout-options", c.headers),
155+
c.merchant,
156+
);
157+
const geo = edgeGeoFromHeaders(c.headers);
158+
const edge = resolveShadow({
159+
overrideCountry: null, sdkCountry: null, sdkProvenance: null,
160+
geoCountry: geo, localeCountry: "US", platform: "web",
161+
});
162+
if (geo === null) {
163+
// Neither side found a usable geo signal. The known, accepted asymmetry applies: the
164+
// dashboard knows which merchant is being checked out and falls back to their market; the
165+
// edge answers for an SDK that already has a locale. Both are last-resort arms.
166+
assertEquals(dash.provenance, "default");
167+
assertEquals(edge.provenance, "locale");
168+
return;
169+
}
170+
assertEquals(dash.country, edge.country, "country mismatch across chains");
171+
assertEquals(dash.provenance, edge.provenance, "provenance mismatch across chains");
172+
});
173+
}

tests/fixtures/paycraft-foundation-canary/green/price-chain-shadow/chain_test.ts

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,24 +65,65 @@ Deno.test("an older SDK is reported as 'device', never as 'storefront'", () => {
6565
assertEquals(resolveShadow(i).provenance, "device");
6666
});
6767

68-
Deno.test("override wins in BOTH chains, so Stage A cannot change an overridden price", () => {
68+
Deno.test("an override moves the SHADOW chain only — Stage A must not honour it", () => {
69+
// Regression for a real Stage A violation. `resolveServed` used to honour the override on the
70+
// stated assumption that one "was already honoured upstream". It was not: pre-deploy
71+
// config/index.ts read only `apiKey` from the query string and priced unconditionally off
72+
// Accept-Language. Honouring it in the served chain meant any request carrying `?country=` or
73+
// `x-country` was priced differently after the deploy than before — silently, and precisely
74+
// what Stage A exists to make impossible.
6975
const i: PriceInputs = {
7076
...base, platform: "web", overrideCountry: "jp", localeCountry: "FR", geoCountry: "US",
7177
};
72-
assertEquals(resolveServed(i), { country: "JP", provenance: "override" });
78+
assertEquals(resolveServed(i), { country: "FR", provenance: "locale" });
7379
assertEquals(resolveShadow(i), { country: "JP", provenance: "override" });
74-
assertEquals(diverges(resolveServed(i), resolveShadow(i)), false);
80+
assertEquals(diverges(resolveServed(i), resolveShadow(i)), true);
81+
});
82+
83+
Deno.test("AC-15 a spoofed provenance header cannot buy a web client into the SDK arm", () => {
84+
// The native gate used to sit AFTER the provenance check, so a caller sending
85+
// `X-PayCraft-Platform: web` with `X-PayCraft-Country-Provenance: storefront` was priced off its
86+
// own Accept-Language while being recorded as an authoritative storefront. There is no storefront
87+
// on the web, so no header can make one exist.
88+
for (const claimed of ["storefront", "device", "override", "default"] as const) {
89+
const i: PriceInputs = {
90+
...base, platform: "web", sdkCountry: "IN", sdkProvenance: claimed,
91+
geoCountry: "US", localeCountry: "IN",
92+
};
93+
assertEquals(resolveShadow(i), { country: "US", provenance: "server_geo" });
94+
}
95+
// Desktop is the same story.
96+
const d: PriceInputs = {
97+
...base, platform: "desktop", sdkCountry: "IN", sdkProvenance: "storefront",
98+
geoCountry: "US", localeCountry: "IN",
99+
};
100+
assertEquals(resolveShadow(d).provenance, "server_geo");
75101
});
76102

77-
Deno.test("STAGE A INVARIANT — served is always locale-or-override, for every input", () => {
103+
Deno.test("STAGE A INVARIANT — served is ALWAYS the Accept-Language country, for every input", () => {
104+
// Deliberately swept over the override too. The previous version of this test asserted
105+
// "locale-OR-override", which is the Stage A violation restated as an expectation — it passed
106+
// while the served chain was moving prices for any request carrying an override.
78107
const countries = ["US", "FR", "IN", "JP"];
79108
const platforms: PriceInputs["platform"][] = ["android", "ios", "web", "desktop", "unknown"];
109+
const provenances: (PriceInputs["sdkProvenance"])[] = [null, "storefront", "device", "override"];
80110
for (const platform of platforms) {
81111
for (const localeCountry of countries) {
82112
for (const geoCountry of [null, "US", "DE"]) {
83113
for (const sdkCountry of [null, "IN"]) {
84-
const i: PriceInputs = { ...base, platform, localeCountry, geoCountry, sdkCountry };
85-
assertEquals(resolveServed(i), { country: localeCountry, provenance: "locale" });
114+
for (const overrideCountry of [null, "JP", "br"]) {
115+
for (const sdkProvenance of provenances) {
116+
const i: PriceInputs = {
117+
...base, platform, localeCountry, geoCountry, sdkCountry,
118+
overrideCountry, sdkProvenance,
119+
};
120+
assertEquals(
121+
resolveServed(i),
122+
{ country: localeCountry, provenance: "locale" },
123+
`served must equal pre-deploy for ${JSON.stringify(i)}`,
124+
);
125+
}
126+
}
86127
}
87128
}
88129
}

0 commit comments

Comments
 (0)