@tokenoftrust/storefront-runner 4.4.0 → 4.6.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 (30) hide show
  1. package/agency-kit/tools/lib/catalog-model/files.mjs +30 -3
  2. package/agency-kit/tools/lib/catalog-model/sku.d.mts +16 -0
  3. package/agency-kit/tools/lib/catalog-model/sku.mjs +146 -0
  4. package/apps/storefront/src/components/catalog/storefront/plp/FacetSidebar.astro +29 -13
  5. package/apps/storefront/src/components/content/storefront/chrome/SiteHeader.astro +20 -2
  6. package/apps/storefront/src/layouts/Layout.astro +20 -2
  7. package/apps/storefront/src/lib/checkout/commerce/finalCartAuthorization.ts +9 -0
  8. package/apps/storefront/src/lib/checkout/shared/lazyCheckoutLoader.ts +120 -29
  9. package/apps/storefront/src/lib/content/shared/rawChrome.ts +15 -2
  10. package/apps/storefront/src/lib/content/shared/rawMarketingHtml.ts +26 -0
  11. package/apps/storefront/src/lib/inventory/allocations.ts +3 -3
  12. package/apps/storefront/src/lib/inventory/mirrorStock.ts +3 -1
  13. package/apps/storefront/src/lib/orders/admin/orderSource.ts +342 -0
  14. package/apps/storefront/src/lib/orders/admin/services/orders.ts +1 -1
  15. package/apps/storefront/src/lib/orders/data/orderMapper.ts +10 -4
  16. package/apps/storefront/src/lib/orders/data/ordersStore.ts +48 -0
  17. package/apps/storefront/src/lib/publication/shared/url.ts +19 -0
  18. package/apps/storefront/src/middleware/renderChain.ts +3 -0
  19. package/apps/storefront/src/pages/api/admin/orders/[id].ts +15 -30
  20. package/apps/storefront/src/pages/api/admin/orders.ts +15 -32
  21. package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +39 -15
  22. package/apps/storefront/src/pages/tenants/[id]/[...path].ts +12 -2
  23. package/package.json +1 -1
  24. package/packages/cli/src/declared-config.mjs +43 -7
  25. package/packages/public-runtime/package.json +2 -0
  26. package/packages/public-runtime/src/checkout.ts +13 -3
  27. package/packages/public-runtime/src/preview-url.d.mts +10 -0
  28. package/packages/public-runtime/src/preview-url.mjs +54 -0
  29. package/packages/public-runtime/src/sku.ts +19 -0
  30. package/packages/public-runtime/src/tenant.ts +3 -0
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Where a tenant's admin reads its orders. Resolved once per request, so the Orders tab, the
3
+ * `/api/admin/orders` routes and the sidebar status always describe the same connection.
4
+ *
5
+ * A tenant admitted to the shared ToT checkout store (checkout's `admitSharedStoreTenant`, the
6
+ * same admission the signed order intake applies) has its paid orders forwarded into the platform
7
+ * ledger (`STOREFRONT_APPS_DB`, `scrappy_commerce_orders`) under this Worker's `COMMERCE_STAGE`.
8
+ * The shared store's partner API returns every tenant's transactions unfiltered, so the ledger is
9
+ * the only per-tenant record for that tenant. A tenant on its own FoxyCart store reads that
10
+ * store's partner API with `FOXY_ORDER_API_KEY`.
11
+ *
12
+ * The ledger carries no customer identity and no address (the intake refuses both), so a ledger
13
+ * order names no customer and has no shipping address here.
14
+ */
15
+ import type {
16
+ CheckoutOrderDetail,
17
+ CheckoutOrderFulfillment,
18
+ CheckoutOrderListFilters,
19
+ CheckoutOrderListResponse,
20
+ CheckoutOrderPayment,
21
+ CheckoutOrderSummary,
22
+ CommerceScope,
23
+ CommerceStage,
24
+ CustomizationEnv,
25
+ PaymentState,
26
+ } from "@tot/public-runtime";
27
+ import { evidencePermitsRelease, paginateOrders } from "@tot/public-runtime";
28
+ import { commerceStageForEnv } from "@/lib/fulfillment/fulfillmentService";
29
+ import { readD1, readEnv } from "@/lib/publication/shared/env";
30
+ import { fromD1, type Queryable } from "@/lib/catalog/d1/catalog.js";
31
+ import { ScrappyOrderLedger, type CommerceOrderBookEntry } from "../data/ordersStore.js";
32
+ import { foldCommerceEvents, type CommerceSourceEvent } from "../data/orderMapper.js";
33
+ import {
34
+ OrderApiError,
35
+ createCheckoutOrderClient,
36
+ orderApiBaseUrl,
37
+ type CheckoutOrderClient,
38
+ } from "./orderClient";
39
+
40
+ /** Why a tenant has no order source. Each names what connects it. */
41
+ export type OrderSourceGap = "no-tenant" | "stage-unknown" | "ledger-unavailable" | "partner-key-missing";
42
+
43
+ /** Ledger health: when the last paid order arrived, and reservations no payment has confirmed. */
44
+ export interface LedgerSyncStatus {
45
+ orders: number;
46
+ lastOrderAt: string | null;
47
+ unconfirmed: { count: number; oldestAt: string | null };
48
+ }
49
+
50
+ /** Which FoxyCart store takes this tenant's payments, or null when no store is selected. */
51
+ export type CheckoutStore = "shared-test-store" | "own-store" | null;
52
+
53
+ export type OrderSource =
54
+ | {
55
+ kind: "ledger";
56
+ checkout: "shared-test-store";
57
+ scope: CommerceScope;
58
+ client: CheckoutOrderClient;
59
+ status: (now?: Date) => Promise<LedgerSyncStatus>;
60
+ }
61
+ | { kind: "partner-api"; checkout: "own-store"; scope: CommerceScope; client: CheckoutOrderClient }
62
+ | { kind: "not-connected"; checkout: CheckoutStore; gap: OrderSourceGap };
63
+
64
+ export interface OrderSourceDeps {
65
+ admitSharedStoreTenant: (tenantId: string) => Promise<{ ok: boolean }>;
66
+ readEnv: (name: string) => Promise<string | undefined>;
67
+ readLedgerDb: () => Promise<Queryable | null>;
68
+ }
69
+
70
+ /**
71
+ * Worker deps. The caller passes checkout's `admitSharedStoreTenant`, the same admission the
72
+ * signed order intake applies, so reading and writing the ledger cannot disagree on who is in it.
73
+ */
74
+ export function workerOrderSourceDeps(
75
+ admitSharedStoreTenant: OrderSourceDeps["admitSharedStoreTenant"],
76
+ ): OrderSourceDeps {
77
+ return {
78
+ admitSharedStoreTenant,
79
+ readEnv: async (name) => (await readEnv(name)) ?? undefined,
80
+ readLedgerDb: async () => {
81
+ const d1 = await readD1("STOREFRONT_APPS_DB");
82
+ return d1 ? fromD1(d1) : null;
83
+ },
84
+ };
85
+ }
86
+
87
+ export async function resolveOrderSource(
88
+ input: { tenantId: string; customizationEnv: CustomizationEnv | undefined },
89
+ deps: OrderSourceDeps,
90
+ ): Promise<OrderSource> {
91
+ const tenantId = input.tenantId.trim();
92
+ if (!tenantId) return { kind: "not-connected", checkout: null, gap: "no-tenant" };
93
+
94
+ if ((await deps.admitSharedStoreTenant(tenantId)).ok) {
95
+ const checkout = "shared-test-store";
96
+ const stage = await deps.readEnv("COMMERCE_STAGE");
97
+ if (stage !== "preview" && stage !== "publication") return { kind: "not-connected", checkout, gap: "stage-unknown" };
98
+ const db = await deps.readLedgerDb();
99
+ if (!db) return { kind: "not-connected", checkout, gap: "ledger-unavailable" };
100
+ const book = ledgerOrderBook(new ScrappyOrderLedger(db), { tenantId, stage });
101
+ return { kind: "ledger", checkout, scope: { tenantId, stage }, client: book.client, status: book.status };
102
+ }
103
+
104
+ const checkout = "own-store";
105
+ const stage = commerceStageForEnv(input.customizationEnv);
106
+ if (!stage) return { kind: "not-connected", checkout, gap: "stage-unknown" };
107
+ const bearer = await deps.readEnv("FOXY_ORDER_API_KEY");
108
+ if (!bearer) return { kind: "not-connected", checkout, gap: "partner-key-missing" };
109
+ return {
110
+ kind: "partner-api",
111
+ checkout,
112
+ scope: { tenantId, stage },
113
+ client: createCheckoutOrderClient({
114
+ baseUrl: orderApiBaseUrl(input.customizationEnv ?? "test"),
115
+ appDomain: tenantId,
116
+ bearer,
117
+ }),
118
+ };
119
+ }
120
+
121
+ /** Merchant copy for a gap: what is missing and who connects it. */
122
+ export function orderSourceGapMessage(gap: OrderSourceGap): string {
123
+ switch (gap) {
124
+ case "no-tenant":
125
+ return "No store is selected.";
126
+ case "stage-unknown":
127
+ return "This environment does not declare a commerce stage, so it has no order record to read. Token of Trust support connects it.";
128
+ case "ledger-unavailable":
129
+ return "The platform order ledger is not bound in this environment. Token of Trust support connects it.";
130
+ case "partner-key-missing":
131
+ return "This store checks out on its own FoxyCart store, and its partner API key is not provisioned on the platform. Token of Trust support provisions it.";
132
+ }
133
+ }
134
+
135
+ export interface OrderSyncHealth {
136
+ value: "Not connected" | "Connected" | "Check";
137
+ detail: string;
138
+ }
139
+
140
+ /** The sidebar's order-sync row, from the same source the Orders tab reads. */
141
+ export function orderSyncHealth(source: OrderSource, status: LedgerSyncStatus | null): OrderSyncHealth {
142
+ if (source.kind === "not-connected") return { value: "Not connected", detail: orderSourceGapMessage(source.gap) };
143
+ if (source.kind === "partner-api" || !status) return { value: "Connected", detail: "Reads this store's checkout orders." };
144
+ if (status.unconfirmed.count > 0) {
145
+ const n = status.unconfirmed.count;
146
+ return {
147
+ value: "Check",
148
+ detail: `${n} checkout${n === 1 ? "" : "s"} in the last 7 days passed final cart with no payment confirmation received.`,
149
+ };
150
+ }
151
+ return {
152
+ value: "Connected",
153
+ detail: status.lastOrderAt ? `Last paid order ${status.lastOrderAt}.` : "No paid orders yet.",
154
+ };
155
+ }
156
+
157
+ /** The sidebar's checkout row: which store takes this tenant's payments. */
158
+ export function checkoutStoreLabel(checkout: CheckoutStore): string {
159
+ if (checkout === "shared-test-store") return "Test store";
160
+ if (checkout === "own-store") return "Own store";
161
+ return "Not connected";
162
+ }
163
+
164
+ /** The ledger's money is integer minor units of a two-decimal currency. */
165
+ function toMajor(minor: number): number {
166
+ return minor / 100;
167
+ }
168
+
169
+ /** Provider timestamps arrive with a colonless offset; compare and page on one ISO form. */
170
+ function isoInstant(value: string): string {
171
+ const parsed = new Date(value);
172
+ return Number.isNaN(parsed.getTime()) ? value : parsed.toISOString();
173
+ }
174
+
175
+ function paymentOf(payment: PaymentState): CheckoutOrderPayment {
176
+ if (payment === "captured") return "captured";
177
+ if (payment === "refunded" || payment === "refund_requested" || payment === "refund_unknown" || payment === "voided") {
178
+ return "refunded";
179
+ }
180
+ return "authorized";
181
+ }
182
+
183
+ interface LedgerOrder {
184
+ summary: CheckoutOrderSummary;
185
+ detail: CheckoutOrderDetail;
186
+ }
187
+
188
+ function ledgerOrder(entry: CommerceOrderBookEntry): LedgerOrder {
189
+ const placedAt = isoInstant(entry.placedAt);
190
+ const snapshot = entry.events.length > 0 ? foldCommerceEvents(entry.events) : null;
191
+ const payment = snapshot?.payment ?? entry.payment;
192
+ const fulfillment = snapshot?.fulfillment ?? entry.fulfillment;
193
+ const evidence = snapshot?.verificationDetail ?? { verification: "UNKNOWN", tax: "UNKNOWN", risk: "UNKNOWN", stock: "UNKNOWN" };
194
+ const totals = snapshot?.totals ?? { subtotalMinor: entry.amountMinor, shippingMinor: 0, taxMinor: 0, totalMinor: entry.amountMinor };
195
+ const lines =
196
+ [...entry.events].sort((a, b) => b.eventVersion - a.eventVersion).find((event) => event.lines?.length)?.lines ?? [];
197
+
198
+ const releasable = payment === "captured" && evidencePermitsRelease(evidence);
199
+ let state: CheckoutOrderFulfillment;
200
+ let blocker: string | null = null;
201
+ if (fulfillment === "handed_off") state = "completed";
202
+ else if (fulfillment === "cancelled") {
203
+ state = "completed";
204
+ blocker = "Cancelled";
205
+ } else if (fulfillment === "UNKNOWN") state = "unfulfilled";
206
+ else if (fulfillment === "ready" && releasable) state = "ready_to_ship";
207
+ else {
208
+ state = "hold_review_required";
209
+ blocker = payment !== "captured" ? "Payment capture" : evidencePermitsRelease(evidence) ? "Manual review" : "Evidence review";
210
+ }
211
+
212
+ const itemCount = lines.reduce((sum, line) => sum + line.quantity, 0);
213
+ const itemsSummary =
214
+ lines.length === 0 ? "Items not recorded" : lines.length === 1 ? lines[0]!.sku : `${lines[0]!.sku} +${lines.length - 1} more`;
215
+ const verification: Record<string, string> = {};
216
+ for (const key of ["verification", "tax", "risk", "stock"] as const) {
217
+ verification[key] = evidence[key];
218
+ verification[`${key}Source`] = "commerce-ledger";
219
+ verification[`${key}AsOf`] = placedAt;
220
+ }
221
+
222
+ const summary: CheckoutOrderSummary = {
223
+ id: entry.orderId,
224
+ placedAt,
225
+ currency: entry.currency,
226
+ customer: { id: null, name: "Checkout customer" },
227
+ itemsSummary,
228
+ itemCount,
229
+ total: toMajor(totals.totalMinor),
230
+ payment: paymentOf(payment),
231
+ fulfillment: state,
232
+ blocker,
233
+ verification,
234
+ };
235
+ return {
236
+ summary,
237
+ detail: {
238
+ ...summary,
239
+ lineItems: lines.map((line) => ({ sku: line.sku, name: line.sku, quantity: line.quantity, unitPrice: toMajor(line.unitMinor) })),
240
+ totals: {
241
+ subtotal: toMajor(totals.subtotalMinor),
242
+ shipping: toMajor(totals.shippingMinor),
243
+ estimatedTax: toMajor(totals.taxMinor),
244
+ taxesAndFees: 0,
245
+ total: toMajor(totals.totalMinor),
246
+ },
247
+ shippingAddress: null,
248
+ verificationDetail: { readinessChecks: [], evidenceTimeline: [] },
249
+ },
250
+ };
251
+ }
252
+
253
+ function matchesQueue(order: CheckoutOrderSummary, queue: string | undefined): boolean {
254
+ switch (queue) {
255
+ case undefined:
256
+ case "all":
257
+ return true;
258
+ case "needs_attention":
259
+ return order.fulfillment === "hold_review_required";
260
+ case "ready_to_ship":
261
+ return order.fulfillment === "ready_to_ship";
262
+ case "unfulfilled":
263
+ return order.fulfillment === "hold_review_required" || order.fulfillment === "ready_to_ship";
264
+ case "completed":
265
+ return order.fulfillment === "completed";
266
+ default:
267
+ return false;
268
+ }
269
+ }
270
+
271
+ function listLimit(limit: number | undefined): number {
272
+ if (limit === undefined || !Number.isFinite(limit)) return 25;
273
+ return Math.min(100, Math.max(1, Math.trunc(limit)));
274
+ }
275
+
276
+ /** A shopper who abandons at payment leaves a reservation too, so only recent ones are reported. */
277
+ export const UNCONFIRMED_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
278
+
279
+ /**
280
+ * Paid orders and sync status over one tenant's ledger book, read once per request. A `scrappy`
281
+ * reservation is never listed as an order; one from the last seven days that no provider event
282
+ * has confirmed is reported as waiting on payment confirmation.
283
+ */
284
+ export function ledgerOrderBook(
285
+ ledger: Pick<ScrappyOrderLedger, "readOrderBook">,
286
+ scope: { tenantId: string; stage: CommerceStage },
287
+ ): { client: CheckoutOrderClient; status: (now?: Date) => Promise<LedgerSyncStatus> } {
288
+ let loaded: Promise<{ orders: LedgerOrder[]; unconfirmed: CommerceOrderBookEntry[] }> | null = null;
289
+ const load = () =>
290
+ (loaded ??= ledger.readOrderBook(scope.tenantId, scope.stage).then((book) => {
291
+ const paid = book.filter((entry) => entry.source !== "scrappy");
292
+ const confirmed = new Set(
293
+ paid.flatMap((entry) => entry.events.map((event: CommerceSourceEvent) => event.admissionReference)).filter(Boolean),
294
+ );
295
+ return {
296
+ orders: paid.map(ledgerOrder),
297
+ unconfirmed: book.filter((entry) => entry.source === "scrappy" && !confirmed.has(entry.orderId)),
298
+ };
299
+ }));
300
+
301
+ const client: CheckoutOrderClient = {
302
+ async listOrders(filters?: CheckoutOrderListFilters): Promise<CheckoutOrderListResponse> {
303
+ const { orders } = await load();
304
+ const from = filters?.from ? isoInstant(filters.from) : null;
305
+ const to = filters?.to ? isoInstant(filters.to) : null;
306
+ const matching = orders
307
+ .map((order) => order.summary)
308
+ .filter((order) => matchesQueue(order, filters?.queue))
309
+ .filter((order) => !filters?.status || order.payment === filters.status)
310
+ .filter((order) => (!from || order.placedAt >= from) && (!to || order.placedAt < to));
311
+ const limit = listLimit(filters?.limit);
312
+ const page = paginateOrders(matching, limit, filters?.cursor ?? null);
313
+ return {
314
+ orders: page.orders,
315
+ page: { limit, cursor: filters?.cursor ?? null, nextCursor: page.nextCursor, total: matching.length },
316
+ };
317
+ },
318
+ async getOrder(orderId: string): Promise<CheckoutOrderDetail> {
319
+ const { orders } = await load();
320
+ const found = orders.find((order) => order.summary.id === orderId);
321
+ if (!found) throw new OrderApiError("order_not_found", 404);
322
+ return found.detail;
323
+ },
324
+ };
325
+
326
+ const status = async (now: Date = new Date()): Promise<LedgerSyncStatus> => {
327
+ const { orders, unconfirmed } = await load();
328
+ const placed = orders.map((order) => order.summary.placedAt).sort();
329
+ const since = new Date(now.getTime() - UNCONFIRMED_WINDOW_MS).toISOString();
330
+ const waiting = unconfirmed
331
+ .map((entry) => isoInstant(entry.placedAt))
332
+ .filter((at) => at >= since)
333
+ .sort();
334
+ return {
335
+ orders: orders.length,
336
+ lastOrderAt: placed[placed.length - 1] ?? null,
337
+ unconfirmed: { count: waiting.length, oldestAt: waiting[0] ?? null },
338
+ };
339
+ };
340
+
341
+ return { client, status };
342
+ }
@@ -52,7 +52,7 @@ import type {
52
52
  } from "../viewmodels/orders";
