@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,83 @@
1
+ /**
2
+ * Teaser truncation for gated posts. A members-only post is still a 200 with
3
+ * partial content (indexable — see the B4.0 spike's SEO note), so an anonymous /
4
+ * unentitled reader gets the first few blocks of the real body plus a paywall
5
+ * card, not a hard wall.
6
+ *
7
+ * Truncation is by TOP-LEVEL BLOCK COUNT, cutting only on an element boundary so
8
+ * the returned string is never a partial tag. The blog renderer emits each block
9
+ * (`<p>`, `<h2>`, `<ul>`, `<blockquote>`, `<pre>`, …) as a top-level sibling, so
10
+ * "first N blocks" is a clean prefix of the rendered HTML; the boundary scan also
11
+ * tolerates legacy/migrated `contentHtml` from other sources.
12
+ */
13
+
14
+ const VOID_TAGS = new Set([
15
+ "area", "base", "br", "col", "embed", "hr", "img", "input",
16
+ "link", "meta", "param", "source", "track", "wbr",
17
+ ]);
18
+
19
+ /** Default teaser length: enough to be a genuine content sample, not the whole story. */
20
+ const DEFAULT_BLOCKS = 3;
21
+
22
+ /**
23
+ * The first `blocks` top-level block elements of `contentHtml`. Returns the whole
24
+ * string when it has fewer than `blocks` top-level elements. Cuts only after a
25
+ * top-level element closes (or a void/self-closing element), so HTML is never
26
+ * severed mid-tag.
27
+ */
28
+ export function teaserHtml(contentHtml: string, blocks: number = DEFAULT_BLOCKS): string {
29
+ if (blocks <= 0) return "";
30
+ const n = contentHtml.length;
31
+ let i = 0;
32
+ let depth = 0;
33
+ let topLevel = 0;
34
+
35
+ while (i < n) {
36
+ if (contentHtml[i] !== "<") {
37
+ i++;
38
+ continue;
39
+ }
40
+ if (contentHtml.startsWith("<!--", i)) {
41
+ const end = contentHtml.indexOf("-->", i + 4);
42
+ i = end === -1 ? n : end + 3;
43
+ continue;
44
+ }
45
+ const gt = contentHtml.indexOf(">", i);
46
+ if (gt === -1) break; // malformed tail — stop rather than emit a partial tag
47
+ const tag = contentHtml.slice(i, gt + 1);
48
+ i = gt + 1;
49
+
50
+ const closing = tag.startsWith("</");
51
+ const selfClosing = tag.endsWith("/>");
52
+ const name = tag.match(/^<\/?\s*([a-zA-Z][a-zA-Z0-9-]*)/)?.[1]?.toLowerCase() ?? "";
53
+
54
+ if (closing) {
55
+ if (depth > 0) depth--;
56
+ if (depth === 0) {
57
+ topLevel++;
58
+ if (topLevel >= blocks) return contentHtml.slice(0, i);
59
+ }
60
+ } else if (selfClosing || VOID_TAGS.has(name)) {
61
+ if (depth === 0) {
62
+ topLevel++;
63
+ if (topLevel >= blocks) return contentHtml.slice(0, i);
64
+ }
65
+ } else {
66
+ depth++;
67
+ }
68
+ }
69
+ return contentHtml;
70
+ }
71
+
72
+ /**
73
+ * The body HTML to render for an article: full content when the viewer is
74
+ * entitled (or the post is ungated), a teaser otherwise. The single place the
75
+ * gate/teaser branch lives, so the public/full-content path stays provably
76
+ * unchanged — `articleBodyHtml(html, { gated: false, ... }) === html`.
77
+ */
78
+ export function articleBodyHtml(
79
+ contentHtml: string,
80
+ opts: { gated: boolean; unlock: boolean },
81
+ ): string {
82
+ return opts.gated && !opts.unlock ? teaserHtml(contentHtml) : contentHtml;
83
+ }
@@ -5,6 +5,16 @@ export interface BlogImage {
5
5
  height?: number;
6
6
  }
7
7
 
