@tokenoftrust/storefront-runner 4.8.0 → 4.9.0

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 (23) hide show
  1. package/apps/storefront/astro.config.mjs +2 -1
  2. package/apps/storefront/dev-plugins/dev-auth-outbox.mjs +181 -0
  3. package/apps/storefront/src/lib/access/auth/devAuth.ts +34 -2
  4. package/apps/storefront/src/lib/access/auth/route.ts +36 -2
  5. package/apps/storefront/src/lib/access/auth/totAccessClient.ts +6 -18
  6. package/apps/storefront/src/lib/access/dashboard/staffStoreEntry.ts +2 -1
  7. package/apps/storefront/src/lib/access/dashboard/staffVendorAccess.ts +6 -10
  8. package/apps/storefront/src/lib/access/shared/accessExplain.ts +106 -28
  9. package/apps/storefront/src/lib/access/shared/developerInvites.ts +40 -38
  10. package/apps/storefront/src/lib/access/shared/invitePreflight.ts +1 -1
  11. package/apps/storefront/src/lib/access/shared/principalGrants.ts +16 -17
  12. package/apps/storefront/src/lib/engagement/messaging/messagesClient.ts +13 -7
  13. package/apps/storefront/src/lib/observability/diag/failureReason.ts +37 -0
  14. package/apps/storefront/src/lib/observability/diag/redact.ts +11 -14
  15. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +4 -7
  16. package/apps/storefront/src/pages/api/auth/send.ts +12 -12
  17. package/apps/storefront/src/pages/api/auth/step-up-verify.ts +2 -1
  18. package/apps/storefront/src/pages/api/auth/verify.ts +7 -3
  19. package/apps/storefront/src/pages/auth/login.astro +24 -6
  20. package/apps/storefront/src/pages/auth/magic.astro +4 -7
  21. package/package.json +1 -1
  22. package/packages/public-runtime/src/image-dependencies.ts +16 -5
  23. package/packages/public-runtime/src/image-release-gate.ts +62 -4
