@tokenoftrust/storefront-runner 1.4.1 → 1.4.2-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/apps/storefront/astro.config.mjs +17 -1
  2. package/apps/storefront/integrations/materialize-guard.mjs +56 -0
  3. package/apps/storefront/migrations-apps/0002_good_switch.sql +26 -0
  4. package/apps/storefront/migrations-apps/0003_mute_marauders.sql +12 -0
  5. package/apps/storefront/migrations-apps/meta/0002_snapshot.json +1160 -0
  6. package/apps/storefront/migrations-apps/meta/0003_snapshot.json +1235 -0
  7. package/apps/storefront/migrations-apps/meta/_journal.json +14 -0
  8. package/apps/storefront/public/js/dashboard-team.js +38 -2
  9. package/apps/storefront/src/components/Seo.astro +65 -1
  10. package/apps/storefront/src/components/admin/AdminBlogDetailRail.astro +878 -0
  11. package/apps/storefront/src/components/admin/AdminBlogTab.astro +1432 -0
  12. package/apps/storefront/src/components/admin/AdminCustomersTab.astro +3 -7
  13. package/apps/storefront/src/components/admin/AdminPublishTab.astro +1105 -2334
  14. package/apps/storefront/src/components/admin/AdminSubscriptionsTab.astro +1 -3
  15. package/apps/storefront/src/components/blog/BlogPaywall.astro +87 -0
  16. package/apps/storefront/src/components/blog/PostAccessBadge.astro +43 -0
  17. package/apps/storefront/src/components/blog/PostGrid.astro +57 -0
  18. package/apps/storefront/src/components/blog/ReactionBar.astro +214 -0
  19. package/apps/storefront/src/layouts/Layout.astro +48 -3
  20. package/apps/storefront/src/lib/activity/alerts.ts +17 -19
  21. package/apps/storefront/src/lib/activity/changeActorAttribution.ts +3 -3
  22. package/apps/storefront/src/lib/activity/deployVersion.ts +19 -26
  23. package/apps/storefront/src/lib/activity/ingest.ts +8 -8
  24. package/apps/storefront/src/lib/activity/ingestAuth.ts +1 -1
  25. package/apps/storefront/src/lib/activity/killSwitch.ts +6 -6
  26. package/apps/storefront/src/lib/activity/query.ts +9 -9
  27. package/apps/storefront/src/lib/activity/recordActivity.ts +11 -15
  28. package/apps/storefront/src/lib/activity/store.ts +4 -6
  29. package/apps/storefront/src/lib/activity/uiActor.ts +12 -12
  30. package/apps/storefront/src/lib/activity/workerCommit.ts +6 -6
  31. package/apps/storefront/src/lib/admin/adminShell.ts +290 -0
  32. package/apps/storefront/src/lib/admin/ai/approvalMode.ts +2 -3
  33. package/apps/storefront/src/lib/admin/ai/audit.ts +3 -5
  34. package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +7 -7
  35. package/apps/storefront/src/lib/admin/checkoutStyleTarget.ts +3 -3
  36. package/apps/storefront/src/lib/admin/orderClient.ts +3 -5
  37. package/apps/storefront/src/lib/admin/services/orders.ts +17 -19
  38. package/apps/storefront/src/lib/admin/viewmodels/aiWorkflows.ts +4 -6
  39. package/apps/storefront/src/lib/admin/viewmodels/settings.ts +1 -1
  40. package/apps/storefront/src/lib/apps/adminService.ts +4 -4
  41. package/apps/storefront/src/lib/apps/adminSession.ts +53 -44
  42. package/apps/storefront/src/lib/apps/apiAuth.ts +2 -2
  43. package/apps/storefront/src/lib/apps/apiRoute.ts +2 -2
  44. package/apps/storefront/src/lib/apps/catalogMapper.ts +1 -1
  45. package/apps/storefront/src/lib/apps/credentials.ts +2 -2
  46. package/apps/storefront/src/lib/apps/gatewayKeys.ts +4 -4
  47. package/apps/storefront/src/lib/apps/healthAggregate.ts +5 -5
  48. package/apps/storefront/src/lib/apps/orders/attributionService.ts +2 -2
  49. package/apps/storefront/src/lib/apps/orders/customerHash.ts +1 -1
  50. package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +12 -12
  51. package/apps/storefront/src/lib/apps/orders/idempotency.ts +7 -7
  52. package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +1 -1
  53. package/apps/storefront/src/lib/apps/orders/orderMapper.ts +2 -2
  54. package/apps/storefront/src/lib/apps/orders/ordersStore.ts +6 -6
  55. package/apps/storefront/src/lib/apps/registryService.ts +6 -6
  56. package/apps/storefront/src/lib/apps/scopes.ts +2 -2
  57. package/apps/storefront/src/lib/apps/tokenIssuer.ts +4 -4
  58. package/apps/storefront/src/lib/apps/tokenVerifier.ts +6 -6
  59. package/apps/storefront/src/lib/apps/widgets/eligibility.ts +2 -2
  60. package/apps/storefront/src/lib/apps/widgets/frameProps.ts +3 -3
  61. package/apps/storefront/src/lib/apps/widgets/launchToken.ts +4 -4
  62. package/apps/storefront/src/lib/apps/widgets/placements.ts +2 -2
  63. package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +1 -1
  64. package/apps/storefront/src/lib/auth/adminEntry.ts +9 -9
  65. package/apps/storefront/src/lib/auth/identityToken.ts +6 -14
  66. package/apps/storefront/src/lib/auth/loginGate.ts +75 -13
  67. package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +2 -4
  68. package/apps/storefront/src/lib/auth/operatorSession.ts +113 -0
  69. package/apps/storefront/src/lib/auth/session.ts +7 -8
  70. package/apps/storefront/src/lib/auth/totAccessClient.ts +8 -18
  71. package/apps/storefront/src/lib/basePath.ts +9 -2
  72. package/apps/storefront/src/lib/blog/access.ts +29 -0
  73. package/apps/storefront/src/lib/blog/collection.ts +313 -0
  74. package/apps/storefront/src/lib/blog/markdown.ts +174 -0
  75. package/apps/storefront/src/lib/blog/provider.ts +5 -0
  76. package/apps/storefront/src/lib/blog/reactions.ts +87 -0
  77. package/apps/storefront/src/lib/blog/rss.ts +92 -0
  78. package/apps/storefront/src/lib/blog/teaser.ts +83 -0
  79. package/apps/storefront/src/lib/blog/types.ts +34 -0
  80. package/apps/storefront/src/lib/blog/viewCounts.ts +243 -0
  81. package/apps/storefront/src/lib/d1/catalog.ts +2 -2
  82. package/apps/storefront/src/lib/d1/schema-apps.ts +96 -17
  83. package/apps/storefront/src/lib/dev/activityIngestToken.ts +2 -4
  84. package/apps/storefront/src/lib/dev/activityStore.ts +3 -5
  85. package/apps/storefront/src/lib/dev/apiBase.ts +1 -4
  86. package/apps/storefront/src/lib/dev/cockpitStore.ts +1 -9
  87. package/apps/storefront/src/lib/dev/devDraft.ts +1 -1
  88. package/apps/storefront/src/lib/dev/previewStatus.ts +9 -21
  89. package/apps/storefront/src/lib/dev/rendezvousBroker.ts +9 -24
  90. package/apps/storefront/src/lib/edgeCache.ts +17 -0
  91. package/apps/storefront/src/lib/http/fetchTransport.ts +63 -0
  92. package/apps/storefront/src/lib/i18n.ts +25 -0
  93. package/apps/storefront/src/lib/jsonld.ts +118 -0
  94. package/apps/storefront/src/lib/monitoring/manifest.ts +11 -11
  95. package/apps/storefront/src/lib/newsletter/confirmationEmail.ts +30 -0
  96. package/apps/storefront/src/lib/newsletter/consent.ts +23 -0
  97. package/apps/storefront/src/lib/newsletter/digest.ts +117 -0
  98. package/apps/storefront/src/lib/newsletter/resendClient.ts +79 -0
  99. package/apps/storefront/src/lib/newsletter/send.ts +265 -0
  100. package/apps/storefront/src/lib/newsletter/subscribers.ts +156 -0
  101. package/apps/storefront/src/lib/publish/adminPublishTab.ts +3519 -0
  102. package/apps/storefront/src/lib/publish/apex-readiness.ts +41 -18
  103. package/apps/storefront/src/lib/publish/dispatchHealth.ts +5 -7
  104. package/apps/storefront/src/lib/publish/domainState.ts +22 -13
  105. package/apps/storefront/src/lib/publish/panelPolling.ts +78 -0
  106. package/apps/storefront/src/lib/publish/shipWorkspace.ts +100 -49
  107. package/apps/storefront/src/lib/publish/stageBundleUpload.ts +215 -0
  108. package/apps/storefront/src/lib/rate/kvWindow.ts +53 -0
  109. package/apps/storefront/src/lib/search/index.ts +2 -2
  110. package/apps/storefront/src/lib/seo/alternates.ts +42 -0
  111. package/apps/storefront/src/lib/seo/meta.ts +65 -0
  112. package/apps/storefront/src/lib/social/golive.ts +212 -0
  113. package/apps/storefront/src/lib/social/notify.ts +32 -0
  114. package/apps/storefront/src/lib/social/tokenStore.ts +171 -0
  115. package/apps/storefront/src/lib/the-build/provider.ts +27 -0
  116. package/apps/storefront/src/lib/the-build/readingTime.ts +35 -0
  117. package/apps/storefront/src/lib/the-build/toc.ts +95 -0
  118. package/apps/storefront/src/lib/tot/ToTClient.ts +1 -1
  119. package/apps/storefront/src/lib/tot/d1Client.ts +2 -2
  120. package/apps/storefront/src/lib/tot/totClientInterface.ts +3 -4
  121. package/apps/storefront/src/lib/webhooks/deliveryStore.ts +16 -17
  122. package/apps/storefront/src/middleware/index.ts +113 -2
  123. package/apps/storefront/src/pages/admin.astro +10 -1312
  124. package/apps/storefront/src/pages/api/blog/[collection]/[slug]/react.ts +104 -0
  125. package/apps/storefront/src/pages/api/newsletter/[collection]/subscribe.ts +146 -0
  126. package/apps/storefront/src/pages/api/newsletter/confirm.ts +75 -0
  127. package/apps/storefront/src/pages/api/newsletter/unsubscribe.ts +90 -0
  128. package/apps/storefront/src/pages/api/publish/stage-bundle.ts +70 -0
  129. package/apps/storefront/src/pages/api/rum/vitals.ts +21 -2
  130. package/apps/storefront/src/pages/blog/[slug].astro +96 -16
  131. package/apps/storefront/src/pages/blog/author/[author].astro +140 -0
  132. package/apps/storefront/src/pages/blog/category/[category].astro +67 -0
  133. package/apps/storefront/src/pages/blog/index.astro +2 -0
  134. package/apps/storefront/src/pages/blog/tag/[tag].astro +65 -0
  135. package/apps/storefront/src/pages/cockpit.astro +37 -12
  136. package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +15 -11
  137. package/apps/storefront/src/pages/search.astro +1 -1
  138. package/apps/storefront/src/pages/sitemap.xml.ts +20 -0
  139. package/apps/storefront/src/pages/the-build/[slug].astro +129 -0
  140. package/apps/storefront/src/pages/the-build/index.astro +146 -0
  141. package/apps/storefront/src/pages/the-build/rss.xml.ts +33 -0
  142. package/apps/storefront/src/styles/admin.css +1080 -0
  143. package/apps/storefront/tsconfig.json +5 -0
  144. package/package.json +1 -1
  145. package/packages/public-runtime/package.json +1 -0
  146. package/packages/public-runtime/src/activity/catalog.ts +52 -0
  147. package/packages/public-runtime/src/catalog-d1.ts +1 -1
  148. package/packages/public-runtime/src/customization-preview.ts +25 -2
  149. package/packages/public-runtime/src/customization-runtime.ts +125 -3
  150. package/packages/public-runtime/src/customization-versioning.ts +10 -0
  151. package/packages/public-runtime/src/index.ts +1 -0
  152. package/packages/public-runtime/src/static-bundle.ts +357 -0
  153. package/packages/public-runtime/src/tenant.ts +36 -8
  154. package/scripts/build/copy-tenant-assets.mjs +14 -4
  155. package/scripts/dev/ai-edit.mjs +1 -1
  156. package/scripts/dev/checkout-watch.mjs +2 -2
  157. package/scripts/dev/file-browser.mjs +2 -4
  158. package/scripts/dev/file-writer.mjs +1 -1
  159. package/scripts/dev/git-status.mjs +2 -2
  160. package/scripts/dev/locate-handler.mjs +3 -3
  161. package/scripts/dev/port-check.mjs +3 -3
  162. package/scripts/dev/publish.mjs +5 -9
  163. package/scripts/dev/shot.mjs +1 -1
  164. package/scripts/dev/unified-diff.mjs +8 -3
  165. package/scripts/tot-dev.mjs +6 -9
  166. package/apps/storefront/src/lib/dev/vcBinding.ts +0 -80
