@tokenoftrust/storefront-runner 1.3.4-rc.4 → 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 (224) 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/previewStatus.ts +112 -0
  94. package/apps/storefront/src/lib/dev/rendezvousBroker.ts +241 -0
  95. package/apps/storefront/src/lib/dev/subjectReissue.ts +67 -0
  96. package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +10 -10
  97. package/apps/storefront/src/lib/env.ts +12 -0
  98. package/apps/storefront/src/lib/jsonld.ts +12 -17
  99. package/apps/storefront/src/lib/membership/eligibility.ts +37 -0
  100. package/apps/storefront/src/lib/monitoring/manifest.ts +302 -0
  101. package/apps/storefront/src/lib/privacy/emailHint.ts +13 -5
  102. package/apps/storefront/src/lib/publish/apex-readiness.ts +337 -0
  103. package/apps/storefront/src/lib/publish/dispatchHealth.ts +269 -0
  104. package/apps/storefront/src/lib/publish/domainState.ts +351 -0
  105. package/apps/storefront/src/lib/publish/shipWorkspace.ts +362 -0
  106. package/apps/storefront/src/lib/rawChrome.ts +34 -3
  107. package/apps/storefront/src/lib/storyblok/content-model.ts +34 -2
  108. package/apps/storefront/src/lib/storyblok/provider.ts +11 -4
  109. package/apps/storefront/src/lib/subscription/model.ts +114 -0
  110. package/apps/storefront/src/lib/tot/ToTClient.ts +3 -3
  111. package/apps/storefront/src/lib/tot/query.ts +32 -0
  112. package/apps/storefront/src/lib/webhooks/cloudflareQueueDispatcher.ts +82 -0
  113. package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +245 -0
  114. package/apps/storefront/src/lib/webhooks/deliveryMapper.ts +34 -0
  115. package/apps/storefront/src/lib/webhooks/deliveryStore.ts +668 -0
  116. package/apps/storefront/src/lib/webhooks/dispatcher.ts +168 -0
  117. package/apps/storefront/src/lib/webhooks/emit.ts +167 -0
  118. package/apps/storefront/src/lib/webhooks/endpointGuard.ts +135 -0
  119. package/apps/storefront/src/lib/webhooks/events.ts +98 -0
  120. package/apps/storefront/src/lib/webhooks/getDispatcher.ts +49 -0
  121. package/apps/storefront/src/lib/webhooks/signing.ts +29 -0
  122. package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +146 -0
  123. package/apps/storefront/src/middleware/index.ts +28 -13
  124. package/apps/storefront/src/pages/404.astro +21 -9
  125. package/apps/storefront/src/pages/[...slug].astro +36 -2
  126. package/apps/storefront/src/pages/admin.astro +29 -3
  127. package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +60 -0
  128. package/apps/storefront/src/pages/api/apps/admin/health.ts +44 -0
  129. package/apps/storefront/src/pages/api/apps/admin/install.ts +100 -0
  130. package/apps/storefront/src/pages/api/apps/admin/list.ts +26 -0
  131. package/apps/storefront/src/pages/api/apps/admin/resume.ts +56 -0
  132. package/apps/storefront/src/pages/api/apps/admin/suspend.ts +57 -0
  133. package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +74 -0
  134. package/apps/storefront/src/pages/api/apps/admin/update.ts +103 -0
  135. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +59 -0
  136. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries.ts +51 -0
  137. package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +172 -0
  138. package/apps/storefront/src/pages/api/apps/oauth/token.ts +87 -0
  139. package/apps/storefront/src/pages/api/apps/v1/attribution.ts +142 -0
  140. package/apps/storefront/src/pages/api/apps/v1/catalog/products/[handle].ts +39 -0
  141. package/apps/storefront/src/pages/api/apps/v1/catalog/products.ts +49 -0
  142. package/apps/storefront/src/pages/api/apps/v1/health.ts +32 -0
  143. package/apps/storefront/src/pages/api/apps/v1/inventory.ts +59 -0
  144. package/apps/storefront/src/pages/api/apps/v1/orders/[id].ts +48 -0
  145. package/apps/storefront/src/pages/api/apps/v1/orders.ts +73 -0
  146. package/apps/storefront/src/pages/api/apps/v1/reports.ts +21 -0
  147. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +62 -0
  148. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries.ts +47 -0
  149. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +48 -1
  150. package/apps/storefront/src/pages/api/auth/step-up-send.ts +57 -0
  151. package/apps/storefront/src/pages/api/auth/step-up-verify.ts +129 -0
  152. package/apps/storefront/src/pages/api/auth/verify.ts +23 -0
  153. package/apps/storefront/src/pages/api/compliance/preflight.ts +206 -0
  154. package/apps/storefront/src/pages/api/rum/vitals.ts +54 -0
  155. package/apps/storefront/src/pages/api/test/dev-session.ts +133 -0
  156. package/apps/storefront/src/pages/auth/login.astro +197 -41
  157. package/apps/storefront/src/pages/auth/magic.astro +75 -43
  158. package/apps/storefront/src/pages/blog/[slug].astro +107 -0
  159. package/apps/storefront/src/pages/blog/index.astro +98 -0
  160. package/apps/storefront/src/pages/capabilities.astro +8 -0
  161. package/apps/storefront/src/pages/cockpit.astro +433 -69
  162. package/apps/storefront/src/pages/collections/[handle].astro +8 -0
  163. package/apps/storefront/src/pages/collections/index.astro +10 -2
  164. package/apps/storefront/src/pages/dashboard/[appDomain]/apps/index.astro +119 -0
  165. package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +5 -0
  166. package/apps/storefront/src/pages/index.astro +57 -0
  167. package/apps/storefront/src/pages/llms.txt.ts +31 -10
  168. package/apps/storefront/src/pages/products/[handle].astro +100 -9
  169. package/apps/storefront/src/pages/sitemap.xml.ts +21 -4
  170. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +198 -0
  171. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +7 -0
  172. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +7 -0
  173. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -0
  174. package/apps/storefront/src/pages/style-guide/index.astro +10 -0
  175. package/apps/storefront/src/styles/fonts.css +54 -0
  176. package/apps/storefront/src/styles/global.css +22 -2
  177. package/apps/storefront/src/themes/schema.ts +3 -25
  178. package/apps/storefront/tsconfig.json +1 -1
  179. package/apps/storefront/vitest.config.ts +4 -1
  180. package/package.json +1 -1
  181. package/packages/public-runtime/src/candidate-index.ts +311 -0
  182. package/packages/public-runtime/src/checkout.ts +94 -2
  183. package/packages/public-runtime/src/compliance/evaluate.ts +265 -0
  184. package/packages/public-runtime/src/compliance/evidence-signals.ts +81 -0
  185. package/packages/public-runtime/src/compliance/index.ts +22 -0
  186. package/packages/public-runtime/src/compliance/pact-report.ts +94 -0
  187. package/packages/public-runtime/src/compliance/profile.ts +198 -0
  188. package/packages/public-runtime/src/compliance/ruleset.ts +117 -0
  189. package/packages/public-runtime/src/compliance/verification.ts +81 -0
  190. package/packages/public-runtime/src/csp.ts +27 -3
  191. package/packages/public-runtime/src/customization-reconcile.ts +35 -0
  192. package/packages/public-runtime/src/customization-runtime.ts +8 -0
  193. package/packages/public-runtime/src/customization-versioning.ts +17 -0
  194. package/packages/public-runtime/src/extension-contract.ts +2 -1
  195. package/packages/public-runtime/src/hash.ts +25 -0
  196. package/packages/public-runtime/src/index.ts +6 -0
  197. package/packages/public-runtime/src/membership.ts +353 -0
  198. package/packages/public-runtime/src/product.ts +24 -2
  199. package/packages/public-runtime/src/review-trust-proof.ts +194 -0
  200. package/packages/public-runtime/src/tenant-assets.ts +40 -5
  201. package/packages/public-runtime/src/tenant.ts +236 -0
  202. package/packages/public-runtime/src/widget-postmessage.ts +205 -0
  203. package/scripts/dev/publish.mjs +158 -0
  204. package/scripts/dev/transient-files.mjs +2 -1
  205. package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
  206. package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
  207. package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
  208. package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
  209. package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
  210. package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
  211. package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  212. package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
  213. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
  214. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
  215. package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
  216. package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
  217. package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
  218. package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
  219. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
  220. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
  221. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
  222. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
  223. package/tenants/home/public/pages/storefront.css +23 -0
  224. package/apps/storefront/src/lib/dev/hostedCockpit.ts +0 -169
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Drizzle schema — the source of truth for `STOREFRONT_APPS_DB` *migration
3
+ * generation* (ADR-0002). Split out of `schema.ts` (which now covers only the
4
+ * catalog + customization tables on `CATALOG_DB`): these tables are the
5
+ * storefront-apps registry/webhooks/orders spine and belong to a DEDICATED
6
+ * D1, never the catalog.
7
+ *
8
+ * `drizzle-kit generate --config drizzle.config.apps.ts` diffs this into
9
+ * numbered SQL under `migrations-apps/`, applied by `wrangler d1 migrations
10
+ * apply STOREFRONT_APPS_DB`.
11
+ */
12
+ import { sqliteTable, text, integer, real, index, uniqueIndex, primaryKey } from "drizzle-orm/sqlite-core";
13
+
14
+ // --- Private Apps registry (PrivateApps epic, D2) ----------------------------
15
+ // One install per (app, tenant, env). `installId` is the future JWT `sub`.
16
+ // Append-only `app_audit_log` is never pruned by the service layer.
17
+ export const appInstalls = sqliteTable(
18
+ "app_installs",
19
+ {
20
+ installId: text("install_id").primaryKey(),
21
+ appId: text("app_id").notNull(),
22
+ appVersion: text("app_version").notNull(),
23
+ tenantId: text("tenant_id").notNull(),
24
+ env: text("env").notNull(),
25
+ status: text("status").notNull().default("active"),
26
+ scopes: text("scopes").notNull().default("[]"),
27
+ manifest: text("manifest").notNull(),
28
+ webhookEndpoint: text("webhook_endpoint"),
29
+ widgetPlacements: text("widget_placements").notNull().default("[]"),
30
+ installActor: text("install_actor").notNull(),
31
+ createdAt: text("created_at").notNull(),
32
+ updatedAt: text("updated_at").notNull(),
33
+ },
34
+ (t) => ({
35
+ uqAppInstallsTenantEnv: uniqueIndex("uq_app_installs_app_tenant_env").on(
36
+ t.appId,
37
+ t.tenantId,
38
+ t.env,
39
+ ),
40
+ idxAppInstallsTenant: index("idx_app_installs_tenant").on(t.tenantId),
41
+ }),
42
+ );
43
+
44
+ // Server-issued client-credentials for one install. The secret is never stored
45
+ // in plaintext — only its sha256 hash (@tot/public-runtime's `sha256Hex`).
46
+ export const appCredentials = sqliteTable("app_credentials", {
47
+ installId: text("install_id").primaryKey(),
48
+ clientId: text("client_id").notNull().unique(),
49
+ secretHash: text("secret_hash").notNull(),
50
+ secretCreatedAt: text("secret_created_at").notNull(),
51
+ rotatedAt: text("rotated_at"),
52
+ jwksKid: text("jwks_kid").notNull(),
53
+ });
54
+
55
+ // Append-only audit trail. `actor` is an opaque id — never email/PII — and
56
+ // `detail` is redacted JSON (never secret material).
57
+ export const appAuditLog = sqliteTable(
58
+ "app_audit_log",
59
+ {
60
+ id: text("id").primaryKey(),
61
+ installId: text("install_id").notNull(),
62
+ tenantId: text("tenant_id").notNull(),
63
+ event: text("event").notNull(),
64
+ actor: text("actor").notNull(),
65
+ traceId: text("trace_id"),
66
+ detail: text("detail").notNull().default("{}"),
67
+ at: text("at").notNull(),
68
+ },
69
+ (t) => ({
70
+ idxAppAuditInstall: index("idx_app_audit_install").on(t.installId),
71
+ }),
72
+ );
73
+
74
+ // --- Webhook event spine (PrivateApps epic, D4 Chunk A) ---------------------
75
+ // Immutable event log. `id` is the semantic event id we mint — distinct from
76
+ // any per-delivery wire envelope id (see `webhookDeliveries.deliveryId` below).
77
+ export const webhookEvents = sqliteTable("webhook_events", {
78
+ id: text("id").primaryKey(),
79
+ tenantId: text("tenant_id").notNull(),
80
+ topic: text("topic").notNull(),
81
+ dataschemaVersion: text("dataschema_version").notNull(),
82
+ payload: text("payload").notNull(),
83
+ createdAt: text("created_at").notNull(),
84
+ });
85
+
86
+ // One row per (event × target install). `deliveryId` is the on-wire
87
+ // CloudEvents `id` — stable across retries/replays so receivers can dedupe —
88
+ // which is why it, not `eventId`, is the primary key here.
89
+ export const webhookDeliveries = sqliteTable(
90
+ "webhook_deliveries",
91
+ {
92
+ deliveryId: text("delivery_id").primaryKey(),
93
+ // FK to webhookEvents.id, enforced in code only (no SQL FK — repo convention).
94
+ eventId: text("event_id").notNull(),
95
+ installId: text("install_id").notNull(),
96
+ tenantId: text("tenant_id").notNull(),
97
+ topic: text("topic").notNull(),
98
+ state: text("state").notNull().default("pending"),
99
+ attempts: integer("attempts").notNull().default(0),
100
+ lastStatus: integer("last_status"),
101
+ lastErrorClass: text("last_error_class"),
102
+ lastTraceId: text("last_trace_id"),
103
+ nextAttemptAt: text("next_attempt_at"),
104
+ createdAt: text("created_at").notNull(),
105
+ updatedAt: text("updated_at").notNull(),
106
+ },
107
+ (t) => ({
108
+ idxWebhookDeliveriesInstall: index("idx_webhook_deliveries_install").on(t.installId),
109
+ idxWebhookDeliveriesDue: index("idx_webhook_deliveries_due").on(t.state, t.nextAttemptAt),
110
+ }),
111
+ );
112
+
113
+ // Append-only per-HTTP-attempt log, never pruned by the service layer.
114
+ export const webhookDeliveryAttempts = sqliteTable(
115
+ "webhook_delivery_attempts",
116
+ {
117
+ id: text("id").primaryKey(),
118
+ deliveryId: text("delivery_id").notNull(),
119
+ attemptNo: integer("attempt_no").notNull(),
120
+ startedAt: text("started_at").notNull(),
121
+ durationMs: integer("duration_ms"),
122
+ httpStatus: integer("http_status"),
123
+ errorClass: text("error_class"),
124
+ traceId: text("trace_id"),
125
+ },
126
+ (t) => ({
127
+ idxWebhookDeliveryAttemptsDelivery: index("idx_webhook_delivery_attempts_delivery").on(t.deliveryId),
128
+ }),
129
+ );
130
+
131
+ // Rollup, one row per install — mirrors the openapi `InstallHealth` shape.
132
+ // `recentTraceIds` is a capped (~10) JSON array, newest first.
133
+ export const webhookInstallHealth = sqliteTable("webhook_install_health", {
134
+ installId: text("install_id").primaryKey(),
135
+ lastSuccessAt: text("last_success_at"),
136
+ failureStreak: integer("failure_streak").notNull().default(0),
137
+ dlqCount: integer("dlq_count").notNull().default(0),
138
+ lastStatus: integer("last_status"),
139
+ lastErrorClass: text("last_error_class"),
140
+ recentTraceIds: text("recent_trace_ids").notNull().default("[]"),
141
+ updatedAt: text("updated_at").notNull(),
142
+ });
143
+
144
+ // --- Order ingestion (PrivateApps epic, D6 Chunk A) --------------------------
145
+ // The read-model `ordersStore.ts` diffs incoming Foxy orders against (created
146
+ // → fulfilled transitions, for a later chunk's event emission) and that the
147
+ // attribution route (also later) reads from for its cross-tenant ownership
148
+ // check. `(tenantId, externalOrderId)` is the composite PK — same "no
149
+ // synthetic id, the natural key IS the key" style as `customizationVersions`
150
+ // (schema.ts) — no SQL FK to `appAttributions.orderId` (repo convention — code-only).
151
+ export const orders = sqliteTable(
152
+ "orders",
153
+ {
154
+ tenantId: text("tenant_id").notNull(),
155
+ // Foxy's own transaction id — never generated here.
156
+ externalOrderId: text("external_order_id").notNull(),
157
+ env: text("env").notNull(),
158
+ status: text("status").notNull(),
159
+ currency: text("currency").notNull(),
160
+ total: real("total").notNull().default(0),
161
+ subtotal: real("subtotal"),
162
+ // Opaque, stable per (tenant, app-install) — see `orders/customerHash.ts`. Never raw PII.
163
+ customerHash: text("customer_hash"),
164
+ lineItems: text("line_items").notNull().default("[]"),
165
+ createdAt: text("created_at").notNull(),
166
+ updatedAt: text("updated_at").notNull(),
167
+ },
168
+ (t) => ({
169
+ pk: primaryKey({ columns: [t.tenantId, t.externalOrderId] }),
170
+ idxOrdersTenant: index("idx_orders_tenant").on(t.tenantId),
171
+ }),
172
+ );
173
+
174
+ // First idempotency ledger in the codebase (D6 Chunk A) — keyed per
175
+ // `(installId, idempotencyKey)` so a retried write (e.g. attribution POST)
176
+ // replays the stored `responseBody` instead of re-applying the mutation.
177
+ // `bodyHash` (sha256 of the canonical request body) lets a caller re-sending
178
+ // the SAME key with a DIFFERENT body be rejected rather than silently replayed.
179
+ export const idempotencyKeys = sqliteTable(
180
+ "idempotency_keys",
181
+ {
182
+ installId: text("install_id").notNull(),
183
+ idempotencyKey: text("idempotency_key").notNull(),
184
+ bodyHash: text("body_hash").notNull(),
185
+ responseBody: text("response_body").notNull(),
186
+ createdAt: text("created_at").notNull(),
187
+ },
188
+ (t) => ({ pk: primaryKey({ columns: [t.installId, t.idempotencyKey] }) }),
189
+ );
190
+
191
+ // App-owned attribution/commission records (`attribution:write`, D6 — later
192
+ // chunk owns the route). `orderId` references `orders.externalOrderId` in code
193
+ // only (no SQL FK, repo convention); the route chunk enforces the cross-tenant
194
+ // ownership check the contract requires before ever writing a row here.
195
+ export const appAttributions = sqliteTable(
196
+ "app_attributions",
197
+ {
198
+ installId: text("install_id").notNull(),
199
+ attributionId: text("attribution_id").notNull(),
200
+ tenantId: text("tenant_id").notNull(),
201
+ orderId: text("order_id").notNull(),
202
+ campaign: text("campaign"),
203
+ referralCode: text("referral_code"),
204
+ commissionAmount: real("commission_amount"),
205
+ commissionCurrency: text("commission_currency"),
206
+ customerHash: text("customer_hash"),
207
+ recordedAt: text("recorded_at").notNull(),
208
+ },
209
+ (t) => ({
210
+ pk: primaryKey({ columns: [t.installId, t.attributionId] }),
211
+ idxAppAttributionsTenantOrder: index("idx_app_attributions_tenant_order").on(t.tenantId, t.orderId),
212
+ }),
213
+ );
@@ -8,7 +8,13 @@
8
8
  *
