@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
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import type { ManagedCheckoutConfig } from "./checkout.js";
9
9
  import type { CapabilityConfig } from "./capabilities.js";
10
+ import type { AutopilotConfig, MembershipProgramConfig } from "./membership.js";
10
11
 
11
12
  /**
12
13
  * The shared semantic COLOR contract — the widget library's public API.
@@ -63,6 +64,22 @@ export interface ThemeColorTokens {
63
64
  "primary-hover"?: string;
64
65
  /** Hover/active shade of `accent`. Default: a derived darken of `accent`. */
65
66
  "accent-hover"?: string;
67
+
68
+ // --- Interaction/quality-bar extensions (unit F6). Same "always resolvable"
69
+ // contract: each carries a derived @theme-bridge default so a component may
70
+ // read it unconditionally; a theme sets one only to brand-tune it. ---
71
+ /** Keyboard focus-ring color (`:focus-visible` outline). Default: `accent`. */
72
+ "focus-ring"?: string;
73
+ /** Fill for a disabled/loading control (buttons, inputs). Default: a muted
74
+ * greige mix of `muted`+`surface`. */
75
+ disabled?: string;
76
+ /** Readable text/icon color ON a `disabled` control. Default: `muted`. */
77
+ "disabled-contrast"?: string;
78
+ /** Form-field border — a stronger, ≥3:1 outline for inputs/selects than the
79
+ * hairline `border`. Default: a mix of `border`+`text`. */
80
+ "field-border"?: string;
81
+ /** Review/rating star fill (trust/UGC surfaces). Default: `accent`. */
82
+ star?: string;
66
83
  }
67
84
 
