@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.
@@ -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
- capability: string;
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
- const label = viewer ? (CAP_LABEL[viewer.capability] ?? viewer.capability) : "";
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
- <span class="rounded-full bg-[var(--color-primary)] px-2 py-0.5 font-semibold text-[var(--color-primary-contrast)]">
33
- {label}
34
- </span>
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
- // Prefer the host-scoped `locals.viewer` (set by middleware for tenant-gated
111
- // surfaces — the ship/grants routes); fall back to the raw session's
112
- // email/capability for a route that never went through that host gate (e.g. the
113
- // /dev cockpit's rendezvous-approval endpoint) but still has a real signed-in
114
- // viewer. Both describe the SAME signed-in-or-not shape.
115
- const viewer: ViewerLike | undefined =
116
- (context.locals?.viewer as ViewerLike | undefined) ??
117
- (session ? { email: session.email, capability: session.capability } : undefined);
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
- * member/owner (or staff) of THIS tenant → allowed, entry returned
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 refused) — for routes that mutate/expose
32
- * owner-level operational surfaces. Default (false) admits any member/owner/staff
33
- * of the tenant, matching who reaches the admin shell.
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 || staffScope
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, or ToT-staff
27
- * scope) — who must NEVER see the "Coming soon" holding page at their own admin —
28
- * from an authenticated NON-member and an anonymous visitor, who legitimately do
29
- * not enter. `principal` labels which of those four cases this is.
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 is an owner/member of this tenant (host-bound capability)
53
- * or a ToT-staff viewer — i.e. reaches the admin context, never the gate. False
54
- * for an authenticated non-member and an anonymous visitor (the legit gate).
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 admitted = hostCapability !== null || staffScope;
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 `locals.viewer` on the
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
- if (config.capabilities.has(viewer.capability)) {
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 so a capability the session holds for another tenant never leaks
200
- // in: a member sees their tenants[] role; the owner of their own tenant sees
201
- // the resource-scoped session's capability; a ToT-staff viewer admitted via
202
- // an explicit vendor selection sees the staffRoles-scoped capability. The
203
- // ship gates (decideIsOwner/resolveShipPrincipal) read this capability,
204
- // so binding it here keeps owner-resolution server-side + tenant-correct.
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] : viewerRecord.roles,
225
- capability: hostCapability ?? viewerRecord.capability,
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.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",