@tokenoftrust/storefront-runner 2.0.8 → 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.
Files changed (97) hide show
  1. package/apps/storefront/integrations/materialize-guard.mjs +8 -2
  2. package/apps/storefront/src/lib/auth/brokerAssertion.ts +31 -5
  3. package/apps/storefront/src/lib/shared/principalGrants.ts +215 -94
  4. package/apps/storefront/src/lib/tot/syncState.ts +109 -0
  5. package/package.json +1 -1
  6. package/packages/public-runtime/src/customization-reconcile.ts +15 -1
  7. package/packages/public-runtime/src/evidence.ts +14 -11
  8. package/pnpm-workspace.yaml +0 -3
  9. package/scripts/tenant/materialize-tenant-content.mjs +107 -7
  10. package/apps/storefront/.astro/content.d.ts +0 -159
  11. package/apps/storefront/.astro/integrations/_astrojs_cloudflare/cloudflare.d.ts +0 -1
  12. package/apps/storefront/.astro/types.d.ts +0 -3
  13. package/apps/storefront/node_modules/.bin/acorn +0 -43
  14. package/apps/storefront/node_modules/.bin/astro +0 -43
  15. package/apps/storefront/node_modules/.bin/astro-check +0 -43
  16. package/apps/storefront/node_modules/.bin/browserslist +0 -43
  17. package/apps/storefront/node_modules/.bin/cf-wrangler +0 -43
  18. package/apps/storefront/node_modules/.bin/depcruise +0 -43
  19. package/apps/storefront/node_modules/.bin/depcruise-baseline +0 -43
  20. package/apps/storefront/node_modules/.bin/depcruise-fmt +0 -43
  21. package/apps/storefront/node_modules/.bin/depcruise-wrap-stream-in-html +0 -43
  22. package/apps/storefront/node_modules/.bin/dependency-cruise +0 -43
  23. package/apps/storefront/node_modules/.bin/dependency-cruiser +0 -43
  24. package/apps/storefront/node_modules/.bin/drizzle-kit +0 -43
  25. package/apps/storefront/node_modules/.bin/esbuild +0 -43
  26. package/apps/storefront/node_modules/.bin/jiti +0 -43
  27. package/apps/storefront/node_modules/.bin/playwright +0 -43
  28. package/apps/storefront/node_modules/.bin/playwright-core +0 -43
  29. package/apps/storefront/node_modules/.bin/prettier +0 -43
  30. package/apps/storefront/node_modules/.bin/rollup +0 -43
  31. package/apps/storefront/node_modules/.bin/tsc +0 -43
  32. package/apps/storefront/node_modules/.bin/tsserver +0 -43
  33. package/apps/storefront/node_modules/.bin/tsx +0 -43
  34. package/apps/storefront/node_modules/.bin/vite +0 -43
  35. package/apps/storefront/node_modules/.bin/vitest +0 -43
  36. package/apps/storefront/node_modules/.bin/workerd +0 -43
  37. package/apps/storefront/node_modules/.bin/wrangler +0 -43
  38. package/apps/storefront/node_modules/.bin/wrangler2 +0 -43
  39. package/apps/storefront/node_modules/.bin/yaml +0 -43
  40. package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_client__js.js +0 -58
  41. package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_client__js.js.map +0 -1
  42. package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_n_@preact_signals.js +0 -741
  43. package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_n_@preact_signals.js.map +0 -1
  44. package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_server__js.js +0 -2549
  45. package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_server__js.js.map +0 -1
  46. package/apps/storefront/node_modules/.vite/deps/_metadata.json +0 -150
  47. package/apps/storefront/node_modules/.vite/deps/astro-BIHuWFxm.js +0 -434
  48. package/apps/storefront/node_modules/.vite/deps/astro-BIHuWFxm.js.map +0 -1
  49. package/apps/storefront/node_modules/.vite/deps/astro_n_aria-query.js +0 -6390
  50. package/apps/storefront/node_modules/.vite/deps/astro_n_aria-query.js.map +0 -1
  51. package/apps/storefront/node_modules/.vite/deps/astro_n_axobject-query.js +0 -2705
  52. package/apps/storefront/node_modules/.vite/deps/astro_n_axobject-query.js.map +0 -1
  53. package/apps/storefront/node_modules/.vite/deps/astro_n_html-escaper.js +0 -65
  54. package/apps/storefront/node_modules/.vite/deps/astro_n_html-escaper.js.map +0 -1
  55. package/apps/storefront/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js +0 -455
  56. package/apps/storefront/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js.map +0 -1
  57. package/apps/storefront/node_modules/.vite/deps/audit-DKDpcdEI.js +0 -1535
  58. package/apps/storefront/node_modules/.vite/deps/audit-DKDpcdEI.js.map +0 -1
  59. package/apps/storefront/node_modules/.vite/deps/highlight-B9UoX1wa.js +0 -48
  60. package/apps/storefront/node_modules/.vite/deps/highlight-B9UoX1wa.js.map +0 -1
  61. package/apps/storefront/node_modules/.vite/deps/icons-BWi2wI9n.js +0 -42
  62. package/apps/storefront/node_modules/.vite/deps/icons-BWi2wI9n.js.map +0 -1
  63. package/apps/storefront/node_modules/.vite/deps/package.json +0 -3
  64. package/apps/storefront/node_modules/.vite/deps/preact.js +0 -310
  65. package/apps/storefront/node_modules/.vite/deps/preact.js.map +0 -1
  66. package/apps/storefront/node_modules/.vite/deps/preact_compat.js +0 -420
  67. package/apps/storefront/node_modules/.vite/deps/preact_compat.js.map +0 -1
  68. package/apps/storefront/node_modules/.vite/deps/preact_compat_jsx-runtime.js +0 -4
  69. package/apps/storefront/node_modules/.vite/deps/preact_debug.js +0 -236
  70. package/apps/storefront/node_modules/.vite/deps/preact_debug.js.map +0 -1
  71. package/apps/storefront/node_modules/.vite/deps/preact_devtools.js +0 -12
  72. package/apps/storefront/node_modules/.vite/deps/preact_devtools.js.map +0 -1
  73. package/apps/storefront/node_modules/.vite/deps/preact_hooks.js +0 -192
  74. package/apps/storefront/node_modules/.vite/deps/preact_hooks.js.map +0 -1
  75. package/apps/storefront/node_modules/.vite/deps/preact_jsx-dev-runtime.js +0 -3
  76. package/apps/storefront/node_modules/.vite/deps/preact_jsx-runtime.js +0 -92
  77. package/apps/storefront/node_modules/.vite/deps/preact_jsx-runtime.js.map +0 -1
  78. package/apps/storefront/node_modules/.vite/deps/preact_test-utils.js +0 -59
  79. package/apps/storefront/node_modules/.vite/deps/preact_test-utils.js.map +0 -1
  80. package/apps/storefront/node_modules/.vite/deps/rolldown-runtime-BPOCksWG.js +0 -24
  81. package/apps/storefront/node_modules/.vite/deps/settings-BVDV0Roj.js +0 -189
  82. package/apps/storefront/node_modules/.vite/deps/settings-BVDV0Roj.js.map +0 -1
  83. package/apps/storefront/node_modules/.vite/deps/static-html-DveYqwMo.js +0 -13
  84. package/apps/storefront/node_modules/.vite/deps/static-html-DveYqwMo.js.map +0 -1
  85. package/apps/storefront/node_modules/.vite/deps/toolbar-B3VIHCXg.js +0 -458
  86. package/apps/storefront/node_modules/.vite/deps/toolbar-B3VIHCXg.js.map +0 -1
  87. package/apps/storefront/node_modules/.vite/deps/ui-library-BK6ricUd.js +0 -1016
  88. package/apps/storefront/node_modules/.vite/deps/ui-library-BK6ricUd.js.map +0 -1
  89. package/apps/storefront/node_modules/.vite/deps/window-BvxDenKN.js +0 -32
  90. package/apps/storefront/node_modules/.vite/deps/window-BvxDenKN.js.map +0 -1
  91. package/apps/storefront/node_modules/.vite/deps/xray-YFiRUU0Z.js +0 -113
  92. package/apps/storefront/node_modules/.vite/deps/xray-YFiRUU0Z.js.map +0 -1
  93. package/apps/storefront/node_modules/.vite/deps_ssr/_metadata.json +0 -8
  94. package/apps/storefront/node_modules/.vite/deps_ssr/package.json +0 -3
  95. package/packages/public-runtime/node_modules/.bin/tsc +0 -43
  96. package/packages/public-runtime/node_modules/.bin/tsserver +0 -43
  97. package/packages/public-runtime/node_modules/.bin/vitest +0 -43
@@ -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
  }