@@ -1,7 +1,15 @@
1
1
  ---
2
2
  import Layout from "@/layouts/Layout.astro";
3
- import { getBlogPost, resolveBlogRedirect } from "@/lib/blog/provider.js";
4
- import { breadcrumbLd } from "@/lib/jsonld.js";
3
+ import BlogPaywall from "@/components/blog/BlogPaywall.astro";
4
+ import ReactionBar from "@/components/blog/ReactionBar.astro";
5
+ import { getBlogPost, listBlogPosts, resolveBlogRedirect } from "@/lib/blog/provider.js";
6
+ import { authorSlug, getTranslations } from "@/lib/blog/collection.js";
7
+ import { viewerHasBlogAccess } from "@/lib/blog/access.js";
8
+ import { articleBodyHtml } from "@/lib/blog/teaser.js";
9
+ import { readViewerSession, safeNext } from "@/lib/auth/route.js";
10
+ import { buildAlternates } from "@/lib/seo/alternates.js";
11
+ import { languageLabel } from "@/lib/i18n.js";
12
+ import { breadcrumbLd, blogPostingLd } from "@/lib/jsonld.js";
5
13
 
6
14
  const { tenant, canonicalBase, basePath } = Astro.locals;
7
15
  const slug = Astro.params.slug ?? "";