8
+ export type PostStatus = "draft" | "published" | "unlisted" | "scheduled"; // "scheduled" added in B3.1
9
+
10
+ /**
11
+ * Per-post entitlement gate — who may read the body. Orthogonal to `PostStatus`
12
+ * (which answers "is this published to the live site"): a post has both. Checked
13
+ * at render time via `viewerHasBlogAccess`, never folded into `isVisibleOnLive`.
14
+ * Room for future `"tier:<id>"` / `"assurance:<acr>"`; only these three today.
15
+ */
16
+ export type BlogAccess = "public" | "authenticated" | "members";
17
+
8
18
  export interface BlogPostSummary {
9
19
  source: { platform: "wordpress"; id: string };
10
20
  slug: string;
@@ -18,9 +28,33 @@ export interface BlogPostSummary {
18
28
  categories: string[];
19
29
  tags: string[];
20
30
  featuredImage?: BlogImage;
31
+ status: PostStatus; // seeded/legacy posts without this field default to "published"
32
+ scheduledAt?: string; // ISO date; when status === "scheduled", the post goes live once this instant has passed (B3.1)
33
+ canonicalUrl?: string; // syndication override (B1.6)
34
+ inLanguage?: string; // default "en" (B1.6)
35
+ access?: BlogAccess; // entitlement gate; default "public". Orthogonal to `status` (B4.0)
36
+ translationGroupId?: string; // stable id shared by every language variant of one article; absent = singleton (I1)
21
37
  }
22
38
 
23
39
  export interface BlogPost extends BlogPostSummary {
24
40
  oldSlugs: string[];
25
41
  contentHtml: string;
42
+ // Markdown source for the admin editor (B3.4). When present, `contentHtml` is
43
+ // its rendered output; migrated/legacy posts may carry only `contentHtml`.
44
+ bodyMarkdown?: string;
45
+ }
46
+
47
+ /**
48
+ * Optional per-tenant × per-collection author bios, keyed by the author's slug
49
+ * (see `authorSlug`). Sourced from `tenants/<id>/content/<collection>/authors.json`;
50
+ * absent file or missing key falls back to just the byline name.
51
+ */
52
+ export interface AuthorBio {
53
+ name: string;
54
+ bio?: string;
55
+ title?: string; // role/title, e.g. "Founder"
56
+ avatar?: string; // image path (site-relative or absolute)
57
+ url?: string; // personal/site URL
58
+ sameAs?: string[]; // social profile URLs — E-E-A-T signal (feeds Person.sameAs, B1.6)
59
+ twitter?: string; // handle, e.g. "@ada" (feeds twitter:creator, B1.6)
26
60
  }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Per-post view counts: the "first demand signal" for the blog platform.
3
+ *
4
+ * Rides the EXISTING RUM beacon (`api/rum/vitals.ts`) rather than adding a new
5
+ * ingestion path. Each page load emits exactly one CLS field-vitals sample (LCP
6
+ * and INP are conditional), so the beacon handler treats that CLS sample as the
7
+ * one-per-view signal and increments counters when it lands on a blog-post URL.
8
+ * All counts are keyed by (tenant, collection, slug) — no PII, no per-visitor
9
+ * identity.
10
+ *
11
+ * Two counters per view, written together:
12
+ * - a rolling LIFETIME total (`blogViewsKey`), unbounded; and
13
+ * - a per-UTC-day BUCKET (`blogViewsDayKey`, TTL-expiring) that powers the
14
+ * time-series sparkline and the "views over last N days" top-posts ranking.
15
+ * The day buckets are additive to the lifetime total, not a replacement.
16
+ *
17
+ * Storage is the TENANT_CACHE KV namespace — the same binding the newsletter stub
18
+ * writes to and the only durable store bound on the live `preview` worker. The
19
+ * increment is a read-modify-write, so under concurrency some increments can be
20
+ * lost to KV's last-write-wins semantics: acceptable for a rough demand signal,
21
+ * not a billing counter.
22
+ *
23
+ * Tenant/collection-generic by construction — nothing here is bound to
24
+ * `the-build` or `tokenoftrust.com`.
25
+ */
26
+ import { getPost, listPosts } from "./collection.js";
27
+
28
+ // Mirror the engine's guards (collection.ts): a collection is one path segment;
29
+ // a slug is lower-kebab. Keeping them local avoids widening the engine's surface.
30
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
31
+ const SEGMENT_RE = /^[a-z0-9][a-z0-9._-]*$/;
32
+
33
+ // Daily buckets auto-expire this many days after their last write. Comfortably
34
+ // past the widest window the dashboard reads (30d), so a "last N days" query
35
+ // never asks for a key that should still exist but was reaped.
36
+ const DAY_BUCKET_TTL_DAYS = 45;
37
+ const DAY_MS = 86_400_000;
38
+
39
+ /** KV key for one post's rolling lifetime view count. */
40
+ export function blogViewsKey(tenantId: string, collection: string, slug: string): string {
41
+ return `blogviews:${tenantId}:${collection}:${slug}`;
42
+ }
43
+
44
+ /** `yyyy-mm-dd` (UTC) stamp for a Date — the day-bucket suffix. */
45
+ export function dayStamp(date: Date): string {
46
+ return date.toISOString().slice(0, 10);
47
+ }
48
+
49
+ /**
50
+ * KV key for one post's views on ONE UTC day. Distinct from `blogViewsKey` by
51
+ * the trailing `:<yyyy-mm-dd>` segment, so the lifetime counter and the daily
52
+ * buckets never collide.
53
+ */
54
+ export function blogViewsDayKey(
55
+ tenantId: string,
56
+ collection: string,
57
+ slug: string,
58
+ day: string,
59
+ ): string {
60
+ return `${blogViewsKey(tenantId, collection, slug)}:${day}`;
61
+ }
62
+
63
+ /**
64
+ * Parse a request pathname into a candidate (collection, slug) post ref, or null
65
+ * if it is not shaped like a post URL. A post lives at `<base>/<slug>/`, so the
66
+ * LAST two non-empty path segments are the candidate collection + slug. Taking
67
+ * the last two (not the first) naturally ignores any `/<domain>` basePath prefix
68
+ * (DNS-free preview routing) and rejects listing/section pages (`/the-build/`,
69
+ * `/the-build/category/x/`, `/the-build/author/x/`) and file routes
70
+ * (`/the-build/rss.xml`, whose slug fails `SLUG_RE`) — those either fail the
71
+ * guards or resolve to no post. This is only a SHAPE guard; the caller confirms
72
+ * the post actually exists and is live via `getPost`.
73
+ */
74
+ export function parseBlogPostPath(path: string): { collection: string; slug: string } | null {
75
+ const segs = path.split("/").filter(Boolean);
76
+ if (segs.length < 2) return null;
77
+ const slug = segs[segs.length - 1]!;
78
+ const collection = segs[segs.length - 2]!;
79
+ if (!SEGMENT_RE.test(collection) || !SLUG_RE.test(slug)) return null;
80
+ return { collection, slug };
81
+ }
82
+
83
+ async function readCount(kv: KVNamespace, key: string): Promise<number> {
84
+ const raw = await kv.get(key);
85
+ const n = raw == null ? 0 : Number.parseInt(raw, 10);
86
+ return Number.isFinite(n) && n >= 0 ? n : 0;
87
+ }
88
+
89
+ /**
90
+ * Increment the view counter for a post IF the pathname resolves to a live
91
+ * (published or unlisted) post for this tenant. Drafts, 404s, and non-blog paths
92
+ * are ignored — `getPost` returns null on the live path for anything not visible.
93
+ * Best-effort: never throws (a telemetry beacon must never fail the request).
94
+ */
95
+ export async function recordBlogView(
96
+ kv: KVNamespace,
97
+ tenantId: string,
98
+ path: string,
99
+ ): Promise<void> {
100
+ try {
101
+ const ref = parseBlogPostPath(path);
102
+ if (!ref) return;
103
+ const post = await getPost(tenantId, ref.collection, ref.slug);
104
+ if (!post) return; // not a live post (draft / 404 / non-blog) — do not count
105
+ const key = blogViewsKey(tenantId, ref.collection, ref.slug);
106
+ const dayKey = blogViewsDayKey(tenantId, ref.collection, ref.slug, dayStamp(new Date()));
107
+ const [lifetime, today] = await Promise.all([readCount(kv, key), readCount(kv, dayKey)]);
108
+ await Promise.all([
109
+ // Rolling lifetime counter — unbounded.
110
+ kv.put(key, String(lifetime + 1)),
111
+ // Additive daily bucket for the time-series/top-posts dashboard. TTL keeps
112
+ // the day-bucket keyspace bounded (a day is only ever written on that day,
113
+ // so it expires ~45d after it stops receiving writes).
114
+ kv.put(dayKey, String(today + 1), { expirationTtl: (DAY_BUCKET_TTL_DAYS * DAY_MS) / 1000 }),
115
+ ]);
116
+ } catch {
117
+ /* swallow — view counting is best-effort telemetry */
118
+ }
119
+ }
120
+
121
+ /**
122
+ * READER for the future admin "Blog" tab (B2.1, not yet in this base branch).
123
+ * The tab is server-rendered with `Astro.locals.tenant` and can obtain the KV via
124
+ * `readKv("TENANT_CACHE")`, so it calls this directly with the slugs from
125
+ * `listPosts(tenantId, collection, { includeUnpublished: true })` to render a
126
+ * "Views" column. Returns a slug -> count map (missing keys read as 0); a null KV
127
+ * (local dev, no binding) yields all-zero counts.
128
+ */
129
+ export async function getBlogViewCounts(
130
+ kv: KVNamespace | undefined,
131
+ tenantId: string,
132
+ collection: string,
133
+ slugs: readonly string[],
134
+ ): Promise<Record<string, number>> {
135
+ const out: Record<string, number> = {};
136
+ for (const slug of slugs) out[slug] = 0;
137
+ if (!kv) return out;
138
+ await Promise.all(
139
+ slugs.map(async (slug) => {
140
+ out[slug] = await readCount(kv, blogViewsKey(tenantId, collection, slug));
141
+ }),
142
+ );
143
+ return out;
144
+ }
145
+
146
+ /** The UTC day stamps for the last `days` days, oldest first (today is last). */
147
+ export function recentDayStamps(days: number, now: Date = new Date()): string[] {
148
+ const n = Math.max(1, Math.floor(days));
149
+ const end = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
150
+ const out: string[] = [];
151
+ for (let i = n - 1; i >= 0; i--) out.push(dayStamp(new Date(end - i * DAY_MS)));
152
+ return out;
153
+ }
154
+
155
+ /** One day of a post's view series. */
156
+ export interface DailyViews {
157
+ day: string;
158
+ views: number;
159
+ }
160
+
161
+ /**
162
+ * Per-day view series for one post over the last `days` days (oldest first),
163
+ * for a sparkline/trend. Missing days read as 0; a null KV yields an all-zero
164
+ * series of the right length.
165
+ */
166
+ export async function getPostViewSeries(
167
+ kv: KVNamespace | undefined,
168
+ tenantId: string,
169
+ collection: string,
170
+ slug: string,
171
+ days: number,
172
+ now: Date = new Date(),
173
+ ): Promise<DailyViews[]> {
174
+ const stamps = recentDayStamps(days, now);
175
+ if (!kv) return stamps.map((day) => ({ day, views: 0 }));
176
+ return Promise.all(
177
+ stamps.map(async (day) => ({
178
+ day,
179
+ views: await readCount(kv, blogViewsDayKey(tenantId, collection, slug, day)),
180
+ })),
181
+ );
182
+ }
183
+
184
+ /** One ranked row in the top-posts dashboard. */
185
+ export interface TopPost {
186
+ collection: string;
187
+ slug: string;
188
+ title: string;
189
+ status: string;
190
+ /** Views within the window (`sinceDays`), or lifetime when no window given. */
191
+ views: number;
192
+ /** Lifetime total, always populated. */
193
+ lifetime: number;
194
+ /** Per-day series over the window, oldest first — omitted when unwindowed. */
195
+ series?: DailyViews[];
196
+ }
197
+
198
+ /**
199
+ * Rank a collection's posts by view count, most-viewed first.
200
+ *
201
+ * KV has no cross-key aggregation, so we take the simplest correct approach:
202
+ * enumerate the known posts via `listPosts` and read each one's counter(s). At
203
+ * blog scale (tens of posts) this is a bounded fan-out of parallel KV gets, not
204
+ * a scan — fine for an admin-tab render, and it needs no write-time index to
205
+ * keep in sync. `opts.sinceDays` ranks by a windowed sum of the daily buckets
206
+ * (and returns the series for a sparkline); without it, ranking is by lifetime.
207
+ */
208
+ export async function getTopPosts(
209
+ kv: KVNamespace | undefined,
210
+ tenantId: string,
211
+ collection: string,
212
+ opts?: { limit?: number; sinceDays?: number; now?: Date },
213
+ ): Promise<TopPost[]> {
214
+ const posts = await listPosts(tenantId, collection, { includeUnpublished: true });
215
+ const now = opts?.now ?? new Date();
216
+ const rows = await Promise.all(
217
+ posts.map(async (post) => {
218
+ const lifetime = kv ? await readCount(kv, blogViewsKey(tenantId, collection, post.slug)) : 0;
219
+ if (opts?.sinceDays == null) {
220
+ return {
221
+ collection,
222
+ slug: post.slug,
223
+ title: post.title,
224
+ status: post.status,
225
+ views: lifetime,
226
+ lifetime,
227
+ } satisfies TopPost;
228
+ }
229
+ const series = await getPostViewSeries(
230
+ kv,
231
+ tenantId,
232
+ collection,
233
+ post.slug,
234
+ opts.sinceDays,
235
+ now,
236
+ );
237
+ const views = series.reduce((sum, d) => sum + d.views, 0);
238
+ return { collection, slug: post.slug, title: post.title, status: post.status, views, lifetime, series } satisfies TopPost;
239
+ }),
240
+ );
241
+ rows.sort((a, b) => b.views - a.views || b.lifetime - a.lifetime);
242
+ return opts?.limit != null ? rows.slice(0, Math.max(0, opts.limit)) : rows;
243
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * D1 catalog read-model queries (the one-store target — epics/edge-read-replica.md).
2
+ * D1 catalog read-model queries (the one-store target).
3
3
  *
4
4
  * Queries run against a small `Queryable` seam so the SAME code serves D1 in the
5
5
  * Worker (via `fromD1`) and an in-memory `node:sqlite` in tests. Covers FTS5
@@ -154,7 +154,7 @@ export async function getProductByHandle(
154
154
  }
155
155
 
156
156
  /**
157
- * All active-status products, full data. No pagination — the D3 inventory
157
+ * All active-status products, full data. No pagination — the inventory
158
158
  * projection (`api/apps/v1/inventory.ts`) reads the whole active catalog to
159
159
  * build its per-SKU list, mirroring `searchProducts`'s active-only scoping.
160
160
  */
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * Drizzle schema — the source of truth for `STOREFRONT_APPS_DB` *migration
3
- * generation* (ADR-0002). Split out of `schema.ts` (which now covers only the
4
- * catalog + customization tables on `CATALOG_DB`): these tables are the
5
- * storefront-apps registry/webhooks/orders spine and belong to a DEDICATED
6
- * D1, never the catalog.
3
+ * generation*. `schema.ts` covers the catalog + customization tables on
4
+ * `CATALOG_DB`; these tables are the storefront-apps registry/webhooks/orders
5
+ * spine and belong to a DEDICATED D1, never the catalog.
7
6
  *
8
7
  * `drizzle-kit generate --config drizzle.config.apps.ts` diffs this into
9
8
  * numbered SQL under `migrations-apps/`, applied by `wrangler d1 migrations
@@ -11,8 +10,8 @@
11
10
  */
12
11
  import { sqliteTable, text, integer, real, index, uniqueIndex, primaryKey } from "drizzle-orm/sqlite-core";
13
12
 
14
- // --- Private Apps registry (PrivateApps epic, D2) ----------------------------
15
- // One install per (app, tenant, env). `installId` is the future JWT `sub`.
13
+ // --- Private Apps registry --------------------------------------------------
14
+ // One install per (app, tenant, env). `installId` is the JWT `sub`.
16
15
  // Append-only `app_audit_log` is never pruned by the service layer.
17
16
  export const appInstalls = sqliteTable(
18
17
  "app_installs",
@@ -71,7 +70,61 @@ export const appAuditLog = sqliteTable(
71
70
  }),
72
71
  );
73
72
 
74
- // --- Webhook event spine (PrivateApps epic, D4 Chunk A) ---------------------
73
+ // --- Operational activity timeline ------------------------------------------
74
+ // The low-volume / high-value LIFECYCLE cohort of the actor×action telemetry
75
+ // spine (DECISIONS.md §8). Only actions whose catalog `spec.sinks` include
76
+ // "timeline" land here; the high-volume cohort goes to Cloudflare Analytics
77
+ // Engine (binding `ACTIVITY_ANALYTICS`), and EVERY action also goes to Workers
78
+ // Logs. Reuses the EXISTING STOREFRONT_APPS_DB (precedent: `app_audit_log`
79
+ // above) rather than a new binding — the cohort is small (~50k rows/month at
80
+ // 10× pilot) and the timeline query UI needs point queries + joins + arbitrary
81
+ // retention that Analytics Engine cannot give.
82
+ //
83
+ // `id` is the emitter-minted event UUID and the PRIMARY KEY, so a retried/
84
+ // replayed ingest is idempotent (INSERT OR IGNORE). `tenant_id` is written from
85
+ // the AUTHENTICATED ingest identity, never the client-supplied `scope.tenantId`
86
+ // (see api/activity.ts) — that is the multi-tenant isolation boundary. `payload`
87
+ // is the SERVER-redacted JSON (default-deny allowlist + canary scan re-applied
88
+ // at ingest, never trusting client-side redaction alone). The core columns
89
+ // mirror the `ActivityEvent` envelope's non-payload zone (packages/public-
90
+ // runtime/src/activity/event.ts), flattened for SQL point queries.
91
+ export const activityEvents = sqliteTable(
92
+ "activity_events",
93
+ {
94
+ id: text("id").primaryKey(),
95
+ tenantId: text("tenant_id").notNull(),
96
+ action: text("action").notNull(),
97
+ actorKind: text("actor_kind").notNull(),
98
+ // Opaque, pseudonymized actor id — NEVER raw email/PII (emitter's contract).
99
+ actorId: text("actor_id").notNull(),
100
+ actorRef: text("actor_ref"),
101
+ source: text("source").notNull(),
102
+ status: text("status").notNull(),
103
+ // Low-cardinality error CLASS, never a raw exception message.
104
+ errorClass: text("error_class"),
105
+ durationMs: integer("duration_ms"),
106
+ changeId: text("change_id"),
107
+ traceId: text("trace_id"),
108
+ // Server-redacted payload JSON (args + rendered lanes), default "{}".
109
+ payload: text("payload").notNull().default("{}"),
110
+ // Envelope contract version (ACTIVITY_SCHEMA_VERSION) so a reader can migrate.
111
+ schemaVersion: integer("schema_version").notNull(),
112
+ // Emit-clock ISO-8601 (the event's `at`).
113
+ at: text("at").notNull(),
114
+ // Server receive-clock ISO-8601 (when ingest stored it).
115
+ ingestedAt: text("ingested_at").notNull(),
116
+ },
117
+ (t) => ({
118
+ // The timeline query: a tenant's events newest-first.
119
+ idxActivityTenantAt: index("idx_activity_tenant_at").on(t.tenantId, t.at),
120
+ // Filter a tenant's timeline by action family (dashboards glob the prefix).
121
+ idxActivityTenantAction: index("idx_activity_tenant_action").on(t.tenantId, t.action),
122
+ // Join a change's whole lifecycle span across actions.
123
+ idxActivityChange: index("idx_activity_change").on(t.changeId),
124
+ }),
125
+ );
126
+
127
+ // --- Webhook event spine ----------------------------------------------------
75
128
  // Immutable event log. `id` is the semantic event id we mint — distinct from
76
129
  // any per-delivery wire envelope id (see `webhookDeliveries.deliveryId` below).
77
130
  export const webhookEvents = sqliteTable("webhook_events", {
@@ -141,10 +194,10 @@ export const webhookInstallHealth = sqliteTable("webhook_install_health", {
141
194
  updatedAt: text("updated_at").notNull(),
142
195
  });
143
196
 
144
- // --- Order ingestion (PrivateApps epic, D6 Chunk A) --------------------------
197
+ // --- Order ingestion --------------------------------------------------------
145
198
  // The read-model `ordersStore.ts` diffs incoming Foxy orders against (created
146
- // → fulfilled transitions, for a later chunk's event emission) and that the
147
- // attribution route (also later) reads from for its cross-tenant ownership
199
+ // → fulfilled transitions, for event emission) and that the
200
+ // attribution route reads from for its cross-tenant ownership
148
201
  // check. `(tenantId, externalOrderId)` is the composite PK — same "no
149
202
  // synthetic id, the natural key IS the key" style as `customizationVersions`
150
203
  // (schema.ts) — no SQL FK to `appAttributions.orderId` (repo convention — code-only).
@@ -171,7 +224,7 @@ export const orders = sqliteTable(
171
224
  }),
172
225
  );
173
226
 
174
- // First idempotency ledger in the codebase (D6 Chunk A) — keyed per
227
+ // The idempotency ledger — keyed per
175
228
  // `(installId, idempotencyKey)` so a retried write (e.g. attribution POST)
176
229
  // replays the stored `responseBody` instead of re-applying the mutation.
177
230
  // `bodyHash` (sha256 of the canonical request body) lets a caller re-sending
@@ -188,9 +241,38 @@ export const idempotencyKeys = sqliteTable(
188
241
  (t) => ({ pk: primaryKey({ columns: [t.installId, t.idempotencyKey] }) }),
189
242
  );
190
243
 
191
- // App-owned attribution/commission records (`attribution:write`, D6 later
192
- // chunk owns the route). `orderId` references `orders.externalOrderId` in code
193
- // only (no SQL FK, repo convention); the route chunk enforces the cross-tenant
244
+ // --- Encrypted outbound social OAuth tokens ---------------------------------
245
+ // The ONLY retrievable secret store in this codebase: per-`(tenant, network)`
246
+ // OAuth access/refresh tokens we must REPLAY to X/LinkedIn, so unlike the
247
+ // one-way-hashed `app_credentials` above — they are stored ENCRYPTED-AT-REST,
248
+ // not hashed. `ciphertext` is AES-GCM(base64) over the JSON token payload and
249
+ // `iv` its per-write 96-bit nonce (base64); the key-encryption-key (KEK) is a
250
+ // Worker env binding (`SOCIAL_TOKEN_KEK`), NEVER a column here — mirroring how
251
+ // every other real secret in this codebase is an env binding, not row data.
252
+ // Composite PK `(tenant_id, network)` is the natural key (same "the natural key
253
+ // IS the key" style as `orders`); a rotate is an INSERT OR REPLACE overwrite.
254
+ // `expires_at` is the token's own OAuth expiry (nullable — long-lived manual
255
+ // creds have none), distinct from the row's `updated_at` bookkeeping clock.
256
+ export const socialTokens = sqliteTable(
257
+ "social_tokens",
258
+ {
259
+ tenantId: text("tenant_id").notNull(),
260
+ network: text("network").notNull(),
261
+ ciphertext: text("ciphertext").notNull(),
262
+ iv: text("iv").notNull(),
263
+ expiresAt: text("expires_at"),
264
+ createdAt: text("created_at").notNull(),
265
+ updatedAt: text("updated_at").notNull(),
266
+ },
267
+ (t) => ({
268
+ pk: primaryKey({ columns: [t.tenantId, t.network] }),
269
+ idxSocialTokensTenant: index("idx_social_tokens_tenant").on(t.tenantId),
270
+ }),
271
+ );
272
+
273
+ // App-owned attribution/commission records (`attribution:write`). `orderId`
274
+ // references `orders.externalOrderId` in code
275
+ // only (no SQL FK, repo convention); the route enforces the cross-tenant
194
276
  // ownership check the contract requires before ever writing a row here.
195
277
  export const appAttributions = sqliteTable(
196
278
  "app_attributions",
@@ -211,3 +293,54 @@ export const appAttributions = sqliteTable(
211
293
  idxAppAttributionsTenantOrder: index("idx_app_attributions_tenant_order").on(t.tenantId, t.orderId),
212
294
  }),
213
295
  );
296
+
297
+ // --- Newsletter subscribers -------------------------------------------------
298
+ // First-party audience for the blog/newsletter digest, kept in OUR D1 (not a
299
+ // vendor list) so the per-tenant isolation + GDPR data-control invariants stay
300
+ // under our control.
301
+ // `tenantId` is the isolation boundary, written from the resolved host/session
302
+ // — NEVER a client body. Keyed on `(tenant_id, collection, email)` so a tenant
303
+ // with multiple collections (e.g. `blog` + `the-build`) runs independent lists.
304
+ // `token` is an opaque random used for both confirm + one-click unsubscribe
305
+ // links; `consentIpHash` is sha256(ip+salt) — never a raw IP (PII). Timestamps
306
+ // are ISO-8601 text. `status`: pending | confirmed | unsubscribed | bounced.
307
+ export const newsletterSubscribers = sqliteTable(
308
+ "newsletter_subscribers",
309
+ {
310
+ tenantId: text("tenant_id").notNull(),
311
+ collection: text("collection").notNull(),
312
+ email: text("email").notNull(),
313
+ status: text("status").notNull().default("pending"),
314
+ token: text("token").notNull(),
315
+ consentSource: text("consent_source"),
316
+ consentIpHash: text("consent_ip_hash"),
317
+ subscribedAt: text("subscribed_at").notNull(),
318
+ confirmedAt: text("confirmed_at"),
319
+ unsubscribedAt: text("unsubscribed_at"),
320
+ lastSentAt: text("last_sent_at"),
321
+ },
322
+ (t) => ({
323
+ pk: primaryKey({ columns: [t.tenantId, t.collection, t.email] }),
324
+ idxNewsletterTenantCollStatus: index("idx_newsletter_tenant_coll_status").on(
325
+ t.tenantId,
326
+ t.collection,
327
+ t.status,
328
+ ),
329
+ }),
330
+ );
331
+
332
+ // Idempotent per-digest send log so a re-triggered digest can't double-send.
333
+ // Keyed on `(tenant_id, collection, digest_id)` (e.g. `the-build-2026-W34`);
334
+ // `postSlugs` is a JSON array of the slugs included in that digest.
335
+ export const newsletterSends = sqliteTable(
336
+ "newsletter_sends",
337
+ {
338
+ tenantId: text("tenant_id").notNull(),
339
+ collection: text("collection").notNull(),
340
+ digestId: text("digest_id").notNull(),
341
+ postSlugs: text("post_slugs").notNull().default("[]"),
342
+ recipientCount: integer("recipient_count").notNull().default(0),
343
+ sentAt: text("sent_at").notNull(),
344
+ },
345
+ (t) => ({ pk: primaryKey({ columns: [t.tenantId, t.collection, t.digestId] }) }),
346
+ );
@@ -1,8 +1,7 @@
1
1
  /**
2
2
  * Opaque, SESSION-KV-backed credential that lets a developer's LOCAL dev-loop
3
3
  * process authenticate POST /api/dev/activity on the hosted Worker — the
4
- * local→hosted activity bridge (handoff
5
- * 2026-07-14-hosted-dev-activity-bridge-and-stepper).
4
+ * local→hosted activity bridge.
6
5
  *
7
6
  * Minted alongside the existing cli-signin-code paste
8
7
  * (apps/storefront/src/lib/dev/cliSignInCode.ts) and cached by the CLI in
@@ -30,8 +29,7 @@ export interface ActivityIngestClaims {
30
29
  * sign-in code was issued (see api/dev/cli-signin-code.ts). It rides in the
31
30
  * claims so every runtime/activity write the local loop makes with this token
32
31
  * is stampable with the same id — support looks a live session up by it.
33
- * Optional so tokens minted before this field existed still verify (they just
34
- * carry no trace); every freshly-minted token sets it.
32
+ * Optional: a token without it carries no trace; every freshly-minted token sets it.
35
33
  */
36
34
  traceId?: string;
37
35
  }
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * Per-vendor "recent activity" feed for the hosted /dev panel — a small ring
3
3
  * buffer of file-save events reported by a developer's LOCAL dev loop (see
4
- * activityIngestToken.ts for the write-path auth). File-level only, per
5
- * decision (no per-line diff, no separate published-history tier — see
6
- * handoff 2026-07-14-hosted-dev-activity-bridge-and-stepper).
4
+ * activityIngestToken.ts for the write-path auth). File-level only: no
5
+ * per-line diff, no separate published-history tier.
7
6
  */
8
7
 
9
8
  export type ActivityEventKind = "change" | "add" | "unlink" | "up";
@@ -62,8 +61,7 @@ export async function clearActivity(kv: KVNamespace, appDomain: string): Promise
62
61
 
63
62
  /**
64
63
  * Business-readable translation of a reported file path + event, for the
65
- * activity log. File-level only — no per-line diff enrichment (Darrin,
66
- * 2026-07-14: "we can show that in step 2").
64
+ * activity log. File-level only — no per-line diff enrichment.
67
65
  */
68
66
  export function describeActivity(file: string | undefined, event: ActivityEventKind): string {
69
67
  if (event === "up") return "Your store is running locally";
@@ -3,12 +3,9 @@
3
3
  * click-to-source). Local `astro dev` serves these off dev-server-only plugins at
4
4
  * `/__tot/*` (dev-plugins/dev-file-browser.mjs, dev-write.mjs, dev-diff.mjs, ...) —
5
5
  * paths that don't exist in the deployed Cloudflare Worker. The hosted Worker instead
6
- * serves the SAME shape off `/api/dev/*` Astro API routes, landing incrementally
6
+ * serves the SAME shape off `/api/dev/*` Astro API routes
7
7
  * (cp-tree, cp-file, cp-write, cp-diff, cp-locate). One dev.astro UI, one resolver.
8
8
  *
9
- * See decision `architecture-environment-aware-cockpit`
10
- * (workstream tot-lime-cloud-dev-cockpit).
11
- *
12
9
  * `basePath` is the request's tenant prefix ("" host-routed, "/<tenant>" path-routed).
13
10
  * The HOSTED routes are tenant-scoped through the middleware's host/path resolution, so
14
11
  * a `/<tenant>/cockpit` render must call `/<tenant>/api/dev/*` for `locals.tenant` to be