@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
@@ -54,9 +54,9 @@
54
54
  */
55
55
  import { readEnv } from "@/lib/publication/shared/env";
56
56
  import { callMcpService } from "@/lib/developer-experience/http/mcpServiceCall";
57
- import { diagEmail } from "@/lib/observability/diag/redact";
58
57
  import { checkStorePreviewReady, type PreflightDecision } from "@/lib/access/shared/invitePreflight";
59
58
  import { explainDeveloperAccess, type AccessVerification } from "@/lib/access/shared/accessExplain";
59
+ import { failureReasonForStatus, type FailureReason } from "@/lib/observability/diag/failureReason";
60
60
 
61
61
  /**
62
62
  * The header carrying the storefront-signed staff BROKER ASSERTION to tot20
@@ -444,8 +444,6 @@ export class HttpDeveloperInviteClient implements DeveloperInviteClient {
444
444
  // the email-send trace). `signInUrlHost` is the top allowlist suspect.
445
445
  logInviteDiag("request", {
446
446
  endpoint: `${this.baseUrl}/api/clients/developerInvites`,
447
- tenant: input.tenant,
448
- email: diagEmail(input.email.toLowerCase()),
449
447
  sendEmail: input.sendEmail ?? "(unset → tot20 default)",
450
448
  hasSignInUrl: !!input.signInUrl,
451
449
  signInUrlHost: safeHost(input.signInUrl),
@@ -460,14 +458,12 @@ export class HttpDeveloperInviteClient implements DeveloperInviteClient {
460
458
  headers,
461
459
  body,
462
460
  );
463
- } catch (cause) {
461
+ } catch {
464
462
  return {
465
463
  ok: false,
466
464
  status: 0,
467
465
  reason: "error",
468
- message:
469
- "developerInvites: transport failure calling tot20 " +
470
- (cause instanceof Error ? cause.message : String(cause)),
466
+ message: "developerInvites: transport failure calling tot20",
471
467
  };
472
468
  }
473
469
 
@@ -486,19 +482,17 @@ export class HttpDeveloperInviteClient implements DeveloperInviteClient {
486
482
  // DIAGNOSTIC: tot20's decision — the crux is `emailed`. `changed:false, emailed:false`
487
483
  // means tot20 authorized + already had the role but did NOT send (the gate we're hunting).
488
484
  logInviteDiag("response", {
489
- tenant: input.tenant,
490
- email: diagEmail(input.email.toLowerCase()),
491
485
  httpStatus: res.status,
492
486
  ok: result.ok,
493
487
  ...(result.ok
494
488
  ? {
495
489
  changed: result.changed,
496
490
  emailed: result.emailed,
497
- emailReason: result.emailReason ?? "(none)",
498
- emailDetail: result.emailDetail,
491
+ hasEmailReason: !!result.emailReason,
492
+ hasEmailDetail: !!result.emailDetail,
499
493
  signInEligible: result.signInEligible,
500
494
  }
501
- : { reason: result.reason, code: result.code }),
495
+ : { reason: result.reason, hasCode: !!result.code }),
502
496
  });
503
497
  return result;
504
498
  }
@@ -529,14 +523,12 @@ export class HttpDeveloperInviteClient implements DeveloperInviteClient {
529
523
  `${this.baseUrl}/api/clients/developerInvites?${q.toString()}`,
530
524
  headers,
531
525
  );
532
- } catch (cause) {
526
+ } catch {
533
527
  return {
534
528
  ok: false,
535
529
  status: 0,
536
530
  reason: "error",
537
- message:
538
- "developerInvites: transport failure listing invites " +
539
- (cause instanceof Error ? cause.message : String(cause)),
531
+ message: "developerInvites: transport failure listing invites",
540
532
  };
541
533
  }
542
534
  let parsed: unknown;
@@ -576,14 +568,12 @@ export class HttpDeveloperInviteClient implements DeveloperInviteClient {
576
568
  `${this.baseUrl}/api/clients/developerInvites/${encodeURIComponent(inviteId)}`,
577
569
  headers,
578
570
  );
579
- } catch (cause) {
571
+ } catch {
580
572
  return {
581
573
  ok: false,
582
574
  status: 0,
583
575
  reason: "error",
584
- message:
585
- "developerInvites: transport failure revoking invite " +
586
- (cause instanceof Error ? cause.message : String(cause)),
576
+ message: "developerInvites: transport failure revoking invite",
587
577
  };
588
578
  }
589
579
  let parsed: unknown;
@@ -801,6 +791,29 @@ export function developerInviteHttpStatus(
801
791
  }
802
792
  }
803
793
 
794
+ /**
795
+ * Client-facing copy for a developer-invite failure. Routes answer with this, never with
796
+ * the result's `message` (which can carry tot20's own text).
797
+ */
798
+ export function developerInviteFailureMessage(reason: DeveloperInviteFailureReason): string {
799
+ switch (reason) {
800
+ case "notGranted":
801
+ return "storefront isn't authorized to manage developers on this vendor";
802
+ case "notAuthenticated":
803
+ return "storefront's operator credential was rejected";
804
+ case "tenantRequired":
805
+ return "a target vendor is required";
806
+ case "invalidEmail":
807
+ return "a valid developer email is required";
808
+ case "tenantNotFound":
809
+ return "this vendor isn't a resolvable Token of Trust app";
810
+ case "storeNotProvisioned":
811
+ return "this vendor's store repo isn't provisioned on this environment yet";
812
+ default:
813
+ return "Token of Trust couldn't complete the request; try again shortly";
814
+ }
815
+ }
816
+
804
817
  function defaultMessage(
805
818
  reason: DeveloperInviteFailureReason,
806
819
  status: number,
@@ -1108,9 +1121,9 @@ export async function recordDeveloperAppBinding(
1108
1121
  export type AcceptInviteResult =
1109
1122
  | { accepted: true; changed: boolean }
1110
1123
  // Not attempted (seam unconfigured) or no invite to accept — not a hard failure.
1111
- | { accepted: false; skipped: true; reason: string }
1124
+ | { accepted: false; skipped: true; reason: "no-tenant-or-email" | "not-configured" | "no-invite-for-email" }
1112
1125
  // Attempted but failed for a real reason (auth / store fault / transport).
1113
- | { accepted: false; skipped: false; reason: string };
1126
+ | { accepted: false; skipped: false; reason: FailureReason };
1114
1127
 
1115
1128
  /**
1116
1129
  * Mark a developer invite ACCEPTED at the magic-link confirm (#8425 accept-before-
@@ -1167,19 +1180,9 @@ export async function acceptDeveloperInvite(
1167
1180
  if (res.status === 404) {
1168
1181
  return { accepted: false, skipped: true, reason: "no-invite-for-email" };
1169
1182
  }
1170
- return {
1171
- accepted: false,
1172
- skipped: false,
1173
- reason: `tot20 accept returned status ${res.status}`,
1174
- };
1175
- } catch (cause) {
1176
- return {
1177
- accepted: false,
1178
- skipped: false,
1179
- reason:
1180
- "developerInvites accept transport failure: " +
1181
- (cause instanceof Error ? cause.message : String(cause)),
1182
- };
1183
+ return { accepted: false, skipped: false, reason: failureReasonForStatus(res.status) };
1184
+ } catch {
1185
+ return { accepted: false, skipped: false, reason: "transport" };
1183
1186
  }
1184
1187
  }
1185
1188
 
@@ -1258,9 +1261,8 @@ export async function inviteDeveloperToTenant(
1258
1261
  const preflight = deps.preflight ?? checkStorePreviewReady;
1259
1262
  const ready = await preflight(input.tenant);
1260
1263
  if (ready.block) {
1261
- console.warn(
1262
- `[developerInvites] invite BLOCKED by preflight for ${input.tenant}: ${ready.reason}`,
1263
- );
1264
+ // Finite fields only: no tenant, no free-form preflight reason.
1265
+ console.warn("[developerInvites] invite blocked by preflight");
1264
1266
  return {
1265
1267
  invite: {
1266
1268
  ok: false,
@@ -82,7 +82,7 @@ export async function checkStorePreviewReady(
82
82
  if (outcome.kind === "not-configured") return { block: false, reason: "not-configured" };
83
83
  if (outcome.kind === "bad-url") return { block: false, reason: "bad-mcp-url" };
84
84
  if (outcome.kind === "transport-error") {
85
- return { block: false, reason: "preflight request failed: " + outcome.message };
85
+ return { block: false, reason: "preflight request failed" };
86
86
  }
87
87
  if (outcome.kind === "misconfigured") {
88
88
  return { block: false, reason: `preflight misconfigured: status ${outcome.status}` };
@@ -76,7 +76,6 @@
76
76
  * swaps in the Http client with no call-site change.
77
77
  */
78
78
  import { readEnv } from "@/lib/publication/shared/env";
79
- import { diagEmail } from "@/lib/observability/diag/redact";
80
79
  import { BROKER_ASSERTION_HEADER, BROKER_PRIVATE_KEY_ENV } from "@/lib/access/auth/brokerAssertion";
81
80
 
82
81
  /** A human grantee — the only principal type this HTTP surface accepts. */
@@ -285,8 +284,6 @@ export class HttpPrincipalGrantClient implements PrincipalGrantClient {
285
284
 
286
285
  logGrantDiag("request", {
287
286
  endpoint: `${this.baseUrl}/api/clients/principalGrants`,
288
- tenant: input.tenant,
289
- email: diagEmail(input.principal.email.toLowerCase()),
290
287
  capability: input.capability,
291
288
  expiresAt: input.expiresAt,
292
289
  });
@@ -298,17 +295,18 @@ export class HttpPrincipalGrantClient implements PrincipalGrantClient {
298
295
  this.brokeredHeaders(assertion, false),
299
296
  body,
300
297
  );
301
- } catch (cause) {
302
- return transportFailure("creating principal grant", cause);
298
+ } catch {
299
+ return transportFailure("creating principal grant");
303
300
  }
304
301
  const parsed = await safeJson(res, "creating principal grant");
305
302
  if ("failed" in parsed) return parsed.failed;
306
303
  const result = normalizeCreateResponse(res.status, parsed.body);
307
304
  logGrantDiag("response", {
308
- tenant: input.tenant,
309
305
  httpStatus: res.status,
310
306
  ok: result.ok,
311
- ...(result.ok ? { ref: result.ref } : { reason: result.reason, code: result.code }),
307
+ ...(result.ok
308
+ ? { hasRef: !!result.ref }
309
+ : { reason: result.reason, hasCode: !!result.code }),
312
310
  });
313
311
  return result;
314
312
  }
@@ -324,8 +322,8 @@ export class HttpPrincipalGrantClient implements PrincipalGrantClient {
324
322
  `${this.baseUrl}/api/clients/principalGrants?tenant=${encodeURIComponent(tenant)}`,
325
323
  this.brokeredHeaders(assertion, true),
326
324
  );
327
- } catch (cause) {
328
- return transportFailure("listing principal grants", cause);
325
+ } catch {
326
+ return transportFailure("listing principal grants");
329
327
  }
