@tokenoftrust/storefront-runner 4.9.2 → 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 (27) 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
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The order contact a merchant needs to ship and support an order, as the signed contact command
3
+ * carries it (decision gv-contact-vault-command-contract-v1b). A strict allowlist: any extra or
4
+ * missing key, wrong type or out-of-range length is malformed.
5
+ */
6
+
7
+ export interface ContactAddress {
8
+ name?: string;
9
+ company?: string;
10
+ line1: string;
11
+ line2?: string;
12
+ city: string;
13
+ region?: string;
14
+ postalCode?: string;
15
+ country: string;
16
+ }
17
+
18
+ export interface OrderContact {
19
+ customer: { name: string; email: string; phone: string };
20
+ shipTo: ContactAddress;
21
+ billTo: ContactAddress;
22
+ }
23
+
24
+ export interface OrderContactCommand extends OrderContact {
25
+ orderId: string;
26
+ }
27
+
28
+ function isRecord(value: unknown): value is Record<string, unknown> {
29
+ return value !== null && typeof value === "object" && !Array.isArray(value);
30
+ }
31
+
32
+ function exact(value: Record<string, unknown>, required: readonly string[], optional: readonly string[] = []): boolean {
33
+ const keys = Object.keys(value);
34
+ return required.every((key) => keys.includes(key)) && keys.every((key) => required.includes(key) || optional.includes(key));
35
+ }
36
+
37
+ function text(value: unknown, min: number, max: number): value is string {
38
+ return typeof value === "string" && value.length >= min && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
39
+ }
40
+
41
+ const ADDRESS_REQUIRED = ["line1", "city", "country"] as const;
42
+ const ADDRESS_OPTIONAL = ["name", "company", "line2", "region", "postalCode"] as const;
43
+ const ADDRESS_LIMITS: Record<string, [number, number]> = {
44
+ name: [0, 200], company: [0, 200], line1: [1, 200], line2: [0, 200], city: [1, 100], region: [0, 100], postalCode: [0, 20],
45
+ };
46
+
47
+ function readAddress(value: unknown): ContactAddress | null {
48
+ if (!isRecord(value) || !exact(value, ADDRESS_REQUIRED, ADDRESS_OPTIONAL)) return null;
49
+ if (typeof value.country !== "string" || !/^[A-Z]{2}$/.test(value.country)) return null;
50
+ const out: Record<string, string> = { country: value.country };
51
+ for (const [key, [min, max]] of Object.entries(ADDRESS_LIMITS)) {
52
+ if (value[key] === undefined) continue;
53
+ if (!text(value[key], min, max)) return null;
54
+ out[key] = value[key] as string;
55
+ }
56
+ return out as unknown as ContactAddress;
57
+ }
58
+
59
+ /** Narrow the command's `contact` member, or null when it is not exactly the contract shape. */
60
+ export function readOrderContactCommand(value: unknown): OrderContactCommand | null {
61
+ if (!isRecord(value) || !exact(value, ["kind", "v", "orderId", "customer", "shipTo", "billTo"])) return null;
62
+ if (value.kind !== "order-contact" || value.v !== 1) return null;
63
+ if (!text(value.orderId, 1, 128) || value.orderId.trim() === "") return null;
64
+ const customer = value.customer;
65
+ if (!isRecord(customer) || !exact(customer, ["name", "email", "phone"])) return null;
66
+ if (!text(customer.name, 1, 200) || !text(customer.email, 3, 254) || !customer.email.includes("@") || !text(customer.phone, 0, 40)) {
67
+ return null;
68
+ }
69
+ const shipTo = readAddress(value.shipTo);
70
+ const billTo = readAddress(value.billTo);
71
+ if (!shipTo || !billTo) return null;
72
+ return {
73
+ orderId: value.orderId,
74
+ customer: { name: customer.name, email: customer.email, phone: customer.phone },
75
+ shipTo,
76
+ billTo,
77
+ };
78
+ }
79
+
80
+ /** The sealed plaintext: the contact without the order id, which the AAD already binds. */
81
+ export function orderContactPlaintext(command: OrderContactCommand): OrderContact {
82
+ return { customer: command.customer, shipTo: command.shipTo, billTo: command.billTo };
83
+ }
84
+
85
+ /** Deep structural equality of two contacts, for the duplicate-resend check. */
86
+ export function sameOrderContact(left: unknown, right: OrderContact): boolean {
87
+ return JSON.stringify(canonical(left)) === JSON.stringify(canonical(right));
88
+ }
89
+
90
+ function canonical(value: unknown): unknown {
91
+ if (Array.isArray(value)) return value.map(canonical);
92
+ if (isRecord(value)) {
93
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
94
+ }
95
+ return value;
96
+ }
@@ -0,0 +1,369 @@
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:
5
+ *
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.
11
+ *
12
+ * Nothing here logs. Errors and outcomes carry fixed codes only.
13
+ */
14
+ import type { Queryable, QueryStatement } from "../../catalog/d1/catalog.js";
15
+ 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";
24
+ import {
25
+ admitAudit,
26
+ auditStatement,
27
+ batchAuditStatement,
28
+ type ContactActor,
29
+ type VaultStage,
30
+ } from "@/lib/customers/contactVault/contactAudit.js";
31
+
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
+ };
39
+
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" };
43
+
44
+ export type EraseOutcome =
45
+ | { ok: true }
46
+ | { ok: false; status: 404 | 429 | 503; reason: "not-found" | "quota" | "audit-unavailable" };
47
+
48
+ interface OrderRow {
49
+ placed_at: unknown;
50
+ fulfillment_state: unknown;
51
+ }
52
+
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;
60
+ }
61
+
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'))`;
65
+
66
+ function binding(tenantId: string, stage: VaultStage, orderId: string, notAfter: string): VaultBinding {
67
+ return { purpose: "order-contact", tenantId, stage, recordId: orderId, notAfter };
68
+ }
69
+
70
+ function requireBatch(db: Queryable): NonNullable<Queryable["batch"]> {
71
+ if (!db.batch) throw new Error("D1 batch is unavailable");
72
+ return db.batch.bind(db);
73
+ }
74
+
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";
80
+ }
81
+
82
+ /** The order's not_after and expiry, derived from the order row alone. */
83
+ export async function orderRetention(
84
+ db: Queryable,
85
+ tenantId: string,
86
+ 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,
92
+ );
93
+ 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) };
100
+ }
101
+
102
+ /** 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> {
108
+ const { tenantId, stage, command, issuer, jti, now } = input;
109
+ const orderId = command.orderId;
110
+ const nowIso = now.toISOString();
111
+ 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);
121
+ if (existing) return existing;
122
+ if (nowIso >= retention.expiresAt) return { ok: false, status: 410, reason: "contact-expired" };
123
+
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;
130
+ }
131
+ const statements: QueryStatement[] = [
132
+ {
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 = ?)
138
+ AND ? < ?
139
+ ON CONFLICT (tenant_id, stage, order_id) DO NOTHING`,
140
+ params: [
141
+ tenantId, stage, orderId, sealed.keyId, sealed.nonce, sealed.ciphertext, retention.notAfter, retention.expiresAt, nowIso, sealedJti,
142
+ tenantId, stage, orderId,
143
+ tenantId, stage, orderId,
144
+ nowIso, retention.expiresAt,
145
+ ],
146
+ },
147
+ {
148
+ 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],
152
+ },
153
+ ];
154
+ try {
155
+ await requireBatch(db)(statements);
156
+ } catch (error) {
157
+ // The only conflict the batch can raise is the replay key: this jti committed concurrently
158
+ // 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" };
164
+ throw error;
165
+ }
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);
169
+ if (again === "order-unknown") return { ok: false, status: 409, reason: "order-unknown" };
170
+ return { ok: false, status: 410, reason: "contact-expired" };
171
+ }
172
+
173
+ /** Classify an order that already has a vault row or a tombstone, or null when it has neither. */
174
+ async function classifyExisting(
175
+ db: Queryable,
176
+ keyring: VaultKeyring | null,
177
+ tenantId: string,
178
+ stage: VaultStage,
179
+ command: OrderContactCommand,
180
+ sealedJti: string,
181
+ ): Promise<SealOutcome | null> {
182
+ 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 = ?`,
185
+ tenantId, stage, command.orderId,
186
+ );
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" };
201
+ }
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,
205
+ );
206
+ if (tomb) return { ok: false, status: 410, reason: tomb.reason === "erased" ? "contact-erased" : "contact-expired" };
207
+ return null;
208
+ }
209
+
210
+ /** Read one order's contact. The audit row commits before anything is decrypted. */
211
+ export async function readOrderContact(
212
+ db: Queryable,
213
+ keyring: VaultKeyring | null,
214
+ input: { tenantId: string; stage: VaultStage; orderId: string; actor: ContactActor; auditId: string; now: Date },
215
+ ): Promise<ReadOutcome> {
216
+ const { tenantId, stage, orderId, actor, auditId, now } = input;
217
+ const admitted = await admitAudit(db, auditStatement({
218
+ auditId, tenantId, stage, recordKind: "order-contact", recordId: orderId, actor, action: "read", now,
219
+ }));
220
+ if (admitted === "quota") return { ok: false, status: 429, reason: "quota" };
221
+ 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,
226
+ );
227
+ if (!row || !isReadable(now, row.expires_at, row.not_after)) return { ok: false, status: 404, reason: "not-found" };
228
+ let opened: unknown | null;
229
+ 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
+ });
233
+ } catch (error) {
234
+ if (error instanceof VaultKeyUnavailableError) return { ok: false, status: 503, reason: "vault-key-unavailable" };
235
+ throw error;
236
+ }
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 };
239
+ }
240
+
241
+ /** Erase one order's contact: audit, tombstone and delete in one batch. */
242
+ export async function eraseOrderContact(
243
+ db: Queryable,
244
+ input: { tenantId: string; stage: VaultStage; orderId: string; actor: ContactActor; auditId: string; now: Date },
245
+ ): Promise<EraseOutcome> {
246
+ 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,
251
+ );
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 = ?`;
254
+ const audit = auditStatement({
255
+ auditId, tenantId, stage, recordKind: "order-contact", recordId: orderId, actor, action: "erase", now,
256
+ requireRow: { sql: rowSql, params: [tenantId, stage, orderId] },
257
+ });
258
+ const statements: QueryStatement[] = [
259
+ 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
+ },
273
+ ];
274
+ try {
275
+ await requireBatch(db)(statements);
276
+ } catch {
277
+ return { ok: false, status: 503, reason: "audit-unavailable" };
278
+ }
279
+ const audited = await db.first(`SELECT 1 AS present FROM contact_access_audit WHERE audit_id = ?`, auditId);
280
+ if (audited) return { ok: true };
281
+ 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
+ return still ? { ok: false, status: 429, reason: "quota" } : { ok: false, status: 404, reason: "not-found" };
283
+ }
284
+
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)],
291
+ };
292
+ }
293
+
294
+ /**
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.
297
+ */
298
+ export function terminalTransitionExpiryStatement(
299
+ order: { tenantId: string; stage: VaultStage; orderId: string },
300
+ version: number,
301
+ terminalAt: Date,
302
+ ): { sql: string; params: unknown[] } {
303
+ const expiry = terminalExpiryFrom(terminalAt);
304
+ return {
305
+ sql: `UPDATE order_contact_vault SET expires_at = ?
306
+ WHERE tenant_id = ? AND stage = ? AND order_id = ? AND expires_at > ?
307
+ AND EXISTS (
308
+ SELECT 1 FROM scrappy_commerce_orders
309
+ WHERE tenant_id = ? AND stage = ? AND order_id = ? AND event_version = ?
310
+ AND fulfillment_state IN ('handed_off', 'cancelled')
311
+ )`,
312
+ params: [expiry, order.tenantId, order.stage, order.orderId, expiry, order.tenantId, order.stage, order.orderId, version],
313
+ };
314
+ }
315
+
316
+ export interface PurgeCounts {
317
+ purged: number;
318
+ tombstonesRemoved: number;
319
+ shortened: number;
320
+ }
321
+
322
+ /**
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.
327
+ */
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");
330
+ 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
351
+ 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,
356
+ );
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);
362
+ }
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
+ };
369
+ }
@@ -550,31 +550,42 @@ export const newsletterSubscribers = sqliteTable(
550
550
 
551
551
  // --- Contact messages -------------------------------------------------------
552
552
  // Visitor messages from a store's contact form, written here FIRST so a message is
553
- // never lost to a mail failure. `tenantId` comes from the routed tenant, never a
554
- // client body; every read and write is scoped by it. `status`: unread | read.
553
+ // never lost to a mail failure. `tenantId` comes from the routed tenant and `stage`
554
+ // (preview | publication) from the routed customization environment, never a client
555
+ // body; every read and write is scoped by both. The visitor's name, email, phone and
556
+ // message exist only as a contact vault envelope (`keyId`, `nonce`, `ciphertext`;
557
+ // lib/customers/contactVault/envelope.ts) whose AAD binds tenant, stage, id and `notAfter`.
558
+ // `legacy*` hold plaintext only on rows from before migrations-apps/0021 until they
559
+ // are sealed; triggers refuse writing any of them. `status`: unread | read.
555
560
  // `notifyStatus` (pending | sent | failed) and `notifyAttempts` belong to the
556
- // owner-notification sender. Rows are retained for 365 days (`purgeExpiredContactMessages`).
557
- // Timestamps are ISO-8601 text.
561
+ // owner-notification sender. Rows are unreadable and purged at `expiresAt`
562
+ // (`purgeExpiredContactMessages`). Timestamps are ISO-8601 text.
558
563
  export const contactMessages = sqliteTable(
559
564
  "contact_messages",
560
565
  {
561
566
  id: text("id").primaryKey(),
562
567
  tenantId: text("tenant_id").notNull(),
563
- name: text("name").notNull().default(""),
564
- email: text("email").notNull(),
565
- phone: text("phone").notNull().default(""),
566
- message: text("message").notNull(),
568
+ stage: text("stage").notNull(),
569
+ keyId: text("key_id"),
570
+ nonce: text("nonce"),
571
+ ciphertext: text("ciphertext"),
572
+ notAfter: text("not_after").notNull(),
573
+ expiresAt: text("expires_at").notNull(),
567
574
  pagePath: text("page_path"),
568
575
  status: text("status").notNull().default("unread"),
569
576
  createdAt: text("created_at").notNull(),
570
577
  readAt: text("read_at"),
571
578
  notifyStatus: text("notify_status").notNull().default("pending"),
572
579
  notifyAttempts: integer("notify_attempts").notNull().default(0),
580
+ legacyName: text("legacy_name"),
581
+ legacyEmail: text("legacy_email"),
582
+ legacyPhone: text("legacy_phone"),
583
+ legacyMessage: text("legacy_message"),
573
584
  },
574
585
  (t) => ({
575
- idxContactTenantCreated: index("idx_contact_messages_tenant_created").on(t.tenantId, t.createdAt),
576
- idxContactTenantStatus: index("idx_contact_messages_tenant_status").on(t.tenantId, t.status),
577
- idxContactCreated: index("idx_contact_messages_created").on(t.createdAt),
586
+ idxContactTenantStageCreated: index("idx_contact_messages_tenant_stage_created").on(t.tenantId, t.stage, t.createdAt),
587
+ idxContactTenantStageStatus: index("idx_contact_messages_tenant_stage_status").on(t.tenantId, t.stage, t.status),
588
+ idxContactExpires: index("idx_contact_messages_expires").on(t.expiresAt),
578
589
  }),
579
590
  );
580
591
 
@@ -4,6 +4,9 @@ const ROUTES = {
4
4
  "pipeline-job": { path: /^\/api\/pipeline\/jobs\/([A-Za-z0-9._:-]{1,128})\/?$/, param: "jobId" },
5
5
  "fulfillment-order": { path: /^\/api\/admin\/fulfillment\/orders\/([A-Za-z0-9._:-]{1,128})\/?$/, param: "id" },
6
6
  "fulfillment-address": { path: /^\/api\/admin\/fulfillment\/orders\/([A-Za-z0-9._:-]{1,128})\/address\/?$/, param: "id" },
7
+ "fulfillment-contact-support-read": { path: /^\/api\/admin\/fulfillment\/orders\/([A-Za-z0-9._:-]{1,128})\/contact\/support-read\/?$/, param: "id" },
8
+ "fulfillment-contact-support-erase": { path: /^\/api\/admin\/fulfillment\/orders\/([A-Za-z0-9._:-]{1,128})\/contact\/support-erase\/?$/, param: "id" },
9
+ "message-support-read": { path: /^\/api\/admin\/messages\/([0-9a-f-]{36})\/support-read\/?$/, param: "id" },
7
10
  } as const;
8
11
 
9
12
  export type RoutedApiKind = keyof typeof ROUTES;
@@ -1,33 +1,36 @@
1
1
  import { routedApiParam } from "@/lib/publication/shared/routedApiParam";
2
2
  /**
3
- * Owner-only retrieval of a stored ship-to address.
4
- * The read runs only after the owner gate admits the caller.
3
+ * Owner retrieval and erase of an order's customer contact (name, email, phone, ship-to, bill-to)
4
+ * from the contact vault. ToT staff are refused here (403 support-read-required) and use the
5
+ * reason-and-grant routes under ./contact/. Every read and erase is audited before it acts.
5
6
  */
6
7
  import type { APIContext } from "astro";
7
- import { requireAdminApiEntry } from "@/lib/access/auth/adminApiGuard";
8
- import {
9
- fulfillmentResultResponse,
10
- isOpaqueId,
11
- openFulfillment,
12
- } from "@/lib/fulfillment/fulfillmentService";
8
+ import { isOpaqueId } from "@/lib/fulfillment/fulfillmentService";
9
+ import { contactJson, openContactRoute } from "@/lib/orders/vault/contactRoutes";
10
+ import { eraseOrderContact, readOrderContact } from "@/lib/orders/vault/orderContactVault";
13
11
 
14
12
  export const prerender = false;
15
13
 
16
14
  export async function GET(context: APIContext): Promise<Response> {
17
- const access = await requireAdminApiEntry(context, { requireOwner: true });
18
- if (!access.ok) {
19
- return new Response(JSON.stringify({ error: access.reason }), {
20
- status: access.status,
21
- headers: {
22
- "content-type": "application/json; charset=utf-8",
23
- "cache-control": "no-store",
24
- "x-robots-tag": "noindex, nofollow",
25
- },
26
- });
27
- }
28
15
  const orderId = routedApiParam(context, "fulfillment-address");
29
- if (!orderId || !isOpaqueId(orderId)) return fulfillmentResultResponse({ ok: false, code: "not-found" }, () => ({}));
30
- const opened = await openFulfillment(context);
31
- if (!opened.ok) return opened.response;
32
- return fulfillmentResultResponse(await opened.service.readAddress(opened.actor, orderId), (address) => address);
16
+ if (!orderId || !isOpaqueId(orderId)) return contactJson({ error: "not-found" }, 404);
17
+ const route = await openContactRoute(context, "owner");
18
+ if (!route.ok) return route.response;
19
+ const read = await readOrderContact(route.db, route.keyring, {
20
+ tenantId: route.tenantId, stage: route.stage, orderId, actor: route.actor, auditId: crypto.randomUUID(), now: new Date(),
21
+ });
22
+ if (!read.ok) return contactJson({ error: read.reason }, read.status);
23
+ return contactJson({ ...read.contact, expiresAt: read.expiresAt }, 200);
24
+ }
25
+
26
+ export async function DELETE(context: APIContext): Promise<Response> {
27
+ const orderId = routedApiParam(context, "fulfillment-address");
28
+ if (!orderId || !isOpaqueId(orderId)) return contactJson({ error: "not-found" }, 404);
29
+ const route = await openContactRoute(context, "owner");
30
+ if (!route.ok) return route.response;
31
+ const erased = await eraseOrderContact(route.db, {
32
+ tenantId: route.tenantId, stage: route.stage, orderId, actor: route.actor, auditId: crypto.randomUUID(), now: new Date(),
33
+ });
34
+ if (!erased.ok) return contactJson({ error: erased.reason }, erased.status);
35
+ return contactJson({ erased: true }, 200);
33
36
  }
@@ -9,6 +9,10 @@
9
9
  * public, gets the success response and stores nothing. A missing secret, KV or D1
10
10
  * binding refuses with 503; the rate limiter fails closed. Responses never echo
11
11
  * input, and logs carry only tenant, submission id and outcome.
12
+ *
13
+ * The message is stored only as a contact vault envelope under the routed stage
14
+ * (`customizationEnv` live = publication, test = preview). An unknown stage refuses
15
+ * 409 and a missing or unreadable vault keyring refuses 503, both before any write.
12
16
  */
13
17
  import type { APIContext } from "astro";
14
18
  import { readD1, readEnv, readKv } from "@/lib/publication/shared/env";
@@ -16,6 +20,9 @@ import { fromD1 } from "@/lib/catalog/d1/catalog";
16
20
  import { checkRateLimit } from "@/lib/access/rate/kvWindow";
17
21
  import { hashConsentIp } from "@/lib/engagement/newsletter/consent";
18
22
  import { ContactMessagesStore } from "@/lib/engagement/contact/contactMessages";
23
+ import { commerceStageForEnv } from "@/lib/fulfillment/fulfillmentService";
24
+ import { loadVaultKeyring } from "@/lib/orders/vault/keyringEnv";
25
+ import { VaultKeyUnavailableError } from "@/lib/customers/contactVault/envelope.js";
19
26
  import { verifyContactToken } from "@/lib/engagement/contact/submitToken";
20
27
  import { requestClientIp } from "@/lib/publication/shared/cdnForward";
21
28
  import {
@@ -100,9 +107,9 @@ export async function POST(context: APIContext): Promise<Response> {
100
107
  });
101
108
  if (!challenge.ok) return refuse("challenge_failed", "invalid_token", 403);
102
109
 
103
- if (context.locals.storeNotPublic || context.locals.customizationEnv !== "live") {
104
- return accept("not_public");
105
- }
110
+ const stage = commerceStageForEnv(context.locals.customizationEnv);
111
+ if (!stage) return refuse("stage_unknown", "stage-unknown", 409);
112
+ if (context.locals.storeNotPublic) return accept("not_public");
106
113
 
107
114
  const kv = await readKv("TENANT_CACHE");
108
115
  const ip = await requestClientIp(request.headers, await readEnv("CDN_ORIGIN_SECRET"));
@@ -120,14 +127,19 @@ export async function POST(context: APIContext): Promise<Response> {
120
127
  const d1 = await readD1("STOREFRONT_APPS_DB");
121
128
  if (!d1) return refuse("no_database", "unavailable", 503);
122
129
 
130
+ const keyring = await loadVaultKeyring();
131
+ if (!keyring) return refuse("vault_key_unavailable", "unavailable", 503);
132
+
123
133
  try {
124
- await new ContactMessagesStore(fromD1(d1)).insert({
134
+ await new ContactMessagesStore(fromD1(d1), keyring).insert({
125
135
  id: submissionId,
126
136
  tenantId,
137
+ stage,
127
138
  ...fields.fields,
128
139
  pagePath: sameOriginPagePath(request, expectedOrigin),
129
140
  });
130
141
  } catch (err) {
142
+ if (err instanceof VaultKeyUnavailableError) return refuse("vault_key_unavailable", "unavailable", 503);
131
143
  return refuse(`store_failed:${err instanceof Error ? err.name : "unknown"}`, "store_failed", 500);
132
144
  }
133
145
  return accept("stored");