@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,214 @@
1
+ /**
2
+ * Per-DEVELOPER "startup obstacle" — the FAILURE half of the local→hosted dev
3
+ * bridge (v2 §6). Where runtimeStore.ts holds the latest SUCCESSFUL heartbeat and
4
+ * activityStore.ts the file-save ring buffer, this holds the latest reason a
5
+ * developer's local `tot start` could NOT come up: a Node-version floor miss, a
6
+ * missing pnpm, a failed install/clone. The hosted cockpit reads it to turn the
7
+ * bridge strip into an actionable obstacle card ("This machine is running Node
8
+ * 20.17 — needs 22.12+; fix: …") instead of spinning forever on "watching…".
9
+ *
10
+ * WHY ITS OWN KEY (not folded into the runtime record): an obstacle is PRECISELY
11
+ * the state where no successful heartbeat exists, so it must never carry an
12
+ * `aliveAt` that liveness reads — mixing it into the runtime record would flicker
13
+ * the loop "live". It is its own latest-wins slot, cleared the moment a real
14
+ * heartbeat lands (writeRuntime → clearObstacle) and on a ?reset.
15
+ *
16
+ * SCOPED PER (tenant, developer email), exactly like the runtime record
17
+ * (commit 0d4bb7d): the obstacle is beaconed with the developer's OWN
18
+ * activity-ingest token — carried in the setup command's argv — so a machine that
19
+ * FAILS the Node gate (no session, no MCP, maybe no global `fetch`) can still
20
+ * report it. The viewer's own GET (their email) reads their own obstacle; a
21
+ * teammate's failure is theirs.
22
+ *
23
+ * REMEDIATION COPY LIVES HERE (server-side), keyed by `kind` — so the fix-it
24
+ * guidance changes without shipping a new CLI. The CLI reports only a stable,
25
+ * machine-readable {kind, have, need}; the human sentence is composed here.
26
+ */
27
+
28
+ /** The failure modes `tot start` can beacon. Stable machine-readable contract —
29
+ * the CLI emitter (a bounded follow-up) POSTs one of these; the human copy is
30
+ * composed server-side in describeObstacle(). */
31
+ export const OBSTACLE_KINDS = [
32
+ "node-too-old",
33
+ "pnpm-missing",
34
+ "install-failed",
35
+ "clone-failed",
36
+ ] as const;
37
+ export type ObstacleKind = (typeof OBSTACLE_KINDS)[number];
38
+
39
+ /** The latest startup obstacle a developer's local loop reported. */
40
+ export interface DevObstacle {
41
+ kind: ObstacleKind;
42
+ /** The version the machine HAS, when the kind carries one (node-too-old). e.g. "20.17.0". */
43
+ have?: string;
44
+ /** The version the loop NEEDS, when the beacon knows it. e.g. "22.12.0". */
45
+ need?: string;
46
+ /** The `tot` CLI version that reported the obstacle, when carried. */
47
+ cliVersion?: string;
48
+ /** Epoch ms the obstacle was reported, stamped by the SERVER at ingest (never
49
+ * the client `ts` — skew-proof, matching the runtime record's aliveAt). */
50
+ reportedAt: number;
51
+ }
52
+
53
+ /** The obstacle as the GET feed exposes it: the stored record PLUS the
54
+ * server-composed remediation copy the cockpit renders verbatim. */
55
+ export interface DevObstacleView extends DevObstacle {
56
+ /** Short label for the bridge kicker, e.g. "Node version too old". */
57
+ title: string;
58
+ /** One-line human explanation, with have/need interpolated. */
59
+ message: string;
60
+ /** The single shell command that fixes it, when there is one (rendered in mono). */
61
+ fixCommand?: string;
62
+ /** The instruction around the fix command (or the whole fix, when there is no command). */
63
+ fixNote: string;
64
+ }
65
+
66
+ const KEY_PREFIX = "dev-obstacle:";
67
+ /** Match the runtime record's TTL: an obstacle lingers long enough for the
68
+ * developer to see it after a failed start, then self-expires. A real heartbeat
69
+ * clears it well before this. */
70
+ const TTL_SECONDS = 3600;
71
+
72
+ /** A reported version ("20.17.0") is short; cap it for KV value-size hygiene. */
73
+ const MAX_VERSION_LEN = 32;
74
+ const MAX_CLI_LEN = 64;
75
+
76
+ /** Astro's engines floor (enforced by CLI 1.3.4-rc.2). Used ONLY as a fallback
77
+ * when a node-too-old beacon omits `need`; a beacon that carries `need` wins. */
78
+ const NODE_FLOOR = "22.12";
79
+
80
+ /** Per-developer record key: `dev-obstacle:<tenant>:<email>` (email lowercased
81
+ * for a stable key, mirroring runtimeStore). */
82
+ function key(appDomain: string, email: string | undefined): string {
83
+ return `${KEY_PREFIX}${appDomain}:${(email ?? "").toLowerCase()}`;
84
+ }
85
+
86
+ function clip(v: unknown, max: number): string | undefined {
87
+ return typeof v === "string" && v.length > 0 ? v.slice(0, max) : undefined;
88
+ }
89
+
90
+ function isKind(v: unknown): v is ObstacleKind {
91
+ return typeof v === "string" && (OBSTACLE_KINDS as readonly string[]).includes(v);
92
+ }
93
+
94
+ /**
95
+ * Normalize a raw obstacle beacon into a stored record, or null when the `kind`
96
+ * is unrecognized (the route rejects that as a 400 — an unknown kind has no
97
+ * remediation copy, so storing it would render a blank card). Pure + exported so
98
+ * the route's validation is unit-tested without KV. have/need/cliVersion are
99
+ * bounded strings; `reportedAt` is the SERVER clock, never the client's `ts`.
100
+ */
101
+ export function shapeObstacle(
102
+ body: { kind?: unknown; have?: unknown; need?: unknown; cliVersion?: unknown },
103
+ reportedAt: number,
104
+ ): DevObstacle | null {
105
+ if (!isKind(body.kind)) return null;
106
+ const rec: DevObstacle = { kind: body.kind, reportedAt };
107
+ const have = clip(body.have, MAX_VERSION_LEN);
108
+ const need = clip(body.need, MAX_VERSION_LEN);
109
+ const cliVersion = clip(body.cliVersion, MAX_CLI_LEN);
110
+ if (have) rec.have = have;
111
+ if (need) rec.need = need;
112
+ if (cliVersion) rec.cliVersion = cliVersion;
113
+ return rec;
114
+ }
115
+
116
+ /** Read a developer's latest obstacle (their tenant + email), or null when
117
+ * none/malformed/unknown-kind. Never throws. */
118
+ export async function readObstacle(
119
+ kv: KVNamespace,
120
+ appDomain: string,
121
+ email: string | undefined,
122
+ ): Promise<DevObstacle | null> {
123
+ try {
124
+ const raw = await kv.get(key(appDomain, email));
125
+ if (!raw) return null;
126
+ const parsed = JSON.parse(raw);
127
+ if (
128
+ !parsed ||
129
+ typeof parsed !== "object" ||
130
+ typeof parsed.reportedAt !== "number" ||
131
+ !isKind(parsed.kind)
132
+ ) {
133
+ return null;
134
+ }
135
+ return parsed as DevObstacle;
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ /** Overwrite a developer's obstacle with their latest beacon (latest-wins, TTL'd),
142
+ * scoped to (tenant, email). */
143
+ export async function writeObstacle(
144
+ kv: KVNamespace,
145
+ appDomain: string,
146
+ email: string | undefined,
147
+ rec: DevObstacle,
148
+ ): Promise<void> {
149
+ await kv.put(key(appDomain, email), JSON.stringify(rec), { expirationTtl: TTL_SECONDS });
150
+ }
151
+
152
+ /** Delete a developer's obstacle record — a live heartbeat supersedes it, and a
153
+ * ?reset clears it. Idempotent; never throws on an absent key. */
154
+ export async function clearObstacle(
155
+ kv: KVNamespace,
156
+ appDomain: string,
157
+ email: string | undefined,
158
+ ): Promise<void> {
159
+ await kv.delete(key(appDomain, email));
160
+ }
161
+
162
+ /**
163
+ * Compose the human remediation copy for an obstacle, keyed by kind — SERVER-side
164
+ * so the fix-it guidance changes without a CLI release. Pure. The exhaustive
165
+ * switch means adding a new ObstacleKind is a compile error here until its copy
166
+ * exists (so no kind can ship without a fix message).
167
+ */
168
+ export function describeObstacle(rec: DevObstacle): {
169
+ title: string;
170
+ message: string;
171
+ fixCommand?: string;
172
+ fixNote: string;
173
+ } {
174
+ switch (rec.kind) {
175
+ case "node-too-old": {
176
+ const need = rec.need || NODE_FLOOR;
177
+ const message = rec.have
178
+ ? `This machine is running Node ${rec.have} — the dev loop needs ${need} or newer.`
179
+ : `The dev loop needs Node ${need} or newer, and this machine's version is too old.`;
180
+ return {
181
+ title: "Node version too old",
182
+ message,
183
+ fixCommand: "nvm install 24 && nvm use 24",
184
+ fixNote: "then re-run the setup command above. No nvm? Install Node 24 LTS from nodejs.org.",
185
+ };
186
+ }
187
+ case "pnpm-missing":
188
+ return {
189
+ title: "pnpm not found",
190
+ message: "The dev loop needs pnpm, which isn't installed on this machine.",
191
+ fixCommand: "npm install -g pnpm",
192
+ fixNote: "then re-run the setup command above.",
193
+ };
194
+ case "install-failed":
195
+ return {
196
+ title: "Install failed",
197
+ message: "Installing the dev runner didn't finish on this machine.",
198
+ fixNote:
199
+ "Check your terminal for the npm or network error, then re-run the setup command. If it keeps failing, send us that output.",
200
+ };
201
+ case "clone-failed":
202
+ return {
203
+ title: "Couldn't fetch your store",
204
+ message: "The dev loop couldn't download your store's starter files.",
205
+ fixNote: "Check your network and the terminal output, then re-run the setup command.",
206
+ };
207
+ }
208
+ }
209
+
210
+ /** Attach the server-composed remediation copy to a stored obstacle — the
211
+ * developer-facing view the GET feed returns. Pure. */
212
+ export function toObstacleView(rec: DevObstacle): DevObstacleView {
213
+ return { ...rec, ...describeObstacle(rec) };
214
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Read whether a tenant appDomain's store is SET UP — its forge repo is provisioned —
3
+ * from the credential-free MCP preview-status seam (tot-mcp
4
+ * GET /internal/preview-status; legacy alias /internal/dev-access/preview-status). The
5
+ * seam answers `{ tenant, app, repo, provisioned, previewReleased }`.
6
+ *
7
+ * This is the cockpit's own small reader — self-contained, mirroring `cliSignInCode.ts`
8
+ * (same MCP-origin + service-secret seam) rather than importing another cluster's copy,
9
+ * so the two stay decoupled (the same reason `cliSignInCode` duplicates its transport).
10
+ * It gates the CLI sign-in-code mint + the /cockpit setup fastpath so a developer whose
11
+ * store isn't provisioned yet gets an honest "not set up" state instead of a
12
+ * `tot login --code … && tot start` command that would die at checkout on
13
+ * "Repo <owner>-main is not provisioned" (James's 2026-07-19 first-run failure).
14
+ *
15
+ * - MCP host ← the ORIGIN of MCP_APP_BINDING_URL (one source, so this read hits the
16
+ * SAME MCP tier the sign-in-code mint does).
17
+ * - auth ← header x-mcp-service-secret: MCP_SERVICE_SHARED_SECRET.
18
+ *
19
+ * Fail-closed but non-blocking by design: an UNCONFIGURED seam (no URL/secret) →
20
+ * { configured:false } so callers keep today's behavior (no regression); any non-2xx /
21
+ * transport / parse fault → { configured:true, ok:false } so a caller can distinguish
22
+ * "definitely not provisioned" (ok + provisioned:false) from "couldn't tell". Never throws.
23
+ */
24
+ import { readEnv } from "@/lib/env";
25
+
26
+ export interface HttpGetTransport {
27
+ get(
28
+ url: string,
29
+ headers: Record<string, string>,
30
+ ): Promise<{ status: number; json(): Promise<unknown> }>;
31
+ }
32
+
33
+ export class FetchGetTransport implements HttpGetTransport {
34
+ async get(url: string, headers: Record<string, string>) {
35
+ const res = await fetch(url, { method: "GET", headers });
36
+ return { status: res.status, json: () => res.json() };
37
+ }
38
+ }
39
+
40
+ export interface TenantPreviewStatus {
41
+ /** False when the seam isn't wired on this deploy (no URL/secret) — gate inactive. */
42
+ configured: boolean;
43
+ /** True only when we got a definitive answer back from the MCP. */
44
+ ok: boolean;
45
+ /** The forge repo is provisioned for this owner (checkout/`tot start` can succeed). */
46
+ provisioned?: boolean;
47
+ /** A released preview exists. TODAY equals `provisioned`; Track B.3 tightens it. */
48
+ previewReleased?: boolean;
49
+ status?: number;
50
+ reason?: string;
51
+ }
52
+
53
+ export async function readTenantPreviewStatus(
54
+ tenant: string | null,
55
+ deps: { transport?: HttpGetTransport } = {},
56
+ ): Promise<TenantPreviewStatus> {
57
+ const bindingUrl = await readEnv("MCP_APP_BINDING_URL");
58
+ const secret = await readEnv("MCP_SERVICE_SHARED_SECRET");
59
+ if (!bindingUrl || !secret || !tenant) return { configured: false, ok: false };
60
+
61
+ let endpoint: string;
62
+ try {
63
+ // Mounted OUTSIDE /api on the MCP (its /api surface is session-guarded); use the
64
+ // ORIGIN of MCP_APP_BINDING_URL, same as the cli-signin-code mint. Canonical path
65
+ // (namespace-neutral) — the MCP still serves the /internal/dev-access alias too.
66
+ endpoint = `${new URL(bindingUrl).origin}/internal/preview-status`;
67
+ } catch {
68
+ return { configured: false, ok: false, reason: "bad-mcp-url" };
69
+ }
70
+
71
+ const transport = deps.transport ?? new FetchGetTransport();
72
+ const full = `${endpoint}?tenant=${encodeURIComponent(tenant.toLowerCase())}`;
73
+ let res: { status: number; json(): Promise<unknown> };
74
+ try {
75
+ res = await transport.get(full, { "x-mcp-service-secret": secret });
76
+ } catch (cause) {
77
+ return {
78
+ configured: true,
79
+ ok: false,
80
+ reason: "mcp request failed: " + (cause instanceof Error ? cause.message : String(cause)),
81
+ };
82
+ }
83
+ if (res.status < 200 || res.status >= 300) {
84
+ return { configured: true, ok: false, status: res.status, reason: `status ${res.status}` };
85
+ }
86
+ let payload: unknown;
87
+ try {
88
+ payload = await res.json();
89
+ } catch {
90
+ return { configured: true, ok: false, status: res.status, reason: "unparseable mcp response" };
91
+ }
92
+ const p = (payload ?? {}) as Record<string, unknown>;
93
+ return {
94
+ configured: true,
95
+ ok: true,
96
+ status: res.status,
97
+ provisioned: p.provisioned === true,
98
+ previewReleased: p.previewReleased === true,
99
+ };
100
+ }
101
+
102
+ /**
103
+ * The gate signal shared by /cockpit + the CLI sign-in-code mint: TRUE only when the
104
+ * seam gave a DEFINITIVE "this store's repo is NOT provisioned" — the exact case where
105
+ * `tot clone` / `tot start` would die locally on "Repo … is not provisioned". An
106
+ * unconfigured seam or one that couldn't answer returns FALSE — callers then keep today's
107
+ * behavior and lean on the CLI's own clean "not set up yet" error as the backstop, rather
108
+ * than blocking a possibly-fine developer on a transient MCP blip.
109
+ */
110
+ export function isStoreNotSetUp(status: TenantPreviewStatus): boolean {
111
+ return status.configured && status.ok && status.provisioned === false;
112
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * The storefront (cockpit) half of the browserless CLI sign-in RENDEZVOUS — the
3
+ * server-to-server calls into the MCP's device-rendezvous endpoints, authenticated
4
+ * by the storefront's SIGNED CLIENT ASSERTION (private_key_jwt, RFC 7523), NOT the
5
+ * retired `x-mcp-service-secret`. The developer's cockpit (already authenticated as
6
+ * that developer) drives these; the MCP verifies the assertion against the
7
+ * storefront's published JWKS and admits only a client registered to broker CLI
8
+ * sign-in.
9
+ *
10
+ * createRendezvous → POST /oauth/device/rendezvous (start; returns the
11
+ * NON-SECRET rendezvous_code rendered into `tot login --code`)
12
+ * rendezvousStatus → POST /oauth/device/rendezvous/status (poll for a terminal
13
+ * attaching → { state, fingerprint })
14
+ * approveRendezvous → POST /oauth/device/rendezvous/approve (the human confirmed
15
+ * the fingerprint; carries the dev's fresh subject_token proof)
16
+ * denyRendezvous → POST /oauth/device/rendezvous/deny
17
+ *
18
+ * All four endpoints verify the SAME assertion audience — `<mcpBase>/oauth/device/rendezvous`
19
+ * (the MCP derives the expected audience from that fixed path, not the sub-path), so
20
+ * one minted assertion shape covers every call. SERVER-SIDE ONLY.
21
+ */
22
+ import { readEnv } from "@/lib/env";
23
+ import { mintMcpClientAssertion, McpClientAssertionError } from "@/lib/auth/mcpClientAssertion";
24
+
25
+ const JWT_BEARER_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
26
+ /** The path whose audience the MCP binds EVERY rendezvous assertion to. */
27
+ const RENDEZVOUS_AUDIENCE_PATH = "/oauth/device/rendezvous";
28
+
29
+ /** Injectable transport so the broker calls unit-test with no network. */
30
+ export interface HttpTransport {
31
+ post(
32
+ url: string,
33
+ headers: Record<string, string>,
34
+ body: unknown,
35
+ ): Promise<{ status: number; json(): Promise<unknown> }>;
36
+ }
37
+
38
+ // Bound every rendezvous call to the MCP. The MCP runs on AppRunner (scale-to-zero),
39
+ // so the FIRST call after idle hits a cold instance that can take many seconds to
40
+ // answer. Without a bound the storefront Worker's subrequest hangs and the browser's
41
+ // POST to /api/dev/cli-signin-code eventually dies at the transport layer — the cockpit
42
+ // shows the opaque catch-branch "Couldn't reach the sign-in service" and the button is
43
+ // simply dead. Aborting at a fixed deadline turns that silent hang into a fast, RETRYABLE
44
+ // 502 ("having a moment — try again"); the retry lands on a now-warm MCP and succeeds.
45
+ const RENDEZVOUS_FETCH_TIMEOUT_MS = 12_000;
46
+
47
+ export class FetchHttpTransport implements HttpTransport {
48
+ async post(url: string, headers: Record<string, string>, body: unknown) {
49
+ const res = await fetch(url, {
50
+ method: "POST",
51
+ headers: { "Content-Type": "application/json", ...headers },
52
+ body: JSON.stringify(body),
53
+ // A timeout/abort rejects the fetch → callRendezvous's catch → a 502 the cockpit
54
+ // renders as a retryable error, instead of a hung request the browser gives up on.
55
+ signal: AbortSignal.timeout(RENDEZVOUS_FETCH_TIMEOUT_MS),
56
+ });
57
+ return { status: res.status, json: () => res.json() };
58
+ }
59
+ }
60
+
61
+ export interface RendezvousDeps {
62
+ transport?: HttpTransport;
63
+ /** Override the assertion minter in tests (defaults to the real private-key signer). */
64
+ mintAssertion?: (audience: string) => Promise<string>;
65
+ }
66
+
67
+ /** A resolved MCP target: the origin to POST to + the assertion audience. */
68
+ interface McpTarget {
69
+ origin: string;
70
+ audience: string;
71
+ }
72
+
73
+ /**
74
+ * Resolve the MCP origin from `MCP_BASE_URL` (its ORIGIN). MCP_BASE_URL is the MCP
75
+ * this storefront authenticates to with its private_key_jwt client assertion (kid
76
+ * storefront-mcp-client-1) — the SAME credential + tier the confidential broker
77
+ * client (canBrokerCliSignin) is registered under (e.g. mcp.qa for preview). NOT
78
+ * MCP_APP_BINDING_URL, which the retired shared-secret shim used and which may point
79
+ * at a different MCP tier. Returns null when unset/malformed (route maps that to 503).
80
+ */
81
+ export async function resolveMcpTarget(): Promise<McpTarget | null> {
82
+ const baseUrl = await readEnv("MCP_BASE_URL");
83
+ if (!baseUrl) return null;
84
+ try {
85
+ const origin = new URL(baseUrl).origin;
86
+ return { origin, audience: `${origin}${RENDEZVOUS_AUDIENCE_PATH}` };
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ async function callRendezvous(
93
+ target: McpTarget,
94
+ path: string,
95
+ extra: Record<string, unknown>,
96
+ deps: RendezvousDeps,
97
+ ): Promise<{ status: number; body: Record<string, unknown> }> {
98
+ const mint = deps.mintAssertion ?? ((aud: string) => mintMcpClientAssertion(aud));
99
+ const clientAssertion = await mint(target.audience);
100
+ const transport = deps.transport ?? new FetchHttpTransport();
101
+ const res = await transport.post(
102
+ `${target.origin}${path}`,
103
+ {},
104
+ {
105
+ client_assertion: clientAssertion,
106
+ client_assertion_type: JWT_BEARER_ASSERTION_TYPE,
107
+ ...extra,
108
+ },
109
+ );
110
+ let body: Record<string, unknown> = {};
111
+ try {
112
+ body = (await res.json()) as Record<string, unknown>;
113
+ } catch {
114
+ body = {};
115
+ }
116
+ return { status: res.status, body };
117
+ }
118
+
119
+ export type RendezvousCreateResult =
120
+ | { ok: true; rendezvousCode: string; expiresIn: number | null }
121
+ | { ok: false; status: 502 | 503; reason: string };
122
+
123
+ /**
124
+ * Start a rendezvous for the signed-in developer's OWN email (+ their vendor tenant).
125
+ * Fail-closed mapping mirrors the retired mint: an unconfigured seam or a 401/403
126
+ * (assertion rejected / client not entitled to broker) is a 503 ("not wired up on
127
+ * this environment"); any other fault is a 502.
128
+ */
129
+ export async function createRendezvous(
130
+ email: string,
131
+ tenant: string | null,
132
+ deps: RendezvousDeps = {},
133
+ ): Promise<RendezvousCreateResult> {
134
+ const target = await resolveMcpTarget();
135
+ if (!target) return { ok: false, status: 503, reason: "not-configured" };
136
+ let res: { status: number; body: Record<string, unknown> };
137
+ try {
138
+ res = await callRendezvous(
139
+ target,
140
+ RENDEZVOUS_AUDIENCE_PATH,
141
+ { email: email.toLowerCase(), ...(tenant ? { tenant: tenant.toLowerCase() } : {}) },
142
+ deps,
143
+ );
144
+ } catch (cause) {
145
+ if (cause instanceof McpClientAssertionError) return { ok: false, status: 503, reason: "assertion-unconfigured" };
146
+ return { ok: false, status: 502, reason: "mcp request failed: " + (cause instanceof Error ? cause.message : String(cause)) };
147
+ }
148
+ if (res.status < 200 || res.status >= 300) {
149
+ // 401 invalid_client / 403 unauthorized_client|not_entitled = "not wired up /
150
+ // not entitled on this environment" → 503; a fault is 502.
151
+ const notConfigured = res.status === 401 || res.status === 403;
152
+ return { ok: false, status: notConfigured ? 503 : 502, reason: `rendezvous create returned ${res.status}` };
153
+ }
154
+ const rendezvousCode = typeof res.body.rendezvous_code === "string" ? res.body.rendezvous_code : "";
155
+ if (!rendezvousCode) return { ok: false, status: 502, reason: "rendezvous create returned no code" };
156
+ return {
157
+ ok: true,
158
+ rendezvousCode,
159
+ expiresIn: typeof res.body.expires_in === "number" ? res.body.expires_in : null,
160
+ };
161
+ }
162
+
163
+ export type RendezvousState = "pending" | "attached" | "approved" | "denied" | "expired";
164
+ export type RendezvousStatusResult =
165
+ | { ok: true; state: RendezvousState; fingerprint: string | null }
166
+ | { ok: false; status: 502 | 503; reason: string };
167
+
168
+ /** Poll for a terminal attaching. `fingerprint` is present once state is `attached`. */
169
+ export async function rendezvousStatus(
170
+ rendezvousCode: string,
171
+ deps: RendezvousDeps = {},
172
+ ): Promise<RendezvousStatusResult> {
173
+ const target = await resolveMcpTarget();
174
+ if (!target) return { ok: false, status: 503, reason: "not-configured" };
175
+ let res: { status: number; body: Record<string, unknown> };
176
+ try {
177
+ res = await callRendezvous(target, `${RENDEZVOUS_AUDIENCE_PATH}/status`, { rendezvous_code: rendezvousCode }, deps);
178
+ } catch (cause) {
179
+ if (cause instanceof McpClientAssertionError) return { ok: false, status: 503, reason: "assertion-unconfigured" };
180
+ return { ok: false, status: 502, reason: "mcp request failed" };
181
+ }
182
+ if (res.status < 200 || res.status >= 300) {
183
+ const notConfigured = res.status === 401 || res.status === 403;
184
+ return { ok: false, status: notConfigured ? 503 : 502, reason: `rendezvous status returned ${res.status}` };
185
+ }
186
+ const state = String(res.body.state ?? "expired") as RendezvousState;
187
+ return { ok: true, state, fingerprint: typeof res.body.fingerprint === "string" ? res.body.fingerprint : null };
188
+ }
189
+
190
+ export type RendezvousDecisionResult =
191
+ | { ok: true }
192
+ | { ok: false; status: 403 | 409 | 502 | 503; reason: string };
193
+
194
+ /**
195
+ * Approve the attached terminal. Carries the developer's fresh `subject_token`
196
+ * (Part A / Finding 3) so the MCP can bind the approval to the dev's own identity —
197
+ * a 403 `approver_mismatch` means the proof didn't match the rendezvous developer;
198
+ * a 409 `not_approvable` means the fingerprint didn't match a waiting terminal.
199
+ */
200
+ export async function approveRendezvous(
201
+ rendezvousCode: string,
202
+ fingerprint: string,
203
+ subjectToken: string,
204
+ deps: RendezvousDeps = {},
205
+ ): Promise<RendezvousDecisionResult> {
206
+ const target = await resolveMcpTarget();
207
+ if (!target) return { ok: false, status: 503, reason: "not-configured" };
208
+ let res: { status: number; body: Record<string, unknown> };
209
+ try {
210
+ res = await callRendezvous(
211
+ target,
212
+ `${RENDEZVOUS_AUDIENCE_PATH}/approve`,
213
+ { rendezvous_code: rendezvousCode, fingerprint, subject_token: subjectToken },
214
+ deps,
215
+ );
216
+ } catch (cause) {
217
+ if (cause instanceof McpClientAssertionError) return { ok: false, status: 503, reason: "assertion-unconfigured" };
218
+ return { ok: false, status: 502, reason: "mcp request failed" };
219
+ }
220
+ if (res.status >= 200 && res.status < 300) return { ok: true };
221
+ if (res.status === 403) return { ok: false, status: 403, reason: String(res.body.error ?? "approver_mismatch") };
222
+ if (res.status === 409) return { ok: false, status: 409, reason: String(res.body.error ?? "not_approvable") };
223
+ const notConfigured = res.status === 401;
224
+ return { ok: false, status: notConfigured ? 503 : 502, reason: `rendezvous approve returned ${res.status}` };
225
+ }
226
+
227
+ /** Reject the attached terminal (the human said "not me"). */
228
+ export async function denyRendezvous(
229
+ rendezvousCode: string,
230
+ deps: RendezvousDeps = {},
231
+ ): Promise<RendezvousDecisionResult> {
232
+ const target = await resolveMcpTarget();
233
+ if (!target) return { ok: false, status: 503, reason: "not-configured" };
234
+ try {
235
+ const res = await callRendezvous(target, `${RENDEZVOUS_AUDIENCE_PATH}/deny`, { rendezvous_code: rendezvousCode }, deps);
236
+ if (res.status >= 200 && res.status < 300) return { ok: true };
237
+ return { ok: false, status: res.status === 401 ? 503 : 502, reason: `rendezvous deny returned ${res.status}` };
238
+ } catch {
239
+ return { ok: false, status: 502, reason: "mcp request failed" };
240
+ }
241
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Re-issue a FRESH tot20-signed id_token for an already-signed-in developer — the
3
+ * `subject_token` proof the cockpit sends when APPROVING a browserless CLI sign-in
4
+ * rendezvous (Part A / Finding 3).
5
+ *
6
+ * WHY re-issue: the storefront verifies tot20's id_token OFFLINE at sign-in and then
7
+ * DISCARDS it (only derived claims live in the KV session), and it's short-lived —
8
+ * long expired by the time the developer approves. So at approve time we MINT a fresh
9
+ * single-use sign-in token for the session's VERIFIED email and exchange it for a
10
+ * freshly-signed id_token.
11
+ *
12
+ * WHY it closes Finding 3: the mint + exchange authenticate with the storefront's tot20
13
+ * CORE machine credential (TOT_API_KEY/TOT_SECRET_KEY) — a DIFFERENT secret from the MCP
14
+ * broker key. A compromised broker key alone cannot produce this proof.
15
+ *
16
+ * WHY mint, NOT developerInvites: tot20's access-code `/mint` is NOT operator-grant-gated
17
+ * per tenant, whereas developerInvites is (it 403s `notGranted` for a non-staff developer
18
+ * re-attesting themselves on a tenant the storefront doesn't operate). `/mint` binds the
19
+ * token to (email, aud = the storefront's OWN appDomain) — which equals the storefront's
20
+ * MCP client_id, exactly what the MCP binds the subject_token audience to. This is the
21
+ * same non-gated mint the MCP itself uses to power rendezvous creation.
22
+ */
23
+ import { getAccessClient } from "@/lib/auth/route";
24
+ import type { TotAccessClient } from "@/lib/auth/totAccessClient";
25
+
26
+ /** Short — the proof is minted and used immediately at approve time. */
27
+ const SUBJECT_TOKEN_TTL_SECONDS = 120;
28
+
29
+ export type SubjectReissueResult =
30
+ | { ok: true; idToken: string }
31
+ | { ok: false; reason: string };
32
+
33
+ export interface SubjectReissueDeps {
34
+ accessClient?: TotAccessClient | null;
35
+ }
36
+
37
+ /**
38
+ * Produce a fresh subject id_token for `email`. Fail-closed: any gap (unconfigured seam,
39
+ * mint refused, exchange failed / step-up required) returns a typed `{ ok:false, reason }`
40
+ * and NEVER throws — the caller maps that to a refusal to approve. The id_token is returned
41
+ * only to the server; it is never logged.
42
+ */
43
+ export async function reissueSubjectToken(
44
+ email: string,
45
+ deps: SubjectReissueDeps = {},
46
+ ): Promise<SubjectReissueResult> {
47
+ if (!email) return { ok: false, reason: "missing-identity" };
48
+
49
+ const accessClient =
50
+ deps.accessClient !== undefined ? deps.accessClient : await getAccessClient();
51
+ if (!accessClient) return { ok: false, reason: "access-client-unconfigured" };
52
+
53
+ // Mint a fresh single-use sign-in token for the developer's OWN email (bound to the
54
+ // storefront's appDomain as audience — not grant-gated per tenant).
55
+ const minted = await accessClient.mintSignInToken(email, SUBJECT_TOKEN_TTL_SECONDS);
56
+ if (!minted.ok || !minted.token) {
57
+ return { ok: false, reason: minted.ok ? "no-signin-token" : `mint-${minted.reason}` };
58
+ }
59
+
60
+ // Exchange (single-use; tot20 burns it) → a signed federated id_token whose aud is the
61
+ // storefront appDomain and whose email is this developer.
62
+ const exchanged = await accessClient.exchangeSignInToken(minted.token);
63
+ if (!exchanged.ok || !exchanged.id_token) {
64
+ return { ok: false, reason: exchanged.ok ? "no-id-token" : `exchange-${exchanged.reason}` };
65
+ }
66
+ return { ok: true, idToken: exchanged.id_token };
67
+ }
@@ -40,20 +40,20 @@ export function renderMagicLinkInviteEmail(input: MagicLinkInviteEmailInput): {
40
40
  const html = `<!doctype html>
41
41
  <html lang="en">
42
42
  <head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /></head>
43
- <body style="margin:0;padding:0;background:#f5f4f0;">
44
- <div style="max-width:520px;margin:0 auto;padding:32px 20px;font:16px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#1c1c1c;">
45
- <div style="font:600 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.12em;text-transform:uppercase;color:#0f7b6c;margin:0 0 24px;">
43
+ <body style="margin:0;padding:0;background:#f7f9fc;">
44
+ <div style="max-width:520px;margin:0 auto;padding:32px 20px;font:16px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#33415b;">
45
+ <div style="font:600 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.12em;text-transform:uppercase;color:#0b7a6d;margin:0 0 24px;">
46
46
  Token of Trust&#169; &middot; ${esc(store)}
47
47
  </div>
48
- <h1 style="font-size:22px;line-height:1.3;letter-spacing:-.01em;margin:0 0 12px;color:#0b1f3a;">You've been invited</h1>
49
- <p style="margin:0 0 8px;color:#3a3a3a;">You've been given developer access to <strong>${esc(store)}</strong>. Click below to sign in — no password needed.</p>
50
- ${note ? `<div style="margin:16px 0;padding:12px 14px;background:#eef4ff;border:1px solid #c3d4f5;border-radius:8px;color:#234;font-size:14px;"><strong>Note from your inviter:</strong><br>${note}</div>` : ""}
48
+ <h1 style="font-size:22px;line-height:1.3;letter-spacing:-.02em;margin:0 0 12px;color:#0a2540;">You've been invited</h1>
49
+ <p style="margin:0 0 8px;color:#33415b;">You've been given developer access to <strong>${esc(store)}</strong>. Click below to sign in — no password needed.</p>
50
+ ${note ? `<div style="margin:16px 0;padding:12px 14px;background:#e7f6f4;border:1px solid #b8e6df;border-radius:10px;color:#0a2540;font-size:14px;"><strong>Note from your inviter:</strong><br>${note}</div>` : ""}
51
51
  <div style="margin:28px 0;">
52
- <a href="${url}" style="display:inline-block;background:#0f7b6c;color:#ffffff;text-decoration:none;font-weight:600;font-size:16px;padding:14px 28px;border-radius:9px;">Sign in to ${esc(store)}</a>
52
+ <a href="${url}" style="display:inline-block;background:#0b7a6d;color:#ffffff;text-decoration:none;font-weight:600;font-size:16px;padding:14px 28px;border-radius:999px;">Sign in to ${esc(store)}</a>
53
53
  </div>
54
- <p style="margin:0 0 4px;font-size:13px;color:#6a6a6a;">Or paste this link into your browser:</p>
55
- <p style="margin:0 0 24px;font-size:13px;word-break:break-all;"><a href="${url}" style="color:#0f7b6c;">${url}</a></p>
56
- <p style="margin:0;font-size:12px;color:#9a9a9a;border-top:1px solid #e6e4de;padding-top:16px;">This is a single-use sign-in link. If you didn't expect this invitation, you can ignore this email.</p>
54
+ <p style="margin:0 0 4px;font-size:13px;color:#55637a;">Or paste this link into your browser:</p>
55
+ <p style="margin:0 0 24px;font-size:13px;word-break:break-all;"><a href="${url}" style="color:#0b7a6d;">${url}</a></p>
56
+ <p style="margin:0;font-size:12px;color:#8593a8;border-top:1px solid #e3e8ef;padding-top:16px;">This is a single-use sign-in link. If you didn't expect this invitation, you can ignore this email.</p>
57
57
  </div>
58
58
  </body>
59
59
  </html>`;