@tokenoftrust/storefront-runner 2.0.6 → 2.0.8

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.
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Client behavior for the "Delegate ship-on-behalf" section of
3
+ * /dashboard/<appDomain>/team (handoff 2026-09-03-storefront-owner-grants-a-developer).
4
+ *
5
+ * Served as a STATIC same-origin file (public/), same CSP rationale as
6
+ * dashboard-team.js — see that file's header. Only wires up when the delegation
7
+ * section is present (real member only; absent for a staff-viewed tenant).
8
+ */
9
+ (function () {
10
+ const $ = (id) => document.getElementById(id);
11
+ const body = $("delegations");
12
+ if (!body) return; // section not rendered (staff view) — nothing to wire up
13
+ const tenant = body.dataset.tenant;
14
+
15
+ function showMsg(kind, text) {
16
+ const el = $("delegateMsg");
17
+ el.className = "msg " + kind;
18
+ el.textContent = text;
19
+ }
20
+
21
+ function renderRows(grants) {
22
+ body.replaceChildren();
23
+ if (!grants || !grants.length) {
24
+ const tr = document.createElement("tr");
25
+ const td = document.createElement("td");
26
+ td.colSpan = 3; td.className = "muted";
27
+ td.textContent = "No active delegations.";
28
+ tr.appendChild(td); body.appendChild(tr);
29
+ return;
30
+ }
31
+ for (const g of grants) {
32
+ const tr = document.createElement("tr");
33
+ const dev = document.createElement("td");
34
+ dev.textContent = (g.principal && g.principal.email) || "";
35
+ const exp = document.createElement("td");
36
+ exp.textContent = g.expiresAt ? new Date(g.expiresAt).toLocaleString() : "";
37
+ const act = document.createElement("td");
38
+ act.className = "act";
39
+ const revokeBtn = document.createElement("button");
40
+ revokeBtn.type = "button";
41
+ revokeBtn.className = "rowbtn";
42
+ revokeBtn.textContent = "Revoke";
43
+ revokeBtn.addEventListener("click", () => revoke(g.ref, dev.textContent, revokeBtn));
44
+ act.appendChild(revokeBtn);
45
+ tr.appendChild(dev); tr.appendChild(exp); tr.appendChild(act);
46
+ body.appendChild(tr);
47
+ }
48
+ }
49
+
50
+ function renderError(msg) {
51
+ body.replaceChildren();
52
+ const tr = document.createElement("tr");
53
+ const td = document.createElement("td");
54
+ td.colSpan = 3; td.className = "muted";
55
+ td.textContent = msg;
56
+ tr.appendChild(td); body.appendChild(tr);
57
+ }
58
+
59
+ async function loadDelegations() {
60
+ try {
61
+ const res = await fetch("/api/grants/principal?tenant=" + encodeURIComponent(tenant), {
62
+ headers: { accept: "application/json" },
63
+ });
64
+ const data = await res.json().catch(() => ({}));
65
+ if (res.ok) renderRows(data.grants);
66
+ else renderError(data.error || ("Couldn't load delegations (HTTP " + res.status + ")."));
67
+ } catch (e) {
68
+ renderError("Couldn't load delegations: " + (e && e.message ? e.message : e));
69
+ }
70
+ }
71
+
72
+ async function revoke(ref, email, btn) {
73
+ if (!ref) return;
74
+ if (!confirm("Revoke " + (email || "this developer") + "'s ship-on-behalf access?")) return;
75
+ if (btn) btn.disabled = true;
76
+ try {
77
+ const res = await fetch("/api/grants/principal", {
78
+ method: "DELETE",
79
+ headers: { "content-type": "application/json", accept: "application/json" },
80
+ body: JSON.stringify({ tenant, ref }),
81
+ });
82
+ const data = await res.json().catch(() => ({}));
83
+ if (res.ok) {
84
+ showMsg("ok", "Revoked " + (email || "") + "'s access.");
85
+ loadDelegations();
86
+ } else {
87
+ showMsg("err", data.error || ("Couldn't revoke (HTTP " + res.status + ")."));
88
+ if (btn) btn.disabled = false;
89
+ }
90
+ } catch (e) {
91
+ showMsg("err", "Couldn't revoke: " + (e && e.message ? e.message : e));
92
+ if (btn) btn.disabled = false;
93
+ }
94
+ }
95
+
96
+ async function grant() {
97
+ const email = $("delegateEmail").value.trim();
98
+ if (!email) { showMsg("err", "Enter a developer email."); return; }
99
+ const ttlEl = $("delegateTtl");
100
+ const ttlMs = ttlEl ? Number(ttlEl.value) : 2592000000;
101
+ const btn = $("delegateBtn");
102
+ btn.disabled = true;
103
+ showMsg("", "Granting…");
104
+ try {
105
+ const res = await fetch("/api/grants/principal", {
106
+ method: "POST",
107
+ headers: { "content-type": "application/json", accept: "application/json" },
108
+ body: JSON.stringify({ tenant, email, ttlMs }),
109
+ });
110
+ const data = await res.json().catch(() => ({}));
111
+ if (res.ok) {
112
+ showMsg("ok", "Granted ship-on-behalf to " + email + ".");
113
+ $("delegateEmail").value = "";
114
+ loadDelegations();
115
+ } else {
116
+ showMsg("err", data.error || ("Grant failed (HTTP " + res.status + ")."));
117
+ }
118
+ } catch (e) {
119
+ showMsg("err", "Grant failed: " + (e && e.message ? e.message : e));
120
+ } finally {
121
+ btn.disabled = false;
122
+ }
123
+ }
124
+
125
+ const delegateBtn = $("delegateBtn");
126
+ const emailInput = $("delegateEmail");
127
+ if (delegateBtn && emailInput) {
128
+ delegateBtn.addEventListener("click", grant);
129
+ emailInput.addEventListener("keydown", (e) => { if (e.key === "Enter") grant(); });
130
+ }
131
+ loadDelegations();
132
+ })();
@@ -21,11 +21,25 @@
21
21
  * → 422 developerInvites:{tenantRequired|invalidEmail|tenantNotFound}
