@c1m-public/platform-auth 0.3.1 → 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
+ }
@@ -0,0 +1,31 @@
1
+ /** Scope a permission entry applies to. Mirrors c1m-server authz.Scope*. */
2
+ export type AccessScopeType = "platform" | "saas_app" | "workspace";
3
+ export interface AccessEntry {
4
+ /** Permission key, e.g. "platform.users.manage_access". */
5
+ key: string;
6
+ scope_type: AccessScopeType;
7
+ /** "" for platform scope; saas_app slug or "*" for saas_app scope; workspace id for workspace scope. */
8
+ scope_id: string;
9
+ }
10
+ /**
11
+ * The resolved authorization snapshot for one subject, as returned by
12
+ * `GET /api/v1/platform/access`. This is the *output* of the backend resolver
13
+ * (base-role grant ∪ scoped grants), never the grant rows themselves.
14
+ */
15
+ export interface AccessSnapshot {
16
+ subject_id: string;
17
+ /** Monotonic; bumps on any grant/role change. Use for optimistic concurrency and cache keys. */
18
+ access_version: number;
19
+ is_owner: boolean;
20
+ active: boolean;
21
+ /** Platform operator base role (derived; display only — never authorize on it). */
22
+ platform_role: string;
23
+ permissions: AccessEntry[];
24
+ /** Effective workspace-staff permission keys per workspace id. */
25
+ workspace_permissions: Record<string, string[]>;
26
+ /** Workspace-staff role bundle per workspace id (display only). */
27
+ workspace_roles: Record<string, string>;
28
+ }
29
+ export declare function emptySnapshot(subjectId?: string): AccessSnapshot;
30
+ /** Runtime validator for the wire payload. Throws AccessSnapshotError("invalid_snapshot"). */
31
+ export declare function parseAccessSnapshot(input: unknown): AccessSnapshot;
@@ -0,0 +1,80 @@
1
+ import { AccessSnapshotError } from "./errors.js";
2
+ const SCOPE_TYPES = new Set(["platform", "saas_app", "workspace"]);
3
+ export function emptySnapshot(subjectId = "") {
4
+ return {
5
+ subject_id: subjectId,
6
+ access_version: 0,
7
+ is_owner: false,
8
+ active: false,
9
+ platform_role: "",
10
+ permissions: [],
11
+ workspace_permissions: {},
12
+ workspace_roles: {},
13
+ };
14
+ }
15
+ function isRecord(value) {
16
+ return typeof value === "object" && value !== null && !Array.isArray(value);
17
+ }
18
+ function fail(field, why) {
19
+ throw new AccessSnapshotError("invalid_snapshot", `access snapshot: ${field} ${why}`);
20
+ }
21
+ /** Runtime validator for the wire payload. Throws AccessSnapshotError("invalid_snapshot"). */
22
+ export function parseAccessSnapshot(input) {
23
+ if (!isRecord(input))
24
+ fail("payload", "is not an object");
25
+ const { subject_id, access_version, is_owner, active, platform_role, permissions, workspace_permissions, workspace_roles } = input;
26
+ if (typeof subject_id !== "string" || subject_id === "")
27
+ fail("subject_id", "must be a non-empty string");
28
+ if (typeof access_version !== "number" || !Number.isInteger(access_version) || access_version < 0)
29
+ fail("access_version", "must be a non-negative integer");
30
+ if (typeof is_owner !== "boolean")
31
+ fail("is_owner", "must be a boolean");
32
+ if (typeof active !== "boolean")
33
+ fail("active", "must be a boolean");
34
+ if (platform_role !== undefined && platform_role !== null && typeof platform_role !== "string")
35
+ fail("platform_role", "must be a string");
36
+ if (!Array.isArray(permissions))
37
+ fail("permissions", "must be an array");
38
+ const entries = permissions.map((raw, i) => {
39
+ if (!isRecord(raw))
40
+ fail(`permissions[${i}]`, "is not an object");
41
+ const { key, scope_type, scope_id } = raw;
42
+ if (typeof key !== "string" || key === "")
43
+ fail(`permissions[${i}].key`, "must be a non-empty string");
44
+ if (typeof scope_type !== "string" || !SCOPE_TYPES.has(scope_type))
45
+ fail(`permissions[${i}].scope_type`, "must be platform|saas_app|workspace");
46
+ if (scope_id !== undefined && scope_id !== null && typeof scope_id !== "string")
47
+ fail(`permissions[${i}].scope_id`, "must be a string");
48
+ return { key, scope_type: scope_type, scope_id: typeof scope_id === "string" ? scope_id : "" };
49
+ });
50
+ const wsPerms = {};
51
+ if (workspace_permissions !== undefined && workspace_permissions !== null) {
52
+ if (!isRecord(workspace_permissions))
53
+ fail("workspace_permissions", "must be an object");
54
+ for (const [ws, keys] of Object.entries(workspace_permissions)) {
55
+ if (!Array.isArray(keys) || keys.some((k) => typeof k !== "string"))
56
+ fail(`workspace_permissions[${ws}]`, "must be a string array");
57
+ wsPerms[ws] = [...keys];
58
+ }
59
+ }
60
+ const wsRoles = {};
61
+ if (workspace_roles !== undefined && workspace_roles !== null) {
62
+ if (!isRecord(workspace_roles))
63
+ fail("workspace_roles", "must be an object");
64
+ for (const [ws, role] of Object.entries(workspace_roles)) {
65
+ if (typeof role !== "string")
66
+ fail(`workspace_roles[${ws}]`, "must be a string");
67
+ wsRoles[ws] = role;
68
+ }
69
+ }
70
+ return {
71
+ subject_id,
72
+ access_version,
73
+ is_owner,
74
+ active,
75
+ platform_role: typeof platform_role === "string" ? platform_role : "",
76
+ permissions: entries,
77
+ workspace_permissions: wsPerms,
78
+ workspace_roles: wsRoles,
79
+ };
80
+ }
@@ -1,4 +1,13 @@
1
1
  export declare const HANDOFF_ERROR_MESSAGES: Record<string, string>;
