@tokenoftrust/storefront-runner 4.9.2 → 4.9.3
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 +162 -0
- package/apps/storefront/src/lib/access/auth/contactReaderAuthority.ts +64 -0
- package/apps/storefront/src/lib/access/shared/viewerGrant.ts +30 -0
- package/apps/storefront/src/lib/customers/contactVault/contactAudit.ts +80 -0
- package/apps/storefront/src/lib/customers/contactVault/envelope.ts +166 -0
- package/apps/storefront/src/lib/customers/contactVault/retention.ts +44 -0
- package/apps/storefront/src/lib/engagement/contact/contactMessages.ts +242 -65
- package/apps/storefront/src/lib/engagement/contact/contract.ts +2 -0
- package/apps/storefront/src/lib/fulfillment/commerceProjection.ts +0 -1
- package/apps/storefront/src/lib/fulfillment/fulfillmentService.ts +3 -78
- package/apps/storefront/src/lib/integrations/webhooks/deliveryEngine.ts +4 -0
- package/apps/storefront/src/lib/orders/admin/orderActions.ts +229 -19
- package/apps/storefront/src/lib/orders/data/commerceCallerConfig.ts +37 -0
- package/apps/storefront/src/lib/orders/data/orderForwardReceiver.ts +10 -3
- package/apps/storefront/src/lib/orders/data/orderMapper.ts +9 -0
- package/apps/storefront/src/lib/orders/data/sourceAuthorityContract.ts +2 -1
- package/apps/storefront/src/lib/orders/vault/contactIntake.ts +61 -0
- package/apps/storefront/src/lib/orders/vault/contactRoutes.ts +99 -0
- package/apps/storefront/src/lib/orders/vault/keyringEnv.ts +7 -0
- package/apps/storefront/src/lib/orders/vault/orderContact.ts +96 -0
- package/apps/storefront/src/lib/orders/vault/orderContactVault.ts +369 -0
- package/apps/storefront/src/lib/publication/d1/schema-apps.ts +22 -11
- package/apps/storefront/src/lib/publication/shared/routedApiParam.ts +3 -0
- package/apps/storefront/src/pages/api/admin/fulfillment/orders/[id]/address.ts +26 -23
- package/apps/storefront/src/pages/api/contact.ts +16 -4
- package/apps/storefront/src/scheduled/contactVaultSweep.ts +80 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Orders workspace actions. Every button in the order detail answers: it opens the part of the
|
|
3
|
-
* order it names, moves to the tab that resolves it, loads the
|
|
3
|
+
* order it names, moves to the tab that resolves it, loads the customer contact details, or says why
|
|
4
4
|
* nothing can be done from here. `planOrderAction` decides on the server; `mountOrderActions`
|
|
5
5
|
* carries the plan out in the browser. Neither ever leaves a click without a visible result.
|
|
6
6
|
*/
|
|
@@ -78,8 +78,8 @@ function blockerPlan(input: OrderActionInput): OrderActionPlan {
|
|
|
78
78
|
return { kind: "tab", target: "fulfillment", message: `${input.blocker}: holds are worked in the Fulfillment tab.` };
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
/**
|
|
82
|
-
const ADDRESS_FIELDS = ["name", "line1", "line2", "city", "region", "postalCode", "country"] as const;
|
|
81
|
+
/** Address fields shown, in this order. Anything else in a response is dropped. */
|
|
82
|
+
const ADDRESS_FIELDS = ["name", "company", "line1", "line2", "city", "region", "postalCode", "country"] as const;
|
|
83
83
|
|
|
84
84
|
export function addressLines(address: unknown): string[] {
|
|
85
85
|
if (address === null || typeof address !== "object" || Array.isArray(address)) return [];
|
|
@@ -92,16 +92,44 @@ export function addressLines(address: unknown): string[] {
|
|
|
92
92
|
return lines;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
export const
|
|
96
|
-
"No
|
|
97
|
-
export const
|
|
98
|
-
export const
|
|
95
|
+
export const CONTACT_NONE =
|
|
96
|
+
"No customer contact details are on file for this order. They are kept for a limited time and are gone once expired or erased.";
|
|
97
|
+
export const CONTACT_DENIED = "You cannot view customer contact details for this order.";
|
|
98
|
+
export const CONTACT_GRANT_REQUIRED = "The store owner has not granted ToT support access to customer contact details.";
|
|
99
|
+
export const CONTACT_QUOTA = "The hourly limit for reading customer contact details has been reached. Try again later.";
|
|
100
|
+
export const CONTACT_UNAVAILABLE = "Customer contact details are temporarily unavailable. Try again shortly.";
|
|
101
|
+
export const CONTACT_FAILED = "The contact details could not be loaded. Try again.";
|
|
102
|
+
export const CONTACT_ERASED = "Customer contact details were erased.";
|
|
103
|
+
export const ERASE_FAILED = "The contact details could not be erased. Try again.";
|
|
104
|
+
export const ERASE_CONFIRM =
|
|
105
|
+
"Permanently erase this customer's name, email, phone and addresses? This cannot be undone and the details cannot be recovered.";
|
|
106
|
+
export const TICKET_INVALID = "Enter a ticket reference of letters, digits, dot, dash or underscore, up to 64 characters.";
|
|
107
|
+
|
|
108
|
+
const TICKET_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
|
|
109
|
+
|
|
110
|
+
/** Support-read reason codes, as the server accepts them. */
|
|
111
|
+
export const SUPPORT_REASONS: readonly { code: string; label: string }[] = [
|
|
112
|
+
{ code: "customer-request", label: "Customer request" },
|
|
113
|
+
{ code: "delivery-issue", label: "Delivery issue" },
|
|
114
|
+
{ code: "payment-dispute", label: "Payment dispute" },
|
|
115
|
+
{ code: "compliance-review", label: "Compliance review" },
|
|
116
|
+
{ code: "merchant-request", label: "Merchant request" },
|
|
117
|
+
];
|
|
99
118
|
|
|
100
119
|
export interface OrderActionDeps {
|
|
101
120
|
fetch: typeof fetch;
|
|
102
121
|
/** The fulfillment API root for the selected store, or null when this page cannot reach it. */
|
|
103
122
|
api: string | null;
|
|
104
123
|
navigate: (tab: string) => boolean;
|
|
124
|
+
/** Asks the person to confirm an irreversible action. */
|
|
125
|
+
confirm: (message: string) => boolean;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface OrderContact {
|
|
129
|
+
customer: unknown;
|
|
130
|
+
shipTo: unknown;
|
|
131
|
+
billTo: unknown;
|
|
132
|
+
expiresAt: unknown;
|
|
105
133
|
}
|
|
106
134
|
|
|
107
135
|
function show(note: HTMLElement | null, lines: readonly string[]): void {
|
|
@@ -110,24 +138,191 @@ function show(note: HTMLElement | null, lines: readonly string[]): void {
|
|
|
110
138
|
note.hidden = false;
|
|
111
139
|
}
|
|
112
140
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
141
|
+
function node<K extends keyof HTMLElementTagNameMap>(tag: K, text = "", className = ""): HTMLElementTagNameMap[K] {
|
|
142
|
+
const element = document.createElement(tag);
|
|
143
|
+
if (text) element.textContent = text;
|
|
144
|
+
if (className) element.className = className;
|
|
145
|
+
return element;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function parseContact(body: unknown): OrderContact | null {
|
|
149
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) return null;
|
|
150
|
+
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 };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function customerLines(customer: unknown): string[] {
|
|
158
|
+
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
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
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" })}.`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function group(label: string, lines: readonly string[]): HTMLElement {
|
|
174
|
+
const wrap = node("div", "", "orders-contact-group");
|
|
175
|
+
wrap.append(node("strong", label), ...lines.map((line) => node("span", line)));
|
|
176
|
+
return wrap;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function readJson(response: Response): Promise<unknown> {
|
|
180
|
+
try {
|
|
181
|
+
return await response.json();
|
|
182
|
+
} catch (error) {
|
|
183
|
+
// A body that is not JSON shows the status message for this response; anything else is a fault.
|
|
184
|
+
if (error instanceof SyntaxError) return null;
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function errorCode(body: unknown): string {
|
|
190
|
+
if (body !== null && typeof body === "object" && typeof (body as { error?: unknown }).error === "string") {
|
|
191
|
+
return (body as { error: string }).error;
|
|
117
192
|
}
|
|
118
|
-
|
|
193
|
+
return "";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
type ContactVia = "owner" | "support";
|
|
197
|
+
|
|
198
|
+
interface ContactView {
|
|
199
|
+
orderId: string;
|
|
200
|
+
panel: HTMLElement;
|
|
201
|
+
deps: OrderActionDeps;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Turn any contact response into what the panel shows: the details, a reason form, or why there are none. */
|
|
205
|
+
async function settleContact(response: Response, via: ContactVia, view: ContactView): Promise<void> {
|
|
206
|
+
const { panel } = view;
|
|
207
|
+
if (response.status === 404) return show(panel, [CONTACT_NONE]);
|
|
208
|
+
if (response.status === 429) return show(panel, [CONTACT_QUOTA]);
|
|
209
|
+
if (response.status === 503) return show(panel, [CONTACT_UNAVAILABLE]);
|
|
210
|
+
if (response.status === 401 || response.status === 403) {
|
|
211
|
+
const code = errorCode(await readJson(response));
|
|
212
|
+
if (code === "support-read-required") return renderReasonForm(view);
|
|
213
|
+
if (code === "grant-required") return show(panel, [CONTACT_GRANT_REQUIRED]);
|
|
214
|
+
return show(panel, [CONTACT_DENIED]);
|
|
215
|
+
}
|
|
216
|
+
if (!response.ok) return show(panel, [CONTACT_FAILED]);
|
|
217
|
+
const contact = parseContact(await readJson(response));
|
|
218
|
+
if (!contact) return show(panel, [CONTACT_FAILED]);
|
|
219
|
+
renderContact(contact, via, view);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function renderContact(contact: OrderContact, via: ContactVia, view: ContactView): void {
|
|
223
|
+
const { panel } = view;
|
|
224
|
+
const status = node("p", "", "orders-contact-status");
|
|
225
|
+
status.setAttribute("role", "status");
|
|
226
|
+
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));
|
|
234
|
+
if (via === "owner") {
|
|
235
|
+
const erase = node("button", "Erase contact details", "btn");
|
|
236
|
+
erase.type = "button";
|
|
237
|
+
erase.setAttribute("data-orders-contact-erase", "");
|
|
238
|
+
erase.addEventListener("click", () => void eraseContact(view, erase, status));
|
|
239
|
+
children.push(erase);
|
|
240
|
+
}
|
|
241
|
+
children.push(status);
|
|
242
|
+
panel.replaceChildren(...children);
|
|
243
|
+
panel.hidden = false;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function eraseContact(view: ContactView, button: HTMLButtonElement, status: HTMLElement): Promise<void> {
|
|
247
|
+
const { deps, orderId, panel } = view;
|
|
248
|
+
if (!deps.api) return show(status, [ERASE_FAILED]);
|
|
249
|
+
if (!deps.confirm(ERASE_CONFIRM)) return;
|
|
250
|
+
button.disabled = true;
|
|
119
251
|
try {
|
|
120
252
|
const response = await deps.fetch(`${deps.api}/orders/${encodeURIComponent(orderId)}/address`, {
|
|
253
|
+
method: "DELETE",
|
|
121
254
|
credentials: "same-origin",
|
|
122
255
|
cache: "no-store",
|
|
123
256
|
});
|
|
124
|
-
|
|
125
|
-
if (response.
|
|
126
|
-
if (
|
|
127
|
-
|
|
128
|
-
show(
|
|
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]);
|
|
259
|
+
if (response.status === 404) return show(panel, [CONTACT_NONE]);
|
|
260
|
+
button.disabled = false;
|
|
261
|
+
show(status, [ERASE_FAILED]);
|
|
129
262
|
} catch {
|
|
130
|
-
|
|
263
|
+
button.disabled = false;
|
|
264
|
+
show(status, [ERASE_FAILED]);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function renderReasonForm(view: ContactView): void {
|
|
269
|
+
const { deps, orderId, panel } = view;
|
|
270
|
+
const form = node("form", "", "orders-contact-reason");
|
|
271
|
+
form.noValidate = true;
|
|
272
|
+
const select = node("select");
|
|
273
|
+
select.setAttribute("aria-label", "Reason for reading customer contact details");
|
|
274
|
+
select.required = true;
|
|
275
|
+
for (const reason of SUPPORT_REASONS) select.append(Object.assign(document.createElement("option"), { value: reason.code, textContent: reason.label }));
|
|
276
|
+
const ticket = node("input");
|
|
277
|
+
ticket.type = "text";
|
|
278
|
+
ticket.maxLength = 64;
|
|
279
|
+
ticket.autocomplete = "off";
|
|
280
|
+
ticket.placeholder = "Ticket reference";
|
|
281
|
+
ticket.setAttribute("aria-label", "Support ticket reference");
|
|
282
|
+
const submit = node("button", "Read contact details", "btn primary");
|
|
283
|
+
submit.type = "submit";
|
|
284
|
+
const status = node("p", "", "orders-contact-status");
|
|
285
|
+
status.setAttribute("role", "status");
|
|
286
|
+
status.hidden = true;
|
|
287
|
+
form.append(node("span", "Reading customer contact details as ToT support needs a reason and a ticket. Every read is recorded."), select, ticket, submit, status);
|
|
288
|
+
form.addEventListener("submit", (event) => {
|
|
289
|
+
event.preventDefault();
|
|
290
|
+
if (!deps.api) return show(status, [CONTACT_FAILED]);
|
|
291
|
+
const ticketRef = ticket.value.trim();
|
|
292
|
+
if (!TICKET_PATTERN.test(ticketRef)) return show(status, [TICKET_INVALID]);
|
|
293
|
+
submit.disabled = true;
|
|
294
|
+
void (async () => {
|
|
295
|
+
try {
|
|
296
|
+
const response = await deps.fetch(`${deps.api}/orders/${encodeURIComponent(orderId)}/contact/support-read`, {
|
|
297
|
+
method: "POST",
|
|
298
|
+
credentials: "same-origin",
|
|
299
|
+
cache: "no-store",
|
|
300
|
+
headers: { "content-type": "application/json" },
|
|
301
|
+
body: JSON.stringify({ reasonCode: select.value, ticketRef }),
|
|
302
|
+
});
|
|
303
|
+
await settleContact(response, "support", view);
|
|
304
|
+
} catch {
|
|
305
|
+
submit.disabled = false;
|
|
306
|
+
show(status, [CONTACT_FAILED]);
|
|
307
|
+
}
|
|
308
|
+
})();
|
|
309
|
+
});
|
|
310
|
+
panel.replaceChildren(form);
|
|
311
|
+
panel.hidden = false;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function loadContact(orderId: string, panel: HTMLElement | null, deps: OrderActionDeps): Promise<void> {
|
|
315
|
+
if (!panel) return;
|
|
316
|
+
if (!deps.api) return show(panel, [CONTACT_FAILED]);
|
|
317
|
+
show(panel, ["Loading contact details…"]);
|
|
318
|
+
try {
|
|
319
|
+
const response = await deps.fetch(`${deps.api}/orders/${encodeURIComponent(orderId)}/address`, {
|
|
320
|
+
credentials: "same-origin",
|
|
321
|
+
cache: "no-store",
|
|
322
|
+
});
|
|
323
|
+
await settleContact(response, "owner", { orderId, panel, deps });
|
|
324
|
+
} catch {
|
|
325
|
+
show(panel, [CONTACT_FAILED]);
|
|
131
326
|
}
|
|
132
327
|
}
|
|
133
328
|
|
|
@@ -157,12 +352,27 @@ export function mountOrderActions(root: HTMLElement, deps: OrderActionDeps): voi
|
|
|
157
352
|
} else if (kind === "tab") {
|
|
158
353
|
show(note, [deps.navigate(target) ? message : `${message} The ${target} tab is not available to this account.`]);
|
|
159
354
|
} else if (kind === "address") {
|
|
160
|
-
void
|
|
355
|
+
void loadContact(orderId, record.querySelector<HTMLElement>(target) ?? note, deps);
|
|
161
356
|
} else {
|
|
162
357
|
show(note, [message || "Nothing can be done from here."]);
|
|
163
358
|
}
|
|
164
359
|
};
|
|
165
360
|
|
|
361
|
+
// Contact details are not kept in the page once another order is selected.
|
|
362
|
+
const detailRoot = root.querySelector<HTMLElement>("[data-orders-detail-root]");
|
|
363
|
+
if (detailRoot && typeof MutationObserver !== "undefined") {
|
|
364
|
+
new MutationObserver(() => {
|
|
365
|
+
for (const record of detailRoot.querySelectorAll<HTMLElement>("[data-orders-detail-record]")) {
|
|
366
|
+
if (record.dataset.ordersDetailRecord === detailRoot.dataset.adminSelectedId) continue;
|
|
367
|
+
const panel = record.querySelector<HTMLElement>("[data-orders-address]");
|
|
368
|
+
if (panel && !panel.hidden) {
|
|
369
|
+
panel.replaceChildren();
|
|
370
|
+
panel.hidden = true;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}).observe(detailRoot, { attributes: true, attributeFilter: ["data-admin-selected-id"] });
|
|
374
|
+
}
|
|
375
|
+
|
|
166
376
|
root.addEventListener("click", (event) => {
|
|
167
377
|
const origin = event.target instanceof Element ? event.target : null;
|
|
168
378
|
const button = origin?.closest<HTMLElement>("[data-orders-action]");
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The signed caller configuration both signed intakes verify against (order-forward's commerce
|
|
3
|
+
* events and order-contact's contact commands): the expected stage plus the caller JWKS, trusted
|
|
4
|
+
* issuers, audience and active kids. Null when any part is absent or malformed — callers answer 503.
|
|
5
|
+
*/
|
|
6
|
+
import { readEnv } from "@/lib/publication/shared/env";
|
|
7
|
+
import type { CommerceCallerConfig } from "./orderForwardReceiver.js";
|
|
8
|
+
|
|
9
|
+
function callerList(value: string): string[] {
|
|
10
|
+
return value
|
|
11
|
+
.split(",")
|
|
12
|
+
.map((item) => item.trim())
|
|
13
|
+
.filter((item) => item !== "");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function readCommerceCallerConfig(now: Date): Promise<CommerceCallerConfig | null> {
|
|
17
|
+
const expectedStage = await readEnv("COMMERCE_STAGE");
|
|
18
|
+
if (expectedStage !== "preview" && expectedStage !== "publication") return null;
|
|
19
|
+
const jwksRaw = await readEnv("COMMERCE_CALLER_JWKS");
|
|
20
|
+
const issuersRaw = await readEnv("COMMERCE_CALLER_ISSUERS");
|
|
21
|
+
const audience = await readEnv("COMMERCE_CALLER_AUDIENCE");
|
|
22
|
+
const kidsRaw = await readEnv("COMMERCE_CALLER_ACTIVE_KIDS");
|
|
23
|
+
if (!jwksRaw || !issuersRaw || !audience || !kidsRaw) return null;
|
|
24
|
+
let jwks: CommerceCallerConfig["jwks"];
|
|
25
|
+
try {
|
|
26
|
+
jwks = JSON.parse(jwksRaw) as CommerceCallerConfig["jwks"];
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error instanceof SyntaxError) return null;
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
const trustedIssuers = callerList(issuersRaw);
|
|
32
|
+
const activeKids = callerList(kidsRaw);
|
|
33
|
+
if (trustedIssuers.length === 0 || activeKids.length === 0 || !Array.isArray(jwks.keys) || jwks.keys.length === 0) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
return { expectedStage, jwks, trustedIssuers, expectedAudience: audience, activeKids, now: now.toISOString() };
|
|
37
|
+
}
|
|
@@ -159,7 +159,13 @@ export interface CommerceCallerConfig {
|
|
|
159
159
|
now: string;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
export type SignedCommerceRefusal = CallerTokenRefusal | "version" | "malformed" | "totals" | "private" | "replay";
|
|
162
|
+
export type SignedCommerceRefusal = CallerTokenRefusal | "version" | "use" | "malformed" | "totals" | "private" | "replay";
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The `use` claim separates the two signed intakes: a commerce token carries none, and the
|
|
166
|
+
* order-contact route requires exactly "order-contact". Each route refuses the other's tokens.
|
|
167
|
+
*/
|
|
168
|
+
export type SignedCallerUse = "order-contact";
|
|
163
169
|
|
|
164
170
|
export type SignedCommerceResult =
|
|
165
171
|
| { ok:true; authorityAck:BeginAck|ResultAck; tenantId:string; stage:CommerceStage }
|
|
@@ -200,8 +206,8 @@ function expiresAtIso(exp: unknown): string {
|
|
|
200
206
|
export async function verifySignedCommerceToken(
|
|
201
207
|
token: string,
|
|
202
208
|
config: CommerceCallerConfig,
|
|
203
|
-
binding: { tenantId: string; stage: CommerceStage },
|
|
204
|
-
): Promise<{ ok: true; issuer: string; jti: string; digest: string } | { ok: false; reason: CallerTokenRefusal | "version" }> {
|
|
209
|
+
binding: { tenantId: string; stage: CommerceStage; use?: SignedCallerUse },
|
|
210
|
+
): Promise<{ ok: true; issuer: string; jti: string; digest: string } | { ok: false; reason: CallerTokenRefusal | "version" | "use" }> {
|
|
205
211
|
if(!isStage(config.expectedStage)||binding.stage!==config.expectedStage)return {ok:false,reason:"stage"};
|
|
206
212
|
if (token.split(".").length !== 3) {
|
|
207
213
|
const admitted = admitCallerToken({
|
|
@@ -272,6 +278,7 @@ export async function verifySignedCommerceToken(
|
|
|
272
278
|
if (!admitted.ok) return admitted;
|
|
273
279
|
if(typeof claims.exp!=="number"||claims.exp-Math.floor(Date.parse(config.now)/1000)>60)return {ok:false,reason:"lifetime"};
|
|
274
280
|
if (claims.ver !== 1) return { ok: false, reason: "version" };
|
|
281
|
+
if (binding.use === undefined ? claims.use !== undefined : claims.use !== binding.use) return { ok: false, reason: "use" };
|
|
275
282
|
if (typeof claims.digest !== "string" || claims.digest === "") return { ok: false, reason: "signature" };
|
|
276
283
|
return { ok: true, issuer: typeof claims.iss === "string" ? claims.iss : "", jti: typeof claims.jti === "string" ? claims.jti : "", digest: claims.digest };
|
|
277
284
|
}
|
|
@@ -113,6 +113,11 @@ export async function toContractOrder(order: FoxyOrder, ctx: ToContractOrderCont
|
|
|
113
113
|
export const COMMERCE_COMMANDS = ["observe", "capture", "refund", "cancel"] as const;
|
|
114
114
|
export type CommerceCommandName = (typeof COMMERCE_COMMANDS)[number];
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Keys that identify a customer. Any of them, at any depth, refuses a commerce payload: contact
|
|
118
|
+
* arrives only through the signed order-contact command. `country` and `region` are not listed —
|
|
119
|
+
* they are the coarse destination every provider cart carries.
|
|
120
|
+
*/
|
|
116
121
|
export const PRIVATE_COMMERCE_KEYS = new Set([
|
|
117
122
|
"customer",
|
|
118
123
|
"customerEmail",
|
|
@@ -121,9 +126,13 @@ export const PRIVATE_COMMERCE_KEYS = new Set([
|
|
|
121
126
|
"phone",
|
|
122
127
|
"line1",
|
|
123
128
|
"line2",
|
|
129
|
+
"city",
|
|
130
|
+
"company",
|
|
124
131
|
"postalCode",
|
|
125
132
|
"shippingAddress",
|
|
126
133
|
"billingAddress",
|
|
134
|
+
"shipTo",
|
|
135
|
+
"billTo",
|
|
127
136
|
"address",
|
|
128
137
|
"firstName",
|
|
129
138
|
"lastName",
|
|
@@ -129,7 +129,8 @@ export type AuthorityCode =
|
|
|
129
129
|
| "source-authority-unavailable"
|
|
130
130
|
| "source-sync-pending"
|
|
131
131
|
| "authority-budget"
|
|
132
|
-
| "control-identity-conflict"
|
|
132
|
+
| "control-identity-conflict"
|
|
133
|
+
| "use";
|
|
133
134
|
export class SourceAuthorityError extends Error {
|
|
134
135
|
constructor(readonly code: AuthorityCode) {
|
|
135
136
|
super(code);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The signed order-contact command (decision gv-contact-vault-command-contract-v1b): the only
|
|
3
|
+
* inbound path for a customer's contact. Body `{tenantId, stage, contact}` exactly; the caller token
|
|
4
|
+
* must carry `use: "order-contact"` and a digest of the canonical body. The commerce intake refuses
|
|
5
|
+
* these tokens and keeps refusing every private key in its own events.
|
|
6
|
+
*/
|
|
7
|
+
import { canonicalJson, sha256Hex } from "@tot/public-runtime";
|
|
8
|
+
import type { Queryable } from "../../catalog/d1/catalog.js";
|
|
9
|
+
import { verifySignedCommerceToken, type CommerceCallerConfig } from "../data/orderForwardReceiver.js";
|
|
10
|
+
import type { VaultKeyring } from "@/lib/customers/contactVault/envelope.js";
|
|
11
|
+
import { readOrderContactCommand } from "./orderContact.js";
|
|
12
|
+
import { sealOrderContact, type SealOutcome } from "./orderContactVault.js";
|
|
13
|
+
import { type VaultStage } from "@/lib/customers/contactVault/contactAudit.js";
|
|
14
|
+
|
|
15
|
+
export type ContactIntakeResult =
|
|
16
|
+
| { ok: true; duplicate: boolean; tenantId: string; stage: VaultStage }
|
|
17
|
+
| { ok: false; status: number; reason: string };
|
|
18
|
+
|
|
19
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
20
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isStage(value: unknown): value is VaultStage {
|
|
24
|
+
return value === "preview" || value === "publication";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function receiveOrderContact(
|
|
28
|
+
db: Queryable,
|
|
29
|
+
keyring: VaultKeyring | null,
|
|
30
|
+
body: unknown,
|
|
31
|
+
token: string,
|
|
32
|
+
config: CommerceCallerConfig,
|
|
33
|
+
): Promise<ContactIntakeResult> {
|
|
34
|
+
if (!isStage(config.expectedStage)) return { ok: false, status: 503, reason: "vault-unavailable" };
|
|
35
|
+
if (!isRecord(body)) return { ok: false, status: 400, reason: "malformed" };
|
|
36
|
+
const { tenantId, stage, contact } = body;
|
|
37
|
+
if (typeof tenantId !== "string" || tenantId.trim() === "") return { ok: false, status: 401, reason: "tenant" };
|
|
38
|
+
if (!isStage(stage)) return { ok: false, status: 401, reason: "stage" };
|
|
39
|
+
const verified = await verifySignedCommerceToken(token, config, { tenantId, stage, use: "order-contact" });
|
|
40
|
+
if (!verified.ok) return { ok: false, status: 401, reason: verified.reason };
|
|
41
|
+
const keys = Object.keys(body);
|
|
42
|
+
if (keys.length !== 3 || !["tenantId", "stage", "contact"].every((key) => keys.includes(key))) {
|
|
43
|
+
return { ok: false, status: 400, reason: "malformed" };
|
|
44
|
+
}
|
|
45
|
+
if ((await sha256Hex(canonicalJson({ tenantId, stage, contact }))) !== verified.digest) {
|
|
46
|
+
return { ok: false, status: 401, reason: "signature" };
|
|
47
|
+
}
|
|
48
|
+
const command = readOrderContactCommand(contact);
|
|
49
|
+
if (!command) return { ok: false, status: 400, reason: "malformed" };
|
|
50
|
+
const sealed: SealOutcome = await sealOrderContact(db, keyring, {
|
|
51
|
+
tenantId,
|
|
52
|
+
stage,
|
|
53
|
+
command,
|
|
54
|
+
issuer: verified.issuer,
|
|
55
|
+
jti: verified.jti,
|
|
56
|
+
now: new Date(config.now),
|
|
57
|
+
});
|
|
58
|
+
return sealed.ok
|
|
59
|
+
? { ok: true, duplicate: sealed.duplicate, tenantId, stage }
|
|
60
|
+
: { ok: false, status: sealed.status, reason: sealed.reason };
|
|
61
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The admission every order-contact admin route runs before it touches the vault: the owner gate,
|
|
3
|
+
* then the owner-or-staff split (contactReaderAuthority), then — for staff — a reason, a ticket and a
|
|
4
|
+
* LIVE owner-consented customer-contact-read grant. The tenant comes from the routed host and the
|
|
5
|
+
* stage from the routed customization environment, never from the request.
|
|
6
|
+
*/
|
|
7
|
+
import type { APIContext } from "astro";
|
|
8
|
+
import { requireAdminApiEntry } from "@/lib/access/auth/adminApiGuard";
|
|
9
|
+
import { readViewerSession } from "@/lib/access/auth/route";
|
|
10
|
+
import { contactReaderAuthority, resolveContactReadGrant } from "@/lib/access/auth/contactReaderAuthority";
|
|
11
|
+
import { readD1 } from "@/lib/publication/shared/env";
|
|
12
|
+
import { fromD1, type Queryable } from "@/lib/catalog/d1/catalog.js";
|
|
13
|
+
import { commerceStageForEnv } from "@/lib/fulfillment/fulfillmentService";
|
|
14
|
+
import type { VaultKeyring } from "@/lib/customers/contactVault/envelope.js";
|
|
15
|
+
import { loadVaultKeyring } from "./keyringEnv.js";
|
|
16
|
+
import { type ContactActor, type VaultStage } from "@/lib/customers/contactVault/contactAudit.js";
|
|
17
|
+
|
|
18
|
+
export const SUPPORT_REASON_CODES = [
|
|
19
|
+
"customer-request",
|
|
20
|
+
"delivery-issue",
|
|
21
|
+
"payment-dispute",
|
|
22
|
+
"compliance-review",
|
|
23
|
+
"merchant-request",
|
|
24
|
+
] as const;
|
|
25
|
+
const TICKET_REF = /^[A-Za-z0-9._-]{1,64}$/;
|
|
26
|
+
|
|
27
|
+
export function contactJson(body: unknown, status: number): Response {
|
|
28
|
+
return new Response(JSON.stringify(body), {
|
|
29
|
+
status,
|
|
30
|
+
headers: {
|
|
31
|
+
"content-type": "application/json; charset=utf-8",
|
|
32
|
+
"cache-control": "no-store",
|
|
33
|
+
"x-robots-tag": "noindex, nofollow",
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A support reason from a request body, or null when it is not exactly {reasonCode, ticketRef}. */
|
|
39
|
+
export function readSupportReason(body: unknown): { reasonCode: string; ticketRef: string } | null {
|
|
40
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return null;
|
|
41
|
+
const record = body as Record<string, unknown>;
|
|
42
|
+
const keys = Object.keys(record);
|
|
43
|
+
if (keys.length !== 2 || !keys.includes("reasonCode") || !keys.includes("ticketRef")) return null;
|
|
44
|
+
const { reasonCode, ticketRef } = record;
|
|
45
|
+
if (typeof reasonCode !== "string" || !(SUPPORT_REASON_CODES as readonly string[]).includes(reasonCode)) return null;
|
|
46
|
+
if (typeof ticketRef !== "string" || !TICKET_REF.test(ticketRef)) return null;
|
|
47
|
+
return { reasonCode, ticketRef };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type ContactRoute =
|
|
51
|
+
| { ok: false; response: Response }
|
|
52
|
+
| { ok: true; db: Queryable; keyring: VaultKeyring | null; tenantId: string; stage: VaultStage; actor: ContactActor };
|
|
53
|
+
|
|
54
|
+
export async function openContactRoute(
|
|
55
|
+
context: APIContext,
|
|
56
|
+
expect: "owner" | "staff",
|
|
57
|
+
body?: unknown,
|
|
58
|
+
): Promise<ContactRoute> {
|
|
59
|
+
const refuse = (error: string, status: number): ContactRoute => ({ ok: false, response: contactJson({ error }, status) });
|
|
60
|
+
const url = new URL(context.request.url);
|
|
61
|
+
if (url.searchParams.has("tenant") || url.searchParams.has("tenantId") || url.searchParams.has("stage")) {
|
|
62
|
+
return refuse("denied", 403);
|
|
63
|
+
}
|
|
64
|
+
const access = await requireAdminApiEntry(context, { requireOwner: true });
|
|
65
|
+
if (!access.ok) return refuse(access.reason, access.status);
|
|
66
|
+
const host = context.locals.tenant?.appDomain?.trim() ?? "";
|
|
67
|
+
const authority = contactReaderAuthority(await readViewerSession(context), host, Math.floor(Date.now() / 1000));
|
|
68
|
+
if (authority.kind === "none") return refuse("denied", 403);
|
|
69
|
+
|
|
70
|
+
let actor: ContactActor;
|
|
71
|
+
if (expect === "owner") {
|
|
72
|
+
if (authority.kind === "staff") return refuse("support-read-required", 403);
|
|
73
|
+
actor = { actorId: authority.actorId, role: "owner" };
|
|
74
|
+
} else {
|
|
75
|
+
if (authority.kind === "owner") return refuse("use-owner-route", 403);
|
|
76
|
+
const reason = readSupportReason(body);
|
|
77
|
+
if (!reason) return refuse("malformed", 400);
|
|
78
|
+
if (!(await resolveContactReadGrant(authority.email, host))) return refuse("grant-required", 403);
|
|
79
|
+
actor = { actorId: authority.actorId, role: "staff", ...reason };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const stage = commerceStageForEnv(context.locals.customizationEnv);
|
|
83
|
+
if (!stage) return refuse("stage-unknown", 409);
|
|
84
|
+
const d1 = await readD1("STOREFRONT_APPS_DB");
|
|
85
|
+
if (!d1) return refuse("unavailable", 503);
|
|
86
|
+
return { ok: true, db: fromD1(d1), keyring: await loadVaultKeyring(), tenantId: host, stage, actor };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Read a request's JSON body, or undefined when it is absent or not JSON. */
|
|
90
|
+
export async function readRequestJson(request: Request): Promise<unknown> {
|
|
91
|
+
const raw = await request.text();
|
|
92
|
+
if (raw.length > 4096) return undefined;
|
|
93
|
+
try {
|
|
94
|
+
return JSON.parse(raw);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (error instanceof SyntaxError) return undefined;
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** The Worker's contact vault keyring, from the declared `ORDER_CONTACT_VAULT_KEYRING` secret only. */
|
|
2
|
+
import { readEnv } from "@/lib/publication/shared/env";
|
|
3
|
+
import { parseVaultKeyring, type VaultKeyring } from "@/lib/customers/contactVault/envelope.js";
|
|
4
|
+
|
|
5
|
+
export async function loadVaultKeyring(): Promise<VaultKeyring | null> {
|
|
6
|
+
return parseVaultKeyring(await readEnv("ORDER_CONTACT_VAULT_KEYRING"));
|
|
7
|
+
}
|