@tokenoftrust/storefront-runner 4.9.1 → 4.9.3

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 (28) hide show
  1. package/apps/storefront/migrations-apps/0021_contact_vault.sql +162 -0
  2. package/apps/storefront/src/lib/access/auth/contactReaderAuthority.ts +64 -0
  3. package/apps/storefront/src/lib/access/shared/viewerGrant.ts +30 -0
  4. package/apps/storefront/src/lib/customers/contactVault/contactAudit.ts +80 -0
  5. package/apps/storefront/src/lib/customers/contactVault/envelope.ts +166 -0
  6. package/apps/storefront/src/lib/customers/contactVault/retention.ts +44 -0
  7. package/apps/storefront/src/lib/engagement/contact/contactMessages.ts +242 -65
  8. package/apps/storefront/src/lib/engagement/contact/contract.ts +2 -0
  9. package/apps/storefront/src/lib/fulfillment/commerceProjection.ts +0 -1
  10. package/apps/storefront/src/lib/fulfillment/fulfillmentService.ts +3 -78
  11. package/apps/storefront/src/lib/integrations/webhooks/deliveryEngine.ts +4 -0
  12. package/apps/storefront/src/lib/orders/admin/orderActions.ts +229 -19
  13. package/apps/storefront/src/lib/orders/data/commerceCallerConfig.ts +37 -0
  14. package/apps/storefront/src/lib/orders/data/orderForwardReceiver.ts +10 -3
  15. package/apps/storefront/src/lib/orders/data/orderMapper.ts +9 -0
  16. package/apps/storefront/src/lib/orders/data/sourceAuthorityContract.ts +2 -1
  17. package/apps/storefront/src/lib/orders/vault/contactIntake.ts +61 -0
  18. package/apps/storefront/src/lib/orders/vault/contactRoutes.ts +99 -0
  19. package/apps/storefront/src/lib/orders/vault/keyringEnv.ts +7 -0
  20. package/apps/storefront/src/lib/orders/vault/orderContact.ts +96 -0
  21. package/apps/storefront/src/lib/orders/vault/orderContactVault.ts +369 -0
  22. package/apps/storefront/src/lib/publication/d1/schema-apps.ts +22 -11
  23. package/apps/storefront/src/lib/publication/shared/routedApiParam.ts +3 -0
  24. package/apps/storefront/src/pages/api/admin/fulfillment/orders/[id]/address.ts +26 -23
  25. package/apps/storefront/src/pages/api/contact.ts +16 -4
  26. package/apps/storefront/src/scheduled/contactVaultSweep.ts +80 -0
  27. package/package.json +1 -1
  28. package/packages/public-runtime/tests/fixtures/compliance-decisions.golden.json +609 -0
@@ -1,20 +1,41 @@
1
1
  /**
2
2
  * `contact_messages` store — visitor messages from a store's contact form, kept in
3
- * `STOREFRONT_APPS_DB`. Pure over a minimal SQL seam, so the Worker runs it
3
+ * `STOREFRONT_APPS_DB` (migrations-apps/0021). Pure over a minimal SQL seam, so the Worker runs it
4
4
  * against D1 and tests against `node:sqlite`.
5
5
  *
6
- * Every method takes the tenant id and scopes its SQL by it: an id belonging to
7
- * another tenant is indistinguishable from an id that does not exist. The caller
8
- * resolves `tenantId` from the routed tenant, never from a request body.
6
+ * The visitor's name, email, phone and message exist at rest only as one contact vault envelope
7
+ * (`lib/customers/contactVault/envelope.ts`, purpose `contact-message`) whose AAD binds tenant, stage, message
8
+ * id and the immutable `not_after`. A row still holding legacy plaintext is never read; the rollout
9
+ * and the hourly sweep seal it ({@link sealLegacyContactMessages}).
10
+ *
11
+ * Every method takes the tenant id and the routed stage and scopes its SQL by both: an id belonging
12
+ * to another tenant or stage is indistinguishable from an id that does not exist. Every read writes
13
+ * its `contact_access_audit` row before anything is decrypted. Nothing here logs.
9
14
  */
