@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.
Files changed (225) hide show
  1. package/apps/storefront/astro.config.mjs +15 -0
  2. package/apps/storefront/dev-plugins/dev-publish.mjs +55 -0
  3. package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +86 -7
  4. package/apps/storefront/drizzle.config.apps.ts +13 -0
  5. package/apps/storefront/env.d.ts +10 -0
  6. package/apps/storefront/migrations/README.md +13 -7
  7. package/apps/storefront/migrations-apps/0000_fast_millenium_guard.sql +129 -0
  8. package/apps/storefront/migrations-apps/meta/0000_snapshot.json +843 -0
  9. package/apps/storefront/migrations-apps/meta/_journal.json +13 -0
  10. package/apps/storefront/package.json +13 -1
  11. package/apps/storefront/perf/README.md +64 -0
  12. package/apps/storefront/perf/assert-budgets.ts +159 -0
  13. package/apps/storefront/playwright.config.ts +23 -0
  14. package/apps/storefront/public/js/dashboard-apps.js +173 -0
  15. package/apps/storefront/public/shared/commerce-marketing.css +221 -0
  16. package/apps/storefront/src/components/CollectionCard.astro +1 -0
  17. package/apps/storefront/src/components/ProductCard.astro +1 -0
  18. package/apps/storefront/src/components/admin/AdminPublishTab.astro +1959 -0
  19. package/apps/storefront/src/components/apps/AppWidgetFrame.astro +30 -0
  20. package/apps/storefront/src/components/chrome/NavDropdown.astro +6 -3
  21. package/apps/storefront/src/components/chrome/SiteFooter.astro +10 -0
  22. package/apps/storefront/src/components/chrome/SiteHeader.astro +10 -0
  23. package/apps/storefront/src/components/commerce/RatingStars.astro +3 -2
  24. package/apps/storefront/src/components/content/Callout.astro +75 -0
  25. package/apps/storefront/src/components/content/NeedsReviewCallout.astro +66 -0
  26. package/apps/storefront/src/components/content/ProseSections.astro +121 -0
  27. package/apps/storefront/src/components/content/ProseToc.astro +34 -0
  28. package/apps/storefront/src/components/content/RichText.astro +44 -0
  29. package/apps/storefront/src/components/content/TrustStrip.astro +46 -0
  30. package/apps/storefront/src/components/home/Hero.astro +14 -0
  31. package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +295 -0
  32. package/apps/storefront/src/components/islands/ImageGallery.tsx +42 -21
  33. package/apps/storefront/src/components/islands/VariantSelector.tsx +62 -12
  34. package/apps/storefront/src/components/plp/FacetSidebar.astro +2 -2
  35. package/apps/storefront/src/components/subscription/ManageSubscriptionEntry.astro +1 -0
  36. package/apps/storefront/src/config/compliance/rulesets.ts +79 -0
  37. package/apps/storefront/src/config/storeName.ts +34 -0
  38. package/apps/storefront/src/layouts/Layout.astro +97 -23
  39. package/apps/storefront/src/lib/analytics/budgets.json +69 -0
  40. package/apps/storefront/src/lib/analytics/lighthouseReport.ts +109 -0
  41. package/apps/storefront/src/lib/analytics/perfBudgets.ts +452 -0
  42. package/apps/storefront/src/lib/analytics/rumAlert.ts +179 -0
  43. package/apps/storefront/src/lib/analytics/webVitals.ts +269 -0
  44. package/apps/storefront/src/lib/apps/adminService.ts +106 -0
  45. package/apps/storefront/src/lib/apps/adminSession.ts +205 -0
  46. package/apps/storefront/src/lib/apps/apiAuth.ts +91 -0
  47. package/apps/storefront/src/lib/apps/apiRoute.ts +35 -0
  48. package/apps/storefront/src/lib/apps/catalogMapper.ts +39 -0
  49. package/apps/storefront/src/lib/apps/credentials.ts +153 -0
  50. package/apps/storefront/src/lib/apps/gatewayKeys.ts +156 -0
  51. package/apps/storefront/src/lib/apps/healthAggregate.ts +66 -0
  52. package/apps/storefront/src/lib/apps/orders/attributionService.ts +206 -0
  53. package/apps/storefront/src/lib/apps/orders/customerHash.ts +28 -0
  54. package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +203 -0
  55. package/apps/storefront/src/lib/apps/orders/idempotency.ts +100 -0
  56. package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +117 -0
  57. package/apps/storefront/src/lib/apps/orders/orderMapper.ts +91 -0
  58. package/apps/storefront/src/lib/apps/orders/ordersStore.ts +181 -0
  59. package/apps/storefront/src/lib/apps/registryService.ts +579 -0
  60. package/apps/storefront/src/lib/apps/scopes.ts +79 -0
  61. package/apps/storefront/src/lib/apps/tokenIssuer.ts +121 -0
  62. package/apps/storefront/src/lib/apps/tokenVerifier.ts +148 -0
  63. package/apps/storefront/src/lib/apps/widgets/eligibility.ts +18 -0
  64. package/apps/storefront/src/lib/apps/widgets/frameProps.ts +52 -0
  65. package/apps/storefront/src/lib/apps/widgets/launchToken.ts +84 -0
  66. package/apps/storefront/src/lib/apps/widgets/placements.ts +57 -0
  67. package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +111 -0
  68. package/apps/storefront/src/lib/auth/adminEntry.ts +119 -0
  69. package/apps/storefront/src/lib/auth/identityToken.ts +21 -2
  70. package/apps/storefront/src/lib/auth/loginGate.ts +102 -18
  71. package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +215 -0
  72. package/apps/storefront/src/lib/auth/route.ts +16 -1
  73. package/apps/storefront/src/lib/auth/session.ts +8 -0
  74. package/apps/storefront/src/lib/auth/stepUpChallenge.ts +107 -0
  75. package/apps/storefront/src/lib/auth/totAccessClient.ts +208 -0
  76. package/apps/storefront/src/lib/blog/provider.ts +39 -0
  77. package/apps/storefront/src/lib/blog/types.ts +26 -0
  78. package/apps/storefront/src/lib/checkoutCommerce.ts +39 -1
  79. package/apps/storefront/src/lib/chrome/model.ts +11 -0
  80. package/apps/storefront/src/lib/compliance/enforcement.ts +95 -0
  81. package/apps/storefront/src/lib/content/callout.ts +78 -0
  82. package/apps/storefront/src/lib/content/index.ts +27 -0
  83. package/apps/storefront/src/lib/content/needsReview.ts +58 -0
  84. package/apps/storefront/src/lib/content/prose.ts +186 -0
  85. package/apps/storefront/src/lib/content/richtext.ts +76 -0
  86. package/apps/storefront/src/lib/content/trustStrip.ts +74 -0
  87. package/apps/storefront/src/lib/content-edit/client.ts +70 -14
  88. package/apps/storefront/src/lib/d1/catalog.ts +12 -0
  89. package/apps/storefront/src/lib/d1/schema-apps.ts +213 -0
  90. package/apps/storefront/src/lib/dev/apiBase.ts +8 -2
  91. package/apps/storefront/src/lib/dev/cliSignInCode.ts +65 -115
  92. package/apps/storefront/src/lib/dev/cockpitStore.ts +65 -0
  93. package/apps/storefront/src/lib/dev/obstacleStore.ts +214 -0
  94. package/apps/storefront/src/lib/dev/previewStatus.ts +112 -0
  95. package/apps/storefront/src/lib/dev/rendezvousBroker.ts +241 -0
  96. package/apps/storefront/src/lib/dev/subjectReissue.ts +67 -0
  97. package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +10 -10
  98. package/apps/storefront/src/lib/env.ts +12 -0
  99. package/apps/storefront/src/lib/jsonld.ts +12 -17
  100. package/apps/storefront/src/lib/membership/eligibility.ts +37 -0
  101. package/apps/storefront/src/lib/monitoring/manifest.ts +302 -0
  102. package/apps/storefront/src/lib/privacy/emailHint.ts +13 -5
  103. package/apps/storefront/src/lib/publish/apex-readiness.ts +337 -0
  104. package/apps/storefront/src/lib/publish/dispatchHealth.ts +269 -0
  105. package/apps/storefront/src/lib/publish/domainState.ts +351 -0
  106. package/apps/storefront/src/lib/publish/shipWorkspace.ts +362 -0
  107. package/apps/storefront/src/lib/rawChrome.ts +34 -3
  108. package/apps/storefront/src/lib/storyblok/content-model.ts +34 -2
  109. package/apps/storefront/src/lib/storyblok/provider.ts +11 -4
  110. package/apps/storefront/src/lib/subscription/model.ts +114 -0
  111. package/apps/storefront/src/lib/tot/ToTClient.ts +3 -3
  112. package/apps/storefront/src/lib/tot/query.ts +32 -0
  113. package/apps/storefront/src/lib/webhooks/cloudflareQueueDispatcher.ts +82 -0
  114. package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +245 -0
  115. package/apps/storefront/src/lib/webhooks/deliveryMapper.ts +34 -0
  116. package/apps/storefront/src/lib/webhooks/deliveryStore.ts +668 -0
  117. package/apps/storefront/src/lib/webhooks/dispatcher.ts +168 -0
  118. package/apps/storefront/src/lib/webhooks/emit.ts +167 -0
  119. package/apps/storefront/src/lib/webhooks/endpointGuard.ts +135 -0
  120. package/apps/storefront/src/lib/webhooks/events.ts +98 -0
  121. package/apps/storefront/src/lib/webhooks/getDispatcher.ts +49 -0
  122. package/apps/storefront/src/lib/webhooks/signing.ts +29 -0
  123. package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +146 -0
  124. package/apps/storefront/src/middleware/index.ts +28 -13
  125. package/apps/storefront/src/pages/404.astro +21 -9
  126. package/apps/storefront/src/pages/[...slug].astro +36 -2
  127. package/apps/storefront/src/pages/admin.astro +29 -3
  128. package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +60 -0
  129. package/apps/storefront/src/pages/api/apps/admin/health.ts +44 -0
  130. package/apps/storefront/src/pages/api/apps/admin/install.ts +100 -0
  131. package/apps/storefront/src/pages/api/apps/admin/list.ts +26 -0
  132. package/apps/storefront/src/pages/api/apps/admin/resume.ts +56 -0
  133. package/apps/storefront/src/pages/api/apps/admin/suspend.ts +57 -0
  134. package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +74 -0
  135. package/apps/storefront/src/pages/api/apps/admin/update.ts +103 -0
  136. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +59 -0
  137. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries.ts +51 -0
  138. package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +172 -0
  139. package/apps/storefront/src/pages/api/apps/oauth/token.ts +87 -0
  140. package/apps/storefront/src/pages/api/apps/v1/attribution.ts +142 -0
  141. package/apps/storefront/src/pages/api/apps/v1/catalog/products/[handle].ts +39 -0
  142. package/apps/storefront/src/pages/api/apps/v1/catalog/products.ts +49 -0
  143. package/apps/storefront/src/pages/api/apps/v1/health.ts +32 -0
  144. package/apps/storefront/src/pages/api/apps/v1/inventory.ts +59 -0
  145. package/apps/storefront/src/pages/api/apps/v1/orders/[id].ts +48 -0
  146. package/apps/storefront/src/pages/api/apps/v1/orders.ts +73 -0
  147. package/apps/storefront/src/pages/api/apps/v1/reports.ts +21 -0
  148. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +62 -0
  149. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries.ts +47 -0
  150. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +48 -1
  151. package/apps/storefront/src/pages/api/auth/step-up-send.ts +57 -0
  152. package/apps/storefront/src/pages/api/auth/step-up-verify.ts +129 -0
  153. package/apps/storefront/src/pages/api/auth/verify.ts +23 -0
  154. package/apps/storefront/src/pages/api/compliance/preflight.ts +206 -0
  155. package/apps/storefront/src/pages/api/rum/vitals.ts +54 -0
  156. package/apps/storefront/src/pages/api/test/dev-session.ts +133 -0
  157. package/apps/storefront/src/pages/auth/login.astro +197 -41
  158. package/apps/storefront/src/pages/auth/magic.astro +75 -43
  159. package/apps/storefront/src/pages/blog/[slug].astro +107 -0
  160. package/apps/storefront/src/pages/blog/index.astro +98 -0
  161. package/apps/storefront/src/pages/capabilities.astro +8 -0
  162. package/apps/storefront/src/pages/cockpit.astro +496 -74
  163. package/apps/storefront/src/pages/collections/[handle].astro +8 -0
  164. package/apps/storefront/src/pages/collections/index.astro +10 -2
  165. package/apps/storefront/src/pages/dashboard/[appDomain]/apps/index.astro +119 -0
  166. package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +5 -0
  167. package/apps/storefront/src/pages/index.astro +57 -0
  168. package/apps/storefront/src/pages/llms.txt.ts +31 -10
  169. package/apps/storefront/src/pages/products/[handle].astro +100 -9
  170. package/apps/storefront/src/pages/sitemap.xml.ts +21 -4
  171. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +198 -0
  172. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +7 -0
  173. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +7 -0
  174. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -0
  175. package/apps/storefront/src/pages/style-guide/index.astro +10 -0
  176. package/apps/storefront/src/styles/fonts.css +54 -0
  177. package/apps/storefront/src/styles/global.css +22 -2
  178. package/apps/storefront/src/themes/schema.ts +3 -25
  179. package/apps/storefront/tsconfig.json +1 -1
  180. package/apps/storefront/vitest.config.ts +4 -1
  181. package/package.json +1 -1
  182. package/packages/public-runtime/src/candidate-index.ts +311 -0
  183. package/packages/public-runtime/src/checkout.ts +94 -2
  184. package/packages/public-runtime/src/compliance/evaluate.ts +265 -0
  185. package/packages/public-runtime/src/compliance/evidence-signals.ts +81 -0
  186. package/packages/public-runtime/src/compliance/index.ts +22 -0
  187. package/packages/public-runtime/src/compliance/pact-report.ts +94 -0
  188. package/packages/public-runtime/src/compliance/profile.ts +198 -0
  189. package/packages/public-runtime/src/compliance/ruleset.ts +117 -0
  190. package/packages/public-runtime/src/compliance/verification.ts +81 -0
  191. package/packages/public-runtime/src/csp.ts +27 -3
  192. package/packages/public-runtime/src/customization-reconcile.ts +35 -0
  193. package/packages/public-runtime/src/customization-runtime.ts +8 -0
  194. package/packages/public-runtime/src/customization-versioning.ts +17 -0
  195. package/packages/public-runtime/src/extension-contract.ts +2 -1
  196. package/packages/public-runtime/src/hash.ts +25 -0
  197. package/packages/public-runtime/src/index.ts +6 -0
  198. package/packages/public-runtime/src/membership.ts +353 -0
  199. package/packages/public-runtime/src/product.ts +24 -2
  200. package/packages/public-runtime/src/review-trust-proof.ts +194 -0
  201. package/packages/public-runtime/src/tenant-assets.ts +40 -5
  202. package/packages/public-runtime/src/tenant.ts +236 -0
  203. package/packages/public-runtime/src/widget-postmessage.ts +205 -0
  204. package/scripts/dev/publish.mjs +158 -0
  205. package/scripts/dev/transient-files.mjs +2 -1
  206. package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
  207. package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
  208. package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
  209. package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
  210. package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
  211. package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
  212. package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  213. package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
  214. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
  215. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
  216. package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
  217. package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
  218. package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
  219. package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
  220. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
  221. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
  222. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
  223. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
  224. package/tenants/home/public/pages/storefront.css +23 -0
  225. 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
