@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,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 9470 step-up challenge types + classification — storefront's OWN port.
|
|
3
|
+
*
|
|
4
|
+
* storefront's server calls tot CORE's internal routes directly with machine
|
|
5
|
+
* creds (`totAccessClient.ts`), never through the tot-mcp gateway. That means
|
|
6
|
+
* there is NO `WWW-Authenticate` header on this path (that header only exists
|
|
7
|
+
* on the gateway) — CORE signals a step-up challenge or a pending identity-bind
|
|
8
|
+
* as PLAIN JSON on the response BODY. This module classifies that (already
|
|
9
|
+
* envelope-unwrapped) body into a typed result.
|
|
10
|
+
*
|
|
11
|
+
* This mirrors launchpad's step-up parser/`runWithStepUp` helper in spirit, but
|
|
12
|
+
* is a deliberately independent, storefront-owned module: storefront is a
|
|
13
|
+
* separate repo with disjoint history and cannot import launchpad's kernel.
|
|
14
|
+
*
|
|
15
|
+
* Verified shapes (tot20 @ bb66934f / qa core):
|
|
16
|
+
* STEP-UP challenge (HTTP 401):
|
|
17
|
+
* { ok:false, reason:'step-up-required',
|
|
18
|
+
* obligations:[{ type:'step-up', requiredTier, requiredAcr, maxAuthAgeSeconds, message }] }
|
|
19
|
+
* SUCCESS-PENDING (HTTP 2xx, identity-bind):
|
|
20
|
+
* { ok:true, signinEnabled:false, obligations:[...] }
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** A single obligation tot20 attaches to a step-up/pending response. */
|
|
24
|
+
export interface StepUpObligation {
|
|
25
|
+
type: "step-up" | "dpop-required" | "reauthenticate";
|
|
26
|
+
requiredAcr?: string;
|
|
27
|
+
requiredAmr?: string[];
|
|
28
|
+
requiredTier?: string;
|
|
29
|
+
maxAuthAgeSeconds?: string | number;
|
|
30
|
+
message?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The step-up challenge, flattened from the first `type:'step-up'` obligation. */
|
|
34
|
+
export interface StepUpChallenge {
|
|
35
|
+
obligations: StepUpObligation[];
|
|
36
|
+
requiredAcr?: string;
|
|
37
|
+
requiredTier?: string;
|
|
38
|
+
maxAuthAgeSeconds?: string | number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type StepUpClassification =
|
|
42
|
+
| { kind: "step-up-required"; challenge: StepUpChallenge }
|
|
43
|
+
| { kind: "success-pending"; obligations: StepUpObligation[] }
|
|
44
|
+
| { kind: "none" };
|
|
45
|
+
|
|
46
|
+
const OBLIGATION_TYPES = new Set(["step-up", "dpop-required", "reauthenticate"]);
|
|
47
|
+
|
|
48
|
+
/** Parse + validate an `obligations` field into typed obligations (fail-open to `[]`). */
|
|
49
|
+
function toObligations(raw: unknown): StepUpObligation[] {
|
|
50
|
+
if (!Array.isArray(raw)) return [];
|
|
51
|
+
const out: StepUpObligation[] = [];
|
|
52
|
+
for (const item of raw) {
|
|
53
|
+
if (!item || typeof item !== "object") continue;
|
|
54
|
+
const o = item as Record<string, unknown>;
|
|
55
|
+
if (typeof o.type !== "string" || !OBLIGATION_TYPES.has(o.type)) continue;
|
|
56
|
+
const obligation: StepUpObligation = { type: o.type as StepUpObligation["type"] };
|
|
57
|
+
if (typeof o.requiredAcr === "string") obligation.requiredAcr = o.requiredAcr;
|
|
58
|
+
if (Array.isArray(o.requiredAmr)) {
|
|
59
|
+
obligation.requiredAmr = o.requiredAmr.filter((a): a is string => typeof a === "string");
|
|
60
|
+
}
|
|
61
|
+
if (typeof o.requiredTier === "string") obligation.requiredTier = o.requiredTier;
|
|
62
|
+
if (typeof o.maxAuthAgeSeconds === "string" || typeof o.maxAuthAgeSeconds === "number") {
|
|
63
|
+
obligation.maxAuthAgeSeconds = o.maxAuthAgeSeconds;
|
|
64
|
+
}
|
|
65
|
+
if (typeof o.message === "string") obligation.message = o.message;
|
|
66
|
+
out.push(obligation);
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Classify an UNWRAPPED tot CORE response body (already stripped of the
|
|
73
|
+
* `{ content, metadata }` envelope) into a step-up state. Pure + independently
|
|
74
|
+
* testable — `normalizeFederatedVerify` calls this BEFORE its `b.ok === true`
|
|
75
|
+
* branch so every other body shape falls through unchanged.
|
|
76
|
+
*/
|
|
77
|
+
export function classifyStepUp(body: unknown): StepUpClassification {
|
|
78
|
+
if (!body || typeof body !== "object") return { kind: "none" };
|
|
79
|
+
const b = body as Record<string, unknown>;
|
|
80
|
+
const obligations = toObligations(b.obligations);
|
|
81
|
+
|
|
82
|
+
// SUCCESS-PENDING: identity bound (HTTP 2xx, `ok:true`), sign-in awaits
|
|
83
|
+
// step-up completion. Checked FIRST because `ok:true` bodies never fall into
|
|
84
|
+
// the step-up-required (HTTP 401, `ok:false`) branch below, even though both
|
|
85
|
+
// shapes carry the same `type:'step-up'` obligation riding along.
|
|
86
|
+
if (b.ok === true) {
|
|
87
|
+
return b.signinEnabled === false ? { kind: "success-pending", obligations } : { kind: "none" };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// STEP-UP challenge (`ok:false`): signalled either by the explicit `reason`,
|
|
91
|
+
// or by an obligation of type `step-up` riding along on the response.
|
|
92
|
+
const stepUpObligation = obligations.find((o) => o.type === "step-up");
|
|
93
|
+
if (b.reason === "step-up-required" || stepUpObligation) {
|
|
94
|
+
const first = stepUpObligation ?? obligations[0];
|
|
95
|
+
return {
|
|
96
|
+
kind: "step-up-required",
|
|
97
|
+
challenge: {
|
|
98
|
+
obligations,
|
|
99
|
+
requiredAcr: first?.requiredAcr,
|
|
100
|
+
requiredTier: first?.requiredTier,
|
|
101
|
+
maxAuthAgeSeconds: first?.maxAuthAgeSeconds,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { kind: "none" };
|
|
107
|
+
}
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
*/
|
|
29
29
|
import { readEnv } from "@/lib/env";
|
|
30
30
|
import { diagEmail } from "@/lib/diag/redact";
|
|
31
|
+
import { classifyStepUp, type StepUpChallenge, type StepUpObligation } from "./stepUpChallenge.js";
|
|
31
32
|
|
|
32
33
|
/** `send` is deliberately opaque — it never reveals whether the email was eligible. */
|
|
33
34
|
export interface AccessCodeSendResult {
|
|
@@ -92,6 +93,31 @@ export interface TotAccessClient {
|
|
|
92
93
|
* authenticated caller server-side, never passed in the body.
|
|
93
94
|
*/
|
|
94
95
|
exchangeSignInToken(token: string): Promise<FederatedVerifyResult>;
|
|
96
|
+
/**
|
|
97
|
+
* MINT a fresh single-use sign-in token for `email`, bound to (email, aud = THIS
|
|
98
|
+
* caller's appDomain), by calling tot20's access-code `/mint` as the storefront's
|
|
99
|
+
* OWN machine credential. Unlike `developerInvites` this is NOT operator-grant-gated
|
|
100
|
+
* per tenant — tot20 re-resolves the email's memberships fresh at exchange. Used to
|
|
101
|
+
* RE-ISSUE a dev's identity proof at CLI-sign-in approve time (subjectReissue), where
|
|
102
|
+
* the id_token's `aud` (= the storefront appDomain = the MCP client_id) is exactly
|
|
103
|
+
* what the MCP binds the subject_token to. Fails closed.
|
|
104
|
+
*/
|
|
105
|
+
mintSignInToken(email: string, ttlSeconds?: number): Promise<SignInTokenMintResult>;
|
|
106
|
+
/**
|
|
107
|
+
* STEP-UP COMPLETION (SMS), leg 1: ask tot CORE to send a one-time SMS code to
|
|
108
|
+
* `identifier` (the identifier bound during a `step-up-required` challenge or a
|
|
109
|
+
* `success-pending` identity-bind). Neutral-on-failure — mirrors
|
|
110
|
+
* {@link sendFederatedCode}: a transport error still resolves `{ ok: true }` so
|
|
111
|
+
* the caller never learns whether the identifier was valid/eligible.
|
|
112
|
+
*/
|
|
113
|
+
sendSmsChallenge(identifier: string): Promise<{ ok: true }>;
|
|
114
|
+
/**
|
|
115
|
+
* STEP-UP COMPLETION (SMS), leg 2: verify the code. On success the response
|
|
116
|
+
* carries the FRESHER `id_token` (acr `tot:aal:sms`, fresh `auth_time`) that
|
|
117
|
+
* satisfies the step-up obligation — the caller re-derives the session from it
|
|
118
|
+
* exactly like a normal federated verify. Fail-closed typed on any other shape.
|
|
119
|
+
*/
|
|
120
|
+
verifySmsChallenge(identifier: string, code: string): Promise<SmsVerifyResult>;
|
|
95
121
|
}
|
|
96
122
|
|
|
97
123
|
/**
|
|
@@ -99,9 +125,27 @@ export interface TotAccessClient {
|
|
|
99
125
|
* claims — email + all `tenants[]` — are the sole authority) and the `audience`
|
|
100
126
|
* tot set (= the calling app). `id_token` is present-usually, not guaranteed
|
|
101
127
|
* (signer degradation, contract §6); the consumer fails closed when it's absent.
|
|
128
|
+
*
|
|
129
|
+
* The `step-up-required`/`success-pending` variants carry tot20's RFC 9470
|
|
130
|
+
* obligations (see `@/lib/auth/stepUpChallenge`) instead of a plain failure
|
|
131
|
+
* reason, so the caller can drive the SMS completion loop rather than just
|
|
132
|
+
* showing a generic "invalid code" error.
|
|
102
133
|
*/
|
|
103
134
|
export type FederatedVerifyResult =
|
|
104
135
|
| { ok: true; email: string; audience: string; id_token?: string }
|
|
136
|
+
| { ok: false; reason: "step-up-required"; stepUp: StepUpChallenge }
|
|
137
|
+
| { ok: false; reason: "success-pending"; obligations: StepUpObligation[] }
|
|
138
|
+
| { ok: false; reason: VerifyFailureReason };
|
|
139
|
+
|
|
140
|
+
/** Result of {@link TotAccessClient.mintSignInToken}. The raw `token` is a single-use
|
|
141
|
+
* secret — server-to-server only, NEVER logged. */
|
|
142
|
+
export type SignInTokenMintResult =
|
|
143
|
+
| { ok: true; token: string; expiresAt?: string; ttlSeconds?: number }
|
|
144
|
+
| { ok: false; reason: VerifyFailureReason };
|
|
145
|
+
|
|
146
|
+
/** Result of {@link TotAccessClient.verifySmsChallenge}. */
|
|
147
|
+
export type SmsVerifyResult =
|
|
148
|
+
| { ok: true; idToken: string; email?: string }
|
|
105
149
|
| { ok: false; reason: VerifyFailureReason };
|
|
106
150
|
|
|
107
151
|
// --- HTTP transport seam (injected by tests) --------------------------------
|
|
@@ -286,6 +330,82 @@ export class HttpTotAccessClient implements TotAccessClient {
|
|
|
286
330
|
// authoritative email (no client-supplied fallback needed).
|
|
287
331
|
return normalizeFederatedVerify(body, "");
|
|
288
332
|
}
|
|
333
|
+
|
|
334
|
+
async mintSignInToken(email: string, ttlSeconds?: number): Promise<SignInTokenMintResult> {
|
|
335
|
+
let res: HttpResponseLike;
|
|
336
|
+
try {
|
|
337
|
+
res = await this.transport.post(
|
|
338
|
+
`${this.baseUrl}/api/internal/access-code/mint`,
|
|
339
|
+
{},
|
|
340
|
+
{
|
|
341
|
+
email: email.toLowerCase(),
|
|
342
|
+
...(ttlSeconds != null ? { ttlSeconds } : {}),
|
|
343
|
+
...this.credentialFields(),
|
|
344
|
+
},
|
|
345
|
+
);
|
|
346
|
+
} catch (e) {
|
|
347
|
+
try {
|
|
348
|
+
console.warn("[diag:mint] transport error", e instanceof Error ? e.message : String(e));
|
|
349
|
+
} catch {
|
|
350
|
+
/* never break */
|
|
351
|
+
}
|
|
352
|
+
return { ok: false, reason: "error" };
|
|
353
|
+
}
|
|
354
|
+
let body: unknown = "(unread)";
|
|
355
|
+
try {
|
|
356
|
+
body = await res.json();
|
|
357
|
+
} catch {
|
|
358
|
+
body = "(non-JSON)";
|
|
359
|
+
}
|
|
360
|
+
const result = normalizeMint(body);
|
|
361
|
+
// DIAGNOSTIC (secret-safe): on a non-ok mint, log tot20's status + body so a
|
|
362
|
+
// 404 (path) / 401·403 (cred not accepted here) / 5xx is diagnosable. The `token`
|
|
363
|
+
// (only present on success) is never in this branch, so nothing sensitive leaks.
|
|
364
|
+
if (!result.ok) {
|
|
365
|
+
try {
|
|
366
|
+
console.warn("[diag:mint]", JSON.stringify({ status: res.status, body }));
|
|
367
|
+
} catch {
|
|
368
|
+
/* never break */
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return result;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async sendSmsChallenge(identifier: string): Promise<{ ok: true }> {
|
|
375
|
+
// Neutral by contract, mirroring sendFederatedCode: a transport error still
|
|
376
|
+
// resolves { ok: true } so the caller never learns eligibility. Best-effort
|
|
377
|
+
// server-side diagnostic only.
|
|
378
|
+
try {
|
|
379
|
+
await this.transport.post(
|
|
380
|
+
`${this.baseUrl}/api/internal/access-code/identity-send`,
|
|
381
|
+
{},
|
|
382
|
+
{ identifier, channel: "sms", ...this.credentialFields() },
|
|
383
|
+
);
|
|
384
|
+
} catch {
|
|
385
|
+
/* swallow — neutral response */
|
|
386
|
+
}
|
|
387
|
+
return { ok: true };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async verifySmsChallenge(identifier: string, code: string): Promise<SmsVerifyResult> {
|
|
391
|
+
let res: HttpResponseLike;
|
|
392
|
+
try {
|
|
393
|
+
res = await this.transport.post(
|
|
394
|
+
`${this.baseUrl}/api/internal/access-code/identity-verify`,
|
|
395
|
+
{},
|
|
396
|
+
{ identifier, channel: "sms", code, ...this.credentialFields() },
|
|
397
|
+
);
|
|
398
|
+
} catch {
|
|
399
|
+
return { ok: false, reason: "error" };
|
|
400
|
+
}
|
|
401
|
+
let body: unknown;
|
|
402
|
+
try {
|
|
403
|
+
body = await res.json();
|
|
404
|
+
} catch {
|
|
405
|
+
return { ok: false, reason: "error" };
|
|
406
|
+
}
|
|
407
|
+
return normalizeSmsVerify(body);
|
|
408
|
+
}
|
|
289
409
|
}
|
|
290
410
|
|
|
291
411
|
/** Parse tot20's FEDERATED verify response into a typed result (fail closed). */
|
|
@@ -296,6 +416,18 @@ export function normalizeFederatedVerify(
|
|
|
296
416
|
// Unwrap the `{ content, metadata }` envelope when present (see normalizeVerify).
|
|
297
417
|
const raw = (body ?? {}) as Record<string, unknown>;
|
|
298
418
|
const b = (raw.content && typeof raw.content === "object" ? raw.content : raw) as Record<string, unknown>;
|
|
419
|
+
|
|
420
|
+
// Classify BEFORE the `b.ok === true` branch: a step-up challenge is `ok:false`
|
|
421
|
+
// but success-pending is `ok:true` — both must be caught here, ahead of the
|
|
422
|
+
// ordinary success/failure handling below.
|
|
423
|
+
const stepUp = classifyStepUp(b);
|
|
424
|
+
if (stepUp.kind === "step-up-required") {
|
|
425
|
+
return { ok: false, reason: "step-up-required", stepUp: stepUp.challenge };
|
|
426
|
+
}
|
|
427
|
+
if (stepUp.kind === "success-pending") {
|
|
428
|
+
return { ok: false, reason: "success-pending", obligations: stepUp.obligations };
|
|
429
|
+
}
|
|
430
|
+
|
|
299
431
|
if (b.ok === true) {
|
|
300
432
|
const idToken =
|
|
301
433
|
typeof b.id_token === "string"
|
|
@@ -319,6 +451,51 @@ export function normalizeFederatedVerify(
|
|
|
319
451
|
};
|
|
320
452
|
}
|
|
321
453
|
|
|
454
|
+
/** Parse tot20's access-code `/mint` response into a typed result (fail closed). */
|
|
455
|
+
export function normalizeMint(body: unknown): SignInTokenMintResult {
|
|
456
|
+
const raw = (body ?? {}) as Record<string, unknown>;
|
|
457
|
+
const b = (raw.content && typeof raw.content === "object" ? raw.content : raw) as Record<string, unknown>;
|
|
458
|
+
if (b.ok === true && typeof b.token === "string" && b.token) {
|
|
459
|
+
const result: SignInTokenMintResult = { ok: true, token: b.token };
|
|
460
|
+
if (typeof b.expiresAt === "string") result.expiresAt = b.expiresAt;
|
|
461
|
+
if (typeof b.ttlSeconds === "number") result.ttlSeconds = b.ttlSeconds;
|
|
462
|
+
return result;
|
|
463
|
+
}
|
|
464
|
+
const reason = b.reason;
|
|
465
|
+
const known: VerifyFailureReason[] = ["invalid", "expired", "too_many_attempts", "error"];
|
|
466
|
+
return {
|
|
467
|
+
ok: false,
|
|
468
|
+
reason: known.includes(reason as VerifyFailureReason) ? (reason as VerifyFailureReason) : "error",
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Parse tot20's SMS step-up-completion verify response (fail closed). */
|
|
473
|
+
export function normalizeSmsVerify(body: unknown): SmsVerifyResult {
|
|
474
|
+
// Same envelope-unwrap convention as normalizeVerify/normalizeFederatedVerify.
|
|
475
|
+
const raw = (body ?? {}) as Record<string, unknown>;
|
|
476
|
+
const b = (raw.content && typeof raw.content === "object" ? raw.content : raw) as Record<string, unknown>;
|
|
477
|
+
if (b.ok === true) {
|
|
478
|
+
const idToken =
|
|
479
|
+
typeof b.id_token === "string"
|
|
480
|
+
? b.id_token
|
|
481
|
+
: typeof b.assertion === "string"
|
|
482
|
+
? b.assertion
|
|
483
|
+
: undefined;
|
|
484
|
+
// The fresher credential is the SOLE authority for completing step-up — fail
|
|
485
|
+
// closed if it's absent rather than trusting an unsigned "ok".
|
|
486
|
+
if (!idToken) return { ok: false, reason: "error" };
|
|
487
|
+
const result: SmsVerifyResult = { ok: true, idToken };
|
|
488
|
+
if (typeof b.email === "string") result.email = b.email.toLowerCase();
|
|
489
|
+
return result;
|
|
490
|
+
}
|
|
491
|
+
const reason = b.reason;
|
|
492
|
+
const known: VerifyFailureReason[] = ["invalid", "expired", "too_many_attempts", "error"];
|
|
493
|
+
return {
|
|
494
|
+
ok: false,
|
|
495
|
+
reason: known.includes(reason as VerifyFailureReason) ? (reason as VerifyFailureReason) : "invalid",
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
322
499
|
/** Parse + validate tot20's verify response into our typed result (fail closed). */
|
|
323
500
|
export function normalizeVerify(
|
|
324
501
|
body: unknown,
|
|
@@ -379,6 +556,8 @@ export class MockTotAccessClient implements TotAccessClient {
|
|
|
379
556
|
/** key = `${resource}${emailLower}` → roster entry. */
|
|
380
557
|
private roster = new Map<string, MockRosterEntry>();
|
|
381
558
|
sent: Array<{ email: string; resource: string }> = [];
|
|
559
|
+
/** Identifiers the mock was asked to SMS-challenge (step-up completion). */
|
|
560
|
+
smsSent: string[] = [];
|
|
382
561
|
|
|
383
562
|
constructor(
|
|
384
563
|
private readonly code = "123456",
|
|
@@ -472,6 +651,35 @@ export class MockTotAccessClient implements TotAccessClient {
|
|
|
472
651
|
}
|
|
473
652
|
return { ok: false, reason: "invalid" };
|
|
474
653
|
}
|
|
654
|
+
|
|
655
|
+
async mintSignInToken(email: string): Promise<SignInTokenMintResult> {
|
|
656
|
+
// Mirror the mock exchange's accepted shape ("mock|<tenant>|<email>") so a
|
|
657
|
+
// mint→exchange round-trip resolves back to this email in tests / local dev.
|
|
658
|
+
const token = Buffer.from(`mock|storefront|${email.toLowerCase()}`, "utf8").toString("base64url");
|
|
659
|
+
return { ok: true, token };
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
async sendSmsChallenge(identifier: string): Promise<{ ok: true }> {
|
|
663
|
+
this.smsSent.push(identifier);
|
|
664
|
+
return { ok: true }; // neutral regardless of eligibility
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async verifySmsChallenge(identifier: string, code: string): Promise<SmsVerifyResult> {
|
|
668
|
+
if (code !== this.code) return { ok: false, reason: "invalid" };
|
|
669
|
+
// The mock has no private key material; it returns a deterministic
|
|
670
|
+
// placeholder token unless a real signer is wired in an integration test.
|
|
671
|
+
const idToken = this.signIdToken
|
|
672
|
+
? await this.signIdToken({
|
|
673
|
+
email: identifier.toLowerCase(),
|
|
674
|
+
resource: "",
|
|
675
|
+
roles: [],
|
|
676
|
+
capability: "member",
|
|
677
|
+
})
|
|
678
|
+
: `mock-sms-id-token|${identifier.toLowerCase()}`;
|
|
679
|
+
const result: SmsVerifyResult = { ok: true, idToken };
|
|
680
|
+
if (identifier.includes("@")) result.email = identifier.toLowerCase();
|
|
681
|
+
return result;
|
|
682
|
+
}
|
|
475
683
|
}
|
|
476
684
|
|
|
477
685
|
/** Collapse a role array to one effective capability (mirrors tot20's precedence). */
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { BlogPost, BlogPostSummary } from "./types.js";
|
|
2
|
+
|
|
3
|
+
const indexGlobs = import.meta.glob<BlogPostSummary[]>(
|
|
4
|
+
"../../../../../tenants/*/content/blog/index.json",
|
|
5
|
+
{ import: "default" },
|
|
6
|
+
);
|
|
7
|
+
const postGlobs = import.meta.glob<BlogPost>(
|
|
8
|
+
"../../../../../tenants/*/content/blog/posts/*.json",
|
|
9
|
+
{ import: "default" },
|
|
10
|
+
);
|
|
11
|
+
const redirectGlobs = import.meta.glob<Record<string, string>>(
|
|
12
|
+
"../../../../../tenants/*/content/blog/redirects.json",
|
|
13
|
+
{ import: "default" },
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
function tenantKey(keys: string[], tenantId: string, suffix: string): string | undefined {
|
|
17
|
+
const segment = `/tenants/${tenantId}/content/blog/`;
|
|
18
|
+
return keys.find((key) => key.includes(segment) && key.endsWith(suffix));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function listBlogPosts(tenantId: string): Promise<BlogPostSummary[]> {
|
|
22
|
+
const key = tenantKey(Object.keys(indexGlobs), tenantId, "/index.json")
|
|
23
|
+
?? Object.keys(indexGlobs).find((candidate) => candidate.includes(`/tenants/${tenantId}/content/blog/index.json`));
|
|
24
|
+
return key ? indexGlobs[key]!() : [];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function getBlogPost(tenantId: string, slug: string): Promise<BlogPost | null> {
|
|
28
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) return null;
|
|
29
|
+
const key = tenantKey(Object.keys(postGlobs), tenantId, `/posts/${slug}.json`);
|
|
30
|
+
return key ? postGlobs[key]!() : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function resolveBlogRedirect(tenantId: string, slug: string): Promise<string | null> {
|
|
34
|
+
const key = tenantKey(Object.keys(redirectGlobs), tenantId, "/redirects.json")
|
|
35
|
+
?? Object.keys(redirectGlobs).find((candidate) => candidate.includes(`/tenants/${tenantId}/content/blog/redirects.json`));
|
|
36
|
+
if (!key) return null;
|
|
37
|
+
const redirects = await redirectGlobs[key]!();
|
|
38
|
+
return redirects[slug] ?? null;
|
|
39
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface BlogImage {
|
|
2
|
+
src: string;
|
|
3
|
+
alt: string;
|
|
4
|
+
width?: number;
|
|
5
|
+
height?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface BlogPostSummary {
|
|
9
|
+
source: { platform: "wordpress"; id: string };
|
|
10
|
+
slug: string;
|
|
11
|
+
title: string;
|
|
12
|
+
seoTitle: string;
|
|
13
|
+
description: string;
|
|
14
|
+
excerpt: string;
|
|
15
|
+
publishedAt: string;
|
|
16
|
+
modifiedAt: string;
|
|
17
|
+
author: string;
|
|
18
|
+
categories: string[];
|
|
19
|
+
tags: string[];
|
|
20
|
+
featuredImage?: BlogImage;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface BlogPost extends BlogPostSummary {
|
|
24
|
+
oldSlugs: string[];
|
|
25
|
+
contentHtml: string;
|
|
26
|
+
}
|
|
@@ -47,5 +47,43 @@ export async function readCartSecret(
|
|
|
47
47
|
commerce: ResolvedManagedCheckout,
|
|
48
48
|
): Promise<string | undefined> {
|
|
49
49
|
if (commerce.mode !== "signed") return undefined;
|
|
50
|
-
|
|
50
|
+
const secret = await readEnv("FOXY_CART_SECRET");
|
|
51
|
+
return secret && secret.length ? secret : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The outcome of resolving whether — and how — a tenant may build add-to-cart
|
|
56
|
+
* forms this request.
|
|
57
|
+
* - `enabled: true, secret: undefined` → `unsigned-demo` (no secret needed).
|
|
58
|
+
* - `enabled: true, secret: "…"` → `signed` with its validation secret.
|
|
59
|
+
* - `enabled: false` → FAIL CLOSED: signed mode is configured
|
|
60
|
+
* but the secret is missing, so NO cart may be built. The caller must render
|
|
61
|
+
* no buy form (never a tamperable / broken one).
|
|
62
|
+
*/
|
|
63
|
+
export interface CartSigningResolution {
|
|
64
|
+
enabled: boolean;
|
|
65
|
+
secret?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolve cart signing for a request, failing CLOSED for production signed
|
|
70
|
+
* checkout. Signed mode is the real-merchant configuration, so its
|
|
71
|
+
* cart-validation secret is REQUIRED: without it we refuse to emit any buy form
|
|
72
|
+
* (a signed form we can't sign would be tamperable; an unsigned one against a
|
|
73
|
+
* validation-ON store would be broken). Missing-secret in signed mode is a
|
|
74
|
+
* deployment misconfiguration, so we log it loudly (`console.error`) in every
|
|
75
|
+
* environment — it should never happen on a live store and must be caught fast.
|
|
76
|
+
* `unsigned-demo` needs no secret and is always enabled.
|
|
77
|
+
*/
|
|
78
|
+
export async function resolveCartSigning(
|
|
79
|
+
commerce: ResolvedManagedCheckout,
|
|
80
|
+
): Promise<CartSigningResolution> {
|
|
81
|
+
if (commerce.mode !== "signed") return { enabled: true };
|
|
82
|
+
const secret = await readCartSecret(commerce);
|
|
83
|
+
if (secret) return { enabled: true, secret };
|
|
84
|
+
console.error(
|
|
85
|
+
"[storefront] signed checkout is configured but FOXY_CART_SECRET is missing — " +
|
|
86
|
+
"buy forms disabled (fail-closed). Set the cart-validation secret for this environment.",
|
|
87
|
+
);
|
|
88
|
+
return { enabled: false };
|
|
51
89
|
}
|
|
@@ -220,6 +220,17 @@ export interface TenantChrome {
|
|
|
220
220
|
* cutover of a SHIPPED tenant stays a deliberate, gated step.
|
|
221
221
|
*/
|
|
222
222
|
sharedChrome?: boolean;
|
|
223
|
+
/**
|
|
224
|
+
* OPT-IN: also link the shared commerce-MARKETING stylesheet
|
|
225
|
+
* (/shared/commerce-marketing.css, unit F5) into this tenant's wrapper — the
|
|
226
|
+
* raw-page BODY layer (page-hero / prose / callouts / value & tier cards /
|
|
227
|
+
* contact form / FAQ / CTA band) its content fragments repeat. Linked AFTER the
|
|
228
|
+
* chrome sheet so its page-level variants win. Absent/false ⇒ not linked, so a
|
|
229
|
+
* SHIPPED tenant that still carries its own page-local <style> never
|
|
230
|
+
* double-loads or silently flips until its integration unit strips that CSS and
|
|
231
|
+
* adopts (same gated-cutover discipline as `sharedChrome`).
|
|
232
|
+
*/
|
|
233
|
+
sharedMarketingCss?: boolean;
|
|
223
234
|
/** <html lang> (defaults to "en"). */
|
|
224
235
|
lang?: string;
|
|
225
236
|
head?: ChromeHead;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compliance enforcement ADAPTER — the request-scoped bridge between a resolved
|
|
3
|
+
* tenant and the pure `@tot/public-runtime` enforcement engine. It:
|
|
4
|
+
* - decides whether a tenant is REGULATED (age/nicotine),
|
|
5
|
+
* - resolves its data-driven ruleset (fail-closed: regulated + no ruleset ⇒ null),
|
|
6
|
+
* - wires the LIVE verification provider from operational env
|
|
7
|
+
* (`TOT_VERIFICATION_PROVIDER`) — absent ⇒ the verification gate fails closed,
|
|
8
|
+
* - and produces the enforcement→evidence signals the promote gate consumes.
|
|
9
|
+
*
|
|
10
|
+
* The verification provider being external config is deliberate: this repo owns
|
|
11
|
+
* the GATE and its safe default (BLOCK), not the provider integration. Setting
|
|
12
|
+
* `TOT_VERIFICATION_PROVIDER=tot` (once the live ToT verification flow is wired)
|
|
13
|
+
* is what flips a regulated tenant from "blocked/not-promotable" to enforceable.
|
|
14
|
+
*/
|
|
15
|
+
import type {
|
|
16
|
+
TenantConfig,
|
|
17
|
+
ComplianceRuleset,
|
|
18
|
+
EnforcementEvidenceSignals,
|
|
19
|
+
} from "@tot/public-runtime";
|
|
20
|
+
import { enforcementSignals } from "@tot/public-runtime";
|
|
21
|
+
import { readEnv } from "@/lib/env";
|
|
22
|
+
import { COMPLIANCE_RULESETS } from "@/config/compliance/rulesets";
|
|
23
|
+
|
|
24
|
+
/** True when the tenant sells age/nicotine-restricted goods (drives fail-closed). */
|
|
25
|
+
export function isRegulatedTenant(
|
|
26
|
+
tenant: Pick<TenantConfig, "compliance">,
|
|
27
|
+
): boolean {
|
|
28
|
+
const c = tenant.compliance;
|
|
29
|
+
return !!c && ((c.minAge != null && c.minAge > 0) || !!c.nicotineWarning);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Provider values that denote a NON-LIVE, test/sandbox verification profile. This
|
|
34
|
+
* is the mode the 8501 demo/preview runs in: there is no live identity provider,
|
|
35
|
+
* so the storefront's front-door 21+ affirmation is allowed to SIMULATE a passed
|
|
36
|
+
* age/identity verification — and ONLY then — so the regulated checkout can be
|
|
37
|
+
* exercised end-to-end for the demo without a real provider or any secret.
|
|
38
|
+
*
|
|
39
|
+
* This does NOT weaken the F12 invariant that a soft affirmation is never real
|
|
40
|
+
* verification: the simulation is gated on an EXPLICIT sandbox provider value set
|
|
41
|
+
* per-environment (test-tier only). Production leaves `TOT_VERIFICATION_PROVIDER`
|
|
42
|
+
* UNSET (→ fail-closed) or points it at the live provider (whose own outcome is
|
|
43
|
+
* then required); in neither case does affirmation substitute for verification.
|
|
44
|
+
*/
|
|
45
|
+
export const SANDBOX_VERIFICATION_PROVIDERS: ReadonlySet<string> = new Set([
|
|
46
|
+
"sandbox",
|
|
47
|
+
"test",
|
|
48
|
+
"demo",
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
/** True when the resolved verification provider is an explicit sandbox/test profile. */
|
|
52
|
+
export function isSandboxVerificationProvider(
|
|
53
|
+
provider: string | undefined | null,
|
|
54
|
+
): boolean {
|
|
55
|
+
return !!provider && SANDBOX_VERIFICATION_PROVIDERS.has(provider.toLowerCase());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The PACT reporting CAPTURE sink status. The record BUILDER
|
|
60
|
+
* (`buildPactReportRecord`) ships in this unit and the capture point is wired at
|
|
61
|
+
* order intake; the durable per-state export/FILING is a documented follow-up. We
|
|
62
|
+
* report the capture path as wired so the promote gate doesn't hard-block on the
|
|
63
|
+
* filing (merchant/counsel-owned) while still requiring the per-order data model.
|
|
64
|
+
*/
|
|
65
|
+
const PACT_REPORTING_CAPTURE_WIRED = true;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve a tenant's compliance ruleset, or `null` when it can't be resolved for a
|
|
69
|
+
* regulated tenant (the caller must then fail closed). Wires the live verification
|
|
70
|
+
* provider from env. An unregulated tenant returns `null` too — callers key on
|
|
71
|
+
* {@link isRegulatedTenant} to distinguish "no ruleset needed" from "fail closed".
|
|
72
|
+
*/
|
|
73
|
+
export async function resolveTenantRuleset(
|
|
74
|
+
tenant: Pick<TenantConfig, "tenant_id" | "compliance">,
|
|
75
|
+
): Promise<ComplianceRuleset | null> {
|
|
76
|
+
const base = COMPLIANCE_RULESETS[tenant.tenant_id];
|
|
77
|
+
if (!base) return null;
|
|
78
|
+
const provider = (await readEnv("TOT_VERIFICATION_PROVIDER")) || undefined;
|
|
79
|
+
return {
|
|
80
|
+
...base,
|
|
81
|
+
verification: { ...base.verification, provider },
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Build the enforcement→evidence signals for a tenant (for the promote gate). */
|
|
86
|
+
export async function resolveEnforcementEvidenceSignals(
|
|
87
|
+
tenant: Pick<TenantConfig, "tenant_id" | "compliance">,
|
|
88
|
+
): Promise<EnforcementEvidenceSignals> {
|
|
89
|
+
const regulated = isRegulatedTenant(tenant);
|
|
90
|
+
const ruleset = await resolveTenantRuleset(tenant);
|
|
91
|
+
return enforcementSignals(ruleset, {
|
|
92
|
+
regulated,
|
|
93
|
+
reportingCaptureWired: PACT_REPORTING_CAPTURE_WIRED,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Callout contract (spec §F4). A tone-styled aside — the structured, data-driven
|
|
3
|
+
* form of the repeated `.disclaimer` / `.warn-band` page patterns that the f05
|
|
4
|
+
* shared sheet (public/shared/commerce-marketing.css) defines for the raw-HTML
|
|
5
|
+
* path. A tenant supplies a {@link CalloutModel}; <Callout> paints it from f06
|
|
6
|
+
* tokens with the a11y semantics baked in (see {@link calloutPresentation}), so
|
|
7
|
+
* no tenant hand-authors the callout markup + its role/aria.
|
|
8
|
+
*
|
|
9
|
+
* Tones map to the same token vocabulary the f05 sheet uses:
|
|
10
|
+
* info → `--color-info` (the sheet's `.disclaimer`, e.g. an FYI)
|
|
11
|
+
* warning → `--color-warning` (the sheet's `.warn-band`, a caution)
|
|
12
|
+
* legal → `--color-warning` (compliance/legal caution; distinct label)
|
|
13
|
+
* success → `--color-success` (a positive confirmation)
|
|
14
|
+
* Node-free + presentation-free so the contract tests and the .astro widget
|
|
15
|
+
* share ONE definition.
|
|
16
|
+
*/
|
|
17
|
+
import type { RichText } from "./richtext.js";
|
|
18
|
+
|
|
19
|
+
export type CalloutTone = "info" | "warning" | "legal" | "success";
|
|
20
|
+
|
|
21
|
+
export interface CalloutModel {
|
|
22
|
+
tone: CalloutTone;
|
|
23
|
+
/** Optional bold lead line above the body. */
|
|
24
|
+
title?: string;
|
|
25
|
+
/** The callout copy (inline links/emphasis allowed; never HTML). */
|
|
26
|
+
body: RichText;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The a11y + styling recipe for a tone. Kept here (not in the component) so the
|
|
31
|
+
* semantics are one testable mapping: `role` and `label` drive the assistive
|
|
32
|
+
* announcement, `tokenVar` is the accent/tint token, `iconPath` is the SVG path.
|
|
33
|
+
* `role` stays "note" for all tones — these are STATIC page asides, not live
|
|
34
|
+
* regions, so "alert" would be wrong (it would interrupt a screen reader).
|
|
35
|
+
*/
|
|
36
|
+
export interface CalloutPresentation {
|
|
37
|
+
role: "note";
|
|
38
|
+
/** aria-label for the callout region (announces its nature). */
|
|
39
|
+
label: string;
|
|
40
|
+
/** The contract token this tone tints from (accent + border + icon). */
|
|
41
|
+
tokenVar: `--color-${string}`;
|
|
42
|
+
/** SVG path `d` for the tone's leading icon (info "i" / caution "!" / check). */
|
|
43
|
+
iconPath: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Simple, self-contained 24-grid icon paths (stroke, currentColor via token).
|
|
47
|
+
const ICON_INFO =
|
|
48
|
+
"M12 16v-5M12 8h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z";
|
|
49
|
+
const ICON_CAUTION =
|
|
50
|
+
"M12 9v4m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z";
|
|
51
|
+
const ICON_CHECK = "M20 6 9 17l-5-5";
|
|
52
|
+
|
|
53
|
+
const PRESENTATION: Record<CalloutTone, CalloutPresentation> = {
|
|
54
|
+
info: { role: "note", label: "Note", tokenVar: "--color-info", iconPath: ICON_INFO },
|
|
55
|
+
warning: {
|
|
56
|
+
role: "note",
|
|
57
|
+
label: "Important",
|
|
58
|
+
tokenVar: "--color-warning",
|
|
59
|
+
iconPath: ICON_CAUTION,
|
|
60
|
+
},
|
|
61
|
+
legal: {
|
|
62
|
+
role: "note",
|
|
63
|
+
label: "Legal notice",
|
|
64
|
+
tokenVar: "--color-warning",
|
|
65
|
+
iconPath: ICON_CAUTION,
|
|
66
|
+
},
|
|
67
|
+
success: {
|
|
68
|
+
role: "note",
|
|
69
|
+
label: "Confirmed",
|
|
70
|
+
tokenVar: "--color-success",
|
|
71
|
+
iconPath: ICON_CHECK,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** The a11y + tone recipe for a callout tone. */
|
|
76
|
+
export function calloutPresentation(tone: CalloutTone): CalloutPresentation {
|
|
77
|
+
return PRESENTATION[tone] ?? PRESENTATION.info;
|
|
78
|
+
}
|