@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,171 @@
1
+ /**
2
+ * Encrypted per-`(tenant, network)` outbound social OAuth token store
3
+ * (social-autopost epic, S2). The one RETRIEVABLE secret store in the
4
+ * storefront: unlike `app_credentials` (one-way `sha256Hex` for INBOUND auth),
5
+ * these are access/refresh tokens we must REPLAY to X/LinkedIn, so they are
6
+ * encrypted-at-rest and decrypted at post time — never hashed.
7
+ *
8
+ * Envelope encryption, Workers-native Web Crypto `AES-GCM` (no Node `crypto`):
9
+ * - The data-encrypting step runs per write with a fresh random 96-bit IV.
10
+ * - The key-encrypting key (KEK) is a Worker env binding (`SOCIAL_TOKEN_KEK`,
11
+ * a base64-encoded 256-bit key), imported to a non-extractable `CryptoKey`.
12
+ * It is NEVER a column — mirroring how every other real secret here is an
13
+ * env binding, not row data (see the `social_tokens` note in schema-apps.ts).
14
+ *
15
+ * PURE over injected seams (a `Queryable` for D1, a `CryptoKey` for the KEK) so
16
+ * the same code serves the real Worker bindings and an in-memory test — the
17
+ * activity/store.ts convention. `resolveSocialTokenStore()` is the composition
18
+ * root that wires the real bindings (mirrors `getDispatcher()`); it returns
19
+ * `null` when either binding is absent so callers degrade rather than throw.
20
+ */
21
+ import type { Queryable } from "../d1/catalog.js";
22
+ import { readD1, readEnv } from "../env.js";
23
+
24
+ /** The decrypted token material we persist as one JSON blob under AES-GCM. */
25
+ export interface SocialTokenPayload {
26
+ accessToken: string;
27
+ /** OAuth refresh token, when the network issues one (X app-passwords don't). */
28
+ refreshToken?: string;
29
+ /** Token expiry, ISO-8601. Absent for long-lived manual creds. */
30
+ expiresAt?: string;
31
+ }
32
+
33
+ /** Injected seams a store operation runs over. */
34
+ export interface SocialTokenStoreDeps {
35
+ db: Queryable;
36
+ /** AES-GCM key imported from the `SOCIAL_TOKEN_KEK` binding via {@link importKek}. */
37
+ kek: CryptoKey;
38
+ /** Clock seam (ISO-8601), overridable in tests. Defaults to `Date.now`. */
39
+ now?: () => string;
40
+ }
41
+
42
+ const IV_BYTES = 12; // AES-GCM standard 96-bit nonce.
43
+
44
+ function nowIso(deps: SocialTokenStoreDeps): string {
45
+ return (deps.now ?? (() => new Date().toISOString()))();
46
+ }
47
+
48
+ function toBase64(bytes: Uint8Array): string {
49
+ let s = "";
50
+ for (const b of bytes) s += String.fromCharCode(b);
51
+ return btoa(s);
52
+ }
53
+
54
+ function fromBase64(b64: string): Uint8Array<ArrayBuffer> {
55
+ const s = atob(b64);
56
+ const bytes = new Uint8Array(s.length);
57
+ for (let i = 0; i < s.length; i++) bytes[i] = s.charCodeAt(i);
58
+ return bytes;
59
+ }
60
+
61
+ /**
62
+ * Import the raw `SOCIAL_TOKEN_KEK` secret (base64 of a 256-bit key) into a
63
+ * non-extractable AES-GCM `CryptoKey`. Throws if the decoded key isn't 32 bytes
64
+ * — a misprovisioned KEK must fail loudly at the composition root, not silently
65
+ * produce unopenable ciphertext.
66
+ */
67
+ export async function importKek(rawBase64Key: string): Promise<CryptoKey> {
68
+ const raw = fromBase64(rawBase64Key.trim());
69
+ if (raw.length !== 32) {
70
+ throw new Error(`SOCIAL_TOKEN_KEK must decode to 32 bytes (got ${raw.length})`);
71
+ }
72
+ return crypto.subtle.importKey("raw", raw, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
73
+ }
74
+
75
+ /** Encrypt a token payload → `{ ciphertext, iv }` (both base64). Fresh IV each call. */
76
+ export async function encryptTokenPayload(
77
+ kek: CryptoKey,
78
+ payload: SocialTokenPayload,
79
+ ): Promise<{ ciphertext: string; iv: string }> {
80
+ const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
81
+ const plaintext = new TextEncoder().encode(JSON.stringify(payload));
82
+ const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, kek, plaintext);
83
+ return { ciphertext: toBase64(new Uint8Array(ct)), iv: toBase64(iv) };
84
+ }
85
+
86
+ /** Decrypt a stored `{ ciphertext, iv }` back to the token payload. */
87
+ export async function decryptTokenPayload(
88
+ kek: CryptoKey,
89
+ ciphertext: string,
90
+ iv: string,
91
+ ): Promise<SocialTokenPayload> {
92
+ const pt = await crypto.subtle.decrypt(
93
+ { name: "AES-GCM", iv: fromBase64(iv) },
94
+ kek,
95
+ fromBase64(ciphertext),
96
+ );
97
+ return JSON.parse(new TextDecoder().decode(pt)) as SocialTokenPayload;
98
+ }
99
+
100
+ /**
101
+ * Store (or overwrite) the token for `(tenantId, network)`. `INSERT OR REPLACE`
102
+ * on the composite PK, so this is an idempotent upsert; `created_at` follows the
103
+ * row's replacement (the natural key is the identity, not a birth timestamp).
104
+ */
105
+ export async function putSocialToken(
106
+ deps: SocialTokenStoreDeps,
107
+ tenantId: string,
108
+ network: string,
109
+ payload: SocialTokenPayload,
110
+ ): Promise<void> {
111
+ const { ciphertext, iv } = await encryptTokenPayload(deps.kek, payload);
112
+ const at = nowIso(deps);
113
+ await deps.db.run(
114
+ `INSERT OR REPLACE INTO social_tokens
115
+ (tenant_id, network, ciphertext, iv, expires_at, created_at, updated_at)
116
+ VALUES (?,?,?,?,?,?,?)`,
117
+ tenantId,
118
+ network,
119
+ ciphertext,
120
+ iv,
121
+ payload.expiresAt ?? null,
122
+ at,
123
+ at,
124
+ );
125
+ }
126
+
127
+ /** Decrypt and return the token for `(tenantId, network)`, or `null` if none stored. */
128
+ export async function getSocialToken(
129
+ deps: SocialTokenStoreDeps,
130
+ tenantId: string,
131
+ network: string,
132
+ ): Promise<SocialTokenPayload | null> {
133
+ const row = await deps.db.first<{ ciphertext: string; iv: string }>(
134
+ `SELECT ciphertext, iv FROM social_tokens WHERE tenant_id = ? AND network = ?`,
135
+ tenantId,
136
+ network,
137
+ );
138
+ if (!row) return null;
139
+ return decryptTokenPayload(deps.kek, row.ciphertext, row.iv);
140
+ }
141
+
142
+ /**
143
+ * Overwrite the token for `(tenantId, network)` with rotated material — the
144
+ * explicit name for the refresh-token-rotation path. Same write as
145
+ * {@link putSocialToken}; distinct export so a caller's intent reads at the seam.
146
+ */
147
+ export async function rotateSocialToken(
148
+ deps: SocialTokenStoreDeps,
149
+ tenantId: string,
150
+ network: string,
151
+ newPayload: SocialTokenPayload,
152
+ ): Promise<void> {
153
+ await putSocialToken(deps, tenantId, network, newPayload);
154
+ }
155
+
156
+ /**
157
+ * Composition root: wire the real Worker bindings into a `SocialTokenStoreDeps`.
158
+ * Returns `null` when the D1 binding or the `SOCIAL_TOKEN_KEK` secret is absent
159
+ * (unprovisioned runtime / dev), so a caller degrades to "social posting
160
+ * unavailable" rather than throwing — the KEK is a runtime secret that must be
161
+ * provisioned out of band (`wrangler secret put SOCIAL_TOKEN_KEK`).
162
+ */
163
+ export async function resolveSocialTokenStore(): Promise<SocialTokenStoreDeps | null> {
164
+ const [{ fromD1 }, d1, rawKek] = await Promise.all([
165
+ import("../d1/catalog.js"),
166
+ readD1("STOREFRONT_APPS_DB"),
167
+ readEnv("SOCIAL_TOKEN_KEK"),
168
+ ]);
169
+ if (!d1 || !rawKek) return null;
170
+ return { db: fromD1(d1), kek: await importKek(rawKek) };
171
+ }
@@ -0,0 +1,27 @@
1
+ // Thin wrapper binding the generic blog engine to the "the-build" collection.
2
+ // The engine (src/lib/blog/collection.ts) is the single implementation; this
3
+ // module only fixes the collection id so existing/downstream callers can import
4
+ // collection-bound helpers.
5
+ import { getPost, listPosts, resolveRedirect, type Collection } from "../blog/collection.js";
6
+ import type { BlogPost, BlogPostSummary } from "../blog/types.js";
7
+
8
+ export const THE_BUILD: Collection = { id: "the-build", base: "/the-build" };
9
+
10
+ export function listBuildPosts(
11
+ tenantId: string,
12
+ opts?: { includeUnpublished?: boolean; now?: string },
13
+ ): Promise<BlogPostSummary[]> {
14
+ return listPosts(tenantId, THE_BUILD.id, opts);
15
+ }
16
+
17
+ export function getBuildPost(
18
+ tenantId: string,
19
+ slug: string,
20
+ opts?: { includeUnpublished?: boolean; now?: string },
21
+ ): Promise<BlogPost | null> {
22
+ return getPost(tenantId, THE_BUILD.id, slug, opts);
23
+ }
24
+
25
+ export function resolveBuildRedirect(tenantId: string, slug: string): Promise<string | null> {
26
+ return resolveRedirect(tenantId, THE_BUILD.id, slug);
27
+ }
@@ -0,0 +1,35 @@
1
+ // Estimated reading time for a post's rendered HTML (B1.5). Pure + Node-free
2
+ // (parse5 is Workers-safe), so it's unit-testable and usable on any render path.
3
+ import { parseFragment } from "parse5";
4
+
5
+ /** Minimal structural view of a parse5 node (duck-typed, as scripts/dev/locate.mjs does). */
6
+ interface P5Node {
7
+ nodeName: string;
8
+ tagName?: string;
9
+ value?: string;
10
+ childNodes?: P5Node[];
11
+ }
12
+
13
+ const WORDS_PER_MINUTE = 225;
14
+
15
+ function collectText(node: P5Node, out: string[]): void {
16
+ if (node.nodeName === "#text") {
17
+ if (node.value) out.push(node.value);
18
+ return;
19
+ }
20
+ // Non-visible/text-irrelevant subtrees never count toward reading time.
21
+ if (node.tagName === "script" || node.tagName === "style") return;
22
+ for (const child of node.childNodes ?? []) collectText(child, out);
23
+ }
24
+
25
+ /**
26
+ * Reading time in whole minutes: visible word count / 225 wpm, rounded up, min 1.
27
+ * Ignores markup, script, and style content.
28
+ */
29
+ export function readingTime(html: string): number {
30
+ const frag = parseFragment(html ?? "") as unknown as P5Node;
31
+ const parts: string[] = [];
32
+ collectText(frag, parts);
33
+ const words = parts.join(" ").trim().split(/\s+/).filter(Boolean);
34
+ return Math.max(1, Math.ceil(words.length / WORDS_PER_MINUTE));
35
+ }
@@ -0,0 +1,95 @@
1
+ // Table of contents for a post's rendered HTML (B1.5): parse h2/h3 headings and
2
+ // derive stable anchor ids. Pure + Node-free (parse5 is Workers-safe).
3
+ //
4
+ // `toc()` and `htmlWithHeadingIds()` derive ids from the SAME deterministic pass
5
+ // (`annotate`), so a render path can feed `htmlWithHeadingIds(post.contentHtml)`
6
+ // to `set:html` and `toc(post.contentHtml)` to the aside and the anchors line up.
7
+ import { parseFragment, serialize } from "parse5";
8
+ import { slugifyHeading } from "../content/prose.js";
9
+
10
+ /** Minimal structural view of a parse5 node (duck-typed, as scripts/dev/locate.mjs does). */
11
+ interface P5Node {
12
+ nodeName: string;
13
+ tagName?: string;
14
+ value?: string;
15
+ attrs?: { name: string; value: string }[];
16
+ childNodes?: P5Node[];
17
+ }
18
+
19
+ export interface TocEntry {
20
+ id: string;
21
+ text: string;
22
+ level: 2 | 3;
23
+ }
24
+
25
+ function textOf(node: P5Node): string {
26
+ if (node.nodeName === "#text") return node.value ?? "";
27
+ return (node.childNodes ?? []).map(textOf).join("");
28
+ }
29
+
30
+ function getId(node: P5Node): string | undefined {
31
+ return node.attrs?.find((a) => a.name === "id")?.value?.trim() || undefined;
32
+ }
33
+
34
+ function* headings(node: P5Node): Generator<P5Node> {
35
+ for (const child of node.childNodes ?? []) {
36
+ if (child.tagName === "h2" || child.tagName === "h3") yield child;
37
+ yield* headings(child);
38
+ }
39
+ }
40
+
41
+ interface AnnotatedHeading {
42
+ node: P5Node;
43
+ id: string;
44
+ text: string;
45
+ level: 2 | 3;
46
+ }
47
+
48
+ /**
49
+ * Walk headings in document order, assigning each a unique anchor id: an
50
+ * author-supplied `id` is kept as-is; otherwise the slugified text, de-duped
51
+ * with a numeric suffix on collision. Empty headings are skipped.
52
+ */
53
+ function* annotate(root: P5Node): Generator<AnnotatedHeading> {
54
+ const seen = new Map<string, number>();
55
+ for (const node of headings(root)) {
56
+ const text = textOf(node).replace(/\s+/g, " ").trim();
57
+ if (!text) continue;
58
+ const level: 2 | 3 = node.tagName === "h2" ? 2 : 3;
59
+ const explicit = getId(node);
60
+ let id: string;
61
+ if (explicit) {
62
+ id = explicit;
63
+ seen.set(id, (seen.get(id) ?? 0) + 1);
64
+ } else {
65
+ const base = slugifyHeading(text) || "section";
66
+ const n = seen.get(base) ?? 0;
67
+ seen.set(base, n + 1);
68
+ id = n === 0 ? base : `${base}-${n + 1}`;
69
+ }
70
+ yield { node, id, text, level };
71
+ }
72
+ }
73
+
74
+ /** TOC entries (one per h2/h3) with stable anchor ids, in document order. */
75
+ export function toc(html: string): TocEntry[] {
76
+ const frag = parseFragment(html ?? "") as unknown as P5Node;
77
+ return [...annotate(frag)].map(({ id, text, level }) => ({ id, text, level }));
78
+ }
79
+
80
+ /**
81
+ * The same HTML with anchor ids added to any h2/h3 that lacks one (author ids
82
+ * untouched). Returns the input unchanged when every heading already has an id,
83
+ * so posts without generated anchors are byte-for-byte preserved.
84
+ */
85
+ export function htmlWithHeadingIds(html: string): string {
86
+ const frag = parseFragment(html ?? "") as unknown as P5Node;
87
+ let changed = false;
88
+ for (const { node, id } of annotate(frag)) {
89
+ if (!getId(node)) {
90
+ node.attrs = [...(node.attrs ?? []), { name: "id", value: id }];
91
+ changed = true;
92
+ }
93
+ }
94
+ return changed ? serialize(frag as never) : (html ?? "");
95
+ }
@@ -166,7 +166,7 @@ export async function createToTClient(
166
166
  tenant: TenantConfig,
167
167
  d1?: D1Like,
168
168
  ): Promise<ToTClient> {
169
- // Edge read replica (epics/edge-read-replica.md): when a D1 binding is wired,
169
+ // Edge read replica: when a D1 binding is wired,
170
170
  // serve reads from it (FTS5 search + SQL facets). Otherwise the bundled fixture
171
171
  // snapshot renders the site offline. The tot20 HTTP catalog API has been retired,
172
172
  // so the read path is fully edge-native.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * D1-backed ToTClient — the edge read-replica path (epics/edge-read-replica.md).
2
+ * D1-backed ToTClient — the edge read-replica path.
3
3
  *
4
4
  * Implements the same ToTClient interface as Fixture/Http, but every read is a D1
5
5
  * query (FTS5 search, SQL faceted listing, row-blob retrieval) instead of an
@@ -56,7 +56,7 @@ export class D1ToTClient implements ToTClient {
56
56
  }
57
57
 
58
58
  async getReviews(_handle: string): Promise<Review[]> {
59
- // Phase 1 reviews are display-via-metafields; D1 carries none yet.
59
+ // Reviews are display-via-metafields; the D1 catalog carries none.
60
60
  return [];
61
61
  }
62
62
  }
