@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
@@ -1,10 +1,10 @@
1
1
  /**
2
- * Widget eligibility — PrivateApps epic, D5 (Chunk C). Mirrors
2
+ * Widget eligibility. Mirrors
3
3
  * `webhooks/emit.ts`'s `isEligibleTarget` discipline: an install is eligible
4
4
  * to render its widget at `slot` only while it is LIVE — `active`, scoped for
5
5
  * `widgets:launch`, and carrying a validated placement for that slot. This
6
6
  * live check is what makes suspending an install actually stop it rendering
7
- * on the very next page load — the D8 "suspension kills widgets"
7
+ * on the very next page load — the "suspension kills widgets"
8
8
  * enforcement — with no separate revoke step; it's just this gate re-run on
9
9
  * every request.
10
10
  */
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Pure prop-building for `<AppWidgetFrame>` PrivateApps epic, D5 (Chunk D).
3
- * Split out of the `.astro` component so the query-param handoff and the
4
- * sandbox attrs are unit-testable: `vitest.config.ts` deliberately does NOT
2
+ * Pure prop-building for `<AppWidgetFrame>`. The query-param handoff and the
3
+ * sandbox attrs live here (not in the `.astro` component) so they are
4
+ * unit-testable: `vitest.config.ts` deliberately does NOT
5
5
  * render `.astro` components (Astro Container API breaks under vitest@2 +
6
6
  * astro@7 — see that file's docstring), so render *logic* lives here and
7
7
  * rendered output is verified on a live preview instead.
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Widget launch-token minting — PrivateApps epic, D5 Chunk B. Mints the
2
+ * Widget launch-token minting. Mints the
3
3
  * short-lived RS256 JWT a platform-managed sandboxed widget iframe carries at
4
- * SSR render time: Chunk D calls `mintWidgetLaunchToken` while rendering the
4
+ * SSR render time: the page-render path calls `mintWidgetLaunchToken` while rendering the
5
5
  * page for each declared placement, and the resulting token is what the
6
6
  * widget's own endpoint receives as proof it was launched by THIS platform,
7
7
  * for THIS install, at THIS placement — never a merchant-forged or replayed
@@ -31,10 +31,10 @@ import {
31
31
  export interface MintWidgetLaunchTokenInput {
32
32
  /**
33
33
  * Only `installId`/`tenantId`/`env` are read. Narrowed from `AppInstall` to
34
- * this `Pick` so Chunk D's render path — which has `placements.ts`'s
34
+ * this `Pick` so the render path — which has `placements.ts`'s
35
35
  * `ResolvedPlacement` (installId/appOrigin/endpoint), not the full
36
36
  * `AppInstall` — can build the minimal object itself instead of re-fetching
37
- * the install; a full `AppInstall` still satisfies this shape unchanged.
37
+ * the install; a full `AppInstall` still satisfies this shape.
38
38
  */
39
39
  install: Pick<AppInstall, "installId" | "tenantId" | "env">;
40
40
  slot: WidgetPlacement;
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Widget placement resolution — PrivateApps epic, D5 (Chunk C). Turns "which
3
- * apps have a live widget at this slot" into the concrete list Chunk D needs
2
+ * Widget placement resolution. Turns "which
3
+ * apps have a live widget at this slot" into the concrete list the render path needs
4
4
  * to render sandboxed iframes and mint launch tokens: reads
5
5
  * `registry.listByTenant`, filters to the requested `env` and
6
6
  * {@link isWidgetEligible}, then reads each eligible install's `endpoint`
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Widget slot render composition — PrivateApps epic, D5 (Chunk D). The one
2
+ * Widget slot render composition. The one
3
3
  * call a page/layout makes to turn "render slot X for this request" into the
4
4
  * concrete, launch-token-bearing list `AppWidgetFrame` needs: resolves live
5
5
  * placements (`placements.ts`), mints each one a launch token
@@ -1,10 +1,10 @@
1
1
  /**
2
- * Admin session-entry resolution (unit dg6) — the SERVER-SIDE decision that puts
2
+ * Admin session-entry resolution — the SERVER-SIDE decision that puts
3
3
  * an authenticated owner/team member into the CORRECT tenant admin context, and
4
4
  * keeps a non-member / unauthenticated visitor on the legitimate sign-in gate.
5
5
  *
6
- * NARROW to auth/session ENTRY: it CONSUMES the go-live admin tab UX
7
- * (`pages/admin.astro` + `components/admin/AdminPublishTab.astro`) and u10's
6
+ * NARROW to auth/session ENTRY: it CONSUMES the admin tab UX
7
+ * (`pages/admin.astro` + `components/admin/AdminPublishTab.astro`) and the
8
8
  * ship-capability model (`change/shipCapabilities` + `change/route` decideIsOwner)
9
9
  * rather than reimplementing either. Everything it resolves is bound to the
10
10
  * RESOLVED tenant, never taken from client input, and never requires a
@@ -14,15 +14,15 @@
14
14
  * - `hostCapability` — the viewer's owner/admin/member standing FOR THIS host,
15
15
  * from {@link sessionHostCapability} (tenants[] membership → resource-scoped
16
16
  * session → live staff selection). null ⇒ not a member of this tenant.
17
- * - `isOwnerViewer` / `shipCapabilities` — u10's {@link decideIsOwner} +
17
+ * - `isOwnerViewer` / `shipCapabilities` — {@link decideIsOwner} +
18
18
  * {@link resolveShipCapabilities} over that host-bound capability. This is the
19
- * OWNER VIEWER identity (decision `owner-identity-narrow-viewer-not-bearer-console`):
19
+ * OWNER VIEWER identity:
20
20
  * a signed-in owner/admin viewer over the resolved tenant, NOT the bearer
21
21
  * operator console. The ship-axis `capability` is the fail-closed
22
22
  * `preview-access` — a viewer session carries no operator ship grant, so the
23
23
  * owner is the ONLY source of `shipOnBehalf`/`apexCutover` here.
24
24
  *
25
- * The distinguishing logic the go-live "definition of done" demands: `admitted`
25
+ * The distinguishing logic: `admitted`
26
26
  * separates an authenticated OWNER/member (a host-bound capability, or ToT-staff
27
27
  * scope) — who must NEVER see the "Coming soon" holding page at their own admin —
28
28
  * from an authenticated NON-member and an anonymous visitor, who legitimately do
@@ -56,9 +56,9 @@ export interface AdminEntryDecision {
56
56
  admitted: boolean;
57
57
  /** The owner/admin/member standing bound to this host, or null. */
58
58
  hostCapability: string | null;
59
- /** u10: the acting viewer IS the tenant owner/admin (superset of ship-on-behalf). */
59
+ /** The acting viewer IS the tenant owner/admin (superset of ship-on-behalf). */
60
60
  isOwnerViewer: boolean;
61
- /** u10: the two derived ship-gate capabilities for this viewer entry. */
61
+ /** The two derived ship-gate capabilities for this viewer entry. */
62
62
  shipCapabilities: ShipCapabilities;
63
63
  /** Which of the four entry cases this is. */
64
64
  principal: AdminEntryPrincipal;
@@ -90,7 +90,7 @@ export function resolveAdminEntry(input: {
90
90
  const staffScope = Array.isArray(record?.staff) && record!.staff.length > 0;
91
91
  const admitted = hostCapability !== null || staffScope;
92
92
 
93
- // u10 owner resolution — the OWNER VIEWER, not the bearer console: authorized
93
+ // Owner resolution — the OWNER VIEWER, not the bearer console: authorized
94
94
  // (a member/staff viewer of THIS tenant) AND holding an owner/admin capability.
95
95
  const isOwnerViewer = decideIsOwner({
96
96
  authorized: admitted,
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Offline verification of tot's identity assertion (`id_token`) — the consuming
3
- * half of the decoupled-authZ architecture (Phase 3).
3
+ * half of the decoupled-authZ architecture.
4
4
  *
5
5
  * tot signs a short-lived ES256 assertion ONCE at sign-in with its PRIVATE key
6
6
  * (`/api/internal/access-code/verify` → `id_token`). This module verifies it
@@ -10,20 +10,12 @@
10
10
  * after sign-in must not break authorization; the token's own `exp` bounds
11
11
  * staleness.
12
12
  *
13
- * This mirrors the Phase-2 launchpad kernel's lightweight offline verifier
14
- * (`launchpad src/modules/authz/jwks-verifier.ts` + `identity-claims.ts`) and the
15
- * tot-internal verify path proven in
16
- * `tot20 services/InternalAuthService/identityTokenService.jest.js`.
13
+ * SCOPE: this does ONLY the JWKS offline-verify + a coarse claims→session
14
+ * derivation. Do NOT vendor the Cedar-wasm PDP into the Worker; it is
15
+ * unnecessary for the current coarse gate.
17
16
  *
18
- * PROTOTYPE SCOPE: this replicates ONLY the JWKS offline-verify + a coarse
19
- * claims→session derivation. The full Cedar PDP (`can()` policy engine, staff
20
- * sandbox) and consuming the launchpad kernel as an installed package are
21
- * Phase 4 — see branch `feat/authz-cedar-kernel`. Do NOT vendor the Cedar-wasm
22
- * PDP into the Worker; it is unnecessary for the current coarse gate.
23
- *
24
- * Contract: /tmp/authz-token-claims-contract.md (AS-BUILT). Key points mirrored
25
- * here: §2 (ES256 + jose), §3 (claim set), §5 (consumer verify algorithm),
26
- * §6 (field name `id_token`), §6b (JWKS host is per-env app config; jose
17
+ * Contract points as-built: ES256 + jose, the claim set, the consumer verify
18
+ * algorithm, field name `id_token`, and JWKS host is per-env app config (jose
27
19
  * cross-version 4↔6 is wire-compatible by construction).
28
20
  */
29
21
  import {
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Login-gate decision (pure) — the "Sign in with ToT" enforcement mode for a
3
- * gated demo host, the eventual replacement for the interim Basic-Auth password
4
- * (see `@/lib/demoGate`). Same `DEMO_GATE_HOSTS` list, switched by `DEMO_GATE_MODE`:
3
+ * gated demo host (see `@/lib/demoGate`). Same `DEMO_GATE_HOSTS` list, switched
4
+ * by `DEMO_GATE_MODE`:
5
5
  *
6
- * DEMO_GATE_MODE = "password" → interim Basic-Auth gate (evaluateDemoGate)
6
+ * DEMO_GATE_MODE = "password" → Basic-Auth gate (evaluateDemoGate)
7
7
  * DEMO_GATE_MODE = "login" → this: redirect to /auth/login unless a valid,
8
8
  * resource-bound tot_session cookie is present.
9
9
  *
@@ -29,7 +29,7 @@ export const PUBLIC_HOME_TENANT_ID = "home";
29
29
 
30
30
  /**
31
31
  * The record fields the host-admission + host-capability checks read. All optional
32
- * — these readers tolerate a partial/legacy record and are deny-closed on absence.
32
+ * — these readers tolerate a partial record and are deny-closed on absence.
33
33
  */
34
34
  type HostAdmissionRecord = Partial<
35
35
  Pick<SessionRecord, "tenants" | "staffSelection" | "staff" | "resource" | "capability">
@@ -144,6 +144,18 @@ export function isPublicPath(pathname: string): boolean {
144
144
  return pathname === "/api/cli/policy";
145
145
  }
146
146
 
147
+ /**
148
+ * The authenticated static-bundle staging upload (`/api/publish/stage-bundle`).
149
+ * Like the candidate-preview control plane, it governs its OWN, stricter auth (a
150
+ * CI `private_key_jwt` assertion — see `@/lib/publish/stageBundleUpload`), so it
151
+ * must reach its handler server-to-server rather than being masked behind the
152
+ * demo login gate's "Coming soon" holding page. Gate-exemption does NOT weaken
153
+ * it: an unauthenticated request still gets a 401 from the route's own check.
154
+ */
155
+ export function isStaticBundleUploadPath(pathname: string): boolean {
156
+ return pathname === "/api/publish/stage-bundle";
157
+ }
158
+
147
159
  /**
148
160
  * The candidate-preview CONTROL-PLANE routes — the per-change hosted review
149
161
  * surfaces that carry their OWN, stricter auth (the cockpit gate: signed-in +
@@ -158,20 +170,56 @@ export function isPublicPath(pathname: string): boolean {
158
170
  * blocker naming the head SHA), never a misleading "Coming soon". A demo BROWSE of
159
171
  * the tenant's own pages stays gated as before.
160
172
  *
161
- * Matched (prefix, so sub-paths are covered):
162
- * /preview/render/<tenant>/<changeId>/... the candidate content renderer (s4)
163
- * /preview/change/<changeId> the per-change review dashboard (s5)
164
- * /preview/<tenant>/pr/<n> the friendly PR-number alias (u4)
165
- * /preview/<tenant>/rev/<sha>/... the immutable per-revision renderer (u4)
173
+ * Matched (prefix, so sub-paths are covered). The PR/rev aliases are tenant-first
174
+ * (`/<tenant>/preview/...`, the leading segment middleware-selects the tenant),
175
+ * matched here on the pre-strip request path:
176
+ * /preview/render/<tenant>/<changeId>/... the candidate content renderer
177
+ * /preview/change/<changeId> the per-change review dashboard
178
+ * /<tenant>/preview/pr/<n> the friendly PR-number alias
179
+ * /<tenant>/preview/rev/<sha>/... the immutable per-revision renderer
166
180
  */
167
181
  export function isCandidatePreviewPath(pathname: string): boolean {
168
182
  if (pathname.startsWith("/preview/render/")) return true;
169
183
  if (pathname === "/preview/change" || pathname.startsWith("/preview/change/")) return true;
170
- // /preview/<tenant>/pr and /preview/<tenant>/pr/<n> — the PR-number alias.
171
- if (/^\/preview\/[^/]+\/pr(?:\/|$)/.test(pathname)) return true;
172
- // /preview/<tenant>/rev/<sha> and /preview/<tenant>/rev/<sha>/<path...> — the
184
+ // /<tenant>/preview/pr and /<tenant>/preview/pr/<n> — the PR-number alias.
185
+ if (/^\/[^/]+\/preview\/pr(?:\/|$)/.test(pathname)) return true;
186
+ // /<tenant>/preview/rev/<sha> and /<tenant>/preview/rev/<sha>/<path...> — the
173
187
  // immutable per-revision alias.
174
- return /^\/preview\/[^/]+\/rev(?:\/|$)/.test(pathname);
188
+ return /^\/[^/]+\/preview\/rev(?:\/|$)/.test(pathname);
189
+ }
190
+
191
+ /**
192
+ * Admin surfaces, in BOTH the bare host-routed form (`/admin`, `/admin/...`) and
193
+ * the tenant-first path form (`/<tenant>/admin`, `/<tenant>/admin/...`, where the
194
+ * leading segment middleware-selects the tenant). Admin reuses the same
195
+ * loginGate/sessionAdmitsHost mechanism as public content — there is no separate
196
+ * password layer — so this predicate is what keeps the QA content bypass from ever
197
+ * reaching admin: /admin ALWAYS requires a real, valid session.
198
+ */
199
+ export function isAdminPath(pathname: string): boolean {
200
+ if (pathname === "/admin" || pathname.startsWith("/admin/")) return true;
201
+ return /^\/[^/]+\/admin(?:\/|$)/.test(pathname);
202
+ }
203
+
204
+ /**
205
+ * Paths the QA content bypass must NEVER cover — everything privileged or
206
+ * state-mutating. The bypass is a convenience for browsing public tenant content
207
+ * (product/marketing pages) only; these fail toward requiring a real session:
208
+ * - admin ({@link isAdminPath}) — the hard security requirement.
209
+ * - the cockpit control-plane (`/cockpit`, `/<tenant>/cockpit`) and its `/dev`
210
+ * redirect alias.
211
+ * - every `/api/` route (tenant-state mutation). Genuinely-public API endpoints
212
+ * are already gate-exempt via {@link isPublicPath}/{@link isAuthPath}, so
213
+ * excluding all of `/api/` here costs nothing and fails safe.
214
+ */
215
+ export function isBypassPrivilegedPath(pathname: string): boolean {
216
+ if (isAdminPath(pathname)) return true;
217
+ if (pathname === "/cockpit" || pathname.startsWith("/cockpit/")) return true;
218
+ if (/^\/[^/]+\/cockpit(?:\/|$)/.test(pathname)) return true;
219
+ if (pathname === "/dev" || pathname.startsWith("/dev/")) return true;
220
+ if (/^\/[^/]+\/dev(?:\/|$)/.test(pathname)) return true;
221
+ if (pathname === "/api" || pathname.startsWith("/api/")) return true;
222
+ return false;
175
223
  }
176
224
 
177
225
  export type LoginGateDecision =
@@ -192,6 +240,14 @@ export interface LoginGateInput {
192
240
  * exempt from the gate so it renders publicly. See {@link PUBLIC_HOME_TENANT_ID}.
193
241
  */
194
242
  tenantExempt?: boolean;
243
+ /**
244
+ * True when the request presented the server-configured QA bypass token (see
245
+ * `DEMO_GATE_BYPASS_TOKEN` in middleware). Only ever set in a QA/test deploy — an
246
+ * unset server token leaves it false, so this is fully inert by default. Passes
247
+ * the gate for PUBLIC CONTENT paths only; {@link isBypassPrivilegedPath} paths
248
+ * (admin, cockpit, /api) are never bypassed regardless of this flag.
249
+ */
250
+ qaBypass?: boolean;
195
251
  }
196
252
 
197
253
  /**
@@ -208,6 +264,12 @@ export function evaluateLoginGate(input: LoginGateInput): LoginGateDecision {
208
264
  // Candidate-preview control-plane routes govern their OWN (stricter, honest) auth
209
265
  // and must never fall to the "Coming soon" holding page for a real candidate.
210
266
  if (isCandidatePreviewPath(input.pathname)) return { action: "pass" };
267
+ // The static-bundle staging upload carries its own private_key_jwt auth.
268
+ if (isStaticBundleUploadPath(input.pathname)) return { action: "pass" };
269
+ // QA content-bypass: a request presenting the server-configured token passes for
270
+ // public content only. NEVER for admin/cockpit/api — that guard lives here (not
271
+ // just in middleware) so /admin stays gated even if the flag is set upstream.
272
+ if (input.qaBypass && !isBypassPrivilegedPath(input.pathname)) return { action: "pass" };
211
273
  if (input.hasValidSession) return { action: "pass" };
212
274
  const next = input.pathname + (input.search ?? "");
213
275
  return { action: "redirect", location: `/auth/login?next=${encodeURIComponent(next)}` };
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * MCP client-assertion MINTER + signing-key loader — the storefront half of the
3
- * "MCP machine identity" contract (workstream `mcp-machine-identity`, unit B1
4
- * `sf-client-assertion`).
3
+ * "MCP machine identity" contract.
5
4
  *
6
5
  * The deployed storefront Worker is a browserless, multi-tenant service with no
7
6
  * human in the loop. It authenticates to the MCP's `client_credentials` grant
@@ -10,8 +9,7 @@
10
9
  * it holds `MCP_CLIENT_ASSERTION_PRIVATE_KEY_B64`, and the MCP verifies the
11
10
  * signature against storefront's PUBLISHED public key (fetched by `kid` from the
12
11
  * JWKS this module derives — see `loadMcpClientAssertionJwks` + the sibling
13
- * `.well-known/mcp-client-jwks.json` route, binding decision
14
- * `c0-client-key-resolution`).
12
+ * `.well-known/mcp-client-jwks.json` route).
15
13
  *
16
14
  * Claims (RFC 7523 §3): `iss = sub = client_id`, `aud = <mcp token endpoint>`,
17
15
  * `exp ~= iat + 60s`, a fresh `jti` every call (one-time-use, verified server-side
@@ -30,6 +28,10 @@
30
28
  * header comment for the same rule stated from the gateway side). The key is a
31
29
  * PROVIDED secret (generated + synced via SSM/`.secrets[]`, see B3), never
32
30
  * autogenerated at runtime; tests/local-dev generate a throwaway keypair instead.
31
+ * This same rule applies ACROSS EXECUTION ENVIRONMENTS, not just within this Worker:
32
+ * a different runtime (e.g. a CI pipeline) needing MCP machine identity gets its OWN
33
+ * confidential-client registration + keypair, never a copy of this one — see tot-mcp
34
+ * `devbook/app-binding.md` Recipe A ("multiple execution environments").
33
35
  */
34
36
  import { importPKCS8, exportJWK, SignJWT, type CryptoKey, type JWK, type JSONWebKeySet } from "jose";
35
37
  import { readEnv } from "@/lib/env";
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Shared machinery for storefront's two-path operator-session resolvers: a
3
+ * signed-in host-bound tenant viewer (path 1), else a Bearer operator secret
4
+ * (path 2), fail-closed if neither validates. The grants owner session
5
+ * (`../grants/session.ts`) and the Private Apps admin session
6
+ * (`../apps/adminSession.ts`) are both configured from these primitives, so the
7
+ * fail-closed ordering and the host-bound tenant derivation live in ONE audited
8
+ * place. Each resolver supplies only what genuinely differs — the result shape,
9
+ * the Bearer secret/env sourcing, the operator-capability set, and how a present
10
+ * viewer that lacks that capability is treated.
11
+ */
12
+ import type { APIContext } from "astro";
13
+
14
+ /** The host-bound viewer chrome the middleware sets on `locals.viewer`. */
15
+ type Viewer = NonNullable<APIContext["locals"]["viewer"]>;
16
+
17
+ /** The fail-closed shape both resolvers share for a refused session. */
18
+ export type SessionFailure = { ok: false; status: number; message: string };
19
+
20
+ /** Pure inputs to the Bearer-fallback authorization decision (env + request, no I/O). */
21
+ export interface BearerOperatorInput {
22
+ /** The request `Authorization` header, if any. */
23
+ authHeader: string | null;
24
+ /** The configured operator gate secret (or undefined if unconfigured). */
25
+ gateSecret: string | undefined;
26
+ /** Server-configured principal for this deployment, preferred over the header. */
27
+ configuredPrincipal: string | undefined;
28
+ /** Requested-principal header — honoured ONLY once the Bearer authenticates. */
29
+ requestedPrincipal: string | null;
30
+ /** 503 message when the gate secret is unconfigured. */
31
+ unconfiguredMessage: string;
32
+ /** 400 message when no principal can be resolved. */
33
+ unresolvedMessage: string;
34
+ }
35
+
36
+ export type BearerOperatorResult = { ok: true; principal: string } | SessionFailure;
37
+
38
+ /**
39
+ * The fail-closed Bearer-operator core, with NO I/O — unit-testable. The
40
+ * ordering is the security contract: unconfigured gate → 503, bad/absent Bearer
41
+ * → 401 (BEFORE any principal is read, so a bare caller cannot target another
42
+ * tenant by naming it in the requested-principal header), no resolvable
43
+ * principal → 400. The server-configured principal is preferred over the
44
+ * request header so a single-principal deploy cannot be redirected cross-tenant.
45
+ */
46
+ export function authorizeBearerOperator(
47
+ input: BearerOperatorInput,
48
+ ): BearerOperatorResult {
49
+ if (!input.gateSecret) {
50
+ return { ok: false, status: 503, message: input.unconfiguredMessage };
51
+ }
52
+ if (input.authHeader !== `Bearer ${input.gateSecret}`) {
53
+ return { ok: false, status: 401, message: "Unauthorized" };
54
+ }
55
+ const principal = (
56
+ input.configuredPrincipal ??
57
+ input.requestedPrincipal ??
58
+ ""
59
+ ).trim();
60
+ if (!principal) {
61
+ return { ok: false, status: 400, message: input.unresolvedMessage };
62
+ }
63
+ return { ok: true, principal };
64
+ }
65
+
66
+ /**
67
+ * How to treat a signed-in viewer that IS present on the resolved host but lacks
68
+ * an operator capability. The two resolvers deliberately differ here:
69
+ * - `fallThrough` — continue to the Bearer gate (grants' owner session), so a
70
+ * lower-capability viewer that also presents a valid operator secret is
71
+ * admitted via path 2 exactly as an unauthenticated headless caller is.
72
+ * - `failClosed` — return the supplied 403 and do NOT consult the Bearer gate
73
+ * (apps admin session), so an authenticated-but-insufficient viewer is never
74
+ * silently admitted by a secret it happens to also present.
75
+ */
76
+ export type InsufficientCapabilityPolicy =
77
+ | { mode: "fallThrough" }
78
+ | { mode: "failClosed"; failure: SessionFailure };
79
+
80
+ export interface HostBoundSessionConfig<T> {
81
+ /** Capabilities that make a host-bound viewer an operator for their tenant. */
82
+ capabilities: ReadonlySet<string>;
83
+ /** How to treat a present-but-insufficient viewer (see the type). */
84
+ onInsufficientCapability: InsufficientCapabilityPolicy;
85
+ /** Build the success result from the authorized viewer + resolved host appDomain. */
86
+ resolveViewer: (viewer: Viewer, tenant: string) => Promise<T> | T;
87
+ /** The Bearer path (path 2), consulted whenever path 1 does not resolve. */
88
+ resolveBearer: (context: APIContext) => Promise<T | SessionFailure>;
89
+ }
90
+
91
+ /**
92
+ * Two-path host-bound operator session: a signed-in `locals.viewer` on the
93
+ * middleware-resolved `locals.tenant` (path 1), else the Bearer gate (path 2).
94
+ * The tenant is ALWAYS the resolved host `appDomain` — never the request body.
95
+ * When no viewer/tenant pair is present, or a present viewer is insufficient
96
+ * under a `fallThrough` policy, path 2 decides.
97
+ */
98
+ export async function resolveHostBoundSession<T>(
99
+ context: APIContext,
100
+ config: HostBoundSessionConfig<T>,
101
+ ): Promise<T | SessionFailure> {
102
+ const viewer = context.locals?.viewer;
103
+ const tenant = context.locals?.tenant?.appDomain;
104
+ if (viewer && tenant) {
105
+ if (config.capabilities.has(viewer.capability)) {
106
+ return config.resolveViewer(viewer, tenant);
107
+ }
108
+ if (config.onInsufficientCapability.mode === "failClosed") {
109
+ return config.onInsufficientCapability.failure;
110
+ }
111
+ }
112
+ return config.resolveBearer(context);
113
+ }
@@ -120,14 +120,54 @@ export function clearSessionCookie(context: APIContext): void {
120
120
  context.cookies.delete(SESSION_COOKIE, { path: "/" });
121
121
  }
122
122
 
123
+ /** True if `s` contains any C0 control char (0x00–0x1F) or DEL (0x7F) — CR/LF
124
+ * included, so a `next` value can never smuggle a header break or NUL. Written as
125
+ * a charCode scan (not a control-char regex literal) so the source stays printable. */
126
+ function hasControlChar(s: string): boolean {
127
+ for (let i = 0; i < s.length; i++) {
128
+ const c = s.charCodeAt(i);
129
+ if (c <= 0x1f || c === 0x7f) return true;
130
+ }
131
+ return false;
132
+ }
133
+
123
134
  /**
124
- * Sanitize a `next` redirect target to a same-origin PATH only (starts with a
125
- * single "/", not "//" or a scheme) closes the open-redirect hole. Falls back
126
- * to "/".
135
+ * Sanitize a post-sign-in `next`/returnTo redirect target to a same-origin,
136
+ * path-only reference — the single open-redirect chokepoint for the whole sign-in
137
+ * surface (the login gate, verify, and the shared `pr/<N>` reviewer link all funnel
138
+ * through here). Anything that could resolve off-origin falls back to "/".
139
+ *
140
+ * Rejected, because a browser would treat each as an ABSOLUTE/off-origin URL:
141
+ * - a scheme or bare host ("https://evil.com", "evil.com")
142
+ * - protocol-relative ("//evil.com")
143
+ * - backslash-smuggled ("/\evil.com", "\\evil.com") — browsers normalize
144
+ * "\" → "/", so "/\evil.com" resolves as "//evil.com"
145
+ * - encoded slash/backslash ("/%2F%2Fevil.com", "/%5Cevil.com") that DECODES
146
+ * to a protocol-relative form
147
+ * - control chars / newlines (header-injection belt-and-suspenders)
148
+ * - malformed percent-encoding (refuse rather than guess)
149
+ *
150
+ * The ORIGINAL (still-encoded) value is returned on success — decoding is only for
151
+ * the safety check, never for the redirect target.
127
152
  */
128
153
  export function safeNext(next: string | null | undefined): string {
129
154
  if (!next) return "/";
130
- if (!next.startsWith("/") || next.startsWith("//")) return "/";
155
+ if (!next.startsWith("/")) return "/";
156
+ // No control chars / CR / LF anywhere (header-injection belt-and-suspenders).
157
+ if (hasControlChar(next)) return "/";
158
+ // Normalize backslashes to slashes the way a browser would, then reject any
159
+ // protocol-relative ("//host") form — raw or backslash-smuggled.
160
+ if (next.replace(/\\/g, "/").startsWith("//")) return "/";
161
+ // Defeat encoded slash/backslash smuggling: decode once and re-check that it is
162
+ // still a single-slash, same-origin path. Malformed encoding → refuse.
163
+ let decoded: string;
164
+ try {
165
+ decoded = decodeURIComponent(next);
166
+ } catch {
167
+ return "/";
168
+ }
169
+ const decodedNormalized = decoded.replace(/\\/g, "/");
170
+ if (!decodedNormalized.startsWith("/") || decodedNormalized.startsWith("//")) return "/";
131
171
  return next;
132
172
  }
133
173
 
@@ -20,7 +20,7 @@ export const SESSION_COOKIE = "tot_session";
20
20
 
21
21
  /**
22
22
  * A ToT-staff viewer's ACTIVE vendor selection — the two-phase-admission marker
23
- * (#8425 staff cross-vendor access). This is a DISTINCT access path from
23
+ * for staff cross-vendor access. This is a DISTINCT access path from
24
24
  * `tenants[]`: a staff user isn't a member of a bounded set of vendors, so we
25
25
  * NEVER synthesize a `tenants[]` entry for them. Instead, after the staff viewer
26
26
  * explicitly searches + enters a vendor and tot20's `staffVendorAccess` writes
@@ -43,25 +43,24 @@ export const DEFAULT_SESSION_TTL_SECONDS = 7 * 24 * 3600;
43
43
  /**
44
44
  * What we know about a signed-in viewer.
45
45
  *
46
- * Phase 3: `email`/`resource`/`roles`/`capability` are now derived from tot's
46
+ * `email`/`resource`/`roles`/`capability` are derived from tot's
47
47
  * OFFLINE-VERIFIED `id_token` claims (see `@/lib/auth/identityToken`
48
48
  * `sessionFromClaims`), not from trusting tot's flat `/verify` JSON. `capability`
49
49
  * is the coarse standing on `resource` (owner|admin|member). `tenants`/`staff`
50
- * are the multi-tenant claims carried in the verified token (present when the
51
- * session was minted via the Phase-3 path; optional for backward compatibility).
50
+ * are the multi-tenant claims carried in the verified token.
52
51
  */
53
52
  export interface SessionRecord {
54
53
  /** Verified email (lowercased) — the principal that passed the grant check. */
55
54
  email: string;
56
55
  /** The resource (appDomain/tenant, e.g. "sample-store.example") this session authorizes. */
57
56
  resource: string;
58
- /** Display roles for `resource` (Phase 3: the coarse role from verified claims). */
57
+ /** Display roles for `resource` (the coarse role from verified claims). */
59
58
  roles: string[];
60
- /** Coarse capability on `resource` (owner|admin|member; legacy: developer|inviter|staff|none). */
59
+ /** Coarse capability on `resource` (owner|admin|member). */
61
60
  capability: string;
62
- /** All tenant memberships from the verified assertion (policy input). Phase-3 path only. */
61
+ /** All tenant memberships from the verified assertion (policy input). */
63
62
  tenants?: { resource: string; role: string }[];
64
- /** ToT-staff scope from the verified assertion (sandboxed; grants nothing today). Phase-3 path only. */
63
+ /** ToT-staff scope from the verified assertion (sandboxed; grants nothing). */
65
64
  staff?: string[];
66
65
  /**
67
66
  * A staff viewer's active, explicitly-entered vendor selection (the two-phase
@@ -28,6 +28,7 @@
28
28
  */
29
29
  import { readEnv } from "@/lib/env";
30
30
  import { diagEmail } from "@/lib/diag/redact";
31
+ import { FetchHttpTransport, type HttpResponseLike, type HttpTransport } from "@/lib/http/fetchTransport";
31
32
  import { classifyStepUp, type StepUpChallenge, type StepUpObligation } from "./stepUpChallenge.js";
32
33
 
33
34
  /** `send` is deliberately opaque — it never reveals whether the email was eligible. */
@@ -150,24 +151,13 @@ export type SmsVerifyResult =
150
151
 
151
152
  // --- HTTP transport seam (injected by tests) --------------------------------
152
153
 
153
- export interface HttpResponseLike {
154
- status: number;
155
- json(): Promise<unknown>;
156
- }
157
- export interface HttpTransport {
158
- post(url: string, headers: Record<string, string>, body: unknown): Promise<HttpResponseLike>;
159
- }
154
+ export type { HttpResponseLike, HttpTransport };
160
155
 
161
- /** Default transport: a JSON `fetch` POST. */
162
- export class FetchHttpTransport implements HttpTransport {
163
- async post(url: string, headers: Record<string, string>, body: unknown) {
164
- return fetch(url, {
165
- method: "POST",
166
- headers: { "content-type": "application/json", accept: "application/json", ...headers },
167
- body: JSON.stringify(body),
168
- });
169
- }
170
- }
156
+ /** Default transport: a JSON `fetch` POST that also sends `accept: application/json`. */
157
+ const defaultTransport = () =>
158
+ new FetchHttpTransport({
159
+ defaultHeaders: { "content-type": "application/json", accept: "application/json" },
160
+ });
171
161
 
172
162
  export interface HttpTotAccessClientConfig {
173
163
  /** tot20 core API base (no trailing slash), e.g. https://qa.tokenoftrust.com */
@@ -193,7 +183,7 @@ export class HttpTotAccessClient implements TotAccessClient {
193
183
  this.baseUrl = config.baseUrl.replace(/\/+$/, "");
194
184
  this.apiKey = config.apiKey;
195
185
  this.secretKey = config.secretKey;
196
- this.transport = config.transport ?? new FetchHttpTransport();
186
+ this.transport = config.transport ?? defaultTransport();
197
187
  }
198
188
 
199
189
  /**
@@ -13,13 +13,20 @@
13
13
  *
14
14
  * It is idempotent and conservative: only root-absolute single-slash paths are
15
15
  * prefixed. Protocol-relative URLs ("//cdn…"), absolute URLs ("https://…"),
16
- * fragments/queries ("#x", "?x"), and already-prefixed paths pass through. This
17
- * mirrors the defense-in-depth client script in Layout.astro.
16
+ * fragments/queries ("#x", "?x"), and already-prefixed paths pass through. The
17
+ * GLOBAL static-asset namespaces are also left untouched — they are served at root
18
+ * under both routing modes and must NOT carry a tenant prefix:
19
+ * - `/tenants/…` — per-tenant static assets (logo, favicon, theme/mkt css);
20
+ * - `/shared/…` — platform-shipped shared assets (shared commerce-chrome css).
21
+ * This mirrors both `prefixTenantLinks` (lib/rawMarketingHtml.ts) and the
22
+ * defense-in-depth client script in Layout.astro.
18
23
  */
19
24
  export function withBase(basePath: string, href: string): string {
20
25
  if (!basePath || !href) return href;
21
26
  // Only same-origin root-absolute paths; skip protocol-relative "//".
22
27
  if (href.charAt(0) !== "/" || href.charAt(1) === "/") return href;
28
+ // Global asset namespaces are root-served under every tenant; never prefix them.
29
+ if (href.startsWith("/tenants/") || href.startsWith("/shared/")) return href;
23
30
  // Already inside the prefix.
24
31
  if (href === basePath || href.startsWith(`${basePath}/`)) return href;
25
32
  return `${basePath}${href}`;
@@ -0,0 +1,29 @@
1
+ import type { SessionRecord } from "@/lib/auth/session.js";
2
+ import type { BlogAccess } from "./types.js";
3
+
4
+ /**
5
+ * Whether a signed-in viewer counts as a paid member. v1: authenticated ⇒ member,
6
+ * because there is no consumer entitlement source yet. This is the single seam a
7
+ * future billing/entitlement integration plugs into — keep it a named export so
8
+ * routes and predicates never re-implement the "is a member" decision inline.
9
+ */
10
+ export function isConsumerMember(_viewer: SessionRecord): boolean {
11
+ return true;
12
+ }
13
+
14
+ /**
15
+ * Entitlement chokepoint: may this viewer read the post's body? Orthogonal to
16
+ * visibility (`isVisibleOnLive`) — a gated post is still published/listed; this
17
+ * only decides full body vs teaser. Every reader path calls this one function.
18
+ */
19
+ export function viewerHasBlogAccess(
20
+ viewer: SessionRecord | null,
21
+ post: { access?: BlogAccess },
22
+ ): boolean {
23
+ const need = post.access ?? "public";
24
+ if (need === "public") return true;
25
+ if (!viewer) return false;
26
+ if (need === "authenticated") return true;
27
+ if (need === "members") return isConsumerMember(viewer);
28
+ return false;
29
+ }