53
53
  import type { CheckoutOrderClient } from "../orderClient";
54
54
 
55
- const EVIDENCE_STATES: readonly EvidenceState[] = ["fresh", "UNKNOWN", "stale", "unreadable", "absent"];
55
+ const EVIDENCE_STATES: readonly EvidenceState[] = ["fresh", "not-required", "UNKNOWN", "stale", "unreadable", "absent"];
56
56
  const REFUND_PAYMENTS: ReadonlySet<PaymentState> = new Set(["refunded", "refund_requested", "refund_unknown"]);
57
57
 
58
58
  export interface SourcedState<T extends string = string> {
@@ -8,6 +8,7 @@
8
8
  * internals — and never lets a raw customer identifier leave this module:
9
9
  * customer identity is always the per-(tenant, install) `customerHash`.
10
10
  */
11
+ import { skuFromToken } from "@tot/public-runtime/sku";
11
12
  import { customerHash } from "../../integrations/apps/orders/customerHash.js";
12
13
  import type { FoxyOrder, FoxyOrderLineItem } from "../providers/foxyOrderClient.js";
13
14
  import {
@@ -82,9 +83,10 @@ function toIsoDateTime(foxyDate: string): string {
82
83
  return Number.isNaN(parsed.getTime()) ? foxyDate : parsed.toISOString();
83
84
  }
84
85
 
86
+ /** The provider carries the signed checkout product code; the order names the stored SKU it encodes. */
85
87
  function toContractLineItem(item: FoxyOrderLineItem): ContractOrderLineItem {
86
88
  return {
87
- sku: item.sku,
89
+ sku: skuFromToken(item.sku) ?? item.sku,
88
90
  quantity: item.quantity,
89
91
  lineTotal: item.totalPrice,
90
92
  };
@@ -264,11 +266,15 @@ export function normalizeCommerceEvent(value: unknown): CommerceSourceEvent | "m
264
266
  if (!(STOCK_STATES as readonly string[]).includes(stock)) return "malformed";
265
267
  if (!(FULFILLMENT_STATES as readonly string[]).includes(fulfillment)) return "malformed";
266
268
  if (!(LABEL_STATES as readonly string[]).includes(label)) return "malformed";
267
- const lines = value.lines;
268
- if (lines !== undefined && (!Array.isArray(lines) || lines.some(line => !isRecord(line) ||
269
- typeof line.sku !== "string" || !/^[A-Za-z0-9._:-]{1,128}$/.test(line.sku) ||
269
+ // A line's `sku` arrives as the signed checkout product code and is kept as the line key it
270
+ // encodes, the key the admission record and the stock allocations carry.
271
+ const rawLines = value.lines;
272
+ if (rawLines !== undefined && (!Array.isArray(rawLines) || rawLines.some(line => !isRecord(line) ||
273
+ skuFromToken(line.sku) === null ||
270
274
  !Number.isSafeInteger(line.quantity) || Number(line.quantity) < 1 ||
271
275
  !Number.isSafeInteger(line.unitMinor) || Number(line.unitMinor) < 0))) return "malformed";
276
+ const lines = rawLines === undefined ? undefined : (rawLines as Record<string, unknown>[]).map(line =>
277
+ ({ ...line, sku: skuFromToken(line.sku)! }));
272
278
  for (const key of ["admissionReference", "providerCartDigest", "destinationDigest"] as const) {
273
279
  if (value[key] !== undefined && (typeof value[key] !== "string" || !/^[A-Za-z0-9._:-]{1,128}$/.test(value[key] as string))) return "malformed";
274
280
  }
@@ -222,6 +222,18 @@ export interface CommerceOrderQueryRow {
222
222
  eventVersion: number;
223
223
  }
224
224
 
225
+ /** One ledger order row and the source events recorded for it. */
226
+ export interface CommerceOrderBookEntry {
227
+ orderId: string;
228
+ source: string;
229
+ placedAt: string;
230
+ currency: string;
231
+ payment: PaymentState;
232
+ fulfillment: CommerceSnapshot["fulfillment"];
233
+ amountMinor: number;
234
+ events: CommerceSourceEvent[];
235
+ }
236
+
225
237
  export interface CommerceAggregate {
226
238
  count: number;
227
239
  amountMinor: number;
@@ -468,6 +480,42 @@ export class ScrappyOrderLedger {
468
480
  return { count, amountMinor, byPayment };
469
481
  }
470
482
 
483
+ /**
484
+ * Every order row for one tenant and stage, each with the source events recorded for it. Two
485
+ * reads, never one per order. A `scrappy` row is a final-cart reservation, not a paid order: a
486
+ * provider event names it in `admissionReference` once that payment reaches the ledger.
487
+ */
488
+ async readOrderBook(tenantId: string, stage: CommerceStage): Promise<CommerceOrderBookEntry[]> {
489
+ const rows = await this.db.all<CommerceOrderRow>(
490
+ `SELECT * FROM scrappy_commerce_orders WHERE tenant_id = ? AND stage = ?`,
491
+ tenantId,
492
+ stage,
493
+ );
494
+ const payloads = await this.db.all<{ order_id: string; payload: string }>(
495
+ `SELECT order_id, payload FROM scrappy_commerce_outbox WHERE tenant_id = ? AND stage = ?`,
496
+ tenantId,
497
+ stage,
498
+ );
499
+ const events = new Map<string, CommerceSourceEvent[]>();
500
+ for (const row of payloads) {
501
+ const parsed = JSON.parse(row.payload) as StoredCommercePayload;
502
+ if (!parsed.source) continue;
503
+ const list = events.get(row.order_id) ?? [];
504
+ list.push(parsed.source);
505
+ events.set(row.order_id, list);
506
+ }
507
+ return rows.map((row) => ({
508
+ orderId: row.order_id,
509
+ source: row.source,
510
+ placedAt: row.placed_at,
511
+ currency: row.currency,
512
+ payment: row.payment_state,
513
+ fulfillment: row.fulfillment_state,
514
+ amountMinor: row.amount_minor,
515
+ events: events.get(row.order_id) ?? [],
516
+ }));
517
+ }
518
+
471
519
  /** Reset one outbox row for this tenant and stage. Does not write another order. */
472
520
  async replayOutbox(tenantId: string, stage: CommerceStage, outboxId: string, now: string): Promise<boolean> {
473
521
  const row = await this.db.first<{ outbox_id: string }>(
@@ -96,6 +96,25 @@ export function buildQueryString(
96
96
  return s ? `?${s}` : "";
97
97
  }
98
98
 
99
+ /** Facet values shown before the nested "Show more" disclosure. */
100
+ export const FACET_VISIBLE_VALUES = 8;
101
+
102
+ /**
103
+ * Split a facet group's values into the always-visible head and the collapsed
104
+ * remainder: the first FACET_VISIBLE_VALUES, plus every selected value wherever
105
+ * it sits, so a selected value is never hidden in the remainder.
106
+ */
107
+ export function splitFacetValues<T extends { value: string }>(
108
+ values: readonly T[],
109
+ isSelected: (value: string) => boolean,
110
+ limit: number = FACET_VISIBLE_VALUES,
111
+ ): { shown: T[]; rest: T[] } {
112
+ const shown: T[] = [];
113
+ const rest: T[] = [];
114
+ values.forEach((fv, i) => (i < limit || isSelected(fv.value) ? shown : rest).push(fv));
115
+ return { shown, rest };
116
+ }
117
+
99
118
  /** Toggle a single facet value on/off, returning the new querystring (page reset). */
100
119
  export function toggleFacet(
101
120
  current: ListProductsQuery,
@@ -948,6 +948,9 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
948
948
  const key = pageKey;
949
949
  await timed(timing, "page-cache", () => putCachedPage(cache, key, response));
950
950
  }
951
+ // A preview-token (`test`) render is unpublished content shown to one viewer: no
952
+ // browser, proxy, or CDN may keep it or hand it to the next visitor of this URL.
953
+ if (customization.env === "test") response.headers.set("Cache-Control", "private, no-store");
951
954
  // MATERIALIZE-ONLY capture (ADR 0013): expose the tenant's resolved route-document
952
955
  // Cache-Control so the publish/materialize plane can bake it into the bundle
953
956
  // manifest (the same capture pattern as CSP). Gated on the materialize probe
@@ -1,23 +1,16 @@
1
1
  /**
2
2
  * `GET /api/admin/orders/:id` — one order's full detail for the Orders tab.
3
3
  *
4
- * Server-side proxy to checkout `GET /commerce/orders/:orderId` for the
5
- * resolved tenant + env. Bearer stays server-side. Address is included only
6
- * for an owner of this tenant and stage. Readiness is assessed, not inferred.
7
- * Read-only.
4
+ * Reads one order from the source `resolveOrderSource` picks for the resolved tenant + env.
5
+ * Any bearer stays server-side. Address is included only for an owner of this tenant and stage.
6
+ * Readiness is assessed, not inferred. Read-only.
8
7
  */
9
8
  import type { APIContext } from "astro";
10
- import { readEnv } from "@/lib/publication/shared/env";
11
9
  import { requireAdminApiEntry } from "@/lib/access/auth/adminApiGuard";
12
- import {
13
- commerceActorForAdmin,
14
- createCheckoutOrderClient,
15
- orderApiBaseUrl,
16
- OrderApiError,
17
- projectCheckoutOrder,
18
- } from "@/lib/orders/admin/orderClient";
10
+ import { commerceActorForAdmin, OrderApiError, projectCheckoutOrder } from "@/lib/orders/admin/orderClient";
11
+ import { resolveOrderSource, workerOrderSourceDeps } from "@/lib/orders/admin/orderSource";
12
+ import { admitSharedStoreTenant } from "@/lib/checkout/commerce/sharedStoreAdmission";
19
13
  import { assessCheckoutOrder } from "@/lib/orders/admin/services/orders";
20
- import { commerceStageForEnv } from "@/lib/fulfillment/fulfillmentService";
21
14
 
22
15
  export const prerender = false;
23
16
 
@@ -43,29 +36,21 @@ export async function GET(context: APIContext): Promise<Response> {
43
36
  const orderId = context.params.id;
44
37
  if (!orderId) return jsonError("order_not_found", 404);
45
38
 
46
- const bearer = await readEnv("FOXY_ORDER_API_KEY");
47
- if (!bearer) return jsonError("not_provisioned", 503);
48
-
49
- const stage = commerceStageForEnv(customizationEnv);
50
- if (!stage) return jsonError("stage-unknown", 409);
51
- const tenantId = tenant.appDomain.trim();
52
- if (!tenantId) return jsonError("not_provisioned", 503);
53
-
54
- const client = createCheckoutOrderClient({
55
- baseUrl: orderApiBaseUrl(customizationEnv),
56
- appDomain: tenantId,
57
- bearer,
58
- });
39
+ const source = await resolveOrderSource(
40
+ { tenantId: tenant.appDomain, customizationEnv },
41
+ workerOrderSourceDeps(admitSharedStoreTenant),
42
+ );
43
+ if (source.kind === "not-connected") return jsonError(source.gap, source.gap === "stage-unknown" ? 409 : 503);
59
44
 
60
45
  try {
61
- const scope = { tenantId, stage };
46
+ const scope = source.scope;
62
47
  const actor = commerceActorForAdmin({
63
- tenantId,
64
- stage,
48
+ tenantId: scope.tenantId,
49
+ stage: scope.stage,
65
50
  owner: access.entry.isOwnerViewer,
66
51
  admitted: access.entry.admitted,
67
52
  });
68
- const order = projectCheckoutOrder(await client.getOrder(orderId), actor, scope);
53
+ const order = projectCheckoutOrder(await source.client.getOrder(orderId), actor, scope);
69
54
  const readiness = assessCheckoutOrder(order, scope);
70
55
  return new Response(JSON.stringify({ order, readiness }), { headers: JSON_HEADERS });
71
56
  } catch (err) {
@@ -1,23 +1,19 @@
1
1
  /**
2
2
  * `GET /api/admin/orders` — internal admin route backing the Orders tab.
3
3
  *
4
- * Server-side proxy to the checkout order READ API for the resolved tenant +
5
- * environment. The per-tenant bearer key stays on the Worker (never in the
6
- * response); the browser only ever sees order DTO JSON. Read-only (scope-locks).
4
+ * Reads the tenant's orders from the source `resolveOrderSource` picks (the platform ledger for
5
+ * a shared-checkout tenant, the store's partner API otherwise). Any partner bearer key stays on
6
+ * the Worker (never in the response); the browser only ever sees order DTO JSON. Read-only.
7
7
  * The admin UI consumes this and maps the DTO → OrdersViewModel.
8
8
  *
9
9
  * Access control matches the rest of the admin surface (owned by the admin
10
10
  * host/approver layer) — same posture as `api/evidence.json.ts`; no new gate here.
11
11
  */
12
12
  import type { APIContext } from "astro";
13
- import { readEnv } from "@/lib/publication/shared/env";
14
13
  import { requireAdminApiEntry } from "@/lib/access/auth/adminApiGuard";
15
- import {
16
- createCheckoutOrderClient,
17
- orderApiBaseUrl,
18
- OrderApiError,
19
- } from "@/lib/orders/admin/orderClient";
20
- import { commerceStageForEnv } from "@/lib/fulfillment/fulfillmentService";
14
+ import { OrderApiError } from "@/lib/orders/admin/orderClient";
15
+ import { resolveOrderSource, workerOrderSourceDeps } from "@/lib/orders/admin/orderSource";
16
+ import { admitSharedStoreTenant } from "@/lib/checkout/commerce/sharedStoreAdmission";
21
17
  import { assessCheckoutOrder, parseOrderFilters } from "@/lib/orders/admin/services/orders";
22
18
 
23
19
  export const prerender = false;
@@ -43,34 +39,21 @@ export async function GET(context: APIContext): Promise<Response> {
43
39
  const access = await requireAdminApiEntry(context);
44
40
  if (!access.ok) return jsonError(access.reason, access.status);
45
41
 
46
- const bearer = await readEnv("FOXY_ORDER_API_KEY");
47
- if (!bearer) {
48
- // Worker secret not provisioned for this env — a config error, not the
49
- // caller's fault. Never echo the (missing) key.
50
- return jsonError("not_provisioned", 503);
51
- }
52
-
53
- const stage = commerceStageForEnv(customizationEnv);
54
- if (!stage) return jsonError("stage-unknown", 409);
55
- const tenantId = tenant.appDomain.trim();
56
- if (!tenantId) return jsonError("not_provisioned", 503);
57
-
58
- const url = new URL(context.request.url);
59
42
  // Tenant comes from the host, never from a query parameter.
60
- const filters = parseOrderFilters(url.searchParams);
43
+ const source = await resolveOrderSource(
44
+ { tenantId: tenant.appDomain, customizationEnv },
45
+ workerOrderSourceDeps(admitSharedStoreTenant),
46
+ );
47
+ // An unconnected source is a config gap, not the caller's fault. Never echo a key.
48
+ if (source.kind === "not-connected") return jsonError(source.gap, source.gap === "stage-unknown" ? 409 : 503);
61
49
 
62
- const client = createCheckoutOrderClient({
63
- baseUrl: orderApiBaseUrl(customizationEnv),
64
- appDomain: tenantId,
65
- bearer,
66
- });
50
+ const filters = parseOrderFilters(new URL(context.request.url).searchParams);
67
51
 
68
52
  try {
69
- const data = await client.listOrders(filters);
70
- const scope = { tenantId, stage };
53
+ const data = await source.client.listOrders(filters);
71
54
  const orders = data.orders.map((order) => ({
72
55
  ...order,
73
- readiness: assessCheckoutOrder(order, scope),
56
+ readiness: assessCheckoutOrder(order, source.scope),
74
57
  }));
75
58
  return new Response(JSON.stringify({ ...data, orders }), { headers: JSON_HEADERS });
76
59
  } catch (err) {