@tokenoftrust/storefront-runner 4.10.1 → 4.11.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.
Files changed (31) hide show
  1. package/agency-kit/tools/lib/analytics-declaration.mjs +21 -5
  2. package/apps/storefront/migrations-apps/0021_contact_vault.sql +4 -0
  3. package/apps/storefront/migrations-apps/0022_contact_vault_fields.sql +342 -0
  4. package/apps/storefront/src/lib/customers/contactVault/blindIndex.ts +28 -0
  5. package/apps/storefront/src/lib/customers/contactVault/contactAudit.ts +21 -7
  6. package/apps/storefront/src/lib/customers/contactVault/dataKeys.ts +256 -0
  7. package/apps/storefront/src/lib/customers/contactVault/fieldEnvelope.ts +88 -0
  8. package/apps/storefront/src/lib/customers/contactVault/isolateMetrics.ts +37 -0
  9. package/apps/storefront/src/lib/customers/contactVault/kms.ts +292 -0
  10. package/apps/storefront/src/lib/customers/contactVault/retention.ts +270 -12
  11. package/apps/storefront/src/lib/customers/contactVault/retentionPolicy.ts +290 -0
  12. package/apps/storefront/src/lib/customers/contactVault/zip3.ts +24 -0
  13. package/apps/storefront/src/lib/engagement/contact/contactMessages.ts +458 -118
  14. package/apps/storefront/src/lib/engagement/contact/messagesTabClient.ts +33 -3
  15. package/apps/storefront/src/lib/integrations/integration/stageDestinationIds.ts +5 -2
  16. package/apps/storefront/src/lib/orders/admin/orderActions.ts +180 -32
  17. package/apps/storefront/src/lib/orders/vault/contactIntake.ts +5 -3
  18. package/apps/storefront/src/lib/orders/vault/contactRoutes.ts +4 -4
  19. package/apps/storefront/src/lib/orders/vault/kmsEnv.ts +11 -0
  20. package/apps/storefront/src/lib/orders/vault/orderContactVault.ts +1053 -227
  21. package/apps/storefront/src/lib/orders/vault/vault-test-support.ts +258 -0
  22. package/apps/storefront/src/pages/api/admin/fulfillment/orders/[id]/address.ts +34 -5
  23. package/apps/storefront/src/pages/api/admin/fulfillment/orders/contact-search.ts +25 -0
  24. package/apps/storefront/src/pages/api/admin/fulfillment/orders/contacts.ts +21 -0
  25. package/apps/storefront/src/pages/api/contact.ts +7 -9
  26. package/apps/storefront/src/scheduled/contactVaultSweep.ts +55 -26
  27. package/package.json +1 -1
  28. package/packages/public-runtime/src/integration/platform-adapters.ts +4 -2
  29. package/packages/public-runtime/src/integration/result.ts +12 -2
  30. package/apps/storefront/src/lib/customers/contactVault/envelope.ts +0 -166
  31. package/apps/storefront/src/lib/orders/vault/keyringEnv.ts +0 -7
@@ -1,70 +1,139 @@
1
1
  /**
2
- * The order contact vault: one sealed contact per order, in `order_contact_vault`
3
- * (migrations-apps/0021). Every effect is one statement or one D1 batch, so a check and its write
4
- * can never be split by a concurrent intake, erase or purge:
2
+ * The order contact vault (migrations-apps/0022): every personal field of an order's contact is its
3
+ * own AES-256-GCM ciphertext under the tenant's KMS-wrapped order-contact data key, held per field
4
+ * group (identity, email, phone, ship, bill) by the declared retention rules. The row keyed by a
5
+ * random fact_id is also the long-lived facts row: once its last group is cleared it is detached
6
+ * from the order and keeps only month, zip3, region, country and totals.
5
7
  *
6
- * - seal: a conditional vault insert plus a replay row recorded only when THIS command sealed;
7
- * every refused outcome writes nothing.
8
- * - read: the audit row commits (with the staff quota in the same statement) before any decrypt.
9
- * - erase: audit, tombstone and delete commit together.
10
- * - purge: tombstone and delete expired rows together, so a purged contact cannot be resealed.
8
+ * Every effect is one statement or one D1 batch, so a check and its write can never be split:
9
+ * - seal: [new data-key rows] + conditional vault insert + marker + replay (both conditioned on the
10
+ * vault row this command sealed) + key cleanup. Every refused or racing outcome commits nothing.
11
+ * - read / list: the audit row commits (staff quota in the same statement) before any decrypt.
12
+ * - erase: audit, group clearing and detach commit together.
13
+ * - purge: one clock reading; each page is one batch.
11
14
  *
12
- * Nothing here logs. Errors and outcomes carry fixed codes only.
15
+ * - rotation: retire and replace due keys, recompute blind indexes under a retired kid (one
16
+ * conditioned update per row), then delete unreferenced retired keys.
17
+ *
18
+ * Nothing here logs. Errors and outcomes carry fixed codes only; fact_id never leaves this module.
13
19
  */
14
20
  import type { Queryable, QueryStatement } from "../../catalog/d1/catalog.js";
21
+ import { VaultKeyUnavailableError, type DataKeyPurpose, type EncryptionContext, type KmsClient } from "@/lib/customers/contactVault/kms.js";
22
+ import {
23
+ activeKey,
24
+ cleanupStatement,
25
+ deleteUnreferencedRetiredKeys,
26
+ keyForKid,
27
+ rotateDueKeys,
28
+ } from "@/lib/customers/contactVault/dataKeys.js";
29
+ import { openField, sealField, type FieldBinding } from "@/lib/customers/contactVault/fieldEnvelope.js";
30
+ import { blindIndex, normalizeEmail, normalizePhone } from "@/lib/customers/contactVault/blindIndex.js";
15
31
  import {
16
- openVaultRecord,
17
- sealVaultRecord,
18
- VaultKeyUnavailableError,
19
- type VaultBinding,
20
- type VaultKeyring,
21
- } from "@/lib/customers/contactVault/envelope.js";
22
- import { expiresAtFor, isReadable, notAfterFrom, readInstant, terminalExpiryFrom } from "@/lib/customers/contactVault/retention.js";
23
- import { orderContactPlaintext, sameOrderContact, type OrderContact, type OrderContactCommand } from "./orderContact.js";
32
+ TERMINAL_AT_SQL,
33
+ isGroupReadable,
34
+ previewDeleteAt,
35
+ readInstant,
36
+ readStampInstant,
37
+ rederiveGroup,
38
+ sealRetention,
39
+ } from "@/lib/customers/contactVault/retention.js";
40
+ import { GROUPS, RETENTION_POLICY, applicableRules, rulesForTenant, type Group, type RetentionPolicy } from "@/lib/customers/contactVault/retentionPolicy.js";
41
+ import { zip3 } from "@/lib/customers/contactVault/zip3.js";
24
42
  import {
43
+ AUDIT_RECORD_IDS_MAX,
25
44
  admitAudit,
45
+ auditRecordIds,
26
46
  auditStatement,
27
47
  batchAuditStatement,
28
48
  type ContactActor,
29
49
  type VaultStage,
30
50
  } from "@/lib/customers/contactVault/contactAudit.js";
51
+ import { orderContactPlaintext, sameOrderContact, type ContactAddress, type OrderContactCommand } from "./orderContact.js";
31
52
 
32
- export type SealOutcome =
33
- | { ok: true; status: 200; duplicate: boolean }
34
- | {
35
- ok: false;
36
- status: 401 | 409 | 410 | 503;
37
- reason: "replay" | "order-unknown" | "order-time-unreadable" | "contact-conflict" | "contact-expired" | "contact-erased" | "vault-key-unavailable";
38
- };
53
+ export type { Group } from "@/lib/customers/contactVault/retentionPolicy.js";
39
54
 
40
- export type ReadOutcome =
41
- | { ok: true; contact: OrderContact; expiresAt: string }
42
- | { ok: false; status: 404 | 429 | 503; reason: "not-found" | "quota" | "audit-unavailable" | "vault-key-unavailable" };
55
+ // ---------------------------------------------------------------------------------------------
56
+ // Layout
57
+ // ---------------------------------------------------------------------------------------------
43
58
 
44
- export type EraseOutcome =
45
- | { ok: true }
46
- | { ok: false; status: 404 | 429 | 503; reason: "not-found" | "quota" | "audit-unavailable" };
59
+ interface GroupLayout {
60
+ sealed: readonly string[];
61
+ bidx: string | null;
62
+ plain: readonly string[];
63
+ }
47
64
 
