@tokenoftrust/storefront-runner 2.0.1 → 2.0.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 (120) hide show
  1. package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +19 -1
  2. package/apps/storefront/dev-plugins/tenant-validate-on-save.mjs +78 -0
  3. package/apps/storefront/src/middleware/index.ts +17 -1
  4. package/package.json +1 -1
  5. package/scripts/lib/tenant-dirs.mjs +80 -0
  6. package/scripts/tenant/lint-embeds.mjs +180 -0
  7. package/scripts/tenant/materialize-tenant-content.mjs +533 -0
  8. package/scripts/tenant/validate.mjs +837 -0
  9. package/apps/storefront/scripts/build-v84-clean-clone-standalone.mjs +0 -68
  10. package/apps/storefront/scripts/deny-v84-clean-clone-standalone-capabilities.mjs +0 -111
  11. package/apps/storefront/scripts/deny-v84-dogfood-capabilities.mjs +0 -2
  12. package/apps/storefront/scripts/deny-v84-lifecycle-capabilities.mjs +0 -247
  13. package/apps/storefront/scripts/dogfood-v84-nonportable-routes.ts +0 -73
  14. package/apps/storefront/scripts/fixed-v84-native-install-producer.ts +0 -79
  15. package/apps/storefront/scripts/generated/v84-clean-clone-replay.bundle.mjs +0 -10982
  16. package/apps/storefront/scripts/import-v84-through-ws2.ts +0 -38
  17. package/apps/storefront/scripts/initialize-v84-lifecycle-tooling-dependency.ts +0 -2
  18. package/apps/storefront/scripts/initialize-v84-lifecycle-tooling.ts +0 -4
  19. package/apps/storefront/scripts/install-v84-native-through-ws1.ts +0 -185
  20. package/apps/storefront/scripts/prove-clone-isolation.ts +0 -506
  21. package/apps/storefront/scripts/replay-v84-clean-clone-pass.ts +0 -148
  22. package/apps/storefront/scripts/replay-v84-clean-clone-standalone.ts +0 -35
  23. package/apps/storefront/scripts/replay-v84-clean-clone.ts +0 -214
  24. package/apps/storefront/scripts/run-fixed-v84-native-install-producer.mjs +0 -18
  25. package/apps/storefront/scripts/run-v84-clone-isolation-proof.mjs +0 -18
  26. package/apps/storefront/scripts/run-v84-clone-lifecycle-proof.mjs +0 -18
  27. package/apps/storefront/scripts/run-v84-import-through-ws2-proof.mjs +0 -17
  28. package/apps/storefront/scripts/run-v84-nonportable-route-dogfood.mjs +0 -15
  29. package/apps/storefront/scripts/sanitize-v84-proof-environment.mjs +0 -21
  30. package/apps/storefront/scripts/v84CleanCloneGraphPin.ts +0 -42
  31. package/apps/storefront/scripts/v84CleanCloneSourceGraph.ts +0 -145
  32. package/apps/storefront/scripts/validate-v84-clone-lifecycle.ts +0 -18
  33. package/apps/storefront/src/components/admin/AdminAIWorkflowsTab.astro +0 -531
  34. package/apps/storefront/src/components/admin/AdminBlogDetailRail.astro +0 -878
  35. package/apps/storefront/src/components/admin/AdminBlogTab.astro +0 -1435
  36. package/apps/storefront/src/components/admin/AdminCheckoutSettingsPanel.astro +0 -226
  37. package/apps/storefront/src/components/admin/AdminCustomersTab.astro +0 -593
  38. package/apps/storefront/src/components/admin/AdminFulfillmentTab.astro +0 -465
  39. package/apps/storefront/src/components/admin/AdminIntegrationsTab.astro +0 -105
  40. package/apps/storefront/src/components/admin/AdminProductsTab.astro +0 -901
  41. package/apps/storefront/src/components/admin/AdminPublishTab.astro +0 -2978
  42. package/apps/storefront/src/components/admin/AdminReportingTab.astro +0 -435
  43. package/apps/storefront/src/components/admin/AdminSettingsTab.astro +0 -782
  44. package/apps/storefront/src/components/admin/AdminSubscriptionsTab.astro +0 -611
  45. package/apps/storefront/src/components/admin/orders/OrdersWorkspace.astro +0 -1223
  46. package/apps/storefront/src/lib/dev/cockpitStore.ts +0 -57
  47. package/apps/storefront/src/lib/publish/adminPublishTab.ts +0 -4625
  48. package/apps/storefront/src/lib/publish/apex-readiness.ts +0 -360
  49. package/apps/storefront/src/lib/publish/buildStream.ts +0 -269
  50. package/apps/storefront/src/lib/publish/collapsePersistence.ts +0 -72
  51. package/apps/storefront/src/lib/publish/dispatchHealth.ts +0 -267
  52. package/apps/storefront/src/lib/publish/domainState.ts +0 -360
  53. package/apps/storefront/src/lib/publish/lifecycleState.ts +0 -369
  54. package/apps/storefront/src/lib/publish/liveReleaseSummary.ts +0 -29
  55. package/apps/storefront/src/lib/publish/mergeDoctorOnFailure.ts +0 -78
  56. package/apps/storefront/src/lib/publish/mergeHealthBanner.ts +0 -62
  57. package/apps/storefront/src/lib/publish/mergeRowFindings.ts +0 -124
  58. package/apps/storefront/src/lib/publish/mergeSupportEscalation.ts +0 -75
  59. package/apps/storefront/src/lib/publish/panelPolling.ts +0 -78
  60. package/apps/storefront/src/lib/publish/publish-guard.ts +0 -88
  61. package/apps/storefront/src/lib/publish/publishFormat.ts +0 -165
  62. package/apps/storefront/src/lib/publish/publishHero.ts +0 -177
  63. package/apps/storefront/src/lib/publish/reviewRelease/changeState.ts +0 -66
  64. package/apps/storefront/src/lib/publish/reviewRelease/copy.ts +0 -141
  65. package/apps/storefront/src/lib/publish/reviewRelease/index.ts +0 -41
  66. package/apps/storefront/src/lib/publish/reviewRelease/permissions.ts +0 -47
  67. package/apps/storefront/src/lib/publish/reviewRelease/releaseState.ts +0 -42
  68. package/apps/storefront/src/lib/publish/reviewRelease/types.ts +0 -59
  69. package/apps/storefront/src/lib/publish/reviewRelease/viewModel.ts +0 -440
  70. package/apps/storefront/src/lib/publish/reviewReleaseWorkspace/actions.ts +0 -161
  71. package/apps/storefront/src/lib/publish/reviewReleaseWorkspace/controller.ts +0 -325
  72. package/apps/storefront/src/lib/publish/reviewReleaseWorkspace/render.ts +0 -241
  73. package/apps/storefront/src/lib/publish/shipWorkspace.ts +0 -439
  74. package/apps/storefront/src/lib/publish/sseRoute.ts +0 -119
  75. package/apps/storefront/src/lib/publish/stageBundleUpload.ts +0 -215
  76. package/apps/storefront/src/lib/publish/streamLogConsole.ts +0 -142
  77. package/apps/storefront/src/lib/publish/versionNumber.ts +0 -52
  78. package/apps/storefront/src/pages/admin/ops-timeline.astro +0 -420
  79. package/apps/storefront/src/pages/admin/review-release.astro +0 -265
  80. package/apps/storefront/src/pages/admin/select.astro +0 -96
  81. package/apps/storefront/src/pages/admin.astro +0 -568
  82. package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +0 -86
  83. package/apps/storefront/src/pages/api/apps/admin/gtm-migration-install.ts +0 -174
  84. package/apps/storefront/src/pages/api/apps/admin/gtm-migration.ts +0 -96
  85. package/apps/storefront/src/pages/api/apps/admin/health.ts +0 -44
  86. package/apps/storefront/src/pages/api/apps/admin/install.ts +0 -125
  87. package/apps/storefront/src/pages/api/apps/admin/integration-lifecycle.ts +0 -118
  88. package/apps/storefront/src/pages/api/apps/admin/integrations.ts +0 -89
  89. package/apps/storefront/src/pages/api/apps/admin/list.ts +0 -26
  90. package/apps/storefront/src/pages/api/apps/admin/platform-adapters.ts +0 -85
  91. package/apps/storefront/src/pages/api/apps/admin/resume.ts +0 -79
  92. package/apps/storefront/src/pages/api/apps/admin/suspend.ts +0 -80
  93. package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +0 -98
  94. package/apps/storefront/src/pages/api/apps/admin/update.ts +0 -126
  95. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +0 -70
  96. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries.ts +0 -51
  97. package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +0 -255
  98. package/apps/storefront/src/pages/api/apps/oauth/token.ts +0 -87
  99. package/apps/storefront/src/pages/api/apps/v1/attribution.ts +0 -162
  100. package/apps/storefront/src/pages/api/apps/v1/catalog/products/[handle].ts +0 -39
  101. package/apps/storefront/src/pages/api/apps/v1/catalog/products.ts +0 -49
  102. package/apps/storefront/src/pages/api/apps/v1/health.ts +0 -32
  103. package/apps/storefront/src/pages/api/apps/v1/inventory.ts +0 -59
  104. package/apps/storefront/src/pages/api/apps/v1/orders/[id].ts +0 -48
  105. package/apps/storefront/src/pages/api/apps/v1/orders.ts +0 -73
  106. package/apps/storefront/src/pages/api/apps/v1/reports.ts +0 -21
  107. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +0 -74
  108. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries.ts +0 -47
  109. package/apps/storefront/src/pages/api/cache-purge.ts +0 -55
  110. package/apps/storefront/src/pages/api/compliance/tamper.ts +0 -88
  111. package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +0 -154
  112. package/apps/storefront/src/pages/api/dashboard/vendor-search.ts +0 -120
  113. package/apps/storefront/src/pages/api/integrations/v1/[operation].ts +0 -108
  114. package/apps/storefront/src/pages/api/publish/stage-bundle.ts +0 -70
  115. package/apps/storefront/src/pages/api/test/dev-session.ts +0 -133
  116. package/apps/storefront/src/pages/cockpit.astro +0 -1548
  117. package/apps/storefront/src/pages/dashboard/[appDomain]/apps/index.astro +0 -119
  118. package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +0 -105
  119. package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +0 -177
  120. package/apps/storefront/src/pages/dashboard/index.astro +0 -160
