@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
@@ -14,8 +14,8 @@
14
14
  * mockup's visual system — the tokenoftrust tenant theme (navy ink #0A2540 + trust teal
15
15
  * #0E9F8E on cool-white #F7F9FC), Plus Jakarta Sans / Inter / JetBrains Mono, rounded cards —
16
16
  * now with full dark-mode (system + manual toggle) and reduced-motion support. Same CSP as
17
- * every storefront response (googleapis/gstatic allowlisted, so the shared typefaces load);
18
- * inline style/script carry this request's nonce.
17
+ * every storefront response; the shared typefaces are self-hosted same-origin (@fontsource,
18
+ * imported via `@/styles/fonts.css`), and inline style/script carry this request's nonce.
19
19
  *
20
20
  * The one command honours the shipped browserless-signin fastpath: a developer who reached
21
21
  * this page from their magic link already has a session here, so the Copy button mints a
@@ -25,12 +25,24 @@
25
25
  */
26
26
  import { readViewerSession } from "@/lib/auth/route";
27
27
  import { staticTenants } from "@/config/tenants";
28
+ import { resolvePreviewTenant } from "@/config/previewTenantLookup";
29
+ import { readTenantPreviewStatus, isStoreNotSetUp } from "@/lib/dev/previewStatus";
28
30
  import { resolveDevApiBase } from "@/lib/dev/apiBase";
31
+ import { resolveCockpitStoreResource } from "@/lib/dev/cockpitStore";
29
32
  import { normalizeEmailHint, redactEmailForHint } from "@/lib/privacy/emailHint";
33
+ // Self-hosted web fonts (audit finding 6) — same-origin @fontsource, replacing
34
+ // the render-blocking fonts.googleapis.com stylesheet. Covers Plus Jakarta Sans,
35
+ // Inter, and JetBrains Mono used by this page's inline styles.
36
+ import "@/styles/fonts.css";
30
37
 
31
38
  export const prerender = false;
32
39
  const cspNonce = Astro.locals.cspNonce;
33
40
  const url = new URL(Astro.request.url);
41
+ // The tenant prefix ("" host-routed, "/<tenant>" path-routed) this cockpit is served
42
+ // under — set by the middleware. Every self-link + same-store API call below carries
43
+ // it so a `/<tenant>/cockpit` invite keeps its store across the login round-trip and
44
+ // the command mint (see resolveCockpitStoreResource / the basePath-scoped fetches).
45
+ const basePath = Astro.locals.basePath ?? "";
34
46
  const isCredentialRecovery = url.searchParams.get("recover") === "cli";
35
47
  const legacyRecoveryEmail = (url.searchParams.get("email") ?? "").trim();
36
48
  const recoveryEmailHint =
@@ -42,34 +54,41 @@ if (isCredentialRecovery && url.searchParams.has("email")) {
42
54
  canonical.delete("email");
43
55
  if (recoveryEmailHint) canonical.set("email_hint", recoveryEmailHint);
44
56
  return Astro.redirect(
45
- `/cockpit${canonical.toString() ? `?${canonical.toString()}` : ""}`,
57
+ `${basePath}/cockpit${canonical.toString() ? `?${canonical.toString()}` : ""}`,
46
58
  302,
47
59
  );
48
60
  }
49
- const cockpitNext = isCredentialRecovery ? "/cockpit?recover=cli" : "/cockpit";
61
+ const cockpitNext = `${basePath}/cockpit${isCredentialRecovery ? "?recover=cli" : ""}`;
50
62
  const loginParams = new URLSearchParams({ next: cockpitNext });
51
63
  if (recoveryEmailHint) loginParams.set("email_hint", recoveryEmailHint);
52
64
  const loginHref = `/auth/login?${loginParams.toString()}`;
53
65
 
54
66
  const session = await readViewerSession({ cookies: Astro.cookies });
55
67
  const signedIn = !!session;
68
+ // d1 (board: d1-unmask-invite-email) — the FULL invite address to show its OWNER in the
69
+ // recovery notice, never a redacted hint. Prefer the validated session email; else a full
70
+ // `?email=` if one ever survives to render. Empty when only a redacted `email_hint` is on
71
+ // hand (the common case — the CLI masks the address upstream before it reaches this page),
72
+ // and the notice then falls back to neutral, non-redacted copy rather than re-showing the
73
+ // owner a masked form of their own address. redactEmailForHint/emailHint.ts stay intact for
74
+ // every other caller (e.g. the /auth/login hint below) — we simply stop masking HERE.
75
+ const recoveryEmailFull =
76
+ session?.email ||
77
+ (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(legacyRecoveryEmail) ? legacyRecoveryEmail.toLowerCase() : "");
56
78
  // The fastpath (browserless terminal sign-in) is gated behind the developer's own session +
57
79
  // capability — the same gate /api/dev/cli-signin-code enforces. Mirror it here so a
58
80
  // logged-out / no-capability visitor gets the generic command, never a broken mint.
59
81
  const hasStoreAccess = signedIn && !!session?.capability && session.capability !== "none";
60
82
 
61
- // Resolve the developer's tenant from their first membership (or ?tenant= for an
62
- // ANONYMOUS preview only never override a signed-in developer's own tenant with
63
- // caller-supplied input; the mint/activity endpoints already derive tenant solely
64
- // from the session/token, but letting the query param win here still meant a
65
- // signed-in dev could be shown another vendor's branding + `tot checkout <theirs>`
66
- // instructions, which is confusing/spoofable chrome even though nothing privileged
67
- // leaked).
68
- const wanted = (
69
- session
70
- ? (session?.tenants?.[0]?.resource ?? session?.resource ?? "")
71
- : (url.searchParams.get("tenant") ?? "")
72
- ).toLowerCase();
83
+ // Resolve which store this cockpit is for. The URL wins when a `/<tenant>/cockpit`
84
+ // request named a specific store the signed-in developer can access (the trusted
85
+ // host/path seam on `locals.tenant`, membership-checked) this is the regression
86
+ // fix: an invite to another store than the session's first membership no longer
87
+ // "loses" that store. Otherwise: the developer's own first store (generic host), or
88
+ // for an ANONYMOUS viewer only the path store's branding, else `?tenant=`. Never
89
+ // a signed-in developer's store overridden by caller-supplied query input.
90
+ // See resolveCockpitStoreResource; the same resolver gates the mint + /api/dev/*.
91
+ const wanted = resolveCockpitStoreResource(Astro, session);
73
92
  const tenant =
