@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,215 @@
1
+ /**
2
+ * Authenticated static-bundle STAGING upload — the Worker-side write surface that
3
+ * lets CI stage a materialized bundle's objects into the `STATIC_BUNDLES` R2
4
+ * binding WITHOUT a static R2 S3-compatible key pair (`R2_ACCESS_KEY_ID`/
5
+ * `R2_SECRET_ACCESS_KEY`). CI proves identity with the SAME OIDC→AWS→SSM→
6
+ * `private_key_jwt` chain it already uses to authenticate to MCP
7
+ * (`scripts/publish/ci-mcp-read-remote.mjs`): it mints a short-lived RS256
8
+ * `client_assertion` under its dedicated confidential client
9
+ * (`storefront-ci.tokenoftrust.com` / kid `storefront-ci-publish-1`, the key held
10
+ * only in SSM), audience-bound to THIS upload endpoint, and the Worker verifies
11
+ * the signature against that client's PUBLISHED public JWKS.
12
+ *
13
+ * This is a WRITE path to a production-serving bucket, so the auth boundary is
14
+ * deliberately as strong as the MCP leg:
15
+ * - RS256 signature over the CI client's JWKS (no shared static secret).
16
+ * - `iss == sub == <expected CI client id>` (RFC 7523 §3).
17
+ * - `aud == <this exact upload endpoint>` (RFC 8707 resource binding) — an
18
+ * assertion minted for MCP's token endpoint can never be replayed here, and
19
+ * vice versa.
20
+ * - `exp`/`iat` freshness (jose enforces `exp`; assertions live ~60s).
21
+ * - `jti` one-time-use replay rejection (KV-backed), mirroring MCP's replay
22
+ * store — so a captured assertion can't be replayed inside its TTL window.
23
+ * - Object key confined to `versions/<digest>/…`: a valid CI credential can
24
+ * only write immutable version archives, never the `live/` pointer or any
25
+ * other bucket key.
26
+ *
27
+ * The KEY SCHEME (`versions/<digest>/<rel>`) MUST byte-match the read side
28
+ * (`@tot/public-runtime` `staticBundleObjectKey`/`staticBundleManifestKey`) and
29
+ * the CI writer (`scripts/publish/lib/r2-publish.mjs` `r2ObjectKey`) — the same
30
+ * lockstep those two already keep. MANIFEST-LAST ordering (the manifest is the
31
+ * "fully staged" signal) is the CALLER's responsibility (CI writes it last, the
32
+ * same as `writeBundleToR2`); this surface writes exactly the one object it is
33
+ * handed per request.
34
+ */
35
+ import { createLocalJWKSet, jwtVerify, type JSONWebKeySet, type JWTPayload } from "jose";
36
+
37
+ /** The only signature algorithm accepted (matches the CI minter). */
38
+ export const STAGE_BUNDLE_UPLOAD_ALG = "RS256";
39
+
40
+ /** The route this surface is mounted at — the canonical `aud`/resource identifier. */
41
+ export const STAGE_BUNDLE_UPLOAD_PATH = "/api/publish/stage-bundle";
42
+
43
+ /** Default CI confidential-client id (`iss == sub`), overridable per env. */
44
+ export const DEFAULT_STAGE_BUNDLE_UPLOAD_ISSUER = "storefront-ci.tokenoftrust.com";
45
+
46
+ /** How long a used `jti` is remembered — the assertion TTL (~60s) plus clock skew. */
47
+ export const STAGE_BUNDLE_JTI_TTL_SECONDS = 120;
48
+
49
+ /** Clock skew tolerated on `exp`/`iat`, in seconds. */
50
+ const CLOCK_TOLERANCE_SECONDS = 5;
51
+
52
+ /**
53
+ * The `versions/<digest>/<rel>` keys a CI credential is allowed to write. The
54
+ * digest is a lowercase hex tree digest; `rel` is a bundle-relative POSIX path
55
+ * with no traversal. Anything else (a bare key, `live/…`, an absolute path, a
56
+ * `..` segment) is refused with 400 — the write authority is bounded to
57
+ * immutable version archives regardless of a valid signature.
58
+ */
59
+ const VERSION_KEY_RE = /^versions\/[a-f0-9]{8,}\/.+$/;
60
+
61
+ /** Minimal R2 surface this module needs — structural, so tests pass a fake. */
62
+ export interface StageBundleUploadBucket {
63
+ head(key: string): Promise<{ key: string } | null>;
64
+ put(
65
+ key: string,
66
+ value: ArrayBuffer | ArrayBufferView,
67
+ options?: { httpMetadata?: { contentType?: string } },
68
+ ): Promise<unknown>;
69
+ }
70
+
71
+ /**
72
+ * One-time-use `jti` store. `checkAndRemember` returns `true` when the `jti` was
73
+ * unseen (and records it), `false` when it was already used (a replay). Backed by
74
+ * KV in the deployed Worker; omitted in dev/tests where no KV is bound (replay
75
+ * protection then relies on the ~60s `aud`-bound `exp` window alone).
76
+ */
77
+ export interface JtiReplayStore {
78
+ checkAndRemember(jti: string, ttlSeconds: number): Promise<boolean>;
79
+ }
80
+
81
+ export interface StageBundleUploadConfig {
82
+ /** The CI client's published PUBLIC JWKS (verifies the assertion signature). */
83
+ jwks: JSONWebKeySet;
84
+ /** Expected `iss == sub` (the CI confidential-client id). */
85
+ issuer: string;
86
+ /** Expected `aud` — this exact upload endpoint URL (RFC 8707 resource). */
87
+ audience: string;
88
+ }
89
+
90
+ /** A KV-namespace-shaped store (`get`/`put`) — the deployed jti replay backing. */
91
+ export interface JtiKvLike {
92
+ get(key: string): Promise<string | null>;
93
+ put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;
94
+ }
95
+
96
+ /** Build a KV-backed {@link JtiReplayStore}. Keyed under a dedicated prefix. */
97
+ export function kvJtiReplayStore(kv: JtiKvLike): JtiReplayStore {
98
+ return {
99
+ async checkAndRemember(jti, ttlSeconds) {
100
+ const key = `stage-bundle-jti:${jti}`;
101
+ if (await kv.get(key)) return false;
102
+ // KV's minimum expirationTtl is 60s; the TTL default already exceeds it.
103
+ await kv.put(key, "1", { expirationTtl: Math.max(60, ttlSeconds) });
104
+ return true;
105
+ },
106
+ };
107
+ }
108
+
109
+ function json(status: number, body: Record<string, unknown>): Response {
110
+ return new Response(JSON.stringify(body), {
111
+ status,
112
+ headers: { "content-type": "application/json" },
113
+ });
114
+ }
115
+
116
+ /** Extract the bearer assertion from the Authorization header, or null. */
117
+ function bearer(request: Request): string | null {
118
+ const h = request.headers.get("authorization");
119
+ if (!h) return null;
120
+ const m = /^Bearer (.+)$/.exec(h.trim());
121
+ return m ? m[1]!.trim() : null;
122
+ }
123
+
124
+ /**
125
+ * Verify the CI `client_assertion`: RS256 signature over the configured JWKS,
126
+ * `iss`/`sub`/`aud` exact match, `exp` freshness, and one-time `jti`. Returns the
127
+ * verified payload, or a `Response` (401) to return verbatim on any failure — no
128
+ * failure detail beyond a stable machine reason is leaked.
129
+ */
130
+ export async function verifyUploadAssertion(
131
+ token: string,
132
+ config: StageBundleUploadConfig,
133
+ jtiStore?: JtiReplayStore,
134
+ ): Promise<{ payload: JWTPayload } | { reject: Response }> {
135
+ let payload: JWTPayload;
136
+ try {
137
+ const keys = createLocalJWKSet(config.jwks);
138
+ ({ payload } = await jwtVerify(token, keys, {
139
+ algorithms: [STAGE_BUNDLE_UPLOAD_ALG],
140
+ issuer: config.issuer,
141
+ audience: config.audience,
142
+ clockTolerance: CLOCK_TOLERANCE_SECONDS,
143
+ }));
144
+ } catch {
145
+ // Signature, iss/aud mismatch, or expiry — one opaque 401 (never which).
146
+ return { reject: json(401, { error: "invalid_assertion" }) };
147
+ }
148
+ // RFC 7523 §3: sub must equal the client id (iss). jose verifies iss but not
149
+ // that sub matches it, so assert it explicitly.
150
+ if (payload.sub !== config.issuer) {
151
+ return { reject: json(401, { error: "invalid_assertion" }) };
152
+ }
153
+ const jti = typeof payload.jti === "string" ? payload.jti : "";
154
+ if (!jti) {
155
+ return { reject: json(401, { error: "invalid_assertion" }) };
156
+ }
157
+ if (jtiStore) {
158
+ const fresh = await jtiStore.checkAndRemember(jti, STAGE_BUNDLE_JTI_TTL_SECONDS);
159
+ if (!fresh) return { reject: json(401, { error: "assertion_replayed" }) };
160
+ }
161
+ return { payload };
162
+ }
163
+
164
+ /** True iff `key` is a well-formed, traversal-free `versions/<digest>/<rel>` key. */
165
+ export function isSafeVersionKey(key: string): boolean {
166
+ if (!VERSION_KEY_RE.test(key)) return false;
167
+ return !key.split("/").some((seg) => seg === ".." || seg === "." || seg === "");
168
+ }
169
+
170
+ /**
171
+ * Handle one authenticated staging request against `bucket`:
172
+ * - `PUT ?key=versions/<digest>/<rel>` — write the request body as that object.
173
+ * - `HEAD ?key=versions/<digest>/<rel>` — 200 if present, 404 if not (the CI
174
+ * idempotency probe, mirroring `r2ObjectExists`).
175
+ * Both require a valid CI assertion; the key is confined to `versions/<digest>/`.
176
+ */
177
+ export async function handleStageBundleUpload(opts: {
178
+ request: Request;
179
+ bucket: StageBundleUploadBucket;
180
+ config: StageBundleUploadConfig;
181
+ jtiStore?: JtiReplayStore;
182
+ }): Promise<Response> {
183
+ const { request, bucket, config, jtiStore } = opts;
184
+ const method = request.method.toUpperCase();
185
+ if (method !== "PUT" && method !== "HEAD") {
186
+ return json(405, { error: "method_not_allowed" });
187
+ }
188
+
189
+ const token = bearer(request);
190
+ if (!token) return json(401, { error: "missing_bearer" });
191
+
192
+ // HEAD is a read/probe and must NOT consume the one-time jti (a PUT retry after
193
+ // a probe would then falsely 401 as a replay). Only PUT records the jti.
194
+ const verified = await verifyUploadAssertion(
195
+ token,
196
+ config,
197
+ method === "PUT" ? jtiStore : undefined,
198
+ );
199
+ if ("reject" in verified) return verified.reject;
200
+
201
+ const key = new URL(request.url).searchParams.get("key") ?? "";
202
+ if (!isSafeVersionKey(key)) {
203
+ return json(400, { error: "invalid_key" });
204
+ }
205
+
206
+ if (method === "HEAD") {
207
+ const obj = await bucket.head(key);
208
+ return new Response(null, { status: obj ? 200 : 404 });
209
+ }
210
+
211
+ const body = await request.arrayBuffer();
212
+ const contentType = request.headers.get("content-type") ?? undefined;
213
+ await bucket.put(key, body, contentType ? { httpMetadata: { contentType } } : undefined);
214
+ return json(200, { key, staged: true });
215
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Fixed-window KV rate limiter — the storefront's first throttle, built small
3
+ * and generic so any public POST can share it (reactions today; blog comments
4
+ * and other reader writes next).
5
+ *
6
+ * A request is counted into the bucket for the current fixed window
7
+ * (`floor(now / windowSeconds)`), keyed `<key>:<bucket>`. The counter TTLs out a
8
+ * window after it is first written, so old buckets self-expire — no sweep.
9
+ *
10
+ * Best-effort by construction: a KV read/write hiccup FAILS OPEN (returns
11
+ * `allowed: true`) and never throws. A rate limiter must never turn a KV blip
12
+ * into a rejected legitimate request; the abuse it guards against is low-stakes
13
+ * (mirrors the "never throws" discipline of `blog/viewCounts.ts`).
14
+ */
15
+
16
+ export interface RateLimitOptions {
17
+ /** Window length in seconds; the counter resets each window. */
18
+ windowSeconds: number;
19
+ /** Max requests permitted per key per window before rejecting. */
20
+ maxRequests: number;
21
+ }
22
+
23
+ /** The current fixed-window bucket index for `now` (ms epoch). */
24
+ function windowBucket(windowSeconds: number, nowMs: number): number {
25
+ return Math.floor(nowMs / 1000 / windowSeconds);
26
+ }
27
+
28
+ /**
29
+ * Count one request against `key` and report whether it is within the cap for
30
+ * the current window. Increment is a read-modify-write, so under concurrency a
31
+ * few increments can be lost to KV's last-write-wins — acceptable for a soft
32
+ * abuse throttle, not a hard quota. Fails OPEN on any KV error.
33
+ */
34
+ export async function checkRateLimit(
35
+ kv: KVNamespace | undefined,
36
+ key: string,
37
+ opts: RateLimitOptions,
38
+ nowMs: number = Date.now(),
39
+ ): Promise<{ allowed: boolean }> {
40
+ if (!kv) return { allowed: true };
41
+ try {
42
+ const bucket = windowBucket(opts.windowSeconds, nowMs);
43
+ const bucketKey = `${key}:${bucket}`;
44
+ const raw = await kv.get(bucketKey);
45
+ const current = raw == null ? 0 : Number.parseInt(raw, 10);
46
+ const count = Number.isFinite(current) && current >= 0 ? current : 0;
47
+ if (count >= opts.maxRequests) return { allowed: false };
48
+ await kv.put(bucketKey, String(count + 1), { expirationTtl: opts.windowSeconds });
49
+ return { allowed: true };
50
+ } catch {
51
+ return { allowed: true };
52
+ }
53
+ }
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Used by FixtureToTClient.search (no D1 binding): typo-tolerant, relevance-ranked
5
5
  * search over the bundled catalog snapshot, zero infra. When a D1 binding is wired
6
- * (D1ToTClient), search instead goes through FTS5 — see lib/d1/catalog.ts and
7
- * epics/edge-read-replica.md. This module is the portable floor an extracted
6
+ * (D1ToTClient), search instead goes through FTS5 — see lib/d1/catalog.ts.
7
+ * This module is the portable floor an extracted
8
8
  * merchant gets out of the box (docs/extraction-runbook.md).
9
9
  */
10
10
  import Fuse, { type IFuseOptions } from "fuse.js";
@@ -0,0 +1,42 @@
1
+ /**
2
+ * hreflang alternates for a translated article (I2). Two pure helpers so the
3
+ * article route and <Seo> stay thin and the "singleton emits nothing" guard is
4
+ * unit-testable without an Astro render harness.
5
+ */
6
+ export interface Alternate {
7
+ hreflang: string;
8
+ href: string;
9
+ }
10
+
11
+ /**
12
+ * Build the per-variant alternate set for an article's translation group. Returns
13
+ * `[]` for a singleton (≤1 variant) so a non-translated post emits no `hreflang`
14
+ * links and renders byte-identically to today. Each variant maps to its own
15
+ * localized canonical via `hrefForSlug`; `inLanguage` is the per-variant language
16
+ * key (defaulting "en", matching the field's repo-wide default).
17
+ */
18
+ export function buildAlternates(
19
+ variants: { slug: string; inLanguage?: string }[],
20
+ hrefForSlug: (slug: string) => string,
21
+ ): Alternate[] {
22
+ if (variants.length <= 1) return [];
23
+ return variants.map((v) => ({
24
+ hreflang: v.inLanguage ?? "en",
25
+ href: hrefForSlug(v.slug),
26
+ }));
27
+ }
28
+
29
+ /**
30
+ * Append the `x-default` entry to a per-variant alternate set. It points at the
31
+ * English variant (`en` or an `en-*` region variant); with no English variant it
32
+ * falls back to the first entry — a deterministic primary rather than none, so
33
+ * search engines always have a language-agnostic default. Returns `[]` unchanged
34
+ * for an empty input (nothing to default to).
35
+ */
36
+ export function withXDefault(alternates: Alternate[]): Alternate[] {
37
+ if (alternates.length === 0) return [];
38
+ const primary =
39
+ alternates.find((a) => a.hreflang === "en" || a.hreflang.startsWith("en-")) ??
40
+ alternates[0]!;
41
+ return [...alternates, { hreflang: "x-default", href: primary.href }];
42
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Pure builders for the extra <meta> tags `<Seo>` emits on article pages
3
+ * (B1.6). Kept as a pure function so the "no article meta leaks onto
4
+ * website/product pages" invariant is unit-testable without rendering Astro.
5
+ */
6
+
7
+ /** Open Graph `article:*` fields — only meaningful when `ogType === "article"`. */
8
+ export interface ArticleMeta {
9
+ publishedTime?: string;
10
+ modifiedTime?: string;
11
+ /** Author name or author-page URL → article:author. */
12
+ author?: string;
13
+ /** The category/pillar → article:section. */
14
+ section?: string;
15
+ /** One <meta property="article:tag"> per entry. */
16
+ tags?: string[];
17
+ }
18
+
19
+ /** A meta tag is keyed by either `property` (OG) or `name` (Twitter/std). */
20
+ export interface MetaTag {
21
+ property?: string;
22
+ name?: string;
23
+ content: string;
24
+ }
25
+
26
+ export interface ArticleSeoInput {
27
+ ogType?: "website" | "product" | "article";
28
+ article?: ArticleMeta;
29
+ /** og:image:alt — the accessible description of the OG image. */
30
+ imageAlt?: string;
31
+ imageWidth?: number;
32
+ imageHeight?: number;
33
+ twitterSite?: string;
34
+ twitterCreator?: string;
35
+ }
36
+
37
+ /**
38
+ * The article-only OG/Twitter meta tags. Returns `[]` for any non-article page
39
+ * so `article:*`, `og:image:alt`, and `twitter:site`/`creator` can never leak
40
+ * onto website/product pages (guarded here, asserted in meta.test.ts).
41
+ */
42
+ export function articleSeoMetaTags(input: ArticleSeoInput): MetaTag[] {
43
+ if (input.ogType !== "article") return [];
44
+ const tags: MetaTag[] = [];
45
+ const a = input.article;
46
+ if (a?.publishedTime)
47
+ tags.push({ property: "article:published_time", content: a.publishedTime });
48
+ if (a?.modifiedTime)
49
+ tags.push({ property: "article:modified_time", content: a.modifiedTime });
50
+ if (a?.author) tags.push({ property: "article:author", content: a.author });
51
+ if (a?.section) tags.push({ property: "article:section", content: a.section });
52
+ for (const tag of a?.tags ?? [])
53
+ tags.push({ property: "article:tag", content: tag });
54
+ if (input.imageAlt)
55
+ tags.push({ property: "og:image:alt", content: input.imageAlt });
56
+ if (input.imageWidth)
57
+ tags.push({ property: "og:image:width", content: String(input.imageWidth) });
58
+ if (input.imageHeight)
59
+ tags.push({ property: "og:image:height", content: String(input.imageHeight) });
60
+ if (input.twitterSite)
61
+ tags.push({ name: "twitter:site", content: input.twitterSite });
62
+ if (input.twitterCreator)
63
+ tags.push({ name: "twitter:creator", content: input.twitterCreator });
64
+ return tags;
65
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * S1 — the social go-live hook + idempotency.
3
+ *
4
+ * WHERE IT FIRES: at the true go-live edge — after `AggregateShipOrchestrator`
5
+ * flips a tenant's `live` channel to a freshly-shipped version and records the
6
+ * release ledger. NOT on `POST /api/admin/blog`'s `publish` (which only opens a
7
+ * candidate PR a human still promotes) and NOT on accept-to-preview
8
+ * (`/api/changes/accept` → `integrate`, which only advances the `preview`
9
+ * aggregate). Go-live is where a post's file actually becomes public, so it is the
10
+ * only truthful "it went live" edge.
11
+ *
12
+ * WHAT IT DETECTS: a blog post that transitioned INTO a live-visible status — was
13
+ * absent or not-live in the version live before this ship (`fromVersionId`), and
14
+ * is live-visible in the version just promoted (`toVersionId`). Visibility is the
15
+ * ONE chokepoint `isVisibleOnLive` (published / unlisted / due-scheduled), reused
16
+ * so this never drifts from the render path.
17
+ *
18
+ * DISCIPLINE (mirrors `webhooks/emit.ts`): BEST-EFFORT and fire-and-forget — every
19
+ * failure is logged and swallowed. A social outage must NEVER fail or roll back a
20
+ * completed go-live.
21
+ *
22
+ * IDEMPOTENCY: a check-and-set posted-state key per `(tenant, collection, slug,
23
+ * network)` fires each post AT MOST ONCE, so a re-ship / re-run never re-posts. The
24
+ * transition guard also marks already-live posts as "primed" (key set, not fired)
25
+ * so the first ship after this hook deploys does NOT retro-announce the tenant's
26
+ * whole back-catalog.
27
+ */
28
+ import { isVisibleOnLive } from "../blog/collection.js";
29
+ import type { BlogPost, PostStatus } from "../blog/types.js";
30
+ import { notifyPostPublished } from "./notify.js";
31
+
32
+ /**
33
+ * The minimal version-read surface this hook needs — a structural subset of the
34
+ * control-plane `SiteVersionStore`. Declared locally (not imported) so `lib/social`
35
+ * stays inside the source-available renderer carve and never edges onto
36
+ * `@tot/private-controlplane`; the caller (the control-plane-cluster orchestrator)
37
+ * passes its real store in.
38
+ */
39
+ export interface VersionReadStore {
40
+ getSiteVersion(
41
+ tenantId: string,
42
+ versionId: string,
43
+ ): Promise<{ artifacts: Record<string, string> } | null>;
44
+ getVersionContent(
45
+ tenantId: string,
46
+ path: string,
47
+ versionId: string,
48
+ ): Promise<string | Uint8Array | null>;
49
+ }
50
+
51
+ /**
52
+ * The v1 idempotency network token — a single whole-post gate. S3/S4 replace this
53
+ * with the real per-network id (`x` / `linkedin` / …) and iterate networks; the key
54
+ * shape is ALREADY per-network so that extension needs no rework here.
55
+ */
56
+ export const SOCIAL_NETWORK = "social";
57
+
58
+ /** The posted-once key for one post on one network. */
59
+ export function socialPostedKey(
60
+ tenantId: string,
61
+ collection: string,
62
+ slug: string,
63
+ network: string,
64
+ ): string {
65
+ return `social:posted:${tenantId}:${collection}:${slug}:${network}`;
66
+ }
67
+
68
+ /** The minimal KV surface the idempotency store needs (TENANT_CACHE in prod). */
69
+ export interface SocialIdempotencyKv {
70
+ get(key: string): Promise<string | null>;
71
+ put(key: string, value: string): Promise<void>;
72
+ }
73
+
74
+ // `.../content/<collection>/posts/<slug>.json` — capture collection + slug from a
75
+ // version-manifest artifact path (robust to the leading `tenants/<id>/` prefix).
76
+ const POST_PATH_RE = /(?:^|\/)content\/([a-z0-9][a-z0-9._-]*)\/posts\/([a-z0-9][a-z0-9-]*)\.json$/;
77
+
78
+ function logDiag(fields: Record<string, unknown>): void {
79
+ try {
80
+ console.warn("[diag:social:golive]", JSON.stringify(fields));
81
+ } catch {
82
+ /* diagnostics must never break the caller */
83
+ }
84
+ }
85
+
86
+ // Legacy/seeded post JSON may omit `status`; default it to "published" on load, the
87
+ // same normalization `blog/collection.ts` applies so visibility matches the render path.
88
+ function normalizeStatus(raw: unknown): PostStatus {
89
+ return raw === "draft" || raw === "unlisted" || raw === "published" || raw === "scheduled"
90
+ ? raw
91
+ : "published";
92
+ }
93
+
94
+ // `contentVersionId` is the artifact's content hash (`manifest.artifacts[path]`) —
95
+ // the version store keys blob content by that hash, not by the site version id.
96
+ async function readPost(
97
+ store: VersionReadStore,
98
+ tenantId: string,
99
+ artifactPath: string,
100
+ contentVersionId: string,
101
+ ): Promise<BlogPost | null> {
102
+ const raw = await store.getVersionContent(tenantId, artifactPath, contentVersionId);
103
+ if (typeof raw !== "string") return null;
104
+ let parsed: unknown;
105
+ try {
106
+ parsed = JSON.parse(raw);
107
+ } catch {
108
+ return null;
109
+ }
110
+ if (parsed === null || typeof parsed !== "object") return null;
111
+ const post = parsed as BlogPost;
112
+ return { ...post, status: normalizeStatus((post as { status?: unknown }).status) };
113
+ }
114
+
115
+ export interface NotifyPostsWentLiveDeps {
116
+ /** The version store the shipped tenant publishes to (a control-plane `SiteVersionStore`). */
117
+ store: VersionReadStore;
118
+ /** Idempotency store — TENANT_CACHE in prod. */
119
+ kv: SocialIdempotencyKv;
120
+ now?: () => string;
121
+ /** Override the publish seam (tests + S3/S4 injection). Defaults to {@link notifyPostPublished}. */
122
+ notify?: (tenantId: string, collection: string, slug: string, post: BlogPost) => Promise<void>;
123
+ }
124
+
125
+ export interface NotifyPostsWentLiveInput {
126
+ tenantId: string;
127
+ /** The version just promoted to `live`. */
128
+ toVersionId: string;
129
+ /** The version live immediately BEFORE this ship, or null on the first-ever ship. */
130
+ fromVersionId: string | null;
131
+ }
132
+
133
+ /**
134
+ * For each blog post that went live in `toVersionId`, fire {@link notifyPostPublished}
135
+ * at most once. NEVER throws — the whole body is guarded so a completed go-live is
136
+ * never failed or rolled back by this hook.
137
+ */
138
+ export async function notifyPostsWentLive(
139
+ deps: NotifyPostsWentLiveDeps,
140
+ input: NotifyPostsWentLiveInput,
141
+ ): Promise<void> {
142
+ try {
143
+ const { store, kv } = deps;
144
+ const now = (deps.now ?? (() => new Date().toISOString()))();
145
+ const notify = deps.notify ?? notifyPostPublished;
146
+
147
+ const toManifest = await store.getSiteVersion(input.tenantId, input.toVersionId);
148
+ if (!toManifest) return;
149
+ const fromManifest = input.fromVersionId
150
+ ? await store.getSiteVersion(input.tenantId, input.fromVersionId)
151
+ : null;
152
+
153
+ for (const [artifactPath, contentHash] of Object.entries(toManifest.artifacts)) {
154
+ const match = POST_PATH_RE.exec(artifactPath);
155
+ if (!match) continue;
156
+ const collection = match[1]!;
157
+ const slug = match[2]!;
158
+ const key = socialPostedKey(input.tenantId, collection, slug, SOCIAL_NETWORK);
159
+
160
+ // Already posted (or primed) — skip before any content read (cheap steady state).
161
+ let already: string | null = null;
162
+ try {
163
+ already = await kv.get(key);
164
+ } catch {
165
+ already = null;
166
+ }
167
+ if (already) continue;
168
+
169
+ const post = await readPost(store, input.tenantId, artifactPath, contentHash);
170
+ // Not live now (draft / not-yet-due scheduled) — do NOT set the key, so it is
171
+ // re-checked on the ship that eventually takes it live.
172
+ if (!post || !isVisibleOnLive(post, now)) continue;
173
+
174
+ // Transition guard: if the post was ALREADY live in the prior version, this ship
175
+ // did not take it live — PRIME the key (so we never re-read it) but do NOT notify.
176
+ const prevHash = fromManifest?.artifacts[artifactPath];
177
+ if (prevHash) {
178
+ const prev = await readPost(store, input.tenantId, artifactPath, prevHash);
179
+ if (prev && isVisibleOnLive(prev, now)) {
180
+ try {
181
+ await kv.put(key, now);
182
+ } catch {
183
+ /* best-effort prime */
184
+ }
185
+ continue;
186
+ }
187
+ }
188
+
189
+ // Genuine went-live transition — fire once, THEN set the posted key. If notify
190
+ // throws, the key is left unset so the next ship retries (at-least-check, at-most-post).
191
+ try {
192
+ await notify(input.tenantId, collection, slug, post);
193
+ await kv.put(key, now);
194
+ } catch (err) {
195
+ logDiag({
196
+ event: "notify_failed",
197
+ tenantId: input.tenantId,
198
+ collection,
199
+ slug,
200
+ error: (err as Error).message,
201
+ });
202
+ }
203
+ }
204
+ } catch (err) {
205
+ logDiag({
206
+ event: "hook_failed",
207
+ tenantId: input.tenantId,
208
+ toVersionId: input.toVersionId,
209
+ error: (err as Error).message,
210
+ });
211
+ }
212
+ }
@@ -0,0 +1,32 @@
1
+ import type { BlogPost } from "../blog/types.js";
2
+
3
+ /** Swallow-and-log a soft failure — logging itself must never throw (mirrors `webhooks/emit.ts`). */
4
+ function logDiag(fields: Record<string, unknown>): void {
5
+ try {
6
+ console.warn("[diag:social:notify]", JSON.stringify(fields));
7
+ } catch {
8
+ /* diagnostics must never break the caller */
9
+ }
10
+ }
11
+
12
+ /**
13
+ * The S3/S4 SEAM — the single call the go-live hook makes when a blog post
14
+ * transitions INTO a live-visible state (see {@link notifyPostsWentLive}). This
15
+ * unit (S1) ships the HOOK + the at-most-once idempotency key, NOT the network
16
+ * last-mile: v1 is a no-op announcement (logs only).
17
+ *
18
+ * S3 (the `SocialPublisher` interface + X adapter) and S4 (per-`(tenant,network)`
19
+ * config + encrypted token store) replace THIS body to compose a per-network
20
+ * payload — title, canonical URL, excerpt, featured image, `categories[0]` →
21
+ * hashtags — and POST it to each configured network's publish API. The signature
22
+ * is deliberately per-POST; per-network fan-out and the per-network idempotency
23
+ * key live one layer up in the hook, so extending to X + LinkedIn is additive.
24
+ */
25
+ export async function notifyPostPublished(
26
+ tenantId: string,
27
+ collection: string,
28
+ slug: string,
29
+ post: BlogPost,
30
+ ): Promise<void> {
31
+ logDiag({ event: "post_published", tenantId, collection, slug, title: post.title });
32
+ }