@@ -1,11 +1,10 @@
1
1
  /**
2
2
  * The ToTClient interface — the storefront's single product-truth contract.
3
3
  *
4
- * Extracted from ToTClient.ts so the concrete backends (FixtureToTClient here,
4
+ * Lives in its own module so the concrete backends (FixtureToTClient here,
5
5
  * D1ToTClient in d1Client.ts) can both `implements ToTClient` without a circular
6
6
  * module edge: d1Client imports only this contract, while ToTClient.ts imports
7
- * D1ToTClient for its factory. ToTClient.ts re-exports this type, so existing
8
- * `import type { ToTClient } from "./ToTClient"` call sites are unchanged.
7
+ * D1ToTClient for its factory. ToTClient.ts re-exports this type.
9
8
  */
10
9
  import type {
11
10
  CatalogCollection,
@@ -25,6 +24,6 @@ export interface ToTClient {
25
24
  getFeatured(limit?: number): Promise<CatalogProduct[]>;
26
25
  /** Full active catalog — used to build the per-tenant search index. */
27
26
  getAllActiveProducts(): Promise<CatalogProduct[]>;
28
- /** Full review content for a product (migrated from the store's reviews app). */
27
+ /** Full review content for a product. */
29
28
  getReviews(handle: string): Promise<Review[]>;
30
29
  }
@@ -1,10 +1,9 @@
1
1
  /**
2
- * Webhook event/delivery storage PrivateApps epic, D4 (Chunk A: Storage
3
- * only). Owns the `webhook_events` / `webhook_deliveries` /
2
+ * Webhook event/delivery storage. Owns the `webhook_events` / `webhook_deliveries` /
4
3
  * `webhook_delivery_attempts` / `webhook_install_health` rows described in
5
- * `../d1/schema.ts`. Signing (Chunk B), the retry/backoff dispatcher
6
- * (Chunk C), event emission/targeting (Chunk D), and the `/webhooks/*`
7
- * routes (Chunk E) all build on this store; none of that policy lives here.
4
+ * `../d1/schema.ts`. Signing, the retry/backoff dispatcher, event
5
+ * emission/targeting, and the `/webhooks/*` routes all build on this store; none
6
+ * of that policy lives here.
8
7
  *
9
8
  * Pure over the `Queryable` seam (see `../d1/catalog.ts`) — the same shape
10
9
  * `AppRegistryService` uses, so this runs unchanged against real D1 in the
@@ -16,12 +15,12 @@
16
15
  * - `deliveryId` (`webhook_deliveries.delivery_id`) — the CloudEvents
17
16
  * envelope `id` for one (event × target install) delivery. This is what
18
17
  * goes on the wire and is what receivers dedupe on; it stays stable
19
- * across that delivery's own retries AND across a manual replay (Chunk
20
- * E's `requeueForReplay` resets state/attempts on the SAME row rather
18
+ * across that delivery's own retries AND across a manual replay
19
+ * (`requeueForReplay` resets state/attempts on the SAME row rather
21
20
  * than minting a new one — a receiver that already acked a delivered
22
21
  * delivery must still dedupe a replayed resend of it).
23
22
  *
24
- * Responsibility split for the two mutating paths a dispatcher (Chunk C)
23
+ * Responsibility split for the two mutating paths a dispatcher
25
24
  * drives per attempt:
26
25
  * - `recordAttempt` is dumb bookkeeping of ONE HTTP attempt's outcome: it
27
26
  * appends to `webhook_delivery_attempts`, bumps the delivery's
@@ -117,7 +116,7 @@ export interface CreateEventResult {
117
116
 
118
117
  export interface ListDeliveriesOptions {
119
118
  state?: DeliveryState;
120
- /** Cap the result, newest first. Used internally by {@link WebhookDeliveryStore.getInstallHealth} to bound its p95 sample; unset means unbounded (the historical behavior). */
119
+ /** Cap the result, newest first. Used internally by {@link WebhookDeliveryStore.getInstallHealth} to bound its p95 sample; unset means unbounded. */
121
120
  limit?: number;
122
121
  }
