@tokenoftrust/storefront-runner 4.11.0 → 4.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/apps/storefront/migrations-apps/0021_contact_vault.sql +4 -0
  2. package/apps/storefront/migrations-apps/0022_contact_vault_fields.sql +342 -0
  3. package/apps/storefront/package.json +1 -1
  4. package/apps/storefront/src/lib/customers/contactVault/blindIndex.ts +28 -0
  5. package/apps/storefront/src/lib/customers/contactVault/contactAudit.ts +21 -7
  6. package/apps/storefront/src/lib/customers/contactVault/dataKeys.ts +256 -0
  7. package/apps/storefront/src/lib/customers/contactVault/fieldEnvelope.ts +88 -0
  8. package/apps/storefront/src/lib/customers/contactVault/isolateMetrics.ts +37 -0
  9. package/apps/storefront/src/lib/customers/contactVault/kms.ts +292 -0
  10. package/apps/storefront/src/lib/customers/contactVault/retention.ts +270 -12
  11. package/apps/storefront/src/lib/customers/contactVault/retentionPolicy.ts +290 -0
  12. package/apps/storefront/src/lib/customers/contactVault/zip3.ts +24 -0
  13. package/apps/storefront/src/lib/engagement/contact/contactMessages.ts +458 -118
  14. package/apps/storefront/src/lib/engagement/contact/messagesTabClient.ts +33 -3
  15. package/apps/storefront/src/lib/orders/admin/orderActions.ts +180 -32
  16. package/apps/storefront/src/lib/orders/vault/contactIntake.ts +5 -3
  17. package/apps/storefront/src/lib/orders/vault/contactRoutes.ts +4 -4
  18. package/apps/storefront/src/lib/orders/vault/kmsEnv.ts +11 -0
  19. package/apps/storefront/src/lib/orders/vault/orderContactVault.ts +1053 -227
  20. package/apps/storefront/src/lib/orders/vault/vault-test-support.ts +258 -0
  21. package/apps/storefront/src/pages/api/admin/fulfillment/orders/[id]/address.ts +34 -5
  22. package/apps/storefront/src/pages/api/admin/fulfillment/orders/contact-search.ts +25 -0
  23. package/apps/storefront/src/pages/api/admin/fulfillment/orders/contacts.ts +21 -0
  24. package/apps/storefront/src/pages/api/contact.ts +7 -9
  25. package/apps/storefront/src/scheduled/contactVaultSweep.ts +55 -26
  26. package/package.json +1 -1
  27. package/packages/public-runtime/package.json +1 -1
  28. package/apps/storefront/src/lib/customers/contactVault/envelope.ts +0 -166
  29. package/apps/storefront/src/lib/orders/vault/keyringEnv.ts +0 -7
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Test support for the order contact vault suites: node:sqlite with every migrations-apps file
3
+ * applied in order (fault hooks to inject failures and force races), a stubbed KMS that records
4
+ * every EncryptionContext it sees, and synthetic fixtures. Never imported by Worker code.
5
+ */
6
+ import { createRequire } from "node:module";
7
+ import { readFileSync, readdirSync } from "node:fs";
8
+ import { fileURLToPath } from "node:url";
9
+ import { dirname, join } from "node:path";
10
+ import type { Queryable, QueryStatement } from "../../catalog/d1/catalog.js";
11
+ import { VaultKeyUnavailableError, type EncryptionContext, type KmsClient } from "@/lib/customers/contactVault/kms.js";
12
+ import { readOrderContactCommand, type OrderContactCommand } from "./orderContact.js";
13
+
14
+ export interface SqliteStmt {
15
+ all(...params: unknown[]): Array<Record<string, unknown>>;
16
+ get(...params: unknown[]): Record<string, unknown> | undefined;
17
+ run(...params: unknown[]): unknown;
18
+ }
19
+ export interface SqliteDatabase {
20
+ prepare(sql: string): SqliteStmt;
21
+ exec(sql: string): void;
22
+ }
23
+ const { DatabaseSync } = createRequire(import.meta.url)("node:sqlite") as {
24
+ DatabaseSync: new (path: string) => SqliteDatabase;
25
+ };
26
+
27
+ export const MIGRATIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "..", "migrations-apps");
28
+
29
+ export function migrationFiles(): string[] {
30
+ return readdirSync(MIGRATIONS_DIR).filter((name) => name.endsWith(".sql")).sort();
31
+ }
32
+
33
+ export function newDatabase(): SqliteDatabase {
34
+ return new DatabaseSync(":memory:");
35
+ }
36
+
37
+ export function applyMigration(raw: SqliteDatabase, file: string): void {
38
+ raw.exec(readFileSync(join(MIGRATIONS_DIR, file), "utf8"));
39
+ }
40
+
41
+ /** Fault hooks a test sets to inject failures or force two operations to race. */
42
+ export interface Faults {
43
+ /** Called after statement `index` of a batch ran; throwing rolls the batch back. */
44
+ afterBatchStatement?: (index: number, sql: string) => void;
45
+ /** Awaited before a batch starts. */
46
+ beforeBatch?: () => Promise<void>;
47
+ /** Called (and awaited) before `all`; throwing fails that statement. */
48
+ beforeAll?: (sql: string) => Promise<void> | void;
49
+ }
50
+
51
+ export interface Harness {
52
+ db: Queryable;
53
+ raw: SqliteDatabase;
54
+ faults: Faults;
55
+ }
56
+
57
+ export function harness(): Harness {
58
+ const raw = newDatabase();
59
+ const faults: Faults = {};
60
+ const db: Queryable = {
61
+ all: async (sql, ...params) => {
62
+ await faults.beforeAll?.(sql);
63
+ return raw.prepare(sql).all(...params) as never;
64
+ },
65
+ first: async (sql, ...params) => (raw.prepare(sql).get(...params) ?? null) as never,
66
+ run: async (sql, ...params) => {
67
+ raw.prepare(sql).run(...params);
68
+ },
69
+ exec: async (sql) => {
70
+ raw.exec(sql);
71
+ },
72
+ batch: async (statements: readonly QueryStatement[]) => {
73
+ await faults.beforeBatch?.();
74
+ raw.exec("BEGIN");
75
+ try {
76
+ statements.forEach((statement, index) => {
77
+ raw.prepare(statement.sql).run(...(statement.params ?? []));
78
+ faults.afterBatchStatement?.(index, statement.sql);
79
+ });
80
+ raw.exec("COMMIT");
81
+ } catch (error) {
82
+ raw.exec("ROLLBACK");
83
+ throw error;
84
+ }
85
+ },
86
+ };
87
+ for (const file of migrationFiles()) applyMigration(raw, file);
88
+ return { db, raw, faults };
89
+ }
90
+
91
+ /** Resolves once `size` callers are waiting, so they proceed together. */
92
+ export function barrier(size: number): () => Promise<void> {
93
+ let waiting = 0;
94
+ let release!: () => void;
95
+ const gate = new Promise<void>((resolve) => { release = resolve; });
96
+ return async () => {
97
+ waiting += 1;
98
+ if (waiting >= size) release();
99
+ await gate;
100
+ };
101
+ }
102
+
103
+ /** A placeholder key ARN, assembled at runtime so no account-id-shaped literal ships in the runner. */
104
+ export const KMS_KEY_ARN = ["arn", "aws", "kms", "us-east-1", "0".repeat(12), `key/${"0".repeat(8)}-0000-0000-0000-${"0".repeat(12)}`].join(":");
105
+
106
+ export interface StubKms {
107
+ client: KmsClient;
108
+ generated: EncryptionContext[];
109
+ decrypted: EncryptionContext[];
110
+ /** Every key byte string (base64 and hex) and wrapped blob the stub ever handed out. */
111
+ material(): string[];
112
+ fail: boolean;
113
+ /** Decrypt ignores the tenant in the context (simulates a mis-scoped KMS policy: only the AAD protects). */
114
+ ignoreTenant: boolean;
115
+ }
116
+
117
+ export function stubKms(): StubKms {
118
+ const store = new Map<string, { bytes: Uint8Array; ctx: EncryptionContext }>();
119
+ const stub: StubKms = {
120
+ generated: [],
121
+ decrypted: [],
122
+ fail: false,
123
+ ignoreTenant: false,
124
+ material: () => [...store.entries()].flatMap(([blob, { bytes }]) => [
125
+ blob, Buffer.from(bytes).toString("base64"), Buffer.from(bytes).toString("hex"),
126
+ ]),
127
+ client: {
128
+ async generateDataKey(ctx) {
129
+ if (stub.fail) throw new VaultKeyUnavailableError();
130
+ stub.generated.push({ ...ctx });
131
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
132
+ const blob = Buffer.from(crypto.getRandomValues(new Uint8Array(48))).toString("base64");
133
+ store.set(blob, { bytes: bytes.slice(), ctx: { ...ctx } });
134
+ return { plaintext: bytes, ciphertextBlob: blob, keyArn: KMS_KEY_ARN };
135
+ },
136
+ async decrypt(blob, ctx, keyArn) {
137
+ if (stub.fail) throw new VaultKeyUnavailableError();
138
+ stub.decrypted.push({ ...ctx });
139
+ const entry = store.get(blob);
140
+ if (!entry || keyArn !== KMS_KEY_ARN || entry.ctx.stage !== ctx.stage || entry.ctx.purpose !== ctx.purpose
141
+ || (!stub.ignoreTenant && entry.ctx.tenantId !== ctx.tenantId)) {
142
+ throw new VaultKeyUnavailableError();
143
+ }
144
+ return entry.bytes.slice();
145
+ },
146
+ },
147
+ };
148
+ return stub;
149
+ }
150
+
151
+ export const DAY_MS = 86_400_000;
152
+ export const HOUR_MS = 3_600_000;
153
+ export const T0 = Date.parse("2026-01-01T00:00:00.000Z");
154
+ export const day = (n: number): Date => new Date(T0 + n * DAY_MS);
155
+ export const iso = (n: number): string => day(n).toISOString();
156
+
157
+ export const TENANT = "synthetic-tenant";
158
+ export const OTHER_TENANT = "other-synthetic-tenant";
159
+ export const ISSUER = "synthetic-issuer";
160
+ export const SYNTHETIC = [
161
+ "Synthetic Customer", "customer@example.test", "+1 555 0100", "15550100", "1 Example Way", "Suite 9", "Synthetic Billing",
162
+ "Example City", "90210",
163
+ ];
164
+
165
+ export function contact(orderId = "order-1", overrides: { phone?: string; email?: string } = {}): OrderContactCommand {
166
+ const read = readOrderContactCommand({
167
+ kind: "order-contact",
168
+ v: 1,
169
+ orderId,
170
+ customer: { name: "Synthetic Customer", email: overrides.email ?? "customer@example.test", phone: overrides.phone ?? "+1 555 0100" },
171
+ shipTo: { name: "Synthetic Customer", line1: "1 Example Way", line2: "Suite 9", city: "Example City", region: "CA", postalCode: "90210", country: "US" },
172
+ billTo: { name: "Synthetic Billing", line1: "1 Example Way", city: "Example City", postalCode: "90210", country: "US" },
173
+ });
174
+ if (!read) throw new Error("fixture command did not parse");
175
+ return read;
176
+ }
177
+
178
+ export function seedOrder(
179
+ h: { raw: SqliteDatabase },
180
+ orderId: string,
181
+ options: { placedAt?: string; fulfillment?: string; tenantId?: string; stage?: string; amountMinor?: number } = {},
182
+ ): void {
183
+ h.raw.prepare(
184
+ `INSERT INTO scrappy_commerce_orders
185
+ (tenant_id, stage, order_id, source, source_order_id, currency, placed_at, payment_state, compliance_state,
186
+ stock_state, fulfillment_state, label_state, amount_minor, event_version)
187
+ VALUES (?, ?, ?, 'foxycart', ?, 'USD', ?, 'captured', 'fresh', 'confirmed', ?, 'none', ?, 1)`,
188
+ ).run(
189
+ options.tenantId ?? TENANT, options.stage ?? "publication", orderId, orderId,
190
+ options.placedAt ?? iso(0), options.fulfillment ?? "ready", options.amountMinor ?? 2500,
191
+ );
192
+ }
193
+
194
+ export function seedAllocation(h: { raw: SqliteDatabase }, orderId: string, sku: string, quantity: number, stage = "publication"): void {
195
+ h.raw.prepare(
196
+ `INSERT INTO scrappy_allocations (tenant_id, stage, order_id, sku, location_id, quantity, state)
197
+ VALUES (?, ?, ?, ?, 'unspecified', ?, 'confirmed')`,
198
+ ).run(TENANT, stage, orderId, sku, quantity);
199
+ }
200
+
201
+ let auditSeq = 0;
202
+ /** A terminal shipment-audit row, written in the real `<ISO>#NNNNNN` stamp format by default. */
203
+ export function terminalAudit(
204
+ h: { raw: SqliteDatabase },
205
+ orderId: string,
206
+ at: string,
207
+ options: { kind?: "transition" | "pending-cancel"; stage?: string; raw?: boolean; tenantId?: string } = {},
208
+ ): void {
209
+ auditSeq += 1;
210
+ const kind = options.kind ?? "transition";
211
+ const stamp = options.raw ? at : `${at}#${String(auditSeq).padStart(6, "0")}`;
212
+ h.raw.prepare(
213
+ `INSERT INTO scrappy_shipment_audit (audit_id, tenant_id, stage, order_id, at, actor_id, kind, from_state, to_state)
214
+ VALUES (?, ?, ?, ?, ?, 'subject-merchant', ?, ?, ?)`,
215
+ ).run(`ship-audit-${auditSeq}`, options.tenantId ?? TENANT, options.stage ?? "publication", orderId, stamp, kind,
216
+ kind === "transition" ? "ready" : "requested", kind === "transition" ? "handed_off" : "confirmed");
217
+ }
218
+
219
+ /** Make an order terminal (handed off) at `at`, as the fulfillment service records it. */
220
+ export function handOff(h: { raw: SqliteDatabase }, orderId: string, at: string, stage = "publication"): void {
221
+ h.raw.prepare(`UPDATE scrappy_commerce_orders SET fulfillment_state = 'handed_off' WHERE order_id = ? AND stage = ?`).run(orderId, stage);
222
+ terminalAudit(h, orderId, at, { stage });
223
+ }
224
+
225
+ export function count(h: { raw: SqliteDatabase }, table: string, where = "1 = 1", ...params: unknown[]): number {
226
+ return Number(h.raw.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE ${where}`).get(...params)?.n ?? 0);
227
+ }
228
+
229
+ /** Row counts of every table an intake, read, erase or purge could touch. */
230
+ export function counts(h: { raw: SqliteDatabase }): Record<string, number> {
231
+ return {
232
+ vault: count(h, "order_contact_vault"),
233
+ markers: count(h, "order_contact_markers"),
234
+ keys: count(h, "contact_vault_data_keys"),
235
+ replay: count(h, "scrappy_caller_replay"),
236
+ audit: count(h, "contact_access_audit"),
237
+ orders: count(h, "scrappy_commerce_orders"),
238
+ messages: count(h, "contact_messages"),
239
+ };
240
+ }
241
+
242
+ export function vaultRow(h: { raw: SqliteDatabase }, orderId: string, tenantId = TENANT, stage = "publication"): Record<string, unknown> | undefined {
243
+ return h.raw.prepare(`SELECT * FROM order_contact_vault WHERE tenant_id = ? AND stage = ? AND order_id = ?`).get(tenantId, stage, orderId);
244
+ }
245
+
246
+ /** Every table's contents as one string, to prove no synthetic contact value lands outside a ciphertext. */
247
+ export function allTableText(h: { raw: SqliteDatabase }): string {
248
+ const tables = h.raw.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`).all().map((row) => String(row.name));
249
+ return tables.map((table) => JSON.stringify(h.raw.prepare(`SELECT * FROM "${table}"`).all())).join("\n");
250
+ }
251
+
252
+ /** Every ciphertext and blind index currently stored in the vault. */
253
+ export function storedSecrets(h: { raw: SqliteDatabase }): string[] {
254
+ const rows = h.raw.prepare(`SELECT * FROM order_contact_vault`).all();
255
+ return rows.flatMap((row) => Object.entries(row)
256
+ .filter(([column, value]) => (column.endsWith("_ct") || column.endsWith("_bidx")) && typeof value === "string")
257
+ .map(([, value]) => String(value)));
258
+ }
@@ -3,11 +3,15 @@ import { routedApiParam } from "@/lib/publication/shared/routedApiParam";
3
3
  * Owner retrieval and erase of an order's customer contact (name, email, phone, ship-to, bill-to)
