@tokenoftrust/storefront-runner 1.3.4-rc.3 → 1.4.0
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/astro.config.mjs +15 -0
- package/apps/storefront/dev-plugins/dev-publish.mjs +55 -0
- package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +86 -7
- package/apps/storefront/drizzle.config.apps.ts +13 -0
- package/apps/storefront/env.d.ts +10 -0
- package/apps/storefront/migrations/README.md +13 -7
- package/apps/storefront/migrations-apps/0000_fast_millenium_guard.sql +129 -0
- package/apps/storefront/migrations-apps/meta/0000_snapshot.json +843 -0
- package/apps/storefront/migrations-apps/meta/_journal.json +13 -0
- package/apps/storefront/package.json +13 -1
- package/apps/storefront/perf/README.md +64 -0
- package/apps/storefront/perf/assert-budgets.ts +159 -0
- package/apps/storefront/playwright.config.ts +23 -0
- package/apps/storefront/public/js/dashboard-apps.js +173 -0
- package/apps/storefront/public/shared/commerce-marketing.css +221 -0
- package/apps/storefront/src/components/CollectionCard.astro +1 -0
- package/apps/storefront/src/components/ProductCard.astro +1 -0
- package/apps/storefront/src/components/admin/AdminPublishTab.astro +1959 -0
- package/apps/storefront/src/components/apps/AppWidgetFrame.astro +30 -0
- package/apps/storefront/src/components/chrome/NavDropdown.astro +6 -3
- package/apps/storefront/src/components/chrome/SiteFooter.astro +10 -0
- package/apps/storefront/src/components/chrome/SiteHeader.astro +10 -0
- package/apps/storefront/src/components/commerce/RatingStars.astro +3 -2
- package/apps/storefront/src/components/content/Callout.astro +75 -0
- package/apps/storefront/src/components/content/NeedsReviewCallout.astro +66 -0
- package/apps/storefront/src/components/content/ProseSections.astro +121 -0
- package/apps/storefront/src/components/content/ProseToc.astro +34 -0
- package/apps/storefront/src/components/content/RichText.astro +44 -0
- package/apps/storefront/src/components/content/TrustStrip.astro +46 -0
- package/apps/storefront/src/components/home/Hero.astro +14 -0
- package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +295 -0
- package/apps/storefront/src/components/islands/ImageGallery.tsx +42 -21
- package/apps/storefront/src/components/islands/VariantSelector.tsx +62 -12
- package/apps/storefront/src/components/plp/FacetSidebar.astro +2 -2
- package/apps/storefront/src/components/subscription/ManageSubscriptionEntry.astro +1 -0
- package/apps/storefront/src/config/compliance/rulesets.ts +79 -0
- package/apps/storefront/src/config/storeName.ts +34 -0
- package/apps/storefront/src/layouts/Layout.astro +97 -23
- package/apps/storefront/src/lib/analytics/budgets.json +69 -0
- package/apps/storefront/src/lib/analytics/lighthouseReport.ts +109 -0
- package/apps/storefront/src/lib/analytics/perfBudgets.ts +452 -0
- package/apps/storefront/src/lib/analytics/rumAlert.ts +179 -0
- package/apps/storefront/src/lib/analytics/webVitals.ts +269 -0
- package/apps/storefront/src/lib/apps/adminService.ts +106 -0
- package/apps/storefront/src/lib/apps/adminSession.ts +205 -0
- package/apps/storefront/src/lib/apps/apiAuth.ts +91 -0
- package/apps/storefront/src/lib/apps/apiRoute.ts +35 -0
- package/apps/storefront/src/lib/apps/catalogMapper.ts +39 -0
- package/apps/storefront/src/lib/apps/credentials.ts +153 -0
- package/apps/storefront/src/lib/apps/gatewayKeys.ts +156 -0
- package/apps/storefront/src/lib/apps/healthAggregate.ts +66 -0
- package/apps/storefront/src/lib/apps/orders/attributionService.ts +206 -0
- package/apps/storefront/src/lib/apps/orders/customerHash.ts +28 -0
- package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +203 -0
- package/apps/storefront/src/lib/apps/orders/idempotency.ts +100 -0
- package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +117 -0
- package/apps/storefront/src/lib/apps/orders/orderMapper.ts +91 -0
- package/apps/storefront/src/lib/apps/orders/ordersStore.ts +181 -0
- package/apps/storefront/src/lib/apps/registryService.ts +579 -0
- package/apps/storefront/src/lib/apps/scopes.ts +79 -0
- package/apps/storefront/src/lib/apps/tokenIssuer.ts +121 -0
- package/apps/storefront/src/lib/apps/tokenVerifier.ts +148 -0
- package/apps/storefront/src/lib/apps/widgets/eligibility.ts +18 -0
- package/apps/storefront/src/lib/apps/widgets/frameProps.ts +52 -0
- package/apps/storefront/src/lib/apps/widgets/launchToken.ts +84 -0
- package/apps/storefront/src/lib/apps/widgets/placements.ts +57 -0
- package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +111 -0
- package/apps/storefront/src/lib/auth/adminEntry.ts +119 -0
- package/apps/storefront/src/lib/auth/identityToken.ts +21 -2
- package/apps/storefront/src/lib/auth/loginGate.ts +102 -18
- package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +215 -0
- package/apps/storefront/src/lib/auth/route.ts +16 -1
- package/apps/storefront/src/lib/auth/session.ts +8 -0
- package/apps/storefront/src/lib/auth/stepUpChallenge.ts +107 -0
- package/apps/storefront/src/lib/auth/totAccessClient.ts +208 -0
- package/apps/storefront/src/lib/blog/provider.ts +39 -0
- package/apps/storefront/src/lib/blog/types.ts +26 -0
- package/apps/storefront/src/lib/checkoutCommerce.ts +39 -1
- package/apps/storefront/src/lib/chrome/model.ts +11 -0
- package/apps/storefront/src/lib/compliance/enforcement.ts +95 -0
- package/apps/storefront/src/lib/content/callout.ts +78 -0
- package/apps/storefront/src/lib/content/index.ts +27 -0
- package/apps/storefront/src/lib/content/needsReview.ts +58 -0
- package/apps/storefront/src/lib/content/prose.ts +186 -0
- package/apps/storefront/src/lib/content/richtext.ts +76 -0
- package/apps/storefront/src/lib/content/trustStrip.ts +74 -0
- package/apps/storefront/src/lib/content-edit/client.ts +70 -14
- package/apps/storefront/src/lib/d1/catalog.ts +12 -0
- package/apps/storefront/src/lib/d1/schema-apps.ts +213 -0
- package/apps/storefront/src/lib/dev/apiBase.ts +8 -2
- package/apps/storefront/src/lib/dev/cliSignInCode.ts +65 -115
- package/apps/storefront/src/lib/dev/cockpitStore.ts +65 -0
- package/apps/storefront/src/lib/dev/obstacleStore.ts +214 -0
- package/apps/storefront/src/lib/dev/previewStatus.ts +112 -0
- package/apps/storefront/src/lib/dev/rendezvousBroker.ts +241 -0
- package/apps/storefront/src/lib/dev/subjectReissue.ts +67 -0
- package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +10 -10
- package/apps/storefront/src/lib/env.ts +12 -0
- package/apps/storefront/src/lib/jsonld.ts +12 -17
- package/apps/storefront/src/lib/membership/eligibility.ts +37 -0
- package/apps/storefront/src/lib/monitoring/manifest.ts +302 -0
- package/apps/storefront/src/lib/privacy/emailHint.ts +13 -5
- package/apps/storefront/src/lib/publish/apex-readiness.ts +337 -0
- package/apps/storefront/src/lib/publish/dispatchHealth.ts +269 -0
- package/apps/storefront/src/lib/publish/domainState.ts +351 -0
- package/apps/storefront/src/lib/publish/shipWorkspace.ts +362 -0
- package/apps/storefront/src/lib/rawChrome.ts +34 -3
- package/apps/storefront/src/lib/storyblok/content-model.ts +34 -2
- package/apps/storefront/src/lib/storyblok/provider.ts +11 -4
- package/apps/storefront/src/lib/subscription/model.ts +114 -0
- package/apps/storefront/src/lib/tot/ToTClient.ts +3 -3
- package/apps/storefront/src/lib/tot/query.ts +32 -0
- package/apps/storefront/src/lib/webhooks/cloudflareQueueDispatcher.ts +82 -0
- package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +245 -0
- package/apps/storefront/src/lib/webhooks/deliveryMapper.ts +34 -0
- package/apps/storefront/src/lib/webhooks/deliveryStore.ts +668 -0
- package/apps/storefront/src/lib/webhooks/dispatcher.ts +168 -0
- package/apps/storefront/src/lib/webhooks/emit.ts +167 -0
- package/apps/storefront/src/lib/webhooks/endpointGuard.ts +135 -0
- package/apps/storefront/src/lib/webhooks/events.ts +98 -0
- package/apps/storefront/src/lib/webhooks/getDispatcher.ts +49 -0
- package/apps/storefront/src/lib/webhooks/signing.ts +29 -0
- package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +146 -0
- package/apps/storefront/src/middleware/index.ts +28 -13
- package/apps/storefront/src/pages/404.astro +21 -9
- package/apps/storefront/src/pages/[...slug].astro +36 -2
- package/apps/storefront/src/pages/admin.astro +29 -3
- package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +60 -0
- package/apps/storefront/src/pages/api/apps/admin/health.ts +44 -0
- package/apps/storefront/src/pages/api/apps/admin/install.ts +100 -0
- package/apps/storefront/src/pages/api/apps/admin/list.ts +26 -0
- package/apps/storefront/src/pages/api/apps/admin/resume.ts +56 -0
- package/apps/storefront/src/pages/api/apps/admin/suspend.ts +57 -0
- package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +74 -0
- package/apps/storefront/src/pages/api/apps/admin/update.ts +103 -0
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +59 -0
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries.ts +51 -0
- package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +172 -0
- package/apps/storefront/src/pages/api/apps/oauth/token.ts +87 -0
- package/apps/storefront/src/pages/api/apps/v1/attribution.ts +142 -0
- package/apps/storefront/src/pages/api/apps/v1/catalog/products/[handle].ts +39 -0
- package/apps/storefront/src/pages/api/apps/v1/catalog/products.ts +49 -0
- package/apps/storefront/src/pages/api/apps/v1/health.ts +32 -0
- package/apps/storefront/src/pages/api/apps/v1/inventory.ts +59 -0
- package/apps/storefront/src/pages/api/apps/v1/orders/[id].ts +48 -0
- package/apps/storefront/src/pages/api/apps/v1/orders.ts +73 -0
- package/apps/storefront/src/pages/api/apps/v1/reports.ts +21 -0
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +62 -0
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries.ts +47 -0
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +48 -1
- package/apps/storefront/src/pages/api/auth/step-up-send.ts +57 -0
- package/apps/storefront/src/pages/api/auth/step-up-verify.ts +129 -0
- package/apps/storefront/src/pages/api/auth/verify.ts +23 -0
- package/apps/storefront/src/pages/api/compliance/preflight.ts +206 -0
- package/apps/storefront/src/pages/api/rum/vitals.ts +54 -0
- package/apps/storefront/src/pages/api/test/dev-session.ts +133 -0
- package/apps/storefront/src/pages/auth/login.astro +197 -41
- package/apps/storefront/src/pages/auth/magic.astro +75 -43
- package/apps/storefront/src/pages/blog/[slug].astro +107 -0
- package/apps/storefront/src/pages/blog/index.astro +98 -0
- package/apps/storefront/src/pages/capabilities.astro +8 -0
- package/apps/storefront/src/pages/cockpit.astro +496 -74
- package/apps/storefront/src/pages/collections/[handle].astro +8 -0
- package/apps/storefront/src/pages/collections/index.astro +10 -2
- package/apps/storefront/src/pages/dashboard/[appDomain]/apps/index.astro +119 -0
- package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +5 -0
- package/apps/storefront/src/pages/index.astro +57 -0
- package/apps/storefront/src/pages/llms.txt.ts +31 -10
- package/apps/storefront/src/pages/products/[handle].astro +100 -9
- package/apps/storefront/src/pages/sitemap.xml.ts +21 -4
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +198 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +7 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +7 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -0
- package/apps/storefront/src/pages/style-guide/index.astro +10 -0
- package/apps/storefront/src/styles/fonts.css +54 -0
- package/apps/storefront/src/styles/global.css +22 -2
- package/apps/storefront/src/themes/schema.ts +3 -25
- package/apps/storefront/tsconfig.json +1 -1
- package/apps/storefront/vitest.config.ts +4 -1
- package/package.json +1 -1
- package/packages/public-runtime/src/candidate-index.ts +311 -0
- package/packages/public-runtime/src/checkout.ts +94 -2
- package/packages/public-runtime/src/compliance/evaluate.ts +265 -0
- package/packages/public-runtime/src/compliance/evidence-signals.ts +81 -0
- package/packages/public-runtime/src/compliance/index.ts +22 -0
- package/packages/public-runtime/src/compliance/pact-report.ts +94 -0
- package/packages/public-runtime/src/compliance/profile.ts +198 -0
- package/packages/public-runtime/src/compliance/ruleset.ts +117 -0
- package/packages/public-runtime/src/compliance/verification.ts +81 -0
- package/packages/public-runtime/src/csp.ts +27 -3
- package/packages/public-runtime/src/customization-reconcile.ts +35 -0
- package/packages/public-runtime/src/customization-runtime.ts +8 -0
- package/packages/public-runtime/src/customization-versioning.ts +17 -0
- package/packages/public-runtime/src/extension-contract.ts +2 -1
- package/packages/public-runtime/src/hash.ts +25 -0
- package/packages/public-runtime/src/index.ts +6 -0
- package/packages/public-runtime/src/membership.ts +353 -0
- package/packages/public-runtime/src/product.ts +24 -2
- package/packages/public-runtime/src/review-trust-proof.ts +194 -0
- package/packages/public-runtime/src/tenant-assets.ts +40 -5
- package/packages/public-runtime/src/tenant.ts +236 -0
- package/packages/public-runtime/src/widget-postmessage.ts +205 -0
- package/scripts/dev/publish.mjs +158 -0
- package/scripts/dev/transient-files.mjs +2 -1
- package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
- package/tenants/home/public/pages/storefront.css +23 -0
- package/apps/storefront/src/lib/dev/hostedCockpit.ts +0 -169
|
@@ -0,0 +1,172 @@
|
|
|
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 type { OrderLineItem, OrderStatus } from "@/lib/apps/orders/ordersStore.js";
|
|
27
|
+
|
|
28
|
+
export const prerender = false;
|
|
29
|
+
|
|
30
|
+
const SIGNATURE_HEADER = "x-order-forward-signature";
|
|
31
|
+
const ORDER_STATUSES: readonly OrderStatus[] = ["created", "fulfilled", "cancelled"];
|
|
32
|
+
|
|
33
|
+
function json(body: unknown, status: number): Response {
|
|
34
|
+
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// --- HMAC verification -----------------------------------------------------
|
|
38
|
+
// Same shape as `@tot/private-controlplane`'s `verifyGiteaSignature` (hex
|
|
39
|
+
// HMAC-SHA256 over the raw body, verified via `crypto.subtle.verify` so a
|
|
40
|
+
// short/malformed signature can't be timed) — kept local rather than shared
|
|
41
|
+
// since that package's scheme is Gitea's, not this contract's; this is the
|
|
42
|
+
// one caller of it today.
|
|
43
|
+
const textEncoder = new TextEncoder();
|
|
44
|
+
|
|
45
|
+
function hexToBytes(hex: string): Uint8Array | null {
|
|
46
|
+
if (hex.length === 0 || hex.length % 2 !== 0 || /[^0-9a-fA-F]/.test(hex)) return null;
|
|
47
|
+
const out = new Uint8Array(hex.length / 2);
|
|
48
|
+
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function verifyOrderForwardSignature(
|
|
53
|
+
secret: string,
|
|
54
|
+
rawBody: string,
|
|
55
|
+
signatureHex: string | null,
|
|
56
|
+
): Promise<boolean> {
|
|
57
|
+
if (!signatureHex) return false;
|
|
58
|
+
const sig = hexToBytes(signatureHex.trim());
|
|
59
|
+
if (!sig) return false;
|
|
60
|
+
const key = await crypto.subtle.importKey(
|
|
61
|
+
"raw",
|
|
62
|
+
textEncoder.encode(secret) as unknown as BufferSource,
|
|
63
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
64
|
+
false,
|
|
65
|
+
["verify"],
|
|
66
|
+
);
|
|
67
|
+
return crypto.subtle.verify(
|
|
68
|
+
"HMAC",
|
|
69
|
+
key,
|
|
70
|
+
sig as unknown as BufferSource,
|
|
71
|
+
textEncoder.encode(rawBody) as unknown as BufferSource,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// --- Payload validation ------------------------------------------------------
|
|
76
|
+
interface OrderForwardBody {
|
|
77
|
+
tenantId?: unknown;
|
|
78
|
+
externalOrderId?: unknown;
|
|
79
|
+
env?: unknown;
|
|
80
|
+
status?: unknown;
|
|
81
|
+
currency?: unknown;
|
|
82
|
+
total?: unknown;
|
|
83
|
+
subtotal?: unknown;
|
|
84
|
+
lineItems?: unknown;
|
|
85
|
+
rawCustomerId?: unknown;
|
|
86
|
+
createdAt?: unknown;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseLineItems(value: unknown): OrderLineItem[] | string {
|
|
90
|
+
if (!Array.isArray(value) || value.length === 0) return "lineItems must be a non-empty array";
|
|
91
|
+
const items: OrderLineItem[] = [];
|
|
92
|
+
for (const raw of value as unknown[]) {
|
|
93
|
+
if (raw === null || typeof raw !== "object") return "each lineItem must be an object";
|
|
94
|
+
const item = raw as Record<string, unknown>;
|
|
95
|
+
if (typeof item.sku !== "string" || !item.sku.trim()) return "each lineItem.sku is required";
|
|
96
|
+
if (typeof item.quantity !== "number") return "each lineItem.quantity must be a number";
|
|
97
|
+
if (item.handle !== undefined && typeof item.handle !== "string") return "lineItem.handle must be a string";
|
|
98
|
+
if (item.lineTotal !== undefined && typeof item.lineTotal !== "number") {
|
|
99
|
+
return "lineItem.lineTotal must be a number";
|
|
100
|
+
}
|
|
101
|
+
items.push({
|
|
102
|
+
sku: item.sku,
|
|
103
|
+
quantity: item.quantity,
|
|
104
|
+
...(item.handle !== undefined ? { handle: item.handle as string } : {}),
|
|
105
|
+
...(item.lineTotal !== undefined ? { lineTotal: item.lineTotal as number } : {}),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return items;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Validate + narrow the request body into `OrderForwardInput`, or a 400 message. */
|
|
112
|
+
function parseOrderForwardInput(body: OrderForwardBody): OrderForwardInput | string {
|
|
113
|
+
if (typeof body.tenantId !== "string" || !body.tenantId.trim()) return "tenantId is required";
|
|
114
|
+
if (typeof body.externalOrderId !== "string" || !body.externalOrderId.trim()) {
|
|
115
|
+
return "externalOrderId is required";
|
|
116
|
+
}
|
|
117
|
+
if (typeof body.env !== "string" || !body.env.trim()) return "env is required";
|
|
118
|
+
if (typeof body.status !== "string" || !ORDER_STATUSES.includes(body.status as OrderStatus)) {
|
|
119
|
+
return `status must be one of ${ORDER_STATUSES.join(", ")}`;
|
|
120
|
+
}
|
|
121
|
+
if (typeof body.currency !== "string" || !body.currency.trim()) return "currency is required";
|
|
122
|
+
if (typeof body.total !== "number") return "total must be a number";
|
|
123
|
+
if (body.subtotal !== undefined && typeof body.subtotal !== "number") return "subtotal must be a number";
|
|
124
|
+
const lineItems = parseLineItems(body.lineItems);
|
|
125
|
+
if (typeof lineItems === "string") return lineItems;
|
|
126
|
+
if (body.rawCustomerId !== undefined && typeof body.rawCustomerId !== "string") {
|
|
127
|
+
return "rawCustomerId must be a string";
|
|
128
|
+
}
|
|
129
|
+
if (body.createdAt !== undefined && typeof body.createdAt !== "string") return "createdAt must be a string";
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
tenantId: body.tenantId,
|
|
133
|
+
externalOrderId: body.externalOrderId,
|
|
134
|
+
env: body.env,
|
|
135
|
+
status: body.status as OrderStatus,
|
|
136
|
+
currency: body.currency,
|
|
137
|
+
total: body.total,
|
|
138
|
+
subtotal: body.subtotal as number | undefined,
|
|
139
|
+
lineItems,
|
|
140
|
+
rawCustomerId: body.rawCustomerId as string | undefined,
|
|
141
|
+
createdAt: body.createdAt as string | undefined,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function POST(context: APIContext): Promise<Response> {
|
|
146
|
+
const signature = context.request.headers.get(SIGNATURE_HEADER);
|
|
147
|
+
if (!signature) return json({ error: "Missing signature" }, 401);
|
|
148
|
+
|
|
149
|
+
const secret = await readEnv("FOXY_ORDER_FORWARD_SECRET");
|
|
150
|
+
if (!secret) return json({ error: "order-forward secret not configured" }, 503);
|
|
151
|
+
|
|
152
|
+
// Raw body: the HMAC is computed over the exact bytes, same as the Gitea scheme.
|
|
153
|
+
const rawBody = await context.request.text();
|
|
154
|
+
const valid = await verifyOrderForwardSignature(secret, rawBody, signature);
|
|
155
|
+
if (!valid) return json({ error: "Invalid signature" }, 401);
|
|
156
|
+
|
|
157
|
+
let body: OrderForwardBody;
|
|
158
|
+
try {
|
|
159
|
+
body = JSON.parse(rawBody) as OrderForwardBody;
|
|
160
|
+
} catch {
|
|
161
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const input = parseOrderForwardInput(body);
|
|
165
|
+
if (typeof input === "string") return json({ error: input }, 400);
|
|
166
|
+
|
|
167
|
+
const d1 = await readD1("STOREFRONT_APPS_DB");
|
|
168
|
+
if (!d1) return json({ error: "orders store not configured" }, 503);
|
|
169
|
+
|
|
170
|
+
const result = await receiveForwardedOrder(fromD1(d1), input);
|
|
171
|
+
return json({ ok: true, status: result.order.status, emitted: result.emitted }, 200);
|
|
172
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
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
|
+
|
|
22
|
+
export const prerender = false;
|
|
23
|
+
|
|
24
|
+
const COMMISSION_KEYS = new Set(["amount", "currency"]);
|
|
25
|
+
const BODY_KEYS = new Set(["attributionId", "orderId", "campaign", "referralCode", "customerHash", "commission"]);
|
|
26
|
+
|
|
27
|
+
interface AttributionBody {
|
|
28
|
+
attributionId?: unknown;
|
|
29
|
+
orderId?: unknown;
|
|
30
|
+
campaign?: unknown;
|
|
31
|
+
referralCode?: unknown;
|
|
32
|
+
customerHash?: unknown;
|
|
33
|
+
commission?: unknown;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ParsedAttributionBody {
|
|
37
|
+
attributionId: string;
|
|
38
|
+
orderId: string;
|
|
39
|
+
campaign?: string;
|
|
40
|
+
referralCode?: string;
|
|
41
|
+
customerHash?: string;
|
|
42
|
+
commission?: { amount?: number; currency?: string };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Validate + narrow the request body, or return a 400 message. Rejects unknown fields (contract: additionalProperties: false). */
|
|
46
|
+
function parseAttributionBody(body: AttributionBody): ParsedAttributionBody | string {
|
|
47
|
+
for (const key of Object.keys(body)) {
|
|
48
|
+
if (!BODY_KEYS.has(key)) return `unknown field: ${key}`;
|
|
49
|
+
}
|
|
50
|
+
if (typeof body.attributionId !== "string" || !body.attributionId.trim()) return "attributionId is required";
|
|
51
|
+
if (typeof body.orderId !== "string" || !body.orderId.trim()) return "orderId is required";
|
|
52
|
+
if (body.campaign !== undefined && typeof body.campaign !== "string") return "campaign, when given, must be a string";
|
|
53
|
+
if (body.referralCode !== undefined && typeof body.referralCode !== "string") {
|
|
54
|
+
return "referralCode, when given, must be a string";
|
|
55
|
+
}
|
|
56
|
+
if (body.customerHash !== undefined && typeof body.customerHash !== "string") {
|
|
57
|
+
return "customerHash, when given, must be a string";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let commission: { amount?: number; currency?: string } | undefined;
|
|
61
|
+
if (body.commission !== undefined) {
|
|
62
|
+
if (body.commission === null || typeof body.commission !== "object" || Array.isArray(body.commission)) {
|
|
63
|
+
return "commission, when given, must be an object";
|
|
64
|
+
}
|
|
65
|
+
const c = body.commission as Record<string, unknown>;
|
|
66
|
+
for (const key of Object.keys(c)) {
|
|
67
|
+
if (!COMMISSION_KEYS.has(key)) return `unknown field: commission.${key}`;
|
|
68
|
+
}
|
|
69
|
+
if (c.amount !== undefined && typeof c.amount !== "number") return "commission.amount, when given, must be a number";
|
|
70
|
+
if (c.currency !== undefined && typeof c.currency !== "string") return "commission.currency, when given, must be a string";
|
|
71
|
+
commission = { ...(c.amount !== undefined ? { amount: c.amount as number } : {}), ...(c.currency !== undefined ? { currency: c.currency as string } : {}) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
attributionId: body.attributionId,
|
|
76
|
+
orderId: body.orderId,
|
|
77
|
+
...(body.campaign !== undefined ? { campaign: body.campaign as string } : {}),
|
|
78
|
+
...(body.referralCode !== undefined ? { referralCode: body.referralCode as string } : {}),
|
|
79
|
+
...(body.customerHash !== undefined ? { customerHash: body.customerHash as string } : {}),
|
|
80
|
+
...(commission !== undefined ? { commission } : {}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function POST(context: APIContext): Promise<Response> {
|
|
85
|
+
const auth = await authorizeAppRequest(context, "attribution:write");
|
|
86
|
+
if (!auth.ok) return withRequestId(auth.response, context);
|
|
87
|
+
|
|
88
|
+
const idempotencyKey = context.request.headers.get("idempotency-key");
|
|
89
|
+
if (!idempotencyKey) {
|
|
90
|
+
return withRequestId(json({ error: "Idempotency-Key header is required", code: "bad_request" }, 400), context);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let rawBody: AttributionBody;
|
|
94
|
+
try {
|
|
95
|
+
rawBody = (await context.request.json()) as AttributionBody;
|
|
96
|
+
} catch {
|
|
97
|
+
return withRequestId(json({ error: "invalid JSON body", code: "bad_request" }, 400), context);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const parsed = parseAttributionBody(rawBody);
|
|
101
|
+
if (typeof parsed === "string") {
|
|
102
|
+
return withRequestId(json({ error: parsed, code: "bad_request" }, 400), context);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const d1 = await readD1("STOREFRONT_APPS_DB");
|
|
106
|
+
if (!d1) return withRequestId(json({ error: "attribution store not configured", code: "unconfigured" }, 503), context);
|
|
107
|
+
const db = fromD1(d1);
|
|
108
|
+
|
|
109
|
+
const bodyHash = await computeBodyHash(rawBody);
|
|
110
|
+
const idem = await checkIdempotency(db, auth.install.installId, idempotencyKey, bodyHash);
|
|
111
|
+
if (idem.status === "conflict") {
|
|
112
|
+
return withRequestId(json({ error: "Idempotency-Key reused with a different request body", code: "idempotency_conflict" }, 409), context);
|
|
113
|
+
}
|
|
114
|
+
if (idem.status === "replay") {
|
|
115
|
+
return withRequestId(json(idem.responseBody, 201), context);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const service = new AppAttributionService(db);
|
|
119
|
+
let record;
|
|
120
|
+
try {
|
|
121
|
+
record = await service.record({
|
|
122
|
+
installId: auth.install.installId,
|
|
123
|
+
tenantId: auth.install.tenantId,
|
|
124
|
+
attributionId: parsed.attributionId,
|
|
125
|
+
orderId: parsed.orderId,
|
|
126
|
+
campaign: parsed.campaign,
|
|
127
|
+
referralCode: parsed.referralCode,
|
|
128
|
+
commission: parsed.commission,
|
|
129
|
+
customerHash: parsed.customerHash,
|
|
130
|
+
});
|
|
131
|
+
} catch (err) {
|
|
132
|
+
if (err instanceof AttributionError) {
|
|
133
|
+
const status =
|
|
134
|
+
err.code === "attribution_forbidden" ? 403 : err.code === "attribution_conflict" ? 409 : 400;
|
|
135
|
+
return withRequestId(json({ error: err.message, code: err.code }, status), context);
|
|
136
|
+
}
|
|
137
|
+
throw err;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
await recordIdempotency(db, auth.install.installId, idempotencyKey, bodyHash, record);
|
|
141
|
+
return withRequestId(json(record, 201), context);
|
|
142
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET /api/apps/v1/health` — the D4 install delivery/telemetry health read
|
|
3
|
+
* (PrivateApps epic; `docs/private-apps/contract/openapi.yaml`'s
|
|
4
|
+
* `getInstallHealth`). Requires no scope beyond a valid token — only ever the
|
|
5
|
+
* caller's OWN install health (the `sub` claim `authorizeAppRequest` already
|
|
6
|
+
* resolved), never another install's.
|
|
7
|
+
*
|
|
8
|
+
* `WebhookDeliveryStore.getInstallHealth`'s `InstallHealth` return already IS
|
|
9
|
+
* the contract's `InstallHealth` shape 1:1, incl. `p95DeliveryMs` (a
|
|
10
|
+
* never-attempted install reads as healthy/zeroed) — no mapper needed, unlike
|
|
11
|
+
* `Delivery` below.
|
|
12
|
+
*/
|
|
13
|
+
import type { APIContext } from "astro";
|
|
14
|
+
import { authorizeAppRequest } from "@/lib/apps/apiAuth.js";
|
|
15
|
+
import { json, withRequestId } from "@/lib/apps/apiRoute.js";
|
|
16
|
+
import { WebhookDeliveryStore } from "@/lib/webhooks/deliveryStore.js";
|
|
17
|
+
import { fromD1 } from "@/lib/d1/catalog.js";
|
|
18
|
+
import { readD1 } from "@/lib/env";
|
|
19
|
+
|
|
20
|
+
export const prerender = false;
|
|
21
|
+
|
|
22
|
+
export async function GET(context: APIContext): Promise<Response> {
|
|
23
|
+
const auth = await authorizeAppRequest(context, null);
|
|
24
|
+
if (!auth.ok) return withRequestId(auth.response, context);
|
|
25
|
+
|
|
26
|
+
const d1 = await readD1("STOREFRONT_APPS_DB");
|
|
27
|
+
if (!d1) return withRequestId(json({ error: "webhook store not configured", code: "unconfigured" }, 503), context);
|
|
28
|
+
const store = new WebhookDeliveryStore(fromD1(d1));
|
|
29
|
+
|
|
30
|
+
const health = await store.getInstallHealth(auth.install.installId);
|
|
31
|
+
return withRequestId(json(health), context);
|
|
32
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET /api/apps/v1/inventory` — the D3 inventory-availability read
|
|
3
|
+
* (PrivateApps epic; `docs/private-apps/contract/openapi.yaml`'s
|
|
4
|
+
* `listInventory`). Requires scope `inventory:read`.
|
|
5
|
+
*
|
|
6
|
+
* No new table: this projects `{sku, available, quantity?, policy?}` off the
|
|
7
|
+
* variants embedded in the existing catalog read model
|
|
8
|
+
* (`../../../lib/d1/catalog.js`'s `listAllActiveProducts`) — active products
|
|
9
|
+
* only. A variant with no `sku` is skipped; the contract's `InventoryList`
|
|
10
|
+
* item requires one, and there's nothing to key it by otherwise.
|
|
11
|
+
*/
|
|
12
|
+
import type { APIContext } from "astro";
|
|
13
|
+
import { authorizeAppRequest } from "@/lib/apps/apiAuth.js";
|
|
14
|
+
import { json, withRequestId } from "@/lib/apps/apiRoute.js";
|
|
15
|
+
import { listAllActiveProducts, fromD1 } from "@/lib/d1/catalog.js";
|
|
16
|
+
import { readD1 } from "@/lib/env";
|
|
17
|
+
|
|
18
|
+
export const prerender = false;
|
|
19
|
+
|
|
20
|
+
interface InventoryItem {
|
|
21
|
+
sku: string;
|
|
22
|
+
available: boolean;
|
|
23
|
+
quantity?: number;
|
|
24
|
+
policy?: "deny" | "continue";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function toInventoryItem(policy: string | undefined, quantity: number | undefined, sku: string, available: boolean): InventoryItem {
|
|
28
|
+
return {
|
|
29
|
+
sku,
|
|
30
|
+
available,
|
|
31
|
+
quantity,
|
|
32
|
+
policy: policy === "deny" || policy === "continue" ? policy : undefined,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function GET(context: APIContext): Promise<Response> {
|
|
37
|
+
const auth = await authorizeAppRequest(context, "inventory:read");
|
|
38
|
+
if (!auth.ok) return withRequestId(auth.response, context);
|
|
39
|
+
|
|
40
|
+
const d1 = await readD1("CATALOG_DB");
|
|
41
|
+
if (!d1) return withRequestId(json({ error: "catalog not configured", code: "unconfigured" }, 503), context);
|
|
42
|
+
const db = fromD1(d1);
|
|
43
|
+
|
|
44
|
+
const skuFilter = new URL(context.request.url).searchParams.get("sku");
|
|
45
|
+
const products = await listAllActiveProducts(db);
|
|
46
|
+
|
|
47
|
+
const items: InventoryItem[] = [];
|
|
48
|
+
for (const product of products) {
|
|
49
|
+
for (const variant of product.variants) {
|
|
50
|
+
if (!variant.sku) continue;
|
|
51
|
+
if (skuFilter && variant.sku !== skuFilter) continue;
|
|
52
|
+
items.push(
|
|
53
|
+
toInventoryItem(variant.inventory.policy, variant.inventory.quantity, variant.sku, variant.inventory.available),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return withRequestId(json({ items }), context);
|
|
59
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET /api/apps/v1/orders/{id}` — the D6 minimal single-order read
|
|
3
|
+
* (PrivateApps epic; `docs/private-apps/contract/openapi.yaml`'s `getOrder`).
|
|
4
|
+
* Requires scope `orders:read:minimal`.
|
|
5
|
+
*
|
|
6
|
+
* `{id}` is tot-foxycart's `externalId` — the same id `listOrders` echoes
|
|
7
|
+
* back as `Order.id`. There's no cross-tenant oracle here: the Foxy partner
|
|
8
|
+
* API key is itself tenant-scoped, so a call can only ever resolve THIS
|
|
9
|
+
* tenant's own orders — an unknown id and a different tenant's id both come
|
|
10
|
+
* back `null` from `FoxyOrderClient.getOrder` and are reported identically
|
|
11
|
+
* as 404.
|
|
12
|
+
*/
|
|
13
|
+
import type { APIContext } from "astro";
|
|
14
|
+
import { authorizeAppRequest } from "@/lib/apps/apiAuth.js";
|
|
15
|
+
import { json, withRequestId } from "@/lib/apps/apiRoute.js";
|
|
16
|
+
import { toContractOrder } from "@/lib/apps/orders/orderMapper.js";
|
|
17
|
+
import { getFoxyOrderClient } from "@/lib/apps/orders/foxyOrderClient.js";
|
|
18
|
+
|
|
19
|
+
export const prerender = false;
|
|
20
|
+
|
|
21
|
+
export async function GET(context: APIContext): Promise<Response> {
|
|
22
|
+
const auth = await authorizeAppRequest(context, "orders:read:minimal");
|
|
23
|
+
if (!auth.ok) return withRequestId(auth.response, context);
|
|
24
|
+
|
|
25
|
+
const id = context.params.id;
|
|
26
|
+
if (!id) return withRequestId(json({ error: "missing order id", code: "bad_request" }, 400), context);
|
|
27
|
+
|
|
28
|
+
let client;
|
|
29
|
+
try {
|
|
30
|
+
client = await getFoxyOrderClient();
|
|
31
|
+
} catch {
|
|
32
|
+
return withRequestId(json({ error: "orders not configured", code: "unconfigured" }, 503), context);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let foxyOrder;
|
|
36
|
+
try {
|
|
37
|
+
foxyOrder = await client.getOrder(auth.install.tenantId, id);
|
|
38
|
+
} catch {
|
|
39
|
+
return withRequestId(json({ error: "failed to read order", code: "upstream_error" }, 502), context);
|
|
40
|
+
}
|
|
41
|
+
if (!foxyOrder) return withRequestId(json({ error: "order not found", code: "not_found" }, 404), context);
|
|
42
|
+
|
|
43
|
+
const order = await toContractOrder(foxyOrder, {
|
|
44
|
+
tenantId: auth.install.tenantId,
|
|
45
|
+
installId: auth.install.installId,
|
|
46
|
+
});
|
|
47
|
+
return withRequestId(json(order), context);
|
|
48
|
+
}
|