@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,104 @@
1
+ /**
2
+ * E1.1 — public reaction endpoint. POST a reaction for the post addressed by the
3
+ * route (`/api/blog/<collection>/<slug>/react`); returns the post's full reaction
4
+ * tallies as JSON. Anonymous, no identity — the reader is never authenticated.
5
+ *
6
+ * Discipline (mirrors the other public POSTs, `newsletter.ts` / `rum/vitals.ts`):
7
+ * - Tenant is resolved from `locals.tenant`, NEVER the request body.
8
+ * - A soft per-IP KV rate limit stops trivial scripted inflation; over-cap
9
+ * returns the current tallies (429) without counting — abuse blast-radius is
10
+ * ~0 (a counter), so the cap is lenient.
11
+ * - Never 500. Every path returns a clean JSON response; `recordReaction` is
12
+ * itself best-effort and never throws.
13
+ */
14
+ import type { APIRoute } from "astro";
15
+ import { readKv } from "@/lib/env";
16
+ import {
17
+ getReactionCounts,
18
+ isReaction,
19
+ recordReaction,
20
+ type Reaction,
21
+ } from "@/lib/blog/reactions";
22
+ import { checkRateLimit } from "@/lib/rate/kvWindow";
23
+
24
+ export const prerender = false;
25
+
26
+ const RATE_WINDOW_SECONDS = 60;
27
+ const RATE_MAX_PER_WINDOW = 10;
28
+
29
+ function json(body: unknown, status = 200): Response {
30
+ return new Response(JSON.stringify(body), {
31
+ status,
32
+ headers: { "content-type": "application/json; charset=utf-8" },
33
+ });
34
+ }
35
+
36
+ /** Best-effort client IP for rate-limit keying — Cloudflare's header, else XFF. */
37
+ function clientIp(request: Request): string {
38
+ const cf = request.headers.get("cf-connecting-ip");
39
+ if (cf) return cf;
40
+ const xff = request.headers.get("x-forwarded-for");
41
+ if (xff) return xff.split(",")[0]!.trim();
42
+ return "unknown";
43
+ }
44
+
45
+ /** Pull the reaction from a JSON or form-encoded body. */
46
+ async function readReaction(request: Request): Promise<string | null> {
47
+ try {
48
+ const type = request.headers.get("content-type") ?? "";
49
+ if (type.includes("application/json")) {
50
+ const body = (await request.json()) as { reaction?: unknown };
51
+ return typeof body.reaction === "string" ? body.reaction : null;
52
+ }
53
+ const form = await request.formData();
54
+ const value = form.get("reaction");
55
+ return typeof value === "string" ? value : null;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ export const POST: APIRoute = async (context) => {
62
+ const tenantId = context.locals?.tenant?.tenant_id;
63
+ const collection = context.params.collection;
64
+ const slug = context.params.slug;
65
+ if (!tenantId || !collection || !slug) {
66
+ return json({ ok: false, error: "not_found", counts: {} as Record<Reaction, number> }, 404);
67
+ }
68
+
69
+ const reaction = await readReaction(context.request);
70
+ const kv = await readKv("TENANT_CACHE");
71
+
72
+ if (!isReaction(reaction)) {
73
+ return json(
74
+ {
75
+ ok: false,
76
+ error: "invalid_reaction",
77
+ counts: await getReactionCounts(kv, tenantId, collection, slug),
78
+ },
79
+ 400,
80
+ );
81
+ }
82
+
83
+ const { allowed } = await checkRateLimit(kv, `react:${tenantId}:${clientIp(context.request)}`, {
84
+ windowSeconds: RATE_WINDOW_SECONDS,
85
+ maxRequests: RATE_MAX_PER_WINDOW,
86
+ });
87
+ if (!allowed) {
88
+ return json(
89
+ {
90
+ ok: false,
91
+ error: "rate_limited",
92
+ counts: await getReactionCounts(kv, tenantId, collection, slug),
93
+ },
94
+ 429,
95
+ );
96
+ }
97
+
98
+ // Best-effort: no-ops for a non-live post; never throws. When there is no KV
99
+ // binding (local dev) the increment can't persist, but we still return the
100
+ // (all-zero) tallies so the client has a consistent shape.
101
+ if (kv) await recordReaction(kv, tenantId, collection, slug, reaction);
102
+
103
+ return json({ ok: true, counts: await getReactionCounts(kv, tenantId, collection, slug) });
104
+ };
@@ -12,6 +12,7 @@
12
12
  import type { APIContext } from "astro";
13
13
  import { readEnv, readKv } from "@/lib/env";
14
14
  import { bumpCacheVersion } from "@/lib/edgeCache";
15
+ import { recordActivity } from "@/lib/activity/recordActivity";
15
16
 
16
17
  export const prerender = false;
17
18
 
@@ -38,6 +39,16 @@ export async function POST(context: APIContext): Promise<Response> {
38
39
  }
39
40
 
40
41
  const version = await bumpCacheVersion(kv, tenantId);
42
+ // D11: the caller is the Bearer-secret ETL load step (a machine, no session) — emit as
43
+ // the `agent` cohort with an opaque handle. Best-effort; never affects the purge.
44
+ recordActivity({
45
+ action: "cache.purged",
46
+ actor: { kind: "agent", id: "storefront" },
47
+ source: "server",
48
+ outcome: { status: "succeeded" },
49
+ scope: { tenantId },
50
+ payload: { args: { version } },
51
+ });
41
52
  return new Response(JSON.stringify({ tenantId, version }), {
42
53
  headers: { "content-type": "application/json" },
43
54
  });
@@ -29,6 +29,7 @@ import {
29
29
  StaffVendorAccessClientError,
30
30
  } from "@/lib/dashboard/staffVendorAccess";
31
31
  import { mintBrokerAssertion, BrokerAssertionError } from "@/lib/auth/brokerAssertion";
32
+ import { resolveUiActor, recordUiActivity } from "@/lib/activity/uiActor";
32
33
 
33
34
  export const prerender = false;
34
35
 
@@ -57,6 +58,11 @@ export async function POST(context: APIContext): Promise<Response> {
57
58
  typeof body.vendorAppDomain === "string" ? body.vendorAppDomain.trim() : "";
58
59
  if (!vendorAppDomain) return json({ error: "vendorAppDomain is required" }, 400);
59
60
 
61
+ // D11: staff-impersonation audit event. Resolve the actor AFTER the staff gate above,
62
+ // so the cohort is always `admin` (a ToT-staff session); the staff email is PII and is
63
+ // never stored (actor.id is its salted hash). Reused at each emit exit point below.
64
+ const activityActor = await resolveUiActor(context, {});
65
+
60
66
  let client;
61
67
  try {
62
68
  client = await createStaffVendorAccessClient();
@@ -84,6 +90,12 @@ export async function POST(context: APIContext): Promise<Response> {
84
90
  // capability, and writes the audit row. We admit ONLY on its ok.
85
91
  const result = await client.grantStaffAccess(brokerAssertion, vendorAppDomain);
86
92
  if (!result.ok) {
93
+ recordUiActivity(activityActor, {
94
+ action: "dashboard.vendor_entered",
95
+ outcome: { status: "refused", errorClass: result.reason ?? `http_${result.status}` },
96
+ scope: { tenantId: vendorAppDomain },
97
+ payload: { args: { resource: vendorAppDomain } },
98
+ });
87
99
  return json(
88
100
  { error: result.message, reason: result.reason, code: result.code },
89
101
  staffAccessHttpStatus(result.reason, result.status),
@@ -103,6 +115,12 @@ export async function POST(context: APIContext): Promise<Response> {
103
115
  // No SESSION KV / no cookie (e.g. plain `astro dev`) — the audit row is
104
116
  // written but we can't persist the marker. Fail closed so the viewer isn't
105
117
  // told they're admitted when the guard can't see it.
118
+ recordUiActivity(activityActor, {
119
+ action: "dashboard.vendor_entered",
120
+ outcome: { status: "failed", errorClass: "session_store_unavailable" },
121
+ scope: { tenantId: vendorAppDomain },
122
+ payload: { args: { resource: vendorAppDomain, capability: result.capability } },
123
+ });
106
124
  return json({ error: "session store unavailable; cannot persist admission" }, 503);
107
125
  }
108
126
  const updated = await updateSession(
@@ -112,9 +130,21 @@ export async function POST(context: APIContext): Promise<Response> {
112
130
  nowSeconds,
113
131
  );
114
132
  if (!updated) {
133
+ recordUiActivity(activityActor, {
134
+ action: "dashboard.vendor_entered",
135
+ outcome: { status: "failed", errorClass: "session_expired" },
136
+ scope: { tenantId: vendorAppDomain },
137
+ payload: { args: { resource: vendorAppDomain, capability: result.capability } },
138
+ });
115
139
  return json({ error: "session expired; sign in again" }, 401);
116
140
  }
117
141
 
142
+ recordUiActivity(activityActor, {
143
+ action: "dashboard.vendor_entered",
144
+ outcome: { status: "succeeded" },
145
+ scope: { tenantId: vendorAppDomain },
146
+ payload: { args: { resource: vendorAppDomain, capability: result.capability } },
147
+ });
118
148
  return json({
119
149
  ok: true,
120
150
  appDomain: vendorAppDomain,
@@ -0,0 +1,146 @@
1
+ /**
2
+ * N2 — newsletter double-opt-in subscribe. `POST /api/newsletter/<collection>/subscribe`
3
+ * with body `{ email }` (JSON) or an `email` form field.
4
+ *
5
+ * Discipline (mirrors the other public POSTs, `blog/.../react.ts` / `newsletter.ts`):
6
+ * - `tenant_id` is resolved from `locals.tenant`, `collection` from the ROUTE —
7
+ * NEVER the request body (tenant is the isolation boundary).
8
+ * - A soft per-IP KV rate limit (`kvWindow`) stops trivial scripted signups.
9
+ * - `INSERT OR IGNORE` a `pending` row with a fresh opaque token + consent
10
+ * record (`consent_source` = referrer, `consent_ip_hash` = sha256(ip+salt) —
11
+ * NEVER a raw IP). A repeat subscribe is idempotent.
12
+ * - Confirmation email is STUBBED (no ESP in-repo yet — N4 wires it); a stub
13
+ * failure never fails the subscribe.
14
+ * - Never 500. A `fetch`/JSON caller gets a clean JSON body; a plain form POST
15
+ * is redirected back to the referrer so the page can show its own thanks.
16
+ */
17
+ import type { APIRoute } from "astro";
18
+ import { readD1, readKv, readEnv } from "@/lib/env";
19
+ import { fromD1 } from "@/lib/d1/catalog";
20
+ import { checkRateLimit } from "@/lib/rate/kvWindow";
21
+ import { NewsletterSubscribersStore } from "@/lib/newsletter/subscribers";
22
+ import { randomToken, hashConsentIp } from "@/lib/newsletter/consent";
23
+ import { sendConfirmationEmail } from "@/lib/newsletter/confirmationEmail";
24
+
25
+ export const prerender = false;
26
+
27
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
28
+ const COLLECTION_RE = /^[a-z0-9][a-z0-9._-]*$/;
29
+ const RATE_WINDOW_SECONDS = 3600;
30
+ const RATE_MAX_PER_WINDOW = 5;
31
+ /** Dev fallback only — production sets NEWSLETTER_CONSENT_SALT as a secret. */
32
+ const DEV_CONSENT_SALT = "newsletter-consent-dev-salt";
33
+
34
+ function json(body: unknown, status = 200): Response {
35
+ return new Response(JSON.stringify(body), {
36
+ status,
37
+ headers: { "content-type": "application/json; charset=utf-8" },
38
+ });
39
+ }
40
+
41
+ /** Best-effort client IP for rate-limit keying + consent record — CF header, else XFF. */
42
+ function clientIp(request: Request): string {
43
+ const cf = request.headers.get("cf-connecting-ip");
44
+ if (cf) return cf;
45
+ const xff = request.headers.get("x-forwarded-for");
46
+ if (xff) return xff.split(",")[0]!.trim();
47
+ return "unknown";
48
+ }
49
+
50
+ /** Read `email` from a JSON or form-encoded body; null if unparseable. */
51
+ async function readEmail(request: Request): Promise<string | null> {
52
+ try {
53
+ const type = request.headers.get("content-type") ?? "";
54
+ if (type.includes("application/json")) {
55
+ const body = (await request.json()) as { email?: unknown };
56
+ return typeof body.email === "string" ? body.email : null;
57
+ }
58
+ const value = (await request.formData()).get("email");
59
+ return typeof value === "string" ? value : null;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /** True when the caller wants JSON (fetch/JS) rather than a page navigation. */
66
+ function wantsJson(request: Request): boolean {
67
+ const ct = request.headers.get("content-type") ?? "";
68
+ if (ct.includes("application/json")) return true;
69
+ return (request.headers.get("accept") ?? "").includes("application/json");
70
+ }
71
+
72
+ /** Success response: JSON for fetch, else redirect back to the referrer with a flag. */
73
+ function ok(request: Request): Response {
74
+ if (wantsJson(request)) return json({ ok: true, status: "pending" });
75
+ const referer = request.headers.get("referer");
76
+ if (referer) {
77
+ try {
78
+ const back = new URL(referer);
79
+ back.searchParams.set("subscribed", "1");
80
+ return Response.redirect(back.toString(), 303);
81
+ } catch {
82
+ /* fall through */
83
+ }
84
+ }
85
+ return json({ ok: true, status: "pending" });
86
+ }
87
+
88
+ export const POST: APIRoute = async (context) => {
89
+ const tenantId = context.locals?.tenant?.tenant_id;
90
+ const collection = context.params.collection;
91
+ if (!tenantId || !collection || !COLLECTION_RE.test(collection)) {
92
+ return json({ ok: false, error: "not_found" }, 404);
93
+ }
94
+
95
+ try {
96
+ const email = (await readEmail(context.request))?.trim().toLowerCase() ?? "";
97
+ if (!EMAIL_RE.test(email)) {
98
+ return json({ ok: false, error: "invalid_email" }, 400);
99
+ }
100
+
101
+ const kv = await readKv("TENANT_CACHE");
102
+ const { allowed } = await checkRateLimit(
103
+ kv,
104
+ `newsletter-subscribe:${tenantId}:${clientIp(context.request)}`,
105
+ { windowSeconds: RATE_WINDOW_SECONDS, maxRequests: RATE_MAX_PER_WINDOW },
106
+ );
107
+ if (!allowed) return json({ ok: false, error: "rate_limited" }, 429);
108
+
109
+ const d1 = await readD1("STOREFRONT_APPS_DB");
110
+ if (!d1) {
111
+ // No apps DB binding (local/dev without D1): can't persist. Don't fail the
112
+ // visitor — log and acknowledge so the flow is exercisable in dev.
113
+ console.log(`[newsletter] subscribe (no D1) ${email} → ${tenantId}/${collection}`);
114
+ return ok(context.request);
115
+ }
116
+
117
+ const referer = context.request.headers.get("referer");
118
+ const consentSource = referer ? new URL(referer).pathname : null;
119
+ const salt = (await readEnv("NEWSLETTER_CONSENT_SALT")) ?? DEV_CONSENT_SALT;
120
+ const consentIpHash = await hashConsentIp(clientIp(context.request), salt);
121
+
122
+ const store = new NewsletterSubscribersStore(fromD1(d1));
123
+ const result = await store.subscribe({
124
+ tenantId,
125
+ collection,
126
+ email,
127
+ token: randomToken(),
128
+ consentSource,
129
+ consentIpHash,
130
+ });
131
+
132
+ // Only (re)send confirmation while still pending — never re-mail a confirmed
133
+ // or unsubscribed address. Best-effort; a stub failure must not fail signup.
134
+ if (result.status === "pending") {
135
+ const origin = new URL(context.request.url).origin;
136
+ const basePath = context.locals?.basePath ?? "";
137
+ const confirmUrl = `${origin}${basePath}/api/newsletter/confirm?token=${result.token}`;
138
+ await sendConfirmationEmail({ email, collection, confirmUrl }).catch(() => {});
139
+ }
140
+
141
+ return ok(context.request);
142
+ } catch {
143
+ // Never 500 on a signup — acknowledge and move on.
144
+ return ok(context.request);
145
+ }
146
+ };
@@ -0,0 +1,75 @@
1
+ /**
2
+ * N2 — newsletter double-opt-in confirm. `GET /api/newsletter/confirm?token=…`
3
+ * (the link in the confirmation email). Flips the addressed `pending` subscriber
4
+ * to `confirmed`; that timestamp + the stored consent source/ip-hash IS the GDPR
5
+ * consent record.
6
+ *
7
+ * The token is opaque + per-subscriber, so it identifies the exact row without
8
+ * an email in the URL; the lookup is still scoped to `locals.tenant`. Idempotent:
9
+ * re-clicking an already-confirmed link is a success, not an error. An unknown/
10
+ * stale token renders a clean "invalid link" page — never a 500.
11
+ */
12
+ import type { APIRoute } from "astro";
13
+ import { readD1 } from "@/lib/env";
14
+ import { fromD1 } from "@/lib/d1/catalog";
15
+ import { NewsletterSubscribersStore } from "@/lib/newsletter/subscribers";
16
+
17
+ export const prerender = false;
18
+
19
+ function page(title: string, message: string, status = 200): Response {
20
+ const esc = (s: string) => s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]!);
21
+ const html = `<!doctype html>
22
+ <html lang="en">
23
+ <head>
24
+ <meta charset="utf-8" />
25
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
26
+ <meta name="robots" content="noindex" />
27
+ <title>${esc(title)}</title>
28
+ <style>
29
+ :root { color-scheme: light dark; }
30
+ body { font-family: ui-serif, Georgia, serif; margin: 0; min-height: 100vh;
31
+ display: grid; place-items: center; padding: 2rem; text-align: center;
32
+ background: #f4f1ea; color: #2f3a34; }
33
+ .card { max-width: 30rem; }
34
+ h1 { font-size: clamp(1.6rem, 4vw, 2.2rem); margin: 0 0 0.75rem; }
35
+ p { color: #5a655e; line-height: 1.6; }
36
+ a { color: #2f3a34; font-weight: 600; }
37
+ </style>
38
+ </head>
39
+ <body>
40
+ <div class="card">
41
+ <h1>${esc(title)}</h1>
42
+ <p>${esc(message)}</p>
43
+ <p><a href="/">Back to the shop →</a></p>
44
+ </div>
45
+ </body>
46
+ </html>`;
47
+ return new Response(html, {
48
+ status,
49
+ headers: { "content-type": "text/html; charset=utf-8" },
50
+ });
51
+ }
52
+
53
+ export const GET: APIRoute = async (context) => {
54
+ const tenantId = context.locals?.tenant?.tenant_id;
55
+ const token = context.url.searchParams.get("token")?.trim();
56
+ if (!tenantId || !token) {
57
+ return page("Invalid link", "That confirmation link isn't valid. Try subscribing again.", 400);
58
+ }
59
+
60
+ try {
61
+ const d1 = await readD1("STOREFRONT_APPS_DB");
62
+ if (!d1) {
63
+ return page("Try again shortly", "We couldn't confirm your subscription just now. Please try again in a moment.", 503);
64
+ }
65
+ const store = new NewsletterSubscribersStore(fromD1(d1));
66
+ const result = await store.confirmByToken({ tenantId, token });
67
+ if (result === "not_found") {
68
+ return page("Invalid link", "That confirmation link is expired or already used. Try subscribing again.", 400);
69
+ }
70
+ // "confirmed" and "already" are both success — idempotent.
71
+ return page("You're confirmed.", "Thanks for confirming — you'll hear from us when something worth knowing lands.");
72
+ } catch {
73
+ return page("Try again shortly", "We couldn't confirm your subscription just now. Please try again in a moment.", 503);
74
+ }
75
+ };
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Newsletter one-click unsubscribe (newsletter-send epic, N3) — the endpoint the
3
+ * unsubscribe footer link and the RFC 8058 `List-Unsubscribe` header both point at.
4
+ * (N4 composes those headers/links; this unit is only the target they hit.)
5
+ *
6
+ * - GET /api/newsletter/unsubscribe?token=… — a clicked email-footer link. Flips
7
+ * the subscriber to `unsubscribed` and renders a plain confirmation page.
8
+ * - POST /api/newsletter/unsubscribe?token=… — the RFC 8058 `List-Unsubscribe-Post:
9
+ * List-Unsubscribe=One-Click` request a mailbox provider (Gmail/Yahoo) fires. Same
10
+ * effect, 200 with no interstitial and no confirmation step.
11
+ *
12
+ * The `token` (query string only — never the email in the URL) is an opaque per-
13
+ * subscriber capability, so this succeeds WITHOUT login in one click. It is idempotent
14
+ * (re-unsubscribing is a no-op success) and NON-LEAKING: an unknown token gets the exact
15
+ * same response as a real one, so an observer can't probe which tokens exist.
16
+ *
17
+ * NOTE (integration): Astro's global `security.checkOrigin` (default-on for a server
18
+ * build) 403s a cross-origin form-encoded POST before it reaches this handler, which
19
+ * would break the RFC 8058 one-click POST from a mailbox provider. See the unit report.
20
+ */
21
+ import type { APIContext } from "astro";
22
+ import { readD1 } from "@/lib/env";
23
+ import { fromD1 } from "@/lib/d1/catalog";
24
+ import { unsubscribeByToken } from "@/lib/newsletter/subscribers";
25
+
26
+ export const prerender = false;
27
+
28
+ /** Confirmation page for the clicked-link (GET) path. `noindex`; house serif style. */
29
+ function confirmationPage(): Response {
30
+ const html = `<!doctype html>
31
+ <html lang="en">
32
+ <head>
33
+ <meta charset="utf-8" />
34
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
35
+ <meta name="robots" content="noindex" />
36
+ <title>Unsubscribed</title>
37
+ <style>
38
+ :root { color-scheme: light dark; }
39
+ body { font-family: ui-serif, Georgia, serif; margin: 0; min-height: 100vh;
40
+ display: grid; place-items: center; padding: 2rem; text-align: center;
41
+ background: #f4f1ea; color: #2f3a34; }
42
+ .card { max-width: 30rem; }
43
+ h1 { font-size: clamp(1.6rem, 4vw, 2.2rem); margin: 0 0 0.75rem; }
44
+ p { color: #5a655e; line-height: 1.6; }
45
+ a { color: #2f3a34; font-weight: 600; }
46
+ </style>
47
+ </head>
48
+ <body>
49
+ <div class="card">
50
+ <h1>You're unsubscribed.</h1>
51
+ <p>You won't receive any more of these emails. Changed your mind? You can always sign up again.</p>
52
+ <p><a href="/">Back to the shop →</a></p>
53
+ </div>
54
+ </body>
55
+ </html>`;
56
+ return new Response(html, {
57
+ status: 200,
58
+ headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
59
+ });
60
+ }
61
+
62
+ /** Apply the unsubscribe for the token in the query string. Best-effort: never throws,
63
+ * and returns the same value regardless of whether the token was real (non-leaking).
64
+ * When no D1 binding is present (local/dev) it's a silent no-op — the response shape
65
+ * is identical either way. */
66
+ async function applyUnsubscribe(context: APIContext): Promise<void> {
67
+ try {
68
+ const token = new URL(context.request.url).searchParams.get("token") ?? "";
69
+ const d1 = await readD1("STOREFRONT_APPS_DB");
70
+ if (!d1) return;
71
+ await unsubscribeByToken(fromD1(d1), token, new Date().toISOString());
72
+ } catch {
73
+ // Unsubscribe must never error out at the reader — degrade to the same success
74
+ // response. (A failed write is preferable to showing a subscriber an error.)
75
+ }
76
+ }
77
+
78
+ export async function GET(context: APIContext): Promise<Response> {
79
+ await applyUnsubscribe(context);
80
+ return confirmationPage();
81
+ }
82
+
83
+ export async function POST(context: APIContext): Promise<Response> {
84
+ await applyUnsubscribe(context);
85
+ // RFC 8058 one-click: a bare 200, no body of consequence, no confirmation step.
86
+ return new Response("Unsubscribed", {
87
+ status: 200,
88
+ headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" },
89
+ });
90
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * PUT/HEAD /api/publish/stage-bundle?key=versions/<digest>/<rel> — the
3
+ * authenticated Worker staging surface that writes bundle objects into the
4
+ * `STATIC_BUNDLES` R2 binding, so CI stages digests WITHOUT a static R2
5
+ * S3-compatible key pair. Auth (a CI `private_key_jwt` assertion, RFC 7523/8707,
6
+ * one-time `jti`) + the write logic live in `@/lib/publish/stageBundleUpload`;
7
+ * this route only resolves the runtime bindings/config.
8
+ *
9
+ * Fails CLOSED (503 "not configured") until a human wires the CI client's public
10
+ * JWKS (`STATIC_BUNDLE_UPLOAD_JWKS`) + the endpoint's own audience
11
+ * (`STATIC_BUNDLE_UPLOAD_AUDIENCE`) — the same "needs provisioning" gate the R2
12
+ * buckets themselves sit behind. It is EXEMPT from the demo login gate
13
+ * (loginGate.ts `isStaticBundleUploadPath`): it carries its own, stricter auth,
14
+ * so it must answer server-to-server rather than being masked by a "Coming soon"
15
+ * holding page.
16
+ */
17
+ import type { APIContext } from "astro";
18
+ import { readEnv, readKv, readR2 } from "@/lib/env";
19
+ import {
20
+ handleStageBundleUpload,
21
+ kvJtiReplayStore,
22
+ DEFAULT_STAGE_BUNDLE_UPLOAD_ISSUER,
23
+ type StageBundleUploadBucket,
24
+ type StageBundleUploadConfig,
25
+ type JtiKvLike,
26
+ } from "@/lib/publish/stageBundleUpload";
27
+
28
+ export const prerender = false;
29
+
30
+ async function handle(context: APIContext): Promise<Response> {
31
+ const bucket = (await readR2("STATIC_BUNDLES")) as StageBundleUploadBucket | undefined;
32
+ if (!bucket) {
33
+ return new Response(JSON.stringify({ error: "no_bundle_store" }), {
34
+ status: 503,
35
+ headers: { "content-type": "application/json" },
36
+ });
37
+ }
38
+
39
+ const jwksRaw = await readEnv("STATIC_BUNDLE_UPLOAD_JWKS");
40
+ const audience = await readEnv("STATIC_BUNDLE_UPLOAD_AUDIENCE");
41
+ if (!jwksRaw || !audience) {
42
+ return new Response(JSON.stringify({ error: "upload_not_configured" }), {
43
+ status: 503,
44
+ headers: { "content-type": "application/json" },
45
+ });
46
+ }
47
+ let jwks: StageBundleUploadConfig["jwks"];
48
+ try {
49
+ jwks = JSON.parse(jwksRaw) as StageBundleUploadConfig["jwks"];
50
+ } catch {
51
+ return new Response(JSON.stringify({ error: "upload_not_configured" }), {
52
+ status: 503,
53
+ headers: { "content-type": "application/json" },
54
+ });
55
+ }
56
+ const issuer =
57
+ (await readEnv("STATIC_BUNDLE_UPLOAD_ISSUER")) ?? DEFAULT_STAGE_BUNDLE_UPLOAD_ISSUER;
58
+
59
+ const kv = (await readKv("TENANT_CACHE")) as JtiKvLike | undefined;
60
+
61
+ return handleStageBundleUpload({
62
+ request: context.request,
63
+ bucket,
64
+ config: { jwks, issuer, audience },
65
+ ...(kv ? { jtiStore: kvJtiReplayStore(kv) } : {}),
66
+ });
67
+ }
68
+
69
+ export const PUT = handle;
70
+ export const HEAD = handle;
@@ -14,6 +14,8 @@
14
14
  */
15
15
  import type { APIRoute } from "astro";
16
16
  import { rateMetric, type WebVitalName, type WebVitalsSample } from "@/lib/analytics/webVitals";
17
+ import { readKv, runAfterResponse, type CfExecutionContextLike } from "@/lib/env";
18
+ import { recordBlogView } from "@/lib/blog/viewCounts";
17
19
 
18
20
  export const prerender = false;
19
21
 
@@ -38,14 +40,31 @@ function parseSample(raw: unknown): WebVitalsSample | null {
38
40
  };
39
41
  }
40
42
 
41
- export const POST: APIRoute = async ({ request }) => {
43
+ export const POST: APIRoute = async (context) => {
42
44
  try {
43
- const sample = parseSample(await request.json());
45
+ const sample = parseSample(await context.request.json());
44
46
  if (sample) {
45
47
  // eslint-disable-next-line no-console
46
48
  console.info("[rum:web-vitals]", JSON.stringify(sample));
47
49
  // TODO(F14 follow-up): forward to Analytics Engine and run
48
50
  // rumAlert.detectRegressions on the per-route/hour rollup.
51
+
52
+ // B3.3 — per-post blog view counts. CLS is emitted exactly once per page
53
+ // load (LCP/INP are conditional), so it is the reliable one-per-view
54
+ // signal; count off it only. `recordBlogView` no-ops unless `sample.path`
55
+ // resolves to a live post for the resolved tenant. Runs PAST the response
56
+ // so the beacon 204 is never delayed and the KV write never fails the
57
+ // request.
58
+ if (sample.metric === "cls") {
59
+ const tenantId = context.locals?.tenant?.tenant_id;
60
+ const kv = tenantId ? await readKv("TENANT_CACHE") : undefined;
61
+ if (tenantId && kv) {
62
+ const cfContext =
63
+ (context.locals as { runtime?: { ctx?: CfExecutionContextLike } })?.runtime?.ctx ??
64
+ null;
65
+ runAfterResponse(recordBlogView(kv, tenantId, sample.path), cfContext);
66
+ }
67
+ }
49
68
  }
50
69
  } catch {
51
70
  /* never fail a telemetry beacon */
@@ -11,6 +11,7 @@
11
11
  * reach the browser. Inline style carries the per-request CSP nonce.
12
12
  */
13
13
  import { normalizeEmailHint } from "@/lib/privacy/emailHint";
14
+ import { safeNext } from "@/lib/auth/route";
14
15
  // Self-hosted web fonts (audit finding 6) — same-origin @fontsource, replacing
15
16
  // the render-blocking fonts.googleapis.com stylesheet. Covers Plus Jakarta Sans,
16
17
  // Inter, and JetBrains Mono used by this page's inline styles.
@@ -24,10 +25,10 @@ const sent = url.searchParams.get("sent") === "1";
24
25
  const email = (url.searchParams.get("email") ?? "").trim();
25
26
  const emailHint = normalizeEmailHint(url.searchParams.get("email_hint")) ?? "";
26
27
  const errorCode = url.searchParams.get("error");
27
- const next = (() => {
28
- const n = url.searchParams.get("next") ?? "/";
29
- return n.startsWith("/") && !n.startsWith("//") ? n : "/";
30
- })();
28
+ // Same open-redirect chokepoint the verify endpoint + login gate use — never a
29
+ // parallel weaker copy (this value is threaded into the sign-in form's hidden
30
+ // `next` and back to /api/auth/verify, which re-checks it with the same guard).
31
+ const next = safeNext(url.searchParams.get("next"));
31
32
 
32
33
  // RFC 9470 step-up: tot core told /api/auth/verify (or /api/auth/magic-exchange,
33
34
  // which redirects to /auth/magic instead) that it needs a fresher credential