@tokenoftrust/storefront-runner 4.7.10 → 4.8.1

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 (37) 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/components/content/storefront/blog/PostGrid.astro +8 -4
  4. package/apps/storefront/src/lib/access/auth/devAuth.ts +34 -2
  5. package/apps/storefront/src/lib/access/auth/route.ts +36 -2
  6. package/apps/storefront/src/lib/access/auth/totAccessClient.ts +6 -18
  7. package/apps/storefront/src/lib/access/dashboard/staffStoreEntry.ts +2 -1
  8. package/apps/storefront/src/lib/access/dashboard/staffVendorAccess.ts +6 -10
  9. package/apps/storefront/src/lib/access/shared/accessExplain.ts +106 -28
  10. package/apps/storefront/src/lib/access/shared/developerInvites.ts +40 -38
  11. package/apps/storefront/src/lib/access/shared/invitePreflight.ts +1 -1
  12. package/apps/storefront/src/lib/access/shared/principalGrants.ts +16 -17
  13. package/apps/storefront/src/lib/content/shared/homeVisualParity.ts +69 -0
  14. package/apps/storefront/src/lib/content/storyblok/content-model.ts +21 -0
  15. package/apps/storefront/src/lib/engagement/messaging/messagesClient.ts +13 -7
  16. package/apps/storefront/src/lib/observability/diag/failureReason.ts +37 -0
  17. package/apps/storefront/src/lib/observability/diag/redact.ts +11 -14
  18. package/apps/storefront/src/lib/orders/admin/orderSource.ts +26 -5
  19. package/apps/storefront/src/lib/orders/admin/services/orders.ts +121 -22
  20. package/apps/storefront/src/lib/orders/admin/viewmodels/orders.ts +7 -0
  21. package/apps/storefront/src/lib/orders/fixtures/orders.ts +2 -0
  22. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +4 -7
  23. package/apps/storefront/src/pages/api/auth/send.ts +12 -12
  24. package/apps/storefront/src/pages/api/auth/step-up-verify.ts +2 -1
  25. package/apps/storefront/src/pages/api/auth/verify.ts +7 -3
  26. package/apps/storefront/src/pages/auth/login.astro +24 -6
  27. package/apps/storefront/src/pages/auth/magic.astro +4 -7
  28. package/apps/storefront/src/pages/index.astro +43 -1
  29. package/package.json +1 -1
  30. package/packages/cli/src/declared-config.mjs +42 -1
  31. package/packages/public-runtime/src/declared-tenant-config.d.mts +4 -1
  32. package/packages/public-runtime/src/declared-tenant-config.mjs +40 -1
  33. package/packages/public-runtime/src/extension-contract-values.d.mts +4 -1
  34. package/packages/public-runtime/src/extension-contract-values.mjs +6 -1
  35. package/packages/public-runtime/src/extension-contract.ts +2 -0
  36. package/packages/public-runtime/src/tenant.ts +6 -0
  37. package/scripts/tenant/validate.mjs +17 -0
@@ -49,13 +49,85 @@ import type {
49
49
  OrderRelease,
50
50
  OrderRow,
51
51
  OrderShipState,
52
+ OrdersTodaySummary,
52
53
  OrdersViewModel,
53
54
  } from "../viewmodels/orders";
54
55
  import type { CheckoutOrderClient } from "../orderClient";
56
+ import { DEFAULT_TIMEZONE } from "@tot/public-runtime/declared-tenant-config";
55
57
 
56
58
  const EVIDENCE_STATES: readonly EvidenceState[] = ["fresh", "not-required", "UNKNOWN", "stale", "unreadable", "absent"];
57
59
  const REFUND_PAYMENTS: ReadonlySet<PaymentState> = new Set(["refunded", "refund_requested", "refund_unknown"]);
58
60
 
