@tokenoftrust/storefront-runner 1.4.1 → 1.4.2-rc.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 (166) hide show
  1. package/apps/storefront/astro.config.mjs +17 -1
  2. package/apps/storefront/integrations/materialize-guard.mjs +56 -0
  3. package/apps/storefront/migrations-apps/0002_good_switch.sql +26 -0
  4. package/apps/storefront/migrations-apps/0003_mute_marauders.sql +12 -0
  5. package/apps/storefront/migrations-apps/meta/0002_snapshot.json +1160 -0
  6. package/apps/storefront/migrations-apps/meta/0003_snapshot.json +1235 -0
  7. package/apps/storefront/migrations-apps/meta/_journal.json +14 -0
  8. package/apps/storefront/public/js/dashboard-team.js +38 -2
  9. package/apps/storefront/src/components/Seo.astro +65 -1
  10. package/apps/storefront/src/components/admin/AdminBlogDetailRail.astro +878 -0
  11. package/apps/storefront/src/components/admin/AdminBlogTab.astro +1432 -0
  12. package/apps/storefront/src/components/admin/AdminCustomersTab.astro +3 -7
  13. package/apps/storefront/src/components/admin/AdminPublishTab.astro +1105 -2334
  14. package/apps/storefront/src/components/admin/AdminSubscriptionsTab.astro +1 -3
  15. package/apps/storefront/src/components/blog/BlogPaywall.astro +87 -0
  16. package/apps/storefront/src/components/blog/PostAccessBadge.astro +43 -0
  17. package/apps/storefront/src/components/blog/PostGrid.astro +57 -0
  18. package/apps/storefront/src/components/blog/ReactionBar.astro +214 -0
  19. package/apps/storefront/src/layouts/Layout.astro +48 -3
  20. package/apps/storefront/src/lib/activity/alerts.ts +17 -19
  21. package/apps/storefront/src/lib/activity/changeActorAttribution.ts +3 -3
  22. package/apps/storefront/src/lib/activity/deployVersion.ts +19 -26
  23. package/apps/storefront/src/lib/activity/ingest.ts +8 -8
  24. package/apps/storefront/src/lib/activity/ingestAuth.ts +1 -1
  25. package/apps/storefront/src/lib/activity/killSwitch.ts +6 -6
  26. package/apps/storefront/src/lib/activity/query.ts +9 -9
  27. package/apps/storefront/src/lib/activity/recordActivity.ts +11 -15
  28. package/apps/storefront/src/lib/activity/store.ts +4 -6
  29. package/apps/storefront/src/lib/activity/uiActor.ts +12 -12
  30. package/apps/storefront/src/lib/activity/workerCommit.ts +6 -6
  31. package/apps/storefront/src/lib/admin/adminShell.ts +290 -0
  32. package/apps/storefront/src/lib/admin/ai/approvalMode.ts +2 -3
  33. package/apps/storefront/src/lib/admin/ai/audit.ts +3 -5
  34. package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +7 -7
  35. package/apps/storefront/src/lib/admin/checkoutStyleTarget.ts +3 -3
  36. package/apps/storefront/src/lib/admin/orderClient.ts +3 -5
  37. package/apps/storefront/src/lib/admin/services/orders.ts +17 -19
  38. package/apps/storefront/src/lib/admin/viewmodels/aiWorkflows.ts +4 -6
  39. package/apps/storefront/src/lib/admin/viewmodels/settings.ts +1 -1
  40. package/apps/storefront/src/lib/apps/adminService.ts +4 -4
  41. package/apps/storefront/src/lib/apps/adminSession.ts +53 -44
  42. package/apps/storefront/src/lib/apps/apiAuth.ts +2 -2
  43. package/apps/storefront/src/lib/apps/apiRoute.ts +2 -2
  44. package/apps/storefront/src/lib/apps/catalogMapper.ts +1 -1
  45. package/apps/storefront/src/lib/apps/credentials.ts +2 -2
  46. package/apps/storefront/src/lib/apps/gatewayKeys.ts +4 -4
  47. package/apps/storefront/src/lib/apps/healthAggregate.ts +5 -5
  48. package/apps/storefront/src/lib/apps/orders/attributionService.ts +2 -2
  49. package/apps/storefront/src/lib/apps/orders/customerHash.ts +1 -1
  50. package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +12 -12
  51. package/apps/storefront/src/lib/apps/orders/idempotency.ts +7 -7
  52. package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +1 -1
  53. package/apps/storefront/src/lib/apps/orders/orderMapper.ts +2 -2
  54. package/apps/storefront/src/lib/apps/orders/ordersStore.ts +6 -6
  55. package/apps/storefront/src/lib/apps/registryService.ts +6 -6
  56. package/apps/storefront/src/lib/apps/scopes.ts +2 -2
  57. package/apps/storefront/src/lib/apps/tokenIssuer.ts +4 -4
  58. package/apps/storefront/src/lib/apps/tokenVerifier.ts +6 -6
  59. package/apps/storefront/src/lib/apps/widgets/eligibility.ts +2 -2
  60. package/apps/storefront/src/lib/apps/widgets/frameProps.ts +3 -3
  61. package/apps/storefront/src/lib/apps/widgets/launchToken.ts +4 -4
  62. package/apps/storefront/src/lib/apps/widgets/placements.ts +2 -2
  63. package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +1 -1
  64. package/apps/storefront/src/lib/auth/adminEntry.ts +9 -9
  65. package/apps/storefront/src/lib/auth/identityToken.ts +6 -14
  66. package/apps/storefront/src/lib/auth/loginGate.ts +75 -13
  67. package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +2 -4
  68. package/apps/storefront/src/lib/auth/operatorSession.ts +113 -0
  69. package/apps/storefront/src/lib/auth/session.ts +7 -8
  70. package/apps/storefront/src/lib/auth/totAccessClient.ts +8 -18
  71. package/apps/storefront/src/lib/basePath.ts +9 -2
  72. package/apps/storefront/src/lib/blog/access.ts +29 -0
  73. package/apps/storefront/src/lib/blog/collection.ts +313 -0
  74. package/apps/storefront/src/lib/blog/markdown.ts +174 -0
  75. package/apps/storefront/src/lib/blog/provider.ts +5 -0
  76. package/apps/storefront/src/lib/blog/reactions.ts +87 -0
  77. package/apps/storefront/src/lib/blog/rss.ts +92 -0
  78. package/apps/storefront/src/lib/blog/teaser.ts +83 -0
  79. package/apps/storefront/src/lib/blog/types.ts +34 -0
  80. package/apps/storefront/src/lib/blog/viewCounts.ts +243 -0
  81. package/apps/storefront/src/lib/d1/catalog.ts +2 -2
  82. package/apps/storefront/src/lib/d1/schema-apps.ts +96 -17
  83. package/apps/storefront/src/lib/dev/activityIngestToken.ts +2 -4
  84. package/apps/storefront/src/lib/dev/activityStore.ts +3 -5
  85. package/apps/storefront/src/lib/dev/apiBase.ts +1 -4
  86. package/apps/storefront/src/lib/dev/cockpitStore.ts +1 -9
  87. package/apps/storefront/src/lib/dev/devDraft.ts +1 -1
  88. package/apps/storefront/src/lib/dev/previewStatus.ts +9 -21
  89. package/apps/storefront/src/lib/dev/rendezvousBroker.ts +9 -24
  90. package/apps/storefront/src/lib/edgeCache.ts +17 -0
  91. package/apps/storefront/src/lib/http/fetchTransport.ts +63 -0
  92. package/apps/storefront/src/lib/i18n.ts +25 -0
  93. package/apps/storefront/src/lib/jsonld.ts +118 -0
  94. package/apps/storefront/src/lib/monitoring/manifest.ts +11 -11
  95. package/apps/storefront/src/lib/newsletter/confirmationEmail.ts +30 -0
  96. package/apps/storefront/src/lib/newsletter/consent.ts +23 -0
  97. package/apps/storefront/src/lib/newsletter/digest.ts +117 -0
  98. package/apps/storefront/src/lib/newsletter/resendClient.ts +79 -0
  99. package/apps/storefront/src/lib/newsletter/send.ts +265 -0
  100. package/apps/storefront/src/lib/newsletter/subscribers.ts +156 -0
  101. package/apps/storefront/src/lib/publish/adminPublishTab.ts +3519 -0
  102. package/apps/storefront/src/lib/publish/apex-readiness.ts +41 -18
  103. package/apps/storefront/src/lib/publish/dispatchHealth.ts +5 -7
  104. package/apps/storefront/src/lib/publish/domainState.ts +22 -13
  105. package/apps/storefront/src/lib/publish/panelPolling.ts +78 -0
  106. package/apps/storefront/src/lib/publish/shipWorkspace.ts +100 -49
  107. package/apps/storefront/src/lib/publish/stageBundleUpload.ts +215 -0
  108. package/apps/storefront/src/lib/rate/kvWindow.ts +53 -0
  109. package/apps/storefront/src/lib/search/index.ts +2 -2
  110. package/apps/storefront/src/lib/seo/alternates.ts +42 -0
  111. package/apps/storefront/src/lib/seo/meta.ts +65 -0
  112. package/apps/storefront/src/lib/social/golive.ts +212 -0
  113. package/apps/storefront/src/lib/social/notify.ts +32 -0
  114. package/apps/storefront/src/lib/social/tokenStore.ts +171 -0
  115. package/apps/storefront/src/lib/the-build/provider.ts +27 -0
  116. package/apps/storefront/src/lib/the-build/readingTime.ts +35 -0
  117. package/apps/storefront/src/lib/the-build/toc.ts +95 -0
  118. package/apps/storefront/src/lib/tot/ToTClient.ts +1 -1
  119. package/apps/storefront/src/lib/tot/d1Client.ts +2 -2
  120. package/apps/storefront/src/lib/tot/totClientInterface.ts +3 -4
  121. package/apps/storefront/src/lib/webhooks/deliveryStore.ts +16 -17
  122. package/apps/storefront/src/middleware/index.ts +113 -2
  123. package/apps/storefront/src/pages/admin.astro +10 -1312
  124. package/apps/storefront/src/pages/api/blog/[collection]/[slug]/react.ts +104 -0
  125. package/apps/storefront/src/pages/api/newsletter/[collection]/subscribe.ts +146 -0
  126. package/apps/storefront/src/pages/api/newsletter/confirm.ts +75 -0
  127. package/apps/storefront/src/pages/api/newsletter/unsubscribe.ts +90 -0
  128. package/apps/storefront/src/pages/api/publish/stage-bundle.ts +70 -0
  129. package/apps/storefront/src/pages/api/rum/vitals.ts +21 -2
  130. package/apps/storefront/src/pages/blog/[slug].astro +96 -16
  131. package/apps/storefront/src/pages/blog/author/[author].astro +140 -0
  132. package/apps/storefront/src/pages/blog/category/[category].astro +67 -0
  133. package/apps/storefront/src/pages/blog/index.astro +2 -0
  134. package/apps/storefront/src/pages/blog/tag/[tag].astro +65 -0
  135. package/apps/storefront/src/pages/cockpit.astro +37 -12
  136. package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +15 -11
  137. package/apps/storefront/src/pages/search.astro +1 -1
  138. package/apps/storefront/src/pages/sitemap.xml.ts +20 -0
  139. package/apps/storefront/src/pages/the-build/[slug].astro +129 -0
  140. package/apps/storefront/src/pages/the-build/index.astro +146 -0
  141. package/apps/storefront/src/pages/the-build/rss.xml.ts +33 -0
  142. package/apps/storefront/src/styles/admin.css +1080 -0
  143. package/apps/storefront/tsconfig.json +5 -0
  144. package/package.json +1 -1
  145. package/packages/public-runtime/package.json +1 -0
  146. package/packages/public-runtime/src/activity/catalog.ts +52 -0
  147. package/packages/public-runtime/src/catalog-d1.ts +1 -1
  148. package/packages/public-runtime/src/customization-preview.ts +25 -2
  149. package/packages/public-runtime/src/customization-runtime.ts +125 -3
  150. package/packages/public-runtime/src/customization-versioning.ts +10 -0
  151. package/packages/public-runtime/src/index.ts +1 -0
  152. package/packages/public-runtime/src/static-bundle.ts +357 -0
  153. package/packages/public-runtime/src/tenant.ts +36 -8
  154. package/scripts/build/copy-tenant-assets.mjs +14 -4
  155. package/scripts/dev/ai-edit.mjs +1 -1
  156. package/scripts/dev/checkout-watch.mjs +2 -2
  157. package/scripts/dev/file-browser.mjs +2 -4
  158. package/scripts/dev/file-writer.mjs +1 -1
  159. package/scripts/dev/git-status.mjs +2 -2
  160. package/scripts/dev/locate-handler.mjs +3 -3
  161. package/scripts/dev/port-check.mjs +3 -3
  162. package/scripts/dev/publish.mjs +5 -9
  163. package/scripts/dev/shot.mjs +1 -1
  164. package/scripts/dev/unified-diff.mjs +8 -3
  165. package/scripts/tot-dev.mjs +6 -9
  166. package/apps/storefront/src/lib/dev/vcBinding.ts +0 -80
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Digest-addressed static-bundle serving (T2, ADR 0012 —
3
+ * `docs/architecture/decisions/0012-instant-static-rollback-pointer-substrate.md`),
4
+ * T6-aware (per-file content-addressed asset dedup,
5
+ * `docs/epics/proposed/storefront-instant-static-rollback.md` Addendum §A).
6
+ *
7
+ * Reads an already-materialized, already-staged static bundle
8
+ * (`scripts/publish/materialize-tenant.mjs` output, staged by
9
+ * `scripts/publish/stage-bundle.mjs` to `versions/<treeDigest>/…` PLUS the
10
+ * shared `assets/<sha256>` pool, T6) straight out of R2 and serves the bytes
11
+ * VERBATIM — no re-render, no per-request CSP-nonce middleware (the bundle
12
+ * carries one pinned nonce for its whole tree; see the ADR's "Consequences"
13
+ * section). This is the read half: the write path (what PUTs a bundle into this
14
+ * R2 bucket) is `scripts/publish/lib/r2-runner.mjs`, which stages every
15
+ * `versions/<digest>/` bundle at candidate-prebuild time. Every static-publish
16
+ * tenant's production reads are served through here.
17
+ *
18
+ * DIGEST CONTRACT (RECONCILED, T14 — ADR 0012): the caller resolves "what to
19
+ * serve" via `resolveChannelDigest(get, tenantId, "live")` — the `channelDigests`
20
+ * companion to the `live` channel pointer, NOT `resolveChannelVersion` (which
21
+ * returns the versionId / Gitea sha, a DIFFERENT namespace than the treeDigest).
22
+ * `gatedPromote` (go-live) and `rollbackCandidateAtomic` (rollback) write the
23
+ * staged bundle's real `treeDigest` to `channelDigests.live` in the SAME CAS flip
24
+ * that moves the versionId, so this module gets the exact R2 key prefix
25
+ * `versions/<treeDigest>/`. A static tenant whose `live` has a versionId but no
26
+ * digest companion yet resolves to `null` here and fails closed (see below)
27
+ * rather than serving the wrong bytes under the versionId.
28
+ *
29
+ * T6 KEY RESOLUTION (`kind: "route"` vs `kind: "asset"`, `scripts/publish/lib/
30
+ * asset-pool.mjs`'s classification, carried verbatim in `manifest.files`): the
31
+ * manifest is now read on EVERY request (not just HTML, as before T6) because
32
+ * it's the ONLY source of truth for which R2 key a bundle-relative path
33
+ * resolves to — `kind: "route"` files still live at `versions/<digest>/<rel>`
34
+ * (rendered fresh per version), `kind: "asset"` files live at the SHARED,
35
+ * content-addressed `assets/<sha256><ext>` pool (`staticBundleAssetPoolKey`,
36
+ * kept in lockstep with `scripts/publish/lib/asset-pool.mjs`'s `assetPoolKey`
37
+ * — that module can't be imported here, this is the Worker bundle, that one
38
+ * runs under plain node). A `rel` absent from `manifest.files` (a genuinely
39
+ * unexpected path, or a pre-T6 manifest with no `files` classification) falls
40
+ * back to the OLD `versions/<digest>/<rel>` lookup — fail-closed on a miss
41
+ * either way, never a guess.
42
+ */
43
+ import { assetContentType } from "./tenant-assets.js";
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // R2 seam — same structural subset tenant-assets.ts uses (no workers-types dep)
47
+ // ---------------------------------------------------------------------------
48
+
49
+ export interface StaticBundleR2ObjectLike {
50
+ body: ReadableStream<Uint8Array> | null;
51
+ arrayBuffer(): Promise<ArrayBuffer>;
52
+ size?: number;
53
+ }
54
+
55
+ export interface StaticBundleR2BucketLike {
56
+ get(key: string): Promise<StaticBundleR2ObjectLike | null>;
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Path mapping — request pathname -> bundle-relative object path
61
+ // ---------------------------------------------------------------------------
62
+
63
+ /**
64
+ * Reject empty, absolute-outside-bundle, or traversal-bearing relative paths
65
+ * (mirrors `tenant-assets.ts`'s `isSafeRel`, duplicated here to keep this
66
+ * module import-independent of the assets pointer model).
67
+ */
68
+ function isSafeRel(rel: string): boolean {
69
+ if (!rel) return false;
70
+ return !rel.split("/").some((seg) => seg === ".." || seg === ".");
71
+ }
72
+
73
+ /**
74
+ * Map a request pathname to the bundle-relative file the materializer wrote
75
+ * for it — the SAME transform `scripts/publish/lib/materialize.mjs`'s
76
+ * `bundlePathForRoute` (routes) and `assetFilePath` (assets) apply, ported
77
+ * here as one pure function since `scripts/` is not importable from the
78
+ * Worker bundle:
79
+ * "/" -> "index.html"
80
+ * "/about" -> "about/index.html" (directory-index for pretty URLs)
81
+ * "/a/b/" -> "a/b/index.html"
82
+ * "/sitemap.xml" -> "sitemap.xml" (a real file extension: as-is)
83
+ * "/_astro/x.js" -> "_astro/x.js" (asset: as-is)
84
+ * Returns null for an unsafe path (traversal, decode failure).
85
+ */
86
+ export function staticBundleRelPath(pathname: string): string | null {
87
+ let decoded: string;
88
+ try {
89
+ decoded = decodeURIComponent(pathname);
90
+ } catch {
91
+ return null;
92
+ }
93
+ const clean = decoded.replace(/^\/+/, "").replace(/\/+$/, "");
94
+ if (clean === "") return "index.html";
95
+ if (!isSafeRel(clean)) return null;
96
+ const last = clean.split("/").pop() || "";
97
+ if (/\.[a-z0-9]+$/i.test(last)) return clean;
98
+ return `${clean}/index.html`;
99
+ }
100
+
101
+ /** Full R2 object key for a `route`-kind bundle-relative path at a given digest. */
102
+ export function staticBundleObjectKey(digest: string, rel: string): string {
103
+ return `versions/${digest}/${rel}`;
104
+ }
105
+
106
+ /** R2 key for a digest's bundle manifest (materialize-tenant.mjs's `manifest.json`). */
107
+ export function staticBundleManifestKey(digest: string): string {
108
+ return `versions/${digest}/manifest.json`;
109
+ }
110
+
111
+ /**
112
+ * T6: the shared content-addressed pool key for one asset's hash — MUST stay
113
+ * in lockstep with `scripts/publish/lib/asset-pool.mjs`'s `assetPoolKey`
114
+ * (same extension-preserving `assets/<sha256hex><ext>` shape; that module
115
+ * can't be imported here since this is the Worker bundle and that one runs
116
+ * under plain node — both sides' tests assert the exact key shape).
117
+ */
118
+ export function staticBundleAssetPoolKey(hash: string, rel: string): string {
119
+ const dot = rel.lastIndexOf(".");
120
+ const slash = rel.lastIndexOf("/");
121
+ const ext = dot > slash ? rel.slice(dot) : "";
122
+ return `assets/${hash}${ext}`;
123
+ }
124
+
125
+ /**
126
+ * Resolve the R2 key to fetch for a `rel` bundle path, using the manifest's
127
+ * T6 file classification: `kind: "asset"` -> the shared pool key (by content
128
+ * hash, NOT the version); anything else (`kind: "route"`, or `rel` absent
129
+ * from `files` entirely — a pre-T6 manifest, or a genuinely unlisted path) ->
130
+ * the per-version `versions/<digest>/<rel>` key, same as before T6.
131
+ */
132
+ export function resolveStaticBundleObjectKey(
133
+ digest: string,
134
+ rel: string,
135
+ files: StaticBundleManifest["files"] | undefined,
136
+ ): string {
137
+ const entry = files?.[rel];
138
+ if (entry?.kind === "asset" && entry.hash) {
139
+ return staticBundleAssetPoolKey(entry.hash, rel);
140
+ }
141
+ return staticBundleObjectKey(digest, rel);
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Manifest (CSP pinned-nonce header) — read once per hit for an HTML response
146
+ // ---------------------------------------------------------------------------
147
+
148
+ /** One `manifest.files` entry (T6, `scripts/publish/lib/asset-pool.mjs`'s `classifyBundleFiles` shape). */
149
+ export interface StaticBundleFileEntry {
150
+ hash: string;
151
+ kind: "route" | "asset";
152
+ }
153
+
154
+ /** The slice of `materialize-tenant.mjs`'s manifest this read path needs. */
155
+ export interface StaticBundleManifest {
156
+ treeDigest: string;
157
+ pinnedNonce: string;
158
+ csp: { headerName: string; value: string } | null;
159
+ /** T6 classification: relPath -> {hash, kind}. Absent on a pre-T6 manifest — every lookup then falls back to the old versions/<digest>/<rel> key (see `resolveStaticBundleObjectKey`). */
160
+ files?: Record<string, StaticBundleFileEntry>;
161
+ }
162
+
163
+ function parseManifest(raw: string): StaticBundleManifest | null {
164
+ try {
165
+ const m = JSON.parse(raw) as Partial<StaticBundleManifest>;
166
+ if (typeof m.treeDigest !== "string" || typeof m.pinnedNonce !== "string") return null;
167
+ const csp =
168
+ m.csp &&
169
+ typeof (m.csp as { headerName?: unknown }).headerName === "string" &&
170
+ typeof (m.csp as { value?: unknown }).value === "string"
171
+ ? (m.csp as { headerName: string; value: string })
172
+ : null;
173
+ let files: StaticBundleManifest["files"];
174
+ if (m.files && typeof m.files === "object") {
175
+ files = {};
176
+ for (const [rel, entry] of Object.entries(m.files as Record<string, unknown>)) {
177
+ const e = entry as Partial<StaticBundleFileEntry> | null;
178
+ if (
179
+ e &&
180
+ typeof e.hash === "string" &&
181
+ (e.kind === "route" || e.kind === "asset")
182
+ ) {
183
+ files[rel] = { hash: e.hash, kind: e.kind };
184
+ }
185
+ }
186
+ }
187
+ return { treeDigest: m.treeDigest, pinnedNonce: m.pinnedNonce, csp, files };
188
+ } catch {
189
+ return null;
190
+ }
191
+ }
192
+
193
+ /** Read+parse a digest's `manifest.json` from R2. Null on any miss/malformed data. */
194
+ export async function readStaticBundleManifest(
195
+ bucket: StaticBundleR2BucketLike,
196
+ digest: string,
197
+ ): Promise<StaticBundleManifest | null> {
198
+ const object = await bucket.get(staticBundleManifestKey(digest));
199
+ if (!object) return null;
200
+ const text = new TextDecoder().decode(await object.arrayBuffer());
201
+ return parseManifest(text);
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Serve — resolve digest, stream the object from R2, fail closed on a miss
206
+ // ---------------------------------------------------------------------------
207
+
208
+ export interface ServeStaticBundleDeps {
209
+ bucket: StaticBundleR2BucketLike;
210
+ /**
211
+ * The digest to serve, already resolved by the caller (typically
212
+ * `resolveChannelVersion(get, tenantId, "live")` — see the module
213
+ * docstring's DIGEST CONTRACT). `null` means "pointer-serving is on for
214
+ * this tenant but nothing is live yet" — fails closed, same as a missing
215
+ * object, per the ADR: this path is only ever reached for a confirmed
216
+ * pointer-serving tenant, so an unresolved digest is itself a fault, not a
217
+ * "fall through to SSR" signal.
218
+ */
219
+ digest: string | null;
220
+ /** Best-effort diagnostic sink (never throws, never blocks the response). */
221
+ onFailClosed?: (detail: string) => void;
222
+ }
223
+
224
+ /** Structured detail for the one fail-closed shape this module can produce. */
225
+ export class StaticBundleUnavailableError extends Error {
226
+ readonly code = "static-bundle-unavailable" as const;
227
+ constructor(detail: string) {
228
+ super(detail);
229
+ this.name = "StaticBundleUnavailableError";
230
+ }
231
+ }
232
+
233
+ function failClosed(deps: ServeStaticBundleDeps, detail: string): Response {
234
+ try {
235
+ deps.onFailClosed?.(detail);
236
+ } catch {
237
+ /* diagnostic sink must never break the response path */
238
+ }
239
+ // 503, not a 200 masquerading as content and not a 404 (which would read as
240
+ // "this route doesn't exist" — it does, the bundle is just unreachable) and
241
+ // never an SSR fallthrough: this route is ONLY reached for a tenant
242
+ // confirmed to be on the pointer-serving model, so an unreadable bundle is
243
+ // an infra fault to surface (verify/monitoring), not a content gap.
244
+ return new Response("Static bundle unavailable", {
245
+ status: 503,
246
+ headers: {
247
+ "cache-control": "no-store",
248
+ "x-tot-static-bundle": "fail-closed",
249
+ },
250
+ });
251
+ }
252
+
253
+ /** Cache-Control for a `route`-kind file (HTML/xml/txt — rendered fresh per version, stable non-digest url). */
254
+ const ROUTE_CACHE_CONTROL = "public, max-age=0, s-maxage=300, stale-while-revalidate=86400";
255
+ /** Cache-Control for an `asset`-kind file — the pool key IS the content hash, so caching forever is always correct (epic §A). */
256
+ const ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable";
257
+
258
+ /**
259
+ * Serve `pathname` from the digest-addressed static bundle in R2.
260
+ *
261
+ * Returns `null` — the ONE "fall through to SSR" signal — when the manifest's
262
+ * `files` map proves `pathname` is not part of this bundle's static surface:
263
+ * app surfaces (/admin, /cockpit, checkout, …) share the tenant's host but are
264
+ * never materialized into a bundle, so the caller must render them. Only a
265
+ * manifest WITH `files` can prove non-membership; a pre-T6 manifest keeps the
266
+ * fetch-then-fail-closed path.
267
+ *
268
+ * Every OTHER outcome (hit, unsafe path, unresolved digest, missing manifest,
269
+ * missing object for a LISTED file) terminates the request here rather than
270
+ * falling through — a staged digest's listed route must never silently degrade
271
+ * into SSR (wrong/divergent bytes), per the ADR's fail-closed guidance.
272
+ *
273
+ * T6: the manifest is now read FIRST, on every request (not just HTML, as
274
+ * before T6) — it's the only source of truth for whether `rel` resolves to
275
+ * the per-version `versions/<digest>/<rel>` key or the shared content-addressed
276
+ * `assets/<sha256>` pool key (`resolveStaticBundleObjectKey`). A missing
277
+ * manifest fails closed the same way a missing object always has: the
278
+ * manifest is deliberately the LAST thing `stage-bundle.mjs` writes for a
279
+ * digest (see `s3-publish.mjs`'s `buildManifestPromoteStep`), so its absence
280
+ * means this digest was never fully staged — serving from it would be wrong
281
+ * before T6 too, this just makes the fail-closed check unconditional.
282
+ *
283
+ * On a hit, serves the R2 bytes VERBATIM with a Cache-Control keyed by the
284
+ * manifest's file `kind` (long-lived immutable for the shared `asset` pool —
285
+ * safe because the key itself is the content hash; short/revalidating for a
286
+ * `route` doc, rendered fresh per version at a STABLE, non-digest url) and,
287
+ * for an HTML response, the bundle's PINNED CSP header read back from the
288
+ * same manifest read — never the Worker's per-request nonce middleware, per
289
+ * the ADR's CSP-consequences note.
290
+ */
291
+ export async function serveStaticBundle(
292
+ deps: ServeStaticBundleDeps,
293
+ pathname: string,
294
+ ): Promise<Response | null> {
295
+ const { bucket, digest } = deps;
296
+ if (!digest) {
297
+ return failClosed(deps, "pointer-serving tenant has no resolved live digest");
298
+ }
299
+
300
+ const rel = staticBundleRelPath(pathname);
301
+ if (!rel) {
302
+ return failClosed(deps, `unsafe/unparseable path: ${JSON.stringify(pathname)}`);
303
+ }
304
+
305
+ const manifest = await readStaticBundleManifest(bucket, digest);
306
+ if (!manifest) {
307
+ return failClosed(deps, `missing/unreadable manifest for digest ${digest}`);
308
+ }
309
+
310
+ const entry = manifest.files?.[rel];
311
+ if (manifest.files && !entry) {
312
+ // Provably not in this bundle's static surface — an app surface (/admin,
313
+ // /cockpit, …) or a route this version never materialized. SSR owns it.
314
+ return null;
315
+ }
316
+ const objectKey = resolveStaticBundleObjectKey(digest, rel, manifest.files);
317
+ const object = await bucket.get(objectKey);
318
+ if (!object) {
319
+ return failClosed(deps, `missing R2 object ${objectKey}`);
320
+ }
321
+
322
+ const isHtml = rel.endsWith(".html");
323
+ // T6 cache tier: use the manifest's `kind` classification when present.
324
+ // A pre-T6 manifest (no `files` at all) falls back to the ORIGINAL
325
+ // isHtml-based heuristic — every non-html path used to be treated as a
326
+ // long-cacheable asset, which this preserves exactly for an old bundle
327
+ // this Worker version might still encounter mid-rollout.
328
+ const cacheControl = entry
329
+ ? entry.kind === "asset"
330
+ ? ASSET_CACHE_CONTROL
331
+ : ROUTE_CACHE_CONTROL
332
+ : isHtml
333
+ ? ROUTE_CACHE_CONTROL
334
+ : ASSET_CACHE_CONTROL;
335
+ const headers: Record<string, string> = {
336
+ "content-type": assetContentType(rel),
337
+ // Cache-key-by-digest lives in the caller's edge-cache layer for `route`
338
+ // keys (versions/<digest>/<rel> is digest-scoped — a pointer flip is a
339
+ // clean cache miss on the NEW digest's keys, never a stale serve of the
340
+ // old one); `asset` keys are ADDITIONALLY digest-independent (content-
341
+ // addressed by hash), so they're safe to cache forever downstream too.
342
+ // This header governs downstream/browser caching of the STABLE URL:
343
+ "cache-control": cacheControl,
344
+ "x-tot-static-bundle": "hit",
345
+ "x-tot-static-bundle-digest": digest,
346
+ };
347
+
348
+ if (isHtml && manifest.csp) {
349
+ headers[manifest.csp.headerName] = manifest.csp.value;
350
+ }
351
+
352
+ if (typeof object.size === "number") {
353
+ headers["content-length"] = String(object.size);
354
+ }
355
+ const body: BodyInit = object.body ?? (await object.arrayBuffer());
356
+ return new Response(body, { status: 200, headers });
357
+ }
@@ -392,6 +392,34 @@ export interface TenantConfig {
392
392
  features?: Partial<TenantFeatures>;
393
393
  /** Regulated-commerce affordances; undefined for unregulated tenants. */
394
394
  compliance?: ComplianceConfig;
395
+ /**
396
+ * Per-tenant newsletter compliance/sender config. Sourced when a digest is
397
+ * built and sent (the send pipeline is a later unit). Distinct from the
398
+ * {@link TenantFeatures} `newsletter` flag, which only gates the CAPTURE form:
399
+ * this object supplies the CAN-SPAM physical postal address rendered in every
400
+ * email footer, plus the sender identity. Absent for tenants that don't send a
401
+ * newsletter; a sender falls back to a platform default when a field is unset.
402
+ */
403
+ newsletter?: {
404
+ /**
405
+ * Physical postal mailing address rendered in every newsletter email footer
406
+ * (CAN-SPAM §5(a)(5) requirement). Plain text; multi-line allowed.
407
+ */
408
+ postalAddress: string;
409
+ /** Display name on the `From:` header; falls back to `displayName`. */
410
+ fromName?: string;
411
+ /** `Reply-To:` address for the newsletter; falls back to a platform default. */
412
+ replyTo?: string;
413
+ /**
414
+ * Verified sending address on the `From:` header, e.g.
415
+ * `the-build@mail.tokenoftrust.store`. MUST be an address on a domain that is
416
+ * SPF/DKIM/DMARC-verified with the ESP (Resend) — NOT the tenant's own store
417
+ * domain, which is not a verified sending domain and would fail alignment.
418
+ * Unset → the send path derives `<collection>@mail.tokenoftrust.store` (the
419
+ * shared verified domain). Override only to use a per-tenant verified sender.
420
+ */
421
+ fromAddress?: string;
422
+ };
395
423
  /**
396
424
  * Cart/checkout engine wiring (Track A). Present on commerce tenants
397
425
  * that have a checkout store; drives the loader + signed add-to-cart. The
@@ -426,14 +454,14 @@ export interface TenantConfig {
426
454
  */
427
455
  capabilities?: Partial<CapabilityConfig>;
428
456
  /**
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.
457
+ * Static-publish target. This tenant's PUBLIC site is served by the Worker+R2
458
+ * pointer/digest plane (ADR 0012): go-live and rollback are instant, in-request
459
+ * KV `live` pointer flips over an already-staged, content-addressed bundle in
460
+ * R2. A per-tenant PROPERTY, not a global dark flag: presence opts the tenant in
461
+ * (default ON); `enabled: false` opts back out without dropping the target
462
+ * coordinates. The dispatch credentials/coordinates live in env/secrets, NEVER
463
+ * here. `target` is an opaque, TARGET-NEUTRAL label forwarded verbatim into the
464
+ * background stage/publish job so ONE reconcile publish-sink drives any target.
437
465
  */
438
466
  staticPublish?: {
439
467
  /** Target-neutral label, e.g. "www-s3". Forwarded into the dispatch payload. */
@@ -16,12 +16,20 @@
16
16
  * Idempotent: it wipes+rewrites the destination on every run. `TENANT_ASSETS_DEST`
17
17
  * overrides the destination (used by tests / spikes).
18
18
  *
19
- * VERSIONED TENANTS (decision `assets-r2-versioned`): a tenant whose assets are
19
+ * VERSIONED TENANTS: a tenant whose assets are
20
20
  * served version-scoped from R2 (src/pages/tenants/[id]/[...path].ts) must NOT be
21
21
  * baked here — otherwise the static file would shadow the R2 route. Opt a tenant
22
22
  * out by adding a `tenants/<id>/.tot/assets-versioned` marker file, or by listing
23
23
  * its id in the `TENANT_ASSETS_VERSIONED` env (comma-separated). Non-versioned
24
- * tenants keep the baked path unchanged (back-compat).
24
+ * tenants are baked here.
25
+ *
26
+ * TENANT_ASSETS_BAKE_VERSIONED=1 overrides ALL of the above and bakes every
27
+ * tenant's public/ regardless of versioned-ness. Set by the STATIC-plane bundle
28
+ * build (scripts/publish/materialize-tenant.mjs): the S3/CloudFront static site
29
+ * has no R2 asset route — a bundle is self-contained by definition — so
30
+ * skipping a versioned tenant's assets there just produces a bundle whose pages
31
+ * reference files that don't exist. The Worker-plane build never sets this, so
32
+ * R2 shadowing semantics are unchanged there.
25
33
  */
26
34
  import { cp, mkdir, readdir, rm } from "node:fs/promises";
27
35
  import { existsSync } from "node:fs";
@@ -44,9 +52,11 @@ const versionedEnv = new Set(
44
52
  .map((s) => s.trim())
45
53
  .filter(Boolean),
46
54
  );
55
+ const bakeVersioned = process.env.TENANT_ASSETS_BAKE_VERSIONED === "1";
47
56
  const isVersioned = (id) =>
48
- versionedEnv.has(id) ||
49
- existsSync(join(src, id, ".tot", "assets-versioned"));
57
+ !bakeVersioned &&
58
+ (versionedEnv.has(id) ||
59
+ existsSync(join(src, id, ".tot", "assets-versioned")));
50
60
 
51
61
  let copied = 0;
52
62
  let skipped = 0;
@@ -15,7 +15,7 @@
15
15
  * `tot` CLI, so `~/.tot/credentials.json` is directly readable. No token
16
16
  * plumbing needed between the CLI and the dev server.
17
17
  *
18
- * Scope (decision `d2-resolved-inline-ai-scope`): content + theme.json ONLY.
18
+ * Scope: content + theme.json ONLY.
19
19
  * The MCP tool enforces this server-side; nothing here widens it.
20
20
  */
21
21
  import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } from "node:fs";
@@ -35,8 +35,8 @@ export function resync(sourceRoot, targetRoot, rel) {
35
35
  * RELIABILITY-FIRST: defaults to an mtime POLL, not native `fs.watch`. Native watch
36
36
  * is event-driven but LOSSY on macOS — editors that save via write-temp-then-rename
37
37
  * (VS Code, vim, JetBrains, …) routinely deliver NO change event on the original
38
- * path, so the save silently fails to hot-reload (the 2026-07-14 "didn't reload"
39
- * report). The poll stat()s the tree on a short interval and never misses an edit.
38
+ * path, so the save silently fails to hot-reload. The poll stat()s the tree on a
39
+ * short interval and never misses an edit.
40
40
  *
41
41
  * Opt into event-driven native watch with TOT_DEV_NATIVE_WATCH=1 (it still falls
42
42
  * back to polling where recursive watch is unavailable, e.g. Linux). Dependency-free
@@ -5,13 +5,11 @@
5
5
  *
6
6
  * Follows the scripts/dev delegation pattern (see ai-edit.mjs): all logic lives
7
7
  * here, dependency-free and testable under `node --test`; a thin dev-only Vite
8
- * plugin (dev-plugins/dev-file-browser.mjs — added when C0/C1 integrate) maps
8
+ * plugin (dev-plugins/dev-file-browser.mjs) maps
9
9
  * req/res onto `handleTree` / `handleFile`. Every path is routed through
10
10
  * safe-path.mjs; the source root comes from tenant-source-root.mjs (never SSR).
11
11
  *
12
- * Git-status badges (C3 acceptance) layer on when C4's git-status.mjs lands —
13
- * the tree here carries structure only, so this module owns no git surface and
14
- * doesn't collide with C4.
12
+ * The tree here carries structure only, so this module owns no git surface.
15
13
  */
16
14
  import { readFileSync, readdirSync, statSync } from "node:fs";
17
15
  import { join, extname, sep } from "node:path";
@@ -123,7 +123,7 @@ export function handleWrite(env = process.env, body = {}) {
123
123
  return { status: 400, json: { ok: false, status: "no_path", message: "path is required" } };
124
124
  }
125
125
 
126
- const result = writeSourceFile(root.sourceRoot, relPath, body.content, {
126
+ const result = writeSourceFile(root.sourceRoot, relPath, /** @type {string} */ (body.content), {
127
127
  baseVersion: body ? body.baseVersion : undefined,
128
128
  });
129
129
  const status = result.ok
@@ -118,10 +118,10 @@ export function computeDiff(gitCwd, sourceRoot, relPath) {
118
118
  } catch {
119
119
  workingExists = false;
120
120
  }
121
- if (workingExists && st.isDirectory()) {
121
+ if (st && st.isDirectory()) {
122
122
  return { ok: false, status: "is_dir", message: `${relPath} is a directory` };
123
123
  }
124
- if (workingExists) {
124
+ if (workingExists && st) {
125
125
  if (BINARY_EXT.has(extname(posix).toLowerCase())) {
126
126
  return { ok: true, path: posix, status: fileStatus(gitCwd, relPath), shown: false, reason: "binary" };
127
127
  }
@@ -3,12 +3,12 @@
3
3
  * POST /__tot/locate {domPath, path} → {ok, path, line, col} | {status}
4
4
  * POST /__tot/open-in-editor {path, line, col} → {ok} | {status}
5
5
  *
6
- * Keeps locate.mjs a PURE parse5 walk (already integrated + tested 5/5); this module
6
+ * Keeps locate.mjs a PURE parse5 walk; this module
7
7
  * adds the env/path-resolution/refusal concerns and is itself unit-testable under
8
8
  * `node --test` with no running dev server (the scripts/dev delegation pattern — see
9
9
  * file-browser.mjs). The thin dev-plugins/dev-locate.mjs wrapper maps req/res onto it.
10
10
  *
11
- * HONORS decision `click-to-source-anchors-at-content-region`: the served page wraps
11
+ * The served page wraps
12
12
  * the raw content region (<main>, the whole of content/home.html) in the storefront's
13
13
  * own chrome (skip-link, header, nav, footer) that is NOT in the tenant source. So we
14
14
  * resolve the clicked URL to its raw content source and walk THAT from <body>:
@@ -90,7 +90,7 @@ export function handleLocate(env = process.env, body = {}) {
90
90
 
91
91
  const html = readFileSync(abs, "utf8");
92
92
  const found = locate(html, body.domPath);
93
- if (typeof found.line === "number") {
93
+ if ("line" in found && typeof found.line === "number") {
94
94
  return { status: 200, json: { ok: true, path: rel, line: found.line, col: found.col } };
95
95
  }
96
96
  // The walk missed: the element is outside the raw content region — i.e. the storefront
@@ -8,9 +8,9 @@ import net from "node:net";
8
8
  * predicts astro's own bind on either IP family.
9
9
  *
10
10
  * WHY THIS EXISTS: Vite's `strictPort` defaults OFF, so when the requested port is
11
- * taken astro SILENTLY moves to the next free port. On 2026-07-14 that stranded the
12
- * CLI's liveness poll on the ORIGINAL port (answered by a stale foreign server) →
13
- * a false "✨ You're live" pointing at the wrong server. Pre-flighting the port and
11
+ * taken astro SILENTLY moves to the next free port stranding the CLI's liveness
12
+ * poll on the ORIGINAL port (answered by a stale foreign server) → a false
13
+ * "✨ You're live" pointing at the wrong server. Pre-flighting the port and
14
14
  * failing clearly removes that whole class of silent failure.
15
15
  *
16
16
  * Dependency-free (node:net only) so it copies into the standalone runner and is
@@ -20,15 +20,11 @@
20
20
  * Auth: the developer's own cached OAuth session (`tot login`,
21
21
  * `~/.tot/credentials.json`), via `packages/cli/src/auth.mjs`'s
22
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.
23
+ * Bearer exactly as `tot submit` does. Every caller needs a real OAuth bearer
24
+ * just to complete `initialize`; there is no service-account / client-credentials
25
+ * grant on this MCP (confirmed against its OAuth authorization-server metadata),
26
+ * so a human `tot login` (one-time; refresh is silent afterward, ~30-day refresh
27
+ * tokens) is the only way in.
32
28
  */
33
29
  import { createHash } from "node:crypto";
34
30
  import { readFile } from "./file-browser.mjs";
@@ -20,7 +20,7 @@ const CHROME_PATHS = [
20
20
  "/usr/bin/chromium-browser",
21
21
  "/usr/bin/chromium",
22
22
  ].filter(Boolean);
23
- const executablePath = CHROME_PATHS.find((p) => existsSync(p));
23
+ const executablePath = CHROME_PATHS.find((p) => !!p && existsSync(p));
24
24
 
25
25
  const args = process.argv.slice(2);
26
26
  const url = args[0];
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Minimal unified-diff applier for the inline-AI edit loop (WS5 G1).
3
3
  *
4
- * The MCP `content_edit` tool returns a per-file unified diff (`--- a/<path>` /
5
- * `+++ b/<path>` header, `@@ -l,c +l,c @@` hunks) rather than a full-file
4
+ * The MCP `content_edit` tool returns a per-file unified diff (a `---`/`+++`
5
+ * file header, then hunk headers of the form "-l,c +l,c") rather than a full-file
6
6
  * rewrite — this is what turns that diff back into the file's new content, so
7
7
  * `tot dev` can apply it, hot-reload, and undo it cleanly. Dependency-free
8
8
  * (matches the rest of the tenant tooling — see packages/cli/src/mcp.mjs).
@@ -12,10 +12,15 @@
12
12
  * that's exactly the shape content_edit's system prompt asks the model for.
13
13
  */
14
14
 
15
- /** One `@@ -l,c +l,c @@` hunk: the old/new starting line + its body lines. */
15
+ /**
16
+ * One hunk (its "-l,c +l,c" header): the old/new starting line + its body lines.
17
+ * @typedef {{ oldStart: number, body: string[] }} Hunk
18
+ */
16
19
  function parseHunks(diff) {
17
20
  const lines = diff.split(/\r?\n/);
21
+ /** @type {Hunk[]} */
18
22
  const hunks = [];
23
+ /** @type {Hunk | null} */
19
24
  let current = null;
20
25
  const hunkHeader = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
21
26