68
85
  export interface ThemeFontFamily {
@@ -92,6 +109,13 @@ export interface ThemeShapeTokens {
92
109
  radius: { sm: string; md: string; lg: string; full: string };
93
110
  borderWeights: { thin: string; medium: string; thick: string };
94
111
  shadow: { sm: string; md: string; lg: string };
112
+ /**
113
+ * Minimum interactive tap-target size (WCAG 2.2 AA §2.5.8 · 24px floor, 44px
114
+ * recommended for mobile commerce). Serialized to `--tap-target`; components
115
+ * size touch controls with `min-height/min-width: var(--tap-target)`. Optional
116
+ * — the `:root` base default (44px) applies when a theme omits it. (Unit F6.)
117
+ */
118
+ tapTarget?: string;
95
119
  }
96
120
 
97
121
  export interface ThemeBrandTokens {
@@ -247,6 +271,29 @@ export interface ComplianceConfig {
247
271
  */
248
272
  export type SiteType = "commerce" | "marketing";
249
273
 
274
+ /**
275
+ * The storefront paywall mode (decision `golive-paywall-storefront-mode-enabled`).
276
+ * `"enabled"` is the ONLY value that permits go-live; `"disabled"` (or an absent
277
+ * `storefront.mode`) fails the paywall closed. Kept OPEN as a string union with an
278
+ * `enabled` member so a future billing state (e.g. "trial", "past_due") can be added
279
+ * without changing the gate — {@link StorefrontModeAllowed} stays the single source
280
+ * of the allow test.
281
+ */
282
+ export type StorefrontMode = "enabled" | "disabled";
283
+
284
+ /** The one storefront mode value that permits go-live. The gate tests strict equality. */
285
+ export const STOREFRONT_MODE_ENABLED = "enabled" as const;
286
+
287
+ /**
288
+ * PURE paywall predicate: does this storefront mode permit go-live? True iff the
289
+ * mode is exactly `"enabled"` (decision `golive-paywall-storefront-mode-enabled`).
290
+ * Generic — takes the mode value, NEVER a domain — so there is no per-store
291
+ * special-case. Undefined/unknown → false (fail-closed).
292
+ */
293
+ export function storefrontModeAllowsGoLive(mode: string | undefined | null): boolean {
294
+ return mode === STOREFRONT_MODE_ENABLED;
295
+ }
296
+
250
297
  /**
251
298
  * Per-tenant capability switches. These gate whole surfaces so the same
252
299
  * component library renders both a store and a marketing site without any
@@ -355,6 +402,20 @@ export interface TenantConfig {
355
402
  */
356
403
  commerce?: {
357
404
  checkout?: ManagedCheckoutConfig;
405
+ /**
406
+ * Autopilot (subscription) program visibility (F10). Absent → subscription
407
+ * purchase options render wherever a product authors them (framework/dev
408
+ * default). `enabled: false` HIDES Autopilot at launch — the first-class
409
+ * hide-at-launch state for a tenant that isn't selling recurring orders yet.
410
+ */
411
+ autopilot?: AutopilotConfig;
412
+ /**
413
+ * Membership pricing program (F10): member-vs-list pricing + how a member
414
+ * discount stacks with a subscription discount. Absent/`enabled:false` → no
415
+ * member pricing renders (the launch default; shopper membership accounts are
416
+ * a Phase-2 managed-checkout capability).
417
+ */
418
+ membership?: MembershipProgramConfig;
358
419
  };
359
420
  /**
360
421
  * PR-gated capability switches (cart/checkout, age/identity verification,
@@ -364,6 +425,181 @@ export interface TenantConfig {
364
425
  * Undefined entries default from siteType/compliance — see resolveCapabilities.
365
426
  */
366
427
  capabilities?: Partial<CapabilityConfig>;
428
+ /**
429
+ * Static-publish target (T5, tokenoftrust-com-static-publish epic). This
430
+ * tenant's PUBLIC site is materialized to an external static host
431
+ * (S3/CloudFront) OFF the Worker and republished on go-live. A per-tenant
432
+ * PROPERTY, not a global dark flag: presence opts the tenant in (default ON);
433
+ * `enabled: false` opts back out without dropping the target coordinates. The
434
+ * dispatch credentials/coordinates live in env/secrets, NEVER here. `target` is
435
+ * an opaque, TARGET-NEUTRAL label (S3 now, R2 later) forwarded verbatim into the
436
+ * publish job so ONE reconcile publish-sink drives any target.
437
+ */
438
+ staticPublish?: {
439
+ /** Target-neutral label, e.g. "www-s3". Forwarded into the dispatch payload. */
440
+ target: string;
441
+ /** Opt back out without removing the target. Default true when present. */
442
+ enabled?: boolean;
443
+ };
444
+ /**
445
+ * Storefront product/subscription state — the paywall the platform gates GO-LIVE
446
+ * on (decision `golive-paywall-storefront-mode-enabled`). This is the storefront's
447
+ * analog of the ToT platform SDK's `apiClient.options.storefront.mode`: a per-store
448
+ * PROPERTY set from the merchant's account/subscription, NEVER a per-domain
449
+ * special-case in code. Go-live (the public LIVE publish, `POST /api/changes/accept`)
450
+ * is permitted ONLY when `mode === "enabled"`; any other value — including undefined
451
+ * (never provisioned) — refuses go-live (fail-closed paywall). Preview/dev never
452
+ * consult it. See `storefrontModeGate` (`lib/change/route.ts`).
453
+ */
454
+ storefront?: {
455
+ /**
456
+ * The storefront paywall mode. `"enabled"` = the store has an active storefront
457
+ * subscription and MAY go live. `"disabled"` (or absent) = not entitled to
458
+ * go-live yet.
459
+ */
460
+ mode?: StorefrontMode;
461
+ };
462
+ /**
463
+ * Internal test/e2e tenant — never a real client. A tenant carrying this
464
+ * marker (or an `.e2e.test` appDomain) is excluded from real/prospect-facing
465
+ * listings, HARD-blocked from go-live/promote, and excluded from
466
+ * billing/analytics. It exists only to host a destructive full-cycle e2e
467
+ * (candidate create/ship/rollback) against a CLONE of a real tenant.
468
+ */
469
+ classification?: "internal-test";
470
+ }
471
+
472
+ /**
473
+ * True when a tenant is an internal test/e2e tenant — either explicitly marked
474
+ * `classification: "internal-test"` or resolving to an `.e2e.test` appDomain
475
+ * (fail-safe belt-and-suspenders so a clone missing the marker is still caught).
476
+ * Callers use this to keep such tenants out of real listings and off the
477
+ * go-live/promote path.
478
+ */
479
+ export function isInternalTestTenant(
480
+ t: Pick<TenantConfig, "classification" | "appDomain">,
481
+ ): boolean {
482
+ return t.classification === "internal-test" || t.appDomain.endsWith(".e2e.test");
483
+ }
484
+
485
+ // ---------------------------------------------------------------------------
486
+ // Non-premium (self-serve) tenant identity — D4.
487
+ //
488
+ // A NEW self-serve / non-premium shop is minted with its identity anchored to a
489
+ // per-tenant subdomain on our own Cloudflare zone: `<label>.tokenoftrust.store`.
490
+ // That value is the tenant_id AND the appDomain AND the canonicalDomain AND the
491
+ // tot_data_scope (the dir==tenant_id==appDomain==scope invariant the
492
+ // `unify-tenant-id-equals-appdomain` decision established) — a REAL dotted
493
+ // appDomain, NOT a reintroduced bare slug (bare slugs were deliberately retired).
494
+ // A custom `.com` is layered on LATER as an optional premium alias/upgrade
495
+ // (`rebindPremiumDomain`), never a precondition for a preview.
496
+ //
497
+ // See docs/epics/proposed/invite-gated-auto-build.md §D4.
498
+ // ---------------------------------------------------------------------------
499
+
500
+ /**
501
+ * The Cloudflare zone that hosts every non-premium (self-serve) store as a
502
+ * per-tenant subdomain. The `*.tokenoftrust.store` wildcard route already exists
503
+ * in apps/storefront/wrangler.toml, so routing a freshly minted store is free.
504
+ */
505
+ export const HOSTED_STORE_ZONE = "tokenoftrust.store";
506
+
507
+ /**
508
+ * A single DNS label — the `<label>` in `<label>.tokenoftrust.store`: lowercase
509
+ * alphanumerics with internal hyphens, 1–63 chars, no leading/trailing hyphen and
510
+ * NO dots (a dot would create a deeper subdomain the wildcard route can't reach).
511
+ */
512
+ const HOSTED_STORE_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
513
+
514
+ /**
515
+ * The identity subset of a {@link TenantConfig} — the fields that establish WHO a
516
+ * tenant is (as opposed to how it looks or what it sells). {@link mintHostedStoreIdentity}
517
+ * produces exactly this; the rest of a config (theme, storyblok, compliance, …) is
518
+ * layered on by the app-level minter / build workstream.
519
+ */
520
+ export type TenantIdentity = Pick<
521
+ TenantConfig,
522
+ "tenant_id" | "appDomain" | "canonicalDomain" | "tot_data_scope" | "domains"
523
+ >;
524
+
525
+ /**
526
+ * The hosted-store hostname for a label: `<label>.tokenoftrust.store`.
527
+ * @throws if `label` is not a valid single DNS label (see {@link HOSTED_STORE_LABEL_RE}).
528
+ */
529
+ export function hostedStoreDomain(label: string): string {
530
+ const clean = String(label ?? "").toLowerCase().trim();
531
+ if (!HOSTED_STORE_LABEL_RE.test(clean)) {
532
+ throw new Error(
533
+ `hostedStoreDomain: invalid store label "${label}" — want a single DNS label ` +
534
+ `(lowercase alnum + internal hyphens, 1–63 chars, no dots), e.g. "acme-vapor".`,
535
+ );
536
+ }
537
+ return `${clean}.${HOSTED_STORE_ZONE}`;
538
+ }
539
+
540
+ /** True when `domain` is a `<label>.tokenoftrust.store` hosted-store hostname. */
541
+ export function isHostedStoreDomain(domain: string): boolean {
542
+ const clean = String(domain ?? "").toLowerCase().split(":")[0]?.trim() ?? "";
543
+ const suffix = `.${HOSTED_STORE_ZONE}`;
544
+ if (!clean.endsWith(suffix)) return false;
545
+ return HOSTED_STORE_LABEL_RE.test(clean.slice(0, -suffix.length));
546
+ }
547
+
548
+ /**
549
+ * Mint the identity for a NEW self-serve / non-premium tenant (D4).
550
+ *
551
+ * tenant_id == appDomain == canonicalDomain == tot_data_scope ==
552
+ * `<label>.tokenoftrust.store`, with that single hostname as the sole `domains`
553
+ * entry. A premium custom domain is added later via {@link rebindPremiumDomain}.
554
+ *
555
+ * @throws if `label` is not a valid single DNS label.
556
+ */
557
+ export function mintHostedStoreIdentity(label: string): TenantIdentity {
558
+ const appDomain = hostedStoreDomain(label); // validates the label
559
+ return {
560
+ tenant_id: appDomain,
561
+ appDomain,
562
+ canonicalDomain: appDomain,
563
+ tot_data_scope: appDomain,
564
+ domains: [appDomain],
565
+ };
566
+ }
567
+
568
+ /**
569
+ * Layer an optional PREMIUM custom domain onto an existing hosted-store tenant —
570
+ * the upgrade path in D4. Returns a NEW config (the input is not mutated).
571
+ *
572
+ * The tenant's IDENTITY stays anchored to its `.tokenoftrust.store` value:
573
+ * `tenant_id` AND `appDomain` are STABLE. This is the recorded recommendation for
574
+ * D4's open question — keep the id stable and treat the custom domain purely as an
575
+ * alias — which avoids repo-rename + registry-re-key churn. What changes:
576
+ * - the custom domain (and its `www.` host) is ADDED to `domains` (idempotent);
577
+ * - `canonicalDomain` FLIPS to the custom domain (SEO canonical → premium domain).
578
+ *
579
+ * NOTE (open human gate — invite-gated-auto-build.md §D4): whether a rebind should
580
+ * instead CHURN tenant_id/appDomain to the custom domain is an unresolved product
581
+ * decision that would need repo-rename + registry-re-key infrastructure which does
582
+ * not exist yet. This helper deliberately implements ONLY the churn-free alias form.
583
+ *
584
+ * @throws if `customDomain` is not a plausible dotted hostname.
585
+ */
586
+ export function rebindPremiumDomain(
587
+ config: TenantConfig,
588
+ customDomain: string,
589
+ ): TenantConfig {
590
+ const clean = String(customDomain ?? "").toLowerCase().trim();
591
+ if (!clean || clean.includes("/") || clean.includes(" ") || !clean.includes(".")) {
592
+ throw new Error(
593
+ `rebindPremiumDomain: invalid custom domain "${customDomain}" — want a dotted hostname, e.g. "acme.com".`,
594
+ );
595
+ }
596
+ const domains = [...config.domains];
597
+ const add = (host: string): void => {
598
+ if (!domains.some((d) => d.toLowerCase() === host)) domains.push(host);
599
+ };
600
+ add(clean);
601
+ if (!clean.startsWith("www.")) add(`www.${clean}`);
602
+ return { ...config, domains, canonicalDomain: clean };
367
603
  }
368
604
 
369
605
  export type DeepPartial<T> = {
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Parent-side postMessage bridge for sandboxed widget iframes — PrivateApps
3
+ * epic, D5 (Chunk E). The widget sandbox (`allow-scripts`, no
4
+ * `allow-same-origin` — `customization-scripts.ts`'s `widgetFrameAttrs()`)
5
+ * blocks DOM/cookie/storage access outright; this is the ONLY channel a
6
+ * widget has back to the page, so it gets the same fail-closed discipline as
7
+ * the sandbox itself: a message is honored only when BOTH `event.source` is
8
+ * a TRACKED iframe's `contentWindow` AND `event.origin` is the OPAQUE sandbox
9
+ * origin `"null"` (see {@link OPAQUE_SANDBOX_ORIGIN} — a frame sandboxed
10
+ * without `allow-same-origin` has no origin of its own, so every browser
11
+ * reports its postMessage origin as the literal string `"null"`, never its
12
+ * real HTTP(S) endpoint; verified against a real sandboxed cross-origin
13
+ * iframe, not assumed — jsdom can't observe this, only a real browser can).
14
+ * Anything else — an untracked window, a non-opaque origin, an
15
+ * unrecognized/malformed `type` — is a silent no-op: no reply, no state
16
+ * change, so a probing widget can't learn whether its message was rejected
17
+ * for being unknown-type vs a weakened sandbox (no oracle).
18
+ *
19
+ * Dependency-free + Workers/browser-portable: nothing here touches
20
+ * `window`/`document` at module scope, only inside `mountWidgetPostMessageBridge`
21
+ * — so importing this module (via `@tot/public-runtime`'s barrel, which also
22
+ * loads server-side into the SSR bundle) is safe outside a browser; only
23
+ * calling the mount function needs one.
24
+ */
25
+
26
+ /** Own-height clamp — a widget can't grow the iframe past a sane bound, never the width. */
27
+ const MIN_HEIGHT_PX = 20;
28
+ const MAX_HEIGHT_PX = 2000;
29
+
30
+ /**
31
+ * The `event.origin` a browser reports for a message from an opaque-origin
32
+ * frame — i.e. every widget iframe, since all of them are rendered with
33
+ * `WIDGET_SANDBOX` (`allow-scripts`, no `allow-same-origin`). This is what
34
+ * the origin check below actually compares against — NOT the widget's real
35
+ * endpoint origin (`data-tot-widget-origin`, kept for install identification
36
+ * / diagnostics only). If the sandbox were ever weakened to add
37
+ * `allow-same-origin`, the frame would start reporting its real origin
38
+ * instead of `"null"`, and this check would then reject every message,
39
+ * turning a silent security downgrade into a loud, visible break.
40
+ */
41
+ const OPAQUE_SANDBOX_ORIGIN = "null";
42
+
43
+ /** Same-tenant relative path only: a leading `/`, never `//` (protocol-relative), never a scheme. */
44
+ const RELATIVE_PATH = /^\/(?!\/)[a-zA-Z0-9\-._~!$&'()*+,;=:@%/?#]*$/;
45
+
46
+ /** The closed set of message types a widget may send. Everything else is a no-op. */
47
+ export type WidgetMessageType = "resize" | "navigate" | "analytics";
48
+
49
+ export interface WidgetAnalyticsEvent {
50
+ installId: string;
51
+ name: string;
52
+ props: Record<string, string | number | boolean>;
53
+ }
54
+
55
+ /** Minimal DOM surface this module needs — a real `HTMLIFrameElement` satisfies it structurally. */
56
+ export interface WidgetFrameHost {
57
+ contentWindow: unknown;
58
+ style: { height: string };
59
+ getAttribute(name: string): string | null;
60
+ }
61
+ export interface WidgetFrameRoot {
62
+ querySelectorAll(selector: string): Iterable<WidgetFrameHost>;
63
+ }
64
+
65
+ /** Minimal message-event surface — real `MessageEvent` satisfies it structurally. */
66
+ export interface WidgetMessageEvent {
67
+ source: unknown;
68
+ origin: string;
69
+ data: unknown;
70
+ }
71
+ /** Minimal event-target surface — real `Window` satisfies it structurally. */
72
+ export interface WidgetMessageTarget {
73
+ addEventListener(type: "message", listener: (event: WidgetMessageEvent) => void): void;
74
+ removeEventListener(type: "message", listener: (event: WidgetMessageEvent) => void): void;
75
+ }
76
+
77
+ export interface WidgetPostMessageBridgeOptions {
78
+ /** Sink for validated `analytics` messages. Defaults to a no-op (Phase 1 has no wired sink yet). */
79
+ onAnalytics?: (event: WidgetAnalyticsEvent) => void;
80
+ /** Sink for validated `navigate` messages. Defaults to `window.location.assign` (same-tab). */
81
+ onNavigate?: (path: string) => void;
82
+ /** Element scanned for tracked iframes. Defaults to `document`. */
83
+ root?: WidgetFrameRoot;
84
+ /** Event target the bridge listens on. Defaults to `window`. */
85
+ target?: WidgetMessageTarget;
86
+ }
87
+
88
+ interface TrackedFrame {
89
+ expectedOrigin: string;
90
+ installId: string;
91
+ el: WidgetFrameHost;
92
+ }
93
+
94
+ function isRecord(v: unknown): v is Record<string, unknown> {
95
+ return typeof v === "object" && v !== null;
96
+ }
97
+
98
+ /**
99
+ * Scan `root` for `iframe[data-tot-widget-origin]` — `AppWidgetFrame`'s
100
+ * render output — and index each by its `contentWindow`. Re-scanned on every
101
+ * message rather than cached once, so a widget that mounts after the initial
102
+ * page load (e.g. a `loading="lazy"` frame near the viewport) is still found.
103
+ */
104
+ function collectTrackedFrames(root: WidgetFrameRoot): Map<unknown, TrackedFrame> {
105
+ const frames = new Map<unknown, TrackedFrame>();
106
+ for (const el of root.querySelectorAll("iframe[data-tot-widget-origin]")) {
107
+ const expectedOrigin = el.getAttribute("data-tot-widget-origin");
108
+ const installId = el.getAttribute("data-tot-install-id");
109
+ if (!expectedOrigin || !installId || !el.contentWindow) continue;
110
+ frames.set(el.contentWindow, { expectedOrigin, installId, el });
111
+ }
112
+ return frames;
113
+ }
114
+
115
+ function warn(message: string, detail: Record<string, unknown>): void {
116
+ console.warn(`[diag:widget-postmessage] ${message}`, detail);
117
+ }
118
+
119
+ /**
120
+ * Mount the parent-side bridge for the lifetime of the page. Returns an
121
+ * unmount function (removes the listener) — mainly for tests; the browser
122
+ * bootstrap script mounts once and never unmounts.
123
+ */
124
+ export function mountWidgetPostMessageBridge(
125
+ options: WidgetPostMessageBridgeOptions = {},
126
+ ): () => void {
127
+ const root = options.root ?? (document as unknown as WidgetFrameRoot);
128
+ const target = options.target ?? (window as unknown as WidgetMessageTarget);
129
+ const onAnalytics = options.onAnalytics ?? (() => {});
130
+ const onNavigate = options.onNavigate ?? ((path: string) => window.location.assign(path));
131
+
132
+ function handleMessage(event: WidgetMessageEvent): void {
133
+ try {
134
+ const frame = collectTrackedFrames(root).get(event.source);
135
+ if (!frame) {
136
+ warn("rejected — source is not a tracked widget iframe", { origin: event.origin });
137
+ return;
138
+ }
139
+ if (event.origin !== OPAQUE_SANDBOX_ORIGIN) {
140
+ warn("rejected — origin is not the opaque sandbox origin (sandbox may have been weakened)", {
141
+ installId: frame.installId,
142
+ expected: OPAQUE_SANDBOX_ORIGIN,
143
+ got: event.origin,
144
+ });
145
+ return;
146
+ }
147
+
148
+ const data = event.data;
149
+ if (!isRecord(data) || typeof data.type !== "string") {
150
+ warn("rejected — malformed message", { installId: frame.installId });
151
+ return;
152
+ }
153
+
154
+ switch (data.type as WidgetMessageType | string) {
155
+ case "resize": {
156
+ if (typeof data.height !== "number" || !Number.isFinite(data.height)) {
157
+ warn("rejected — non-numeric resize height", { installId: frame.installId });
158
+ return;
159
+ }
160
+ const clamped = Math.min(MAX_HEIGHT_PX, Math.max(MIN_HEIGHT_PX, data.height));
161
+ // Own height only — width is never touched from a widget message.
162
+ frame.el.style.height = `${clamped}px`;
163
+ return;
164
+ }
165
+ case "navigate": {
166
+ if (typeof data.path !== "string" || !RELATIVE_PATH.test(data.path)) {
167
+ warn("rejected — navigate target is not a same-tenant relative path", {
168
+ installId: frame.installId,
169
+ });
170
+ return;
171
+ }
172
+ onNavigate(data.path);
173
+ return;
174
+ }
175
+ case "analytics": {
176
+ if (typeof data.name !== "string") {
177
+ warn("rejected — analytics event missing a name", { installId: frame.installId });
178
+ return;
179
+ }
180
+ const rawProps = isRecord(data.props) ? data.props : {};
181
+ const props: Record<string, string | number | boolean> = {};
182
+ for (const [key, value] of Object.entries(rawProps)) {
183
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
184
+ props[key] = value;
185
+ }
186
+ }
187
+ onAnalytics({ installId: frame.installId, name: data.name, props });
188
+ return;
189
+ }
190
+ default:
191
+ warn("rejected — unknown message type", { installId: frame.installId, type: data.type });
192
+ }
193
+ } catch {
194
+ // A hostile `event.data` (e.g. a throwing getter on `type` or a
195
+ // property read inside the analytics props loop) must never escape as
196
+ // an uncaught exception out of the message listener — treat it exactly
197
+ // like any other malformed message: a silent no-op, no reply, no state
198
+ // change, no oracle for what specifically was invalid.
199
+ warn("rejected — reading the message threw", { origin: event.origin });
200
+ }
201
+ }
202
+
203
+ target.addEventListener("message", handleMessage);
204
+ return () => target.removeEventListener("message", handleMessage);
205
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * publish.mjs — server-side logic for C5's "Publish" action: open/update a
3
+ * PR-backed candidate (g1b `candidate_open`) for the developer's current
4
+ * on-disk draft of one writable file (`POST /__tot/publish {path}`).
5
+ *
6
+ * Follows the file-writer.mjs / file-browser.mjs delegation pattern: all
7
+ * logic lives here, dependency-free (global fetch + node builtins) and
8
+ * testable under `node --test`; `dev-plugins/dev-publish.mjs` maps req/res
9
+ * onto `handlePublish`. Local mode has no draft-overlay concept — C5's writer
10
+ * already writes straight to the developer's real checkout (file-writer.mjs),
11
+ * so the "draft" IS whatever's currently on disk: this reads it fresh
12
+ * (file-browser.mjs's `readFile`) rather than tracking a separate store.
13
+ *
14
+ * `deriveChangeId` mirrors `tot submit`'s own scheme
15
+ * (packages/cli/src/commands/submit.mjs) so repeat publishes from this local
16
+ * editor update ONE PR instead of opening a new one each time — keyed on the
17
+ * signed-in DEVELOPER's identity (their email), same as the CLI's own
18
+ * `actorKeyFor`.
19
+ *
20
+ * Auth: the developer's own cached OAuth session (`tot login`,
21
+ * `~/.tot/credentials.json`), via `packages/cli/src/auth.mjs`'s
22
+ * `resolveDeveloperSession` — reads, silently refreshes, and attaches the
23
+ * Bearer exactly as `tot submit` does. NOT the storefront-operator
24
+ * TOT_API_KEY/TOT_SECRET_KEY path this module used before 2026-07-26:
25
+ * tot-mcp went OAuth-first on 2026-07-23 ("Require OAuth before remote MCP
26
+ * initialize"), which retired the old no-browser operator/credential_validate
27
+ * trust path entirely — every caller, including this one, now needs a real
28
+ * OAuth bearer just to complete `initialize`. There is no service-account /
29
+ * client-credentials grant on this MCP (confirmed against its OAuth
30
+ * authorization-server metadata), so a human `tot login` (one-time; refresh
31
+ * is silent afterward, ~30-day refresh tokens) is the only way in.
32
+ */
33
+ import { createHash } from "node:crypto";
34
+ import { readFile } from "./file-browser.mjs";
35
+ import { matchesWriteAllowlist } from "./safe-path.mjs";
36
+ import { resolveTenantSourceRoot } from "./tenant-source-root.mjs";
37
+ import { createMcpClient } from "../../packages/cli/src/mcp.mjs";
38
+ import { resolveDeveloperSession, AuthUnavailableError } from "../../packages/cli/src/auth.mjs";
39
+
40
+ const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
41
+
42
+ /**
43
+ * A stable per-developer-per-tenant candidate handle, matching `tot submit`'s
44
+ * own scheme (`deriveChangeId` in packages/cli/src/commands/submit.mjs) so
45
+ * repeat local publishes update the SAME PR (`candidate_open` is idempotent
46
+ * on `changeId`) instead of piling up duplicates. Pure — unit-tested.
47
+ * @param {string} tenant
48
+ * @param {string} actorKey
49
+ * @returns {string}
50
+ */
51
+ export function deriveChangeId(tenant, actorKey) {
52
+ const hash = createHash("sha256").update(`${tenant}|${actorKey}`).digest("hex").slice(0, 16);
53
+ return `local-${hash}`;
54
+ }
55
+
56
+ /**
57
+ * Open/update the PR-backed candidate via MCP `candidate_open` for one file's
58
+ * current on-disk content. `deps.mcpClient`/`deps.resolveSession` are
59
+ * injectable for tests (else a real client + `resolveDeveloperSession` are
60
+ * used, against `env.MCP_BASE_URL`).
61
+ * @param {NodeJS.ProcessEnv} env
62
+ * @param {string} sourceRoot
63
+ * @param {string} tenantId
64
+ * @param {string} relPath
65
+ * @param {{mcpClient?: ReturnType<typeof createMcpClient>, resolveSession?: typeof resolveDeveloperSession}} [deps]
66
+ * @returns {Promise<{status:number, json:object}>}
67
+ */
68
+ export async function publishFile(env, sourceRoot, tenantId, relPath, deps = {}) {
69
+ if (!matchesWriteAllowlist(relPath)) {
70
+ return {
71
+ status: 403,
72
+ json: { ok: false, status: "not_writable", message: `not in the write allowlist: ${relPath}` },
73
+ };
74
+ }
75
+
76
+ const file = readFile(sourceRoot, relPath);
77
+ if (!file.ok) {
78
+ return { status: file.status === "not_found" ? 404 : 400, json: file };
79
+ }
80
+ if (file.binary || file.content == null) {
81
+ return {
82
+ status: 400,
83
+ json: { ok: false, status: "not_publishable", message: `${relPath} has no text content to publish` },
84
+ };
85
+ }
86
+
87
+ const client = deps.mcpClient || createMcpClient(env.MCP_BASE_URL || DEFAULT_MCP_URL);
88
+
89
+ let session;
90
+ try {
91
+ // Developer handshake order (auth.mjs establishSession): attach the Bearer
92
+ // via setToken() BEFORE initialize() — the server binds the session to
93
+ // this identity at initialize time; init-then-attach leaves it anonymous.
94
+ session = await (deps.resolveSession || resolveDeveloperSession)(client, env);
95
+ await client.initialize();
96
+ } catch (e) {
97
+ if (e instanceof AuthUnavailableError) {
98
+ return {
99
+ status: 503,
100
+ json: { ok: false, status: "not_signed_in", message: `${e.message}${e.hint ? ` — ${e.hint}` : ""}` },
101
+ };
102
+ }
103
+ return { status: 502, json: { ok: false, status: "error", message: String(e?.message || e) } };
104
+ }
105
+
106
+ const repo = `${tenantId}-main`;
107
+ const changeId = deriveChangeId(tenantId, session.email || session.token);
108
+
109
+ try {
110
+ const result = await client.callTool("candidate_open", {
111
+ repo,
112
+ changeId,
113
+ // Bounded to candidate_open's own schema limits (title<=200, body<=4000),
114
+ // same guard the hosted publish route applies.
115
+ title: `Cockpit draft: ${relPath}`.slice(0, 200),
116
+ body: `Submitted from the local \`tot dev\` cockpit for \`${relPath}\`.`.slice(0, 4000),
117
+ patch: [{ path: relPath, content: file.content }],
118
+ });
119
+ return {
120
+ status: 200,
121
+ json: {
122
+ ok: true,
123
+ status: "submitted",
124
+ path: relPath,
125
+ changeId: result?.changeId || changeId,
126
+ prNumber: result?.prNumber ?? null,
127
+ branch: result?.branch ?? null,
128
+ url: result?.url ?? null,
129
+ mergeable: result?.mergeable ?? null,
130
+ state: result?.state ?? null,
131
+ },
132
+ };
133
+ } catch (e) {
134
+ return { status: 502, json: { ok: false, status: "error", message: String(e?.message || e) } };
135
+ }
136
+ }
137
+
138
+ // --- request-level handler (env + body → {status, json}) -------------------
139
+ // The deferred dev-plugins/dev-publish.mjs wrapper maps the HTTP req/res onto
140
+ // this; keeping it here makes the endpoint contract unit-testable without a
141
+ // running dev server (same pattern as file-writer.mjs's handleWrite).
142
+
143
+ /**
144
+ * POST /__tot/publish — body {path}.
145
+ * @param {NodeJS.ProcessEnv} [env]
146
+ * @param {{path?:string}} [body]
147
+ */
148
+ export async function handlePublish(env = process.env, body = {}) {
149
+ const root = resolveTenantSourceRoot(env);
150
+ if (!root.ok) return { status: root.status === "no_tenant" ? 403 : 500, json: root };
151
+
152
+ const relPath = body && body.path;
153
+ if (!relPath) {
154
+ return { status: 400, json: { ok: false, status: "no_path", message: "path is required" } };
155
+ }
156
+
157
+ return publishFile(env, root.sourceRoot, root.tenantId, relPath);
158
+ }
@@ -29,6 +29,7 @@ export function isTransientWatchFile(name) {
29
29
  /^#.*#$/.test(name) || // emacs autosave (#file#)
30
30
  name === ".DS_Store" ||
31
31
  name === "Thumbs.db" ||
32
- name === "4913" // vim's write-probe file
32
+ name === "4913" || // vim's write-probe file
33
+ name.startsWith(".") // any dotfile — not store content, catches the rest (vim swaps included)
33
34
  );
34
35
  }