@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
@@ -1,4 +1,9 @@
1
1
  {
2
+ // Unlike the packages/* configs (which extend the repo's ../../tsconfig.base.json),
3
+ // the storefront extends Astro's own preset: it wires up .astro type resolution
4
+ // (.astro/types.d.ts), Astro/Vite bundler module resolution, and the Preact JSX
5
+ // runtime the client islands compile against. verbatimModuleSyntax is turned off
6
+ // (the base sets it on) because the JSX/Preact + Astro compilation model needs it.
2
7
  "extends": "astro/tsconfigs/strict",
3
8
  "compilerOptions": {
4
9
  "strict": true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "1.4.1",
3
+ "version": "1.4.2-rc.0",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@tot/public-runtime",
3
3
  "version": "0.1.0",
4
+ "description": "Public, edge-shipped storefront runtime model — renderer-safe tenant/product/catalog types, the CSP builder, the customization read/apply runtime, and the activity-telemetry contract. Node-free and open-sourceable; bundles into the Cloudflare Worker and must not import @tot/private-controlplane.",
4
5
  "private": true,
5
6
  "license": "SEE LICENSE IN LICENSE",
6
7
  "type": "module",
@@ -179,6 +179,26 @@ export const ACTION_CATALOG = {
179
179
  argsAllow: ["changeId", "result", "evidenceStatus"], renderedAllow: [],
180
180
  desc: "A candidate reconcile reached a terminal state.",
181
181
  },
182
+ "candidate.closed": {
183
+ // A `pull_request` `closed` webhook drove a candidate to its TERMINAL status
184
+ // (candidate-reconcile.ts): `merged` (squash-merged) or `closed` (closed
185
+ // unmerged), so the row leaves the operator's review queue. Low volume (one
186
+ // per PR close), high value — the end-of-life marker for a candidate's timeline.
187
+ actors: ["system"], source: "server", sinks: LIFECYCLE,
188
+ argsAllow: ["changeId", "merged", "result", "status"], renderedAllow: [],
189
+ desc: "A merged/closed PR webhook transitioned a candidate to a terminal state.",
190
+ },
191
+ "prebuild.dispatched": {
192
+ // T1 background pre-build (docs/epics/proposed/storefront-instant-static-rollback.md
193
+ // Addendum §B): a successful candidate reconcile fired (or failed to fire) the
194
+ // materialize+validate+STAGE-ONLY repository_dispatch for a static-publish
195
+ // tenant (candidate-reconcile.ts). Fail-open by design — this never gates the
196
+ // candidate reaching `ready`; recorded so a stuck/never-dispatched prebuild is
197
+ // observable rather than silently absent.
198
+ actors: ["system"], source: "server", sinks: LIFECYCLE,
199
+ argsAllow: ["changeId", "headSha"], renderedAllow: [],
200
+ desc: "A candidate's background static-prebuild dispatch (stage-only) succeeded or failed.",
201
+ },
182
202
 
183
203
  // ── Deploy provenance (source: server or cli) — card D2 ────────────────────
184
204
  "deploy.version_observed": {
@@ -186,6 +206,11 @@ export const ACTION_CATALOG = {
186
206
  argsAllow: ["version", "commit", "env", "observedFrom"], renderedAllow: [],
187
207
  desc: "A deployed worker version/commit was observed (provenance).",
188
208
  },
209
+ "publish.readiness_evidence.recorded": {
210
+ actors: ["system"], source: "server", sinks: PROVENANCE,
211
+ argsAllow: ["routes", "cfFailures", "statusMismatches", "targetHealthy"], renderedAllow: [],
212
+ desc: "Go-live readiness dual-run evidence was recorded for a tenant (u9 producer).",
213
+ },
189
214
 
190
215
  // ── ShipLoop change lifecycle (source: ui/server) ──────────────────────────
191
216
  // 1:1 with the admin ship routes + CHANGE_STATES (change-record.ts). Low
@@ -208,6 +233,15 @@ export const ACTION_CATALOG = {
208
233
  argsAllow: ["changeId"], renderedAllow: ["reason"],
209
234
  desc: "A change was rejected (reason required).",
210
235
  },
236
+ "change.resolve": {
237
+ // u3 (tot-merge-conflict-resolution-ux): one-click conflict resolution via
238
+ // candidate_refresh. Same admission ladder as change.reject (resolveShipSession
239
+ // → ship-on-behalf floor), so the same actor cohort. `strategy` is a
240
+ // low-cardinality enum (ours|theirs|merge), safe to render.
241
+ actors: ["admin"], source: "ui", sinks: LIFECYCLE,
242
+ argsAllow: ["changeId"], renderedAllow: ["strategy"],
243
+ desc: "A not-mergeable change was resolved by rebuilding it from the base tip under a strategy.",
244
+ },
211
245
  "change.ship": {
212
246
  // D6: the admin/cockpit "Publish" (go-live) handler admits the store OWNER
213
247
  // (merchant), ToT staff (admin), a ship-on-behalf developer (dev), AND the
@@ -247,6 +281,24 @@ export const ACTION_CATALOG = {
247
281
  argsAllow: ["changeId"], renderedAllow: [],
248
282
  desc: "A change was integrated into the shared preview.",
249
283
  },
284
+ "change.rebuild": {
285
+ // u15: retry a dead aggregate rebuild without a new merge (/api/changes/rebuild).
286
+ // Same admission ladder as change.integrate/revert (resolveShipSession) — the
287
+ // route gates on the SAME seam, so the emitter cohort matches. No changeId: a
288
+ // rebuild retries the tenant's most recent integration attempt, not one candidate.
289
+ actors: ["admin", "merchant", "dev", "agent"], source: "ui", sinks: LIFECYCLE,
290
+ argsAllow: [], renderedAllow: [],
291
+ desc: "A dead aggregate rebuild was retried without a new merge.",
292
+ },
293
+ "change.resync": {
294
+ // u12: advance the shared preview aggregate to an out-of-band branch tip
295
+ // without a candidate/<changeId> PR (/api/changes/resync). Same admission
296
+ // ladder as change.integrate/rebuild (resolveShipSession). No changeId: a
297
+ // resync pins an operator-supplied branch/sha, not one candidate.
298
+ actors: ["admin", "merchant", "dev", "agent"], source: "ui", sinks: LIFECYCLE,
299
+ argsAllow: [], renderedAllow: [],
300
+ desc: "The shared preview aggregate was resynced to an out-of-band branch tip.",
301
+ },
250
302
  "change.transition": {
251
303
  // Generic SDLC transition for states without a dedicated verb above
252
304
  // (triage↔in_progress↔ready_for_review↔announced↔closed).
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * D1 (edge SQLite) catalog read-model — schema + row projections (the one-store
3
- * target from epics/edge-read-replica.md). The storefront reads search, faceted
3
+ * target). The storefront reads search, faceted
4
4
  * listing, and full products from D1; the ETL emits the rows. This is the shared
5
5
  * contract both sides agree on.
6
6
  *
@@ -142,6 +142,8 @@ export interface EnvResolutionInput {
142
142
  /** Platform preview-signing secret (env), if configured. */
143
143
  secret?: string | null;
144
144
  nowSeconds: number;
145
+ /** Request pathname — used only to exempt the token dispenser itself (see {@link isPreviewDispatcherPath}). */
146
+ pathname?: string;
145
147
  }
146
148
 
147
149
  export interface EnvResolution {
@@ -152,16 +154,37 @@ export interface EnvResolution {
152
154
  reject?: { status: number; message: string };
153
155
  }
154
156
 
157
+ /**
158
+ * The bare tenant-preview DISPATCHER path (`/<tenant>/preview` or
159
+ * `/<tenant>/preview/`, e.g. `pages/preview/index.astro`, tenant-first) — the page
160
+ * whose ENTIRE job is to mint a fresh `__preview` token and hand it to the
161
+ * visitor. On a preview-prefixed host this path would otherwise hit the SAME
162
+ * "no token -> 401" gate `resolveCustomizationEnv` enforces for every other
163
+ * path, deadlocking a signed-in-but-token-less visitor: they can never reach
164
+ * the one page that gives them a token. Distinct from `isCandidatePreviewPath`
165
+ * (loginGate.ts) — that exempts several preview routes from the LOGIN wall; this
166
+ * exempts exactly ONE route from the TOKEN wall. `<tenant>` is required to look
167
+ * like a real tenant id (contains a `.`) so this never accidentally matches a
168
+ * sibling route. Matched on the pre-strip request path.
169
+ */
170
+ export function isPreviewDispatcherPath(pathname: string): boolean {
171
+ const m = pathname.match(/^\/([^/]+)\/preview\/?$/);
172
+ return m != null && m[1]!.includes(".");
173
+ }
174
+
155
175
  /**
156
176
  * Decide which customization environment to serve. Production hosts always get
157
177
  * `live` and never consult a token. Preview hosts are auth-gated: a valid,
158
178
  * unexpired, tenant-matching token yields `test` (noindex); anything else is
159
- * rejected.
179
+ * rejected. The one exception is the token-dispenser path itself (see
180
+ * {@link isPreviewDispatcherPath}) — it renders the dispatcher's own minimal
181
+ * page, never tenant content, so it's let through as `live`/no-token rather
182
+ * than 401ing on the very request that would otherwise fetch it a token.
160
183
  */
161
184
  export async function resolveCustomizationEnv(
162
185
  input: EnvResolutionInput,
163
186
  ): Promise<EnvResolution> {
164
- if (!input.isPreviewHost) {
187
+ if (!input.isPreviewHost || isPreviewDispatcherPath(input.pathname ?? "")) {
165
188
  return { env: "live", noindex: false };
166
189
  }
167
190
  if (!input.secret) {
@@ -15,6 +15,7 @@ import {
15
15
  type ChannelName,
16
16
  type PointerRecord,
17
17
  type SiteVersionManifest,
18
+ type SiteVersionSummary,
18
19
  } from "./customization-versioning.js";
19
20
  import type { CustomizationEnv } from "./customization-preview.js";
20
21
 
@@ -93,6 +94,27 @@ export async function resolveChannelVersion(
93
94
  return pointer?.channels?.[channel] ?? null;
94
95
  }
95
96
 
97
+ /**
98
+ * The static-bundle `treeDigest` a channel currently serves (ADR 0012), or null
99
+ * when the tenant has no digest companion for that channel (a hosted tenant — it
100
+ * sets only `channels`, never `channelDigests`).
101
+ *
102
+ * This is the RECONCILED read the digest-addressed serving path
103
+ * (`static-bundle.ts`), the pointer verify (`createPointerVerifyTarget`), and
104
+ * the pointer-served "current" read (`resolveStaticLiveDigestFromPointer`) use
105
+ * INSTEAD of `resolveChannelVersion` for a static tenant: the versionId
106
+ * (`channels[ch]`, the Gitea sha) is NOT the R2 key — the treeDigest is, and it
107
+ * lives here. Reads the pointer fresh; never throws.
108
+ */
109
+ export async function resolveChannelDigest(
110
+ get: KvGet,
111
+ tenantId: string,
112
+ channel: ChannelName,
113
+ ): Promise<string | null> {
114
+ const pointer = await readPointer(get, tenantId);
115
+ return pointer?.channelDigests?.[channel] ?? null;
116
+ }
117
+
96
118
  /** Read one immutable site-version manifest, or null. Never throws. */
97
119
  export async function readSiteVersion(
98
120
  get: KvGet,
@@ -108,12 +130,112 @@ export async function readSiteVersion(
108
130
  }
109
131
  }
110
132
 
133
+ /** A manifest is BUILT (servable) when it exists AND materialized ≥1 artifact. */
134
+ function isBuiltManifest(m: SiteVersionManifest | null): m is SiteVersionManifest {
135
+ return !!m && Object.keys(m.artifacts ?? {}).length > 0;
136
+ }
137
+
138
+ /**
139
+ * Read a tenant's version-history index (list of summaries), NEWEST first — the
140
+ * runtime read-side view of the same `siteVersionIndexKey` the KV store appends
141
+ * to (stored oldest-first). Never throws — malformed/absent → `[]`.
142
+ */
143
+ export async function readSiteVersionIndex(
144
+ get: KvGet,
145
+ tenantId: string,
146
+ ): Promise<SiteVersionSummary[]> {
147
+ const raw = await get(siteVersionIndexKey(tenantId));
148
+ if (!raw) return [];
149
+ try {
150
+ return (JSON.parse(raw) as SiteVersionSummary[]).slice().reverse();
151
+ } catch {
152
+ return [];
153
+ }
154
+ }
155
+
156
+ /** The outcome of self-healing a channel pointer to a SERVABLE version. */
157
+ export interface ServableChannelVersion {
158
+ /**
159
+ * The versionId to actually SERVE from: the pointed version when it is built,
160
+ * else the newest BUILT version in history (self-heal), else `null` (nothing
161
+ * built at all — the caller falls back to build-time, still never a bare 404).
162
+ */
163
+ versionId: string | null;
164
+ /** The versionId the channel pointer NAMES (may dangle at an unbuilt version). */
165
+ pointedVersionId: string | null;
166
+ /**
167
+ * True when the pointer named a version with no built manifest — so the read
168
+ * was self-healed onto an older built version (or found none). Signals the
169
+ * caller to enqueue a rebuild of the pointed version.
170
+ */
171
+ dangling: boolean;
172
+ }
173
+
174
+ /** Optional seam so a self-healing read can enqueue a rebuild of the dangling
175
+ * version. Best-effort/fail-open: never awaited into the serve path, never
176
+ * throws through it. Absent ⇒ pure resolution (no side effect). */
177
+ export type EnqueueRebuild = (info: {
178
+ tenantId: string;
179
+ channel: ChannelName;
180
+ versionId: string;
181
+ }) => void;
182
+
183
+ /**
184
+ * Resolve the SERVABLE version for a channel, self-healing a dangling pointer.
185
+ *
186
+ * A channel pointer can name a version that was never built (or whose manifest
187
+ * was evicted). Serving that naively empties/404s the whole surface. Instead: if
188
+ * the pointed version has no built manifest, fall back to the newest version in
189
+ * history that DOES, and flag `dangling` so the caller can enqueue a rebuild.
190
+ * Only kicks in when the pointed
191
+ * manifest is entirely absent/unbuilt — a present manifest that merely omits one
192
+ * path stays authoritative (unchanged behavior). Reads fresh; never throws.
193
+ */
194
+ export async function resolveServableChannelVersion(
195
+ get: KvGet,
196
+ tenantId: string,
197
+ channel: ChannelName,
198
+ enqueueRebuild?: EnqueueRebuild,
199
+ ): Promise<ServableChannelVersion> {
200
+ const pointedVersionId = await resolveChannelVersion(get, tenantId, channel);
201
+ if (!pointedVersionId) {
202
+ return { versionId: null, pointedVersionId: null, dangling: false };
203
+ }
204
+ const pointed = await readSiteVersion(get, tenantId, pointedVersionId);
205
+ if (isBuiltManifest(pointed)) {
206
+ return { versionId: pointedVersionId, pointedVersionId, dangling: false };
207
+ }
208
+ // Dangling: the pointer names an unbuilt/absent version. Self-heal onto the
209
+ // newest BUILT version in history so the surface keeps rendering last-good.
210
+ let healed: string | null = null;
211
+ for (const summary of await readSiteVersionIndex(get, tenantId)) {
212
+ if (summary.versionId === pointedVersionId) continue;
213
+ if (isBuiltManifest(await readSiteVersion(get, tenantId, summary.versionId))) {
214
+ healed = summary.versionId;
215
+ break;
216
+ }
217
+ }
218
+ if (enqueueRebuild) {
219
+ try {
220
+ enqueueRebuild({ tenantId, channel, versionId: pointedVersionId });
221
+ } catch {
222
+ /* fail-open: enqueue is best-effort, never breaks the read */
223
+ }
224
+ }
225
+ return { versionId: healed, pointedVersionId, dangling: true };
226
+ }
227
+
111
228
  /**
112
229
  * Resolve a single artifact's content via the VERSIONED path:
113
230
  * channel → versionId → site-version manifest → content hash → bytes. Returns
114
231
  * null (so the caller falls back to build-time) when the tenant has no channel
115
- * pointer, the version manifest is missing, the path isn't in the snapshot, or
116
- * the content is gone. Never throws.
232
+ * pointer, the path isn't in the snapshot, or the content is gone. Never throws.
233
+ *
234
+ * Self-healing (u3): when the channel pointer DANGLES (names an unbuilt/absent
235
+ * version), the artifact is read from the newest built version instead of
236
+ * returning null for every path — so a dangling `preview`/`live` pointer serves
237
+ * the last built site rather than 404ing the whole surface. See
238
+ * {@link resolveServableChannelVersion}.
117
239
  */
118
240
  export async function readVersionedArtifact(
119
241
  get: KvGet,
@@ -121,7 +243,7 @@ export async function readVersionedArtifact(
121
243
  channel: ChannelName,
122
244
  path: string,
123
245
  ): Promise<string | null> {
124
- const versionId = await resolveChannelVersion(get, tenantId, channel);
246
+ const { versionId } = await resolveServableChannelVersion(get, tenantId, channel);
125
247
  if (!versionId) return null;
126
248
  const manifest = await readSiteVersion(get, tenantId, versionId);
127
249
  const contentHash = manifest?.artifacts?.[path];
@@ -37,6 +37,15 @@ export type ChannelPointers = Partial<Record<ChannelName, string>>;
37
37
  * - `channels`: the versioned-publish channel pointers (`live`/`preview` →
38
38
  * a single site `versionId`). Additive + optional; when present it is
39
39
  * authoritative for that channel and shadows the legacy selection.
40
+ * - `channelDigests`: (ADR 0012) the RECONCILED static-serving companion to
41
+ * `channels`. For a static-publish tenant, `channels[ch]` still holds the site
42
+ * `versionId` (the tenant Gitea commit sha — the hosted contract), while
43
+ * `channelDigests[ch]` holds that same publish's bundle `treeDigest` (the
44
+ * content-addressed R2 key prefix `versions/<digest>/` `static-bundle.ts`
45
+ * reads). The two are DISTINCT namespaces — the Gitea sha is NOT the treeDigest
46
+ * — and are written in the SAME CAS pointer write (`setChannel`), so they can
47
+ * never drift. Absent for a hosted tenant (which only ever sets/reads
48
+ * `channels`).
40
49
  * - `rev`: optimistic-concurrency token, incremented on every successful write.
41
50
  */
42
51
  export interface PointerRecord {
@@ -45,6 +54,7 @@ export interface PointerRecord {
45
54
  live: Selection;
46
55
  test: Selection;
47
56
  channels?: ChannelPointers;
57
+ channelDigests?: ChannelPointers;
48
58
  }
49
59
 
50
60
  export function emptyPointer(tenantId: string): PointerRecord {
@@ -36,6 +36,7 @@ export * from "./raw-html-policy.js";
36
36
  export * from "./review-trust-proof.js";
37
37
  export * from "./compliance/index.js";
38
38
  export * from "./tenant-assets.js";
39
+ export * from "./static-bundle.js";
39
40
  export * from "./binary-path.js";
40
41
  export * from "./hot-standby-readiness.js";
41
42
  export * from "./hash.js";