74
93
  (wanted
75
94
  ? staticTenants.find((t) =>
@@ -82,9 +101,42 @@ const tenant =
82
101
  staticTenants[0];
83
102
 
84
103
  const appDomain = tenant?.appDomain ?? "tokenoftrust.com";
85
- // Signed in, but the account carries no developer capability on any store — an honest
86
- // recovery state (not a 401): show what to do, not the fastpath.
87
- const noStoreAccess = signedIn && !hasStoreAccess;
104
+
105
+ // fix-staff-cockpit-403: `hasStoreAccess` above only proves the session carries SOME
106
+ // eligible capability the federated sign-in path stamps capability:"member"
107
+ // unconditionally (auth/magic-exchange), so a ToT-staff viewer admitted by the
108
+ // loginGate staff bypass (no real store membership) passes it too, even on the home
109
+ // host where `wanted` never names a real store. Every /api/dev/* route instead gates
110
+ // on this SAME tenant actually RESOLVING (resolvePreviewTenant — see
111
+ // lib/dev/hostedCockpit.ts, api/dev/activity.ts) — mirror that here so we never mint a
112
+ // setup command or start polling for a viewer who'd just 403-loop against it.
113
+ const resolvedStoreTenant = hasStoreAccess && wanted ? await resolvePreviewTenant(wanted) : null;
114
+ // Capability-eligible, but no store resolves for this viewer/host (the staff/home-host
115
+ // case above). Gets its own calm "pick a store" state instead of the full flow.
116
+ const noResolvableStore = hasStoreAccess && !resolvedStoreTenant;
117
+
118
+ // STORE-NOT-SET-UP (#8425 invite-gated-auto-build): a developer WITH access + a
119
+ // resolvable store, but whose forge repo isn't provisioned yet — the invite landed
120
+ // before (or without) the store being set up. Offering the fastpath here would hand
121
+ // them a `tot login --code … && tot start` that dies at checkout on "Repo … is not
122
+ // provisioned" (James's first-run failure). Ask the credential-free MCP preview-status
123
+ // seam; only a DEFINITIVE not-provisioned flips the state (an unconfigured / can't-answer
124
+ // seam keeps the normal fastpath, with the CLI's own clean "not set up yet" error as the
125
+ // backstop — so no regression on local/dev or a transient MCP blip). This is a DISTINCT
126
+ // axis from noResolvableStore/noStoreAccess and gets its own honest notice below.
127
+ const storeNotSetUp =
128
+ hasStoreAccess && !noResolvableStore && isStoreNotSetUp(await readTenantPreviewStatus(appDomain));
129
+
130
+ // The gate for rendering the setup-command + activity-poll flow: eligible AND a real
131
+ // store to point it at AND that store is actually provisioned. Use this (never the raw
132
+ // hasStoreAccess) for anything that mints a command or starts the activity poll.
133
+ const canSetupStore = hasStoreAccess && !noResolvableStore && !storeNotSetUp;
134
+
135
+ // Signed in, but either no developer capability on any store OR (staff bypass) no store
136
+ // resolves for this viewer/host — an honest recovery state (not a 401): show what to
137
+ // do, not the fastpath. NOTE: storeNotSetUp is deliberately NOT folded in here — it is
138
+ // its own state with its own notice, so this stays computed from resolvable-store only.
139
+ const noStoreAccess = signedIn && (!hasStoreAccess || noResolvableStore);
88
140
  // The generic command shown (and copied) when we can't mint a personalised one.
89
141
  // Pinned to the EXACT CLI version this build was cut alongside (`@tokenoftrust/cli@<version>`,
90
142
  // astro.config Vite define) so the paste installs precisely the CLI this cockpit expects —
@@ -112,7 +164,7 @@ const isLocalDevLoop = import.meta.env.DEV;
112
164
  // on our hosted surface. It is NOT a port of the local-only Monitor/Files/preview
113
165
  // panels (those depend on /__tot/* endpoints that exist only under `astro dev`;
114
166
  // bringing those hosted is cp-tree/cp-file/cp-preview, tracked separately).
115
- const hostedActivity = !isLocalDevLoop && hasStoreAccess;
167
+ const hostedActivity = !isLocalDevLoop && canSetupStore;
116
168
 
117
169
  // cp-env (cloud-dev-cockpit Phase 0): the file-tree/viewer/diff + click-to-source
118
170
  // cockpit scripts below (filesScript, c8Script) are ENVIRONMENT-AWARE — same client
@@ -121,7 +173,7 @@ const hostedActivity = !isLocalDevLoop && hasStoreAccess;
121
173
  // cp-locate — separate cards). Resolving it here, unconditionally, means those cards
122
174
  // don't each have to invent the same env check; on isLocalDevLoop this is a no-op
123
175
  // (resolves to the unchanged /__tot base). See decision architecture-environment-aware-cockpit.
124
- const apiBase = resolveDevApiBase(isLocalDevLoop);
176
+ const apiBase = resolveDevApiBase(isLocalDevLoop, basePath);
125
177
 
126
178
  // Live loop Monitor (C1) client. Polls the dev server's /__tot/dev-loop-status
127
179
  // and renders the loopbar: server health, watched file, and the save→reload
@@ -568,20 +620,22 @@ const filesScript = `
568
620
  }
569
621
  if (diffBtn) diffBtn.addEventListener("click", doDiff);
570
622
 
571
- // cp-publish: draft -> TEST channel (never live see decision
572
- // cp-publish-scope-correction). Confirm-before-publish per the card.
623
+ // cp-publish: draft -> PR-backed candidate for review (unit s6, milestone
624
+ // "PR-Backed Hosted Review Loop") never straight to live, and no longer
625
+ // straight to the test channel either; it opens/updates a pull request an
626
+ // owner reviews and accepts. Confirm-before-submit per the card.
573
627
  function doPublish() {
574
628
  if (!cur || !cur.writable || !publishBtn) return;
575
- if (!window.confirm("Publish your saved edit to the test channel? (Not your live site.)")) return;
629
+ if (!window.confirm("Submit your saved edit for review? (Opens/updates a pull request \\u2014 not your live site.)")) return;
576
630
  var prevLabel = publishBtn.textContent;
577
631
  publishBtn.disabled = true;
578
- setStatus("Publishing\\u2026");
632
+ setStatus("Submitting\\u2026");
579
633
  fetch(apiBase + "/publish", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path: cur.path }) })
580
634
  .then(function (r) { return r.json().then(function (j) { return { s: r.status, j: j }; }); })
581
635
  .then(function (res) {
582
- if (res.s === 200 && res.j && res.j.ok) { setStatus(res.j.applied ? "Published to test \\u2713" : "Published (not persisted \\u2014 no storage configured)", "ok"); return; }
583
- if (res.s === 409 && res.j && res.j.status === "no_draft") { setStatus("No unpublished edit to publish.", "warn"); return; }
584
- setStatus((res.j && res.j.message) || "Publish failed", "warn");
636
+ if (res.s === 200 && res.j && res.j.ok) { setStatus("Submitted for review \\u2713" + (res.j.prNumber ? " (PR #" + res.j.prNumber + ")" : ""), "ok"); return; }
637
+ if (res.s === 409 && res.j && res.j.status === "no_draft") { setStatus("No unpublished edit to submit.", "warn"); return; }
638
+ setStatus((res.j && res.j.message) || "Submit failed", "warn");
585
639
  })
586
640
  .catch(function () { setStatus(offlineMsg, "warn"); })
587
641
  .then(function () { publishBtn.disabled = false; publishBtn.textContent = prevLabel; });
@@ -718,6 +772,12 @@ const hostedActivityScript = `
718
772
  var root = document.getElementById("hostedActivity");
719
773
  if (!root) return;
720
774
 
775
+ // The request's tenant prefix ("" host-routed, "/<tenant>" path-routed). Every
776
+ // /api/dev/* call below carries it so the activity feed + the CLI sign-in-code mint
777
+ // run in THIS store's scope (locals.tenant on the API request) — the same store this
778
+ // /<tenant>/cockpit rendered for, never the session's first membership.
779
+ var basePath = ${JSON.stringify(basePath)};
780
+
721
781
  // ?reset — a TRUE reset for re-testing the onboarding loop: clear THIS browser's
722
782
  // stepper progress AND the server-side KV (live-runtime record + activity buffer),
723
783
  // then reload clean. Clearing the server too means a stopped local loop stops reading
@@ -730,7 +790,7 @@ const hostedActivityScript = `
730
790
  var reloaded = false;
731
791
  var doneReset = function () { if (reloaded) return; reloaded = true; location.replace(location.pathname); };
732
792
  setTimeout(doneReset, 2000); // fallback so a hung request still lands us fresh
733
- try { fetch("/api/dev/activity", { method: "DELETE", cache: "no-store" }).then(doneReset, doneReset); }
793
+ try { fetch(basePath + "/api/dev/activity", { method: "DELETE", cache: "no-store" }).then(doneReset, doneReset); }
734
794
  catch (e) { doneReset(); }
735
795
  return; // the post-clear reload re-runs this script against a fresh, empty state
736
796
  }
@@ -750,12 +810,42 @@ const hostedActivityScript = `
750
810
  var bKicker = document.getElementById("bKicker");
751
811
  var bHint = document.getElementById("bHint");
752
812
  var bDiag = document.getElementById("bDiag");
813
+ var bTxt = document.getElementById("bTxt");
814
+ var bObstacle = document.getElementById("bObstacle");
815
+ var bObTitle = document.getElementById("bObTitle");
816
+ var bObMsg = document.getElementById("bObMsg");
817
+ var bObCmd = document.getElementById("bObCmd");
818
+ var bObNote = document.getElementById("bObNote");
753
819
  var doneRunMeta = document.getElementById("doneRunMeta");
754
- function updateBridge(rt) {
820
+ function updateBridge(rt, obstacle) {
755
821
  if (!bridge) return;
756
- if (!rt || !rt.url) { bridge.hidden = true; return; }
757
- var live = !!rt.live;
822
+ var live = !!(rt && rt.live);
758
823
  if (live) sawLive = true;
824
+ // Obstacle lane (v2 §6): a failed \`tot start\` is the ONE reason the strip
825
+ // shows BEFORE the loop was ever live this page-view — the developer needs the
826
+ // fix, not a silent "watching…". The server returns \`obstacle\` only when there
827
+ // is no live heartbeat, so a live loop supersedes it. Fill from server copy.
828
+ if (obstacle && !live) {
829
+ bridge.hidden = false;
830
+ bridge.setAttribute("data-live", "obstacle");
831
+ if (bTxt) bTxt.hidden = true;
832
+ if (bDiag) bDiag.hidden = true;
833
+ if (bGo) bGo.hidden = true;
834
+ if (bHint) bHint.hidden = true;
835
+ if (bObstacle) bObstacle.hidden = false;
836
+ if (bObTitle) bObTitle.textContent = obstacle.title || "Setup hit a snag";
837
+ if (bObMsg) bObMsg.textContent = obstacle.message || "";
838
+ if (bObCmd) {
839
+ if (obstacle.fixCommand) { bObCmd.textContent = obstacle.fixCommand; bObCmd.hidden = false; }
840
+ else bObCmd.hidden = true;
841
+ }
842
+ if (bObNote) bObNote.textContent = obstacle.fixNote || "";
843
+ return;
844
+ }
845
+ // Back to the normal live/quiet strip: restore the pieces the obstacle state hid.
846
+ if (bObstacle) bObstacle.hidden = true;
847
+ if (bTxt) bTxt.hidden = false;
848
+ if (!rt || !rt.url) { bridge.hidden = true; return; }
759
849
  // On FIRST arrival, a stale runtime record (offline, never seen live this
760
850
  // page-view — e.g. left over from a prior run, still within the heartbeat TTL)
761
851
  // must NOT show — let Step 1 "Get your store running" do the work. Only surface
@@ -1081,29 +1171,40 @@ const hostedActivityScript = `
1081
1171
  }
1082
1172
 
1083
1173
  function poll() {
1084
- fetch("/api/dev/activity", { cache: "no-store" })
1174
+ fetch(basePath + "/api/dev/activity", { cache: "no-store" })
1085
1175
  .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
1086
1176
  .then(function (d) {
1087
1177
  var events = (d && d.events) || [];
1088
1178
  var rt = d && d.runtime;
1089
1179
  var isLive = !!(rt && rt.live);
1090
1180
  liveNow = isLive; // drives Step 1 minimize + the Step-2 teaser visibility below
1091
- updateBridge(rt); // same poll drives the bridge strip
1181
+ updateBridge(rt, d && d.obstacle); // same poll drives the bridge strip (+ obstacle lane)
1092
1182
  render(events);
1093
- var last = events.length ? events[events.length - 1] : null;
1094
- var latest = last ? last.at : 0;
1095
- var lastIsEdit = last && last.event !== "up"; // "up" = a server-running ping, not an edit
1183
+ // b1 fix Step-2 completion must key off the SAME events the feed shows:
1184
+ // REAL file edits (event !== "up"), NOT the single newest buffer entry. The
1185
+ // local loop interleaves recurring "up" liveness pings into this one ring
1186
+ // buffer, so the newest event is frequently an "up" ping even right after a
1187
+ // save. The old gate read events[last]: when an "up" ping landed AFTER the
1188
+ // edit it (1) made lastIsEdit false so completeStep("edit") never fired, and
1189
+ // (2) still advanced the baseline watermark past the edit's timestamp —
1190
+ // permanently stranding Step 2 even though the save rendered in "Your
1191
+ // activity". Filter to real edits (mirroring render()'s filter) and advance
1192
+ // the watermark only from real edits, so a liveness ping can neither satisfy
1193
+ // nor poison the gate.
1194
+ var realEdits = events.filter(function (e) { return e && e.event !== "up"; });
1195
+ var lastEdit = realEdits.length ? realEdits[realEdits.length - 1] : null;
1196
+ var latestEdit = lastEdit ? lastEdit.at : 0;
1096
1197
  // Step 1 "Get running" completes once localhost reports LIVE (or any activity
1097
1198
  // arrives). Once complete, renderSteps() minimizes Step 1 and reveals the Step-2 nudge.
1098
1199
  if (isLive || events.length) completeStep("run");
1099
1200
  if (baseline === null) {
1100
- setBaseline(latest); // first poll primes the watermark — no edit inferred yet
1101
- } else if (latest > baseline) {
1102
- setBaseline(latest);
1103
- // Step 2 completes only on a real FILE edit landing (not a running ping).
1201
+ setBaseline(latestEdit); // first poll primes the watermark from real edits — no edit inferred yet
1202
+ } else if (latestEdit > baseline) {
1203
+ setBaseline(latestEdit);
1204
+ // A real FILE edit landed after the watermark Step 2 completes (once).
1104
1205
  // completeStep flips + persists once, returning true on that first poll only.
1105
- if (lastIsEdit && completeStep("edit")) {
1106
- celebrate(last && last.file);
1206
+ if (completeStep("edit")) {
1207
+ celebrate(lastEdit && lastEdit.file);
1107
1208
  if (current === "edit") current = null; // minimize Step 2 → reveal Step-3 teaser
1108
1209
  }
1109
1210
  }
@@ -1149,13 +1250,21 @@ const hostedActivityScript = `
1149
1250
  <meta charset="utf-8" />
1150
1251
  <meta name="viewport" content="width=device-width, initial-scale=1" />
1151
1252
  <meta name="robots" content="noindex, nofollow" />
1152
- <title>Get your store running — {appDomain}</title>
1153
- <link rel="preconnect" href="https://fonts.googleapis.com" />
1154
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
1155
- <link
1156
- rel="stylesheet"
1157
- href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@500;600;700;800&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"
1158
- />
1253
+ <title>Developer Cockpit — {appDomain}</title>
1254
+ {/* cockpit-theme-persist: seed data-theme from the STORED choice before the stylesheet/
1255
+ style below ever paint, so a reload of a manually-toggled theme doesn't flash the OS
1256
+ default first. Runs before the theme toggle's own script (end of body) exists — it
1257
+ only ever READS here; the toggle is what writes. No stored choice → leave the
1258
+ attribute unset, so the `@media (prefers-color-scheme)` block keeps deciding. */}
1259
+ <script nonce={cspNonce} is:inline>
1260
+ (function () {
1261
+ try {
1262
+ var v = localStorage.getItem("tot.theme");
1263
+ if (v === "dark" || v === "light") document.documentElement.setAttribute("data-theme", v);
1264
+ } catch (e) {}
1265
+ })();
1266
+ </script>
1267
+ {/* Web fonts are self-hosted via `@/styles/fonts.css` (imported above). */}
1159
1268
  <style nonce={cspNonce}>
1160
1269
  :root {
1161
1270
  --bg: #f7f9fc; --bg-2: #eef3f9; --surface: #ffffff;
@@ -1250,6 +1359,8 @@ const hostedActivityScript = `
1250
1359
  .brand .dom { font-family: var(--display); font-weight: 700; color: var(--ink); font-size: .98rem; letter-spacing: -.01em; }
1251
1360
  .brand .sub { font-family: var(--mono); font-size: .7rem; color: var(--faint); text-transform: uppercase; letter-spacing: .12em; }
1252
1361
  .topbar .right { display: flex; align-items: center; gap: .9rem; font-family: var(--mono); font-size: .78rem; color: var(--muted); }
1362
+ .cockpit-lede { margin: -0.6rem 0 1.6rem; font-family: var(--body); font-size: .92rem; color: var(--muted); max-width: 60ch; }
1363
+ .cockpit-lede strong { color: var(--ink); font-weight: 600; }
1253
1364
  .topbar .right .sess { display: inline-flex; align-items: center; gap: .45rem; }
1254
1365
  .topbar .right .sess::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 3px var(--accent-wash); }
1255
1366
  .themetog {
@@ -1344,6 +1455,25 @@ const hostedActivityScript = `
1344
1455
  .b-go:hover { background: var(--accent-ink); transform: translateY(-1px); }
1345
1456
  .b-hint { flex-basis: 100%; margin: 0; font-size: .8rem; color: var(--amber); }
1346
1457
  .b-hint code { font-family: var(--mono); }
1458
+ /* Obstacle lane (v2 §6): a failed `tot start` — an ERROR, so it uses the
1459
+ existing --warn (red) token triple (NO new palette values), which also sets
1460
+ it apart from the amber "went quiet". Content is a stacked block, so the
1461
+ beacon aligns to the top rather than centering on a tall card. */
1462
+ .bridge[data-live="obstacle"] { align-items: flex-start; border-color: var(--warn-line); background: var(--warn-wash); }
1463
+ .bridge[data-live="obstacle"] .beacon { background: var(--warn); }
1464
+ .b-obstacle { flex: 1 1 100%; display: flex; flex-direction: column; gap: .3rem; min-width: 0; }
1465
+ .b-ob-title { color: var(--warn); }
1466
+ .b-ob-title::before { content: "\26A0 "; } /* ⚠ warning glyph */
1467
+ .b-ob-msg { margin: 0; font-size: .9rem; font-weight: 600; color: var(--ink); }
1468
+ .b-ob-fix { margin: 0; font-size: .82rem; color: var(--muted); line-height: 1.5; }
1469
+ .b-ob-lead { color: var(--warn); font-weight: 700; }
1470
+ .b-ob-cmd { font-family: var(--mono); font-size: .8rem; background: var(--bg-2); color: var(--ink); padding: .1rem .35rem; border-radius: var(--r-sm); }
1471
+ /* These three set their own `display`, which (author > UA) would beat the UA
1472
+ [hidden]{display:none} — so the script's `.hidden` toggles silently no-op on
1473
+ them. Re-assert [hidden] at higher specificity so hidden means hidden: the
1474
+ obstacle block and the live-strip text swap cleanly, AND the quiet-state CTA
1475
+ (b-go) actually hides. */
1476
+ .b-txt[hidden], .b-go[hidden], .b-obstacle[hidden] { display: none; }
1347
1477
  @media (prefers-reduced-motion: reduce) { .bridge[data-live="1"], .beacon, .b-go { animation: none !important; transition: none; } }
1348
1478
  @media (max-width: 34rem) { .b-diag { display: none; } }
1349
1479
 
@@ -1551,6 +1681,34 @@ const hostedActivityScript = `
1551
1681
  .term-bar .dot { width: 11px; height: 11px; border-radius: 50%; }
1552
1682
  .dot.r { background: #ff5f56; } .dot.y { background: #ffbd2e; } .dot.g { background: #27c93f; }
1553
1683
  .term-bar .ttl { margin-left: .4rem; font-size: .72rem; color: var(--term-dim); }
1684
+ /* Standard "copy this code block" affordance, docked in the terminal chrome.
1685
+ Revealed once the command is minted; its icon→check swap is the obvious,
1686
+ inline copy confirmation (no reliance on the transient toast). */
1687
+ .term-copy {
1688
+ margin-left: auto; display: inline-flex; align-items: center; justify-content: center;
1689
+ width: 30px; height: 26px; padding: 0; border-radius: var(--r-sm); cursor: pointer;
1690
+ background: rgba(255,255,255,.04); border: 1px solid var(--term-line);
1691
+ color: var(--term-dim); transition: color .12s, background .12s, border-color .12s;
1692
+ }
1693
+ .term-copy:hover { color: var(--term-fg); background: rgba(255,255,255,.09); border-color: var(--term-dim); }
1694
+ .term-copy svg { width: 15px; height: 15px; }
1695
+ .term-copy .ic-check { display: none; }
1696
+ .term-copy[data-copied="1"] { color: var(--term-ok); border-color: var(--term-ok); }
1697
+ .term-copy[data-copied="1"] .ic-copy { display: none; }
1698
+ .term-copy[data-copied="1"] .ic-check { display: block; }
1699
+ /* Rendezvous approval widget — the confirm-your-terminal step after pasting. */
1700
+ .rzv { margin-top: 14px; }
1701
+ .rzv[hidden] { display: none; }
1702
+ .rzv-wait { display: flex; align-items: center; gap: 10px; font-size: .92rem; opacity: .8; }
1703
+ .rzv-confirm { border: 1px solid var(--term-ok, #0e9f8e); border-radius: 12px; padding: 16px 18px; background: color-mix(in srgb, var(--term-ok, #0e9f8e) 8%, transparent); }
1704
+ .rzv-q { margin: 0 0 6px; font-weight: 600; }
1705
+ .rzv-fp { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 1.75rem; letter-spacing: .12em; text-align: center; margin: 8px 0 16px; }
1706
+ .rzv-actions { display: flex; gap: 10px; flex-wrap: wrap; }
1707
+ .rzv-actions .cta { margin: 0; }
1708
+ .rzv-deny { background: none; border: 1px solid currentColor; opacity: .7; border-radius: 8px; padding: 8px 14px; cursor: pointer; color: inherit; font: inherit; }
1709
+ .rzv-deny:hover { opacity: 1; }
1710
+ .rzv-deny[disabled], .rzv-actions .cta[disabled] { opacity: .5; cursor: default; }
1711
+ .rzv-done { margin-top: 12px; font-weight: 600; }
1554
1712
  .term-body { padding: 1rem 1.05rem 1.1rem; color: var(--term-fg); white-space: pre-wrap; word-break: break-word; }
1555
1713
  /* block, not inline — Astro trims the whitespace text node between the spans, so the
1556
1714
  line break must be structural or the comment + command collapse onto one line. */
@@ -1726,6 +1884,9 @@ const hostedActivityScript = `
1726
1884
  .df-view { flex: 1 1 auto; width: 100%; box-sizing: border-box; display: block; margin: 0; border: 0; resize: none; outline: none; overflow: auto; padding: .75rem .9rem; font-family: var(--mono); font-size: .76rem; line-height: 1.55; color: var(--term-fg); background: var(--term); white-space: pre; tab-size: 2; }
1727
1885
  .df-view[readonly] { opacity: .9; }
1728
1886
  .df-diffbtn { margin-left: auto; }
1887
+ .df-publishbtn { border-color: var(--accent); color: var(--accent-ink); font-weight: 600; }
1888
+ .df-publishbtn:hover { background: var(--accent-wash); }
1889
+ .df-publishbtn:disabled { opacity: .6; cursor: default; }
1729
1890
  .df-diff { flex: 1 1 auto; margin: 0; overflow: auto; padding: .5rem 0; font-family: var(--mono); font-size: .76rem; line-height: 1.5; background: var(--term); color: var(--term-fg); }
1730
1891
  .df-diff .dl { display: block; padding: 0 .9rem; white-space: pre; }
1731
1892
  .df-diff .add { background: rgba(52, 211, 153, .16); color: #bff2e0; }
@@ -1781,6 +1942,11 @@ const hostedActivityScript = `
1781
1942
  .ask-foot { padding: .7rem 1.1rem; border-top: 1px solid var(--border-2); font-family: var(--mono); font-size: .68rem; color: var(--faint); }
1782
1943
  @media (prefers-reduced-motion: reduce) { .ask-panel { transition: none; } }
1783
1944
 
1945
+ /* ---- connect the MCP (d3-unhold-dev-step0-connect-mcp) ---- */
1946
+ #stage-mcp { margin-top: 2rem; }
1947
+ /* disable ligatures so `--transport` renders as literal characters, not a glyph */
1948
+ #mcpCmdText { font-variant-ligatures: none; font-feature-settings: "liga" 0, "calt" 0; }
1949
+
1784
1950
  /* ---- what's next : coming soon shelf (U4) ---- */
1785
1951
  .next { margin-top: 3.4rem; padding-top: 2rem; border-top: 1px dashed var(--border); }
1786
1952
  .next h3 { font-family: var(--display); font-weight: 700; color: var(--ink-2); font-size: 1.1rem; margin: 0 0 .3rem; letter-spacing: -.02em; }
@@ -1832,7 +1998,7 @@ const hostedActivityScript = `
1832
1998
  <span class="mark" aria-hidden="true">
1833
1999
  <svg viewBox="0 0 24 24" fill="none"><path d="M12 2.5 20 6v6c0 4.6-3.2 7.9-8 9.5-4.8-1.6-8-4.9-8-9.5V6l8-3.5Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="m8.5 12 2.4 2.4L15.8 9.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
1834
2000
  </span>
1835
- <span class="who"><span class="dom">{appDomain}</span><span class="sub">Developer</span></span>
2001
+ <span class="who"><span class="dom">{appDomain}</span><span class="sub">Developer Cockpit</span></span>
1836
2002
  </div>
1837
2003
  <div class="right">
1838
2004
  {session?.email ? <span class="sess">{session.email}</span> : null}
@@ -1840,11 +2006,23 @@ const hostedActivityScript = `
1840
2006
  </div>
1841
2007
  </header>
1842
2008
 
2009
+ {/* U1 anchor — name the surface so a developer knows which page is "home". */}
2010
+ <p class="cockpit-lede">This is your <strong>Developer Cockpit</strong> — your home base while your store spins up.</p>
2011
+
1843
2012
  {signedIn === false && (
1844
2013
  <div class="notice">
2014
+ {/* d1 (board: d1-unmask-invite-email) — the invitee owns this address (the
2015
+ link was sent to it), so masking it back to them is pointless friction.
2016
+ Show the FULL invite email when we actually have it; NEVER show the redacted
2017
+ hint to its owner. This branch is signed-OUT, so there's no session.email to
2018
+ read; the recovery link arrives carrying only a redacted `email_hint` (the
2019
+ full address is masked UPSTREAM by the CLI before it ever reaches this page —
2020
+ see report), so the full email is genuinely unavailable here and we fall back
2021
+ to neutral copy rather than re-displaying the redacted form. `recoveryEmailFull`
2022
+ lights up automatically the moment a full address is available. */}
1845
2023
  <p>{isCredentialRecovery
1846
- ? recoveryEmailHint
1847
- ? <>Sign in with the invite email hinted as <strong>{recoveryEmailHint}</strong> to generate a fresh setup command for this machine.</>
2024
+ ? recoveryEmailFull
2025
+ ? <>Sign in with your invite email, <strong>{recoveryEmailFull}</strong>, to generate a fresh setup command for this machine.</>
1848
2026
  : <>Sign in with the same email that accepted the invite to generate a fresh setup command for this machine.</>
1849
2027
  : <>Sign in with the email your invite came to — then this page shows your store and your one command.</>}</p>
1850
2028
  <a class="action" href={loginHref}>Sign in</a>
@@ -1853,8 +2031,27 @@ const hostedActivityScript = `
1853
2031
 
1854
2032
  {noStoreAccess && (
1855
2033
  <div class="notice">
1856
- <p>You're signed in{session?.email ? <>{" "}as <strong>{session.email}</strong></> : null}, but this account isn't linked to a store yet. Ask whoever sent your invite to grant developer access — then reload this page.</p>
1857
- <a class="action" href="/auth/logout">Switch account</a>
2034
+ {noResolvableStore
2035
+ ? <>
2036
+ <p>You're signed in{session?.email ? <>{" "}as <strong>{session.email}</strong></> : null}, but this page doesn't have a store to set up yet. Pick one from your dashboard to get its setup command.</p>
2037
+ <a class="action" href="/dashboard">Pick a store</a>
2038
+ </>
2039
+ : <>
2040
+ <p>You're signed in{session?.email ? <>{" "}as <strong>{session.email}</strong></> : null}, but this account isn't linked to a store yet. Ask whoever sent your invite to grant developer access — then reload this page.</p>
2041
+ <a class="action" href="/auth/logout">Switch account</a>
2042
+ </>}
2043
+ </div>
2044
+ )}
2045
+
2046
+ {/* STORE-NOT-SET-UP notice — a real, signed-in developer whose store's repo isn't
2047
+ provisioned yet. We deliberately withhold the setup command here (canSetupStore
2048
+ is false, so the mint fastpath + activity rail below don't render) rather than
2049
+ hand out a `tot start` that would die at checkout. Honest, low-friction copy:
2050
+ nothing for them to do but wait + reload. */}
2051
+ {storeNotSetUp && (
2052
+ <div class="notice">
2053
+ <p>You're all set{session?.email ? <>{" "}as <strong>{session.email}</strong></> : null} — but <strong>{appDomain}</strong>'s store is still being set up. Your one-paste setup command appears here the moment it's ready. Nothing to do on your end; check back shortly, or ask your Token of Trust contact if it's urgent.</p>
2054
+ <a class="action" href="/cockpit">Reload</a>
1858
2055
  </div>
1859
2056
  )}
1860
2057
 
@@ -1866,13 +2063,22 @@ const hostedActivityScript = `
1866
2063
  {hostedActivity && (
1867
2064
  <section class="bridge" id="bridge" data-live="1" hidden>
1868
2065
  <span class="beacon" aria-hidden="true"></span>
1869
- <div class="b-txt">
2066
+ <div class="b-txt" id="bTxt">
1870
2067
  <span class="b-kicker" id="bKicker">Live on your machine</span>
1871
2068
  <a class="b-url" id="bUrl" href="#" target="_blank" rel="noreferrer">http://localhost/</a>
1872
2069
  </div>
1873
2070
  <span class="b-diag" id="bDiag" hidden></span>
1874
2071
  <a class="b-go" id="bGo" href="#" target="_blank" rel="noreferrer">Open your store ↗</a>
1875
2072
  <p class="b-hint" id="bHint" hidden>Your store went quiet. Run <code>tot start</code> in your project to bring it back — Step 1 has the command.</p>
2073
+ {/* Obstacle lane (v2 §6): a failed `tot start` (wrong Node, missing pnpm,
2074
+ install/clone error) beacons why via the setup command's activity token;
2075
+ the strip becomes an actionable fix card. Text is set via textContent from
2076
+ the SERVER-composed remediation copy (obstacleStore) — never innerHTML. */}
2077
+ <div class="b-obstacle" id="bObstacle" hidden>
2078
+ <span class="b-kicker b-ob-title" id="bObTitle">Setup hit a snag</span>
2079
+ <p class="b-ob-msg" id="bObMsg"></p>
2080
+ <p class="b-ob-fix"><span class="b-ob-lead">Fix:</span> <code class="b-ob-cmd" id="bObCmd" hidden></code> <span id="bObNote"></span></p>
2081
+ </div>
1876
2082
  </section>
1877
2083
  )}
1878
2084
 
@@ -1930,31 +2136,62 @@ const hostedActivityScript = `
1930
2136
  <span class="info-pop" role="tooltip">Installs the <code>tot</code> command, signs your terminal in with a single-use code, checks out your store into a new <code>{appDomain}</code> folder, runs it, and opens your browser. No Docker, no browser sign-in, nothing to provision. From then on it's just <code>tot start</code>.</span>
1931
2137
  </span>
1932
2138
  </h1>
1933
- <p class="goal">{hasStoreAccess
2139
+ <p class="goal">{canSetupStore
1934
2140
  ? isCredentialRecovery
1935
2141
  ? <>Generate a fresh personal setup command, then paste it into your terminal and press Enter.</>
1936
2142
  : <>Generate your personal setup command, then paste it into your terminal and press Enter.</>
1937
- : <>Open the invite link we emailed you to unlock your personal setup command.</>}</p>
2143
+ : storeNotSetUp
2144
+ ? <>We're finishing setting up <strong>{appDomain}</strong>. Your one-paste setup command appears here the moment it's ready — check back shortly.</>
2145
+ : <>Open the invite link we emailed you to unlock your personal setup command.</>}</p>
1938
2146
 
1939
2147
  <div class="term">
1940
- <div class="term-bar"><span class="dots"><span class="dot r"></span><span class="dot y"></span><span class="dot g"></span></span><span class="ttl">your terminal</span></div>
2148
+ <div class="term-bar"><span class="dots"><span class="dot r"></span><span class="dot y"></span><span class="dot g"></span></span><span class="ttl">your terminal</span>{canSetupStore && (
2149
+ <button class="term-copy" id="copyIcon" type="button" aria-label="Copy the command" title="Copy the command" hidden>
2150
+ <svg class="ic-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
2151
+ <svg class="ic-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
2152
+ </button>
2153
+ )}</div>
1941
2154
  <div class="term-body"><span class="c"># your personal, single-use setup command</span>
1942
- <span class="cmd is-placeholder" id="cli-cmd-text">{hasStoreAccess ? (isCredentialRecovery ? "→ click “Generate a fresh setup command” below" : "→ click “Generate my setup command” below") : "→ available once you’re signed in via your invite"}</span></div>
2155
+ <span class="cmd is-placeholder" id="cli-cmd-text">{canSetupStore ? (isCredentialRecovery ? "→ click “Generate a fresh setup command” below" : "→ click “Generate my setup command” below") : storeNotSetUp ? "→ available once your store finishes setting up" : "→ available once you’re signed in via your invite"}</span></div>
1943
2156
  </div>
1944
2157
 
1945
- {hasStoreAccess && (
2158
+ {canSetupStore && (
1946
2159
  <div class="cmd-actions">
1947
- <button class="cta" id="copyCmd" type="button" data-fastpath="1">
2160
+ <button class="cta" id="copyCmd" type="button" data-fastpath="1" data-base-path={basePath}>
1948
2161
  <svg viewBox="0 0 24 24" fill="none"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
1949
2162
  {isCredentialRecovery ? "Generate a fresh setup command" : "Generate my setup command"}
1950
2163
  </button>
1951
2164
  </div>
1952
2165
  )}
1953
- <p class="cmd-note" id="cli-cmd-note">{hasStoreAccess
2166
+
2167
+ {/* Rendezvous approval — after you paste the command, your terminal connects here and
2168
+ you confirm the code it printed matches (the load-bearing security step: it's how
2169
+ you prove it's YOUR terminal, not an interceptor's). Hidden until a command is
2170
+ generated; the client script drives the wait → confirm → done states. */}
2171
+ {canSetupStore && (
2172
+ <div class="rzv" id="rzvApprove" hidden aria-live="polite">
2173
+ <div class="rzv-wait" id="rzvWait" hidden>
2174
+ <span class="radar" aria-hidden="true"></span>
2175
+ <span>Waiting for your terminal to connect — run the pasted command, then come back here.</span>
2176
+ </div>
2177
+ <div class="rzv-confirm" id="rzvConfirm" hidden>
2178
+ <p class="rzv-q">Your terminal is asking to sign in. Confirm the code it printed matches:</p>
2179
+ <p class="rzv-fp" id="rzvFp">····-····</p>
2180
+ <div class="rzv-actions">
2181
+ <button class="cta" id="rzvApproveBtn" type="button">Yes — that's my terminal, approve</button>
2182
+ <button class="rzv-deny" id="rzvDenyBtn" type="button">Not me — reject</button>
2183
+ </div>
2184
+ </div>
2185
+ <p class="rzv-done" id="rzvDone" hidden></p>
2186
+ </div>
2187
+ )}
2188
+ <p class="cmd-note" id="cli-cmd-note">{canSetupStore
1954
2189
  ? <>This mints a <em>single-use, short-lived</em> sign-in code, shows the full command here, and copies it to your clipboard. Paste it <em>from the folder where you keep projects</em> — it creates a <code>{appDomain}</code> folder there with your store's code, signs you in, runs your store, and opens your browser. Nothing to retype.</>
1955
- : <>You'll need <em>Node.js 22.12+ (24 LTS recommended) and your invite</em>. Sign in through your invite link and this page hands you a one-paste setup command — no Docker, nothing to provision.</>}</p>
2190
+ : storeNotSetUp
2191
+ ? <>Nothing to do on your end — a Token of Trust reviewer is finishing your store's first setup. Once it's ready, reload and this page hands you the one-paste command.</>
2192
+ : <>You'll need <em>Node.js 22.12+ (24 LTS recommended) and your invite</em>. Sign in through your invite link and this page hands you a one-paste setup command — no Docker, nothing to provision.</>}</p>
1956
2193
 
1957
- <p class="cmd-steps">Prefer the steps? <code>npm i -g {cliInstallSpec}</code>, then <code>tot checkout {appDomain}</code>, <code>cd {appDomain}</code>, <code>tot dev</code>. New to Node? Get Node 24 (LTS) at <a href="https://nodejs.org">nodejs.org</a> — it includes <code>npm</code>.</p>
2194
+ <p class="cmd-steps">Prefer the steps? <code>npm i -g {cliInstallSpec}</code>, then <code>tot clone {appDomain}</code>, <code>cd {appDomain}</code>, <code>tot dev</code>. New to Node? Get Node 24 (LTS) at <a href="https://nodejs.org">nodejs.org</a> — it includes <code>npm</code>.</p>
1958
2195
 
1959
2196
  {isLocalDevLoop && (
1960
2197
  <div class="detect" id="detect" data-listening="true">
@@ -2024,11 +2261,11 @@ const hostedActivityScript = `
2024
2261
  <div class="stage-body">
2025
2262
  <span class="kicker">Step 3</span>
2026
2263
  <h1>Submit your change for preview.</h1>
2027
- <p class="goal">You've got the loop: edit locally, see it reflected here. When a change is ready, submit it for a preview build it goes through the compliance review step before it can go live.</p>
2264
+ <p class="goal">You've got the loop: edit locally, see it reflected here. When a change is ready, <code>tot preview</code> bundles it into a compliance-reviewed preview build; once that preview looks right, <code>tot ship</code> promotes it live.</p>
2028
2265
  <div class="term">
2029
2266
  <div class="term-bar"><span class="dots"><span class="dot r"></span><span class="dot y"></span><span class="dot g"></span></span><span class="ttl">preview</span></div>
2030
2267
  <div class="term-body"><span class="c"># bundle your local changes into a reviewable preview</span>
2031
- <span class="cmd">COMING SOON (tot submit)</span></div>
2268
+ <span class="cmd">tot preview</span></div>
2032
2269
  </div>
2033
2270
  <div class="cmd-actions">
2034
2271
  <button class="cta" id="submitPreview" type="button">
@@ -2036,7 +2273,7 @@ const hostedActivityScript = `
2036
2273
  Submit for preview
2037
2274
  </button>
2038
2275
  </div>
2039
- <p class="cmd-note">This packages your changes and kicks off a compliance-reviewed preview build.</p>
2276
+ <p class="cmd-note">This packages your changes and kicks off a compliance-reviewed preview build. Once it reconciles cleanly, <code>tot ship</code> promotes it live — it always shows you the diff and asks for a confirm first.</p>
2040
2277
  </div>
2041
2278
  </section>
2042
2279
  </div>
@@ -2108,6 +2345,7 @@ const hostedActivityScript = `
2108
2345
  <button type="button" class="df-mini" id="filesForce">Overwrite</button>
2109
2346
  </span>
2110
2347
  <button type="button" class="df-mini df-diffbtn" id="filesDiffBtn" hidden>Diff</button>
2348
+ <button type="button" class="df-mini df-publishbtn" id="filesPublishBtn" hidden>Publish</button>
2111
2349
  </div>
2112
2350
  <textarea class="df-view" id="filesView" readonly spellcheck="false" wrap="off">Pick a file on the left to view its source.</textarea>
2113
2351
  <pre class="df-diff" id="filesDiff" hidden></pre>
@@ -2160,18 +2398,44 @@ const hostedActivityScript = `
2160
2398
  </div>
2161
2399
  )}
2162
2400
 
2401
+ {/* d3-unhold-dev-step0-connect-mcp: un-held now that MCP access follows the tot20
2402
+ developer grant (federated sign-in / 8425 Stage B is live) — an invited developer
2403
+ already has what this needs, so it's no longer aspirational. Copy/command carried
2404
+ over from the pre-U1-strip /dev (dev.astro @ 110f73e); the "FUNCTIONALLY PENDING"
2405
+ caveat from e58eab5 is dropped since the prerequisite is now real. Static, renders
2406
+ unconditionally like the "next" teaser grid below — not gated on capability. */}
2407
+ <section class="stage" id="stage-mcp">
2408
+ <div class="stage-body">
2409
+ <span class="kicker">Connect the MCP</span>
2410
+ <h1>Bring your AI assistant into the loop.</h1>
2411
+ <p class="goal">The Regulated Storefront is a native AI platform — connecting the Token of Trust MCP lets Claude, Cursor, or any MCP-speaking assistant work your store directly. It works with every major AI platform. On Claude:</p>
2412
+ <div class="term">
2413
+ <div class="term-bar">
2414
+ <span class="dots"><span class="dot r"></span><span class="dot y"></span><span class="dot g"></span></span><span class="ttl">Claude Code</span>
2415
+ <button class="term-copy" id="mcpCopyIcon" type="button" aria-label="Copy the command" title="Copy the command">
2416
+ <svg class="ic-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
2417
+ <svg class="ic-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
2418
+ </button>
2419
+ </div>
2420
+ <div class="term-body"><span class="c"># add the Token of Trust MCP</span>
2421
+ <span class="cmd" id="mcpCmdText">claude mcp add --transport http tot https://mcp.tokenoftrust.com/mcp</span>
2422
+ <span class="c">&nbsp;</span>
2423
+ <span class="c"># then run /mcp in Claude and sign in with your invite email</span></div>
2424
+ </div>
2425
+ <p class="cmd-note">Other assistants: point them at <code>https://mcp.tokenoftrust.com/mcp</code>. Sign in with the email your invite came to — your access follows your developer grant on this store.</p>
2426
+ </div>
2427
+ </section>
2428
+
2163
2429
  {/* Static "coming soon" cards — pure /dev rendering with no dependency on the
2164
2430
  isLocalDevLoop-only /__tot/* endpoints and no need for the activity bridge, so
2165
2431
  it renders unconditionally on the deployed hosted panel (and locally). Not
2166
- gated on capability either — it's a teaser of what's next, and AI is one card
2167
- among several, not a prerequisite. */}
2432
+ gated on capability either — it's a teaser of what's next. */}
2168
2433
  <section class="next">
2169
2434
  <h3>The rest of the workflow</h3>
2170
2435
  <p class="sub">Previews, publish checks, AI assistance, and diagnostics. These open up once you've got the loop — coming soon.</p>
2171
2436
  <div class="nextgrid">
2172
2437
  <div class="nextcard"><span class="path">Share a preview</span><span class="d">Put a branch or PR up at a shareable URL.</span><span class="soon">Coming soon</span></div>
2173
2438
  <div class="nextcard"><span class="path">Publish readiness</span><span class="d">What happens if you publish today — checks &amp; blockers.</span><span class="soon">Coming soon</span></div>
2174
- <div class="nextcard"><span class="path">Edit with AI</span><span class="d">Connect Claude, Cursor, or Codex to your store.</span><span class="soon">Coming soon</span></div>
2175
2439
  <div class="nextcard"><span class="path">Troubleshooting</span><span class="d">Doctor, logs, server health, and auth checks.</span><span class="soon">Coming soon</span></div>
2176
2440
  </div>
2177
2441
  </section>
@@ -2246,16 +2510,55 @@ const hostedActivityScript = `
2246
2510
  )}
2247
2511
 
2248
2512
  <script nonce={cspNonce} is:inline>
2249
- // Theme toggle: flip data-theme on <html>, seeded from the OS preference on first click.
2513
+ // Theme toggle: flip data-theme on <html>, seeded from the OS preference on first
2514
+ // click, and PERSISTED to localStorage so a reload stays on the manual choice (the
2515
+ // head seed script above reads this same key before first paint). Clearing storage
2516
+ // falls back to prefers-color-scheme again, same as a first-ever visit.
2250
2517
  (function () {
2251
2518
  var root = document.documentElement;
2252
2519
  var btn = document.getElementById("themeBtn");
2253
2520
  if (!btn) return;
2521
+ var KEY = "tot.theme";
2522
+ function store(v) { try { localStorage.setItem(KEY, v); } catch (e) {} }
2254
2523
  btn.addEventListener("click", function () {
2255
2524
  var cur = root.getAttribute("data-theme");
2256
2525
  var next = cur === "dark" ? "light" : cur === "light" ? "dark"
2257
2526
  : (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "light" : "dark");
2258
2527
  root.setAttribute("data-theme", next);
2528
+ store(next);
2529
+ });
2530
+ })();
2531
+ </script>
2532
+
2533
+ <script nonce={cspNonce} is:inline>
2534
+ // Copy the "connect the MCP" command (d3-unhold-dev-step0-connect-mcp). Static
2535
+ // text (no minting), so unlike #copyIcon this button is visible from load.
2536
+ (function () {
2537
+ var copyIcon = document.getElementById("mcpCopyIcon");
2538
+ var cmdText = document.getElementById("mcpCmdText");
2539
+ var toast = document.getElementById("toast");
2540
+ var toastMsg = document.getElementById("toastMsg");
2541
+ if (!copyIcon || !cmdText) return;
2542
+ var tT;
2543
+ function ping(m) {
2544
+ if (!toast || !toastMsg) return;
2545
+ toastMsg.textContent = m; toast.classList.add("show");
2546
+ clearTimeout(tT); tT = setTimeout(function () { toast.classList.remove("show"); }, 1900);
2547
+ }
2548
+ copyIcon.addEventListener("click", function () {
2549
+ var text = cmdText.textContent;
2550
+ var done = navigator.clipboard
2551
+ ? navigator.clipboard.writeText(text).then(function () { return true; }).catch(function () { return false; })
2552
+ : Promise.resolve(false);
2553
+ done.then(function (ok) {
2554
+ if (ok) {
2555
+ copyIcon.setAttribute("data-copied", "1");
2556
+ setTimeout(function () { copyIcon.removeAttribute("data-copied"); }, 1600);
2557
+ ping("Copied");
2558
+ } else {
2559
+ ping("Select the command above to copy");
2560
+ }
2561
+ });
2259
2562
  });
2260
2563
  })();
2261
2564
  </script>
@@ -2283,9 +2586,16 @@ const hostedActivityScript = `
2283
2586
  var note = document.getElementById("cli-cmd-note");
2284
2587
  var toast = document.getElementById("toast");
2285
2588
  var toastMsg = document.getElementById("toastMsg");
2589
+ var copyIcon = document.getElementById("copyIcon");
2286
2590
  if (!btn || !textEl) return;
2287
2591
  var generic = btn.getAttribute("data-generic") || "";
2288
2592
  var fastpath = btn.getAttribute("data-fastpath") === "1";
2593
+ // The request's tenant prefix ("" host-routed, "/<tenant>" path-routed). Carried
2594
+ // as a data-attr because `basePath` is otherwise defined only inside the SEPARATE
2595
+ // hostedActivity IIFE — referencing it here (the cli-signin-code / -status / -approve
2596
+ // fetches below) was a ReferenceError that the mint's catch surfaced as the opaque
2597
+ // "Couldn't reach the sign-in service" with no request ever sent.
2598
+ var basePath = btn.getAttribute("data-base-path") || "";
2289
2599
  var tT;
2290
2600
  function ping(m) {
2291
2601
  if (!toast) return;
@@ -2298,6 +2608,111 @@ const hostedActivityScript = `
2298
2608
  }
2299
2609
  function label(s) { btn.childNodes[btn.childNodes.length - 1].textContent = s; }
2300
2610
 
2611
+ // Standard copy-icon affordance on the terminal box. Once the command is
2612
+ // revealed, this is the obvious, always-there way to (re)copy it — the
2613
+ // icon→check swap is its own inline confirmation, so copying no longer
2614
+ // depends on the transient toast being noticed.
2615
+ var iconT;
2616
+ function flashCopied() {
2617
+ if (!copyIcon) return;
2618
+ copyIcon.setAttribute("data-copied", "1");
2619
+ copyIcon.setAttribute("aria-label", "Copied");
2620
+ copyIcon.setAttribute("title", "Copied");
2621
+ clearTimeout(iconT);
2622
+ iconT = setTimeout(function () {
2623
+ copyIcon.removeAttribute("data-copied");
2624
+ copyIcon.setAttribute("aria-label", "Copy the command");
2625
+ copyIcon.setAttribute("title", "Copy the command");
2626
+ }, 1600);
2627
+ }
2628
+ function revealIcon() {
2629
+ if (copyIcon) copyIcon.hidden = false;
2630
+ }
2631
+ if (copyIcon) {
2632
+ copyIcon.addEventListener("click", function () {
2633
+ copy(textEl.textContent).then(function (ok) {
2634
+ if (ok) { flashCopied(); ping("Copied — paste & press Enter"); }
2635
+ else ping("Select the command above to copy");
2636
+ });
2637
+ });
2638
+ }
2639
+
2640
+ // ── Rendezvous approval: after a command is generated, the terminal ATTACHES
2641
+ // its own PKCE key and prints a fingerprint; the developer confirms it HERE. This
2642
+ // is the load-bearing anti-interception step — approving the wrong fingerprint is
2643
+ // how a hijack is caught, so the confirm is an explicit, human decision.
2644
+ var rzvRoot = document.getElementById("rzvApprove");
2645
+ var rzvWait = document.getElementById("rzvWait");
2646
+ var rzvConfirm = document.getElementById("rzvConfirm");
2647
+ var rzvFp = document.getElementById("rzvFp");
2648
+ var rzvApproveBtn = document.getElementById("rzvApproveBtn");
2649
+ var rzvDenyBtn = document.getElementById("rzvDenyBtn");
2650
+ var rzvDone = document.getElementById("rzvDone");
2651
+ var rzvTimer = null, rzvCode = null, rzvDecided = false;
2652
+ function rzvShow(el) { if (el) el.hidden = false; }
2653
+ function rzvHide(el) { if (el) el.hidden = true; }
2654
+ function rzvStopPoll() { if (rzvTimer) { clearTimeout(rzvTimer); rzvTimer = null; } }
2655
+ function rzvFinish(msg) {
2656
+ rzvStopPoll(); rzvDecided = true;
2657
+ rzvHide(rzvWait); rzvHide(rzvConfirm);
2658
+ if (rzvDone) { rzvDone.textContent = msg; rzvDone.hidden = false; }
2659
+ }
2660
+ async function rzvPoll() {
2661
+ if (rzvDecided) return;
2662
+ var r, b;
2663
+ try {
2664
+ r = await fetch(basePath + "/api/dev/cli-signin-status", {
2665
+ method: "POST", headers: { "content-type": "application/json" },
2666
+ body: JSON.stringify({ rendezvousCode: rzvCode }),
2667
+ });
2668
+ b = await r.json().catch(function () { return {}; });
2669
+ } catch (e) { rzvTimer = setTimeout(rzvPoll, 2800); return; }
2670
+ if (!r || !r.ok) { rzvTimer = setTimeout(rzvPoll, 3200); return; }
2671
+ if (b.state === "attached" && b.fingerprint) {
2672
+ rzvHide(rzvWait); rzvShow(rzvConfirm);
2673
+ if (rzvFp) { rzvFp.textContent = b.fingerprint; rzvFp.setAttribute("data-fp", b.fingerprint); }
2674
+ return; // stop polling — wait for the human decision
2675
+ }
2676
+ if (b.state === "approved") { rzvFinish("Signed in ✓ — your terminal is good to go."); return; }
2677
+ if (b.state === "denied") { rzvFinish("Rejected — that terminal wasn’t approved. You’re safe; generate a fresh command to try again."); return; }
2678
+ if (b.state === "expired") { rzvFinish("This sign-in expired. Generate a fresh setup command above."); return; }
2679
+ rzvTimer = setTimeout(rzvPoll, 2500); // pending → keep watching
2680
+ }
2681
+ function rzvStart(code) {
2682
+ rzvCode = code; rzvDecided = false;
2683
+ if (rzvApproveBtn) rzvApproveBtn.disabled = false;
2684
+ if (rzvDenyBtn) rzvDenyBtn.disabled = false;
2685
+ rzvShow(rzvRoot); rzvShow(rzvWait); rzvHide(rzvConfirm); rzvHide(rzvDone);
2686
+ rzvStopPoll(); rzvPoll();
2687
+ }
2688
+ async function rzvDecide(decision) {
2689
+ if (rzvDecided) return;
2690
+ var fp = rzvFp ? (rzvFp.getAttribute("data-fp") || "") : "";
2691
+ if (rzvApproveBtn) rzvApproveBtn.disabled = true;
2692
+ if (rzvDenyBtn) rzvDenyBtn.disabled = true;
2693
+ var r;
2694
+ try {
2695
+ r = await fetch(basePath + "/api/dev/cli-signin-approve", {
2696
+ method: "POST", headers: { "content-type": "application/json" },
2697
+ body: JSON.stringify({ rendezvousCode: rzvCode, fingerprint: fp, decision: decision }),
2698
+ });
2699
+ } catch (e) {
2700
+ if (rzvApproveBtn) rzvApproveBtn.disabled = false;
2701
+ if (rzvDenyBtn) rzvDenyBtn.disabled = false;
2702
+ return;
2703
+ }
2704
+ if (decision === "deny") { rzvFinish("Rejected — that terminal wasn’t approved."); return; }
2705
+ if (r.ok) { rzvFinish("Signed in ✓ — your terminal is good to go."); return; }
2706
+ if (rzvApproveBtn) rzvApproveBtn.disabled = false;
2707
+ if (rzvDenyBtn) rzvDenyBtn.disabled = false;
2708
+ var m = r.status === 403 ? "That didn’t match your identity — generate a fresh command and try again."
2709
+ : r.status === 409 ? "That code didn’t match a waiting terminal — re-check it, or generate a fresh command."
2710
+ : "Couldn’t complete approval — try again.";
2711
+ if (rzvDone) { rzvDone.textContent = m; rzvDone.hidden = false; }
2712
+ }
2713
+ if (rzvApproveBtn) rzvApproveBtn.addEventListener("click", function () { rzvDecide("approve"); });
2714
+ if (rzvDenyBtn) rzvDenyBtn.addEventListener("click", function () { rzvDecide("deny"); });
2715
+
2301
2716
  if (!fastpath) {
2302
2717
  btn.addEventListener("click", function () {
2303
2718
  copy(generic).then(function (ok) {
@@ -2316,7 +2731,7 @@ const hostedActivityScript = `
2316
2731
  label(" …"); btn.disabled = true;
2317
2732
  var res, body;
2318
2733
  try {
2319
- res = await fetch("/api/dev/cli-signin-code", {
2734
+ res = await fetch(basePath + "/api/dev/cli-signin-code", {
2320
2735
  method: "POST",
2321
2736
  headers: { "content-type": "application/json" },
2322
2737
  body: "{}",
@@ -2331,6 +2746,7 @@ const hostedActivityScript = `
2331
2746
  var byStatus = {
2332
2747
  401: "Your session expired — reload the page and sign in again.",
2333
2748
  403: "This account doesn’t have developer access to a store yet.",
2749
+ 409: "Your store isn’t set up yet — reload this page for its status.",
2334
2750
  502: "The sign-in service is having a moment — try again.",
2335
2751
  503: "Terminal sign-in isn’t configured on this environment.",
2336
2752
  };
@@ -2338,9 +2754,15 @@ const hostedActivityScript = `
2338
2754
  label(" Copy the command"); btn.disabled = false; busy = false;
2339
2755
  return;
2340
2756
  }
2341
- // Reveal the real command (contains the one-time code) as a pure text node, then copy it.
2757
+ // Reveal the real command (carries the non-secret rendezvous handle) as a pure
2758
+ // text node, then copy it.
2342
2759
  textEl.textContent = body.command;
2760
+ textEl.classList.remove("is-placeholder");
2761
+ revealIcon();
2762
+ // Start watching for the terminal to attach so we can prompt for approval.
2763
+ if (body.rendezvousCode) rzvStart(body.rendezvousCode);
2343
2764
  var ok = await copy(body.command);
2765
+ if (ok) flashCopied();
2344
2766
  label(ok ? " Copied ✓" : " Select & copy");
2345
2767
  ping(ok ? "Copied — paste & press Enter" : "Select the command above to copy");
2346
2768
  if (note) {