@tokenoftrust/storefront-runner 2.3.2 → 2.4.1
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/env.d.ts +5 -2
- package/apps/storefront/src/components/auth/AuthBadge.astro +7 -4
- package/apps/storefront/src/lib/activity/uiActor.ts +10 -9
- package/apps/storefront/src/lib/auth/adminApiGuard.ts +6 -6
- package/apps/storefront/src/lib/auth/adminEntry.ts +16 -9
- package/apps/storefront/src/lib/auth/operatorSession.ts +10 -3
- package/apps/storefront/src/middleware/controlPlane.ts +9 -8
- package/package.json +1 -1
package/apps/storefront/env.d.ts
CHANGED
|
@@ -206,14 +206,17 @@ declare global {
|
|
|
206
206
|
* gate once a valid, resource-bound `tot_session` cookie is present). Carries
|
|
207
207
|
* the opaque subject, email + role(s)/capability tot20 resolved on this tenant, so chrome
|
|
208
208
|
* can show "signed in as … — owner/developer/staff". Absent on open or
|
|
209
|
-
* password-gated hosts.
|
|
209
|
+
* password-gated hosts. Chrome only: authorization reads the session record for the
|
|
210
|
+
* routed tenant (lib/pipeline/admission-request), never this.
|
|
210
211
|
*/
|
|
211
212
|
viewer?: {
|
|
212
213
|
/** Opaque identity-provider subject from the verified session assertion. */
|
|
213
214
|
subject?: string;
|
|
214
215
|
email: string;
|
|
216
|
+
/** The viewer's standing on THIS host's tenant, as a one-element list; empty when none. */
|
|
215
217
|
roles: string[];
|
|
216
|
-
|
|
218
|
+
/** The viewer's standing on THIS host's tenant; null when the session holds none here. */
|
|
219
|
+
capability: string | null;
|
|
217
220
|
};
|
|
218
221
|
/** Cloudflare runtime (env bindings) — provided by the adapter on Workers. */
|
|
219
222
|
runtime?: { env: CloudflareEnv };
|
|
@@ -17,7 +17,8 @@ const CAP_LABEL: Record<string, string> = {
|
|
|
17
17
|
inviter: "Inviter",
|
|
18
18
|
staff: "ToT Staff",
|
|
19
19
|
};
|
|
20
|
-
|
|
20
|
+
// The capability is the viewer's standing on THIS store only; null = none here.
|
|
21
|
+
const label = viewer?.capability ? (CAP_LABEL[viewer.capability] ?? viewer.capability) : "";
|
|
21
22
|
---
|
|
22
23
|
|
|
23
24
|
{viewer && (
|
|
@@ -29,9 +30,11 @@ const label = viewer ? (CAP_LABEL[viewer.capability] ?? viewer.capability) : "";
|
|
|
29
30
|
<span aria-hidden="true">🔓</span>
|
|
30
31
|
<span class="text-[var(--color-muted)]">Signed in as</span>
|
|
31
32
|
<span class="font-semibold">{viewer.email}</span>
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
{label && (
|
|
34
|
+
<span class="rounded-full bg-[var(--color-primary)] px-2 py-0.5 font-semibold text-[var(--color-primary-contrast)]">
|
|
35
|
+
{label}
|
|
36
|
+
</span>
|
|
37
|
+
)}
|
|
35
38
|
<form method="post" action="/api/auth/logout" class="contents">
|
|
36
39
|
<button
|
|
37
40
|
type="submit"
|
|
@@ -49,6 +49,7 @@ import type {
|
|
|
49
49
|
} from "@tot/public-runtime";
|
|
50
50
|
import type { ActionKey } from "@tot/public-runtime";
|
|
51
51
|
import { readViewerSession } from "@/lib/auth/route";
|
|
52
|
+
import { sessionHostCapability } from "@/lib/auth/loginGate";
|
|
52
53
|
import { isStaffSession } from "@/lib/dashboard/staffAdmission";
|
|
53
54
|
import { readEnv } from "@/lib/env";
|
|
54
55
|
import { isOwnerCapability } from "@/lib/shared/capability";
|
|
@@ -57,7 +58,7 @@ import { hashActorId, recordActivity } from "./recordActivity";
|
|
|
57
58
|
/** The minimal viewer chrome the middleware stamps on `locals.viewer`. */
|
|
58
59
|
interface ViewerLike {
|
|
59
60
|
email?: string;
|
|
60
|
-
capability?: string;
|
|
61
|
+
capability?: string | null;
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
/**
|
|
@@ -107,14 +108,14 @@ export async function resolveUiActor(
|
|
|
107
108
|
// The host-bound `locals.viewer` chrome omits `staff[]`, so read the full session
|
|
108
109
|
// to tell a ToT-staff (admin) actor from a store owner (merchant). Best-effort.
|
|
109
110
|
const session = await readViewerSession(context).catch(() => null);
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
const viewer: ViewerLike | undefined =
|
|
116
|
-
(context.locals?.
|
|
117
|
-
|
|
111
|
+
// The signed-in identity is the host gate's `locals.viewer`, else the raw session (a
|
|
112
|
+
// route that never went through that gate, e.g. the /dev cockpit's rendezvous
|
|
113
|
+
// approval). Ownership is the session's standing on the ROUTED tenant only — a
|
|
114
|
+
// session that owns another store is not this store's merchant.
|
|
115
|
+
const email = context.locals?.viewer?.email ?? session?.email;
|
|
116
|
+
const viewer: ViewerLike | undefined = email
|
|
117
|
+
? { email, capability: sessionHostCapability(session, context.locals?.tenant?.appDomain ?? "") }
|
|
118
|
+
: undefined;
|
|
118
119
|
const kind = classifyActorKind(viewer, session);
|
|
119
120
|
const identifier = viewer?.email ?? input.owner ?? "system";
|
|
120
121
|
const id = await hashActorId(identifier, await readEnv("ACTIVITY_ACTOR_SALT"));
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
*
|
|
11
11
|
* no session → 401 sign_in_required
|
|
12
12
|
* session, but no standing for THIS tenant → 403 not_authorized
|
|
13
|
-
*
|
|
13
|
+
* (ToT staff without a live vendor selection for THIS tenant included)
|
|
14
|
+
* member/owner of THIS tenant, or staff holding a live selection for it → allowed
|
|
14
15
|
*
|
|
15
16
|
* The decision core is PURE (unit-testable); `requireAdminApiEntry` is the thin
|
|
16
17
|
* Astro/I-O wrapper the routes call.
|
|
@@ -28,9 +29,9 @@ export type AdminApiAccess =
|
|
|
28
29
|
* PURE fail-closed decision for a tenant-scoped admin API caller.
|
|
29
30
|
*
|
|
30
31
|
* @param requireOwner when true, only an owner/admin viewer OR a ToT-staff viewer
|
|
31
|
-
* passes (a plain team member is
|
|
32
|
-
* owner-level operational surfaces.
|
|
33
|
-
*
|
|
32
|
+
* holding a live, audited selection for THIS tenant passes (a plain team member is
|
|
33
|
+
* refused) — for routes that mutate/expose owner-level operational surfaces.
|
|
34
|
+
* Default (false) admits anyone with standing on the tenant.
|
|
34
35
|
*/
|
|
35
36
|
export function decideAdminApiAccess(input: {
|
|
36
37
|
record: SessionRecord | null | undefined;
|
|
@@ -42,9 +43,8 @@ export function decideAdminApiAccess(input: {
|
|
|
42
43
|
if (!record) return { ok: false, status: 401, reason: "sign_in_required" };
|
|
43
44
|
|
|
44
45
|
const entry = resolveAdminEntry({ record, hostResource, nowSeconds });
|
|
45
|
-
const staffScope = Array.isArray(record.staff) && record.staff.length > 0;
|
|
46
46
|
const authorized = requireOwner
|
|
47
|
-
? entry.isOwnerViewer ||
|
|
47
|
+
? entry.isOwnerViewer || entry.staffSelected
|
|
48
48
|
: entry.admitted;
|
|
49
49
|
if (!authorized) return { ok: false, status: 403, reason: "not_authorized" };
|
|
50
50
|
|
|
@@ -23,13 +23,15 @@
|
|
|
23
23
|
* owner is the ONLY source of `shipOnBehalf`/`apexCutover` here.
|
|
24
24
|
*
|
|
25
25
|
* The distinguishing logic: `admitted`
|
|
26
|
-
* separates an authenticated OWNER/member (a host-bound capability,
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
26
|
+
* separates an authenticated OWNER/member (a host-bound capability, which for ToT
|
|
27
|
+
* staff means a LIVE, audited vendor selection for THIS host) from an authenticated
|
|
28
|
+
* NON-member, staff without a selection for this store, and an anonymous visitor —
|
|
29
|
+
* none of whom act on this store's admin. Staff scope alone is a browsing
|
|
30
|
+
* admission at the login gate, never an admin one. `principal` labels the case.
|
|
30
31
|
*/
|
|
31
32
|
import type { SessionRecord } from "./session.js";
|
|
32
33
|
import { sessionHostCapability } from "./loginGate.js";
|
|
34
|
+
import { staffAdmits } from "@/lib/dashboard/tenantSelection";
|
|
33
35
|
import {
|
|
34
36
|
decideIsOwner,
|
|
35
37
|
isOwnerCapability,
|
|
@@ -49,11 +51,13 @@ export type AdminEntryPrincipal =
|
|
|
49
51
|
|
|
50
52
|
export interface AdminEntryDecision {
|
|
51
53
|
/**
|
|
52
|
-
* True when the viewer
|
|
53
|
-
* or a
|
|
54
|
-
* for
|
|
54
|
+
* True when the viewer holds standing on this tenant (membership, a session scoped
|
|
55
|
+
* to it, or a live staff selection for it). False for an authenticated non-member,
|
|
56
|
+
* staff without a selection for this store, and an anonymous visitor.
|
|
55
57
|
*/
|
|
56
58
|
admitted: boolean;
|
|
59
|
+
/** A ToT-staff viewer holding a LIVE, audited vendor selection for THIS host. */
|
|
60
|
+
staffSelected: boolean;
|
|
57
61
|
/** The owner/admin/member standing bound to this host, or null. */
|
|
58
62
|
hostCapability: string | null;
|
|
59
63
|
/** The acting viewer IS the tenant owner/admin (superset of ship-on-behalf). */
|
|
@@ -88,7 +92,10 @@ export function resolveAdminEntry(input: {
|
|
|
88
92
|
const { record, hostResource, nowSeconds, shipGrant } = input;
|
|
89
93
|
const hostCapability = sessionHostCapability(record ?? null, hostResource, nowSeconds);
|
|
90
94
|
const staffScope = Array.isArray(record?.staff) && record!.staff.length > 0;
|
|
91
|
-
const
|
|
95
|
+
const staffSelected =
|
|
96
|
+
staffScope && staffAdmits({ selection: record?.staffSelection, nowSeconds }, (hostResource ?? "").trim());
|
|
97
|
+
// Standing on THIS host only; a staff selection for this host is part of it.
|
|
98
|
+
const admitted = hostCapability !== null;
|
|
92
99
|
|
|
93
100
|
// Owner resolution — the OWNER VIEWER, not the bearer console: authorized
|
|
94
101
|
// (a member/staff viewer of THIS tenant) AND holding an owner/admin capability.
|
|
@@ -115,5 +122,5 @@ export function resolveAdminEntry(input: {
|
|
|
115
122
|
? "staff"
|
|
116
123
|
: "authenticated-non-member";
|
|
117
124
|
|
|
118
|
-
return { admitted, hostCapability, isOwnerViewer, shipCapabilities, principal };
|
|
125
|
+
return { admitted, staffSelected, hostCapability, isOwnerViewer, shipCapabilities, principal };
|
|
119
126
|
}
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* viewer that lacks that capability is treated.
|
|
11
11
|
*/
|
|
12
12
|
import type { APIContext } from "astro";
|
|
13
|
+
import { readViewerSession } from "./route.js";
|
|
14
|
+
import { sessionHostCapability } from "./loginGate.js";
|
|
13
15
|
|
|
14
16
|
/** The host-bound viewer chrome the middleware sets on `locals.viewer`. */
|
|
15
17
|
type Viewer = NonNullable<APIContext["locals"]["viewer"]>;
|
|
@@ -89,9 +91,12 @@ export interface HostBoundSessionConfig<T> {
|
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
/**
|
|
92
|
-
* Two-path host-bound operator session: a signed-in
|
|
94
|
+
* Two-path host-bound operator session: a signed-in viewer on the
|
|
93
95
|
* 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
|
|
96
|
+
* The tenant is ALWAYS the resolved host `appDomain` — never the request body —
|
|
97
|
+
* and the viewer's standing is read from the session record FOR THAT TENANT
|
|
98
|
+
* (`sessionHostCapability`), never from the `locals.viewer` chrome, so standing the
|
|
99
|
+
* session holds on another store never authorizes here.
|
|
95
100
|
* When no viewer/tenant pair is present, or a present viewer is insufficient
|
|
96
101
|
* under a `fallThrough` policy, path 2 decides.
|
|
97
102
|
*/
|
|
@@ -102,7 +107,9 @@ export async function resolveHostBoundSession<T>(
|
|
|
102
107
|
const viewer = context.locals?.viewer;
|
|
103
108
|
const tenant = context.locals?.tenant?.appDomain;
|
|
104
109
|
if (viewer && tenant) {
|
|
105
|
-
|
|
110
|
+
const session = await readViewerSession(context).catch(() => null);
|
|
111
|
+
const standing = sessionHostCapability(session, tenant);
|
|
112
|
+
if (standing !== null && config.capabilities.has(standing)) {
|
|
106
113
|
return config.resolveViewer(viewer, tenant);
|
|
107
114
|
}
|
|
108
115
|
if (config.onInsufficientCapability.mode === "failClosed") {
|
|
@@ -196,12 +196,13 @@ export async function serveControlPlane(input: ControlPlaneInput): Promise<Respo
|
|
|
196
196
|
}
|
|
197
197
|
if (admitted && viewerRecord) {
|
|
198
198
|
// Surface the viewer's standing FOR THIS host's tenant as chrome, BOUND to
|
|
199
|
-
// the host
|
|
200
|
-
//
|
|
201
|
-
// the
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
199
|
+
// the host: a member sees their tenants[] role; the owner of their own tenant
|
|
200
|
+
// sees the resource-scoped session's capability; a ToT-staff viewer admitted
|
|
201
|
+
// via an explicit vendor selection sees the selection's capability. A viewer
|
|
202
|
+
// with no standing here (staff browsing without a selection) carries none —
|
|
203
|
+
// the session's standing on any other store never appears on this host.
|
|
204
|
+
// Authorization never reads this chrome: admission resolves standing from the
|
|
205
|
+
// session record for the routed tenant (lib/pipeline/admission-request).
|
|
205
206
|
//
|
|
206
207
|
// On an admin path, "the host" a staff viewer is really acting on is the
|
|
207
208
|
// ?asTenant= impersonation target (mirrors admin.astro/AdminPublishTab.astro's
|
|
@@ -221,8 +222,8 @@ export async function serveControlPlane(input: ControlPlaneInput): Promise<Respo
|
|
|
221
222
|
locals.viewer = {
|
|
222
223
|
...(viewerRecord.subject ? { subject: viewerRecord.subject } : {}),
|
|
223
224
|
email: viewerRecord.email,
|
|
224
|
-
roles: hostCapability ? [hostCapability] :
|
|
225
|
-
capability: hostCapability
|
|
225
|
+
roles: hostCapability ? [hostCapability] : [],
|
|
226
|
+
capability: hostCapability,
|
|
226
227
|
};
|
|
227
228
|
}
|
|
228
229
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.1",
|
|
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",
|