@@ -12,26 +20,55 @@ if (!post) {
12
20
  return new Response(null, { status: 404 });
13
21
  }
14
22
 
23
+ // Entitlement (⟂ visibility): a gated post is still published/listed, but an
24
+ // unentitled reader sees a teaser + paywall rather than the full body. A teaser
25
+ // is a legitimate partial-content 200 — indexable, NOT noindexed (B4.0 spike §4).
26
+ const viewer = await readViewerSession(Astro);
27
+ const gated = (post.access ?? "public") !== "public";
28
+ const unlock = viewerHasBlogAccess(viewer, post);
29
+ const bodyHtml = articleBodyHtml(post.contentHtml, { gated, unlock });
30
+ const next = safeNext(`${basePath}/blog/${post.slug}/`);
31
+ const loginHref = `/auth/login?next=${encodeURIComponent(next)}`;
32
+
15
33
  const canonical = new URL(`/blog/${post.slug}/`, canonicalBase + "/").toString();
16
- const articleLd = {
17
- "@context": "https://schema.org",
18
- "@type": "BlogPosting",
19
- headline: post.title,
20
- description: post.description,
34
+ // The author archive (`/blog/author/<slug>`) lists posts from the curated blog
35
+ // index, and 404s for an author with no LISTED post (an unlisted-only or
36
+ // index-omitted byline). Resolve that up front so the byline links ONLY when the
37
+ // archive will actually render — a byline must never point at a missing page
38
+ // (the invariant `blog/author/[author].astro` documents). Same slug + same source
39
+ // the archive itself filters on, so the two can't disagree.
40
+ const authorArchiveSlug = authorSlug(post.author);
41
+ const authorHasArchive = (await listBlogPosts(tenant.tenant_id)).some(
42
+ (p) => authorSlug(p.author) === authorArchiveSlug,
43
+ );
44
+ const authorUrl = authorHasArchive
45
+ ? new URL(`/blog/author/${authorArchiveSlug}/`, canonicalBase + "/").toString()
46
+ : undefined;
47
+ const articleLd = blogPostingLd(post, {
21
48
  url: canonical,
22
- mainEntityOfPage: canonical,
23
- datePublished: post.publishedAt,
24
- dateModified: post.modifiedAt,
25
- author: { "@type": "Person", name: post.author },
26
- publisher: { "@type": "Organization", name: tenant.displayName, url: canonicalBase },
27
- ...(post.featuredImage ? { image: new URL(post.featuredImage.src, canonicalBase + "/").toString() } : {}),
28
- };
49
+ canonicalBase,
50
+ publisherName: tenant.displayName,
51
+ authorUrl,
52
+ });
29
53
  const crumbs = breadcrumbLd([
30
54
  { label: tenant.displayName, href: "/" },
31
55
  { label: "Blog", href: "/blog/" },
32
56
  { label: post.title, href: `/blog/${post.slug}/` },
33
57
  ], canonicalBase);
34
58
  const blogHref = `${basePath}/blog/`;
59
+ const authorHref = authorHasArchive
60
+ ? `${basePath}/blog/author/${authorArchiveSlug}/`
61
+ : null;
62
+
63
+ // I2 — hreflang alternates + per-variant <html lang>. Singleton ⇒ [] alternates
64
+ // and no `lang` override, so a non-translated post is byte-identical to today.
65
+ const translations = post.translationGroupId
66
+ ? await getTranslations(tenant.tenant_id, "blog", post.slug)
67
+ : [];
68
+ const alternates = buildAlternates(translations, (s) =>
69
+ new URL(`/blog/${s}/`, canonicalBase + "/").toString(),
70
+ );
71
+ const lang = alternates.length ? (post.inLanguage ?? "en") : undefined;
35
72
  ---
36
73
 
37
74
  <Layout
@@ -41,6 +78,19 @@ const blogHref = `${basePath}/blog/`;
41
78
  ogType="article"
42
79
  ogImage={post.featuredImage?.src}
43
80
  jsonLd={[crumbs, articleLd]}
81
+ article={{
82
+ publishedTime: post.publishedAt,
83
+ modifiedTime: post.modifiedAt,
84
+ author: authorUrl,
85
+ section: post.categories[0],
86
+ tags: post.tags,
87
+ }}
88
+ imageAlt={post.featuredImage?.alt}
89
+ imageWidth={post.featuredImage?.width}
90
+ imageHeight={post.featuredImage?.height}
91
+ canonicalOverride={post.canonicalUrl}
92
+ alternates={alternates}
93
+ lang={lang}
44
94
  >
45
95
  <article class="article-shell">
46
96
  <nav class="breadcrumbs" aria-label="Breadcrumb"><a href={blogHref}>Blog</a><span aria-hidden="true">/</span><span>{post.categories[0] ?? "Article"}</span></nav>
@@ -49,10 +99,27 @@ const blogHref = `${basePath}/blog/`;
49
99
  <h1>{post.title}</h1>
50
100
  <p class="dek">{post.excerpt}</p>
51
101
  <div class="byline">
52
- <span>By {post.author}</span>
102
+ <span>By {authorHref
103
+ ? <a href={authorHref} class="byline-author" rel="author">{post.author}</a>
104
+ : <span class="byline-author">{post.author}</span>}</span>
53
105
  <span aria-hidden="true">·</span>
