@tokenoftrust/storefront-runner 4.11.0 → 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.
- package/apps/storefront/migrations-apps/0021_contact_vault.sql +4 -0
- package/apps/storefront/migrations-apps/0022_contact_vault_fields.sql +342 -0
- package/apps/storefront/src/lib/customers/contactVault/blindIndex.ts +28 -0
- package/apps/storefront/src/lib/customers/contactVault/contactAudit.ts +21 -7
- package/apps/storefront/src/lib/customers/contactVault/dataKeys.ts +256 -0
- package/apps/storefront/src/lib/customers/contactVault/fieldEnvelope.ts +88 -0
- package/apps/storefront/src/lib/customers/contactVault/isolateMetrics.ts +37 -0
- package/apps/storefront/src/lib/customers/contactVault/kms.ts +292 -0
- package/apps/storefront/src/lib/customers/contactVault/retention.ts +270 -12
- package/apps/storefront/src/lib/customers/contactVault/retentionPolicy.ts +290 -0
- package/apps/storefront/src/lib/customers/contactVault/zip3.ts +24 -0
- package/apps/storefront/src/lib/engagement/contact/contactMessages.ts +458 -118
- package/apps/storefront/src/lib/engagement/contact/messagesTabClient.ts +33 -3
- package/apps/storefront/src/lib/orders/admin/orderActions.ts +180 -32
- package/apps/storefront/src/lib/orders/vault/contactIntake.ts +5 -3
- package/apps/storefront/src/lib/orders/vault/contactRoutes.ts +4 -4
- package/apps/storefront/src/lib/orders/vault/kmsEnv.ts +11 -0
- package/apps/storefront/src/lib/orders/vault/orderContactVault.ts +1053 -227
- package/apps/storefront/src/lib/orders/vault/vault-test-support.ts +258 -0
- package/apps/storefront/src/pages/api/admin/fulfillment/orders/[id]/address.ts +34 -5
- package/apps/storefront/src/pages/api/admin/fulfillment/orders/contact-search.ts +25 -0
- package/apps/storefront/src/pages/api/admin/fulfillment/orders/contacts.ts +21 -0
- package/apps/storefront/src/pages/api/contact.ts +7 -9
- package/apps/storefront/src/scheduled/contactVaultSweep.ts +55 -26
- package/package.json +1 -1
- package/apps/storefront/src/lib/customers/contactVault/envelope.ts +0 -166
- 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/
|
|
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
|
|
7
|
-
* (`lib/customers/contactVault/
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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 =
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
136
|
+
function blindIndexContext(tenantId: string, stage: VaultStage): EncryptionContext {
|
|
137
|
+
return { tenantId, stage, purpose: "blind-index" };
|
|
138
|
+
}
|
|
98
139
|
|
|
99
|
-
function
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
228
|
+
private readonly kms: KmsClient,
|
|
120
229
|
private readonly now: () => Date = () => new Date(),
|
|
121
230
|
) {}
|
|
122
231
|
|
|
123
|
-
/**
|
|
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
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
/**
|
|
147
|
-
|
|
148
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
376
|
+
batchAuditStatement(audit),
|
|
226
377
|
{
|
|
227
|
-
sql: `DELETE FROM contact_messages WHERE tenant_id = ? AND stage = ? AND id = ? AND
|
|
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
|
-
/**
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const
|
|
253
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
267
|
-
*
|
|
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
|
|
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
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
293
|
-
*
|
|
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
|
-
|
|
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,
|
|
304
|
-
FROM contact_messages WHERE
|
|
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
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
}
|