@c1m-public/platform-auth 0.6.0 → 0.7.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.
Files changed (47) hide show
  1. package/README.md +27 -0
  2. package/dist/access/index.d.ts +148 -5
  3. package/dist/access/index.js +2 -5
  4. package/dist/access/next.d.ts +11 -10
  5. package/dist/access/next.js +70 -81
  6. package/dist/access/react.d.ts +59 -12
  7. package/dist/access/react.js +84 -37
  8. package/dist/chunk-FBC2NUH7.js +83 -0
  9. package/dist/chunk-RR5DP3QT.js +261 -0
  10. package/dist/chunk-RVKIDFXM.js +44 -0
  11. package/dist/chunk-SMBKTB5V.js +389 -0
  12. package/dist/index.d.ts +220 -9
  13. package/dist/index.js +3 -9
  14. package/dist/internal-rnEuAE21.d.ts +4 -0
  15. package/dist/next.d.ts +168 -2
  16. package/dist/next.js +306 -3
  17. package/package.json +6 -4
  18. package/dist/access/errors.d.ts +0 -13
  19. package/dist/access/errors.js +0 -19
  20. package/dist/access/fetch.d.ts +0 -23
  21. package/dist/access/fetch.js +0 -43
  22. package/dist/access/generated/keys.d.ts +0 -20
  23. package/dist/access/generated/keys.js +0 -87
  24. package/dist/access/predicates.d.ts +0 -48
  25. package/dist/access/predicates.js +0 -95
  26. package/dist/access/snapshot.d.ts +0 -39
  27. package/dist/access/snapshot.js +0 -92
  28. package/dist/cookies.d.ts +0 -11
  29. package/dist/cookies.js +0 -42
  30. package/dist/device-cookie.d.ts +0 -18
  31. package/dist/device-cookie.js +0 -22
  32. package/dist/forwarding.d.ts +0 -2
  33. package/dist/forwarding.js +0 -32
  34. package/dist/handoff-errors.d.ts +0 -13
  35. package/dist/handoff-errors.js +0 -65
  36. package/dist/handoff.d.ts +0 -55
  37. package/dist/handoff.js +0 -195
  38. package/dist/internal.d.ts +0 -16
  39. package/dist/internal.js +0 -27
  40. package/dist/return-path.d.ts +0 -6
  41. package/dist/return-path.js +0 -33
  42. package/dist/session-gate.d.ts +0 -26
  43. package/dist/session-gate.js +0 -33
  44. package/dist/session-refresh.d.ts +0 -24
  45. package/dist/session-refresh.js +0 -62
  46. package/dist/token.d.ts +0 -51
  47. package/dist/token.js +0 -104
