@tokenoftrust/storefront-runner 2.0.9 → 2.0.10

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.
@@ -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
- const { problems } = checkPinned(undefined, { expectRef, allowUnpinned, onlyTenants });
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
- /** Optional test/DI overrides (avoids touching process env in unit tests). */
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 `subjectEmail` is the
99
- * staff actor. Returns the compact RS256 JWT to place in `X-ToT-Broker-Assertion`.
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)
@@ -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 { principal:{type:'user',email}, capability, expiresAt }
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
- * → 403 operatorGrants:notAuthenticated (bad/absent owner credential)
16
- * GET /api/clients/principalGrants → 200 { code:'operatorGrants:principalGrantsOk', grants:[...] }
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
- * ⚠ KNOWN GAP (2026-09-03, see handoff 2026-09-03-storefront-owner-grants-a-developer,
25
- * Part 1): unlike every other tot20 seam in this repo, this route is
26
- * OWNER-authenticated — `req.getApiClient()` must resolve to the TARGET TENANT's
27
- * own credential, not storefront's own operator identity. Storefront holds NO
28
- * per-tenant owner secret key anywhere (confirmed: no such credential exists in
29
- * this codebase — every other tot20 call here, `developerInvites`/`teamGrants`,
30
- * authenticates as storefront ITSELF via `TOT_API_KEY`/`TOT_SECRET_KEY`, which
31
- * resolves to storefront's OWN app domain, not a hosted tenant's). Presenting
32
- * storefront's operator credential here would not grant on behalf of the target
33
- * tenant — it would either self-grant on storefront's own domain or be rejected.
34
- * `createPrincipalGrantClient` therefore FAILS CLOSED (throws
35
- * {@link PrincipalGrantClientError}) until one of the following lands:
36
- * (a) tot20 accepts the signed-in owner's verified id_token as bearer auth on
37
- * this route, or
38
- * (b) tot20 adds a storefront-proxy grantor mode (operator credential + a
39
- * verified owner id_token/session claim, scoped to storefront's own hosted
40
- * tenants) — the same shape as the ToT-staff-broker-assertion widening
41
- * requested separately for this same route.
42
- * The client/route/UI below are otherwise complete and ready to light up the
43
- * moment a real credential resolver exists — see {@link OwnerCredentialResolver}.
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). Custom `x-tot-*` headers
50
- * are NOT read. This is the only integration-sensitive spot
51
- * ({@link OwnerCredentialResolver}, {@link credentialAuthHeader}).
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 them in the body; `GET`/`DELETE`
54
- * have no body, so they use the `Authorization` license-key header — NOT the query
55
- * string, even though tot20 would accept it there. A query string lands in access
56
- * logs, proxy logs, browser history and error-tracking payloads, so a credential
57
- * there is a disclosure with a long tail. tot20 holds this same line deliberately
58
- * (its staff broker assertions are header-only "so the staff subject never lands
59
- * in URLs/access logs", and its revoke addresses a grant by an opaque `ref`
60
- * precisely so an email never becomes a path segment); this client must not be the
61
- * thing that breaks it. `principalGrants.test.ts` asserts no credential value ever
62
- * appears in a request URL, for any method — keep that guard.
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 — owner credential missing/rejected
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
- | "ownerCredentialNotConfigured" // LOCAL: no owner-credential resolver wired yet (see file header)
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
- * Resolves the tot20 credential fields to merge into a request body FOR A GIVEN
177
- * TENANT — the seam the known gap above is about. Returns `null` when no owner
178
- * credential can be resolved for that tenant (fails closed; never falls back to
179
- * storefront's own operator credential, which would silently misdirect the grant).
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 OwnerCredentialResolver = (
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
- resolveOwnerCredential: OwnerCredentialResolver;
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 resolveOwnerCredential: OwnerCredentialResolver;
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.resolveOwnerCredential = config.resolveOwnerCredential;
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 cred = await this.resolveOwnerCredential(input.tenant);
227
- if (!cred) return notConfigured();
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
- totApiKey: cred.totApiKey,
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(`${this.baseUrl}/api/clients/principalGrants`, {}, body);
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 cred = await this.resolveOwnerCredential(tenant);
267
- if (!cred) return notConfigured();
317
+ const assertion = await this.mintOwnerAssertion(tenant);
318
+ if (!assertion) return brokerNotConfigured();
268
319
  let res: HttpResponseLike;
269
320
  try {
270
- // Credential travels in the Authorization header, never the query string.
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
- credentialAuthHeader(cred),
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 cred = await this.resolveOwnerCredential(tenant);
285
- if (!cred) return notConfigured();
336
+ const assertion = await this.mintOwnerAssertion(tenant);
337
+ if (!assertion) return brokerNotConfigured();
286
338
  let res: HttpResponseLike;
287
339
  try {
288
- // Credential travels in the Authorization header, never the query string.
289
- // `ref` is tot20's opaque grant handle (never a raw email), so it is safe
290
- // as a path segment — that is exactly why tot20 addresses revoke by ref.
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
- credentialAuthHeader(cred),
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 notConfigured(): PrincipalGrantFailed {
357
+ function brokerNotConfigured(): PrincipalGrantFailed {
305
358
  return {
306
359
  ok: false,
307
360
  status: 503,
308
- reason: "ownerCredentialNotConfigured",
361
+ reason: "brokerNotConfigured",
309
362
  message:
310
- "storefront has no owner credential to present to tot20 for this tenant yet " +
311
- "(see the KNOWN GAP note in lib/shared/principalGrants.ts)",
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 "ownerCredentialNotConfigured":
388
- return "storefront has no owner credential configured for this tenant";
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
- const reason = reasonFor(code, status);
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
- const reason = reasonFor(code, status);
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
- const reason = reasonFor(code, status);
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 "ownerCredentialNotConfigured":
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 with NO owner credential configured (simulates the known gap). */
470
- private readonly unconfigured: Set<string>;
569
+ /** Tenants the signed-in subject does NOT own (tot20's `subjectNotTenantOwner`). */
570
+ private readonly notOwned: Set<string>;
471
571
 
472
- constructor(unconfiguredTenants: string[] = []) {
473
- this.unconfigured = new Set(unconfiguredTenants.map((t) => t.toLowerCase()));
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.unconfigured.has(tenant)) return notConfigured();
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.unconfigured.has(t)) return notConfigured();
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.unconfigured.has(t)) return notConfigured();
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. `resolveOwnerCredential` defaults to a
515
- * fail-closed stub (see the KNOWN GAP note at the top of this file) — pass an
516
- * override once a real owner-credential resolver exists.
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
- overrides: Partial<HttpPrincipalGrantClientConfig> = {},
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
- resolveOwnerCredential: overrides.resolveOwnerCredential ?? (async () => null),
643
+ apiKey,
644
+ mintOwnerAssertion,
645
+ ...(secretKey ? { secretKey } : {}),
525
646
  ...(overrides.transport ? { transport: overrides.transport } : {}),
526
647
  });
527
648
  }
@@ -0,0 +1,109 @@
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
+ products: SyncStateEntity;
69
+ collections: SyncStateEntity;
70
+ /**
71
+ * The content-hash delta of the most recent sweep. Optional: a catalog
72
+ * migrated before hsby-B2 (change detection) has no `lastChanges`.
73
+ */
74
+ lastChanges?: SyncStateSweepChanges;
75
+ }
76
+
77
+ // Vite resolves this glob at build time and bundles every tenant's sync-state
78
+ // document, so the Worker has no runtime filesystem dependency — same pattern
79
+ // as productGlobs/collectionGlobs/reviewGlobs in ToTClient.ts.
80
+ const syncStateGlobs = import.meta.glob<CatalogSyncState>(
81
+ "../../../../../packages/migration/out/catalog/*/sync-state.json",
82
+ { import: "default" },
83
+ );
84
+
85
+ const syncStateCacheByScope = new Map<string, CatalogSyncState | null>();
86
+
87
+ /** Pick the glob entry for a tenant scope (matches .../catalog/<scope>/file). */
88
+ function pickByScope(scope: string): (() => Promise<CatalogSyncState>) | null {
89
+ const suffix = `/catalog/${scope}/`;
90
+ const key = Object.keys(syncStateGlobs).find((k) => k.includes(suffix));
91
+ return (key ? syncStateGlobs[key] : null) ?? null;
92
+ }
93
+
94
+ /**
95
+ * The tenant's durable sync state, or null when none was ever recorded (no
96
+ * migration run yet, or a pre-hsby-B1 catalog). Never throws.
97
+ */
98
+ export async function getSyncState(scope: string): Promise<CatalogSyncState | null> {
99
+ if (syncStateCacheByScope.has(scope)) return syncStateCacheByScope.get(scope)!;
100
+ const loader = pickByScope(scope);
101
+ const state = loader ? await loader() : null;
102
+ syncStateCacheByScope.set(scope, state);
103
+ return state;
104
+ }
105
+
106
+ /** Test seam: drop the per-isolate cache. */
107
+ export function clearSyncStateCache(): void {
108
+ syncStateCacheByScope.clear();
109
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.0.9",
3
+ "version": "2.0.10",
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?: { checks: PreviewStateEvidenceCheck[]; promotable: boolean; generatedAt: string } | null;
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
- // NOTE (2026-08): a v2 `assets_materialized` check was briefly added here and REVERTED —
13
- // the deployed tot-mcp evidence validator pins `contract_version:1` + a 7-id enum and
14
- // REJECTED every v2 report (".contract_version should be equal to constant; .checks[7].id
15
- // should be equal to one of the allowed values"), silently dropping the evidence push so
16
- // `preview_status` hung `pending` on every reconcile. The reconcile STILL fetch-verifies
17
- // the served asset subtree (verify-assets.ts) and surfaces the result in the reconcile
18
- // trace ("assets-verified" marker) — that regression guard is intact. Re-add the
19
- // `assets_materialized` check id here + bump to 2 ONLY in lockstep with tot-mcp accepting
20
- // contract_version 2 (and mirroring the evidence-url CRC). Until then this stays v1.
21
- export const EVIDENCE_CONTRACT_VERSION = 1;
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
- // (v2 `assets_materialized` intentionally omitted — see EVIDENCE_CONTRACT_VERSION note.)
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
- /** Human-readable refusal for a failed `checkPinned` (shared by the CLI + the
384
- * build-time guard). `problems` is checkPinned().problems; `expectRef` is echoed
385
- * into the off-ref explanation when set. */
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 { ok, problems } = checkPinned(repoRoot, {
583
+ const { problems } = checkPinned(repoRoot, {
490
584
  expectRef: args.expectRef,
491
585
  allowUnpinned: args.allowUnpinned,
492
586
  });
493
- if (!ok) {
494
- console.error(describePinnedProblems(problems, args.expectRef));
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("All Gitea-classified tenants materialized from Gitea at a pinned ref.");
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();