@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.
Files changed (27) hide show
  1. package/apps/storefront/migrations-apps/0021_contact_vault.sql +162 -0
  2. package/apps/storefront/src/lib/access/auth/contactReaderAuthority.ts +64 -0
  3. package/apps/storefront/src/lib/access/shared/viewerGrant.ts +30 -0
  4. package/apps/storefront/src/lib/customers/contactVault/contactAudit.ts +80 -0
  5. package/apps/storefront/src/lib/customers/contactVault/envelope.ts +166 -0
  6. package/apps/storefront/src/lib/customers/contactVault/retention.ts +44 -0
  7. package/apps/storefront/src/lib/engagement/contact/contactMessages.ts +242 -65
  8. package/apps/storefront/src/lib/engagement/contact/contract.ts +2 -0
  9. package/apps/storefront/src/lib/fulfillment/commerceProjection.ts +0 -1
  10. package/apps/storefront/src/lib/fulfillment/fulfillmentService.ts +3 -78
  11. package/apps/storefront/src/lib/integrations/webhooks/deliveryEngine.ts +4 -0
  12. package/apps/storefront/src/lib/orders/admin/orderActions.ts +229 -19
  13. package/apps/storefront/src/lib/orders/data/commerceCallerConfig.ts +37 -0
  14. package/apps/storefront/src/lib/orders/data/orderForwardReceiver.ts +10 -3
  15. package/apps/storefront/src/lib/orders/data/orderMapper.ts +9 -0
  16. package/apps/storefront/src/lib/orders/data/sourceAuthorityContract.ts +2 -1
  17. package/apps/storefront/src/lib/orders/vault/contactIntake.ts +61 -0
  18. package/apps/storefront/src/lib/orders/vault/contactRoutes.ts +99 -0
  19. package/apps/storefront/src/lib/orders/vault/keyringEnv.ts +7 -0
  20. package/apps/storefront/src/lib/orders/vault/orderContact.ts +96 -0
  21. package/apps/storefront/src/lib/orders/vault/orderContactVault.ts +369 -0
  22. package/apps/storefront/src/lib/publication/d1/schema-apps.ts +22 -11
  23. package/apps/storefront/src/lib/publication/shared/routedApiParam.ts +3 -0
  24. package/apps/storefront/src/pages/api/admin/fulfillment/orders/[id]/address.ts +26 -23
  25. package/apps/storefront/src/pages/api/contact.ts +16 -4
  26. package/apps/storefront/src/scheduled/contactVaultSweep.ts +80 -0
  27. 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 owner-only address, or says why
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
- /** Owner address response, as stored. Only these fields are shown, in this order. */
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 ADDRESS_NONE =
96
- "No ship-to address is stored for this order. The checkout confirmation carries none and the order ledger keeps no customer data.";
97
- export const ADDRESS_DENIED = "Only the store owner can view a ship-to address.";
98
- export const ADDRESS_FAILED = "The address could not be loaded. Try again.";
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
- async function loadAddress(orderId: string, note: HTMLElement | null, deps: OrderActionDeps): Promise<void> {
114
- if (!deps.api) {
115
- show(note, [ADDRESS_FAILED]);
116
- return;
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
- show(note, ["Loading address…"]);
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
- if (response.status === 404) return show(note, [ADDRESS_NONE]);
125
- if (response.status === 401 || response.status === 403) return show(note, [ADDRESS_DENIED]);
126
- if (!response.ok) return show(note, [ADDRESS_FAILED]);
127
- const lines = addressLines(await response.json());
128
- show(note, lines.length ? lines : [ADDRESS_NONE]);
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
- show(note, [ADDRESS_FAILED]);
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 loadAddress(orderId, record.querySelector<HTMLElement>(target) ?? note, deps);
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
+ }