@tokenoftrust/storefront-runner 6.0.2 → 6.1.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.
@@ -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";
@@ -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,6 +26,7 @@ import type {
26
26
  PublicCommerceEvent,
27
27
  StockState,
28
28
  TenantConfig,
29
+ OrderContactState,
29
30
  } from "@tot/public-runtime";
30
31
  import {
31
32
  actorMayCommand,
@@ -158,6 +159,7 @@ export type AuthorityState = "fresh" | "expired" | "unreadable" | "absent";
158
159
 
159
160
  /** One commerce order. Columns do not borrow each other's state, source, or as-of. */
160
161
  export interface ScrappyOrderRecord {
162
+ shipTo?: OrderContactState;
161
163
  id: string;
162
164
  placedAt: string;
163
165
  tenantId: string;
@@ -264,6 +266,7 @@ function deriveRisk(
264
266
 
265
267
  function deriveNextStep(fulfillment: OrderShipState, blocker: string | null): string {
266
268
  if (fulfillment === "hold_review_required") {
269
+ if (blocker === "Ship-to address") return "Review address";
267
270
  return blocker === "Payment capture" ? "Capture payment" : "Review risk";
268
271
  }
269
272
  if (fulfillment === "completed") return "View record";
@@ -348,6 +351,7 @@ export function releaseOf(order: ScrappyOrderRecord): OrderRelease {
348
351
  if (!evidencePermitsRelease(evidence)) blocks.push("evidence");
349
352
  if (order.stock.state !== "confirmed" && !blocks.includes("stock")) blocks.push("stock");
350
353
  if (order.fulfillment.state !== "ready" && !blocks.includes("fulfillment")) blocks.push("fulfillment");
354
+ if (!usableContact(order.shipTo)) blocks.push("ship-to");
351
355
 
352
356
  const decision = decideCommerceCommand({
353
357
  command: "hand-off",
@@ -357,6 +361,7 @@ export function releaseOf(order: ScrappyOrderRecord): OrderRelease {
357
361
  label: order.label,
358
362
  evidence,
359
363
  providerOutcome: order.providerOutcome,
364
+ shipTo: order.shipTo,
360
365
  });
361
366
  if (!decision.admit) {
362
367
  if (decision.reason === "payment" && !blocks.includes("payment") && !blocks.includes("refund")) blocks.push("payment");
@@ -383,6 +388,7 @@ function checkoutRecord(order: CheckoutOrderSummary, scope: CommerceScope): Scra
383
388
  tenantId: scope.tenantId,
384
389
  stage: scope.stage,
385
390
  currency: order.currency,
391
+ shipTo: order.contactAvailability?.shipTo,
386
392
  payment: { state: payment, source: "checkout-order", asOf: order.placedAt },
387
393
  tax: readSourcedEvidence(order.verification, "tax"),
388
394
  verification: readSourcedEvidence(order.verification, "verification"),
@@ -415,14 +421,16 @@ export function assessCheckoutOrder(order: CheckoutOrderSummary, scope: Commerce
415
421
 
416
422
  export function mapSummaryToRow(s: CheckoutOrderSummary): OrderRow {
417
423
  const verificationMissing = s.verification == null;
418
- const shipState: OrderShipState = verificationMissing ? "hold_review_required" : s.fulfillment;
419
- const blocker = s.blocker ?? (verificationMissing ? "Verification unknown" : null);
424
+ const addressMissing = !usableContact(s.contactAvailability?.shipTo);
425
+ const addressHold = s.fulfillment !== "completed" && addressMissing;
426
+ const shipState: OrderShipState = verificationMissing || addressHold ? "hold_review_required" : s.fulfillment;
427
+ const blocker = s.blocker ?? (verificationMissing ? "Verification unknown" : addressHold ? "Ship-to address" : null);
420
428
  return {
421
429
  id: s.id,
422
430
  placedAt: s.placedAt,
423
431
  customerId: s.customer.id ?? "",
424
432
  customerName: s.customer.name,
425
- customerNote: "",
433
+ customerNote: contactPresenceCopy(s.contactAvailability?.identity, "Customer identity"),
426
434
  itemsSummary: s.itemsSummary,
427
435
  productId: "",
428
436
  total: s.total,
@@ -434,6 +442,21 @@ export function mapSummaryToRow(s: CheckoutOrderSummary): OrderRow {
434
442
  };
435
443
  }
436
444
 
445
+ function usableContact(state: OrderContactState | undefined): boolean {
446
+ return state === "present" || state === "restricted";
447
+ }
448
+
449
+ function contactPresenceCopy(state: OrderContactState | undefined, subject: string): string {
450
+ switch (state) {
451
+ case "present": return `${subject} is on file in the private contact vault. Viewing details requires owner access or a support grant.`;
452
+ case "restricted": return `${subject} is on file; you do not have access to view its details.`;
453
+ case "absent": return `${subject} is not on file.`;
454
+ case "expired": return `${subject} has expired under the contact retention policy.`;
455
+ case "erased": return `${subject} has been erased.`;
456
+ default: return `${subject} availability could not be verified. Try again shortly.`;
457
+ }
458
+ }
459
+
437
460
  function paymentValue(payment: CheckoutOrderPayment): string {
438
461
  if (payment === "captured") return "Captured";
439
462
  if (payment === "refunded") return "Refunded";
@@ -489,13 +512,11 @@ function deriveReadinessChecks(d: CheckoutOrderDetail, scope: CommerceScope): Or
489
512
  {
490
513
  key: "address",
491
514
  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",
515
+ value: d.contactAvailability?.shipTo ?? (d.shippingAddress ? "present" : "Unknown"),
516
+ detail: contactPresenceCopy(d.contactAvailability?.shipTo ?? (d.shippingAddress ? "present" : undefined), "Ship-to address"),
517
+ tone: usableContact(d.contactAvailability?.shipTo) || (!d.contactAvailability && d.shippingAddress) ? "green" : "amber",
497
518
  action: "View address",
498
- blocking: false,
519
+ blocking: !usableContact(d.contactAvailability?.shipTo) && !(!d.contactAvailability && d.shippingAddress),
499
520
  },
500
521
  columnCheck(column("risk"), {
501
522
  label: "Risk",
@@ -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>>;
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.0",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",
@@ -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;