123
122
 
@@ -165,8 +164,8 @@ const MAX_RECENT_TRACE_IDS = 10;
165
164
  const P95_SAMPLE_SIZE = 50;
166
165
 
167
166
  // A delivery only ever moves forward via `transitionState`; `delivered`/
168
- // `dead-lettered` are terminal THERE. A manual replay (Chunk E's
169
- // `requeueForReplay`) is the one deliberate exception: it resets a delivery
167
+ // `dead-lettered` are terminal THERE. A manual replay (`requeueForReplay`)
168
+ // is the one deliberate exception: it resets a delivery
170
169
  // out of a terminal state back to `pending` on the SAME row, bypassing this
171
170
  // table entirely — see that method's own doc comment for why.
172
171
  const ALLOWED_TRANSITIONS: Record<DeliveryState, readonly DeliveryState[]> = {
@@ -287,7 +286,7 @@ export class WebhookDeliveryStore {
287
286
  /**
288
287
  * Write one immutable event, then fan it out to one `pending` delivery per
289
288
  * target install. Targeting (which installs get the event) is the caller's
290
- * job (Chunk D) — this just persists whatever target list it's handed.
289
+ * job — this just persists whatever target list it's handed.
291
290
  */
292
291
  async createEvent(input: CreateEventInput, targets: readonly DeliveryTarget[]): Promise<CreateEventResult> {
293
292
  const eventId = input.eventId ?? crypto.randomUUID();
@@ -345,8 +344,8 @@ export class WebhookDeliveryStore {
345
344
  }
346
345
 
347
346
  /**
348
- * Read one event by id, or `null` if it doesn't exist. Added for the
349
- * dispatcher/delivery engine (Chunk C): `attemptDelivery` resolves a
347
+ * Read one event by id, or `null` if it doesn't exist. The dispatcher/delivery
348
+ * engine uses it: `attemptDelivery` resolves a
350
349
  * delivery's `eventId` via `getDelivery`, then needs this to fetch the
351
350
  * `payload`/`dataschemaVersion` that only `webhook_events` (not
352
351
  * `webhook_deliveries`) carries, in order to build the CloudEvents
@@ -381,7 +380,7 @@ export class WebhookDeliveryStore {
381
380
  * All deliveries for a tenant, across every install (the ops/support admin
382
381
  * view — an install only ever sees its OWN deliveries via
383
382
  * {@link listDeliveriesByInstall}; this is the tenant-wide superset), newest
384
- * first. Added for D4 Chunk E's admin route; Chunk A/B/C never call this.
383
+ * first.
385
384
  */
386
385
  async listDeliveriesByTenant(
387
386
  tenantId: string,
@@ -443,7 +442,7 @@ export class WebhookDeliveryStore {
443
442
 
444
443
  /**
445
444
  * Move a delivery's `state` forward per `ALLOWED_TRANSITIONS`. The
446
- * caller (Chunk C's dispatcher) decides WHEN to retry vs. give up; this
445
+ * caller (the dispatcher) decides WHEN to retry vs. give up; this
447
446
  * only records that decision. Dead-lettering bumps the install's
448
447
  * `dlqCount` — the one rollup field {@link recordAttempt} doesn't own.
449
448
  */
@@ -478,7 +477,7 @@ export class WebhookDeliveryStore {
478
477
  }
479
478
 
480
479
  /**
481
- * D4 Chunk E's replay support: force a delivery back to `pending` with a
480
+ * Replay support: force a delivery back to `pending` with a
482
481
  * fresh attempt budget — from ANY current state, INCLUDING the terminal
483
482
  * `delivered`/`dead-lettered` states `transitionState`/`ALLOWED_TRANSITIONS`
484
483
  * refuse to leave. This is deliberately a separate write path, not a call
@@ -11,7 +11,7 @@ import { getTenantResolver } from "@/config/resolver";
11
11
  import { referenceTheme } from "@/themes/reference";
12
12
  import { mergeTheme, themeToCssVars } from "@/themes/schema";
13
13
  import { createToTClient } from "@/lib/tot/ToTClient";
14
- import { readEnv, readKv, readD1 } from "@/lib/env";
14
+ import { readEnv, readKv, readD1, readR2 } from "@/lib/env";
15
15
  import { resolveTenantRouting } from "@/lib/tenantRouting";
16
16
  import { createContentProvider, DevDraftContentProvider } from "@/lib/storyblok/provider";
17
17
  import { DEV_DRAFT_COOKIE, devDraftKey, verifyDevDraftToken } from "@/lib/dev/devDraft";
@@ -21,6 +21,7 @@ import {
21
21
  readCacheVersion,
22
22
  getCachedPage,
23
23
  putCachedPage,
24
+ staticBundleCacheKey,
24
25
  } from "@/lib/edgeCache";
25
26
  import { STATIC_SCRIPT_HASHES, STATIC_STYLE_HASHES } from "@/lib/cspStaticHashes";
26
27
  import { applySecurityHeaders } from "@/lib/securityHeaders";
@@ -38,8 +39,12 @@ import {
38
39
  resolveTenantFeatures,
39
40
  resolveCapabilities,
40
41
  capabilitiesArtifactPath,
42
+ resolveChannelDigest,
43
+ resolveServableChannelVersion,
44
+ channelFromEnv,
45
+ serveStaticBundle,
41
46
  } from "@tot/public-runtime";
42
- import type { CapabilityConfig } from "@tot/public-runtime";
47
+ import type { CapabilityConfig, StaticBundleR2BucketLike } from "@tot/public-runtime";
43
48
  import { resolveTenantCommerce } from "@/lib/checkoutCommerce";
44
49
  import { evaluateDemoGate, hostInGateList } from "@/lib/demoGate";
45
50
  import {
@@ -55,6 +60,7 @@ import { readSession, KvSessionStore, SESSION_COOKIE } from "@/lib/auth/session"
55
60
  import { isMaintenanceOn, maintenanceResponse } from "@/lib/maintenance";
56
61
  import { HEALTH_PATH, healthResponse } from "@/lib/health";
57
62
  import { observeDeployVersion } from "@/lib/activity/deployVersion";
63
+ import { recordActivity } from "@/lib/activity/recordActivity";
58
64
  import { primeActivityKillSwitch } from "@/lib/activity/killSwitch";
59
65
  import type { DeepPartial, ThemeTokens } from "@tot/public-runtime";
60
66
 
@@ -164,6 +170,14 @@ export const onRequest = defineMiddleware(async (context, next) => {
164
170
  }
165
171
  const nowSeconds = Math.floor(Date.now() / 1000);
166
172
  const admitted = sessionAdmitsHost(viewerRecord, tenant.appDomain, nowSeconds);
173
+ // QA content-bypass: only when the server has configured DEMO_GATE_BYPASS_TOKEN
174
+ // (never in a prod slot) AND the request presents a matching X-Demo-Gate-Bypass
175
+ // header. Unset token ⇒ false ⇒ fully inert; a client cannot self-grant. The
176
+ // public-content-only restriction (admin/cockpit/api excluded) is enforced
177
+ // inside evaluateLoginGate via isBypassPrivilegedPath.
178
+ const bypassToken = await readEnv("DEMO_GATE_BYPASS_TOKEN");
179
+ const qaBypass =
180
+ !!bypassToken && request.headers.get("x-demo-gate-bypass") === bypassToken;
167
181
  const decision = evaluateLoginGate({
168
182
  host,
169
183
  pathname: url.pathname,
@@ -172,6 +186,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
172
186
  gatedHosts: demoGateHosts,
173
187
  hasValidSession: admitted,
174
188
  tenantExempt,
189
+ qaBypass,
175
190
  });
176
191
  if (decision.action === "redirect") {
177
192
  // Withhold content but offer sign-in (carrying the intended destination).
@@ -205,6 +220,79 @@ export const onRequest = defineMiddleware(async (context, next) => {
205
220
  // signature, expiry, tenant binding — before the overlay is applied further down.
206
221
  const draftCookie = context.cookies.get(DEV_DRAFT_COOKIE)?.value ?? null;
207
222
 
223
+ // ---- Digest-addressed static-bundle serving (ADR 0012) --------------------
224
+ // For a static-publish tenant's PRODUCTION reads only (never preview/test — the
225
+ // "Worker serves interactive preview, R2 serves prod" split), read the live
226
+ // bundle straight out of R2 instead of falling through to SSR. Deliberately
227
+ // BEFORE the edge page cache / catalog / theme / customization machinery below:
228
+ // none of that applies to bundle bytes served verbatim, and this path must
229
+ // never SSR-fallthrough once it's eligible (a missing digest/object fails
230
+ // closed, see `serveStaticBundle`), so it has to fully own the response, not
231
+ // degrade into the normal render.
232
+ // Host-resolved reads only (`basePath === ""`): bundle bytes are served
233
+ // VERBATIM with root-absolute links, so a path-prefixed view
234
+ // (/<domain>/… on the shared .store host) would escape its prefix on every
235
+ // href — those views keep SSR, which rewrites hrefs through basePath.
236
+ const staticBundleEligible =
237
+ tenant.staticPublish != null &&
238
+ tenant.staticPublish.enabled !== false &&
239
+ basePath === "" &&
240
+ request.method.toUpperCase() === "GET" &&
241
+ !isPreviewHost &&
242
+ !queryToken &&
243
+ !cookieToken &&
244
+ !draftCookie &&
245
+ !url.pathname.startsWith("/api/");
246
+ if (staticBundleEligible) {
247
+ const bundleBucket = await readR2("STATIC_BUNDLES");
248
+ if (bundleBucket) {
249
+ const kvGet = kv ? (key: string) => kv.get(key) : async () => null;
250
+ // The R2 bundle key is the `treeDigest`, NOT the `live` channel versionId
251
+ // (the Gitea sha). Read the reconciled digest companion (`channelDigests.live`)
252
+ // — `gatedPromote`/`rollbackCandidateAtomic` write it in the same CAS flip as
253
+ // the versionId. No digest = this tenant has never staged a bundle — SSR
254
+ // serves it (a static-publish tenant must be able to bootstrap, and its
255
+ // app surfaces must never be downed by an unstaged static plane).
256
+ const digest = await resolveChannelDigest(kvGet, tenant.tenant_id, "live");
257
+ if (digest) {
258
+ const bundleCacheKey = cache
259
+ ? staticBundleCacheKey(tenant.tenant_id, digest, url.pathname, url.search)
260
+ : null;
261
+ if (bundleCacheKey && cache) {
262
+ const hit = await getCachedPage(cache, bundleCacheKey);
263
+ if (hit) return hit;
264
+ }
265
+ const bundleResponse = await serveStaticBundle(
266
+ {
267
+ bucket: bundleBucket as unknown as StaticBundleR2BucketLike,
268
+ digest,
269
+ onFailClosed: (detail) => {
270
+ console.error(
271
+ `[static-bundle] fail-closed for ${tenant.tenant_id} ${url.pathname}: ${detail}`,
272
+ );
273
+ },
274
+ },
275
+ url.pathname,
276
+ );
277
+ // `null` = the manifest proves this path is outside the bundle's static
278
+ // surface (an app surface like /admin) — SSR owns it below.
279
+ if (bundleResponse) {
280
+ if (bundleCacheKey && cache) {
281
+ await putCachedPage(cache, bundleCacheKey, bundleResponse);
282
+ }
283
+ return bundleResponse;
284
+ }
285
+ } else {
286
+ console.error(
287
+ `[static-bundle] no live digest for ${tenant.tenant_id} — SSR until a bundle is staged`,
288
+ );
289
+ }
290
+ }
291
+ // No STATIC_BUNDLES binding (plain `astro dev`, or unconfigured) —
292
+ // degrading to SSR here rather than a hard failure keeps `astro dev`/tests
293
+ // usable without provisioning R2.
294
+ }
295
+
208
296
  // ---- Edge page cache (D1): short-circuit production browse reads on a hit,
209
297
  // BEFORE building the client / theme / rendering. Misses store the final
210
298
  // response at the end of this middleware.
@@ -262,6 +350,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
262
350
  token: previewToken,
263
351
  secret: previewSecret,
264
352
  nowSeconds,
353
+ pathname: url.pathname,
265
354
  });
266
355
  if (customization.reject) {
267
356
  return new Response(customization.reject.message, {
@@ -290,6 +379,28 @@ export const onRequest = defineMiddleware(async (context, next) => {
290
379
  // pointer fall back to the legacy per-path selection — no behavior change.
291
380
  const kvGet = kv ? (key: string) => kv.get(key) : async () => null;
292
381
 
382
+ // Self-heal a DANGLING channel pointer: the pointer for this env's channel may
383
+ // name a version that was never built (or whose manifest was evicted).
384
+ // `readVersionedArtifact` already serves last-built content instead of emptying
385
+ // the surface; here we do the once-per-request detection to ENQUEUE a rebuild of
386
+ // the dangling version. Fail-open: a KV hiccup never blocks the render (the reads
387
+ // below still run).
388
+ if (kv) {
389
+ const channel = channelFromEnv(customization.env);
390
+ await resolveServableChannelVersion(kvGet, tenant.tenant_id, channel, ({ versionId }) => {
391
+ recordActivity({
392
+ action: "preview.build.requested",
393
+ actor: { kind: "system", id: "storefront" },
394
+ source: "server",
395
+ outcome: { status: "succeeded" },
396
+ scope: { tenantId: tenant.tenant_id },
397
+ payload: { args: { changeId: versionId } },
398
+ });
399
+ }).catch(() => {
400
+ /* fail-open: self-heal detection never breaks the request */
401
+ });
402
+ }
403
+
293
404
  // Verify the developer-draft cookie (signature + expiry + tenant binding) — the
294
405
  // overlay below only engages for the developer who minted the draft, on THIS
295
406
  // tenant. Same secret + trust model as the preview token; invalid/expired/foreign