54
106
  <time datetime={post.publishedAt}>{new Date(post.publishedAt).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}</time>
55
107
  </div>
108
+ {translations.length > 1 && (
109
+ <nav class="lang-switcher" aria-label="Available languages">
110
+ {translations.map((variant) => {
111
+ const code = variant.inLanguage ?? "en";
112
+ const current = variant.slug === post.slug;
113
+ return (
114
+ <a
115
+ hreflang={code}
116
+ href={`${basePath}/blog/${variant.slug}/`}
117
+ aria-current={current ? "true" : undefined}
118
+ >{languageLabel(code)}</a>
119
+ );
120
+ })}
121
+ </nav>
122
+ )}
56
123
  </header>
57
124
  {post.featuredImage && (
58
125
  <img
@@ -70,8 +137,14 @@ const blogHref = `${basePath}/blog/`;
70
137
  <a href={blogHref}>← All articles</a>
71
138
  {post.tags.length > 0 && <div class="article-tags">{post.tags.map((tag) => <span>{tag}</span>)}</div>}
72
139
  </aside>
73
- <div class="blog-body" set:html={post.contentHtml}></div>
140
+ <div class="blog-main">
141
+ <div class="blog-body" set:html={bodyHtml}></div>
142
+ {gated && !unlock && (
143
+ <BlogPaywall access={post.access ?? "authenticated"} next={next} signedIn={!!viewer} loginHref={loginHref} />
144
+ )}
145
+ </div>
74
146
  </div>
147
+ <ReactionBar collection="blog" slug={post.slug} />
75
148
  </article>
76
149
  </Layout>
77
150
 
@@ -84,12 +157,19 @@ const blogHref = `${basePath}/blog/`;
84
157
  h1 { margin-top: .9rem; font-size: clamp(2.5rem, 6vw, 5rem); line-height: 1.02; }
85
158
  .dek { max-width: 68ch; margin: 1.5rem auto 0; color: var(--color-muted); font-size: clamp(1rem, 2vw, 1.2rem); }
86
159
  .byline { display: flex; justify-content: center; gap: .55rem; margin-top: 1.35rem; color: var(--color-muted); font-size: .85rem; }
160
+ .byline-author { color: var(--color-accent-ink); font-weight: 700; text-decoration: none; }
161
+ .byline-author:hover { text-decoration: underline; text-underline-offset: .18em; }
162
+ .lang-switcher { display: flex; flex-wrap: wrap; justify-content: center; gap: .4rem; margin-top: 1.1rem; }
163
+ .lang-switcher a { padding: .28rem .6rem; border: 1px solid var(--color-border); border-radius: var(--radius-full); color: var(--color-muted); font-size: .78rem; font-weight: 600; text-decoration: none; }
164
+ .lang-switcher a:hover { color: var(--color-accent-ink); border-color: var(--color-accent); }
165
+ .lang-switcher a[aria-current="true"] { color: var(--color-accent-ink); border-color: var(--color-accent); background: var(--color-surface); }
87
166
  .featured { display: block; width: 100%; max-height: 620px; object-fit: cover; border-radius: var(--radius-lg); box-shadow: var(--shadow-md); }
88
167
  .article-grid { display: grid; grid-template-columns: 190px minmax(0, 720px); justify-content: center; gap: clamp(2rem, 6vw, 5rem); margin-top: clamp(3rem, 7vw, 5.5rem); }
89
168
  aside { align-self: start; position: sticky; top: 7rem; }
90
169
  aside > a { color: var(--color-accent-ink); font-size: .88rem; font-weight: 700; }
91
170
  .article-tags { display: flex; flex-wrap: wrap; gap: .45rem; margin-top: 1.2rem; }
92
171
  .article-tags span { padding: .3rem .55rem; border: 1px solid var(--color-border); border-radius: var(--radius-full); color: var(--color-muted); font-size: .7rem; }
172
+ .blog-main { min-width: 0; }
93
173
  .blog-body { min-width: 0; font-size: 1.05rem; line-height: 1.78; }
94
174
  .blog-body :global(p), .blog-body :global(ul), .blog-body :global(ol), .blog-body :global(blockquote), .blog-body :global(figure), .blog-body :global(table), .blog-body :global(pre) { margin-block: 1.25rem; }
95
175
  .blog-body :global(h2), .blog-body :global(h3), .blog-body :global(h4) { scroll-margin-top: 7rem; margin: 2.8rem 0 1rem; line-height: 1.18; }
@@ -0,0 +1,140 @@
1
+ ---
2
+ import Layout from "@/layouts/Layout.astro";
3
+ import PostAccessBadge from "@/components/blog/PostAccessBadge.astro";
4
+ import { listBlogPosts } from "@/lib/blog/provider.js";
5
+ import { authorSlug, getAuthors } from "@/lib/blog/collection.js";
6
+ import { breadcrumbLd } from "@/lib/jsonld.js";
7
+
8
+ // Author archive for the `/blog` collection. Lists exactly the posts whose
9
+ // bylines link here (same source as /blog/index.astro + /blog/[slug].astro),
10
+ // so a byline never points at a page missing its post.
11
+ const { tenant, basePath, canonicalBase } = Astro.locals;
12
+ const slug = Astro.params.author ?? "";
13
+
14
+ const allPosts = await listBlogPosts(tenant.tenant_id);
15
+ const posts = allPosts.filter((post) => authorSlug(post.author) === slug);
16
+ if (posts.length === 0) return new Response(null, { status: 404 });
17
+
18
+ const authorName = posts[0]!.author;
19
+ const bio = (await getAuthors(tenant.tenant_id, "blog"))[slug];
20
+
21
+ const href = (path: string) => `${basePath}${path}` || "/";
22
+ const canonical = new URL(`/blog/author/${slug}/`, canonicalBase + "/").toString();
23
+ const avatarUrl = bio?.avatar ? new URL(bio.avatar, canonicalBase + "/").toString() : undefined;
24
+
25
+ const personLd = {
26
+ "@context": "https://schema.org",
27
+ "@type": "Person",
28
+ name: authorName,
29
+ url: canonical,
30
+ ...(bio?.title ? { jobTitle: bio.title } : {}),
31
+ ...(bio?.bio ? { description: bio.bio } : {}),
32
+ ...(avatarUrl ? { image: avatarUrl } : {}),
33
+ ...(bio?.url ? { mainEntityOfPage: bio.url } : {}),
34
+ ...(bio?.sameAs && bio.sameAs.length > 0 ? { sameAs: bio.sameAs } : {}),
35
+ };
36
+ const profileLd = {
37
+ "@context": "https://schema.org",
38
+ "@type": "ProfilePage",
39
+ mainEntity: personLd,
40
+ };
41
+ const itemListLd = {
42
+ "@context": "https://schema.org",
43
+ "@type": "ItemList",
44
+ numberOfItems: posts.length,
45
+ itemListElement: posts.map((post, index) => ({
46
+ "@type": "ListItem",
47
+ position: index + 1,
48
+ name: post.title,
49
+ url: new URL(`/blog/${post.slug}/`, canonicalBase + "/").toString(),
50
+ })),
51
+ };
52
+ const crumbs = breadcrumbLd([
53
+ { label: tenant.displayName, href: "/" },
54
+ { label: "Blog", href: "/blog/" },
55
+ { label: authorName, href: `/blog/author/${slug}/` },
56
+ ], canonicalBase);
57
+ ---
58
+
59
+ <Layout
60
+ title={`${authorName} — Blog`}
61
+ description={bio?.bio ?? `Articles by ${authorName}.`}
62
+ path={`/blog/author/${slug}/`}
63
+ fullBleed
64
+ jsonLd={[crumbs, profileLd, itemListLd]}
65
+ >
66
+ <header class="author-hero">
67
+ <div class="blog-shell">
68
+ <nav class="breadcrumbs" aria-label="Breadcrumb"><a href={href("/blog/")}>Blog</a><span aria-hidden="true">/</span><span>Author</span></nav>
69
+ <div class="author-id">
70
+ {avatarUrl && <img class="avatar" src={avatarUrl} alt={authorName} width="96" height="96" decoding="async" />}
71
+ <div>
72
+ <p class="eyebrow eyebrow--tick">Author</p>
73
+ <h1>{authorName}</h1>
74
+ {bio?.title && <p class="author-title">{bio.title}</p>}
75
+ {bio?.bio && <p class="author-bio">{bio.bio}</p>}
76
+ {bio?.sameAs && bio.sameAs.length > 0 && (
77
+ <p class="author-links">
78
+ {bio.sameAs.map((url) => <a href={url} rel="me nofollow" target="_blank">{new URL(url).hostname.replace(/^www\./, "")}</a>)}
79
+ </p>
80
+ )}
81
+ </div>
82
+ </div>
83
+ </div>
84
+ </header>
85
+
86
+ <div class="blog-shell">
87
+ <section class="post-grid" aria-label={`Posts by ${authorName}`}>
88
+ {posts.map((post, index) => (
89
+ <article class="post-card">
90
+ <a href={href(`/blog/${post.slug}/`)} aria-label={`Read ${post.title}`}>
91
+ {post.featuredImage ? (
92
+ <img
93
+ src={post.featuredImage.src}
94
+ alt={post.featuredImage.alt}
95
+ width={post.featuredImage.width}
96
+ height={post.featuredImage.height}
97
+ loading={index < 3 ? "eager" : "lazy"}
98
+ decoding="async"
99
+ />
100
+ ) : <div class="image-placeholder" aria-hidden="true"></div>}
101
+ <div class="post-card-copy">
102
+ <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>
103
+ <PostAccessBadge access={post.access} />
104
+ <h2>{post.title}</h2>
105
+ <p>{post.excerpt}</p>
106
+ <span>Read article <span aria-hidden="true">→</span></span>
107
+ </div>
108
+ </a>
109
+ </article>
110
+ ))}
111
+ </section>
112
+ </div>
113
+ </Layout>
114
+
115
+ <style>
116
+ .blog-shell { width: min(1180px, calc(100% - 2rem)); margin-inline: auto; }
117
+ .author-hero { padding: clamp(3.5rem, 8vw, 6rem) 0 clamp(2rem, 5vw, 3.5rem); background: linear-gradient(145deg, var(--color-primary), color-mix(in srgb, var(--color-primary) 82%, var(--color-accent))); color: var(--color-primary-contrast); }
118
+ .breadcrumbs { display: flex; gap: .55rem; align-items: center; font-size: .84rem; color: color-mix(in srgb, var(--color-primary-contrast) 78%, transparent); }
119
+ .breadcrumbs a { color: inherit; font-weight: 700; }
120
+ .author-id { display: flex; gap: 1.5rem; align-items: center; margin-top: 1.5rem; }
121
+ .avatar { flex: 0 0 auto; width: 96px; height: 96px; border-radius: var(--radius-full); object-fit: cover; box-shadow: var(--shadow-md); }
122
+ .author-hero h1 { margin-top: .5rem; color: inherit; font-size: clamp(2.4rem, 6vw, 4.2rem); }
123
+ .author-hero .eyebrow { color: color-mix(in srgb, var(--color-primary-contrast) 75%, transparent); }
124
+ .author-title { margin-top: .35rem; font-weight: 700; color: color-mix(in srgb, var(--color-primary-contrast) 90%, transparent); }
125
+ .author-bio { max-width: 62ch; margin-top: 1rem; color: color-mix(in srgb, var(--color-primary-contrast) 82%, transparent); font-size: 1.05rem; }
126
+ .author-links { display: flex; flex-wrap: wrap; gap: 1rem; margin-top: 1.1rem; }
127
+ .author-links a { color: inherit; font-weight: 700; font-size: .85rem; text-decoration: underline; text-underline-offset: .18em; }
128
+ .post-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1.35rem; align-items: start; padding-block: clamp(2.5rem, 6vw, 4.5rem); }
129
+ .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; }
130
+ .post-card:hover { transform: translateY(-4px); box-shadow: var(--shadow-md); }
131
+ .post-card a { display: flex; height: 100%; flex-direction: column; }
132
+ .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)); }
133
+ .post-card-copy { display: flex; flex: 1; flex-direction: column; padding: 1.25rem; }
134
+ .post-meta { margin: 0 0 .7rem; color: var(--color-muted); font: 600 .72rem/1.45 var(--font-mono); text-transform: uppercase; letter-spacing: .05em; }
135
+ .post-card h2 { font-size: 1.18rem; line-height: 1.25; }
136
+ .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; }
137
+ .post-card-copy > span { margin-top: auto; color: var(--color-accent-ink); font-weight: 700; font-size: .88rem; }
138
+ @media (max-width: 980px) { .post-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
139
+ @media (max-width: 640px) { .author-id { flex-direction: column; align-items: flex-start; } .post-grid { grid-template-columns: 1fr; } }
140
+ </style>
@@ -0,0 +1,67 @@
1
+ ---
2
+ import Layout from "@/layouts/Layout.astro";
3
+ import PostGrid from "@/components/blog/PostGrid.astro";
4
+ import { listBlogPosts } from "@/lib/blog/provider.js";
5
+ import { collectPostsByTerm } from "@/lib/blog/collection.js";
6
+ import { breadcrumbLd } from "@/lib/jsonld.js";
7
+
8
+ const { tenant, basePath, canonicalBase } = Astro.locals;
9
+ const param = Astro.params.category ?? "";
10
+
11
+ // Same source as the /blog index (the collection's curated list), filtered to
12
+ // one category — so a post can never appear here that isn't already live on /blog.
13
+ const all = await listBlogPosts(tenant.tenant_id);
14
+ const match = collectPostsByTerm(all, "categories", param);
15
+ if (!match) return new Response(null, { status: 404 });
16
+ const { label, posts } = match;
17
+
18
+ const path = `/blog/category/${param}/`;
19
+ const itemList = {
20
+ "@context": "https://schema.org",
21
+ "@type": "ItemList",
22
+ numberOfItems: posts.length,
23
+ itemListElement: posts.map((post, index) => ({
24
+ "@type": "ListItem",
25
+ position: index + 1,
26
+ name: post.title,
27
+ url: new URL(`/blog/${post.slug}/`, canonicalBase + "/").toString(),
28
+ })),
29
+ };
30
+ const crumbs = breadcrumbLd([
31
+ { label: tenant.displayName, href: "/" },
32
+ { label: "Blog", href: "/blog/" },
33
+ { label, href: path },
34
+ ], canonicalBase);
35
+ ---
36
+
37
+ <Layout
38
+ title={`${label} — Blog`}
39
+ description={`Articles in ${label} from ${tenant.displayName}.`}
40
+ path={path}
41
+ fullBleed
42
+ jsonLd={[crumbs, itemList]}
43
+ >
44
+ <header class="blog-hero">
45
+ <div class="blog-shell">
46
+ <nav class="breadcrumbs" aria-label="Breadcrumb"><a href={`${basePath}/blog/`}>Blog</a><span aria-hidden="true">/</span><span>Category</span></nav>
47
+ <p class="eyebrow eyebrow--tick">Category</p>
48
+ <h1>{label}</h1>
49
+ <p>{posts.length} {posts.length === 1 ? "article" : "articles"} in this category.</p>
50
+ </div>
51
+ </header>
52
+
53
+ <div class="blog-shell blog-layout">
54
+ <PostGrid posts={posts} basePath={basePath} base="/blog" />
55
+ </div>
56
+ </Layout>
57
+
58
+ <style>
59
+ .blog-shell { width: min(1180px, calc(100% - 2rem)); margin-inline: auto; }
60
+ .blog-hero { padding: clamp(4.5rem, 9vw, 8rem) 0 clamp(3rem, 6vw, 5rem); background: linear-gradient(145deg, var(--color-primary), color-mix(in srgb, var(--color-primary) 82%, var(--color-accent))); color: var(--color-primary-contrast); }
61
+ .breadcrumbs { display: flex; gap: .55rem; align-items: center; margin-bottom: 1rem; color: color-mix(in srgb, var(--color-primary-contrast) 78%, transparent); font-size: .84rem; }
62
+ .breadcrumbs a { color: inherit; font-weight: 700; }
63
+ .blog-hero h1 { max-width: 14ch; margin-top: .75rem; color: inherit; font-size: clamp(2.6rem, 6vw, 4.6rem); }
64
+ .blog-hero p:last-child { max-width: 58ch; margin-top: 1rem; color: color-mix(in srgb, var(--color-primary-contrast) 82%, transparent); font-size: 1.05rem; }
65
+ .blog-hero .eyebrow { color: color-mix(in srgb, var(--color-primary-contrast) 75%, transparent); }
66
+ .blog-layout { padding-block: clamp(3rem, 7vw, 6rem); }
67
+ </style>
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import Layout from "@/layouts/Layout.astro";
3
+ import PostAccessBadge from "@/components/blog/PostAccessBadge.astro";
3
4
  import { listBlogPosts } from "@/lib/blog/provider.js";
4
5
 
5
6
  const { tenant, basePath, canonicalBase } = Astro.locals;
@@ -60,6 +61,7 @@ const itemList = {
60
61
  ) : <div class="image-placeholder" aria-hidden="true"></div>}
