@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.
- 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/package.json +1 -1
- 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/packages/public-runtime/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
|
@@ -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(
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
if (
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
202
|
+
function customerField(customer: unknown, key: "name" | "email" | "phone"): string[] {
|
|
158
203
|
if (customer === null || typeof customer !== "object") return [];
|
|
159
|
-
const
|
|
160
|
-
return
|
|
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
|
|
167
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
group(
|
|
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
|
|
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(
|
|
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 =
|
|
258
|
-
if (response.ok && (body as { erased?: unknown } | null)?.erased === true)
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
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 {
|
|
15
|
-
import {
|
|
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;
|
|
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),
|
|
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
|
+
}
|