15
+ import {
16
+ openVaultRecord,
17
+ sealVaultRecord,
18
+ VaultKeyUnavailableError,
19
+ type VaultBinding,
20
+ type VaultKeyring,
21
+ } from "@/lib/customers/contactVault/envelope.js";
22
+ import { isReadable, notAfterFrom } from "@/lib/customers/contactVault/retention.js";
23
+ import { admitAudit, auditStatement, type ContactActor, type VaultStage } from "@/lib/customers/contactVault/contactAudit.js";
24
+
25
+ interface QueryStatement {
26
+ sql: string;
27
+ params?: readonly unknown[];
28
+ }
29
+
10
30
  /** The SQL surface this store needs; D1's `fromD1` and the test harness both satisfy it. */
11
31
  export interface ContactDb {
12
32
  all<T = Record<string, unknown>>(sql: string, ...params: unknown[]): Promise<T[]>;
13
33
  first<T = Record<string, unknown>>(sql: string, ...params: unknown[]): Promise<T | null>;
14
34
  run(sql: string, ...params: unknown[]): Promise<void>;
35
+ /** D1 executes a batch sequentially inside one implicit transaction. */
36
+ batch?(statements: readonly QueryStatement[]): Promise<void>;
15
37
  }
16
38
 
17
- export const CONTACT_RETENTION_DAYS = 365;
18
39
  export const CONTACT_LIST_LIMIT = 200;
19
40
 
20
41
  export type ContactMessageStatus = "unread" | "read";
