@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
@@ -3,8 +3,15 @@
3
3
  *
4
4
  * Every visitor-supplied value is written with `textContent`; nothing from a
5
5
  * message is ever parsed as markup.
6
+ *
7
+ * Optional exact-match filter: a `form[data-messages-filter]` with a `kind`
8
+ * control (`email` | `phone`) and a `value` input. A filtered load POSTs
9
+ * `{ [kind]: value }` as the body — never a URL parameter — and an empty value
10
+ * (or `[data-messages-filter-clear]`) returns to the full inbox.
6
11
  */
7
12
 
13
+ type MessageFilter = { kind: "email" | "phone"; value: string } | null;
14
+
8
15
  interface InboxMessage {
9
16
  id: string;
10
17
  status: string;
@@ -52,7 +59,9 @@ export function initMessagesTab(root: HTMLElement): void {
52
59
  const template = root.querySelector<HTMLTemplateElement>("template[data-messages-row]");
53
60
  const refresh = root.querySelector<HTMLElement>("[data-messages-refresh]");
54
61
  if (!statusEl || !panel || !list || !empty || !unreadEl || !template || !refresh) return;
62
+ const filterForm = root.querySelector<HTMLFormElement>("form[data-messages-filter]");
55
63
  let loaded = false;
64
+ let filter: MessageFilter = null;
56
65
 
57
66
  const setStatus = (text: string, tone: "error" | null): void => {
58
67
  statusEl.textContent = text;
@@ -87,9 +96,16 @@ export function initMessagesTab(root: HTMLElement): void {
87
96
  loaded = true;
88
97
  setStatus("Loading messages…", null);
89
98
  try {
90
- const { ok, status, data } = await call("/api/admin/messages", {
91
- headers: { accept: "application/json" },
92
- });
99
+ const { ok, status, data } = await call(
100
+ "/api/admin/messages",
101
+ filter
102
+ ? {
103
+ method: "POST",
104
+ headers: { accept: "application/json", "content-type": "application/json" },
105
+ body: JSON.stringify({ [filter.kind]: filter.value }),
106
+ }
107
+ : { headers: { accept: "application/json" } },
108
+ );
93
109
  if (!ok) {
94
110
  setStatus(`Could not load messages (${data.error || status}).`, "error");
95
111
  return;
@@ -134,6 +150,20 @@ export function initMessagesTab(root: HTMLElement): void {
134
150
  };
135
151
 
136
152
  refresh.addEventListener("click", () => void load());
153
+ if (filterForm) {
154
+ filterForm.addEventListener("submit", (event) => {
155
+ event.preventDefault();
156
+ const kind = (filterForm.elements.namedItem("kind") as HTMLSelectElement | null)?.value;
157
+ const value = ((filterForm.elements.namedItem("value") as HTMLInputElement | null)?.value ?? "").trim();
158
+ filter = value && (kind === "email" || kind === "phone") ? { kind, value } : null;
159
+ void load();
160
+ });
161
+ filterForm.querySelector<HTMLElement>("[data-messages-filter-clear]")?.addEventListener("click", () => {
162
+ filterForm.reset();
163
+ filter = null;
164
+ void load();
165
+ });
166
+ }
137
167
  const loadIfShown = (): void => {
138
168
  if (!loaded && document.documentElement.dataset.adminTab === "messages") void load();
139
169
  };
@@ -125,11 +125,48 @@ export interface OrderActionDeps {
125
125
  confirm: (message: string) => boolean;
126
126
  }
127
127
 
128
+ export type ContactGroup = "identity" | "email" | "phone" | "ship" | "bill";
129
+
130
+ /** The field groups the vault keeps, in display order. */
131
+ export const CONTACT_GROUPS: readonly { group: ContactGroup; label: string }[] = [
132
+ { group: "identity", label: "Name" },
133
+ { group: "email", label: "Email" },
134
+ { group: "phone", label: "Phone" },
135
+ { group: "ship", label: "Ship to" },
136
+ { group: "bill", label: "Bill to" },
137
+ ];
138
+
139
+ /** Short human labels for the retention rules that hold a group. */
140
+ export const RULE_LABELS: Readonly<Record<string, string>> = {
141
+ "fulfillment-and-service": "Order fulfillment and service",
142
+ "card-disputes": "Card disputes",
143
+ "returns-and-warranty": "Returns and warranty",
144
+ "returns-and-warranty-eu-eea-uk": "Returns and warranty (EU, EEA, UK)",
145
+ "sales-tax-sourcing": "Sales tax sourcing",
146
+ "legal-claims": "Legal claims",
147
+ "pact-delivery-sale": "PACT delivery-sale record",
148
+ };
149
+
150
+ export function ruleLabel(id: unknown): string {
151
+ if (typeof id !== "string" || id.trim() === "") return "Retention rule";
152
+ if (Object.prototype.hasOwnProperty.call(RULE_LABELS, id)) return RULE_LABELS[id]!;
153
+ const spaced = id.replace(/[-_]+/g, " ").trim();
154
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
155
+ }
156
+
157
+ interface GroupView {
158
+ expiresAt: string;
159
+ rule: string | null;
160
+ }
161
+
128
162
  interface OrderContact {
129
163
  customer: unknown;
130
164
  shipTo: unknown;
131
165
  billTo: unknown;
132
- expiresAt: unknown;
166
+ /** Only the groups readable now; an expired group is absent. */
167
+ groups: Partial<Record<ContactGroup, GroupView>>;
168
+ /** Groups the server refused to erase, with the date each is held until. */
169
+ held: Partial<Record<ContactGroup, string>>;
133
170
  }
134
171
 
135
172
  function show(note: HTMLElement | null, lines: readonly string[]): void {
@@ -148,31 +185,44 @@ function node<K extends keyof HTMLElementTagNameMap>(tag: K, text = "", classNam
148
185
  function parseContact(body: unknown): OrderContact | null {
149
186
  if (body === null || typeof body !== "object" || Array.isArray(body)) return null;
150
187
  const record = body as Record<string, unknown>;
151
- if (record.customer === null || typeof record.customer !== "object") return null;
152
- if (record.shipTo === null || typeof record.shipTo !== "object") return null;
153
- if (record.billTo === null || typeof record.billTo !== "object") return null;
154
- return { customer: record.customer, shipTo: record.shipTo, billTo: record.billTo, expiresAt: record.expiresAt };
188
+ const raw = record.groups;
189
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
190
+ const groups: Partial<Record<ContactGroup, GroupView>> = {};
191
+ for (const { group } of CONTACT_GROUPS) {
192
+ const entry = (raw as Record<string, unknown>)[group];
193
+ if (entry === null || typeof entry !== "object") continue;
194
+ const { expiresAt, rule } = entry as { expiresAt?: unknown; rule?: unknown };
195
+ if (typeof expiresAt !== "string" || Number.isNaN(new Date(expiresAt).getTime())) continue;
196
+ groups[group] = { expiresAt, rule: typeof rule === "string" ? rule : null };
197
+ }
198
+ if (Object.keys(groups).length === 0) return null;
199
+ return { customer: record.customer ?? {}, shipTo: record.shipTo ?? {}, billTo: record.billTo ?? {}, groups, held: {} };
155
200
  }
156
201
 
157
- function customerLines(customer: unknown): string[] {
202
+ function customerField(customer: unknown, key: "name" | "email" | "phone"): string[] {
158
203
  if (customer === null || typeof customer !== "object") return [];
159
- const record = customer as Record<string, unknown>;
160
- return (["name", "email", "phone"] as const).flatMap((key) => {
161
- const value = record[key];
162
- return typeof value === "string" && value.trim() !== "" ? [value.trim()] : [];
163
- });
204
+ const value = (customer as Record<string, unknown>)[key];
205
+ return typeof value === "string" && value.trim() !== "" ? [value.trim()] : [];
164
206
  }
165
207
 
166
- function keptUntil(expiresAt: unknown): string | null {
167
- if (typeof expiresAt !== "string") return null;
168
- const date = new Date(expiresAt);
169
- if (Number.isNaN(date.getTime())) return null;
170
- return `Contact details kept until ${date.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })}.`;
208
+ function formatDay(iso: string): string {
209
+ return new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
171
210
  }
172
211
 
173
- function group(label: string, lines: readonly string[]): HTMLElement {
212
+ function groupLines(contact: OrderContact, group: ContactGroup): string[] {
213
+ if (group === "identity") return customerField(contact.customer, "name");
214
+ if (group === "email") return customerField(contact.customer, "email");
215
+ if (group === "phone") return customerField(contact.customer, "phone");
216
+ return addressLines(group === "ship" ? contact.shipTo : contact.billTo);
217
+ }
218
+
219
+ function keptUntil(view: GroupView): string {
220
+ return `Kept until ${formatDay(view.expiresAt)}. Held by: ${ruleLabel(view.rule)}.`;
221
+ }
222
+
223
+ function group(label: string, lines: readonly string[], view: GroupView): HTMLElement {
174
224
  const wrap = node("div", "", "orders-contact-group");
175
- wrap.append(node("strong", label), ...lines.map((line) => node("span", line)));
225
+ wrap.append(node("strong", label), ...lines.map((line) => node("span", line)), node("small", keptUntil(view)));
176
226
  return wrap;
177
227
  }
178
228
 
@@ -219,33 +269,60 @@ async function settleContact(response: Response, via: ContactVia, view: ContactV
219
269
  renderContact(contact, via, view);
220
270
  }
221
271
 
222
- function renderContact(contact: OrderContact, via: ContactVia, view: ContactView): void {
272
+ export const ERASE_HELD = (label: string, until: string): string =>
273
+ `${label} cannot be erased yet: it must be kept until ${formatDay(until)}.`;
274
+ export const ERASE_NONE_SELECTED = "Pick at least one group to erase.";
275
+
276
+ function renderContact(contact: OrderContact, via: ContactVia, view: ContactView, notice = ""): void {
223
277
  const { panel } = view;
224
278
  const status = node("p", "", "orders-contact-status");
225
279
  status.setAttribute("role", "status");
226
280
  status.hidden = true;
227
- const children: HTMLElement[] = [
228
- group("Customer", customerLines(contact.customer)),
229
- group("Ship to", addressLines(contact.shipTo)),
230
- group("Bill to", addressLines(contact.billTo)),
231
- ];
232
- const until = keptUntil(contact.expiresAt);
233
- if (until) children.push(node("small", until));
281
+ const children: HTMLElement[] = [];
282
+ for (const { group: key, label } of CONTACT_GROUPS) {
283
+ const groupView = contact.groups[key];
284
+ if (groupView) children.push(group(label, groupLines(contact, key), groupView));
285
+ }
234
286
  if (via === "owner") {
235
- const erase = node("button", "Erase contact details", "btn");
287
+ const picker = node("fieldset", "", "orders-contact-erase-groups");
288
+ picker.append(node("legend", "Erase these details"));
289
+ const boxes = new Map<ContactGroup, HTMLInputElement>();
290
+ for (const { group: key, label } of CONTACT_GROUPS) {
291
+ if (!contact.groups[key]) continue;
292
+ const holdUntil = contact.held[key];
293
+ const box = node("input");
294
+ box.type = "checkbox";
295
+ box.checked = holdUntil === undefined;
296
+ box.disabled = holdUntil !== undefined;
297
+ box.setAttribute("data-orders-erase-group", key);
298
+ const row = node("label", "", "orders-contact-erase-row");
299
+ row.append(box, node("span", holdUntil ? ERASE_HELD(label, holdUntil) : label));
300
+ picker.append(row);
301
+ boxes.set(key, box);
302
+ }
303
+ const erase = node("button", "Erase selected", "btn");
236
304
  erase.type = "button";
237
305
  erase.setAttribute("data-orders-contact-erase", "");
238
- erase.addEventListener("click", () => void eraseContact(view, erase, status));
239
- children.push(erase);
306
+ erase.addEventListener("click", () => void eraseContact(contact, view, boxes, erase, status));
307
+ children.push(picker, erase);
240
308
  }
241
309
  children.push(status);
310
+ if (notice) show(status, [notice]);
242
311
  panel.replaceChildren(...children);
243
312
  panel.hidden = false;
244
313
  }
245
314
 
246
- async function eraseContact(view: ContactView, button: HTMLButtonElement, status: HTMLElement): Promise<void> {
315
+ async function eraseContact(
316
+ contact: OrderContact,
317
+ view: ContactView,
318
+ boxes: ReadonlyMap<ContactGroup, HTMLInputElement>,
319
+ button: HTMLButtonElement,
320
+ status: HTMLElement,
321
+ ): Promise<void> {
247
322
  const { deps, orderId, panel } = view;
248
323
  if (!deps.api) return show(status, [ERASE_FAILED]);
324
+ const groups = [...boxes].filter(([, box]) => box.checked && !box.disabled).map(([key]) => key);
325
+ if (groups.length === 0) return show(status, [ERASE_NONE_SELECTED]);
249
326
  if (!deps.confirm(ERASE_CONFIRM)) return;
250
327
  button.disabled = true;
251
328
  try {
@@ -253,10 +330,29 @@ async function eraseContact(view: ContactView, button: HTMLButtonElement, status
253
330
  method: "DELETE",
254
331
  credentials: "same-origin",
255
332
  cache: "no-store",
333
+ headers: { "content-type": "application/json" },
334
+ body: JSON.stringify({ groups }),
256
335
  });
257
- const body = response.ok ? await readJson(response) : null;
258
- if (response.ok && (body as { erased?: unknown } | null)?.erased === true) return show(panel, [CONTACT_ERASED]);
336
+ const body = await readJson(response);
337
+ if (response.ok && (body as { erased?: unknown } | null)?.erased === true) {
338
+ const reported = (body as { groups?: unknown }).groups;
339
+ const erased = Array.isArray(reported)
340
+ ? CONTACT_GROUPS.map((g) => g.group).filter((key) => reported.includes(key))
341
+ : groups;
342
+ for (const key of erased) delete contact.groups[key];
343
+ if (Object.keys(contact.groups).length === 0) return show(panel, [CONTACT_ERASED]);
344
+ const names = CONTACT_GROUPS.filter((g) => erased.includes(g.group)).map((g) => g.label).join(", ");
345
+ return renderContact(contact, "owner", view, `Erased: ${names}.`);
346
+ }
259
347
  if (response.status === 404) return show(panel, [CONTACT_NONE]);
348
+ if (response.status === 409 && errorCode(body) === "retention-hold") {
349
+ const held = body as { group?: unknown; holdUntil?: unknown };
350
+ const entry = CONTACT_GROUPS.find((g) => g.group === held.group);
351
+ if (entry && typeof held.holdUntil === "string" && !Number.isNaN(new Date(held.holdUntil).getTime())) {
352
+ contact.held[entry.group] = held.holdUntil;
353
+ return renderContact(contact, "owner", view, ERASE_HELD(entry.label, held.holdUntil));
354
+ }
355
+ }
260
356
  button.disabled = false;
261
357
  show(status, [ERASE_FAILED]);
262
358
  } catch {
@@ -326,6 +422,57 @@ async function loadContact(orderId: string, panel: HTMLElement | null, deps: Ord
326
422
  }
327
423
  }
328
424
 
425
+ export const SEARCH_MALFORMED = "Enter a complete email address or phone number.";
426
+ export const SEARCH_NONE = "No orders match that contact.";
427
+ export const SEARCH_FAILED = "The search could not be run. Try again.";
428
+ export const SEARCH_OWNER_ONLY = "Searching by customer contact is for the store owner.";
429
+
430
+ /**
431
+ * Exact-match contact search: `form[data-orders-contact-search]` with a `kind` select (email|phone) and a
432
+ * `value` input. The value travels only as a JSON POST body, never in a URL, and is never logged.
433
+ */
434
+ function mountContactSearch(root: HTMLElement, deps: OrderActionDeps): void {
435
+ const form = root.querySelector<HTMLFormElement>("form[data-orders-contact-search]");
436
+ const results = root.querySelector<HTMLElement>("[data-orders-contact-search-results]");
437
+ if (!form || !results) return;
438
+ const say = (lines: readonly string[]): void => {
439
+ results.replaceChildren(...lines.map((line) => node("span", line)));
440
+ results.hidden = false;
441
+ };
442
+ form.addEventListener("submit", (event) => {
443
+ event.preventDefault();
444
+ const kind = (form.elements.namedItem("kind") as HTMLSelectElement | null)?.value;
445
+ const value = ((form.elements.namedItem("value") as HTMLInputElement | null)?.value ?? "").trim();
446
+ if (!value || (kind !== "email" && kind !== "phone")) return say([SEARCH_MALFORMED]);
447
+ if (!deps.api) return say([SEARCH_FAILED]);
448
+ void (async () => {
449
+ try {
450
+ const response = await deps.fetch(`${deps.api}/orders/contact-search`, {
451
+ method: "POST",
452
+ credentials: "same-origin",
453
+ cache: "no-store",
454
+ headers: { "content-type": "application/json" },
455
+ body: JSON.stringify({ [kind]: value }),
456
+ });
457
+ if (response.status === 400) return say([SEARCH_MALFORMED]);
458
+ if (response.status === 403) return say([SEARCH_OWNER_ONLY]);
459
+ if (response.status === 429) return say([CONTACT_QUOTA]);
460
+ if (response.status === 503) return say([CONTACT_UNAVAILABLE]);
461
+ const body = (await readJson(response)) as { orderIds?: unknown } | null;
462
+ if (!response.ok || !body || !Array.isArray(body.orderIds)) return say([SEARCH_FAILED]);
463
+ const ids = body.orderIds.filter((id): id is string => typeof id === "string");
464
+ if (ids.length === 0) return say([SEARCH_NONE]);
465
+ const list = node("ul", "", "orders-contact-search-list");
466
+ for (const id of ids) list.append(node("li", id));
467
+ results.replaceChildren(node("span", `${ids.length} matching order${ids.length === 1 ? "" : "s"}:`), list);
468
+ results.hidden = false;
469
+ } catch {
470
+ say([SEARCH_FAILED]);
471
+ }
472
+ })();
473
+ });
474
+ }
475
+
329
476
  /**
330
477
  * Wire every `[data-orders-action]` button under `root`. The button carries its plan as
331
478
  * `data-orders-action-kind|target|message`; its note is the nearest `[data-orders-action-note]` in
@@ -333,6 +480,7 @@ async function loadContact(orderId: string, panel: HTMLElement | null, deps: Ord
333
480
  * `[data-orders-blocker-action]`.
334
481
  */
335
482
  export function mountOrderActions(root: HTMLElement, deps: OrderActionDeps): void {
483
+ mountContactSearch(root, deps);
336
484
  const run = (button: HTMLElement, record: HTMLElement): void => {
337
485
  const kind = button.dataset.ordersActionKind as OrderActionKind | undefined;
338
486
  const target = button.dataset.ordersActionTarget ?? "";
@@ -7,7 +7,7 @@
7
7
  import { canonicalJson, sha256Hex } from "@tot/public-runtime";
8
8
  import type { Queryable } from "../../catalog/d1/catalog.js";
9
9
  import { verifySignedCommerceToken, type CommerceCallerConfig } from "../data/orderForwardReceiver.js";
10
- import type { VaultKeyring } from "@/lib/customers/contactVault/envelope.js";
10
+ import type { KmsClient } from "@/lib/customers/contactVault/kms.js";
11
11
  import { readOrderContactCommand } from "./orderContact.js";
12
12
  import { sealOrderContact, type SealOutcome } from "./orderContactVault.js";
13
13
  import { type VaultStage } from "@/lib/customers/contactVault/contactAudit.js";
@@ -26,10 +26,11 @@ function isStage(value: unknown): value is VaultStage {
26
26
 
27
27
  export async function receiveOrderContact(
28
28
  db: Queryable,
29
- keyring: VaultKeyring | null,
29
+ kms: KmsClient,
30
30
  body: unknown,
31
31
  token: string,
32
32
  config: CommerceCallerConfig,
33
+ tenantClass: { regulated: boolean },
33
34
  ): Promise<ContactIntakeResult> {
34
35
  if (!isStage(config.expectedStage)) return { ok: false, status: 503, reason: "vault-unavailable" };
35
36
  if (!isRecord(body)) return { ok: false, status: 400, reason: "malformed" };
@@ -47,10 +48,11 @@ export async function receiveOrderContact(
47
48
  }
48
49
  const command = readOrderContactCommand(contact);
49
50
  if (!command) return { ok: false, status: 400, reason: "malformed" };
50
- const sealed: SealOutcome = await sealOrderContact(db, keyring, {
51
+ const sealed: SealOutcome = await sealOrderContact(db, kms, {
51
52
  tenantId,
52
53
  stage,
53
54
  command,
55
+ regulated: tenantClass.regulated,
54
56
  issuer: verified.issuer,
55
57
  jti: verified.jti,
56
58
  now: new Date(config.now),
@@ -11,8 +11,8 @@ import { contactReaderAuthority, resolveContactReadGrant } from "@/lib/access/au
11
11
  import { readD1 } from "@/lib/publication/shared/env";
12
12
  import { fromD1, type Queryable } from "@/lib/catalog/d1/catalog.js";
13
13
  import { commerceStageForEnv } from "@/lib/fulfillment/fulfillmentService";
14
- import type { VaultKeyring } from "@/lib/customers/contactVault/envelope.js";
15
- import { loadVaultKeyring } from "./keyringEnv.js";
14
+ import type { KmsClient } from "@/lib/customers/contactVault/kms.js";
15
+ import { loadVaultKms } from "./kmsEnv.js";
16
16
  import { type ContactActor, type VaultStage } from "@/lib/customers/contactVault/contactAudit.js";
17
17
 
18
18
  export const SUPPORT_REASON_CODES = [
@@ -49,7 +49,7 @@ export function readSupportReason(body: unknown): { reasonCode: string; ticketRe
49
49
 
50
50
  export type ContactRoute =
51
51
  | { ok: false; response: Response }
52
- | { ok: true; db: Queryable; keyring: VaultKeyring | null; tenantId: string; stage: VaultStage; actor: ContactActor };
52
+ | { ok: true; db: Queryable; kms: KmsClient; tenantId: string; stage: VaultStage; actor: ContactActor };
53
53
 
54
54
  export async function openContactRoute(
55
55
  context: APIContext,
@@ -83,7 +83,7 @@ export async function openContactRoute(
83
83
  if (!stage) return refuse("stage-unknown", 409);
84
84
  const d1 = await readD1("STOREFRONT_APPS_DB");
85
85
  if (!d1) return refuse("unavailable", 503);
86
- return { ok: true, db: fromD1(d1), keyring: await loadVaultKeyring(), tenantId: host, stage, actor };
86
+ return { ok: true, db: fromD1(d1), kms: await loadVaultKms(), tenantId: host, stage, actor };
87
87
  }
88
88
 
89
89
  /** Read a request's JSON body, or undefined when it is absent or not JSON. */
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The Worker's contact vault KMS client, from the declared `CONTACT_VAULT_KMS` secret only. An absent
3
+ * or malformed secret yields a client whose every call refuses with vault-key-unavailable; there is
4
+ * no fallback key.
5
+ */
6
+ import { readEnv } from "@/lib/publication/shared/env";
7
+ import { createKmsClient, parseKmsCredential, type KmsClient } from "@/lib/customers/contactVault/kms.js";
8
+
9
+ export async function loadVaultKms(): Promise<KmsClient> {
10
+ return createKmsClient(parseKmsCredential(await readEnv("CONTACT_VAULT_KMS")));
11
+ }