@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.
- package/apps/storefront/astro.config.mjs +2 -1
- package/apps/storefront/dev-plugins/dev-auth-outbox.mjs +181 -0
- package/apps/storefront/src/components/content/storefront/blog/PostGrid.astro +8 -4
- package/apps/storefront/src/lib/access/auth/devAuth.ts +34 -2
- package/apps/storefront/src/lib/access/auth/route.ts +36 -2
- package/apps/storefront/src/lib/access/auth/totAccessClient.ts +6 -18
- package/apps/storefront/src/lib/access/dashboard/staffStoreEntry.ts +2 -1
- package/apps/storefront/src/lib/access/dashboard/staffVendorAccess.ts +6 -10
- package/apps/storefront/src/lib/access/shared/accessExplain.ts +106 -28
- package/apps/storefront/src/lib/access/shared/developerInvites.ts +40 -38
- package/apps/storefront/src/lib/access/shared/invitePreflight.ts +1 -1
- package/apps/storefront/src/lib/access/shared/principalGrants.ts +16 -17
- package/apps/storefront/src/lib/content/shared/homeVisualParity.ts +69 -0
- package/apps/storefront/src/lib/content/storyblok/content-model.ts +21 -0
- package/apps/storefront/src/lib/engagement/messaging/messagesClient.ts +13 -7
- package/apps/storefront/src/lib/observability/diag/failureReason.ts +37 -0
- package/apps/storefront/src/lib/observability/diag/redact.ts +11 -14
- package/apps/storefront/src/lib/orders/admin/orderSource.ts +26 -5
- package/apps/storefront/src/lib/orders/admin/services/orders.ts +121 -22
- package/apps/storefront/src/lib/orders/admin/viewmodels/orders.ts +7 -0
- package/apps/storefront/src/lib/orders/fixtures/orders.ts +2 -0
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +4 -7
- package/apps/storefront/src/pages/api/auth/send.ts +12 -12
- package/apps/storefront/src/pages/api/auth/step-up-verify.ts +2 -1
- package/apps/storefront/src/pages/api/auth/verify.ts +7 -3
- package/apps/storefront/src/pages/auth/login.astro +24 -6
- package/apps/storefront/src/pages/auth/magic.astro +4 -7
- package/apps/storefront/src/pages/index.astro +43 -1
- package/package.json +1 -1
- package/packages/cli/src/declared-config.mjs +42 -1
- package/packages/public-runtime/src/declared-tenant-config.d.mts +4 -1
- package/packages/public-runtime/src/declared-tenant-config.mjs +40 -1
- package/packages/public-runtime/src/extension-contract-values.d.mts +4 -1
- package/packages/public-runtime/src/extension-contract-values.mjs +6 -1
- package/packages/public-runtime/src/extension-contract.ts +2 -0
- package/packages/public-runtime/src/tenant.ts +6 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
|
55
|
-
//
|
|
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
|
-
|
|
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 {
|
|
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 = (
|
|
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 →
|
|
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 —
|
|
239
|
-
your sign-in code is
|
|
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
|
|
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
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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.
|
|
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[];
|