@tokenoftrust/storefront-runner 2.0.15 → 2.0.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/storefront/src/lib/apps/routeParams.ts +45 -0
- package/apps/storefront/src/lib/blog/collection.ts +3 -0
- package/apps/storefront/src/lib/blog/provider.ts +47 -13
- package/apps/storefront/src/lib/fixtures/regulated-commerce-truth/index.ts +540 -0
- package/apps/storefront/src/lib/fixtures/ws3-readonly-clone-preflight/index.ts +495 -0
- package/apps/storefront/src/pages/blog/index.astro +10 -1
- package/package.json +1 -1
- package/packages/public-runtime/tests/conformance/fake-destination.ts +402 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* operationParam — resolve the managed-analytics v1 `[operation]` path param
|
|
3
|
+
* robustly on BOTH admin surfaces.
|
|
4
|
+
*
|
|
5
|
+
* On the host-routed surface Astro populates `context.params.operation` normally.
|
|
6
|
+
* On the TENANT surface (`/<domain>/api/…`) the middleware selects the tenant by
|
|
7
|
+
* path prefix and rewrites via `next(renderPath)` — and across that rewrite this
|
|
8
|
+
* flat dynamic route comes back with an EMPTY `context.params` too (mirrors the
|
|
9
|
+
* identical rewrite defect `changeIdParam` solves for the change routes;
|
|
10
|
+
* `[operation].ts` is single-segment, so it has no nested-route case to worry
|
|
11
|
+
* about).
|
|
12
|
+
*
|
|
13
|
+
* Fallback: parse the operation from the pathname tail, which is stable on both
|
|
14
|
+
* surfaces and both sides of the rewrite (`request.url` is the original request,
|
|
15
|
+
* unaffected by the rewrite — see tenantPreview.ts's `stripBasePath`). No static
|
|
16
|
+
* sibling reaches this handler — Astro matches static routes first — so the tail
|
|
17
|
+
* capture is unambiguous wherever this helper runs.
|
|
18
|
+
*/
|
|
19
|
+
import type { APIContext } from "astro";
|
|
20
|
+
|
|
21
|
+
const TAIL = /\/api\/integrations\/v1\/([^/]+)\/?$/;
|
|
22
|
+
|
|
23
|
+
function fromPathname(pathname: string | undefined): string | undefined {
|
|
24
|
+
if (!pathname) return undefined;
|
|
25
|
+
const m = pathname.match(TAIL);
|
|
26
|
+
if (!m?.[1]) return undefined;
|
|
27
|
+
let id: string = m[1];
|
|
28
|
+
try {
|
|
29
|
+
id = decodeURIComponent(id);
|
|
30
|
+
} catch {
|
|
31
|
+
// keep the raw segment — the allowlist lookup downstream will refuse garbage
|
|
32
|
+
}
|
|
33
|
+
const trimmed = id.trim();
|
|
34
|
+
return trimmed || undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function operationParam(context: APIContext): string | undefined {
|
|
38
|
+
const fromParams = context.params.operation?.trim();
|
|
39
|
+
if (fromParams) return fromParams;
|
|
40
|
+
try {
|
|
41
|
+
return fromPathname(new URL(context.request.url).pathname);
|
|
42
|
+
} catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -156,6 +156,9 @@ function toSummary(p: BlogPost): BlogPostSummary {
|
|
|
156
156
|
tags: p.tags,
|
|
157
157
|
status: p.status,
|
|
158
158
|
...(p.scheduledAt ? { scheduledAt: p.scheduledAt } : {}),
|
|
159
|
+
// Carried, not dropped: a listing renders the entitlement badge off this,
|
|
160
|
+
// so omitting it makes a members-only post read as public in every listing.
|
|
161
|
+
...(p.access ? { access: p.access } : {}),
|
|
159
162
|
...(p.featuredImage ? { featuredImage: p.featuredImage } : {}),
|
|
160
163
|
...(p.canonicalUrl ? { canonicalUrl: p.canonicalUrl } : {}),
|
|
161
164
|
...(p.inLanguage ? { inLanguage: p.inLanguage } : {}),
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
// The live WordPress `/blog` deliberately keeps reading its committed, curated
|
|
2
2
|
// index.json (its order is hand-maintained and SEO-load-bearing). The generic
|
|
3
3
|
// derived engine lives in ./collection.ts and powers `the-build` + any new
|
|
4
|
-
// collection
|
|
4
|
+
// collection, and backs `/blog` whenever that curated index is unusable;
|
|
5
|
+
// switching `/blog` onto it outright is a later migration once order is
|
|
5
6
|
// encoded as data (see B0.1 §"/blog migration caution").
|
|
6
7
|
import { tenantDirSegment } from "@tot/public-runtime";
|
|
8
|
+
import { listPosts } from "./collection.js";
|
|
7
9
|
import type { BlogPost, BlogPostSummary } from "./types.js";
|
|
8
10
|
import type { ContentProvider } from "../storyblok/provider.js";
|
|
9
11
|
|
|
12
|
+
/** The collection id `/blog` reads — the derived fallback's second argument. */
|
|
13
|
+
const BLOG_COLLECTION = "blog";
|
|
14
|
+
|
|
10
15
|
type ArtifactSource = Pick<ContentProvider, "readArtifact" | "listArtifactPaths">;
|
|
11
16
|
|
|
12
17
|
function canReadArtifacts(value: ArtifactSource | undefined): value is ArtifactSource {
|
|
@@ -43,25 +48,54 @@ function tenantKey(keys: string[], tenantId: string, suffix: string): string | u
|
|
|
43
48
|
return keys.find((key) => key.includes(segment) && key.endsWith(suffix));
|
|
44
49
|
}
|
|
45
50
|
|
|
46
|
-
|
|
51
|
+
/** A curated index is usable only if it parses AND is the array shape it claims. */
|
|
52
|
+
function parseCuratedIndex(raw: string): BlogPostSummary[] | null {
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
55
|
+
return Array.isArray(parsed) ? (parsed as BlogPostSummary[]) : null;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The curated `index.json` for this tenant, or null when it is absent, malformed,
|
|
63
|
+
* or not an array. Null is "cannot use the curated order", never "no posts".
|
|
64
|
+
*/
|
|
65
|
+
async function readCuratedIndex(
|
|
47
66
|
tenantId: string,
|
|
48
67
|
source?: ArtifactSource,
|
|
49
|
-
): Promise<BlogPostSummary[]> {
|
|
68
|
+
): Promise<BlogPostSummary[] | null> {
|
|
50
69
|
if (canReadArtifacts(source)) {
|
|
51
|
-
const paths = await source.listArtifactPaths("blog/");
|
|
52
70
|
const raw = await source.readArtifact("blog/index.json");
|
|
53
|
-
if (raw)
|
|
54
|
-
try {
|
|
55
|
-
return JSON.parse(raw) as BlogPostSummary[];
|
|
56
|
-
} catch {
|
|
57
|
-
return [];
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
if (paths !== null) return [];
|
|
71
|
+
if (raw) return parseCuratedIndex(raw);
|
|
61
72
|
}
|
|
62
73
|
const key = tenantKey(Object.keys(indexGlobs), tenantId, "/index.json")
|
|
63
74
|
?? Object.keys(indexGlobs).find((candidate) => candidate.includes(`${tenantDirSegment(tenantId)}content/blog/index.json`));
|
|
64
|
-
return key ? indexGlobs[key]!() :
|
|
75
|
+
return key ? (await indexGlobs[key]!()) ?? null : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The curated index stays the preferred source — its order is hand-maintained
|
|
80
|
+
* and SEO-load-bearing — but it must never be the blog's single point of
|
|
81
|
+
* failure. One malformed byte in `content/blog/index.json` makes the reconcile's
|
|
82
|
+
* JSON validator advisory-skip it, and the whole surface (index, taxonomy pages,
|
|
83
|
+
* RSS, sitemap entries) then reads as an empty collection and hard-404s while
|
|
84
|
+
* every `posts/*.json` sits materialized beside it.
|
|
85
|
+
*
|
|
86
|
+
* So an unusable curated index falls back to the derived index over
|
|
87
|
+
* `blog/posts/*.json` (./collection.ts): newest-first rather than curated order,
|
|
88
|
+
* and every article still reachable and listed. Whether the curated index is
|
|
89
|
+
* authoritative or derived, the runtime/build-time source selection is identical
|
|
90
|
+
* — `listPosts` applies the same "artifact paths, else build-time globs" rule.
|
|
91
|
+
*/
|
|
92
|
+
export async function listBlogPosts(
|
|
93
|
+
tenantId: string,
|
|
94
|
+
source?: ArtifactSource,
|
|
95
|
+
): Promise<BlogPostSummary[]> {
|
|
96
|
+
const curated = await readCuratedIndex(tenantId, source);
|
|
97
|
+
if (curated && curated.length > 0) return curated;
|
|
98
|
+
return listPosts(tenantId, BLOG_COLLECTION, { content: source });
|
|
65
99
|
}
|
|
66
100
|
|
|
67
101
|
export async function getBlogPost(
|
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synthetic regulated-commerce truth used by cross-workstream conformance tests.
|
|
3
|
+
*
|
|
4
|
+
* This module is deliberately fixture-only. It does not call an integration API,
|
|
5
|
+
* deliver an order, import a report, or mutate a tenant. Consumers receive a
|
|
6
|
+
* validated, detached snapshot whose identifiers can never name a live tenant or
|
|
7
|
+
* destination.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const REGULATED_COMMERCE_TRUTH_SCHEMA = "regulated-commerce-truth.v1" as const;
|
|
11
|
+
|
|
12
|
+
export type ExpectedInvariant =
|
|
13
|
+
| { disposition: "allow"; violations: [] }
|
|
14
|
+
| { disposition: "closed-failure"; violations: ["age-verification-required"] };
|
|
15
|
+
|
|
16
|
+
export interface RegulatedCommerceTruthFixture {
|
|
17
|
+
schemaVersion: typeof REGULATED_COMMERCE_TRUTH_SCHEMA;
|
|
18
|
+
fixtureId: string;
|
|
19
|
+
synthetic: true;
|
|
20
|
+
capturedAt: string;
|
|
21
|
+
tenant: {
|
|
22
|
+
tenantId: string;
|
|
23
|
+
/** Canonical public-runtime TenantTier vocabulary. */
|
|
24
|
+
tenantTier: "regulated-commerce";
|
|
25
|
+
jurisdiction: string;
|
|
26
|
+
minimumAge: number;
|
|
27
|
+
ageVerificationRequired: true;
|
|
28
|
+
};
|
|
29
|
+
integrations: Array<{
|
|
30
|
+
reference: string;
|
|
31
|
+
kind: "order-risk";
|
|
32
|
+
destination: string;
|
|
33
|
+
enabled: boolean;
|
|
34
|
+
}>;
|
|
35
|
+
orders: Array<{
|
|
36
|
+
orderReference: string;
|
|
37
|
+
integrationReference: string;
|
|
38
|
+
occurredAt: string;
|
|
39
|
+
status: "paid" | "closed";
|
|
40
|
+
currency: "USD";
|
|
41
|
+
totalMinor: number;
|
|
42
|
+
destinationCountry: "US";
|
|
43
|
+
regulatedItems: Array<{
|
|
44
|
+
sku: string;
|
|
45
|
+
category: "age-restricted";
|
|
46
|
+
quantity: number;
|
|
47
|
+
unitPriceMinor: number;
|
|
48
|
+
}>;
|
|
49
|
+
compliance: {
|
|
50
|
+
ageVerification: "passed" | "missing";
|
|
51
|
+
consentRecorded: boolean;
|
|
52
|
+
taxCollectedMinor: number;
|
|
53
|
+
};
|
|
54
|
+
expectedInvariant: ExpectedInvariant;
|
|
55
|
+
}>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const FIXTURE_SOURCE = {
|
|
59
|
+
schemaVersion: REGULATED_COMMERCE_TRUTH_SCHEMA,
|
|
60
|
+
fixtureId: "synthetic-regulated-commerce-risk-v1",
|
|
61
|
+
synthetic: true,
|
|
62
|
+
capturedAt: "2026-08-29T12:00:00.000Z",
|
|
63
|
+
tenant: {
|
|
64
|
+
tenantId: "synthetic-regulated-merchant.test",
|
|
65
|
+
tenantTier: "regulated-commerce",
|
|
66
|
+
jurisdiction: "US-MN",
|
|
67
|
+
minimumAge: 21,
|
|
68
|
+
ageVerificationRequired: true,
|
|
69
|
+
},
|
|
70
|
+
integrations: [
|
|
71
|
+
{
|
|
72
|
+
reference: "integration:synthetic:order-risk-v1",
|
|
73
|
+
kind: "order-risk",
|
|
74
|
+
destination: "fake://synthetic-order-risk/deliveries",
|
|
75
|
+
enabled: true,
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
orders: [
|
|
79
|
+
{
|
|
80
|
+
orderReference: "order:synthetic:verified-001",
|
|
81
|
+
integrationReference: "integration:synthetic:order-risk-v1",
|
|
82
|
+
occurredAt: "2026-08-29T12:01:00.000Z",
|
|
83
|
+
status: "paid",
|
|
84
|
+
currency: "USD",
|
|
85
|
+
totalMinor: 6499,
|
|
86
|
+
destinationCountry: "US",
|
|
87
|
+
regulatedItems: [
|
|
88
|
+
{
|
|
89
|
+
sku: "SYNTH-AGE-21-001",
|
|
90
|
+
category: "age-restricted",
|
|
91
|
+
quantity: 1,
|
|
92
|
+
unitPriceMinor: 5999,
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
compliance: {
|
|
96
|
+
ageVerification: "passed",
|
|
97
|
+
consentRecorded: true,
|
|
98
|
+
taxCollectedMinor: 500,
|
|
99
|
+
},
|
|
100
|
+
expectedInvariant: { disposition: "allow", violations: [] },
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
orderReference: "order:synthetic:closed-002",
|
|
104
|
+
integrationReference: "integration:synthetic:order-risk-v1",
|
|
105
|
+
occurredAt: "2026-08-29T12:02:00.000Z",
|
|
106
|
+
status: "closed",
|
|
107
|
+
currency: "USD",
|
|
108
|
+
totalMinor: 3299,
|
|
109
|
+
destinationCountry: "US",
|
|
110
|
+
regulatedItems: [
|
|
111
|
+
{
|
|
112
|
+
sku: "SYNTH-AGE-21-002",
|
|
113
|
+
category: "age-restricted",
|
|
114
|
+
quantity: 1,
|
|
115
|
+
unitPriceMinor: 2999,
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
compliance: {
|
|
119
|
+
ageVerification: "missing",
|
|
120
|
+
consentRecorded: false,
|
|
121
|
+
taxCollectedMinor: 300,
|
|
122
|
+
},
|
|
123
|
+
expectedInvariant: {
|
|
124
|
+
disposition: "closed-failure",
|
|
125
|
+
violations: ["age-verification-required"],
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
} satisfies RegulatedCommerceTruthFixture;
|
|
130
|
+
|
|
131
|
+
const UNSAFE_KEY = /(authorization|credential|secret|password|api[-_]?key|access[-_]?token|refresh[-_]?token|email|phone|address|(?:^|[-_])name(?:$|[-_]))/i;
|
|
132
|
+
const CREDENTIAL_VALUE = /(-----BEGIN [A-Z ]*PRIVATE KEY-----|\bBearer\s+[A-Za-z0-9._~-]+|\bAKIA[0-9A-Z]{16}\b)/;
|
|
133
|
+
const EMAIL_VALUE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i;
|
|
134
|
+
const PHONE_VALUE = /\b(?:\+?\d{1,3}[-. ]?)?(?:\(\d{3}\)|\d{3})[-. ]\d{3}[-. ]\d{4}\b/;
|
|
135
|
+
const PAYMENT_VALUE = /(?:^|[^0-9])(?:\d{4}[- ]?){3}\d{4}(?:$|[^0-9])|(?:^|[^0-9])\d{13,19}(?:$|[^0-9])/;
|
|
136
|
+
/** US SSN/EIN-like nine-digit identifiers, contiguous or in their common hyphenated forms. */
|
|
137
|
+
const GOVERNMENT_ID_VALUE = /(?:^|[^0-9])(?:\d{3}-\d{2}-\d{4}|\d{2}-\d{7}|\d{9})(?:$|[^0-9])/;
|
|
138
|
+
const ADDRESS_VALUE = /\b\d{1,6}(?:[- ]+[A-Za-z][A-Za-z0-9.']*){1,5}[- ]+(?:street|st|avenue|ave|road|rd|boulevard|blvd|lane|ln|drive|dr|court|ct|way)\b/i;
|
|
139
|
+
const PERSON_NAME_VALUE = /\b(?:[A-Z][a-z]{1,30}\s+[A-Z][a-z]{1,30}|(?:jane|john)[-_ ]doe)\b/i;
|
|
140
|
+
const LIVE_URL_VALUE = /\b(?:https?|wss?):\/\//i;
|
|
141
|
+
const CODE_VALUE = /(?:<\/?script\b|javascript:|\{\{|=>|\bfunction\s*\(|\b(?:window|document)\.|\bdataLayer\b|\bGTM-[A-Z0-9-]+\b)/i;
|
|
142
|
+
|
|
143
|
+
// This is one local golden fixture, not a public extensible identity contract.
|
|
144
|
+
const FIXTURE_ID = /^synthetic-regulated-commerce-risk-v1$/;
|
|
145
|
+
const TENANT_ID = /^synthetic-regulated-merchant\.test$/;
|
|
146
|
+
const JURISDICTION = /^US-MN$/;
|
|
147
|
+
const INTEGRATION_REFERENCE = /^integration:synthetic:order-risk-v1$/;
|
|
148
|
+
const FAKE_DESTINATION = /^fake:\/\/synthetic-order-risk\/deliveries$/;
|
|
149
|
+
const ORDER_REFERENCE = /^order:synthetic:(?:verified|closed)-\d{3}$/;
|
|
150
|
+
const SYNTHETIC_SKU = /^SYNTH-AGE-21-\d{3}$/;
|
|
151
|
+
const MAX_MINIMUM_AGE = 125;
|
|
152
|
+
const MAX_ITEM_QUANTITY = 10_000;
|
|
153
|
+
const MAX_MINOR_AMOUNT = 99_999_999;
|
|
154
|
+
|
|
155
|
+
class FixtureValidationError extends Error {}
|
|
156
|
+
const VALIDATION_ERRORS = new WeakSet<object>();
|
|
157
|
+
|
|
158
|
+
function fail(path: string, message: string): never {
|
|
159
|
+
const error = new FixtureValidationError(`Invalid regulated-commerce truth at ${path}: ${message}`);
|
|
160
|
+
VALIDATION_ERRORS.add(error);
|
|
161
|
+
throw Object.freeze(error);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function record(value: unknown, path: string): Record<string, unknown> {
|
|
165
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
166
|
+
fail(path, "expected a plain data object");
|
|
167
|
+
}
|
|
168
|
+
const prototype = Object.getPrototypeOf(value);
|
|
169
|
+
if (prototype !== Object.prototype && prototype !== null) fail(path, "expected a plain data object");
|
|
170
|
+
return value as Record<string, unknown>;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function ownDataValue(value: Record<string, unknown>, key: string): unknown {
|
|
174
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
175
|
+
return descriptor && descriptor.enumerable && Object.hasOwn(descriptor, "value")
|
|
176
|
+
? descriptor.value
|
|
177
|
+
: undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function arrayDataValues(value: unknown, path: string): unknown[] {
|
|
181
|
+
if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) {
|
|
182
|
+
fail(path, "expected an ordinary data array");
|
|
183
|
+
}
|
|
184
|
+
const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length");
|
|
185
|
+
const length = lengthDescriptor && Object.hasOwn(lengthDescriptor, "value")
|
|
186
|
+
? lengthDescriptor.value
|
|
187
|
+
: -1;
|
|
188
|
+
if (!Number.isSafeInteger(length) || (length as number) < 0) fail(path, "array length is invalid");
|
|
189
|
+
const result: unknown[] = [];
|
|
190
|
+
for (let index = 0; index < (length as number); index += 1) {
|
|
191
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
192
|
+
if (!descriptor || !descriptor.enumerable || !Object.hasOwn(descriptor, "value")) {
|
|
193
|
+
fail(`${path}[${index}]`, "expected an own enumerable data item");
|
|
194
|
+
}
|
|
195
|
+
result.push(descriptor.value);
|
|
196
|
+
}
|
|
197
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
198
|
+
if (key === "length") continue;
|
|
199
|
+
if (typeof key !== "string" || !/^(0|[1-9][0-9]*)$/.test(key) || Number(key) >= (length as number)) {
|
|
200
|
+
fail(path, "array contains unexpected metadata");
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function string(value: unknown, path: string): string {
|
|
207
|
+
if (typeof value !== "string" || value.length === 0) fail(path, "expected a non-empty string");
|
|
208
|
+
return value;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function boundedInteger(value: unknown, path: string, minimum: number, maximum: number): number {
|
|
212
|
+
if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) {
|
|
213
|
+
fail(path, `expected an integer from ${minimum} through ${maximum}`);
|
|
214
|
+
}
|
|
215
|
+
return value as number;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function exactKeys(value: Record<string, unknown>, keys: string[], path: string): void {
|
|
219
|
+
const expected = new Set(keys);
|
|
220
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
221
|
+
if (typeof key !== "string" || !expected.has(key)) fail(path, "contains an unexpected field");
|
|
222
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
223
|
+
if (!descriptor || !descriptor.enumerable || !Object.hasOwn(descriptor, "value")) {
|
|
224
|
+
fail(`${path}.${key}`, "must be an own enumerable data field");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
for (const key of keys) {
|
|
228
|
+
if (ownDataValue(value, key) === undefined) fail(`${path}.${key}`, "required data field is missing");
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function literal<T extends string | boolean>(value: unknown, expected: T, path: string): T {
|
|
233
|
+
if (value !== expected) fail(path, `expected ${JSON.stringify(expected)}`);
|
|
234
|
+
return expected;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function oneOf<T extends string>(value: unknown, allowed: readonly T[], path: string): T {
|
|
238
|
+
if (typeof value !== "string" || !allowed.includes(value as T)) {
|
|
239
|
+
fail(path, `expected one of ${allowed.join(", ")}`);
|
|
240
|
+
}
|
|
241
|
+
return value as T;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function isoTimestamp(value: unknown, path: string): string {
|
|
245
|
+
const parsed = string(value, path);
|
|
246
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(parsed) || Number.isNaN(Date.parse(parsed))) {
|
|
247
|
+
fail(path, "expected a canonical UTC timestamp");
|
|
248
|
+
}
|
|
249
|
+
return parsed;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function assertSanitized(value: unknown, path = "fixture"): void {
|
|
253
|
+
if (typeof value === "string") {
|
|
254
|
+
if (CREDENTIAL_VALUE.test(value)) fail(path, "credential-shaped value is forbidden");
|
|
255
|
+
if (
|
|
256
|
+
EMAIL_VALUE.test(value) ||
|
|
257
|
+
PHONE_VALUE.test(value) ||
|
|
258
|
+
PAYMENT_VALUE.test(value) ||
|
|
259
|
+
GOVERNMENT_ID_VALUE.test(value) ||
|
|
260
|
+
ADDRESS_VALUE.test(value) ||
|
|
261
|
+
PERSON_NAME_VALUE.test(value)
|
|
262
|
+
) {
|
|
263
|
+
fail(path, "PII-shaped value is forbidden");
|
|
264
|
+
}
|
|
265
|
+
if (LIVE_URL_VALUE.test(value)) fail(path, "live URL value is forbidden");
|
|
266
|
+
if (CODE_VALUE.test(value)) fail(path, "code-bearing value is forbidden");
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (Array.isArray(value)) {
|
|
270
|
+
const entries = arrayDataValues(value, path);
|
|
271
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
272
|
+
assertSanitized(entries[index], `${path}[${index}]`);
|
|
273
|
+
}
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (value !== null && typeof value === "object") {
|
|
277
|
+
const object = record(value, path);
|
|
278
|
+
for (const key of Reflect.ownKeys(object)) {
|
|
279
|
+
if (typeof key !== "string") fail(path, "contains a non-JSON field");
|
|
280
|
+
const descriptor = Object.getOwnPropertyDescriptor(object, key);
|
|
281
|
+
if (!descriptor || !descriptor.enumerable || !Object.hasOwn(descriptor, "value")) {
|
|
282
|
+
fail(path, "contains a non-data field");
|
|
283
|
+
}
|
|
284
|
+
if (UNSAFE_KEY.test(key)) fail(path, "contains a PII/secret-shaped field");
|
|
285
|
+
// Do not include caller-controlled property names in diagnostics.
|
|
286
|
+
assertSanitized(descriptor.value, path);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function parseTenant(value: unknown): RegulatedCommerceTruthFixture["tenant"] {
|
|
292
|
+
const input = record(value, "fixture.tenant");
|
|
293
|
+
exactKeys(
|
|
294
|
+
input,
|
|
295
|
+
["tenantId", "tenantTier", "jurisdiction", "minimumAge", "ageVerificationRequired"],
|
|
296
|
+
"fixture.tenant",
|
|
297
|
+
);
|
|
298
|
+
const tenantId = string(ownDataValue(input, "tenantId"), "fixture.tenant.tenantId");
|
|
299
|
+
if (!TENANT_ID.test(tenantId)) {
|
|
300
|
+
fail("fixture.tenant.tenantId", "must use the closed synthetic tenant-id format");
|
|
301
|
+
}
|
|
302
|
+
const jurisdiction = string(ownDataValue(input, "jurisdiction"), "fixture.tenant.jurisdiction");
|
|
303
|
+
if (!JURISDICTION.test(jurisdiction)) {
|
|
304
|
+
fail("fixture.tenant.jurisdiction", "must use the closed jurisdiction format");
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
tenantId,
|
|
308
|
+
tenantTier: literal(ownDataValue(input, "tenantTier"), "regulated-commerce", "fixture.tenant.tenantTier"),
|
|
309
|
+
jurisdiction,
|
|
310
|
+
minimumAge: boundedInteger(
|
|
311
|
+
ownDataValue(input, "minimumAge"),
|
|
312
|
+
"fixture.tenant.minimumAge",
|
|
313
|
+
18,
|
|
314
|
+
MAX_MINIMUM_AGE,
|
|
315
|
+
),
|
|
316
|
+
ageVerificationRequired: literal(
|
|
317
|
+
ownDataValue(input, "ageVerificationRequired"),
|
|
318
|
+
true,
|
|
319
|
+
"fixture.tenant.ageVerificationRequired",
|
|
320
|
+
),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function parseIntegrations(value: unknown): RegulatedCommerceTruthFixture["integrations"] {
|
|
325
|
+
const inputs = arrayDataValues(value, "fixture.integrations");
|
|
326
|
+
if (inputs.length === 0) fail("fixture.integrations", "at least one integration reference is required");
|
|
327
|
+
const integrations: RegulatedCommerceTruthFixture["integrations"] = [];
|
|
328
|
+
for (let index = 0; index < inputs.length; index += 1) {
|
|
329
|
+
const path = `fixture.integrations[${index}]`;
|
|
330
|
+
const input = record(inputs[index], path);
|
|
331
|
+
exactKeys(input, ["reference", "kind", "destination", "enabled"], path);
|
|
332
|
+
const reference = string(ownDataValue(input, "reference"), `${path}.reference`);
|
|
333
|
+
if (!INTEGRATION_REFERENCE.test(reference)) {
|
|
334
|
+
fail(`${path}.reference`, "must use the closed synthetic integration-reference format");
|
|
335
|
+
}
|
|
336
|
+
const destination = string(ownDataValue(input, "destination"), `${path}.destination`);
|
|
337
|
+
if (!FAKE_DESTINATION.test(destination)) {
|
|
338
|
+
fail(`${path}.destination`, "must use the closed fake destination format");
|
|
339
|
+
}
|
|
340
|
+
const enabled = ownDataValue(input, "enabled");
|
|
341
|
+
if (typeof enabled !== "boolean") fail(`${path}.enabled`, "expected a boolean");
|
|
342
|
+
integrations.push({
|
|
343
|
+
reference,
|
|
344
|
+
kind: literal(ownDataValue(input, "kind"), "order-risk", `${path}.kind`),
|
|
345
|
+
destination,
|
|
346
|
+
enabled,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
return integrations;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function parseExpectedInvariant(value: unknown, path: string): ExpectedInvariant {
|
|
353
|
+
const input = record(value, path);
|
|
354
|
+
exactKeys(input, ["disposition", "violations"], path);
|
|
355
|
+
const disposition = oneOf(
|
|
356
|
+
ownDataValue(input, "disposition"),
|
|
357
|
+
["allow", "closed-failure"] as const,
|
|
358
|
+
`${path}.disposition`,
|
|
359
|
+
);
|
|
360
|
+
const violationInputs = arrayDataValues(ownDataValue(input, "violations"), `${path}.violations`);
|
|
361
|
+
const violations: Array<"age-verification-required"> = [];
|
|
362
|
+
for (let index = 0; index < violationInputs.length; index += 1) {
|
|
363
|
+
violations.push(
|
|
364
|
+
literal(violationInputs[index], "age-verification-required", `${path}.violations[${index}]`),
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
if (disposition === "allow") {
|
|
368
|
+
if (violations.length !== 0) fail(`${path}.violations`, "allow must have no violations");
|
|
369
|
+
return { disposition, violations: [] };
|
|
370
|
+
}
|
|
371
|
+
if (violations.length !== 1) {
|
|
372
|
+
fail(`${path}.violations`, "closed-failure must contain the age-verification-required violation");
|
|
373
|
+
}
|
|
374
|
+
return { disposition, violations: ["age-verification-required"] };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function parseOrders(
|
|
378
|
+
value: unknown,
|
|
379
|
+
integrations: RegulatedCommerceTruthFixture["integrations"],
|
|
380
|
+
): RegulatedCommerceTruthFixture["orders"] {
|
|
381
|
+
const inputs = arrayDataValues(value, "fixture.orders");
|
|
382
|
+
if (inputs.length === 0) fail("fixture.orders", "at least one truth order is required");
|
|
383
|
+
const integrationReferences = new Set<string>();
|
|
384
|
+
for (const integration of integrations) integrationReferences.add(integration.reference);
|
|
385
|
+
const orders: RegulatedCommerceTruthFixture["orders"] = [];
|
|
386
|
+
for (let index = 0; index < inputs.length; index += 1) {
|
|
387
|
+
const path = `fixture.orders[${index}]`;
|
|
388
|
+
const input = record(inputs[index], path);
|
|
389
|
+
exactKeys(
|
|
390
|
+
input,
|
|
391
|
+
[
|
|
392
|
+
"orderReference",
|
|
393
|
+
"integrationReference",
|
|
394
|
+
"occurredAt",
|
|
395
|
+
"status",
|
|
396
|
+
"currency",
|
|
397
|
+
"totalMinor",
|
|
398
|
+
"destinationCountry",
|
|
399
|
+
"regulatedItems",
|
|
400
|
+
"compliance",
|
|
401
|
+
"expectedInvariant",
|
|
402
|
+
],
|
|
403
|
+
path,
|
|
404
|
+
);
|
|
405
|
+
const orderReference = string(ownDataValue(input, "orderReference"), `${path}.orderReference`);
|
|
406
|
+
if (!ORDER_REFERENCE.test(orderReference)) {
|
|
407
|
+
fail(`${path}.orderReference`, "must use the closed synthetic order-reference format");
|
|
408
|
+
}
|
|
409
|
+
const integrationReference = string(
|
|
410
|
+
ownDataValue(input, "integrationReference"),
|
|
411
|
+
`${path}.integrationReference`,
|
|
412
|
+
);
|
|
413
|
+
if (!integrationReferences.has(integrationReference)) {
|
|
414
|
+
fail(`${path}.integrationReference`, "does not reference a declared integration");
|
|
415
|
+
}
|
|
416
|
+
const itemInputs = arrayDataValues(ownDataValue(input, "regulatedItems"), `${path}.regulatedItems`);
|
|
417
|
+
if (itemInputs.length === 0) fail(`${path}.regulatedItems`, "at least one regulated item is required");
|
|
418
|
+
const regulatedItems: RegulatedCommerceTruthFixture["orders"][number]["regulatedItems"] = [];
|
|
419
|
+
for (let itemIndex = 0; itemIndex < itemInputs.length; itemIndex += 1) {
|
|
420
|
+
const itemPath = `${path}.regulatedItems[${itemIndex}]`;
|
|
421
|
+
const item = record(itemInputs[itemIndex], itemPath);
|
|
422
|
+
exactKeys(item, ["sku", "category", "quantity", "unitPriceMinor"], itemPath);
|
|
423
|
+
const sku = string(ownDataValue(item, "sku"), `${itemPath}.sku`);
|
|
424
|
+
if (!SYNTHETIC_SKU.test(sku)) fail(`${itemPath}.sku`, "must use the closed synthetic SKU format");
|
|
425
|
+
regulatedItems.push({
|
|
426
|
+
sku,
|
|
427
|
+
category: literal(ownDataValue(item, "category"), "age-restricted", `${itemPath}.category`),
|
|
428
|
+
quantity: boundedInteger(
|
|
429
|
+
ownDataValue(item, "quantity"),
|
|
430
|
+
`${itemPath}.quantity`,
|
|
431
|
+
1,
|
|
432
|
+
MAX_ITEM_QUANTITY,
|
|
433
|
+
),
|
|
434
|
+
unitPriceMinor: boundedInteger(
|
|
435
|
+
ownDataValue(item, "unitPriceMinor"),
|
|
436
|
+
`${itemPath}.unitPriceMinor`,
|
|
437
|
+
1,
|
|
438
|
+
MAX_MINOR_AMOUNT,
|
|
439
|
+
),
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
const complianceInput = record(ownDataValue(input, "compliance"), `${path}.compliance`);
|
|
443
|
+
exactKeys(complianceInput, ["ageVerification", "consentRecorded", "taxCollectedMinor"], `${path}.compliance`);
|
|
444
|
+
const consentRecorded = ownDataValue(complianceInput, "consentRecorded");
|
|
445
|
+
if (typeof consentRecorded !== "boolean") {
|
|
446
|
+
fail(`${path}.compliance.consentRecorded`, "expected a boolean");
|
|
447
|
+
}
|
|
448
|
+
const compliance = {
|
|
449
|
+
ageVerification: oneOf(
|
|
450
|
+
ownDataValue(complianceInput, "ageVerification"),
|
|
451
|
+
["passed", "missing"] as const,
|
|
452
|
+
`${path}.compliance.ageVerification`,
|
|
453
|
+
),
|
|
454
|
+
consentRecorded,
|
|
455
|
+
taxCollectedMinor: boundedInteger(
|
|
456
|
+
ownDataValue(complianceInput, "taxCollectedMinor"),
|
|
457
|
+
`${path}.compliance.taxCollectedMinor`,
|
|
458
|
+
0,
|
|
459
|
+
MAX_MINOR_AMOUNT,
|
|
460
|
+
),
|
|
461
|
+
};
|
|
462
|
+
const status = oneOf(ownDataValue(input, "status"), ["paid", "closed"] as const, `${path}.status`);
|
|
463
|
+
const expectedInvariant = parseExpectedInvariant(
|
|
464
|
+
ownDataValue(input, "expectedInvariant"),
|
|
465
|
+
`${path}.expectedInvariant`,
|
|
466
|
+
);
|
|
467
|
+
const shouldFailClosed = status === "closed" || compliance.ageVerification !== "passed";
|
|
468
|
+
if (shouldFailClosed !== (expectedInvariant.disposition === "closed-failure")) {
|
|
469
|
+
fail(`${path}.expectedInvariant`, "does not match the fixture's regulated-commerce facts");
|
|
470
|
+
}
|
|
471
|
+
orders.push({
|
|
472
|
+
orderReference,
|
|
473
|
+
integrationReference,
|
|
474
|
+
occurredAt: isoTimestamp(ownDataValue(input, "occurredAt"), `${path}.occurredAt`),
|
|
475
|
+
status,
|
|
476
|
+
currency: literal(ownDataValue(input, "currency"), "USD", `${path}.currency`),
|
|
477
|
+
totalMinor: boundedInteger(
|
|
478
|
+
ownDataValue(input, "totalMinor"),
|
|
479
|
+
`${path}.totalMinor`,
|
|
480
|
+
1,
|
|
481
|
+
MAX_MINOR_AMOUNT,
|
|
482
|
+
),
|
|
483
|
+
destinationCountry: literal(
|
|
484
|
+
ownDataValue(input, "destinationCountry"),
|
|
485
|
+
"US",
|
|
486
|
+
`${path}.destinationCountry`,
|
|
487
|
+
),
|
|
488
|
+
regulatedItems,
|
|
489
|
+
compliance,
|
|
490
|
+
expectedInvariant,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
return orders;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function deepFreeze<T>(value: T): T {
|
|
497
|
+
if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
|
|
498
|
+
Object.freeze(value);
|
|
499
|
+
for (const child of Object.values(value)) deepFreeze(child);
|
|
500
|
+
}
|
|
501
|
+
return value;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function parseRegulatedCommerceTruth(value: unknown): Readonly<RegulatedCommerceTruthFixture> {
|
|
505
|
+
assertSanitized(value);
|
|
506
|
+
const input = record(value, "fixture");
|
|
507
|
+
exactKeys(input, ["schemaVersion", "fixtureId", "synthetic", "capturedAt", "tenant", "integrations", "orders"], "fixture");
|
|
508
|
+
const fixtureId = string(ownDataValue(input, "fixtureId"), "fixture.fixtureId");
|
|
509
|
+
if (!FIXTURE_ID.test(fixtureId)) fail("fixture.fixtureId", "must use the closed synthetic fixture-id format");
|
|
510
|
+
const integrations = parseIntegrations(ownDataValue(input, "integrations"));
|
|
511
|
+
const parsed: RegulatedCommerceTruthFixture = {
|
|
512
|
+
schemaVersion: literal(
|
|
513
|
+
ownDataValue(input, "schemaVersion"),
|
|
514
|
+
REGULATED_COMMERCE_TRUTH_SCHEMA,
|
|
515
|
+
"fixture.schemaVersion",
|
|
516
|
+
),
|
|
517
|
+
fixtureId,
|
|
518
|
+
synthetic: literal(ownDataValue(input, "synthetic"), true, "fixture.synthetic"),
|
|
519
|
+
capturedAt: isoTimestamp(ownDataValue(input, "capturedAt"), "fixture.capturedAt"),
|
|
520
|
+
tenant: parseTenant(ownDataValue(input, "tenant")),
|
|
521
|
+
integrations,
|
|
522
|
+
orders: parseOrders(ownDataValue(input, "orders"), integrations),
|
|
523
|
+
};
|
|
524
|
+
return deepFreeze(parsed);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Validate unknown fixture input and return a detached, immutable truth snapshot. */
|
|
528
|
+
export function loadRegulatedCommerceTruth(value: unknown): Readonly<RegulatedCommerceTruthFixture> {
|
|
529
|
+
try {
|
|
530
|
+
return parseRegulatedCommerceTruth(value);
|
|
531
|
+
} catch (error) {
|
|
532
|
+
if (error !== null && typeof error === "object" && VALIDATION_ERRORS.has(error)) throw error;
|
|
533
|
+
throw new FixtureValidationError("Invalid regulated-commerce truth: input could not be safely inspected");
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Load the repository's canonical deterministic fixture as a fresh snapshot. */
|
|
538
|
+
export function loadSyntheticRegulatedCommerceTruth(): Readonly<RegulatedCommerceTruthFixture> {
|
|
539
|
+
return loadRegulatedCommerceTruth(FIXTURE_SOURCE);
|
|
540
|
+
}
|