@tokenoftrust/storefront-runner 4.11.0 → 4.11.2

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 (29) hide show
  1. package/apps/storefront/migrations-apps/0021_contact_vault.sql +4 -0
  2. package/apps/storefront/migrations-apps/0022_contact_vault_fields.sql +342 -0
  3. package/apps/storefront/package.json +1 -1
  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/orders/admin/orderActions.ts +180 -32
  16. package/apps/storefront/src/lib/orders/vault/contactIntake.ts +5 -3
  17. package/apps/storefront/src/lib/orders/vault/contactRoutes.ts +4 -4
  18. package/apps/storefront/src/lib/orders/vault/kmsEnv.ts +11 -0
  19. package/apps/storefront/src/lib/orders/vault/orderContactVault.ts +1053 -227
  20. package/apps/storefront/src/lib/orders/vault/vault-test-support.ts +258 -0
  21. package/apps/storefront/src/pages/api/admin/fulfillment/orders/[id]/address.ts +34 -5
  22. package/apps/storefront/src/pages/api/admin/fulfillment/orders/contact-search.ts +25 -0
  23. package/apps/storefront/src/pages/api/admin/fulfillment/orders/contacts.ts +21 -0
  24. package/apps/storefront/src/pages/api/contact.ts +7 -9
  25. package/apps/storefront/src/scheduled/contactVaultSweep.ts +55 -26
  26. package/package.json +1 -1
  27. package/packages/public-runtime/package.json +1 -1
  28. package/apps/storefront/src/lib/customers/contactVault/envelope.ts +0 -166
  29. package/apps/storefront/src/lib/orders/vault/keyringEnv.ts +0 -7
@@ -1,28 +1,42 @@
1
1
  /**
2
2
  * `contact_messages` store — visitor messages from a store's contact form, kept in
3
- * `STOREFRONT_APPS_DB` (migrations-apps/0021). Pure over a minimal SQL seam, so the Worker runs it
4
- * against D1 and tests against `node:sqlite`.
3
+ * `STOREFRONT_APPS_DB` (migrations-apps/0022). Pure over a minimal SQL seam and an injected KMS
4
+ * client, so the Worker runs it against D1 and tests against `node:sqlite`.
5
5
  *
6
- * The visitor's name, email, phone and message exist at rest only as one contact vault envelope
7
- * (`lib/customers/contactVault/envelope.ts`, purpose `contact-message`) whose AAD binds tenant, stage, message
8
- * id and the immutable `not_after`. A row still holding legacy plaintext is never read; the rollout
9
- * and the hourly sweep seal it ({@link sealLegacyContactMessages}).
6
+ * Field-level vault. The visitor's name, email, phone and message are each their own AES-256-GCM
7
+ * ciphertext (`lib/customers/contactVault/fieldEnvelope.ts`) under the tenant's `contact-message`
8
+ * data key; the AAD binds purpose, field, tenant, stage, message id, kid and the row's immutable
9
+ * `ceiling`, so a ciphertext moved to another field, row, tenant or stage reads as absent. Email and
10
+ * phone also carry blind indexes under the tenant's `blind-index` key for exact-match search.
11
+ * A message is sealed in ONE batch: any pending data-key rows first, the conditional insert, the
12
+ * key cleanups last (the dataKeys.ts seal protocol), so a refused or racing insert commits nothing.
13
+ *
14
+ * Retention (retention.ts `messageRetention`): 365 days from receipt; preview runs it on the
15
+ * compressed clock and deletes the whole row at receipt + the preview cutoff. Content is readable
16
+ * strictly before `min(expires_at, ceiling)`.
10
17
  *
11
18
  * Every method takes the tenant id and the routed stage and scopes its SQL by both: an id belonging
12
19
  * to another tenant or stage is indistinguishable from an id that does not exist. Every read writes
13
- * its `contact_access_audit` row before anything is decrypted. Nothing here logs.
20
+ * its `contact_access_audit` row before anything is decrypted. Nothing here logs, and every error is
21
+ * a fixed code.
14
22
  */
23
+ import { activeKey, cleanupStatement, keyForKid, type ActiveDataKey } from "@/lib/customers/contactVault/dataKeys.js";
24
+ import { openField, sealField, type FieldBinding } from "@/lib/customers/contactVault/fieldEnvelope.js";
25
+ import { blindIndex, normalizeEmail, normalizePhone } from "@/lib/customers/contactVault/blindIndex.js";
26
+ import { VaultKeyUnavailableError, type EncryptionContext, type KmsClient } from "@/lib/customers/contactVault/kms.js";
27
+ import { isGroupReadable, messageRetention, previewDeleteAt, readInstant } from "@/lib/customers/contactVault/retention.js";
28
+ import { RETENTION_POLICY } from "@/lib/customers/contactVault/retentionPolicy.js";
15
29
  import {
16
- openVaultRecord,
17
- sealVaultRecord,
18
- VaultKeyUnavailableError,
19
- type VaultBinding,
20
- type VaultKeyring,
21
- } from "@/lib/customers/contactVault/envelope.js";
22
- import { isReadable, notAfterFrom } from "@/lib/customers/contactVault/retention.js";
23
- import { admitAudit, auditStatement, type ContactActor, type VaultStage } from "@/lib/customers/contactVault/contactAudit.js";
24
-
25
- interface QueryStatement {
30
+ AUDIT_RECORD_IDS_MAX,
31
+ admitAudit,
32
+ auditRecordIds,
33
+ auditStatement,
34
+ batchAuditStatement,
35
+ type ContactActor,
36
+ type VaultStage,
37
+ } from "@/lib/customers/contactVault/contactAudit.js";
38
+
39
+ export interface QueryStatement {
26
40
  sql: string;
27
41
  params?: readonly unknown[];
28
42
  }
