@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.
- package/apps/storefront/astro.config.mjs +17 -1
- package/apps/storefront/integrations/materialize-guard.mjs +56 -0
- package/apps/storefront/migrations-apps/0001_woozy_lyja.sql +22 -0
- package/apps/storefront/migrations-apps/0002_good_switch.sql +26 -0
- package/apps/storefront/migrations-apps/0003_mute_marauders.sql +12 -0
- package/apps/storefront/migrations-apps/meta/0001_snapshot.json +990 -0
- package/apps/storefront/migrations-apps/meta/0002_snapshot.json +1160 -0
- package/apps/storefront/migrations-apps/meta/0003_snapshot.json +1235 -0
- package/apps/storefront/migrations-apps/meta/_journal.json +21 -0
- package/apps/storefront/package.json +0 -1
- package/apps/storefront/public/js/dashboard-team.js +38 -2
- package/apps/storefront/src/components/Seo.astro +65 -1
- package/apps/storefront/src/components/admin/AdminBlogDetailRail.astro +878 -0
- package/apps/storefront/src/components/admin/AdminBlogTab.astro +1432 -0
- package/apps/storefront/src/components/admin/AdminCustomersTab.astro +3 -7
- package/apps/storefront/src/components/admin/AdminPublishTab.astro +1756 -1382
- package/apps/storefront/src/components/admin/AdminSubscriptionsTab.astro +1 -3
- package/apps/storefront/src/components/blog/BlogPaywall.astro +87 -0
- package/apps/storefront/src/components/blog/PostAccessBadge.astro +43 -0
- package/apps/storefront/src/components/blog/PostGrid.astro +57 -0
- package/apps/storefront/src/components/blog/ReactionBar.astro +214 -0
- package/apps/storefront/src/layouts/Layout.astro +48 -3
- package/apps/storefront/src/lib/activity/alerts.ts +426 -0
- package/apps/storefront/src/lib/activity/changeActorAttribution.ts +85 -0
- package/apps/storefront/src/lib/activity/deployVersion.ts +120 -0
- package/apps/storefront/src/lib/activity/ingest.ts +188 -0
- package/apps/storefront/src/lib/activity/ingestAuth.ts +105 -0
- package/apps/storefront/src/lib/activity/killSwitch.ts +80 -0
- package/apps/storefront/src/lib/activity/query.ts +403 -0
- package/apps/storefront/src/lib/activity/recordActivity.ts +101 -0
- package/apps/storefront/src/lib/activity/store.ts +120 -0
- package/apps/storefront/src/lib/activity/uiActor.ts +150 -0
- package/apps/storefront/src/lib/activity/workerCommit.ts +70 -0
- package/apps/storefront/src/lib/admin/adminShell.ts +290 -0
- package/apps/storefront/src/lib/admin/ai/approvalMode.ts +2 -3
- package/apps/storefront/src/lib/admin/ai/audit.ts +3 -5
- package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +7 -7
- package/apps/storefront/src/lib/admin/checkoutStyleTarget.ts +3 -3
- package/apps/storefront/src/lib/admin/orderClient.ts +3 -5
- package/apps/storefront/src/lib/admin/services/orders.ts +17 -19
- package/apps/storefront/src/lib/admin/viewmodels/aiWorkflows.ts +4 -6
- package/apps/storefront/src/lib/admin/viewmodels/settings.ts +1 -1
- package/apps/storefront/src/lib/apps/adminService.ts +4 -4
- package/apps/storefront/src/lib/apps/adminSession.ts +53 -44
- package/apps/storefront/src/lib/apps/apiAuth.ts +2 -2
- package/apps/storefront/src/lib/apps/apiRoute.ts +2 -2
- package/apps/storefront/src/lib/apps/catalogMapper.ts +1 -1
- package/apps/storefront/src/lib/apps/credentials.ts +2 -2
- package/apps/storefront/src/lib/apps/gatewayKeys.ts +4 -4
- package/apps/storefront/src/lib/apps/healthAggregate.ts +5 -5
- package/apps/storefront/src/lib/apps/orders/attributionService.ts +2 -2
- package/apps/storefront/src/lib/apps/orders/customerHash.ts +1 -1
- package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +12 -12
- package/apps/storefront/src/lib/apps/orders/idempotency.ts +7 -7
- package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +1 -1
- package/apps/storefront/src/lib/apps/orders/orderMapper.ts +2 -2
- package/apps/storefront/src/lib/apps/orders/ordersStore.ts +6 -6
- package/apps/storefront/src/lib/apps/registryService.ts +6 -6
- package/apps/storefront/src/lib/apps/scopes.ts +2 -2
- package/apps/storefront/src/lib/apps/tokenIssuer.ts +4 -4
- package/apps/storefront/src/lib/apps/tokenVerifier.ts +6 -6
- package/apps/storefront/src/lib/apps/widgets/eligibility.ts +2 -2
- package/apps/storefront/src/lib/apps/widgets/frameProps.ts +3 -3
- package/apps/storefront/src/lib/apps/widgets/launchToken.ts +4 -4
- package/apps/storefront/src/lib/apps/widgets/placements.ts +2 -2
- package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +1 -1
- package/apps/storefront/src/lib/auth/adminEntry.ts +9 -9
- package/apps/storefront/src/lib/auth/identityToken.ts +6 -14
- package/apps/storefront/src/lib/auth/loginGate.ts +75 -13
- package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +6 -4
- package/apps/storefront/src/lib/auth/operatorSession.ts +113 -0
- package/apps/storefront/src/lib/auth/route.ts +44 -4
- package/apps/storefront/src/lib/auth/session.ts +7 -8
- package/apps/storefront/src/lib/auth/totAccessClient.ts +8 -18
- package/apps/storefront/src/lib/basePath.ts +9 -2
- package/apps/storefront/src/lib/blog/access.ts +29 -0
- package/apps/storefront/src/lib/blog/collection.ts +313 -0
- package/apps/storefront/src/lib/blog/markdown.ts +174 -0
- package/apps/storefront/src/lib/blog/provider.ts +5 -0
- package/apps/storefront/src/lib/blog/reactions.ts +87 -0
- package/apps/storefront/src/lib/blog/rss.ts +92 -0
- package/apps/storefront/src/lib/blog/teaser.ts +83 -0
- package/apps/storefront/src/lib/blog/types.ts +34 -0
- package/apps/storefront/src/lib/blog/viewCounts.ts +243 -0
- package/apps/storefront/src/lib/d1/catalog.ts +2 -2
- package/apps/storefront/src/lib/d1/schema-apps.ts +147 -14
- package/apps/storefront/src/lib/dev/activityIngestToken.ts +2 -4
- package/apps/storefront/src/lib/dev/activityStore.ts +3 -5
- package/apps/storefront/src/lib/dev/apiBase.ts +1 -4
- package/apps/storefront/src/lib/dev/cockpitStore.ts +1 -9
- package/apps/storefront/src/lib/dev/devDraft.ts +1 -1
- package/apps/storefront/src/lib/dev/previewStatus.ts +9 -21
- package/apps/storefront/src/lib/dev/rendezvousBroker.ts +9 -24
- package/apps/storefront/src/lib/edgeCache.ts +17 -0
- package/apps/storefront/src/lib/env.ts +51 -0
- package/apps/storefront/src/lib/http/fetchTransport.ts +63 -0
- package/apps/storefront/src/lib/i18n.ts +25 -0
- package/apps/storefront/src/lib/jsonld.ts +118 -0
- package/apps/storefront/src/lib/monitoring/manifest.ts +11 -11
- package/apps/storefront/src/lib/newsletter/confirmationEmail.ts +30 -0
- package/apps/storefront/src/lib/newsletter/consent.ts +23 -0
- package/apps/storefront/src/lib/newsletter/digest.ts +117 -0
- package/apps/storefront/src/lib/newsletter/resendClient.ts +79 -0
- package/apps/storefront/src/lib/newsletter/send.ts +265 -0
- package/apps/storefront/src/lib/newsletter/subscribers.ts +156 -0
- package/apps/storefront/src/lib/publish/adminPublishTab.ts +3519 -0
- package/apps/storefront/src/lib/publish/apex-readiness.ts +41 -18
- package/apps/storefront/src/lib/publish/dispatchHealth.ts +5 -7
- package/apps/storefront/src/lib/publish/domainState.ts +22 -13
- package/apps/storefront/src/lib/publish/panelPolling.ts +78 -0
- package/apps/storefront/src/lib/publish/shipWorkspace.ts +100 -42
- package/apps/storefront/src/lib/publish/stageBundleUpload.ts +215 -0
- package/apps/storefront/src/lib/rate/kvWindow.ts +53 -0
- package/apps/storefront/src/lib/search/index.ts +2 -2
- package/apps/storefront/src/lib/seo/alternates.ts +42 -0
- package/apps/storefront/src/lib/seo/meta.ts +65 -0
- package/apps/storefront/src/lib/social/golive.ts +212 -0
- package/apps/storefront/src/lib/social/notify.ts +32 -0
- package/apps/storefront/src/lib/social/tokenStore.ts +171 -0
- package/apps/storefront/src/lib/the-build/provider.ts +27 -0
- package/apps/storefront/src/lib/the-build/readingTime.ts +35 -0
- package/apps/storefront/src/lib/the-build/toc.ts +95 -0
- package/apps/storefront/src/lib/tot/ToTClient.ts +1 -1
- package/apps/storefront/src/lib/tot/d1Client.ts +2 -2
- package/apps/storefront/src/lib/tot/totClientInterface.ts +3 -4
- package/apps/storefront/src/lib/webhooks/deliveryStore.ts +16 -17
- package/apps/storefront/src/middleware/index.ts +130 -2
- package/apps/storefront/src/pages/admin/ops-timeline.astro +420 -0
- package/apps/storefront/src/pages/admin.astro +52 -1206
- package/apps/storefront/src/pages/api/activity.ts +137 -0
- package/apps/storefront/src/pages/api/admin/activity-alerts.ts +73 -0
- package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +27 -1
- package/apps/storefront/src/pages/api/apps/admin/install.ts +26 -1
- package/apps/storefront/src/pages/api/apps/admin/resume.ts +24 -1
- package/apps/storefront/src/pages/api/apps/admin/suspend.ts +24 -1
- package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +25 -1
- package/apps/storefront/src/pages/api/apps/admin/update.ts +24 -1
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +11 -0
- package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +11 -0
- package/apps/storefront/src/pages/api/apps/v1/attribution.ts +20 -0
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +12 -0
- package/apps/storefront/src/pages/api/blog/[collection]/[slug]/react.ts +104 -0
- package/apps/storefront/src/pages/api/cache-purge.ts +11 -0
- package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +30 -0
- package/apps/storefront/src/pages/api/newsletter/[collection]/subscribe.ts +146 -0
- package/apps/storefront/src/pages/api/newsletter/confirm.ts +75 -0
- package/apps/storefront/src/pages/api/newsletter/unsubscribe.ts +90 -0
- package/apps/storefront/src/pages/api/publish/stage-bundle.ts +70 -0
- package/apps/storefront/src/pages/api/rum/vitals.ts +21 -2
- package/apps/storefront/src/pages/auth/login.astro +5 -4
- package/apps/storefront/src/pages/blog/[slug].astro +96 -16
- package/apps/storefront/src/pages/blog/author/[author].astro +140 -0
- package/apps/storefront/src/pages/blog/category/[category].astro +67 -0
- package/apps/storefront/src/pages/blog/index.astro +2 -0
- package/apps/storefront/src/pages/blog/tag/[tag].astro +65 -0
- package/apps/storefront/src/pages/cockpit.astro +74 -12
- package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +15 -11
- package/apps/storefront/src/pages/search.astro +1 -1
- package/apps/storefront/src/pages/sitemap.xml.ts +20 -0
- package/apps/storefront/src/pages/the-build/[slug].astro +129 -0
- package/apps/storefront/src/pages/the-build/index.astro +146 -0
- package/apps/storefront/src/pages/the-build/rss.xml.ts +33 -0
- package/apps/storefront/src/styles/admin.css +1080 -0
- package/apps/storefront/tsconfig.json +5 -0
- package/package.json +1 -1
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/src/activity/README.md +146 -0
- package/packages/public-runtime/src/activity/catalog.ts +553 -0
- package/packages/public-runtime/src/activity/event.ts +168 -0
- package/packages/public-runtime/src/activity/index.ts +16 -0
- package/packages/public-runtime/src/activity/redaction.ts +263 -0
- package/packages/public-runtime/src/candidate-index.ts +13 -0
- package/packages/public-runtime/src/catalog-d1.ts +1 -1
- package/packages/public-runtime/src/customization-preview.ts +25 -2
- package/packages/public-runtime/src/customization-runtime.ts +125 -3
- package/packages/public-runtime/src/customization-versioning.ts +10 -0
- package/packages/public-runtime/src/index.ts +7 -0
- package/packages/public-runtime/src/static-bundle.ts +357 -0
- package/packages/public-runtime/src/tenant.ts +36 -8
- package/scripts/build/copy-tenant-assets.mjs +14 -4
- package/scripts/dev/ai-edit.mjs +1 -1
- package/scripts/dev/checkout-watch.mjs +2 -2
- package/scripts/dev/file-browser.mjs +2 -4
- package/scripts/dev/file-writer.mjs +1 -1
- package/scripts/dev/git-status.mjs +2 -2
- package/scripts/dev/locate-handler.mjs +3 -3
- package/scripts/dev/port-check.mjs +3 -3
- package/scripts/dev/publish.mjs +5 -9
- package/scripts/dev/shot.mjs +1 -1
- package/scripts/dev/unified-diff.mjs +8 -3
- package/scripts/tot-dev.mjs +6 -9
- package/apps/storefront/src/lib/webhooks/signing.ts +0 -29
- package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +0 -146
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Widget eligibility
|
|
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
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
3
|
-
* apps have a live widget at this slot" into the concrete list
|
|
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
|
|
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
|
|
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
|
|
7
|
-
* (`pages/admin.astro` + `components/admin/AdminPublishTab.astro`) and
|
|
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` —
|
|
17
|
+
* - `isOwnerViewer` / `shipCapabilities` — {@link decideIsOwner} +
|
|
18
18
|
* {@link resolveShipCapabilities} over that host-bound capability. This is the
|
|
19
|
-
* OWNER VIEWER identity
|
|
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
|
|
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
|
-
/**
|
|
59
|
+
/** The acting viewer IS the tenant owner/admin (superset of ship-on-behalf). */
|
|
60
60
|
isOwnerViewer: boolean;
|
|
61
|
-
/**
|
|
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
|
-
//
|
|
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
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
*
|
|
19
|
-
*
|
|
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
|
|
4
|
-
*
|
|
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" →
|
|
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
|
|
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
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* /preview/<tenant
|
|
165
|
-
* /preview/<
|
|
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
|
-
//
|
|
171
|
-
if (/^\/
|
|
172
|
-
//
|
|
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 /^\/
|
|
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
|
|
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
|
|
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
|
|
125
|
-
*
|
|
126
|
-
*
|
|
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("/")
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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` (
|
|
57
|
+
/** Display roles for `resource` (the coarse role from verified claims). */
|
|
59
58
|
roles: string[];
|
|
60
|
-
/** Coarse capability on `resource` (owner|admin|member
|
|
59
|
+
/** Coarse capability on `resource` (owner|admin|member). */
|
|
61
60
|
capability: string;
|
|
62
|
-
/** All tenant memberships from the verified assertion (policy input).
|
|
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
|
|
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
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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 ??
|
|
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.
|
|
17
|
-
*
|
|
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
|
+
}
|