@@ -1,51 +0,0 @@
1
- /**
2
- * GET /api/apps/admin/webhooks/deliveries — the ops/support tenant-wide
3
- * delivery view (PrivateApps epic, D4 Chunk E; D8 console-session auth
4
- * added). `tenantId` used below comes only from the resolved operator
5
- * session (`auth.tenantId`), never a raw query param — mirrors
6
- * `admin/list.ts`'s tenant scoping; the same `?tenant=` param is read only as
7
- * a CANDIDATE for `resolveOperatorSession` to admit against. Unlike the
8
- * app-facing `/api/apps/v1/webhooks/deliveries` (one install's own
9
- * deliveries), this surfaces every install's deliveries for the operator's
10
- * tenant via the additive `listDeliveriesByTenant` store method. Read-only:
11
- * any membership role admits.
12
- *
13
- * Not part of the app-facing `openapi.yaml` contract (same as the rest of
14
- * `admin/*`) — returns the internal `WebhookDelivery` shape as-is (installId
15
- * included; useful for support triage), no `toContractDelivery` narrowing.
16
- */
17
- import type { APIContext } from "astro";
18
- import { resolveOperatorSession } from "@/lib/apps/adminSession.js";
19
- import { json } from "@/lib/apps/adminService.js";
20
- import { WebhookDeliveryStore, type DeliveryState } from "@/lib/webhooks/deliveryStore.js";
21
- import { fromD1 } from "@/lib/d1/catalog.js";
22
- import { readD1 } from "@/lib/env";
23
-
24
- export const prerender = false;
25
-
26
- const DELIVERY_STATES: readonly DeliveryState[] = ["pending", "retrying", "delivered", "dead-lettered"];
27
-
28
- /** Absent = no filter (`undefined`); an unrecognized value is a 400, not a silent no-op. */
29
- function parseStateFilter(raw: string | null): DeliveryState | undefined | "invalid" {
30
- if (raw === null) return undefined;
31
- return (DELIVERY_STATES as readonly string[]).includes(raw) ? (raw as DeliveryState) : "invalid";
32
- }
33
-
34
- export async function GET(context: APIContext): Promise<Response> {
35
- const params = new URL(context.request.url).searchParams;
36
- const auth = await resolveOperatorSession(context, params.get("tenant"));
37
- if (!auth.ok) return json({ error: auth.message }, auth.status);
38
-
39
- const state = parseStateFilter(params.get("state"));
40
- if (state === "invalid") {
41
- return json({ error: "invalid state filter", code: "invalid_state_filter" }, 400);
42
- }
43
-
44
- const d1 = await readD1("STOREFRONT_APPS_DB");
45
- if (!d1) return json({ error: "webhook store not configured" }, 503);
46
- const store = new WebhookDeliveryStore(fromD1(d1));
47
-
48
- const deliveries = await store.listDeliveriesByTenant(auth.tenantId, { state });
49
-
50
- return json({ deliveries });
51
- }
@@ -1,255 +0,0 @@
1
- /**
2
- * POST /api/apps/internal/order-forward — PrivateApps epic, D6 (Chunk D).
3
- * Service-to-service, HMAC-authed (NOT the app-JWT bearer scheme
4
- * `pages/api/apps/v1/*` uses — this is a different caller entirely: the
5
- * tot-foxycart order-reconciliation unit, not an installed private app).
6
- * Lives under `internal/`, a sibling of `v1/`/`admin/`, precisely so it's
7
- * excluded from both `v1/routeInventory.test.ts` and
8
- * `admin/routeInventory.test.ts`'s directory-scoped inventories — this is
9
- * neither surface.
10
- *
11
- * `docs/private-apps/order-forward-contract.md` is the normative spec for the
12
- * request shape + signature scheme below; this file is the HTTP transport
13
- * only — `orderForwardReceiver.ts` owns the actual decision logic.
14
- *
15
- * Fail-closed, same posture as `pages/api/preview/reconcile.ts`'s Gitea
16
- * webhook verification: missing/invalid signature -> 401, unconfigured
17
- * secret -> 503. Never call the receiver on an unverified request.
18
- */
19
- import type { APIContext } from "astro";
20
- import { readEnv, readD1 } from "@/lib/env";
21
- import { fromD1 } from "@/lib/d1/catalog.js";
22
- import {
23
- receiveForwardedOrder,
24
- type OrderForwardInput,
25
- } from "@/lib/apps/orders/orderForwardReceiver.js";
26
- import { joinForwardedOrderConversion } from "@/lib/apps/orders/orderConversionD1.js";
27
- import { D1VerifiedProviderMeasurementStore } from "@/lib/apps/orders/verifiedProviderMeasurement.js";
28
- import type { OrderLineItem, OrderStatus } from "@/lib/apps/orders/ordersStore.js";
29
- import { recordActivity } from "@/lib/activity/recordActivity";
30
-
31
- export const prerender = false;
32
-
33
- const SIGNATURE_HEADER = "x-order-forward-signature";
34
- const ORDER_STATUSES: readonly OrderStatus[] = ["created", "fulfilled", "refunded", "cancelled"];
35
-
36
- function json(body: unknown, status: number): Response {
37
- return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
38
- }
39
-
40
- /** Coarse, non-PII conversion-join diagnostic (never the order id or correlation token). */
41
- function logConversionDiag(fields: Record<string, unknown>): void {
42
- try {
43
- console.warn("[diag:order-conversion]", JSON.stringify(fields));
44
- } catch {
45
- /* diagnostics must never break the caller */
46
- }
47
- }
48
-
49
- // --- HMAC verification -----------------------------------------------------
50
- // Same shape as `@tot/private-controlplane`'s `verifyGiteaSignature` (hex
51
- // HMAC-SHA256 over the raw body, verified via `crypto.subtle.verify` so a
52
- // short/malformed signature can't be timed) — kept local rather than shared
53
- // since that package's scheme is Gitea's, not this contract's; this is the
54
- // one caller of it today.
55
- const textEncoder = new TextEncoder();
56
-
57
- function hexToBytes(hex: string): Uint8Array | null {
58
- if (hex.length === 0 || hex.length % 2 !== 0 || /[^0-9a-fA-F]/.test(hex)) return null;
59
- const out = new Uint8Array(hex.length / 2);
60
- for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
61
- return out;
62
- }
63
-
64
- async function verifyOrderForwardSignature(
65
- secret: string,
66
- rawBody: string,
67
- signatureHex: string | null,
68
- ): Promise<boolean> {
69
- if (!signatureHex) return false;
70
- const sig = hexToBytes(signatureHex.trim());
71
- if (!sig) return false;
72
- const key = await crypto.subtle.importKey(
73
- "raw",
74
- textEncoder.encode(secret) as unknown as BufferSource,
75
- { name: "HMAC", hash: "SHA-256" },
76
- false,
77
- ["verify"],
78
- );
79
- return crypto.subtle.verify(
80
- "HMAC",
81
- key,
82
- sig as unknown as BufferSource,
83
- textEncoder.encode(rawBody) as unknown as BufferSource,
84
- );
85
- }
86
-
87
- // --- Payload validation ------------------------------------------------------
88
- interface OrderForwardBody {
89
- tenantId?: unknown;
90
- externalOrderId?: unknown;
91
- env?: unknown;
92
- status?: unknown;
93
- currency?: unknown;
94
- total?: unknown;
95
- subtotal?: unknown;
96
- lineItems?: unknown;
97
- rawCustomerId?: unknown;
98
- createdAt?: unknown;
99
- providerFields?: unknown;
100
- providerEventId?: unknown;
101
- occurredAt?: unknown;
102
- amountMinor?: unknown;
103
- }
104
-
105
- function parseLineItems(value: unknown): OrderLineItem[] | string {
106
- if (!Array.isArray(value) || value.length === 0) return "lineItems must be a non-empty array";
107
- const items: OrderLineItem[] = [];
108
- for (const raw of value as unknown[]) {
109
- if (raw === null || typeof raw !== "object") return "each lineItem must be an object";
110
- const item = raw as Record<string, unknown>;
111
- if (typeof item.sku !== "string" || !item.sku.trim()) return "each lineItem.sku is required";
112
- if (typeof item.quantity !== "number") return "each lineItem.quantity must be a number";
113
- if (item.handle !== undefined && typeof item.handle !== "string") return "lineItem.handle must be a string";
114
- if (item.lineTotal !== undefined && typeof item.lineTotal !== "number") {
115
- return "lineItem.lineTotal must be a number";
116
- }
117
- items.push({
118
- sku: item.sku,
119
- quantity: item.quantity,
120
- ...(item.handle !== undefined ? { handle: item.handle as string } : {}),
121
- ...(item.lineTotal !== undefined ? { lineTotal: item.lineTotal as number } : {}),
122
- });
123
- }
124
- return items;
125
- }
126
-
127
- /** Validate + narrow the request body into `OrderForwardInput`, or a 400 message. */
128
- function parseOrderForwardInput(body: OrderForwardBody): OrderForwardInput | string {
129
- if (typeof body.tenantId !== "string" || !body.tenantId.trim()) return "tenantId is required";
130
- if (typeof body.externalOrderId !== "string" || !body.externalOrderId.trim()) {
131
- return "externalOrderId is required";
132
- }
133
- if (typeof body.env !== "string" || !body.env.trim()) return "env is required";
134
- if (typeof body.status !== "string" || !ORDER_STATUSES.includes(body.status as OrderStatus)) {
135
- return `status must be one of ${ORDER_STATUSES.join(", ")}`;
136
- }
137
- if (typeof body.currency !== "string" || !body.currency.trim()) return "currency is required";
138
- if (typeof body.total !== "number") return "total must be a number";
139
- if (body.subtotal !== undefined && typeof body.subtotal !== "number") return "subtotal must be a number";
140
- const lineItems = parseLineItems(body.lineItems);
141
- if (typeof lineItems === "string") return lineItems;
142
- if (body.rawCustomerId !== undefined && typeof body.rawCustomerId !== "string") {
143
- return "rawCustomerId must be a string";
144
- }
145
- if (body.createdAt !== undefined && typeof body.createdAt !== "string") return "createdAt must be a string";
146
- if (
147
- body.providerFields !== undefined &&
148
- (typeof body.providerFields !== "object" || body.providerFields === null || Array.isArray(body.providerFields))
149
- ) {
150
- return "providerFields must be an object";
151
- }
152
-
153
- if (body.providerEventId !== undefined && (typeof body.providerEventId !== "string" || !body.providerEventId.trim())) return "providerEventId must be a non-empty string";
154
- if (body.occurredAt !== undefined && typeof body.occurredAt !== "string") return "occurredAt must be a string";
155
- if (body.amountMinor !== undefined && (typeof body.amountMinor !== "number" || !Number.isSafeInteger(body.amountMinor) || body.amountMinor < 0)) return "amountMinor must be a non-negative safe integer";
156
- return {
157
- tenantId: body.tenantId,
158
- externalOrderId: body.externalOrderId,
159
- env: body.env,
160
- status: body.status as OrderStatus,
161
- currency: body.currency,
162
- total: body.total,
163
- subtotal: body.subtotal as number | undefined,
164
- lineItems,
165
- rawCustomerId: body.rawCustomerId as string | undefined,
166
- createdAt: body.createdAt as string | undefined,
167
- providerEventId: body.providerEventId as string | undefined,
168
- occurredAt: body.occurredAt as string | undefined,
169
- amountMinor: body.amountMinor as number | undefined,
170
- providerFields: body.providerFields as Record<string, unknown> | undefined,
171
- };
172
- }
173
-
174
- export async function POST(context: APIContext): Promise<Response> {
175
- const signature = context.request.headers.get(SIGNATURE_HEADER);
176
- if (!signature) return json({ error: "Missing signature" }, 401);
177
-
178
- const secret = await readEnv("FOXY_ORDER_FORWARD_SECRET");
179
- if (!secret) return json({ error: "order-forward secret not configured" }, 503);
180
-
181
- // Raw body: the HMAC is computed over the exact bytes, same as the Gitea scheme.
182
- const rawBody = await context.request.text();
183
- const valid = await verifyOrderForwardSignature(secret, rawBody, signature);
184
- if (!valid) return json({ error: "Invalid signature" }, 401);
185
-
186
- let body: OrderForwardBody;
187
- try {
188
- body = JSON.parse(rawBody) as OrderForwardBody;
189
- } catch {
190
- return json({ error: "invalid JSON body" }, 400);
191
- }
192
-
193
- const input = parseOrderForwardInput(body);
194
- if (typeof input === "string") return json({ error: input }, 400);
195
-
196
- const d1 = await readD1("STOREFRONT_APPS_DB");
197
- if (!d1) return json({ error: "orders store not configured" }, 503);
198
-
199
- const db = fromD1(d1);
200
- const result = await receiveForwardedOrder(db, input);
201
-
202
-
203
- // This local-only outbox runs only after raw-body HMAC verification and the
204
- // trusted order receiver. Its failure is fail-soft: commerce still ACKs.
205
- try {
206
- const measurement = await new D1VerifiedProviderMeasurementStore(db).record(input);
207
- if (measurement.outcome === "recorded") {
208
- logConversionDiag({ tenantId: input.tenantId, env: input.env, regulatedMeasurement: "recorded" });
209
- }
210
- } catch (cause) {
211
- logConversionDiag({
212
- tenantId: input.tenantId,
213
- env: input.env,
214
- regulatedMeasurement: "outbox-unavailable",
215
- error: cause instanceof Error ? cause.name : "unknown",
216
- });
217
- }
218
-
219
- // Join the verified order to its browser handoff correlation and produce the
220
- // authoritative server conversion event(s) — ext-commerce-05 destinations fan
221
- // these out. The join owns idempotency + single-use replay defense; a failure
222
- // here must never fail the order ACK, so it is fail-soft (logged, swallowed).
223
- try {
224
- const conversion = await joinForwardedOrderConversion(db, input, result);
225
- if (conversion.events.length > 0) {
226
- // Coarse, non-PII diagnostic: attribution outcome + conversion count only —
227
- // never the order id, correlation token, or any customer field.
228
- logConversionDiag({
229
- tenantId: input.tenantId,
230
- env: input.env,
231
- conversions: conversion.events.length,
232
- attributed: conversion.attribution.attributed,
233
- reason: conversion.attribution.attributed ? undefined : conversion.attribution.reason,
234
- });
235
- }
236
- } catch (cause) {
237
- logConversionDiag({
238
- tenantId: input.tenantId,
239
- env: input.env,
240
- error: cause instanceof Error ? cause.name : "unknown",
241
- });
242
- }
243
-
244
- // D11: HIGH-VOLUME machine action (the tot-foxycart forwarder, HMAC-authed) → analytics
245
- // only, never timelined. Coarse dimensions only; customer/order ids never stored.
246
- recordActivity({
247
- action: "app.order.forwarded",
248
- actor: { kind: "agent", id: "order-forwarder" },
249
- source: "server",
250
- outcome: { status: "succeeded" },
251
- scope: { tenantId: input.tenantId },
252
- payload: { args: { status: result.order.status, env: input.env, currency: input.currency } },
253
- });
254
- return json({ ok: true, status: result.order.status, emitted: result.emitted }, 200);
255
- }
@@ -1,87 +0,0 @@
1
- /**
2
- * `POST /api/apps/oauth/token` — the Storefront App Gateway's OAuth2
3
- * client-credentials grant (PrivateApps epic, D2 Chunk B; feature-brief.md §3
4
- * "Scoped credentials"). A private app exchanges its `(client_id,
5
- * client_secret)` for a short-lived RS256 bearer JWT it then presents to the
6
- * D3 scoped APIs. Standard RFC 6749 §4.4 form-encoded request/response — any
7
- * language's off-the-shelf OAuth2 client works unmodified (feature-brief.md's
8
- * "never a ToT SDK requirement").
9
- *
10
- * `tenant` and `env` are NEVER taken from the request body: `tenant` is this
11
- * host's resolved `appDomain` (`context.locals.tenant`, set by
12
- * `src/middleware` from the request Host) and `env` is this deploy's own
13
- * `NODE_ENV` — both bound server-side so a leaked credential can't be
14
- * replayed against a different tenant or environment than the one it was
15
- * issued for. This endpoint's absence from `contract/openapi.yaml` (the D1
16
- * data-API file) is intentional — see that file's `securitySchemes.appJwt`
17
- * note.
18
- */
19
- import type { APIContext } from "astro";
20
- import { AppRegistryService } from "@/lib/apps/registryService.js";
21
- import { D1CredentialStore } from "@/lib/apps/credentials.js";
22
- import { AppTokenIssuer, TokenError, type TokenErrorCode } from "@/lib/apps/tokenIssuer.js";
23
- import { DEFAULT_APP_GATEWAY_KID, APP_GATEWAY_KID_ENV } from "@/lib/apps/gatewayKeys.js";
24
- import { fromD1 } from "@/lib/d1/catalog.js";
25
- import { readD1, readEnv } from "@/lib/env";
26
-
27
- export const prerender = false;
28
-
29
- /** No-store JSON responder — a token (or its error) must never be edge-cached. */
30
- function json(body: unknown, status = 200): Response {
31
- return new Response(JSON.stringify(body), {
32
- status,
33
- headers: { "content-type": "application/json", "cache-control": "no-store" },
34
- });
35
- }
36
-
37
- const STATUS_BY_CODE: Record<TokenErrorCode, number> = {
38
- invalid_client: 401,
39
- invalid_grant: 403,
40
- temporarily_unavailable: 503,
41
- };
42
-
43
- export async function POST(context: APIContext): Promise<Response> {
44
- const contentType = context.request.headers.get("content-type") ?? "";
45
- if (!contentType.includes("application/x-www-form-urlencoded")) {
46
- return json({ error: "invalid_request", error_description: "expected application/x-www-form-urlencoded" }, 400);
47
- }
48
-
49
- const form = new URLSearchParams(await context.request.text());
50
- if (form.get("grant_type") !== "client_credentials") {
51
- return json({ error: "unsupported_grant_type" }, 400);
52
- }
53
- const clientId = form.get("client_id");
54
- const clientSecret = form.get("client_secret");
55
- if (!clientId || !clientSecret) {
56
- return json({ error: "invalid_request", error_description: "client_id and client_secret are required" }, 400);
57
- }
58
-
59
- const d1 = await readD1("STOREFRONT_APPS_DB");
60
- if (!d1) return json({ error: "temporarily_unavailable" }, 503);
61
- const db = fromD1(d1);
62
-
63
- const activeKid = (await readEnv(APP_GATEWAY_KID_ENV)) ?? DEFAULT_APP_GATEWAY_KID;
64
- const credentials = new D1CredentialStore(db, activeKid);
65
- const registry = new AppRegistryService(db, credentials);
66
- const issuer = new AppTokenIssuer(registry, credentials);
67
-
68
- try {
69
- const token = await issuer.issueToken({
70
- clientId,
71
- secret: clientSecret,
72
- tenant: context.locals.tenant.appDomain,
73
- env: (await readEnv("NODE_ENV")) ?? "development",
74
- });
75
- return json({
76
- access_token: token.accessToken,
77
- token_type: token.tokenType,
78
- expires_in: token.expiresIn,
79
- scope: token.scope,
80
- });
81
- } catch (err) {
82
- if (err instanceof TokenError) {
83
- return json({ error: err.code }, STATUS_BY_CODE[err.code]);
84
- }
85
- throw err;
86
- }
87
- }
@@ -1,162 +0,0 @@
1
- /**
2
- * `POST /api/apps/v1/attribution` — the D6 app-owned attribution write
3
- * (PrivateApps epic; `docs/private-apps/contract/openapi.yaml`'s
4
- * `writeAttribution`). Requires scope `attribution:write`.
5
- *
6
- * Real idempotency, unlike D4's replay endpoint: the `Idempotency-Key`
7
- * header is required, and a reused key with a DIFFERENT request body is
8
- * rejected with 409 (`idempotency.ts`'s `checkIdempotency`, backed by the
9
- * `idempotency_keys` table). The body is validated strictly — the contract
10
- * is `additionalProperties: false`, so an unknown field 400s rather than
11
- * being silently ignored. `orderId` ownership and the `attributionId`
12
- * repeat-write rule are enforced in `attributionService.ts`.
13
- */
14
- import type { APIContext } from "astro";
15
- import { authorizeAppRequest } from "@/lib/apps/apiAuth.js";
16
- import { json, withRequestId } from "@/lib/apps/apiRoute.js";
17
- import { checkIdempotency, computeBodyHash, recordIdempotency } from "@/lib/apps/orders/idempotency.js";
18
- import { AppAttributionService, AttributionError } from "@/lib/apps/orders/attributionService.js";
19
- import { fromD1 } from "@/lib/d1/catalog.js";
20
- import { readD1 } from "@/lib/env";
21
- import { recordActivity } from "@/lib/activity/recordActivity";
22
-
23
- export const prerender = false;
24
-
25
- const COMMISSION_KEYS = new Set(["amount", "currency"]);
26
- const BODY_KEYS = new Set(["attributionId", "orderId", "campaign", "referralCode", "customerHash", "commission"]);
27
-
28
- interface AttributionBody {
29
- attributionId?: unknown;
30
- orderId?: unknown;
31
- campaign?: unknown;
32
- referralCode?: unknown;
33
- customerHash?: unknown;
34
- commission?: unknown;
35
- }
36
-
37
- interface ParsedAttributionBody {
38
- attributionId: string;
39
- orderId: string;
40
- campaign?: string;
41
- referralCode?: string;
42
- customerHash?: string;
43
- commission?: { amount?: number; currency?: string };
44
- }
45
-
46
- /** Validate + narrow the request body, or return a 400 message. Rejects unknown fields (contract: additionalProperties: false). */
47
- function parseAttributionBody(body: AttributionBody): ParsedAttributionBody | string {
48
- for (const key of Object.keys(body)) {
49
- if (!BODY_KEYS.has(key)) return `unknown field: ${key}`;
50
- }
51
- if (typeof body.attributionId !== "string" || !body.attributionId.trim()) return "attributionId is required";
52
- if (typeof body.orderId !== "string" || !body.orderId.trim()) return "orderId is required";
53
- if (body.campaign !== undefined && typeof body.campaign !== "string") return "campaign, when given, must be a string";
54
- if (body.referralCode !== undefined && typeof body.referralCode !== "string") {
55
- return "referralCode, when given, must be a string";
56
- }
57
- if (body.customerHash !== undefined && typeof body.customerHash !== "string") {
58
- return "customerHash, when given, must be a string";
59
- }
60
-
61
- let commission: { amount?: number; currency?: string } | undefined;
62
- if (body.commission !== undefined) {
63
- if (body.commission === null || typeof body.commission !== "object" || Array.isArray(body.commission)) {
64
- return "commission, when given, must be an object";
65
- }
66
- const c = body.commission as Record<string, unknown>;
67
- for (const key of Object.keys(c)) {
68
- if (!COMMISSION_KEYS.has(key)) return `unknown field: commission.${key}`;
69
- }
70
- if (c.amount !== undefined && typeof c.amount !== "number") return "commission.amount, when given, must be a number";
71
- if (c.currency !== undefined && typeof c.currency !== "string") return "commission.currency, when given, must be a string";
72
- commission = { ...(c.amount !== undefined ? { amount: c.amount as number } : {}), ...(c.currency !== undefined ? { currency: c.currency as string } : {}) };
73
- }
74
-
75
- return {
76
- attributionId: body.attributionId,
77
- orderId: body.orderId,
78
- ...(body.campaign !== undefined ? { campaign: body.campaign as string } : {}),
79
- ...(body.referralCode !== undefined ? { referralCode: body.referralCode as string } : {}),
80
- ...(body.customerHash !== undefined ? { customerHash: body.customerHash as string } : {}),
81
- ...(commission !== undefined ? { commission } : {}),
82
- };
83
- }
84
-
85
- export async function POST(context: APIContext): Promise<Response> {
86
- const auth = await authorizeAppRequest(context, "attribution:write");
87
- if (!auth.ok) return withRequestId(auth.response, context);
88
-
89
- const idempotencyKey = context.request.headers.get("idempotency-key");
90
- if (!idempotencyKey) {
91
- return withRequestId(json({ error: "Idempotency-Key header is required", code: "bad_request" }, 400), context);
92
- }
93
-
94
- let rawBody: AttributionBody;
95
- try {
96
- rawBody = (await context.request.json()) as AttributionBody;
97
- } catch {
98
- return withRequestId(json({ error: "invalid JSON body", code: "bad_request" }, 400), context);
99
- }
100
-
101
- const parsed = parseAttributionBody(rawBody);
102
- if (typeof parsed === "string") {
103
- return withRequestId(json({ error: parsed, code: "bad_request" }, 400), context);
104
- }
105
-
106
- const d1 = await readD1("STOREFRONT_APPS_DB");
107
- if (!d1) return withRequestId(json({ error: "attribution store not configured", code: "unconfigured" }, 503), context);
108
- const db = fromD1(d1);
109
-
110
- const bodyHash = await computeBodyHash(rawBody);
111
- const idem = await checkIdempotency(db, auth.install.installId, idempotencyKey, bodyHash);
112
- if (idem.status === "conflict") {
113
- return withRequestId(json({ error: "Idempotency-Key reused with a different request body", code: "idempotency_conflict" }, 409), context);
114
- }
115
- if (idem.status === "replay") {
116
- return withRequestId(json(idem.responseBody, 201), context);
117
- }
118
-
119
- const service = new AppAttributionService(db);
120
- let record;
121
- try {
122
- record = await service.record({
123
- installId: auth.install.installId,
124
- tenantId: auth.install.tenantId,
125
- attributionId: parsed.attributionId,
126
- orderId: parsed.orderId,
127
- campaign: parsed.campaign,
128
- referralCode: parsed.referralCode,
129
- commission: parsed.commission,
130
- customerHash: parsed.customerHash,
131
- });
132
- } catch (err) {
133
- if (err instanceof AttributionError) {
134
- recordActivity({
135
- action: "app.attribution.recorded",
136
- actor: { kind: "agent", id: auth.install.installId },
137
- source: "server",
138
- outcome: { status: "failed", errorClass: err.code },
139
- scope: { tenantId: auth.install.tenantId },
140
- ...(parsed.campaign ? { payload: { args: { campaign: parsed.campaign } } } : {}),
141
- });
142
- const status =
143
- err.code === "attribution_forbidden" ? 403 : err.code === "attribution_conflict" ? 409 : 400;
144
- return withRequestId(json({ error: err.message, code: err.code }, status), context);
145
- }
146
- throw err;
147
- }
148
-
149
- await recordIdempotency(db, auth.install.installId, idempotencyKey, bodyHash, record);
150
- // D11: HIGH-VOLUME machine action (an installed private app / external bot, app-JWT
151
- // authed) → analytics only, never timelined. installId is an opaque, per-install handle
152
- // (non-PII) — a good stable actor id. referralCode/customerHash are never stored.
153
- recordActivity({
154
- action: "app.attribution.recorded",
155
- actor: { kind: "agent", id: auth.install.installId },
156
- source: "server",
157
- outcome: { status: "succeeded" },
158
- scope: { tenantId: auth.install.tenantId },
159
- ...(parsed.campaign ? { payload: { args: { campaign: parsed.campaign } } } : {}),
160
- });
161
- return withRequestId(json(record, 201), context);
162
- }
@@ -1,39 +0,0 @@
1
- /**
2
- * `GET /api/apps/v1/catalog/products/{handle}` — the D3 scoped single-product
3
- * read (PrivateApps epic; `docs/private-apps/contract/openapi.yaml`'s
4
- * `getCatalogProduct`). Requires scope `catalog:read`.
5
- *
6
- * `getProductByHandle` resolves drafts too (see its own doc comment) — this
7
- * route is the caller that gates visibility: a missing handle OR a
8
- * non-active product are BOTH reported as a plain 404, so an app can't use
9
- * this endpoint to probe for unpublished products.
10
- */
11
- import type { APIContext } from "astro";
12
- import { authorizeAppRequest } from "@/lib/apps/apiAuth.js";
13
- import { json, withRequestId } from "@/lib/apps/apiRoute.js";
14
- import { toContractProduct } from "@/lib/apps/catalogMapper.js";
15
- import { getProductByHandle, fromD1 } from "@/lib/d1/catalog.js";
16
- import { readD1 } from "@/lib/env";
17
-
18
- export const prerender = false;
19
-
20
- export async function GET(context: APIContext): Promise<Response> {
21
- const auth = await authorizeAppRequest(context, "catalog:read");
22
- if (!auth.ok) return withRequestId(auth.response, context);
23
-
24
- const handle = context.params.handle;
25
- if (!handle) {
26
- return withRequestId(json({ error: "handle is required", code: "not_found" }, 404), context);
27
- }
28
-
29
- const d1 = await readD1("CATALOG_DB");
30
- if (!d1) return withRequestId(json({ error: "catalog not configured", code: "unconfigured" }, 503), context);
31
- const db = fromD1(d1);
32
-
33
- const product = await getProductByHandle(db, handle);
34
- if (!product || product.status !== "active") {
35
- return withRequestId(json({ error: "Product not found.", code: "not_found" }, 404), context);
36
- }
37
-
38
- return withRequestId(json(toContractProduct(product)), context);
39
- }
@@ -1,49 +0,0 @@
1
- /**
2
- * `GET /api/apps/v1/catalog/products` — the D3 scoped catalog list
3
- * (PrivateApps epic; `docs/private-apps/contract/openapi.yaml`'s
4
- * `listCatalogProducts`). Requires scope `catalog:read`. Reads the same D1
5
- * edge replica the storefront itself reads (`../../../../../lib/d1/catalog.js`),
6
- * which already scopes to `status = 'active'` — published products only —
7
- * and projects each row through `catalogMapper.js` so nothing beyond the
8
- * contract's `Product` shape ever leaves this route.
9
- */
10
- import type { APIContext } from "astro";
11
- import { authorizeAppRequest } from "@/lib/apps/apiAuth.js";
12
- import { json, withRequestId } from "@/lib/apps/apiRoute.js";
13
- import { toContractProduct } from "@/lib/apps/catalogMapper.js";
14
- import { listProducts, fromD1 } from "@/lib/d1/catalog.js";
15
- import { readD1 } from "@/lib/env";
16
-
17
- export const prerender = false;
18
-
19
- function parsePage(raw: string | null, fallback: number): number {
20
- const n = raw != null ? Number(raw) : NaN;
21
- return Number.isFinite(n) && n >= 1 ? Math.floor(n) : fallback;
22
- }
23
-
24
- export async function GET(context: APIContext): Promise<Response> {
25
- const auth = await authorizeAppRequest(context, "catalog:read");
26
- if (!auth.ok) return withRequestId(auth.response, context);
27
-
28
- const d1 = await readD1("CATALOG_DB");
29
- if (!d1) return withRequestId(json({ error: "catalog not configured", code: "unconfigured" }, 503), context);
30
- const db = fromD1(d1);
31
-
32
- const url = new URL(context.request.url);
33
- const result = await listProducts(db, {
34
- collection: url.searchParams.get("collection") ?? undefined,
35
- q: url.searchParams.get("q") ?? undefined,
36
- page: parsePage(url.searchParams.get("page"), 1),
37
- pageSize: Math.min(100, Math.max(1, parsePage(url.searchParams.get("pageSize"), 24))),
38
- });
39
-
40
- return withRequestId(
41
- json({
42
- products: result.products.map(toContractProduct),
43
- page: result.page,
44
- pageSize: result.pageSize,
45
- total: result.total,
46
- }),
47
- context,
48
- );
49
- }