@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,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin session-entry resolution (unit dg6) — the SERVER-SIDE decision that puts
|
|
3
|
+
* an authenticated owner/team member into the CORRECT tenant admin context, and
|
|
4
|
+
* keeps a non-member / unauthenticated visitor on the legitimate sign-in gate.
|
|
5
|
+
*
|
|
6
|
+
* NARROW to auth/session ENTRY: it CONSUMES the go-live admin tab UX
|
|
7
|
+
* (`pages/admin.astro` + `components/admin/AdminPublishTab.astro`) and u10's
|
|
8
|
+
* ship-capability model (`change/shipCapabilities` + `change/route` decideIsOwner)
|
|
9
|
+
* rather than reimplementing either. Everything it resolves is bound to the
|
|
10
|
+
* RESOLVED tenant, never taken from client input, and never requires a
|
|
11
|
+
* bearer/operator secret.
|
|
12
|
+
*
|
|
13
|
+
* Two things are resolved, both host-bound:
|
|
14
|
+
* - `hostCapability` — the viewer's owner/admin/member standing FOR THIS host,
|
|
15
|
+
* from {@link sessionHostCapability} (tenants[] membership → resource-scoped
|
|
16
|
+
* session → live staff selection). null ⇒ not a member of this tenant.
|
|
17
|
+
* - `isOwnerViewer` / `shipCapabilities` — u10's {@link decideIsOwner} +
|
|
18
|
+
* {@link resolveShipCapabilities} over that host-bound capability. This is the
|
|
19
|
+
* OWNER VIEWER identity (decision `owner-identity-narrow-viewer-not-bearer-console`):
|
|
20
|
+
* a signed-in owner/admin viewer over the resolved tenant, NOT the bearer
|
|
21
|
+
* operator console. The ship-axis `capability` is the fail-closed
|
|
22
|
+
* `preview-access` — a viewer session carries no operator ship grant, so the
|
|
23
|
+
* owner is the ONLY source of `shipOnBehalf`/`apexCutover` here.
|
|
24
|
+
*
|
|
25
|
+
* The distinguishing logic the go-live "definition of done" demands: `admitted`
|
|
26
|
+
* separates an authenticated OWNER/member (a host-bound capability, or ToT-staff
|
|
27
|
+
* scope) — who must NEVER see the "Coming soon" holding page at their own admin —
|
|
28
|
+
* from an authenticated NON-member and an anonymous visitor, who legitimately do
|
|
29
|
+
* not enter. `principal` labels which of those four cases this is.
|
|
30
|
+
*/
|
|
31
|
+
import type { SessionRecord } from "./session.js";
|
|
32
|
+
import { sessionHostCapability } from "./loginGate.js";
|
|
33
|
+
import { decideIsOwner } from "@/lib/change/route";
|
|
34
|
+
import {
|
|
35
|
+
resolveShipCapabilities,
|
|
36
|
+
type ShipCapabilities,
|
|
37
|
+
} from "@/lib/change/shipCapabilities";
|
|
38
|
+
import type { Capability } from "@/lib/change/types";
|
|
39
|
+
import { OWNER_CAPABILITIES } from "@/lib/grants/session";
|
|
40
|
+
|
|
41
|
+
/** Which entry case a viewer is in, for chrome + the DoD test matrix. */
|
|
42
|
+
export type AdminEntryPrincipal =
|
|
43
|
+
| "owner"
|
|
44
|
+
| "admin"
|
|
45
|
+
| "member"
|
|
46
|
+
| "staff"
|
|
47
|
+
| "authenticated-non-member"
|
|
48
|
+
| "anonymous";
|
|
49
|
+
|
|
50
|
+
export interface AdminEntryDecision {
|
|
51
|
+
/**
|
|
52
|
+
* True when the viewer is an owner/member of this tenant (host-bound capability)
|
|
53
|
+
* or a ToT-staff viewer — i.e. reaches the admin context, never the gate. False
|
|
54
|
+
* for an authenticated non-member and an anonymous visitor (the legit gate).
|
|
55
|
+
*/
|
|
56
|
+
admitted: boolean;
|
|
57
|
+
/** The owner/admin/member standing bound to this host, or null. */
|
|
58
|
+
hostCapability: string | null;
|
|
59
|
+
/** u10: the acting viewer IS the tenant owner/admin (superset of ship-on-behalf). */
|
|
60
|
+
isOwnerViewer: boolean;
|
|
61
|
+
/** u10: the two derived ship-gate capabilities for this viewer entry. */
|
|
62
|
+
shipCapabilities: ShipCapabilities;
|
|
63
|
+
/** Which of the four entry cases this is. */
|
|
64
|
+
principal: AdminEntryPrincipal;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve the admin session-entry decision for a viewer over the resolved tenant.
|
|
69
|
+
* PURE (no I/O, no Astro) so the four-way distinction is unit-testable in
|
|
70
|
+
* isolation and stays identical across every admin route.
|
|
71
|
+
*
|
|
72
|
+
* @param record the viewer's full session (from `readViewerSession`), or null
|
|
73
|
+
* @param hostResource the resolved tenant's appDomain (server-derived, never input)
|
|
74
|
+
* @param nowSeconds epoch seconds, for the staff-selection expiry check
|
|
75
|
+
* @param shipGrant OPTIONAL: the signed-in developer's REAL, LIVE, owner-revocable
|
|
76
|
+
* ship grant over this tenant, resolved server-side from the MCP subject-capability
|
|
77
|
+
* store (`resolveViewerShipCapability`). Feeds the ship AXIS so a ship-granted
|
|
78
|
+
* developer (not owner/admin) gets `shipOnBehalf: true` — but NEVER apex-cutover
|
|
79
|
+
* (owner-only). Absent ⇒ the fail-closed `preview-access` (no ship). NEVER derive
|
|
80
|
+
* this from the coarse role or a client header.
|
|
81
|
+
*/
|
|
82
|
+
export function resolveAdminEntry(input: {
|
|
83
|
+
record: SessionRecord | null | undefined;
|
|
84
|
+
hostResource: string;
|
|
85
|
+
nowSeconds: number;
|
|
86
|
+
shipGrant?: Capability;
|
|
87
|
+
}): AdminEntryDecision {
|
|
88
|
+
const { record, hostResource, nowSeconds, shipGrant } = input;
|
|
89
|
+
const hostCapability = sessionHostCapability(record ?? null, hostResource, nowSeconds);
|
|
90
|
+
const staffScope = Array.isArray(record?.staff) && record!.staff.length > 0;
|
|
91
|
+
const admitted = hostCapability !== null || staffScope;
|
|
92
|
+
|
|
93
|
+
// u10 owner resolution — the OWNER VIEWER, not the bearer console: authorized
|
|
94
|
+
// (a member/staff viewer of THIS tenant) AND holding an owner/admin capability.
|
|
95
|
+
const isOwnerViewer = decideIsOwner({
|
|
96
|
+
authorized: admitted,
|
|
97
|
+
viewerCapability: hostCapability ?? undefined,
|
|
98
|
+
});
|
|
99
|
+
// The ship axis: a viewer entry carries no operator ship grant EXCEPT an explicit,
|
|
100
|
+
// LIVE ship-on-behalf grant a developer may hold (passed in as `shipGrant`).
|
|
101
|
+
// Fail-closed default is `preview-access`. Only an admitted viewer's grant counts —
|
|
102
|
+
// a non-member's resolved grant never leaks ship into this host's entry.
|
|
103
|
+
const shipCapabilities = resolveShipCapabilities({
|
|
104
|
+
isOwner: isOwnerViewer,
|
|
105
|
+
capability: admitted ? (shipGrant ?? "preview-access") : "preview-access",
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const principal: AdminEntryPrincipal = !record
|
|
109
|
+
? "anonymous"
|
|
110
|
+
: hostCapability
|
|
111
|
+
? OWNER_CAPABILITIES.has(hostCapability)
|
|
112
|
+
? (hostCapability as "owner" | "admin")
|
|
113
|
+
: "member"
|
|
114
|
+
: staffScope
|
|
115
|
+
? "staff"
|
|
116
|
+
: "authenticated-non-member";
|
|
117
|
+
|
|
118
|
+
return { admitted, hostCapability, isOwnerViewer, shipCapabilities, principal };
|
|
119
|
+
}
|
|
@@ -85,6 +85,15 @@ export interface IdentityClaims {
|
|
|
85
85
|
* app privileges (enforced by the Phase-4 PDP staff sandbox).
|
|
86
86
|
*/
|
|
87
87
|
staff: string[];
|
|
88
|
+
/**
|
|
89
|
+
* RFC 9470 Authentication Context Class Reference tot stamped on this
|
|
90
|
+
* assertion (e.g. `tot:aal:sms` for a step-up-satisfied credential). Optional:
|
|
91
|
+
* only present on tokens minted through an assurance-aware path (step-up
|
|
92
|
+
* completion); a plain federated-verify token may omit it.
|
|
93
|
+
*/
|
|
94
|
+
acr?: string;
|
|
95
|
+
/** Epoch seconds the underlying authentication event occurred, when tot sends it. */
|
|
96
|
+
auth_time?: number;
|
|
88
97
|
}
|
|
89
98
|
|
|
90
99
|
/** Coarse-role collapse guard: anything tot didn't send as owner/admin → member. */
|
|
@@ -115,7 +124,7 @@ export function toIdentityClaims(payload: Record<string, unknown>): IdentityClai
|
|
|
115
124
|
? payload.staff.filter((s): s is string => typeof s === "string")
|
|
116
125
|
: [];
|
|
117
126
|
|
|
118
|
-
|
|
127
|
+
const claims: IdentityClaims = {
|
|
119
128
|
iss: String(payload.iss ?? ""),
|
|
120
129
|
sub: String(payload.sub ?? email),
|
|
121
130
|
aud: String(payload.aud ?? ""),
|
|
@@ -127,6 +136,9 @@ export function toIdentityClaims(payload: Record<string, unknown>): IdentityClai
|
|
|
127
136
|
tenants,
|
|
128
137
|
staff,
|
|
129
138
|
};
|
|
139
|
+
if (typeof payload.acr === "string") claims.acr = payload.acr;
|
|
140
|
+
if (typeof payload.auth_time === "number") claims.auth_time = payload.auth_time;
|
|
141
|
+
return claims;
|
|
130
142
|
}
|
|
131
143
|
|
|
132
144
|
/** Derive tot's JWKS URL from the configured tot API base (§6b: path fixed, host app-config). */
|
|
@@ -321,6 +333,10 @@ export interface FederatedSessionFields {
|
|
|
321
333
|
tenants: TenantMembership[];
|
|
322
334
|
/** ToT-staff scope carried in the token (sandboxed; grants nothing here). */
|
|
323
335
|
staff: string[];
|
|
336
|
+
/** RFC 9470 ACR the assertion carried (e.g. `tot:aal:sms`), when tot sent one. */
|
|
337
|
+
acr?: string;
|
|
338
|
+
/** Epoch seconds of the underlying authentication event, when tot sent one. */
|
|
339
|
+
authTime?: number;
|
|
324
340
|
}
|
|
325
341
|
|
|
326
342
|
/**
|
|
@@ -332,10 +348,13 @@ export interface FederatedSessionFields {
|
|
|
332
348
|
* `tenants[]`, and the tenant-scoped preview gate never matches it.
|
|
333
349
|
*/
|
|
334
350
|
export function sessionFromFederatedClaims(claims: IdentityClaims): FederatedSessionFields {
|
|
335
|
-
|
|
351
|
+
const fields: FederatedSessionFields = {
|
|
336
352
|
email: claims.email,
|
|
337
353
|
resource: claims.aud,
|
|
338
354
|
tenants: claims.tenants,
|
|
339
355
|
staff: claims.staff,
|
|
340
356
|
};
|
|
357
|
+
if (claims.acr) fields.acr = claims.acr;
|
|
358
|
+
if (typeof claims.auth_time === "number") fields.authTime = claims.auth_time;
|
|
359
|
+
return fields;
|
|
341
360
|
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* so a visitor can actually sign in.
|
|
15
15
|
*/
|
|
16
16
|
import { hostInGateList } from "@/lib/demoGate";
|
|
17
|
-
import {
|
|
17
|
+
import { normalizeMemberships, staffAdmits } from "@/lib/dashboard/tenantSelection";
|
|
18
18
|
import type { SessionRecord } from "./session.js";
|
|
19
19
|
|
|
20
20
|
export type GateMode = "password" | "login";
|
|
@@ -28,18 +28,72 @@ export type GateMode = "password" | "login";
|
|
|
28
28
|
export const PUBLIC_HOME_TENANT_ID = "home";
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
*
|
|
31
|
+
* The record fields the host-admission + host-capability checks read. All optional
|
|
32
|
+
* — these readers tolerate a partial/legacy record and are deny-closed on absence.
|
|
33
|
+
*/
|
|
34
|
+
type HostAdmissionRecord = Partial<
|
|
35
|
+
Pick<SessionRecord, "tenants" | "staffSelection" | "staff" | "resource" | "capability">
|
|
36
|
+
>;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The owner/admin/member standing a session holds FOR THIS EXACT HOST tenant, or
|
|
40
|
+
* null when it is not a member of the host. Resolved SERVER-SIDE and BOUND to
|
|
41
|
+
* `hostResource` — it never returns a capability the session holds for some OTHER
|
|
42
|
+
* tenant, so the caller can surface it as the viewer's per-host capability without
|
|
43
|
+
* an over-grant. Resolution order (all host-bound):
|
|
44
|
+
*
|
|
45
|
+
* 1. A `tenants[]` membership for `hostResource` (the federated multi-tenant
|
|
46
|
+
* signal) → its role.
|
|
47
|
+
* 2. A session minted SCOPED to this host (`sessionFromClaims`): its primary
|
|
48
|
+
* `resource` IS this tenant and its `capability` is the owner/admin/member
|
|
49
|
+
* standing verified for it. This is the near-side signal that admits the
|
|
50
|
+
* authenticated OWNER of their own tenant even when the multi-tenant
|
|
51
|
+
* `tenants[]` projection is absent — the fix for an authenticated owner
|
|
52
|
+
* wrongly served the "Coming soon" gate at their own `/admin`. A federated
|
|
53
|
+
* console session's `resource` is the storefront app `aud` (never a tenant
|
|
54
|
+
* host), so it never matches here — deny-closed for cross-tenant.
|
|
55
|
+
* 3. A LIVE ToT-staff vendor selection for this exact host (the distinct
|
|
56
|
+
* staff-admission path — never a synthesized `tenants[]` member).
|
|
57
|
+
*
|
|
58
|
+
* Deny-closed: no session, or none of the above binds to `hostResource`, → null.
|
|
59
|
+
*/
|
|
60
|
+
export function sessionHostCapability(
|
|
61
|
+
record: HostAdmissionRecord | null | undefined,
|
|
62
|
+
hostResource: string,
|
|
63
|
+
nowSeconds: number = Math.floor(Date.now() / 1000),
|
|
64
|
+
): string | null {
|
|
65
|
+
if (!record) return null;
|
|
66
|
+
const target = (hostResource ?? "").trim();
|
|
67
|
+
if (!target) return null;
|
|
68
|
+
|
|
69
|
+
const membership = normalizeMemberships(record.tenants).find((t) => t.resource === target);
|
|
70
|
+
if (membership) return membership.role;
|
|
71
|
+
|
|
72
|
+
if (record.resource === target) {
|
|
73
|
+
const cap = (record.capability ?? "").trim();
|
|
74
|
+
if (cap && cap !== "none") return cap;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (staffAdmits({ selection: record.staffSelection, nowSeconds }, target)) {
|
|
78
|
+
return record.staffSelection!.capability;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Whether a viewer session admits the gated HOST — i.e. the signed-in user is a
|
|
86
|
+
* member/owner of the host's tenant (`hostResource`, the host's appDomain), or a
|
|
87
|
+
* ToT-staff viewer. This UNIFIES the preview-host gate onto the single federated
|
|
88
|
+
* session: federated sign-in mints a multi-tenant session whose `aud`/`resource`
|
|
89
|
+
* is the storefront app (not a tenant), so a `tenants[]` membership is the primary
|
|
90
|
+
* test; a session minted SCOPED to this host (its own primary `resource` +
|
|
91
|
+
* `capability`) is admitted too, so an authenticated OWNER of their own tenant is
|
|
92
|
+
* never mistaken for an anonymous visitor. Deny-closed: no session, or one with no
|
|
93
|
+
* standing for `hostResource`, is not admitted.
|
|
40
94
|
*/
|
|
41
95
|
export function sessionAdmitsHost(
|
|
42
|
-
record:
|
|
96
|
+
record: HostAdmissionRecord | null,
|
|
43
97
|
hostResource: string,
|
|
44
98
|
nowSeconds: number = Math.floor(Date.now() / 1000),
|
|
45
99
|
): boolean {
|
|
@@ -47,14 +101,11 @@ export function sessionAdmitsHost(
|
|
|
47
101
|
// ToT STAFF get in everywhere: any session carrying a verified `staff` scope is
|
|
48
102
|
// admitted to EVERY gated demo host with just a login — no per-vendor selection
|
|
49
103
|
// needed. This is a GATE-level admission for demo BROWSING only; dashboard-action
|
|
50
|
-
// authorization
|
|
104
|
+
// authorization stays membership/selection-bound + audited.
|
|
51
105
|
if (Array.isArray(record.staff) && record.staff.length > 0) return true;
|
|
52
|
-
// Otherwise
|
|
53
|
-
//
|
|
54
|
-
return
|
|
55
|
-
selection: record.staffSelection,
|
|
56
|
-
nowSeconds,
|
|
57
|
-
});
|
|
106
|
+
// Otherwise a host-bound standing (membership, resource-scoped session, or a live
|
|
107
|
+
// staff selection for this exact host) admits — see sessionHostCapability.
|
|
108
|
+
return sessionHostCapability(record, hostResource, nowSeconds) !== null;
|
|
58
109
|
}
|
|
59
110
|
|
|
60
111
|
/** Parse `DEMO_GATE_MODE` (default "password"). */
|
|
@@ -93,6 +144,36 @@ export function isPublicPath(pathname: string): boolean {
|
|
|
93
144
|
return pathname === "/api/cli/policy";
|
|
94
145
|
}
|
|
95
146
|
|
|
147
|
+
/**
|
|
148
|
+
* The candidate-preview CONTROL-PLANE routes — the per-change hosted review
|
|
149
|
+
* surfaces that carry their OWN, stricter auth (the cockpit gate: signed-in +
|
|
150
|
+
* developer capability + a SESSION-derived tenant — see
|
|
151
|
+
* `@/lib/preview/candidateRender` and `@/lib/preview/reviewDashboard`). They MUST be
|
|
152
|
+
* exempt from the demo login gate's "Coming soon" holding page: a REAL candidate
|
|
153
|
+
* that exists must never be masked behind a generic holding page — that false-
|
|
154
|
+
* negative is exactly `dogfood-definition-of-done`'s NOT-DONE bar ("a preview that
|
|
155
|
+
* renders 'Coming soon'"). Exempting them here does NOT open them up: the route's
|
|
156
|
+
* own cockpit gate still withholds a candidate from an unauthenticated / wrong-
|
|
157
|
+
* tenant viewer, but HONESTLY (a real 401/403, or an honest "not renderable"
|
|
158
|
+
* blocker naming the head SHA), never a misleading "Coming soon". A demo BROWSE of
|
|
159
|
+
* the tenant's own pages stays gated as before.
|
|
160
|
+
*
|
|
161
|
+
* Matched (prefix, so sub-paths are covered):
|
|
162
|
+
* /preview/render/<tenant>/<changeId>/... the candidate content renderer (s4)
|
|
163
|
+
* /preview/change/<changeId> the per-change review dashboard (s5)
|
|
164
|
+
* /preview/<tenant>/pr/<n> the friendly PR-number alias (u4)
|
|
165
|
+
* /preview/<tenant>/rev/<sha>/... the immutable per-revision renderer (u4)
|
|
166
|
+
*/
|
|
167
|
+
export function isCandidatePreviewPath(pathname: string): boolean {
|
|
168
|
+
if (pathname.startsWith("/preview/render/")) return true;
|
|
169
|
+
if (pathname === "/preview/change" || pathname.startsWith("/preview/change/")) return true;
|
|
170
|
+
// /preview/<tenant>/pr and /preview/<tenant>/pr/<n> — the PR-number alias.
|
|
171
|
+
if (/^\/preview\/[^/]+\/pr(?:\/|$)/.test(pathname)) return true;
|
|
172
|
+
// /preview/<tenant>/rev/<sha> and /preview/<tenant>/rev/<sha>/<path...> — the
|
|
173
|
+
// immutable per-revision alias.
|
|
174
|
+
return /^\/preview\/[^/]+\/rev(?:\/|$)/.test(pathname);
|
|
175
|
+
}
|
|
176
|
+
|
|
96
177
|
export type LoginGateDecision =
|
|
97
178
|
| { action: "pass" }
|
|
98
179
|
| { action: "redirect"; location: string };
|
|
@@ -124,6 +205,9 @@ export function evaluateLoginGate(input: LoginGateInput): LoginGateDecision {
|
|
|
124
205
|
if (input.tenantExempt) return { action: "pass" };
|
|
125
206
|
if (isAuthPath(input.pathname)) return { action: "pass" };
|
|
126
207
|
if (isPublicPath(input.pathname)) return { action: "pass" };
|
|
208
|
+
// Candidate-preview control-plane routes govern their OWN (stricter, honest) auth
|
|
209
|
+
// and must never fall to the "Coming soon" holding page for a real candidate.
|
|
210
|
+
if (isCandidatePreviewPath(input.pathname)) return { action: "pass" };
|
|
127
211
|
if (input.hasValidSession) return { action: "pass" };
|
|
128
212
|
const next = input.pathname + (input.search ?? "");
|
|
129
213
|
return { action: "redirect", location: `/auth/login?next=${encodeURIComponent(next)}` };
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP client-assertion MINTER + signing-key loader — the storefront half of the
|
|
3
|
+
* "MCP machine identity" contract (workstream `mcp-machine-identity`, unit B1
|
|
4
|
+
* `sf-client-assertion`).
|
|
5
|
+
*
|
|
6
|
+
* The deployed storefront Worker is a browserless, multi-tenant service with no
|
|
7
|
+
* human in the loop. It authenticates to the MCP's `client_credentials` grant
|
|
8
|
+
* (RFC 6749 §4.4) using `private_key_jwt` client authentication (OIDC Core §9,
|
|
9
|
+
* RFC 7523 §2.2/§3): it MINTS a short-lived RS256 `client_assertion` JWT proving
|
|
10
|
+
* it holds `MCP_CLIENT_ASSERTION_PRIVATE_KEY_B64`, and the MCP verifies the
|
|
11
|
+
* signature against storefront's PUBLISHED public key (fetched by `kid` from the
|
|
12
|
+
* JWKS this module derives — see `loadMcpClientAssertionJwks` + the sibling
|
|
13
|
+
* `.well-known/mcp-client-jwks.json` route, binding decision
|
|
14
|
+
* `c0-client-key-resolution`).
|
|
15
|
+
*
|
|
16
|
+
* Claims (RFC 7523 §3): `iss = sub = client_id`, `aud = <mcp token endpoint>`,
|
|
17
|
+
* `exp ~= iat + 60s`, a fresh `jti` every call (one-time-use, verified server-side
|
|
18
|
+
* by the MCP's replay store). Header: `{ alg: RS256, kid, typ: JWT }`.
|
|
19
|
+
*
|
|
20
|
+
* SERVER-SIDE ONLY. Mirrors `./brokerAssertion.ts`'s `mintBrokerAssertion()` shape
|
|
21
|
+
* (short-lived RS256 assertion signed with a private key that never leaves the
|
|
22
|
+
* Worker/server env) and `../apps/gatewayKeys.ts`'s signing-key-loader +
|
|
23
|
+
* JWKS-derivation shape (public JWKS derived from the same RSA key, every private
|
|
24
|
+
* field stripped before publish).
|
|
25
|
+
*
|
|
26
|
+
* KEY CONVENTION: this is a NEW, DEDICATED key — `MCP_CLIENT_ASSERTION_PRIVATE_KEY_B64`
|
|
27
|
+
* — never the broker key (`STOREFRONT_BROKER_PRIVATE_KEY_B64`) or the app-gateway
|
|
28
|
+
* key (`JWT_APP_GATEWAY_PRIVATE_KEY_B64`). Each trust direction gets its own key so a
|
|
29
|
+
* compromise or rotation of one never implicates another (see gatewayKeys.ts's
|
|
30
|
+
* header comment for the same rule stated from the gateway side). The key is a
|
|
31
|
+
* PROVIDED secret (generated + synced via SSM/`.secrets[]`, see B3), never
|
|
32
|
+
* autogenerated at runtime; tests/local-dev generate a throwaway keypair instead.
|
|
33
|
+
*/
|
|
34
|
+
import { importPKCS8, exportJWK, SignJWT, type CryptoKey, type JWK, type JSONWebKeySet } from "jose";
|
|
35
|
+
import { readEnv } from "@/lib/env";
|
|
36
|
+
|
|
37
|
+
/** The only algorithm this assertion is signed with (the MCP verifier rejects anything else). */
|
|
38
|
+
export const MCP_CLIENT_ASSERTION_ALG = "RS256";
|
|
39
|
+
|
|
40
|
+
/** Default key id (overridable via `MCP_CLIENT_ASSERTION_KID`). Matches the C0 golden fixture's `kid`. */
|
|
41
|
+
export const DEFAULT_MCP_CLIENT_ASSERTION_KID = "storefront-mcp-client-1";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Default issuer — this doubles as the OAuth `client_id`: RFC 7523 §3 requires
|
|
45
|
+
* `iss == sub == client_id`, so one env value covers both claims. Overridable via
|
|
46
|
+
* `MCP_CLIENT_ASSERTION_ISSUER`. Matches the C0 golden fixture's `clientId`.
|
|
47
|
+
*/
|
|
48
|
+
export const DEFAULT_MCP_CLIENT_ASSERTION_ISSUER = "storefront.tokenoftrust.com";
|
|
49
|
+
|
|
50
|
+
/** Assertion lifetime — RFC 7523 §3 wants this short; the contract pins `exp = iat + 60`. */
|
|
51
|
+
export const MCP_CLIENT_ASSERTION_TTL_SECONDS = 60;
|
|
52
|
+
|
|
53
|
+
/** Env keys the minter/loader reads (server-only). */
|
|
54
|
+
export const MCP_CLIENT_ASSERTION_PRIVATE_KEY_ENV = "MCP_CLIENT_ASSERTION_PRIVATE_KEY_B64";
|
|
55
|
+
export const MCP_CLIENT_ASSERTION_KID_ENV = "MCP_CLIENT_ASSERTION_KID";
|
|
56
|
+
export const MCP_CLIENT_ASSERTION_ISSUER_ENV = "MCP_CLIENT_ASSERTION_ISSUER";
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Thrown when the assertion can't be minted or the JWKS can't be derived — the
|
|
60
|
+
* private key env is absent/malformed, or (for minting) no audience/token-endpoint
|
|
61
|
+
* was supplied. Routes/callers map this to "integration not configured" and FAIL
|
|
62
|
+
* CLOSED: with no key, no assertion goes out and no JWKS is published.
|
|
63
|
+
*/
|
|
64
|
+
export class McpClientAssertionError extends Error {}
|
|
65
|
+
|
|
66
|
+
/** Optional test/DI overrides (avoids touching process env in unit tests). */
|
|
67
|
+
export interface McpClientAssertionOverrides {
|
|
68
|
+
/** base64 of the PKCS8 PEM private key (defaults to the env value). */
|
|
69
|
+
privateKeyB64?: string;
|
|
70
|
+
kid?: string;
|
|
71
|
+
/** Also used as the OAuth `client_id` (both `iss` and `sub`). */
|
|
72
|
+
issuer?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface McpClientAssertionSigningConfig {
|
|
76
|
+
key: CryptoKey;
|
|
77
|
+
kid: string;
|
|
78
|
+
issuer: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Import the signing key once per distinct PEM (importPKCS8 is not free), same
|
|
82
|
+
// cache shape as brokerAssertion.ts / gatewayKeys.ts. Keyed on the base64 string
|
|
83
|
+
// so a rotated key (new env value) transparently re-imports and re-derives.
|
|
84
|
+
const signingKeyCache = new Map<string, CryptoKey>();
|
|
85
|
+
const publicJwkCache = new Map<string, JWK>();
|
|
86
|
+
|
|
87
|
+
async function importPrivateKey(privateKeyB64: string): Promise<CryptoKey> {
|
|
88
|
+
const cached = signingKeyCache.get(privateKeyB64);
|
|
89
|
+
if (cached) return cached;
|
|
90
|
+
let pem: string;
|
|
91
|
+
try {
|
|
92
|
+
pem = Buffer.from(privateKeyB64, "base64").toString("utf8");
|
|
93
|
+
} catch (cause) {
|
|
94
|
+
throw new McpClientAssertionError(
|
|
95
|
+
`mcp client assertion: ${MCP_CLIENT_ASSERTION_PRIVATE_KEY_ENV} is not valid base64 ` +
|
|
96
|
+
(cause instanceof Error ? cause.message : String(cause)),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
if (!/-----BEGIN (RSA )?PRIVATE KEY-----/.test(pem)) {
|
|
100
|
+
throw new McpClientAssertionError(
|
|
101
|
+
`mcp client assertion: decoded ${MCP_CLIENT_ASSERTION_PRIVATE_KEY_ENV} is not a PEM private key`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
let key: CryptoKey;
|
|
105
|
+
try {
|
|
106
|
+
// extractable: true — required so `loadMcpClientAssertionJwks` below can
|
|
107
|
+
// derive the public JWK from this same key. The private key itself is
|
|
108
|
+
// still never exported anywhere outside this module.
|
|
109
|
+
key = (await importPKCS8(pem, MCP_CLIENT_ASSERTION_ALG, { extractable: true })) as CryptoKey;
|
|
110
|
+
} catch (cause) {
|
|
111
|
+
throw new McpClientAssertionError(
|
|
112
|
+
`mcp client assertion: failed to import the RS256 private key ` +
|
|
113
|
+
(cause instanceof Error ? cause.message : String(cause)),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
signingKeyCache.set(privateKeyB64, key);
|
|
117
|
+
return key;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolve the active signing config (key + kid + issuer/client_id) from env, or
|
|
122
|
+
* `overrides` in tests. FAILS CLOSED: throws {@link McpClientAssertionError} when
|
|
123
|
+
* the private key env is absent/malformed.
|
|
124
|
+
*/
|
|
125
|
+
export async function loadMcpClientAssertionSigningConfig(
|
|
126
|
+
overrides: McpClientAssertionOverrides = {},
|
|
127
|
+
): Promise<McpClientAssertionSigningConfig> {
|
|
128
|
+
const privateKeyB64 =
|
|
129
|
+
overrides.privateKeyB64 ?? (await readEnv(MCP_CLIENT_ASSERTION_PRIVATE_KEY_ENV));
|
|
130
|
+
if (!privateKeyB64) {
|
|
131
|
+
throw new McpClientAssertionError(
|
|
132
|
+
`mcp client assertion: ${MCP_CLIENT_ASSERTION_PRIVATE_KEY_ENV} is not configured (fail closed)`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
const kid =
|
|
136
|
+
overrides.kid ?? (await readEnv(MCP_CLIENT_ASSERTION_KID_ENV)) ?? DEFAULT_MCP_CLIENT_ASSERTION_KID;
|
|
137
|
+
const issuer =
|
|
138
|
+
overrides.issuer ??
|
|
139
|
+
(await readEnv(MCP_CLIENT_ASSERTION_ISSUER_ENV)) ??
|
|
140
|
+
DEFAULT_MCP_CLIENT_ASSERTION_ISSUER;
|
|
141
|
+
const key = await importPrivateKey(privateKeyB64);
|
|
142
|
+
return { key, kid, issuer };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Derive the PUBLIC JWKS for the active key — sanitized (every private RSA field
|
|
147
|
+
* stripped) and cached by the source private-key base64 (so a rotated key
|
|
148
|
+
* transparently re-derives). This is what the `.well-known/mcp-client-jwks.json`
|
|
149
|
+
* route publishes, and what the MCP fetches by `kid` (binding decision
|
|
150
|
+
* `c0-client-key-resolution`) to verify assertions minted by
|
|
151
|
+
* {@link mintMcpClientAssertion}. V1 always returns exactly one key; the envelope
|
|
152
|
+
* (`{ keys: [] }`) is forward-compatible with more (rotation-with-overlap).
|
|
153
|
+
*/
|
|
154
|
+
export async function loadMcpClientAssertionJwks(
|
|
155
|
+
overrides: McpClientAssertionOverrides = {},
|
|
156
|
+
): Promise<JSONWebKeySet> {
|
|
157
|
+
const privateKeyB64 =
|
|
158
|
+
overrides.privateKeyB64 ?? (await readEnv(MCP_CLIENT_ASSERTION_PRIVATE_KEY_ENV));
|
|
159
|
+
if (!privateKeyB64) {
|
|
160
|
+
throw new McpClientAssertionError(
|
|
161
|
+
`mcp client assertion: ${MCP_CLIENT_ASSERTION_PRIVATE_KEY_ENV} is not configured (fail closed)`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
const { key, kid } = await loadMcpClientAssertionSigningConfig(overrides);
|
|
165
|
+
let jwk = publicJwkCache.get(privateKeyB64);
|
|
166
|
+
if (!jwk) {
|
|
167
|
+
const full = (await exportJWK(key)) as Record<string, unknown>;
|
|
168
|
+
// Strip every private RSA field (RFC 7518 §6.3.2) — only the public parts
|
|
169
|
+
// (kty, n, e) are ever published. This line is the one place that matters.
|
|
170
|
+
const { d: _d, p: _p, q: _q, dp: _dp, dq: _dq, qi: _qi, oth: _oth, ...publicOnly } = full;
|
|
171
|
+
jwk = { ...publicOnly, kid, alg: MCP_CLIENT_ASSERTION_ALG, use: "sig" };
|
|
172
|
+
publicJwkCache.set(privateKeyB64, jwk);
|
|
173
|
+
}
|
|
174
|
+
return { keys: [jwk] };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Mint a fresh private_key_jwt `client_assertion` PROVING this Worker holds the
|
|
179
|
+
* dedicated MCP signing key. `audience` is the MCP's OAuth token endpoint
|
|
180
|
+
* (`<mcpBase>/oauth/token`) — callers (B2's forge/change service-auth transport)
|
|
181
|
+
* supply it from their own resolved `MCP_BASE_URL` config, since it can differ
|
|
182
|
+
* per deploy target (qa vs prod).
|
|
183
|
+
*
|
|
184
|
+
* Claims: `{ iss:<client_id>, sub:<client_id>, aud:<audience>, iat, exp:iat+60,
|
|
185
|
+
* jti:<uuid> }`. Header: `{ alg:'RS256', kid, typ:'JWT' }`. Claim/header field
|
|
186
|
+
* order mirrors the C0 golden fixture (`iss,sub,aud,iat,exp,jti` /
|
|
187
|
+
* `alg,kid,typ`) so a structural diff against the fixture is a no-op modulo
|
|
188
|
+
* `iat`/`exp`/`jti`.
|
|
189
|
+
*
|
|
190
|
+
* FAILS CLOSED: throws {@link McpClientAssertionError} when the private key env
|
|
191
|
+
* is absent/malformed or no audience is supplied — the caller must NOT fall back
|
|
192
|
+
* to an unsigned/unproven call.
|
|
193
|
+
*/
|
|
194
|
+
export async function mintMcpClientAssertion(
|
|
195
|
+
audience: string,
|
|
196
|
+
overrides: McpClientAssertionOverrides = {},
|
|
197
|
+
): Promise<string> {
|
|
198
|
+
const aud = (audience ?? "").trim();
|
|
199
|
+
if (!aud) {
|
|
200
|
+
throw new McpClientAssertionError(
|
|
201
|
+
"mcp client assertion: an audience (the MCP token endpoint) is required",
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
const signing = await loadMcpClientAssertionSigningConfig(overrides);
|
|
205
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
206
|
+
return new SignJWT({})
|
|
207
|
+
.setProtectedHeader({ alg: MCP_CLIENT_ASSERTION_ALG, kid: signing.kid, typ: "JWT" })
|
|
208
|
+
.setIssuer(signing.issuer)
|
|
209
|
+
.setSubject(signing.issuer)
|
|
210
|
+
.setAudience(aud)
|
|
211
|
+
.setIssuedAt(nowSeconds)
|
|
212
|
+
.setExpirationTime(nowSeconds + MCP_CLIENT_ASSERTION_TTL_SECONDS)
|
|
213
|
+
.setJti(crypto.randomUUID())
|
|
214
|
+
.sign(signing.key);
|
|
215
|
+
}
|
|
@@ -134,12 +134,27 @@ export function safeNext(next: string | null | undefined): string {
|
|
|
134
134
|
/** Redirect back to the login page carrying step-2 state (email/error), no-JS-safe. */
|
|
135
135
|
export function loginRedirect(
|
|
136
136
|
context: APIContext,
|
|
137
|
-
params: {
|
|
137
|
+
params: {
|
|
138
|
+
sent?: boolean;
|
|
139
|
+
email?: string;
|
|
140
|
+
error?: string;
|
|
141
|
+
next?: string;
|
|
142
|
+
/**
|
|
143
|
+
* Step-up state distinct from a plain `error`: `step-up-required` (the login
|
|
144
|
+
* page should offer the SMS step-up completion card) or `success-pending`
|
|
145
|
+
* (identity is bound; sign-in awaits step-up). See `@/lib/auth/stepUpChallenge`.
|
|
146
|
+
*/
|
|
147
|
+
reason?: "step-up-required" | "success-pending";
|
|
148
|
+
/** The RFC 9470 `requiredAcr` from the step-up obligation, when known. */
|
|
149
|
+
requiredAcr?: string;
|
|
150
|
+
},
|
|
138
151
|
): Response {
|
|
139
152
|
const q = new URLSearchParams();
|
|
140
153
|
if (params.sent) q.set("sent", "1");
|
|
141
154
|
if (params.email) q.set("email", params.email);
|
|
142
155
|
if (params.error) q.set("error", params.error);
|
|
156
|
+
if (params.reason) q.set("reason", params.reason);
|
|
157
|
+
if (params.requiredAcr) q.set("requiredAcr", params.requiredAcr);
|
|
143
158
|
if (params.next && params.next !== "/") q.set("next", params.next);
|
|
144
159
|
const qs = q.toString();
|
|
145
160
|
return context.redirect(`/auth/login${qs ? `?${qs}` : ""}`, 303);
|
|
@@ -69,6 +69,14 @@ export interface SessionRecord {
|
|
|
69
69
|
* DISTINCT from `tenants[]` — never a synthesized membership. See {@link StaffSelection}.
|
|
70
70
|
*/
|
|
71
71
|
staffSelection?: StaffSelection;
|
|
72
|
+
/**
|
|
73
|
+
* RFC 9470 Authentication Context Class Reference the session was established
|
|
74
|
+
* at (e.g. `tot:aal:sms` after a step-up completion). Absent for a session
|
|
75
|
+
* minted from a plain federated verify that carried no `acr` claim.
|
|
76
|
+
*/
|
|
77
|
+
acr?: string;
|
|
78
|
+
/** Epoch seconds of the underlying authentication event, when known (step-up path). */
|
|
79
|
+
authTime?: number;
|
|
72
80
|
/** Issued-at, epoch seconds. */
|
|
73
81
|
createdAt: number;
|
|
74
82
|
/** Expiry, epoch seconds. The store TTL mirrors this; we re-check on read. */
|