@@ -59,6 +59,7 @@ const gitSha = (() => {
59
59
  import preact from "@astrojs/preact";
60
60
  import tailwindcss from "@tailwindcss/vite";
61
61
  import { tenantHotReload } from "./dev-plugins/tenant-hot-reload.mjs";
62
+ import { devAuthOutbox } from "./dev-plugins/dev-auth-outbox.mjs";
62
63
  import { materializeGuard } from "./integrations/materialize-guard.mjs";
63
64
  import { lifecycleHostAsset } from "./integrations/lifecycle-host-asset.mjs";
64
65
  import { googleMeasurementSandboxAsset } from "./integrations/google-measurement-sandbox-asset.mjs";
@@ -133,7 +134,7 @@ export default defineConfig({
133
134
  __CLI_INSTALL_SPEC__: JSON.stringify(cliInstallSpec),
134
135
  __GIT_SHA__: JSON.stringify(gitSha),
135
136
  },
136
- plugins: [tailwindcss(), tenantHotReload()],
137
+ plugins: [tailwindcss(), tenantHotReload(), devAuthOutbox()],
137
138
  optimizeDeps: {
138
139
  // Vite's SSR optimizer can try to prebundle the Cloudflare adapter's
139
140
  // worker entrypoints, then the worker runner looks for a generated
@@ -0,0 +1,181 @@
1
+ // @ts-check
2
+ import { randomBytes, timingSafeEqual } from "node:crypto";
3
+ import { appendFileSync, chmodSync, mkdirSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ // DEV_AUTH=1 local sign-in code delivery. Dev-only (`apply: "serve"`), never reaches
8
+ // the production Worker.
9
+ //
10
+ // The one-time code is minted inside workerd (POST /api/auth/send), which has no host
11
+ // filesystem, so it hands the code to this Node-side endpoint. The endpoint appends it to
12
+ // apps/storefront/.dev-auth/outbox.txt (directory 0700, file 0600, gitignored) — readable
13
+ // only by the developer who owns the checkout. The code and the address are never written
14
+ // to the console or to any response.
15
+ //
16
+ // Wiring: on POST /api/auth/send this plugin stamps the request with the outbox URL and a
17
+ // per-process token (`x-tot-dev-auth-outbox`, `x-tot-dev-auth-outbox-token`). Both headers
18
+ // are overwritten on that route and stripped from every other request, so a browser can
19
+ // neither forge them nor learn the token.
20
+
21
+ export const OUTBOX_PATH = "/__tot/dev-auth-outbox";
22
+ export const OUTBOX_URL_HEADER = "x-tot-dev-auth-outbox";
23
+ export const OUTBOX_TOKEN_HEADER = "x-tot-dev-auth-outbox-token";
24
+ const SEND_ROUTE = "/api/auth/send";
25
+
26
+ const EMAIL_SHAPE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
27
+ const CODE_SHAPE = /^\d{6}$/;
28
+
29
+ /** Default outbox file: apps/storefront/.dev-auth/outbox.txt */
30
+ export const DEFAULT_OUTBOX_FILE = join(
31
+ dirname(fileURLToPath(import.meta.url)),
32
+ "..",
33
+ ".dev-auth",
34
+ "outbox.txt",
35
+ );
36
+
37
+ /** @param {any} req @returns {Promise<string>} */
38
+ function readBody(req) {
39
+ return new Promise((resolve) => {
40
+ let data = "";
41
+ req.on("data", (/** @type {any} */ c) => {
42
+ data += c;
43
+ if (data.length > 16 * 1024) req.destroy();
44
+ });
45
+ req.on("end", () => resolve(data));
46
+ req.on("error", () => resolve(data));
47
+ });
48
+ }
49
+
50
+ /** @param {string | undefined} address */
51
+ export function isLoopback(address) {
52
+ if (!address) return false;
53
+ return address === "::1" || address === "::ffff:127.0.0.1" || address.startsWith("127.");
54
+ }
55
+
56
+ /** @param {string} a @param {string} b */
57
+ function tokenMatches(a, b) {
58
+ const x = Buffer.from(a);
59
+ const y = Buffer.from(b);
60
+ return x.length === y.length && timingSafeEqual(x, y);
61
+ }
62
+
63
+ /**
64
+ * The outbox endpoint as a plain function, so it is testable without a dev server.
65
+ * Returns the HTTP status it answered with.
66
+ *
67
+ * @param {{ outboxFile: string, token: string, log: (line: string) => void, now?: () => Date }} opts
68
+ */
69
+ export function createOutboxHandler({ outboxFile, token, log, now = () => new Date() }) {
70
+ /**
71
+ * @param {{ remoteAddress?: string, headers: Record<string, string | string[] | undefined>, body: string }} input
72
+ * @returns {number}
73
+ */
74
+ return function handle({ remoteAddress, headers, body }) {
75
+ if (!isLoopback(remoteAddress)) return 403;
76
+ const presented = headers[OUTBOX_TOKEN_HEADER];
77
+ if (typeof presented !== "string" || !tokenMatches(presented, token)) return 403;
78
+ /** @type {any} */
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(body || "{}");
82
+ } catch {
83
+ return 400;
84
+ }
85
+ const email = typeof parsed?.email === "string" ? parsed.email : "";
86
+ const code = typeof parsed?.code === "string" ? parsed.code : "";
87
+ const ttl = Number.isInteger(parsed?.ttlSeconds) && parsed.ttlSeconds > 0 ? parsed.ttlSeconds : 0;
88
+ if (!EMAIL_SHAPE.test(email) || !CODE_SHAPE.test(code) || /[\r\n]/.test(email)) return 400;
89
+
90
+ const at = now();
91
+ const expires = ttl ? new Date(at.getTime() + ttl * 1000).toISOString() : "unknown";
92
+ mkdirSync(dirname(outboxFile), { recursive: true, mode: 0o700 });
93
+ chmodSync(dirname(outboxFile), 0o700);
94
+ appendFileSync(
95
+ outboxFile,
96
+ `${at.toISOString()} ${email} code ${code} valid until ${expires} (single use)\n`,
97
+ { mode: 0o600 },
98
+ );
99
+ chmodSync(outboxFile, 0o600);
100
+ log(`[DEV AUTH] sign-in code delivered to ${outboxFile} (owner-only)`);
101
+ return 204;
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Remove any client-supplied outbox headers, then set `set`. Both `headers` and
107
+ * `rawHeaders` are rewritten, because request adapters differ in which one they read.
108
+ *
109
+ * @param {any} req @param {Record<string, string>} set
110
+ */
111
+ export function stampHeaders(req, set) {
112
+ const names = [OUTBOX_URL_HEADER, OUTBOX_TOKEN_HEADER];
113
+ for (const n of names) delete req.headers[n];
114
+ if (Array.isArray(req.rawHeaders)) {
115
+ /** @type {string[]} */
116
+ const kept = [];
117
+ for (let i = 0; i + 1 < req.rawHeaders.length; i += 2) {
118
+ if (!names.includes(String(req.rawHeaders[i]).toLowerCase())) {
119
+ kept.push(req.rawHeaders[i], req.rawHeaders[i + 1]);
120
+ }
121
+ }
122
+ for (const [k, v] of Object.entries(set)) kept.push(k, v);
123
+ req.rawHeaders.length = 0;
124
+ req.rawHeaders.push(...kept);
125
+ }
126
+ for (const [k, v] of Object.entries(set)) req.headers[k] = v;
127
+ }
128
+
129
+ /** @param {any} server @returns {string} */
130
+ function outboxUrl(server) {
131
+ const addr = server.httpServer?.address?.();
132
+ if (!addr || typeof addr === "string") return "";
133
+ const host = addr.family === "IPv6" || addr.family === 6 ? `[${addr.address === "::" ? "::1" : addr.address}]` : addr.address === "0.0.0.0" ? "127.0.0.1" : addr.address;
134
+ return `http://${host}:${addr.port}${OUTBOX_PATH}`;
135
+ }
136
+
137
+ /** @param {{ outboxFile?: string }} [opts] */
138
+ export function devAuthOutbox(opts = {}) {
139
+ const outboxFile = opts.outboxFile ?? DEFAULT_OUTBOX_FILE;
140
+ const token = randomBytes(32).toString("hex");
141
+ const handle = createOutboxHandler({
142
+ outboxFile,
143
+ token,
144
+ // eslint-disable-next-line no-console -- pointer only; never the code or address
145
+ log: (line) => console.log(line),
146
+ });
147
+
148
+ /** @type {any} */
149
+ const plugin = {
150
+ name: "tot:dev-auth-outbox",
151
+ apply: "serve",
152
+ /** @param {any} server */
153
+ configureServer(server) {
154
+ server.middlewares.use(
155
+ async (/** @type {any} */ req, /** @type {any} */ res, /** @type {any} */ next) => {
156
+ const path = (req.url ?? "").split("?")[0];
157
+ if (path === OUTBOX_PATH) {
158
+ if (req.method !== "POST") {
159
+ res.statusCode = 405;
160
+ res.end();
161
+ return;
162
+ }
163
+ const status = handle({
164
+ remoteAddress: req.socket?.remoteAddress,
165
+ headers: req.headers,
166
+ body: await readBody(req),
167
+ });
168
+ res.statusCode = status;
169
+ res.setHeader("cache-control", "no-store");
170
+ res.end();
171
+ return;
172
+ }
173
+ const url = req.method === "POST" && path === SEND_ROUTE ? outboxUrl(server) : "";
174
+ stampHeaders(req, url ? { [OUTBOX_URL_HEADER]: url, [OUTBOX_TOKEN_HEADER]: token } : {});
175
+ next();
176
+ },
177
+ );
178
+ },
179
+ };
180
+ return plugin;
181
+ }
@@ -3,8 +3,10 @@
3
3
  *
4
4
  * On localhost tot20 isn't reachable, so real sign-in/invite emails can't be sent
5
5
  * or verified. This module lets a developer complete sign-in locally: `/api/auth/send`
6
- * prints a one-time code to the server console + stores it here; `/api/auth/verify`
7
- * consumes it and mints a session WITHOUT tot20/JWKS.
6
+ * stores a one-time code here and delivers it to the owner-private local outbox
7
+ * (`apps/storefront/.dev-auth/outbox.txt`, written by `dev-plugins/dev-auth-outbox.mjs`);
8
+ * `/api/auth/verify` consumes it and mints a session WITHOUT tot20/JWKS. The code is
9
+ * never printed to the console or returned in a response.
8
10
  *
9
11
  * SECURITY — this must never exist in a deployed build:
10
12
  * 1. EVERY call site MUST wrap use in `if (import.meta.env.DEV)`. That is a STATIC
@@ -54,6 +56,36 @@ export async function storeDevCode(kv: CodeKv, email: string, code: string): Pro
54
56
  });
55
57
  }
56
58
 
59
+ /** Headers the dev-auth-outbox Vite plugin stamps onto POST /api/auth/send. */
60
+ export const DEV_AUTH_OUTBOX_URL_HEADER = "x-tot-dev-auth-outbox";
61
+ export const DEV_AUTH_OUTBOX_TOKEN_HEADER = "x-tot-dev-auth-outbox-token";
62
+
63
+ /**
64
+ * Hand `code` to the dev server's owner-private outbox. Returns false when the outbox
65
+ * plugin isn't wired (no stamped headers) or the write fails; never throws, and never
66
+ * logs the code or the address.
67
+ */
68
+ export async function deliverDevCode(
69
+ request: Request,
70
+ email: string,
71
+ code: string,
72
+ fetchImpl: typeof fetch = fetch,
73
+ ): Promise<boolean> {
74
+ const url = request.headers.get(DEV_AUTH_OUTBOX_URL_HEADER);
75
+ const token = request.headers.get(DEV_AUTH_OUTBOX_TOKEN_HEADER);
76
+ if (!url || !token) return false;
77
+ try {
78
+ const res = await fetchImpl(url, {
79
+ method: "POST",
80
+ headers: { "content-type": "application/json", [DEV_AUTH_OUTBOX_TOKEN_HEADER]: token },
81
+ body: JSON.stringify({ email, code, ttlSeconds: DEV_AUTH_CODE_TTL_SECONDS }),
82
+ });
83
+ return res.status === 204;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
57
89
  /**
58
90
  * One-time consume: returns true iff `code` matches the stored code for `email`,
59
91
  * deleting it on success so a code can't be replayed.
@@ -171,7 +171,41 @@ export function safeNext(next: string | null | undefined): string {
171
171
  return next;
172
172
  }
173
173
 
174
- /** Redirect back to the login page carrying step-2 state (email/error), no-JS-safe. */
174
+ /**
175
+ * The address being signed in, carried between the login steps. It rides an HttpOnly
176
+ * cookie rather than the redirect URL, so it never lands in access logs, browser
177
+ * history, Referer headers or analytics. Its lifetime matches a sign-in code's.
178
+ */
179
+ export const LOGIN_EMAIL_COOKIE = "tot_login_email";
180
+ export const LOGIN_EMAIL_MAX_AGE_SECONDS = 600;
181
+
182
+ type CookieContext = { cookies: APIContext["cookies"] };
183
+
184
+ export function setLoginEmailCookie(context: CookieContext, email: string): void {
185
+ context.cookies.set(LOGIN_EMAIL_COOKIE, email, {
186
+ httpOnly: true,
187
+ secure: true,
188
+ sameSite: "lax",
189
+ path: "/",
190
+ maxAge: LOGIN_EMAIL_MAX_AGE_SECONDS,
191
+ });
192
+ }
193
+
194
+ /** The in-progress sign-in address, or "" when absent or not email-shaped. */
195
+ export function readLoginEmail(context: CookieContext): string {
196
+ const value = context.cookies.get(LOGIN_EMAIL_COOKIE)?.value ?? "";
197
+ return EMAIL_RE.test(value) ? value : "";
198
+ }
199
+
200
+ export function clearLoginEmailCookie(context: CookieContext): void {
201
+ context.cookies.delete(LOGIN_EMAIL_COOKIE, { path: "/" });
202
+ }
203
+
204
+ /**
205
+ * Redirect back to the login page carrying step-2 state, no-JS-safe. The query holds
206
+ * only finite state (`sent`, `error`, `reason`, `requiredAcr`, `next`); a supplied
207
+ * `email` goes to the HttpOnly login-email cookie, never the URL.
208
+ */
175
209
  export function loginRedirect(
176
210
  context: APIContext,
177
211
  params: {
@@ -189,9 +223,9 @@ export function loginRedirect(
189
223
  requiredAcr?: string;
190
224
  },
191
225
  ): Response {
226
+ if (params.email) setLoginEmailCookie(context, params.email);
192
227
  const q = new URLSearchParams();
193
228
  if (params.sent) q.set("sent", "1");
194
- if (params.email) q.set("email", params.email);
195
229
  if (params.error) q.set("error", params.error);
196
230
  if (params.reason) q.set("reason", params.reason);
197
231
  if (params.requiredAcr) q.set("requiredAcr", params.requiredAcr);
@@ -27,7 +27,6 @@
27
27
  * bodies, response shapes) is contract-fixed.
28
28
  */
29
29
  import { readEnv } from "@/lib/publication/shared/env";
30
- import { diagEmail } from "@/lib/observability/diag/redact";
31
30
  import { FetchHttpTransport, type HttpResponseLike, type HttpTransport } from "@/lib/developer-experience/http/fetchTransport";
32
31
  import { classifyStepUp, type StepUpChallenge, type StepUpObligation } from "./stepUpChallenge.js";
33
32
 
@@ -249,26 +248,15 @@ export class HttpTotAccessClient implements TotAccessClient {
249
248
  {},
250
249
  { email: email.toLowerCase(), ...this.credentialFields() },
251
250
  );
252
- let body: unknown = "(unread)";
251
+ // Finite fields only: no address, domain, or upstream body content.
253
252
  try {
254
- body = await res.json();
255
- } catch {
256
- body = "(non-JSON)";
257
- }
258
- try {
259
- console.info(
260
- "[diag:federated-send]",
261
- JSON.stringify({ email: diagEmail(email.toLowerCase()), status: res.status, body }),
262
- );
253
+ console.info("[diag:federated-send]", JSON.stringify({ status: res.status }));
263
254
  } catch {
264
255
  /* never break */
265
256
  }
266
257
  } catch (e) {
267
258
  try {
268
- console.warn(
269
- "[diag:federated-send] transport error",
270
- e instanceof Error ? e.message : String(e),
271
- );
259
+ console.warn("[diag:federated-send] transport error");
272
260
  } catch {
273
261
  /* never break */
274
262
  }
@@ -333,9 +321,9 @@ export class HttpTotAccessClient implements TotAccessClient {
333
321
  ...this.credentialFields(),
334
322
  },
335
323
  );
336
- } catch (e) {
324
+ } catch {
337
325
  try {
338
- console.warn("[diag:mint] transport error", e instanceof Error ? e.message : String(e));
326
+ console.warn("[diag:mint] transport error");
339
327
  } catch {
340
328
  /* never break */
341
329
  }
@@ -353,7 +341,7 @@ export class HttpTotAccessClient implements TotAccessClient {
353
341
  // (only present on success) is never in this branch, so nothing sensitive leaks.
354
342
  if (!result.ok) {
355
343
  try {
356
- console.warn("[diag:mint]", JSON.stringify({ status: res.status, body }));
344
+ console.warn("[diag:mint]", JSON.stringify({ status: res.status }));
357
345
  } catch {
358
346
  /* never break */
359
347
  }
@@ -21,6 +21,7 @@ import { updateSession } from "@/lib/access/auth/session";
21
21
  import { isStaffSession, buildStaffSelection } from "./staffAdmission";
22
22
  import {
23
23
  createStaffVendorAccessClient,
24
+ staffAccessFailureMessage,
24
25
  staffAccessHttpStatus,
25
26
  StaffVendorAccessClientError,
26
27
  } from "./staffVendorAccess";
@@ -89,7 +90,7 @@ export async function enterStoreAsStaff(input: StaffStoreEntryInput): Promise<St
89
90
  return {
90
91
  ok: false,
91
92
  status: staffAccessHttpStatus(result.reason, result.status),
92
- body: { error: result.message, reason: result.reason, code: result.code },
93
+ body: { error: staffAccessFailureMessage(result.reason), reason: result.reason, code: result.code },
93
94
  };
94
95
  }
95
96
 
@@ -188,9 +188,7 @@ export class HttpStaffVendorAccessClient implements StaffVendorAccessClient {
188
188
  ok: false,
189
189
  status: 0,
190
190
  reason: "error",
191
- message:
192
- "staffVendorAccess: transport failure searching vendors " +
193
- (cause instanceof Error ? cause.message : String(cause)),
191
+ message: "staffVendorAccess: transport failure searching vendors",
194
192
  };
195
193
  }
196
194
  let parsed: unknown;
@@ -228,9 +226,7 @@ export class HttpStaffVendorAccessClient implements StaffVendorAccessClient {
228
226
  ok: false,
229
227
  status: 0,
230
228
  reason: "error",
231
- message:
232
- "staffVendorAccess: transport failure granting access " +
233
- (cause instanceof Error ? cause.message : String(cause)),
229
+ message: "staffVendorAccess: transport failure granting access",
234
230
  };
235
231
  }
236
232
  let parsed: unknown;
@@ -295,9 +291,8 @@ function failure(
295
291
  c: Record<string, unknown>,
296
292
  ): { ok: false; status: number; reason: StaffAccessFailureReason; message: string; code?: string } {
297
293
  const code = typeof c.code === "string" ? c.code : undefined;
298
- const message = typeof c.message === "string" ? c.message : undefined;
299
294
  const reason = reasonFor(code, status);
300
- return { ok: false, status, reason, message: message ?? defaultMessage(reason, status), ...(code ? { code } : {}) };
295
+ return { ok: false, status, reason, message: staffAccessFailureMessage(reason), ...(code ? { code } : {}) };
301
296
  }
302
297
 
303
298
  function reasonFor(code: string | undefined, status: number): StaffAccessFailureReason {
@@ -340,7 +335,8 @@ export function staffAccessHttpStatus(reason: StaffAccessFailureReason, upstream
340
335
  }
341
336
  }
342
337
 
343
- function defaultMessage(reason: StaffAccessFailureReason, status: number): string {
338
+ /** Finite staff-facing copy for a staff-access refusal — the only text a route returns. */
339
+ export function staffAccessFailureMessage(reason: StaffAccessFailureReason): string {
344
340
  switch (reason) {
345
341
  case "notStaff":
346
342
  return "this account doesn't hold ToT-staff access to that vendor";
@@ -351,7 +347,7 @@ function defaultMessage(reason: StaffAccessFailureReason, status: number): strin
351
347
  case "invalid":
352
348
  return "a valid vendor is required";
353
349
  default:
354
- return `staffVendorAccess: tot20 returned status ${status}`;
350
+ return "Token of Trust couldn't complete the staff-access request; try again shortly";
355
351
  }
356
352
  }
357
353
 
@@ -41,19 +41,109 @@ export interface AccessExplanation {
41
41
  steps: ExplainStep[];
42
42
  }
43
43
 
44
- /** The granter-facing verdict. */
44
+ /** The steps tot-mcp's explain can fail on (tot-mcp access-explain-advice.ts `AccessStep`). */
45
+ export const ACCESS_STEPS = [
46
+ "identity",
47
+ "broker",
48
+ "tenant-context",
49
+ "capability-row",
50
+ "row-resolution",
51
+ "repo-provisioned",
52
+ "session-scope",
53
+ "checkout",
54
+ ] as const;
55
+
56
+ export type AccessStep = (typeof ACCESS_STEPS)[number];
57
+ export type VerdictStep = AccessStep | "unknown";
58
+
59
+ /**
60
+ * The reason and fix shown for each failed step. The storefront owns this copy: tot-mcp's
61
+ * own `reason`/`fix` are fixed text today, but the granter's page and its logs must not
62
+ * depend on a remote service never putting identity detail there.
63
+ */
64
+ export const STEP_ADVICE = {
65
+ // Each value is ONE string literal: `"a" + "b"` widens to `string` even under
66
+ // `as const`, which would make StepReason/StepFix free text again.
67
+ identity: {
68
+ reason:
69
+ "This person has no Token of Trust identity enrolled with tot-mcp yet, so no session of theirs can resolve any store.",
70
+ fix: "Have them accept the invite and sign in once (`tot login`, or connect the tot MCP and complete sign-in). If they already have, run identity_link_begin from their session; enrollment did not happen at sign-in.",
71
+ },
72
+ broker: {
73
+ reason: "Token of Trust did not answer the store enumeration for this identity.",
74
+ fix: "Retry in a minute. If it persists, the broker is failing for this identity — file it with this result attached.",
75
+ },
76
+ "tenant-context": {
77
+ reason:
78
+ "Token of Trust does not list this store for this identity: the invite is still pending, or it was accepted under a different email address.",
79
+ fix: "Confirm the invitee accepted from the exact address invited. If they accepted from a different address, resend the invite to the address they sign in with.",
80
+ },
81
+ "capability-row": {
82
+ reason: "No invite record exists for this person on this store.",
83
+ fix: "Resend the invite.",
84
+ },
85
+ "row-resolution": {
86
+ reason:
87
+ "The invite record and the sign-in read disagree about this person — the two paths resolve different identities.",
88
+ fix: "This is a tot-mcp defect, not a setup problem. File it with this result attached.",
89
+ },
90
+ "repo-provisioned": {
91
+ reason: "The store has no provisioned code repository, so there is nothing to check out.",
92
+ fix: "Provision the store repository first (Team page \"Provision now\", or repo_provision).",
93
+ },
94
+ "session-scope": {
95
+ reason:
96
+ "Token of Trust lists the store for this identity, but a fresh session built from that answer does not hold it.",
97
+ fix: "This is a tot-mcp defect, not a setup problem. File it with this result attached.",
98
+ },
99
+ checkout: {
100
+ reason: "A session holds the store, but checkout cannot resolve its repository.",
101
+ fix: "Provision the store repository, or resend the invite so it records the app binding.",
102
+ },
103
+ unknown: {
104
+ reason: "Access does not resolve.",
105
+ fix: "Run access_explain for this developer and store.",
106
+ },
107
+ } as const satisfies Record<VerdictStep, { reason: string; fix: string }>;
108
+
109
+ export type StepReason = (typeof STEP_ADVICE)[VerdictStep]["reason"];
110
+ export type StepFix = (typeof STEP_ADVICE)[VerdictStep]["fix"];
111
+
112
+ /** Why a verification could not be completed; never upstream text. */
113
+ export const UNAVAILABLE_REASONS = [
114
+ "verification is not configured here",
115
+ "the tot-mcp address is malformed",
116
+ "tot-mcp did not answer",
117
+ "tot-mcp refused the verification request",
118
+ "tot-mcp sent an unreadable answer",
119
+ "not verified",
120
+ ] as const;
121
+
122
+ export type UnavailableReason = (typeof UNAVAILABLE_REASONS)[number];
123
+
124
+ const STEPS: ReadonlySet<string> = new Set(ACCESS_STEPS);
125
+
126
+ /** The finite step for a value tot-mcp sent ("unknown" when it is not one we know). */
127
+ export function verdictStepOf(value: unknown): VerdictStep {
128
+ return typeof value === "string" && STEPS.has(value) ? (value as AccessStep) : "unknown";
129
+ }
130
+
131
+ /** The capability a verified invitee holds (the invite always writes this one). */
132
+ const VERIFIED_CAPABILITY = "preview-access";
133
+
134
+ /** The granter-facing verdict. Every string in it is storefront-owned, finite copy. */
45
135
  export type AccessVerification =
46
- | { state: "verified"; capability: string; expiry: "none"; advice: "tot-mcp" }
136
+ | { state: "verified"; capability: typeof VERIFIED_CAPABILITY; expiry: "none"; advice: "tot-mcp" }
47
137
  | {
48
138
  state: "awaiting-acceptance";
49
139
  inviteStatus: "pending" | "accepted";
50
- failedStep: string;
51
- reason: string;
52
- fix: string;
140
+ failedStep: VerdictStep;
141
+ reason: StepReason;
142
+ fix: StepFix;
53
143
  advice: "tot-mcp";
54
144
  }
55
- | { state: "broken"; failedStep: string; reason: string; fix: string; advice: "tot-mcp" }
56
- | { state: "unavailable"; reason: string; advice: "tot-mcp" };
145
+ | { state: "broken"; failedStep: VerdictStep; reason: StepReason; fix: StepFix; advice: "tot-mcp" }
146
+ | { state: "unavailable"; reason: UnavailableReason; advice: "tot-mcp" };
57
147
 
58
148
  /** POST transport seam (injectable for tests). */
59
149
  export interface ExplainTransport {
@@ -88,18 +178,14 @@ export function classifyExplanation(
88
178
  inviteStatus: "pending" | "accepted",
89
179
  ): AccessVerification {
90
180
  if (explanation.verified) {
91
- return {
92
- state: "verified",
93
- capability: explanation.capability ?? "preview-access",
94
- expiry: "none",
95
- advice: "tot-mcp",
96
- };
181
+ return { state: "verified", capability: VERIFIED_CAPABILITY, expiry: "none", advice: "tot-mcp" };
97
182
  }
98
183
  const failed = explanation.steps.find((s) => s.step === explanation.failedStep);
184
+ const failedStep = verdictStepOf(explanation.failedStep);
99
185
  const verdict = {
100
- failedStep: explanation.failedStep ?? "unknown",
101
- reason: explanation.reason ?? "Access does not resolve.",
102
- fix: explanation.fix ?? "Run access_explain for this developer and store.",
186
+ failedStep,
187
+ reason: STEP_ADVICE[failedStep].reason,
188
+ fix: STEP_ADVICE[failedStep].fix,
103
189
  advice: "tot-mcp" as const,
104
190
  };
105
191
  // Accepting on the storefront does not enroll the tot identity — the developer's first
@@ -133,14 +219,10 @@ export async function explainDeveloperAccess(
133
219
  return { state: "unavailable", reason: "the tot-mcp address is malformed", advice: "tot-mcp" };
134
220
  }
135
221
  if (outcome.kind === "transport-error") {
136
- return {
137
- state: "unavailable",
138
- reason: "tot-mcp did not answer: " + outcome.message,
139
- advice: "tot-mcp",
140
- };
222
+ return { state: "unavailable", reason: "tot-mcp did not answer", advice: "tot-mcp" };
141
223
  }
142
224
  if (outcome.kind === "misconfigured" || outcome.kind === "http-error") {
143
- return { state: "unavailable", reason: `tot-mcp answered ${outcome.status}`, advice: "tot-mcp" };
225
+ return { state: "unavailable", reason: "tot-mcp refused the verification request", advice: "tot-mcp" };
144
226
  }
145
227
  const res = outcome;
146
228
  try {
@@ -149,11 +231,7 @@ export async function explainDeveloperAccess(
149
231
  return { state: "unavailable", reason: "tot-mcp sent an unreadable answer", advice: "tot-mcp" };
150
232
  }
151
233
  return classifyExplanation(explanation, inviteStatus);
152
- } catch (cause) {
153
- return {
154
- state: "unavailable",
155
- reason: "tot-mcp did not answer: " + (cause instanceof Error ? cause.message : String(cause)),
156
- advice: "tot-mcp",
157
- };
234
+ } catch {
235
+ return { state: "unavailable", reason: "tot-mcp sent an unreadable answer", advice: "tot-mcp" };
158
236
  }
159
237
  }