61
62
  <div class="post-card-copy">
62
63
  <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>
64
+ <PostAccessBadge access={post.access} />
63
65
  <h2>{post.title}</h2>
64
66
  <p>{post.excerpt}</p>
65
67
  <span>Read article <span aria-hidden="true">→</span></span>
@@ -0,0 +1,65 @@
1
+ ---
2
+ import Layout from "@/layouts/Layout.astro";
3
+ import PostGrid from "@/components/blog/PostGrid.astro";
4
+ import { listBlogPosts } from "@/lib/blog/provider.js";
5
+ import { collectPostsByTerm } from "@/lib/blog/collection.js";
6
+ import { breadcrumbLd } from "@/lib/jsonld.js";
7
+
8
+ const { tenant, basePath, canonicalBase } = Astro.locals;
9
+ const param = Astro.params.tag ?? "";
10
+
11
+ const all = await listBlogPosts(tenant.tenant_id);
12
+ const match = collectPostsByTerm(all, "tags", param);
13
+ if (!match) return new Response(null, { status: 404 });
14
+ const { label, posts } = match;
15
+
16
+ const path = `/blog/tag/${param}/`;
17
+ const itemList = {
18
+ "@context": "https://schema.org",
19
+ "@type": "ItemList",
20
+ numberOfItems: posts.length,
21
+ itemListElement: posts.map((post, index) => ({
22
+ "@type": "ListItem",
23
+ position: index + 1,
24
+ name: post.title,
25
+ url: new URL(`/blog/${post.slug}/`, canonicalBase + "/").toString(),
26
+ })),
27
+ };
28
+ const crumbs = breadcrumbLd([
29
+ { label: tenant.displayName, href: "/" },
30
+ { label: "Blog", href: "/blog/" },
31
+ { label: `#${label}`, href: path },
32
+ ], canonicalBase);
33
+ ---
34
+
35
+ <Layout
36
+ title={`#${label} — Blog`}
37
+ description={`Articles tagged ${label} from ${tenant.displayName}.`}
38
+ path={path}
39
+ fullBleed
40
+ jsonLd={[crumbs, itemList]}
41
+ >
42
+ <header class="blog-hero">
43
+ <div class="blog-shell">
44
+ <nav class="breadcrumbs" aria-label="Breadcrumb"><a href={`${basePath}/blog/`}>Blog</a><span aria-hidden="true">/</span><span>Tag</span></nav>
45
+ <p class="eyebrow eyebrow--tick">Tag</p>
46
+ <h1>#{label}</h1>
47
+ <p>{posts.length} {posts.length === 1 ? "article" : "articles"} with this tag.</p>
48
+ </div>
49
+ </header>
50
+
51
+ <div class="blog-shell blog-layout">
52
+ <PostGrid posts={posts} basePath={basePath} base="/blog" />
53
+ </div>
54
+ </Layout>
55
+
56
+ <style>
57
+ .blog-shell { width: min(1180px, calc(100% - 2rem)); margin-inline: auto; }
58
+ .blog-hero { padding: clamp(4.5rem, 9vw, 8rem) 0 clamp(3rem, 6vw, 5rem); background: linear-gradient(145deg, var(--color-primary), color-mix(in srgb, var(--color-primary) 82%, var(--color-accent))); color: var(--color-primary-contrast); }
59
+ .breadcrumbs { display: flex; gap: .55rem; align-items: center; margin-bottom: 1rem; color: color-mix(in srgb, var(--color-primary-contrast) 78%, transparent); font-size: .84rem; }
60
+ .breadcrumbs a { color: inherit; font-weight: 700; }
61
+ .blog-hero h1 { max-width: 14ch; margin-top: .75rem; color: inherit; font-size: clamp(2.6rem, 6vw, 4.6rem); }
62
+ .blog-hero p:last-child { max-width: 58ch; margin-top: 1rem; color: color-mix(in srgb, var(--color-primary-contrast) 82%, transparent); font-size: 1.05rem; }
63
+ .blog-hero .eyebrow { color: color-mix(in srgb, var(--color-primary-contrast) 75%, transparent); }
64
+ .blog-layout { padding-block: clamp(3rem, 7vw, 6rem); }
65
+ </style>
@@ -27,7 +27,6 @@ import { readViewerSession } from "@/lib/auth/route";
27
27
  import { staticTenants } from "@/config/tenants";