@@ -32,13 +46,18 @@ export interface ContactDb {
32
46
  all<T = Record<string, unknown>>(sql: string, ...params: unknown[]): Promise<T[]>;
33
47
  first<T = Record<string, unknown>>(sql: string, ...params: unknown[]): Promise<T | null>;
34
48
  run(sql: string, ...params: unknown[]): Promise<void>;
49
+ exec(sql: string): Promise<void>;
35
50
  /** D1 executes a batch sequentially inside one implicit transaction. */
36
51
  batch?(statements: readonly QueryStatement[]): Promise<void>;
37
52
  }
38
53
 
39
- export const CONTACT_LIST_LIMIT = 200;
54
+ export const CONTACT_LIST_LIMIT = AUDIT_RECORD_IDS_MAX;
55
+ /** Insert attempts when a concurrent first seal for the tenant wins the data-key race. */
56
+ const SEAL_ATTEMPTS = 3;
57
+ const DAY_MS = 86_400_000;
40
58
 
41
59
  export type ContactMessageStatus = "unread" | "read";
60
+ export type MessageField = "name" | "email" | "phone" | "message";
42
61
 
43
62
  export interface NewContactMessage {
44
63
  id: string;
@@ -63,8 +82,12 @@ export interface ContactMessage {
63
82
  readAt: string | null;
64
83
  }
65
84
 
85
+ /** An exact-match inbox filter; the value never reaches a log, an audit row or a URL. */
86
+ export type ContactSearch = { kind: "email"; value: string } | { kind: "phone"; value: string };
87
+
66
88
  export type ContactRefusal =
67
89
  | { ok: false; status: 404; reason: "not-found" }
90
+ | { ok: false; status: 410; reason: "contact-expired" }
68
91
  | { ok: false; status: 429; reason: "quota" }
69
92
  | { ok: false; status: 503; reason: "audit-unavailable" | "vault-key-unavailable" };
70
93
 
@@ -72,12 +95,22 @@ export type ListOutcome = { ok: true; messages: ContactMessage[] } | ContactRefu
72
95
  export type SupportReadOutcome = { ok: true; message: ContactMessage } | ContactRefusal;
73
96
  export type EraseOutcome = { ok: true } | ContactRefusal;
74
97
 
98
+ /** A message the store could not write; carries a fixed code only. */
99
+ export class ContactMessageNotStoredError extends Error {
100
+ constructor(readonly code: "contact-expired" | "seal-race-lost") {
101
+ super(code);
102
+ this.name = "ContactMessageNotStoredError";
103
+ }
104
+ }
105
+
75
106
  interface SealedRow {
76
107
  id: string;
77
- key_id: string;
78
- nonce: string;
79
- ciphertext: string;
80
- not_after: string;
108
+ msg_kid: string;
109
+ name_ct: string | null;
110
+ email_ct: string | null;
111
+ phone_ct: string | null;
112
+ message_ct: string;
113
+ ceiling: string;
81
114
  expires_at: string;
82
115
  page_path: string | null;
83
116
  status: string;
@@ -85,19 +118,27 @@ interface SealedRow {
85
118
  read_at: string | null;
86
119
  }
87
120
 
88
- interface ContactPlaintext {
89
- name: string;
90
- email: string;
91
- phone: string;
92
- message: string;
121
+ const SEALED_COLUMNS =
122
+ "id, msg_kid, name_ct, email_ct, phone_ct, message_ct, ceiling, expires_at, page_path, status, created_at, read_at";
123
+ /** A row the store may act on: sealed, and strictly before both its expiry and its ceiling. */
124
+ const READABLE = "msg_kid IS NOT NULL AND message_ct IS NOT NULL AND ? < expires_at AND ? < ceiling";
125
+ /** Rows that still hold legacy plaintext in any column. */
126
+ const HOLDS_LEGACY =
127
+ "(legacy_name IS NOT NULL OR legacy_email IS NOT NULL OR legacy_phone IS NOT NULL OR legacy_message IS NOT NULL)";
128
+ const REFERENCES_MSG_KID = `SELECT 1 FROM contact_messages WHERE tenant_id = ? AND stage = ? AND msg_kid = ?`;
129
+ const REFERENCES_BIDX_KID = `SELECT 1 FROM order_contact_vault WHERE tenant_id = ? AND stage = ? AND bidx_kid = ?
130
+ UNION ALL SELECT 1 FROM contact_messages WHERE tenant_id = ? AND stage = ? AND bidx_kid = ?`;
131
+
132
+ function messageContext(tenantId: string, stage: VaultStage): EncryptionContext {
133
+ return { tenantId, stage, purpose: "contact-message" };
93
134
  }
94
135
 
95
- const SEALED_COLUMNS = "id, key_id, nonce, ciphertext, not_after, expires_at, page_path, status, created_at, read_at";
96
- /** A row the store may act on: sealed, and strictly before both its expiry and its ceiling. */
97
- const READABLE = "ciphertext IS NOT NULL AND ? < expires_at AND ? < not_after";
136
+ function blindIndexContext(tenantId: string, stage: VaultStage): EncryptionContext {
137
+ return { tenantId, stage, purpose: "blind-index" };
138
+ }
98
139
 
99
- function binding(tenantId: string, stage: VaultStage, id: string, notAfter: string): VaultBinding {
100
- return { purpose: "contact-message", tenantId, stage, recordId: id, notAfter };
140
+ function fieldBinding(tenantId: string, stage: VaultStage, id: string, kid: string, ceiling: string, field: MessageField): FieldBinding {
141
+ return { purpose: "contact-message", field, tenantId, stage, recordId: id, kid, notAfter: ceiling };
101
142
  }
102
143
 
103
144
  function requireBatch(db: ContactDb): NonNullable<ContactDb["batch"]> {
@@ -105,64 +146,159 @@ function requireBatch(db: ContactDb): NonNullable<ContactDb["batch"]> {
105
146
  return db.batch.bind(db);
106
147
  }
107
148
 
108
- function asPlaintext(value: unknown): ContactPlaintext | null {
109
- if (!value || typeof value !== "object" || Array.isArray(value)) return null;
110
- const v = value as Record<string, unknown>;
111
- const fields = [v.name, v.email, v.phone, v.message];
112
- if (!fields.every((f) => typeof f === "string")) return null;
113
- return { name: v.name as string, email: v.email as string, phone: v.phone as string, message: v.message as string };
149
+ function keyExists(ctx: EncryptionContext, kid: string): { sql: string; params: unknown[] } {
150
+ return {
151
+ sql: `SELECT 1 FROM contact_vault_data_keys WHERE tenant_id = ? AND stage = ? AND purpose = ? AND kid = ?`,
152
+ params: [ctx.tenantId, ctx.stage, ctx.purpose, kid],
153
+ };
154
+ }
155
+
156
+ function cleanups(ctx: EncryptionContext, key: ActiveDataKey, referenced: string, params: readonly unknown[]): QueryStatement[] {
157
+ return key.pending.length > 0 ? [cleanupStatement(ctx, key.kid, referenced, params)] : [];
158
+ }
159
+
160
+ interface SealedFields {
161
+ msgKid: string;
162
+ bidxKid: string | null;
163
+ nameCt: string | null;
164
+ emailCt: string | null;
165
+ emailBidx: string | null;
166
+ phoneCt: string | null;
167
+ phoneBidx: string | null;
168
+ messageCt: string;
169
+ /** Batch prefix (pending key rows), the key-existence guard for the record write, and the batch suffix (cleanups). */
170
+ pending: QueryStatement[];
171
+ guard: { sql: string; params: unknown[] };
172
+ cleanup: QueryStatement[];
173
+ }
174
+
175
+ /**
176
+ * Seal one message's fields under the tenant's active keys. Empty optional values store NULL; the
177
+ * message text is always sealed. Throws {@link VaultKeyUnavailableError} when KMS cannot serve.
178
+ */
179
+ async function sealFields(
180
+ db: ContactDb,
181
+ kms: KmsClient,
182
+ tenantId: string,
183
+ stage: VaultStage,
184
+ id: string,
185
+ ceiling: string,
186
+ values: { name: string; email: string; phone: string; message: string },
187
+ now: Date,
188
+ ): Promise<SealedFields> {
189
+ const msgCtx = messageContext(tenantId, stage);
190
+ const bidxCtx = blindIndexContext(tenantId, stage);
191
+ const email = normalizeEmail(values.email);
192
+ const phone = normalizePhone(values.phone);
193
+ const msgKey = await activeKey(db, kms, msgCtx, now);
194
+ const bidxKey = email !== null || phone !== null ? await activeKey(db, kms, bidxCtx, now) : null;
195
+ const seal = (field: MessageField, value: string) =>
196
+ sealField(msgKey.key, fieldBinding(tenantId, stage, id, msgKey.kid, ceiling, field), value);
197
+ const optional = (field: MessageField, value: string) => (value === "" ? Promise.resolve(null) : seal(field, value));
198
+ const msgGuard = keyExists(msgCtx, msgKey.kid);
199
+ const bidxGuard = bidxKey ? keyExists(bidxCtx, bidxKey.kid) : null;
200
+ return {
201
+ msgKid: msgKey.kid,
202
+ bidxKid: bidxKey?.kid ?? null,
203
+ nameCt: await optional("name", values.name),
204
+ emailCt: await optional("email", values.email),
205
+ emailBidx: bidxKey && email !== null ? await blindIndex(bidxKey.key, email) : null,
206
+ phoneCt: await optional("phone", values.phone),
207
+ phoneBidx: bidxKey && phone !== null ? await blindIndex(bidxKey.key, phone) : null,
208
+ messageCt: await seal("message", values.message),
209
+ pending: [...msgKey.pending, ...(bidxKey?.pending ?? [])],
210
+ guard: {
211
+ sql: `EXISTS (${msgGuard.sql})${bidxGuard ? ` AND EXISTS (${bidxGuard.sql})` : ""}`,
212
+ params: [...msgGuard.params, ...(bidxGuard?.params ?? [])],
213
+ },
214
+ cleanup: [
215
+ ...cleanups(msgCtx, msgKey, REFERENCES_MSG_KID, [tenantId, stage, msgKey.kid]),
216
+ ...(bidxKey ? cleanups(bidxCtx, bidxKey, REFERENCES_BIDX_KID, [tenantId, stage, bidxKey.kid, tenantId, stage, bidxKey.kid]) : []),
217
+ ],
218
+ };
219
+ }
220
+
221
+ function isKeyUnavailable(error: unknown): boolean {
222
+ return error instanceof VaultKeyUnavailableError;
114
223
  }
115
224
 
116
225
  export class ContactMessagesStore {
117
226
  constructor(
118
227
  private readonly db: ContactDb,
119
- private readonly keyring: VaultKeyring | null,
228
+ private readonly kms: KmsClient,
120
229
  private readonly now: () => Date = () => new Date(),
121
230
  ) {}
122
231
 
123
- /** Seal and store one message. Throws {@link VaultKeyUnavailableError} (writing nothing) without a keyring. */
232
+ /**
233
+ * Seal and store one message in one batch. Throws {@link VaultKeyUnavailableError} (writing
234
+ * nothing) when KMS cannot serve, and {@link ContactMessageNotStoredError} when retention refuses
235
+ * or the data-key race is lost on every attempt.
236
+ */
124
237
  async insert(input: NewContactMessage): Promise<void> {
125
- const created = this.now();
126
- const notAfter = notAfterFrom(created);
127
- const plaintext: ContactPlaintext = { name: input.name, email: input.email, phone: input.phone, message: input.message };
128
- const sealed = await sealVaultRecord(this.keyring, binding(input.tenantId, input.stage, input.id, notAfter), plaintext);
129
- await this.db.run(
130
- `INSERT INTO contact_messages
131
- (id, tenant_id, stage, key_id, nonce, ciphertext, not_after, expires_at, page_path, status, created_at, notify_status, notify_attempts)
132
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'unread', ?, 'pending', 0)`,
133
- input.id,
134
- input.tenantId,
135
- input.stage,
136
- sealed.keyId,
137
- sealed.nonce,
138
- sealed.ciphertext,
139
- notAfter,
140
- notAfter,
141
- input.pagePath,
142
- created.toISOString(),
143
- );
238
+ const received = this.now();
239
+ const retention = await messageRetention({ stage: input.stage, receivedAt: received, now: received });
240
+ if (retention.kind !== "ok") throw new ContactMessageNotStoredError("contact-expired");
241
+ const batch = requireBatch(this.db);
242
+ for (let attempt = 0; attempt < SEAL_ATTEMPTS; attempt++) {
243
+ const sealed = await sealFields(this.db, this.kms, input.tenantId, input.stage, input.id, retention.ceiling, input, received);
244
+ await batch([
245
+ ...sealed.pending,
246
+ {
247
+ sql: `INSERT INTO contact_messages
248
+ (id, tenant_id, stage, msg_kid, bidx_kid, name_ct, email_ct, email_bidx, phone_ct, phone_bidx, message_ct,
249
+ ceiling, expires_at, time_scale, rule_version, page_path, status, created_at, notify_status, notify_attempts)
250
+ SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'unread', ?, 'pending', 0
251
+ WHERE ${sealed.guard.sql} AND NOT EXISTS (SELECT 1 FROM contact_messages WHERE id = ?)`,
252
+ params: [
253
+ input.id, input.tenantId, input.stage, sealed.msgKid, sealed.bidxKid, sealed.nameCt, sealed.emailCt, sealed.emailBidx,
254
+ sealed.phoneCt, sealed.phoneBidx, sealed.messageCt, retention.ceiling, retention.expiresAt, retention.timeScale,
255
+ retention.ruleVersion, input.pagePath, received.toISOString(), ...sealed.guard.params, input.id,
256
+ ],
257
+ },
258
+ ...sealed.cleanup,
259
+ ]);
260
+ const stored = await this.db.first(
261
+ `SELECT 1 AS present FROM contact_messages WHERE id = ? AND tenant_id = ? AND stage = ? AND msg_kid = ?`,
262
+ input.id, input.tenantId, input.stage, sealed.msgKid,
263
+ );
264
+ if (stored) return;
265
+ }
266
+ throw new ContactMessageNotStoredError("seal-race-lost");
144
267
  }
145
268
 
146
- /** The owner inbox: one `list` audit row commits, then newest first, capped at {@link CONTACT_LIST_LIMIT}. */
147
- async list(tenantId: string, stage: VaultStage, actor: ContactActor, auditId: string): Promise<ListOutcome> {
148
- if (!this.keyring) return { ok: false, status: 503, reason: "vault-key-unavailable" };
269
+ /**
270
+ * The owner inbox, newest first and capped at {@link CONTACT_LIST_LIMIT}, optionally narrowed by
271
+ * an exact email or phone match. Order: select the sealed rows, unwrap their keys (a KMS failure
272
+ * answers 503 with no audit row), commit ONE `list` (or `search`) audit row naming the message ids
273
+ * it returns and their count (never the searched value), then decrypt.
274
+ */
275
+ async list(tenantId: string, stage: VaultStage, actor: ContactActor, auditId: string, search?: ContactSearch): Promise<ListOutcome> {
149
276
  const now = this.now();
277
+ const iso = now.toISOString();
278
+ let rows: SealedRow[];
279
+ let keys: Map<string, CryptoKey | null>;
280
+ try {
281
+ rows = search ? await this.searchRows(tenantId, stage, iso, search) : await this.db.all<SealedRow>(
282
+ `SELECT ${SEALED_COLUMNS} FROM contact_messages
283
+ WHERE tenant_id = ? AND stage = ? AND ${READABLE}
284
+ ORDER BY created_at DESC, id DESC
285
+ LIMIT ?`,
286
+ tenantId, stage, iso, iso, CONTACT_LIST_LIMIT,
287
+ );
288
+ keys = await this.keysFor(tenantId, stage, rows);
289
+ } catch (error) {
290
+ if (isKeyUnavailable(error)) return { ok: false, status: 503, reason: "vault-key-unavailable" };
291
+ throw error;
292
+ }
150
293
  const admitted = await admitAudit(this.db, auditStatement({
151
- auditId, tenantId, stage, recordKind: "contact-message", recordId: "*", actor, action: "list", now,
294
+ auditId, tenantId, stage, recordKind: "contact-message", recordId: auditRecordIds(rows.map((row) => row.id)), recordCount: rows.length,
295
+ actor, action: search ? "search" : "list", now,
152
296
  }));
153
297
  if (admitted === "quota") return { ok: false, status: 429, reason: "quota" };
154
298
  if (admitted === "unavailable") return { ok: false, status: 503, reason: "audit-unavailable" };
155
- const iso = now.toISOString();
156
- const rows = await this.db.all<SealedRow>(
157
- `SELECT ${SEALED_COLUMNS} FROM contact_messages
158
- WHERE tenant_id = ? AND stage = ? AND ${READABLE}
159
- ORDER BY created_at DESC, id DESC
160
- LIMIT ?`,
161
- tenantId, stage, iso, iso, CONTACT_LIST_LIMIT,
162
- );
163
299
  const messages: ContactMessage[] = [];
164
300
  for (const row of rows) {
165
- const message = await this.open(tenantId, stage, row, now);
301
+ const message = await this.open(tenantId, stage, row, keys.get(row.msg_kid) ?? null, now);
166
302
  if (message) messages.push(message);
167
303
  }
168
304
  return { ok: true, messages };
@@ -170,11 +306,18 @@ export class ContactMessagesStore {
170
306
 
171
307
  /**
172
308
  * ToT support reads one message. The caller has already proven a live staff selection and a live
173
- * owner-consented grant; the audit row (with the staff quota in the same statement) commits first.
309
+ * owner-consented grant. A preview message at or past received_at + the preview cutoff answers 410
310
+ * with zero writes; otherwise the audit row (with the staff quota in the same statement) commits first.
174
311
  */
175
312
  async supportRead(tenantId: string, stage: VaultStage, id: string, actor: ContactActor, auditId: string): Promise<SupportReadOutcome> {
176
- if (!this.keyring) return { ok: false, status: 503, reason: "vault-key-unavailable" };
177
313
  const now = this.now();
314
+ if (stage === "preview") {
315
+ const received = await this.db.first<{ created_at: unknown }>(
316
+ `SELECT created_at FROM contact_messages WHERE tenant_id = ? AND stage = ? AND id = ?`, tenantId, stage, id,
317
+ );
318
+ const receivedAt = received ? readInstant(received.created_at) : null;
319
+ if (receivedAt && now.getTime() >= Date.parse(previewDeleteAt(receivedAt))) return { ok: false, status: 410, reason: "contact-expired" };
320
+ }
178
321
  const admitted = await admitAudit(this.db, auditStatement({
179
322
  auditId, tenantId, stage, recordKind: "contact-message", recordId: id, actor, action: "read", now,
180
323
  }));
@@ -185,7 +328,15 @@ export class ContactMessagesStore {
185
328
  `SELECT ${SEALED_COLUMNS} FROM contact_messages WHERE tenant_id = ? AND stage = ? AND id = ? AND ${READABLE}`,
186
329
  tenantId, stage, id, iso, iso,
187
330
  );
188
- const message = row ? await this.open(tenantId, stage, row, now) : null;
331
+ if (!row) return { ok: false, status: 404, reason: "not-found" };
332
+ let key: CryptoKey | null;
333
+ try {
334
+ key = await keyForKid(this.db, this.kms, messageContext(tenantId, stage), row.msg_kid);
335
+ } catch (error) {
336
+ if (isKeyUnavailable(error)) return { ok: false, status: 503, reason: "vault-key-unavailable" };
337
+ throw error;
338
+ }
339
+ const message = await this.open(tenantId, stage, row, key, now);
189
340
  return message ? { ok: true, message } : { ok: false, status: 404, reason: "not-found" };
190
341
  }
191
342
 
@@ -214,7 +365,7 @@ export class ContactMessagesStore {
214
365
  /** Erase one sealed message: its audit row and its delete commit in one batch, or neither does. */
215
366
  async erase(tenantId: string, stage: VaultStage, id: string, actor: ContactActor, auditId: string): Promise<EraseOutcome> {
216
367
  const now = this.now();
217
- const rowSql = `SELECT 1 FROM contact_messages WHERE tenant_id = ? AND stage = ? AND id = ? AND ciphertext IS NOT NULL`;
368
+ const rowSql = `SELECT 1 FROM contact_messages WHERE tenant_id = ? AND stage = ? AND id = ? AND message_ct IS NOT NULL`;
218
369
  const exists = await this.db.first(rowSql, tenantId, stage, id);
219
370
  if (!exists) return { ok: false, status: 404, reason: "not-found" };
220
371
  const audit = auditStatement({
@@ -222,9 +373,9 @@ export class ContactMessagesStore {
222
373
  requireRow: { sql: rowSql, params: [tenantId, stage, id] },
223
374
  });
224
375
  const statements: QueryStatement[] = [
225
- { sql: audit.sql.replace(/\s*RETURNING audit_id$/, ""), params: audit.params },
376
+ batchAuditStatement(audit),
226
377
  {
227
- sql: `DELETE FROM contact_messages WHERE tenant_id = ? AND stage = ? AND id = ? AND ciphertext IS NOT NULL
378
+ sql: `DELETE FROM contact_messages WHERE tenant_id = ? AND stage = ? AND id = ? AND message_ct IS NOT NULL
228
379
  AND EXISTS (SELECT 1 FROM contact_access_audit WHERE audit_id = ?)`,
229
380
  params: [tenantId, stage, id, auditId],
230
381
  },
@@ -240,20 +391,64 @@ export class ContactMessagesStore {
240
391
  return still ? { ok: false, status: 429, reason: "quota" } : { ok: false, status: 404, reason: "not-found" };
241
392
  }
242
393
 
243
- /** Decrypt one row, or null when it is past retention or does not authenticate under its binding. */
244
- private async open(tenantId: string, stage: VaultStage, row: SealedRow, now: Date): Promise<ContactMessage | null> {
245
- if (!isReadable(now, row.expires_at, row.not_after)) return null;
246
- const opened = await openVaultRecord(this.keyring, binding(tenantId, stage, row.id, row.not_after), {
247
- keyId: row.key_id, nonce: row.nonce, ciphertext: row.ciphertext,
248
- }).catch((error: unknown) => {
249
- if (error instanceof VaultKeyUnavailableError) return null;
250
- throw error;
251
- });
252
- const plain = asPlaintext(opened);
253
- if (!plain) return null;
394
+ /**
395
+ * Readable rows whose blind index equals the searched value under ANY of the tenant's blind-index
396
+ * keys (rotation leaves still-sealed rows on the retired kid until they are recomputed). A tenant
397
+ * with no blind-index key holds no indexed row; a value that normalizes to nothing matches nothing.
398
+ */
399
+ private async searchRows(tenantId: string, stage: VaultStage, iso: string, search: ContactSearch): Promise<SealedRow[]> {
400
+ const normalized = search.kind === "email" ? normalizeEmail(search.value) : normalizePhone(search.value);
401
+ if (normalized === null) return [];
402
+ const ctx = blindIndexContext(tenantId, stage);
403
+ const kids = await this.db.all<{ kid: string }>(
404
+ `SELECT kid FROM contact_vault_data_keys WHERE tenant_id = ? AND stage = ? AND purpose = 'blind-index' ORDER BY created_at, kid`,
405
+ tenantId, stage,
406
+ );
407
+ const pairs: unknown[] = [];
408
+ for (const { kid } of kids) {
409
+ const key = await keyForKid(this.db, this.kms, ctx, kid);
410
+ if (key) pairs.push(kid, await blindIndex(key, normalized));
411
+ }
412
+ if (pairs.length === 0) return [];
413
+ const column = search.kind === "email" ? "email_bidx" : "phone_bidx";
414
+ const match = Array.from({ length: pairs.length / 2 }, () => `(bidx_kid = ? AND ${column} = ?)`).join(" OR ");
415
+ return this.db.all<SealedRow>(
416
+ `SELECT ${SEALED_COLUMNS} FROM contact_messages
417
+ WHERE tenant_id = ? AND stage = ? AND ${READABLE} AND (${match})
418
+ ORDER BY created_at DESC, id DESC
419
+ LIMIT ?`,
420
+ tenantId, stage, iso, iso, ...pairs, CONTACT_LIST_LIMIT,
421
+ );
422
+ }
423
+
424
+ /** Each distinct kid's key, its row read from D1 once per call (null when the row is gone). */
425
+ private async keysFor(tenantId: string, stage: VaultStage, rows: readonly SealedRow[]): Promise<Map<string, CryptoKey | null>> {
426
+ const keys = new Map<string, CryptoKey | null>();
427
+ for (const kid of new Set(rows.map((row) => row.msg_kid))) {
428
+ keys.set(kid, await keyForKid(this.db, this.kms, messageContext(tenantId, stage), kid));
429
+ }
430
+ return keys;
431
+ }
432
+
433
+ /**
434
+ * Decrypt one row, or null when it is past retention, its key is gone, or any present field does
435
+ * not authenticate under its own binding.
436
+ */
437
+ private async open(tenantId: string, stage: VaultStage, row: SealedRow, key: CryptoKey | null, now: Date): Promise<ContactMessage | null> {
438
+ if (!key || !isGroupReadable(now, row.expires_at, row.ceiling)) return null;
439
+ const read = async (field: MessageField, sealed: string | null): Promise<string | null> =>
440
+ sealed === null ? "" : openField(key, fieldBinding(tenantId, stage, row.id, row.msg_kid, row.ceiling, field), sealed);
441
+ const name = await read("name", row.name_ct);
442
+ const email = await read("email", row.email_ct);
443
+ const phone = await read("phone", row.phone_ct);
444
+ const message = await read("message", row.message_ct);
445
+ if (name === null || email === null || phone === null || message === null) return null;
254
446
  return {
255
447
  id: row.id,
256
- ...plain,
448
+ name,
449
+ email,
450
+ phone,
451
+ message,
257
452
  pagePath: row.page_path,
258
453
  status: row.status === "read" ? "read" : "unread",
259
454
  createdAt: row.created_at,
@@ -262,25 +457,151 @@ export class ContactMessagesStore {
262
457
  }
263
458
  }
264
459
 
460
+ export interface MessagePurgeCounts {
461
+ /** Rows whose expired content (ciphertexts, blind indexes, legacy plaintext) was nulled. */
462
+ contentCleared: number;
463
+ /** Preview rows deleted whole at receipt + the preview cutoff. */
464
+ previewDeleted: number;
465
+ /** Publication rows deleted once their content was gone and their ceiling had passed. */
466
+ publicationDeleted: number;
467
+ }
468
+
469
+ export const PURGE_PAGE_SIZE = 500;
470
+ export const PURGE_MAX_PAGES = 20;
471
+
265
472
  /**
266
- * Delete every message at or past its expiry or its ceiling, across all tenants and stages (legacy
267
- * rows included). Returns the number of rows removed. An unreadable clock throws.
473
+ * The hourly purge, across all tenants and stages, under ONE clock reading:
474
+ * 1. null the content of every row at or past `min(expires_at, ceiling)`: each ciphertext, both
475
+ * blind indexes, `bidx_kid` and any legacy plaintext. `msg_kid` stays, so the row can never
476
+ * be refilled (the 0022 trigger guards the ciphertexts while a kid is set);
477
+ * 2. delete every preview row at or past receipt + the preview cutoff, content or not;
478
+ * 3. delete every publication row whose content is gone and whose ceiling has passed. A message
479
+ * has no facts row (design 8c), so once its content is gone nothing left in it is worth
480
+ * keeping: the row goes rather than lingering as metadata.
481
+ * Bounded: each step works in pages of `pageSize`, at most `maxPages` pages per step per run; the
482
+ * next run takes whatever is left. Returns counts only. An unreadable clock throws.
268
483
  */
269
- export async function purgeExpiredContactMessages(db: ContactDb, now: Date): Promise<number> {
484
+ export async function purgeContactMessages(
485
+ db: ContactDb,
486
+ now: Date,
487
+ pageSize = PURGE_PAGE_SIZE,
488
+ maxPages = PURGE_MAX_PAGES,
489
+ ): Promise<MessagePurgeCounts> {
270
490
  if (Number.isNaN(now.getTime())) throw new Error("contact message purge: unreadable clock");
271
491
  const iso = now.toISOString();
272
- const rows = await db.all<{ id: string }>(
273
- `DELETE FROM contact_messages WHERE expires_at <= ? OR not_after <= ? RETURNING id`,
274
- iso, iso,
492
+ const previewCutoff = new Date(now.getTime() - RETENTION_POLICY.previewCutoffDays * DAY_MS).toISOString();
493
+ const paged = async (sql: string, params: unknown[]): Promise<number> => {
494
+ let total = 0;
495
+ for (let page = 0; page < maxPages; page++) {
496
+ const rows = await db.all<{ id: string }>(sql, ...params, pageSize);
497
+ total += rows.length;
498
+ if (rows.length < pageSize) break;
499
+ }
500
+ return total;
501
+ };
502
+ const contentCleared = await paged(
503
+ `UPDATE contact_messages
504
+ SET name_ct = NULL, email_ct = NULL, email_bidx = NULL, phone_ct = NULL, phone_bidx = NULL, message_ct = NULL,
505
+ bidx_kid = NULL, legacy_name = NULL, legacy_email = NULL, legacy_phone = NULL, legacy_message = NULL
506
+ WHERE id IN (
507
+ SELECT id FROM contact_messages
508
+ WHERE (expires_at <= ? OR ceiling <= ?)
509
+ AND (name_ct IS NOT NULL OR email_ct IS NOT NULL OR email_bidx IS NOT NULL OR phone_ct IS NOT NULL
510
+ OR phone_bidx IS NOT NULL OR message_ct IS NOT NULL OR bidx_kid IS NOT NULL OR ${HOLDS_LEGACY})
511
+ LIMIT ?)
512
+ RETURNING id`,
513
+ [iso, iso],
514
+ );
515
+ const previewDeleted = await paged(
516
+ `DELETE FROM contact_messages
517
+ WHERE id IN (SELECT id FROM contact_messages WHERE stage = 'preview' AND created_at <= ? LIMIT ?)
518
+ RETURNING id`,
519
+ [previewCutoff],
520
+ );
521
+ const publicationDeleted = await paged(
522
+ `DELETE FROM contact_messages
523
+ WHERE id IN (
524
+ SELECT id FROM contact_messages
525
+ WHERE stage = 'publication' AND ceiling <= ?
526
+ AND name_ct IS NULL AND email_ct IS NULL AND phone_ct IS NULL AND message_ct IS NULL
527
+ AND email_bidx IS NULL AND phone_bidx IS NULL AND NOT ${HOLDS_LEGACY}
528
+ LIMIT ?)
529
+ RETURNING id`,
530
+ [iso],
531
+ );
532
+ return { contentCleared, previewDeleted, publicationDeleted };
533
+ }
534
+
535
+ interface BlindIndexRow {
536
+ id: string;
537
+ tenant_id: string;
538
+ stage: VaultStage;
539
+ msg_kid: string;
540
+ bidx_kid: string;
541
+ email_ct: string | null;
542
+ email_bidx: string | null;
543
+ phone_ct: string | null;
544
+ phone_bidx: string | null;
545
+ ceiling: string;
546
+ }
547
+
548
+ /**
549
+ * Blind-index rotation recompute for messages (orderContactVault.ts `rotateContactVaultKeys` runs
550
+ * it): up to `limit` sealed rows whose bidx_kid is retired are re-indexed under the active
551
+ * blind-index key, by decrypting email and phone under the message key, in ONE update per row that
552
+ * moves both indexes and bidx_kid together, conditioned on the row being unchanged since it was
553
+ * read. An index whose field no longer opens is cleared. Returns the rows moved. Throws
554
+ * {@link VaultKeyUnavailableError} when KMS cannot serve.
555
+ */
556
+ export async function recomputeMessageBlindIndexes(
557
+ db: Pick<ContactDb, "all" | "first">,
558
+ kms: KmsClient,
559
+ now: Date,
560
+ limit: number,
561
+ ): Promise<number> {
562
+ const rows = await db.all<BlindIndexRow>(
563
+ `SELECT m.id, m.tenant_id, m.stage, m.msg_kid, m.bidx_kid, m.email_ct, m.email_bidx, m.phone_ct, m.phone_bidx, m.ceiling
564
+ FROM contact_messages m
565
+ JOIN contact_vault_data_keys k ON k.tenant_id = m.tenant_id AND k.stage = m.stage AND k.purpose = 'blind-index' AND k.kid = m.bidx_kid
566
+ WHERE k.retired_at IS NOT NULL AND m.msg_kid IS NOT NULL
567
+ ORDER BY m.id LIMIT ?`,
568
+ Math.max(0, Math.floor(limit)),
275
569
  );
276
- return rows.length;
570
+ let moved = 0;
571
+ for (const row of rows) {
572
+ const active = await activeKey(db, kms, blindIndexContext(row.tenant_id, row.stage), now);
573
+ // Rotation installs the new active key before this runs; never mint one from here.
574
+ if (active.pending.length > 0 || active.kid === row.bidx_kid) continue;
575
+ const msgKey = await keyForKid(db, kms, messageContext(row.tenant_id, row.stage), row.msg_kid);
576
+ const reindex = async (field: "email" | "phone", sealed: string | null, current: string | null): Promise<string | null> => {
577
+ if (current === null || sealed === null || !msgKey) return null;
578
+ const value = await openField(msgKey, fieldBinding(row.tenant_id, row.stage, row.id, row.msg_kid, row.ceiling, field), sealed);
579
+ const normalized = value === null ? null : field === "email" ? normalizeEmail(value) : normalizePhone(value);
580
+ return normalized === null ? null : blindIndex(active.key, normalized);
581
+ };
582
+ const email = await reindex("email", row.email_ct, row.email_bidx);
583
+ const phone = await reindex("phone", row.phone_ct, row.phone_bidx);
584
+ const updated = await db.all(
585
+ `UPDATE contact_messages SET email_bidx = ?, phone_bidx = ?, bidx_kid = ?
586
+ WHERE id = ? AND msg_kid = ? AND bidx_kid = ? AND email_bidx IS ? AND phone_bidx IS ? AND email_ct IS ? AND phone_ct IS ?
587
+ AND EXISTS (SELECT 1 FROM contact_vault_data_keys
588
+ WHERE tenant_id = ? AND stage = ? AND purpose = 'blind-index' AND kid = ? AND retired_at IS NULL)
589
+ RETURNING id`,
590
+ email, phone, email === null && phone === null ? null : active.kid,
591
+ row.id, row.msg_kid, row.bidx_kid, row.email_bidx, row.phone_bidx, row.email_ct, row.phone_ct,
592
+ row.tenant_id, row.stage, active.kid,
593
+ );
594
+ moved += updated.length;
595
+ }
596
+ return moved;
277
597
  }
278
598
 
279
599
  interface LegacyRow {
280
600
  id: string;
281
601
  tenant_id: string;
282
602
  stage: VaultStage;
283
- not_after: string;
603
+ ceiling: string;
604
+ expires_at: string;
284
605
  legacy_name: string | null;
285
606
  legacy_email: string | null;
286
607
  legacy_phone: string | null;
@@ -289,43 +610,62 @@ interface LegacyRow {
289
610
 
290
611
  /**
291
612
  * Seal up to `limit` rows still holding legacy plaintext (oldest id first), across all tenants.
292
- * Each row is one statement: the envelope is written and every `legacy_*` column nulled together,
293
- * only while the row is still unsealed. `remaining` counts the unsealed rows left.
613
+ * Each row is sealed field by field under its own tenant and stage keys, keeping its recorded
614
+ * ceiling, and ONE statement writes `msg_kid`, every ciphertext and blind index and nulls every
615
+ * `legacy_*` column, only while the row is still unsealed (any pending key rows before it and the
616
+ * key cleanups after it, in one batch). A row already past its retention is not sealed: its
617
+ * plaintext is nulled instead. `sealed` counts rows that no longer hold plaintext; `remaining`
618
+ * counts the rows that still do. Throws {@link VaultKeyUnavailableError} when KMS cannot serve.
294
619
  */
295
620
  export async function sealLegacyContactMessages(
296
621
  db: ContactDb,
297
- keyring: VaultKeyring,
622
+ kms: KmsClient,
298
623
  now: Date,
299
624
  limit: number,
300
625
  ): Promise<{ sealed: number; remaining: number }> {
301
626
  if (Number.isNaN(now.getTime())) throw new Error("contact message sealing: unreadable clock");
627
+ const batch = requireBatch(db);
302
628
  const rows = await db.all<LegacyRow>(
303
- `SELECT id, tenant_id, stage, not_after, legacy_name, legacy_email, legacy_phone, legacy_message
304
- FROM contact_messages WHERE ciphertext IS NULL
629
+ `SELECT id, tenant_id, stage, ceiling, expires_at, legacy_name, legacy_email, legacy_phone, legacy_message
630
+ FROM contact_messages WHERE msg_kid IS NULL AND ${HOLDS_LEGACY}
305
631
  ORDER BY id LIMIT ?`,
306
632
  Math.max(0, Math.floor(limit)),
307
633
  );
634
+ const unsealed = `id = ? AND msg_kid IS NULL AND ${HOLDS_LEGACY}`;
308
635
  let sealed = 0;
309
636
  for (const row of rows) {
310
- const plaintext: ContactPlaintext = {
637
+ if (!isGroupReadable(now, row.expires_at, row.ceiling)) {
638
+ const cleared = await db.all<{ id: string }>(
639
+ `UPDATE contact_messages SET legacy_name = NULL, legacy_email = NULL, legacy_phone = NULL, legacy_message = NULL
640
+ WHERE ${unsealed} RETURNING id`,
641
+ row.id,
642
+ );
643
+ sealed += cleared.length;
644
+ continue;
645
+ }
646
+ const fields = await sealFields(db, kms, row.tenant_id, row.stage, row.id, row.ceiling, {
311
647
  name: row.legacy_name ?? "",
312
648
  email: row.legacy_email ?? "",
313
649
  phone: row.legacy_phone ?? "",
314
650
  message: row.legacy_message ?? "",
315
- };
316
- const envelope = await sealVaultRecord(keyring, binding(row.tenant_id, row.stage, row.id, row.not_after), plaintext);
317
- const changed = await db.all<{ id: string }>(
318
- `UPDATE contact_messages
319
- SET key_id = ?, nonce = ?, ciphertext = ?,
320
- legacy_name = NULL, legacy_email = NULL, legacy_phone = NULL, legacy_message = NULL
321
- WHERE id = ? AND ciphertext IS NULL
322
- RETURNING id`,
323
- envelope.keyId, envelope.nonce, envelope.ciphertext, row.id,
324
- );
325
- sealed += changed.length;
651
+ }, now);
652
+ await batch([
653
+ ...fields.pending,
654
+ {
655
+ sql: `UPDATE contact_messages
656
+ SET msg_kid = ?, bidx_kid = ?, name_ct = ?, email_ct = ?, email_bidx = ?, phone_ct = ?, phone_bidx = ?, message_ct = ?,
657
+ legacy_name = NULL, legacy_email = NULL, legacy_phone = NULL, legacy_message = NULL
658
+ WHERE ${unsealed} AND ${fields.guard.sql}`,
659
+ params: [
660
+ fields.msgKid, fields.bidxKid, fields.nameCt, fields.emailCt, fields.emailBidx, fields.phoneCt, fields.phoneBidx,
661
+ fields.messageCt, row.id, ...fields.guard.params,
662
+ ],
663
+ },
664
+ ...fields.cleanup,
665
+ ]);
666
+ const done = await db.first(`SELECT 1 AS present FROM contact_messages WHERE id = ? AND msg_kid = ?`, row.id, fields.msgKid);
667
+ if (done) sealed++;
326
668
  }
327
- const left = await db.first<{ n: number }>(
328
- `SELECT COUNT(*) AS n FROM contact_messages WHERE ciphertext IS NULL`,
329
- );
669
+ const left = await db.first<{ n: number }>(`SELECT COUNT(*) AS n FROM contact_messages WHERE ${HOLDS_LEGACY}`);
330
670
  return { sealed, remaining: Number(left?.n ?? 0) };
331
671
  }