@tokenoftrust/storefront-runner 2.0.17 → 2.0.19
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/env.d.ts +21 -0
- package/apps/storefront/migrations-apps/0012_test_destinations.sql +29 -0
- package/apps/storefront/src/components/blog/ReactionBar.astro +1 -1
- package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +365 -49
- package/apps/storefront/src/components/islands/checkoutVerificationFlow.ts +251 -0
- package/apps/storefront/src/config/compliance/rulesets.ts +18 -7
- package/apps/storefront/src/config/devTenantSeed.ts +24 -1
- package/apps/storefront/src/config/evidenceTenantRoster.ts +25 -0
- package/apps/storefront/src/config/publicationSpaceConfig.ts +25 -0
- package/apps/storefront/src/config/resolver.ts +5 -0
- package/apps/storefront/src/config/tenantAnalyticsFiles.ts +43 -0
- package/apps/storefront/src/config/tenants.ts +11 -0
- package/apps/storefront/src/layouts/Layout.astro +5 -1
- package/apps/storefront/src/lib/activity/killSwitch.ts +1 -1
- package/apps/storefront/src/lib/admin/consentSettingsClient.ts +113 -0
- package/apps/storefront/src/lib/apps/consentConfigRead.ts +24 -0
- package/apps/storefront/src/lib/apps/gtmMigrationDraft.ts +103 -0
- package/apps/storefront/src/lib/auth/brokerAssertion.ts +18 -0
- package/apps/storefront/src/lib/auth/identityToken.ts +11 -1
- package/apps/storefront/src/lib/auth/internalContentAuthority.ts +153 -0
- package/apps/storefront/src/lib/auth/loginGate.ts +25 -0
- package/apps/storefront/src/lib/auth/session.ts +3 -0
- package/apps/storefront/src/lib/checkoutCommerce.ts +1 -1
- package/apps/storefront/src/lib/compliance/enforcement.ts +25 -3
- package/apps/storefront/src/lib/d1/schema-apps.ts +44 -0
- package/apps/storefront/src/lib/edgeCache.ts +17 -1
- package/apps/storefront/src/lib/env.ts +13 -111
- package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +198 -0
- package/apps/storefront/src/lib/monitoring/manifest.ts +1 -1
- package/apps/storefront/src/lib/publication/contentProvider.ts +159 -0
- package/apps/storefront/src/lib/publication/runtimeSource.ts +97 -0
- package/apps/storefront/src/lib/rawMarketingHtml.ts +9 -1
- package/apps/storefront/src/lib/runtimeEnv.ts +124 -0
- package/apps/storefront/src/lib/verification/totVerificationClient.ts +485 -0
- package/apps/storefront/src/middleware/candidatePin.ts +35 -0
- package/apps/storefront/src/middleware/controlPlane.ts +291 -0
- package/apps/storefront/src/middleware/index.ts +200 -793
- package/apps/storefront/src/middleware/publicRender.ts +43 -0
- package/apps/storefront/src/middleware/renderChain.ts +730 -0
- package/apps/storefront/src/middleware/routeBranch.ts +192 -0
- package/apps/storefront/src/middleware/staticBundle.ts +109 -0
- package/apps/storefront/src/pages/[...slug].astro +1 -0
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +1 -0
- package/apps/storefront/src/pages/api/auth/step-up-verify.ts +1 -0
- package/apps/storefront/src/pages/api/auth/verify.ts +1 -0
- package/apps/storefront/src/pages/api/compliance/preflight.ts +179 -2
- package/apps/storefront/src/pages/index.astro +1 -0
- package/apps/storefront/vitest.config.ts +5 -0
- package/package.json +1 -1
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/deliver-test-event-request.schema.json +122 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/get-test-destination-delivery-request.schema.json +79 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/register-test-destination-request.schema.json +93 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/test-destination-delivery.schema.json +106 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/test-destination.schema.json +77 -0
- package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +195 -0
- package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +80 -0
- package/packages/public-runtime/schemas/publication-space/v1/ref.schema.json +20 -0
- package/packages/public-runtime/schemas/publication-version/v1/pointer.schema.json +28 -0
- package/packages/public-runtime/schemas/publication-version/v1/transition-request.schema.json +44 -0
- package/packages/public-runtime/schemas/publication-version/v1/transition.schema.json +47 -0
- package/packages/public-runtime/schemas/publication-version/v1/version.schema.json +58 -0
- package/packages/public-runtime/src/activity/catalog.ts +1 -1
- package/packages/public-runtime/src/candidate-index.ts +6 -0
- package/packages/public-runtime/src/customization-reconcile.ts +8 -0
- package/packages/public-runtime/src/html-cache-policy.ts +57 -0
- package/packages/public-runtime/src/index.ts +9 -0
- package/packages/public-runtime/src/integration/consent-broker.ts +115 -0
- package/packages/public-runtime/src/integration/interim-bridge.ts +111 -27
- package/packages/public-runtime/src/integration/managed-analytics-api-v1.schemas.ts +65 -0
- package/packages/public-runtime/src/integration/managed-analytics-api-v1.ts +81 -0
- package/packages/public-runtime/src/integration/render-phases.ts +8 -3
- package/packages/public-runtime/src/publication-artifact.ts +220 -0
- package/packages/public-runtime/src/publication-reader.ts +187 -0
- package/packages/public-runtime/src/publication-routing.ts +246 -0
- package/packages/public-runtime/src/publication-space.ts +853 -0
- package/packages/public-runtime/src/publication-version.ts +602 -0
- package/packages/public-runtime/src/route-tier.ts +257 -0
- package/packages/public-runtime/src/static-bundle.ts +18 -5
- package/packages/public-runtime/src/tenant-assets.ts +85 -17
- package/packages/public-runtime/src/tenant.ts +70 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/internal.json +32 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/mounted-public.json +38 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/normalized-mounts.json +39 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/ref.json +5 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/root-public.json +31 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/articles-v1.json +20 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/articles-v2.json +20 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/internal-v1.json +20 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/live-pointer.json +11 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/live-transition-request.json +14 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/live-transition.json +17 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/root-v1.json +20 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/root-v2.json +20 -0
- package/scripts/dev/ai-edit.mjs +16 -3
- package/scripts/dev/port-check.mjs +18 -3
- package/scripts/dev/publish.mjs +26 -3
- package/scripts/tenant/validate.mjs +14 -1
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime env access that works in BOTH contexts:
|
|
3
|
+
* - `astro dev` (Node): reads import.meta.env / process.env.
|
|
4
|
+
* - Cloudflare Workers (prod): reads the `cloudflare:workers` env module.
|
|
5
|
+
*
|
|
6
|
+
* Astro v6+/@astrojs/cloudflare v14 REMOVED `Astro.locals.runtime.env`; the
|
|
7
|
+
* supported path is `import { env } from "cloudflare:workers"`. That module only
|
|
8
|
+
* exists in the worker runtime, so we import it lazily + guarded and fall back
|
|
9
|
+
* to build-time env in dev. See DECISIONS.md.
|
|
10
|
+
*
|
|
11
|
+
* This module holds every binding EXCEPT D1. `./env.js` is this surface plus
|
|
12
|
+
* `readD1`, and the public-render branch may reach this module but not that one —
|
|
13
|
+
* `public-render-no-request-time-d1` in .dependency-cruiser.cjs enforces it. A
|
|
14
|
+
* fence can only forbid a MODULE, so the one accessor that hands out a live
|
|
15
|
+
* control-plane database has to live in a module of its own for the fence to
|
|
16
|
+
* have anything to name.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
let workerEnv: Record<string, unknown> | null | undefined;
|
|
20
|
+
|
|
21
|
+
/** The raw Workers env bag, or null outside the Workers runtime. */
|
|
22
|
+
export async function getWorkerEnv(): Promise<Record<string, unknown> | null> {
|
|
23
|
+
if (workerEnv !== undefined) return workerEnv;
|
|
24
|
+
try {
|
|
25
|
+
// @ts-expect-error — virtual module, only resolvable in the Workers runtime.
|
|
26
|
+
const mod = await import("cloudflare:workers");
|
|
27
|
+
workerEnv = (mod.env ?? null) as Record<string, unknown> | null;
|
|
28
|
+
} catch {
|
|
29
|
+
workerEnv = null;
|
|
30
|
+
}
|
|
31
|
+
return workerEnv;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function fromBuild(key: string): string | undefined {
|
|
35
|
+
const meta = import.meta.env as Record<string, string | undefined>;
|
|
36
|
+
if (meta[key] != null) return meta[key];
|
|
37
|
+
if (typeof process !== "undefined" && process.env && process.env[key] != null) {
|
|
38
|
+
return process.env[key];
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function readEnv(key: string): Promise<string | undefined> {
|
|
44
|
+
const w = await getWorkerEnv();
|
|
45
|
+
const wv = w?.[key];
|
|
46
|
+
if (typeof wv === "string" && wv.length) return wv;
|
|
47
|
+
return fromBuild(key);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The slice of the Workers ExecutionContext runAfterResponse needs — the
|
|
51
|
+
* @astrojs/cloudflare adapter exposes it to routes as `locals.cfContext`. */
|
|
52
|
+
export interface CfExecutionContextLike {
|
|
53
|
+
waitUntil(promise: Promise<unknown>): void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Run `work` PAST the response — the request's `ExecutionContext.waitUntil`
|
|
58
|
+
* when the caller passes it, else a detached floating promise (dev/Node). For
|
|
59
|
+
* webhook RECEIVERS that must ACK fast: the upstream forwarder (tot-mcp
|
|
60
|
+
* gitea-webhook proxy) aborts its forward at 15s and Gitea bounds its delivery
|
|
61
|
+
* wait too, while a real reconcile takes tens of seconds — running it inside
|
|
62
|
+
* the request meant every healthy reconcile was killed mid-flight and recorded
|
|
63
|
+
* as `forward_failed` (a layer of Trello 13075, behind the auth fix).
|
|
64
|
+
*
|
|
65
|
+
* CALLERS MUST PASS `locals.cfContext` (the @astrojs/cloudflare adapter's
|
|
66
|
+
* per-request ExecutionContext, `createLocals: { cfContext: ctx }`): in the
|
|
67
|
+
* Workers runtime a merely-floating promise is CANCELLED the moment the
|
|
68
|
+
* response returns — an earlier revision here imported `waitUntil` from
|
|
69
|
+
* `cloudflare:workers` (not exported by this adapter/runtime version), silently
|
|
70
|
+
* degraded to the floating promise, and the async reconcile died unobserved
|
|
71
|
+
* right after its 202. The floating-promise branch is a DEV-ONLY fallback.
|
|
72
|
+
* Errors are the caller's to handle inside `work` (this never throws).
|
|
73
|
+
*/
|
|
74
|
+
export function runAfterResponse(
|
|
75
|
+
work: Promise<unknown>,
|
|
76
|
+
cfContext?: CfExecutionContextLike | null,
|
|
77
|
+
): void {
|
|
78
|
+
const swallowed = work.catch(() => {});
|
|
79
|
+
if (cfContext && typeof cfContext.waitUntil === "function") {
|
|
80
|
+
cfContext.waitUntil(swallowed);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// dev/Node only — in the Workers runtime this promise would be cancelled at
|
|
84
|
+
// response end, which is exactly the bug the parameter above exists to avoid.
|
|
85
|
+
void swallowed;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** KV binding for the newsletter stub / tenant cache, if present in the runtime. */
|
|
89
|
+
export async function readKv(binding: string): Promise<KVNamespace | undefined> {
|
|
90
|
+
const w = await getWorkerEnv();
|
|
91
|
+
const b = w?.[binding];
|
|
92
|
+
return (b as KVNamespace | undefined) ?? undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** R2 bucket binding (e.g. TENANT_ASSETS, PRODUCT_IMAGES), if present. */
|
|
96
|
+
export async function readR2(binding: string): Promise<R2Bucket | undefined> {
|
|
97
|
+
const w = await getWorkerEnv();
|
|
98
|
+
return (w?.[binding] as R2Bucket | undefined) ?? undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Cloudflare Analytics Engine dataset binding (e.g. ACTIVITY_ANALYTICS), if
|
|
103
|
+
* present. Untyped (`unknown`) deliberately — the caller casts to its own
|
|
104
|
+
* minimal `writeDataPoint` shape (see `activity/store.ts`'s
|
|
105
|
+
* `AnalyticsEngineLike`) rather than this module taking a types dep. Absent in
|
|
106
|
+
* dev/Node and until the binding is provisioned; callers treat undefined as
|
|
107
|
+
* "analytics sink unavailable, skip it" (never an error).
|
|
108
|
+
*/
|
|
109
|
+
export async function readAnalytics(binding: string): Promise<unknown | undefined> {
|
|
110
|
+
const w = await getWorkerEnv();
|
|
111
|
+
return w?.[binding] ?? undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Cloudflare Queue producer binding (e.g. WEBHOOK_DELIVERY_QUEUE), if present.
|
|
116
|
+
* Untyped (`unknown`) deliberately — callers cast to their own minimal
|
|
117
|
+
* queue-shape interface (see `webhooks/cloudflareQueueDispatcher.ts`'s
|
|
118
|
+
* `CloudflareQueueSender`) rather than this module taking a types dep on a
|
|
119
|
+
* specific binding's shape.
|
|
120
|
+
*/
|
|
121
|
+
export async function readQueue(binding: string): Promise<unknown | undefined> {
|
|
122
|
+
const w = await getWorkerEnv();
|
|
123
|
+
return w?.[binding] ?? undefined;
|
|
124
|
+
}
|
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TotVerificationClient — the storefront's seam to Token of Trust's LIVE
|
|
3
|
+
* age/identity verification API, i.e. the code behind
|
|
4
|
+
* `TOT_VERIFICATION_PROVIDER="tot"`.
|
|
5
|
+
*
|
|
6
|
+
* POST /api/person { totApiKey, totSecretKey, appDomain, appTransactionId, person }
|
|
7
|
+
* → { content: { code, continuation: { params: { url } }, reservationToken } }
|
|
8
|
+
* `code` = "WorkflowService:conditionsMet" → nothing further needed
|
|
9
|
+
* = "WorkflowService:nextStepIsInteractive" → send the shopper to
|
|
10
|
+
* `continuation.params.url` (the redirect trip owned by the
|
|
11
|
+
* checkout-verification-flow unit; this client only surfaces it)
|
|
12
|
+
*
|
|
13
|
+
* GET /api/person?totApiKey=…&totSecretKey=…&appDomain=…&appTransactionId=…
|
|
14
|
+
* → { content: { appTransactionId, user, transaction: { report: {
|
|
15
|
+
* connection: { status }, gates: { isCleared, isSubmitted,
|
|
16
|
+
* isRejected, isUpdateRequested } } } } }
|
|
17
|
+
* The report is NESTED under `transaction.report` — see `reportOf`.
|
|
18
|
+
*
|
|
19
|
+
* Credentials are the VERIFICATION app's pair (`TOT_VERIFICATION_API_KEY` /
|
|
20
|
+
* `TOT_VERIFICATION_SECRET_KEY`, appDomain `TOT_VERIFICATION_APP_DOMAIN`) — never
|
|
21
|
+
* `TOT_API_KEY`/`TOT_SECRET_KEY`, which are the storefront's OWN operator identity
|
|
22
|
+
* under a different appDomain. Authenticating a shopper's verification under the
|
|
23
|
+
* operator identity would silently bind it to the wrong tenant.
|
|
24
|
+
*
|
|
25
|
+
* FAIL CLOSED, ALWAYS. Every method returns a typed result and never throws: a
|
|
26
|
+
* transport error, a non-2xx, an unparseable body, an unexpected shape, or a
|
|
27
|
+
* missing credential all resolve to `{ ok: false, reason }`. The caller
|
|
28
|
+
* (`/api/compliance/preflight`) turns any `ok:false` into the same BLOCK +
|
|
29
|
+
* `failedClosed:true` decision as "no provider configured".
|
|
30
|
+
*
|
|
31
|
+
* `totSecretKey` never appears in a log line, an error message, or a response —
|
|
32
|
+
* only status codes and coarse shape descriptions are logged on failure.
|
|
33
|
+
*
|
|
34
|
+
* Structure mirrors `@/lib/auth/totAccessClient`: interface + Http impl + Mock +
|
|
35
|
+
* env factory, with the wire-credential shape isolated to `credentialFields()`.
|
|
36
|
+
*/
|
|
37
|
+
import { readEnv } from "@/lib/env";
|
|
38
|
+
import {
|
|
39
|
+
FetchHttpTransport,
|
|
40
|
+
type HttpGetTransport,
|
|
41
|
+
type HttpResponseLike,
|
|
42
|
+
type HttpTransport,
|
|
43
|
+
} from "@/lib/http/fetchTransport";
|
|
44
|
+
|
|
45
|
+
// --- Gate normalization -----------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Gate values that denote a POSITIVE (set) gate. `GET /api/person` reports each
|
|
49
|
+
* gate either as a bare value or as a `{ value, dependsOn }` object — observed
|
|
50
|
+
* live for this app as `isCleared: { value: "fullMatch", dependsOn: [...] }`.
|
|
51
|
+
*/
|
|
52
|
+
const POSITIVE_GATE_VALUES: ReadonlySet<string> = new Set([
|
|
53
|
+
"fullmatch",
|
|
54
|
+
"partialmatch",
|
|
55
|
+
"true",
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Normalize one gate from `GET /api/person` to a boolean.
|
|
60
|
+
*
|
|
61
|
+
* The gate is set when it is `true`, the string `"fullMatch"`/`"partialMatch"`,
|
|
62
|
+
* or an OBJECT whose `.value` is one of those. **Never compare a gate with a bare
|
|
63
|
+
* `===` string.** The wire shape for this tenant/app is the object form, so
|
|
64
|
+
* `gates.isCleared === "fullMatch"` is always false and reports "pending"
|
|
65
|
+
* forever, with no error anywhere — the single most common integration bug
|
|
66
|
+
* against this API. Anything else (missing, `false`, an unrecognized string, an
|
|
67
|
+
* object with no usable `.value`) is NOT set.
|
|
68
|
+
*/
|
|
69
|
+
export function isGateSet(gate: unknown): boolean {
|
|
70
|
+
if (gate === true) return true;
|
|
71
|
+
if (typeof gate === "string") return POSITIVE_GATE_VALUES.has(gate.toLowerCase());
|
|
72
|
+
if (gate && typeof gate === "object") {
|
|
73
|
+
const value = (gate as { value?: unknown }).value;
|
|
74
|
+
if (value === true) return true;
|
|
75
|
+
if (typeof value === "string") return POSITIVE_GATE_VALUES.has(value.toLowerCase());
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- Result types -----------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Why a call did not produce a usable answer. Every one of these is fail-closed
|
|
84
|
+
* at the call site — they are distinguished only for operator diagnostics.
|
|
85
|
+
*/
|
|
86
|
+
export type TotVerificationFailureReason =
|
|
87
|
+
| "not_configured"
|
|
88
|
+
| "transport_error"
|
|
89
|
+
| "http_error"
|
|
90
|
+
| "malformed_response";
|
|
91
|
+
|
|
92
|
+
/** What the create call decided about the shopper's next step. */
|
|
93
|
+
export type TotVerificationNextStep =
|
|
94
|
+
| "conditions_met"
|
|
95
|
+
| "interactive"
|
|
96
|
+
/** A `code` we don't recognize — treated as "not cleared" by the caller. */
|
|
97
|
+
| "indeterminate";
|
|
98
|
+
|
|
99
|
+
export type CreateVerificationResult =
|
|
100
|
+
| {
|
|
101
|
+
ok: true;
|
|
102
|
+
nextStep: TotVerificationNextStep;
|
|
103
|
+
/** The raw `content.code` (diagnostics + downstream flow branching). */
|
|
104
|
+
code: string;
|
|
105
|
+
/**
|
|
106
|
+
* Where to send the shopper when `nextStep === "interactive"`. Consumed by
|
|
107
|
+
* the downstream checkout-verification-flow unit; this client only surfaces
|
|
108
|
+
* it and never redirects.
|
|
109
|
+
*/
|
|
110
|
+
continuationUrl?: string;
|
|
111
|
+
reservationToken?: string;
|
|
112
|
+
/** Echo of the correlation id, to be replayed to {@link readVerification}. */
|
|
113
|
+
appTransactionId: string;
|
|
114
|
+
}
|
|
115
|
+
| { ok: false; reason: TotVerificationFailureReason };
|
|
116
|
+
|
|
117
|
+
/** Where the shopper stands, as the READ call reports it. */
|
|
118
|
+
export type TotVerificationStatus =
|
|
119
|
+
/** `isCleared` is set — verification passed. */
|
|
120
|
+
| "cleared"
|
|
121
|
+
/** `isRejected` is set — verification did not pass. */
|
|
122
|
+
| "rejected"
|
|
123
|
+
/** Submitted or connected but neither cleared nor rejected yet. */
|
|
124
|
+
| "pending"
|
|
125
|
+
/** `connection.status === "noUser"` — nothing started for this correlation id. */
|
|
126
|
+
| "not_started";
|
|
127
|
+
|
|
128
|
+
export type ReadVerificationResult =
|
|
129
|
+
| {
|
|
130
|
+
ok: true;
|
|
131
|
+
status: TotVerificationStatus;
|
|
132
|
+
submitted: boolean;
|
|
133
|
+
updateRequested: boolean;
|
|
134
|
+
}
|
|
135
|
+
| { ok: false; reason: TotVerificationFailureReason };
|
|
136
|
+
|
|
137
|
+
/** The person payload the create call submits. Only `primaryEmail` is required. */
|
|
138
|
+
export interface VerificationPersonInput {
|
|
139
|
+
primaryEmail: string;
|
|
140
|
+
givenName?: string;
|
|
141
|
+
familyName?: string;
|
|
142
|
+
/** ISO date (YYYY-MM-DD) when the shopper supplied one. */
|
|
143
|
+
dateOfBirth?: string;
|
|
144
|
+
location?: Record<string, unknown>;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface CreateVerificationInput {
|
|
148
|
+
/** Per-checkout-attempt correlation id — replayed to the read call. */
|
|
149
|
+
appTransactionId: string;
|
|
150
|
+
person: VerificationPersonInput;
|
|
151
|
+
appUserid?: string;
|
|
152
|
+
reservation?: boolean;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface ReadVerificationInput {
|
|
156
|
+
appTransactionId?: string;
|
|
157
|
+
appUserid?: string;
|
|
158
|
+
scope?: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface TotVerificationClient {
|
|
162
|
+
/**
|
|
163
|
+
* Start (or re-assert) a verification for this checkout attempt. Returns the
|
|
164
|
+
* continuation URL when the shopper must complete an interactive step.
|
|
165
|
+
*/
|
|
166
|
+
createVerification(input: CreateVerificationInput): Promise<CreateVerificationResult>;
|
|
167
|
+
/** Read where an existing verification stands. Safe to call repeatedly. */
|
|
168
|
+
readVerification(input: ReadVerificationInput): Promise<ReadVerificationResult>;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// --- HTTP implementation ----------------------------------------------------
|
|
172
|
+
|
|
173
|
+
/** The transport this client needs: JSON POST plus a query-string GET. */
|
|
174
|
+
export type VerificationTransport = HttpTransport & HttpGetTransport;
|
|
175
|
+
|
|
176
|
+
const defaultTransport = (): VerificationTransport =>
|
|
177
|
+
new FetchHttpTransport({
|
|
178
|
+
defaultHeaders: { "content-type": "application/json", accept: "application/json" },
|
|
179
|
+
// A hung verification subrequest must not hold the checkout gate open; an
|
|
180
|
+
// abort surfaces as `transport_error`, which fails closed.
|
|
181
|
+
timeoutMs: 10_000,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
export interface HttpTotVerificationClientConfig {
|
|
185
|
+
/** Verification API base, e.g. `https://qa.tokenoftrust.com/api` (no trailing slash). */
|
|
186
|
+
baseUrl: string;
|
|
187
|
+
apiKey: string;
|
|
188
|
+
secretKey: string;
|
|
189
|
+
/** The VERIFICATION app's domain, e.g. `verify.e2e.test`. */
|
|
190
|
+
appDomain: string;
|
|
191
|
+
transport?: VerificationTransport;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Truthy only when every field the live API requires is present. The factory
|
|
196
|
+
* returns `null` otherwise so the caller fails closed instead of issuing a call
|
|
197
|
+
* that would 401 (or, worse, authenticate as the wrong app).
|
|
198
|
+
*/
|
|
199
|
+
export function isVerificationClientConfigured(
|
|
200
|
+
config: Partial<HttpTotVerificationClientConfig>,
|
|
201
|
+
): config is HttpTotVerificationClientConfig {
|
|
202
|
+
return !!(config.baseUrl && config.apiKey && config.secretKey && config.appDomain);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export class HttpTotVerificationClient implements TotVerificationClient {
|
|
206
|
+
private readonly baseUrl: string;
|
|
207
|
+
private readonly apiKey: string;
|
|
208
|
+
private readonly secretKey: string;
|
|
209
|
+
private readonly appDomain: string;
|
|
210
|
+
private readonly transport: VerificationTransport;
|
|
211
|
+
|
|
212
|
+
constructor(config: HttpTotVerificationClientConfig) {
|
|
213
|
+
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
|
214
|
+
this.apiKey = config.apiKey;
|
|
215
|
+
this.secretKey = config.secretKey;
|
|
216
|
+
this.appDomain = config.appDomain;
|
|
217
|
+
this.transport = config.transport ?? defaultTransport();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The one integration-sensitive spot: how this client authenticates. tot reads
|
|
222
|
+
* `totApiKey`/`totSecretKey`/`appDomain` from the request BODY on POST and from
|
|
223
|
+
* the QUERY STRING on GET (no bearer header). Swap here if that ever changes —
|
|
224
|
+
* nowhere else knows the wire shape.
|
|
225
|
+
*/
|
|
226
|
+
private credentialFields(): Record<string, string> {
|
|
227
|
+
return {
|
|
228
|
+
totApiKey: this.apiKey,
|
|
229
|
+
totSecretKey: this.secretKey,
|
|
230
|
+
appDomain: this.appDomain,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async createVerification(
|
|
235
|
+
input: CreateVerificationInput,
|
|
236
|
+
): Promise<CreateVerificationResult> {
|
|
237
|
+
let res: HttpResponseLike;
|
|
238
|
+
try {
|
|
239
|
+
res = await this.transport.post(
|
|
240
|
+
`${this.baseUrl}/person`,
|
|
241
|
+
{},
|
|
242
|
+
{
|
|
243
|
+
...this.credentialFields(),
|
|
244
|
+
appTransactionId: input.appTransactionId,
|
|
245
|
+
...(input.appUserid ? { appUserid: input.appUserid } : {}),
|
|
246
|
+
...(input.reservation != null ? { reservation: input.reservation } : {}),
|
|
247
|
+
person: input.person,
|
|
248
|
+
},
|
|
249
|
+
);
|
|
250
|
+
} catch (e) {
|
|
251
|
+
logFailure("create", { transport: errorLabel(e) });
|
|
252
|
+
return { ok: false, reason: "transport_error" };
|
|
253
|
+
}
|
|
254
|
+
if (!isOk(res.status)) {
|
|
255
|
+
logFailure("create", { status: res.status });
|
|
256
|
+
return { ok: false, reason: "http_error" };
|
|
257
|
+
}
|
|
258
|
+
let body: unknown;
|
|
259
|
+
try {
|
|
260
|
+
body = await res.json();
|
|
261
|
+
} catch {
|
|
262
|
+
logFailure("create", { status: res.status, body: "unparseable" });
|
|
263
|
+
return { ok: false, reason: "malformed_response" };
|
|
264
|
+
}
|
|
265
|
+
return normalizeCreate(body, input.appTransactionId);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async readVerification(input: ReadVerificationInput): Promise<ReadVerificationResult> {
|
|
269
|
+
if (!input.appTransactionId && !input.appUserid) {
|
|
270
|
+
// Nothing to correlate on — the API requires at least one.
|
|
271
|
+
logFailure("read", { body: "no-correlation-id" });
|
|
272
|
+
return { ok: false, reason: "not_configured" };
|
|
273
|
+
}
|
|
274
|
+
const query = new URLSearchParams({
|
|
275
|
+
...this.credentialFields(),
|
|
276
|
+
...(input.appTransactionId ? { appTransactionId: input.appTransactionId } : {}),
|
|
277
|
+
...(input.appUserid ? { appUserid: input.appUserid } : {}),
|
|
278
|
+
...(input.scope ? { scope: input.scope } : {}),
|
|
279
|
+
});
|
|
280
|
+
let res: HttpResponseLike;
|
|
281
|
+
try {
|
|
282
|
+
res = await this.transport.get(`${this.baseUrl}/person?${query.toString()}`, {});
|
|
283
|
+
} catch (e) {
|
|
284
|
+
logFailure("read", { transport: errorLabel(e) });
|
|
285
|
+
return { ok: false, reason: "transport_error" };
|
|
286
|
+
}
|
|
287
|
+
if (!isOk(res.status)) {
|
|
288
|
+
logFailure("read", { status: res.status });
|
|
289
|
+
return { ok: false, reason: "http_error" };
|
|
290
|
+
}
|
|
291
|
+
let body: unknown;
|
|
292
|
+
try {
|
|
293
|
+
body = await res.json();
|
|
294
|
+
} catch {
|
|
295
|
+
logFailure("read", { status: res.status, body: "unparseable" });
|
|
296
|
+
return { ok: false, reason: "malformed_response" };
|
|
297
|
+
}
|
|
298
|
+
return normalizeRead(body);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const isOk = (status: number) => status >= 200 && status < 300;
|
|
303
|
+
|
|
304
|
+
const errorLabel = (e: unknown) => (e instanceof Error ? e.name : "unknown");
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Log ONLY non-secret diagnostics. The request body carries `totSecretKey`, so it
|
|
308
|
+
* is never logged — status codes and coarse shape labels only.
|
|
309
|
+
*/
|
|
310
|
+
function logFailure(op: "create" | "read", detail: Record<string, unknown>): void {
|
|
311
|
+
try {
|
|
312
|
+
console.warn("[verification:tot]", JSON.stringify({ op, ...detail }));
|
|
313
|
+
} catch {
|
|
314
|
+
/* never break the gate on a logging failure */
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Unwrap tot's `{ content, metadata }` envelope (falls back to the raw body). */
|
|
319
|
+
function unwrap(body: unknown): Record<string, unknown> {
|
|
320
|
+
const raw = (body ?? {}) as Record<string, unknown>;
|
|
321
|
+
return (raw.content && typeof raw.content === "object" ? raw.content : raw) as Record<
|
|
322
|
+
string,
|
|
323
|
+
unknown
|
|
324
|
+
>;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Locate the gate/connection REPORT inside a `GET /api/person` body.
|
|
329
|
+
*
|
|
330
|
+
* The live wire nests it: `content.transaction.report.{connection,gates}` — the
|
|
331
|
+
* envelope's `content` carries only `appUserid`/`appTransactionId`/`user`/
|
|
332
|
+
* `transaction`. Reading `content.gates` therefore finds nothing and every read
|
|
333
|
+
* resolves to `malformed_response`, which fails closed for EVERY shopper,
|
|
334
|
+
* including a cleared one — silently, since fail-closed is also the correct
|
|
335
|
+
* answer when the service is genuinely unreachable. Descend first; a body that
|
|
336
|
+
* carries the report un-nested is used as-is.
|
|
337
|
+
*/
|
|
338
|
+
function reportOf(body: unknown): Record<string, unknown> {
|
|
339
|
+
const content = unwrap(body);
|
|
340
|
+
const transaction = content.transaction as { report?: unknown } | undefined;
|
|
341
|
+
const report = transaction?.report;
|
|
342
|
+
if (report && typeof report === "object") return report as Record<string, unknown>;
|
|
343
|
+
return content;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Parse `POST /api/person` into a typed result (fail closed on any surprise). */
|
|
347
|
+
export function normalizeCreate(
|
|
348
|
+
body: unknown,
|
|
349
|
+
appTransactionId: string,
|
|
350
|
+
): CreateVerificationResult {
|
|
351
|
+
const b = unwrap(body);
|
|
352
|
+
const code = typeof b.code === "string" ? b.code : "";
|
|
353
|
+
if (!code) {
|
|
354
|
+
logFailure("create", { body: "no-code" });
|
|
355
|
+
return { ok: false, reason: "malformed_response" };
|
|
356
|
+
}
|
|
357
|
+
const nextStep: TotVerificationNextStep = code.endsWith(":conditionsMet")
|
|
358
|
+
? "conditions_met"
|
|
359
|
+
: code.endsWith(":nextStepIsInteractive")
|
|
360
|
+
? "interactive"
|
|
361
|
+
: "indeterminate";
|
|
362
|
+
|
|
363
|
+
const continuation = b.continuation as { params?: { url?: unknown } } | undefined;
|
|
364
|
+
const url = continuation?.params?.url;
|
|
365
|
+
const result: CreateVerificationResult = {
|
|
366
|
+
ok: true,
|
|
367
|
+
nextStep,
|
|
368
|
+
code,
|
|
369
|
+
appTransactionId,
|
|
370
|
+
};
|
|
371
|
+
if (typeof url === "string" && url) result.continuationUrl = url;
|
|
372
|
+
if (typeof b.reservationToken === "string" && b.reservationToken) {
|
|
373
|
+
result.reservationToken = b.reservationToken;
|
|
374
|
+
}
|
|
375
|
+
return result;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Parse `GET /api/person` into a typed status (fail closed on any surprise). */
|
|
379
|
+
export function normalizeRead(body: unknown): ReadVerificationResult {
|
|
380
|
+
const b = reportOf(body);
|
|
381
|
+
const connection = b.connection as { status?: unknown } | undefined;
|
|
382
|
+
const gatesRaw = b.gates;
|
|
383
|
+
const gates = (gatesRaw && typeof gatesRaw === "object" ? gatesRaw : null) as Record<
|
|
384
|
+
string,
|
|
385
|
+
unknown
|
|
386
|
+
> | null;
|
|
387
|
+
const connectionStatus =
|
|
388
|
+
connection && typeof connection === "object" && typeof connection.status === "string"
|
|
389
|
+
? connection.status
|
|
390
|
+
: undefined;
|
|
391
|
+
|
|
392
|
+
if (!gates && connectionStatus !== "noUser") {
|
|
393
|
+
// Neither a recognizable gate map nor an explicit "no user" — we cannot tell
|
|
394
|
+
// where the shopper stands, so we refuse to guess.
|
|
395
|
+
logFailure("read", { body: "no-gates" });
|
|
396
|
+
return { ok: false, reason: "malformed_response" };
|
|
397
|
+
}
|
|
398
|
+
if (connectionStatus === "noUser") {
|
|
399
|
+
return { ok: true, status: "not_started", submitted: false, updateRequested: false };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const submitted = isGateSet(gates?.isSubmitted);
|
|
403
|
+
const updateRequested = isGateSet(gates?.isUpdateRequested);
|
|
404
|
+
// DENY WINS: a rejection is honored even if a cleared gate is somehow also set.
|
|
405
|
+
const status: TotVerificationStatus = isGateSet(gates?.isRejected)
|
|
406
|
+
? "rejected"
|
|
407
|
+
: isGateSet(gates?.isCleared)
|
|
408
|
+
? "cleared"
|
|
409
|
+
: "pending";
|
|
410
|
+
return { ok: true, status, submitted, updateRequested };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// --- Mock (tests + local dev) -----------------------------------------------
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* In-memory client for unit tests and local dev. It NEVER stands in for the live
|
|
417
|
+
* provider at runtime: the factory only ever builds {@link HttpTotVerificationClient},
|
|
418
|
+
* so an unconfigured environment fails closed rather than silently mocking a pass.
|
|
419
|
+
*/
|
|
420
|
+
export class MockTotVerificationClient implements TotVerificationClient {
|
|
421
|
+
created: CreateVerificationInput[] = [];
|
|
422
|
+
read: ReadVerificationInput[] = [];
|
|
423
|
+
|
|
424
|
+
constructor(
|
|
425
|
+
private readonly createResult: CreateVerificationResult = {
|
|
426
|
+
ok: true,
|
|
427
|
+
nextStep: "interactive",
|
|
428
|
+
code: "WorkflowService:nextStepIsInteractive",
|
|
429
|
+
continuationUrl: "https://qa.tokenoftrust.com/verify/mock",
|
|
430
|
+
appTransactionId: "mock-txn",
|
|
431
|
+
},
|
|
432
|
+
private readonly readResult: ReadVerificationResult = {
|
|
433
|
+
ok: true,
|
|
434
|
+
status: "pending",
|
|
435
|
+
submitted: false,
|
|
436
|
+
updateRequested: false,
|
|
437
|
+
},
|
|
438
|
+
) {}
|
|
439
|
+
|
|
440
|
+
async createVerification(input: CreateVerificationInput): Promise<CreateVerificationResult> {
|
|
441
|
+
this.created.push(input);
|
|
442
|
+
return this.createResult;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async readVerification(input: ReadVerificationInput): Promise<ReadVerificationResult> {
|
|
446
|
+
this.read.push(input);
|
|
447
|
+
return this.readResult;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// --- Factory ----------------------------------------------------------------
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Build the env-configured live client, or `null` when the environment is not
|
|
455
|
+
* fully configured (missing base URL, appDomain, or either half of the
|
|
456
|
+
* verification credential pair). `null` is the fail-closed signal — the caller
|
|
457
|
+
* MUST block rather than proceed.
|
|
458
|
+
*
|
|
459
|
+
* Reads `TOT_VERIFICATION_API_KEY`/`TOT_VERIFICATION_SECRET_KEY` and NEVER falls
|
|
460
|
+
* back to `TOT_API_KEY`/`TOT_SECRET_KEY` (a different app identity).
|
|
461
|
+
*/
|
|
462
|
+
export async function createTotVerificationClient(
|
|
463
|
+
overrides: Partial<HttpTotVerificationClientConfig> = {},
|
|
464
|
+
): Promise<HttpTotVerificationClient | null> {
|
|
465
|
+
const config: Partial<HttpTotVerificationClientConfig> = {
|
|
466
|
+
baseUrl: overrides.baseUrl ?? (await readEnv("TOT_VERIFICATION_API_BASE_URL")),
|
|
467
|
+
apiKey: overrides.apiKey ?? (await readEnv("TOT_VERIFICATION_API_KEY")),
|
|
468
|
+
secretKey: overrides.secretKey ?? (await readEnv("TOT_VERIFICATION_SECRET_KEY")),
|
|
469
|
+
appDomain: overrides.appDomain ?? (await readEnv("TOT_VERIFICATION_APP_DOMAIN")),
|
|
470
|
+
};
|
|
471
|
+
if (overrides.transport) config.transport = overrides.transport;
|
|
472
|
+
if (!isVerificationClientConfigured(config)) {
|
|
473
|
+
logFailure("create", {
|
|
474
|
+
body: "not-configured",
|
|
475
|
+
missing: [
|
|
476
|
+
!config.baseUrl && "TOT_VERIFICATION_API_BASE_URL",
|
|
477
|
+
!config.apiKey && "TOT_VERIFICATION_API_KEY",
|
|
478
|
+
!config.secretKey && "TOT_VERIFICATION_SECRET_KEY",
|
|
479
|
+
!config.appDomain && "TOT_VERIFICATION_APP_DOMAIN",
|
|
480
|
+
].filter(Boolean),
|
|
481
|
+
});
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
return new HttpTotVerificationClient(config);
|
|
485
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The candidate-render CONTENT PIN (`__tot_candidate_pin`), resolved once for
|
|
3
|
+
* whichever branch serves the request.
|
|
4
|
+
*
|
|
5
|
+
* The token is signed, tenant-bound and expiry-bound, minted only by the
|
|
6
|
+
* control-plane candidate/revision renderers. Both branches that can reach the
|
|
7
|
+
* static bundle ask this — the render chain and the static passthrough — so
|
|
8
|
+
* `hasCandidatePin` means the same thing at both: a pin this tenant actually
|
|
9
|
+
* verified, never the mere presence of a query param a client can type.
|
|
10
|
+
*/
|
|
11
|
+
import { readEnv } from "@/lib/runtimeEnv";
|
|
12
|
+
import { CANDIDATE_CONTENT_PIN_PARAM, verifyContentPinToken } from "@tot/public-runtime";
|
|
13
|
+
|
|
14
|
+
export interface CandidateContentPin {
|
|
15
|
+
versionId: string;
|
|
16
|
+
basePath: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The pin this request carries, or null. Fail-closed: an absent secret, an
|
|
21
|
+
* unverifiable token, or one claiming another tenant all resolve to null and the
|
|
22
|
+
* caller falls through to normal channel-resolved content.
|
|
23
|
+
*/
|
|
24
|
+
export async function resolveCandidateContentPin(
|
|
25
|
+
url: URL,
|
|
26
|
+
tenantId: string,
|
|
27
|
+
): Promise<CandidateContentPin | null> {
|
|
28
|
+
const param = url.searchParams.get(CANDIDATE_CONTENT_PIN_PARAM);
|
|
29
|
+
if (!param) return null;
|
|
30
|
+
const secret = (await readEnv("CUSTOMIZATION_PREVIEW_SECRET")) ?? null;
|
|
31
|
+
if (!secret) return null;
|
|
32
|
+
const verified = await verifyContentPinToken(secret, param, Math.floor(Date.now() / 1000));
|
|
33
|
+
if (!verified.ok || verified.claims.tenantId !== tenantId) return null;
|
|
34
|
+
return { versionId: verified.claims.versionId, basePath: verified.claims.basePath };
|
|
35
|
+
}
|