22
22
  *
23
23
  * AUTH WIRE (mirrors `@/lib/auth/totAccessClient`): tot20's swagger
24
- * `normalizeSecureUserRequest` extracts machine creds
25
- * from the request BODY fields `totApiKey`/`totSecretKey`, NOT custom `x-tot-*`
26
- * headers. That is the ONLY integration-sensitive spot ({@link credentialFields}).
24
+ * `normalizeSecureUserRequest` accepts machine creds three ways — request BODY
25
+ * fields `totApiKey`/`totSecretKey`, the same fields as QUERY params, or an
26
+ * `Authorization` header carrying a base64 "license key"
27
+ * (`base64("<apiKey>;<secretKey>")`, expanded by tot20's `expandLicenseKey`, which
28
+ * takes PRECEDENCE over the body/query values). Custom `x-tot-*` headers are NOT
29
+ * read. Those are the only integration-sensitive spots
30
+ * ({@link credentialFields}, {@link credentialAuthHeader}).
27
31
  * tot20 wraps every response in a `{ content, metadata }` envelope.
28
32
  *
33
+ * ⚠ CREDENTIALS NEVER GO IN A URL. `POST` carries them in the body; `GET`/`DELETE`
34
+ * have no body, so they use the `Authorization` license-key header — NOT the query
35
+ * string, even though tot20 would accept it there. A query string lands in access
36
+ * logs, proxy logs, browser history and error-tracking payloads, so a credential
37
+ * there is a disclosure with a long tail. tot20 holds this same line deliberately
38
+ * (its staff broker assertions are header-only "so the staff subject never lands in
39
+ * URLs/access logs"); this client must not be the thing that breaks it.
40
+ * `developerInvites.test.ts` asserts no credential value ever appears in a request
41
+ * URL, for any method — keep that guard.
42
+ *
29
43
  * TIER: the host + operator credential are per-DEPLOY env (`TOT_CORE_URL` +
30
44
  * `TOT_API_KEY`/`TOT_SECRET_KEY`) — each tier's deploy is configured with its
31
45
  * tier-matching values (test/qa → https://qa.tokenoftrust.com, prod →
@@ -372,6 +386,29 @@ export class HttpDeveloperInviteClient implements DeveloperInviteClient {
372
386
  return f;
373
387
  }
374
388
 
389
+ /**
390
+ * The body-less counterpart to {@link credentialFields}: the `Authorization`
391
+ * header tot20 accepts for GET/DELETE, so the credential never reaches the URL.
392
+ * tot20's `expandLicenseKey` base64-decodes this value and splits it on `;` into
393
+ * `apiKey;secretKey[;appDomain]`, and `normalizeSecureUserRequest` prefers it over
394
+ * any body/query credential.
395
+ *
396
+ * The encoding is not optional: `expandLicenseKey` returns `undefined` for a PLAIN
397
+ * `a;b` string (it only decodes values that don't already split into 2-3 fields),
398
+ * so the base64 wrapper is what makes tot20 read it at all. Base64's alphabet
399
+ * contains no `;`, so the wrapper can never be mistaken for the plain form.
400
+ *
401
+ * Deliberately duplicated from the sibling `@/lib/shared/principalGrants`
402
+ * (`credentialAuthHeader`) rather than imported, for the same reason the HTTP
403
+ * transport seam is: this client stays self-contained, and enrollment (L1) does not
404
+ * take a dependency on the authorization (L2) client. Each copy is guarded by its
405
+ * own no-credential-in-URL test.
406
+ */
407
+ private credentialAuthHeader(): Record<string, string> {
408
+ const licenseKey = `${this.apiKey};${this.secretKey ?? ""}`;
409
+ return { authorization: Buffer.from(licenseKey, "utf8").toString("base64") };
410
+ }
411
+
375
412
  async createInvite(
376
413
  input: DeveloperInviteInput,
377
414
  ): Promise<DeveloperInviteResult> {
@@ -474,12 +511,13 @@ export class HttpDeveloperInviteClient implements DeveloperInviteClient {
474
511
  message: "developerInvites: transport does not support GET",
475
512
  };
476
513
  }
477
- // GET → creds go in the query string (tot20 reads totApiKey/totSecretKey from
478
- // body OR query; a GET has no body). tenant is a normal query param.
479
- const q = new URLSearchParams({ tenant, ...this.credentialFields() });
514
+ // GET has no body, so the credential rides the Authorization license-key header
515
+ // — NEVER the query string (see the ⚠ note in this file's header). `tenant` is a
516
+ // normal, non-secret query param and stays where it is.
517
+ const q = new URLSearchParams({ tenant });
480
518
  // #8453: the staff actor is proven by the signed
481
519
  // assertion header — NEVER a plaintext email query param.
482
- const headers: Record<string, string> = {};
520
+ const headers: Record<string, string> = { ...this.credentialAuthHeader() };
483
521
  if (brokerAssertion) headers[BROKER_ASSERTION_HEADER] = brokerAssertion;
484
522
  let res: HttpResponseLike;
485
523
  try {
@@ -523,14 +561,15 @@ export class HttpDeveloperInviteClient implements DeveloperInviteClient {
523
561
  message: "developerInvites: transport does not support DELETE",
524
562
  };
525
563
  }
526
- // DELETE has no body — creds ride the query string, matching listInvites.
527
- const q = new URLSearchParams(this.credentialFields());
528
- const headers: Record<string, string> = {};
564
+ // DELETE has no body — the credential rides the Authorization license-key header,
565
+ // matching listInvites. Nothing secret goes in the URL, so there is no query
566
+ // string here at all (inviteId is an opaque id, already path-encoded).
567
+ const headers: Record<string, string> = { ...this.credentialAuthHeader() };
529
568
  if (brokerAssertion) headers[BROKER_ASSERTION_HEADER] = brokerAssertion;
530
569
  let res: HttpResponseLike;
531
570
  try {
532
571
  res = await this.transport.delete(
533
- `${this.baseUrl}/api/clients/developerInvites/${encodeURIComponent(inviteId)}?${q.toString()}`,
572
+ `${this.baseUrl}/api/clients/developerInvites/${encodeURIComponent(inviteId)}`,
534
573
  headers,
535
574
  );
536
575
  } catch (cause) {
@@ -0,0 +1,527 @@
1
+ /**
2
+ * PrincipalGrantClient — the storefront's seam to tot20's principal-keyed grant
3
+ * writer (ADR-0003, #8425): a scoped, revocable `ship-on-behalf` capability
4
+ * issued to a HUMAN (by email), never a team role.
5
+ *
6
+ * This is the AUTHORIZATION (L2) surface — see docs/architecture/grant-surfaces.md
7
+ * ("Let an operator app act on a store" row's owner-consent shape, generalized to
8
+ * a human grantee). It is NOT the enrollment path (`@/lib/shared/developerInvites`,
9
+ * which only ever emits `preview-access`/`blog-contribute` sign-in eligibility) —
10
+ * the two revoke independently and must never be merged.
11
+ *
12
+ * POST /api/clients/principalGrants { principal:{type:'user',email}, capability, expiresAt }
13
+ * → 200 { code:'operatorGrants:principalGrantCreated', tenant, principal, ref, capability, expiresAt }
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 }
18
+ *
19
+ * `expiresAt` (epoch-ms) is REQUIRED for a human grantee — tot20 fails closed
20
+ * with `expiresAtRequired` if omitted (ADR-0003 D4: no standing authority for a
21
+ * human/service principal). `ref` is opaque (never the raw email) and is what
22
+ * `revoke`/list rows key on.
23
+ *
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}.
44
+ *
45
+ * AUTH WIRE: tot20's swagger `normalizeSecureUserRequest` accepts machine creds
46
+ * three ways — request BODY fields `totApiKey`/`totSecretKey`, the same fields as
47
+ * QUERY params, or an `Authorization` header carrying a base64 "license key"
48
+ * (`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
+ *
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.
63
+ *
64
+ * TIER: host + credentials are per-DEPLOY env, same `TOT_CORE_URL` the sibling
65
+ * developerInvites/forge clients read (qa tier is what's live today for this route).
66
+ *
67
+ * Built like `@/lib/shared/developerInvites` (interface + Http impl + Mock + env
68
+ * factory) so the route develops/tests against `MockPrincipalGrantClient` and
69
+ * swaps in the Http client with no call-site change.
70
+ */
71
+ import { readEnv } from "@/lib/env";
72
+ import { diagEmail } from "@/lib/diag/redact";
73
+
74
+ /** A human grantee — the only principal type this HTTP surface accepts. */
75
+ export interface UserPrincipal {
76
+ type: "user";
77
+ email: string;
78
+ }
79
+
80
+ /** `ship-on-behalf` today; kept as a string so a future tot20 vocabulary addition
81
+ * (see `grantCapabilities.CAPABILITIES` in tot20) doesn't need a type change here. */
82
+ export type PrincipalGrantCapability = "ship-on-behalf" | (string & {});
83
+
84
+ export interface CreatePrincipalGrantInput {
85
+ /** Target tenant this grant is issued on (session-derived; never trust a body field). */
86
+ tenant: string;
87
+ principal: UserPrincipal;
88
+ capability: PrincipalGrantCapability;
89
+ /** Epoch-ms; REQUIRED (ADR-0003 D4 — a human grant is never standing authority). */
90
+ expiresAt: number;
91
+ scope?: string;
92
+ purpose?: string;
93
+ }
94
+
95
+ export interface PrincipalGrantCreated {
96
+ ok: true;
97
+ tenant: string;
98
+ principal: UserPrincipal;
99
+ /** Opaque revoke handle — never the raw email. */
100
+ ref: string;
101
+ capability: string;
102
+ expiresAt: number;
103
+ }
104
+
105
+ export type PrincipalGrantFailureReason =
106
+ | "notAuthenticated" // 403/422 — owner credential missing/rejected
107
+ | "expiresAtRequired" // 422 — ADR-0003 D4
108
+ | "unknownCapability" // 422
109
+ | "invalidPrincipal" // 422
110
+ | "principalTypeNotSupported" // 422 — non-'user' principal at this HTTP layer
111
+ | "ownerCredentialNotConfigured" // LOCAL: no owner-credential resolver wired yet (see file header)
112
+ | "error"; // transport / unexpected upstream fault
113
+
114
+ export interface PrincipalGrantFailed {
115
+ ok: false;
116
+ status: number;
117
+ reason: PrincipalGrantFailureReason;
118
+ message: string;
119
+ code?: string;
120
+ }
121
+
122
+ export type PrincipalGrantResult = PrincipalGrantCreated | PrincipalGrantFailed;
123
+
124
+ export interface PrincipalGrantSummary {
125
+ principal: UserPrincipal;
126
+ capability: string;
127
+ expiresAt: number;
128
+ ref: string;
129
+ }
130
+
131
+ export type PrincipalGrantListResult =
132
+ | { ok: true; tenant: string; grants: PrincipalGrantSummary[] }
133
+ | { ok: false; status: number; reason: PrincipalGrantFailureReason; message: string; code?: string };
134
+
135
+ export type PrincipalGrantRevokeResult =
136
+ | { ok: true; tenant: string; ref: string }
137
+ | { ok: false; status: number; reason: PrincipalGrantFailureReason; message: string; code?: string };
138
+
139
+ export interface PrincipalGrantClient {
140
+ createGrant(input: CreatePrincipalGrantInput): Promise<PrincipalGrantResult>;
141
+ listGrants(tenant: string): Promise<PrincipalGrantListResult>;
142
+ revokeGrant(tenant: string, ref: string): Promise<PrincipalGrantRevokeResult>;
143
+ }
144
+
145
+ // --- HTTP transport seam (injected by tests) --------------------------------
146
+
147
+ export interface HttpResponseLike {
148
+ status: number;
149
+ json(): Promise<unknown>;
150
+ }
151
+ export interface HttpTransport {
152
+ post(url: string, headers: Record<string, string>, body: unknown): Promise<HttpResponseLike>;
153
+ get(url: string, headers: Record<string, string>): Promise<HttpResponseLike>;
154
+ delete(url: string, headers: Record<string, string>): Promise<HttpResponseLike>;
155
+ }
156
+
157
+ export class FetchHttpTransport implements HttpTransport {
158
+ async post(url: string, headers: Record<string, string>, body: unknown) {
159
+ return fetch(url, {
160
+ method: "POST",
161
+ headers: { "content-type": "application/json", accept: "application/json", ...headers },
162
+ body: JSON.stringify(body),
163
+ });
164
+ }
165
+ async get(url: string, headers: Record<string, string>) {
166
+ return fetch(url, { method: "GET", headers: { accept: "application/json", ...headers } });
167
+ }
168
+ async delete(url: string, headers: Record<string, string>) {
169
+ return fetch(url, { method: "DELETE", headers: { accept: "application/json", ...headers } });
170
+ }
171
+ }
172
+
173
+ export class PrincipalGrantClientError extends Error {}
174
+
175
+ /**
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).
180
+ */
181
+ export type OwnerCredentialResolver = (
182
+ tenant: string,
183
+ ) => Promise<{ totApiKey: string; totSecretKey?: string } | null>;
184
+
185
+ /**
186
+ * Build the `Authorization` header tot20 accepts for a body-less request (GET /
187
+ * DELETE), so the credential never reaches the URL. tot20's `expandLicenseKey`
188
+ * base64-decodes this value and splits it on `;` into `apiKey;secretKey[;appDomain]`,
189
+ * and `normalizeSecureUserRequest` prefers it over any body/query credential.
190
+ *
191
+ * Note the encoding is not optional: `expandLicenseKey` returns `undefined` for a
192
+ * PLAIN `a;b` string (it only decodes values that don't already split into 2-3
193
+ * fields), so the base64 wrapper is what makes tot20 read it at all. Base64's
194
+ * alphabet contains no `;`, so the wrapper can never be mistaken for the plain form.
195
+ */
196
+ export function credentialAuthHeader(cred: {
197
+ totApiKey: string;
198
+ totSecretKey?: string;
199
+ }): { authorization: string } {
200
+ const licenseKey = `${cred.totApiKey};${cred.totSecretKey ?? ""}`;
201
+ return { authorization: Buffer.from(licenseKey, "utf8").toString("base64") };
202
+ }
203
+
204
+ export interface HttpPrincipalGrantClientConfig {
205
+ /** tot20 core API base (no trailing slash), e.g. https://qa.tokenoftrust.com */
206
+ baseUrl: string;
207
+ resolveOwnerCredential: OwnerCredentialResolver;
208
+ transport?: HttpTransport;
209
+ }
210
+
211
+ export class HttpPrincipalGrantClient implements PrincipalGrantClient {
212
+ private readonly baseUrl: string;
213
+ private readonly resolveOwnerCredential: OwnerCredentialResolver;
214
+ private readonly transport: HttpTransport;
215
+
216
+ constructor(config: HttpPrincipalGrantClientConfig) {
217
+ if (!config.baseUrl) {
218
+ throw new PrincipalGrantClientError("principalGrants: TOT_CORE_URL is not configured");
219
+ }
220
+ this.baseUrl = config.baseUrl.replace(/\/+$/, "");
221
+ this.resolveOwnerCredential = config.resolveOwnerCredential;
222
+ this.transport = config.transport ?? new FetchHttpTransport();
223
+ }
224
+
225
+ async createGrant(input: CreatePrincipalGrantInput): Promise<PrincipalGrantResult> {
226
+ const cred = await this.resolveOwnerCredential(input.tenant);
227
+ if (!cred) return notConfigured();
228
+
229
+ const body: Record<string, unknown> = {
230
+ principal: { type: "user", email: input.principal.email.toLowerCase() },
231
+ capability: input.capability,
232
+ expiresAt: input.expiresAt,
233
+ totApiKey: cred.totApiKey,
234
+ ...(cred.totSecretKey ? { totSecretKey: cred.totSecretKey } : {}),
235
+ };
236
+ if (input.scope) body.scope = input.scope;
237
+ if (input.purpose) body.purpose = input.purpose;
238
+
239
+ logGrantDiag("request", {
240
+ endpoint: `${this.baseUrl}/api/clients/principalGrants`,
241
+ tenant: input.tenant,
242
+ email: diagEmail(input.principal.email.toLowerCase()),
243
+ capability: input.capability,
244
+ expiresAt: input.expiresAt,
245
+ });
246
+
247
+ let res: HttpResponseLike;
248
+ try {
249
+ res = await this.transport.post(`${this.baseUrl}/api/clients/principalGrants`, {}, body);
250
+ } catch (cause) {
251
+ return transportFailure("creating principal grant", cause);
252
+ }
253
+ const parsed = await safeJson(res, "creating principal grant");
254
+ if ("failed" in parsed) return parsed.failed;
255
+ const result = normalizeCreateResponse(res.status, parsed.body);
256
+ logGrantDiag("response", {
257
+ tenant: input.tenant,
258
+ httpStatus: res.status,
259
+ ok: result.ok,
260
+ ...(result.ok ? { ref: result.ref } : { reason: result.reason, code: result.code }),
261
+ });
262
+ return result;
263
+ }
264
+
265
+ async listGrants(tenant: string): Promise<PrincipalGrantListResult> {
266
+ const cred = await this.resolveOwnerCredential(tenant);
267
+ if (!cred) return notConfigured();
268
+ let res: HttpResponseLike;
269
+ try {
270
+ // Credential travels in the Authorization header, never the query string.
271
+ res = await this.transport.get(
272
+ `${this.baseUrl}/api/clients/principalGrants`,
273
+ credentialAuthHeader(cred),
274
+ );
275
+ } catch (cause) {
276
+ return transportFailure("listing principal grants", cause);
277
+ }
278
+ const parsed = await safeJson(res, "listing principal grants");
279
+ if ("failed" in parsed) return parsed.failed;
280
+ return normalizeListResponse(res.status, tenant, parsed.body);
281
+ }
282
+
283
+ async revokeGrant(tenant: string, ref: string): Promise<PrincipalGrantRevokeResult> {
284
+ const cred = await this.resolveOwnerCredential(tenant);
285
+ if (!cred) return notConfigured();
286
+ let res: HttpResponseLike;
287
+ 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.
291
+ res = await this.transport.delete(
292
+ `${this.baseUrl}/api/clients/principalGrants/${encodeURIComponent(ref)}`,
293
+ credentialAuthHeader(cred),
294
+ );
295
+ } catch (cause) {
296
+ return transportFailure("revoking principal grant", cause);
297
+ }
298
+ const parsed = await safeJson(res, "revoking principal grant");
299
+ if ("failed" in parsed) return parsed.failed;
300
+ return normalizeRevokeResponse(res.status, tenant, parsed.body);
301
+ }
302
+ }
303
+
304
+ function notConfigured(): PrincipalGrantFailed {
305
+ return {
306
+ ok: false,
307
+ status: 503,
308
+ reason: "ownerCredentialNotConfigured",
309
+ 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)",
312
+ };
313
+ }
314
+
315
+ async function transportFailure(action: string, cause: unknown): Promise<PrincipalGrantFailed> {
316
+ return {
317
+ ok: false,
318
+ status: 0,
319
+ reason: "error",
320
+ message: `principalGrants: transport failure ${action} ` + (cause instanceof Error ? cause.message : String(cause)),
321
+ };
322
+ }
323
+
324
+ async function safeJson(
325
+ res: HttpResponseLike,
326
+ action: string,
327
+ ): Promise<{ body: unknown } | { failed: PrincipalGrantFailed }> {
328
+ try {
329
+ return { body: await res.json() };
330
+ } catch {
331
+ return {
332
+ failed: {
333
+ ok: false,
334
+ status: res.status,
335
+ reason: "error",
336
+ message: `principalGrants: tot20 returned a non-JSON body ${action} (status ${res.status})`,
337
+ },
338
+ };
339
+ }
340
+ }
341
+
342
+ function logGrantDiag(phase: "request" | "response", fields: Record<string, unknown>): void {
343
+ try {
344
+ console.info(`[diag:principalGrants:${phase}]`, JSON.stringify(fields));
345
+ } catch {
346
+ /* diagnostics must never break the call */
347
+ }
348
+ }
349
+
350
+ function unwrapEnvelope(body: unknown): Record<string, unknown> {
351
+ const raw = (body ?? {}) as Record<string, unknown>;
352
+ return (raw.content && typeof raw.content === "object" ? raw.content : raw) as Record<string, unknown>;
353
+ }
354
+
355
+ function reasonFor(code: string | undefined, status: number): PrincipalGrantFailureReason {
356
+ switch (code) {
357
+ case "operatorGrants:notAuthenticated":
358
+ return "notAuthenticated";
359
+ case "operatorGrants:expiresAtRequired":
360
+ case "operatorGrants:invalidExpiresAt":
361
+ return "expiresAtRequired";
362
+ case "operatorGrants:unknownCapability":
363
+ return "unknownCapability";
364
+ case "operatorGrants:invalidPrincipal":
365
+ case "operatorGrants:principalNotFound":
366
+ return "invalidPrincipal";
367
+ case "operatorGrants:principalTypeNotSupported":
368
+ return "principalTypeNotSupported";
369
+ default:
370
+ if (status === 403 || status === 401) return "notAuthenticated";
371
+ return "error";
372
+ }
373
+ }
374
+
375
+ function defaultMessage(reason: PrincipalGrantFailureReason, status: number): string {
376
+ switch (reason) {
377
+ case "notAuthenticated":
378
+ return "storefront isn't authorized to grant on this tenant";
379
+ case "expiresAtRequired":
380
+ return "a future expiresAt is required for a human grant";
381
+ case "unknownCapability":
382
+ return "unknown capability";
383
+ case "invalidPrincipal":
384
+ return "a valid grantee email is required";
385
+ case "principalTypeNotSupported":
386
+ return "this endpoint grants a human (email) principal only";
387
+ case "ownerCredentialNotConfigured":
388
+ return "storefront has no owner credential configured for this tenant";
389
+ default:
390
+ return `principalGrants: tot20 returned status ${status}`;
391
+ }
392
+ }
393
+
394
+ export function normalizeCreateResponse(status: number, body: unknown): PrincipalGrantResult {
395
+ const c = unwrapEnvelope(body);
396
+ const code = typeof c.code === "string" ? c.code : undefined;
397
+ const isCreated = code === "operatorGrants:principalGrantCreated" || (status >= 200 && status < 300 && typeof c.ref === "string");
398
+ if (isCreated) {
399
+ return {
400
+ ok: true,
401
+ tenant: String(c.tenant ?? ""),
402
+ principal: (c.principal as UserPrincipal) ?? { type: "user", email: "" },
403
+ ref: String(c.ref ?? ""),
404
+ capability: String(c.capability ?? ""),
405
+ expiresAt: Number(c.expiresAt ?? 0),
406
+ };
407
+ }
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 } : {}) };
411
+ }
412
+
413
+ export function normalizeListResponse(status: number, tenant: string, body: unknown): PrincipalGrantListResult {
414
+ const c = unwrapEnvelope(body);
415
+ const code = typeof c.code === "string" ? c.code : undefined;
416
+ const isOk = code === "operatorGrants:principalGrantsOk" || (status >= 200 && status < 300 && Array.isArray(c.grants));
417
+ if (isOk) {
418
+ const grants: PrincipalGrantSummary[] = (Array.isArray(c.grants) ? c.grants : []).map((raw) => {
419
+ const g = (raw ?? {}) as Record<string, unknown>;
420
+ return {
421
+ principal: (g.principal as UserPrincipal) ?? { type: "user", email: "" },
422
+ capability: String(g.capability ?? ""),
423
+ expiresAt: Number(g.expiresAt ?? 0),
424
+ ref: String(g.ref ?? ""),
425
+ };
426
+ });
427
+ return { ok: true, tenant, grants };
428
+ }
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 } : {}) };
432
+ }
433
+
434
+ export function normalizeRevokeResponse(status: number, tenant: string, body: unknown): PrincipalGrantRevokeResult {
435
+ const c = unwrapEnvelope(body);
436
+ const code = typeof c.code === "string" ? c.code : undefined;
437
+ const isOk = code === "operatorGrants:principalGrantRevoked" || (status >= 200 && status < 300);
438
+ if (isOk) {
439
+ return { ok: true, tenant: String(c.tenant ?? tenant), ref: "" };
440
+ }
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 } : {}) };
444
+ }
445
+
446
+ /** Map a typed failure onto the HTTP status a storefront route should return. */
447
+ export function principalGrantHttpStatus(reason: PrincipalGrantFailureReason, upstreamStatus: number): number {
448
+ switch (reason) {
449
+ case "notAuthenticated":
450
+ return 403;
451
+ case "expiresAtRequired":
452
+ case "unknownCapability":
453
+ case "invalidPrincipal":
454
+ case "principalTypeNotSupported":
455
+ return 422;
456
+ case "ownerCredentialNotConfigured":
457
+ return 503;
458
+ default:
459
+ return upstreamStatus >= 400 ? upstreamStatus : 502;
460
+ }
461
+ }
462
+
463
+ // --- Mock (tests + local dev) -----------------------------------------------
464
+
465
+ /** In-memory PrincipalGrantClient — deterministic for route/UI tests. */
466
+ export class MockPrincipalGrantClient implements PrincipalGrantClient {
467
+ private grants = new Map<string, { principal: UserPrincipal; capability: string; expiresAt: number }>();
468
+ private seq = 0;
469
+ /** Tenants with NO owner credential configured (simulates the known gap). */
470
+ private readonly unconfigured: Set<string>;
471
+
472
+ constructor(unconfiguredTenants: string[] = []) {
473
+ this.unconfigured = new Set(unconfiguredTenants.map((t) => t.toLowerCase()));
474
+ }
475
+
476
+ async createGrant(input: CreatePrincipalGrantInput): Promise<PrincipalGrantResult> {
477
+ const tenant = input.tenant.toLowerCase();
478
+ if (this.unconfigured.has(tenant)) return notConfigured();
479
+ if (!input.expiresAt || input.expiresAt <= Date.now()) {
480
+ return { ok: false, status: 422, reason: "expiresAtRequired", message: "a future expiresAt is required", code: "operatorGrants:expiresAtRequired" };
481
+ }
482
+ const email = input.principal.email.trim().toLowerCase();
483
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
484
+ return { ok: false, status: 422, reason: "invalidPrincipal", message: "a valid grantee email is required", code: "operatorGrants:invalidPrincipal" };
485
+ }
486
+ const ref = `mock-ref-${tenant}-${++this.seq}`;
487
+ this.grants.set(ref, { principal: { type: "user", email }, capability: input.capability, expiresAt: input.expiresAt });
488
+ return { ok: true, tenant, principal: { type: "user", email }, ref, capability: input.capability, expiresAt: input.expiresAt };
489
+ }
490
+
491
+ async listGrants(tenant: string): Promise<PrincipalGrantListResult> {
492
+ const t = tenant.toLowerCase();
493
+ if (this.unconfigured.has(t)) return notConfigured();
494
+ const grants = [...this.grants.entries()]
495
+ .filter(([ref]) => ref.startsWith(`mock-ref-${t}-`))
496
+ .map(([ref, g]) => ({ ...g, ref }));
497
+ return { ok: true, tenant: t, grants };
498
+ }
499
+
500
+ async revokeGrant(tenant: string, ref: string): Promise<PrincipalGrantRevokeResult> {
501
+ const t = tenant.toLowerCase();
502
+ if (this.unconfigured.has(t)) return notConfigured();
503
+ if (!ref.startsWith(`mock-ref-${t}-`) || !this.grants.has(ref)) {
504
+ return { ok: false, status: 404, reason: "error", message: "no such grant" };
505
+ }
506
+ this.grants.delete(ref);
507
+ return { ok: true, tenant: t, ref };
508
+ }
509
+ }
510
+
511
+ // --- Factory ----------------------------------------------------------------
512
+
513
+ /**
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.
517
+ */
518
+ export async function createPrincipalGrantClient(
519
+ overrides: Partial<HttpPrincipalGrantClientConfig> = {},
520
+ ): Promise<HttpPrincipalGrantClient> {
521
+ const baseUrl = overrides.baseUrl ?? (await readEnv("TOT_CORE_URL")) ?? "";
522
+ return new HttpPrincipalGrantClient({
523
+ baseUrl,
524
+ resolveOwnerCredential: overrides.resolveOwnerCredential ?? (async () => null),
525
+ ...(overrides.transport ? { transport: overrides.transport } : {}),
526
+ });
527
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.0.6",
3
+ "version": "2.0.8",
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",