61
+ /** `YYYY-MM-DD` of an instant on the store's calendar. `null` when the instant does not parse. */
62
+ export function storeDay(instant: string | Date, timeZone: string): string | null {
63
+ const at = typeof instant === "string" ? new Date(instant) : instant;
64
+ if (Number.isNaN(at.getTime())) return null;
65
+ const parts = new Intl.DateTimeFormat("en-US", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" }).formatToParts(at);
66
+ const part = (type: string) => parts.find((p) => p.type === type)?.value ?? "";
67
+ return `${part("year")}-${part("month")}-${part("day")}`;
68
+ }
69
+
70
+ /** An instant's clock time on the store's clock, e.g. `12:03 PM`. */
71
+ export function formatStoreTime(instant: string, timeZone: string): string {
72
+ const at = new Date(instant);
73
+ if (Number.isNaN(at.getTime())) return "";
74
+ return new Intl.DateTimeFormat("en-US", { timeZone, hour: "numeric", minute: "2-digit" }).format(at);
75
+ }
76
+
77
+ /** An instant's date and clock time on the store's clock, e.g. `Oct 27, 12:03 PM`. */
78
+ export function formatStoreDateTime(instant: string, timeZone: string): string {
79
+ const at = new Date(instant);
80
+ if (Number.isNaN(at.getTime())) return "";
81
+ return new Intl.DateTimeFormat("en-US", { timeZone, month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }).format(at);
82
+ }
83
+
84
+ /** An instant's date on the store's calendar, e.g. `Oct 27`. */
85
+ export function formatStoreDate(instant: string, timeZone: string): string {
86
+ const at = new Date(instant);
87
+ if (Number.isNaN(at.getTime())) return "";
88
+ return new Intl.DateTimeFormat("en-US", { timeZone, month: "short", day: "numeric" }).format(at);
89
+ }
90
+
91
+ /** A store day (`YYYY-MM-DD`) as `Oct 27`. */
92
+ export function formatStoreDay(day: string): string {
93
+ const [y, m, d] = day.split("-").map(Number);
94
+ return new Intl.DateTimeFormat("en-US", { timeZone: "UTC", month: "short", day: "numeric" }).format(new Date(Date.UTC(y!, m! - 1, d!)));
95
+ }
96
+
97
+ /** The Today card when its orders could not be read: no figure is better than a wrong one. */
98
+ export function unknownOrdersToday(timeZone: string = DEFAULT_TIMEZONE): OrdersTodaySummary {
99
+ return { grossSales: 0, paidOrders: 0, averageOrder: 0, refunds: 0, oldestOpenAt: null, day: null, timeZone };
100
+ }
101
+
102
+ /**
103
+ * Totals for the orders placed on the store's current calendar day. `rows` may span any dates;
104
+ * only those whose placement falls on `now`'s day in `timeZone` count. `oldestOpenAt` is not a
105
+ * today figure: it is the oldest order of `rows` that has not shipped, on any day.
106
+ */
107
+ export function summarizeOrdersToday(
108
+ rows: readonly CheckoutOrderSummary[],
109
+ open: readonly CheckoutOrderSummary[] | null,
110
+ now: Date,
111
+ timeZone: string,
112
+ ): OrdersTodaySummary {
113
+ const day = storeDay(now, timeZone);
114
+ const today = rows.filter((r) => day !== null && storeDay(r.placedAt, timeZone) === day);
115
+ const paid = today.filter((r) => r.payment === "captured");
116
+ const cents = (rows: readonly CheckoutOrderSummary[]) => rows.reduce((sum, r) => sum + Math.round(r.total * 100), 0);
117
+ const grossSales = cents(paid) / 100;
118
+ const refunds = cents(today.filter((r) => r.payment === "refunded")) / 100;
119
+ const openAt = (open ?? []).filter((r) => r.fulfillment !== "completed").map((r) => r.placedAt).sort();
120
+ return {
121
+ grossSales,
122
+ paidOrders: paid.length,
123
+ averageOrder: paid.length ? Math.round((grossSales / paid.length) * 100) / 100 : 0,
124
+ refunds,
125
+ oldestOpenAt: openAt[0] ?? null,
126
+ day,
127
+ timeZone,
128
+ };
129
+ }
130
+
59
131
  export interface SourcedState<T extends string = string> {
60
132
  state: T;
61
133
  source: string;
@@ -491,6 +563,7 @@ export function mapOrdersToViewModel(
491
563
  summaries: CheckoutOrderSummary[],
492
564
  details: Record<string, CheckoutOrderDetail>,
493
565
  scope: CommerceScope,
566
+ today: OrdersTodaySummary,
494
567
  ): OrdersViewModel {
495
568
  const rows = summaries.map(mapSummaryToRow);
496
569
  const detailsById: Record<string, OrderDetail> = {};
@@ -509,40 +582,23 @@ export function mapOrdersToViewModel(
509
582
  };
510
583
  }
511
584
 
512
- const paidOrders = rows.filter((r) => r.payment === "captured");
513
- const refunded = rows.filter((r) => r.payment === "refunded");
514
- const grossSales = paidOrders.reduce((sum, r) => sum + r.total, 0);
515
- const refunds = refunded.reduce((sum, r) => sum + r.total, 0);
516
-
517
585
  return {
518
586
  targetTenant,
519
587
  queues: buildQueues(rows),
520
588
  rows,
521
589
  detailsById,
522
- today: {
523
- grossSales,
524
- paidOrders: paidOrders.length,
525
- averageOrder: paidOrders.length ? Math.round((grossSales / paidOrders.length) * 100) / 100 : 0,
526
- refunds,
527
- oldestOpenAt: rows.length ? rows[rows.length - 1]!.placedAt : null,
528
- },
590
+ today,
529
591
  };
530
592
  }
531
593
 
532
- export function emptyOrdersViewModel(targetTenant: string): OrdersViewModel {
594
+ export function emptyOrdersViewModel(targetTenant: string, timeZone: string = DEFAULT_TIMEZONE): OrdersViewModel {
533
595
  const rows: OrderRow[] = [];
534
596
  return {
535
597
  targetTenant,
536
598
  queues: buildQueues(rows),
537
599
  rows,
538
600
  detailsById: {},
539
- today: {
540
- grossSales: 0,
541
- paidOrders: 0,
542
- averageOrder: 0,
543
- refunds: 0,
544
- oldestOpenAt: null,
545
- },
601
+ today: unknownOrdersToday(timeZone),
546
602
  };
547
603
  }
548
604
 
@@ -551,6 +607,9 @@ export interface GetOrdersViewModelOptions {
551
607
  scope: CommerceScope;
552
608
  client: CheckoutOrderClient;
553
609
  filters?: Parameters<CheckoutOrderClient["listOrders"]>[0];
610
+ /** The store's declared IANA time zone (`resolveStoreTimeZone`); its calendar decides what "today" is. */
611
+ timeZone: string;
612
+ now?: Date;
554
613
  }
555
614
 
556
615
  export type OrdersLoadStatus = "loaded" | "empty" | "error" | "not_configured";
@@ -578,6 +637,45 @@ export function parseOrderFilters(params: URLSearchParams): CheckoutOrderListFil
578
637
  return f;
579
638
  }
580
639
 
640
+ /** Most pages one whole-book read follows: 20 x 100 orders. */
641
+ const WHOLE_BOOK_PAGES = 20;
642
+
643
+ type WholeBook = { ok: true; orders: CheckoutOrderSummary[] } | { ok: false; reason: "unreadable" | "too-long" };
644
+
645
+ /**
646
+ * Every order matching `filters`, page after page. Never a partial list: a book longer than
647
+ * `WHOLE_BOOK_PAGES` pages, or a page that cannot be read, is `ok: false`, because a total built
648
+ * from part of the book is the bug this exists to prevent.
649
+ */
650
+ async function listWholeBook(client: CheckoutOrderClient, filters: CheckoutOrderListFilters): Promise<WholeBook> {
651
+ const orders: CheckoutOrderSummary[] = [];
652
+ let cursor: string | undefined;
653
+ for (let page = 0; page < WHOLE_BOOK_PAGES; page++) {
654
+ let read: Awaited<ReturnType<CheckoutOrderClient["listOrders"]>>;
655
+ try {
656
+ read = await client.listOrders({ ...filters, limit: 100, ...(cursor ? { cursor } : {}) });
657
+ } catch {
658
+ return { ok: false, reason: "unreadable" };
659
+ }
660
+ orders.push(...read.orders);
661
+ if (!read.page.nextCursor) return { ok: true, orders };
662
+ cursor = read.page.nextCursor;
663
+ }
664
+ return { ok: false, reason: "too-long" };
665
+ }
666
+
667
+ /** The Today card, read from the whole book so a queue filter or a page boundary never changes it. */
668
+ async function readOrdersToday(client: CheckoutOrderClient, now: Date, timeZone: string): Promise<OrdersTodaySummary> {
669
+ // Two calendar days of slack: a store west of UTC starts its day up to a day behind UTC.
670
+ const since = new Date(now.getTime() - 48 * 60 * 60 * 1000).toISOString();
671
+ const [recent, open] = await Promise.all([
672
+ listWholeBook(client, { from: since }),
673
+ listWholeBook(client, { queue: "unfulfilled" }),
674
+ ]);
675
+ if (!recent.ok) return unknownOrdersToday(timeZone);
676
+ return summarizeOrdersToday(recent.orders, open.ok ? open.orders : null, now, timeZone);
677
+ }
678
+
581
679
  export async function getOrdersViewModelResult(opts: GetOrdersViewModelOptions): Promise<OrdersLoadResult> {
582
680
  try {
583
681
  const { orders, page } = await opts.client.listOrders(opts.filters);
@@ -591,13 +689,14 @@ export async function getOrdersViewModelResult(opts: GetOrdersViewModelOptions):
591
689
  }
592
690
  }),
