@tokenoftrust/storefront-runner 2.0.21 → 2.0.23
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/src/lib/activity/uiActor.ts +2 -4
- package/apps/storefront/src/lib/auth/adminEntry.ts +2 -2
- package/apps/storefront/src/lib/auth/devAuth.ts +1 -1
- package/apps/storefront/src/lib/auth/identityToken.ts +13 -2
- package/apps/storefront/src/lib/shared/capability.ts +16 -2
- package/apps/storefront/src/lib/shared/viewerGrant.ts +166 -0
- package/package.json +1 -1
|
@@ -51,11 +51,9 @@ import type { ActionKey } from "@tot/public-runtime";
|
|
|
51
51
|
import { readViewerSession } from "@/lib/auth/route";
|
|
52
52
|
import { isStaffSession } from "@/lib/dashboard/staffAdmission";
|
|
53
53
|
import { readEnv } from "@/lib/env";
|
|
54
|
+
import { isOwnerCapability } from "@/lib/shared/capability";
|
|
54
55
|
import { hashActorId, recordActivity } from "./recordActivity";
|
|
55
56
|
|
|
56
|
-
/** The host-bound capabilities that mark a viewer as the store OWNER (merchant). */
|
|
57
|
-
const OWNER_CAPABILITIES = new Set(["owner", "admin"]);
|
|
58
|
-
|
|
59
57
|
/** The minimal viewer chrome the middleware stamps on `locals.viewer`. */
|
|
60
58
|
interface ViewerLike {
|
|
61
59
|
email?: string;
|
|
@@ -81,7 +79,7 @@ export function classifyActorKind(
|
|
|
81
79
|
): ActorKind {
|
|
82
80
|
if (!viewer?.email) return "agent";
|
|
83
81
|
if (isStaffSession(session)) return "admin";
|
|
84
|
-
if (
|
|
82
|
+
if (isOwnerCapability(viewer.capability)) return "merchant";
|
|
85
83
|
return "dev";
|
|
86
84
|
}
|
|
87
85
|
|
|
@@ -32,8 +32,8 @@ import type { SessionRecord } from "./session.js";
|
|
|
32
32
|
import { sessionHostCapability } from "./loginGate.js";
|
|
33
33
|
import {
|
|
34
34
|
decideIsOwner,
|
|
35
|
+
isOwnerCapability,
|
|
35
36
|
resolveShipCapabilities,
|
|
36
|
-
OWNER_CAPABILITIES,
|
|
37
37
|
type Capability,
|
|
38
38
|
type ShipCapabilities,
|
|
39
39
|
} from "@/lib/shared/capability";
|
|
@@ -108,7 +108,7 @@ export function resolveAdminEntry(input: {
|
|
|
108
108
|
const principal: AdminEntryPrincipal = !record
|
|
109
109
|
? "anonymous"
|
|
110
110
|
: hostCapability
|
|
111
|
-
?
|
|
111
|
+
? isOwnerCapability(hostCapability)
|
|
112
112
|
? (hostCapability as "owner" | "admin")
|
|
113
113
|
: "member"
|
|
114
114
|
: staffScope
|
|
@@ -77,7 +77,7 @@ export async function devSessionFields(
|
|
|
77
77
|
const resource = (await readEnv("STOREFRONT_APP_DOMAIN")) || "storefront";
|
|
78
78
|
const tenants = devTenantResources().map((resource) => ({
|
|
79
79
|
resource,
|
|
80
|
-
role: "
|
|
80
|
+
role: "member" as const,
|
|
81
81
|
}));
|
|
82
82
|
return {
|
|
83
83
|
email: email.toLowerCase(),
|
|
@@ -27,6 +27,11 @@ import {
|
|
|
27
27
|
type JSONWebKeySet,
|
|
28
28
|
} from "jose";
|
|
29
29
|
import { readEnv } from "@/lib/env";
|
|
30
|
+
import {
|
|
31
|
+
TENANT_ROLES,
|
|
32
|
+
isTenantRole,
|
|
33
|
+
type TenantRole,
|
|
34
|
+
} from "@/lib/shared/capability";
|
|
30
35
|
|
|
31
36
|
/** tot's issuer — consumers pin/allowlist this (`auth.jwt.issuer`). */
|
|
32
37
|
export const TOT_ISSUER = "tokenoftrust-internal-ops";
|
|
@@ -45,7 +50,13 @@ export const JWKS_PATH = "/api/.well-known/jwks.json";
|
|
|
45
50
|
* (`developer`/`inviter`/none → `member`). Org standing only; finer granularity
|
|
46
51
|
* is the storefront's own job to derive locally (Phase-4 PDP work).
|
|
47
52
|
*/
|
|
48
|
-
export
|
|
53
|
+
export const COARSE_ROLES = TENANT_ROLES;
|
|
54
|
+
export type CoarseRole = TenantRole;
|
|
55
|
+
|
|
56
|
+
/** Runtime counterpart to the closed coarse-role type. */
|
|
57
|
+
export function isCoarseRole(value: unknown): value is CoarseRole {
|
|
58
|
+
return isTenantRole(value);
|
|
59
|
+
}
|
|
49
60
|
|
|
50
61
|
/** A single tenant membership carried in the assertion. */
|
|
51
62
|
export interface TenantMembership {
|
|
@@ -90,7 +101,7 @@ export interface IdentityClaims {
|
|
|
90
101
|
|
|
91
102
|
/** Coarse-role collapse guard: anything tot didn't send as owner/admin → member. */
|
|
92
103
|
function coerceRole(role: unknown): CoarseRole {
|
|
93
|
-
return role
|
|
104
|
+
return isCoarseRole(role) ? role : "member";
|
|
94
105
|
}
|
|
95
106
|
|
|
96
107
|
/**
|
|
@@ -16,8 +16,22 @@
|
|
|
16
16
|
/** Access level the acting operator holds. */
|
|
17
17
|
export type Capability = "preview-access" | "ship-on-behalf";
|
|
18
18
|
|
|
19
|
+
/** Complete tenant-role vocabulary emitted by verified identity assertions. */
|
|
20
|
+
export const TENANT_ROLES = ["owner", "admin", "member"] as const;
|
|
21
|
+
export type TenantRole = (typeof TENANT_ROLES)[number];
|
|
22
|
+
|
|
23
|
+
export function isTenantRole(value: unknown): value is TenantRole {
|
|
24
|
+
return typeof value === "string" && (TENANT_ROLES as readonly string[]).includes(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
19
27
|
/** Capabilities that carry tenant-owner standing (vs. a granted developer). */
|
|
20
|
-
export const
|
|
28
|
+
export const OWNER_CAPABILITY_VALUES = ["owner", "admin"] as const satisfies readonly TenantRole[];
|
|
29
|
+
export type OwnerCapability = (typeof OWNER_CAPABILITY_VALUES)[number];
|
|
30
|
+
export const OWNER_CAPABILITIES: ReadonlySet<string> = new Set(OWNER_CAPABILITY_VALUES);
|
|
31
|
+
|
|
32
|
+
export function isOwnerCapability(value: unknown): value is OwnerCapability {
|
|
33
|
+
return typeof value === "string" && OWNER_CAPABILITIES.has(value);
|
|
34
|
+
}
|
|
21
35
|
|
|
22
36
|
/** Pure inputs to the owner-principal decision (session only, no I/O). */
|
|
23
37
|
export interface OwnerPrincipalInput {
|
|
@@ -34,7 +48,7 @@ export interface OwnerPrincipalInput {
|
|
|
34
48
|
*/
|
|
35
49
|
export function decideIsOwner(input: OwnerPrincipalInput): boolean {
|
|
36
50
|
if (!input.authorized) return false;
|
|
37
|
-
return
|
|
51
|
+
return isOwnerCapability(input.viewerCapability);
|
|
38
52
|
}
|
|
39
53
|
|
|
40
54
|
/**
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Viewer ship-grant resolver (security-critical) — resolve a signed-in developer
|
|
3
|
+
* VIEWER's REAL, owner-revocable ship capability over a tenant, LIVE from the MCP
|
|
4
|
+
* subject-capability store. This is the near-side read of the Step-1 tot-mcp seam:
|
|
5
|
+
*
|
|
6
|
+
* GET `${origin}/api/internal/dev-access/subject-capability?subject=<email>&tenant=<appDomain>&app=storefront`
|
|
7
|
+
* Auth: header `x-mcp-service-secret: <MCP_SERVICE_SHARED_SECRET>` (NOT a bearer)
|
|
8
|
+
* → 200 { capability: "ship-on-behalf" | "preview-access" }
|
|
9
|
+
* · an ABSENT / REVOKED grant answers `preview-access`
|
|
10
|
+
* · a store fault answers 502
|
|
11
|
+
*
|
|
12
|
+
* WHY it exists (the security constraint): a normally-invited developer's COARSE
|
|
13
|
+
* role is `preview-access`, NOT ship — the coarse "developer" role collapses to
|
|
14
|
+
* member. ship-on-behalf is a SEPARATE, explicit, OWNER-REVOCABLE grant. So the
|
|
15
|
+
* viewer/browser ship path must NEVER map the coarse role → ship and must NEVER
|
|
16
|
+
* trust a client-supplied `x-tot-capability` header; it resolves the REAL grant
|
|
17
|
+
* here, LIVE (no cache that could outlive a revoke).
|
|
18
|
+
*
|
|
19
|
+
* FAIL-CLOSED, TOTAL, NEVER THROWS: an unconfigured seam (no URL/secret), a missing
|
|
20
|
+
* subject/tenant, any non-2xx (INCLUDING 502), an unparseable body, an unexpected
|
|
21
|
+
* capability value, or a transport/timeout fault ALL resolve to `preview-access` —
|
|
22
|
+
* the deny default. Only an explicit `200 { capability: "ship-on-behalf" }` grants
|
|
23
|
+
* ship. This mirrors `dev/previewStatus.ts` + `grants/developerInvites.ts`'s
|
|
24
|
+
* `recordDeveloperAppBinding` transport (same MCP-origin + service-secret seam);
|
|
25
|
+
* it keeps its OWN small transport rather than importing another cluster's copy so
|
|
26
|
+
* the two stay decoupled (the same reason previewStatus duplicates its transport).
|
|
27
|
+
*
|
|
28
|
+
* The MCP HOST is the ORIGIN of `MCP_APP_BINDING_URL` — the same tier the invite
|
|
29
|
+
* app-binding WRITE (`recordDeveloperAppBinding`) targets, so this READ and that
|
|
30
|
+
* WRITE always agree on which subject-capability store they speak to.
|
|
31
|
+
*/
|
|
32
|
+
import { readEnv } from "@/lib/env";
|
|
33
|
+
import type { Capability } from "./capability.js";
|
|
34
|
+
|
|
35
|
+
/** Minimal GET transport (mirrors `dev/previewStatus.ts` — decoupled by design). */
|
|
36
|
+
export interface ViewerShipGrantGetTransport {
|
|
37
|
+
get(
|
|
38
|
+
url: string,
|
|
39
|
+
headers: Record<string, string>,
|
|
40
|
+
): Promise<{ status: number; json(): Promise<unknown> }>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Default transport: JSON `fetch` GET. */
|
|
44
|
+
export class FetchShipGrantTransport implements ViewerShipGrantGetTransport {
|
|
45
|
+
async get(url: string, headers: Record<string, string>) {
|
|
46
|
+
// Bounded: this GET runs during admin-page SSR (AdminPublishTab frontmatter, on
|
|
47
|
+
// every admin tab), so an unbounded hang stalls the whole document stream. A
|
|
48
|
+
// timeout aborts to the documented deny-default (the resolver maps any transport/
|
|
49
|
+
// timeout fault to `preview-access`) — never a render-blocking wait on a slow MCP.
|
|
50
|
+
const res = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(2500) });
|
|
51
|
+
return { status: res.status, json: () => res.json() };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Injectable deps so the resolver is unit-testable without a live MCP. */
|
|
56
|
+
export interface ViewerShipGrantDeps {
|
|
57
|
+
transport?: ViewerShipGrantGetTransport;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The Step-1 subject-capability reader path (mounted OUTSIDE the session guard). */
|
|
61
|
+
const SUBJECT_CAPABILITY_PATH = "/api/internal/dev-access/subject-capability";
|
|
62
|
+
|
|
63
|
+
/** The viewer's blog-contribute capability — a NEW, blog-scoped projection kept OUT
|
|
64
|
+
* of the forge `Capability` union (which stays exactly two rungs). `ship-on-behalf`
|
|
65
|
+
* supersets contribute, so a ship grant also yields `"blog-contribute"` here. */
|
|
66
|
+
export type ViewerContributeCapability = "preview-access" | "blog-contribute";
|
|
67
|
+
|
|
68
|
+
/** The raw recognized capability read off the subject-capability seam. The private
|
|
69
|
+
* transport whitelists BOTH grant literals; the public wrappers narrow it. */
|
|
70
|
+
type RawViewerCapability = "preview-access" | "blog-contribute" | "ship-on-behalf";
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* ONE live subject-capability lookup, returning the raw whitelisted capability.
|
|
74
|
+
* FAIL-CLOSED, TOTAL, NEVER THROWS: an unconfigured seam (no URL/secret), a missing
|
|
75
|
+
* subject/tenant, any non-2xx (INCLUDING 502), an unparseable body, an unexpected
|
|
76
|
+
* capability value, or a transport/timeout fault ALL resolve to `"preview-access"` —
|
|
77
|
+
* the deny default. Only an explicit `200 { capability: "ship-on-behalf" }` or
|
|
78
|
+
* `200 { capability: "blog-contribute" }` returns a grant literal.
|
|
79
|
+
*/
|
|
80
|
+
async function resolveRawViewerCapability(
|
|
81
|
+
email: string | null | undefined,
|
|
82
|
+
appDomain: string | null | undefined,
|
|
83
|
+
deps: ViewerShipGrantDeps = {},
|
|
84
|
+
): Promise<RawViewerCapability> {
|
|
85
|
+
const subject = (email ?? "").trim().toLowerCase();
|
|
86
|
+
const tenant = (appDomain ?? "").trim();
|
|
87
|
+
if (!subject || !tenant) return "preview-access";
|
|
88
|
+
|
|
89
|
+
const bindingUrl = await readEnv("MCP_APP_BINDING_URL");
|
|
90
|
+
const secret = await readEnv("MCP_SERVICE_SHARED_SECRET");
|
|
91
|
+
// Unconfigured seam → deny (the MCP route is only mounted once the secret exists).
|
|
92
|
+
if (!bindingUrl || !secret) return "preview-access";
|
|
93
|
+
|
|
94
|
+
let endpoint: string;
|
|
95
|
+
try {
|
|
96
|
+
endpoint = `${new URL(bindingUrl).origin}${SUBJECT_CAPABILITY_PATH}`;
|
|
97
|
+
} catch {
|
|
98
|
+
return "preview-access";
|
|
99
|
+
}
|
|
100
|
+
const full =
|
|
101
|
+
`${endpoint}?subject=${encodeURIComponent(subject)}` +
|
|
102
|
+
`&tenant=${encodeURIComponent(tenant)}&app=storefront`;
|
|
103
|
+
|
|
104
|
+
const transport = deps.transport ?? new FetchShipGrantTransport();
|
|
105
|
+
let res: { status: number; json(): Promise<unknown> };
|
|
106
|
+
try {
|
|
107
|
+
res = await transport.get(full, { "x-mcp-service-secret": secret });
|
|
108
|
+
} catch {
|
|
109
|
+
// Transport / timeout fault — fail closed.
|
|
110
|
+
return "preview-access";
|
|
111
|
+
}
|
|
112
|
+
// Any non-2xx (incl. 502 store fault, 401 unmounted, 404) → deny.
|
|
113
|
+
if (res.status < 200 || res.status >= 300) return "preview-access";
|
|
114
|
+
|
|
115
|
+
let payload: unknown;
|
|
116
|
+
try {
|
|
117
|
+
payload = await res.json();
|
|
118
|
+
} catch {
|
|
119
|
+
return "preview-access";
|
|
120
|
+
}
|
|
121
|
+
const capability = (payload as Record<string, unknown> | null)?.capability;
|
|
122
|
+
// Whitelist: ONLY the explicit grant literals admit; anything else is deny-default.
|
|
123
|
+
if (capability === "ship-on-behalf") return "ship-on-behalf";
|
|
124
|
+
if (capability === "blog-contribute") return "blog-contribute";
|
|
125
|
+
return "preview-access";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Resolve the viewer's LIVE ship capability over `appDomain`. Returns the granted
|
|
130
|
+
* `Capability` — `"ship-on-behalf"` ONLY on an explicit `200 { capability:
|
|
131
|
+
* "ship-on-behalf" }`; EVERYTHING else (unconfigured, absent, 502, timeout,
|
|
132
|
+
* malformed, unexpected value) fail-closes to `"preview-access"`. Never throws.
|
|
133
|
+
*
|
|
134
|
+
* @param email the signed-in viewer's email (the subject-capability store key)
|
|
135
|
+
* @param appDomain the resolved tenant the capability is scoped to (server-derived)
|
|
136
|
+
*/
|
|
137
|
+
export async function resolveViewerShipCapability(
|
|
138
|
+
email: string | null | undefined,
|
|
139
|
+
appDomain: string | null | undefined,
|
|
140
|
+
deps: ViewerShipGrantDeps = {},
|
|
141
|
+
): Promise<Capability> {
|
|
142
|
+
const raw = await resolveRawViewerCapability(email, appDomain, deps);
|
|
143
|
+
return raw === "ship-on-behalf" ? "ship-on-behalf" : "preview-access";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Resolve the viewer's LIVE blog-contribute capability over `appDomain`, from the
|
|
148
|
+
* SAME subject-capability seam the ship resolver reads. Returns `"blog-contribute"`
|
|
149
|
+
* ONLY on an explicit `200 { capability: "blog-contribute" }` OR `200 { capability:
|
|
150
|
+
* "ship-on-behalf" }` (ship supersets contribute); EVERYTHING else (unconfigured,
|
|
151
|
+
* absent, 502, timeout, malformed, unexpected value) fail-closes to
|
|
152
|
+
* `"preview-access"`. Never throws. Mirrors `resolveViewerShipCapability` exactly.
|
|
153
|
+
*
|
|
154
|
+
* @param email the signed-in viewer's email (the subject-capability store key)
|
|
155
|
+
* @param appDomain the resolved tenant the capability is scoped to (server-derived)
|
|
156
|
+
*/
|
|
157
|
+
export async function resolveViewerContributeCapability(
|
|
158
|
+
email: string | null | undefined,
|
|
159
|
+
appDomain: string | null | undefined,
|
|
160
|
+
deps: ViewerShipGrantDeps = {},
|
|
161
|
+
): Promise<ViewerContributeCapability> {
|
|
162
|
+
const raw = await resolveRawViewerCapability(email, appDomain, deps);
|
|
163
|
+
return raw === "blog-contribute" || raw === "ship-on-behalf"
|
|
164
|
+
? "blog-contribute"
|
|
165
|
+
: "preview-access";
|
|
166
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.23",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|