330
328
  const parsed = await safeJson(res, "listing principal grants");
331
329
  if ("failed" in parsed) return parsed.failed;
@@ -345,8 +343,8 @@ export class HttpPrincipalGrantClient implements PrincipalGrantClient {
345
343
  `?tenant=${encodeURIComponent(tenant)}`,
346
344
  this.brokeredHeaders(assertion, true),
347
345
  );
348
- } catch (cause) {
349
- return transportFailure("revoking principal grant", cause);
346
+ } catch {
347
+ return transportFailure("revoking principal grant");
350
348
  }
351
349
  const parsed = await safeJson(res, "revoking principal grant");
352
350
  if ("failed" in parsed) return parsed.failed;
@@ -365,12 +363,12 @@ function brokerNotConfigured(): PrincipalGrantFailed {
365
363
  };
366
364
  }
367
365
 
368
- async function transportFailure(action: string, cause: unknown): Promise<PrincipalGrantFailed> {
366
+ async function transportFailure(action: string): Promise<PrincipalGrantFailed> {
369
367
  return {
370
368
  ok: false,
371
369
  status: 0,
372
370
  reason: "error",
373
- message: `principalGrants: transport failure ${action} ` + (cause instanceof Error ? cause.message : String(cause)),
371
+ message: `principalGrants: transport failure ${action}`,
374
372
  };
375
373
  }
376
374
 
@@ -458,11 +456,12 @@ function failureFrom(
458
456
  const code = typeof c.code === "string" ? c.code : undefined;
459
457
  const reason = reasonFor(code, status);
460
458
  const upstream = typeof c.message === "string" ? c.message : undefined;
461
- const message = reason === "error" && upstream ? upstream : defaultMessage(reason, status);
459
+ const message = reason === "error" && upstream ? upstream : principalGrantFailureMessage(reason);
462
460
  return { status, reason, message, ...(code ? { code } : {}) };
463
461
  }
464
462
 
465
- function defaultMessage(reason: PrincipalGrantFailureReason, status: number): string {
463
+ /** Finite operator-facing copy for a principal-grant refusal — the only text a route returns. */
464
+ export function principalGrantFailureMessage(reason: PrincipalGrantFailureReason): string {
466
465
  switch (reason) {
467
466
  case "notAuthenticated":
468
467
  return "storefront isn't authorized to grant on this tenant";
@@ -486,7 +485,7 @@ function defaultMessage(reason: PrincipalGrantFailureReason, status: number): st
486
485
  case "brokerNotConfigured":
487
486
  return "delegation isn't available on this deployment";
488
487
  default:
489
- return `principalGrants: tot20 returned status ${status}`;
488
+ return "Token of Trust couldn't complete the delegation; try again shortly";
490
489
  }
491
490
  }
492
491
 
@@ -578,7 +577,7 @@ export class MockPrincipalGrantClient implements PrincipalGrantClient {
578
577
  ok: false,
579
578
  status: 403,
580
579
  reason: "subjectNotTenantOwner",
581
- message: defaultMessage("subjectNotTenantOwner", 403),
580
+ message: principalGrantFailureMessage("subjectNotTenantOwner"),
582
581
  code: "operatorGrants:subjectNotTenantOwner",
583
582
  };
584
583
  }
@@ -16,6 +16,10 @@
16
16
  * Built like `@/lib/grants/developerInvites` (interface + Http impl + Mock + env factory).
17
17
  */
18
18
  import { readEnv } from "@/lib/publication/shared/env";
19
+ import {
20
+ failureReasonForStatus,
21
+ type FailureReason,
22
+ } from "@/lib/observability/diag/failureReason";
19
23
 
20
24
  export interface InlineEmail {
21
25
  /** Recipient address. */
@@ -26,9 +30,13 @@ export interface InlineEmail {
26
30
  html: string;
27
31
  }
28
32
 
33
+ /**
34
+ * `reason` is a finite category. Upstream failure text (exception messages, tot20's
35
+ * `message`/`reason` fields) can echo the recipient address, so it never leaves this client.
36
+ */
29
37
  export type MessageSendResult =
30
38
  | { sent: true; id?: string }
31
- | { sent: false; status: number; reason: string };
39
+ | { sent: false; status: number; reason: FailureReason };
32
40
 
33
41
  export interface MessagesClient {
34
42
  sendInlineEmail(msg: InlineEmail): Promise<MessageSendResult>;
@@ -97,14 +105,14 @@ export class HttpMessagesClient implements MessagesClient {
97
105
  ...this.credentialFields(),
98
106
  },
99
107
  );
100
- } catch (cause) {
101
- return { sent: false, status: 0, reason: cause instanceof Error ? cause.message : String(cause) };
108
+ } catch {
109
+ return { sent: false, status: 0, reason: "transport" };
102
110
  }
103
111
  let body: unknown;
104
112
  try {
105
113
  body = await res.json();
106
114
  } catch {
107
- return { sent: false, status: res.status, reason: `non-JSON body (status ${res.status})` };
115
+ return { sent: false, status: res.status, reason: "non-json" };
108
116
  }
109
117
  return normalizeSend(res.status, body);
110
118
  }