593
691
  );
692
+ const today = await readOrdersToday(opts.client, opts.now ?? new Date(), opts.timeZone);
594
693
  return {
595
694
  status: orders.length ? "loaded" : "empty",
596
- vm: mapOrdersToViewModel(opts.targetTenant, orders, details, opts.scope),
695
+ vm: mapOrdersToViewModel(opts.targetTenant, orders, details, opts.scope, today),
597
696
  page,
598
697
  };
599
698
  } catch {
600
- return { status: "error", vm: emptyOrdersViewModel(opts.targetTenant), page: null };
699
+ return { status: "error", vm: emptyOrdersViewModel(opts.targetTenant, opts.timeZone), page: null };
601
700
  }
602
701
  }
603
702
 
@@ -110,11 +110,18 @@ export interface OrderQueueSummary {
110
110
  }
111
111
 
112
112
  export interface OrdersTodaySummary {
113
+ /** Captured orders placed on the store's current day. */
113
114
  grossSales: number;
114
115
  paidOrders: number;
115
116
  averageOrder: number;
117
+ /** Refunded orders placed on the store's current day. */
116
118
  refunds: number;
119
+ /** The oldest order that has not shipped, on any day. Null when none is open or the book was not read whole. */
117
120
  oldestOpenAt: string | null;
121
+ /** The store calendar day (`YYYY-MM-DD`) these figures cover; null when the orders could not be read. */
122
+ day: string | null;
123
+ /** The IANA zone that calendar is read in. */
124
+ timeZone: string;
118
125
  }
