@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
@@ -4,6 +4,7 @@
4
4
  * truth; lifecycle actions require a later subscription API and audit gates.
5
5
  */
6
6
  import { getSubscriptionsViewModel } from "@/lib/admin/services/mock";
7
+ import { formatDate } from "@/lib/format";
7
8
  import type { AdminTenantEnvelope } from "@/lib/admin/envelope";
8
9
  import type {
9
10
  SubscriptionAttentionRow,
@@ -64,9 +65,6 @@ function formatMoney(value: number): string {
64
65
  return `$${value.toFixed(2)}`;
65
66
  }
66
67
 
67
- function formatDate(iso: string): string {
68
- return new Date(iso).toLocaleDateString([], { month: "short", day: "numeric", year: "numeric" });
69
- }
70
68
  ---
71
69
 
72
70
  <section
@@ -0,0 +1,87 @@
1
+ ---
2
+ // Paywall card shown under the teaser of a gated post the viewer can't read in
3
+ // full. Mirrors the "Coming soon" host gate (`src/lib/auth/gatePage.ts`):
4
+ // badge + headline + one line of value + a single CTA — restyled with the site's
5
+ // design tokens so it reads as native article chrome, and preceded by a fade that
6
+ // dissolves the truncated body into the card.
7
+ import type { BlogAccess } from "@/lib/blog/types.js";
8
+
9
+ interface Props {
10
+ access: BlogAccess;
11
+ next: string;
12
+ signedIn: boolean;
13
+ loginHref: string;
14
+ }
15
+
16
+ const { next, signedIn, loginHref } = Astro.props;
17
+ ---
18
+
19
+ <div class="paywall-fade" aria-hidden="true"></div>
20
+ <aside class="paywall" data-next={next} role="note">
21
+ <span class="paywall-badge">Members only</span>
22
+ {signedIn ? (
23
+ <>
24
+ <h2>You're signed in</h2>
25
+ <p>Membership tiers for this story are coming soon.</p>
26
+ {/* Unreachable in v1 (authenticated ⇒ member) — coded per the B4.0 spike so
27
+ a future paid tier only has to point this CTA at a membership route. */}
28
+ <a class="paywall-cta" href={loginHref}>Become a member</a>
29
+ </>
30
+ ) : (
31
+ <>
32
+ <h2>This story is for members</h2>
33
+ <p>Sign in with Token of Trust to keep reading — it's free.</p>
34
+ <a class="paywall-cta" href={loginHref}>Sign in to keep reading</a>
35
+ </>
36
+ )}
37
+ </aside>
38
+
39
+ <style>
40
+ .paywall-fade {
41
+ height: 7rem;
42
+ margin-top: -7rem;
43
+ position: relative;
44
+ pointer-events: none;
45
+ background: linear-gradient(to bottom, transparent, var(--color-bg));
46
+ }
47
+ .paywall {
48
+ display: flex;
49
+ flex-direction: column;
50
+ align-items: center;
51
+ text-align: center;
52
+ gap: .9rem;
53
+ max-width: 34rem;
54
+ margin: 0 auto;
55
+ padding: clamp(1.75rem, 4vw, 2.5rem);
56
+ border: 1px solid var(--color-border);
57
+ border-radius: var(--radius-lg);
58
+ background: var(--color-surface);
59
+ box-shadow: var(--shadow-md);
60
+ }
61
+ .paywall-badge {
62
+ display: inline-block;
63
+ letter-spacing: .18em;
64
+ text-transform: uppercase;
65
+ font-size: .72rem;
66
+ font-weight: 700;
67
+ color: var(--color-muted);
68
+ border: 1px solid var(--color-border);
69
+ border-radius: var(--radius-full);
70
+ padding: .35rem .8rem;
71
+ }
72
+ .paywall h2 { margin: 0; font-size: clamp(1.5rem, 3vw, 2rem); line-height: 1.15; }
73
+ .paywall p { margin: 0; max-width: 28rem; color: var(--color-muted); font-size: 1.02rem; line-height: 1.6; }
74
+ .paywall-cta {
75
+ margin-top: .4rem;
76
+ display: inline-block;
77
+ text-decoration: none;
78
+ font-weight: 700;
79
+ font-size: 1rem;
80
+ color: var(--color-primary-contrast);
81
+ background: var(--color-primary);
82
+ border-radius: var(--radius-md);
83
+ padding: .8rem 1.6rem;
84
+ transition: background .15s ease;
85
+ }
86
+ .paywall-cta:hover { background: var(--color-primary-hover); }
87
+ </style>
@@ -0,0 +1,43 @@
1
+ ---
2
+ // Visual-only lock badge for a gated post on listing card grids. Reads
3
+ // `post.access` and renders nothing for public posts. No gating logic lives
4
+ // here — entitlement is decided in the route via `viewerHasBlogAccess`; this
5
+ // is purely a "this story is members-only" indicator on listings and archives.
6
+ import type { BlogAccess } from "@/lib/blog/types.js";
7
+
8
+ interface Props {
9
+ access?: BlogAccess;
10
+ }
11
+
12
+ const { access } = Astro.props;
13
+ const gated = (access ?? "public") !== "public";
14
+ ---
15
+
16
+ {gated && (
17
+ <p class="access-badge">
18
+ <svg class="lock" width="12" height="12" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
19
+ <rect x="4" y="11" width="16" height="9" rx="2"></rect>
20
+ <path d="M8 11V7a4 4 0 0 1 8 0v4"></path>
21
+ </svg>
22
+ Members
23
+ </p>
24
+ )}
25
+
26
+ <style>
27
+ .access-badge {
28
+ display: inline-flex;
29
+ gap: .3rem;
30
+ align-items: center;
31
+ align-self: flex-start;
32
+ margin: 0 0 .55rem;
33
+ padding: .2rem .55rem;
34
+ border: 1px solid color-mix(in srgb, var(--color-accent) 45%, var(--color-border));
35
+ border-radius: var(--radius-full);
36
+ background: color-mix(in srgb, var(--color-accent) 10%, var(--color-surface));
37
+ color: var(--color-accent-ink);
38
+ font: 700 .68rem/1 var(--font-mono);
39
+ letter-spacing: .06em;
40
+ text-transform: uppercase;
41
+ }
42
+ .lock { flex: 0 0 auto; }
43
+ </style>
@@ -0,0 +1,57 @@
1
+ ---
2
+ // Reader-facing card grid for a list of blog posts, shared by the collection's
3
+ // category and tag index pages. `base` is the collection route base (e.g.
4
+ // "/blog"); `basePath` is the tenant/preview path prefix from Astro.locals.
5
+ import type { BlogPostSummary } from "@/lib/blog/types.js";
6
+ import PostAccessBadge from "@/components/blog/PostAccessBadge.astro";
7
+
8
+ interface Props {
9
+ posts: BlogPostSummary[];
10
+ basePath: string;
11
+ base: string;
12
+ }
13
+
14
+ const { posts, basePath, base } = Astro.props;
15
+ const href = (path: string) => `${basePath}${path}` || "/";
16
+ ---
17
+
18
+ <section class="post-grid" aria-label="Posts">
19
+ {posts.map((post, index) => (
20
+ <article class="post-card">
21
+ <a href={href(`${base}/${post.slug}/`)} aria-label={`Read ${post.title}`}>
22
+ {post.featuredImage ? (
23
+ <img
24
+ src={post.featuredImage.src}
25
+ alt={post.featuredImage.alt}
26
+ width={post.featuredImage.width}
27
+ height={post.featuredImage.height}
28
+ loading={index < 3 ? "eager" : "lazy"}
29
+ decoding="async"
30
+ />
31
+ ) : <div class="image-placeholder" aria-hidden="true"></div>}
32
+ <div class="post-card-copy">
33
+ <p class="post-meta"><time datetime={post.publishedAt}>{new Date(post.publishedAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" })}</time>{post.tags[0] && <> · {post.tags[0]}</>}</p>
34
+ <PostAccessBadge access={post.access} />
35
+ <h2>{post.title}</h2>
36
+ <p>{post.excerpt}</p>
37
+ <span>Read article <span aria-hidden="true">→</span></span>
38
+ </div>
39
+ </a>
40
+ </article>
41
+ ))}
42
+ </section>
43
+
44
+ <style>
45
+ .post-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1.35rem; align-items: start; }
46
+ .post-card { height: 100%; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-surface); box-shadow: var(--shadow-sm); transition: transform .2s ease, box-shadow .2s ease; }
47
+ .post-card:hover { transform: translateY(-4px); box-shadow: var(--shadow-md); }
48
+ .post-card a { display: flex; height: 100%; flex-direction: column; }
49
+ .post-card img, .image-placeholder { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; background: linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 12%, white), color-mix(in srgb, var(--color-accent) 24%, white)); }
50
+ .post-card-copy { display: flex; flex: 1; flex-direction: column; padding: 1.25rem; }
51
+ .post-meta { margin: 0 0 .7rem; color: var(--color-muted); font: 600 .72rem/1.45 var(--font-mono); text-transform: uppercase; letter-spacing: .05em; }
52
+ .post-card h2 { font-size: 1.18rem; line-height: 1.25; }
53
+ .post-card-copy > p:not(.post-meta) { display: -webkit-box; margin: .75rem 0 1rem; overflow: hidden; color: var(--color-muted); font-size: .92rem; -webkit-line-clamp: 3; -webkit-box-orient: vertical; }
54
+ .post-card-copy > span { margin-top: auto; color: var(--color-accent-ink); font-weight: 700; font-size: .88rem; }
55
+ @media (max-width: 980px) { .post-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
56
+ @media (max-width: 520px) { .post-grid { grid-template-columns: 1fr; } }
57
+ </style>
@@ -0,0 +1,214 @@
1
+ ---
2
+ /**
3
+ * E1.2 — reader reaction bar. Server-renders the post's current reaction tallies
4
+ * (from the E1.1 KV counters) and offers the fixed vocabulary as clickable
5
+ * buttons; a client script POSTs the chosen reaction to the public react endpoint
6
+ * and updates the displayed count optimistically, then reconciles against the
7
+ * authoritative tallies the endpoint returns. Anonymous, no identity, no
8
+ * moderation — an emoji tally, the first reader-sentiment signal (complements
9
+ * view counts). Tenant/collection-generic: mounts on any post route.
10
+ */
11
+ import { getReactionCounts, REACTIONS, type Reaction } from "@/lib/blog/reactions.js";
12
+ import { readKv } from "@/lib/env";
13
+ import { withBase } from "@/lib/basePath";
14
+
15
+ interface Props {
16
+ /** Collection id the post lives in (e.g. "blog", "the-build"). */
17
+ collection: string;
18
+ /** Post slug. */
19
+ slug: string;
20
+ }
21
+
22
+ const { collection, slug } = Astro.props;
23
+ const { tenant, basePath, cspNonce } = Astro.locals;
24
+
25
+ const META: Record<Reaction, { emoji: string; label: string }> = {
26
+ like: { emoji: "👍", label: "Like" },
27
+ insightful: { emoji: "💡", label: "Insightful" },
28
+ celebrate: { emoji: "🎉", label: "Celebrate" },
29
+ };
30
+
31
+ const kv = await readKv("TENANT_CACHE");
32
+ const counts = await getReactionCounts(kv, tenant.tenant_id, collection, slug);
33
+
34
+ // The react endpoint is base-path-scoped like every other same-origin POST
35
+ // (mirrors the newsletter form action); host-routed tenants get an empty prefix.
36
+ const endpoint = withBase(
37
+ basePath,
38
+ `/api/blog/${encodeURIComponent(collection)}/${encodeURIComponent(slug)}/react`,
39
+ );
40
+ ---
41
+
42
+ <section class="reaction-bar" aria-label="React to this article" data-reaction-bar data-endpoint={endpoint}>
43
+ <span class="reaction-bar__prompt">React</span>
44
+ <div class="reaction-bar__buttons">
45
+ {
46
+ REACTIONS.map((reaction) => (
47
+ <button
48
+ type="button"
49
+ class="reaction-btn"
50
+ data-reaction={reaction}
51
+ aria-pressed="false"
52
+ aria-label={META[reaction].label}
53
+ >
54
+ <span class="reaction-btn__emoji" aria-hidden="true">
55
+ {META[reaction].emoji}
56
+ </span>
57
+ <span class="reaction-btn__label">{META[reaction].label}</span>
58
+ <span class="reaction-btn__count" data-reaction-count>
59
+ {counts[reaction]}
60
+ </span>
61
+ </button>
62
+ ))
63
+ }
64
+ </div>
65
+ </section>
66
+
67
+ <style>
68
+ .reaction-bar {
69
+ display: flex;
70
+ flex-wrap: wrap;
71
+ align-items: center;
72
+ gap: 0.75rem 1rem;
73
+ width: min(720px, 100%);
74
+ margin: clamp(2.5rem, 6vw, 4rem) auto 0;
75
+ padding-top: clamp(1.5rem, 4vw, 2.25rem);
76
+ border-top: 1px solid var(--color-border);
77
+ }
78
+ .reaction-bar__prompt {
79
+ color: var(--color-muted);
80
+ font-size: 0.8rem;
81
+ font-weight: 700;
82
+ text-transform: uppercase;
83
+ letter-spacing: 0.04em;
84
+ }
85
+ .reaction-bar__buttons {
86
+ display: flex;
87
+ flex-wrap: wrap;
88
+ gap: 0.6rem;
89
+ }
90
+ .reaction-btn {
91
+ display: inline-flex;
92
+ align-items: center;
93
+ gap: 0.45rem;
94
+ padding: 0.5rem 0.85rem;
95
+ border: 1px solid var(--color-border);
96
+ border-radius: var(--radius-full);
97
+ background: var(--color-surface);
98
+ color: var(--color-ink, inherit);
99
+ font: inherit;
100
+ font-size: 0.9rem;
101
+ line-height: 1;
102
+ cursor: pointer;
103
+ transition: border-color 0.15s ease, background 0.15s ease, transform 0.08s ease;
104
+ }
105
+ .reaction-btn:hover {
106
+ border-color: var(--color-accent);
107
+ }
108
+ .reaction-btn:active {
109
+ transform: translateY(1px);
110
+ }
111
+ .reaction-btn[aria-pressed="true"] {
112
+ border-color: var(--color-accent);
113
+ background: color-mix(in srgb, var(--color-accent) 12%, var(--color-surface));
114
+ }
115
+ .reaction-btn__emoji {
116
+ font-size: 1.1rem;
117
+ }
118
+ .reaction-btn__label {
119
+ font-weight: 700;
120
+ }
121
+ .reaction-btn__count {
122
+ min-width: 1.25ch;
123
+ padding-left: 0.15rem;
124
+ color: var(--color-muted);
125
+ font-variant-numeric: tabular-nums;
126
+ font-weight: 700;
127
+ }
128
+ .reaction-btn[aria-pressed="true"] .reaction-btn__count {
129
+ color: var(--color-accent-ink);
130
+ }
131
+ </style>
132
+
133
+ <script is:inline nonce={cspNonce}>
134
+ (() => {
135
+ // One article (and one reaction bar) per page.
136
+ const root = document.querySelector("[data-reaction-bar]");
137
+ if (!root) return;
138
+
139
+ const endpoint = root.dataset.endpoint;
140
+ if (!endpoint) return;
141
+
142
+ // Remember this browser's reactions so a reload shows the pressed state and
143
+ // the same reader can't trivially re-inflate a tally by re-clicking.
144
+ const storeKey = "blogreact:" + endpoint;
145
+ let reacted = {};
146
+ try {
147
+ reacted = JSON.parse(localStorage.getItem(storeKey) || "{}") || {};
148
+ } catch {
149
+ reacted = {};
150
+ }
151
+ const persist = () => {
152
+ try {
153
+ localStorage.setItem(storeKey, JSON.stringify(reacted));
154
+ } catch {
155
+ /* storage disabled — pressed state is best-effort */
156
+ }
157
+ };
158
+
159
+ const buttons = Array.from(root.querySelectorAll("[data-reaction]"));
160
+ const countEl = (btn) => btn.querySelector("[data-reaction-count]");
161
+ const readCount = (btn) => {
162
+ const n = parseInt((countEl(btn)?.textContent || "0").trim(), 10);
163
+ return Number.isFinite(n) && n >= 0 ? n : 0;
164
+ };
165
+ const setCount = (btn, n) => {
166
+ const el = countEl(btn);
167
+ if (el) el.textContent = String(n);
168
+ };
169
+
170
+ for (const btn of buttons) {
171
+ if (reacted[btn.dataset.reaction]) btn.setAttribute("aria-pressed", "true");
172
+ }
173
+
174
+ const applyServerCounts = (counts) => {
175
+ if (!counts || typeof counts !== "object") return;
176
+ for (const btn of buttons) {
177
+ const v = counts[btn.dataset.reaction];
178
+ if (typeof v === "number" && v >= 0) setCount(btn, v);
179
+ }
180
+ };
181
+
182
+ for (const btn of buttons) {
183
+ btn.addEventListener("click", async () => {
184
+ const reaction = btn.dataset.reaction;
185
+ if (!reaction || reacted[reaction]) return;
186
+
187
+ // Optimistic: bump immediately, mark pressed.
188
+ const before = readCount(btn);
189
+ setCount(btn, before + 1);
190
+ btn.setAttribute("aria-pressed", "true");
191
+ reacted[reaction] = true;
192
+ persist();
193
+
194
+ try {
195
+ const res = await fetch(endpoint, {
196
+ method: "POST",
197
+ headers: { "content-type": "application/json" },
198
+ body: JSON.stringify({ reaction }),
199
+ });
200
+ const data = await res.json().catch(() => null);
201
+ // The endpoint always returns the authoritative tallies (even on
202
+ // rate-limit / invalid) — reconcile the display to them.
203
+ if (data && data.counts) applyServerCounts(data.counts);
204
+ } catch {
205
+ // Network fault: revert the optimistic bump and pressed state.
206
+ setCount(btn, before);
207
+ btn.setAttribute("aria-pressed", "false");
208
+ delete reacted[reaction];
209
+ persist();
210
+ }
211
+ });
212
+ }
213
+ })();
214
+ </script>
@@ -36,12 +36,46 @@ interface Props {
36
36
  ogImage?: string;
37
37
  noindex?: boolean;
38
38
  jsonLd?: Record<string, unknown> | Record<string, unknown>[];
39
+ /** Alternate RSS/Atom feeds this page advertises (threaded to <Seo>). */
40
+ feeds?: { title: string; href: string }[];
39
41
  /** Wider pages (PLP/home) drop the prose max-width. */
40
42
  fullBleed?: boolean;
43
+ // --- B1.6 article SEO hardening: pass-through to <Seo> (all additive) ---
44
+ article?: import("@/lib/seo/meta.js").ArticleMeta;
45
+ imageAlt?: string;
46
+ imageWidth?: number;
47
+ imageHeight?: number;
48
+ twitterSite?: string;
49
+ twitterCreator?: string;
50
+ canonicalOverride?: string;
51
+ // --- I2 hreflang / <html lang> (additive) ---
52
+ /** Per-variant `hreflang` alternates for a translated article; threaded to
53
+ * <Seo>. Absent/empty ⇒ no alternate links (singleton unchanged). */
54
+ alternates?: import("@/lib/seo/alternates.js").Alternate[];
55
+ /** Overrides `<html lang>` (e.g. a translated article's `inLanguage`). Absent ⇒
56
+ * the tenant-locale default, so non-translated pages are unchanged. */
57
+ lang?: string;
41
58
  }
42
59
 
43
- const { title, description, path, ogType, ogImage, noindex, jsonLd } =
44
- Astro.props;
60
+ const {
61
+ title,
62
+ description,
63
+ path,
64
+ ogType,
65
+ ogImage,
66
+ noindex,
67
+ jsonLd,
68
+ feeds,
69
+ article,
70
+ imageAlt,
71
+ imageWidth,
72
+ imageHeight,
73
+ twitterSite,
74
+ twitterCreator,
75
+ canonicalOverride,
76
+ alternates,
77
+ lang,
78
+ } = Astro.props;
45
79
  const { tenant, themeStyle, canonicalBase, basePath, cspNonce, features, capabilities } =
46
80
  Astro.locals;
47
81
 
@@ -156,7 +190,7 @@ const footerWidgets = await renderWidgetSlot(
156
190
  ---
157
191
 
158
192
  <!doctype html>
159
- <html lang={tenant.locale.split("-")[0] ?? "en"} data-base-path={basePath || undefined}>
193
+ <html lang={lang ?? tenant.locale.split("-")[0] ?? "en"} data-base-path={basePath || undefined}>
160
194
  <head>
161
195
  <meta charset="utf-8" />
162
196
  <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -185,6 +219,15 @@ const footerWidgets = await renderWidgetSlot(
185
219
  ogImage={ogImage}
186
220
  noindex={noindex}
187
221
  jsonLd={allJsonLd}
222
+ feeds={feeds}
223
+ article={article}
224
+ imageAlt={imageAlt}
225
+ imageWidth={imageWidth}
226
+ imageHeight={imageHeight}
227
+ twitterSite={twitterSite}
228
+ twitterCreator={twitterCreator}
229
+ canonicalOverride={canonicalOverride}
230
+ alternates={alternates}
188
231
  />
189
232
 
190
233
  {
@@ -357,6 +400,8 @@ const footerWidgets = await renderWidgetSlot(
357
400
  function fix(a) {
358
401
  var href = a.getAttribute("href");
359
402
  if (!href || href.charAt(0) !== "/" || href.charAt(1) === "/") return;
403
+ // Global asset namespaces are root-served under every tenant.
404
+ if (href.indexOf("/tenants/") === 0 || href.indexOf("/shared/") === 0) return;
360
405
  if (href === base || href.indexOf(base + "/") === 0) return;
361
406
  a.setAttribute("href", base + href);
362
407
  }
@@ -1,17 +1,17 @@
1
1
  /**
2
- * Operational-activity ALERT conditions (card D7) — the detection layer on top of
3
- * the D5 query surface.
2
+ * Operational-activity ALERT conditions — the detection layer on top of
3
+ * the activity query surface.
4
4
  *
5
- * D1–D5 make operational activity queryable; D7 turns four specific, high-value
6
- * conditions in that stream into ALERTS. It deliberately follows the SAME shape as
7
- * this repo's only other alert contract, F14's `analytics/rumAlert.ts`: a set of
5
+ * The query surface makes operational activity queryable; this turns four specific,
6
+ * high-value conditions in that stream into ALERTS. It follows the same shape as
7
+ * this repo's other alert contract, `analytics/rumAlert.ts`: a set of
8
8
  * PURE, transport-agnostic detectors that take a window of activity and return a
9
- * structured `ActivityAlert[]`. WHERE the alerts go (a log line, a future
9
+ * structured `ActivityAlert[]`. WHERE the alerts go (a log line, a
10
10
  * PagerDuty/Slack sink, a polled endpoint) is the sink's job — there is NO outbound
11
- * notification transport in this repo today (rumAlert is likewise detection-only),
12
- * so D7 does not invent one. It also does NOT add a cron: the checker is on-demand
11
+ * notification transport in this repo (rumAlert is likewise detection-only),
12
+ * so this layer does not invent one. It also does NOT add a cron: the checker is on-demand
13
13
  * (computed when `/admin/ops-timeline` loads, or when the read-only alerts endpoint
14
- * is polled), exactly as the card scopes it.
14
+ * is polled).
15
15
  *
16
16
  * THE FOUR CONDITIONS (and how each avoids false-positive noise):
17
17
  *
@@ -37,7 +37,7 @@
37
37
  * still legitimately in flight (received < grace ago) never fires; a terminal
38
38
  * after the received (any status) clears it.
39
39
  *
40
- * 4. deploy-skew — promotes D5's visibility-only `deriveDeploySkew()` into an
40
+ * 4. deploy-skew — promotes the visibility-only `deriveDeploySkew()` into an
41
41
  * alert. `skewDetected` is true on EVERY normal rollout (two isolates coexist
42
42
  * briefly), so alerting on it directly would page on every deploy. Instead this
43
43
  * fires ONLY when the build serving this request differs from the newest
@@ -45,12 +45,10 @@
45
45
  * period (default 15m) — i.e. THIS isolate failed to roll to a deploy that
46
46
  * finished long ago (a persistent stale isolate), not a rollout in progress.
47
47
  *
48
- * DATA DEPENDENCY (honest wiring status, mirroring F16's `WiringStatus`). The
49
- * detectors read the D1 `activity_events` timeline through the D5 `queryActivity`
50
- * seam, so they alert on whatever is in that timeline. Getting SERVER-emitted
51
- * lifecycle events INTO D1 is the D1/D4 fan-out wiring (today `recordActivity()` is
52
- * logs-only; `deployVersion.ts` notes the swap to a timeline-fanning emitter is
53
- * pending) — NOT D7's job. `preview.serve` stays `analytics`-sink by design (too
48
+ * DATA DEPENDENCY. The detectors read the D1 `activity_events` timeline through the
49
+ * `queryActivity` seam, so they alert on whatever is in that timeline. Getting
50
+ * server-emitted lifecycle events into the timeline is the fan-out wiring's job, not
51
+ * this layer's. `preview.serve` stays `analytics`-sink by design (too
54
52
  * high-volume to timeline wholesale); detector 2 is source-agnostic (the rumAlert
55
53
  * precedent) and fires against an Analytics-Engine-sourced or seeded feed. See
56
54
  * `ALERT_CONDITIONS` for the per-condition source + wiring note.
@@ -63,10 +61,10 @@ import {
63
61
  } from "./query.js";
64
62
  import type { Queryable } from "../d1/catalog.js";
65
63
 
66
- /** Severity, mirroring F14's `rumAlert.AlertSeverity` / F16's `manifest.Severity`. */
64
+ /** Severity, mirroring `rumAlert.AlertSeverity` / `manifest.Severity`. */
67
65
  export type AlertSeverity = "page" | "critical" | "warning" | "info";
68
66
 
69
- /** The four D7 conditions. Stable ids — a downstream sink/dashboard keys on them. */
67
+ /** The four conditions. Stable ids — a downstream sink/dashboard keys on them. */
70
68
  export type ActivityAlertType =
71
69
  | "silent-terminal"
72
70
  | "recurring-preview-miss"
@@ -276,7 +274,7 @@ export interface DeploySkewAlertOptions {
276
274
  }
277
275
 
278
276
  /**
279
- * Promote D5's visibility-only `DeploySkewSummary` into an alert, WITHOUT firing on
277
+ * Promote the visibility-only `DeploySkewSummary` into an alert, WITHOUT firing on
280
278
  * every normal rollout. Fires only when the build serving this request differs from
281
279
  * the newest observed build AND that newest build has been latest for longer than
282
280
  * `graceMs` — i.e. THIS isolate never rolled to a deploy that finished long ago.
@@ -2,7 +2,7 @@
2
2
  * Owner-facing "who did this" attribution for a candidate change or aggregate —
3
3
  * DELIBERATELY separate from the anonymized activity spine. `ActivityActor.id`
4
4
  * is a salted, non-reversible hash by design (see `recordActivity.ts`'s header:
5
- * a raw PII id in the core zone would defeat the D0 contract), so it can never
5
+ * a raw PII id in the core zone would defeat the redaction contract), so it can never
6
6
  * answer "who submitted/accepted/rejected/shipped this" for an owner reviewing
7
7
  * `/admin#publish`. This persists the real email (never joined into the general
8
8
  * `queryActivity()` surface, never cross-tenant) alongside the existing hashed
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { displayNameFromEmail } from "./recordActivity";
19
19
 
20
- export type ChangeActorAction = "submit" | "reject" | "accept" | "ship" | "build";
20
+ export type ChangeActorAction = "submit" | "reject" | "resolve" | "accept" | "ship" | "build";
21
21
 
22
22
  export interface ChangeActorRecord {
23
23
  email: string;
@@ -69,7 +69,7 @@ export async function readChangeActors(
69
69
  tenantId: string,
70
70
  subjectId: string,
71
71
  ): Promise<Partial<Record<ChangeActorAction, ChangeActorRecord>>> {
72
- const actions: ChangeActorAction[] = ["submit", "reject", "accept", "ship", "build"];
72
+ const actions: ChangeActorAction[] = ["submit", "reject", "resolve", "accept", "ship", "build"];
73
73
  const out: Partial<Record<ChangeActorAction, ChangeActorRecord>> = {};
74
74
  await Promise.all(
75
75
  actions.map(async (action) => {