28
28
  import { resolvePreviewTenant } from "@/config/previewTenantLookup";
29
29
  import { readTenantPreviewStatus, isStoreNotSetUp } from "@/lib/dev/previewStatus";
30
- import { ensureCockpitVcBinding } from "@/lib/dev/vcBinding";
31
30
  import { resolveDevApiBase } from "@/lib/dev/apiBase";
32
31
  import { resolveCockpitStoreResource } from "@/lib/dev/cockpitStore";
33
32
  import { normalizeEmailHint, redactEmailForHint } from "@/lib/privacy/emailHint";
@@ -143,9 +142,19 @@ const canSetupStore = hasStoreAccess && !noResolvableStore && !storeNotSetUp;
143
142
  // off the hot render path; the authority-seam binding + backfill remain the real
144
143
  // guarantee. Gated on canSetupStore (entitled + resolvable + provisioned), the exact
145
144
  // condition under which we render a runnable command.
145
+ //
146
+ // The binding itself is control-plane (authors a forge/MCP binding under the app
147
+ // credential), so this renderer page reaches it only over the network — POST
148
+ // /api/dev/ensure-vc-binding — never by static import (renderer-carve moat). The
149
+ // endpoint re-derives the developer from the forwarded session cookie.
146
150
  if (canSetupStore && session && !Astro.cookies.has("tot_vc_bound")) {
147
- const vcBind = await ensureCockpitVcBinding(session.email);
148
- if (vcBind.ok) {
151
+ const vcBind = await fetch(new URL(`${basePath}/api/dev/ensure-vc-binding`, url.origin), {
152
+ method: "POST",
153
+ headers: { cookie: Astro.request.headers.get("cookie") ?? "" },
154
+ })
155
+ .then((r) => (r.ok ? r.json() : null))
156
+ .catch(() => null);
157
+ if (vcBind?.ok) {
149
158
  // Throttle only on a real success — a fail-open miss retries on the next load.
150
159
  Astro.cookies.set("tot_vc_bound", "1", {
151
160
  path: "/",
@@ -896,10 +905,18 @@ const hostedActivityScript = `
896
905
  }
897
906
 
898
907
  // Item 6 — once the CLI reports the developer's $EDITOR, show the EXACT command to
899
- // open the first file to edit (e.g. \`code content/home.html\`); otherwise a generic
900
- // \`$EDITOR content/home.html\` plus a nudge to set $EDITOR.
908
+ // open the first file to edit (e.g. \`code content/home.html\`). Without one, guess
909
+ // from the browser's OS (a reasonable proxy for the local dev machine in the common
910
+ // case of viewing the cockpit on the same laptop) and default to VS Code (\`code\`) —
911
+ // the most common case — with an alternate + AI-agent note underneath.
901
912
  var editOpenCmd = document.getElementById("editOpenCmd");
902
913
  var editOpenHint = document.getElementById("editOpenHint");
914
+ function platformOpenDirCmd(dirArg) {
915
+ var plat = String((navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || navigator.userAgent || "");
916
+ if (/win/i.test(plat)) return "explorer " + dirArg;
917
+ if (/mac/i.test(plat)) return "open " + dirArg;
918
+ return "xdg-open " + dirArg;
919
+ }
903
920
  function updateEditHint(rt) {
904
921
  if (!editOpenCmd) return;
905
922
  var editor = rt && typeof rt.editor === "string" ? rt.editor : "";
@@ -907,13 +924,18 @@ const hostedActivityScript = `
907
924
  // Prefer the FULL path (works from any directory) when the CLI reports the
908
925
  // project dir; else a project-relative path with a "run it from there" note.
909
926
  var file = cwd ? (cwd.replace(/\\/+$/, "") + "/content/home.html") : "content/home.html";
910
- editOpenCmd.textContent = (editor || "$EDITOR") + " " + file;
927
+ editOpenCmd.textContent = (editor || "code") + " " + file;
911
928
  if (editOpenHint) {
912
- editOpenHint.textContent = editor
913
- ? (cwd
914
- ? "Run that anywhere to open the file — then change a line and save."
915
- : "Run that from your project directory — then change a line and save.")
916
- : "Tip: set $EDITOR (e.g. \`export EDITOR=\\"code\\"\`) and we'll show the exact open command here.";
929
+ if (editor) {
930
+ editOpenHint.textContent = cwd
931
+ ? "Run that anywhere to open the file — then change a line and save."
932
+ : "Run that from your project directory — then change a line and save.";
933
+ } else {
934
+ var altCmd = platformOpenDirCmd(cwd || ".");
935
+ editOpenHint.textContent = "Using VS Code? Run that. Prefer Cursor, another editor, or an AI coding " +
936
+ "agent like Claude? Same idea — open the folder (\`" + altCmd + "\`) or file above and edit there. " +
937
+ "Set $EDITOR and we'll show your exact command instead.";
938
+ }
917
939
  }
918
940
  }
919
941
 
@@ -1595,6 +1617,8 @@ const hostedActivityScript = `
1595
1617
 
1596
1618
  /* ---- Step 2 "lay it beside your editor" tip ---- */
1597
1619
  .tip-lay { margin: 1rem 0 0; font-size: .9rem; color: var(--muted); line-height: 1.5; }
1620
+ .tip-branch { margin: .5rem 0 0; font-size: .9rem; color: var(--muted); line-height: 1.5; }
1621
+ .tip-branch a { color: inherit; text-decoration: underline; }
1598
1622
 
1599
1623
  /* ---- Guided accordion: a step body animates IN when opened (collapse is instant
1600
1624
  + synchronous so the flow can't get stuck). Shared with isLocalDevLoop —
@@ -2260,7 +2284,8 @@ const hostedActivityScript = `
2260
2284
  <span class="cmd" id="editOpenCmd">$EDITOR content/home.html</span></div>
2261
2285
  </div>
2262
2286
  <p class="cmd-note" id="editOpenHint">Tip: set <code>$EDITOR</code> and we'll show the exact open command here.</p>
2263
- <p class="tip-lay">Lay this cockpit <em>beside</em> your editor and your running store — hot reload is on, so every save reloads the store and lands in <strong>Your activity</strong> here, live.</p>
2287
+ <p class="tip-lay">Lay this cockpit <em>beside</em> your editor (or an AI coding agent like Claude — same idea) and your running store — hot reload is on, so every save reloads the store and lands in <strong>Your activity</strong> here, live. Keep the terminal running <code>tot start</code>/<code>tot dev</code> open too, watching for build errors — need the command line for something else? Open a new terminal instead of closing that one.</p>
2288
+ <p class="tip-branch">No need to think about git branches for this — just keep editing and running <code>tot preview</code>; it updates the same change every time. <a href="https://github.com/tokenoftrust/storefront/blob/feature/8500-storefront-integration/docs/kb/develop-ship-and-connect-domain.md#branching-is-an-advanced-feature--you-almost-never-need-it" target="_blank" rel="noopener">Working on two things at once? Read this first →</a></p>
2264
2289
  </div>
2265
2290
  </section>
2266
2291
  </div>
@@ -127,19 +127,23 @@ const backHref = `/dashboard/${encodeURIComponent(appDomain)}`;
127
127
  <div class="card">
128
128
  <label for="email">Developer email</label>
129
129
  <input id="email" type="email" placeholder="dev@agency.com" autocomplete="off" />
130
- {!isRealMember && (
131
- <>
132
- {/* #8453: ToT staff may grant any role; a plain member is developer-only
133
- (no selector — tot20 ignores a member's role and pins to developer).
134
- Values are tot20's role vocabulary: developer | appAdmin | appOwner. */}
135
- <label for="role">Role</label>
136
- <select id="role">
137
- <option value="developer" selected>Developer</option>
130
+ {/* tot20's role vocabulary: developer | appAdmin | appOwner | contributor.
131
+ `contributor` is a strictly-weaker self-downgrade a plain member may
132
+ request directly (tot20 honors it for a non-staff operator handoff
133
+ 2026-08-22-tot20-contributor-role); appAdmin/appOwner still require ToT
134
+ staff (#8453) and tot20 ignores them from a plain member (pins to
135
+ developer), so only staff see those two options. */}
136
+ <label for="role">Role</label>
137
+ <select id="role">
138
+ <option value="developer" selected>Developer</option>
139
+ <option value="contributor">Contributor (blog &amp; content only)</option>
140
+ {!isRealMember && (
141
+ <>
138
142
  <option value="appAdmin">Admin</option>
139
143
  <option value="appOwner">Owner</option>
140
- </select>
141
- </>
142
- )}
144
+ </>
145
+ )}
146
+ </select>
143
147
  <label for="note">Note to the developer (optional)</label>
144
148
  <textarea id="note" rows="3" maxlength="1000"
145
149
  placeholder="Add a personal message — they'll see it in the invite email."></textarea>
@@ -28,7 +28,7 @@ const isJson = url.searchParams.get("format") === "json";
28
28
  // Search goes through the ToTClient: D1 FTS5 when a D1 binding is wired, else a
29
29
  // Fuse index over the bundled snapshot (FixtureToTClient). It returns full
30
30
  // products, so the HTML grid renders directly and the typeahead just projects to
31
- // the compact shape. See epics/edge-read-replica.md.
31
+ // the compact shape.
32
32
 
33
33
  // --- JSON path: powers the header typeahead island. Must return BEFORE render.
34
34
  if (isJson) {
@@ -11,6 +11,8 @@
11
11
  import type { APIContext } from "astro";
12
12
  import { nonEmptyCollections } from "@/lib/tot/query.js";
13
13
  import { listBlogPosts } from "@/lib/blog/provider.js";
14
+ import { authorSlug } from "@/lib/blog/collection.js";
15
+ import { listBuildPosts } from "@/lib/the-build/provider.js";
14
16
 
15
17
  interface SitemapUrl {
16
18
  loc: string;
@@ -71,6 +73,24 @@ export async function GET(context: APIContext): Promise<Response> {
71
73
  for (const post of blogPosts) {
72
74
  urls.push({ loc: `${base}/blog/${post.slug}/`, lastmod: post.modifiedAt || post.publishedAt });
73
75
  }
76
+ // Author archives (blog/author/[author].astro): one per distinct byline,
77
+ // same source as the pages themselves so the sitemap never lists an
78
+ // archive that would 404 (and vice versa — the static publish's T3 gate
79
+ // requires every rendered route to appear here).
80
+ const authorSlugs = [
81
+ ...new Set(blogPosts.map((post) => authorSlug(post.author)).filter(Boolean)),
82
+ ].sort();
83
+ for (const slug of authorSlugs) {
84
+ urls.push({ loc: `${base}/blog/author/${slug}/` });
85
+ }
86
+ }
87
+
88
+ const buildPosts = await listBuildPosts(tenant.tenant_id);
89
+ if (buildPosts.length > 0) {
90
+ urls.push({ loc: `${base}/the-build/` });
91
+ for (const post of buildPosts) {
92
+ urls.push({ loc: `${base}/the-build/${post.slug}/`, lastmod: post.modifiedAt || post.publishedAt });
93
+ }
74
94
  }
75
95
 
76
96
  const body = `<?xml version="1.0" encoding="UTF-8"?>