119
126
 
120
127
  export interface OrdersViewModel {
@@ -177,6 +177,8 @@ function computeOrdersViewModel(envelope: AdminTenantEnvelope): OrdersViewModel
177
177
  averageOrder: paidOrders.length ? Math.round((grossSales / paidOrders.length) * 100) / 100 : 0,
178
178
  refunds: 0,
179
179
  oldestOpenAt,
180
+ day: null,
181
+ timeZone: "UTC",
180
182
  },
181
183
  };
182
184
  }
@@ -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,
@@ -16,6 +16,7 @@ import Img from "@/components/assets/storefront/Img.astro";
16
16
  import PromoTiles from "@/components/content/storefront/content/PromoTiles.astro";
17
17
  import TrustStrip from "@/components/content/storefront/content/TrustStrip.astro";
18
18
  import Faq from "@/components/content/storefront/marketing/Faq.astro";
19
+ import PostGrid from "@/components/content/storefront/blog/PostGrid.astro";
19
20
  import MarketingBlocks from "@/components/content/storefront/marketing/MarketingBlocks.astro";
20
21
  import TierCards from "@/components/membership/storefront/membership/TierCards.astro";
21
22
  import { withBase } from "@/lib/publication/shared/basePath";
@@ -41,11 +42,15 @@ import {
41
42
  editorialBlockView,
42
43
  editorialMediaView,
43
44
  homeBlockId,
45
+ latestPostsView,
44
46
  resolveCommerceStaticBlock,
45
47
  type CommerceStaticResolved,
48
+ type LatestPostsView,
46
49
  } from "@/lib/content/shared/homeVisualParity";
50
+ import { getBlogSettings, listBlogPosts } from "@/lib/content/blog/provider.js";
51
+ import { collectionPathsV1, blogPathOptsV1 } from "@/lib/content/blog/collectionPaths.js";
47
52
 
48
- const { tenant, tot, content, basePath, apiBasePath, canonicalBase, features, capabilities } = Astro.locals;
53
+ const { tenant, tot, content, basePath, apiBasePath, canonicalBase, features, capabilities, publicationTimestamp } = Astro.locals;
49
54
  const marketing = !features.catalog;
50
55
 
51
56
  // Raw-HTML home (vendor-injected): serve the tenant's own self-contained