4
4
  * from the contact vault. ToT staff are refused here (403 support-read-required) and use the
5
5
  * reason-and-grant routes under ./contact/. Every read and erase is audited before it acts.
6
+ *
7
+ * GET returns only the field groups readable now, each with its expiry and retention rule id.
8
+ * DELETE erases the groups named in an optional JSON body `{groups: [...]}` (default: every group no
9
+ * retention floor holds); a floor-held group answers 409 retention-hold with its hold-until date.
6
10
  */
7
11
  import type { APIContext } from "astro";
8
12
  import { isOpaqueId } from "@/lib/fulfillment/fulfillmentService";
9
13
  import { contactJson, openContactRoute } from "@/lib/orders/vault/contactRoutes";
10
- import { eraseOrderContact, readOrderContact } from "@/lib/orders/vault/orderContactVault";
14
+ import { eraseOrderContact, readEraseGroups, readOrderContact, type Group } from "@/lib/orders/vault/orderContactVault";
11
15
 
12
16
  export const prerender = false;
13
17
 
@@ -16,11 +20,29 @@ export async function GET(context: APIContext): Promise<Response> {
16
20
  if (!orderId || !isOpaqueId(orderId)) return contactJson({ error: "not-found" }, 404);
17
21
  const route = await openContactRoute(context, "owner");
18
22
  if (!route.ok) return route.response;
19
- const read = await readOrderContact(route.db, route.keyring, {
23
+ const read = await readOrderContact(route.db, route.kms, {
20
24
  tenantId: route.tenantId, stage: route.stage, orderId, actor: route.actor, auditId: crypto.randomUUID(), now: new Date(),
21
25
  });
22
26
  if (!read.ok) return contactJson({ error: read.reason }, read.status);
23
- return contactJson({ ...read.contact, expiresAt: read.expiresAt }, 200);
27
+ return contactJson({ ...read.contact, expiresAt: read.expiresAt, groups: read.groups }, 200);
28
+ }
29
+
30
+ /** The erase body: empty means the default groups; otherwise exactly `{groups}`. Null when malformed. */
31
+ async function eraseGroups(request: Request): Promise<Group[] | undefined | null> {
32
+ const raw = await request.text();
33
+ if (raw.trim() === "") return undefined;
34
+ if (raw.length > 4096) return null;
35
+ let body: unknown;
36
+ try {
37
+ body = JSON.parse(raw);
38
+ } catch (error) {
39
+ if (error instanceof SyntaxError) return null;
40
+ throw error;
41
+ }
42
+ if (!body || typeof body !== "object" || Array.isArray(body)) return null;
43
+ const keys = Object.keys(body);
44
+ if (keys.length !== 1 || keys[0] !== "groups") return null;
45
+ return readEraseGroups((body as { groups: unknown }).groups);
24
46
  }
25
47
 
26
48
  export async function DELETE(context: APIContext): Promise<Response> {
@@ -28,9 +50,16 @@ export async function DELETE(context: APIContext): Promise<Response> {
28
50
  if (!orderId || !isOpaqueId(orderId)) return contactJson({ error: "not-found" }, 404);
29
51
  const route = await openContactRoute(context, "owner");
30
52
  if (!route.ok) return route.response;
53
+ const groups = await eraseGroups(context.request);
54
+ if (groups === null) return contactJson({ error: "malformed" }, 400);
31
55
  const erased = await eraseOrderContact(route.db, {
32
56
  tenantId: route.tenantId, stage: route.stage, orderId, actor: route.actor, auditId: crypto.randomUUID(), now: new Date(),
57
+ ...(groups ? { groups } : {}),
33
58
  });
34
- if (!erased.ok) return contactJson({ error: erased.reason }, erased.status);
35
- return contactJson({ erased: true }, 200);
59
+ if (!erased.ok) {
60
+ return erased.reason === "retention-hold"
61
+ ? contactJson({ error: erased.reason, group: erased.group, holdUntil: erased.holdUntil }, erased.status)
62
+ : contactJson({ error: erased.reason }, erased.status);
63
+ }
64
+ return contactJson({ erased: true, groups: erased.groups }, 200);
36
65
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * `POST /api/admin/fulfillment/orders/contact-search` — owner exact-match search of order contact by
3
+ * email or phone. The value travels only in the JSON body `{email}` or `{phone}`: Workers logs
4
+ * record URLs, so any query string is refused before the body is read. Returns the routed tenant
5
+ * and stage's matching order ids (no-store) and writes a `search` audit row holding the count only.
6
+ * Staff are refused 403 support-read-required. Nothing here logs the body.
7
+ */
8
+ import type { APIContext } from "astro";
9
+ import { contactJson, openContactRoute, readRequestJson } from "@/lib/orders/vault/contactRoutes";
10
+ import { readSearchQuery, searchOrderContacts } from "@/lib/orders/vault/orderContactVault";
11
+
12
+ export const prerender = false;
13
+
14
+ export async function POST(context: APIContext): Promise<Response> {
15
+ if (new URL(context.request.url).search !== "") return contactJson({ error: "malformed" }, 400);
16
+ const route = await openContactRoute(context, "owner");
17
+ if (!route.ok) return route.response;
18
+ const query = readSearchQuery(await readRequestJson(context.request));
19
+ if (!query) return contactJson({ error: "malformed" }, 400);
20
+ const found = await searchOrderContacts(route.db, route.kms, {
21
+ tenantId: route.tenantId, stage: route.stage, actor: route.actor, auditId: crypto.randomUUID(), now: new Date(), query,
22
+ });
23
+ if (!found.ok) return contactJson({ error: found.reason }, found.status);
24
+ return contactJson({ orderIds: found.orderIds }, 200);
25
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `GET /api/admin/fulfillment/orders/contacts` — the Orders panel's contact list for the routed
3
+ * tenant and stage: one query joining orders to the vault, one `list` audit row carrying the count
4
+ * (written before anything is decrypted), then only the field groups readable now with their
5
+ * expiries and rule ids. Owner only; staff are refused 403 support-read-required.
6
+ */
7
+ import type { APIContext } from "astro";
8
+ import { contactJson, openContactRoute } from "@/lib/orders/vault/contactRoutes";
9
+ import { listOrderContacts } from "@/lib/orders/vault/orderContactVault";
10
+
11
+ export const prerender = false;
12
+
13
+ export async function GET(context: APIContext): Promise<Response> {
14
+ const route = await openContactRoute(context, "owner");
15
+ if (!route.ok) return route.response;
16
+ const listed = await listOrderContacts(route.db, route.kms, {
17
+ tenantId: route.tenantId, stage: route.stage, actor: route.actor, auditId: crypto.randomUUID(), now: new Date(),
18
+ });
19
+ if (!listed.ok) return contactJson({ error: listed.reason }, listed.status);
20
+ return contactJson({ contacts: listed.contacts }, 200);
21
+ }
@@ -10,9 +10,10 @@
10
10
  * binding refuses with 503; the rate limiter fails closed. Responses never echo
11
11
  * input, and logs carry only tenant, submission id and outcome.
12
12
  *
13
- * The message is stored only as a contact vault envelope under the routed stage
14
- * (`customizationEnv` live = publication, test = preview). An unknown stage refuses
15
- * 409 and a missing or unreadable vault keyring refuses 503, both before any write.
13
+ * The message is stored only as field-level contact vault ciphertexts under the
14
+ * routed stage (`customizationEnv` live = publication, test = preview). An unknown
15
+ * stage refuses 409 before any write; a missing or malformed `CONTACT_VAULT_KMS`
16
+ * secret, a KMS error or a KMS timeout refuses 503 (retryable) with nothing written.
16
17
  */
17
18
  import type { APIContext } from "astro";
18
19
  import { readD1, readEnv, readKv } from "@/lib/publication/shared/env";
@@ -21,8 +22,8 @@ import { checkRateLimit } from "@/lib/access/rate/kvWindow";
21
22
  import { hashConsentIp } from "@/lib/engagement/newsletter/consent";
22
23
  import { ContactMessagesStore } from "@/lib/engagement/contact/contactMessages";
23
24
  import { commerceStageForEnv } from "@/lib/fulfillment/fulfillmentService";
24
- import { loadVaultKeyring } from "@/lib/orders/vault/keyringEnv";
25
- import { VaultKeyUnavailableError } from "@/lib/customers/contactVault/envelope.js";
25
+ import { VaultKeyUnavailableError } from "@/lib/customers/contactVault/kms.js";
26
+ import { loadVaultKms } from "@/lib/orders/vault/kmsEnv";
26
27
  import { verifyContactToken } from "@/lib/engagement/contact/submitToken";
27
28
  import { requestClientIp } from "@/lib/publication/shared/cdnForward";
28
29
  import {
@@ -127,11 +128,8 @@ export async function POST(context: APIContext): Promise<Response> {
127
128
  const d1 = await readD1("STOREFRONT_APPS_DB");
128
129
  if (!d1) return refuse("no_database", "unavailable", 503);
129
130
 
130
- const keyring = await loadVaultKeyring();
131
- if (!keyring) return refuse("vault_key_unavailable", "unavailable", 503);
132
-
133
131
  try {
134
- await new ContactMessagesStore(fromD1(d1), keyring).insert({
132
+ await new ContactMessagesStore(fromD1(d1), await loadVaultKms()).insert({
135
133
  id: submissionId,
136
134
  tenantId,
137
135
  stage,
@@ -1,18 +1,25 @@
1
1
  /**
2
2
  * The hourly contact vault sweep — the Worker's `scheduled` handler body. One `now` per firing,
3
- * taken from its scheduled time: purge expired order contacts and contact messages, shorten terminal
4
- * orders' retention, and seal any contact message still held as legacy plaintext. Expiry predicates
5
- * only; it never touches an order, audit or other row.
3
+ * taken from its scheduled time: clear expired order-contact field groups and contact messages,
4
+ * shorten terminal orders' retention, seal any contact message still held as legacy plaintext,
5
+ * rotate data keys older than a year, and recompute blind indexes still under a retired kid. Expiry predicates only; it never touches an order or audit row.
6
6
  *
7
- * It logs ONE structured line of counts on success and ONE fixed error code on failure — never an
8
- * error message, id or row, since either could carry data. It composes orders and engagement, which
9
- * no module under src/lib may do, so it lives beside the Worker entry.
7
+ * Purge never needs a key, so it always runs; sealing and rotation need KMS and are skipped (with
8
+ * `kmsAvailable: false`) when it refuses. It logs ONE structured line of counts on success and ONE
9
+ * fixed error code on failure — never an error message, id or row, since either could carry data.
10
+ * It composes orders and engagement, which no module under src/lib may do, so it lives beside the
11
+ * Worker entry.
10
12
  */
11
13
  import { fromD1, type Queryable } from "@/lib/catalog/d1/catalog.js";
12
- import type { VaultKeyring } from "@/lib/customers/contactVault/envelope.js";
13
- import { purgeExpiredContactMessages, sealLegacyContactMessages } from "@/lib/engagement/contact/contactMessages.js";
14
- import { loadVaultKeyring } from "@/lib/orders/vault/keyringEnv.js";
15
- import { purgeOrderContacts, type PurgeCounts } from "@/lib/orders/vault/orderContactVault.js";
14
+ import { VaultKeyUnavailableError, type KmsClient } from "@/lib/customers/contactVault/kms.js";
15
+ import {
16
+ purgeContactMessages,
17
+ recomputeMessageBlindIndexes,
18
+ sealLegacyContactMessages,
19
+ type MessagePurgeCounts,
20
+ } from "@/lib/engagement/contact/contactMessages.js";
21
+ import { loadVaultKms } from "@/lib/orders/vault/kmsEnv.js";
22
+ import { purgeOrderContacts, rotateContactVaultKeys, type PurgeCounts } from "@/lib/orders/vault/orderContactVault.js";
16
23
  import { readD1 } from "@/lib/publication/shared/env";
17
24
 
18
25
  export const SWEEP_LEGACY_SEAL_LIMIT = 500;
@@ -22,25 +29,45 @@ export const SWEEP_FAILED = "contact-vault-sweep-failed";
22
29
 
23
30
  export interface SweepCounts {
24
31
  orders: PurgeCounts;
25
- messagesPurged: number;
32
+ messages: MessagePurgeCounts;
33
+ kmsAvailable: boolean;
26
34
  legacySealed: number;
27
35
  legacyRemaining: number;
36
+ keysRotated: number;
37
+ blindIndexesRecomputed: number;
28
38
  }
29
39
 
30
- export async function runContactVaultSweep(db: Queryable, keyring: VaultKeyring | null, now: Date): Promise<SweepCounts> {
40
+ /** Run a KMS-dependent step; a KMS refusal reports unavailable instead of failing the sweep. */
41
+ async function withKms<T>(step: () => Promise<T>): Promise<T | null> {
42
+ try {
43
+ return await step();
44
+ } catch (error) {
45
+ if (error instanceof VaultKeyUnavailableError) return null;
46
+ throw error;
47
+ }
48
+ }
49
+
50
+ export async function runContactVaultSweep(db: Queryable, kms: KmsClient, now: Date): Promise<SweepCounts> {
31
51
  if (Number.isNaN(now.getTime())) throw new Error("contact vault sweep: unreadable clock");
32
52
  const orders = await purgeOrderContacts(db, now);
33
- const messagesPurged = await purgeExpiredContactMessages(db, now);
34
- const legacy = keyring
35
- ? await sealLegacyContactMessages(db, keyring, now, SWEEP_LEGACY_SEAL_LIMIT)
36
- : { sealed: 0, remaining: -1 };
37
- return { orders, messagesPurged, legacySealed: legacy.sealed, legacyRemaining: legacy.remaining };
53
+ const messages = await purgeContactMessages(db, now);
54
+ const legacy = await withKms(() => sealLegacyContactMessages(db, kms, now, SWEEP_LEGACY_SEAL_LIMIT));
55
+ const rotation = await withKms(() => rotateContactVaultKeys(db, kms, now, { recompute: [recomputeMessageBlindIndexes] }));
56
+ return {
57
+ orders,
58
+ messages,
59
+ kmsAvailable: legacy !== null && rotation !== null,
60
+ legacySealed: legacy?.sealed ?? 0,
61
+ legacyRemaining: legacy?.remaining ?? -1,
62
+ keysRotated: rotation?.rotated ?? 0,
63
+ blindIndexesRecomputed: rotation?.recomputed ?? 0,
64
+ };
38
65
  }
39
66
 
40
67
  export interface ScheduledSweepDeps {
41
68
  openDb(): Promise<Queryable | null>;
42
- loadKeyring(): Promise<VaultKeyring | null>;
43
- sweep(db: Queryable, keyring: VaultKeyring | null, now: Date): Promise<SweepCounts>;
69
+ loadKms(): Promise<KmsClient>;
70
+ sweep(db: Queryable, kms: KmsClient, now: Date): Promise<SweepCounts>;
44
71
  log(line: string): void;
45
72
  logError(line: string): void;
46
73
  }
@@ -50,7 +77,7 @@ const workerDeps: ScheduledSweepDeps = {
50
77
  const d1 = await readD1("STOREFRONT_APPS_DB");
51
78
  return d1 ? fromD1(d1) : null;
52
79
  },
53
- loadKeyring: loadVaultKeyring,
80
+ loadKms: loadVaultKms,
54
81
  sweep: runContactVaultSweep,
55
82
  log: (line) => console.log(line),
56
83
  logError: (line) => console.error(line),
@@ -62,17 +89,19 @@ export async function runScheduledContactVaultSweep(scheduledTime: number, deps:
62
89
  try {
63
90
  const db = await deps.openDb();
64
91
  if (!db) return fail(SWEEP_DB_UNAVAILABLE);
65
- const keyring = await deps.loadKeyring();
66
- const counts = await deps.sweep(db, keyring, new Date(scheduledTime));
92
+ const counts = await deps.sweep(db, await deps.loadKms(), new Date(scheduledTime));
67
93
  deps.log(JSON.stringify({
68
94
  event: CONTACT_VAULT_SWEEP_EVENT,
69
- keyringAvailable: keyring !== null,
70
- ordersPurged: counts.orders.purged,
71
- tombstonesRemoved: counts.orders.tombstonesRemoved,
95
+ kmsAvailable: counts.kmsAvailable,
96
+ groupsPurged: counts.orders.groupsPurged,
97
+ ordersDetached: counts.orders.detached,
98
+ ordersPreviewDeleted: counts.orders.previewDeleted,
72
99
  ordersShortened: counts.orders.shortened,
73
- messagesPurged: counts.messagesPurged,
100
+ messages: counts.messages,
74
101
  legacySealed: counts.legacySealed,
75
102
  legacyRemaining: counts.legacyRemaining,
103
+ keysRotated: counts.keysRotated,
104
+ blindIndexesRecomputed: counts.blindIndexesRecomputed,
76
105
  }));
77
106
  } catch {
78
107
  fail(SWEEP_FAILED);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "4.11.0",
3
+ "version": "4.11.2",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",
@@ -47,7 +47,7 @@
47
47
  "scripts": {
48
48
  "typecheck": "tsc --noEmit",
49
49
  "build": "tsc",
50
- "test": "node ../../scripts/vitest-run.mjs run",
50
+ "test": "tot-gate vitest run",
51
51
  "test:watch": "vitest",
52
52
  "generate:managed-analytics-schemas": "node --experimental-strip-types scripts/generate-managed-analytics-api-v1-schemas.mjs"
53
53
  },