@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,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth2 client-credentials token issuer — the Storefront App Gateway's
|
|
3
|
+
* `POST /oauth/token` grant (PrivateApps epic, D2 Chunk B). Mints the
|
|
4
|
+
* short-lived RS256 JWT a private app presents as a bearer token to the D3
|
|
5
|
+
* scoped APIs.
|
|
6
|
+
*
|
|
7
|
+
* `tenant` and `env` are supplied by the CALLER (the route, from
|
|
8
|
+
* host-resolved context — never from the request body) and checked against
|
|
9
|
+
* the install's own `tenantId`/`env`: a credential minted for one
|
|
10
|
+
* tenant/env can't be replayed against another, even if the secret leaked.
|
|
11
|
+
*
|
|
12
|
+
* Fails closed on every rejected path; `TokenError.code` uses the RFC 6749
|
|
13
|
+
* §5.2 / §4.1.2.1 error vocabulary so the route can pass it straight through
|
|
14
|
+
* as the OAuth2 `error` field. `invalid_client` deliberately covers "unknown
|
|
15
|
+
* client_id", "wrong secret", AND "tenant/env mismatch" identically — none of
|
|
16
|
+
* those should be distinguishable to a caller (no enumeration oracle).
|
|
17
|
+
*/
|
|
18
|
+
import { SignJWT } from "jose";
|
|
19
|
+
import type { AppRegistryService } from "./registryService.js";
|
|
20
|
+
import {
|
|
21
|
+
loadGatewaySigningConfig,
|
|
22
|
+
GatewayKeyError,
|
|
23
|
+
APP_GATEWAY_ALG,
|
|
24
|
+
APP_TOKEN_TTL_SECONDS,
|
|
25
|
+
type GatewayKeyOverrides,
|
|
26
|
+
} from "./gatewayKeys.js";
|
|
27
|
+
|
|
28
|
+
export type TokenErrorCode = "invalid_client" | "invalid_grant" | "temporarily_unavailable";
|
|
29
|
+
|
|
30
|
+
export class TokenError extends Error {
|
|
31
|
+
constructor(
|
|
32
|
+
message: string,
|
|
33
|
+
readonly code: TokenErrorCode,
|
|
34
|
+
) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = "TokenError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The narrow secret-verification seam `AppTokenIssuer` depends on (`D1CredentialStore` satisfies it). */
|
|
41
|
+
export interface SecretVerifier {
|
|
42
|
+
verifySecret(clientId: string, presentedSecret: string): Promise<string | null>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface IssueTokenRequest {
|
|
46
|
+
clientId: string;
|
|
47
|
+
secret: string;
|
|
48
|
+
/** This request's host-resolved tenant (never client-supplied). */
|
|
49
|
+
tenant: string;
|
|
50
|
+
/** This deploy's own env tier (never client-supplied). */
|
|
51
|
+
env: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface IssuedToken {
|
|
55
|
+
accessToken: string;
|
|
56
|
+
tokenType: "Bearer";
|
|
57
|
+
expiresIn: number;
|
|
58
|
+
scope: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class AppTokenIssuer {
|
|
62
|
+
constructor(
|
|
63
|
+
private readonly registry: AppRegistryService,
|
|
64
|
+
private readonly credentials: SecretVerifier,
|
|
65
|
+
private readonly keyOverrides: GatewayKeyOverrides = {},
|
|
66
|
+
private readonly now: () => number = () => Math.floor(Date.now() / 1000),
|
|
67
|
+
) {}
|
|
68
|
+
|
|
69
|
+
async issueToken(req: IssueTokenRequest): Promise<IssuedToken> {
|
|
70
|
+
const installId = await this.credentials.verifySecret(req.clientId, req.secret);
|
|
71
|
+
if (!installId) {
|
|
72
|
+
throw new TokenError("invalid client_id or client_secret", "invalid_client");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const install = await this.registry.get(installId);
|
|
76
|
+
// An install row can't outlive its credential row (uninstall clears both),
|
|
77
|
+
// but never let a lookup miss here leak any more detail than a bad secret.
|
|
78
|
+
if (!install || install.tenantId !== req.tenant || install.env !== req.env) {
|
|
79
|
+
throw new TokenError("invalid client_id or client_secret", "invalid_client");
|
|
80
|
+
}
|
|
81
|
+
if (install.status !== "active") {
|
|
82
|
+
throw new TokenError(`install is ${install.status}; token issuance refused`, "invalid_grant");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let signing;
|
|
86
|
+
try {
|
|
87
|
+
signing = await loadGatewaySigningConfig(this.keyOverrides);
|
|
88
|
+
} catch (cause) {
|
|
89
|
+
if (cause instanceof GatewayKeyError) {
|
|
90
|
+
throw new TokenError(cause.message, "temporarily_unavailable");
|
|
91
|
+
}
|
|
92
|
+
throw cause;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const nowSeconds = this.now();
|
|
96
|
+
const accessToken = await new SignJWT({
|
|
97
|
+
tenant: install.tenantId,
|
|
98
|
+
env: install.env,
|
|
99
|
+
scopes: install.scopes,
|
|
100
|
+
// Discriminates this bearer token from a widget launch token (D5 Chunk
|
|
101
|
+
// B) so the two can never be replayed for each other even though both
|
|
102
|
+
// are RS256 JWTs signed by the same gateway key — see tokenVerifier.ts.
|
|
103
|
+
token_use: "access",
|
|
104
|
+
})
|
|
105
|
+
.setProtectedHeader({ alg: APP_GATEWAY_ALG, kid: signing.kid, typ: "JWT" })
|
|
106
|
+
.setIssuer(signing.issuer)
|
|
107
|
+
.setSubject(install.installId)
|
|
108
|
+
.setAudience(install.tenantId)
|
|
109
|
+
.setJti(crypto.randomUUID())
|
|
110
|
+
.setIssuedAt(nowSeconds)
|
|
111
|
+
.setExpirationTime(nowSeconds + APP_TOKEN_TTL_SECONDS)
|
|
112
|
+
.sign(signing.key);
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
accessToken,
|
|
116
|
+
tokenType: "Bearer",
|
|
117
|
+
expiresIn: APP_TOKEN_TTL_SECONDS,
|
|
118
|
+
scope: install.scopes.join(" "),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline verification of the Storefront App Gateway's own OAuth2
|
|
3
|
+
* client-credentials JWT (PrivateApps epic, D2 Chunk B) — this is what D3
|
|
4
|
+
* imports at the scoped-API boundary to authorize an app's request.
|
|
5
|
+
*
|
|
6
|
+
* Modeled directly on `../auth/identityToken.ts`'s `IdentityAssertionVerifier`
|
|
7
|
+
* (same `jwtVerify` + JWKS-by-`kid` shape, `aud`/`iss` pinning, alg allowlist),
|
|
8
|
+
* but LOCAL only: this Worker signs its own tokens, so there's no remote host
|
|
9
|
+
* to fetch a JWKS from — the public JWKS is derived in-process by
|
|
10
|
+
* `gatewayKeys.ts` from the very key that signs, via `createLocalJWKSet`.
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
jwtVerify,
|
|
14
|
+
createLocalJWKSet,
|
|
15
|
+
decodeProtectedHeader,
|
|
16
|
+
type JWTVerifyGetKey,
|
|
17
|
+
} from "jose";
|
|
18
|
+
import {
|
|
19
|
+
loadGatewayJwks,
|
|
20
|
+
GatewayKeyError,
|
|
21
|
+
APP_GATEWAY_ALG,
|
|
22
|
+
DEFAULT_APP_GATEWAY_ISSUER,
|
|
23
|
+
APP_GATEWAY_ISSUER_ENV,
|
|
24
|
+
type GatewayKeyOverrides,
|
|
25
|
+
} from "./gatewayKeys.js";
|
|
26
|
+
import { readEnv } from "@/lib/env";
|
|
27
|
+
|
|
28
|
+
/** The verified app-token claims (D3's authorization input). */
|
|
29
|
+
export interface AppTokenClaims {
|
|
30
|
+
iss: string;
|
|
31
|
+
sub: string; // install id
|
|
32
|
+
aud: string; // tenant
|
|
33
|
+
tenant: string;
|
|
34
|
+
env: string;
|
|
35
|
+
scopes: string[];
|
|
36
|
+
iat: number;
|
|
37
|
+
exp: number;
|
|
38
|
+
jti: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Normalize a jose JWT payload into typed {@link AppTokenClaims}, throwing on any missing claim. */
|
|
42
|
+
function toAppTokenClaims(payload: Record<string, unknown>): AppTokenClaims {
|
|
43
|
+
// The security-critical discriminator: a widget launch token (D5 Chunk B,
|
|
44
|
+
// `token_use: "widget_launch"`) is signed by this SAME gateway key, so
|
|
45
|
+
// signature/aud/iss/exp checks alone can't tell it apart from a bearer
|
|
46
|
+
// token. Requiring `token_use === "access"` here is what stops a launch
|
|
47
|
+
// token — minted for a sandboxed iframe, short-TTL, no scopes checked —
|
|
48
|
+
// from ever being replayed as an API bearer token.
|
|
49
|
+
if (payload.token_use !== "access") {
|
|
50
|
+
throw new Error("app token has the wrong `token_use`; expected an access token");
|
|
51
|
+
}
|
|
52
|
+
const tenant = payload.tenant;
|
|
53
|
+
if (typeof tenant !== "string" || !tenant) {
|
|
54
|
+
throw new Error("app token missing `tenant` claim");
|
|
55
|
+
}
|
|
56
|
+
const env = payload.env;
|
|
57
|
+
if (typeof env !== "string" || !env) {
|
|
58
|
+
throw new Error("app token missing `env` claim");
|
|
59
|
+
}
|
|
60
|
+
const jti = payload.jti;
|
|
61
|
+
if (typeof jti !== "string" || !jti) {
|
|
62
|
+
throw new Error("app token missing `jti` claim");
|
|
63
|
+
}
|
|
64
|
+
if (!Array.isArray(payload.scopes)) {
|
|
65
|
+
throw new Error("app token missing `scopes` claim");
|
|
66
|
+
}
|
|
67
|
+
const scopes = payload.scopes.filter((s): s is string => typeof s === "string");
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
iss: String(payload.iss ?? ""),
|
|
71
|
+
sub: String(payload.sub ?? ""),
|
|
72
|
+
aud: String(payload.aud ?? ""),
|
|
73
|
+
tenant,
|
|
74
|
+
env,
|
|
75
|
+
scopes,
|
|
76
|
+
iat: Number(payload.iat ?? 0),
|
|
77
|
+
exp: Number(payload.exp ?? 0),
|
|
78
|
+
jti,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface AppTokenVerifierConfig {
|
|
83
|
+
/** This request's tenant — pinned as the required `aud` (per-tenant, mirrors identityToken.ts). */
|
|
84
|
+
tenant: string;
|
|
85
|
+
issuer?: string;
|
|
86
|
+
keys: JWTVerifyGetKey;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Verifies app-gateway tokens offline against the LOCAL JWKS. One instance
|
|
91
|
+
* per tenant (its `aud` pin is fixed).
|
|
92
|
+
*/
|
|
93
|
+
export class AppTokenVerifier {
|
|
94
|
+
private readonly tenant: string;
|
|
95
|
+
private readonly issuer: string;
|
|
96
|
+
private readonly keys: JWTVerifyGetKey;
|
|
97
|
+
|
|
98
|
+
constructor(config: AppTokenVerifierConfig) {
|
|
99
|
+
if (!config.tenant) {
|
|
100
|
+
throw new Error("AppTokenVerifier requires a `tenant` (this request's resource)");
|
|
101
|
+
}
|
|
102
|
+
this.tenant = config.tenant;
|
|
103
|
+
this.issuer = config.issuer ?? DEFAULT_APP_GATEWAY_ISSUER;
|
|
104
|
+
this.keys = config.keys;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Verify a compact JWS and return typed claims. Rejects (throws) on: any
|
|
109
|
+
* `alg` other than RS256 (blocks `alg:none` and every HS*), bad signature,
|
|
110
|
+
* wrong/absent `aud`, unlisted `iss`, or expired/not-yet-valid `exp`/`iat`.
|
|
111
|
+
*/
|
|
112
|
+
async verify(token: string): Promise<AppTokenClaims> {
|
|
113
|
+
const header = decodeProtectedHeader(token);
|
|
114
|
+
if (header.alg !== APP_GATEWAY_ALG) {
|
|
115
|
+
throw new Error(`unexpected JWT alg ${String(header.alg)}; only ${APP_GATEWAY_ALG} is accepted`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const { payload } = await jwtVerify(token, this.keys, {
|
|
119
|
+
issuer: this.issuer,
|
|
120
|
+
audience: this.tenant,
|
|
121
|
+
algorithms: [APP_GATEWAY_ALG],
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
return toAppTokenClaims(payload as Record<string, unknown>);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Get (or build) the verifier for one tenant, from the gateway's own
|
|
130
|
+
* locally-derived JWKS. Returns `null` when the signing key isn't configured
|
|
131
|
+
* — callers (D3 routes) fail closed with 503, mirroring `getIdentityVerifier`.
|
|
132
|
+
* Not cached across calls (unlike `identityToken.ts`'s remote-fetch verifier):
|
|
133
|
+
* deriving the local JWKS is cheap (`gatewayKeys.ts` caches the export itself).
|
|
134
|
+
*/
|
|
135
|
+
export async function getAppTokenVerifier(
|
|
136
|
+
tenant: string,
|
|
137
|
+
overrides: GatewayKeyOverrides = {},
|
|
138
|
+
): Promise<AppTokenVerifier | null> {
|
|
139
|
+
let jwks;
|
|
140
|
+
try {
|
|
141
|
+
jwks = await loadGatewayJwks(overrides);
|
|
142
|
+
} catch (cause) {
|
|
143
|
+
if (cause instanceof GatewayKeyError) return null;
|
|
144
|
+
throw cause;
|
|
145
|
+
}
|
|
146
|
+
const issuer = overrides.issuer ?? (await readEnv(APP_GATEWAY_ISSUER_ENV)) ?? DEFAULT_APP_GATEWAY_ISSUER;
|
|
147
|
+
return new AppTokenVerifier({ tenant, issuer, keys: createLocalJWKSet(jwks) });
|
|
148
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Widget eligibility — PrivateApps epic, D5 (Chunk C). Mirrors
|
|
3
|
+
* `webhooks/emit.ts`'s `isEligibleTarget` discipline: an install is eligible
|
|
4
|
+
* to render its widget at `slot` only while it is LIVE — `active`, scoped for
|
|
5
|
+
* `widgets:launch`, and carrying a validated placement for that slot. This
|
|
6
|
+
* live check is what makes suspending an install actually stop it rendering
|
|
7
|
+
* on the very next page load — the D8 "suspension kills widgets"
|
|
8
|
+
* enforcement — with no separate revoke step; it's just this gate re-run on
|
|
9
|
+
* every request.
|
|
10
|
+
*/
|
|
11
|
+
import type { AppInstall } from "../registryService.js";
|
|
12
|
+
|
|
13
|
+
/** True when `install` is currently eligible to render its widget at `slot`. */
|
|
14
|
+
export function isWidgetEligible(install: AppInstall, slot: string): boolean {
|
|
15
|
+
if (install.status !== "active") return false;
|
|
16
|
+
if (!install.scopes.includes("widgets:launch")) return false;
|
|
17
|
+
return install.widgetPlacements.some((placement) => placement.placement === slot);
|
|
18
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure prop-building for `<AppWidgetFrame>` — PrivateApps epic, D5 (Chunk D).
|
|
3
|
+
* Split out of the `.astro` component so the query-param handoff and the
|
|
4
|
+
* sandbox attrs are unit-testable: `vitest.config.ts` deliberately does NOT
|
|
5
|
+
* render `.astro` components (Astro Container API breaks under vitest@2 +
|
|
6
|
+
* astro@7 — see that file's docstring), so render *logic* lives here and
|
|
7
|
+
* rendered output is verified on a live preview instead.
|
|
8
|
+
*
|
|
9
|
+
* Sandbox/loading/referrerpolicy come straight from `widgetFrameAttrs()` —
|
|
10
|
+
* the SAME helper `WidgetFrame.astro` calls for tenant `scripts.json`
|
|
11
|
+
* widgets — so `allow-scripts`/no-`allow-same-origin` is defined in exactly
|
|
12
|
+
* ONE place and can't drift between the two call sites.
|
|
13
|
+
*/
|
|
14
|
+
import { widgetFrameAttrs, type ScriptStrategy, type WidgetFrameAttrs } from "@tot/public-runtime";
|
|
15
|
+
|
|
16
|
+
export interface AppWidgetFrameProps {
|
|
17
|
+
endpoint: string;
|
|
18
|
+
launchToken: string;
|
|
19
|
+
appOrigin: string;
|
|
20
|
+
installId: string;
|
|
21
|
+
title: string;
|
|
22
|
+
strategy?: ScriptStrategy;
|
|
23
|
+
height?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AppWidgetFrameAttrs extends WidgetFrameAttrs {
|
|
27
|
+
src: string;
|
|
28
|
+
title: string;
|
|
29
|
+
height: string;
|
|
30
|
+
/** Stamped directly on the `<iframe>` for the postMessage bridge's origin/install registry. */
|
|
31
|
+
dataWidgetOrigin: string;
|
|
32
|
+
dataInstallId: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Query-param handoff: the launch token rides as `?tot_launch=<jwt>` on the
|
|
37
|
+
* VALIDATED `endpoint` (never a merchant-supplied query string blindly
|
|
38
|
+
* concatenated — `URL` + `searchParams.set` handles an endpoint that already
|
|
39
|
+
* carries its own query string correctly, overwriting only `tot_launch`).
|
|
40
|
+
*/
|
|
41
|
+
export function buildAppWidgetFrameAttrs(props: AppWidgetFrameProps): AppWidgetFrameAttrs {
|
|
42
|
+
const url = new URL(props.endpoint);
|
|
43
|
+
url.searchParams.set("tot_launch", props.launchToken);
|
|
44
|
+
return {
|
|
45
|
+
...widgetFrameAttrs(props.strategy ?? "on-idle"),
|
|
46
|
+
src: url.toString(),
|
|
47
|
+
title: props.title,
|
|
48
|
+
height: props.height ?? "auto",
|
|
49
|
+
dataWidgetOrigin: props.appOrigin,
|
|
50
|
+
dataInstallId: props.installId,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Widget launch-token minting — PrivateApps epic, D5 Chunk B. Mints the
|
|
3
|
+
* short-lived RS256 JWT a platform-managed sandboxed widget iframe carries at
|
|
4
|
+
* SSR render time: Chunk D calls `mintWidgetLaunchToken` while rendering the
|
|
5
|
+
* page for each declared placement, and the resulting token is what the
|
|
6
|
+
* widget's own endpoint receives as proof it was launched by THIS platform,
|
|
7
|
+
* for THIS install, at THIS placement — never a merchant-forged or replayed
|
|
8
|
+
* value.
|
|
9
|
+
*
|
|
10
|
+
* Signed by the SAME gateway key as `../tokenIssuer.ts`'s bearer tokens (via
|
|
11
|
+
* `loadGatewaySigningConfig` — no separate key is provisioned for widgets),
|
|
12
|
+
* but carries `token_use: "widget_launch"` instead of `"access"`.
|
|
13
|
+
* `../tokenVerifier.ts` REQUIRES the latter, so this token can never be
|
|
14
|
+
* replayed as an API bearer token even though both are RS256 JWTs from the
|
|
15
|
+
* same key — that discriminator is what keeps the two token kinds separate.
|
|
16
|
+
*
|
|
17
|
+
* Per the `widgets:launch` scope contract
|
|
18
|
+
* (`docs/private-apps/contract/scopes.json`): the claim set is limited to
|
|
19
|
+
* tenant, env, slot, route, an optional product handle, and locale/theme
|
|
20
|
+
* hints — NEVER secrets or customer PII.
|
|
21
|
+
*/
|
|
22
|
+
import { SignJWT } from "jose";
|
|
23
|
+
import type { AppInstall, WidgetPlacement } from "../registryService.js";
|
|
24
|
+
import {
|
|
25
|
+
loadGatewaySigningConfig,
|
|
26
|
+
APP_GATEWAY_ALG,
|
|
27
|
+
WIDGET_LAUNCH_TOKEN_TTL_SECONDS,
|
|
28
|
+
type GatewayKeyOverrides,
|
|
29
|
+
} from "../gatewayKeys.js";
|
|
30
|
+
|
|
31
|
+
export interface MintWidgetLaunchTokenInput {
|
|
32
|
+
/**
|
|
33
|
+
* Only `installId`/`tenantId`/`env` are read. Narrowed from `AppInstall` to
|
|
34
|
+
* this `Pick` so Chunk D's render path — which has `placements.ts`'s
|
|
35
|
+
* `ResolvedPlacement` (installId/appOrigin/endpoint), not the full
|
|
36
|
+
* `AppInstall` — can build the minimal object itself instead of re-fetching
|
|
37
|
+
* the install; a full `AppInstall` still satisfies this shape unchanged.
|
|
38
|
+
*/
|
|
39
|
+
install: Pick<AppInstall, "installId" | "tenantId" | "env">;
|
|
40
|
+
slot: WidgetPlacement;
|
|
41
|
+
route: string;
|
|
42
|
+
/** Present only when the render context is a specific product. */
|
|
43
|
+
productHandle?: string;
|
|
44
|
+
locale: string;
|
|
45
|
+
theme: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Mint one widget launch token, valid for {@link WIDGET_LAUNCH_TOKEN_TTL_SECONDS}.
|
|
50
|
+
* FAILS CLOSED: propagates `GatewayKeyError` when the gateway signing key is
|
|
51
|
+
* unconfigured — same guarantee `AppTokenIssuer.issueToken` relies on; there
|
|
52
|
+
* is no "render the widget anyway" fallback with an unsigned token.
|
|
53
|
+
*/
|
|
54
|
+
export async function mintWidgetLaunchToken(
|
|
55
|
+
input: MintWidgetLaunchTokenInput,
|
|
56
|
+
overrides: GatewayKeyOverrides = {},
|
|
57
|
+
): Promise<string> {
|
|
58
|
+
const signing = await loadGatewaySigningConfig(overrides);
|
|
59
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
60
|
+
|
|
61
|
+
return new SignJWT({
|
|
62
|
+
tenant: input.install.tenantId,
|
|
63
|
+
env: input.install.env,
|
|
64
|
+
slot: input.slot,
|
|
65
|
+
route: input.route,
|
|
66
|
+
...(input.productHandle !== undefined ? { productHandle: input.productHandle } : {}),
|
|
67
|
+
locale: input.locale,
|
|
68
|
+
theme: input.theme,
|
|
69
|
+
token_use: "widget_launch",
|
|
70
|
+
})
|
|
71
|
+
.setProtectedHeader({ alg: APP_GATEWAY_ALG, kid: signing.kid, typ: "JWT" })
|
|
72
|
+
.setIssuer(signing.issuer)
|
|
73
|
+
.setSubject(input.install.installId)
|
|
74
|
+
// `aud` is the INSTALL, not the tenant: a tenant can have several installs,
|
|
75
|
+
// and binding to the tenant would let a token leaked to one install be
|
|
76
|
+
// accepted by a sibling install's (possibly laxer) verifier. Binding to
|
|
77
|
+
// installId means a verifier that only checks iss/aud already fails closed
|
|
78
|
+
// across installs.
|
|
79
|
+
.setAudience(input.install.installId)
|
|
80
|
+
.setJti(crypto.randomUUID())
|
|
81
|
+
.setIssuedAt(nowSeconds)
|
|
82
|
+
.setExpirationTime(nowSeconds + WIDGET_LAUNCH_TOKEN_TTL_SECONDS)
|
|
83
|
+
.sign(signing.key);
|
|
84
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Widget placement resolution — PrivateApps epic, D5 (Chunk C). Turns "which
|
|
3
|
+
* apps have a live widget at this slot" into the concrete list Chunk D needs
|
|
4
|
+
* to render sandboxed iframes and mint launch tokens: reads
|
|
5
|
+
* `registry.listByTenant`, filters to the requested `env` and
|
|
6
|
+
* {@link isWidgetEligible}, then reads each eligible install's `endpoint`
|
|
7
|
+
* from its VALIDATED `widgetPlacements` field — never `install.manifest`,
|
|
8
|
+
* which is the raw unvalidated manifest snapshot. `widgetPlacements` is the
|
|
9
|
+
* one field `AppRegistryService.install`/`update` already ran through the
|
|
10
|
+
* placement-enum + `widgets:launch` scope + SSRF gate (`registryService.ts`'s
|
|
11
|
+
* `validateWidgetPlacements`), so it's the only safe source for an iframe
|
|
12
|
+
* `src`.
|
|
13
|
+
*
|
|
14
|
+
* Multiple eligible installs for the same slot all render, sorted by
|
|
15
|
+
* `createdAt` ascending (install order) rather than trusting
|
|
16
|
+
* `listByTenant`'s own ordering — that's newest-first, chosen for admin-UI
|
|
17
|
+
* listing, not widget rendering — so this stays correct even if that
|
|
18
|
+
* ordering ever changes.
|
|
19
|
+
*/
|
|
20
|
+
import { isWidgetEligible } from "./eligibility.js";
|
|
21
|
+
import type { AppRegistryService } from "../registryService.js";
|
|
22
|
+
|
|
23
|
+
/** One live widget ready to render: the app's origin, the exact endpoint to launch, and its install. */
|
|
24
|
+
export interface ResolvedPlacement {
|
|
25
|
+
installId: string;
|
|
26
|
+
appOrigin: string;
|
|
27
|
+
endpoint: string;
|
|
28
|
+
title?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve every live widget placement for `slot` on `tenantId` within `env`.
|
|
33
|
+
* Returns `[]` (not an error) when nothing is eligible — same "empty is
|
|
34
|
+
* normal" contract as `emit.ts`'s eligible-target resolution.
|
|
35
|
+
*/
|
|
36
|
+
export async function resolveActivePlacements(
|
|
37
|
+
registry: Pick<AppRegistryService, "listByTenant">,
|
|
38
|
+
tenantId: string,
|
|
39
|
+
env: string,
|
|
40
|
+
slot: string,
|
|
41
|
+
): Promise<ResolvedPlacement[]> {
|
|
42
|
+
const candidates = await registry.listByTenant(tenantId);
|
|
43
|
+
const eligible = candidates
|
|
44
|
+
.filter((install) => install.env === env && isWidgetEligible(install, slot))
|
|
45
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.installId.localeCompare(b.installId));
|
|
46
|
+
|
|
47
|
+
return eligible.map((install) => {
|
|
48
|
+
// Guaranteed to exist: isWidgetEligible already confirmed a matching placement.
|
|
49
|
+
const placement = install.widgetPlacements.find((p) => p.placement === slot)!;
|
|
50
|
+
return {
|
|
51
|
+
installId: install.installId,
|
|
52
|
+
appOrigin: new URL(placement.endpoint).origin,
|
|
53
|
+
endpoint: placement.endpoint,
|
|
54
|
+
...(placement.title !== undefined ? { title: placement.title } : {}),
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Widget slot render composition — PrivateApps epic, D5 (Chunk D). The one
|
|
3
|
+
* call a page/layout makes to turn "render slot X for this request" into the
|
|
4
|
+
* concrete, launch-token-bearing list `AppWidgetFrame` needs: resolves live
|
|
5
|
+
* placements (`placements.ts`), mints each one a launch token
|
|
6
|
+
* (`launchToken.ts`), and — via `sink` — reports which endpoints rendered so
|
|
7
|
+
* the middleware can extend the response CSP's `frame-src`
|
|
8
|
+
* (`@tot/public-runtime`'s `appWidgetCspAdditions`).
|
|
9
|
+
*
|
|
10
|
+
* `launchToken.ts` wants `install.tenantId`/`env`/`installId`; a
|
|
11
|
+
* `ResolvedPlacement` only carries `installId` (see its own docstring for
|
|
12
|
+
* why endpoint/origin must come from the validated placement, not a raw
|
|
13
|
+
* install fetch). tenantId and env are already this call's OWN parameters,
|
|
14
|
+
* so the minimal `install` object is built here rather than re-fetching —
|
|
15
|
+
* no second registry round-trip per placement.
|
|
16
|
+
*
|
|
17
|
+
* The storefront's content-serving env (`CustomizationEnv`, `"live"|"test"`)
|
|
18
|
+
* and the app-install env (`docs/private-apps/contract/openapi.yaml`'s
|
|
19
|
+
* `development|test|sandbox|production` enum) are DIFFERENT vocabularies —
|
|
20
|
+
* an install's env is the operator's own choice at `app.install` time,
|
|
21
|
+
* orthogonal to content publishing. `"live"` maps to the env an operator
|
|
22
|
+
* would use once they trust the app in front of real shoppers:
|
|
23
|
+
* `"production"`; `"test"` maps 1:1 (same word, same "not the real site"
|
|
24
|
+
* intent in both vocabularies).
|
|
25
|
+
*/
|
|
26
|
+
import type { AppRegistryService, WidgetPlacement } from "../registryService.js";
|
|
27
|
+
import { resolveActivePlacements, type ResolvedPlacement } from "./placements.js";
|
|
28
|
+
import { mintWidgetLaunchToken } from "./launchToken.js";
|
|
29
|
+
|
|
30
|
+
export type CustomizationEnv = "live" | "test";
|
|
31
|
+
|
|
32
|
+
/** Map the storefront's content-serving env to the app-install env vocabulary. */
|
|
33
|
+
export function appInstallEnv(customizationEnv: CustomizationEnv): string {
|
|
34
|
+
return customizationEnv === "live" ? "production" : "test";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One live widget, ready for `AppWidgetFrame`: its placement plus a minted launch token. */
|
|
38
|
+
export interface RenderedWidgetSlot extends ResolvedPlacement {
|
|
39
|
+
launchToken: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RenderWidgetSlotInput {
|
|
43
|
+
/**
|
|
44
|
+
* `getRegistryService()`'s return type — a `Response` means the app
|
|
45
|
+
* registry isn't configured in this runtime (missing `STOREFRONT_APPS_DB`
|
|
46
|
+
* binding). That must never break the page it would have decorated, so it
|
|
47
|
+
* resolves to no widgets rather than propagating the error.
|
|
48
|
+
*/
|
|
49
|
+
registry: Pick<AppRegistryService, "listByTenant"> | Response;
|
|
50
|
+
tenantId: string;
|
|
51
|
+
customizationEnv: CustomizationEnv;
|
|
52
|
+
slot: WidgetPlacement;
|
|
53
|
+
route: string;
|
|
54
|
+
/** Present only when the render context is a specific product (product.aside). */
|
|
55
|
+
productHandle?: string;
|
|
56
|
+
locale: string;
|
|
57
|
+
theme: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve + mint every live widget for `slot` on this request. Returns `[]`
|
|
62
|
+
* and NEVER throws — when nothing is eligible, the registry isn't configured,
|
|
63
|
+
* OR resolution/minting fails (e.g. the private-apps tables aren't provisioned
|
|
64
|
+
* in this env, or the signing key is unset). A private-apps failure must never
|
|
65
|
+
* break the storefront page it would only have decorated.
|
|
66
|
+
* When `sink` is given, every rendered placement is also pushed onto it —
|
|
67
|
+
* pass `Astro.locals.activeWidgetPlacements` so the middleware can read the
|
|
68
|
+
* full per-request set back out after `next()` for the CSP `frame-src`.
|
|
69
|
+
*/
|
|
70
|
+
export async function renderWidgetSlot(
|
|
71
|
+
input: RenderWidgetSlotInput,
|
|
72
|
+
sink?: Pick<ResolvedPlacement, "endpoint">[],
|
|
73
|
+
): Promise<RenderedWidgetSlot[]> {
|
|
74
|
+
if (input.registry instanceof Response) return [];
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const env = appInstallEnv(input.customizationEnv);
|
|
78
|
+
const placements = await resolveActivePlacements(input.registry, input.tenantId, env, input.slot);
|
|
79
|
+
|
|
80
|
+
const rendered = await Promise.all(
|
|
81
|
+
placements.map(async (placement) => ({
|
|
82
|
+
...placement,
|
|
83
|
+
launchToken: await mintWidgetLaunchToken({
|
|
84
|
+
install: { installId: placement.installId, tenantId: input.tenantId, env },
|
|
85
|
+
slot: input.slot,
|
|
86
|
+
route: input.route,
|
|
87
|
+
...(input.productHandle !== undefined ? { productHandle: input.productHandle } : {}),
|
|
88
|
+
locale: input.locale,
|
|
89
|
+
theme: input.theme,
|
|
90
|
+
}),
|
|
91
|
+
})),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
sink?.push(...rendered);
|
|
95
|
+
return rendered;
|
|
96
|
+
} catch (cause) {
|
|
97
|
+
// NEVER break the host page (which is every page — Layout's global.footer
|
|
98
|
+
// slot). A registry/query/mint failure — e.g. the private-apps tables
|
|
99
|
+
// aren't provisioned in this env, or the app-gateway signing key is unset —
|
|
100
|
+
// resolves to NO widgets, not a 500. This is the choke point that honors
|
|
101
|
+
// this module's documented never-throws contract for all slot hosts.
|
|
102
|
+
console.warn(
|
|
103
|
+
"[diag:widgets:render] slot resolution failed — rendering no widgets",
|
|
104
|
+
JSON.stringify({
|
|
105
|
+
slot: input.slot,
|
|
106
|
+
reason: cause instanceof Error ? cause.message : String(cause),
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
}
|