@@ -109,6 +114,7 @@ type Resolved = (
109
114
  | { kind: "hero"; block: HeroBlock; product: CatalogProduct | null; instanceKey: string }
110
115
  | { kind: "collections"; title?: string; cols: CatalogCollection[] }
111
116
  | { kind: "products"; title?: string; products: CatalogProduct[]; presentation?: FeaturedProductsBlock["presentation"] }
117
+ | { kind: "latest_posts"; view: LatestPostsView }
112
118
  | CommerceStaticResolved
113
119
  ) & { blockId: string };
114
120
 
@@ -124,6 +130,20 @@ async function resolveProducts(
124
130
  );
125
131
  }
126
132
 
133
+ // The blog is read only when a `latest_posts` block asks for it, through the same
134
+ // listing the blog index renders, so this static home and /blog never disagree.
135
+ const wantsLatestPosts = !marketing && home.blocks.some((block) => block.component === "latest_posts");
136
+ const blogPosts = wantsLatestPosts
137
+ ? await listBlogPosts(tenant.tenant_id, content, publicationTimestamp)
138
+ : [];
139
+ const blogPaths = wantsLatestPosts
140
+ ? collectionPathsV1(
141
+ Astro.locals,
142
+ "blog",
143
+ blogPathOptsV1(await getBlogSettings(tenant.tenant_id, content)),
144
+ )
145
+ : null;
146
+
127
147
  const resolved: Resolved[] = [];