package/README.md CHANGED
@@ -56,6 +56,33 @@ const source = createHandoffSource({ apiUrl, clientType, targets: { docs: { orig
56
56
  getAccessToken, loginPath: "/login" });
57
57
  ```
58
58
 
59
+ ## `./next` — a Next.js surface's session, fetch and errors
60
+
61
+ Server-only. Everything under `.` plus the three things every first-party app
62
+ used to hand-roll:
63
+
64
+ | Export | Purpose |
65
+ |---|---|
66
+ | `createSessionStore({ audience, cookies, maxAgeSeconds?, secure?, cookieDomain?, defaultRememberMe?, refresh?, requestBoundary?, onError? })` | The session/refresh/remember cookies over `next/headers`: `createSession`, `writeSessionCookies(response, …)`, `getSession`, `getAccessToken`, `getRefreshToken`, `getSessionWithRefresh`, `updateAccessToken`, `isRememberMe`, `destroySession`, `clearSessionCookies(response)`, `requireSession`, `isAuthenticated`. rememberMe=false → no Max-Age; the remember choice survives refresh; deletes carry the same attributes as sets (a `__Host-` cookie ignores a Secure-less delete); a token for another audience is never stored or read. Pass `requestBoundary: connection` (from `next/server`) so Cache Components treat session state as request-time. |
67
+ | `createPlatformFetch({ apiUrl, getAccessToken, forwardHeaders?, timeoutMs?, fetch? })` | Bearer + forwarded client headers + `no-store` + per-method timeout (10 s reads, 30 s writes): `fetchAuthed`, `fetchAuthedJson` (null on 204/404), `fetchAuthedJsonStrict`, `fetchJsonWithAccessToken`, `fetchPublicJson`, `fetchAuthedField`, `fetchAuthedItems`. A missing session **throws** (401), never returns null. |
68
+ | `buildForwardedRequestHeaders` | The incoming request's client identity as headers, for a call you make yourself. |
69
+ | `SessionExpiredError`, `RecentAuthenticationRequiredError`, `SSOReauthenticationRequiredError`, `PlatformServerFetchError{path,status?,code?,requestId?}` + `is*Error` predicates | One error vocabulary; 401 bodies map by `code`. |
70
+ | `mapControlError`, `controlRequest(run)` | Translate a `@c1m-public/control-client` failure into that vocabulary, keeping the backend's code and request id. |
71
+
72
+ ```ts
73
+ // lib/auth/session.ts
74
+ export const sessions = createSessionStore({
75
+ audience: "platform_admin",
76
+ cookies: { session: "__Host-platform_session", refreshToken: "__Host-platform_refresh_token", rememberMe: "platform_remember" },
77
+ requestBoundary: connection,
78
+ refresh: { apiUrl: getApiUrl, refreshPath: "/api/v1/platform/auth/refresh", clientType: "nextjs-platform" },
79
+ });
80
+
81
+ // lib/platform/fetch.ts
82
+ export const platform = createPlatformFetch({ apiUrl: getApiUrl, getAccessToken: sessions.getAccessToken });
83
+ const items = await platform.fetchAuthedItems<Workspace>("/api/v1/platform/workspaces");
84
+ ```
85
+
59
86
  ## `./access` — authorization
60
87
 
61
88
  The resolved snapshot from `GET /api/v1/platform/access`.
@@ -1,5 +1,148 @@
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";
1
+ import { R as Resolvable } from '../internal-rnEuAE21.js';
2
+
3
+ /** Platform-operator permission keys. */
4
+ declare const PLATFORM_PERMISSIONS: readonly ["affiliates.finance.manage", "affiliates.partners.manage", "affiliates.programmes.manage", "affiliates.view", "linkservice.domains.manage", "linkservice.links.manage", "linkservice.qr.manage", "linkservice.view", "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.offboarding.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"];
5
+ type PlatformPermissionKey = (typeof PLATFORM_PERMISSIONS)[number];
6
+ /** Dash-owner permission keys (granted at dash scope, mirrored from the membership role). */
7
+ declare const DASH_PERMISSIONS: readonly ["dash.apps.manage", "dash.billing.manage", "dash.channels.manage", "dash.customers.manage", "dash.domains.manage", "dash.members.manage", "dash.ownership.transfer", "dash.plans.manage", "dash.publish", "dash.settings.manage", "dash.workspaces.admin", "dash.workspaces.manage"];
8
+ type DashPermissionKey = (typeof DASH_PERMISSIONS)[number];
9
+ /** Membership-mirrored dash role bundles (grant keys at dash scope). */
10
+ declare const DASH_ROLES: readonly ["dash.admin", "dash.owner"];
11
+ type DashRole = (typeof DASH_ROLES)[number];
12
+ /** Lead-broker workload permission keys. */
13
+ declare const LEADBROKER_PERMISSIONS: readonly ["leadbroker.finance.manage", "leadbroker.leads.dispatch", "leadbroker.leads.review", "leadbroker.leads.view", "leadbroker.policy.manage"];
14
+ type LeadBrokerPermissionKey = (typeof LEADBROKER_PERMISSIONS)[number];
15
+ /** Workspace-staff permission keys. */
16
+ 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"];
17
+ type WorkspacePermissionKey = (typeof WORKSPACE_PERMISSIONS)[number];
18
+ /** Assignable workspace-staff role bundles (grant keys at workspace scope). */
19
+ declare const WORKSPACE_ROLES: readonly ["workspace.editor", "workspace.frontdesk", "workspace.manager"];
20
+ type WorkspaceRole = (typeof WORKSPACE_ROLES)[number];
21
+ /** Every permission key the backend can grant, across all scopes. */
22
+ type PermissionKey = PlatformPermissionKey | DashPermissionKey | LeadBrokerPermissionKey | WorkspacePermissionKey;
23
+
24
+ type AccessErrorCode = "unauthenticated" | "forbidden" | "unavailable" | "invalid_snapshot";
25
+ /**
26
+ * Typed failure for loading or validating an access snapshot. `unauthenticated`
27
+ * = no/expired session (401); `forbidden` = the backend refused the snapshot
28
+ * (403; e.g. wrong audience); `unavailable` = transport/5xx; `invalid_snapshot`
29
+ * = the payload did not match the contract.
30
+ */
31
+ declare class AccessSnapshotError extends Error {
32
+ readonly code: AccessErrorCode;
33
+ readonly status: number | undefined;
34
+ constructor(code: AccessErrorCode, message: string, status?: number);
35
+ }
36
+ declare function isAccessSnapshotError(error: unknown, code?: AccessErrorCode): error is AccessSnapshotError;
37
+
38
+ /** Scope a permission entry applies to. Mirrors c1m-server authz.Scope*. */
39
+ type AccessScopeType = "platform" | "saas_app" | "workspace";
40
+ interface AccessEntry {
41
+ /** Permission key, e.g. "platform.users.manage_access". */
42
+ key: string;
43
+ scope_type: AccessScopeType;
44
+ /** "" for platform scope; saas_app slug or "*" for saas_app scope; workspace id for workspace scope. */
45
+ scope_id: string;
46
+ }
47
+ /**
48
+ * The resolved authorization snapshot for one subject, as returned by
49
+ * `GET /api/v1/platform/access`. This is the *output* of the backend resolver
50
+ * (base-role grant ∪ scoped grants), never the grant rows themselves.
51
+ */
52
+ interface AccessSnapshot {
53
+ subject_id: string;
54
+ /** Monotonic; bumps on any grant/role change. Use for optimistic concurrency and cache keys. */
55
+ access_version: number;
56
+ is_owner: boolean;
57
+ active: boolean;
58
+ /** Platform operator base role (derived; display only — never authorize on it). */
59
+ platform_role: string;
60
+ permissions: AccessEntry[];
61
+ /**
62
+ * Dash bundle (`dash.owner` / `dash.admin`) per saas_app id, mirrored from
63
+ * the subject's dash membership. This is the admission list for the dash
64
+ * area: the dashes this subject runs. What they may *do* on one is a normal
65
+ * `hasPermission(snapshot, key, { saasApp })` check — platform managers hold
66
+ * their authority platform-wide and so appear here not at all.
67
+ */
68
+ dash_roles: Record<string, string>;
69
+ /** Effective workspace-staff permission keys per workspace id. */
70
+ workspace_permissions: Record<string, string[]>;
71
+ /** Workspace-staff role bundle per workspace id (display only). */
72
+ workspace_roles: Record<string, string>;
73
+ }
74
+ declare function emptySnapshot(subjectId?: string): AccessSnapshot;
75
+ /** Runtime validator for the wire payload. Throws AccessSnapshotError("invalid_snapshot"). */
76
+ declare function parseAccessSnapshot(input: unknown): AccessSnapshot;
77
+
78
+ interface PermissionCheckOptions {
79
+ /**
80
+ * SaaS app (dash) slug the check is about. Omit for "anywhere": platform
81
+ * entries and any saas_app entry satisfy the check.
82
+ */
83
+ saasApp?: string;
84
+ }
85
+ /**
86
+ * Mirrors c1m-server authz.entryCoversApp: platform scope covers every app;
87
+ * saas_app scope covers "*" or a case-insensitive slug match; workspace-scoped
88
+ * entries never satisfy an app-level check.
89
+ */
90
+ declare function entryCoversApp(entry: AccessEntry, saasApp: string | undefined): boolean;
91
+ /** True when the subject is active and holds `key` for the given app (or anywhere). */
92
+ declare function hasPermission(snapshot: AccessSnapshot, key: string, opts?: PermissionCheckOptions): boolean;
93
+ declare function hasAnyPermission(snapshot: AccessSnapshot, keys: readonly string[], opts?: PermissionCheckOptions): boolean;
94
+ declare function hasAllPermissions(snapshot: AccessSnapshot, keys: readonly string[], opts?: PermissionCheckOptions): boolean;
95
+ /**
96
+ * Workspace-staff check. Platform owners always pass (they own every
97
+ * workspace); otherwise the workspace's effective permission list decides.
98
+ * Platform/saas_app entries never confer workspace-staff permissions.
99
+ */
100
+ declare function hasWorkspacePermission(snapshot: AccessSnapshot, workspaceId: string, key: string): boolean;
101
+ /**
102
+ * The dashes this subject runs, from the mirrored dash bundle grants — the
103
+ * admission list for the Admin Center's dash area. Platform managers are not
104
+ * listed: their reach is platform-wide, so admit them with
105
+ * `hasPermission(snapshot, "platform.tenants.manage")` instead.
106
+ */
107
+ declare function managedDashes(snapshot: AccessSnapshot): Array<{
108
+ saasApp: string;
109
+ role: string;
110
+ }>;
111
+ /** The dash bundle held on one dash, or "" when the subject holds none there. */
112
+ declare function dashRole(snapshot: AccessSnapshot, saasApp: string): string;
113
+ declare function isPlatformOwner(snapshot: AccessSnapshot): boolean;
114
+ /**
115
+ * The saas_app scopes under which `key` is held. Returns `{ all: true }` when a
116
+ * platform-scoped or "*" entry grants it everywhere; otherwise the explicit
117
+ * app slugs (empty = nowhere). Mirrors authz.AppPermissionScope.
118
+ */
119
+ declare function appScopes(snapshot: AccessSnapshot, key: string): {
120
+ all: boolean;
121
+ apps: string[];
122
+ };
123
+ /** Distinct permission keys held for `saasApp` (or anywhere). Handy for nav/badges. */
124
+ declare function permissionsFor(snapshot: AccessSnapshot, opts?: PermissionCheckOptions): string[];
125
+
126
+ declare const DEFAULT_ACCESS_ENDPOINT_PATH = "/api/v1/platform/access";
127
+ interface FetchAccessSnapshotOptions {
128
+ /** Backend origin, e.g. https://api.example.com (no trailing slash needed). */
129
+ apiUrl: Resolvable;
130
+ /** Bearer access token for the platform surface. */
131
+ accessToken: string;
132
+ /** Defaults to /api/v1/platform/access. */
133
+ endpointPath?: string;
134
+ fetchTimeoutMs?: number;
135
+ fetchImpl?: typeof fetch;
136
+ /** Extra headers. Authorization and Accept are always set here. */
137
+ headers?: HeadersInit;
138
+ /** Incoming BFF request; its client identity (IP, geo, UA) is forwarded to the backend. */
139
+ request?: Request;
140
+ }
141
+ /**
142
+ * Framework-neutral fetch of the resolved access snapshot. Throws
143
+ * AccessSnapshotError: 401 → unauthenticated, 403 → forbidden, other non-2xx or
144
+ * network failure → unavailable, malformed body → invalid_snapshot.
145
+ */
146
+ declare function fetchAccessSnapshot(opts: FetchAccessSnapshotOptions): Promise<AccessSnapshot>;
147
+
148
+ export { type AccessEntry, type AccessErrorCode, type AccessScopeType, type AccessSnapshot, AccessSnapshotError, DASH_PERMISSIONS, DASH_ROLES, DEFAULT_ACCESS_ENDPOINT_PATH, type DashPermissionKey, type DashRole, type FetchAccessSnapshotOptions, LEADBROKER_PERMISSIONS, type LeadBrokerPermissionKey, PLATFORM_PERMISSIONS, type PermissionCheckOptions, type PermissionKey, type PlatformPermissionKey, WORKSPACE_PERMISSIONS, WORKSPACE_ROLES, type WorkspacePermissionKey, type WorkspaceRole, appScopes, dashRole, emptySnapshot, entryCoversApp, fetchAccessSnapshot, hasAllPermissions, hasAnyPermission, hasPermission, hasWorkspacePermission, isAccessSnapshotError, isPlatformOwner, managedDashes, parseAccessSnapshot, permissionsFor };
@@ -1,5 +1,2 @@
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";
1
+ export { AccessSnapshotError, DASH_PERMISSIONS, DASH_ROLES, DEFAULT_ACCESS_ENDPOINT_PATH, LEADBROKER_PERMISSIONS, PLATFORM_PERMISSIONS, WORKSPACE_PERMISSIONS, WORKSPACE_ROLES, appScopes, dashRole, emptySnapshot, entryCoversApp, fetchAccessSnapshot, hasAllPermissions, hasAnyPermission, hasPermission, hasWorkspacePermission, isAccessSnapshotError, isPlatformOwner, managedDashes, parseAccessSnapshot, permissionsFor } from '../chunk-RR5DP3QT.js';
2
+ import '../chunk-RVKIDFXM.js';
@@ -1,11 +1,10 @@
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";
1
+ import { R as Resolvable } from '../internal-rnEuAE21.js';
2
+ import { AccessSnapshot, PermissionCheckOptions } from './index.js';
3
+ export { AccessEntry, AccessErrorCode, AccessScopeType, AccessSnapshotError, DASH_PERMISSIONS, DASH_ROLES, DEFAULT_ACCESS_ENDPOINT_PATH, DashPermissionKey, DashRole, FetchAccessSnapshotOptions, LEADBROKER_PERMISSIONS, LeadBrokerPermissionKey, PLATFORM_PERMISSIONS, PermissionKey, PlatformPermissionKey, WORKSPACE_PERMISSIONS, WORKSPACE_ROLES, WorkspacePermissionKey, WorkspaceRole, appScopes, dashRole, emptySnapshot, entryCoversApp, fetchAccessSnapshot, hasAllPermissions, hasAnyPermission, hasPermission, hasWorkspacePermission, isAccessSnapshotError, isPlatformOwner, managedDashes, parseAccessSnapshot, permissionsFor } from './index.js';
4
+
6
5
  /** Default cache tag for one subject's snapshot: `access:<subjectId>`. */
7
- export declare function accessCacheTag(subjectId: string): string;
8
- export interface CreateAccessLoaderOptions {
6
+ declare function accessCacheTag(subjectId: string): string;
7
+ interface CreateAccessLoaderOptions {
9
8
  apiUrl: Resolvable;
10
9
  endpointPath?: string;
11
10
  /** Resolve the current request's bearer token (usually from the httpOnly session cookie). null = no session. */
@@ -32,11 +31,11 @@ export interface CreateAccessLoaderOptions {
32
31
  }) => Promise<AccessSnapshot>;
33
32
  cacheTag?: (subjectId: string) => string;
34
33
  }
35
- export interface RequireAccessOptions extends PermissionCheckOptions {
34
+ interface RequireAccessOptions extends PermissionCheckOptions {
36
35
  /** "all" (default) requires every key; "any" requires at least one. */
37
36
  mode?: "all" | "any";
38
37
  }
39
- export interface AccessLoader {
38
+ interface AccessLoader {
40
39
  /** Snapshot for the current request. Throws AccessSnapshotError (never interrupts). */
41
40
  getAccess(): Promise<AccessSnapshot>;
42
41
  /** Snapshot or null when unauthenticated. Other failures still throw. */
@@ -48,4 +47,6 @@ export interface AccessLoader {
48
47
  invalidateAccess(subjectId: string): void;
49
48
  cacheTag(subjectId: string): string;
50
49
  }
51
- export declare function createAccessLoader(options: CreateAccessLoaderOptions): AccessLoader;
50
+ declare function createAccessLoader(options: CreateAccessLoaderOptions): AccessLoader;
51
+
52
+ export { type AccessLoader, AccessSnapshot, type CreateAccessLoaderOptions, PermissionCheckOptions, type RequireAccessOptions, accessCacheTag, createAccessLoader };
@@ -1,86 +1,75 @@
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}`;
1
+ import { decodeTokenPayload } from '../chunk-FBC2NUH7.js';
2
+ import { AccessSnapshotError, fetchAccessSnapshot, isAccessSnapshotError, hasAnyPermission, hasAllPermissions, hasWorkspacePermission } from '../chunk-RR5DP3QT.js';
3
+ export { AccessSnapshotError, DASH_PERMISSIONS, DASH_ROLES, DEFAULT_ACCESS_ENDPOINT_PATH, LEADBROKER_PERMISSIONS, PLATFORM_PERMISSIONS, WORKSPACE_PERMISSIONS, WORKSPACE_ROLES, appScopes, dashRole, emptySnapshot, entryCoversApp, fetchAccessSnapshot, hasAllPermissions, hasAnyPermission, hasPermission, hasWorkspacePermission, isAccessSnapshotError, isPlatformOwner, managedDashes, parseAccessSnapshot, permissionsFor } from '../chunk-RR5DP3QT.js';
4
+ import { resolveValue } from '../chunk-RVKIDFXM.js';
5
+ import 'server-only';
6
+ import { cache } from 'react';
7
+ import { forbidden, unauthorized } from 'next/navigation';
8
+ import { updateTag } from 'next/cache';
9
+
10
+ function accessCacheTag(subjectId) {
11
+ return `access:${subjectId}`;
17
12
  }
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
- }
13
+ function createAccessLoader(options) {
14
+ const tag = options.cacheTag ?? accessCacheTag;
15
+ const subjectIdOf = (accessToken) => {
16
+ const claims = decodeTokenPayload(accessToken);
17
+ const subject = claims?.actor_id ?? claims?.user_id ?? claims?.customer_id ?? claims?.sub;
18
+ return typeof subject === "string" && subject.length > 0 ? subject : null;
19
+ };
20
+ const load = cache(async () => {
21
+ const accessToken = await options.getAccessToken();
22
+ if (!accessToken) throw new AccessSnapshotError("unauthenticated", "no session", 401);
23
+ if (options.fetchSnapshot) {
24
+ const subjectId = options.getSubjectId ? await options.getSubjectId() : subjectIdOf(accessToken);
25
+ return options.fetchSnapshot({ accessToken, subjectId });
67
26
  }
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;
27
+ const headers = typeof options.headers === "function" ? await options.headers() : options.headers;
28
+ return fetchAccessSnapshot({
29
+ apiUrl: resolveValue(options.apiUrl),
30
+ accessToken,
31
+ endpointPath: options.endpointPath,
32
+ fetchTimeoutMs: options.fetchTimeoutMs,
33
+ fetchImpl: options.fetchImpl,
34
+ headers
35
+ });
36
+ });
37
+ async function getAccess() {
38
+ return load();
39
+ }
40
+ async function getAccessOrNull() {
41
+ try {
42
+ return await load();
43
+ } catch (error) {
44
+ if (isAccessSnapshotError(error, "unauthenticated")) return null;
45
+ throw error;
81
46
  }
82
- function invalidateAccess(subjectId) {
83
- updateTag(tag(subjectId));
47
+ }
48
+ async function loadOrInterrupt() {
49
+ try {
50
+ return await load();
51
+ } catch (error) {
52
+ if (isAccessSnapshotError(error, "unauthenticated")) unauthorized();
53
+ if (isAccessSnapshotError(error, "forbidden")) forbidden();
54
+ throw error;
84
55
  }
85
- return { getAccess, getAccessOrNull, requireAccess, requireWorkspaceAccess, invalidateAccess, cacheTag: tag };
56
+ }
57
+ async function requireAccess(key, opts = {}) {
58
+ const snapshot = await loadOrInterrupt();
59
+ const keys = typeof key === "string" ? [key] : key;
60
+ const ok = opts.mode === "any" ? hasAnyPermission(snapshot, keys, opts) : hasAllPermissions(snapshot, keys, opts);
61
+ if (!ok) forbidden();
62
+ return snapshot;
63
+ }
64
+ async function requireWorkspaceAccess(workspaceId, key) {
65
+ const snapshot = await loadOrInterrupt();
66
+ if (!hasWorkspacePermission(snapshot, workspaceId, key)) forbidden();
67
+ return snapshot;
68
+ }
69
+ function invalidateAccess(subjectId) {
70
+ updateTag(tag(subjectId));
71
+ }
72
+ return { getAccess, getAccessOrNull, requireAccess, requireWorkspaceAccess, invalidateAccess, cacheTag: tag };
86
73
  }
74
+
75
+ export { accessCacheTag, createAccessLoader };
@@ -1,19 +1,64 @@
1
- import { type ReactNode } from "react";
2
- import { type PermissionCheckOptions } from "./predicates.js";
3
- import { type AccessSnapshot } from "./snapshot.js";
4
- export interface AccessProviderProps {
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+
4
+ /** Scope a permission entry applies to. Mirrors c1m-server authz.Scope*. */
5
+ type AccessScopeType = "platform" | "saas_app" | "workspace";
6
+ interface AccessEntry {
7
+ /** Permission key, e.g. "platform.users.manage_access". */
8
+ key: string;
9
+ scope_type: AccessScopeType;
10
+ /** "" for platform scope; saas_app slug or "*" for saas_app scope; workspace id for workspace scope. */
11
+ scope_id: string;
12
+ }
13
+ /**
14
+ * The resolved authorization snapshot for one subject, as returned by
15
+ * `GET /api/v1/platform/access`. This is the *output* of the backend resolver
16
+ * (base-role grant ∪ scoped grants), never the grant rows themselves.
17
+ */
18
+ interface AccessSnapshot {
19
+ subject_id: string;
20
+ /** Monotonic; bumps on any grant/role change. Use for optimistic concurrency and cache keys. */
21
+ access_version: number;
22
+ is_owner: boolean;
23
+ active: boolean;
24
+ /** Platform operator base role (derived; display only — never authorize on it). */
25
+ platform_role: string;
26
+ permissions: AccessEntry[];
27
+ /**
28
+ * Dash bundle (`dash.owner` / `dash.admin`) per saas_app id, mirrored from
29
+ * the subject's dash membership. This is the admission list for the dash
30
+ * area: the dashes this subject runs. What they may *do* on one is a normal
31
+ * `hasPermission(snapshot, key, { saasApp })` check — platform managers hold
32
+ * their authority platform-wide and so appear here not at all.
33
+ */
34
+ dash_roles: Record<string, string>;
35
+ /** Effective workspace-staff permission keys per workspace id. */
36
+ workspace_permissions: Record<string, string[]>;
37
+ /** Workspace-staff role bundle per workspace id (display only). */
38
+ workspace_roles: Record<string, string>;
39
+ }
40
+
41
+ interface PermissionCheckOptions {
42
+ /**
43
+ * SaaS app (dash) slug the check is about. Omit for "anywhere": platform
44
+ * entries and any saas_app entry satisfy the check.
45
+ */
46
+ saasApp?: string;
47
+ }
48
+
49
+ interface AccessProviderProps {
5
50
  snapshot: AccessSnapshot;
6
51
  children: ReactNode;
7
52
  }
8
53
  /** 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;
54
+ declare function AccessProvider({ snapshot, children }: AccessProviderProps): react.JSX.Element;
55
+ declare function useAccess(): AccessSnapshot;
11
56
  /** 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 {
57
+ declare function useAccessOrEmpty(): AccessSnapshot;
58
+ declare function useCan(): (key: string, opts?: PermissionCheckOptions) => boolean;
59
+ declare function useCanWorkspace(workspaceId: string): (key: string) => boolean;
60
+ declare function useIsPlatformOwner(): boolean;
61
+ interface CanProps extends PermissionCheckOptions {
17
62
  /** One key, or several — all must hold unless `mode="any"`. */
18
63
  permission: string | readonly string[];
19
64
  mode?: "all" | "any";
@@ -25,4 +70,6 @@ export interface CanProps extends PermissionCheckOptions {
25
70
  children: ReactNode;
26
71
  }
27
72
  /** 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;
73
+ declare function Can({ permission, mode, workspaceId, saasApp, not, fallback, children }: CanProps): react.JSX.Element;
74
+
75
+ export { AccessProvider, type AccessProviderProps, Can, type CanProps, useAccess, useAccessOrEmpty, useCan, useCanWorkspace, useIsPlatformOwner };
@@ -1,40 +1,87 @@
1
1
  "use client";
2
- import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+
3
+ // src/access/react.tsx
3
4
  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 });
5
+
6
+ // src/access/predicates.ts
7
+ function entryCoversApp(entry, saasApp) {
8
+ switch (entry.scope_type) {
9
+ case "platform":
10
+ return true;
11
+ case "saas_app":
12
+ if (saasApp === void 0) return true;
13
+ return entry.scope_id === "*" || entry.scope_id.toLowerCase() === saasApp.toLowerCase();
14
+ default:
15
+ return false;
16
+ }
40
17
  }
18
+ function hasPermission(snapshot, key, opts = {}) {
19
+ if (!snapshot.active) return false;
20
+ return snapshot.permissions.some((entry) => entry.key === key && entryCoversApp(entry, opts.saasApp));
21
+ }
22
+ function hasWorkspacePermission(snapshot, workspaceId, key) {
23
+ if (!snapshot.active) return false;
24
+ if (snapshot.is_owner) return true;
25
+ return (snapshot.workspace_permissions[workspaceId] ?? []).includes(key);
26
+ }
27
+ function isPlatformOwner(snapshot) {
28
+ return snapshot.active && snapshot.is_owner;
29
+ }
30
+
31
+ // src/access/snapshot.ts
32
+ function emptySnapshot(subjectId = "") {
33
+ return {
34
+ subject_id: subjectId,
35
+ access_version: 0,
36
+ is_owner: false,
37
+ active: false,
38
+ platform_role: "",
39
+ permissions: [],
40
+ dash_roles: {},
41
+ workspace_permissions: {},
42
+ workspace_roles: {}
43
+ };
44
+ }
45
+
46
+ // src/access/react.tsx
47
+ import { Fragment, jsx } from "react/jsx-runtime";
48
+ var AccessContext = createContext(null);
49
+ function AccessProvider({ snapshot, children }) {
50
+ const value = useMemo(() => snapshot, [snapshot]);
51
+ return /* @__PURE__ */ jsx(AccessContext.Provider, { value, children });
52
+ }
53
+ function useAccess() {
54
+ const snapshot = useContext(AccessContext);
55
+ if (!snapshot) throw new Error("useAccess must be used inside <AccessProvider>");
56
+ return snapshot;
57
+ }
58
+ function useAccessOrEmpty() {
59
+ return useContext(AccessContext) ?? emptySnapshot();
60
+ }
61
+ function useCan() {
62
+ const snapshot = useAccess();
63
+ return useMemo(() => (key, opts) => hasPermission(snapshot, key, opts), [snapshot]);
64
+ }
65
+ function useCanWorkspace(workspaceId) {
66
+ const snapshot = useAccess();
67
+ return useMemo(() => (key) => hasWorkspacePermission(snapshot, workspaceId, key), [snapshot, workspaceId]);
68
+ }
69
+ function useIsPlatformOwner() {
70
+ return isPlatformOwner(useAccess());
71
+ }
72
+ function Can({ permission, mode = "all", workspaceId, saasApp, not = false, fallback = null, children }) {
73
+ const snapshot = useAccess();
74
+ const keys = typeof permission === "string" ? [permission] : permission;
75
+ const check = (key) => workspaceId !== void 0 ? hasWorkspacePermission(snapshot, workspaceId, key) : hasPermission(snapshot, key, { saasApp });
76
+ const allowed = mode === "any" ? keys.some(check) : keys.every(check);
77
+ return /* @__PURE__ */ jsx(Fragment, { children: allowed !== not ? children : fallback });
78
+ }
79
+ export {
80
+ AccessProvider,
81
+ Can,
82
+ useAccess,
83
+ useAccessOrEmpty,
84
+ useCan,
85
+ useCanWorkspace,
86
+ useIsPlatformOwner
87
+ };