@@ -22,6 +43,7 @@ export type ContactMessageStatus = "unread" | "read";
22
43
  export interface NewContactMessage {
23
44
  id: string;
24
45
  tenantId: string;
46
+ stage: VaultStage;
25
47
  name: string;
26
48
  email: string;
27
49
  phone: string;
@@ -41,114 +63,269 @@ export interface ContactMessage {
41
63
  readAt: string | null;
42
64
  }
43
65
 
44
- interface ContactMessageRow {
66
+ export type ContactRefusal =
67
+ | { ok: false; status: 404; reason: "not-found" }
68
+ | { ok: false; status: 429; reason: "quota" }
69
+ | { ok: false; status: 503; reason: "audit-unavailable" | "vault-key-unavailable" };
70
+
71
+ export type ListOutcome = { ok: true; messages: ContactMessage[] } | ContactRefusal;
72
+ export type SupportReadOutcome = { ok: true; message: ContactMessage } | ContactRefusal;
73
+ export type EraseOutcome = { ok: true } | ContactRefusal;
74
+
75
+ interface SealedRow {
45
76
  id: string;
46
- name: string;
47
- email: string;
48
- phone: string;
49
- message: string;
77
+ key_id: string;
78
+ nonce: string;
79
+ ciphertext: string;
80
+ not_after: string;
81
+ expires_at: string;
50
82
  page_path: string | null;
51
83
  status: string;
52
84
  created_at: string;
53
85
  read_at: string | null;
54
86
  }
55
87
 
56
- function toMessage(row: ContactMessageRow): ContactMessage {
57
- return {
58
- id: row.id,
59
- name: row.name,
60
- email: row.email,
61
- phone: row.phone,
62
- message: row.message,
63
- pagePath: row.page_path,
64
- status: row.status === "read" ? "read" : "unread",
65
- createdAt: row.created_at,
66
- readAt: row.read_at,
67
- };
88
+ interface ContactPlaintext {
89
+ name: string;
90
+ email: string;
91
+ phone: string;
92
+ message: string;
93
+ }
94
+
95
+ const SEALED_COLUMNS = "id, key_id, nonce, ciphertext, not_after, expires_at, page_path, status, created_at, read_at";
96
+ /** A row the store may act on: sealed, and strictly before both its expiry and its ceiling. */
97
+ const READABLE = "ciphertext IS NOT NULL AND ? < expires_at AND ? < not_after";
98
+
99
+ function binding(tenantId: string, stage: VaultStage, id: string, notAfter: string): VaultBinding {
100
+ return { purpose: "contact-message", tenantId, stage, recordId: id, notAfter };
101
+ }
102
+
103
+ function requireBatch(db: ContactDb): NonNullable<ContactDb["batch"]> {
104
+ if (!db.batch) throw new Error("D1 batch is unavailable");
105
+ return db.batch.bind(db);
106
+ }
107
+
108
+ function asPlaintext(value: unknown): ContactPlaintext | null {
109
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
110
+ const v = value as Record<string, unknown>;
111
+ const fields = [v.name, v.email, v.phone, v.message];
112
+ if (!fields.every((f) => typeof f === "string")) return null;
113
+ return { name: v.name as string, email: v.email as string, phone: v.phone as string, message: v.message as string };
68
114
  }
69
115
 
70
116
  export class ContactMessagesStore {
71
117
  constructor(
72
118
  private readonly db: ContactDb,
119
+ private readonly keyring: VaultKeyring | null,
73
120
  private readonly now: () => Date = () => new Date(),
74
121
  ) {}
75
122
 
123
+ /** Seal and store one message. Throws {@link VaultKeyUnavailableError} (writing nothing) without a keyring. */
76
124
  async insert(input: NewContactMessage): Promise<void> {
125
+ const created = this.now();
126
+ const notAfter = notAfterFrom(created);
127
+ const plaintext: ContactPlaintext = { name: input.name, email: input.email, phone: input.phone, message: input.message };
128
+ const sealed = await sealVaultRecord(this.keyring, binding(input.tenantId, input.stage, input.id, notAfter), plaintext);
77
129
  await this.db.run(
78
130
  `INSERT INTO contact_messages
79
- (id, tenant_id, name, email, phone, message, page_path, status, created_at, notify_status, notify_attempts)
80
- VALUES (?, ?, ?, ?, ?, ?, ?, 'unread', ?, 'pending', 0)`,
131
+ (id, tenant_id, stage, key_id, nonce, ciphertext, not_after, expires_at, page_path, status, created_at, notify_status, notify_attempts)
132
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'unread', ?, 'pending', 0)`,
81
133
  input.id,
82
134
  input.tenantId,
83
- input.name,
84
- input.email,
85
- input.phone,
86
- input.message,
135
+ input.stage,
136
+ sealed.keyId,
137
+ sealed.nonce,
138
+ sealed.ciphertext,
139
+ notAfter,
140
+ notAfter,
87
141
  input.pagePath,
88
- this.now().toISOString(),
142
+ created.toISOString(),
89
143
  );
90
144
  }
91
145
 
92
- /** Newest first, capped at {@link CONTACT_LIST_LIMIT}. */
93
- async list(tenantId: string): Promise<ContactMessage[]> {
94
- const rows = await this.db.all<ContactMessageRow>(
95
- `SELECT id, name, email, phone, message, page_path, status, created_at, read_at
96
- FROM contact_messages
97
- WHERE tenant_id = ?
146
+ /** The owner inbox: one `list` audit row commits, then newest first, capped at {@link CONTACT_LIST_LIMIT}. */
147
+ async list(tenantId: string, stage: VaultStage, actor: ContactActor, auditId: string): Promise<ListOutcome> {
148
+ if (!this.keyring) return { ok: false, status: 503, reason: "vault-key-unavailable" };
149
+ const now = this.now();
150
+ const admitted = await admitAudit(this.db, auditStatement({
151
+ auditId, tenantId, stage, recordKind: "contact-message", recordId: "*", actor, action: "list", now,
152
+ }));
153
+ if (admitted === "quota") return { ok: false, status: 429, reason: "quota" };
154
+ if (admitted === "unavailable") return { ok: false, status: 503, reason: "audit-unavailable" };
155
+ const iso = now.toISOString();
156
+ const rows = await this.db.all<SealedRow>(
157
+ `SELECT ${SEALED_COLUMNS} FROM contact_messages
158
+ WHERE tenant_id = ? AND stage = ? AND ${READABLE}
98
159
  ORDER BY created_at DESC, id DESC
99
160
  LIMIT ?`,
100
- tenantId,
101
- CONTACT_LIST_LIMIT,
161
+ tenantId, stage, iso, iso, CONTACT_LIST_LIMIT,
162
+ );
163
+ const messages: ContactMessage[] = [];
164
+ for (const row of rows) {
165
+ const message = await this.open(tenantId, stage, row, now);
166
+ if (message) messages.push(message);
167
+ }
168
+ return { ok: true, messages };
169
+ }
170
+
171
+ /**
172
+ * ToT support reads one message. The caller has already proven a live staff selection and a live
173
+ * owner-consented grant; the audit row (with the staff quota in the same statement) commits first.
174
+ */
175
+ async supportRead(tenantId: string, stage: VaultStage, id: string, actor: ContactActor, auditId: string): Promise<SupportReadOutcome> {
176
+ if (!this.keyring) return { ok: false, status: 503, reason: "vault-key-unavailable" };
177
+ const now = this.now();
178
+ const admitted = await admitAudit(this.db, auditStatement({
179
+ auditId, tenantId, stage, recordKind: "contact-message", recordId: id, actor, action: "read", now,
180
+ }));
181
+ if (admitted === "quota") return { ok: false, status: 429, reason: "quota" };
182
+ if (admitted === "unavailable") return { ok: false, status: 503, reason: "audit-unavailable" };
183
+ const iso = now.toISOString();
184
+ const row = await this.db.first<SealedRow>(
185
+ `SELECT ${SEALED_COLUMNS} FROM contact_messages WHERE tenant_id = ? AND stage = ? AND id = ? AND ${READABLE}`,
186
+ tenantId, stage, id, iso, iso,
102
187
  );
103
- return rows.map(toMessage);
188
+ const message = row ? await this.open(tenantId, stage, row, now) : null;
189
+ return message ? { ok: true, message } : { ok: false, status: 404, reason: "not-found" };
104
190
  }
105
191
 
106
- async unreadCount(tenantId: string): Promise<number> {
192
+ async unreadCount(tenantId: string, stage: VaultStage): Promise<number> {
193
+ const iso = this.now().toISOString();
107
194
  const row = await this.db.first<{ n: number }>(
108
- `SELECT COUNT(*) AS n FROM contact_messages WHERE tenant_id = ? AND status = 'unread'`,
109
- tenantId,
195
+ `SELECT COUNT(*) AS n FROM contact_messages WHERE tenant_id = ? AND stage = ? AND status = 'unread' AND ${READABLE}`,
196
+ tenantId, stage, iso, iso,
110
197
  );
111
198
  return Number(row?.n ?? 0);
112
199
  }
113
200
 
114
- /** True when the tenant owns a message with this id (now marked read). */
115
- async markRead(tenantId: string, id: string): Promise<boolean> {
201
+ /** True when the tenant holds a readable message with this id in this stage (now marked read). */
202
+ async markRead(tenantId: string, stage: VaultStage, id: string): Promise<boolean> {
203
+ const iso = this.now().toISOString();
116
204
  const rows = await this.db.all<{ id: string }>(
117
205
  `UPDATE contact_messages
118
206
  SET status = 'read', read_at = COALESCE(read_at, ?)
119
- WHERE tenant_id = ? AND id = ?
207
+ WHERE tenant_id = ? AND stage = ? AND id = ? AND ${READABLE}
120
208
  RETURNING id`,
121
- this.now().toISOString(),
122
- tenantId,
123
- id,
209
+ iso, tenantId, stage, id, iso, iso,
124
210
  );
125
211
  return rows.length > 0;
126
212
  }
127
213
 
128
- /** True when the tenant owned a message with this id (now deleted). */
129
- async delete(tenantId: string, id: string): Promise<boolean> {
130
- const rows = await this.db.all<{ id: string }>(
131
- `DELETE FROM contact_messages WHERE tenant_id = ? AND id = ? RETURNING id`,
132
- tenantId,
133
- id,
134
- );
135
- return rows.length > 0;
214
+ /** Erase one sealed message: its audit row and its delete commit in one batch, or neither does. */
215
+ async erase(tenantId: string, stage: VaultStage, id: string, actor: ContactActor, auditId: string): Promise<EraseOutcome> {
216
+ const now = this.now();
217
+ const rowSql = `SELECT 1 FROM contact_messages WHERE tenant_id = ? AND stage = ? AND id = ? AND ciphertext IS NOT NULL`;
218
+ const exists = await this.db.first(rowSql, tenantId, stage, id);
219
+ if (!exists) return { ok: false, status: 404, reason: "not-found" };
220
+ const audit = auditStatement({
221
+ auditId, tenantId, stage, recordKind: "contact-message", recordId: id, actor, action: "erase", now,
222
+ requireRow: { sql: rowSql, params: [tenantId, stage, id] },
223
+ });
224
+ const statements: QueryStatement[] = [
225
+ { sql: audit.sql.replace(/\s*RETURNING audit_id$/, ""), params: audit.params },
226
+ {
227
+ sql: `DELETE FROM contact_messages WHERE tenant_id = ? AND stage = ? AND id = ? AND ciphertext IS NOT NULL
228
+ AND EXISTS (SELECT 1 FROM contact_access_audit WHERE audit_id = ?)`,
229
+ params: [tenantId, stage, id, auditId],
230
+ },
231
+ ];
232
+ try {
233
+ await requireBatch(this.db)(statements);
234
+ } catch {
235
+ return { ok: false, status: 503, reason: "audit-unavailable" };
236
+ }
237
+ const audited = await this.db.first(`SELECT 1 AS present FROM contact_access_audit WHERE audit_id = ?`, auditId);
238
+ if (audited) return { ok: true };
239
+ const still = await this.db.first(rowSql, tenantId, stage, id);
240
+ return still ? { ok: false, status: 429, reason: "quota" } : { ok: false, status: 404, reason: "not-found" };
241
+ }
242
+
243
+ /** Decrypt one row, or null when it is past retention or does not authenticate under its binding. */
244
+ private async open(tenantId: string, stage: VaultStage, row: SealedRow, now: Date): Promise<ContactMessage | null> {
245
+ if (!isReadable(now, row.expires_at, row.not_after)) return null;
246
+ const opened = await openVaultRecord(this.keyring, binding(tenantId, stage, row.id, row.not_after), {
247
+ keyId: row.key_id, nonce: row.nonce, ciphertext: row.ciphertext,
248
+ }).catch((error: unknown) => {
249
+ if (error instanceof VaultKeyUnavailableError) return null;
250
+ throw error;
251
+ });
252
+ const plain = asPlaintext(opened);
253
+ if (!plain) return null;
254
+ return {
255
+ id: row.id,
256
+ ...plain,
257
+ pagePath: row.page_path,
258
+ status: row.status === "read" ? "read" : "unread",
259
+ createdAt: row.created_at,
260
+ readAt: row.read_at,
261
+ };
136
262
  }
137
263
  }
138
264
 
139
265
  /**
140
- * Delete every message older than the retention window, across all tenants.
141
- * Returns the number of rows removed.
266
+ * Delete every message at or past its expiry or its ceiling, across all tenants and stages (legacy
267
+ * rows included). Returns the number of rows removed. An unreadable clock throws.
142
268
  */
143
- export async function purgeExpiredContactMessages(
144
- db: ContactDb,
145
- now: Date = new Date(),
146
- retentionDays: number = CONTACT_RETENTION_DAYS,
147
- ): Promise<number> {
148
- const cutoff = new Date(now.getTime() - retentionDays * 86_400_000).toISOString();
269
+ export async function purgeExpiredContactMessages(db: ContactDb, now: Date): Promise<number> {
270
+ if (Number.isNaN(now.getTime())) throw new Error("contact message purge: unreadable clock");
271
+ const iso = now.toISOString();
149
272
  const rows = await db.all<{ id: string }>(
150
- `DELETE FROM contact_messages WHERE created_at < ? RETURNING id`,
151
- cutoff,
273
+ `DELETE FROM contact_messages WHERE expires_at <= ? OR not_after <= ? RETURNING id`,
274
+ iso, iso,
152
275
  );
153
276
  return rows.length;
154
277
  }
278
+
279
+ interface LegacyRow {
280
+ id: string;
281
+ tenant_id: string;
282
+ stage: VaultStage;
283
+ not_after: string;
284
+ legacy_name: string | null;
285
+ legacy_email: string | null;
286
+ legacy_phone: string | null;
287
+ legacy_message: string | null;
288
+ }
289
+
290
+ /**
291
+ * Seal up to `limit` rows still holding legacy plaintext (oldest id first), across all tenants.
292
+ * Each row is one statement: the envelope is written and every `legacy_*` column nulled together,
293
+ * only while the row is still unsealed. `remaining` counts the unsealed rows left.
294
+ */
295
+ export async function sealLegacyContactMessages(
296
+ db: ContactDb,
297
+ keyring: VaultKeyring,
298
+ now: Date,
299
+ limit: number,
300
+ ): Promise<{ sealed: number; remaining: number }> {
301
+ if (Number.isNaN(now.getTime())) throw new Error("contact message sealing: unreadable clock");
302
+ const rows = await db.all<LegacyRow>(
303
+ `SELECT id, tenant_id, stage, not_after, legacy_name, legacy_email, legacy_phone, legacy_message
304
+ FROM contact_messages WHERE ciphertext IS NULL
305
+ ORDER BY id LIMIT ?`,
306
+ Math.max(0, Math.floor(limit)),
307
+ );
308
+ let sealed = 0;
309
+ for (const row of rows) {
310
+ const plaintext: ContactPlaintext = {
311
+ name: row.legacy_name ?? "",
312
+ email: row.legacy_email ?? "",
313
+ phone: row.legacy_phone ?? "",
314
+ message: row.legacy_message ?? "",
315
+ };
316
+ const envelope = await sealVaultRecord(keyring, binding(row.tenant_id, row.stage, row.id, row.not_after), plaintext);
317
+ const changed = await db.all<{ id: string }>(
318
+ `UPDATE contact_messages
319
+ SET key_id = ?, nonce = ?, ciphertext = ?,
320
+ legacy_name = NULL, legacy_email = NULL, legacy_phone = NULL, legacy_message = NULL
321
+ WHERE id = ? AND ciphertext IS NULL
322
+ RETURNING id`,
323
+ envelope.keyId, envelope.nonce, envelope.ciphertext, row.id,
324
+ );
325
+ sealed += changed.length;
326
+ }
327
+ const left = await db.first<{ n: number }>(
328
+ `SELECT COUNT(*) AS n FROM contact_messages WHERE ciphertext IS NULL`,
329
+ );
330
+ return { sealed, remaining: Number(left?.n ?? 0) };
331
+ }
@@ -8,6 +8,7 @@
8
8
  * → 200 { ok:true }
9
9
  * 400 invalid_json | invalid_input (+ field)
10
10
  * 403 forbidden_origin | invalid_token
11
+ * 409 stage-unknown (the store's stage could not be resolved)
11
12
  * 413 too_large · 415 unsupported_media_type
12
13
  * 429 rate_limited · 503 unavailable
13
14
  * 500 store_failed (keep the visitor's text and retry)
@@ -56,6 +57,7 @@ export type ContactErrorCode =
56
57
  | "unsupported_media_type"
57
58
  | "rate_limited"
58
59
  | "unavailable"
60
+ | "stage-unknown"
59
61
  | "store_failed";
60
62
 
61
63
  export type ContactSubmitResponse =
@@ -104,7 +104,6 @@ export async function projectCommerceOrder(
104
104
  }
105
105
  : {}),
106
106
  lines,
107
- address: null,
108
107
  compliance: trusted ? snapshot.compliance : "UNKNOWN",
109
108
  stock: trusted && snapshot.payment === "captured" ? "confirmed" : "UNKNOWN",
110
109
  fulfillment:
@@ -18,7 +18,6 @@ import {
18
18
  actorMayCommand,
19
19
  decideCommerceCommand,
20
20
  publicCommerceEvent,
21
- readOwnerAddress,
22
21
  totalsMatch,
23
22
  type CommerceActor,
24
23
  type CommerceEvidence,
@@ -32,6 +31,7 @@ import {
32
31
  type StockState,
33
32
  } from "@tot/public-runtime";
34
33
  import { readD1 } from "@/lib/publication/shared/env";
34
+ import { terminalTransitionExpiryStatement } from "@/lib/orders/vault/orderContactVault.js";
35
35
  import { decideAdminApiAccess } from "@/lib/access/auth/adminApiGuard";
36
36
  import { readViewerSession } from "@/lib/access/auth/route";
37
37
  import type { SessionRecord } from "@/lib/access/auth/session";
@@ -80,7 +80,6 @@ export interface SourceOrderSnapshot {
80
80
  totals: MinorTotals;
81
81
  evidence: CommerceEvidence;
82
82
  lines: readonly { sku: string; quantity: number }[];
83
- address: Record<string, unknown> | null;
84
83
  }
85
84
 
86
85
  export interface PackingSlip {
@@ -279,7 +278,6 @@ function observedEvent(input: {
279
278
  label: LabelState;
280
279
  totals: MinorTotals;
281
280
  evidence: CommerceEvidence;
282
- address: unknown;
283
281
  }): string {
284
282
  return jsonText(
285
283
  publicCommerceEvent({
@@ -294,7 +292,6 @@ function observedEvent(input: {
294
292
  label: input.label,
295
293
  totals: input.totals,
296
294
  verificationDetail: input.evidence,
297
- address: input.address,
298
295
  }),
299
296
  );
300
297
  }
@@ -586,32 +583,6 @@ export class FulfillmentService {
586
583
  return ok({ batchId, orders });
587
584
  }
588
585
 
589
- async readAddress(
590
- actor: FulfillmentActor,
591
- orderId: string,
592
- ): Promise<FulfillmentResult<Record<string, unknown>>> {
593
- const scope = { tenantId: actor.tenantId, stage: actor.stage };
594
- let opened = false;
595
- const gate = readOwnerAddress(actor, scope, () => {
596
- opened = true;
597
- return true;
598
- });
599
- if (!gate.ok || !opened || !isOpaqueId(actor.actorId)) return refuse("denied");
600
- if (!isOpaqueId(orderId)) return refuse("not-found");
601
- const row = await this.db.get<{ address_json: string }>(
602
- `SELECT address_json FROM scrappy_order_addresses WHERE tenant_id = ? AND stage = ? AND order_id = ?`,
603
- [scope.tenantId, scope.stage, orderId],
604
- );
605
- if (!row) return refuse("not-found");
606
- try {
607
- const parsed = JSON.parse(row.address_json) as unknown;
608
- if (!isRecord(parsed)) return refuse("unreadable");
609
- return ok(parsed);
610
- } catch {
611
- return refuse("unreadable");
612
- }
613
- }
614
-
615
586
  async applySourceSnapshot(
616
587
  actor: FulfillmentActor,
617
588
  snapshot: SourceOrderSnapshot,
@@ -956,6 +927,7 @@ export class FulfillmentService {
956
927
  ],
957
928
  },
958
929
  auditStatement(state, actor.actorId, "transition", state.order.fulfillment, "handed_off", version, stamp(this.now(), state.auditCount + 1)),
930
+ terminalTransitionExpiryStatement(state.order, version, new Date(this.now())),
959
931
  ],
960
932
  };
961
933
  });
@@ -1177,21 +1149,6 @@ export class FulfillmentService {
1177
1149
  ];
1178
1150
  const gate = `tenant_id = ? AND stage = ? AND order_id = ? AND event_version = 1 AND source = ? AND source_order_id = ?`;
1179
1151
  const gateParams = [snapshot.tenantId, snapshot.stage, snapshot.orderId, snapshot.source, snapshot.sourceOrderId];
1180
- if (snapshot.address) {
1181
- statements.push({
1182
- sql: `INSERT INTO scrappy_order_addresses (tenant_id, stage, order_id, address_json)
1183
- SELECT tenant_id, stage, order_id, ?
1184
- FROM scrappy_commerce_orders
1185
- WHERE ${gate}
1186
- AND NOT EXISTS (
1187
- SELECT 1 FROM scrappy_order_addresses AS a
1188
- WHERE a.tenant_id = scrappy_commerce_orders.tenant_id
1189
- AND a.stage = scrappy_commerce_orders.stage
1190
- AND a.order_id = scrappy_commerce_orders.order_id
1191
- )`,
1192
- params: [jsonText(snapshot.address), ...gateParams],
1193
- });
1194
- }
1195
1152
  const stub = {
1196
1153
  order: {
1197
1154
  tenantId: snapshot.tenantId,
@@ -1215,7 +1172,6 @@ export class FulfillmentService {
1215
1172
  gatedAudit(stub, actor.actorId, "evidence", "none", jsonText(snapshot.evidence), at, gate, gateParams),
1216
1173
  gatedAudit(stub, actor.actorId, "lines", "none", jsonText(snapshot.lines), stamp(this.now(), 2), gate, gateParams),
1217
1174
  gatedAudit(stub, actor.actorId, "totals", "none", jsonText(snapshot.totals), stamp(this.now(), 3), gate, gateParams),
1218
- gatedAudit(stub, actor.actorId, "address-present", "none", snapshot.address ? "yes" : "no", stamp(this.now(), 4), gate, gateParams),
1219
1175
  gatedAudit(
1220
1176
  stub,
1221
1177
  actor.actorId,
@@ -1233,7 +1189,6 @@ export class FulfillmentService {
1233
1189
  label,
1234
1190
  totals: snapshot.totals,
1235
1191
  evidence: snapshot.evidence,
1236
- address: snapshot.address,
1237
1192
  }),
1238
1193
  stamp(this.now(), 5),
1239
1194
  gate,
@@ -1299,35 +1254,6 @@ export class FulfillmentService {
1299
1254
  statements.push(auditStatement(state, actor.actorId, "lines", "source", jsonText(snapshot.lines), nextVersion, stamp(this.now(), n)));
1300
1255
  n += 1;
1301
1256
  statements.push(auditStatement(state, actor.actorId, "totals", "source", jsonText(snapshot.totals), nextVersion, stamp(this.now(), n)));
1302
- if (snapshot.address) {
1303
- statements.push({
1304
- sql: `INSERT INTO scrappy_order_addresses (tenant_id, stage, order_id, address_json)
1305
- SELECT ?, ?, ?, ?
1306
- WHERE EXISTS (
1307
- SELECT 1 FROM scrappy_commerce_orders
1308
- WHERE tenant_id = ? AND stage = ? AND order_id = ? AND event_version = ?
1309
- )
1310
- ON CONFLICT(tenant_id, stage, order_id) DO UPDATE SET address_json = excluded.address_json
1311
- WHERE EXISTS (
1312
- SELECT 1 FROM scrappy_commerce_orders
1313
- WHERE tenant_id = ? AND stage = ? AND order_id = ? AND event_version = ?
1314
- )`,
1315
- params: [
1316
- snapshot.tenantId,
1317
- snapshot.stage,
1318
- snapshot.orderId,
1319
- jsonText(snapshot.address),
1320
- snapshot.tenantId,
1321
- snapshot.stage,
1322
- snapshot.orderId,
1323
- nextVersion,
1324
- snapshot.tenantId,
1325
- snapshot.stage,
1326
- snapshot.orderId,
1327
- nextVersion,
1328
- ],
1329
- });
1330
- }
1331
1257
  const storedFulfillment = state.order.fulfillment === "handed_off" ? "handed_off" : fulfillment;
1332
1258
  n += 1;
1333
1259
  statements.push(
@@ -1348,7 +1274,6 @@ export class FulfillmentService {
1348
1274
  label,
1349
1275
  totals: snapshot.totals,
1350
1276
  evidence: snapshot.evidence,
1351
- address: snapshot.address,
1352
1277
  }),
1353
1278
  nextVersion,
1354
1279
  stamp(this.now(), n),
@@ -1357,6 +1282,7 @@ export class FulfillmentService {
1357
1282
  if (confirmedCancel && state.order.fulfillment !== "handed_off") {
1358
1283
  n += 1;
1359
1284
  statements.push(auditStatement(state, actor.actorId, "pending-cancel", "open", "confirmed", nextVersion, stamp(this.now(), n)));
1285
+ statements.push(terminalTransitionExpiryStatement(state.order, nextVersion, new Date(this.now())));
1360
1286
  }
1361
1287
  if (snapshot.sourceVersion !== undefined) statements.push(projectionCheckpoint(snapshot,nextVersion,this.now()));
1362
1288
  return { type: "write", statements, minimumChanges: 1 };
@@ -1657,7 +1583,6 @@ function validateSnapshot(snapshot: SourceOrderSnapshot): string | null {
1657
1583
  if (!oneOf(snapshot.label, LABEL_STATES) || !oneOf(snapshot.stage, ["preview", "publication"] as const)) return "malformed";
1658
1584
  if (!parseEvidence(snapshot.evidence) || !parseLines(snapshot.lines) || !parseTotals(snapshot.totals)) return "malformed";
1659
1585
  if (snapshot.amountMinor !== snapshot.totals.totalMinor || !totalsMatch(snapshot.totals)) return "malformed";
1660
- if (snapshot.address !== null && !isRecord(snapshot.address)) return "malformed";
1661
1586
  return null;
1662
1587
  }
1663
1588
 
@@ -291,9 +291,13 @@ const COMMERCE_PRIVATE_KEYS = new Set([
291
291
  "phone",
292
292
  "line1",
293
293
  "line2",
294
+ "city",
295
+ "company",
294
296
  "postalCode",
295
297
  "shippingAddress",
296
298
  "billingAddress",
299
+ "shipTo",
300
+ "billTo",
297
301
  "address",
298
302
  "firstName",
299
303
  "lastName",