@tokenoftrust/storefront-runner 6.1.0 → 6.1.1

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.
@@ -14,16 +14,21 @@
14
14
  */
15
15
  import type {
16
16
  CheckoutOrderDetail,
17
- CheckoutOrderDetailResponse,
17
+ CheckoutOrderSummary,
18
18
  CheckoutOrderListFilters,
19
19
  CheckoutOrderListResponse,
20
20
  CommerceActor,
21
21
  CommerceRole,
22
22
  CommerceScope,
23
23
  CommerceStage,
24
+ CheckoutOrderCustomer,
25
+ OrderContactAvailability,
26
+ OrderContactState,
24
27
  } from "@tot/public-runtime";
25
- import { readOwnerAddress } from "@tot/public-runtime";
28
+ import { actorMayCommand, readOwnerAddress } from "@tot/public-runtime";
26
29
  import type { CustomizationEnv } from "@tot/public-runtime";
30
+ import { contactReaderAuthority } from "@/lib/access/auth/contactReaderAuthority";
31
+ import type { SessionRecord } from "@/lib/access/auth/session";
27
32
 
28
33
  /** Fetch shape we depend on — the platform `fetch`, injectable for tests. */
29
34
  export type FetchLike = (
@@ -107,26 +112,83 @@ export function commerceStageForCustomization(env: CustomizationEnv): CommerceSt
107
112
  return env === "live" ? "publication" : "preview";
108
113
  }
109
114
 
110
- /** Owner reads private order fields. An admitted member is a caller. Everyone else is denied. */
115
+ /** Private ownership comes from tenant membership, independently of staff operational capability. */
111
116
  export function commerceActorForAdmin(input: {
112
117
  tenantId: string;
113
118
  stage: CommerceStage;
114
- owner: boolean;
119
+ record: SessionRecord | null | undefined;
120
+ nowSeconds: number;
115
121
  admitted: boolean;
116
122
  }): CommerceActor {
117
- const role: CommerceRole = !input.admitted ? "denied" : input.owner ? "owner" : "caller";
123
+ const privateOwner = contactReaderAuthority(input.record, input.tenantId, input.nowSeconds).kind === "owner";
124
+ const role: CommerceRole = !input.admitted ? "denied" : privateOwner ? "owner" : "caller";
118
125
  return { role, tenantId: input.tenantId, stage: input.stage };
119
126
  }
120
127
 
121
128
  /** Drop the address unless this actor may read it for this tenant and stage. */
