@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.
- package/apps/storefront/integrations/materialize-guard.mjs +8 -2
- package/apps/storefront/src/lib/auth/brokerAssertion.ts +31 -5
- package/apps/storefront/src/lib/shared/principalGrants.ts +215 -94
- package/apps/storefront/src/lib/tot/syncState.ts +109 -0
- package/package.json +1 -1
- package/packages/public-runtime/src/customization-reconcile.ts +15 -1
- package/packages/public-runtime/src/evidence.ts +14 -11
- package/pnpm-workspace.yaml +0 -3
- package/scripts/tenant/materialize-tenant-content.mjs +107 -7
- package/apps/storefront/.astro/content.d.ts +0 -159
- package/apps/storefront/.astro/integrations/_astrojs_cloudflare/cloudflare.d.ts +0 -1
- package/apps/storefront/.astro/types.d.ts +0 -3
- package/apps/storefront/node_modules/.bin/acorn +0 -43
- package/apps/storefront/node_modules/.bin/astro +0 -43
- package/apps/storefront/node_modules/.bin/astro-check +0 -43
- package/apps/storefront/node_modules/.bin/browserslist +0 -43
- package/apps/storefront/node_modules/.bin/cf-wrangler +0 -43
- package/apps/storefront/node_modules/.bin/depcruise +0 -43
- package/apps/storefront/node_modules/.bin/depcruise-baseline +0 -43
- package/apps/storefront/node_modules/.bin/depcruise-fmt +0 -43
- package/apps/storefront/node_modules/.bin/depcruise-wrap-stream-in-html +0 -43
- package/apps/storefront/node_modules/.bin/dependency-cruise +0 -43
- package/apps/storefront/node_modules/.bin/dependency-cruiser +0 -43
- package/apps/storefront/node_modules/.bin/drizzle-kit +0 -43
- package/apps/storefront/node_modules/.bin/esbuild +0 -43
- package/apps/storefront/node_modules/.bin/jiti +0 -43
- package/apps/storefront/node_modules/.bin/playwright +0 -43
- package/apps/storefront/node_modules/.bin/playwright-core +0 -43
- package/apps/storefront/node_modules/.bin/prettier +0 -43
- package/apps/storefront/node_modules/.bin/rollup +0 -43
- package/apps/storefront/node_modules/.bin/tsc +0 -43
- package/apps/storefront/node_modules/.bin/tsserver +0 -43
- package/apps/storefront/node_modules/.bin/tsx +0 -43
- package/apps/storefront/node_modules/.bin/vite +0 -43
- package/apps/storefront/node_modules/.bin/vitest +0 -43
- package/apps/storefront/node_modules/.bin/workerd +0 -43
- package/apps/storefront/node_modules/.bin/wrangler +0 -43
- package/apps/storefront/node_modules/.bin/wrangler2 +0 -43
- package/apps/storefront/node_modules/.bin/yaml +0 -43
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_client__js.js +0 -58
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_client__js.js.map +0 -1
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_n_@preact_signals.js +0 -741
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_n_@preact_signals.js.map +0 -1
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_server__js.js +0 -2549
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_server__js.js.map +0 -1
- package/apps/storefront/node_modules/.vite/deps/_metadata.json +0 -150
- package/apps/storefront/node_modules/.vite/deps/astro-BIHuWFxm.js +0 -434
- package/apps/storefront/node_modules/.vite/deps/astro-BIHuWFxm.js.map +0 -1
- package/apps/storefront/node_modules/.vite/deps/astro_n_aria-query.js +0 -6390
- package/apps/storefront/node_modules/.vite/deps/astro_n_aria-query.js.map +0 -1
- package/apps/storefront/node_modules/.vite/deps/astro_n_axobject-query.js +0 -2705
- package/apps/storefront/node_modules/.vite/deps/astro_n_axobject-query.js.map +0 -1
- package/apps/storefront/node_modules/.vite/deps/astro_n_html-escaper.js +0 -65
- package/apps/storefront/node_modules/.vite/deps/astro_n_html-escaper.js.map +0 -1
- package/apps/storefront/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js +0 -455
- package/apps/storefront/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js.map +0 -1
- package/apps/storefront/node_modules/.vite/deps/audit-DKDpcdEI.js +0 -1535
- package/apps/storefront/node_modules/.vite/deps/audit-DKDpcdEI.js.map +0 -1
- package/apps/storefront/node_modules/.vite/deps/highlight-B9UoX1wa.js +0 -48
- package/apps/storefront/node_modules/.vite/deps/highlight-B9UoX1wa.js.map +0 -1
- package/apps/storefront/node_modules/.vite/deps/icons-BWi2wI9n.js +0 -42
- package/apps/storefront/node_modules/.vite/deps/icons-BWi2wI9n.js.map +0 -1
- package/apps/storefront/node_modules/.vite/deps/package.json +0 -3
- package/apps/storefront/node_modules/.vite/deps/preact.js +0 -310
- package/apps/storefront/node_modules/.vite/deps/preact.js.map +0 -1
- package/apps/storefront/node_modules/.vite/deps/preact_compat.js +0 -420
- package/apps/storefront/node_modules/.vite/deps/preact_compat.js.map +0 -1
- package/apps/storefront/node_modules/.vite/deps/preact_compat_jsx-runtime.js +0 -4
- package/apps/storefront/node_modules/.vite/deps/preact_debug.js +0 -236
- package/apps/storefront/node_modules/.vite/deps/preact_debug.js.map +0 -1
- package/apps/storefront/node_modules/.vite/deps/preact_devtools.js +0 -12
- package/apps/storefront/node_modules/.vite/deps/preact_devtools.js.map +0 -1
- package/apps/storefront/node_modules/.vite/deps/preact_hooks.js +0 -192
- package/apps/storefront/node_modules/.vite/deps/preact_hooks.js.map +0 -1
- package/apps/storefront/node_modules/.vite/deps/preact_jsx-dev-runtime.js +0 -3
- package/apps/storefront/node_modules/.vite/deps/preact_jsx-runtime.js +0 -92
- package/apps/storefront/node_modules/.vite/deps/preact_jsx-runtime.js.map +0 -1
- package/apps/storefront/node_modules/.vite/deps/preact_test-utils.js +0 -59
- package/apps/storefront/node_modules/.vite/deps/preact_test-utils.js.map +0 -1
- package/apps/storefront/node_modules/.vite/deps/rolldown-runtime-BPOCksWG.js +0 -24
- package/apps/storefront/node_modules/.vite/deps/settings-BVDV0Roj.js +0 -189
- package/apps/storefront/node_modules/.vite/deps/settings-BVDV0Roj.js.map +0 -1
- package/apps/storefront/node_modules/.vite/deps/static-html-DveYqwMo.js +0 -13
- package/apps/storefront/node_modules/.vite/deps/static-html-DveYqwMo.js.map +0 -1
- package/apps/storefront/node_modules/.vite/deps/toolbar-B3VIHCXg.js +0 -458
- package/apps/storefront/node_modules/.vite/deps/toolbar-B3VIHCXg.js.map +0 -1
- package/apps/storefront/node_modules/.vite/deps/ui-library-BK6ricUd.js +0 -1016
- package/apps/storefront/node_modules/.vite/deps/ui-library-BK6ricUd.js.map +0 -1
- package/apps/storefront/node_modules/.vite/deps/window-BvxDenKN.js +0 -32
- package/apps/storefront/node_modules/.vite/deps/window-BvxDenKN.js.map +0 -1
- package/apps/storefront/node_modules/.vite/deps/xray-YFiRUU0Z.js +0 -113
- package/apps/storefront/node_modules/.vite/deps/xray-YFiRUU0Z.js.map +0 -1
- package/apps/storefront/node_modules/.vite/deps_ssr/_metadata.json +0 -8
- package/apps/storefront/node_modules/.vite/deps_ssr/package.json +0 -3
- package/packages/public-runtime/node_modules/.bin/tsc +0 -43
- package/packages/public-runtime/node_modules/.bin/tsserver +0 -43
- 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
|
-
|
|
37
|
+
// Framework mode never treats tenant content as a build input (it's
|
|
38
|
+
// resolved from KV/R2 per request), so the guard checks ZERO
|
|
39
|
+
// tenants here — onlyTenants=[] scopes checkPinned to an empty
|
|
40
|
+
// set (not "all tenants", which `null` would mean), so a sibling
|
|
41
|
+
// tenant that is present-but-stale can never block this build.
|
|
42
|
+
const frameworkOnlyTenants = [];
|
|
43
|
+
const { problems } = checkPinned(undefined, { expectRef, allowUnpinned, onlyTenants: frameworkOnlyTenants });
|
|
38
44
|
const absent = problems.filter((p) => p.reason === "absent");
|
|
39
45
|
const real = problems.filter((p) => p.reason !== "absent");
|
|
40
46
|
if (absent.length) {
|
|
@@ -43,7 +49,7 @@ export function materializeGuard() {
|
|
|
43
49
|
else console.log(msg);
|
|
44
50
|
}
|
|
45
51
|
if (real.length) {
|
|
46
|
-
assertPinnedMaterialized({ expectRef, allowUnpinned, onlyTenants });
|
|
52
|
+
assertPinnedMaterialized({ expectRef, allowUnpinned, onlyTenants: frameworkOnlyTenants });
|
|
47
53
|
}
|
|
48
54
|
return;
|
|
49
55
|
}
|
|
@@ -28,6 +28,13 @@ import { readEnv } from "@/lib/env";
|
|
|
28
28
|
/** The audience tot20's broker verifier pins. */
|
|
29
29
|
export const BROKER_AUDIENCE = "tot20-broker";
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* The ONLY place an assertion may travel — a header, never a query string, so the
|
|
33
|
+
* subject never lands in access logs, proxy logs or browser history. tot20 reads
|
|
34
|
+
* it case-insensitively (`readAssertion`, `operatorGrants-api.routes.node.js`).
|
|
35
|
+
*/
|
|
36
|
+
export const BROKER_ASSERTION_HEADER = "X-ToT-Broker-Assertion";
|
|
37
|
+
|
|
31
38
|
/** The only algorithm the storefront broker signs with (tot20 rejects anything else). */
|
|
32
39
|
export const BROKER_ALG = "RS256";
|
|
33
40
|
|
|
@@ -52,8 +59,20 @@ export const BROKER_ISSUER_ENV = "STOREFRONT_BROKER_ISSUER";
|
|
|
52
59
|
*/
|
|
53
60
|
export class BrokerAssertionError extends Error {}
|
|
54
61
|
|
|
55
|
-
/**
|
|
62
|
+
/** Mint options: the optional `tenant` claim, plus test/DI overrides. */
|
|
56
63
|
export interface MintBrokerAssertionOverrides {
|
|
64
|
+
/**
|
|
65
|
+
* Binds this one assertion to a single target tenant (appDomain), signed. tot20
|
|
66
|
+
* reads it as `assertionTenant` and requires it to match the tenant named on the
|
|
67
|
+
* request EXACTLY — an unnormalized string compare, so pass the same value the
|
|
68
|
+
* request carries. It names the tenant the broker was asked to act on; it is
|
|
69
|
+
* NOT evidence the subject owns it (tot20 resolves ownership from its own state).
|
|
70
|
+
*
|
|
71
|
+
* Omit for the surfaces that act on the caller's own store or on ToT-staff
|
|
72
|
+
* authority alone (the invite/vendor-search flows) — tot20 leaves
|
|
73
|
+
* `assertionTenant` undefined there and those routes never consult it.
|
|
74
|
+
*/
|
|
75
|
+
tenant?: string;
|
|
57
76
|
/** base64 of the PKCS8 PEM private key (defaults to the env value). */
|
|
58
77
|
privateKeyB64?: string;
|
|
59
78
|
kid?: string;
|
|
@@ -95,12 +114,17 @@ async function loadSigningKey(privateKeyB64: string): Promise<CryptoKey> {
|
|
|
95
114
|
}
|
|
96
115
|
|
|
97
116
|
/**
|
|
98
|
-
* Mint a fresh, storefront-signed broker assertion PROVING
|
|
99
|
-
*
|
|
117
|
+
* Mint a fresh, storefront-signed broker assertion PROVING the storefront asserts
|
|
118
|
+
* `subjectEmail` as the actor. Returns the compact RS256 JWT to place in
|
|
119
|
+
* {@link BROKER_ASSERTION_HEADER}.
|
|
100
120
|
*
|
|
101
|
-
* Claims: `{ iss, sub:<subjectEmail>, aud:'tot20-broker', jti:<uuid>, iat, exp:iat+300 }
|
|
121
|
+
* Claims: `{ iss, sub:<subjectEmail>, aud:'tot20-broker', jti:<uuid>, iat, exp:iat+300 }`,
|
|
122
|
+
* plus `tenant` when `overrides.tenant` is given.
|
|
102
123
|
* Header: `{ alg:'RS256', kid, typ:'JWT' }`.
|
|
103
124
|
*
|
|
125
|
+
* MINT ONE PER CALL. tot20's verifier BURNS the `jti` on first verify (single-use
|
|
126
|
+
* replay protection), so an assertion must never be reused across two requests.
|
|
127
|
+
*
|
|
104
128
|
* FAILS CLOSED: throws {@link BrokerAssertionError} when the private key env is
|
|
105
129
|
* absent/malformed or no subject is supplied — the caller must NOT fall back to an
|
|
106
130
|
* unproven call.
|
|
@@ -122,9 +146,11 @@ export async function mintBrokerAssertion(
|
|
|
122
146
|
const kid = overrides.kid ?? (await readEnv(BROKER_KID_ENV)) ?? DEFAULT_BROKER_KID;
|
|
123
147
|
const issuer = overrides.issuer ?? (await readEnv(BROKER_ISSUER_ENV)) ?? DEFAULT_BROKER_ISSUER;
|
|
124
148
|
|
|
149
|
+
const tenant = (overrides.tenant ?? "").trim();
|
|
150
|
+
|
|
125
151
|
const key = await loadSigningKey(privateKeyB64);
|
|
126
152
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
127
|
-
return new SignJWT({})
|
|
153
|
+
return new SignJWT(tenant ? { tenant } : {})
|
|
128
154
|
.setProtectedHeader({ alg: BROKER_ALG, kid, typ: "JWT" })
|
|
129
155
|
.setIssuer(issuer)
|
|
130
156
|
.setSubject(sub)
|
|
@@ -9,57 +9,64 @@
|
|
|
9
9
|
* which only ever emits `preview-access`/`blog-contribute` sign-in eligibility) —
|
|
10
10
|
* the two revoke independently and must never be merged.
|
|
11
11
|
*
|
|
12
|
-
* POST /api/clients/principalGrants
|
|
12
|
+
* POST /api/clients/principalGrants { tenant, principal:{type:'user',email}, capability, expiresAt }
|
|
13
13
|
* → 200 { code:'operatorGrants:principalGrantCreated', tenant, principal, ref, capability, expiresAt }
|
|
14
14
|
* → 422 operatorGrants:{expiresAtRequired|unknownCapability|invalidPrincipal|principalTypeNotSupported}
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* DELETE /api/clients/principalGrants/{ref} → 200 { code:'operatorGrants:principalGrantRevoked', tenant }
|
|
15
|
+
* GET /api/clients/principalGrants?tenant=<t> → 200 { code:'operatorGrants:principalGrantsOk', grants:[...] }
|
|
16
|
+
* DELETE /api/clients/principalGrants/{ref}?tenant=<t> → 200 { code:'operatorGrants:principalGrantRevoked', tenant }
|
|
18
17
|
*
|
|
19
18
|
* `expiresAt` (epoch-ms) is REQUIRED for a human grantee — tot20 fails closed
|
|
20
19
|
* with `expiresAtRequired` if omitted (ADR-0003 D4: no standing authority for a
|
|
21
20
|
* human/service principal). `ref` is opaque (never the raw email) and is what
|
|
22
21
|
* `revoke`/list rows key on.
|
|
23
22
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
23
|
+
* GRANTOR MODE: OWNER-BROKERED SESSION. Merchants hold no ToT API keys — they sign
|
|
24
|
+
* into storefront's dashboard. tot20's `resolveGrantorContext` accepts three grantor
|
|
25
|
+
* provenances, and this client drives exactly one of them:
|
|
26
|
+
*
|
|
27
|
+
* - Naming a `tenant` on the request switches tot20 OFF the owner-key path (where
|
|
28
|
+
* the grantor is the CALLER's own store — i.e. storefront itself) and onto the
|
|
29
|
+
* brokered path.
|
|
30
|
+
* - The brokered path requires a VERIFIED broker assertion whose signed `tenant`
|
|
31
|
+
* claim matches the request's tenant EXACTLY (an unnormalized string compare, so
|
|
32
|
+
* both are built from the same value here) and whose SUBJECT is the signed-in
|
|
33
|
+
* owner. tot20 then resolves ownership from its OWN state (`ownsTenant` →
|
|
34
|
+
* `AccessCodeService.resolveEligibility`, strictly `capability === 'owner'`) —
|
|
35
|
+
* the assertion proves WHO the subject is, never WHAT they own.
|
|
36
|
+
* - Transport auth stays storefront's OWN operator credential (`TOT_API_KEY` /
|
|
37
|
+
* `TOT_SECRET_KEY`; the route is `requireSecretKey`), the same credential every
|
|
38
|
+
* sibling client here presents. It authenticates the BROKER; it does not choose
|
|
39
|
+
* the grantor. tot20 audits the result as `authorityPath:'owner-brokered-session'`
|
|
40
|
+
* with actor `owner:<hash>` — distinct from the owner-key `self-service-direct`.
|
|
41
|
+
*
|
|
42
|
+
* The assertion is minted PER CALL by the route (the only layer holding the verified
|
|
43
|
+
* session) and injected as {@link OwnerAssertionMinter}; this module never sees a
|
|
44
|
+
* session and so cannot mint on a client-supplied email. tot20's verifier BURNS the
|
|
45
|
+
* `jti` on first verify, so a minter that memoized would fail the second call as a
|
|
46
|
+
* replay.
|
|
44
47
|
*
|
|
45
48
|
* AUTH WIRE: tot20's swagger `normalizeSecureUserRequest` accepts machine creds
|
|
46
49
|
* three ways — request BODY fields `totApiKey`/`totSecretKey`, the same fields as
|
|
47
50
|
* QUERY params, or an `Authorization` header carrying a base64 "license key"
|
|
48
51
|
* (`base64("<apiKey>;<secretKey>")`, expanded by tot20's `expandLicenseKey`,
|
|
49
|
-
* which takes PRECEDENCE over the body/query values).
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
+
* which takes PRECEDENCE over the body/query values). The broker assertion rides the
|
|
53
|
+
* separate `X-ToT-Broker-Assertion` header ({@link BROKER_ASSERTION_HEADER}) — the
|
|
54
|
+
* only place tot20's `readAssertion` looks. These are the integration-sensitive spots
|
|
55
|
+
* ({@link credentialAuthHeader}, {@link HttpPrincipalGrantClient.brokeredHeaders}).
|
|
52
56
|
*
|
|
53
|
-
* ⚠ CREDENTIALS NEVER GO IN A URL. `POST` carries
|
|
54
|
-
* have no body, so they use the `Authorization` license-key
|
|
55
|
-
* string, even though tot20 would accept it there. A query
|
|
56
|
-
* logs, proxy logs, browser history and error-tracking
|
|
57
|
-
* there is a disclosure with a long tail. tot20 holds this
|
|
58
|
-
* (its
|
|
59
|
-
* in URLs/access logs", and its revoke addresses a grant by an opaque
|
|
60
|
-
* precisely so an email never becomes a path segment); this client must not be
|
|
61
|
-
* thing that breaks it.
|
|
62
|
-
*
|
|
57
|
+
* ⚠ CREDENTIALS AND ASSERTIONS NEVER GO IN A URL. `POST` carries the credential in the
|
|
58
|
+
* body; `GET`/`DELETE` have no body, so they use the `Authorization` license-key
|
|
59
|
+
* header — NOT the query string, even though tot20 would accept it there. A query
|
|
60
|
+
* string lands in access logs, proxy logs, browser history and error-tracking
|
|
61
|
+
* payloads, so a credential there is a disclosure with a long tail. tot20 holds this
|
|
62
|
+
* same line deliberately (its broker assertions are header-only "so the staff subject
|
|
63
|
+
* never lands in URLs/access logs", and its revoke addresses a grant by an opaque
|
|
64
|
+
* `ref` precisely so an email never becomes a path segment); this client must not be
|
|
65
|
+
* the thing that breaks it. `?tenant=` on GET/DELETE is the ONE permitted query param
|
|
66
|
+
* — an appDomain is public routing data, not a credential, and tot20 reads the tenant
|
|
67
|
+
* from `req.query` on exactly those two verbs. `principalGrants.test.ts` asserts that
|
|
68
|
+
* `tenant` is the only param present and that no credential or assertion value ever
|
|
69
|
+
* reaches a URL, for any method — keep that guard.
|
|
63
70
|
*
|
|
64
71
|
* TIER: host + credentials are per-DEPLOY env, same `TOT_CORE_URL` the sibling
|
|
65
72
|
* developerInvites/forge clients read (qa tier is what's live today for this route).
|
|
@@ -70,6 +77,7 @@
|
|
|
70
77
|
*/
|
|
71
78
|
import { readEnv } from "@/lib/env";
|
|
72
79
|
import { diagEmail } from "@/lib/diag/redact";
|
|
80
|
+
import { BROKER_ASSERTION_HEADER, BROKER_PRIVATE_KEY_ENV } from "@/lib/auth/brokerAssertion";
|
|
73
81
|
|
|
74
82
|
/** A human grantee — the only principal type this HTTP surface accepts. */
|
|
75
83
|
export interface UserPrincipal {
|
|
@@ -103,12 +111,18 @@ export interface PrincipalGrantCreated {
|
|
|
103
111
|
}
|
|
104
112
|
|
|
105
113
|
export type PrincipalGrantFailureReason =
|
|
106
|
-
| "notAuthenticated" // 403/422 —
|
|
114
|
+
| "notAuthenticated" // 403/422 — storefront's own operator credential missing/rejected
|
|
107
115
|
| "expiresAtRequired" // 422 — ADR-0003 D4
|
|
108
116
|
| "unknownCapability" // 422
|
|
109
117
|
| "invalidPrincipal" // 422
|
|
110
118
|
| "principalTypeNotSupported" // 422 — non-'user' principal at this HTTP layer
|
|
111
|
-
|
|
119
|
+
// --- owner-brokered grantor path (tot20 `resolveGrantorContext`) ---
|
|
120
|
+
| "brokerAssertionRequired" // 422 — named a tenant with no assertion (client bug)
|
|
121
|
+
| "brokerAssertionInvalid" // 401 — bad signature / expired / replayed jti
|
|
122
|
+
| "assertionTenantMismatch" // 422 — assertion names a different tenant than the request
|
|
123
|
+
| "tenantNotRegistered" // 422 — the named appDomain is not a ToT client
|
|
124
|
+
| "subjectNotTenantOwner" // 403 — the signed-in subject does not OWN this tenant
|
|
125
|
+
| "brokerNotConfigured" // LOCAL 503 — this deploy holds no broker signing key
|
|
112
126
|
| "error"; // transport / unexpected upstream fault
|
|
113
127
|
|
|
114
128
|
export interface PrincipalGrantFailed {
|
|
@@ -173,14 +187,18 @@ export class FetchHttpTransport implements HttpTransport {
|
|
|
173
187
|
export class PrincipalGrantClientError extends Error {}
|
|
174
188
|
|
|
175
189
|
/**
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
190
|
+
* Mints a FRESH broker assertion naming `tenant`, whose subject is the signed-in
|
|
191
|
+
* OWNER. Supplied by the route, which is the only layer holding a verified session
|
|
192
|
+
* — keeping this an injected function is what makes it structurally impossible for
|
|
193
|
+
* this module to assert an email that came off a request.
|
|
194
|
+
*
|
|
195
|
+
* Returns `null` when this deploy cannot sign at all (no broker key) — the caller
|
|
196
|
+
* fails closed with `brokerNotConfigured` rather than sending an unbrokered request
|
|
197
|
+
* that tot20 would silently resolve to storefront's OWN store as grantor.
|
|
198
|
+
*
|
|
199
|
+
* Called once per request: tot20 burns the `jti`, so a cached assertion replays.
|
|
180
200
|
*/
|
|
181
|
-
export type
|
|
182
|
-
tenant: string,
|
|
183
|
-
) => Promise<{ totApiKey: string; totSecretKey?: string } | null>;
|
|
201
|
+
export type OwnerAssertionMinter = (tenant: string) => Promise<string | null>;
|
|
184
202
|
|
|
185
203
|
/**
|
|
186
204
|
* Build the `Authorization` header tot20 accepts for a body-less request (GET /
|
|
@@ -204,34 +222,63 @@ export function credentialAuthHeader(cred: {
|
|
|
204
222
|
export interface HttpPrincipalGrantClientConfig {
|
|
205
223
|
/** tot20 core API base (no trailing slash), e.g. https://qa.tokenoftrust.com */
|
|
206
224
|
baseUrl: string;
|
|
207
|
-
|
|
225
|
+
/** Storefront's OWN operator credential — authenticates the broker, not the grantor. */
|
|
226
|
+
apiKey: string;
|
|
227
|
+
secretKey?: string;
|
|
228
|
+
mintOwnerAssertion: OwnerAssertionMinter;
|
|
208
229
|
transport?: HttpTransport;
|
|
209
230
|
}
|
|
210
231
|
|
|
211
232
|
export class HttpPrincipalGrantClient implements PrincipalGrantClient {
|
|
212
233
|
private readonly baseUrl: string;
|
|
213
|
-
private readonly
|
|
234
|
+
private readonly apiKey: string;
|
|
235
|
+
private readonly secretKey: string | undefined;
|
|
236
|
+
private readonly mintOwnerAssertion: OwnerAssertionMinter;
|
|
214
237
|
private readonly transport: HttpTransport;
|
|
215
238
|
|
|
216
239
|
constructor(config: HttpPrincipalGrantClientConfig) {
|
|
217
240
|
if (!config.baseUrl) {
|
|
218
241
|
throw new PrincipalGrantClientError("principalGrants: TOT_CORE_URL is not configured");
|
|
219
242
|
}
|
|
243
|
+
if (!config.apiKey) {
|
|
244
|
+
throw new PrincipalGrantClientError("principalGrants: TOT_API_KEY is not configured");
|
|
245
|
+
}
|
|
220
246
|
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
|
221
|
-
this.
|
|
247
|
+
this.apiKey = config.apiKey;
|
|
248
|
+
this.secretKey = config.secretKey;
|
|
249
|
+
this.mintOwnerAssertion = config.mintOwnerAssertion;
|
|
222
250
|
this.transport = config.transport ?? new FetchHttpTransport();
|
|
223
251
|
}
|
|
224
252
|
|
|
253
|
+
/** Storefront's own operator credential, as tot20's documented body fields. */
|
|
254
|
+
private credential(): { totApiKey: string; totSecretKey?: string } {
|
|
255
|
+
return { totApiKey: this.apiKey, ...(this.secretKey ? { totSecretKey: this.secretKey } : {}) };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Headers for a brokered call. `assertion` is header-only; the license-key
|
|
260
|
+
* `Authorization` is added for the body-less verbs, which have nowhere else to
|
|
261
|
+
* carry the credential that isn't the URL.
|
|
262
|
+
*/
|
|
263
|
+
private brokeredHeaders(assertion: string, withCredential: boolean): Record<string, string> {
|
|
264
|
+
return {
|
|
265
|
+
[BROKER_ASSERTION_HEADER]: assertion,
|
|
266
|
+
...(withCredential ? credentialAuthHeader(this.credential()) : {}),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
225
270
|
async createGrant(input: CreatePrincipalGrantInput): Promise<PrincipalGrantResult> {
|
|
226
|
-
const
|
|
227
|
-
if (!
|
|
271
|
+
const assertion = await this.mintOwnerAssertion(input.tenant);
|
|
272
|
+
if (!assertion) return brokerNotConfigured();
|
|
228
273
|
|
|
274
|
+
// `tenant` in the BODY is what puts tot20 on the owner-brokered branch for POST
|
|
275
|
+
// (createPrincipalGrant reads `body.tenant`); GET/DELETE read `req.query.tenant`.
|
|
229
276
|
const body: Record<string, unknown> = {
|
|
277
|
+
tenant: input.tenant,
|
|
230
278
|
principal: { type: "user", email: input.principal.email.toLowerCase() },
|
|
231
279
|
capability: input.capability,
|
|
232
280
|
expiresAt: input.expiresAt,
|
|
233
|
-
|
|
234
|
-
...(cred.totSecretKey ? { totSecretKey: cred.totSecretKey } : {}),
|
|
281
|
+
...this.credential(),
|
|
235
282
|
};
|
|
236
283
|
if (input.scope) body.scope = input.scope;
|
|
237
284
|
if (input.purpose) body.purpose = input.purpose;
|
|
@@ -246,7 +293,11 @@ export class HttpPrincipalGrantClient implements PrincipalGrantClient {
|
|
|
246
293
|
|
|
247
294
|
let res: HttpResponseLike;
|
|
248
295
|
try {
|
|
249
|
-
res = await this.transport.post(
|
|
296
|
+
res = await this.transport.post(
|
|
297
|
+
`${this.baseUrl}/api/clients/principalGrants`,
|
|
298
|
+
this.brokeredHeaders(assertion, false),
|
|
299
|
+
body,
|
|
300
|
+
);
|
|
250
301
|
} catch (cause) {
|
|
251
302
|
return transportFailure("creating principal grant", cause);
|
|
252
303
|
}
|
|
@@ -263,14 +314,15 @@ export class HttpPrincipalGrantClient implements PrincipalGrantClient {
|
|
|
263
314
|
}
|
|
264
315
|
|
|
265
316
|
async listGrants(tenant: string): Promise<PrincipalGrantListResult> {
|
|
266
|
-
const
|
|
267
|
-
if (!
|
|
317
|
+
const assertion = await this.mintOwnerAssertion(tenant);
|
|
318
|
+
if (!assertion) return brokerNotConfigured();
|
|
268
319
|
let res: HttpResponseLike;
|
|
269
320
|
try {
|
|
270
|
-
//
|
|
321
|
+
// `tenant` is the ONLY query param — public routing data, and the only place
|
|
322
|
+
// tot20's listPrincipalGrants reads it. The credential stays in a header.
|
|
271
323
|
res = await this.transport.get(
|
|
272
|
-
`${this.baseUrl}/api/clients/principalGrants`,
|
|
273
|
-
|
|
324
|
+
`${this.baseUrl}/api/clients/principalGrants?tenant=${encodeURIComponent(tenant)}`,
|
|
325
|
+
this.brokeredHeaders(assertion, true),
|
|
274
326
|
);
|
|
275
327
|
} catch (cause) {
|
|
276
328
|
return transportFailure("listing principal grants", cause);
|
|
@@ -281,16 +333,17 @@ export class HttpPrincipalGrantClient implements PrincipalGrantClient {
|
|
|
281
333
|
}
|
|
282
334
|
|
|
283
335
|
async revokeGrant(tenant: string, ref: string): Promise<PrincipalGrantRevokeResult> {
|
|
284
|
-
const
|
|
285
|
-
if (!
|
|
336
|
+
const assertion = await this.mintOwnerAssertion(tenant);
|
|
337
|
+
if (!assertion) return brokerNotConfigured();
|
|
286
338
|
let res: HttpResponseLike;
|
|
287
339
|
try {
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
340
|
+
// `ref` is tot20's opaque grant handle (never a raw email), so it is safe as a
|
|
341
|
+
// path segment — that is exactly why tot20 addresses revoke by ref. `tenant` is
|
|
342
|
+
// the only query param; the credential stays in the Authorization header.
|
|
291
343
|
res = await this.transport.delete(
|
|
292
|
-
`${this.baseUrl}/api/clients/principalGrants/${encodeURIComponent(ref)}
|
|
293
|
-
|
|
344
|
+
`${this.baseUrl}/api/clients/principalGrants/${encodeURIComponent(ref)}` +
|
|
345
|
+
`?tenant=${encodeURIComponent(tenant)}`,
|
|
346
|
+
this.brokeredHeaders(assertion, true),
|
|
294
347
|
);
|
|
295
348
|
} catch (cause) {
|
|
296
349
|
return transportFailure("revoking principal grant", cause);
|
|
@@ -301,14 +354,14 @@ export class HttpPrincipalGrantClient implements PrincipalGrantClient {
|
|
|
301
354
|
}
|
|
302
355
|
}
|
|
303
356
|
|
|
304
|
-
function
|
|
357
|
+
function brokerNotConfigured(): PrincipalGrantFailed {
|
|
305
358
|
return {
|
|
306
359
|
ok: false,
|
|
307
360
|
status: 503,
|
|
308
|
-
reason: "
|
|
361
|
+
reason: "brokerNotConfigured",
|
|
309
362
|
message:
|
|
310
|
-
"storefront
|
|
311
|
-
|
|
363
|
+
"this storefront deploy cannot sign an owner assertion " +
|
|
364
|
+
`(${BROKER_PRIVATE_KEY_ENV} is not configured), so the request was not sent`,
|
|
312
365
|
};
|
|
313
366
|
}
|
|
314
367
|
|
|
@@ -366,12 +419,49 @@ function reasonFor(code: string | undefined, status: number): PrincipalGrantFail
|
|
|
366
419
|
return "invalidPrincipal";
|
|
367
420
|
case "operatorGrants:principalTypeNotSupported":
|
|
368
421
|
return "principalTypeNotSupported";
|
|
422
|
+
// --- owner-brokered grantor path ---
|
|
423
|
+
case "operatorGrants:brokerAssertionRequired":
|
|
424
|
+
return "brokerAssertionRequired";
|
|
425
|
+
case "operatorGrants:brokerAssertionInvalid":
|
|
426
|
+
return "brokerAssertionInvalid";
|
|
427
|
+
// All three are the same class of storefront bug: the signed `tenant` claim and
|
|
428
|
+
// the tenant on the request did not line up. Collapsed so the UI has one story.
|
|
429
|
+
case "operatorGrants:assertionTenantRequired":
|
|
430
|
+
case "operatorGrants:tenantAssertionMismatch":
|
|
431
|
+
case "operatorGrants:tenantRequired":
|
|
432
|
+
return "assertionTenantMismatch";
|
|
433
|
+
case "operatorGrants:tenantNotRegistered":
|
|
434
|
+
return "tenantNotRegistered";
|
|
435
|
+
case "operatorGrants:subjectNotTenantOwner":
|
|
436
|
+
// Storefront never drives the staff-consent-capture branch, so a staff-scope
|
|
437
|
+
// refusal here can only mean the subject reached tot20 as staff rather than as
|
|
438
|
+
// the owner — the same operator-facing story as "you don't own this store".
|
|
439
|
+
case "operatorGrants:staffScopeInsufficient":
|
|
440
|
+
return "subjectNotTenantOwner";
|
|
369
441
|
default:
|
|
370
442
|
if (status === 403 || status === 401) return "notAuthenticated";
|
|
371
443
|
return "error";
|
|
372
444
|
}
|
|
373
445
|
}
|
|
374
446
|
|
|
447
|
+
/**
|
|
448
|
+
* Turn a tot20 refusal into a typed failure. When we RECOGNISE the code we prefer
|
|
449
|
+
* our own operator-facing wording over tot20's — tot20's messages are written for
|
|
450
|
+
* an API integrator ("The broker assertion names a different tenant than the
|
|
451
|
+
* request."), which is noise to a merchant clicking Delegate. An unrecognised code
|
|
452
|
+
* keeps tot20's message, since that is the only signal we have.
|
|
453
|
+
*/
|
|
454
|
+
function failureFrom(
|
|
455
|
+
status: number,
|
|
456
|
+
c: Record<string, unknown>,
|
|
457
|
+
): { status: number; reason: PrincipalGrantFailureReason; message: string; code?: string } {
|
|
458
|
+
const code = typeof c.code === "string" ? c.code : undefined;
|
|
459
|
+
const reason = reasonFor(code, status);
|
|
460
|
+
const upstream = typeof c.message === "string" ? c.message : undefined;
|
|
461
|
+
const message = reason === "error" && upstream ? upstream : defaultMessage(reason, status);
|
|
462
|
+
return { status, reason, message, ...(code ? { code } : {}) };
|
|
463
|
+
}
|
|
464
|
+
|
|
375
465
|
function defaultMessage(reason: PrincipalGrantFailureReason, status: number): string {
|
|
376
466
|
switch (reason) {
|
|
377
467
|
case "notAuthenticated":
|
|
@@ -384,8 +474,17 @@ function defaultMessage(reason: PrincipalGrantFailureReason, status: number): st
|
|
|
384
474
|
return "a valid grantee email is required";
|
|
385
475
|
case "principalTypeNotSupported":
|
|
386
476
|
return "this endpoint grants a human (email) principal only";
|
|
387
|
-
case "
|
|
388
|
-
return "
|
|
477
|
+
case "subjectNotTenantOwner":
|
|
478
|
+
return "only an owner of this store can delegate ship-on-behalf";
|
|
479
|
+
case "tenantNotRegistered":
|
|
480
|
+
return "this store isn't registered with Token of Trust";
|
|
481
|
+
case "brokerAssertionInvalid":
|
|
482
|
+
return "your session proof was rejected — sign in again and retry";
|
|
483
|
+
case "brokerAssertionRequired":
|
|
484
|
+
case "assertionTenantMismatch":
|
|
485
|
+
return "storefront sent a malformed delegation request — please report this";
|
|
486
|
+
case "brokerNotConfigured":
|
|
487
|
+
return "delegation isn't available on this deployment";
|
|
389
488
|
default:
|
|
390
489
|
return `principalGrants: tot20 returned status ${status}`;
|
|
391
490
|
}
|
|
@@ -405,9 +504,7 @@ export function normalizeCreateResponse(status: number, body: unknown): Principa
|
|
|
405
504
|
expiresAt: Number(c.expiresAt ?? 0),
|
|
406
505
|
};
|
|
407
506
|
}
|
|
408
|
-
|
|
409
|
-
const message = typeof c.message === "string" ? c.message : defaultMessage(reason, status);
|
|
410
|
-
return { ok: false, status, reason, message, ...(code ? { code } : {}) };
|
|
507
|
+
return { ok: false, ...failureFrom(status, c) };
|
|
411
508
|
}
|
|
412
509
|
|
|
413
510
|
export function normalizeListResponse(status: number, tenant: string, body: unknown): PrincipalGrantListResult {
|
|
@@ -426,9 +523,7 @@ export function normalizeListResponse(status: number, tenant: string, body: unkn
|
|
|
426
523
|
});
|
|
427
524
|
return { ok: true, tenant, grants };
|
|
428
525
|
}
|
|
429
|
-
|
|
430
|
-
const message = typeof c.message === "string" ? c.message : defaultMessage(reason, status);
|
|
431
|
-
return { ok: false, status, reason, message, ...(code ? { code } : {}) };
|
|
526
|
+
return { ok: false, ...failureFrom(status, c) };
|
|
432
527
|
}
|
|
433
528
|
|
|
434
529
|
export function normalizeRevokeResponse(status: number, tenant: string, body: unknown): PrincipalGrantRevokeResult {
|
|
@@ -438,9 +533,7 @@ export function normalizeRevokeResponse(status: number, tenant: string, body: un
|
|
|
438
533
|
if (isOk) {
|
|
439
534
|
return { ok: true, tenant: String(c.tenant ?? tenant), ref: "" };
|
|
440
535
|
}
|
|
441
|
-
|
|
442
|
-
const message = typeof c.message === "string" ? c.message : defaultMessage(reason, status);
|
|
443
|
-
return { ok: false, status, reason, message, ...(code ? { code } : {}) };
|
|
536
|
+
return { ok: false, ...failureFrom(status, c) };
|
|
444
537
|
}
|
|
445
538
|
|
|
446
539
|
/** Map a typed failure onto the HTTP status a storefront route should return. */
|
|
@@ -452,8 +545,15 @@ export function principalGrantHttpStatus(reason: PrincipalGrantFailureReason, up
|
|
|
452
545
|
case "unknownCapability":
|
|
453
546
|
case "invalidPrincipal":
|
|
454
547
|
case "principalTypeNotSupported":
|
|
548
|
+
case "brokerAssertionRequired":
|
|
549
|
+
case "assertionTenantMismatch":
|
|
550
|
+
case "tenantNotRegistered":
|
|
455
551
|
return 422;
|
|
456
|
-
case "
|
|
552
|
+
case "brokerAssertionInvalid":
|
|
553
|
+
return 401;
|
|
554
|
+
case "subjectNotTenantOwner":
|
|
555
|
+
return 403;
|
|
556
|
+
case "brokerNotConfigured":
|
|
457
557
|
return 503;
|
|
458
558
|
default:
|
|
459
559
|
return upstreamStatus >= 400 ? upstreamStatus : 502;
|
|
@@ -466,16 +566,26 @@ export function principalGrantHttpStatus(reason: PrincipalGrantFailureReason, up
|
|
|
466
566
|
export class MockPrincipalGrantClient implements PrincipalGrantClient {
|
|
467
567
|
private grants = new Map<string, { principal: UserPrincipal; capability: string; expiresAt: number }>();
|
|
468
568
|
private seq = 0;
|
|
469
|
-
/** Tenants
|
|
470
|
-
private readonly
|
|
569
|
+
/** Tenants the signed-in subject does NOT own (tot20's `subjectNotTenantOwner`). */
|
|
570
|
+
private readonly notOwned: Set<string>;
|
|
471
571
|
|
|
472
|
-
constructor(
|
|
473
|
-
this.
|
|
572
|
+
constructor(notOwnedTenants: string[] = []) {
|
|
573
|
+
this.notOwned = new Set(notOwnedTenants.map((t) => t.toLowerCase()));
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
private refuseNotOwner(): PrincipalGrantFailed {
|
|
577
|
+
return {
|
|
578
|
+
ok: false,
|
|
579
|
+
status: 403,
|
|
580
|
+
reason: "subjectNotTenantOwner",
|
|
581
|
+
message: defaultMessage("subjectNotTenantOwner", 403),
|
|
582
|
+
code: "operatorGrants:subjectNotTenantOwner",
|
|
583
|
+
};
|
|
474
584
|
}
|
|
475
585
|
|
|
476
586
|
async createGrant(input: CreatePrincipalGrantInput): Promise<PrincipalGrantResult> {
|
|
477
587
|
const tenant = input.tenant.toLowerCase();
|
|
478
|
-
if (this.
|
|
588
|
+
if (this.notOwned.has(tenant)) return this.refuseNotOwner();
|
|
479
589
|
if (!input.expiresAt || input.expiresAt <= Date.now()) {
|
|
480
590
|
return { ok: false, status: 422, reason: "expiresAtRequired", message: "a future expiresAt is required", code: "operatorGrants:expiresAtRequired" };
|
|
481
591
|
}
|
|
@@ -490,7 +600,7 @@ export class MockPrincipalGrantClient implements PrincipalGrantClient {
|
|
|
490
600
|
|
|
491
601
|
async listGrants(tenant: string): Promise<PrincipalGrantListResult> {
|
|
492
602
|
const t = tenant.toLowerCase();
|
|
493
|
-
if (this.
|
|
603
|
+
if (this.notOwned.has(t)) return this.refuseNotOwner();
|
|
494
604
|
const grants = [...this.grants.entries()]
|
|
495
605
|
.filter(([ref]) => ref.startsWith(`mock-ref-${t}-`))
|
|
496
606
|
.map(([ref, g]) => ({ ...g, ref }));
|
|
@@ -499,7 +609,7 @@ export class MockPrincipalGrantClient implements PrincipalGrantClient {
|
|
|
499
609
|
|
|
500
610
|
async revokeGrant(tenant: string, ref: string): Promise<PrincipalGrantRevokeResult> {
|
|
501
611
|
const t = tenant.toLowerCase();
|
|
502
|
-
if (this.
|
|
612
|
+
if (this.notOwned.has(t)) return this.refuseNotOwner();
|
|
503
613
|
if (!ref.startsWith(`mock-ref-${t}-`) || !this.grants.has(ref)) {
|
|
504
614
|
return { ok: false, status: 404, reason: "error", message: "no such grant" };
|
|
505
615
|
}
|
|
@@ -511,17 +621,28 @@ export class MockPrincipalGrantClient implements PrincipalGrantClient {
|
|
|
511
621
|
// --- Factory ----------------------------------------------------------------
|
|
512
622
|
|
|
513
623
|
/**
|
|
514
|
-
* Build the env-configured client
|
|
515
|
-
*
|
|
516
|
-
*
|
|
624
|
+
* Build the env-configured client:
|
|
625
|
+
* - `TOT_CORE_URL` — tot20 core API base (tier-matching per deploy).
|
|
626
|
+
* - `TOT_API_KEY` (+ optional `TOT_SECRET_KEY`) — storefront's own operator creds.
|
|
627
|
+
*
|
|
628
|
+
* `mintOwnerAssertion` is a REQUIRED positional argument, not an override with a
|
|
629
|
+
* default: there is no safe default. A client built without it would send an
|
|
630
|
+
* unbrokered request that tot20 resolves to storefront's OWN store as grantor —
|
|
631
|
+
* silently granting on the wrong tenant. Making it structural means the caller must
|
|
632
|
+
* hold a verified owner session to build a client at all.
|
|
517
633
|
*/
|
|
518
634
|
export async function createPrincipalGrantClient(
|
|
519
|
-
|
|
635
|
+
mintOwnerAssertion: OwnerAssertionMinter,
|
|
636
|
+
overrides: Partial<Omit<HttpPrincipalGrantClientConfig, "mintOwnerAssertion">> = {},
|
|
520
637
|
): Promise<HttpPrincipalGrantClient> {
|
|
521
638
|
const baseUrl = overrides.baseUrl ?? (await readEnv("TOT_CORE_URL")) ?? "";
|
|
639
|
+
const apiKey = overrides.apiKey ?? (await readEnv("TOT_API_KEY")) ?? "";
|
|
640
|
+
const secretKey = overrides.secretKey ?? (await readEnv("TOT_SECRET_KEY"));
|
|
522
641
|
return new HttpPrincipalGrantClient({
|
|
523
642
|
baseUrl,
|
|
524
|
-
|
|
643
|
+
apiKey,
|
|
644
|
+
mintOwnerAssertion,
|
|
645
|
+
...(secretKey ? { secretKey } : {}),
|
|
525
646
|
...(overrides.transport ? { transport: overrides.transport } : {}),
|
|
526
647
|
});
|
|
527
648
|
}
|