@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,117 @@
1
+ /**
2
+ * Hand-rolled newsletter digest HTML builder — the email analogue of
3
+ * `renderRssFeed` (src/lib/blog/rss.ts): pure string concatenation, no
4
+ * templating dependency. Fed the engine's live-visible post list, so drafts /
5
+ * unlisted / not-yet-scheduled posts can never reach an inbox.
6
+ *
7
+ * The digest is built ONCE per send and carries a per-recipient unsubscribe
8
+ * token PLACEHOLDER ({@link UNSUBSCRIBE_TOKEN_PLACEHOLDER}); the send pipeline
9
+ * interpolates each subscriber's real token before handing the message to the
10
+ * ESP (see send.ts). The RFC 8058 `List-Unsubscribe` headers are set on the
11
+ * SEND call, not in this HTML body.
12
+ */
13
+ import type { BlogPostSummary } from "../blog/types.js";
14
+
15
+ /** Replaced per-recipient with the subscriber's opaque token at send time. */
16
+ export const UNSUBSCRIBE_TOKEN_PLACEHOLDER = "{{unsubscribe_token}}";
17
+
18
+ /** The unsubscribe endpoint path (N3 owns the route); shared with the send headers. */
19
+ export const UNSUBSCRIBE_PATH = "/api/newsletter/unsubscribe";
20
+
21
+ export interface DigestConfig {
22
+ /** CAN-SPAM physical postal address, rendered in the footer (per-tenant config). */
23
+ postalAddress: string;
24
+ /** Sender display name; used only for the digest heading here. */
25
+ fromName?: string;
26
+ /** Resolved canonical base, e.g. `https://x.com` — post links + unsubscribe URL. */
27
+ canonicalBase: string;
28
+ }
29
+
30
+ /** Build the one-click unsubscribe URL for a given token (or placeholder). */
31
+ export function unsubscribeUrl(canonicalBase: string, token: string): string {
32
+ const base = canonicalBase.replace(/\/$/, "");
33
+ return `${base}${UNSUBSCRIBE_PATH}?token=${token}`;
34
+ }
35
+
36
+ /** HTML-escape text placed in element content or a quoted attribute. */
37
+ function escapeHtml(value: string): string {
38
+ return value
39
+ .replace(/&/g, "&")
40
+ .replace(/</g, "&lt;")
41
+ .replace(/>/g, "&gt;")
42
+ .replace(/"/g, "&quot;")
43
+ .replace(/'/g, "&#39;");
44
+ }
45
+
46
+ const MONTHS = [
47
+ "January", "February", "March", "April", "May", "June",
48
+ "July", "August", "September", "October", "November", "December",
49
+ ];
50
+
51
+ /** Deterministic UTC "Month D, YYYY" (no locale/timezone drift in tests). */
52
+ function formatDate(iso: string): string {
53
+ const d = new Date(iso);
54
+ if (Number.isNaN(d.getTime())) return "";
55
+ return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`;
56
+ }
57
+
58
+ /**
59
+ * Render the digest email body. `collection` fixes the route base (`/<collection>`,
60
+ * mirroring the RSS renderer's `collectionBase`) so post links resolve to the
61
+ * live article. Every message carries an unsubscribe link (placeholder token)
62
+ * and the CAN-SPAM postal address in the footer.
63
+ */
64
+ export function buildDigestHtml(
65
+ tenantId: string,
66
+ collection: string,
67
+ posts: BlogPostSummary[],
68
+ config: DigestConfig,
69
+ ): string {
70
+ const base = config.canonicalBase.replace(/\/$/, "");
71
+ const collectionBase = `/${collection.replace(/^\/+|\/+$/g, "")}`;
72
+ const heading = escapeHtml(config.fromName ?? "The latest");
73
+
74
+ const items = posts
75
+ .map((post) => {
76
+ const link = `${base}${collectionBase}/${post.slug}/`;
77
+ const date = formatDate(post.publishedAt);
78
+ const image = post.featuredImage
79
+ ? ` <p><a href="${escapeHtml(link)}"><img src="${escapeHtml(post.featuredImage.src)}" alt="${escapeHtml(post.featuredImage.alt)}" style="max-width:100%;height:auto;border-radius:8px;" /></a></p>`
80
+ : "";
81
+ return [
82
+ ` <article style="margin:0 0 2rem;">`,
83
+ image,
84
+ ` <h2 style="margin:0 0 .35rem;font-size:1.25rem;"><a href="${escapeHtml(link)}" style="color:#2f3a34;text-decoration:none;">${escapeHtml(post.title)}</a></h2>`,
85
+ date ? ` <p style="margin:0 0 .5rem;color:#8a938c;font-size:.85rem;">${escapeHtml(date)}</p>` : "",
86
+ ` <p style="margin:0 0 .5rem;color:#5a655e;line-height:1.6;">${escapeHtml(post.excerpt)}</p>`,
87
+ ` <p style="margin:0;"><a href="${escapeHtml(link)}" style="color:#2f3a34;font-weight:600;">Read more &rarr;</a></p>`,
88
+ ` </article>`,
89
+ ]
90
+ .filter(Boolean)
91
+ .join("\n");
92
+ })
93
+ .join("\n");
94
+
95
+ const unsubUrl = unsubscribeUrl(config.canonicalBase, UNSUBSCRIBE_TOKEN_PLACEHOLDER);
96
+ const postalHtml = escapeHtml(config.postalAddress).replace(/\n/g, "<br />");
97
+
98
+ return `<!doctype html>
99
+ <html lang="en">
100
+ <head>
101
+ <meta charset="utf-8" />
102
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
103
+ </head>
104
+ <body style="margin:0;background:#f4f1ea;color:#2f3a34;font-family:ui-serif,Georgia,serif;">
105
+ <div style="max-width:37.5rem;margin:0 auto;padding:2rem 1.5rem;">
106
+ <h1 style="font-size:1.5rem;margin:0 0 1.5rem;">${heading}</h1>
107
+ ${items}
108
+ <hr style="border:none;border-top:1px solid #d8d3c6;margin:2rem 0 1rem;" />
109
+ <p style="color:#8a938c;font-size:.8rem;line-height:1.6;margin:0 0 .5rem;">
110
+ You're receiving this because you subscribed. <a href="${escapeHtml(unsubUrl)}" style="color:#8a938c;">Unsubscribe</a>.
111
+ </p>
112
+ <p style="color:#8a938c;font-size:.8rem;line-height:1.6;margin:0;">${postalHtml}</p>
113
+ </div>
114
+ </body>
115
+ </html>
116
+ `;
117
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Thin `fetch`-based Resend client — no SDK, no npm dependency (the newsletter
3
+ * spike's Workers-native rationale, docs/plans/2026-08-22-newsletter-send-spike.md
4
+ * §1). We use Resend purely as an SMTP-over-HTTPS sender via its batch endpoint
5
+ * (`POST /emails/batch`, up to 100 messages/call); audience/consent/unsubscribe
6
+ * state stays first-party in our D1, never in a vendor list.
7
+ */
8
+ import { readEnv } from "../env.js";
9
+
10
+ /** Resend's batch limit: at most 100 messages per `/emails/batch` call. */
11
+ export const RESEND_BATCH_LIMIT = 100;
12
+
13
+ const RESEND_BATCH_URL = "https://api.resend.com/emails/batch";
14
+
15
+ /** One message in a batch. `reply_to`/`headers` use Resend's wire field names. */
16
+ export interface ResendEmail {
17
+ from: string;
18
+ to: string | string[];
19
+ subject: string;
20
+ html: string;
21
+ reply_to?: string;
22
+ headers?: Record<string, string>;
23
+ }
24
+
25
+ export interface SendBatchResult {
26
+ ok: boolean;
27
+ status: number;
28
+ /** Provider message ids (`data[].id`), one per accepted message. */
29
+ ids: string[];
30
+ /** Present when `ok` is false — the provider error text, for logging. */
31
+ error?: string;
32
+ }
33
+
34
+ /** The send seam — real impl below; injected as a fake in tests. */
35
+ export type SendBatchFn = (apiKey: string, emails: ResendEmail[]) => Promise<SendBatchResult>;
36
+
37
+ /** The `RESEND_API_KEY` secret, read via the runtime env accessor (undefined if unset). */
38
+ export function readResendApiKey(): Promise<string | undefined> {
39
+ return readEnv("RESEND_API_KEY");
40
+ }
41
+
42
+ /**
43
+ * Send up to {@link RESEND_BATCH_LIMIT} messages in one call. An empty list is a
44
+ * no-op success (nothing to send). Never throws for a transport/HTTP error —
45
+ * returns `{ ok:false }` so the caller decides whether a partial send should
46
+ * still record its ledger row.
47
+ */
48
+ export const sendBatch: SendBatchFn = async (apiKey, emails) => {
49
+ if (emails.length === 0) return { ok: true, status: 200, ids: [] };
50
+ if (emails.length > RESEND_BATCH_LIMIT) {
51
+ throw new Error(`sendBatch received ${emails.length} messages; max is ${RESEND_BATCH_LIMIT}`);
52
+ }
53
+ let res: Response;
54
+ try {
55
+ res = await fetch(RESEND_BATCH_URL, {
56
+ method: "POST",
57
+ headers: {
58
+ authorization: `Bearer ${apiKey}`,
59
+ "content-type": "application/json",
60
+ },
61
+ body: JSON.stringify(emails),
62
+ });
63
+ } catch (err) {
64
+ return { ok: false, status: 0, ids: [], error: String(err) };
65
+ }
66
+
67
+ const bodyText = await res.text();
68
+ if (!res.ok) {
69
+ return { ok: false, status: res.status, ids: [], error: bodyText };
70
+ }
71
+ let ids: string[] = [];
72
+ try {
73
+ const parsed = JSON.parse(bodyText) as { data?: Array<{ id?: string }> };
74
+ ids = (parsed.data ?? []).map((d) => d.id ?? "").filter(Boolean);
75
+ } catch {
76
+ // Accepted (2xx) but unparseable body — treat as sent, no ids surfaced.
77
+ }
78
+ return { ok: true, status: res.status, ids };
79
+ };
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Weekly-digest send orchestration (newsletter-send epic, N4). A single callable
3
+ * `sendWeeklyDigest` that N5's admin trigger endpoint invokes; this unit builds
4
+ * NO HTTP route. Flow (docs/plans/2026-08-22-newsletter-send-spike.md §5–6):
5
+ *
6
+ * 1. Compute this period's `digestId` (`<collection>-<ISO-week>`) and short-
7
+ * circuit if a `newsletter_sends` row already exists for it — the
8
+ * idempotency guard that stops a re-triggered send from double-sending.
9
+ * 2. Collect posts published SINCE the last send (all live posts on the first
10
+ * ever send). Empty → skip, no ledger row burned.
11
+ * 3. Query `confirmed` subscribers; none → skip.
12
+ * 4. Build the digest ONCE, personalize the unsubscribe token per recipient,
13
+ * batch-send via Resend (≤100/call), then record the ledger row and stamp
14
+ * each recipient's `last_sent_at`.
15
+ *
16
+ * Idempotency design (the spike offered two; this is the simpler): scope is the
17
+ * whole SEND, keyed by ISO-week `digestId` in `newsletter_sends` — NOT a
18
+ * per-subscriber diff. "New since last send" is computed once from the previous
19
+ * ledger row's `sent_at`, not per-subscriber.
20
+ */
21
+ import type { Queryable } from "../d1/catalog.js";
22
+ import type { BlogPostSummary } from "../blog/types.js";
23
+ import {
24
+ sendBatch as realSendBatch,
25
+ RESEND_BATCH_LIMIT,
26
+ type ResendEmail,
27
+ type SendBatchFn,
28
+ } from "./resendClient.js";
29
+ import {
30
+ buildDigestHtml,
31
+ unsubscribeUrl,
32
+ UNSUBSCRIBE_TOKEN_PLACEHOLDER,
33
+ type DigestConfig,
34
+ } from "./digest.js";
35
+
36
+ export interface SendDeps {
37
+ db: Queryable;
38
+ /** Live-visible post source (the engine's `listPosts`); drafts never leak. */
39
+ listPosts: (tenantId: string, collection: string) => Promise<BlogPostSummary[]>;
40
+ resendApiKey: string;
41
+ config: DigestConfig & {
42
+ /** Verified sending address, e.g. `the-build@mail.tokenoftrust.store`. */
43
+ fromAddress: string;
44
+ /** `Reply-To:`; falls back to `fromAddress`. */
45
+ replyTo?: string;
46
+ };
47
+ /** Send seam — tests inject a fake; defaults to the real Resend client. */
48
+ sendBatch?: SendBatchFn;
49
+ /** Clock injection for deterministic digest ids + timestamps. */
50
+ now?: () => Date;
51
+ }
52
+
53
+ export type SendResult =
54
+ | { sent: number; digestId: string }
55
+ | { skipped: true; reason: string };
56
+
57
+ /** The reasons a send would perform NO work — shared by preview and send. */
58
+ export type DigestSkipReason = "already-sent-this-period" | "no-new-posts" | "no-subscribers";
59
+
60
+ /** The shared verified sending domain (SPF/DKIM/DMARC configured with Resend). */
61
+ export const NEWSLETTER_SENDING_DOMAIN = "mail.tokenoftrust.store";
62
+
63
+ /**
64
+ * Resolve the digest's `From:` address. An explicit per-tenant `fromAddress`
65
+ * wins; otherwise derive `<collection>@mail.tokenoftrust.store` on the shared
66
+ * verified domain. Deliberately NEVER derives from the tenant's own store domain
67
+ * — that domain is not a verified sending domain, so a send from it would fail
68
+ * SPF/DKIM alignment at the ESP.
69
+ */
70
+ export function resolveFromAddress(collection: string, configured?: string): string {
71
+ const explicit = configured?.trim();
72
+ if (explicit) return explicit;
73
+ const localPart =
74
+ collection.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "newsletter";
75
+ return `${localPart}@${NEWSLETTER_SENDING_DOMAIN}`;
76
+ }
77
+
78
+ interface SubscriberRow {
79
+ email: string;
80
+ token: string;
81
+ }
82
+
83
+ /** The read-only shape a dry-run preview needs (no send seam / api key). */
84
+ export interface PreviewDeps {
85
+ db: Queryable;
86
+ listPosts: (tenantId: string, collection: string) => Promise<BlogPostSummary[]>;
87
+ now?: () => Date;
88
+ }
89
+
90
+ /** What a send WOULD do this period, computed without sending anything. */
91
+ export interface DigestPreview {
92
+ digestId: string;
93
+ /** Posts published since the last send (empty when nothing new). */
94
+ postCount: number;
95
+ postSlugs: string[];
96
+ /** Confirmed recipients that would receive the digest. */
97
+ recipientCount: number;
98
+ /** Set when a real send would skip; absent when a send would go out. */
99
+ skip?: DigestSkipReason;
100
+ }
101
+
102
+ async function countConfirmed(db: Queryable, tenantId: string, collection: string): Promise<number> {
103
+ const row = await db.first<{ n: number }>(
104
+ `SELECT COUNT(*) AS n FROM newsletter_subscribers
105
+ WHERE tenant_id = ? AND collection = ? AND status = 'confirmed'`,
106
+ tenantId, collection,
107
+ );
108
+ return Number(row?.n ?? 0);
109
+ }
110
+
111
+ /**
112
+ * Steps (1)+(2) of the send flow, read-only: whether this period was already
113
+ * sent, and the posts published since the last send. Shared verbatim by
114
+ * {@link previewWeeklyDigest} and {@link sendWeeklyDigest} so the dry-run preview
115
+ * can never diverge from what the real send computes.
116
+ */
117
+ async function selectNewPosts(
118
+ deps: PreviewDeps,
119
+ tenantId: string,
120
+ collection: string,
121
+ digestId: string,
122
+ ): Promise<{ alreadySent: boolean; newPosts: BlogPostSummary[] }> {
123
+ const existing = await deps.db.first<{ digest_id: string }>(
124
+ `SELECT digest_id FROM newsletter_sends
125
+ WHERE tenant_id = ? AND collection = ? AND digest_id = ?`,
126
+ tenantId, collection, digestId,
127
+ );
128
+ if (existing) return { alreadySent: true, newPosts: [] };
129
+
130
+ const lastSend = await deps.db.first<{ sent_at: string }>(
131
+ `SELECT sent_at FROM newsletter_sends
132
+ WHERE tenant_id = ? AND collection = ?
133
+ ORDER BY sent_at DESC LIMIT 1`,
134
+ tenantId, collection,
135
+ );
136
+ const sinceMs = lastSend ? new Date(lastSend.sent_at).getTime() : null;
137
+ const posts = await deps.listPosts(tenantId, collection);
138
+ const newPosts = posts.filter(
139
+ (p) => sinceMs === null || new Date(p.publishedAt).getTime() > sinceMs,
140
+ );
141
+ return { alreadySent: false, newPosts };
142
+ }
143
+
144
+ /**
145
+ * Compute what a weekly digest WOULD send this period — post count, post slugs,
146
+ * and confirmed-recipient count — WITHOUT calling the ESP or writing any ledger
147
+ * row. Backs the admin "Send digest" dry-run/confirm dialog (N5).
148
+ */
149
+ export async function previewWeeklyDigest(
150
+ tenantId: string,
151
+ collection: string,
152
+ deps: PreviewDeps,
153
+ ): Promise<DigestPreview> {
154
+ const now = deps.now?.() ?? new Date();
155
+ const digestId = `${collection}-${isoWeekId(now)}`;
156
+ const { alreadySent, newPosts } = await selectNewPosts(deps, tenantId, collection, digestId);
157
+ const recipientCount = await countConfirmed(deps.db, tenantId, collection);
158
+
159
+ let skip: DigestSkipReason | undefined;
160
+ if (alreadySent) skip = "already-sent-this-period";
161
+ else if (newPosts.length === 0) skip = "no-new-posts";
162
+ else if (recipientCount === 0) skip = "no-subscribers";
163
+
164
+ return {
165
+ digestId,
166
+ postCount: newPosts.length,
167
+ postSlugs: newPosts.map((p) => p.slug),
168
+ recipientCount,
169
+ skip,
170
+ };
171
+ }
172
+
173
+ /** ISO-8601 week-numbering id (`YYYY-Www`), Thursday-anchored. */
174
+ export function isoWeekId(d: Date): string {
175
+ const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
176
+ const dayNum = (date.getUTCDay() + 6) % 7; // Mon=0 … Sun=6
177
+ date.setUTCDate(date.getUTCDate() - dayNum + 3); // Thursday of this week
178
+ const firstThursday = new Date(Date.UTC(date.getUTCFullYear(), 0, 4));
179
+ const ftDayNum = (firstThursday.getUTCDay() + 6) % 7;
180
+ firstThursday.setUTCDate(firstThursday.getUTCDate() - ftDayNum + 3);
181
+ const week = 1 + Math.round((date.getTime() - firstThursday.getTime()) / (7 * 86400000));
182
+ return `${date.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
183
+ }
184
+
185
+ function chunk<T>(items: T[], size: number): T[][] {
186
+ const out: T[][] = [];
187
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
188
+ return out;
189
+ }
190
+
191
+ export async function sendWeeklyDigest(
192
+ tenantId: string,
193
+ collection: string,
194
+ deps: SendDeps,
195
+ ): Promise<SendResult> {
196
+ const now = deps.now?.() ?? new Date();
197
+ const nowIso = now.toISOString();
198
+ const send = deps.sendBatch ?? realSendBatch;
199
+ const digestId = `${collection}-${isoWeekId(now)}`;
200
+
201
+ // (1)+(2) Idempotency + posts published since the last send (shared with the
202
+ // dry-run preview so the two can never diverge).
203
+ const { alreadySent, newPosts } = await selectNewPosts(deps, tenantId, collection, digestId);
204
+ if (alreadySent) return { skipped: true, reason: "already-sent-this-period" };
205
+ if (newPosts.length === 0) return { skipped: true, reason: "no-new-posts" };
206
+
207
+ // (3) Confirmed recipients only.
208
+ const subscribers = await deps.db.all<SubscriberRow>(
209
+ `SELECT email, token FROM newsletter_subscribers
210
+ WHERE tenant_id = ? AND collection = ? AND status = 'confirmed'`,
211
+ tenantId, collection,
212
+ );
213
+ if (subscribers.length === 0) return { skipped: true, reason: "no-subscribers" };
214
+
215
+ // (4) Build once, personalize the unsubscribe token per recipient, batch-send.
216
+ const digestHtml = buildDigestHtml(tenantId, collection, newPosts, deps.config);
217
+ const from = deps.config.fromName
218
+ ? `${deps.config.fromName} <${deps.config.fromAddress}>`
219
+ : deps.config.fromAddress;
220
+ const subject = deps.config.fromName ? `${deps.config.fromName} — new this week` : "New this week";
221
+
222
+ const emails: ResendEmail[] = subscribers.map((s) => {
223
+ const unsubUrl = unsubscribeUrl(deps.config.canonicalBase, s.token);
224
+ return {
225
+ from,
226
+ to: s.email,
227
+ subject,
228
+ html: digestHtml.split(UNSUBSCRIBE_TOKEN_PLACEHOLDER).join(s.token),
229
+ reply_to: deps.config.replyTo ?? deps.config.fromAddress,
230
+ headers: {
231
+ // RFC 8058 one-click unsubscribe (Gmail/Yahoo bulk-sender requirement).
232
+ "List-Unsubscribe": `<${unsubUrl}>`,
233
+ "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
234
+ },
235
+ };
236
+ });
237
+
238
+ let sent = 0;
239
+ const sentEmails: string[] = [];
240
+ for (const batch of chunk(emails, RESEND_BATCH_LIMIT)) {
241
+ const result = await send(deps.resendApiKey, batch);
242
+ if (result.ok) {
243
+ sent += batch.length;
244
+ for (const e of batch) sentEmails.push(e.to as string);
245
+ }
246
+ }
247
+
248
+ // (5) Record the ledger row (idempotency key) + stamp recipients.
249
+ await deps.db.run(
250
+ `INSERT OR IGNORE INTO newsletter_sends
251
+ (tenant_id, collection, digest_id, post_slugs, recipient_count, sent_at)
252
+ VALUES (?, ?, ?, ?, ?, ?)`,
253
+ tenantId, collection, digestId,
254
+ JSON.stringify(newPosts.map((p) => p.slug)), sent, nowIso,
255
+ );
256
+ for (const email of sentEmails) {
257
+ await deps.db.run(
258
+ `UPDATE newsletter_subscribers SET last_sent_at = ?
259
+ WHERE tenant_id = ? AND collection = ? AND email = ?`,
260
+ nowIso, tenantId, collection, email,
261
+ );
262
+ }
263
+
264
+ return { sent, digestId };
265
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * `newsletter_subscribers` store — the first-party audience for the blog digest
3
+ * (newsletter-send epic, N2). Kept in OUR `STOREFRONT_APPS_DB` (not a vendor
4
+ * list) so the per-tenant isolation + GDPR data-control invariants stay under
5
+ * our control (docs/plans/2026-08-22-newsletter-send-spike.md §2).
6
+ *
7
+ * Pure over the `Queryable` seam (see `../d1/catalog.ts`), so the same code runs
8
+ * against real D1 in the Worker and an in-memory `node:sqlite` in tests. The
9
+ * caller is responsible for resolving `tenantId` from the request host/session
10
+ * (NEVER a client body) — that is the multi-tenant isolation boundary.
11
+ */
12
+ import type { Queryable } from "../d1/catalog.js";
13
+
14
+ export type SubscriberStatus = "pending" | "confirmed" | "unsubscribed" | "bounced";
15
+
16
+ export interface SubscribeInput {
17
+ tenantId: string;
18
+ collection: string;
19
+ /** Already lowercased + trimmed by the caller. */
20
+ email: string;
21
+ token: string;
22
+ consentSource?: string | null;
23
+ consentIpHash?: string | null;
24
+ }
25
+
26
+ export interface SubscribeResult {
27
+ /** True when this call inserted a new pending row (vs. hitting an existing one). */
28
+ created: boolean;
29
+ status: SubscriberStatus;
30
+ /** The row's live token — the new one when created, the stored one otherwise. */
31
+ token: string;
32
+ }
33
+
34
+ export type ConfirmResult = "confirmed" | "already" | "not_found";
35
+
36
+ interface SubscriberRow {
37
+ status: string;
38
+ token: string;
39
+ collection?: string;
40
+ email?: string;
41
+ }
42
+
43
+ export class NewsletterSubscribersStore {
44
+ constructor(
45
+ private readonly db: Queryable,
46
+ private readonly now: () => string = () => new Date().toISOString(),
47
+ ) {}
48
+
49
+ /**
50
+ * Enrol an email as `pending`, keyed `(tenant_id, collection, email)`. A
51
+ * repeat subscribe is idempotent (`INSERT OR IGNORE`): the existing row is
52
+ * returned untouched, so a still-pending signer can be re-sent their confirm
53
+ * mail while a confirmed/unsubscribed one is never silently resurrected.
54
+ */
55
+ async subscribe(input: SubscribeInput): Promise<SubscribeResult> {
56
+ const existing = await this.db.first<SubscriberRow>(
57
+ `SELECT status, token FROM newsletter_subscribers
58
+ WHERE tenant_id = ? AND collection = ? AND email = ?`,
59
+ input.tenantId,
60
+ input.collection,
61
+ input.email,
62
+ );
63
+ if (existing) {
64
+ return { created: false, status: existing.status as SubscriberStatus, token: existing.token };
65
+ }
66
+ await this.db.run(
67
+ `INSERT OR IGNORE INTO newsletter_subscribers
68
+ (tenant_id, collection, email, status, token, consent_source, consent_ip_hash, subscribed_at)
69
+ VALUES (?, ?, ?, 'pending', ?, ?, ?, ?)`,
70
+ input.tenantId,
71
+ input.collection,
72
+ input.email,
73
+ input.token,
74
+ input.consentSource ?? null,
75
+ input.consentIpHash ?? null,
76
+ this.now(),
77
+ );
78
+ return { created: true, status: "pending", token: input.token };
79
+ }
80
+
81
+ /**
82
+ * Flip a `pending` subscriber to `confirmed` by its opaque token, scoped to
83
+ * the caller's tenant. Idempotent: re-confirming an already-`confirmed` token
84
+ * is a no-op success, not an error. An unknown token — or one whose row is
85
+ * `unsubscribed`/`bounced` (the confirm link is no longer valid) — is
86
+ * `not_found`.
87
+ */
88
+ async confirmByToken(input: { tenantId: string; token: string }): Promise<ConfirmResult> {
89
+ const row = await this.db.first<SubscriberRow>(
90
+ `SELECT status FROM newsletter_subscribers WHERE tenant_id = ? AND token = ?`,
91
+ input.tenantId,
92
+ input.token,
93
+ );
94
+ if (!row) return "not_found";
95
+ if (row.status === "confirmed") return "already";
96
+ if (row.status !== "pending") return "not_found";
97
+ await this.db.run(
98
+ `UPDATE newsletter_subscribers
99
+ SET status = 'confirmed', confirmed_at = ?
100
+ WHERE tenant_id = ? AND token = ? AND status = 'pending'`,
101
+ this.now(),
102
+ input.tenantId,
103
+ input.token,
104
+ );
105
+ return "confirmed";
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Outcome of an unsubscribe attempt. INTERNAL only — the HTTP layer MUST map every
111
+ * variant to the SAME response so an observer can't tell whether a token ever existed
112
+ * (`unknown` vs the others). Distinguished here purely so callers/telemetry can reason
113
+ * about it.
114
+ */
115
+ export type UnsubscribeOutcome = "unsubscribed" | "already-unsubscribed" | "unknown-token";
116
+
117
+ interface StatusRow {
118
+ status: string;
119
+ }
120
+
121
+ /**
122
+ * Flip the subscriber addressed by `token` to `unsubscribed`, stamping `unsubscribed_at`
123
+ * on the FIRST transition only. Idempotent: re-unsubscribing an already-unsubscribed
124
+ * token is a no-op success that preserves the original `unsubscribed_at`. An unknown
125
+ * token is a clean no-op (`unknown-token`) — never an error, never distinguishable to
126
+ * the caller at the HTTP layer.
127
+ *
128
+ * The `token` column is an opaque, per-subscriber random (unique across the table): it
129
+ * is the unsubscribe capability. A link/one-click header carries it and identifies
130
+ * exactly one row without ever putting the email in the URL (which would leak PII into
131
+ * access logs). Lookup is by token alone — no tenant/collection needed — and every
132
+ * write is scoped to that single token, so it can only ever touch the one row it names.
133
+ */
134
+ export async function unsubscribeByToken(
135
+ db: Queryable,
136
+ token: string,
137
+ now: string,
138
+ ): Promise<UnsubscribeOutcome> {
139
+ if (!token) return "unknown-token";
140
+
141
+ const row = await db.first<StatusRow>(
142
+ "SELECT status FROM newsletter_subscribers WHERE token = ?",
143
+ token,
144
+ );
145
+ if (!row) return "unknown-token";
146
+ if (row.status === "unsubscribed") return "already-unsubscribed";
147
+
148
+ // Guard the UPDATE on the current status too, so a concurrent double-submit can't
149
+ // overwrite the first `unsubscribed_at`.
150
+ await db.run(
151
+ "UPDATE newsletter_subscribers SET status = 'unsubscribed', unsubscribed_at = ? WHERE token = ? AND status != 'unsubscribed'",
152
+ now,
153
+ token,
154
+ );
155
+ return "unsubscribed";
156
+ }