@tokenoftrust/storefront-runner 1.3.4-rc.4 → 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/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 +433 -69
- 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
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* PLP behaviour is identical whichever backend is active.
|
|
5
5
|
*/
|
|
6
6
|
import type {
|
|
7
|
+
CatalogCollection,
|
|
7
8
|
CatalogProduct,
|
|
8
9
|
Facet,
|
|
9
10
|
FacetValue,
|
|
@@ -236,6 +237,37 @@ export function sortProducts(
|
|
|
236
237
|
}
|
|
237
238
|
}
|
|
238
239
|
|
|
240
|
+
/**
|
|
241
|
+
* Return the number of ACTIVE products a collection contains. Membership is
|
|
242
|
+
* resolved BOTH ways to match FixtureToTClient.listProducts (collection ->
|
|
243
|
+
* product_handles) AND the product-side back-reference (product.collections),
|
|
244
|
+
* so a collection counts as populated whichever direction the migration wrote.
|
|
245
|
+
*/
|
|
246
|
+
export function activeMemberCount(
|
|
247
|
+
collection: CatalogCollection,
|
|
248
|
+
activeProducts: CatalogProduct[],
|
|
249
|
+
): number {
|
|
250
|
+
const handles = new Set(collection.product_handles);
|
|
251
|
+
return activeProducts.filter(
|
|
252
|
+
(p) => handles.has(p.handle) || p.collections.includes(collection.handle),
|
|
253
|
+
).length;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Drop "empty synthesized collections" — the shells the migration emits for a
|
|
258
|
+
* source smart/manual collection whose every member is a draft or was dropped.
|
|
259
|
+
* They must never reach public SEO surfaces (sitemap, llms.txt, the collections
|
|
260
|
+
* directory + its JSON-LD): a crawlable URL that resolves to zero products is a
|
|
261
|
+
* dead end that dilutes crawl budget and structured data. A collection survives
|
|
262
|
+
* iff at least one ACTIVE product belongs to it.
|
|
263
|
+
*/
|
|
264
|
+
export function nonEmptyCollections(
|
|
265
|
+
collections: CatalogCollection[],
|
|
266
|
+
activeProducts: CatalogProduct[],
|
|
267
|
+
): CatalogCollection[] {
|
|
268
|
+
return collections.filter((c) => activeMemberCount(c, activeProducts) > 0);
|
|
269
|
+
}
|
|
270
|
+
|
|
239
271
|
export function paginate<T>(
|
|
240
272
|
items: T[],
|
|
241
273
|
query: ListProductsQuery,
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Queues adapter for the `WebhookDispatcher` seam — PrivateApps
|
|
3
|
+
* epic, D4 (Chunk C). NOT V1's production path (see `dispatcher.ts`'s header
|
|
4
|
+
* for the full `d4-design-resolutions` context + the `@astrojs/cloudflare` v14
|
|
5
|
+
* investigation): `InMemoryDispatcher` is what actually runs deliveries today.
|
|
6
|
+
* This file exists so the Queues-backed path is built and unit-tested ahead of
|
|
7
|
+
* the infra decision to provision a real Queue — `getDispatcher.ts` only
|
|
8
|
+
* selects it when `env.WEBHOOK_DELIVERY_QUEUE` is bound, and no such binding
|
|
9
|
+
* is declared in `wrangler.toml` yet, so that branch is dead code for now.
|
|
10
|
+
*
|
|
11
|
+
* Local minimal interfaces below (not `@cloudflare/workers-types`, which
|
|
12
|
+
* isn't a dependency here) mirror how `../d1/catalog.ts`'s `D1Like` avoids a
|
|
13
|
+
* types dep on the real Cloudflare D1Database shape — same idea, for Queues.
|
|
14
|
+
*/
|
|
15
|
+
import { computeBackoffDelaySeconds } from "./dispatcher.js";
|
|
16
|
+
import type { DispatchJob, AttemptDeliveryFn, WebhookDispatcher } from "./dispatcher.js";
|
|
17
|
+
|
|
18
|
+
/** The slice of a Cloudflare Queue producer binding we use. */
|
|
19
|
+
export interface CloudflareQueueSender<Body = DispatchJob> {
|
|
20
|
+
send(body: Body, options?: { delaySeconds?: number }): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The slice of a Cloudflare Queue consumer message we use. */
|
|
24
|
+
export interface CloudflareQueueMessage<Body = DispatchJob> {
|
|
25
|
+
readonly body: Body;
|
|
26
|
+
/** The queue's own native redelivery count (distinct from the delivery row's `attempts`). */
|
|
27
|
+
readonly attempts: number;
|
|
28
|
+
ack(): void;
|
|
29
|
+
retry(options?: { delaySeconds?: number }): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The slice of a Cloudflare Queue consumer batch we use. */
|
|
33
|
+
export interface CloudflareQueueBatch<Body = DispatchJob> {
|
|
34
|
+
readonly messages: ReadonlyArray<CloudflareQueueMessage<Body>>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `enqueue()` calls `env.WEBHOOK_DELIVERY_QUEUE.send()` — `notBefore` (if in the future) becomes the send's `delaySeconds`. */
|
|
38
|
+
export class CloudflareQueueDispatcher implements WebhookDispatcher {
|
|
39
|
+
constructor(private readonly queue: CloudflareQueueSender) {}
|
|
40
|
+
|
|
41
|
+
async enqueue(job: DispatchJob): Promise<void> {
|
|
42
|
+
const delaySeconds = job.notBefore ? secondsUntil(job.notBefore) : undefined;
|
|
43
|
+
await this.queue.send(job, delaySeconds ? { delaySeconds } : undefined);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function secondsUntil(iso: string): number | undefined {
|
|
48
|
+
const ms = Date.parse(iso) - Date.now();
|
|
49
|
+
return ms > 0 ? Math.ceil(ms / 1000) : undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The Queue CONSUMER logic a Worker `queue(batch, env, ctx)` export would call
|
|
54
|
+
* per batch, once the entry-wiring TODO in `dispatcher.ts`'s header is
|
|
55
|
+
* resolved — exported as a plain function (not tied to a `queue()` export
|
|
56
|
+
* signature) so it's unit-testable against a fake batch/message with no real
|
|
57
|
+
* Queues binding or Worker runtime involved.
|
|
58
|
+
*
|
|
59
|
+
* Judgment call: the retry delay uses `message.attempts` — the QUEUE's own
|
|
60
|
+
* native redelivery counter — via `computeBackoffDelaySeconds`, not the
|
|
61
|
+
* `nextAttemptAt` `attemptDelivery` already computed from the delivery row's
|
|
62
|
+
* own attempt count. Those two counters normally march together, but a
|
|
63
|
+
* message the queue redelivers for a reason unrelated to `attemptDelivery`
|
|
64
|
+
* (e.g. an uncaught throw) would only bump `message.attempts`, not the row —
|
|
65
|
+
* this keeps the seam correct against the queue's actual retry accounting.
|
|
66
|
+
* `attemptDelivery` has already written the delivery's own terminal/interim
|
|
67
|
+
* state to the store either way; `ack`/`retry` here only affect the QUEUE
|
|
68
|
+
* message, never the delivery's own state.
|
|
69
|
+
*/
|
|
70
|
+
export async function queue(batch: CloudflareQueueBatch, attempt: AttemptDeliveryFn): Promise<void> {
|
|
71
|
+
for (const message of batch.messages) {
|
|
72
|
+
const outcome = await attempt(message.body.deliveryId);
|
|
73
|
+
if (outcome.kind === "retrying") {
|
|
74
|
+
message.retry({ delaySeconds: computeBackoffDelaySeconds(message.attempts) });
|
|
75
|
+
} else {
|
|
76
|
+
// delivered, or dead-lettered (max attempts / install unavailable) —
|
|
77
|
+
// the delivery's own state is already terminal; nothing more for the
|
|
78
|
+
// queue to do with this message.
|
|
79
|
+
message.ack();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delivery engine — PrivateApps epic, D4 (Chunk C). `attemptDelivery` is the
|
|
3
|
+
* ONE HTTP attempt a dispatcher (`dispatcher.ts`) drives per job: resolve the
|
|
4
|
+
* delivery + its event data, resolve the target install, refuse to deliver to
|
|
5
|
+
* a dead/suspended install (this is how suspension kills webhook delivery),
|
|
6
|
+
* build + sign the CloudEvents envelope, POST it with a bounded timeout,
|
|
7
|
+
* classify the outcome, and fold it into the store via `recordAttempt` +
|
|
8
|
+
* `transitionState`. Retry/DLQ policy (how many attempts, what backoff) is
|
|
9
|
+
* `dispatcher.ts`'s `computeBackoffDelaySeconds`/`MAX_DELIVERY_ATTEMPTS` —
|
|
10
|
+
* this module applies that policy, it doesn't redefine it.
|
|
11
|
+
*
|
|
12
|
+
* Fails closed: an unconfigured signing key, or a registry that can't be
|
|
13
|
+
* reached, throws {@link DeliveryEngineError} out of `attemptDelivery` — never
|
|
14
|
+
* a silently-recorded "delivered". Only an actual signed HTTP round trip (or
|
|
15
|
+
* the deliberate no-POST terminal cases below) ever writes a store outcome.
|
|
16
|
+
*
|
|
17
|
+
* SSRF defense-in-depth (belt to `../apps/registryService.ts`'s write-time
|
|
18
|
+
* `endpointGuard.ts` suspenders): `deliverSigned` re-validates
|
|
19
|
+
* `install.webhookEndpoint` right before every POST — catching a row that
|
|
20
|
+
* predates the guard, or a future write path that forgets to call it — and
|
|
21
|
+
* `postWithTimeout` sends with `redirect: "manual"` so a validated https/
|
|
22
|
+
* public target can't 302 its way to an internal one; either case never
|
|
23
|
+
* reads as `delivered`.
|
|
24
|
+
*/
|
|
25
|
+
import { WebhookDeliveryStore, type WebhookDelivery, type WebhookEvent, type WebhookTopic } from "./deliveryStore.js";
|
|
26
|
+
import type { AppInstall } from "../apps/registryService.js";
|
|
27
|
+
import { loadWebhookSigningConfig, type WebhookSigningConfig } from "./webhookSigningKey.js";
|
|
28
|
+
import { signWebhookRequest, type SignWebhookRequestResult } from "./signing.js";
|
|
29
|
+
import { buildCloudEventEnvelope } from "./events.js";
|
|
30
|
+
import { computeBackoffDelaySeconds, MAX_DELIVERY_ATTEMPTS, type AttemptOutcome } from "./dispatcher.js";
|
|
31
|
+
import { validateWebhookEndpoint } from "./endpointGuard.js";
|
|
32
|
+
|
|
33
|
+
/** Bounded per-attempt timeout — a hung receiver must not hold a dispatcher slot open indefinitely. */
|
|
34
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
35
|
+
|
|
36
|
+
/** The minimal registry seam the engine needs: resolving the target install by id. Satisfied by `AppRegistryService`. */
|
|
37
|
+
export interface InstallResolver {
|
|
38
|
+
get(installId: string): Promise<AppInstall | null>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface AttemptDeliveryDeps {
|
|
42
|
+
store: WebhookDeliveryStore;
|
|
43
|
+
registry: InstallResolver;
|
|
44
|
+
/** Defaults to `loadWebhookSigningConfig` (env-backed). Override in tests to avoid touching real env/keys. */
|
|
45
|
+
loadSigningConfig?: () => Promise<WebhookSigningConfig>;
|
|
46
|
+
/** Defaults to the global `fetch`. Override in tests with a fake. */
|
|
47
|
+
fetchImpl?: typeof fetch;
|
|
48
|
+
/** Injected clock. Defaults to `() => new Date()`. */
|
|
49
|
+
now?: () => Date;
|
|
50
|
+
/** Per-attempt HTTP timeout, in ms. Defaults to {@link DEFAULT_TIMEOUT_MS}. */
|
|
51
|
+
timeoutMs?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Thrown when the engine itself can't run the attempt at all (fail-closed) — never a delivery outcome. */
|
|
55
|
+
export class DeliveryEngineError extends Error {
|
|
56
|
+
constructor(
|
|
57
|
+
message: string,
|
|
58
|
+
readonly cause?: unknown,
|
|
59
|
+
) {
|
|
60
|
+
super(message);
|
|
61
|
+
this.name = "DeliveryEngineError";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `app.uninstalled` is exempt from the active-status check — it's the
|
|
67
|
+
* defined farewell delivery, fired AFTER `registry.uninstall()` has already
|
|
68
|
+
* flipped the install to `uninstalled` (Chunk D's `emit.ts` deliberately
|
|
69
|
+
* still creates a delivery row for it), so refusing it here on status alone
|
|
70
|
+
* would silently swallow every uninstall notification. It still refuses a
|
|
71
|
+
* missing install or one with no endpoint, and every OTHER topic still
|
|
72
|
+
* refuses a non-active (suspended/uninstalled) install exactly as before.
|
|
73
|
+
*/
|
|
74
|
+
function isDeadInstall(install: AppInstall | null, topic: WebhookTopic): boolean {
|
|
75
|
+
if (!install || !install.webhookEndpoint) return true;
|
|
76
|
+
if (topic === "app.uninstalled") return false;
|
|
77
|
+
return install.status !== "active";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Run one HTTP delivery attempt for `deliveryId`, folding the outcome into the store. */
|
|
81
|
+
export async function attemptDelivery(deliveryId: string, deps: AttemptDeliveryDeps): Promise<AttemptOutcome> {
|
|
82
|
+
const { store, registry } = deps;
|
|
83
|
+
const now = deps.now ?? (() => new Date());
|
|
84
|
+
const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
85
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
86
|
+
const loadSigningConfig = deps.loadSigningConfig ?? loadWebhookSigningConfig;
|
|
87
|
+
|
|
88
|
+
const delivery = await store.getDelivery(deliveryId);
|
|
89
|
+
if (!delivery) {
|
|
90
|
+
throw new DeliveryEngineError(`Webhook delivery "${deliveryId}" not found.`);
|
|
91
|
+
}
|
|
92
|
+
// Terminal already — a stale/duplicate enqueue racing an outcome that
|
|
93
|
+
// already settled this delivery. No-op rather than re-attempt or throw.
|
|
94
|
+
if (delivery.state === "delivered") return { kind: "delivered" };
|
|
95
|
+
if (delivery.state === "dead-lettered") return { kind: "dead-lettered", reason: "max_attempts" };
|
|
96
|
+
|
|
97
|
+
const event = await store.getEvent(delivery.eventId);
|
|
98
|
+
if (!event) {
|
|
99
|
+
throw new DeliveryEngineError(`Webhook event "${delivery.eventId}" for delivery "${deliveryId}" not found.`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let install: AppInstall | null;
|
|
103
|
+
try {
|
|
104
|
+
install = await registry.get(delivery.installId);
|
|
105
|
+
} catch (cause) {
|
|
106
|
+
throw new DeliveryEngineError(`Cannot resolve install "${delivery.installId}" for delivery "${deliveryId}".`, cause);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (isDeadInstall(install, delivery.topic)) {
|
|
110
|
+
// Don't deliver to a dead/suspended install, or one with no endpoint
|
|
111
|
+
// configured — no POST attempt, no recordAttempt row; go straight to the
|
|
112
|
+
// one terminal-failure state the store has. This is how suspension kills
|
|
113
|
+
// webhook delivery.
|
|
114
|
+
await store.transitionState({ deliveryId, toState: "dead-lettered" });
|
|
115
|
+
return { kind: "dead-lettered", reason: "install_unavailable" };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let signingConfig: WebhookSigningConfig;
|
|
119
|
+
try {
|
|
120
|
+
signingConfig = await loadSigningConfig();
|
|
121
|
+
} catch (cause) {
|
|
122
|
+
throw new DeliveryEngineError(`Cannot sign webhook delivery "${deliveryId}": signing key unavailable.`, cause);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return deliverSigned(delivery, event, install!, signingConfig, { now, timeoutMs, fetchImpl, store });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function deliverSigned(
|
|
129
|
+
delivery: WebhookDelivery,
|
|
130
|
+
event: WebhookEvent,
|
|
131
|
+
install: AppInstall,
|
|
132
|
+
signingConfig: WebhookSigningConfig,
|
|
133
|
+
ctx: {
|
|
134
|
+
now: () => Date;
|
|
135
|
+
timeoutMs: number;
|
|
136
|
+
fetchImpl: typeof fetch;
|
|
137
|
+
store: WebhookDeliveryStore;
|
|
138
|
+
},
|
|
139
|
+
): Promise<AttemptOutcome> {
|
|
140
|
+
const { deliveryId } = delivery;
|
|
141
|
+
const attemptNo = delivery.attempts + 1;
|
|
142
|
+
const startedAt = ctx.now().toISOString();
|
|
143
|
+
|
|
144
|
+
// Defense-in-depth re-check (see module header): refuse to POST to an
|
|
145
|
+
// endpoint that wouldn't pass write-time validation today, even though it
|
|
146
|
+
// passed at install/update time. This gets its own dead-letter reason and
|
|
147
|
+
// a recorded attempt row (unlike `isDeadInstall`'s bare skip above) so
|
|
148
|
+
// support triage can tell "the target refused delivery" apart from "the
|
|
149
|
+
// target was never an allowed one".
|
|
150
|
+
const endpointCheck = validateWebhookEndpoint(install.webhookEndpoint!);
|
|
151
|
+
if (!endpointCheck.ok) {
|
|
152
|
+
await ctx.store.recordAttempt({
|
|
153
|
+
deliveryId,
|
|
154
|
+
attemptNo,
|
|
155
|
+
startedAt,
|
|
156
|
+
durationMs: 0,
|
|
157
|
+
httpStatus: null,
|
|
158
|
+
errorClass: "endpoint_blocked",
|
|
159
|
+
});
|
|
160
|
+
await ctx.store.transitionState({ deliveryId, toState: "dead-lettered" });
|
|
161
|
+
return { kind: "dead-lettered", reason: "endpoint_blocked" };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const envelope = buildCloudEventEnvelope({
|
|
165
|
+
deliveryId,
|
|
166
|
+
tenantId: delivery.tenantId,
|
|
167
|
+
topic: delivery.topic,
|
|
168
|
+
dataschemaVersion: event.dataschemaVersion,
|
|
169
|
+
data: event.payload,
|
|
170
|
+
appInstallId: delivery.installId,
|
|
171
|
+
retryCount: delivery.attempts,
|
|
172
|
+
now: ctx.now,
|
|
173
|
+
});
|
|
174
|
+
const body = JSON.stringify(envelope);
|
|
175
|
+
const { headers } = await signWebhookRequest({
|
|
176
|
+
method: "POST",
|
|
177
|
+
url: install.webhookEndpoint!,
|
|
178
|
+
body,
|
|
179
|
+
key: signingConfig.key,
|
|
180
|
+
kid: signingConfig.kid,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const { httpStatus, errorClass } = await postWithTimeout(
|
|
184
|
+
ctx.fetchImpl,
|
|
185
|
+
install.webhookEndpoint!,
|
|
186
|
+
body,
|
|
187
|
+
headers,
|
|
188
|
+
ctx.timeoutMs,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
await ctx.store.recordAttempt({
|
|
192
|
+
deliveryId,
|
|
193
|
+
attemptNo,
|
|
194
|
+
startedAt,
|
|
195
|
+
durationMs: Date.now() - Date.parse(startedAt),
|
|
196
|
+
httpStatus,
|
|
197
|
+
errorClass,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
if (httpStatus != null && httpStatus >= 200 && httpStatus < 300) {
|
|
201
|
+
await ctx.store.transitionState({ deliveryId, toState: "delivered" });
|
|
202
|
+
return { kind: "delivered" };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (attemptNo >= MAX_DELIVERY_ATTEMPTS) {
|
|
206
|
+
await ctx.store.transitionState({ deliveryId, toState: "dead-lettered" });
|
|
207
|
+
return { kind: "dead-lettered", reason: "max_attempts" };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const nextAttemptAt = new Date(ctx.now().getTime() + computeBackoffDelaySeconds(attemptNo) * 1000).toISOString();
|
|
211
|
+
await ctx.store.transitionState({ deliveryId, toState: "retrying", nextAttemptAt });
|
|
212
|
+
return { kind: "retrying", nextAttemptAt };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function postWithTimeout(
|
|
216
|
+
fetchImpl: typeof fetch,
|
|
217
|
+
url: string,
|
|
218
|
+
body: string,
|
|
219
|
+
signedHeaders: SignWebhookRequestResult["headers"],
|
|
220
|
+
timeoutMs: number,
|
|
221
|
+
): Promise<{ httpStatus: number | null; errorClass: string | null }> {
|
|
222
|
+
const controller = new AbortController();
|
|
223
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
224
|
+
try {
|
|
225
|
+
const res = await fetchImpl(url, {
|
|
226
|
+
method: "POST",
|
|
227
|
+
headers: { "content-type": "application/cloudevents+json", ...signedHeaders },
|
|
228
|
+
body,
|
|
229
|
+
signal: controller.signal,
|
|
230
|
+
// Manual redirects: a validated, public https target must not be able
|
|
231
|
+
// to 302 its way to an internal one. `isSuccessAttempt`/the 2xx check
|
|
232
|
+
// above already treat a 3xx as non-delivered (only 200-299 counts as
|
|
233
|
+
// `delivered`); this just stops `fetch` from silently following it
|
|
234
|
+
// there and reading back THAT response instead.
|
|
235
|
+
redirect: "manual",
|
|
236
|
+
});
|
|
237
|
+
const errorClass = res.status >= 300 && res.status < 400 ? "redirect_blocked" : null;
|
|
238
|
+
return { httpStatus: res.status, errorClass };
|
|
239
|
+
} catch (cause) {
|
|
240
|
+
const errorClass = cause instanceof Error && cause.name === "AbortError" ? "timeout" : "network_error";
|
|
241
|
+
return { httpStatus: null, errorClass };
|
|
242
|
+
} finally {
|
|
243
|
+
clearTimeout(timer);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps the internal `WebhookDelivery` read model to the D4 contract's
|
|
3
|
+
* `Delivery` shape (`docs/private-apps/contract/openapi.yaml`, schema
|
|
4
|
+
* `Delivery` — `additionalProperties: false`). Drops every internal field
|
|
5
|
+
* the contract doesn't declare (installId, tenantId, lastErrorClass,
|
|
6
|
+
* nextAttemptAt, createdAt) so an app only ever sees the documented surface —
|
|
7
|
+
* same discipline `catalogMapper.ts`'s `toContractProduct` keeps for D3.
|
|
8
|
+
*/
|
|
9
|
+
import type { WebhookDelivery } from "./deliveryStore.js";
|
|
10
|
+
|
|
11
|
+
export interface ContractDelivery {
|
|
12
|
+
deliveryId: string;
|
|
13
|
+
eventId: string;
|
|
14
|
+
topic: WebhookDelivery["topic"];
|
|
15
|
+
state: WebhookDelivery["state"];
|
|
16
|
+
attempts: number;
|
|
17
|
+
lastStatus?: number;
|
|
18
|
+
lastTraceId?: string;
|
|
19
|
+
updatedAt: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Project one `WebhookDelivery` into the contract's `Delivery` — nothing else survives. */
|
|
23
|
+
export function toContractDelivery(d: WebhookDelivery): ContractDelivery {
|
|
24
|
+
return {
|
|
25
|
+
deliveryId: d.deliveryId,
|
|
26
|
+
eventId: d.eventId,
|
|
27
|
+
topic: d.topic,
|
|
28
|
+
state: d.state,
|
|
29
|
+
attempts: d.attempts,
|
|
30
|
+
lastStatus: d.lastStatus ?? undefined,
|
|
31
|
+
lastTraceId: d.lastTraceId ?? undefined,
|
|
32
|
+
updatedAt: d.updatedAt,
|
|
33
|
+
};
|
|
34
|
+
}
|