2
2
  export declare const HANDOFF_ERROR_FALLBACK = "Sign-in failed \u2014 please try again.";
3
+ export declare const SSO_ERROR_MESSAGES: Record<string, string>;
4
+ /** Every sign-in failure code this package knows: handoff, admission, and SSO. */
5
+ export declare const AUTH_ERROR_MESSAGES: Record<string, string>;
3
6
  export declare function handoffErrorMessage(code: string | null | undefined): string;
4
7
  export declare function isHandoffErrorCode(code: string | null | undefined): boolean;
8
+ /**
9
+ * Resolve any sign-in failure code to copy. `overrides` wins, so a surface can
10
+ * reword a shared code or add its own without forking the whole map.
11
+ */
12
+ export declare function authErrorMessage(code: string | null | undefined, overrides?: Record<string, string>): string;
13
+ export declare function isAuthErrorCode(code: string | null | undefined, overrides?: Record<string, string>): boolean;
@@ -23,6 +23,23 @@ export const HANDOFF_ERROR_MESSAGES = {
23
23
  admin_surface: "Platform operators sign in here; SaaSDash sent you to the Admin Center.",
24
24
  };
25
25
  export const HANDOFF_ERROR_FALLBACK = "Sign-in failed — please try again.";
26
+ // External sign-in (OIDC/social) codes every platform surface renders identically.
27
+ // Surface-specific copy stays in the app and is passed as `overrides`.
28
+ export const SSO_ERROR_MESSAGES = {
29
+ sso_cancelled: "Sign-in was cancelled.",
30
+ sso_denied: "Sign-in was refused by the provider.",
31
+ sso_state_invalid: "Sign-in attempt expired — please try again.",
32
+ sso_start_failed: "Could not start sign-in with that provider. Please try again.",
33
+ sso_complete_failed: "Sign-in could not be completed. Please try again.",
34
+ sso_unknown_operator: "No operator account matches that identity. Ask an admin for an invite.",
35
+ sso_provider_not_found: "That sign-in provider is not available.",
36
+ sso_not_configured: "That sign-in provider is not fully configured.",
37
+ };
38
+ /** Every sign-in failure code this package knows: handoff, admission, and SSO. */
39
+ export const AUTH_ERROR_MESSAGES = {
40
+ ...HANDOFF_ERROR_MESSAGES,
41
+ ...SSO_ERROR_MESSAGES,
42
+ };
26
43
  export function handoffErrorMessage(code) {
27
44
  if (!code)
28
45
  return HANDOFF_ERROR_FALLBACK;
@@ -31,3 +48,18 @@ export function handoffErrorMessage(code) {
31
48
  export function isHandoffErrorCode(code) {
32
49
  return !!code && Object.prototype.hasOwnProperty.call(HANDOFF_ERROR_MESSAGES, code);
33
50
  }
51
+ /**
52
+ * Resolve any sign-in failure code to copy. `overrides` wins, so a surface can
53
+ * reword a shared code or add its own without forking the whole map.
54
+ */
55
+ export function authErrorMessage(code, overrides) {
56
+ if (!code)
57
+ return HANDOFF_ERROR_FALLBACK;
58
+ return overrides?.[code] ?? AUTH_ERROR_MESSAGES[code] ?? HANDOFF_ERROR_FALLBACK;
59
+ }
60
+ export function isAuthErrorCode(code, overrides) {
61
+ if (!code)
62
+ return false;
63
+ return (Object.prototype.hasOwnProperty.call(AUTH_ERROR_MESSAGES, code) ||
64
+ (!!overrides && Object.prototype.hasOwnProperty.call(overrides, code)));
65
+ }
package/dist/handoff.d.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import type { DeviceIdentityStore } from "./device-cookie.js";
2
+ import { type Resolvable } from "./internal.js";
2
3
  import { type AuthResponse } from "./token.js";
3
4
  export declare function sourceStartPath(app: string): string;
4
5
  export declare function brokerPaths(app: string): {
5
6
  readonly start: `/api/v1/platform/auth/handoffs/${string}/start`;
6
7
  readonly exchange: `/api/v1/platform/auth/handoffs/${string}/exchange`;
7
8
  };
8
- type Resolvable = string | (() => string);
9
9
  export interface HandoffTargetConfig {
10
10
  app: string;
11
11
  apiUrl: Resolvable;
@@ -53,4 +53,3 @@ export declare function createHandoffSource(config: HandoffSourceConfig): {
53
53
  start(request: Request, app: string): Promise<Response>;
54
54
  };
55
55
  export type HandoffSource = ReturnType<typeof createHandoffSource>;
56
- export {};
package/dist/handoff.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { deleteCookie, readCookie, setCookie } from "./cookies.js";
2
2
  import { forwardedRequestHeaders } from "./forwarding.js";
3
3
  import { safeInternalPath } from "./return-path.js";
4
+ import { DEFAULT_FETCH_TIMEOUT_MS, resolveValue, timedFetch } from "./internal.js";
4
5
  import { hasTokenAudience, parseAuthResponse } from "./token.js";
5
6
  /**
6
7
  * Browser halves of c1m-server's `/api/v1/platform/auth/handoffs/{app}` broker.
@@ -10,7 +11,6 @@ import { hasTokenAudience, parseAuthResponse } from "./token.js";
10
11
  */
11
12
  const HANDOFF_COOKIE_TTL_SECONDS = 120;
12
13
  const CHALLENGE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
13
- const DEFAULT_TIMEOUT_MS = 10_000;
14
14
  export function sourceStartPath(app) {
15
15
  return `/api/auth/handoffs/${encodeURIComponent(app)}/start`;
16
16
  }
@@ -21,7 +21,6 @@ export function brokerPaths(app) {
21
21
  exchange: `/api/v1/platform/auth/handoffs/${key}/exchange`,
22
22
  };
23
23
  }
24
- const resolve = (value) => (typeof value === "function" ? value() : value);
25
24
  function originOf(value) {
26
25
  try {
27
26
  return new URL(value).origin;
@@ -56,22 +55,12 @@ function loginErrorRedirect(origin, loginPath, code) {
56
55
  target.searchParams.set("error", code);
57
56
  return redirect(target);
58
57
  }
59
- async function fetchWithTimeout(input, init, timeoutMs) {
60
- const controller = new AbortController();
61
- const timer = setTimeout(() => controller.abort(), timeoutMs);
62
- try {
63
- return await fetch(input, { ...init, signal: controller.signal, cache: "no-store" });
64
- }
65
- finally {
66
- clearTimeout(timer);
67
- }
68
- }
69
58
  export function createHandoffTarget(config) {
70
59
  const secure = config.secureCookies ?? true;
71
60
  const prefix = `${secure ? "__Host-" : ""}${config.cookiePrefix}_handoff_`;
72
61
  const cookies = { state: `${prefix}state`, verifier: `${prefix}verifier`, from: `${prefix}from` };
73
62
  const cookieOptions = { httpOnly: true, secure, sameSite: "lax", path: "/", maxAge: HANDOFF_COOKIE_TTL_SECONDS };
74
- const timeout = config.fetchTimeoutMs ?? DEFAULT_TIMEOUT_MS;
63
+ const timeout = config.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
75
64
  const clearHandoffCookies = (response) => {
76
65
  for (const name of Object.values(cookies))
77
66
  deleteCookie(response, name, { path: "/", secure });
@@ -87,7 +76,7 @@ export function createHandoffTarget(config) {
87
76
  const source = config.sources[sourceKey];
88
77
  if (!source)
89
78
  return fail(url.origin, "handoff_request_invalid");
90
- const sourceOrigin = originOf(resolve(source));
79
+ const sourceOrigin = originOf(resolveValue(source));
91
80
  if (!sourceOrigin)
92
81
  return fail(url.origin, "handoff_not_configured");
93
82
  const verifier = randomToken(48);
@@ -121,7 +110,7 @@ export function createHandoffTarget(config) {
121
110
  "X-Client-Type": config.clientType,
122
111
  "X-Client-Device-ID": device.value,
123
112
  });
124
- const response = await fetchWithTimeout(`${resolve(config.apiUrl)}${brokerPaths(config.app).exchange}`, { method: "POST", headers, body: JSON.stringify({ code, code_verifier: verifier }) }, timeout);
113
+ const response = await timedFetch(`${resolveValue(config.apiUrl)}${brokerPaths(config.app).exchange}`, { init: { method: "POST", headers, body: JSON.stringify({ code, code_verifier: verifier }) }, timeoutMs: timeout });
125
114
  const payload = await response.json().catch(() => ({}));
126
115
  if (!response.ok) {
127
116
  const backendCode = payload?.code;
@@ -144,14 +133,14 @@ export function createHandoffTarget(config) {
144
133
  const DEFAULT_TARGET_CALLBACK = "/api/auth/platform-sso/callback";
145
134
  const DEFAULT_TARGET_LOGIN = "/login";
146
135
  export function createHandoffSource(config) {
147
- const timeout = config.fetchTimeoutMs ?? DEFAULT_TIMEOUT_MS;
136
+ const timeout = config.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
148
137
  return {
149
138
  /** GET `/api/auth/handoffs/{app}/start?code_challenge=&state=` */
150
139
  async start(request, app) {
151
140
  const target = config.targets[app];
152
141
  if (!target)
153
142
  return new Response("unknown handoff application", { status: 404, headers: { "Cache-Control": "no-store" } });
154
- const targetOrigin = originOf(resolve(target.origin));
143
+ const targetOrigin = originOf(resolveValue(target.origin));
155
144
  const targetLogin = target.loginPath ?? DEFAULT_TARGET_LOGIN;
156
145
  if (!targetOrigin)
157
146
  return new Response("handoff target not configured", { status: 503, headers: { "Cache-Control": "no-store" } });
@@ -175,7 +164,7 @@ export function createHandoffSource(config) {
175
164
  "X-Client-Type": config.clientType,
176
165
  Authorization: `Bearer ${token}`,
177
166
  });
178
- const response = await fetchWithTimeout(`${resolve(config.apiUrl)}${brokerPaths(app).start}`, { method: "POST", headers, body: JSON.stringify({ code_challenge: challenge, state }) }, timeout);
167
+ const response = await timedFetch(`${resolveValue(config.apiUrl)}${brokerPaths(app).start}`, { init: { method: "POST", headers, body: JSON.stringify({ code_challenge: challenge, state }) }, timeoutMs: timeout });
179
168
  if (response.status === 401)
180
169
  return sourceLogin();
181
170
  const payload = await response.json().catch(() => ({}));
@@ -0,0 +1,16 @@
1
+ /** A value that may be fixed or resolved per call (env read at request time). */
2
+ export type Resolvable<T = string> = T | (() => T);
3
+ export declare function resolveValue<T>(value: Resolvable<T>): T;
4
+ export declare const DEFAULT_FETCH_TIMEOUT_MS = 10000;
5
+ export interface TimedFetchOptions {
6
+ init?: RequestInit;
7
+ /** Omit or pass 0 for no timeout. */
8
+ timeoutMs?: number;
9
+ fetchImpl?: typeof fetch;
10
+ }
11
+ /**
12
+ * `fetch` with an abort timeout and `cache: "no-store"`. Rejects with whatever
13
+ * fetch rejects with (an abort surfaces as an AbortError); callers map that to
14
+ * their own error vocabulary.
15
+ */
16
+ export declare function timedFetch(url: string, options?: TimedFetchOptions): Promise<Response>;
@@ -0,0 +1,27 @@
1
+ // Shared internals. Not part of the public surface — exported only so the
2
+ // session and access halves of this package stop keeping private copies.
3
+ export function resolveValue(value) {
4
+ return typeof value === "function" ? value() : value;
5
+ }
6
+ export const DEFAULT_FETCH_TIMEOUT_MS = 10_000;
7
+ /**
8
+ * `fetch` with an abort timeout and `cache: "no-store"`. Rejects with whatever
9
+ * fetch rejects with (an abort surfaces as an AbortError); callers map that to
10
+ * their own error vocabulary.
11
+ */
12
+ export async function timedFetch(url, options = {}) {
13
+ const doFetch = options.fetchImpl ?? fetch;
14
+ const controller = options.timeoutMs ? new AbortController() : undefined;
15
+ const timer = controller ? setTimeout(() => controller.abort(), options.timeoutMs) : undefined;
16
+ try {
17
+ return await doFetch(url, {
18
+ ...options.init,
19
+ cache: "no-store",
20
+ ...(controller ? { signal: controller.signal } : {}),
21
+ });
22
+ }
23
+ finally {
24
+ if (timer)
25
+ clearTimeout(timer);
26
+ }
27
+ }
@@ -1,3 +1,4 @@
1
+ import { type Resolvable } from "./internal.js";
1
2
  /**
2
3
  * Coalesced refresh-token exchange for a BFF proxy: same refresh token within
3
4
  * a short window shares one backend call. Per-process best effort; backend
@@ -8,7 +9,7 @@ export interface RefreshedSession {
8
9
  refreshToken?: string;
9
10
  }
10
11
  export interface SessionRefresherConfig {
11
- apiUrl: string | (() => string);
12
+ apiUrl: Resolvable;
12
13
  refreshPath: string;
13
14
  clientType: string;
14
15
  audience: string;
@@ -1,23 +1,21 @@
1
+ import { DEFAULT_FETCH_TIMEOUT_MS, resolveValue, timedFetch } from "./internal.js";
1
2
  import { hasTokenAudience, parseAuthResponse } from "./token.js";
2
3
  const DEFAULT_TTL_MS = 5000;
3
4
  const DEFAULT_MAX = 1000;
4
- const DEFAULT_TIMEOUT_MS = 10_000;
5
5
  export function createSessionRefresher(config) {
6
6
  const ttl = config.singleflightTtlMs ?? DEFAULT_TTL_MS;
7
7
  const max = config.maxSingleflights ?? DEFAULT_MAX;
8
- const timeoutMs = config.fetchTimeoutMs ?? DEFAULT_TIMEOUT_MS;
8
+ const timeoutMs = config.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
9
9
  const inflight = new Map();
10
10
  async function perform(refreshToken) {
11
- const controller = new AbortController();
12
- const timer = setTimeout(() => controller.abort(), timeoutMs);
13
11
  try {
14
- const apiUrl = typeof config.apiUrl === "function" ? config.apiUrl() : config.apiUrl;
15
- const response = await fetch(`${apiUrl}${config.refreshPath}`, {
16
- method: "POST",
17
- headers: { "Content-Type": "application/json", "X-Client-Type": config.clientType },
18
- body: JSON.stringify({ refresh_token: refreshToken }),
19
- cache: "no-store",
20
- signal: controller.signal,
12
+ const response = await timedFetch(`${resolveValue(config.apiUrl)}${config.refreshPath}`, {
13
+ init: {
14
+ method: "POST",
15
+ headers: { "Content-Type": "application/json", "X-Client-Type": config.clientType },
16
+ body: JSON.stringify({ refresh_token: refreshToken }),
17
+ },
18
+ timeoutMs,
21
19
  });
22
20
  if (!response.ok)
23
21
  return null;
@@ -29,9 +27,6 @@ export function createSessionRefresher(config) {
29
27
  catch {
30
28
  return null;
31
29
  }
32
- finally {
33
- clearTimeout(timer);
34
- }
35
30
  }
36
31
  function prune(now) {
37
32
  for (const [token, entry] of inflight) {
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@c1m-public/platform-auth",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
- "description": "Framework-neutral session helpers for C1M platform surfaces: token payload reads, forwarded client headers, safe return paths, device identity cookies, and the first-party session handoff (target callback + source start) built on Web Request/Response",
6
+ "description": "Server-side session and authorization helpers for C1M platform surfaces: token/session reads, the first-party handoff (PKCE target + source), proxy session gate and coalesced refresh, plus the resolved access snapshot, typed permission keys, predicates, a Next loader and React bindings under ./access",
7
7
  "repository": {
8
8
  "type": "git",
9
9
  "url": "git+https://github.com/cloud1mediaLtd/mml-js.git",
@@ -12,12 +12,37 @@
12
12
  "main": "./dist/index.js",
13
13
  "types": "./dist/index.d.ts",
14
14
  "exports": {
15
- ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
16
- "./next": { "types": "./dist/next.d.ts", "default": "./dist/next.js" }
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "default": "./dist/index.js"
18
+ },
19
+ "./next": {
20
+ "types": "./dist/next.d.ts",
21
+ "default": "./dist/next.js"
22
+ },
23
+ "./access": {
24
+ "types": "./dist/access/index.d.ts",
25
+ "default": "./dist/access/index.js"
26
+ },
27
+ "./access/next": {
28
+ "types": "./dist/access/next.d.ts",
29
+ "default": "./dist/access/next.js"
30
+ },
31
+ "./access/react": {
32
+ "types": "./dist/access/react.d.ts",
33
+ "default": "./dist/access/react.js"
34
+ }
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "README.md"
39
+ ],
40
+ "engines": {
41
+ "node": ">=20"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
17
45
  },
18
- "files": ["dist", "README.md"],
19
- "engines": { "node": ">=20" },
20
- "publishConfig": { "access": "public" },
21
46
  "scripts": {
22
47
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
23
48
  "build": "pnpm clean && tsc -p tsconfig.build.json",
@@ -26,6 +51,28 @@
26
51
  "test:run": "vitest run",
27
52
  "type-check": "tsc -p tsconfig.json --noEmit"
28
53
  },
29
- "dependencies": { "server-only": "0.0.1" },
30
- "devDependencies": { "typescript": "5.9.3", "vitest": "^4.1.4" }
54
+ "dependencies": {
55
+ "server-only": "0.0.1"
56
+ },
57
+ "devDependencies": {
58
+ "@types/react": "^19",
59
+ "@types/react-dom": "^19",
60
+ "next": "16.3.1",
61
+ "react": "19.2.4",
62
+ "react-dom": "19.2.4",
63
+ "typescript": "5.9.3",
64
+ "vitest": "^4.1.4"
65
+ },
66
+ "peerDependencies": {
67
+ "next": ">=15",
68
+ "react": ">=18"
69
+ },
70
+ "peerDependenciesMeta": {
71
+ "next": {
72
+ "optional": true
73
+ },
74
+ "react": {
75
+ "optional": true
76
+ }
77
+ }
31
78
  }