@c1m-public/platform-auth 0.2.0 → 0.4.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/README.md CHANGED
@@ -1,28 +1,105 @@
1
1
  # @c1m-public/platform-auth
2
2
 
3
- Framework-neutral session helpers for C1M platform surfaces (Platform Admin
4
- Center, Knowledge Library, SaaSDash BFFs):
5
-
6
- - `token` — decode access-token payloads (audience, expiry, default session).
7
- Decoding is routing, not verification; the backend stays the boundary.
8
- - `safeInternalPath` — strict same-origin return-path sanitiser.
9
- - `forwardedRequestHeaders` — copy the browser's client identity onto a
10
- backend call (`X-Client-*` / `X-Forwarded-*`).
11
- - `createDeviceIdentity` — the `X-Client-Device-ID` cookie the backend needs to
12
- create a session.
13
- - `createHandoffTarget` / `createHandoffSource` — the browser halves of the
14
- first-party session handoff (`/api/v1/platform/auth/handoffs/{app}`):
15
- PKCE S256 + state, one-time code, host-local cookies, no tokens in URLs.
16
- - `HANDOFF_ERROR_MESSAGES` — the one error vocabulary for handoff failures.
17
-
18
- Import from `@c1m-public/platform-auth/next` inside Next.js server code.
3
+ Server-side **session** and **authorization** helpers for C1M platform surfaces
4
+ (Platform Admin Center, Knowledge Library, SaaSDash BFFs, and starter BFFs).
5
+
6
+ Two halves, one package. The root and `./next` entry points carry session
7
+ protocol behaviour and stay framework-neutral; everything under `./access`
8
+ carries the resolved authorization snapshot. Import only the entry point you
9
+ need — the boundary is the subpath.
10
+
11
+ ```
12
+ . session: token reads, handoff, proxy gate, refresh (Web Request/Response)
13
+ ./next session, server-only guard (Next route handlers)
14
+ ./access authorization: snapshot, keys, predicates (framework-neutral)
15
+ ./access/next access loader: require / forbidden / invalidate (Next, server-only)
16
+ ./access/react <Can>, useCan, AccessProvider (client)
17
+ ```
18
+
19
+ `next` and `react` are **optional** peer dependencies: a BFF that only reads
20
+ sessions never needs them.
21
+
22
+ ## `.` — session
23
+
24
+ | Export | Purpose |
25
+ |---|---|
26
+ | `decodeTokenPayload`, `decodeDefaultSession`, `hasTokenAudience`, `isTokenExpired`, `isTokenExpiringSoon`, `tokenMaxAgeSeconds` | Read an access token for routing. Decoding is **not** verification — the backend re-verifies every call. `decodeDefaultSession` prefers `effective_user_id`, so a support session resolves as the impersonated user. |
27
+ | `parseAuthResponse`, `AuthResponse` | Validate a backend auth envelope before trusting it. |
28
+ | `readCookie`, `serializeCookie`, `setCookie`, `deleteCookie` | Cookie helpers over Web `Request`/`Response`. `__Host-` names get `Secure` automatically. |
29
+ | `createDeviceIdentity` | The `X-Client-Device-ID` cookie the backend requires to create a session. |
30
+ | `applyForwardedClientHeaders`, `forwardedRequestHeaders` | Copy client identity (IP, geo, UA) onto a backend call. Never used for auth. |
31
+ | `safeInternalPath` | Strict same-origin return-path sanitiser (rejects `//`, `://`, encoded slashes, control chars, anything that does not round-trip). |
32
+ | `createSessionRefresher` | Coalesced refresh-token exchange: one backend call per token per window. |
33
+ | `evaluateSessionGate`, `hasSessionShape` | Pure proxy decision — `pass` / `redirect_home` / `anonymous_public` / `unauthorized_api` / `redirect_login`. The proxy owns response construction. |
34
+ | `createHandoffTarget`, `createHandoffSource`, `brokerPaths`, `sourceStartPath` | Browser halves of the first-party session handoff (`/api/v1/platform/auth/handoffs/{app}`): PKCE S256 + state, one-time code, host-local cookies, no tokens in URLs. |
35
+ | `HANDOFF_ERROR_MESSAGES`, `handoffErrorMessage`, `isHandoffErrorCode` | One error vocabulary shared by backend and BFF codes. |
19
36
 