- return {
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
- return {
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 { viewerCanAccess } from "@/lib/dashboard/tenantSelection";
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
- * Whether a (federated) viewer session admits the gated HOST — i.e. the signed-in
32
- * user holds a membership for the host's tenant (`hostResource`, the host's
33
- * appDomain). This UNIFIES the preview-host gate onto the single federated session:
34
- * federated sign-in mints a multi-tenant session whose `aud`/`resource` is the
35
- * storefront app (not a tenant), so the old resource-scoped session match never
36
- * fit; membership in `tenants[]` is the right test. Deny-closed: no session, or a
37
- * session without a membership for `hostResource`, is not admitted. (Backward-
38
- * compatible with a legacy resource-scoped session, whose `tenants[]` still lists
39
- * its own resource.)
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: Pick<SessionRecord, "tenants" | "staffSelection" | "staff"> | null,
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 (viewerCanAccess) stays membership/selection-bound + audited.
104
+ // authorization stays membership/selection-bound + audited.
51
105
  if (Array.isArray(record.staff) && record.staff.length > 0) return true;
52
- // Otherwise: a membership OR a live ToT-staff selection for this exact host admits
53
- // the staff-selection path is distinct from tenants[] (never a synthesized member).
54
- return viewerCanAccess(record.tenants, hostResource, {
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: { sent?: boolean; email?: string; error?: string; next?: string },
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. */