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