20
37
  ```ts
21
- // target: app/api/auth/platform-sso/[action]/route.ts
22
- const target = createHandoffTarget({ app: "docs", apiUrl: API_URL, audience: "platform_docs", ... });
23
- export const GET = (req: Request, ctx) => ctx.params.action === "callback" ? target.callback(req) : target.start(req);
38
+ // proxy.ts
39
+ const refresher = createSessionRefresher({ apiUrl: API_URL, refreshPath, clientType, audience });
40
+ const decision = await evaluateSessionGate({
41
+ accessToken: request.cookies.get(SESSION)?.value,
42
+ refreshToken: request.cookies.get(REFRESH)?.value,
43
+ audience, refresher,
44
+ isPublic, isLoginPath, isApiPath: pathname.startsWith("/api/"),
45
+ });
46
+ ```
47
+
48
+ ```ts
49
+ // target: app/api/auth/platform-sso/{start,callback}
50
+ const target = createHandoffTarget({ app: "docs", apiUrl, audience: "platform_docs", clientType,
51
+ sources: { admin: ADMIN_URL, saasdash: SAASDASH_URL }, defaultSource: "admin",
52
+ cookiePrefix: "docs", loginPath: "/login", defaultReturnPath: "/docs", device, establishSession });
53
+
54
+ // source: app/api/auth/handoffs/[app]/start
55
+ const source = createHandoffSource({ apiUrl, clientType, targets: { docs: { origin: DOCS_URL } },
56
+ getAccessToken, loginPath: "/login" });
57
+ ```
58
+
59
+ ## `./access` — authorization
60
+
61
+ The resolved snapshot from `GET /api/v1/platform/access`.
24
62
 
25
- // source: app/api/auth/handoffs/[app]/start/route.ts
26
- const source = createHandoffSource({ apiUrl: API_URL, clientType: "nextjs-platform-admin", targets: { docs: { origin: DOCS_URL } }, getAccessToken, loginPath: "/login" });
27
- export const GET = (req: Request, ctx) => source.start(req, ctx.params.app);
63
+ | Export | Purpose |
64
+ |---|---|
65
+ | `PLATFORM_PERMISSIONS`, `LEADBROKER_PERMISSIONS`, `WORKSPACE_PERMISSIONS`, `WORKSPACE_ROLES` + key types | Generated from the backend catalog — see the drift guard below. |
66
+ | `parseAccessSnapshot`, `emptySnapshot`, `AccessSnapshot`, `AccessEntry` | Validate and shape the snapshot. |
67
+ | `hasPermission`, `hasAnyPermission`, `hasAllPermissions`, `hasWorkspacePermission`, `isPlatformOwner`, `appScopes`, `permissionsFor` | Scope-aware predicates. |
68
+ | `fetchAccessSnapshot` | Framework-neutral fetch. Pass `request` and it forwards client identity for you. |
69
+ | `AccessSnapshotError`, `isAccessSnapshotError` | `unauthenticated` / `forbidden` / `unavailable` / `invalid_snapshot`. |
70
+
71
+ `./access/next` adds `createAccessLoader` (request-scoped via React `cache`),
72
+ `requireAccess`, `requireWorkspaceAccess`, `invalidateAccess`, `accessCacheTag`.
73
+ `./access/react` adds `AccessProvider`, `useCan`, `useCanWorkspace`,
74
+ `useIsPlatformOwner`, `<Can>`.
75
+
76
+ ```ts
77
+ // lib/access.server.ts
78
+ export const access = createAccessLoader({
79
+ apiUrl: getApiUrl,
80
+ getAccessToken: async () => (await cookies()).get(SESSION)?.value ?? null,
81
+ // Cross-request caching is the APP's job: the "use cache" directive does not
82
+ // apply inside node_modules, so wrap the fetch here and tag it yourself.
83
+ fetchSnapshot: cachedSnapshot,
84
+ });
85
+ await access.requireAccess("platform.tenants.manage");
28
86
  ```