122
- export function projectCheckoutOrder<T extends { shippingAddress?: unknown }>(
129
+ export function projectCheckoutOrder<T extends { shippingAddress?: unknown; customer?: CheckoutOrderCustomer; contactAvailability?: OrderContactAvailability }>(
123
130
  order: T,
124
131
  actor: CommerceActor,
125
132
  resource: CommerceScope,
126
133
  ): T {
134
+ const unknown: OrderContactAvailability = { identity: "unreadable", email: "unreadable", shipTo: "unreadable" };
135
+ const availability = order.contactAvailability ?? {
136
+ ...unknown,
137
+ shipTo: Object.hasOwn(order, "shippingAddress") ? addressAvailability(order.shippingAddress) : "unreadable",
138
+ };
127
139
  const read = readOwnerAddress(actor, resource, () => order.shippingAddress ?? null);
128
- if (read.ok) return order;
129
- return { ...order, shippingAddress: null };
140
+ if (read.ok) return { ...order, contactAvailability: availability };
141
+ const restricted = (state: OrderContactState): OrderContactState => state === "present" ? "restricted" : state;
142
+ const projected: Record<string, unknown> = { ...order };
143
+ for (const key of ["customerName", "customerEmail", "email", "phone", "name", "line1", "line2", "postalCode"]) delete projected[key];
144
+ return {
145
+ ...projected,
146
+ shippingAddress: null,
147
+ ...(order.customer ? { customer: { id: null, name: "Checkout customer" } } : {}),
148
+ contactAvailability: actorMayCommand(actor, resource) ? {
149
+ identity: restricted(availability.identity), email: restricted(availability.email), shipTo: restricted(availability.shipTo),
150
+ } : unknown,
151
+ } as T;
152
+ }
153
+
154
+ function addressAvailability(value: unknown): OrderContactState {
155
+ if (value === null) return "absent";
156
+ if (!isRecord(value)) return "unreadable";
157
+ return ["line1", "city", "country"].every((key) => typeof value[key] === "string" && value[key].trim() !== "") ? "present" : "unreadable";
158
+ }
159
+
160
+ function isRecord(value: unknown): value is Record<string, unknown> {
161
+ return typeof value === "object" && value !== null && !Array.isArray(value);
162
+ }
163
+
164
+ const SUMMARY_FIELDS = ["id", "placedAt", "currency", "customer", "contactAvailability", "itemsSummary", "itemCount", "total", "payment", "fulfillment", "blocker", "verification"];
165
+ const DETAIL_FIELDS = [...SUMMARY_FIELDS, "lineItems", "totals", "shippingAddress", "verificationDetail"];
166
+
167
+ /** Refuse an unsupported upstream body before any raw contact aliases reach an admin response. */
168
+ function frozenOrder(value: unknown, detail: true): value is CheckoutOrderDetail;
169
+ function frozenOrder(value: unknown, detail: false): value is CheckoutOrderSummary;
170
+ function frozenOrder(value: unknown, detail: boolean): boolean {
171
+ if (!isRecord(value) || Object.keys(value).some((key) => !(detail ? DETAIL_FIELDS : SUMMARY_FIELDS).includes(key))) return false;
172
+ if (!["id", "placedAt", "currency", "itemsSummary"].every((key) => typeof value[key] === "string")) return false;
173
+ if (!isRecord(value.customer) || typeof value.customer.name !== "string" ||
174
+ !(value.customer.id === null || typeof value.customer.id === "string") ||
175
+ Object.keys(value.customer).some((key) => key !== "id" && key !== "name")) return false;
176
+ if (typeof value.total !== "number" || !Number.isFinite(value.total) || !Number.isInteger(value.itemCount)) return false;
177
+ if (!["authorized", "captured", "refunded"].includes(String(value.payment)) ||
178
+ !["unfulfilled", "ready_to_ship", "hold_review_required", "completed"].includes(String(value.fulfillment))) return false;
179
+ if (value.blocker !== null && typeof value.blocker !== "string") return false;
180
+ if (value.verification !== null && !isRecord(value.verification)) return false;
181
+ const availability = value.contactAvailability;
182
+ if (availability !== undefined && (!isRecord(availability) ||
183
+ !["identity", "email", "shipTo"].every((key) => typeof availability[key] === "string" &&
184
+ ["present", "restricted", "absent", "expired", "erased", "unreadable"].includes(availability[key])) ||
185
+ Object.keys(availability).some((key) => !["identity", "email", "shipTo"].includes(key)))) return false;
186
+ return !detail || (Array.isArray(value.lineItems) && isRecord(value.totals) && isRecord(value.verificationDetail) &&
187
+ (value.shippingAddress === null || isRecord(value.shippingAddress)));
188
+ }
189
+
190
+ function unsupportedBody(): OrderApiError {
191
+ return new OrderApiError("bad_gateway", 502);
130
192
  }
131
193
 
132
194
  /** Build the list querystring: appDomain (required) + any provided filters. */
@@ -151,12 +213,12 @@ export function createCheckoutOrderClient(
151
213
  const base = config.baseUrl.replace(/\/+$/, "");
152
214
  const headers = { authorization: `Bearer ${config.bearer}` };
153
215
 
154
- async function getJson<T>(url: string): Promise<T> {
216
+ async function getJson(url: string): Promise<unknown> {
155
217
  const res = await doFetch(url, { method: "GET", headers });
156
218
  const body = await res.text();
157
219
  if (!res.ok) throw toError(res.status, body);
158
220
  try {
159
- return JSON.parse(body) as T;
221
+ return JSON.parse(body) as unknown;
160
222
  } catch {
161
223
  throw new OrderApiError("unexpected", res.status, "malformed JSON");
162
224
  }
@@ -165,14 +227,22 @@ export function createCheckoutOrderClient(
165
227
  return {
166
228
  async listOrders(filters) {
167
229
  const qs = listQuery(config.appDomain, filters);
168
- return getJson<CheckoutOrderListResponse>(`${base}/commerce/orders?${qs}`);
230
+ const body = await getJson(`${base}/commerce/orders?${qs}`);
231
+ if (!isRecord(body) || !Array.isArray(body.orders) || !body.orders.every((order): order is CheckoutOrderSummary => frozenOrder(order, false)) ||
232
+ !isRecord(body.page) || Object.keys(body).some((key) => key !== "orders" && key !== "page")) throw unsupportedBody();
233
+ const page = body.page;
234
+ if (typeof page.limit !== "number" || !(page.cursor === null || typeof page.cursor === "string") ||
235
+ !(page.nextCursor === null || typeof page.nextCursor === "string") || !(page.total === null || typeof page.total === "number") ||
236
+ Object.keys(page).some((key) => !["limit", "cursor", "nextCursor", "total"].includes(key))) throw unsupportedBody();
237
+ return { orders: body.orders, page: { limit: page.limit, cursor: page.cursor, nextCursor: page.nextCursor, total: page.total } };
169
238
  },
170
239
  async getOrder(orderId) {
171
240
  const qs = new URLSearchParams({ appDomain: config.appDomain }).toString();
172
- const { order } = await getJson<CheckoutOrderDetailResponse>(
241
+ const body = await getJson(
173
242
  `${base}/commerce/orders/${encodeURIComponent(orderId)}?${qs}`,
174
243
  );
175
- return order;
244
+ if (!isRecord(body) || !frozenOrder(body.order, true) || Object.keys(body).some((key) => key !== "order")) throw unsupportedBody();
245
+ return body.order;
176
246
  },
177
247
  };
178
248
  }
@@ -28,6 +28,7 @@ import type {
28
28
  TenantConfig,
29
29
  OrderContactState,
30
30
  } from "@tot/public-runtime";
31
+ import { projectCheckoutOrder } from "../orderClient";
31
32
  import {
32
33
  actorMayCommand,
33
34
  decideCommerceCommand,
@@ -644,6 +645,7 @@ export function emptyOrdersViewModel(targetTenant: string, timeZone: string = DE
644
645
  }
645
646
 
646
647
  export interface GetOrdersViewModelOptions {
648
+ actor?: CommerceActor;
647
649
  targetTenant: string;
648
650
  scope: CommerceScope;
649
651
  client: CheckoutOrderClient;
@@ -719,12 +721,15 @@ async function readOrdersToday(client: CheckoutOrderClient, now: Date, timeZone:
719
721
 
720
722
  export async function getOrdersViewModelResult(opts: GetOrdersViewModelOptions): Promise<OrdersLoadResult> {
721
723
  try {
722
- const { orders, page } = await opts.client.listOrders(opts.filters);
724
+ const listed = await opts.client.listOrders(opts.filters);
725
+ const actor = opts.actor ?? { ...opts.scope, role: "caller" as const };
726
+ const orders = listed.orders.map((order) => projectCheckoutOrder(order, actor, opts.scope));
727
+ const page = listed.page;
723
728
  const details: Record<string, CheckoutOrderDetail> = {};
724
729
  await Promise.all(
725
730
  orders.map(async (o) => {
726
731
  try {
727
- details[o.id] = await opts.client.getOrder(o.id);
732
+ details[o.id] = projectCheckoutOrder(await opts.client.getOrder(o.id), actor, opts.scope);
728
733
  } catch {
729
734
  /* detail unavailable — row still renders from its summary */
730
735
  }
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import type { APIContext } from "astro";
9
9
  import { requireAdminApiEntry } from "@/lib/access/auth/adminApiGuard";
10
+ import { readViewerSession } from "@/lib/access/auth/route";
10
11
  import { commerceActorForAdmin, OrderApiError, projectCheckoutOrder } from "@/lib/orders/admin/orderClient";
11
12
  import { resolveOrderSource, workerOrderSourceDeps } from "@/lib/orders/admin/orderSource";
12
13
  import { admitSharedStoreTenant } from "@/lib/checkout/commerce/sharedStoreAdmission";
@@ -47,7 +48,8 @@ export async function GET(context: APIContext): Promise<Response> {
47
48
  const actor = commerceActorForAdmin({
48
49
  tenantId: scope.tenantId,
49
50
  stage: scope.stage,
50
- owner: access.entry.isOwnerViewer,
51
+ record: await readViewerSession(context),
52
+ nowSeconds: Math.floor(Date.now() / 1000),
51
53
  admitted: access.entry.admitted,
52
54
  });
53
55
  const order = projectCheckoutOrder(await source.client.getOrder(orderId), actor, scope);
@@ -11,7 +11,8 @@
11
11
  */
12
12
  import type { APIContext } from "astro";
13
13
  import { requireAdminApiEntry } from "@/lib/access/auth/adminApiGuard";
14
- import { OrderApiError } from "@/lib/orders/admin/orderClient";
14
+ import { readViewerSession } from "@/lib/access/auth/route";
15
+ import { commerceActorForAdmin, OrderApiError, projectCheckoutOrder } from "@/lib/orders/admin/orderClient";
15
16
  import { resolveOrderSource, workerOrderSourceDeps } from "@/lib/orders/admin/orderSource";
16
17
  import { admitSharedStoreTenant } from "@/lib/checkout/commerce/sharedStoreAdmission";
17
18
  import { assessCheckoutOrder, parseOrderFilters } from "@/lib/orders/admin/services/orders";
@@ -51,10 +52,11 @@ export async function GET(context: APIContext): Promise<Response> {
51
52
 
52
53
  try {
53
54
  const data = await source.client.listOrders(filters);
54
- const orders = data.orders.map((order) => ({
55
- ...order,
56
- readiness: assessCheckoutOrder(order, source.scope),
57
- }));
55
+ const actor = commerceActorForAdmin({ ...source.scope, record: await readViewerSession(context), nowSeconds: Math.floor(Date.now() / 1000), admitted: access.entry.admitted });
56
+ const orders = data.orders.map((raw) => {
57
+ const order = projectCheckoutOrder(raw, actor, source.scope);
58
+ return { ...order, readiness: assessCheckoutOrder(order, source.scope) };
59
+ });
58
60
  return new Response(JSON.stringify({ ...data, orders }), { headers: JSON_HEADERS });
59
61
  } catch (err) {
60
62
  if (err instanceof OrderApiError) return jsonError(err.code, err.status);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "6.1.0",
3
+ "version": "6.1.1",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",