48
- interface OrderRow {
49
- placed_at: unknown;
50
- fulfillment_state: unknown;
65
+ const LAYOUT: Readonly<Record<Group, GroupLayout>> = {
66
+ identity: { sealed: ["name_ct", "company_ct"], bidx: null, plain: [] },
67
+ email: { sealed: ["email_ct"], bidx: "email_bidx", plain: [] },
68
+ phone: { sealed: ["phone_ct"], bidx: "phone_bidx", plain: [] },
69
+ ship: { sealed: ["ship_name_ct", "ship_company_ct", "ship_line1_ct", "ship_line2_ct"], bidx: null, plain: ["ship_city", "ship_postal_code"] },
70
+ bill: { sealed: ["bill_name_ct", "bill_company_ct", "bill_line1_ct", "bill_line2_ct"], bidx: null, plain: ["bill_city", "bill_postal_code"] },
71
+ };
72
+
73
+ function groupColumns(group: Group): string[] {
74
+ const layout = LAYOUT[group];
75
+ return [...layout.sealed, ...(layout.bidx ? [layout.bidx] : []), ...layout.plain];
76
+ }
77
+
78
+ /** SQL: the group still holds any personal value. */
79
+ function hasDataSql(group: Group, alias = ""): string {
80
+ return `(${groupColumns(group).map((column) => `${alias}${column} IS NOT NULL`).join(" OR ")})`;
51
81
  }
52
82
 
53
- interface VaultRow {
54
- key_id: string;
55
- nonce: string;
56
- ciphertext: string;
57
- not_after: string;
58
- expires_at: string;
59
- sealed_jti: string;
83
+ /** SQL SET list nulling every personal column of the groups. */
84
+ function clearSql(groups: readonly Group[]): string {
85
+ return groups.flatMap(groupColumns).map((column) => `${column} = NULL`).join(", ");
60
86
  }
61
87
 
62
- const TERMINAL_AT_SQL = `SELECT MIN(at) AS at FROM scrappy_shipment_audit
63
- WHERE tenant_id = ? AND stage = ? AND order_id = ?
64
- AND ((kind = 'transition' AND to_state = 'handed_off') OR (kind = 'pending-cancel' AND to_state = 'confirmed'))`;
88
+ const ALL_EMPTY_SQL = GROUPS.map((group) => `NOT ${hasDataSql(group)}`).join(" AND ");
89
+
90
+ /** Detach: only the unlinked facts remain (fact_id, tenant, stage, month, totals, zip3, region, country). */
91
+ const DETACH_SET_SQL = [
92
+ "order_id", "ship_city", "ship_postal_code", "bill_city", "bill_postal_code", "order_kid", "bidx_kid",
93
+ "sealed_jti", "sealed_at", "erased_at", "time_scale", "rule_version",
94
+ ...GROUPS.flatMap((group) => [`${group}_ceiling`, `${group}_expires_at`, `${group}_floor_until`, `${group}_rule`]),
95
+ ].map((column) => `${column} = NULL`).join(", ");
96
+
97
+ /**
98
+ * Normalization statements over `scopeSql` (with `scopeParams`): clear bidx_kid once both indexes
99
+ * are gone, then detach a publication row whose last group is gone. Preview rows stay linked until
100
+ * the purge deletes them whole with their marker at placed_at + the cutoff.
101
+ */
102
+ function normalizeStatements(scopeSql: string, scopeParams: readonly unknown[]): QueryStatement[] {
103
+ return [
104
+ {
105
+ sql: `UPDATE order_contact_vault SET bidx_kid = NULL
106
+ WHERE ${scopeSql} AND bidx_kid IS NOT NULL AND email_bidx IS NULL AND phone_bidx IS NULL`,
107
+ params: [...scopeParams],
108
+ },
109
+ {
110
+ sql: `UPDATE order_contact_vault SET ${DETACH_SET_SQL}
111
+ WHERE ${scopeSql} AND stage = 'publication' AND order_id IS NOT NULL AND ${ALL_EMPTY_SQL}`,
112
+ params: [...scopeParams],
113
+ },
114
+ ];
115
+ }
116
+
117
+ type VaultRow = Record<string, unknown>;
118
+
119
+ function text(row: VaultRow, column: string): string | null {
120
+ const value = row[column];
121
+ return typeof value === "string" ? value : null;
122
+ }
123
+
124
+ function rowHasData(row: VaultRow, group: Group): boolean {
125
+ return groupColumns(group).some((column) => row[column] !== null && row[column] !== undefined);
126
+ }
127
+
128
+ const ORDER_KEY = "order-contact" as const;
129
+ const BIDX_KEY = "blind-index" as const;
130
+
131
+ function keyContext(tenantId: string, stage: VaultStage, purpose: DataKeyPurpose): EncryptionContext {
132
+ return { tenantId, stage, purpose };
133
+ }
65
134
 
66
- function binding(tenantId: string, stage: VaultStage, orderId: string, notAfter: string): VaultBinding {
67
- return { purpose: "order-contact", tenantId, stage, recordId: orderId, notAfter };
135
+ function fieldBinding(tenantId: string, stage: VaultStage, factId: string, kid: string, column: string, ceiling: string): FieldBinding {
136
+ return { purpose: ORDER_KEY, field: column.replace(/_ct$/, ""), tenantId, stage, recordId: factId, kid, notAfter: ceiling };
68
137
  }
69
138
 
70
139
  function requireBatch(db: Queryable): NonNullable<Queryable["batch"]> {
@@ -72,298 +141,1055 @@ function requireBatch(db: Queryable): NonNullable<Queryable["batch"]> {
72
141
  return db.batch.bind(db);
73
142
  }
74
143
 
75
- /** When the order became terminal, `null` when it is not terminal, or "unreadable". */
76
- async function terminalAt(db: Queryable, tenantId: string, stage: VaultStage, orderId: string, fulfillment: unknown): Promise<Date | null | "unreadable"> {
77
- if (fulfillment !== "handed_off" && fulfillment !== "cancelled") return null;
78
- const row = await db.first<{ at: unknown }>(TERMINAL_AT_SQL, tenantId, stage, orderId);
79
- return readInstant(row?.at) ?? "unreadable";
144
+ function randomHex128(): string {
145
+ return [...crypto.getRandomValues(new Uint8Array(16))].map((byte) => byte.toString(16).padStart(2, "0")).join("");
80
146
  }
81
147
 
82
- /** The order's not_after and expiry, derived from the order row alone. */
83
- export async function orderRetention(
84
- db: Queryable,
148
+ function sqlLiteral(value: string): string {
149
+ return `'${value.replace(/'/g, "''")}'`;
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------------------------
153
+ // Order snapshot and effective expiry
154
+ // ---------------------------------------------------------------------------------------------
155
+
156
+ interface OrderSnapshot {
157
+ placedAtRaw: unknown;
158
+ fulfillmentState: unknown;
159
+ terminalAtRaw: string | null;
160
+ placedAt: Date;
161
+ terminalAt: Date | null;
162
+ }
163
+
164
+ function isTerminalState(state: unknown): boolean {
165
+ return state === "handed_off" || state === "cancelled";
166
+ }
167
+
168
+ /** Read placed_at and the stamp-tolerant terminal time; refusal codes when either is unreadable. */
169
+ function orderTimes(placedAtRaw: unknown, fulfillmentState: unknown, terminalAtRaw: unknown): OrderSnapshot | "order-time-unreadable" {
170
+ const placedAt = readInstant(placedAtRaw);
171
+ if (!placedAt) return "order-time-unreadable";
172
+ const terminal = isTerminalState(fulfillmentState) ? readStampInstant(terminalAtRaw) : null;
173
+ if (isTerminalState(fulfillmentState) && !terminal) return "order-time-unreadable";
174
+ return {
175
+ placedAtRaw,
176
+ fulfillmentState,
177
+ terminalAtRaw: typeof terminalAtRaw === "string" ? terminalAtRaw : null,
178
+ placedAt,
179
+ terminalAt: terminal,
180
+ };
181
+ }
182
+
183
+ /**
184
+ * Re-derivation applies only non-floor rules (floors come from the row's immutable floor_until), so
185
+ * the tenant's regulated class is taken as true: any regulated non-floor rule then counts, which can
186
+ * only lengthen the derived expiry, never shorten it below what the rules require.
187
+ */
188
+ function rederivationRules(tenantId: string, shipCountry: string | null, shipRegion: string | null, policy: RetentionPolicy) {
189
+ return applicableRules(tenantId, { shipCountry, shipRegion, regulated: true }, policy);
190
+ }
191
+
192
+ /** Each group's effective expiry now (shrink-only re-derivation of the row's values), or null. */
193
+ function effectiveExpiries(
194
+ row: VaultRow,
195
+ tenantId: string,
196
+ stage: VaultStage,
197
+ order: { placedAt: Date; terminalAt: Date | null },
198
+ policy: RetentionPolicy,
199
+ ): Record<Group, string | null> {
200
+ const rules = rederivationRules(tenantId, text(row, "ship_country"), text(row, "ship_region"), policy);
201
+ const timeScale = Number(row.time_scale);
202
+ const out = {} as Record<Group, string | null>;
203
+ for (const group of GROUPS) {
204
+ const ceiling = text(row, `${group}_ceiling`);
205
+ const expiresAt = text(row, `${group}_expires_at`);
206
+ out[group] = ceiling && expiresAt
207
+ ? rederiveGroup({
208
+ group, ceiling, expiresAt, floorUntil: text(row, `${group}_floor_until`), timeScale, stage,
209
+ placedAt: order.placedAt, terminalAt: order.terminalAt, rules, policy,
210
+ })
211
+ : null;
212
+ }
213
+ return out;
214
+ }
215
+
216
+ interface GroupState {
217
+ sealed: boolean;
218
+ readable: boolean;
219
+ expiresAt: string | null;
220
+ }
221
+
222
+ function groupStates(
223
+ row: VaultRow,
224
+ tenantId: string,
225
+ stage: VaultStage,
226
+ order: { placedAt: Date; terminalAt: Date | null },
227
+ now: Date,
228
+ policy: RetentionPolicy,
229
+ ): Record<Group, GroupState> {
230
+ const effective = effectiveExpiries(row, tenantId, stage, order, policy);
231
+ const pastCutoff = stage === "preview" && now.getTime() >= Date.parse(previewDeleteAt(order.placedAt, policy));
232
+ const out = {} as Record<Group, GroupState>;
233
+ for (const group of GROUPS) {
234
+ const sealed = rowHasData(row, group);
235
+ const expiresAt = effective[group];
236
+ out[group] = {
237
+ sealed,
238
+ expiresAt,
239
+ readable: sealed && !pastCutoff && isGroupReadable(now, expiresAt, text(row, `${group}_ceiling`)),
240
+ };
241
+ }
242
+ return out;
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------------------------
246
+ // Opening
247
+ // ---------------------------------------------------------------------------------------------
248
+
249
+ export interface ReadableContact {
250
+ customer?: { name?: string; email?: string; phone?: string };
251
+ shipTo?: ContactAddress;
252
+ billTo?: ContactAddress;
253
+ }
254
+
255
+ export interface GroupView {
256
+ expiresAt: string;
257
+ rule: string | null;
258
+ }
259
+
260
+ /**
261
+ * Open every sealed field of the given groups. Null when any field fails to authenticate (moved,
262
+ * swapped or tampered) or a required value is missing: the record then reads as not found.
263
+ */
264
+ async function openGroups(
265
+ key: CryptoKey,
266
+ row: VaultRow,
85
267
  tenantId: string,
86
268
  stage: VaultStage,
87
- orderId: string,
88
- ): Promise<{ notAfter: string; expiresAt: string } | "order-unknown" | "order-time-unreadable"> {
89
- const order = await db.first<OrderRow>(
90
- `SELECT placed_at, fulfillment_state FROM scrappy_commerce_orders WHERE tenant_id = ? AND stage = ? AND order_id = ?`,
91
- tenantId, stage, orderId,
269
+ groups: readonly Group[],
270
+ ): Promise<ReadableContact | null> {
271
+ const factId = text(row, "fact_id");
272
+ const kid = text(row, "order_kid");
273
+ if (!factId || !kid) return null;
274
+ const opened = new Map<string, string>();
275
+ for (const group of groups) {
276
+ const ceiling = text(row, `${group}_ceiling`);
277
+ if (!ceiling) return null;
278
+ for (const column of LAYOUT[group].sealed) {
279
+ const sealed = text(row, column);
280
+ if (sealed === null) continue;
281
+ const value = await openField(key, fieldBinding(tenantId, stage, factId, kid, column, ceiling), sealed);
282
+ if (value === null) return null;
283
+ opened.set(column, value);
284
+ }
285
+ }
286
+ const contact: ReadableContact = {};
287
+ const customer: { name?: string; email?: string; phone?: string } = {};
288
+ if (groups.includes("identity")) {
289
+ const name = opened.get("name_ct");
290
+ if (name === undefined) return null;
291
+ customer.name = name;
292
+ }
293
+ if (groups.includes("email")) {
294
+ const email = opened.get("email_ct");
295
+ if (email === undefined) return null;
296
+ customer.email = email;
297
+ }
298
+ if (groups.includes("phone")) {
299
+ const phone = opened.get("phone_ct");
300
+ if (phone === undefined) return null;
301
+ customer.phone = phone;
302
+ }
303
+ if (Object.keys(customer).length > 0) contact.customer = customer;
304
+ for (const [group, prefix, member] of [["ship", "ship", "shipTo"], ["bill", "bill", "billTo"]] as const) {
305
+ if (!groups.includes(group)) continue;
306
+ const line1 = opened.get(`${prefix}_line1_ct`);
307
+ const city = text(row, `${prefix}_city`);
308
+ const country = text(row, `${prefix}_country`);
309
+ if (line1 === undefined || city === null || country === null) return null;
310
+ const address: Record<string, string> = { line1, city, country };
311
+ const name = opened.get(`${prefix}_name_ct`);
312
+ const company = opened.get(`${prefix}_company_ct`);
313
+ const line2 = opened.get(`${prefix}_line2_ct`);
314
+ const region = text(row, `${prefix}_region`);
315
+ const postalCode = text(row, `${prefix}_postal_code`);
316
+ if (name !== undefined) address.name = name;
317
+ if (company !== undefined) address.company = company;
318
+ if (line2 !== undefined) address.line2 = line2;
319
+ if (region !== null) address.region = region;
320
+ if (postalCode !== null) address.postalCode = postalCode;
321
+ contact[member] = address as unknown as ContactAddress;
322
+ }
323
+ return contact;
324
+ }
325
+
326
+ // ---------------------------------------------------------------------------------------------
327
+ // Seal
328
+ // ---------------------------------------------------------------------------------------------
329
+
330
+ export type SealOutcome =
331
+ | { ok: true; status: 200; duplicate: boolean }
332
+ | {
333
+ ok: false;
334
+ status: 401 | 409 | 410 | 503;
335
+ reason: "replay" | "order-unknown" | "order-time-unreadable" | "contact-conflict" | "contact-expired" | "contact-erased" | "vault-key-unavailable";
336
+ };
337
+
338
+ export interface SealInput {
339
+ tenantId: string;
340
+ stage: VaultStage;
341
+ command: OrderContactCommand;
342
+ issuer: string;
343
+ jti: string;
344
+ /** isRegulatedTenant for the routed tenant. */
345
+ regulated: boolean;
346
+ now: Date;
347
+ policy?: RetentionPolicy;
348
+ }
349
+
350
+ interface SealSnapshot extends OrderSnapshot {
351
+ marked: boolean;
352
+ }
353
+
354
+ async function sealSnapshot(db: Queryable, tenantId: string, stage: VaultStage, orderId: string): Promise<SealSnapshot | "order-unknown" | "order-time-unreadable"> {
355
+ const order = await db.first<{ placed_at: unknown; fulfillment_state: unknown; terminal_at: unknown; marked: number }>(
356
+ `SELECT o.placed_at, o.fulfillment_state, (${TERMINAL_AT_SQL}) AS terminal_at,
357
+ EXISTS (SELECT 1 FROM order_contact_markers m WHERE m.tenant_id = o.tenant_id AND m.stage = o.stage AND m.order_id = o.order_id) AS marked
358
+ FROM scrappy_commerce_orders o WHERE o.tenant_id = ? AND o.stage = ? AND o.order_id = ?`,
359
+ tenantId, stage, orderId, tenantId, stage, orderId,
92
360
  );
93
361
  if (!order) return "order-unknown";
94
- const placed = readInstant(order.placed_at);
95
- if (!placed) return "order-time-unreadable";
96
- const terminal = await terminalAt(db, tenantId, stage, orderId, order.fulfillment_state);
97
- if (terminal === "unreadable") return "order-time-unreadable";
98
- const notAfter = notAfterFrom(placed);
99
- return { notAfter, expiresAt: expiresAtFor(notAfter, terminal) };
362
+ const times = orderTimes(order.placed_at, order.fulfillment_state, order.terminal_at);
363
+ if (times === "order-time-unreadable") return times;
364
+ return { ...times, marked: Number(order.marked) === 1 };
365
+ }
366
+
367
+ const SEALED_VALUES: ReadonlyArray<[string, (command: OrderContactCommand) => string | undefined]> = [
368
+ ["name_ct", (c) => c.customer.name],
369
+ ["company_ct", () => undefined],
370
+ ["email_ct", (c) => c.customer.email],
371
+ ["phone_ct", (c) => c.customer.phone],
372
+ ["ship_name_ct", (c) => c.shipTo.name],
373
+ ["ship_company_ct", (c) => c.shipTo.company],
374
+ ["ship_line1_ct", (c) => c.shipTo.line1],
375
+ ["ship_line2_ct", (c) => c.shipTo.line2],
376
+ ["bill_name_ct", (c) => c.billTo.name],
377
+ ["bill_company_ct", (c) => c.billTo.company],
378
+ ["bill_line1_ct", (c) => c.billTo.line1],
379
+ ["bill_line2_ct", (c) => c.billTo.line2],
380
+ ];
381
+
382
+ function groupOfColumn(column: string): Group {
383
+ for (const group of GROUPS) if (LAYOUT[group].sealed.includes(column)) return group;
384
+ throw new Error("vault layout");
385
+ }
386
+
387
+ function placedMonth(placedAt: Date): string {
388
+ return `${placedAt.getUTCFullYear()}${String(placedAt.getUTCMonth() + 1).padStart(2, "0")}`;
100
389
  }
101
390
 
102
391
  /** Seal one order's contact from a verified contact command. */
103
- export async function sealOrderContact(
104
- db: Queryable,
105
- keyring: VaultKeyring | null,
106
- input: { tenantId: string; stage: VaultStage; command: OrderContactCommand; issuer: string; jti: string; now: Date },
107
- ): Promise<SealOutcome> {
392
+ export async function sealOrderContact(db: Queryable, kms: KmsClient, input: SealInput): Promise<SealOutcome> {
393
+ try {
394
+ return await sealAttempt(db, kms, input, 3);
395
+ } catch (error) {
396
+ if (error instanceof VaultKeyUnavailableError) return { ok: false, status: 503, reason: "vault-key-unavailable" };
397
+ throw error;
398
+ }
399
+ }
400
+
401
+ async function replayed(db: Queryable, tenantId: string, stage: VaultStage, issuer: string, jti: string): Promise<boolean> {
402
+ return (await db.first(
403
+ `SELECT 1 AS seen FROM scrappy_caller_replay WHERE tenant_id = ? AND stage = ? AND issuer = ? AND jti = ?`,
404
+ tenantId, stage, issuer, jti,
405
+ )) !== null;
406
+ }
407
+
408
+ async function sealAttempt(db: Queryable, kms: KmsClient, input: SealInput, attempts: number): Promise<SealOutcome> {
108
409
  const { tenantId, stage, command, issuer, jti, now } = input;
410
+ const policy = input.policy ?? RETENTION_POLICY;
109
411
  const orderId = command.orderId;
110
412
  const nowIso = now.toISOString();
111
413
  const sealedJti = `${issuer}:${jti}`;
112
- const seen = await db.first(
113
- `SELECT 1 AS seen FROM scrappy_caller_replay WHERE tenant_id = ? AND stage = ? AND issuer = ? AND jti = ?`,
114
- tenantId, stage, issuer, jti,
115
- );
116
- if (seen) return { ok: false, status: 401, reason: "replay" };
117
- const retention = await orderRetention(db, tenantId, stage, orderId);
118
- if (retention === "order-unknown") return { ok: false, status: 409, reason: "order-unknown" };
119
- if (retention === "order-time-unreadable") return { ok: false, status: 409, reason: "order-time-unreadable" };
120
- const existing = await classifyExisting(db, keyring, tenantId, stage, command, sealedJti);
414
+ if (await replayed(db, tenantId, stage, issuer, jti)) return { ok: false, status: 401, reason: "replay" };
415
+ const snapshot = await sealSnapshot(db, tenantId, stage, orderId);
416
+ if (snapshot === "order-unknown") return { ok: false, status: 409, reason: "order-unknown" };
417
+ if (snapshot === "order-time-unreadable") return { ok: false, status: 409, reason: "order-time-unreadable" };
418
+ const existing = await classifyExisting(db, kms, input, snapshot, sealedJti, policy);
121
419
  if (existing) return existing;
122
- if (nowIso >= retention.expiresAt) return { ok: false, status: 410, reason: "contact-expired" };
123
420
 
124
- let sealed;
125
- try {
126
- sealed = await sealVaultRecord(keyring, binding(tenantId, stage, orderId, retention.notAfter), orderContactPlaintext(command));
127
- } catch (error) {
128
- if (error instanceof VaultKeyUnavailableError) return { ok: false, status: 503, reason: "vault-key-unavailable" };
129
- throw error;
421
+ const retention = await sealRetention({
422
+ stage, placedAt: snapshot.placedAt, terminalAt: snapshot.terminalAt,
423
+ shipCountry: command.shipTo.country, shipRegion: command.shipTo.region ?? null,
424
+ regulated: input.regulated, tenantId, now, policy,
425
+ });
426
+ if (retention.kind === "refused") {
427
+ return retention.reason === "order-time-unreadable"
428
+ ? { ok: false, status: 409, reason: "order-time-unreadable" }
429
+ : { ok: false, status: 410, reason: "contact-expired" };
130
430
  }
431
+
432
+ const orderCtx = keyContext(tenantId, stage, ORDER_KEY);
433
+ const bidxCtx = keyContext(tenantId, stage, BIDX_KEY);
434
+ const orderKey = await activeKey(db, kms, orderCtx, now);
435
+ const bidxKey = await activeKey(db, kms, bidxCtx, now);
436
+
437
+ const factId = randomHex128();
438
+ // A group whose expiry has already passed is never written: only live groups are sealed.
439
+ const live = (group: Group): boolean => retention.groups[group].expiresAt > nowIso;
440
+ const ifLive = <T>(group: Group, value: T): T | null => (live(group) ? value : null);
441
+ const columns: Array<[string, unknown]> = [
442
+ ["fact_id", factId], ["tenant_id", tenantId], ["stage", stage], ["order_id", orderId],
443
+ ["placed_month", placedMonth(snapshot.placedAt)],
444
+ ["ship_region", command.shipTo.region ?? null], ["ship_country", command.shipTo.country],
445
+ ["ship_zip3", zip3(command.shipTo.postalCode, command.shipTo.country)],
446
+ ["bill_region", command.billTo.region ?? null], ["bill_country", command.billTo.country],
447
+ ["bill_zip3", zip3(command.billTo.postalCode, command.billTo.country)],
448
+ ["time_scale", retention.timeScale], ["rule_version", retention.ruleVersion],
449
+ ["order_kid", orderKey.kid], ["bidx_kid", bidxKey.kid], ["sealed_at", nowIso], ["sealed_jti", sealedJti],
450
+ ["ship_city", ifLive("ship", command.shipTo.city)], ["ship_postal_code", ifLive("ship", command.shipTo.postalCode ?? null)],
451
+ ["bill_city", ifLive("bill", command.billTo.city)], ["bill_postal_code", ifLive("bill", command.billTo.postalCode ?? null)],
452
+ ];
453
+ for (const [column, pick] of SEALED_VALUES) {
454
+ const value = pick(command);
455
+ const group = groupOfColumn(column);
456
+ columns.push([
457
+ column,
458
+ value === undefined || !live(group)
459
+ ? null
460
+ : await sealField(orderKey.key, fieldBinding(tenantId, stage, factId, orderKey.kid, column, retention.groups[group].ceiling), value),
461
+ ]);
462
+ }
463
+ const email = live("email") ? normalizeEmail(command.customer.email) : null;
464
+ const phone = live("phone") ? normalizePhone(command.customer.phone) : null;
465
+ columns.push(["email_bidx", email === null ? null : await blindIndex(bidxKey.key, email)]);
466
+ columns.push(["phone_bidx", phone === null ? null : await blindIndex(bidxKey.key, phone)]);
467
+ if (email === null && phone === null) columns.find(([column]) => column === "bidx_kid")![1] = null;
468
+ let latest = "";
469
+ for (const group of GROUPS) {
470
+ const held = retention.groups[group];
471
+ columns.push([`${group}_ceiling`, held.ceiling], [`${group}_expires_at`, held.expiresAt], [`${group}_floor_until`, held.floorUntil], [`${group}_rule`, held.rule]);
472
+ if (held.expiresAt > latest) latest = held.expiresAt;
473
+ }
474
+
475
+ const vaultRowSql = `SELECT 1 FROM order_contact_vault WHERE tenant_id = ? AND stage = ? AND order_id = ? AND sealed_jti = ?`;
476
+ const vaultRowParams = [tenantId, stage, orderId, sealedJti];
131
477
  const statements: QueryStatement[] = [
478
+ ...orderKey.pending,
479
+ ...bidxKey.pending,
132
480
  {
133
- sql: `INSERT INTO order_contact_vault
134
- (tenant_id, stage, order_id, key_id, nonce, ciphertext, not_after, expires_at, sealed_at, sealed_jti)
135
- SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
136
- WHERE EXISTS (SELECT 1 FROM scrappy_commerce_orders WHERE tenant_id = ? AND stage = ? AND order_id = ?)
137
- AND NOT EXISTS (SELECT 1 FROM order_contact_tombstones WHERE tenant_id = ? AND stage = ? AND order_id = ?)
481
+ sql: `INSERT INTO order_contact_vault (${columns.map(([column]) => column).join(", ")}, currency, order_total_minor, item_count)
482
+ SELECT ${columns.map(() => "?").join(", ")}, o.currency, o.amount_minor,
483
+ (SELECT SUM(a.quantity) FROM scrappy_allocations a WHERE a.tenant_id = o.tenant_id AND a.stage = o.stage AND a.order_id = o.order_id)
484
+ FROM scrappy_commerce_orders o
485
+ WHERE o.tenant_id = ? AND o.stage = ? AND o.order_id = ? AND o.placed_at IS ? AND o.fulfillment_state IS ?
486
+ AND (${TERMINAL_AT_SQL}) IS ?
487
+ AND NOT EXISTS (SELECT 1 FROM order_contact_markers m WHERE m.tenant_id = ? AND m.stage = ? AND m.order_id = ?)
488
+ AND NOT EXISTS (SELECT 1 FROM order_contact_vault v WHERE v.tenant_id = ? AND v.stage = ? AND v.order_id = ?)
489
+ AND EXISTS (SELECT 1 FROM contact_vault_data_keys k WHERE k.tenant_id = ? AND k.stage = ? AND k.purpose = ? AND k.kid = ?)
490
+ AND EXISTS (SELECT 1 FROM contact_vault_data_keys k WHERE k.tenant_id = ? AND k.stage = ? AND k.purpose = ? AND k.kid = ?)
138
491
  AND ? < ?
139
- ON CONFLICT (tenant_id, stage, order_id) DO NOTHING`,
492
+ ON CONFLICT DO NOTHING`,
140
493
  params: [
141
- tenantId, stage, orderId, sealed.keyId, sealed.nonce, sealed.ciphertext, retention.notAfter, retention.expiresAt, nowIso, sealedJti,
494
+ ...columns.map(([, value]) => value),
495
+ tenantId, stage, orderId, snapshot.placedAtRaw, snapshot.fulfillmentState,
496
+ tenantId, stage, orderId, snapshot.terminalAtRaw,
142
497
  tenantId, stage, orderId,
143
498
  tenantId, stage, orderId,
144
- nowIso, retention.expiresAt,
499
+ tenantId, stage, ORDER_KEY, orderKey.kid,
500
+ tenantId, stage, BIDX_KEY, bidxKey.kid,
501
+ nowIso, latest,
145
502
  ],
146
503
  },
504
+ {
505
+ sql: `INSERT INTO order_contact_markers (tenant_id, stage, order_id)
506
+ SELECT ?, ?, ? WHERE EXISTS (${vaultRowSql})
507
+ ON CONFLICT DO NOTHING`,
508
+ params: [tenantId, stage, orderId, ...vaultRowParams],
509
+ },
147
510
  {
148
511
  sql: `INSERT INTO scrappy_caller_replay (tenant_id, stage, issuer, jti, seen_at)
149
- SELECT ?, ?, ?, ?, ?
150
- WHERE EXISTS (SELECT 1 FROM order_contact_vault WHERE tenant_id = ? AND stage = ? AND order_id = ? AND sealed_jti = ?)`,
151
- params: [tenantId, stage, issuer, jti, nowIso, tenantId, stage, orderId, sealedJti],
512
+ SELECT ?, ?, ?, ?, ? WHERE EXISTS (${vaultRowSql})`,
513
+ params: [tenantId, stage, issuer, jti, nowIso, ...vaultRowParams],
152
514
  },
153
515
  ];
516
+ if (orderKey.pending.length > 0) {
517
+ statements.push(cleanupStatement(orderCtx, orderKey.kid,
518
+ `SELECT 1 FROM order_contact_vault WHERE tenant_id = ? AND stage = ? AND order_kid = ?`, [tenantId, stage, orderKey.kid]));
519
+ }
520
+ if (bidxKey.pending.length > 0) {
521
+ statements.push(cleanupStatement(bidxCtx, bidxKey.kid,
522
+ `SELECT 1 FROM order_contact_vault WHERE tenant_id = ? AND stage = ? AND bidx_kid = ?
523
+ UNION ALL SELECT 1 FROM contact_messages WHERE tenant_id = ? AND stage = ? AND bidx_kid = ?`,
524
+ [tenantId, stage, bidxKey.kid, tenantId, stage, bidxKey.kid]));
525
+ }
154
526
  try {
155
527
  await requireBatch(db)(statements);
156
528
  } catch (error) {
157
529
  // The only conflict the batch can raise is the replay key: this jti committed concurrently
158
530
  // elsewhere. Anything else is a fault the sender must retry, never a replay.
159
- const replayed = await db.first(
160
- `SELECT 1 AS seen FROM scrappy_caller_replay WHERE tenant_id = ? AND stage = ? AND issuer = ? AND jti = ?`,
161
- tenantId, stage, issuer, jti,
162
- );
163
- if (replayed) return { ok: false, status: 401, reason: "replay" };
531
+ if (await replayed(db, tenantId, stage, issuer, jti)) return { ok: false, status: 401, reason: "replay" };
164
532
  throw error;
165
533
  }
166
- const after = await classifyExisting(db, keyring, tenantId, stage, command, sealedJti);
167
- if (after) return after;
168
- const again = await orderRetention(db, tenantId, stage, orderId);
534
+ const again = await sealSnapshot(db, tenantId, stage, orderId);
169
535
  if (again === "order-unknown") return { ok: false, status: 409, reason: "order-unknown" };
170
- return { ok: false, status: 410, reason: "contact-expired" };
536
+ if (again === "order-time-unreadable") return { ok: false, status: 409, reason: "order-time-unreadable" };
537
+ const after = await classifyExisting(db, kms, input, again, sealedJti, policy);
538
+ if (after) return after;
539
+ // Nothing committed: the order's retention snapshot or the active key changed underneath. Re-read
540
+ // and re-seal, bounded.
541
+ if (attempts > 1) return sealAttempt(db, kms, input, attempts - 1);
542
+ throw new Error("contact-retention-changed");
171
543
  }
172
544
 
173
- /** Classify an order that already has a vault row or a tombstone, or null when it has neither. */
545
+ /**
546
+ * Classify an order that already has a linked vault row or a marker, or null when it has neither.
547
+ * A linked row with every group sealed and readable answers duplicate or conflict; one with any
548
+ * group cleared or expired, or a marker without a linked row, answers 410 and never refills.
549
+ */
174
550
  async function classifyExisting(
175
551
  db: Queryable,
176
- keyring: VaultKeyring | null,
177
- tenantId: string,
178
- stage: VaultStage,
179
- command: OrderContactCommand,
552
+ kms: KmsClient,
553
+ input: SealInput,
554
+ snapshot: SealSnapshot,
180
555
  sealedJti: string,
556
+ policy: RetentionPolicy,
181
557
  ): Promise<SealOutcome | null> {
558
+ const { tenantId, stage, command, now } = input;
182
559
  const row = await db.first<VaultRow>(
183
- `SELECT key_id, nonce, ciphertext, not_after, expires_at, sealed_jti FROM order_contact_vault
184
- WHERE tenant_id = ? AND stage = ? AND order_id = ?`,
560
+ `SELECT * FROM order_contact_vault WHERE tenant_id = ? AND stage = ? AND order_id = ?`,
185
561
  tenantId, stage, command.orderId,
186
562
  );
187
- if (row) {
188
- if (row.sealed_jti === sealedJti) return { ok: true, status: 200, duplicate: false };
189
- let stored: unknown | null;
190
- try {
191
- stored = await openVaultRecord(keyring, binding(tenantId, stage, command.orderId, row.not_after), {
192
- keyId: row.key_id, nonce: row.nonce, ciphertext: row.ciphertext,
193
- });
194
- } catch (error) {
195
- if (error instanceof VaultKeyUnavailableError) return { ok: false, status: 503, reason: "vault-key-unavailable" };
196
- throw error;
197
- }
198
- return stored !== null && sameOrderContact(stored, orderContactPlaintext(command))
199
- ? { ok: true, status: 200, duplicate: true }
200
- : { ok: false, status: 409, reason: "contact-conflict" };
563
+ if (!row) return snapshot.marked ? { ok: false, status: 410, reason: "contact-expired" } : null;
564
+ // This command's own seal (the replay row committed with it, so this is only ever the post-batch read).
565
+ if (row.sealed_jti === sealedJti) return { ok: true, status: 200, duplicate: false };
566
+ const states = groupStates(row, tenantId, stage, snapshot, now, policy);
567
+ if (GROUPS.some((group) => !states[group].readable)) {
568
+ return { ok: false, status: 410, reason: row.erased_at ? "contact-erased" : "contact-expired" };
201
569
  }
202
- const tomb = await db.first<{ reason: string }>(
203
- `SELECT reason FROM order_contact_tombstones WHERE tenant_id = ? AND stage = ? AND order_id = ?`,
204
- tenantId, stage, command.orderId,
570
+ const kid = text(row, "order_kid");
571
+ const key = kid ? await keyForKid(db, kms, keyContext(tenantId, stage, ORDER_KEY), kid) : null;
572
+ if (!key) return { ok: false, status: 503, reason: "vault-key-unavailable" };
573
+ const stored = await openGroups(key, row, tenantId, stage, GROUPS);
574
+ return stored !== null && sameOrderContact(stored, orderContactPlaintext(command))
575
+ ? { ok: true, status: 200, duplicate: true }
576
+ : { ok: false, status: 409, reason: "contact-conflict" };
577
+ }
578
+
579
+ // ---------------------------------------------------------------------------------------------
580
+ // Read and list
581
+ // ---------------------------------------------------------------------------------------------
582
+
583
+ export type ReadOutcome =
584
+ | { ok: true; contact: ReadableContact; expiresAt: string; groups: Partial<Record<Group, GroupView>> }
585
+ | { ok: false; status: 410; reason: "contact-expired" }
586
+ | { ok: false; status: 404 | 429 | 503; reason: "not-found" | "quota" | "audit-unavailable" | "vault-key-unavailable" };
587
+
588
+ /** TERMINAL_AT_SQL correlated to the order row aliased `alias` instead of bound parameters. */
589
+ function correlatedTerminalSql(alias: string): string {
590
+ const sql = TERMINAL_AT_SQL.replace(
591
+ "tenant_id = ? AND stage = ? AND order_id = ?",
592
+ `tenant_id = ${alias}.tenant_id AND stage = ${alias}.stage AND order_id = ${alias}.order_id`,
205
593
  );
206
- if (tomb) return { ok: false, status: 410, reason: tomb.reason === "erased" ? "contact-erased" : "contact-expired" };
207
- return null;
594
+ if (sql === TERMINAL_AT_SQL || sql.includes("?")) throw new Error("vault: terminal-time SQL shape changed");
595
+ return sql;
596
+ }
597
+
598
+ const ORDER_JOIN_SELECT = `SELECT v.*, o.placed_at AS order_placed_at, o.fulfillment_state AS order_fulfillment_state,
599
+ (${correlatedTerminalSql("o")}) AS order_terminal_at
600
+ FROM scrappy_commerce_orders o
601
+ 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
+ interface Viewable {
604
+ groups: Group[];
605
+ views: Partial<Record<Group, GroupView>>;
606
+ expiresAt: string;
208
607
  }
209
608
 
210
- /** Read one order's contact. The audit row commits before anything is decrypted. */
609
+ /** The groups of a joined row readable now, or null when none is (or the order times are unreadable). */
610
+ function viewable(row: VaultRow, tenantId: string, stage: VaultStage, now: Date, policy: RetentionPolicy): Viewable | null {
611
+ const times = orderTimes(row.order_placed_at, row.order_fulfillment_state, row.order_terminal_at);
612
+ if (times === "order-time-unreadable") return null;
613
+ const states = groupStates(row, tenantId, stage, times, now, policy);
614
+ const groups = GROUPS.filter((group) => states[group].readable);
615
+ if (groups.length === 0) return null;
616
+ const views: Partial<Record<Group, GroupView>> = {};
617
+ let earliest: string | null = null;
618
+ for (const group of groups) {
619
+ const ceiling = text(row, `${group}_ceiling`)!;
620
+ const effective = states[group].expiresAt!;
621
+ const expiresAt = effective < ceiling ? effective : ceiling;
622
+ views[group] = { expiresAt, rule: text(row, `${group}_rule`) };
623
+ if (earliest === null || expiresAt < earliest) earliest = expiresAt;
624
+ }
625
+ return { groups, views, expiresAt: earliest! };
626
+ }
627
+
628
+ /** True when a preview order is at or past placed_at + the preview cutoff (nothing is admitted then). */
629
+ async function pastPreviewCutoff(db: Queryable, tenantId: string, stage: VaultStage, orderId: string, now: Date, policy: RetentionPolicy): Promise<boolean> {
630
+ if (stage !== "preview") return false;
631
+ const order = await db.first<{ placed_at: unknown }>(
632
+ `SELECT placed_at FROM scrappy_commerce_orders WHERE tenant_id = ? AND stage = ? AND order_id = ?`, tenantId, stage, orderId,
633
+ );
634
+ const placedAt = order ? readInstant(order.placed_at) : null;
635
+ return placedAt !== null && now.getTime() >= Date.parse(previewDeleteAt(placedAt, policy));
636
+ }
637
+
638
+ /**
639
+ * Read one order's contact: only the groups readable now. A preview order at or past its cutoff
640
+ * answers 410 with zero writes; otherwise the audit row commits before any decrypt.
641
+ */
211
642
  export async function readOrderContact(
212
643
  db: Queryable,
213
- keyring: VaultKeyring | null,
214
- input: { tenantId: string; stage: VaultStage; orderId: string; actor: ContactActor; auditId: string; now: Date },
644
+ kms: KmsClient,
645
+ input: { tenantId: string; stage: VaultStage; orderId: string; actor: ContactActor; auditId: string; now: Date; policy?: RetentionPolicy },
215
646
  ): Promise<ReadOutcome> {
216
647
  const { tenantId, stage, orderId, actor, auditId, now } = input;
648
+ const policy = input.policy ?? RETENTION_POLICY;
649
+ if (await pastPreviewCutoff(db, tenantId, stage, orderId, now, policy)) return { ok: false, status: 410, reason: "contact-expired" };
217
650
  const admitted = await admitAudit(db, auditStatement({
218
651
  auditId, tenantId, stage, recordKind: "order-contact", recordId: orderId, actor, action: "read", now,
219
652
  }));
220
653
  if (admitted === "quota") return { ok: false, status: 429, reason: "quota" };
221
654
  if (admitted === "unavailable") return { ok: false, status: 503, reason: "audit-unavailable" };
222
- const row = await db.first<VaultRow>(
223
- `SELECT key_id, nonce, ciphertext, not_after, expires_at, sealed_jti FROM order_contact_vault
224
- WHERE tenant_id = ? AND stage = ? AND order_id = ?`,
225
- tenantId, stage, orderId,
655
+ const row = await db.first<VaultRow>(`${ORDER_JOIN_SELECT} WHERE o.tenant_id = ? AND o.stage = ? AND o.order_id = ?`, tenantId, stage, orderId);
656
+ if (!row) return { ok: false, status: 404, reason: "not-found" };
657
+ const view = viewable(row, tenantId, stage, now, policy);
658
+ if (!view) return { ok: false, status: 404, reason: "not-found" };
659
+ try {
660
+ const kid = text(row, "order_kid");
661
+ const key = kid ? await keyForKid(db, kms, keyContext(tenantId, stage, ORDER_KEY), kid) : null;
662
+ if (!key) return { ok: false, status: 404, reason: "not-found" };
663
+ const contact = await openGroups(key, row, tenantId, stage, view.groups);
664
+ if (!contact) return { ok: false, status: 404, reason: "not-found" };
665
+ return { ok: true, contact, expiresAt: view.expiresAt, groups: view.views };
666
+ } catch (error) {
667
+ if (error instanceof VaultKeyUnavailableError) return { ok: false, status: 503, reason: "vault-key-unavailable" };
668
+ throw error;
669
+ }
670
+ }
671
+
672
+ export interface ListedContact {
673
+ orderId: string;
674
+ contact: ReadableContact;
675
+ expiresAt: string;
676
+ groups: Partial<Record<Group, GroupView>>;
677
+ }
678
+
679
+ export type ListOutcome =
680
+ | { ok: true; contacts: ListedContact[] }
681
+ | { ok: false; status: 403 | 429 | 503; reason: "support-read-required" | "quota" | "audit-unavailable" | "vault-key-unavailable" };
682
+
683
+ /**
684
+ * The Orders panel list: one query joining orders to the vault, one `list` audit row naming the
685
+ * order ids it returns and their count (before anything is decrypted), then local decryption under
686
+ * cached data keys. Owner only; at most {@link AUDIT_RECORD_IDS_MAX} records.
687
+ */
688
+ export async function listOrderContacts(
689
+ db: Queryable,
690
+ kms: KmsClient,
691
+ input: { tenantId: string; stage: VaultStage; actor: ContactActor; auditId: string; now: Date; limit?: number; policy?: RetentionPolicy },
692
+ ): Promise<ListOutcome> {
693
+ const { tenantId, stage, actor, auditId, now } = input;
694
+ const policy = input.policy ?? RETENTION_POLICY;
695
+ if (actor.role !== "owner") return { ok: false, status: 403, reason: "support-read-required" };
696
+ const limit = Math.max(1, Math.min(AUDIT_RECORD_IDS_MAX, Math.floor(input.limit ?? 100)));
697
+ const rows = await db.all<VaultRow>(
698
+ `${ORDER_JOIN_SELECT} WHERE o.tenant_id = ? AND o.stage = ? ORDER BY o.placed_at DESC, o.order_id LIMIT ?`,
699
+ tenantId, stage, limit,
226
700
  );
227
- if (!row || !isReadable(now, row.expires_at, row.not_after)) return { ok: false, status: 404, reason: "not-found" };
228
- let opened: unknown | null;
701
+ const candidates = rows.flatMap((row) => {
702
+ const view = viewable(row, tenantId, stage, now, policy);
703
+ return view ? [{ row, view }] : [];
704
+ });
705
+ const admitted = await admitAudit(db, auditStatement({
706
+ auditId, tenantId, stage, recordKind: "order-contact", recordId: auditRecordIds(candidates.map(({ row }) => String(row.order_id))),
707
+ recordCount: candidates.length, actor, action: "list", now,
708
+ }));
709
+ if (admitted === "quota") return { ok: false, status: 429, reason: "quota" };
710
+ if (admitted === "unavailable") return { ok: false, status: 503, reason: "audit-unavailable" };
711
+ const contacts: ListedContact[] = [];
229
712
  try {
230
- opened = await openVaultRecord(keyring, binding(tenantId, stage, orderId, row.not_after), {
231
- keyId: row.key_id, nonce: row.nonce, ciphertext: row.ciphertext,
232
- });
713
+ const keys = new Map<string, CryptoKey | null>();
714
+ for (const { row, view } of candidates) {
715
+ const kid = text(row, "order_kid");
716
+ if (!kid) continue;
717
+ if (!keys.has(kid)) keys.set(kid, await keyForKid(db, kms, keyContext(tenantId, stage, ORDER_KEY), kid));
718
+ const key = keys.get(kid);
719
+ if (!key) continue;
720
+ const contact = await openGroups(key, row, tenantId, stage, view.groups);
721
+ if (!contact) continue;
722
+ contacts.push({ orderId: String(row.order_id), contact, expiresAt: view.expiresAt, groups: view.views });
723
+ }
233
724
  } catch (error) {
234
725
  if (error instanceof VaultKeyUnavailableError) return { ok: false, status: 503, reason: "vault-key-unavailable" };
235
726
  throw error;
236
727
  }
237
- if (opened === null || typeof opened !== "object") return { ok: false, status: 404, reason: "not-found" };
238
- return { ok: true, contact: opened as OrderContact, expiresAt: row.expires_at < row.not_after ? row.expires_at : row.not_after };
728
+ return { ok: true, contacts };
729
+ }
730
+
731
+ // ---------------------------------------------------------------------------------------------
732
+ // Search
733
+ // ---------------------------------------------------------------------------------------------
734
+
735
+ export type SearchQuery = { email: string } | { phone: string };
736
+
737
+ export type SearchOutcome =
738
+ | { ok: true; orderIds: string[] }
739
+ | { ok: false; status: 400 | 403 | 429 | 503; reason: "malformed" | "support-read-required" | "quota" | "audit-unavailable" | "vault-key-unavailable" };
740
+
741
+ /** A search body: exactly `{email}` or exactly `{phone}`, a bounded string. Null otherwise. */
742
+ export function readSearchQuery(body: unknown): SearchQuery | null {
743
+ if (!body || typeof body !== "object" || Array.isArray(body)) return null;
744
+ const keys = Object.keys(body);
745
+ if (keys.length !== 1) return null;
746
+ const record = body as Record<string, unknown>;
747
+ if (keys[0] === "email" && typeof record.email === "string" && record.email.length <= 254) return { email: record.email };
748
+ if (keys[0] === "phone" && typeof record.phone === "string" && record.phone.length <= 40) return { phone: record.phone };
749
+ return null;
750
+ }
751
+
752
+ /**
753
+ * Owner exact-match search by email or phone: the normalized value's blind index under every
754
+ * blind-index key the tenant still holds, matched against linked rows of the routed tenant and
755
+ * stage whose group is readable now, at most {@link AUDIT_RECORD_IDS_MAX}. The audit row stores the
756
+ * action, the matched order ids and their count, never the searched value.
757
+ */
758
+ export async function searchOrderContacts(
759
+ db: Queryable,
760
+ kms: KmsClient,
761
+ input: { tenantId: string; stage: VaultStage; actor: ContactActor; auditId: string; now: Date; query: SearchQuery },
762
+ ): Promise<SearchOutcome> {
763
+ const { tenantId, stage, actor, auditId, now, query } = input;
764
+ if (actor.role !== "owner") return { ok: false, status: 403, reason: "support-read-required" };
765
+ const isEmail = "email" in query;
766
+ const normalized = isEmail ? normalizeEmail(query.email) : normalizePhone(query.phone);
767
+ if (normalized === null) return { ok: false, status: 400, reason: "malformed" };
768
+ const group: Group = isEmail ? "email" : "phone";
769
+ const column = isEmail ? "email_bidx" : "phone_bidx";
770
+ const pairs: Array<[string, string]> = [];
771
+ try {
772
+ const kids = await db.all<{ kid: string }>(
773
+ `SELECT kid FROM contact_vault_data_keys WHERE tenant_id = ? AND stage = ? AND purpose = ? ORDER BY created_at`,
774
+ tenantId, stage, BIDX_KEY,
775
+ );
776
+ for (const { kid } of kids) {
777
+ const key = await keyForKid(db, kms, keyContext(tenantId, stage, BIDX_KEY), kid);
778
+ if (key) pairs.push([kid, await blindIndex(key, normalized)]);
779
+ }
780
+ } catch (error) {
781
+ if (error instanceof VaultKeyUnavailableError) return { ok: false, status: 503, reason: "vault-key-unavailable" };
782
+ throw error;
783
+ }
784
+ let orderIds: string[] = [];
785
+ if (pairs.length > 0) {
786
+ const rows = await db.all<{ order_id: string; expires_at: string | null; ceiling: string | null }>(
787
+ `SELECT order_id, ${group}_expires_at AS expires_at, ${group}_ceiling AS ceiling FROM order_contact_vault
788
+ WHERE tenant_id = ? AND stage = ? AND order_id IS NOT NULL AND ${group}_expires_at > ? AND ${group}_ceiling > ?
789
+ AND (${pairs.map(() => `(bidx_kid = ? AND ${column} = ?)`).join(" OR ")})
790
+ ORDER BY order_id LIMIT ?`,
791
+ tenantId, stage, now.toISOString(), now.toISOString(), ...pairs.flat(), AUDIT_RECORD_IDS_MAX,
792
+ );
793
+ orderIds = rows.filter((row) => isGroupReadable(now, row.expires_at, row.ceiling)).map((row) => row.order_id);
794
+ }
795
+ const admitted = await admitAudit(db, auditStatement({
796
+ auditId, tenantId, stage, recordKind: "order-contact", recordId: auditRecordIds(orderIds), recordCount: orderIds.length, actor, action: "search", now,
797
+ }));
798
+ if (admitted === "quota") return { ok: false, status: 429, reason: "quota" };
799
+ if (admitted === "unavailable") return { ok: false, status: 503, reason: "audit-unavailable" };
800
+ return { ok: true, orderIds };
239
801
  }
240
802
 
241
- /** Erase one order's contact: audit, tombstone and delete in one batch. */
803
+ // ---------------------------------------------------------------------------------------------
804
+ // Erase
805
+ // ---------------------------------------------------------------------------------------------
806
+
807
+ export type EraseOutcome =
808
+ | { ok: true; groups: Group[] }
809
+ | { ok: false; status: 404 | 429 | 503; reason: "not-found" | "quota" | "audit-unavailable" }
810
+ | { ok: false; status: 409; reason: "retention-hold"; group: Group; holdUntil: string };
811
+
812
+ /** Erase-request groups: a non-empty list of distinct known groups, or null. */
813
+ export function readEraseGroups(value: unknown): Group[] | null {
814
+ if (!Array.isArray(value) || value.length === 0 || value.length > GROUPS.length) return null;
815
+ if (!value.every((group) => typeof group === "string" && (GROUPS as readonly string[]).includes(group))) return null;
816
+ if (new Set(value).size !== value.length) return null;
817
+ return value as Group[];
818
+ }
819
+
820
+ /**
821
+ * Erase field groups of one order's contact. `groups` defaults to every group still holding data
822
+ * that no floor holds. A named group under a floor answers 409 retention-hold with its hold-until
823
+ * date, with no audit row and no change. Otherwise audit, clearing and detach commit together; the
824
+ * marker stays, so a later resend answers 410.
825
+ */
242
826
  export async function eraseOrderContact(
243
827
  db: Queryable,
244
- input: { tenantId: string; stage: VaultStage; orderId: string; actor: ContactActor; auditId: string; now: Date },
828
+ input: { tenantId: string; stage: VaultStage; orderId: string; actor: ContactActor; auditId: string; now: Date; groups?: readonly Group[] },
245
829
  ): Promise<EraseOutcome> {
246
830
  const { tenantId, stage, orderId, actor, auditId, now } = input;
247
- const nowIso = now.toISOString();
248
- const exists = await db.first(
249
- `SELECT 1 AS present FROM order_contact_vault WHERE tenant_id = ? AND stage = ? AND order_id = ?`,
250
- tenantId, stage, orderId,
831
+ const row = await db.first<VaultRow>(
832
+ `SELECT * FROM order_contact_vault WHERE tenant_id = ? AND stage = ? AND order_id = ?`, tenantId, stage, orderId,
251
833
  );
252
- if (!exists) return { ok: false, status: 404, reason: "not-found" };
253
- const rowSql = `SELECT 1 FROM order_contact_vault WHERE tenant_id = ? AND stage = ? AND order_id = ?`;
834
+ if (!row) return { ok: false, status: 404, reason: "not-found" };
835
+ const holdUntil = (group: Group): string | null => {
836
+ const floor = readInstant(text(row, `${group}_floor_until`));
837
+ return floor && floor.getTime() > now.getTime() ? text(row, `${group}_floor_until`) : null;
838
+ };
839
+ const named = input.groups;
840
+ if (named) {
841
+ for (const group of named) {
842
+ const hold = holdUntil(group);
843
+ if (hold) return { ok: false, status: 409, reason: "retention-hold", group, holdUntil: hold };
844
+ }
845
+ }
846
+ const targets = named ? [...named] : GROUPS.filter((group) => rowHasData(row, group) && !holdUntil(group));
847
+ if (targets.length === 0) {
848
+ const held = GROUPS.find((group) => rowHasData(row, group) && holdUntil(group));
849
+ return held
850
+ ? { ok: false, status: 409, reason: "retention-hold", group: held, holdUntil: holdUntil(held)! }
851
+ : { ok: false, status: 404, reason: "not-found" };
852
+ }
853
+ const scope = `tenant_id = ? AND stage = ? AND order_id = ? AND EXISTS (SELECT 1 FROM contact_access_audit WHERE audit_id = ?)`;
854
+ const scopeParams = [tenantId, stage, orderId, auditId];
254
855
  const audit = auditStatement({
255
- auditId, tenantId, stage, recordKind: "order-contact", recordId: orderId, actor, action: "erase", now,
256
- requireRow: { sql: rowSql, params: [tenantId, stage, orderId] },
856
+ auditId, tenantId, stage, recordKind: "order-contact", recordId: orderId, groups: targets, actor, action: "erase", now,
857
+ requireRow: { sql: `SELECT 1 FROM order_contact_vault WHERE tenant_id = ? AND stage = ? AND order_id = ?`, params: [tenantId, stage, orderId] },
257
858
  });
258
859
  const statements: QueryStatement[] = [
259
860
  batchAuditStatement(audit),
260
- {
261
- sql: `INSERT INTO order_contact_tombstones (tenant_id, stage, order_id, reason, at, not_after)
262
- SELECT tenant_id, stage, order_id, 'erased', ?, not_after FROM order_contact_vault
263
- WHERE tenant_id = ? AND stage = ? AND order_id = ?
264
- AND EXISTS (SELECT 1 FROM contact_access_audit WHERE audit_id = ?)
265
- ON CONFLICT (tenant_id, stage, order_id) DO UPDATE SET reason = 'erased', at = excluded.at`,
266
- params: [nowIso, tenantId, stage, orderId, auditId],
267
- },
268
- {
269
- sql: `DELETE FROM order_contact_vault WHERE tenant_id = ? AND stage = ? AND order_id = ?
270
- AND EXISTS (SELECT 1 FROM contact_access_audit WHERE audit_id = ?)`,
271
- params: [tenantId, stage, orderId, auditId],
272
- },
861
+ { sql: `UPDATE order_contact_vault SET ${clearSql(targets)}, erased_at = ? WHERE ${scope}`, params: [now.toISOString(), ...scopeParams] },
862
+ ...normalizeStatements(scope, scopeParams),
273
863
  ];
274
864
  try {
275
865
  await requireBatch(db)(statements);
276
866
  } catch {
277
867
  return { ok: false, status: 503, reason: "audit-unavailable" };
278
868
  }
279
- const audited = await db.first(`SELECT 1 AS present FROM contact_access_audit WHERE audit_id = ?`, auditId);
280
- if (audited) return { ok: true };
869
+ if (await db.first(`SELECT 1 AS present FROM contact_access_audit WHERE audit_id = ?`, auditId)) return { ok: true, groups: targets };
281
870
  const still = await db.first(`SELECT 1 AS present FROM order_contact_vault WHERE tenant_id = ? AND stage = ? AND order_id = ?`, tenantId, stage, orderId);
282
871
  return still ? { ok: false, status: 429, reason: "quota" } : { ok: false, status: 404, reason: "not-found" };
283
872
  }
284
873
 
285
- /** The statement that shortens an order's contact retention when the order becomes terminal. */
286
- export function terminalExpiryStatement(tenantId: string, stage: VaultStage, orderId: string, terminalAt: Date): { sql: string; params: unknown[] } {
287
- return {
288
- sql: `UPDATE order_contact_vault SET expires_at = MIN(expires_at, ?)
289
- WHERE tenant_id = ? AND stage = ? AND order_id = ? AND expires_at > ?`,
290
- params: [terminalExpiryFrom(terminalAt), tenantId, stage, orderId, terminalExpiryFrom(terminalAt)],
874
+ // ---------------------------------------------------------------------------------------------
875
+ // Terminal transition
876
+ // ---------------------------------------------------------------------------------------------
877
+
878
+ const FAR_FUTURE = "9999-12-31T23:59:59.999Z";
879
+
880
+ /** The rederived non-floor expiry of `group` for each ship-to variant the tenant's rules distinguish, as a SQL CASE. */
881
+ function rederivedCaseSql(
882
+ group: Group,
883
+ tenantId: string,
884
+ stage: VaultStage,
885
+ placedAt: Date,
886
+ terminalAt: Date,
887
+ timeScale: number,
888
+ policy: RetentionPolicy,
889
+ ): string | null {
890
+ const tenantRules = rulesForTenant(tenantId, policy);
891
+ const countries = [...new Set(tenantRules.flatMap((rule) => rule.appliesWhen.shipCountries ?? []).map((c) => c.toUpperCase()))];
892
+ const regions = [...new Set(tenantRules.flatMap((rule) => rule.appliesWhen.regions ?? []).map((r) => r.toUpperCase()))];
893
+ const derive = (country: string | null, region: string | null): string | null => rederiveGroup({
894
+ group, ceiling: FAR_FUTURE, expiresAt: FAR_FUTURE, floorUntil: null, timeScale, stage, placedAt, terminalAt,
895
+ rules: rederivationRules(tenantId, country, region, policy), policy,
896
+ });
897
+ const fallback = derive(null, null);
898
+ if (fallback === null) return null;
899
+ const branches = new Map<string, string[]>();
900
+ const add = (condition: string, value: string | null) => {
901
+ if (value === null) return;
902
+ branches.set(value, [...(branches.get(value) ?? []), condition]);
291
903
  };
904
+ const countryExpr = "UPPER(TRIM(COALESCE(ship_country, '')))";
905
+ const regionExpr = "UPPER(TRIM(COALESCE(ship_region, '')))";
906
+ for (const country of countries) {
907
+ for (const region of regions) add(`(${countryExpr} = ${sqlLiteral(country)} AND ${regionExpr} = ${sqlLiteral(region)})`, derive(country, region));
908
+ add(`${countryExpr} = ${sqlLiteral(country)}`, derive(country, null));
909
+ }
910
+ for (const region of regions) add(`${regionExpr} = ${sqlLiteral(region)}`, derive(null, region));
911
+ const whens = [...branches.entries()]
912
+ .filter(([value]) => value !== fallback)
913
+ .map(([value, conditions]) => `WHEN ${conditions.join(" OR ")} THEN ${sqlLiteral(value)}`);
914
+ return whens.length === 0 ? sqlLiteral(fallback) : `CASE ${whens.join(" ")} ELSE ${sqlLiteral(fallback)} END`;
292
915
  }
293
916
 
294
917
  /**
295
- * The same shortening for a fulfillment transition's own batch: it applies only when the order row
296
- * reached a terminal state at exactly `version`, so a refused transition changes nothing.
918
+ * The statement for a fulfillment transition's own batch that shrinks each group's effective expiry
919
+ * now that the order is terminal: max(current non-floor rules given the terminal time, the row's
920
+ * floor_until), never above its current expiry or its ceiling. It applies only when the order row
921
+ * reached a terminal state at exactly `version` and the row was sealed on this stage's clock, so a
922
+ * refused transition changes nothing; the hourly purge re-derives anything it skips.
297
923
  */
298
924
  export function terminalTransitionExpiryStatement(
299
- order: { tenantId: string; stage: VaultStage; orderId: string },
925
+ order: { tenantId: string; stage: VaultStage; orderId: string; placedAt: string },
300
926
  version: number,
301
927
  terminalAt: Date,
928
+ policy: RetentionPolicy = RETENTION_POLICY,
302
929
  ): { sql: string; params: unknown[] } {
303
- const expiry = terminalExpiryFrom(terminalAt);
930
+ const placedAt = readInstant(order.placedAt);
931
+ const timeScale = order.stage === "preview" ? policy.previewTimeScale : 1;
932
+ const derived = placedAt && !Number.isNaN(terminalAt.getTime())
933
+ ? GROUPS.map((group) => [group, rederivedCaseSql(group, order.tenantId, order.stage, placedAt, terminalAt, timeScale, policy)] as const)
934
+ : [];
935
+ if (derived.length === 0 || derived.some(([, sql]) => sql === null)) {
936
+ return { sql: `UPDATE order_contact_vault SET time_scale = time_scale WHERE 0`, params: [] };
937
+ }
938
+ const sets = derived.map(([group, sql]) =>
939
+ `${group}_expires_at = CASE WHEN ${group}_expires_at IS NULL OR ${group}_ceiling IS NULL THEN ${group}_expires_at
940
+ ELSE MIN(${group}_expires_at, ${group}_ceiling, MAX(COALESCE(${group}_floor_until, ''), ${sql})) END`);
304
941
  return {
305
- sql: `UPDATE order_contact_vault SET expires_at = ?
306
- WHERE tenant_id = ? AND stage = ? AND order_id = ? AND expires_at > ?
942
+ sql: `UPDATE order_contact_vault SET ${sets.join(", ")}
943
+ WHERE tenant_id = ? AND stage = ? AND order_id = ? AND time_scale = ?
307
944
  AND EXISTS (
308
945
  SELECT 1 FROM scrappy_commerce_orders
309
- WHERE tenant_id = ? AND stage = ? AND order_id = ? AND event_version = ?
946
+ WHERE tenant_id = ? AND stage = ? AND order_id = ? AND event_version = ? AND placed_at IS ?
310
947
  AND fulfillment_state IN ('handed_off', 'cancelled')
311
948
  )`,
312
- params: [expiry, order.tenantId, order.stage, order.orderId, expiry, order.tenantId, order.stage, order.orderId, version],
949
+ params: [order.tenantId, order.stage, order.orderId, timeScale, order.tenantId, order.stage, order.orderId, version, order.placedAt],
313
950
  };
314
951
  }
315
952
 
953
+ // ---------------------------------------------------------------------------------------------
954
+ // Purge
955
+ // ---------------------------------------------------------------------------------------------
956
+
316
957
  export interface PurgeCounts {
317
- purged: number;
318
- tombstonesRemoved: number;
958
+ /** Field groups cleared, by the rule id that held them. */
959
+ groupsPurged: Record<string, number>;
960
+ /** Rows detached to unlinked facts. */
961
+ detached: number;
962
+ /** Preview rows deleted whole with their marker. */
963
+ previewDeleted: number;
964
+ /** Group expiries shrunk by re-derivation. */
319
965
  shortened: number;
320
966
  }
321
967
 
968
+ /** SQL: the group still holds data and its effective expiry or ceiling has passed (two `now` params). */
969
+ function expiredSql(group: Group): string {
970
+ return `(${hasDataSql(group)} AND (${group}_expires_at IS NULL OR ${group}_expires_at <= ? OR ${group}_ceiling <= ?))`;
971
+ }
972
+
973
+ const TERMINAL_CORRELATED_SQL = correlatedTerminalSql("v");
974
+
322
975
  /**
323
- * Purge expired order contacts. One batch tombstones and deletes every expired row (same `now`), so
324
- * a later resend of a purged contact meets the tombstone. Then terminal orders still holding a
325
- * longer expiry are shortened: to 180 days after their audited terminal time, or 180 days after
326
- * now when no terminal time was audited (the ledger path), never later than they already were.
976
+ * The hourly purge, with one clock reading: re-derive terminal orders' expiries (shrink-only), clear
977
+ * every group whose effective expiry has passed (its ciphertexts, its blind index, and city and full
978
+ * postal code for an address group), detach publication rows whose last group is gone, and delete
979
+ * preview rows with their marker at placed_at + the cutoff. Keyset pages, one batch each, so no
980
+ * page repeats. Aborts on an unreadable clock.
327
981
  */
328
- export async function purgeOrderContacts(db: Queryable, now: Date, limit = 500): Promise<PurgeCounts> {
329
- if (Number.isNaN(now.getTime())) throw new Error("vault purge: unreadable clock");
982
+ export async function purgeOrderContacts(
983
+ db: Queryable,
984
+ now: Date,
985
+ options: { pageSize?: number; shortenLimit?: number; policy?: RetentionPolicy } = {},
986
+ ): Promise<PurgeCounts> {
987
+ if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new Error("vault purge: unreadable clock");
988
+ const pageSize = Math.floor(options.pageSize ?? 50);
989
+ const shortenLimit = Math.floor(options.shortenLimit ?? 500);
990
+ if (!Number.isFinite(pageSize) || pageSize < 1 || pageSize > 90) throw new Error("vault purge: unreadable page size");
991
+ if (!Number.isFinite(shortenLimit) || shortenLimit < 0) throw new Error("vault purge: unreadable limit");
992
+ const policy = options.policy ?? RETENTION_POLICY;
993
+ const batch = requireBatch(db);
330
994
  const nowIso = now.toISOString();
331
- const before = await db.first<{ n: number }>(
332
- `SELECT COUNT(*) AS n FROM order_contact_vault WHERE expires_at <= ? OR not_after <= ?`, nowIso, nowIso,
333
- );
334
- const tombsBefore = await db.first<{ n: number }>(`SELECT COUNT(*) AS n FROM order_contact_tombstones WHERE not_after <= ?`, nowIso);
335
- await requireBatch(db)([
336
- {
337
- sql: `INSERT INTO order_contact_tombstones (tenant_id, stage, order_id, reason, at, not_after)
338
- SELECT tenant_id, stage, order_id, 'purged', ?, not_after FROM order_contact_vault
339
- WHERE expires_at <= ? OR not_after <= ?
340
- ON CONFLICT (tenant_id, stage, order_id) DO NOTHING`,
341
- params: [nowIso, nowIso, nowIso],
342
- },
343
- { sql: `DELETE FROM order_contact_vault WHERE expires_at <= ? OR not_after <= ?`, params: [nowIso, nowIso] },
344
- { sql: `DELETE FROM order_contact_tombstones WHERE not_after <= ?`, params: [nowIso] },
345
- ]);
346
- const terminal = await db.all<{ tenant_id: string; stage: VaultStage; order_id: string; expires_at: string; at: string | null }>(
347
- `SELECT v.tenant_id, v.stage, v.order_id, v.expires_at,
348
- (SELECT MIN(a.at) FROM scrappy_shipment_audit a
349
- WHERE a.tenant_id = v.tenant_id AND a.stage = v.stage AND a.order_id = v.order_id
350
- AND ((a.kind = 'transition' AND a.to_state = 'handed_off') OR (a.kind = 'pending-cancel' AND a.to_state = 'confirmed'))) AS at
995
+ const counts: PurgeCounts = { groupsPurged: {}, detached: 0, previewDeleted: 0, shortened: 0 };
996
+
997
+ // 1. Shrink-only re-derivation for terminal orders, before clearing, so this run clears what it shrank.
998
+ let cursor = "";
999
+ while (counts.shortened < shortenLimit) {
1000
+ const rows = await db.all<VaultRow>(
1001
+ `SELECT v.*, o.placed_at AS order_placed_at, o.fulfillment_state AS order_fulfillment_state, (${TERMINAL_CORRELATED_SQL}) AS order_terminal_at
1002
+ FROM order_contact_vault v
1003
+ JOIN scrappy_commerce_orders o ON o.tenant_id = v.tenant_id AND o.stage = v.stage AND o.order_id = v.order_id
1004
+ WHERE o.fulfillment_state IN ('handed_off', 'cancelled') AND v.fact_id > ?
1005
+ ORDER BY v.fact_id LIMIT ?`,
1006
+ cursor, pageSize,
1007
+ );
1008
+ if (rows.length === 0) break;
1009
+ const updates: QueryStatement[] = [];
1010
+ for (const row of rows) {
1011
+ cursor = String(row.fact_id);
1012
+ const times = orderTimes(row.order_placed_at, row.order_fulfillment_state, row.order_terminal_at);
1013
+ if (times === "order-time-unreadable") continue;
1014
+ const effective = effectiveExpiries(row, String(row.tenant_id), row.stage as VaultStage, times, policy);
1015
+ for (const group of GROUPS) {
1016
+ const current = text(row, `${group}_expires_at`);
1017
+ const next = effective[group];
1018
+ if (!rowHasData(row, group) || !current || !next || next >= current) continue;
1019
+ if (counts.shortened + updates.length >= shortenLimit) break;
1020
+ updates.push({
1021
+ sql: `UPDATE order_contact_vault SET ${group}_expires_at = ? WHERE fact_id = ? AND ${group}_expires_at = ?`,
1022
+ params: [next, row.fact_id, current],
1023
+ });
1024
+ }
1025
+ }
1026
+ if (updates.length > 0) await batch(updates);
1027
+ counts.shortened += updates.length;
1028
+ if (rows.length < pageSize) break;
1029
+ }
1030
+
1031
+ // 2. Clear expired groups and detach, page by page.
1032
+ cursor = "";
1033
+ for (;;) {
1034
+ const rows = await db.all<VaultRow>(
1035
+ `SELECT fact_id, stage, ${GROUPS.map((group) => `${group}_rule, ${group}_expires_at, ${group}_ceiling, ${hasDataSql(group)} AS ${group}_has`).join(", ")}
1036
+ FROM order_contact_vault
1037
+ WHERE fact_id > ? AND (${GROUPS.map(expiredSql).join(" OR ")})
1038
+ ORDER BY fact_id LIMIT ?`,
1039
+ cursor, ...GROUPS.flatMap(() => [nowIso, nowIso]), pageSize,
1040
+ );
1041
+ if (rows.length === 0) break;
1042
+ const ids = rows.map((row) => String(row.fact_id));
1043
+ const inList = ids.map(() => "?").join(", ");
1044
+ const statements: QueryStatement[] = GROUPS.map((group) => ({
1045
+ sql: `UPDATE order_contact_vault SET ${clearSql([group])} WHERE fact_id IN (${inList}) AND ${expiredSql(group)}`,
1046
+ params: [...ids, nowIso, nowIso],
1047
+ }));
1048
+ statements.push(...normalizeStatements(`fact_id IN (${inList})`, ids));
1049
+ await batch(statements);
1050
+ for (const row of rows) {
1051
+ let remaining = 0;
1052
+ for (const group of GROUPS) {
1053
+ if (Number(row[`${group}_has`]) !== 1) continue;
1054
+ const expires = text(row, `${group}_expires_at`);
1055
+ const ceiling = text(row, `${group}_ceiling`);
1056
+ const due = expires === null || expires <= nowIso || (ceiling !== null && ceiling <= nowIso);
1057
+ if (!due) {
1058
+ remaining += 1;
1059
+ continue;
1060
+ }
1061
+ const rule = text(row, `${group}_rule`) ?? "unrecorded";
1062
+ counts.groupsPurged[rule] = (counts.groupsPurged[rule] ?? 0) + 1;
1063
+ }
1064
+ if (remaining === 0 && row.stage === "publication") counts.detached += 1;
1065
+ }
1066
+ cursor = ids[ids.length - 1]!;
1067
+ if (rows.length < pageSize) break;
1068
+ }
1069
+
1070
+ // 3. Preview: delete the whole row and its marker at placed_at + the cutoff.
1071
+ let markerCursor: [string, string] = ["", ""];
1072
+ for (;;) {
1073
+ const markers = await db.all<{ tenant_id: string; order_id: string; placed_at: unknown }>(
1074
+ `SELECT m.tenant_id, m.order_id, o.placed_at FROM order_contact_markers m
1075
+ JOIN scrappy_commerce_orders o ON o.tenant_id = m.tenant_id AND o.stage = m.stage AND o.order_id = m.order_id
1076
+ WHERE m.stage = 'preview' AND (m.tenant_id, m.order_id) > (?, ?)
1077
+ ORDER BY m.tenant_id, m.order_id LIMIT ?`,
1078
+ ...markerCursor, pageSize,
1079
+ );
1080
+ if (markers.length === 0) break;
1081
+ const due = markers.filter((marker) => {
1082
+ const placed = readInstant(marker.placed_at);
1083
+ return placed !== null && nowIso >= previewDeleteAt(placed, policy);
1084
+ });
1085
+ if (due.length > 0) {
1086
+ const pairs = due.map(() => "(tenant_id = ? AND order_id = ?)").join(" OR ");
1087
+ const params = due.flatMap((marker) => [marker.tenant_id, marker.order_id]);
1088
+ await batch([
1089
+ { sql: `DELETE FROM order_contact_vault WHERE stage = 'preview' AND (${pairs})`, params },
1090
+ { sql: `DELETE FROM order_contact_markers WHERE stage = 'preview' AND (${pairs})`, params },
1091
+ ]);
1092
+ counts.previewDeleted += due.length;
1093
+ }
1094
+ const last = markers[markers.length - 1]!;
1095
+ markerCursor = [last.tenant_id, last.order_id];
1096
+ if (markers.length < pageSize) break;
1097
+ }
1098
+ return counts;
1099
+ }
1100
+
1101
+ // ---------------------------------------------------------------------------------------------
1102
+ // Rotation
1103
+ // ---------------------------------------------------------------------------------------------
1104
+
1105
+ /** Correlated reference checks for every data-key purpose (orders and messages). */
1106
+ export const DATA_KEY_REFERENCES: Readonly<Record<DataKeyPurpose, string>> = {
1107
+ "order-contact": `SELECT 1 FROM order_contact_vault v WHERE v.tenant_id = contact_vault_data_keys.tenant_id
1108
+ AND v.stage = contact_vault_data_keys.stage AND v.order_kid = contact_vault_data_keys.kid`,
1109
+ "contact-message": `SELECT 1 FROM contact_messages m WHERE m.tenant_id = contact_vault_data_keys.tenant_id
1110
+ AND m.stage = contact_vault_data_keys.stage AND m.msg_kid = contact_vault_data_keys.kid`,
1111
+ "blind-index": `SELECT 1 FROM order_contact_vault v WHERE v.tenant_id = contact_vault_data_keys.tenant_id
1112
+ AND v.stage = contact_vault_data_keys.stage AND v.bidx_kid = contact_vault_data_keys.kid
1113
+ UNION ALL SELECT 1 FROM contact_messages m WHERE m.tenant_id = contact_vault_data_keys.tenant_id
1114
+ AND m.stage = contact_vault_data_keys.stage AND m.bidx_kid = contact_vault_data_keys.kid`,
1115
+ };
1116
+
1117
+ /** Rows per table whose blind indexes one rotation run recomputes; the next run takes the rest. */
1118
+ export const BLIND_INDEX_RECOMPUTE_LIMIT = 200;
1119
+
1120
+ /**
1121
+ * One table's blind-index recompute step: re-index up to `limit` still-sealed rows whose bidx_kid is
1122
+ * retired under the active blind-index key, returning how many rows it moved. contactMessages.ts
1123
+ * supplies the messages step; the sweep passes it in, since orders never import engagement.
1124
+ */
1125
+ export type BlindIndexRecompute = (db: Queryable, kms: KmsClient, now: Date, limit: number) => Promise<number>;
1126
+
1127
+ /**
1128
+ * Recompute order rows indexed under a retired blind-index kid: decrypt the email and phone fields
1129
+ * under the row's order key, HMAC the normalized values with the active blind-index key, and write
1130
+ * both indexes and bidx_kid in ONE update conditioned on the row being unchanged since it was read.
1131
+ * An index whose field no longer opens is cleared. Throws VaultKeyUnavailableError when KMS refuses.
1132
+ */
1133
+ export const recomputeOrderBlindIndexes: BlindIndexRecompute = async (db, kms, now, limit) => {
1134
+ const rows = await db.all<VaultRow>(
1135
+ `SELECT v.fact_id, v.tenant_id, v.stage, v.order_kid, v.bidx_kid, v.email_ct, v.email_bidx, v.email_ceiling,
1136
+ v.phone_ct, v.phone_bidx, v.phone_ceiling
351
1137
  FROM order_contact_vault v
352
- JOIN scrappy_commerce_orders o ON o.tenant_id = v.tenant_id AND o.stage = v.stage AND o.order_id = v.order_id
353
- WHERE o.fulfillment_state IN ('handed_off', 'cancelled')
354
- LIMIT ?`,
355
- limit,
1138
+ JOIN contact_vault_data_keys k ON k.tenant_id = v.tenant_id AND k.stage = v.stage AND k.purpose = ? AND k.kid = v.bidx_kid
1139
+ WHERE k.retired_at IS NOT NULL
1140
+ ORDER BY v.fact_id LIMIT ?`,
1141
+ BIDX_KEY, Math.max(0, Math.floor(limit)),
356
1142
  );
357
- const updates: QueryStatement[] = [];
358
- for (const row of terminal) {
359
- const at = readInstant(row.at) ?? now;
360
- const statement = terminalExpiryStatement(row.tenant_id, row.stage, row.order_id, at);
361
- if (terminalExpiryFrom(at) < row.expires_at) updates.push(statement);
1143
+ let moved = 0;
1144
+ for (const row of rows) {
1145
+ const tenantId = String(row.tenant_id);
1146
+ const stage = row.stage as VaultStage;
1147
+ const factId = String(row.fact_id);
1148
+ const active = await activeKey(db, kms, keyContext(tenantId, stage, BIDX_KEY), now);
1149
+ // Rotation installs the new active key before this runs; never mint one from here.
1150
+ if (active.pending.length > 0 || active.kid === row.bidx_kid) continue;
1151
+ const orderKid = text(row, "order_kid");
1152
+ const orderKey = orderKid ? await keyForKid(db, kms, keyContext(tenantId, stage, ORDER_KEY), orderKid) : null;
1153
+ const reindex = async (group: "email" | "phone"): Promise<string | null> => {
1154
+ const sealed = text(row, `${group}_ct`);
1155
+ const ceiling = text(row, `${group}_ceiling`);
1156
+ if (text(row, `${group}_bidx`) === null || sealed === null || ceiling === null || !orderKey || !orderKid) return null;
1157
+ const value = await openField(orderKey, fieldBinding(tenantId, stage, factId, orderKid, `${group}_ct`, ceiling), sealed);
1158
+ const normalized = value === null ? null : group === "email" ? normalizeEmail(value) : normalizePhone(value);
1159
+ return normalized === null ? null : blindIndex(active.key, normalized);
1160
+ };
1161
+ const email = await reindex("email");
1162
+ const phone = await reindex("phone");
1163
+ const updated = await db.all(
1164
+ `UPDATE order_contact_vault SET email_bidx = ?, phone_bidx = ?, bidx_kid = ?
1165
+ WHERE fact_id = ? AND bidx_kid IS ? AND email_bidx IS ? AND phone_bidx IS ? AND email_ct IS ? AND phone_ct IS ?
1166
+ AND EXISTS (SELECT 1 FROM contact_vault_data_keys WHERE tenant_id = ? AND stage = ? AND purpose = ? AND kid = ? AND retired_at IS NULL)
1167
+ RETURNING fact_id`,
1168
+ email, phone, email === null && phone === null ? null : active.kid,
1169
+ factId, row.bidx_kid, row.email_bidx, row.phone_bidx, row.email_ct, row.phone_ct,
1170
+ tenantId, stage, BIDX_KEY, active.kid,
1171
+ );
1172
+ moved += updated.length;
362
1173
  }
363
- if (updates.length > 0) await requireBatch(db)(updates);
364
- return {
365
- purged: Number(before?.n ?? 0),
366
- tombstonesRemoved: Number(tombsBefore?.n ?? 0),
367
- shortened: updates.length,
368
- };
1174
+ return moved;
1175
+ };
1176
+
1177
+ /**
1178
+ * Rotate data keys older than 365 days, recompute blind indexes still under a retired blind-index
1179
+ * kid (orders here, plus each step in `recompute`, bounded per run), then delete retired keys no row
1180
+ * references any more.
1181
+ */
1182
+ export async function rotateContactVaultKeys(
1183
+ db: Queryable,
1184
+ kms: KmsClient,
1185
+ now: Date,
1186
+ options: { recompute?: readonly BlindIndexRecompute[]; recomputeLimit?: number } = {},
1187
+ ): Promise<{ rotated: number; recomputed: number }> {
1188
+ if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new Error("vault rotation: unreadable clock");
1189
+ const { rotated } = await rotateDueKeys(db, kms, now);
1190
+ const limit = options.recomputeLimit ?? BLIND_INDEX_RECOMPUTE_LIMIT;
1191
+ let recomputed = 0;
1192
+ for (const step of [recomputeOrderBlindIndexes, ...(options.recompute ?? [])]) recomputed += await step(db, kms, now, limit);
1193
+ await deleteUnreferencedRetiredKeys(db, DATA_KEY_REFERENCES);
1194
+ return { rotated, recomputed };
369
1195
  }