@@ -117,9 +125,7 @@ export function normalizeSend(status: number, body: unknown): MessageSendResult
117
125
  if (c.sent === true || (status >= 200 && status < 300 && c.sent !== false)) {
118
126
  return { sent: true, ...(typeof c.id === "string" ? { id: c.id } : {}) };
119
127
  }
120
- const reason =
121
- typeof c.message === "string" ? c.message : typeof c.reason === "string" ? c.reason : `send failed (status ${status})`;
122
- return { sent: false, status, reason };
128
+ return { sent: false, status, reason: failureReasonForStatus(status) };
123
129
  }
124
130
 
125
131
  // --- Mock (tests + local dev) -----------------------------------------------
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The finite set of failure categories a diagnostic line or client-facing response may
3
+ * carry. Upstream failure text (exception messages, response bodies, remote `message` /
4
+ * `reason` fields) can hold addresses, tokens or tenant detail, so it is mapped to one of
5
+ * these categories at the producer and never forwarded verbatim.
6
+ */
7
+ export const FAILURE_REASONS = [
8
+ "transport",
9
+ "non-json",
10
+ "unauthorized",
11
+ "not-found",
12
+ "rate-limited",
13
+ "rejected",
14
+ "upstream-error",
15
+ "not-configured",
16
+ "unknown",
17
+ ] as const;
18
+
19
+ export type FailureReason = (typeof FAILURE_REASONS)[number];
20
+
21
+ const KNOWN: ReadonlySet<string> = new Set(FAILURE_REASONS);
22
+
23
+ export function isFailureReason(value: unknown): value is FailureReason {
24
+ return typeof value === "string" && KNOWN.has(value);
25
+ }
26
+
27
+ /** Category for an HTTP status (0 = no response reached us). */
28
+ export function failureReasonForStatus(status: number): FailureReason {
29
+ if (status === 0) return "transport";
30
+ if (status === 401 || status === 403) return "unauthorized";
31
+ if (status === 404) return "not-found";
32
+ if (status === 429) return "rate-limited";
33
+ if (status >= 400 && status < 500) return "rejected";
34
+ if (status >= 500 && status < 600) return "upstream-error";
35
+ if (status >= 200 && status < 300) return "rejected";
36
+ return "unknown";
37
+ }
@@ -1,20 +1,17 @@
1
1
  /**
2
- * Redaction helpers for diagnostic logs — keep traces correlatable without persisting PII.
2
+ * Redaction helpers for diagnostic logs. An email address never appears in a diagnostic
3
+ * line in any build: not the local part, not its first character, not the domain.
4
+ * Correlate with an opaque reference (for example `hashActorId`) instead.
3
5
  */
