@tokenoftrust/storefront-runner 2.0.9 → 2.0.11
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/integrations/materialize-guard.mjs +8 -2
- package/apps/storefront/src/lib/auth/brokerAssertion.ts +31 -5
- package/apps/storefront/src/lib/auth/loginGate.ts +35 -0
- package/apps/storefront/src/lib/shared/principalGrants.ts +215 -94
- package/apps/storefront/src/lib/tot/syncState.ts +111 -0
- package/package.json +1 -1
- package/packages/public-runtime/src/customization-reconcile.ts +15 -1
- package/packages/public-runtime/src/evidence.ts +14 -11
- package/scripts/tenant/materialize-tenant-content.mjs +107 -7
|
@@ -34,7 +34,13 @@ export function materializeGuard() {
|
|
|
34
34
|
const onlyTenants = scoped.length > 0 ? scoped : null;
|
|
35
35
|
const frameworkOnly = onlyTenants === null || process.env.TOT_FRAMEWORK_ONLY_BUILD === "1";
|
|
36
36
|
if (frameworkOnly) {
|
|
37
|
-
|
|
37
|
+
// Framework mode never treats tenant content as a build input (it's
|
|
38
|
+
// resolved from KV/R2 per request), so the guard checks ZERO
|
|
39
|
+
// tenants here — onlyTenants=[] scopes checkPinned to an empty
|
|
40
|
+
// set (not "all tenants", which `null` would mean), so a sibling
|
|
41
|
+
// tenant that is present-but-stale can never block this build.
|
|
42
|
+
const frameworkOnlyTenants = [];
|
|
43
|
+
const { problems } = checkPinned(undefined, { expectRef, allowUnpinned, onlyTenants: frameworkOnlyTenants });
|
|
38
44
|
const absent = problems.filter((p) => p.reason === "absent");
|
|
39
45
|
const real = problems.filter((p) => p.reason !== "absent");
|
|
40
46
|
if (absent.length) {
|
|
@@ -43,7 +49,7 @@ export function materializeGuard() {
|
|
|
43
49
|
else console.log(msg);
|
|
44
50
|
}
|
|
45
51
|
if (real.length) {
|
|
46
|
-
assertPinnedMaterialized({ expectRef, allowUnpinned, onlyTenants });
|
|
52
|
+
assertPinnedMaterialized({ expectRef, allowUnpinned, onlyTenants: frameworkOnlyTenants });
|
|
47
53
|
}
|
|
48
54
|
return;
|
|
49
55
|
}
|
|
@@ -28,6 +28,13 @@ import { readEnv } from "@/lib/env";
|
|
|
28
28
|
/** The audience tot20's broker verifier pins. */
|
|
29
29
|
export const BROKER_AUDIENCE = "tot20-broker";
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* The ONLY place an assertion may travel — a header, never a query string, so the
|
|
33
|
+
* subject never lands in access logs, proxy logs or browser history. tot20 reads
|
|
34
|
+
* it case-insensitively (`readAssertion`, `operatorGrants-api.routes.node.js`).
|
|
35
|
+
*/
|
|
36
|
+
export const BROKER_ASSERTION_HEADER = "X-ToT-Broker-Assertion";
|
|
37
|
+
|
|
31
38
|
/** The only algorithm the storefront broker signs with (tot20 rejects anything else). */
|
|
32
39
|
export const BROKER_ALG = "RS256";
|
|
33
40
|
|
|
@@ -52,8 +59,20 @@ export const BROKER_ISSUER_ENV = "STOREFRONT_BROKER_ISSUER";
|
|
|
52
59
|
*/
|
|
53
60
|
export class BrokerAssertionError extends Error {}
|
|
54
61
|
|
|
55
|
-
/**
|
|
62
|
+
/** Mint options: the optional `tenant` claim, plus test/DI overrides. */
|
|
56
63
|
export interface MintBrokerAssertionOverrides {
|
|
64
|
+
/**
|
|
65
|
+
* Binds this one assertion to a single target tenant (appDomain), signed. tot20
|
|
66
|
+
* reads it as `assertionTenant` and requires it to match the tenant named on the
|
|
67
|
+
* request EXACTLY — an unnormalized string compare, so pass the same value the
|
|
68
|
+
* request carries. It names the tenant the broker was asked to act on; it is
|
|
69
|
+
* NOT evidence the subject owns it (tot20 resolves ownership from its own state).
|
|
70
|
+
*
|
|
71
|
+
* Omit for the surfaces that act on the caller's own store or on ToT-staff
|
|
72
|
+
* authority alone (the invite/vendor-search flows) — tot20 leaves
|
|
73
|
+
* `assertionTenant` undefined there and those routes never consult it.
|
|
74
|
+
*/
|
|
75
|
+
tenant?: string;
|
|
57
76
|
/** base64 of the PKCS8 PEM private key (defaults to the env value). */
|
|
58
77
|
privateKeyB64?: string;
|
|
59
78
|
kid?: string;
|
|
@@ -95,12 +114,17 @@ async function loadSigningKey(privateKeyB64: string): Promise<CryptoKey> {
|
|
|
95
114
|
}
|
|
96
115
|
|
|
97
116
|
/**
|
|
98
|
-
* Mint a fresh, storefront-signed broker assertion PROVING
|
|
99
|
-
*
|
|
117
|
+
* Mint a fresh, storefront-signed broker assertion PROVING the storefront asserts
|
|
118
|
+
* `subjectEmail` as the actor. Returns the compact RS256 JWT to place in
|
|
119
|
+
* {@link BROKER_ASSERTION_HEADER}.
|
|
100
120
|
*
|
|
101
|
-
* Claims: `{ iss, sub:<subjectEmail>, aud:'tot20-broker', jti:<uuid>, iat, exp:iat+300 }
|
|
121
|
+
* Claims: `{ iss, sub:<subjectEmail>, aud:'tot20-broker', jti:<uuid>, iat, exp:iat+300 }`,
|
|
122
|
+
* plus `tenant` when `overrides.tenant` is given.
|
|
102
123
|
* Header: `{ alg:'RS256', kid, typ:'JWT' }`.
|
|
103
124
|
*
|
|
125
|
+
* MINT ONE PER CALL. tot20's verifier BURNS the `jti` on first verify (single-use
|
|
126
|
+
* replay protection), so an assertion must never be reused across two requests.
|
|
127
|
+
*
|
|
104
128
|
* FAILS CLOSED: throws {@link BrokerAssertionError} when the private key env is
|
|
105
129
|
* absent/malformed or no subject is supplied — the caller must NOT fall back to an
|
|
106
130
|
* unproven call.
|
|
@@ -122,9 +146,11 @@ export async function mintBrokerAssertion(
|
|
|
122
146
|
const kid = overrides.kid ?? (await readEnv(BROKER_KID_ENV)) ?? DEFAULT_BROKER_KID;
|
|
123
147
|
const issuer = overrides.issuer ?? (await readEnv(BROKER_ISSUER_ENV)) ?? DEFAULT_BROKER_ISSUER;
|
|
124
148
|
|
|
149
|
+
const tenant = (overrides.tenant ?? "").trim();
|
|
150
|
+
|
|
125
151
|
const key = await loadSigningKey(privateKeyB64);
|
|
126
152
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
127
|
-
return new SignJWT({})
|
|
153
|
+
return new SignJWT(tenant ? { tenant } : {})
|
|
128
154
|
.setProtectedHeader({ alg: BROKER_ALG, kid, typ: "JWT" })
|
|
129
155
|
.setIssuer(issuer)
|
|
130
156
|
.setSubject(sub)
|
|
@@ -144,6 +144,39 @@ export function isPublicPath(pathname: string): boolean {
|
|
|
144
144
|
return pathname === "/api/cli/policy";
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
/**
|
|
148
|
+
* The CLI viewer-session mint (`/api/dev/cli-session`) — how an invited developer
|
|
149
|
+
* turns their own `tot login` MCP token into a storefront session so `tot accept`
|
|
150
|
+
* can reach the ship surface. Like the static-bundle upload it governs its OWN,
|
|
151
|
+
* stricter auth and must reach its handler rather than being masked behind the
|
|
152
|
+
* "Coming soon" holding page.
|
|
153
|
+
*
|
|
154
|
+
* Gate-exemption does NOT weaken it, on three independent grounds:
|
|
155
|
+
* - It authenticates ITSELF: no `Authorization: Bearer <token>` → 401
|
|
156
|
+
* `missing_bearer`. It reads no cookie, so it carries zero ambient authority.
|
|
157
|
+
* - It resolves WHO that opaque token is only by asking the MCP over the
|
|
158
|
+
* service-secret seam, fail-closed (unconfigured seam, transport fault, non-2xx,
|
|
159
|
+
* unparseable body, or absent email all → 401).
|
|
160
|
+
* - The session it mints is capability `member`, roles `[]` — byte-for-byte the
|
|
161
|
+
* one `/api/auth/magic-exchange` produces from the same `reissueSubjectToken` →
|
|
162
|
+
* verify → `createSession` path, and that route is ALREADY exempt via
|
|
163
|
+
* {@link isAuthPath}. So this exposes no reachable surface that is not.
|
|
164
|
+
*
|
|
165
|
+
* It also grants no admission: a developer holding no membership for this host gets
|
|
166
|
+
* a session that STILL fails `sessionAdmitsHost`, so the gate stays closed to them.
|
|
167
|
+
*
|
|
168
|
+
* Matched in BOTH addressing forms, like {@link isCandidatePreviewPath} — the gate
|
|
169
|
+
* sees the PRE-STRIP request path, so a tenant reached by path prefix arrives as
|
|
170
|
+
* `/<tenant>/api/dev/cli-session`. The CLI addresses tenants exactly that way (the
|
|
171
|
+
* shared storefront origin serves every tenant under a path prefix, which is the only
|
|
172
|
+
* form that works for a tenant whose apex has not cut over), so an exact-match-only
|
|
173
|
+
* exemption would never fire for the one caller this route exists to serve.
|
|
174
|
+
*/
|
|
175
|
+
export function isDevCliSessionPath(pathname: string): boolean {
|
|
176
|
+
if (pathname === "/api/dev/cli-session") return true;
|
|
177
|
+
return /^\/[^/]+\/api\/dev\/cli-session$/.test(pathname);
|
|
178
|
+
}
|
|
179
|
+
|
|
147
180
|
/**
|
|
148
181
|
* The authenticated static-bundle staging upload (`/api/publish/stage-bundle`).
|
|
149
182
|
* Like the candidate-preview control plane, it governs its OWN, stricter auth (a
|
|
@@ -266,6 +299,8 @@ export function evaluateLoginGate(input: LoginGateInput): LoginGateDecision {
|
|
|
266
299
|
if (isCandidatePreviewPath(input.pathname)) return { action: "pass" };
|
|
267
300
|
// The static-bundle staging upload carries its own private_key_jwt auth.
|
|
268
301
|
if (isStaticBundleUploadPath(input.pathname)) return { action: "pass" };
|
|
302
|
+
// The CLI viewer-session mint carries its own bearer auth — see isDevCliSessionPath.
|
|
303
|
+
if (isDevCliSessionPath(input.pathname)) return { action: "pass" };
|
|
269
304
|
// QA content-bypass: a request presenting the server-configured token passes for
|
|
270
305
|
// public content only. NEVER for admin/cockpit/api — that guard lives here (not
|
|
271
306
|
// just in middleware) so /admin stays gated even if the flag is set upstream.
|
|
@@ -9,57 +9,64 @@
|
|
|
9
9
|
* which only ever emits `preview-access`/`blog-contribute` sign-in eligibility) —
|
|
10
10
|
* the two revoke independently and must never be merged.
|
|
11
11
|
*
|
|
12
|
-
* POST /api/clients/principalGrants
|
|
12
|
+
* POST /api/clients/principalGrants { tenant, principal:{type:'user',email}, capability, expiresAt }
|
|
13
13
|
* → 200 { code:'operatorGrants:principalGrantCreated', tenant, principal, ref, capability, expiresAt }
|
|
14
14
|
* → 422 operatorGrants:{expiresAtRequired|unknownCapability|invalidPrincipal|principalTypeNotSupported}
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* DELETE /api/clients/principalGrants/{ref} → 200 { code:'operatorGrants:principalGrantRevoked', tenant }
|
|
15
|
+
* GET /api/clients/principalGrants?tenant=<t> → 200 { code:'operatorGrants:principalGrantsOk', grants:[...] }
|
|
16
|
+
* DELETE /api/clients/principalGrants/{ref}?tenant=<t> → 200 { code:'operatorGrants:principalGrantRevoked', tenant }
|
|
18
17
|
*
|
|
19
18
|
* `expiresAt` (epoch-ms) is REQUIRED for a human grantee — tot20 fails closed
|
|
20
19
|
* with `expiresAtRequired` if omitted (ADR-0003 D4: no standing authority for a
|
|
21
20
|
* human/service principal). `ref` is opaque (never the raw email) and is what
|
|
22
21
|
* `revoke`/list rows key on.
|
|
23
22
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
23
|
+
* GRANTOR MODE: OWNER-BROKERED SESSION. Merchants hold no ToT API keys — they sign
|
|
24
|
+
* into storefront's dashboard. tot20's `resolveGrantorContext` accepts three grantor
|
|
25
|
+
* provenances, and this client drives exactly one of them:
|
|
26
|
+
*
|
|
27
|
+
* - Naming a `tenant` on the request switches tot20 OFF the owner-key path (where
|
|
28
|
+
* the grantor is the CALLER's own store — i.e. storefront itself) and onto the
|
|
29
|
+
* brokered path.
|
|
30
|
+
* - The brokered path requires a VERIFIED broker assertion whose signed `tenant`
|
|
31
|
+
* claim matches the request's tenant EXACTLY (an unnormalized string compare, so
|
|
32
|
+
* both are built from the same value here) and whose SUBJECT is the signed-in
|
|
33
|
+
* owner. tot20 then resolves ownership from its OWN state (`ownsTenant` →
|
|
34
|
+
* `AccessCodeService.resolveEligibility`, strictly `capability === 'owner'`) —
|
|
35
|
+
* the assertion proves WHO the subject is, never WHAT they own.
|
|
36
|
+
* - Transport auth stays storefront's OWN operator credential (`TOT_API_KEY` /
|
|
37
|
+
* `TOT_SECRET_KEY`; the route is `requireSecretKey`), the same credential every
|
|
38
|
+
* sibling client here presents. It authenticates the BROKER; it does not choose
|
|
39
|
+
* the grantor. tot20 audits the result as `authorityPath:'owner-brokered-session'`
|
|
40
|
+
* with actor `owner:<hash>` — distinct from the owner-key `self-service-direct`.
|
|
41
|
+
*
|
|
42
|
+
* The assertion is minted PER CALL by the route (the only layer holding the verified
|
|
43
|
+
* session) and injected as {@link OwnerAssertionMinter}; this module never sees a
|
|
44
|
+
* session and so cannot mint on a client-supplied email. tot20's verifier BURNS the
|
|
45
|
+
* `jti` on first verify, so a minter that memoized would fail the second call as a
|
|
46
|
+
* replay.
|
|
44
47
|
*
|
|
45
48
|
* AUTH WIRE: tot20's swagger `normalizeSecureUserRequest` accepts machine creds
|
|
46
49
|
* three ways — request BODY fields `totApiKey`/`totSecretKey`, the same fields as
|
|
47
50
|
* QUERY params, or an `Authorization` header carrying a base64 "license key"
|
|
48
51
|
* (`base64("<apiKey>;<secretKey>")`, expanded by tot20's `expandLicenseKey`,
|
|
49
|
-
* which takes PRECEDENCE over the body/query values).
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
+
* which takes PRECEDENCE over the body/query values). The broker assertion rides the
|
|
53
|
+
* separate `X-ToT-Broker-Assertion` header ({@link BROKER_ASSERTION_HEADER}) — the
|
|
54
|
+
* only place tot20's `readAssertion` looks. These are the integration-sensitive spots
|
|
55
|
+
* ({@link credentialAuthHeader}, {@link HttpPrincipalGrantClient.brokeredHeaders}).
|
|
52
56
|
*
|
|
53
|
-
* ⚠ CREDENTIALS NEVER GO IN A URL. `POST` carries
|
|
54
|
-
* have no body, so they use the `Authorization` license-key
|
|
55
|
-
* string, even though tot20 would accept it there. A query
|
|
56
|
-
* logs, proxy logs, browser history and error-tracking
|
|
57
|
-
* there is a disclosure with a long tail. tot20 holds this
|
|
58
|
-
* (its
|
|
59
|
-
* in URLs/access logs", and its revoke addresses a grant by an opaque
|
|
60
|
-
* precisely so an email never becomes a path segment); this client must not be
|
|
61
|
-
* thing that breaks it.
|
|
62
|
-
*
|
|
57
|
+
* ⚠ CREDENTIALS AND ASSERTIONS NEVER GO IN A URL. `POST` carries the credential in the
|
|
58
|
+
* body; `GET`/`DELETE` have no body, so they use the `Authorization` license-key
|
|
59
|
+
* header — NOT the query string, even though tot20 would accept it there. A query
|
|
60
|
+
* string lands in access logs, proxy logs, browser history and error-tracking
|
|
61
|
+
* payloads, so a credential there is a disclosure with a long tail. tot20 holds this
|
|
62
|
+
* same line deliberately (its broker assertions are header-only "so the staff subject
|
|
63
|
+
* never lands in URLs/access logs", and its revoke addresses a grant by an opaque
|
|
64
|
+
* `ref` precisely so an email never becomes a path segment); this client must not be
|
|
65
|
+
* the thing that breaks it. `?tenant=` on GET/DELETE is the ONE permitted query param
|
|
66
|
+
* — an appDomain is public routing data, not a credential, and tot20 reads the tenant
|
|
67
|
+
* from `req.query` on exactly those two verbs. `principalGrants.test.ts` asserts that
|
|
68
|
+
* `tenant` is the only param present and that no credential or assertion value ever
|
|
69
|
+
* reaches a URL, for any method — keep that guard.
|
|
63
70
|
*
|
|
64
71
|
* TIER: host + credentials are per-DEPLOY env, same `TOT_CORE_URL` the sibling
|
|
65
72
|
* developerInvites/forge clients read (qa tier is what's live today for this route).
|
|
@@ -70,6 +77,7 @@
|
|
|
70
77
|
*/
|
|
71
78
|
import { readEnv } from "@/lib/env";
|
|
72
79
|
import { diagEmail } from "@/lib/diag/redact";
|
|
80
|
+
import { BROKER_ASSERTION_HEADER, BROKER_PRIVATE_KEY_ENV } from "@/lib/auth/brokerAssertion";
|
|
73
81
|
|
|
74
82
|
/** A human grantee — the only principal type this HTTP surface accepts. */
|
|
75
83
|
export interface UserPrincipal {
|
|
@@ -103,12 +111,18 @@ export interface PrincipalGrantCreated {
|
|
|
103
111
|
}
|
|
104
112
|
|
|
105
113
|
export type PrincipalGrantFailureReason =
|
|
106
|
-
| "notAuthenticated" // 403/422 —
|
|
114
|
+
| "notAuthenticated" // 403/422 — storefront's own operator credential missing/rejected
|
|
107
115
|
| "expiresAtRequired" // 422 — ADR-0003 D4
|
|
108
116
|
| "unknownCapability" // 422
|
|
109
117
|
| "invalidPrincipal" // 422
|
|
110
118
|
| "principalTypeNotSupported" // 422 — non-'user' principal at this HTTP layer
|
|
111
|
-
|
|
119
|
+
// --- owner-brokered grantor path (tot20 `resolveGrantorContext`) ---
|
|
120
|
+
| "brokerAssertionRequired" // 422 — named a tenant with no assertion (client bug)
|
|
121
|
+
| "brokerAssertionInvalid" // 401 — bad signature / expired / replayed jti
|
|
122
|
+
| "assertionTenantMismatch" // 422 — assertion names a different tenant than the request
|
|
123
|
+
| "tenantNotRegistered" // 422 — the named appDomain is not a ToT client
|
|
124
|
+
| "subjectNotTenantOwner" // 403 — the signed-in subject does not OWN this tenant
|
|
125
|
+
| "brokerNotConfigured" // LOCAL 503 — this deploy holds no broker signing key
|
|
112
126
|
| "error"; // transport / unexpected upstream fault
|
|
113
127
|
|
|
114
128
|
export interface PrincipalGrantFailed {
|
|
@@ -173,14 +187,18 @@ export class FetchHttpTransport implements HttpTransport {
|
|
|
173
187
|
export class PrincipalGrantClientError extends Error {}
|
|
174
188
|
|
|
175
189
|
/**
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
190
|
+
* Mints a FRESH broker assertion naming `tenant`, whose subject is the signed-in
|
|
191
|
+
* OWNER. Supplied by the route, which is the only layer holding a verified session
|
|
192
|
+
* — keeping this an injected function is what makes it structurally impossible for
|
|
193
|
+
* this module to assert an email that came off a request.
|
|
194
|
+
*
|
|
195
|
+
* Returns `null` when this deploy cannot sign at all (no broker key) — the caller
|
|
196
|
+
* fails closed with `brokerNotConfigured` rather than sending an unbrokered request
|
|
197
|
+
* that tot20 would silently resolve to storefront's OWN store as grantor.
|
|
198
|
+
*
|
|
199
|
+
* Called once per request: tot20 burns the `jti`, so a cached assertion replays.
|
|
180
200
|
*/
|
|
181
|
-
export type
|
|
182
|
-
tenant: string,
|
|
183
|
-
) => Promise<{ totApiKey: string; totSecretKey?: string } | null>;
|
|
201
|
+
export type OwnerAssertionMinter = (tenant: string) => Promise<string | null>;
|
|
184
202
|
|
|
185
203
|
/**
|
|
186
204
|
* Build the `Authorization` header tot20 accepts for a body-less request (GET /
|
|
@@ -204,34 +222,63 @@ export function credentialAuthHeader(cred: {
|
|
|
204
222
|
export interface HttpPrincipalGrantClientConfig {
|
|
205
223
|
/** tot20 core API base (no trailing slash), e.g. https://qa.tokenoftrust.com */
|
|
206
224
|
baseUrl: string;
|
|
207
|
-
|
|
225
|
+
/** Storefront's OWN operator credential — authenticates the broker, not the grantor. */
|
|
226
|
+
apiKey: string;
|
|
227
|
+
secretKey?: string;
|
|
228
|
+
mintOwnerAssertion: OwnerAssertionMinter;
|
|
208
229
|
transport?: HttpTransport;
|
|
209
230
|
}
|
|
210
231
|
|
|
211
232
|
export class HttpPrincipalGrantClient implements PrincipalGrantClient {
|
|
212
233
|
private readonly baseUrl: string;
|
|
213
|
-
private readonly
|
|
234
|
+
private readonly apiKey: string;
|
|
235
|
+
private readonly secretKey: string | undefined;
|
|
236
|
+
private readonly mintOwnerAssertion: OwnerAssertionMinter;
|
|
214
237
|
private readonly transport: HttpTransport;
|
|
215
238
|
|
|
216
239
|
constructor(config: HttpPrincipalGrantClientConfig) {
|
|
217
240
|
if (!config.baseUrl) {
|
|
218
241
|
throw new PrincipalGrantClientError("principalGrants: TOT_CORE_URL is not configured");
|
|
219
242
|
}
|
|
243
|
+
if (!config.apiKey) {
|
|
244
|
+
throw new PrincipalGrantClientError("principalGrants: TOT_API_KEY is not configured");
|
|
245
|
+
}
|
|
220
246
|
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
|
221
|
-
this.
|
|
247
|
+
this.apiKey = config.apiKey;
|
|
248
|
+
this.secretKey = config.secretKey;
|
|
249
|
+
this.mintOwnerAssertion = config.mintOwnerAssertion;
|
|
222
250
|
this.transport = config.transport ?? new FetchHttpTransport();
|
|
223
251
|
}
|
|
224
252
|
|
|
253
|
+
/** Storefront's own operator credential, as tot20's documented body fields. */
|
|
254
|
+
private credential(): { totApiKey: string; totSecretKey?: string } {
|
|
255
|
+
return { totApiKey: this.apiKey, ...(this.secretKey ? { totSecretKey: this.secretKey } : {}) };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Headers for a brokered call. `assertion` is header-only; the license-key
|
|
260
|
+
* `Authorization` is added for the body-less verbs, which have nowhere else to
|
|
261
|
+
* carry the credential that isn't the URL.
|
|
262
|
+
*/
|
|
263
|
+
private brokeredHeaders(assertion: string, withCredential: boolean): Record<string, string> {
|
|
264
|
+
return {
|
|
265
|
+
[BROKER_ASSERTION_HEADER]: assertion,
|
|
266
|
+
...(withCredential ? credentialAuthHeader(this.credential()) : {}),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
225
270
|
async createGrant(input: CreatePrincipalGrantInput): Promise<PrincipalGrantResult> {
|
|
226
|
-
const
|
|
227
|
-
if (!
|
|
271
|
+
const assertion = await this.mintOwnerAssertion(input.tenant);
|
|
272
|
+
if (!assertion) return brokerNotConfigured();
|
|
228
273
|
|
|
274
|
+
// `tenant` in the BODY is what puts tot20 on the owner-brokered branch for POST
|
|
275
|
+
// (createPrincipalGrant reads `body.tenant`); GET/DELETE read `req.query.tenant`.
|
|
229
276
|
const body: Record<string, unknown> = {
|
|
277
|
+
tenant: input.tenant,
|
|
230
278
|
principal: { type: "user", email: input.principal.email.toLowerCase() },
|
|
231
279
|
capability: input.capability,
|
|
232
280
|
expiresAt: input.expiresAt,
|
|
233
|
-
|
|
234
|
-
...(cred.totSecretKey ? { totSecretKey: cred.totSecretKey } : {}),
|
|
281
|
+
...this.credential(),
|
|
235
282
|
};
|
|
236
283
|
if (input.scope) body.scope = input.scope;
|
|
237
284
|
if (input.purpose) body.purpose = input.purpose;
|
|
@@ -246,7 +293,11 @@ export class HttpPrincipalGrantClient implements PrincipalGrantClient {
|
|
|
246
293
|
|
|
247
294
|
let res: HttpResponseLike;
|
|
248
295
|
try {
|
|
249
|
-
res = await this.transport.post(
|
|
296
|
+
res = await this.transport.post(
|
|
297
|
+
`${this.baseUrl}/api/clients/principalGrants`,
|
|
298
|
+
this.brokeredHeaders(assertion, false),
|
|
299
|
+
body,
|
|
300
|
+
);
|
|
250
301
|
} catch (cause) {
|
|
251
302
|
return transportFailure("creating principal grant", cause);
|
|
252
303
|
}
|
|
@@ -263,14 +314,15 @@ export class HttpPrincipalGrantClient implements PrincipalGrantClient {
|
|
|
263
314
|
}
|
|
264
315
|
|
|
265
316
|
async listGrants(tenant: string): Promise<PrincipalGrantListResult> {
|
|
266
|
-
const
|
|
267
|
-
if (!
|
|
317
|
+
const assertion = await this.mintOwnerAssertion(tenant);
|
|
318
|
+
if (!assertion) return brokerNotConfigured();
|
|
268
319
|
let res: HttpResponseLike;
|
|
269
320
|
try {
|
|
270
|
-
//
|
|
321
|
+
// `tenant` is the ONLY query param — public routing data, and the only place
|
|
322
|
+
// tot20's listPrincipalGrants reads it. The credential stays in a header.
|
|
271
323
|
res = await this.transport.get(
|
|
272
|
-
`${this.baseUrl}/api/clients/principalGrants`,
|
|
273
|
-
|
|
324
|
+
`${this.baseUrl}/api/clients/principalGrants?tenant=${encodeURIComponent(tenant)}`,
|
|
325
|
+
this.brokeredHeaders(assertion, true),
|
|
274
326
|
);
|
|
275
327
|
} catch (cause) {
|
|
276
328
|
return transportFailure("listing principal grants", cause);
|
|
@@ -281,16 +333,17 @@ export class HttpPrincipalGrantClient implements PrincipalGrantClient {
|
|
|
281
333
|
}
|
|
282
334
|
|
|
283
335
|
async revokeGrant(tenant: string, ref: string): Promise<PrincipalGrantRevokeResult> {
|
|
284
|
-
const
|
|
285
|
-
if (!
|
|
336
|
+
const assertion = await this.mintOwnerAssertion(tenant);
|
|
337
|
+
if (!assertion) return brokerNotConfigured();
|
|
286
338
|
let res: HttpResponseLike;
|
|
287
339
|
try {
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
340
|
+
// `ref` is tot20's opaque grant handle (never a raw email), so it is safe as a
|
|
341
|
+
// path segment — that is exactly why tot20 addresses revoke by ref. `tenant` is
|
|
342
|
+
// the only query param; the credential stays in the Authorization header.
|
|
291
343
|
res = await this.transport.delete(
|
|
292
|
-
`${this.baseUrl}/api/clients/principalGrants/${encodeURIComponent(ref)}
|
|
293
|
-
|
|
344
|
+
`${this.baseUrl}/api/clients/principalGrants/${encodeURIComponent(ref)}` +
|
|
345
|
+
`?tenant=${encodeURIComponent(tenant)}`,
|
|
346
|
+
this.brokeredHeaders(assertion, true),
|
|
294
347
|
);
|
|
295
348
|
} catch (cause) {
|
|
296
349
|
return transportFailure("revoking principal grant", cause);
|
|
@@ -301,14 +354,14 @@ export class HttpPrincipalGrantClient implements PrincipalGrantClient {
|
|
|
301
354
|
}
|
|
302
355
|
}
|
|
303
356
|
|
|
304
|
-
function
|
|
357
|
+
function brokerNotConfigured(): PrincipalGrantFailed {
|
|
305
358
|
return {
|
|
306
359
|
ok: false,
|
|
307
360
|
status: 503,
|
|
308
|
-
reason: "
|
|
361
|
+
reason: "brokerNotConfigured",
|
|
309
362
|
message:
|
|
310
|
-
"storefront
|
|
311
|
-
|
|
363
|
+
"this storefront deploy cannot sign an owner assertion " +
|
|
364
|
+
`(${BROKER_PRIVATE_KEY_ENV} is not configured), so the request was not sent`,
|
|
312
365
|
};
|
|
313
366
|
}
|
|
314
367
|
|
|
@@ -366,12 +419,49 @@ function reasonFor(code: string | undefined, status: number): PrincipalGrantFail
|
|
|
366
419
|
return "invalidPrincipal";
|
|
367
420
|
case "operatorGrants:principalTypeNotSupported":
|
|
368
421
|
return "principalTypeNotSupported";
|
|
422
|
+
// --- owner-brokered grantor path ---
|
|
423
|
+
case "operatorGrants:brokerAssertionRequired":
|
|
424
|
+
return "brokerAssertionRequired";
|
|
425
|
+
case "operatorGrants:brokerAssertionInvalid":
|
|
426
|
+
return "brokerAssertionInvalid";
|
|
427
|
+
// All three are the same class of storefront bug: the signed `tenant` claim and
|
|
428
|
+
// the tenant on the request did not line up. Collapsed so the UI has one story.
|
|
429
|
+
case "operatorGrants:assertionTenantRequired":
|
|
430
|
+
case "operatorGrants:tenantAssertionMismatch":
|
|
431
|
+
case "operatorGrants:tenantRequired":
|
|
432
|
+
return "assertionTenantMismatch";
|
|
433
|
+
case "operatorGrants:tenantNotRegistered":
|
|
434
|
+
return "tenantNotRegistered";
|
|
435
|
+
case "operatorGrants:subjectNotTenantOwner":
|
|
436
|
+
// Storefront never drives the staff-consent-capture branch, so a staff-scope
|
|
437
|
+
// refusal here can only mean the subject reached tot20 as staff rather than as
|
|
438
|
+
// the owner — the same operator-facing story as "you don't own this store".
|
|
439
|
+
case "operatorGrants:staffScopeInsufficient":
|
|
440
|
+
return "subjectNotTenantOwner";
|
|
369
441
|
default:
|
|
370
442
|
if (status === 403 || status === 401) return "notAuthenticated";
|
|
371
443
|
return "error";
|
|
372
444
|
}
|
|
373
445
|
}
|
|
374
446
|
|
|
447
|
+
/**
|
|
448
|
+
* Turn a tot20 refusal into a typed failure. When we RECOGNISE the code we prefer
|
|
449
|
+
* our own operator-facing wording over tot20's — tot20's messages are written for
|
|
450
|
+
* an API integrator ("The broker assertion names a different tenant than the
|
|
451
|
+
* request."), which is noise to a merchant clicking Delegate. An unrecognised code
|
|
452
|
+
* keeps tot20's message, since that is the only signal we have.
|
|
453
|
+
*/
|
|
454
|
+
function failureFrom(
|
|
455
|
+
status: number,
|
|
456
|
+
c: Record<string, unknown>,
|
|
457
|
+
): { status: number; reason: PrincipalGrantFailureReason; message: string; code?: string } {
|
|
458
|
+
const code = typeof c.code === "string" ? c.code : undefined;
|
|
459
|
+
const reason = reasonFor(code, status);
|
|
460
|
+
const upstream = typeof c.message === "string" ? c.message : undefined;
|
|
461
|
+
const message = reason === "error" && upstream ? upstream : defaultMessage(reason, status);
|
|
462
|
+
return { status, reason, message, ...(code ? { code } : {}) };
|
|
463
|
+
}
|
|
464
|
+
|
|
375
465
|
function defaultMessage(reason: PrincipalGrantFailureReason, status: number): string {
|
|
376
466
|
switch (reason) {
|
|
377
467
|
case "notAuthenticated":
|
|
@@ -384,8 +474,17 @@ function defaultMessage(reason: PrincipalGrantFailureReason, status: number): st
|
|
|
384
474
|
return "a valid grantee email is required";
|
|
385
475
|
case "principalTypeNotSupported":
|
|
386
476
|
return "this endpoint grants a human (email) principal only";
|
|
387
|
-
case "
|
|
388
|
-
return "
|
|
477
|
+
case "subjectNotTenantOwner":
|
|
478
|
+
return "only an owner of this store can delegate ship-on-behalf";
|
|
479
|
+
case "tenantNotRegistered":
|
|
480
|
+
return "this store isn't registered with Token of Trust";
|
|
481
|
+
case "brokerAssertionInvalid":
|
|
482
|
+
return "your session proof was rejected — sign in again and retry";
|
|
483
|
+
case "brokerAssertionRequired":
|
|
484
|
+
case "assertionTenantMismatch":
|
|
485
|
+
return "storefront sent a malformed delegation request — please report this";
|
|
486
|
+
case "brokerNotConfigured":
|
|
487
|
+
return "delegation isn't available on this deployment";
|
|
389
488
|
default:
|
|
390
489
|
return `principalGrants: tot20 returned status ${status}`;
|
|
391
490
|
}
|
|
@@ -405,9 +504,7 @@ export function normalizeCreateResponse(status: number, body: unknown): Principa
|
|
|
405
504
|
expiresAt: Number(c.expiresAt ?? 0),
|
|
406
505
|
};
|
|
407
506
|
}
|
|
408
|
-
|
|
409
|
-
const message = typeof c.message === "string" ? c.message : defaultMessage(reason, status);
|
|
410
|
-
return { ok: false, status, reason, message, ...(code ? { code } : {}) };
|
|
507
|
+
return { ok: false, ...failureFrom(status, c) };
|
|
411
508
|
}
|
|
412
509
|
|
|
413
510
|
export function normalizeListResponse(status: number, tenant: string, body: unknown): PrincipalGrantListResult {
|
|
@@ -426,9 +523,7 @@ export function normalizeListResponse(status: number, tenant: string, body: unkn
|
|
|
426
523
|
});
|
|
427
524
|
return { ok: true, tenant, grants };
|
|
428
525
|
}
|
|
429
|
-
|
|
430
|
-
const message = typeof c.message === "string" ? c.message : defaultMessage(reason, status);
|
|
431
|
-
return { ok: false, status, reason, message, ...(code ? { code } : {}) };
|
|
526
|
+
return { ok: false, ...failureFrom(status, c) };
|
|
432
527
|
}
|
|
433
528
|
|
|
434
529
|
export function normalizeRevokeResponse(status: number, tenant: string, body: unknown): PrincipalGrantRevokeResult {
|
|
@@ -438,9 +533,7 @@ export function normalizeRevokeResponse(status: number, tenant: string, body: un
|
|
|
438
533
|
if (isOk) {
|
|
439
534
|
return { ok: true, tenant: String(c.tenant ?? tenant), ref: "" };
|
|
440
535
|
}
|
|
441
|
-
|
|
442
|
-
const message = typeof c.message === "string" ? c.message : defaultMessage(reason, status);
|
|
443
|
-
return { ok: false, status, reason, message, ...(code ? { code } : {}) };
|
|
536
|
+
return { ok: false, ...failureFrom(status, c) };
|
|
444
537
|
}
|
|
445
538
|
|
|
446
539
|
/** Map a typed failure onto the HTTP status a storefront route should return. */
|
|
@@ -452,8 +545,15 @@ export function principalGrantHttpStatus(reason: PrincipalGrantFailureReason, up
|
|
|
452
545
|
case "unknownCapability":
|
|
453
546
|
case "invalidPrincipal":
|
|
454
547
|
case "principalTypeNotSupported":
|
|
548
|
+
case "brokerAssertionRequired":
|
|
549
|
+
case "assertionTenantMismatch":
|
|
550
|
+
case "tenantNotRegistered":
|
|
455
551
|
return 422;
|
|
456
|
-
case "
|
|
552
|
+
case "brokerAssertionInvalid":
|
|
553
|
+
return 401;
|
|
554
|
+
case "subjectNotTenantOwner":
|
|
555
|
+
return 403;
|
|
556
|
+
case "brokerNotConfigured":
|
|
457
557
|
return 503;
|
|
458
558
|
default:
|
|
459
559
|
return upstreamStatus >= 400 ? upstreamStatus : 502;
|
|
@@ -466,16 +566,26 @@ export function principalGrantHttpStatus(reason: PrincipalGrantFailureReason, up
|
|
|
466
566
|
export class MockPrincipalGrantClient implements PrincipalGrantClient {
|
|
467
567
|
private grants = new Map<string, { principal: UserPrincipal; capability: string; expiresAt: number }>();
|
|
468
568
|
private seq = 0;
|
|
469
|
-
/** Tenants
|
|
470
|
-
private readonly
|
|
569
|
+
/** Tenants the signed-in subject does NOT own (tot20's `subjectNotTenantOwner`). */
|
|
570
|
+
private readonly notOwned: Set<string>;
|
|
471
571
|
|
|
472
|
-
constructor(
|
|
473
|
-
this.
|
|
572
|
+
constructor(notOwnedTenants: string[] = []) {
|
|
573
|
+
this.notOwned = new Set(notOwnedTenants.map((t) => t.toLowerCase()));
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
private refuseNotOwner(): PrincipalGrantFailed {
|
|
577
|
+
return {
|
|
578
|
+
ok: false,
|
|
579
|
+
status: 403,
|
|
580
|
+
reason: "subjectNotTenantOwner",
|
|
581
|
+
message: defaultMessage("subjectNotTenantOwner", 403),
|
|
582
|
+
code: "operatorGrants:subjectNotTenantOwner",
|
|
583
|
+
};
|
|
474
584
|
}
|
|
475
585
|
|
|
476
586
|
async createGrant(input: CreatePrincipalGrantInput): Promise<PrincipalGrantResult> {
|
|
477
587
|
const tenant = input.tenant.toLowerCase();
|
|
478
|
-
if (this.
|
|
588
|
+
if (this.notOwned.has(tenant)) return this.refuseNotOwner();
|
|
479
589
|
if (!input.expiresAt || input.expiresAt <= Date.now()) {
|
|
480
590
|
return { ok: false, status: 422, reason: "expiresAtRequired", message: "a future expiresAt is required", code: "operatorGrants:expiresAtRequired" };
|
|
481
591
|
}
|
|
@@ -490,7 +600,7 @@ export class MockPrincipalGrantClient implements PrincipalGrantClient {
|
|
|
490
600
|
|
|
491
601
|
async listGrants(tenant: string): Promise<PrincipalGrantListResult> {
|
|
492
602
|
const t = tenant.toLowerCase();
|
|
493
|
-
if (this.
|
|
603
|
+
if (this.notOwned.has(t)) return this.refuseNotOwner();
|
|
494
604
|
const grants = [...this.grants.entries()]
|
|
495
605
|
.filter(([ref]) => ref.startsWith(`mock-ref-${t}-`))
|
|
496
606
|
.map(([ref, g]) => ({ ...g, ref }));
|
|
@@ -499,7 +609,7 @@ export class MockPrincipalGrantClient implements PrincipalGrantClient {
|
|
|
499
609
|
|
|
500
610
|
async revokeGrant(tenant: string, ref: string): Promise<PrincipalGrantRevokeResult> {
|
|
501
611
|
const t = tenant.toLowerCase();
|
|
502
|
-
if (this.
|
|
612
|
+
if (this.notOwned.has(t)) return this.refuseNotOwner();
|
|
503
613
|
if (!ref.startsWith(`mock-ref-${t}-`) || !this.grants.has(ref)) {
|
|
504
614
|
return { ok: false, status: 404, reason: "error", message: "no such grant" };
|
|
505
615
|
}
|
|
@@ -511,17 +621,28 @@ export class MockPrincipalGrantClient implements PrincipalGrantClient {
|
|
|
511
621
|
// --- Factory ----------------------------------------------------------------
|
|
512
622
|
|
|
513
623
|
/**
|
|
514
|
-
* Build the env-configured client
|
|
515
|
-
*
|
|
516
|
-
*
|
|
624
|
+
* Build the env-configured client:
|
|
625
|
+
* - `TOT_CORE_URL` — tot20 core API base (tier-matching per deploy).
|
|
626
|
+
* - `TOT_API_KEY` (+ optional `TOT_SECRET_KEY`) — storefront's own operator creds.
|
|
627
|
+
*
|
|
628
|
+
* `mintOwnerAssertion` is a REQUIRED positional argument, not an override with a
|
|
629
|
+
* default: there is no safe default. A client built without it would send an
|
|
630
|
+
* unbrokered request that tot20 resolves to storefront's OWN store as grantor —
|
|
631
|
+
* silently granting on the wrong tenant. Making it structural means the caller must
|
|
632
|
+
* hold a verified owner session to build a client at all.
|
|
517
633
|
*/
|
|
518
634
|
export async function createPrincipalGrantClient(
|
|
519
|
-
|
|
635
|
+
mintOwnerAssertion: OwnerAssertionMinter,
|
|
636
|
+
overrides: Partial<Omit<HttpPrincipalGrantClientConfig, "mintOwnerAssertion">> = {},
|
|
520
637
|
): Promise<HttpPrincipalGrantClient> {
|
|
521
638
|
const baseUrl = overrides.baseUrl ?? (await readEnv("TOT_CORE_URL")) ?? "";
|
|
639
|
+
const apiKey = overrides.apiKey ?? (await readEnv("TOT_API_KEY")) ?? "";
|
|
640
|
+
const secretKey = overrides.secretKey ?? (await readEnv("TOT_SECRET_KEY"));
|
|
522
641
|
return new HttpPrincipalGrantClient({
|
|
523
642
|
baseUrl,
|
|
524
|
-
|
|
643
|
+
apiKey,
|
|
644
|
+
mintOwnerAssertion,
|
|
645
|
+
...(secretKey ? { secretKey } : {}),
|
|
525
646
|
...(overrides.transport ? { transport: overrides.transport } : {}),
|
|
526
647
|
});
|
|
527
648
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker-side reader for the durable per-tenant catalog sync state (hsby-B1).
|
|
3
|
+
* The migration CLI writes one JSON document per tenant scope at
|
|
4
|
+
* out/catalog/<scope>/sync-state.json (packages/migration/src/load/sync-state.ts,
|
|
5
|
+
* `SyncStateStore`/`nextSyncState`) — this module is the Worker's read side of
|
|
6
|
+
* that same file, via the same Vite glob mechanism `ToTClient.ts` uses for
|
|
7
|
+
* products.json/collections.json/reviews.json. OPTIONAL: a tenant with no
|
|
8
|
+
* recorded sync (or one migrated before hsby-B1 landed) just gets `null`.
|
|
9
|
+
*
|
|
10
|
+
* This is intentionally a thin, standalone read — not wired into `ToTClient`,
|
|
11
|
+
* since not every caller needs sync provenance. hsby-B3's readiness projection
|
|
12
|
+
* is the expected first consumer.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Mirrors packages/migration/src/load/sync-state.ts's `SourcePlatform`. */
|
|
16
|
+
export type SyncStateSourcePlatform =
|
|
17
|
+
| "shopify-admin"
|
|
18
|
+
| "shopify-public"
|
|
19
|
+
| "fixtures"
|
|
20
|
+
| "existing";
|
|
21
|
+
|
|
22
|
+
/** Mirrors packages/migration/src/load/sync-state.ts's `FidelityLevel`. */
|
|
23
|
+
export type SyncStateFidelityLevel = "full" | "reduced" | "unknown";
|
|
24
|
+
|
|
25
|
+
export interface SyncStateFidelity {
|
|
26
|
+
level: SyncStateFidelityLevel;
|
|
27
|
+
note: string;
|
|
28
|
+
missing: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SyncStateSourceMode {
|
|
32
|
+
platform: SyncStateSourcePlatform;
|
|
33
|
+
mode: "full" | "incremental";
|
|
34
|
+
fullSweepOnly: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SyncStateEntity {
|
|
38
|
+
checksums: Record<string, string>;
|
|
39
|
+
lastUpdatedAt: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Mirrors packages/migration/src/load/sync-state.ts's `EntityChangeSet`. */
|
|
43
|
+
export interface SyncStateEntityChangeSet {
|
|
44
|
+
added: string[];
|
|
45
|
+
updated: string[];
|
|
46
|
+
removed: string[];
|
|
47
|
+
unchanged: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Mirrors packages/migration/src/load/sync-state.ts's `SweepChanges`. */
|
|
51
|
+
export interface SyncStateSweepChanges {
|
|
52
|
+
computedAt: string;
|
|
53
|
+
mode: "full" | "incremental";
|
|
54
|
+
removalsAssessed: boolean;
|
|
55
|
+
fullSweepOnly: boolean;
|
|
56
|
+
products: SyncStateEntityChangeSet;
|
|
57
|
+
collections: SyncStateEntityChangeSet;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The on-disk shape written by the migration CLI (schema "tot.sync-state/1"). */
|
|
61
|
+
export interface CatalogSyncState {
|
|
62
|
+
schema: string;
|
|
63
|
+
tenantId: string;
|
|
64
|
+
scope: string;
|
|
65
|
+
source: SyncStateSourceMode;
|
|
66
|
+
fidelity: SyncStateFidelity;
|
|
67
|
+
lastSyncAt: string | null;
|
|
68
|
+
/** True when the most recent sync loaded with record errors; absent on a pre-hsby-B3 catalog. */
|
|
69
|
+
lastSyncHadErrors?: boolean;
|
|
70
|
+
products: SyncStateEntity;
|
|
71
|
+
collections: SyncStateEntity;
|
|
72
|
+
/**
|
|
73
|
+
* The content-hash delta of the most recent sweep. Optional: a catalog
|
|
74
|
+
* migrated before hsby-B2 (change detection) has no `lastChanges`.
|
|
75
|
+
*/
|
|
76
|
+
lastChanges?: SyncStateSweepChanges;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Vite resolves this glob at build time and bundles every tenant's sync-state
|
|
80
|
+
// document, so the Worker has no runtime filesystem dependency — same pattern
|
|
81
|
+
// as productGlobs/collectionGlobs/reviewGlobs in ToTClient.ts.
|
|
82
|
+
const syncStateGlobs = import.meta.glob<CatalogSyncState>(
|
|
83
|
+
"../../../../../packages/migration/out/catalog/*/sync-state.json",
|
|
84
|
+
{ import: "default" },
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const syncStateCacheByScope = new Map<string, CatalogSyncState | null>();
|
|
88
|
+
|
|
89
|
+
/** Pick the glob entry for a tenant scope (matches .../catalog/<scope>/file). */
|
|
90
|
+
function pickByScope(scope: string): (() => Promise<CatalogSyncState>) | null {
|
|
91
|
+
const suffix = `/catalog/${scope}/`;
|
|
92
|
+
const key = Object.keys(syncStateGlobs).find((k) => k.includes(suffix));
|
|
93
|
+
return (key ? syncStateGlobs[key] : null) ?? null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The tenant's durable sync state, or null when none was ever recorded (no
|
|
98
|
+
* migration run yet, or a pre-hsby-B1 catalog). Never throws.
|
|
99
|
+
*/
|
|
100
|
+
export async function getSyncState(scope: string): Promise<CatalogSyncState | null> {
|
|
101
|
+
if (syncStateCacheByScope.has(scope)) return syncStateCacheByScope.get(scope)!;
|
|
102
|
+
const loader = pickByScope(scope);
|
|
103
|
+
const state = loader ? await loader() : null;
|
|
104
|
+
syncStateCacheByScope.set(scope, state);
|
|
105
|
+
return state;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Test seam: drop the per-isolate cache. */
|
|
109
|
+
export function clearSyncStateCache(): void {
|
|
110
|
+
syncStateCacheByScope.clear();
|
|
111
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.11",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|
|
@@ -90,7 +90,21 @@ export interface PreviewState {
|
|
|
90
90
|
* post-hoc merge sets it, after `reportPreviewEvidence` computes it. Absent and
|
|
91
91
|
* `null` both mean "no verdict yet"; callers should treat them the same.
|
|
92
92
|
*/
|
|
93
|
-
evidence?: {
|
|
93
|
+
evidence?: {
|
|
94
|
+
checks: PreviewStateEvidenceCheck[];
|
|
95
|
+
promotable: boolean;
|
|
96
|
+
generatedAt: string;
|
|
97
|
+
/**
|
|
98
|
+
* Set when the MCP `change_report_evidence` push for this verdict failed —
|
|
99
|
+
* `promotable` above is already forced `false` in that case (fail-closed: MCP
|
|
100
|
+
* has no evidence for this commit, so the local record must not claim
|
|
101
|
+
* shippability the other source of truth doesn't share). Mirrors
|
|
102
|
+
* `ReviewEnvironmentEvidence.pushFailed` (candidate-index.ts).
|
|
103
|
+
*/
|
|
104
|
+
pushFailed?: boolean;
|
|
105
|
+
/** The push failure's error message, when `pushFailed` is true. */
|
|
106
|
+
pushError?: string;
|
|
107
|
+
} | null;
|
|
94
108
|
}
|
|
95
109
|
|
|
96
110
|
export function previewStateKey(tenantId: string): string {
|
|
@@ -9,16 +9,16 @@
|
|
|
9
9
|
* See `epics/dev-loop.md` (D4 + the MCP-foundations block).
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
export const EVIDENCE_CONTRACT_VERSION =
|
|
12
|
+
// v2 (2026-08): added the `assets_materialized` check — reconcile now fetch-verifies
|
|
13
|
+
// that a version's public/app asset subtree actually SERVES (the pagination-truncation
|
|
14
|
+
// class of silent drop, fb-1787794734109-hjru1k, reported success + promotable while
|
|
15
|
+
// the newest asset subtree 404'd). GRANDFATHER: a consumer must treat an
|
|
16
|
+
// already-passed v1 report as still valid — the new check is version-gated, so a v1
|
|
17
|
+
// report (which cannot carry it) is never retroactively failed for its absence. The
|
|
18
|
+
// promote-gate addition (`assets_materialized` → REQUIRED_FOR_PROMOTE) is
|
|
19
|
+
// version-gated to v2 on the CONSUMER (tot-mcp); this producer emits the check but
|
|
20
|
+
// leaves REQUIRED_FOR_PROMOTE unchanged so a v1-shaped promote rule still holds.
|
|
21
|
+
export const EVIDENCE_CONTRACT_VERSION = 2;
|
|
22
22
|
|
|
23
23
|
/** The per-check verdicts. `skip` = not applicable / not yet measured. */
|
|
24
24
|
export const EVIDENCE_STATUSES = ["pass", "warn", "fail", "skip"] as const;
|
|
@@ -37,7 +37,10 @@ export const EVIDENCE_CHECK_IDS = [
|
|
|
37
37
|
"json_ld",
|
|
38
38
|
"sitemap",
|
|
39
39
|
"cutover_crawl",
|
|
40
|
-
//
|
|
40
|
+
// v2: did this version's public/app asset subtree actually materialize + serve
|
|
41
|
+
// (fetch-verified against R2 at the published versionId). Fail-closed — a silent
|
|
42
|
+
// drop (pagination truncation, a failed ingest) reads `fail`, never a vacuous pass.
|
|
43
|
+
"assets_materialized",
|
|
41
44
|
] as const;
|
|
42
45
|
export type EvidenceCheckId = (typeof EVIDENCE_CHECK_IDS)[number];
|
|
43
46
|
|
|
@@ -38,6 +38,12 @@
|
|
|
38
38
|
* node scripts/tenant/materialize-tenant-content.mjs --check-pinned [--expect-ref <sha>] [--allow-unpinned]
|
|
39
39
|
* # stricter deploy gate: verify populated AND materialized from Gitea at a
|
|
40
40
|
* # PINNED ref (.materialized.json present, pinned, on the expected ref); no network
|
|
41
|
+
* node scripts/tenant/materialize-tenant-content.mjs --check-pinned --verify-fresh
|
|
42
|
+
* # opt-in NETWORK addition to --check-pinned: for any stamp pinned to a
|
|
43
|
+
* # MOVABLE ref (a branch name, or an unpinned default-branch materialize),
|
|
44
|
+
* # resolve that ref's CURRENT tip on Gitea and refuse if the stamped sha no
|
|
45
|
+
* # longer matches it (`git ls-remote`, no clone). A stamp pinned to an exact
|
|
46
|
+
* # commit SHA is frozen by design and can never be "stale" under this check.
|
|
41
47
|
*
|
|
42
48
|
* Auth (never logged; tokens are redacted from every error):
|
|
43
49
|
* TENANT_GIT_BASE clone base. Default https://git.tokenoftrust.com/storefront
|
|
@@ -71,6 +77,7 @@ const repoRoot = resolve(here, "..", "..");
|
|
|
71
77
|
|
|
72
78
|
export const MANIFEST_PATH = "tenants/.gitea-tenants.json";
|
|
73
79
|
const TENANT_ID_RE = /^[a-z0-9][a-z0-9.-]{1,60}[a-z0-9]$/;
|
|
80
|
+
const FULL_SHA_RE = /^[0-9a-f]{40}$/i;
|
|
74
81
|
const DEFAULT_GIT_BASE = "https://git.tokenoftrust.com/storefront";
|
|
75
82
|
|
|
76
83
|
/** Load the Gitea-tenant manifest. Returns { tenants: { <id>: { repo } } }. */
|
|
@@ -380,9 +387,87 @@ export function checkPinned(
|
|
|
380
387
|
return { ok: problems.length === 0, problems };
|
|
381
388
|
}
|
|
382
389
|
|
|
383
|
-
/**
|
|
384
|
-
*
|
|
385
|
-
*
|
|
390
|
+
/**
|
|
391
|
+
* `--verify-fresh` (opt-in, NETWORK): the one thing `checkPinned` deliberately cannot tell you —
|
|
392
|
+
* whether a pinned stamp is still current. `checkPinned` only proves the stamp is pinned to SOME
|
|
393
|
+
* ref (and, with `expectRef`, that it matches a caller-supplied value); it never talks to Gitea,
|
|
394
|
+
* so a stamp pinned to a MOVABLE ref (a branch name, or an unpinned default-branch materialize)
|
|
395
|
+
* can silently drift arbitrarily far behind that ref's current tip and keep passing forever.
|
|
396
|
+
*
|
|
397
|
+
* A stamp pinned to an exact commit SHA is excluded on purpose: that's a deliberate promotion to
|
|
398
|
+
* an immutable commit, frozen by design, and is never "stale" — re-promoting to a newer commit is
|
|
399
|
+
* a separate, explicit act (a fresh `--ref <sha>` materialize), not something this check should
|
|
400
|
+
* flag as a problem.
|
|
401
|
+
*
|
|
402
|
+
* For each in-scope tenant with a stamp, resolves the stamped ref's CURRENT tip via
|
|
403
|
+
* `git ls-remote` (no clone) and compares it to the stamped sha. Requires read access to Gitea —
|
|
404
|
+
* same TENANT_GIT_USER/TENANT_GIT_TOKEN as materializeTenant. Tenants that already fail
|
|
405
|
+
* `checkPinned` (absent/missing/unpinned-without-allowUnpinned/off-ref) are left to those
|
|
406
|
+
* existing reasons and skipped here to avoid double-reporting the same tenant.
|
|
407
|
+
*
|
|
408
|
+
* Returns { ok, problems: [{ tenant, reason }] } where reason ∈ "unreachable" | "stale":
|
|
409
|
+
* unreachable the live tip could not be resolved (network/auth failure, deleted ref, etc.) —
|
|
410
|
+
* never treated as fresh; an unreachable Gitea must not silently pass this gate.
|
|
411
|
+
* stale the stamped sha no longer matches the ref's live tip.
|
|
412
|
+
*
|
|
413
|
+
* @param {string} [root]
|
|
414
|
+
* @param {object} [opts]
|
|
415
|
+
* @param {string} [opts.gitBase]
|
|
416
|
+
* @param {string} [opts.user]
|
|
417
|
+
* @param {string} [opts.token]
|
|
418
|
+
* @param {string[]|null} [opts.onlyTenants]
|
|
419
|
+
*/
|
|
420
|
+
export function checkFresh(
|
|
421
|
+
root = repoRoot,
|
|
422
|
+
{
|
|
423
|
+
gitBase = DEFAULT_GIT_BASE,
|
|
424
|
+
user = process.env.TENANT_GIT_USER,
|
|
425
|
+
token = process.env.TENANT_GIT_TOKEN,
|
|
426
|
+
onlyTenants = null,
|
|
427
|
+
} = {},
|
|
428
|
+
) {
|
|
429
|
+
const manifest = loadManifest(root);
|
|
430
|
+
const problems = [];
|
|
431
|
+
const scope = onlyTenants ? new Set(onlyTenants) : null;
|
|
432
|
+
for (const tenant of Object.keys(manifest.tenants)) {
|
|
433
|
+
if (scope && !scope.has(tenant)) continue;
|
|
434
|
+
const stampPath = join(root, "tenants", tenant, ".materialized.json");
|
|
435
|
+
if (!existsSync(stampPath)) continue; // checkPinned already reports absent/missing
|
|
436
|
+
let stamp;
|
|
437
|
+
try {
|
|
438
|
+
stamp = JSON.parse(readFileSync(stampPath, "utf8"));
|
|
439
|
+
} catch {
|
|
440
|
+
continue; // checkPinned already reports this as "missing"
|
|
441
|
+
}
|
|
442
|
+
const sha = typeof stamp.sha === "string" ? stamp.sha : "";
|
|
443
|
+
const rawRef = typeof stamp.ref === "string" ? stamp.ref : "";
|
|
444
|
+
if (!sha || !FULL_SHA_RE.test(sha)) continue; // no usable sha to compare — leave to checkPinned
|
|
445
|
+
if (FULL_SHA_RE.test(rawRef)) continue; // pinned to an exact commit — frozen by design
|
|
446
|
+
const movableRef = rawRef === "" || rawRef === "(default branch)" ? "HEAD" : rawRef;
|
|
447
|
+
const repo = manifest.tenants[tenant]?.repo ?? tenant;
|
|
448
|
+
const cloneUrl = buildCloneUrl({ base: gitBase, repo, user, token });
|
|
449
|
+
let liveSha = "";
|
|
450
|
+
try {
|
|
451
|
+
const out = execFileSync("git", ["ls-remote", cloneUrl, movableRef], {
|
|
452
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
453
|
+
}).toString();
|
|
454
|
+
liveSha = (out.split(/\s+/)[0] || "").trim();
|
|
455
|
+
} catch {
|
|
456
|
+
problems.push({ tenant, reason: "unreachable" });
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
if (!FULL_SHA_RE.test(liveSha)) {
|
|
460
|
+
problems.push({ tenant, reason: "unreachable" });
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (liveSha !== sha) problems.push({ tenant, reason: "stale" });
|
|
464
|
+
}
|
|
465
|
+
return { ok: problems.length === 0, problems };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Human-readable refusal for a failed `checkPinned` (+ optional `checkFresh`) run (shared by
|
|
469
|
+
* the CLI + the build-time guard). `problems` is checkPinned().problems, optionally concatenated
|
|
470
|
+
* with checkFresh().problems; `expectRef` is echoed into the off-ref explanation when set. */
|
|
386
471
|
export function describePinnedProblems(problems, /** @type {string|null} */ expectRef = null) {
|
|
387
472
|
const how = {
|
|
388
473
|
absent: "not materialized (no tenant content on disk at all)",
|
|
@@ -390,6 +475,8 @@ export function describePinnedProblems(problems, /** @type {string|null} */ expe
|
|
|
390
475
|
unpinned:
|
|
391
476
|
"materialized from the tenant repo's DEFAULT branch (no pinned ref) — may be stale",
|
|
392
477
|
"off-ref": `materialized at a different ref than expected (${expectRef})`,
|
|
478
|
+
stale: "pinned ref's live Gitea tip has moved past the materialized sha — re-materialize",
|
|
479
|
+
unreachable: "could not resolve the tenant repo's live tip (network/auth failure)",
|
|
393
480
|
};
|
|
394
481
|
return (
|
|
395
482
|
"REFUSING: tenant content is not materialized from Gitea at a pinned ref.\n" +
|
|
@@ -438,6 +525,7 @@ function parseArgs(argv) {
|
|
|
438
525
|
checkPinned: false,
|
|
439
526
|
expectRef: null,
|
|
440
527
|
allowUnpinned: false,
|
|
528
|
+
verifyFresh: false,
|
|
441
529
|
help: false,
|
|
442
530
|
};
|
|
443
531
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -449,11 +537,15 @@ function parseArgs(argv) {
|
|
|
449
537
|
else if (t === "--check-pinned") a.checkPinned = true;
|
|
450
538
|
else if (t === "--expect-ref") a.expectRef = argv[++i];
|
|
451
539
|
else if (t === "--allow-unpinned") a.allowUnpinned = true;
|
|
540
|
+
else if (t === "--verify-fresh") a.verifyFresh = true;
|
|
452
541
|
else if (t === "--help" || t === "-h") a.help = true;
|
|
453
542
|
else if (!t.startsWith("--") && !a.tenant) a.tenant = t;
|
|
454
543
|
else throw new Error(`unknown argument: ${t}`);
|
|
455
544
|
}
|
|
456
545
|
if (a.remote && a.all) throw new Error("--remote targets ONE tenant; not valid with --all");
|
|
546
|
+
if (a.verifyFresh && !a.checkPinned) {
|
|
547
|
+
throw new Error("--verify-fresh is only valid with --check-pinned");
|
|
548
|
+
}
|
|
457
549
|
return a;
|
|
458
550
|
}
|
|
459
551
|
|
|
@@ -463,6 +555,8 @@ const USAGE = `materialize-tenant-content — populate tenants/<id>/ from its Gi
|
|
|
463
555
|
node scripts/tenant/materialize-tenant-content.mjs --all
|
|
464
556
|
node scripts/tenant/materialize-tenant-content.mjs --check
|
|
465
557
|
node scripts/tenant/materialize-tenant-content.mjs --check-pinned [--expect-ref <sha>] [--allow-unpinned]
|
|
558
|
+
node scripts/tenant/materialize-tenant-content.mjs --check-pinned --verify-fresh
|
|
559
|
+
# opt-in NETWORK addition: refuse if a movable-ref stamp's sha is behind Gitea's live tip
|
|
466
560
|
|
|
467
561
|
env: TENANT_GIT_BASE (default ${DEFAULT_GIT_BASE}), TENANT_GIT_USER, TENANT_GIT_TOKEN`;
|
|
468
562
|
|
|
@@ -486,15 +580,21 @@ function main() {
|
|
|
486
580
|
return;
|
|
487
581
|
}
|
|
488
582
|
if (args.checkPinned) {
|
|
489
|
-
const {
|
|
583
|
+
const { problems } = checkPinned(repoRoot, {
|
|
490
584
|
expectRef: args.expectRef,
|
|
491
585
|
allowUnpinned: args.allowUnpinned,
|
|
492
586
|
});
|
|
493
|
-
|
|
494
|
-
|
|
587
|
+
const freshProblems = args.verifyFresh ? checkFresh(repoRoot, { gitBase }).problems : [];
|
|
588
|
+
const allProblems = [...problems, ...freshProblems];
|
|
589
|
+
if (allProblems.length > 0) {
|
|
590
|
+
console.error(describePinnedProblems(allProblems, args.expectRef));
|
|
495
591
|
process.exit(1);
|
|
496
592
|
}
|
|
497
|
-
console.log(
|
|
593
|
+
console.log(
|
|
594
|
+
args.verifyFresh
|
|
595
|
+
? "All Gitea-classified tenants materialized from Gitea at a pinned ref, verified fresh against the live tip."
|
|
596
|
+
: "All Gitea-classified tenants materialized from Gitea at a pinned ref.",
|
|
597
|
+
);
|
|
498
598
|
return;
|
|
499
599
|
}
|
|
500
600
|
const manifest = loadManifest();
|