9
9
  * See decision `architecture-environment-aware-cockpit`
10
10
  * (workstream tot-lime-cloud-dev-cockpit).
11
+ *
12
+ * `basePath` is the request's tenant prefix ("" host-routed, "/<tenant>" path-routed).
13
+ * The HOSTED routes are tenant-scoped through the middleware's host/path resolution, so
14
+ * a `/<tenant>/cockpit` render must call `/<tenant>/api/dev/*` for `locals.tenant` to be
15
+ * that store on the API request (not the generic home host). The local `/__tot/*` dev
16
+ * plugins are single-tenant and take no prefix.
11
17
  */
12
- export function resolveDevApiBase(isDev: boolean): string {
13
- return isDev ? "/__tot" : "/api/dev";
18
+ export function resolveDevApiBase(isDev: boolean, basePath = ""): string {
19
+ return isDev ? "/__tot" : `${basePath}/api/dev`;
14
20
  }
@@ -1,133 +1,81 @@
1
1
  /**
2
2
  * Mint a browserless "get going" command for an ALREADY-AUTHENTICATED developer — the
3
3
  * second half of the invite story. The developer accepted the magic link (OTP-free web
4
- * sign-in) and landed on /dev; this hands them a single-copy, TWO-LINE paste — line 1 signs
5
- * the terminal in with the one-time code, line 2 runs `start` (checkout → run → open the
6
- * browser on the working site) — so they go from web sign-in to a running store with NO
7
- * second browser round-trip and NO OTP.
4
+ * sign-in) and landed on /cockpit; this hands them a single-copy, THREE-LINE paste — line 1
5
+ * installs the CLI, line 2 signs the terminal in, line 3 runs `start` (checkout → run →
6
+ * open the browser on the working site) — so they go from web sign-in to a running store
7
+ * with NO second browser round-trip and NO OTP.
8
8
  *
9
- * The token is minted by the MCP (POST /api/internal/dev-access/cli-signin-code), bound
10
- * to the MCP's OWN audience, so it's redeemable only by that same MCP's /oauth/redeem-code
11
- * — exactly what `tot login --code` does. We reuse the SAME seam as the app-binding writer:
12
- * - MCP host ← the ORIGIN of MCP_APP_BINDING_URL (one source, so the mint host and the
13
- * --mcp host the developer runs are provably the same MCP tier).
14
- * - auth ← header x-mcp-service-secret: MCP_SERVICE_SHARED_SECRET (the storefront
15
- * never holds the MCP's core credential).
9
+ * The paste carries a NON-SECRET rendezvous handle minted by the MCP's
10
+ * POST /oauth/device/rendezvous (RFC 8628 rendezvous), authenticated by the storefront's
11
+ * SIGNED CLIENT ASSERTION (private_key_jwt) NOT a shared secret. `tot login --code <handle>`
12
+ * then ATTACHES the terminal's own PKCE key and the developer approves it by fingerprint
13
+ * back here in the cockpit (see the /api/dev/cli-signin-{status,approve} routes). The handle
14
+ * is not a credential: intercepting it grants nothing without the terminal's private key,
15
+ * and the developer must still approve the matching fingerprint.
16
16
  *
17
- * Line 1 appends `--mcp <origin>` ONLY when the MCP is not the CLI's built-in prod default —
18
- * on prod the shortest form (`npx @tokenoftrust/cli login --code <token>`) is emitted.
17
+ * Line 2 appends `--mcp <origin>` ONLY when the MCP is not the CLI's built-in prod default —
18
+ * on prod the shortest form is emitted.
19
19
  *
20
20
  * `activityBridge` (optional) piggybacks the local→hosted activity-bridge credential
21
- * (see lib/dev/activityIngestToken.ts) onto the SAME pasted line as two extra `tot
22
- * login` flags — no second browser round-trip, no MCP-repo change. `tot login`
23
- * caches it in ~/.tot/credentials.json, and `tot dev` threads it into the spawned
24
- * dev-loop process so it can report file saves to the developer's own hosted /dev
25
- * panel. Omitted entirely when the caller has no SESSION KV to mint against — the
26
- * local loop then simply runs with no hosted signal, same as today.
21
+ * (see lib/dev/activityIngestToken.ts) onto the SAME pasted line as two extra `tot login`
22
+ * flags — no second browser round-trip. Omitted entirely when the caller has no SESSION KV
23
+ * to mint against the local loop then simply runs with no hosted signal.
27
24
  */
28
25
  import { readEnv } from "@/lib/env";
29
26
  import { redactEmailForHint } from "@/lib/privacy/emailHint";
27
+ import { createRendezvous, type RendezvousDeps } from "@/lib/dev/rendezvousBroker";
30
28
 
31
29
  // The CLI's built-in default MCP (packages/cli login.mjs DEFAULT_MCP_URL). When the derived
32
30
  // MCP origin equals this, --mcp is redundant noise, so we omit it.
33
31
  const PROD_MCP_ORIGIN = "https://mcp.tokenoftrust.com";
34
32
 
35
- export interface HttpTransport {
36
- post(
37
- url: string,
38
- headers: Record<string, string>,
39
- body: unknown,
40
- ): Promise<{ status: number; json(): Promise<unknown> }>;
41
- }
42
-
43
- export class FetchHttpTransport implements HttpTransport {
44
- async post(url: string, headers: Record<string, string>, body: unknown) {
45
- const res = await fetch(url, {
46
- method: "POST",
47
- headers: { "Content-Type": "application/json", ...headers },
48
- body: JSON.stringify(body),
49
- });
50
- return { status: res.status, json: () => res.json() };
51
- }
52
- }
53
-
54
33
  export type CliSignInCodeResult =
55
- | { ok: true; command: string; expiresAt: string | null }
34
+ | { ok: true; command: string; rendezvousCode: string; expiresAt: string | null }
56
35
  | { ok: false; status: 502 | 503; reason: string };
57
36
 
58
37
  /**
59
38
  * Mint the ready-to-paste CLI sign-in command for `email`. The caller MUST pass the
60
39
  * VERIFIED session email (never a client-supplied value) — a signed-in developer can only
61
- * mint a code for themselves. `tenant` (the developer's own vendor, session-derived) is
62
- * threaded to the MCP so the code is TENANT-SCOPED: the redeemed CLI session lands
63
- * already bound to that store (no client_list round-trip; see the route header). Older
64
- * MCP deploys ignore the extra field additive, never breaking. Fail-closed: an
65
- * unconfigured seam (no URL/secret) or a 401 (MCP route unmounted / bad secret) is a 503;
66
- * any other non-2xx / transport / parse fault is a 502. Never throws.
40
+ * start a rendezvous for themselves. `tenant` (the developer's own vendor, session-derived)
41
+ * is threaded so the CLI session lands already bound to that store (no client_list
42
+ * round-trip). Fail-closed: an unconfigured seam or a 401/403 (assertion rejected / client
43
+ * not entitled to broker) is a 503; any other fault is a 502. Never throws.
44
+ *
45
+ * Returns the pasteable `command` AND the `rendezvousCode` the cockpit polls the latter
46
+ * for a terminal attaching, then shows the fingerprint approval prompt.
67
47
  */
68
48
  export async function mintCliSignInCommand(
69
49
  email: string,
70
50
  tenant: string | null = null,
71
- deps: { transport?: HttpTransport } = {},
51
+ deps: RendezvousDeps = {},
72
52
  activityBridge: { token: string; url: string } | null = null,
73
53
  traceId: string | null = null,
74
54
  ): Promise<CliSignInCodeResult> {
75
- const bindingUrl = await readEnv("MCP_APP_BINDING_URL");
76
- const secret = await readEnv("MCP_SERVICE_SHARED_SECRET");
77
- if (!bindingUrl || !secret) {
55
+ // MCP_BASE_URL is the MCP tier this storefront's client assertion is registered
56
+ // with (where the rendezvous is created), so the pasted `--mcp` host MUST match it
57
+ // — the terminal redeems the rendezvous against that same MCP. (The retired shim
58
+ // used MCP_APP_BINDING_URL, which may point at a different tier.)
59
+ const baseUrl = await readEnv("MCP_BASE_URL");
60
+ if (!baseUrl) {
78
61
  return { ok: false, status: 503, reason: "not-configured" };
79
62
  }
80
63
  let mcpOrigin: string;
81
- let endpoint: string;
82
64
  try {
83
- mcpOrigin = new URL(bindingUrl).origin;
84
- // Mounted OUTSIDE /api on the MCP (the /api surface there is session-guarded); use the
85
- // origin of MCP_APP_BINDING_URL, not its full path.
86
- endpoint = `${mcpOrigin}/internal/dev-access/cli-signin-code`;
65
+ mcpOrigin = new URL(baseUrl).origin;
87
66
  } catch {
88
67
  return { ok: false, status: 503, reason: "bad-mcp-url" };
89
68
  }
90
69
 
91
- const transport = deps.transport ?? new FetchHttpTransport();
92
- let res: { status: number; json(): Promise<unknown> };
93
- try {
94
- res = await transport.post(
95
- endpoint,
96
- { "x-mcp-service-secret": secret },
97
- {
98
- email: email.toLowerCase(),
99
- ...(tenant ? { tenant: tenant.toLowerCase() } : {}),
100
- },
101
- );
102
- } catch (cause) {
103
- return {
104
- ok: false,
105
- status: 502,
106
- reason: "mcp request failed: " + (cause instanceof Error ? cause.message : String(cause)),
107
- };
108
- }
109
-
110
- if (res.status < 200 || res.status >= 300) {
111
- // 401 = MCP route unmounted or bad shared secret → treat as not-configured (503).
112
- return {
113
- ok: false,
114
- status: res.status === 401 ? 503 : 502,
115
- reason: `mcp cli-signin-code returned status ${res.status}`,
116
- };
117
- }
118
-
119
- let payload: { token?: string; expiresAt?: string };
120
- try {
121
- payload = (await res.json()) as typeof payload;
122
- } catch {
123
- return { ok: false, status: 502, reason: "unparseable mcp response" };
124
- }
125
- if (!payload.token) {
126
- return { ok: false, status: 502, reason: "mcp returned no token" };
70
+ const created = await createRendezvous(email, tenant, deps);
71
+ if (!created.ok) {
72
+ // 503 = seam not configured / assertion rejected / not entitled on this env;
73
+ // 502 = MCP fault. Surface neither internal detail nor any token.
74
+ return { ok: false, status: created.status, reason: created.reason };
127
75
  }
128
76
 
129
77
  const mcpFlag = mcpOrigin === PROD_MCP_ORIGIN ? "" : ` --mcp ${mcpOrigin}`;
130
- // Display-only recovery hint for a stale one-use code. If redemption fails before
78
+ // Display-only recovery hint for a stale one-use handle. If sign-in fails before
131
79
  // anything is cached locally, `tot login` can still send the developer back to
132
80
  // cockpit with the right address prefilled.
133
81
  const emailHint = redactEmailForHint(email);
@@ -137,38 +85,40 @@ export async function mintCliSignInCommand(
137
85
  const activityFlags = activityBridge
138
86
  ? ` --activity-token ${activityBridge.token} --activity-url ${activityBridge.url}`
139
87
  : "";
140
- // The invite→terminal→problem trace id, minted once with this sign-in code and
141
- // baked into the activity-ingest-token claims. Emitted here too so `tot login`
142
- // records it locally — additive, ignored by older CLI builds. The receiver gets
143
- // the SAME id from the ingest-token claims, so runtime/activity writes stamp it.
88
+ // The invite→terminal→problem trace id, minted once with this rendezvous and baked
89
+ // into the activity-ingest-token claims. Emitted here too so `tot login` records it
90
+ // locally — additive, ignored by older CLI builds.
144
91
  const traceFlag = traceId ? ` --trace-id ${traceId}` : "";
145
- // A single copy a three-line paste that takes the developer all the way to a running
146
- // store, no browser, AND leaves `tot` on their PATH so every later command is a bare
147
- // `tot …` (not `npx @tokenoftrust/cli …`):
148
- // 1. `npm i -g @tokenoftrust/cli` — install the `tot` command once.
149
- // 2. `tot login --code <token>` — sign the terminal in with the single-use code.
150
- // 3. `tot start` — checkout + run + open the browser on the working site, reusing the
151
- // session step 2 just cached (so `start` never opens its own sign-in).
152
- //
153
- // The trailing `&&` chains them: each step runs ONLY if the previous succeeded. In
154
- // particular a stale/burned code (single-use, short-TTL) fails step 2 and stops there
155
- // with `login`'s cockpit recovery step — it never falls through to `start`'s browser
156
- // sign-in (the surprise we're avoiding).
157
- // The EXACT version this build was cut alongside — `@tokenoftrust/cli@<version>` — baked
158
- // in at build time (astro.config Vite define) so the paste installs precisely the CLI this
159
- // cockpit expects, never a floating dist-tag the terminal resolves (which can drift behind
160
- // the build; that gap shipped stampless installs). `typeof` guard: the define isn't applied
161
- // under vitest, where it falls back to the unpinned spec.
92
+ // The EXACT version this build was cut alongside baked in at build time (astro.config
93
+ // Vite define) so the paste installs precisely the CLI this cockpit expects. `typeof`
94
+ // guard: the define isn't applied under vitest, where it falls back to the unpinned spec.
162
95
  const cliInstallSpec =
163
96
  typeof __CLI_INSTALL_SPEC__ !== "undefined" ? __CLI_INSTALL_SPEC__ : "@tokenoftrust/cli";
97
+ // `hash -r` after the install clears the shell's cached command path so the
98
+ // just-installed `tot` is the one that runs — without it, a shell that already
99
+ // resolved an OLDER `tot` (e.g. a stray Homebrew/other-prefix install earlier in
100
+ // this session) keeps running the stale binary, and `npm i -g` silently updates a
101
+ // file the shell never consults. Portable across bash/zsh/sh; fish can't run the
102
+ // `&&` chain anyway. This is what makes the pinned version actually take effect.
103
+ // A first-line no-op stamping which cockpit build generated this command (short git
104
+ // SHA — the same one /health reports) + the CLI it pins, so a paste can be matched to
105
+ // a deploy at a glance (pairs with `tot login`'s runtime version banner). Uses the
106
+ // `: '…'` null-command form, NOT a `#` comment — interactive zsh doesn't treat `#` as
107
+ // a comment (no INTERACTIVE_COMMENTS) and errors on it; `: '…'` is a portable no-op.
108
+ const cockpitBuild = (typeof __GIT_SHA__ !== "undefined" ? __GIT_SHA__ : "unknown").slice(0, 7);
164
109
  const command = [
110
+ `: 'tot setup (cockpit ${cockpitBuild}, ${cliInstallSpec})'`,
165
111
  `npm i -g ${cliInstallSpec} &&`,
166
- `tot login --code ${payload.token}${emailHintFlag}${mcpFlag}${traceFlag}${activityFlags} &&`,
167
- `tot start`,
112
+ `hash -r &&`,
113
+ `tot login --code ${created.rendezvousCode}${emailHintFlag}${mcpFlag}${traceFlag}${activityFlags} &&`,
114
+ // `start` must talk to the SAME MCP the session was minted on — it otherwise defaults
115
+ // to the prod MCP and won't see a qa session (→ "not signed in" → sample fallback).
116
+ `tot start${mcpFlag}`,
168
117
  ].join("\n");
169
118
  return {
170
119
  ok: true,
171
120
  command,
172
- expiresAt: payload.expiresAt ?? null,
121
+ rendezvousCode: created.rendezvousCode,
122
+ expiresAt: created.expiresIn != null ? new Date(Date.now() + created.expiresIn * 1000).toISOString() : null,
173
123
  };
174
124
  }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * "Which store is this cockpit request about?" — the ONE resolver the hosted
3
+ * cockpit page, its /api/dev/* routes, and the CLI sign-in-code mint all share, so
4
+ * they never disagree about the developer's store.
5
+ *
6
+ * REGRESSION FIXED (8500 <tenant>/cockpit): the page and the mint used to derive
7
+ * the store purely from the session's FIRST membership
8
+ * (`session.tenants?.[0]?.resource ?? session.resource`), ignoring the store the
9
+ * request URL actually named. An invite to `/<tenantB>/cockpit` therefore rendered —
10
+ * and minted a `tot login --code … && tot start` for — whatever store happened to be
11
+ * listed first in the session, "losing track of the store" for any developer who
12
+ * belongs to more than one.
13
+ *
14
+ * Rule: the URL wins WHEN THE VIEWER CAN ACCESS IT. The middleware has already
15
+ * resolved the request's host/path to `locals.tenant` (the trusted, authenticated
16
+ * store seam — never client body input), so when that is a SPECIFIC store (not the
17
+ * public marketing home) and the signed-in developer holds a membership for it (or a
18
+ * live staff admission), that store is authoritative. Otherwise we fall back to the
19
+ * session's own first store (the generic-host `/cockpit` case, unchanged), and for an
20
+ * anonymous viewer to the path store's branding, else the caller-supplied `?tenant=`.
21
+ *
22
+ * Access is gated with the SAME primitive (`viewerCanAccess`) the vendor dashboard
23
+ * uses, so a developer can never steer the mint at a store they don't belong to; a
24
+ * store they can't access simply falls through to their own.
25
+ */
26
+ import type { APIContext } from "astro";
27
+ import type { SessionRecord } from "@/lib/auth/session";
28
+ import { PUBLIC_HOME_TENANT_ID } from "@/lib/auth/loginGate";
29
+ import { viewerCanAccess, type StaffAdmission } from "@/lib/dashboard/tenantSelection";
30
+
31
+ /** The request's host/path-resolved store resource, or "" when it's the marketing home. */
32
+ function pathStoreResource(context: Pick<APIContext, "locals">): string {
33
+ const t = context.locals?.tenant;
34
+ if (!t || t.tenant_id === PUBLIC_HOME_TENANT_ID) return "";
35
+ return (t.appDomain || t.tenant_id || "").toLowerCase();
36
+ }
37
+
38
+ /**
39
+ * Resolve the store this cockpit request is scoped to, as a lowercased resource
40
+ * (appDomain/tenant_id). Empty string when nothing resolves (e.g. anonymous on the
41
+ * generic host with no `?tenant=`).
42
+ */
43
+ export function resolveCockpitStoreResource(
44
+ context: Pick<APIContext, "locals" | "url">,
45
+ session: SessionRecord | null,
46
+ ): string {
47
+ const pathStore = pathStoreResource(context);
48
+
49
+ if (session) {
50
+ // The URL wins when the signed-in developer can actually access that store.
51
+ if (pathStore) {
52
+ const staff: StaffAdmission | undefined = session.staffSelection
53
+ ? { selection: session.staffSelection, nowSeconds: Math.floor(Date.now() / 1000) }
54
+ : undefined;
55
+ if (viewerCanAccess(session.tenants, pathStore, staff)) return pathStore;
56
+ }
57
+ // Generic host, or a store the viewer doesn't belong to: their own first store.
58
+ return (session.tenants?.[0]?.resource ?? session.resource ?? "").toLowerCase();
59
+ }
60
+
61
+ // Anonymous: the path store is just branding (no privileged mint happens), else the
62
+ // caller-supplied preview hint. Never overrides a signed-in developer (handled above).
63
+ if (pathStore) return pathStore;
64
+ return (context.url?.searchParams.get("tenant") ?? "").toLowerCase();
65
+ }
@@ -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
+ }