@tokenoftrust/storefront-runner 6.0.2 → 6.1.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.
@@ -650,6 +650,7 @@ function applyToOrder(state: WorkspaceState, order: WorkspaceOrder, command: Wor
650
650
  if (!state.carrierAccountConfigured) return refuse("carrier-account");
651
651
  const decision = decideCommerceCommand({
652
652
  command: "purchase-label",
653
+ shipTo: order.ownerDestination ? "present" : "absent",
653
654
  payment: order.payment,
654
655
  stock: order.stock,
655
656
  fulfillment: order.fulfillment,
@@ -729,6 +730,7 @@ function applyToOrder(state: WorkspaceState, order: WorkspaceOrder, command: Wor
729
730
  if (order.adultSignature !== "accepted") return refuse("adult-signature");
730
731
  const decision = decideCommerceCommand({
731
732
  command: "hand-off",
733
+ shipTo: order.ownerDestination ? "present" : "absent",
732
734
  payment: order.payment,
733
735
  stock: order.stock,
734
736
  fulfillment: order.fulfillment,
@@ -29,9 +29,10 @@ import {
29
29
  type PaymentState,
30
30
  type ProviderOutcome,
31
31
  type StockState,
32
+ type OrderContactState,
32
33
  } from "@tot/public-runtime";
33
34
  import { readD1 } from "@/lib/publication/shared/env";
34
- import { terminalTransitionExpiryStatement } from "@/lib/orders/vault/orderContactVault.js";
35
+ import { readOrderContactAvailability, terminalTransitionExpiryStatement } from "@/lib/orders/vault/orderContactVault.js";
35
36
  import { decideAdminApiAccess } from "@/lib/access/auth/adminApiGuard";
36
37
  import { readViewerSession } from "@/lib/access/auth/route";
37
38
  import type { SessionRecord } from "@/lib/access/auth/session";
@@ -149,6 +150,7 @@ export type FulfillmentResult<T> =
149
150
  | { ok: false; code: string; holds?: readonly ReleaseHold[] };
150
151
 
151
152
  interface LoadedOrder {
153
+ shipTo: OrderContactState;
152
154
  sourceAuthority: AuthorityRow|null;
153
155
  sourceHolds: SourceReadiness[];
154
156
  order: {
@@ -947,6 +949,7 @@ export class FulfillmentService {
947
949
 
948
950
  private readiness(state: LoadedOrder): ReadinessInput {
949
951
  return {
952
+ shipTo: state.shipTo,
950
953
  sourceHolds: state.sourceHolds,
951
954
  payment: state.order.payment,
952
955
  stock: state.order.stock,
@@ -1387,7 +1390,11 @@ export class FulfillmentService {
1387
1390
  try { sourceAuthority=await this.db.get<AuthorityRow & Record<string,unknown>>(`SELECT * FROM commerce_source_authority WHERE tenant_id=? AND stage=? AND source=? AND source_order_id=?`,[tenantId,stage,order.source,order.sourceOrderId]) ?? null;
1388
1391
  sourceHolds=sourceAuthority?.order_id!==order.orderId?["source-authority-unavailable"]:authorityHolds(sourceAuthority,this.now());
1389
1392
  }catch{sourceHolds=["source-authority-unreadable"];}
1393
+ const contacts = await readOrderContactAvailability({
1394
+ all: (sql, ...params) => this.db.all(sql, params),
1395
+ }, { tenantId, stage, orderIds: [orderId], now: new Date(this.now()) });
1390
1396
  const loaded: LoadedOrder = {
1397
+ shipTo: contacts.get(orderId)?.shipTo ?? "unreadable",
1391
1398
  sourceAuthority,sourceHolds,
1392
1399
  order,
1393
1400
  evidence,
@@ -1428,7 +1435,7 @@ export class FulfillmentService {
1428
1435
  const readiness = this.readiness(loaded);
1429
1436
  const holds = releaseHolds(readiness);
1430
1437
  loaded.view.holds = holds;
1431
- if(sourceHolds.length&&order.fulfillment==="ready")loaded.view.fulfillment="held";
1438
+ if(holds.length&&order.fulfillment==="ready")loaded.view.fulfillment="held";
1432
1439
  loaded.view.canRelease = canRelease(readiness) && !loaded.shipment;
1433
1440
  loaded.view.canHandOff = handoffRefusal(readiness) === null && loaded.shipment?.handoffState !== "handed_off";
1434
1441
  loaded.view.shipment = loaded.shipment
@@ -1558,6 +1565,8 @@ function projectedFromSnapshot(
1558
1565
  if (snapshot.fulfillment === "cancelled" || payment === "voided" || payment === "refunded") return "cancelled";
1559
1566
  if (snapshot.fulfillment === "UNKNOWN") return "UNKNOWN";
1560
1567
  const holds = releaseHolds({
1568
+ // Source disposition is independent of contact delivery; commands re-read the vault.
1569
+ shipTo: "present",
1561
1570
  payment,
1562
1571
  stock: snapshot.stock,
1563
1572
  fulfillment: "ready",
@@ -16,6 +16,7 @@ import {
16
16
  type PaymentState,
17
17
  type ProviderOutcome,
18
18
  type StockState,
19
+ type OrderContactState,
19
20
  } from "@tot/public-runtime";
20
21
  import type { SourceReadiness } from "../orders/data/sourceAuthorityStore.js";
21
22
  import { COMPLIANCE_RULE_PROFILES } from "@/config/compliance/rulesets";
@@ -42,6 +43,10 @@ export const RELEASE_HOLDS = [
42
43
  "stock-unknown",
43
44
  "stock-unconfirmed",
44
45
  "fulfillment-unknown",
46
+ "ship-to-absent",
47
+ "ship-to-expired",
48
+ "ship-to-erased",
49
+ "ship-to-unreadable",
45
50
  ] as const;
46
51
 
47
52
  export type ReleaseHold = (typeof RELEASE_HOLDS)[number];
@@ -54,6 +59,7 @@ export interface CarrierPolicy {
54
59
  }
55
60
 
56
61
  export interface ReadinessInput {
62
+ shipTo?: OrderContactState;
57
63
  sourceHolds?: readonly SourceReadiness[];
58
64
  payment: PaymentState;
59
65
  stock: StockState;
@@ -100,6 +106,10 @@ export function releaseHolds(input: ReadinessInput): ReleaseHold[] {
100
106
  else if (!paymentIsCaptured(input.payment)) add(holds, "payment-not-captured");
101
107
  if (input.stock === "UNKNOWN") add(holds, "stock-unknown");
102
108
  else if (input.stock !== "confirmed") add(holds, "stock-unconfirmed");
109
+ if (input.shipTo !== "present" && input.shipTo !== "restricted") {
110
+ add(holds, input.shipTo === "absent" ? "ship-to-absent" : input.shipTo === "expired" ? "ship-to-expired" :
111
+ input.shipTo === "erased" ? "ship-to-erased" : "ship-to-unreadable");
112
+ }
103
113
  return holds;
104
114
  }
105
115
 
@@ -107,7 +117,7 @@ export function canRelease(input: ReadinessInput): boolean {
107
117
  return input.fulfillment === "ready" && releaseHolds(input).length === 0;
108
118
  }
109
119
 
110
- export function handoffRefusal(input: ReadinessInput): ReleaseHold | "adult-signature" | "label" | "reconcile" | "fulfillment" | "evidence" | "payment" | "stock" | "reuse" | null {
120
+ export function handoffRefusal(input: ReadinessInput): ReleaseHold | "adult-signature" | "label" | "reconcile" | "fulfillment" | "evidence" | "payment" | "stock" | "reuse" | "ship-to" | null {
111
121
  const holds = releaseHolds(input);
112
122
  if (holds.length > 0) return holds[0] ?? "fulfillment";
113
123
  const decision = decideCommerceCommand({
@@ -118,6 +128,7 @@ export function handoffRefusal(input: ReadinessInput): ReleaseHold | "adult-sign
118
128
  label: input.label,
119
129
  evidence: input.evidence,
120
130
  providerOutcome: input.providerOutcome,
131
+ shipTo: input.shipTo,
121
132
  });
122
133
  if (!decision.admit) return decision.reason === "admitted" ? "fulfillment" : decision.reason;
123
134
  if (input.adultSignatureRequired && input.adultSignature !== "accepted") return "adult-signature";
@@ -14,16 +14,21 @@
14
14
  */
15
15
  import type {
16
16
  CheckoutOrderDetail,
17
- CheckoutOrderDetailResponse,
17
+ CheckoutOrderSummary,
18
18
  CheckoutOrderListFilters,
19
19
  CheckoutOrderListResponse,
20
20
  CommerceActor,
21
21
  CommerceRole,
22
22
  CommerceScope,
23
23
  CommerceStage,
24
+ CheckoutOrderCustomer,
25
+ OrderContactAvailability,
26
+ OrderContactState,
24
27
  } from "@tot/public-runtime";
25
- import { readOwnerAddress } from "@tot/public-runtime";
28
+ import { actorMayCommand, readOwnerAddress } from "@tot/public-runtime";
26
29
  import type { CustomizationEnv } from "@tot/public-runtime";
30
+ import { contactReaderAuthority } from "@/lib/access/auth/contactReaderAuthority";
31
+ import type { SessionRecord } from "@/lib/access/auth/session";
27
32
 
28
33
  /** Fetch shape we depend on — the platform `fetch`, injectable for tests. */
29
34
  export type FetchLike = (
@@ -107,26 +112,83 @@ export function commerceStageForCustomization(env: CustomizationEnv): CommerceSt
107
112
  return env === "live" ? "publication" : "preview";
108
113
  }
109
114
 
110
- /** Owner reads private order fields. An admitted member is a caller. Everyone else is denied. */
115
+ /** Private ownership comes from tenant membership, independently of staff operational capability. */
111
116
  export function commerceActorForAdmin(input: {
112
117
  tenantId: string;
113
118
  stage: CommerceStage;
114
- owner: boolean;
119
+ record: SessionRecord | null | undefined;
120
+ nowSeconds: number;
115
121
  admitted: boolean;
116
122
  }): CommerceActor {
117
- const role: CommerceRole = !input.admitted ? "denied" : input.owner ? "owner" : "caller";
123
+ const privateOwner = contactReaderAuthority(input.record, input.tenantId, input.nowSeconds).kind === "owner";
124
+ const role: CommerceRole = !input.admitted ? "denied" : privateOwner ? "owner" : "caller";
118
125
  return { role, tenantId: input.tenantId, stage: input.stage };
119
126
  }
120
127
 
121
128
  /** Drop the address unless this actor may read it for this tenant and stage. */
122
- export function projectCheckoutOrder<T extends { shippingAddress?: unknown }>(
129
+ export function projectCheckoutOrder<T extends { shippingAddress?: unknown; customer?: CheckoutOrderCustomer; contactAvailability?: OrderContactAvailability }>(
123
130
  order: T,
124
131
  actor: CommerceActor,
125
132
  resource: CommerceScope,
126
133
  ): T {
134
+ const unknown: OrderContactAvailability = { identity: "unreadable", email: "unreadable", shipTo: "unreadable" };
135
+ const availability = order.contactAvailability ?? {
136
+ ...unknown,
137
+ shipTo: Object.hasOwn(order, "shippingAddress") ? addressAvailability(order.shippingAddress) : "unreadable",
138
+ };
127
139
  const read = readOwnerAddress(actor, resource, () => order.shippingAddress ?? null);
128
- if (read.ok) return order;
129
- return { ...order, shippingAddress: null };
140
+ if (read.ok) return { ...order, contactAvailability: availability };
141
+ const restricted = (state: OrderContactState): OrderContactState => state === "present" ? "restricted" : state;
142
+ const projected: Record<string, unknown> = { ...order };
143
+ for (const key of ["customerName", "customerEmail", "email", "phone", "name", "line1", "line2", "postalCode"]) delete projected[key];
144
+ return {
145
+ ...projected,
146
+ shippingAddress: null,
147
+ ...(order.customer ? { customer: { id: null, name: "Checkout customer" } } : {}),
148
+ contactAvailability: actorMayCommand(actor, resource) ? {
149
+ identity: restricted(availability.identity), email: restricted(availability.email), shipTo: restricted(availability.shipTo),
150
+ } : unknown,
151
+ } as T;
152
+ }
153
+
154
+ function addressAvailability(value: unknown): OrderContactState {
155
+ if (value === null) return "absent";
156
+ if (!isRecord(value)) return "unreadable";
157
+ return ["line1", "city", "country"].every((key) => typeof value[key] === "string" && value[key].trim() !== "") ? "present" : "unreadable";
158
+ }
159
+
160
+ function isRecord(value: unknown): value is Record<string, unknown> {
161
+ return typeof value === "object" && value !== null && !Array.isArray(value);
162
+ }
163
+
164
+ const SUMMARY_FIELDS = ["id", "placedAt", "currency", "customer", "contactAvailability", "itemsSummary", "itemCount", "total", "payment", "fulfillment", "blocker", "verification"];
165
+ const DETAIL_FIELDS = [...SUMMARY_FIELDS, "lineItems", "totals", "shippingAddress", "verificationDetail"];
166
+
167
+ /** Refuse an unsupported upstream body before any raw contact aliases reach an admin response. */
168
+ function frozenOrder(value: unknown, detail: true): value is CheckoutOrderDetail;
169
+ function frozenOrder(value: unknown, detail: false): value is CheckoutOrderSummary;
170
+ function frozenOrder(value: unknown, detail: boolean): boolean {
171
+ if (!isRecord(value) || Object.keys(value).some((key) => !(detail ? DETAIL_FIELDS : SUMMARY_FIELDS).includes(key))) return false;
172
+ if (!["id", "placedAt", "currency", "itemsSummary"].every((key) => typeof value[key] === "string")) return false;
173
+ if (!isRecord(value.customer) || typeof value.customer.name !== "string" ||
174
+ !(value.customer.id === null || typeof value.customer.id === "string") ||
175
+ Object.keys(value.customer).some((key) => key !== "id" && key !== "name")) return false;
176
+ if (typeof value.total !== "number" || !Number.isFinite(value.total) || !Number.isInteger(value.itemCount)) return false;
177
+ if (!["authorized", "captured", "refunded"].includes(String(value.payment)) ||
178
+ !["unfulfilled", "ready_to_ship", "hold_review_required", "completed"].includes(String(value.fulfillment))) return false;
179
+ if (value.blocker !== null && typeof value.blocker !== "string") return false;
180
+ if (value.verification !== null && !isRecord(value.verification)) return false;
181
+ const availability = value.contactAvailability;
182
+ if (availability !== undefined && (!isRecord(availability) ||
183
+ !["identity", "email", "shipTo"].every((key) => typeof availability[key] === "string" &&
184
+ ["present", "restricted", "absent", "expired", "erased", "unreadable"].includes(availability[key])) ||
185
+ Object.keys(availability).some((key) => !["identity", "email", "shipTo"].includes(key)))) return false;
186
+ return !detail || (Array.isArray(value.lineItems) && isRecord(value.totals) && isRecord(value.verificationDetail) &&
187
+ (value.shippingAddress === null || isRecord(value.shippingAddress)));
188
+ }
189
+
190
+ function unsupportedBody(): OrderApiError {
191
+ return new OrderApiError("bad_gateway", 502);
130
192
  }
131
193
 
132
194
  /** Build the list querystring: appDomain (required) + any provided filters. */
@@ -151,12 +213,12 @@ export function createCheckoutOrderClient(
151
213
  const base = config.baseUrl.replace(/\/+$/, "");
152
214
  const headers = { authorization: `Bearer ${config.bearer}` };
153
215
 
154
- async function getJson<T>(url: string): Promise<T> {
216
+ async function getJson(url: string): Promise<unknown> {
155
217
  const res = await doFetch(url, { method: "GET", headers });
156
218
  const body = await res.text();
157
219
  if (!res.ok) throw toError(res.status, body);
158
220
  try {
159
- return JSON.parse(body) as T;
221
+ return JSON.parse(body) as unknown;
160
222
  } catch {
161
223
  throw new OrderApiError("unexpected", res.status, "malformed JSON");
162
224
  }
@@ -165,14 +227,22 @@ export function createCheckoutOrderClient(
165
227
  return {
166
228
  async listOrders(filters) {
167
229
  const qs = listQuery(config.appDomain, filters);
168
- return getJson<CheckoutOrderListResponse>(`${base}/commerce/orders?${qs}`);
230
+ const body = await getJson(`${base}/commerce/orders?${qs}`);
231
+ if (!isRecord(body) || !Array.isArray(body.orders) || !body.orders.every((order): order is CheckoutOrderSummary => frozenOrder(order, false)) ||
232
+ !isRecord(body.page) || Object.keys(body).some((key) => key !== "orders" && key !== "page")) throw unsupportedBody();
233
+ const page = body.page;
234
+ if (typeof page.limit !== "number" || !(page.cursor === null || typeof page.cursor === "string") ||
235
+ !(page.nextCursor === null || typeof page.nextCursor === "string") || !(page.total === null || typeof page.total === "number") ||
236
+ Object.keys(page).some((key) => !["limit", "cursor", "nextCursor", "total"].includes(key))) throw unsupportedBody();
237
+ return { orders: body.orders, page: { limit: page.limit, cursor: page.cursor, nextCursor: page.nextCursor, total: page.total } };
169
238
  },
170
239
  async getOrder(orderId) {
171
240
  const qs = new URLSearchParams({ appDomain: config.appDomain }).toString();
172
- const { order } = await getJson<CheckoutOrderDetailResponse>(
241
+ const body = await getJson(
173
242
  `${base}/commerce/orders/${encodeURIComponent(orderId)}?${qs}`,
174
243
  );
175
- return order;
244
+ if (!isRecord(body) || !frozenOrder(body.order, true) || Object.keys(body).some((key) => key !== "order")) throw unsupportedBody();
245
+ return body.order;
176
246
  },
177
247
  };
178
248
  }
@@ -9,8 +9,8 @@
9
9
  * the only per-tenant record for that tenant. A tenant on its own FoxyCart store reads that
10
10
  * store's partner API with `FOXY_ORDER_API_KEY`.
11
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.
12
+ * Contact values stay in the encrypted vault. Order reads carry only its presence metadata;
13
+ * authorized contact routes retrieve the private values separately.
14
14
  */
15
15
  import type {
16
16
  CheckoutOrderDetail,
@@ -24,6 +24,7 @@ import type {
24
24
  CommerceStage,
25
25
  CustomizationEnv,
26
26
  PaymentState,
27
+ OrderContactAvailability,
27
28
  } from "@tot/public-runtime";
28
29
  import { evidencePermitsRelease, paginateOrders } from "@tot/public-runtime";
29
30
  import { commerceStageForEnv } from "@/lib/fulfillment/fulfillmentService";
@@ -31,6 +32,7 @@ import { readD1, readEnv } from "@/lib/publication/shared/env";
31
32
  import { fromD1, type Queryable } from "@/lib/catalog/d1/catalog.js";
32
33
  import { ScrappyOrderLedger, type CommerceOrderBookEntry } from "../data/ordersStore.js";
33
34
  import { foldCommerceEvents, type CommerceSourceEvent } from "../data/orderMapper.js";
35
+ import { readOrderContactAvailability } from "../vault/orderContactVault.js";
34
36
  import {
35
37
  OrderApiError,
36
38
  createCheckoutOrderClient,
@@ -115,7 +117,9 @@ export async function resolveOrderSource(
115
117
  if (checkout === "shared-test-store") {
116
118
  const db = await deps.readLedgerDb();
117
119
  if (!db) return { kind: "not-connected", checkout, gap: "ledger-unavailable" };
118
- const book = ledgerOrderBook(new ScrappyOrderLedger(db), { tenantId, stage });
120
+ const book = ledgerOrderBook(new ScrappyOrderLedger(db), { tenantId, stage }, {
121
+ readContactAvailability: (orderIds) => readOrderContactAvailability(db, { tenantId, stage, orderIds, now: new Date() }),
122
+ });
119
123
  return { kind: "ledger", checkout, scope: { tenantId, stage }, client: book.client, status: book.status };
120
124
  }
121
125
 
@@ -212,7 +216,7 @@ interface LedgerOrder {
212
216
  detail: CheckoutOrderDetail;
213
217
  }
214
218
 
215
- function ledgerOrder(entry: CommerceOrderBookEntry): LedgerOrder {
219
+ function ledgerOrder(entry: CommerceOrderBookEntry, contactAvailability: OrderContactAvailability): LedgerOrder {
216
220
  const placedAt = isoInstant(entry.placedAt);
217
221
  const snapshot = entry.events.length > 0 ? foldCommerceEvents(entry.events) : null;
218
222
  // The order row is the state of record: provider events and fulfillment commands both write it.
@@ -231,10 +235,12 @@ function ledgerOrder(entry: CommerceOrderBookEntry): LedgerOrder {
231
235
  state = "completed";
232
236
  blocker = "Cancelled";
233
237
  } else if (fulfillment === "UNKNOWN") state = "unfulfilled";
234
- else if (fulfillment === "ready" && releasable) state = "ready_to_ship";
238
+ else if (fulfillment === "ready" && releasable &&
239
+ (contactAvailability.shipTo === "present" || contactAvailability.shipTo === "restricted")) state = "ready_to_ship";
235
240
  else {
236
241
  state = "hold_review_required";
237
- blocker = payment !== "captured" ? "Payment capture" : evidencePermitsRelease(evidence) ? "Manual review" : "Evidence review";
242
+ blocker = payment !== "captured" ? "Payment capture" : !evidencePermitsRelease(evidence) ? "Evidence review" :
243
+ contactAvailability.shipTo !== "present" && contactAvailability.shipTo !== "restricted" ? "Ship-to address" : "Manual review";
238
244
  }
239
245
 
240
246
  const itemCount = lines.reduce((sum, line) => sum + line.quantity, 0);
@@ -252,6 +258,7 @@ function ledgerOrder(entry: CommerceOrderBookEntry): LedgerOrder {
252
258
  placedAt,
253
259
  currency: entry.currency,
254
260
  customer: { id: null, name: "Checkout customer" },
261
+ contactAvailability,
255
262
  itemsSummary,
256
263
  itemCount,
257
264
  total: toMajor(totals.totalMinor),
@@ -320,16 +327,22 @@ const PAYMENT_CONFIRMATION_GRACE_MS = PAYMENT_CONFIRMATION_GRACE_MINUTES * 60 *
320
327
  export function ledgerOrderBook(
321
328
  ledger: Pick<ScrappyOrderLedger, "readOrderBook">,
322
329
  scope: { tenantId: string; stage: CommerceStage },
330
+ options: { readContactAvailability?: (orderIds: readonly string[]) => Promise<ReadonlyMap<string, OrderContactAvailability>> } = {},
323
331
  ): { client: CheckoutOrderClient; status: (now?: Date) => Promise<LedgerSyncStatus> } {
324
332
  let loaded: Promise<{ orders: LedgerOrder[]; unconfirmed: CommerceOrderBookEntry[] }> | null = null;
325
333
  const load = () =>
326
- (loaded ??= ledger.readOrderBook(scope.tenantId, scope.stage).then((book) => {
334
+ (loaded ??= ledger.readOrderBook(scope.tenantId, scope.stage).then(async (book) => {
327
335
  const paid = book.filter((entry) => entry.source !== "scrappy");
328
336
  const confirmed = new Set(
329
337
  paid.flatMap((entry) => entry.events.map((event: CommerceSourceEvent) => event.admissionReference)).filter(Boolean),
330
338
  );
339
+ let availability: ReadonlyMap<string, OrderContactAvailability> = new Map();
340
+ try { availability = await options.readContactAvailability?.(paid.map((entry) => entry.orderId)) ?? availability; }
341
+ catch { /* A failed presence read cannot clear shipment readiness. */ }
331
342
  return {
332
- orders: paid.map(ledgerOrder),
343
+ orders: paid.map((entry) => ledgerOrder(entry, availability.get(entry.orderId) ?? {
344
+ identity: "unreadable", email: "unreadable", shipTo: "unreadable",
345
+ })),
333
346
  unconfirmed: book.filter((entry) => entry.source === "scrappy" && !confirmed.has(entry.orderId)),
334
347
  };
335
348
  }));
@@ -26,7 +26,9 @@ import type {
26
26
  PublicCommerceEvent,
27
27
  StockState,
28
28
  TenantConfig,
29
+ OrderContactState,
29
30
  } from "@tot/public-runtime";
31
+ import { projectCheckoutOrder } from "../orderClient";
30
32
  import {
31
33
  actorMayCommand,
32
34
  decideCommerceCommand,
@@ -158,6 +160,7 @@ export type AuthorityState = "fresh" | "expired" | "unreadable" | "absent";
158
160
 
159
161
  /** One commerce order. Columns do not borrow each other's state, source, or as-of. */
160
162
  export interface ScrappyOrderRecord {
163
+ shipTo?: OrderContactState;
161
164
  id: string;
162
165
  placedAt: string;
163
166
  tenantId: string;
@@ -264,6 +267,7 @@ function deriveRisk(
264
267
 
265
268
  function deriveNextStep(fulfillment: OrderShipState, blocker: string | null): string {
266
269
  if (fulfillment === "hold_review_required") {
270
+ if (blocker === "Ship-to address") return "Review address";
267
271
  return blocker === "Payment capture" ? "Capture payment" : "Review risk";
268
272
  }
269
273
  if (fulfillment === "completed") return "View record";
@@ -348,6 +352,7 @@ export function releaseOf(order: ScrappyOrderRecord): OrderRelease {
348
352
  if (!evidencePermitsRelease(evidence)) blocks.push("evidence");
349
353
  if (order.stock.state !== "confirmed" && !blocks.includes("stock")) blocks.push("stock");
350
354
  if (order.fulfillment.state !== "ready" && !blocks.includes("fulfillment")) blocks.push("fulfillment");
355
+ if (!usableContact(order.shipTo)) blocks.push("ship-to");
351
356
 
352
357
  const decision = decideCommerceCommand({
353
358
  command: "hand-off",
@@ -357,6 +362,7 @@ export function releaseOf(order: ScrappyOrderRecord): OrderRelease {
357
362
  label: order.label,
358
363
  evidence,
359
364
  providerOutcome: order.providerOutcome,
365
+ shipTo: order.shipTo,
360
366
  });
361
367
  if (!decision.admit) {
362
368
  if (decision.reason === "payment" && !blocks.includes("payment") && !blocks.includes("refund")) blocks.push("payment");
@@ -383,6 +389,7 @@ function checkoutRecord(order: CheckoutOrderSummary, scope: CommerceScope): Scra
383
389
  tenantId: scope.tenantId,
384
390
  stage: scope.stage,
385
391
  currency: order.currency,
392
+ shipTo: order.contactAvailability?.shipTo,
386
393
  payment: { state: payment, source: "checkout-order", asOf: order.placedAt },
387
394
  tax: readSourcedEvidence(order.verification, "tax"),
388
395
  verification: readSourcedEvidence(order.verification, "verification"),
@@ -415,14 +422,16 @@ export function assessCheckoutOrder(order: CheckoutOrderSummary, scope: Commerce
415
422
 
416
423
  export function mapSummaryToRow(s: CheckoutOrderSummary): OrderRow {
417
424
  const verificationMissing = s.verification == null;
418
- const shipState: OrderShipState = verificationMissing ? "hold_review_required" : s.fulfillment;
419
- const blocker = s.blocker ?? (verificationMissing ? "Verification unknown" : null);
425
+ const addressMissing = !usableContact(s.contactAvailability?.shipTo);
426
+ const addressHold = s.fulfillment !== "completed" && addressMissing;
427
+ const shipState: OrderShipState = verificationMissing || addressHold ? "hold_review_required" : s.fulfillment;
428
+ const blocker = s.blocker ?? (verificationMissing ? "Verification unknown" : addressHold ? "Ship-to address" : null);
420
429
  return {
421
430
  id: s.id,
422
431
  placedAt: s.placedAt,
423
432
  customerId: s.customer.id ?? "",
424
433
  customerName: s.customer.name,
425
- customerNote: "",
434
+ customerNote: contactPresenceCopy(s.contactAvailability?.identity, "Customer identity"),
426
435
  itemsSummary: s.itemsSummary,
427
436
  productId: "",
428
437
  total: s.total,
@@ -434,6 +443,21 @@ export function mapSummaryToRow(s: CheckoutOrderSummary): OrderRow {
434
443
  };
435
444
  }
436
445
 
446
+ function usableContact(state: OrderContactState | undefined): boolean {
447
+ return state === "present" || state === "restricted";
448
+ }
449
+
450
+ function contactPresenceCopy(state: OrderContactState | undefined, subject: string): string {
451
+ switch (state) {
452
+ case "present": return `${subject} is on file in the private contact vault. Viewing details requires owner access or a support grant.`;
453
+ case "restricted": return `${subject} is on file; you do not have access to view its details.`;
454
+ case "absent": return `${subject} is not on file.`;
455
+ case "expired": return `${subject} has expired under the contact retention policy.`;
456
+ case "erased": return `${subject} has been erased.`;
457
+ default: return `${subject} availability could not be verified. Try again shortly.`;
458
+ }
459
+ }
460
+
437
461
  function paymentValue(payment: CheckoutOrderPayment): string {
438
462
  if (payment === "captured") return "Captured";
439
463
  if (payment === "refunded") return "Refunded";
@@ -489,13 +513,11 @@ function deriveReadinessChecks(d: CheckoutOrderDetail, scope: CommerceScope): Or
489
513
  {
490
514
  key: "address",
491
515
  label: "Address",
492
- value: d.shippingAddress ? "Serviceable" : "Unknown",
493
- detail: d.shippingAddress
494
- ? "An address is on the order."
495
- : "No address is on the order.",
496
- tone: d.shippingAddress ? "green" : "amber",
516
+ value: d.contactAvailability?.shipTo ?? (d.shippingAddress ? "present" : "Unknown"),
517
+ detail: contactPresenceCopy(d.contactAvailability?.shipTo ?? (d.shippingAddress ? "present" : undefined), "Ship-to address"),
518
+ tone: usableContact(d.contactAvailability?.shipTo) || (!d.contactAvailability && d.shippingAddress) ? "green" : "amber",
497
519
  action: "View address",
498
- blocking: false,
520
+ blocking: !usableContact(d.contactAvailability?.shipTo) && !(!d.contactAvailability && d.shippingAddress),
499
521
  },
500
522
  columnCheck(column("risk"), {
501
523
  label: "Risk",
@@ -623,6 +645,7 @@ export function emptyOrdersViewModel(targetTenant: string, timeZone: string = DE
623
645
  }
624
646
 
625
647
  export interface GetOrdersViewModelOptions {
648
+ actor?: CommerceActor;
626
649
  targetTenant: string;
627
650
  scope: CommerceScope;
628
651
  client: CheckoutOrderClient;
@@ -698,12 +721,15 @@ async function readOrdersToday(client: CheckoutOrderClient, now: Date, timeZone:
698
721
 
699
722
  export async function getOrdersViewModelResult(opts: GetOrdersViewModelOptions): Promise<OrdersLoadResult> {
700
723
  try {
701
- const { orders, page } = await opts.client.listOrders(opts.filters);
724
+ const listed = await opts.client.listOrders(opts.filters);
725
+ const actor = opts.actor ?? { ...opts.scope, role: "caller" as const };
726
+ const orders = listed.orders.map((order) => projectCheckoutOrder(order, actor, opts.scope));
727
+ const page = listed.page;
702
728
  const details: Record<string, CheckoutOrderDetail> = {};
703
729
  await Promise.all(
704
730
  orders.map(async (o) => {
705
731
  try {
706
- details[o.id] = await opts.client.getOrder(o.id);
732
+ details[o.id] = projectCheckoutOrder(await opts.client.getOrder(o.id), actor, opts.scope);
707
733
  } catch {
708
734
  /* detail unavailable — row still renders from its summary */
709
735
  }
@@ -18,6 +18,7 @@
18
18
  * Nothing here logs. Errors and outcomes carry fixed codes only; fact_id never leaves this module.
19
19
  */
20
20
  import type { Queryable, QueryStatement } from "../../catalog/d1/catalog.js";
21
+ import type { OrderContactAvailability, OrderContactState } from "@tot/public-runtime";
21
22
  import { VaultKeyUnavailableError, type DataKeyPurpose, type EncryptionContext, type KmsClient } from "@/lib/customers/contactVault/kms.js";
22
23
  import {
23
24
  activeKey,
@@ -600,6 +601,96 @@ const ORDER_JOIN_SELECT = `SELECT v.*, o.placed_at AS order_placed_at, o.fulfill
600
601
  FROM scrappy_commerce_orders o
601
602
  JOIN order_contact_vault v ON v.tenant_id = o.tenant_id AND v.stage = o.stage AND v.order_id = o.order_id`;
602
603
 
604
+ const AVAILABILITY_GROUPS = ["identity", "email", "ship"] as const;
605
+ const AVAILABILITY_CHUNK_SIZE = 80;
606
+
607
+ /** Structural envelope presence only; authentication belongs to an audited contact read. */
608
+ function envelopePresentSql(column: string): string {
609
+ return `(typeof(${column}) = 'text' AND length(${column}) >= 40 AND length(${column}) % 4 = 0
610
+ AND rtrim(${column}, '=') NOT GLOB '*[^A-Za-z0-9+/]*'
611
+ AND length(${column}) - length(rtrim(${column}, '=')) <= 2)`;
612
+ }
613
+
614
+ function completeGroupSql(group: typeof AVAILABILITY_GROUPS[number]): string {
615
+ const required = group === "identity" ? "name_ct" : group === "email" ? "email_ct" : "ship_line1_ct";
616
+ const encrypted = LAYOUT[group].sealed.map((column) => column === required
617
+ ? envelopePresentSql(`v.${column}`)
618
+ : `(v.${column} IS NULL OR ${envelopePresentSql(`v.${column}`)})`);
619
+ if (group === "ship") {
620
+ encrypted.push(...["ship_city", "ship_country"].map((column) => `(typeof(v.${column}) = 'text' AND length(trim(v.${column})) > 0)`));
621
+ }
622
+ return `(${encrypted.join(" AND ")})`;
623
+ }
624
+
625
+ const AVAILABILITY_SELECT = `SELECT o.order_id, o.placed_at AS order_placed_at,
626
+ o.fulfillment_state AS order_fulfillment_state, (${correlatedTerminalSql("o")}) AS order_terminal_at,
627
+ v.order_id IS NOT NULL AS vault_present, v.erased_at IS NOT NULL AS erased,
628
+ EXISTS (SELECT 1 FROM order_contact_markers m
629
+ WHERE m.tenant_id = o.tenant_id AND m.stage = o.stage AND m.order_id = o.order_id) AS marked,
630
+ EXISTS (SELECT 1 FROM contact_vault_data_keys k
631
+ WHERE k.tenant_id = v.tenant_id AND k.stage = v.stage AND k.purpose = 'order-contact' AND k.kid = v.order_kid
632
+ AND typeof(k.wrapped_key) = 'text' AND length(trim(k.wrapped_key)) > 0
633
+ AND typeof(k.kms_key_arn) = 'text' AND length(trim(k.kms_key_arn)) > 0) AS key_present,
634
+ v.time_scale, v.ship_country, v.ship_region,
635
+ ${AVAILABILITY_GROUPS.flatMap((group) => [
636
+ // Sentinel columns let the shared retention reader see presence without selecting values.
637
+ `CASE WHEN ${hasDataSql(group, "v.")} THEN 1 END AS ${LAYOUT[group].sealed[0]}`,
638
+ `${completeGroupSql(group)} AS ${group}_complete`,
639
+ ...["ceiling", "expires_at", "floor_until"].map((suffix) => `v.${group}_${suffix}`),
640
+ ]).join(",\n ")}
641
+ FROM scrappy_commerce_orders o
642
+ LEFT JOIN order_contact_vault v ON v.tenant_id = o.tenant_id AND v.stage = o.stage AND v.order_id = o.order_id`;
643
+
644
+ function uniformAvailability(state: OrderContactState): OrderContactAvailability {
645
+ return { identity: state, email: state, shipTo: state };
646
+ }
647
+
648
+ function contactAvailability(row: VaultRow, input: {
649
+ tenantId: string; stage: VaultStage; now: Date; policy: RetentionPolicy;
650
+ }): OrderContactAvailability {
651
+ if (Number(row.vault_present) !== 1) return uniformAvailability(Number(row.marked) === 1 ? "expired" : "absent");
652
+ const times = orderTimes(row.order_placed_at, row.order_fulfillment_state, row.order_terminal_at);
653
+ if (times === "order-time-unreadable" || !Number.isFinite(input.now.getTime())) return uniformAvailability("unreadable");
654
+ const states = groupStates(row, input.tenantId, input.stage, times, input.now, input.policy);
655
+ const state = (group: typeof AVAILABILITY_GROUPS[number]): OrderContactState => {
656
+ const current = states[group];
657
+ if (!current.sealed && Number(row.erased) === 1) return "erased";
658
+ if (!current.expiresAt) return "unreadable";
659
+ if (!isGroupReadable(input.now, current.expiresAt, text(row, `${group}_ceiling`)) ||
660
+ (input.stage === "preview" && input.now.getTime() >= Date.parse(previewDeleteAt(times.placedAt, input.policy)))) return "expired";
661
+ if (!current.sealed) return "absent";
662
+ if (Number(row.key_present) !== 1 || Number(row[`${group}_complete`]) !== 1) return "unreadable";
663
+ return current.readable ? "present" : "unreadable";
664
+ };
665
+ return { identity: state("identity"), email: state("email"), shipTo: state("ship") };
666
+ }
667
+
668
+ /** Retained contact presence for a bounded order set; no decrypt, audit write, or private values. */
669
+ export async function readOrderContactAvailability(
670
+ db: { all<T extends Record<string, unknown>>(sql: string, ...params: unknown[]): Promise<T[]> },
671
+ input: { tenantId: string; stage: VaultStage; orderIds: readonly string[]; now: Date; policy?: RetentionPolicy },
672
+ ): Promise<ReadonlyMap<string, OrderContactAvailability>> {
673
+ const orderIds = [...new Set(input.orderIds)];
674
+ const result = new Map(orderIds.map((id) => [id, uniformAvailability("absent")]));
675
+ const policy = input.policy ?? RETENTION_POLICY;
676
+ try {
677
+ for (let start = 0; start < orderIds.length; start += AVAILABILITY_CHUNK_SIZE) {
678
+ const chunk = orderIds.slice(start, start + AVAILABILITY_CHUNK_SIZE);
679
+ const rows = await db.all<VaultRow>(
680
+ `${AVAILABILITY_SELECT} WHERE o.tenant_id = ? AND o.stage = ? AND o.order_id IN (${chunk.map(() => "?").join(", ")})`,
681
+ input.tenantId, input.stage, ...chunk,
682
+ );
683
+ for (const row of rows) {
684
+ const id = text(row, "order_id");
685
+ if (id !== null && chunk.includes(id)) result.set(id, contactAvailability(row, { ...input, policy }));
686
+ }
687
+ }
688
+ return result;
689
+ } catch {
690
+ return new Map(orderIds.map((id) => [id, uniformAvailability("unreadable")]));
691
+ }
692
+ }
693
+
603
694
  interface Viewable {
604
695
  groups: Group[];
605
696
  views: Partial<Record<Group, GroupView>>;
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import type { APIContext } from "astro";
9
9
  import { requireAdminApiEntry } from "@/lib/access/auth/adminApiGuard";
10
+ import { readViewerSession } from "@/lib/access/auth/route";
10
11
  import { commerceActorForAdmin, OrderApiError, projectCheckoutOrder } from "@/lib/orders/admin/orderClient";
11
12
  import { resolveOrderSource, workerOrderSourceDeps } from "@/lib/orders/admin/orderSource";
12
13
  import { admitSharedStoreTenant } from "@/lib/checkout/commerce/sharedStoreAdmission";
@@ -47,7 +48,8 @@ export async function GET(context: APIContext): Promise<Response> {
47
48
  const actor = commerceActorForAdmin({
48
49
  tenantId: scope.tenantId,
49
50
  stage: scope.stage,
50
- owner: access.entry.isOwnerViewer,
51
+ record: await readViewerSession(context),
52
+ nowSeconds: Math.floor(Date.now() / 1000),
51
53
  admitted: access.entry.admitted,
52
54
  });
53
55
  const order = projectCheckoutOrder(await source.client.getOrder(orderId), actor, scope);
@@ -11,7 +11,8 @@
11
11
  */
12
12
  import type { APIContext } from "astro";
13
13
  import { requireAdminApiEntry } from "@/lib/access/auth/adminApiGuard";
14
- import { OrderApiError } from "@/lib/orders/admin/orderClient";
14
+ import { readViewerSession } from "@/lib/access/auth/route";
15
+ import { commerceActorForAdmin, OrderApiError, projectCheckoutOrder } from "@/lib/orders/admin/orderClient";
15
16
  import { resolveOrderSource, workerOrderSourceDeps } from "@/lib/orders/admin/orderSource";
16
17
  import { admitSharedStoreTenant } from "@/lib/checkout/commerce/sharedStoreAdmission";
17
18
  import { assessCheckoutOrder, parseOrderFilters } from "@/lib/orders/admin/services/orders";
@@ -51,10 +52,11 @@ export async function GET(context: APIContext): Promise<Response> {
51
52
 
52
53
  try {
53
54
  const data = await source.client.listOrders(filters);
54
- const orders = data.orders.map((order) => ({
55
- ...order,
56
- readiness: assessCheckoutOrder(order, source.scope),
57
- }));
55
+ const actor = commerceActorForAdmin({ ...source.scope, record: await readViewerSession(context), nowSeconds: Math.floor(Date.now() / 1000), admitted: access.entry.admitted });
56
+ const orders = data.orders.map((raw) => {
57
+ const order = projectCheckoutOrder(raw, actor, source.scope);
58
+ return { ...order, readiness: assessCheckoutOrder(order, source.scope) };
59
+ });
58
60
  return new Response(JSON.stringify({ ...data, orders }), { headers: JSON_HEADERS });
59
61
  } catch (err) {
60
62
  if (err instanceof OrderApiError) return jsonError(err.code, err.status);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "6.0.2",
3
+ "version": "6.1.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",
@@ -27,6 +27,15 @@ export interface CheckoutOrderCustomer {
27
27
  name: string;
28
28
  }
29
29
 
30
+ /** Operational presence only. No customer or address values cross this boundary. */
31
+ export type OrderContactState = "present" | "absent" | "restricted" | "expired" | "erased" | "unreadable";
32
+
33
+ export interface OrderContactAvailability {
34
+ identity: OrderContactState;
35
+ email: OrderContactState;
36
+ shipTo: OrderContactState;
37
+ }
38
+
30
39
  /** A row in the orders list. All fields populated by U1 EXCEPT `verification`. */
31
40
  export interface CheckoutOrderSummary {
32
41
  /** Order / transaction id (string). */
@@ -35,6 +44,8 @@ export interface CheckoutOrderSummary {
35
44
  placedAt: string;
36
45
  currency: string;
37
46
  customer: CheckoutOrderCustomer;
47
+ /** Sealed contact availability, independent of permission to read its values. */
48
+ contactAvailability?: OrderContactAvailability;
38
49
  /** Human one-liner, e.g. "Vape Pen Starter Kit +1 more". */
39
50
  itemsSummary: string;
40
51
  itemCount: number;
@@ -300,6 +311,8 @@ export type CommerceCommand =
300
311
  | "reconcile";
301
312
 
302
313
  export interface CommerceCommandInput {
314
+ /** Current server-read presence; shipment commands fail closed when it is unknown. */
315
+ shipTo?: OrderContactState;
303
316
  command: CommerceCommand;
304
317
  payment: PaymentState;
305
318
  stock: StockState;
@@ -311,7 +324,7 @@ export interface CommerceCommandInput {
311
324
 
312
325
  export interface CommerceCommandDecision {
313
326
  admit: boolean;
314
- reason: "admitted" | "evidence" | "payment" | "stock" | "fulfillment" | "label" | "reconcile" | "reuse";
327
+ reason: "admitted" | "evidence" | "payment" | "stock" | "fulfillment" | "label" | "reconcile" | "reuse" | "ship-to";
315
328
  restock: false;
316
329
  releaseReservation: boolean;
317
330
  blindRepurchase: false;
@@ -601,6 +614,7 @@ export function decideCommerceCommand(input: CommerceCommandInput): CommerceComm
601
614
  if (input.stock !== "confirmed") return deny("stock");
602
615
  if (input.fulfillment !== "ready") return deny("fulfillment");
603
616
  if (input.label !== "none" && input.label !== "voided") return deny("label");
617
+ if (input.shipTo !== "present" && input.shipTo !== "restricted") return deny("ship-to");
604
618
  return allow();
605
619
  case "print-label":
606
620
  if (input.fulfillment === "cancelled") return deny("fulfillment");
@@ -613,6 +627,7 @@ export function decideCommerceCommand(input: CommerceCommandInput): CommerceComm
613
627
  if (input.stock !== "confirmed") return deny("stock");
614
628
  if (input.fulfillment !== "ready") return deny("fulfillment");
615
629
  if (input.label !== "purchased" && input.label !== "printed") return deny("label");
630
+ if (input.shipTo !== "present" && input.shipTo !== "restricted") return deny("ship-to");
616
631
  return allow();
617
632
  default: {
618
633
  const unreachable: never = input.command;