128
148
  for (const [index, block] of (marketing ? [] : home.blocks).entries()) {
129
149
  const blockId = homeBlockId(block, index);
@@ -153,6 +173,11 @@ for (const [index, block] of (marketing ? [] : home.blocks).entries()) {
153
173
  resolved.push({ kind: "collections", title: block.title, cols, blockId });
154
174
  break;
155
175
  }
176
+ case "latest_posts": {
177
+ const view = blogPaths ? latestPostsView(block, blogPosts, blogPaths.index, basePath) : null;
178
+ if (view) resolved.push({ kind: "latest_posts", view, blockId });
179
+ break;
180
+ }
156
181
  case "featured_products": {
157
182
  const products = await resolveProducts(
158
183
  block.source,
@@ -364,6 +389,23 @@ const homeSectionWidgets = marketing
364
389
  </Section>
365
390
  );
366
391
  }
392
+ if (r.kind === "latest_posts") {
393
+ return (
394
+ <Section title={r.view.title} eyebrow={r.view.eyebrow} tone="bg" blockId={r.blockId}>
395
+ <PostGrid posts={r.view.posts} basePath={basePath} paths={blogPaths!} headingLevel="h3" eagerCount={0} />
396
+ {r.view.cta && (
397
+ <div class="mt-9 text-center">
398
+ <a
399
+ href={r.view.cta.href}
400
+ class="inline-flex rounded-full bg-[var(--color-primary)] px-6 py-3 font-display text-sm font-medium text-[var(--color-primary-contrast)]"
401
+ >
402
+ {r.view.cta.label}
403
+ </a>
404
+ </div>
405
+ )}
406
+ </Section>
407
+ );
408
+ }
367
409
  if (r.kind === "faq") {
368
410
  return (
369
411
  <div data-block={r.blockId}>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "4.7.10",
3
+ "version": "4.8.1",
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",
@@ -19,7 +19,7 @@
19
19
 
20
20
  export const TOT_CONFIG_KEYS = [
21
21
  "$schema", "tenant", "scope", "mappings", "hostPlatform", "sample", "seed",
22
- "displayName", "siteType", "compliance", "capabilities", "features", "embeds", "seo", "currency", "shipping",
22
+ "displayName", "siteType", "compliance", "capabilities", "features", "embeds", "seo", "currency", "timezone", "shipping",
23
23
  ];
24
24
  // Keys other tooling has written that the platform does NOT read, with the key it does.
25
25
  const RENAMED_CONFIG_KEYS = { appDomain: "scope", tenantId: "tenant" };
@@ -69,6 +69,42 @@ export function currencyDeclarationProblems(currency) {
69
69
  : [`\`currency\` is ${JSON.stringify(currency)} — must be an uppercase ISO 4217 code such as "USD" or "CAD"`];
70
70
  }
71
71
 
72
+ /** The IANA time zone a store's days are counted in when `.tot/config.json` declares none. */
73
+ export const DEFAULT_TIMEZONE = "UTC";
74
+
75
+ /**
76
+ * What is wrong with a declared `timezone`, one sentence; `[]` when it is valid. A valid one is
77
+ * an IANA zone name the runtime's Intl knows, such as "America/Los_Angeles" or "UTC"; a numeric
78
+ * offset is not a zone, so "today" would drift across daylight-saving changes.
79
+ *
80
+ * @param {unknown} timezone
81
+ * @returns {string[]}
82
+ */
83
+ export function timezoneDeclarationProblems(timezone) {
84
+ const named = typeof timezone === "string" && /^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+)*$/.test(timezone);
85
+ if (named) {
86
+ try {
87
+ new Intl.DateTimeFormat("en-US", { timeZone: /** @type {string} */ (timezone) });
88
+ return [];
89
+ } catch {
90
+ /* not a zone the runtime knows */
91
+ }
92
+ }
93
+ return [`\`timezone\` is ${JSON.stringify(timezone)} — must be an IANA time zone name such as "America/Los_Angeles" or "UTC"`];
94
+ }
95
+
96
+ /**
97
+ * The ONE place the time zone a store's days are counted in is resolved. A declared IANA zone
98
+ * wins; anything else (absent, wrong shape, unknown zone) is UTC. "Today", a daily total and an
99
+ * order's displayed time take the zone from here, never from the server's clock.
100
+ *
101
+ * @param {unknown} declared
102
+ * @returns {string}
103
+ */
104
+ export function resolveStoreTimeZone(declared) {
105
+ return timezoneDeclarationProblems(declared).length === 0 ? /** @type {string} */ (declared) : DEFAULT_TIMEZONE;
106
+ }
107
+
72
108
 
73
109
  /** Every `seo` key (mirrors packages/public-runtime/src/declared-tenant-config.mjs). */
74
110
  export const SEO_KEYS = Object.freeze(["trailingSlash", "verbatimSeoTitles", "structuredData"]);
@@ -737,6 +773,11 @@ export function validateDeclaredConfig(config, file = ".tot/config.json") {
737
773
  out.push(mk(ERROR, "config-currency", file, `${problem} — the store sells in ${DEFAULT_CURRENCY} until it is fixed`));
738
774
  }
739
775
  }
776
+ if (config.timezone !== undefined) {
777
+ for (const problem of timezoneDeclarationProblems(config.timezone)) {
778
+ out.push(mk(ERROR, "config-timezone", file, `${problem} — the store counts its days in ${DEFAULT_TIMEZONE} until it is fixed`));
779
+ }
780
+ }
740
781
  if (config.seo !== undefined) {
741
782
  for (const problem of seoDeclarationProblems(config.seo)) {
742
783
  out.push(mk(ERROR, "config-seo", `${file} seo`, `${problem} — the whole \`seo\` declaration is ignored until it is fixed`));
@@ -1,7 +1,7 @@
1
1
  /** Types for the dependency-free declared-tenant-config module (declared-tenant-config.mjs). */
2
2
  import type { TenantConfig } from "./tenant.js";
3
3
 
4
- export type DeclaredTenantConfigField = "displayName" | "siteType" | "compliance" | "capabilities" | "features" | "embeds" | "seo" | "currency" | "shipping";
4
+ export type DeclaredTenantConfigField = "displayName" | "siteType" | "compliance" | "capabilities" | "features" | "embeds" | "seo" | "currency" | "timezone" | "shipping";
5
5
  export type DeclaredTenantConfig = Partial<Pick<TenantConfig, DeclaredTenantConfigField>>;
6
6
 
7
7
  export const DECLARED_TENANT_CONFIG_FIELDS: readonly DeclaredTenantConfigField[];
@@ -13,6 +13,9 @@ export const DEFAULT_CURRENCY: "USD";
13
13
  export const CURRENCY_CODES: readonly string[];
14
14
  export function resolveStoreCurrency(declared: unknown): string;
15
15
  export function currencyDeclarationProblems(currency: unknown): string[];
16
+ export const DEFAULT_TIMEZONE: "UTC";
17
+ export function resolveStoreTimeZone(declared: unknown): string;
18
+ export function timezoneDeclarationProblems(timezone: unknown): string[];
16
19
  export const SEO_KEYS: readonly string[];
17
20
  export const STRUCTURED_DATA_KEYS: readonly string[];
18
21
  export const STRUCTURED_DATA_PAGE_TYPES: readonly import("./tenant.js").StructuredDataPageType[];