87
+
88
+ Omit `getSubjectId` and the loader derives the cache-tag subject from the access
89
+ token, so per-user tagging cannot silently collapse to one shared tag.
90
+
91
+ ## Keys drift guard
92
+
93
+ `src/access/generated/keys.ts` is generated from the backend authz catalog
94
+ (`c1m-server/schemas/api/authz-keys.json`, produced by `make authzgen`) and
95
+ vendored to `contracts/authz-keys.json`. Never hand-edit it. Regenerate with
96
+ `node scripts/generate-access-types.mjs`; `pnpm contract:check` runs
97
+ `--check` and fails on drift, so a backend rename cannot silently diverge from
98
+ the frontend key unions.
99
+
100
+ ## Notes
101
+
102
+ - Import from `@c1m-public/platform-auth/next` (or `/access/next`) inside
103
+ Next.js server code — those entries carry the `server-only` guard.
104
+ - Session cookies stay host-local per surface; this package never shares a
105
+ session across origins. Crossing hosts is always a fresh handoff.
@@ -0,0 +1,13 @@
1
+ export type AccessErrorCode = "unauthenticated" | "forbidden" | "unavailable" | "invalid_snapshot";
2
+ /**
3
+ * Typed failure for loading or validating an access snapshot. `unauthenticated`
4
+ * = no/expired session (401); `forbidden` = the backend refused the snapshot
5
+ * (403; e.g. wrong audience); `unavailable` = transport/5xx; `invalid_snapshot`
6
+ * = the payload did not match the contract.
7
+ */
8
+ export declare class AccessSnapshotError extends Error {
9
+ readonly code: AccessErrorCode;
10
+ readonly status: number | undefined;
11
+ constructor(code: AccessErrorCode, message: string, status?: number);
12
+ }
13
+ export declare function isAccessSnapshotError(error: unknown, code?: AccessErrorCode): error is AccessSnapshotError;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Typed failure for loading or validating an access snapshot. `unauthenticated`
3
+ * = no/expired session (401); `forbidden` = the backend refused the snapshot
4
+ * (403; e.g. wrong audience); `unavailable` = transport/5xx; `invalid_snapshot`
5
+ * = the payload did not match the contract.
6
+ */
7
+ export class AccessSnapshotError extends Error {
8
+ code;
9
+ status;
10
+ constructor(code, message, status) {
11
+ super(message);
12
+ this.name = "AccessSnapshotError";
13
+ this.code = code;
14
+ this.status = status;
15
+ }
16
+ }
17
+ export function isAccessSnapshotError(error, code) {
18
+ return error instanceof AccessSnapshotError && (code === undefined || error.code === code);
19
+ }
@@ -0,0 +1,23 @@
1
+ import { type Resolvable } from "../internal.js";
2
+ import { type AccessSnapshot } from "./snapshot.js";
3
+ export declare const DEFAULT_ACCESS_ENDPOINT_PATH = "/api/v1/platform/access";
4
+ export interface FetchAccessSnapshotOptions {
5
+ /** Backend origin, e.g. https://api.example.com (no trailing slash needed). */
6
+ apiUrl: Resolvable;
7
+ /** Bearer access token for the platform surface. */
8
+ accessToken: string;
9
+ /** Defaults to /api/v1/platform/access. */
10
+ endpointPath?: string;
11
+ fetchTimeoutMs?: number;
12
+ fetchImpl?: typeof fetch;
13
+ /** Extra headers. Authorization and Accept are always set here. */
14
+ headers?: HeadersInit;
15
+ /** Incoming BFF request; its client identity (IP, geo, UA) is forwarded to the backend. */
16
+ request?: Request;
17
+ }
18
+ /**
19
+ * Framework-neutral fetch of the resolved access snapshot. Throws
20
+ * AccessSnapshotError: 401 → unauthenticated, 403 → forbidden, other non-2xx or
21
+ * network failure → unavailable, malformed body → invalid_snapshot.
22
+ */
23
+ export declare function fetchAccessSnapshot(opts: FetchAccessSnapshotOptions): Promise<AccessSnapshot>;
@@ -0,0 +1,43 @@
1
+ import { forwardedRequestHeaders } from "../forwarding.js";
2
+ import { resolveValue, timedFetch } from "../internal.js";
3
+ import { AccessSnapshotError } from "./errors.js";
4
+ import { parseAccessSnapshot } from "./snapshot.js";
5
+ export const DEFAULT_ACCESS_ENDPOINT_PATH = "/api/v1/platform/access";
6
+ /**
7
+ * Framework-neutral fetch of the resolved access snapshot. Throws
8
+ * AccessSnapshotError: 401 → unauthenticated, 403 → forbidden, other non-2xx or
9
+ * network failure → unavailable, malformed body → invalid_snapshot.
10
+ */
11
+ export async function fetchAccessSnapshot(opts) {
12
+ const url = `${resolveValue(opts.apiUrl).replace(/\/$/, "")}${opts.endpointPath ?? DEFAULT_ACCESS_ENDPOINT_PATH}`;
13
+ const headers = opts.request
14
+ ? forwardedRequestHeaders(opts.request, opts.headers)
15
+ : new Headers(opts.headers);
16
+ headers.set("Authorization", `Bearer ${opts.accessToken}`);
17
+ headers.set("Accept", "application/json");
18
+ let response;
19
+ try {
20
+ response = await timedFetch(url, {
21
+ init: { method: "GET", headers },
22
+ timeoutMs: opts.fetchTimeoutMs,
23
+ fetchImpl: opts.fetchImpl,
24
+ });
25
+ }
26
+ catch (error) {
27
+ throw new AccessSnapshotError("unavailable", `access snapshot request failed: ${error.message}`);
28
+ }
29
+ if (response.status === 401)
30
+ throw new AccessSnapshotError("unauthenticated", "session expired", 401);
31
+ if (response.status === 403)
32
+ throw new AccessSnapshotError("forbidden", "access snapshot refused", 403);
33
+ if (!response.ok)
34
+ throw new AccessSnapshotError("unavailable", `access snapshot returned ${response.status}`, response.status);
35
+ let body;
36
+ try {
37
+ body = await response.json();
38
+ }
39
+ catch {
40
+ throw new AccessSnapshotError("invalid_snapshot", "access snapshot body is not JSON", response.status);
41
+ }
42
+ return parseAccessSnapshot(body);
43
+ }
@@ -0,0 +1,14 @@
1
+ /** Platform-operator permission keys. */
2
+ export declare const PLATFORM_PERMISSIONS: readonly ["platform.admin.view", "platform.audit.view", "platform.auth.manage", "platform.billing.manage", "platform.channels.manage", "platform.channels.view", "platform.config.manage", "platform.docs.manage", "platform.docs.view", "platform.observability.manage", "platform.observability.view", "platform.ops.manage", "platform.support.impersonate", "platform.support.sessions", "platform.tenants.archive", "platform.tenants.manage", "platform.tenants.restore", "platform.users.deactivate", "platform.users.delete", "platform.users.invite", "platform.users.manage_access", "platform.users.reactivate", "platform.users.view"];
3
+ export type PlatformPermissionKey = (typeof PLATFORM_PERMISSIONS)[number];
4
+ /** Lead-broker workload permission keys. */
5
+ export declare const LEADBROKER_PERMISSIONS: readonly ["leadbroker.finance.manage", "leadbroker.leads.dispatch", "leadbroker.leads.review", "leadbroker.leads.view", "leadbroker.policy.manage"];
6
+ export type LeadBrokerPermissionKey = (typeof LEADBROKER_PERMISSIONS)[number];
7
+ /** Workspace-staff permission keys. */
8
+ export declare const WORKSPACE_PERMISSIONS: readonly ["workspace.channels.manage", "workspace.channels.secrets", "workspace.channels.view", "workspace.crm.manage", "workspace.crm.view", "workspace.customers.manage", "workspace.customers.view", "workspace.orders.manage", "workspace.orders.view", "workspace.payments.record", "workspace.site.edit", "workspace.site.view", "workspace.staff.manage"];
9
+ export type WorkspacePermissionKey = (typeof WORKSPACE_PERMISSIONS)[number];
10
+ /** Assignable workspace-staff role bundles (grant keys at workspace scope). */
11
+ export declare const WORKSPACE_ROLES: readonly ["workspace.editor", "workspace.frontdesk", "workspace.manager"];
12
+ export type WorkspaceRole = (typeof WORKSPACE_ROLES)[number];
13
+ /** Every permission key the backend can grant, across all scopes. */
14
+ export type PermissionKey = PlatformPermissionKey | LeadBrokerPermissionKey | WorkspacePermissionKey;
@@ -0,0 +1,58 @@
1
+ // GENERATED by `node scripts/generate-access-types.mjs` from contracts/authz-keys.json
2
+ // (vendored from c1m-server/schemas/api/authz-keys.json). Do not edit by hand.
3
+ /** Platform-operator permission keys. */
4
+ export const PLATFORM_PERMISSIONS = [
5
+ "platform.admin.view",
6
+ "platform.audit.view",
7
+ "platform.auth.manage",
8
+ "platform.billing.manage",
9
+ "platform.channels.manage",
10
+ "platform.channels.view",
11
+ "platform.config.manage",
12
+ "platform.docs.manage",
13
+ "platform.docs.view",
14
+ "platform.observability.manage",
15
+ "platform.observability.view",
16
+ "platform.ops.manage",
17
+ "platform.support.impersonate",
18
+ "platform.support.sessions",
19
+ "platform.tenants.archive",
20
+ "platform.tenants.manage",
21
+ "platform.tenants.restore",
22
+ "platform.users.deactivate",
23
+ "platform.users.delete",
24
+ "platform.users.invite",
25
+ "platform.users.manage_access",
26
+ "platform.users.reactivate",
27
+ "platform.users.view",
28
+ ];
29
+ /** Lead-broker workload permission keys. */
30
+ export const LEADBROKER_PERMISSIONS = [
31
+ "leadbroker.finance.manage",
32
+ "leadbroker.leads.dispatch",
33
+ "leadbroker.leads.review",
34
+ "leadbroker.leads.view",
35
+ "leadbroker.policy.manage",
36
+ ];
37
+ /** Workspace-staff permission keys. */
38
+ export const WORKSPACE_PERMISSIONS = [
39
+ "workspace.channels.manage",
40
+ "workspace.channels.secrets",
41
+ "workspace.channels.view",
42
+ "workspace.crm.manage",
43
+ "workspace.crm.view",
44
+ "workspace.customers.manage",
45
+ "workspace.customers.view",
46
+ "workspace.orders.manage",
47
+ "workspace.orders.view",
48
+ "workspace.payments.record",
49
+ "workspace.site.edit",
50
+ "workspace.site.view",
51
+ "workspace.staff.manage",
52
+ ];
53
+ /** Assignable workspace-staff role bundles (grant keys at workspace scope). */
54
+ export const WORKSPACE_ROLES = [
55
+ "workspace.editor",
56
+ "workspace.frontdesk",
57
+ "workspace.manager",
58
+ ];
@@ -0,0 +1,5 @@
1
+ export * from "./generated/keys.js";
2
+ export * from "./errors.js";
3
+ export * from "./snapshot.js";
4
+ export * from "./predicates.js";
5
+ export * from "./fetch.js";
@@ -0,0 +1,5 @@
1
+ export * from "./generated/keys.js";
2
+ export * from "./errors.js";
3
+ export * from "./snapshot.js";
4
+ export * from "./predicates.js";
5
+ export * from "./fetch.js";
@@ -0,0 +1,51 @@
1
+ import "server-only";
2
+ import { type Resolvable } from "../internal.js";
3
+ import { type PermissionCheckOptions } from "./predicates.js";
4
+ import type { AccessSnapshot } from "./snapshot.js";
5
+ export * from "./index.js";
6
+ /** Default cache tag for one subject's snapshot: `access:<subjectId>`. */
7
+ export declare function accessCacheTag(subjectId: string): string;
8
+ export interface CreateAccessLoaderOptions {
9
+ apiUrl: Resolvable;
10
+ endpointPath?: string;
11
+ /** Resolve the current request's bearer token (usually from the httpOnly session cookie). null = no session. */
12
+ getAccessToken: () => Promise<string | null>;
13
+ /**
14
+ * Subject id for the cache tag when `fetchSnapshot` is supplied. Defaults to
15
+ * decoding the access token, so a consumer that forgets this still tags
16
+ * per-subject rather than sharing one tag across users.
17
+ */
18
+ getSubjectId?: () => Promise<string | null>;
19
+ fetchTimeoutMs?: number;
20
+ fetchImpl?: typeof fetch;
21
+ headers?: () => Promise<HeadersInit | undefined> | HeadersInit | undefined;
22
+ /**
23
+ * Optional cross-request cache seam. When provided, the loader calls this
24
+ * instead of fetching directly; the consumer wraps `fetchAccessSnapshot` in
25
+ * a `"use cache: private"` function (see README "Pilot recipe") tagged with
26
+ * `accessCacheTag(subjectId)`. When omitted, the snapshot is deduped per
27
+ * request only (React `cache`).
28
+ */
29
+ fetchSnapshot?: (args: {
30
+ accessToken: string;
31
+ subjectId: string | null;
32
+ }) => Promise<AccessSnapshot>;
33
+ cacheTag?: (subjectId: string) => string;
34
+ }
35
+ export interface RequireAccessOptions extends PermissionCheckOptions {
36
+ /** "all" (default) requires every key; "any" requires at least one. */
37
+ mode?: "all" | "any";
38
+ }
39
+ export interface AccessLoader {
40
+ /** Snapshot for the current request. Throws AccessSnapshotError (never interrupts). */
41
+ getAccess(): Promise<AccessSnapshot>;
42
+ /** Snapshot or null when unauthenticated. Other failures still throw. */
43
+ getAccessOrNull(): Promise<AccessSnapshot | null>;
44
+ /** unauthenticated → unauthorized(); missing permission → forbidden(). */
45
+ requireAccess(key: string | readonly string[], opts?: RequireAccessOptions): Promise<AccessSnapshot>;
46
+ requireWorkspaceAccess(workspaceId: string, key: string): Promise<AccessSnapshot>;
47
+ /** Bust the cross-request cache for one subject (updateTag). No-op without cacheComponents. */
48
+ invalidateAccess(subjectId: string): void;
49
+ cacheTag(subjectId: string): string;
50
+ }
51
+ export declare function createAccessLoader(options: CreateAccessLoaderOptions): AccessLoader;
@@ -0,0 +1,86 @@
1
+ // Server-only entry for Next.js. The root entry is framework-neutral; this one
2
+ // adds the request-scoped loader (React cache), auth interrupts
3
+ // (forbidden/unauthorized) and tag invalidation (updateTag).
4
+ import "server-only";
5
+ import { cache } from "react";
6
+ import { forbidden, unauthorized } from "next/navigation";
7
+ import { updateTag } from "next/cache";
8
+ import { resolveValue } from "../internal.js";
9
+ import { decodeTokenPayload } from "../token.js";
10
+ import { AccessSnapshotError, isAccessSnapshotError } from "./errors.js";
11
+ import { fetchAccessSnapshot } from "./fetch.js";
12
+ import { hasAllPermissions, hasAnyPermission, hasWorkspacePermission } from "./predicates.js";
13
+ export * from "./index.js";
14
+ /** Default cache tag for one subject's snapshot: `access:<subjectId>`. */
15
+ export function accessCacheTag(subjectId) {
16
+ return `access:${subjectId}`;
17
+ }
18
+ export function createAccessLoader(options) {
19
+ const tag = options.cacheTag ?? accessCacheTag;
20
+ const subjectIdOf = (accessToken) => {
21
+ const claims = decodeTokenPayload(accessToken);
22
+ const subject = claims?.actor_id ?? claims?.user_id ?? claims?.customer_id ?? claims?.sub;
23
+ return typeof subject === "string" && subject.length > 0 ? subject : null;
24
+ };
25
+ const load = cache(async () => {
26
+ const accessToken = await options.getAccessToken();
27
+ if (!accessToken)
28
+ throw new AccessSnapshotError("unauthenticated", "no session", 401);
29
+ if (options.fetchSnapshot) {
30
+ const subjectId = options.getSubjectId ? await options.getSubjectId() : subjectIdOf(accessToken);
31
+ return options.fetchSnapshot({ accessToken, subjectId });
32
+ }
33
+ const headers = typeof options.headers === "function" ? await options.headers() : options.headers;
34
+ return fetchAccessSnapshot({
35
+ apiUrl: resolveValue(options.apiUrl),
36
+ accessToken,
37
+ endpointPath: options.endpointPath,
38
+ fetchTimeoutMs: options.fetchTimeoutMs,
39
+ fetchImpl: options.fetchImpl,
40
+ headers,
41
+ });
42
+ });
43
+ async function getAccess() {
44
+ return load();
45
+ }
46
+ async function getAccessOrNull() {
47
+ try {
48
+ return await load();
49
+ }
50
+ catch (error) {
51
+ if (isAccessSnapshotError(error, "unauthenticated"))
52
+ return null;
53
+ throw error;
54
+ }
55
+ }
56
+ async function loadOrInterrupt() {
57
+ try {
58
+ return await load();
59
+ }
60
+ catch (error) {
61
+ if (isAccessSnapshotError(error, "unauthenticated"))
62
+ unauthorized();
63
+ if (isAccessSnapshotError(error, "forbidden"))
64
+ forbidden();
65
+ throw error;
66
+ }
67
+ }
68
+ async function requireAccess(key, opts = {}) {
69
+ const snapshot = await loadOrInterrupt();
70
+ const keys = typeof key === "string" ? [key] : key;
71
+ const ok = opts.mode === "any" ? hasAnyPermission(snapshot, keys, opts) : hasAllPermissions(snapshot, keys, opts);
72
+ if (!ok)
73
+ forbidden();
74
+ return snapshot;
75
+ }
76
+ async function requireWorkspaceAccess(workspaceId, key) {
77
+ const snapshot = await loadOrInterrupt();
78
+ if (!hasWorkspacePermission(snapshot, workspaceId, key))
79
+ forbidden();
80
+ return snapshot;
81
+ }
82
+ function invalidateAccess(subjectId) {
83
+ updateTag(tag(subjectId));
84
+ }
85
+ return { getAccess, getAccessOrNull, requireAccess, requireWorkspaceAccess, invalidateAccess, cacheTag: tag };
86
+ }
@@ -0,0 +1,36 @@
1
+ import type { AccessEntry, AccessSnapshot } from "./snapshot.js";
2
+ export interface PermissionCheckOptions {
3
+ /**
4
+ * SaaS app (dash) slug the check is about. Omit for "anywhere": platform
5
+ * entries and any saas_app entry satisfy the check.
6
+ */
7
+ saasApp?: string;
8
+ }
9
+ /**
10
+ * Mirrors c1m-server authz.entryCoversApp: platform scope covers every app;
11
+ * saas_app scope covers "*" or a case-insensitive slug match; workspace-scoped
12
+ * entries never satisfy an app-level check.
13
+ */
14
+ export declare function entryCoversApp(entry: AccessEntry, saasApp: string | undefined): boolean;
15
+ /** True when the subject is active and holds `key` for the given app (or anywhere). */
16
+ export declare function hasPermission(snapshot: AccessSnapshot, key: string, opts?: PermissionCheckOptions): boolean;
17
+ export declare function hasAnyPermission(snapshot: AccessSnapshot, keys: readonly string[], opts?: PermissionCheckOptions): boolean;
18
+ export declare function hasAllPermissions(snapshot: AccessSnapshot, keys: readonly string[], opts?: PermissionCheckOptions): boolean;
19
+ /**
20
+ * Workspace-staff check. Platform owners always pass (they own every
21
+ * workspace); otherwise the workspace's effective permission list decides.
22
+ * Platform/saas_app entries never confer workspace-staff permissions.
23
+ */
24
+ export declare function hasWorkspacePermission(snapshot: AccessSnapshot, workspaceId: string, key: string): boolean;
25
+ export declare function isPlatformOwner(snapshot: AccessSnapshot): boolean;
26
+ /**
27
+ * The saas_app scopes under which `key` is held. Returns `{ all: true }` when a
28
+ * platform-scoped or "*" entry grants it everywhere; otherwise the explicit
29
+ * app slugs (empty = nowhere). Mirrors authz.AppPermissionScope.
30
+ */
31
+ export declare function appScopes(snapshot: AccessSnapshot, key: string): {
32
+ all: boolean;
33
+ apps: string[];
34
+ };
35
+ /** Distinct permission keys held for `saasApp` (or anywhere). Handy for nav/badges. */
36
+ export declare function permissionsFor(snapshot: AccessSnapshot, opts?: PermissionCheckOptions): string[];
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Mirrors c1m-server authz.entryCoversApp: platform scope covers every app;
3
+ * saas_app scope covers "*" or a case-insensitive slug match; workspace-scoped
4
+ * entries never satisfy an app-level check.
5
+ */
6
+ export function entryCoversApp(entry, saasApp) {
7
+ switch (entry.scope_type) {
8
+ case "platform":
9
+ return true;
10
+ case "saas_app":
11
+ if (saasApp === undefined)
12
+ return true;
13
+ return entry.scope_id === "*" || entry.scope_id.toLowerCase() === saasApp.toLowerCase();
14
+ default:
15
+ return false;
16
+ }
17
+ }
18
+ /** True when the subject is active and holds `key` for the given app (or anywhere). */
19
+ export function hasPermission(snapshot, key, opts = {}) {
20
+ if (!snapshot.active)
21
+ return false;
22
+ return snapshot.permissions.some((entry) => entry.key === key && entryCoversApp(entry, opts.saasApp));
23
+ }
24
+ export function hasAnyPermission(snapshot, keys, opts = {}) {
25
+ return keys.some((key) => hasPermission(snapshot, key, opts));
26
+ }
27
+ export function hasAllPermissions(snapshot, keys, opts = {}) {
28
+ return keys.every((key) => hasPermission(snapshot, key, opts));
29
+ }
30
+ /**
31
+ * Workspace-staff check. Platform owners always pass (they own every
32
+ * workspace); otherwise the workspace's effective permission list decides.
33
+ * Platform/saas_app entries never confer workspace-staff permissions.
34
+ */
35
+ export function hasWorkspacePermission(snapshot, workspaceId, key) {
36
+ if (!snapshot.active)
37
+ return false;
38
+ if (snapshot.is_owner)
39
+ return true;
40
+ return (snapshot.workspace_permissions[workspaceId] ?? []).includes(key);
41
+ }
42
+ export function isPlatformOwner(snapshot) {
43
+ return snapshot.active && snapshot.is_owner;
44
+ }
45
+ /**
46
+ * The saas_app scopes under which `key` is held. Returns `{ all: true }` when a
47
+ * platform-scoped or "*" entry grants it everywhere; otherwise the explicit
48
+ * app slugs (empty = nowhere). Mirrors authz.AppPermissionScope.
49
+ */
50
+ export function appScopes(snapshot, key) {
51
+ if (!snapshot.active)
52
+ return { all: false, apps: [] };
53
+ const apps = new Set();
54
+ for (const entry of snapshot.permissions) {
55
+ if (entry.key !== key)
56
+ continue;
57
+ if (entry.scope_type === "platform")
58
+ return { all: true, apps: [] };
59
+ if (entry.scope_type === "saas_app") {
60
+ if (entry.scope_id === "*")
61
+ return { all: true, apps: [] };
62
+ apps.add(entry.scope_id.toLowerCase());
63
+ }
64
+ }
65
+ return { all: false, apps: [...apps].sort() };
66
+ }
67
+ /** Distinct permission keys held for `saasApp` (or anywhere). Handy for nav/badges. */
68
+ export function permissionsFor(snapshot, opts = {}) {
69
+ if (!snapshot.active)
70
+ return [];
71
+ const keys = new Set();
72
+ for (const entry of snapshot.permissions)
73
+ if (entryCoversApp(entry, opts.saasApp))
74
+ keys.add(entry.key);
75
+ return [...keys].sort();
76
+ }
@@ -0,0 +1,28 @@
1
+ import { type ReactNode } from "react";
2
+ import { type PermissionCheckOptions } from "./predicates.js";
3
+ import { type AccessSnapshot } from "./snapshot.js";
4
+ export interface AccessProviderProps {
5
+ snapshot: AccessSnapshot;
6
+ children: ReactNode;
7
+ }
8
+ /** Feed the server-loaded snapshot to client components once, at the app/area layout. */
9
+ export declare function AccessProvider({ snapshot, children }: AccessProviderProps): import("react").JSX.Element;
10
+ export declare function useAccess(): AccessSnapshot;
11
+ /** Optional-provider variant: returns an inactive empty snapshot outside a provider. */
12
+ export declare function useAccessOrEmpty(): AccessSnapshot;
13
+ export declare function useCan(): (key: string, opts?: PermissionCheckOptions) => boolean;
14
+ export declare function useCanWorkspace(workspaceId: string): (key: string) => boolean;
15
+ export declare function useIsPlatformOwner(): boolean;
16
+ export interface CanProps extends PermissionCheckOptions {
17
+ /** One key, or several — all must hold unless `mode="any"`. */
18
+ permission: string | readonly string[];
19
+ mode?: "all" | "any";
20
+ /** Workspace-staff check instead of platform/app check. */
21
+ workspaceId?: string;
22
+ /** Invert: render children when the check FAILS. */
23
+ not?: boolean;
24
+ fallback?: ReactNode;
25
+ children: ReactNode;
26
+ }
27
+ /** Render-gate. Presentation only — the backend remains the security boundary. */
28
+ export declare function Can({ permission, mode, workspaceId, saasApp, not, fallback, children }: CanProps): import("react").JSX.Element;
@@ -0,0 +1,40 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { createContext, useContext, useMemo } from "react";
4
+ import { hasPermission, hasWorkspacePermission, isPlatformOwner } from "./predicates.js";
5
+ import { emptySnapshot } from "./snapshot.js";
6
+ const AccessContext = createContext(null);
7
+ /** Feed the server-loaded snapshot to client components once, at the app/area layout. */
8
+ export function AccessProvider({ snapshot, children }) {
9
+ const value = useMemo(() => snapshot, [snapshot]);
10
+ return _jsx(AccessContext.Provider, { value: value, children: children });
11
+ }
12
+ export function useAccess() {
13
+ const snapshot = useContext(AccessContext);
14
+ if (!snapshot)
15
+ throw new Error("useAccess must be used inside <AccessProvider>");
16
+ return snapshot;
17
+ }
18
+ /** Optional-provider variant: returns an inactive empty snapshot outside a provider. */
19
+ export function useAccessOrEmpty() {
20
+ return useContext(AccessContext) ?? emptySnapshot();
21
+ }
22
+ export function useCan() {
23
+ const snapshot = useAccess();
24
+ return useMemo(() => (key, opts) => hasPermission(snapshot, key, opts), [snapshot]);
25
+ }
26
+ export function useCanWorkspace(workspaceId) {
27
+ const snapshot = useAccess();
28
+ return useMemo(() => (key) => hasWorkspacePermission(snapshot, workspaceId, key), [snapshot, workspaceId]);
29
+ }
30
+ export function useIsPlatformOwner() {
31
+ return isPlatformOwner(useAccess());
32
+ }
33
+ /** Render-gate. Presentation only — the backend remains the security boundary. */
34
+ export function Can({ permission, mode = "all", workspaceId, saasApp, not = false, fallback = null, children }) {
35
+ const snapshot = useAccess();
36
+ const keys = typeof permission === "string" ? [permission] : permission;
37
+ const check = (key) => workspaceId !== undefined ? hasWorkspacePermission(snapshot, workspaceId, key) : hasPermission(snapshot, key, { saasApp });
38
+ const allowed = mode === "any" ? keys.some(check) : keys.every(check);
39
+ return _jsx(_Fragment, { children: allowed !== not ? children : fallback });
40
+ }