4
6
 
5
- /** Mask an email for logs: keep the first char + the domain (`d***@example.com`). */
6
- export function maskEmail(email: string): string {
7
- const at = email.indexOf("@");
8
- if (at <= 0) return "***";
9
- return `${email[0]}***${email.slice(at)}`;
7
+ export const REDACTED_EMAIL = "[redacted-email]";
8
+
9
+ /** A log-safe stand-in for an email address. Reveals nothing about the input. */
10
+ export function maskEmail(_email: string): string {
11
+ return REDACTED_EMAIL;
10
12
  }
11
13
 
12
- /**
13
- * Email for a diagnostic line: the FULL address in local dev only (`import.meta.env.DEV`
14
- * is a static boolean → this branch is tree-shaken from the production worker), where logs
15
- * are local + gitignored and the raw address is needed for debugging; MASKED everywhere the
16
- * build is deployed, so prod/worker logs never persist raw recipient PII.
17
- */
18
- export function diagEmail(email: string): string {
19
- return import.meta.env.DEV ? email : maskEmail(email);
14
+ /** Email for a diagnostic line. Fully redacted in local dev and deployed builds alike. */
15
+ export function diagEmail(_email: string): string {
16
+ return REDACTED_EMAIL;
20
17
  }
@@ -116,8 +116,9 @@ export async function POST(context: APIContext): Promise<Response> {
116
116
  const claims = await verifier.verify(idToken); // enforces aud === STOREFRONT_APP_DOMAIN
117
117
  derived = sessionFromFederatedClaims(claims);
118
118
  } catch (err) {
119
+ // The error class only: a verifier message can quote token claims (the address).
119
120
  console.error("auth/magic-exchange: id_token verification failed; failing closed", {
120
- error: err instanceof Error ? err.message : String(err),
121
+ errorClass: err instanceof Error ? err.name : typeof err,
121
122
  });
122
123
  return magicRedirect(context, { error: "verify_failed", tenant, emailHint, next });
123
124
  }
@@ -148,14 +149,11 @@ export async function POST(context: APIContext): Promise<Response> {
148
149
  try {
149
150
  const accepted = await acceptDeveloperInvite(tenant, derived.email);
150
151
  if (!accepted.accepted && !accepted.skipped) {
151
- console.warn("auth/magic-exchange: invite accept did not record", {
152
- tenant,
153
- reason: accepted.reason,
154
- });
152
+ console.warn("auth/magic-exchange: invite accept did not record", { reason: accepted.reason });
155
153
  }
156
154
  } catch (err) {
157
155
  console.warn("auth/magic-exchange: invite accept threw (swallowed)", {
158
- error: err instanceof Error ? err.message : String(err),
156
+ errorClass: err instanceof Error ? err.name : typeof err,
159
157
  });
160
158
  }
161
159
 
@@ -167,7 +165,6 @@ export async function POST(context: APIContext): Promise<Response> {
167
165
  const verification = await explainDeveloperAccess(tenant, derived.email, "accepted");
168
166
  if (verification.state === "broken") {
169
167
  console.warn("auth/magic-exchange: accepted invite does not resolve", {
170
- tenant,
171
168
  failedStep: verification.failedStep,
172
169
  });
173
170
  }
@@ -20,7 +20,7 @@ import {
20
20
  devAuthEnabled,
21
21
  devAuthCode,
22
22
  storeDevCode,
23
- DEV_AUTH_CODE_TTL_SECONDS,
23
+ deliverDevCode,
24
24
  } from "@/lib/access/auth/devAuth";
25
25
 
26
26
  export const prerender = false;
@@ -46,22 +46,22 @@ export async function POST(context: APIContext): Promise<Response> {
46
46
  // dev guards in middleware/index.ts). It NEVER weakens the hosted path.
47
47
  //
48
48
  // On localhost tot20 isn't reachable, so no email can be sent. When a developer
49
- // opts in with `DEV_AUTH=1` in `.dev.vars`, we instead mint a one-time code,
50
- // store it in the SESSION KV, and PRINT it to the server console so the developer
51
- // can complete sign-in locally. (The verify side that ACCEPTS this code + mints a
52
- // session without tot20 is the security-sensitive half — see the handoff
53
- // `2026-07-09-storefront-local-dev-auth-mode.md`; until it lands, the code is
54
- // stored + printed but sign-in completes only once that verify path is wired.)
49
+ // opts in with `DEV_AUTH=1` in `.dev.vars`, we instead mint a one-time code, store
50
+ // it in the SESSION KV, and deliver it to the owner-private local outbox
51
+ // (`apps/storefront/.dev-auth/outbox.txt`). The code and address never reach the
52
+ // console or the response. /api/auth/verify consumes it (single use, 10 minutes).
55
53
  if (import.meta.env.DEV && (await devAuthEnabled())) {
56
54
  const kv = await readKv("SESSION");
57
55
  if (kv) {
58
56
  const code = devAuthCode();
59
57
  await storeDevCode(kv, email, code);
60
- // eslint-disable-next-line no-console -- dev-only server-console code delivery
61
- console.log(
62
- `\n 🔑 [DEV AUTH] sign-in code for ${email}: ${code}\n` +
63
- ` valid ${DEV_AUTH_CODE_TTL_SECONDS / 60}m · printed here only (no email in local dev)\n`,
64
- );
58
+ if (!(await deliverDevCode(context.request, email, code))) {
59
+ // eslint-disable-next-line no-console -- dev-only diagnostic; no code, no address
60
+ console.warn(
61
+ "[DEV AUTH] could not deliver the sign-in code to the local outbox " +
62
+ "(is the dev-auth-outbox plugin registered in astro.config.mjs?).",
63
+ );
64
+ }
65
65
  } else {
66
66
  // eslint-disable-next-line no-console -- dev-only diagnostic
67
67
  console.warn(
@@ -94,8 +94,9 @@ export async function POST(context: APIContext): Promise<Response> {
94
94
  const claims = await verifier.verify(result.idToken); // enforces aud === STOREFRONT_APP_DOMAIN
95
95
  derived = sessionFromFederatedClaims(claims);
96
96
  } catch (err) {
97
+ // The error class only: a verifier message can quote token claims (the address).
97
98
  console.error("auth/step-up-verify: id_token verification failed; failing closed", {
98
- error: err instanceof Error ? err.message : String(err),
99
+ errorClass: err instanceof Error ? err.name : typeof err,
99
100
  });
100
101
  return loginRedirect(context, {
101
102
  sent: true,
@@ -19,6 +19,7 @@ import {
19
19
  getAccessClient,
20
20
  getSessionStore,
21
21
  setSessionCookie,
22
+ clearLoginEmailCookie,
22
23
  safeNext,
23
24
  loginRedirect,
24
25
  } from "@/lib/access/auth/route";
@@ -51,8 +52,8 @@ export async function POST(context: APIContext): Promise<Response> {
51
52
  // LOCAL DEV AUTH (verify side). `import.meta.env.DEV` is a STATIC boolean — this
52
53
  // whole branch (and the devAuth imports) are tree-shaken out of the production
53
54
  // build, so the hosted path below (offline JWKS verification, fail-closed) is
54
- // never weakened. Second gate: the DEV_AUTH opt-in. Consume the console-printed
55
- // code stored by /api/auth/send and mint a local session WITHOUT tot20 — the
55
+ // never weakened. Second gate: the DEV_AUTH opt-in. Consume the code stored by
56
+ // /api/auth/send (delivered to the private local outbox) and mint a local session WITHOUT tot20 — the
56
57
  // localhost completion of the invite→sign-in loop. See @/lib/auth/devAuth.
57
58
  if (import.meta.env.DEV && (await devAuthEnabled())) {
58
59
  const store = await getSessionStore();
@@ -72,6 +73,7 @@ export async function POST(context: APIContext): Promise<Response> {
72
73
  nowSeconds,
73
74
  );
74
75
  setSessionCookie(context, id, DEFAULT_SESSION_TTL_SECONDS);
76
+ clearLoginEmailCookie(context);
75
77
  return context.redirect(next, 303);
76
78
  }
77
79
 
@@ -127,8 +129,9 @@ export async function POST(context: APIContext): Promise<Response> {
127
129
  // Bind EVERY membership — no single-tenant scoping (multi-vendor picker input).
128
130
  derived = sessionFromFederatedClaims(claims);
129
131
  } catch (err) {
132
+ // The error class only: a verifier message can quote token claims (the address).
130
133
  console.error("auth/verify: federated id_token verification failed; failing closed", {
131
- error: err instanceof Error ? err.message : String(err),
134
+ errorClass: err instanceof Error ? err.name : typeof err,
132
135
  });
133
136
  return loginRedirect(context, { sent: true, email, error: "verify_failed", next });
134
137
  }
@@ -155,5 +158,6 @@ export async function POST(context: APIContext): Promise<Response> {
155
158
  nowSeconds,
156
159
  );
157
160
  setSessionCookie(context, id, DEFAULT_SESSION_TTL_SECONDS);
161
+ clearLoginEmailCookie(context);
158
162
  return context.redirect(next, 303);
159
163
  }
@@ -11,7 +11,12 @@
11
11
  * reach the browser. Inline style carries the per-request CSP nonce.
12
12
  */
13
13
  import { normalizeEmailHint } from "@/lib/access/privacy/emailHint";
14
- import { safeNext } from "@/lib/access/auth/route";
14
+ import {
15
+ EMAIL_RE,
16
+ safeNext,
17
+ readLoginEmail,
18
+ setLoginEmailCookie,
19
+ } from "@/lib/access/auth/route";
15
20
  // Self-hosted web fonts (audit finding 6) — same-origin @fontsource, replacing
16
21
  // the render-blocking fonts.googleapis.com stylesheet. Covers Plus Jakarta Sans,
17
22
  // Inter, and JetBrains Mono used by this page's inline styles.
@@ -21,8 +26,19 @@ export const prerender = false;
21
26
 
22
27
  const cspNonce = Astro.locals.cspNonce;
23
28
  const url = new URL(Astro.request.url);
29
+ // The in-progress address rides the HttpOnly login-email cookie, never the URL. A
30
+ // legacy `?email=` link is canonicalized: the address moves to the cookie and the
31
+ // page reloads without it, so it doesn't linger in history or Referer headers.
32
+ if (url.searchParams.has("email")) {
33
+ const legacyEmail = (url.searchParams.get("email") ?? "").trim().toLowerCase();
34
+ if (EMAIL_RE.test(legacyEmail)) setLoginEmailCookie(Astro, legacyEmail);
35
+ const canonical = new URLSearchParams(url.searchParams);
36
+ canonical.delete("email");
37
+ const qs = canonical.toString();
38
+ return Astro.redirect(`/auth/login${qs ? `?${qs}` : ""}`, 302);
39
+ }
24
40
  const sent = url.searchParams.get("sent") === "1";
25
- const email = (url.searchParams.get("email") ?? "").trim();
41
+ const email = readLoginEmail(Astro);
26
42
  const emailHint = normalizeEmailHint(url.searchParams.get("email_hint")) ?? "";
27
43
  const errorCode = url.searchParams.get("error");
28
44
  // Same open-redirect chokepoint the verify endpoint + login gate use — never a
@@ -56,7 +72,8 @@ const errorMessage = errorCode ? (ERROR_MESSAGES[errorCode] ?? "Something went w
56
72
  // A stale/failed code should drop back to the email step so a new code can be requested.
57
73
  const showCodeStep = sent && errorCode !== "expired" && errorCode !== "too_many_attempts";
58
74
  // Local-dev banner (statically dropped from prod). The message depends on the mode:
59
- // DEV_AUTH=1 → the console bypass is active (no email; code printed to the server console);
75
+ // DEV_AUTH=1 → local codes are active (no email; the code goes to the owner-private
76
+ // outbox file named in the dev-server log);
60
77
  // DEV_AUTH=0 → the REAL path is active (a code IS emailed by the configured tot20/qa, but
61
78
  // only to an address that already holds a role). Read the toggle so the banner never lies.
62
79
  const devMode = import.meta.env.DEV;
@@ -235,12 +252,13 @@ if (devMode) {
235
252
  </span>
236
253
  <p class="eyebrow">Token of Trust© · Regulated Storefront</p>
237
254
  {devMode && (devAuthOn ? (
238
- <p class="dev"><strong>Local dev — console bypass</strong> (<code>DEV_AUTH=1</code>). No email is sent;
239
- your sign-in code is printed to the <strong>server console</strong>. Set <code>DEV_AUTH=0</code> to use real emails.</p>
255
+ <p class="dev"><strong>Local dev — private outbox</strong> (<code>DEV_AUTH=1</code>). No email is sent;
256
+ your sign-in code is written to <code>apps/storefront/.dev-auth/outbox.txt</code> (readable only by you).
257
+ Set <code>DEV_AUTH=0</code> to use real emails.</p>
240
258
  ) : (
241
259
  <p class="dev"><strong>Local dev — live email</strong> (<code>DEV_AUTH=0</code>). A code is emailed by the
242
260
  configured tot20 (<code>TOT_CORE_URL</code>), but only to an address that already holds a role. Set
243
- <code>DEV_AUTH=1</code> for offline console codes.</p>
261
+ <code>DEV_AUTH=1</code> for offline local codes.</p>
244
262
  ))}
245
263
  {errorMessage && <p class="err">{errorMessage}</p>}
246
264
 
@@ -9,7 +9,7 @@
9
9
  * the token before the user ever clicked. So: NEVER exchange on GET. The token rides
10
10
  * in a hidden form field and leaves only via the POST.
11
11
  */
12
- import { normalizeEmailHint, redactEmailForHint } from "@/lib/access/privacy/emailHint";
12
+ import { normalizeEmailHint } from "@/lib/access/privacy/emailHint";
13
13
  // Self-hosted web fonts (audit finding 6) — same-origin @fontsource, replacing
14
14
  // the render-blocking fonts.googleapis.com stylesheet. Covers Plus Jakarta Sans,
15
15
  // Inter, and JetBrains Mono used by this page's inline styles.
@@ -20,15 +20,12 @@ export const prerender = false;
20
20
  const cspNonce = Astro.locals.cspNonce;
21
21
  const url = new URL(Astro.request.url);
22
22
  const token = url.searchParams.get("token") ?? "";
23
- const legacyEmail = (url.searchParams.get("email") ?? "").trim(); // legacy display hint only
24
- const emailHint =
25
- normalizeEmailHint(url.searchParams.get("email_hint")) ??
26
- redactEmailForHint(legacyEmail) ??
27
- "";
23
+ // An `email` query parameter is never read, echoed or used to prefill: the address is
24
+ // stripped from the URL and sign-in proceeds on the token alone.
25
+ const emailHint = normalizeEmailHint(url.searchParams.get("email_hint")) ?? "";
28
26
  if (url.searchParams.has("email")) {
29
27
  const canonical = new URLSearchParams(url.searchParams);
30
28
  canonical.delete("email");
31
- if (emailHint) canonical.set("email_hint", emailHint);
32
29
  return Astro.redirect(
33
30
  `/auth/magic${canonical.toString() ? `?${canonical.toString()}` : ""}`,
34
31
  302,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "4.8.0",
3
+ "version": "4.9.0",
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",