@tokenoftrust/storefront-runner 1.4.0 → 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 (193) 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/0001_woozy_lyja.sql +22 -0
  4. package/apps/storefront/migrations-apps/0002_good_switch.sql +26 -0
  5. package/apps/storefront/migrations-apps/0003_mute_marauders.sql +12 -0
  6. package/apps/storefront/migrations-apps/meta/0001_snapshot.json +990 -0
  7. package/apps/storefront/migrations-apps/meta/0002_snapshot.json +1160 -0
  8. package/apps/storefront/migrations-apps/meta/0003_snapshot.json +1235 -0
  9. package/apps/storefront/migrations-apps/meta/_journal.json +21 -0
  10. package/apps/storefront/package.json +0 -1
  11. package/apps/storefront/public/js/dashboard-team.js +38 -2
  12. package/apps/storefront/src/components/Seo.astro +65 -1
  13. package/apps/storefront/src/components/admin/AdminBlogDetailRail.astro +878 -0
  14. package/apps/storefront/src/components/admin/AdminBlogTab.astro +1432 -0
  15. package/apps/storefront/src/components/admin/AdminCustomersTab.astro +3 -7
  16. package/apps/storefront/src/components/admin/AdminPublishTab.astro +1756 -1382
  17. package/apps/storefront/src/components/admin/AdminSubscriptionsTab.astro +1 -3
  18. package/apps/storefront/src/components/blog/BlogPaywall.astro +87 -0
  19. package/apps/storefront/src/components/blog/PostAccessBadge.astro +43 -0
  20. package/apps/storefront/src/components/blog/PostGrid.astro +57 -0
  21. package/apps/storefront/src/components/blog/ReactionBar.astro +214 -0
  22. package/apps/storefront/src/layouts/Layout.astro +48 -3
  23. package/apps/storefront/src/lib/activity/alerts.ts +426 -0
  24. package/apps/storefront/src/lib/activity/changeActorAttribution.ts +85 -0
  25. package/apps/storefront/src/lib/activity/deployVersion.ts +120 -0
  26. package/apps/storefront/src/lib/activity/ingest.ts +188 -0
  27. package/apps/storefront/src/lib/activity/ingestAuth.ts +105 -0
  28. package/apps/storefront/src/lib/activity/killSwitch.ts +80 -0
  29. package/apps/storefront/src/lib/activity/query.ts +403 -0
  30. package/apps/storefront/src/lib/activity/recordActivity.ts +101 -0
  31. package/apps/storefront/src/lib/activity/store.ts +120 -0
  32. package/apps/storefront/src/lib/activity/uiActor.ts +150 -0
  33. package/apps/storefront/src/lib/activity/workerCommit.ts +70 -0
  34. package/apps/storefront/src/lib/admin/adminShell.ts +290 -0
  35. package/apps/storefront/src/lib/admin/ai/approvalMode.ts +2 -3
  36. package/apps/storefront/src/lib/admin/ai/audit.ts +3 -5
  37. package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +7 -7
  38. package/apps/storefront/src/lib/admin/checkoutStyleTarget.ts +3 -3
  39. package/apps/storefront/src/lib/admin/orderClient.ts +3 -5
  40. package/apps/storefront/src/lib/admin/services/orders.ts +17 -19
  41. package/apps/storefront/src/lib/admin/viewmodels/aiWorkflows.ts +4 -6
  42. package/apps/storefront/src/lib/admin/viewmodels/settings.ts +1 -1
  43. package/apps/storefront/src/lib/apps/adminService.ts +4 -4
  44. package/apps/storefront/src/lib/apps/adminSession.ts +53 -44
  45. package/apps/storefront/src/lib/apps/apiAuth.ts +2 -2
  46. package/apps/storefront/src/lib/apps/apiRoute.ts +2 -2
  47. package/apps/storefront/src/lib/apps/catalogMapper.ts +1 -1
  48. package/apps/storefront/src/lib/apps/credentials.ts +2 -2
  49. package/apps/storefront/src/lib/apps/gatewayKeys.ts +4 -4
  50. package/apps/storefront/src/lib/apps/healthAggregate.ts +5 -5
  51. package/apps/storefront/src/lib/apps/orders/attributionService.ts +2 -2
  52. package/apps/storefront/src/lib/apps/orders/customerHash.ts +1 -1
  53. package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +12 -12
  54. package/apps/storefront/src/lib/apps/orders/idempotency.ts +7 -7
  55. package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +1 -1
  56. package/apps/storefront/src/lib/apps/orders/orderMapper.ts +2 -2
  57. package/apps/storefront/src/lib/apps/orders/ordersStore.ts +6 -6
  58. package/apps/storefront/src/lib/apps/registryService.ts +6 -6
  59. package/apps/storefront/src/lib/apps/scopes.ts +2 -2
  60. package/apps/storefront/src/lib/apps/tokenIssuer.ts +4 -4
  61. package/apps/storefront/src/lib/apps/tokenVerifier.ts +6 -6
  62. package/apps/storefront/src/lib/apps/widgets/eligibility.ts +2 -2
  63. package/apps/storefront/src/lib/apps/widgets/frameProps.ts +3 -3
  64. package/apps/storefront/src/lib/apps/widgets/launchToken.ts +4 -4
  65. package/apps/storefront/src/lib/apps/widgets/placements.ts +2 -2
  66. package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +1 -1
  67. package/apps/storefront/src/lib/auth/adminEntry.ts +9 -9
  68. package/apps/storefront/src/lib/auth/identityToken.ts +6 -14
  69. package/apps/storefront/src/lib/auth/loginGate.ts +75 -13
  70. package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +6 -4
  71. package/apps/storefront/src/lib/auth/operatorSession.ts +113 -0
  72. package/apps/storefront/src/lib/auth/route.ts +44 -4
  73. package/apps/storefront/src/lib/auth/session.ts +7 -8
  74. package/apps/storefront/src/lib/auth/totAccessClient.ts +8 -18
  75. package/apps/storefront/src/lib/basePath.ts +9 -2
  76. package/apps/storefront/src/lib/blog/access.ts +29 -0
  77. package/apps/storefront/src/lib/blog/collection.ts +313 -0
  78. package/apps/storefront/src/lib/blog/markdown.ts +174 -0
  79. package/apps/storefront/src/lib/blog/provider.ts +5 -0
  80. package/apps/storefront/src/lib/blog/reactions.ts +87 -0
  81. package/apps/storefront/src/lib/blog/rss.ts +92 -0
  82. package/apps/storefront/src/lib/blog/teaser.ts +83 -0
  83. package/apps/storefront/src/lib/blog/types.ts +34 -0
  84. package/apps/storefront/src/lib/blog/viewCounts.ts +243 -0
  85. package/apps/storefront/src/lib/d1/catalog.ts +2 -2
  86. package/apps/storefront/src/lib/d1/schema-apps.ts +147 -14
  87. package/apps/storefront/src/lib/dev/activityIngestToken.ts +2 -4
  88. package/apps/storefront/src/lib/dev/activityStore.ts +3 -5
  89. package/apps/storefront/src/lib/dev/apiBase.ts +1 -4
  90. package/apps/storefront/src/lib/dev/cockpitStore.ts +1 -9
  91. package/apps/storefront/src/lib/dev/devDraft.ts +1 -1
  92. package/apps/storefront/src/lib/dev/previewStatus.ts +9 -21
  93. package/apps/storefront/src/lib/dev/rendezvousBroker.ts +9 -24
  94. package/apps/storefront/src/lib/edgeCache.ts +17 -0
  95. package/apps/storefront/src/lib/env.ts +51 -0
  96. package/apps/storefront/src/lib/http/fetchTransport.ts +63 -0
  97. package/apps/storefront/src/lib/i18n.ts +25 -0
  98. package/apps/storefront/src/lib/jsonld.ts +118 -0
  99. package/apps/storefront/src/lib/monitoring/manifest.ts +11 -11
  100. package/apps/storefront/src/lib/newsletter/confirmationEmail.ts +30 -0
  101. package/apps/storefront/src/lib/newsletter/consent.ts +23 -0
  102. package/apps/storefront/src/lib/newsletter/digest.ts +117 -0
  103. package/apps/storefront/src/lib/newsletter/resendClient.ts +79 -0
  104. package/apps/storefront/src/lib/newsletter/send.ts +265 -0
  105. package/apps/storefront/src/lib/newsletter/subscribers.ts +156 -0
  106. package/apps/storefront/src/lib/publish/adminPublishTab.ts +3519 -0
  107. package/apps/storefront/src/lib/publish/apex-readiness.ts +41 -18
  108. package/apps/storefront/src/lib/publish/dispatchHealth.ts +5 -7
  109. package/apps/storefront/src/lib/publish/domainState.ts +22 -13
  110. package/apps/storefront/src/lib/publish/panelPolling.ts +78 -0
  111. package/apps/storefront/src/lib/publish/shipWorkspace.ts +100 -42
  112. package/apps/storefront/src/lib/publish/stageBundleUpload.ts +215 -0
  113. package/apps/storefront/src/lib/rate/kvWindow.ts +53 -0
  114. package/apps/storefront/src/lib/search/index.ts +2 -2
  115. package/apps/storefront/src/lib/seo/alternates.ts +42 -0
  116. package/apps/storefront/src/lib/seo/meta.ts +65 -0
  117. package/apps/storefront/src/lib/social/golive.ts +212 -0
  118. package/apps/storefront/src/lib/social/notify.ts +32 -0
  119. package/apps/storefront/src/lib/social/tokenStore.ts +171 -0
  120. package/apps/storefront/src/lib/the-build/provider.ts +27 -0
  121. package/apps/storefront/src/lib/the-build/readingTime.ts +35 -0
  122. package/apps/storefront/src/lib/the-build/toc.ts +95 -0
  123. package/apps/storefront/src/lib/tot/ToTClient.ts +1 -1
  124. package/apps/storefront/src/lib/tot/d1Client.ts +2 -2
  125. package/apps/storefront/src/lib/tot/totClientInterface.ts +3 -4
  126. package/apps/storefront/src/lib/webhooks/deliveryStore.ts +16 -17
  127. package/apps/storefront/src/middleware/index.ts +130 -2
  128. package/apps/storefront/src/pages/admin/ops-timeline.astro +420 -0
  129. package/apps/storefront/src/pages/admin.astro +52 -1206
  130. package/apps/storefront/src/pages/api/activity.ts +137 -0
  131. package/apps/storefront/src/pages/api/admin/activity-alerts.ts +73 -0
  132. package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +27 -1
  133. package/apps/storefront/src/pages/api/apps/admin/install.ts +26 -1
  134. package/apps/storefront/src/pages/api/apps/admin/resume.ts +24 -1
  135. package/apps/storefront/src/pages/api/apps/admin/suspend.ts +24 -1
  136. package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +25 -1
  137. package/apps/storefront/src/pages/api/apps/admin/update.ts +24 -1
  138. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +11 -0
  139. package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +11 -0
  140. package/apps/storefront/src/pages/api/apps/v1/attribution.ts +20 -0
  141. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +12 -0
  142. package/apps/storefront/src/pages/api/blog/[collection]/[slug]/react.ts +104 -0
  143. package/apps/storefront/src/pages/api/cache-purge.ts +11 -0
  144. package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +30 -0
  145. package/apps/storefront/src/pages/api/newsletter/[collection]/subscribe.ts +146 -0
  146. package/apps/storefront/src/pages/api/newsletter/confirm.ts +75 -0
  147. package/apps/storefront/src/pages/api/newsletter/unsubscribe.ts +90 -0
  148. package/apps/storefront/src/pages/api/publish/stage-bundle.ts +70 -0
  149. package/apps/storefront/src/pages/api/rum/vitals.ts +21 -2
  150. package/apps/storefront/src/pages/auth/login.astro +5 -4
  151. package/apps/storefront/src/pages/blog/[slug].astro +96 -16
  152. package/apps/storefront/src/pages/blog/author/[author].astro +140 -0
  153. package/apps/storefront/src/pages/blog/category/[category].astro +67 -0
  154. package/apps/storefront/src/pages/blog/index.astro +2 -0
  155. package/apps/storefront/src/pages/blog/tag/[tag].astro +65 -0
  156. package/apps/storefront/src/pages/cockpit.astro +74 -12
  157. package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +15 -11
  158. package/apps/storefront/src/pages/search.astro +1 -1
  159. package/apps/storefront/src/pages/sitemap.xml.ts +20 -0
  160. package/apps/storefront/src/pages/the-build/[slug].astro +129 -0
  161. package/apps/storefront/src/pages/the-build/index.astro +146 -0
  162. package/apps/storefront/src/pages/the-build/rss.xml.ts +33 -0
  163. package/apps/storefront/src/styles/admin.css +1080 -0
  164. package/apps/storefront/tsconfig.json +5 -0
  165. package/package.json +1 -1
  166. package/packages/public-runtime/package.json +1 -0
  167. package/packages/public-runtime/src/activity/README.md +146 -0
  168. package/packages/public-runtime/src/activity/catalog.ts +553 -0
  169. package/packages/public-runtime/src/activity/event.ts +168 -0
  170. package/packages/public-runtime/src/activity/index.ts +16 -0
  171. package/packages/public-runtime/src/activity/redaction.ts +263 -0
  172. package/packages/public-runtime/src/candidate-index.ts +13 -0
  173. package/packages/public-runtime/src/catalog-d1.ts +1 -1
  174. package/packages/public-runtime/src/customization-preview.ts +25 -2
  175. package/packages/public-runtime/src/customization-runtime.ts +125 -3
  176. package/packages/public-runtime/src/customization-versioning.ts +10 -0
  177. package/packages/public-runtime/src/index.ts +7 -0
  178. package/packages/public-runtime/src/static-bundle.ts +357 -0
  179. package/packages/public-runtime/src/tenant.ts +36 -8
  180. package/scripts/build/copy-tenant-assets.mjs +14 -4
  181. package/scripts/dev/ai-edit.mjs +1 -1
  182. package/scripts/dev/checkout-watch.mjs +2 -2
  183. package/scripts/dev/file-browser.mjs +2 -4
  184. package/scripts/dev/file-writer.mjs +1 -1
  185. package/scripts/dev/git-status.mjs +2 -2
  186. package/scripts/dev/locate-handler.mjs +3 -3
  187. package/scripts/dev/port-check.mjs +3 -3
  188. package/scripts/dev/publish.mjs +5 -9
  189. package/scripts/dev/shot.mjs +1 -1
  190. package/scripts/dev/unified-diff.mjs +8 -3
  191. package/scripts/tot-dev.mjs +6 -9
  192. package/apps/storefront/src/lib/webhooks/signing.ts +0 -29
  193. package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +0 -146
@@ -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