@c1m-public/platform-auth 0.3.1 → 0.5.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 +100 -22
- package/dist/access/errors.d.ts +13 -0
- package/dist/access/errors.js +19 -0
- package/dist/access/fetch.d.ts +23 -0
- package/dist/access/fetch.js +43 -0
- package/dist/access/generated/keys.d.ts +20 -0
- package/dist/access/generated/keys.js +78 -0
- package/dist/access/index.d.ts +5 -0
- package/dist/access/index.js +5 -0
- package/dist/access/next.d.ts +51 -0
- package/dist/access/next.js +86 -0
- package/dist/access/predicates.d.ts +48 -0
- package/dist/access/predicates.js +95 -0
- package/dist/access/react.d.ts +28 -0
- package/dist/access/react.js +40 -0
- package/dist/access/snapshot.d.ts +39 -0
- package/dist/access/snapshot.js +92 -0
- package/dist/handoff-errors.d.ts +9 -0
- package/dist/handoff-errors.js +32 -0
- package/dist/handoff.d.ts +1 -2
- package/dist/handoff.js +7 -18
- package/dist/internal.d.ts +16 -0
- package/dist/internal.js +27 -0
- package/dist/session-refresh.d.ts +2 -1
- package/dist/session-refresh.js +9 -14
- package/package.json +58 -11
package/README.md
CHANGED
|
@@ -1,28 +1,106 @@
|
|
|
1
1
|
# @c1m-public/platform-auth
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
Center, Knowledge Library, SaaSDash BFFs)
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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`, `SSO_ERROR_MESSAGES`, `AUTH_ERROR_MESSAGES`, `authErrorMessage(code, overrides?)`, `isAuthErrorCode`, `handoffErrorMessage`, `isHandoffErrorCode` | One sign-in error vocabulary — handoff, admission and SSO codes. Surfaces pass their own copy as `overrides` instead of forking the map. |
|
|
19
36
|
|
|
20
37
|
```ts
|
|
21
|
-
//
|
|
22
|
-
const
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
+
| `managedDashes`, `dashRole` | The dashes a subject runs, from the mirrored `dash.owner` / `dash.admin` bundle grants (`snapshot.dash_roles`). Admission only — gate an action with `hasPermission(snapshot, "dash.publish", { saasApp })`. Platform managers are not listed; admit them on `platform.tenants.manage`. |
|
|
69
|
+
| `fetchAccessSnapshot` | Framework-neutral fetch. Pass `request` and it forwards client identity for you. |
|
|
70
|
+
| `AccessSnapshotError`, `isAccessSnapshotError` | `unauthenticated` / `forbidden` / `unavailable` / `invalid_snapshot`. |
|
|
71
|
+
|
|
72
|
+
`./access/next` adds `createAccessLoader` (request-scoped via React `cache`),
|
|
73
|
+
`requireAccess`, `requireWorkspaceAccess`, `invalidateAccess`, `accessCacheTag`.
|
|
74
|
+
`./access/react` adds `AccessProvider`, `useCan`, `useCanWorkspace`,
|
|
75
|
+
`useIsPlatformOwner`, `<Can>`.
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
// lib/access.server.ts
|
|
79
|
+
export const access = createAccessLoader({
|
|
80
|
+
apiUrl: getApiUrl,
|
|
81
|
+
getAccessToken: async () => (await cookies()).get(SESSION)?.value ?? null,
|
|
82
|
+
// Cross-request caching is the APP's job: the "use cache" directive does not
|
|
83
|
+
// apply inside node_modules, so wrap the fetch here and tag it yourself.
|
|
84
|
+
fetchSnapshot: cachedSnapshot,
|
|
85
|
+
});
|
|
86
|
+
await access.requireAccess("platform.tenants.manage");
|
|
28
87
|
```
|
|
88
|
+
|
|
89
|
+
Omit `getSubjectId` and the loader derives the cache-tag subject from the access
|
|
90
|
+
token, so per-user tagging cannot silently collapse to one shared tag.
|
|
91
|
+
|
|
92
|
+
## Keys drift guard
|
|
93
|
+
|
|
94
|
+
`src/access/generated/keys.ts` is generated from the backend authz catalog
|
|
95
|
+
(`c1m-server/schemas/api/authz-keys.json`, produced by `make authzgen`) and
|
|
96
|
+
vendored to `contracts/authz-keys.json`. Never hand-edit it. Regenerate with
|
|
97
|
+
`node scripts/generate-access-types.mjs`; `pnpm contract:check` runs
|
|
98
|
+
`--check` and fails on drift, so a backend rename cannot silently diverge from
|
|
99
|
+
the frontend key unions.
|
|
100
|
+
|
|
101
|
+
## Notes
|
|
102
|
+
|
|
103
|
+
- Import from `@c1m-public/platform-auth/next` (or `/access/next`) inside
|
|
104
|
+
Next.js server code — those entries carry the `server-only` guard.
|
|
105
|
+
- Session cookies stay host-local per surface; this package never shares a
|
|
106
|
+
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,20 @@
|
|
|
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
|
+
/** Dash-owner permission keys (granted at dash scope, mirrored from the membership role). */
|
|
5
|
+
export 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"];
|
|
6
|
+
export type DashPermissionKey = (typeof DASH_PERMISSIONS)[number];
|
|
7
|
+
/** Membership-mirrored dash role bundles (grant keys at dash scope). */
|
|
8
|
+
export declare const DASH_ROLES: readonly ["dash.admin", "dash.owner"];
|
|
9
|
+
export type DashRole = (typeof DASH_ROLES)[number];
|
|
10
|
+
/** Lead-broker workload permission keys. */
|
|
11
|
+
export declare const LEADBROKER_PERMISSIONS: readonly ["leadbroker.finance.manage", "leadbroker.leads.dispatch", "leadbroker.leads.review", "leadbroker.leads.view", "leadbroker.policy.manage"];
|
|
12
|
+
export type LeadBrokerPermissionKey = (typeof LEADBROKER_PERMISSIONS)[number];
|
|
13
|
+
/** Workspace-staff permission keys. */
|
|
14
|
+
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"];
|
|
15
|
+
export type WorkspacePermissionKey = (typeof WORKSPACE_PERMISSIONS)[number];
|
|
16
|
+
/** Assignable workspace-staff role bundles (grant keys at workspace scope). */
|
|
17
|
+
export declare const WORKSPACE_ROLES: readonly ["workspace.editor", "workspace.frontdesk", "workspace.manager"];
|
|
18
|
+
export type WorkspaceRole = (typeof WORKSPACE_ROLES)[number];
|
|
19
|
+
/** Every permission key the backend can grant, across all scopes. */
|
|
20
|
+
export type PermissionKey = PlatformPermissionKey | DashPermissionKey | LeadBrokerPermissionKey | WorkspacePermissionKey;
|
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
/** Dash-owner permission keys (granted at dash scope, mirrored from the membership role). */
|
|
30
|
+
export const DASH_PERMISSIONS = [
|
|
31
|
+
"dash.apps.manage",
|
|
32
|
+
"dash.billing.manage",
|
|
33
|
+
"dash.channels.manage",
|
|
34
|
+
"dash.customers.manage",
|
|
35
|
+
"dash.domains.manage",
|
|
36
|
+
"dash.members.manage",
|
|
37
|
+
"dash.ownership.transfer",
|
|
38
|
+
"dash.plans.manage",
|
|
39
|
+
"dash.publish",
|
|
40
|
+
"dash.settings.manage",
|
|
41
|
+
"dash.workspaces.admin",
|
|
42
|
+
"dash.workspaces.manage",
|
|
43
|
+
];
|
|
44
|
+
/** Membership-mirrored dash role bundles (grant keys at dash scope). */
|
|
45
|
+
export const DASH_ROLES = [
|
|
46
|
+
"dash.admin",
|
|
47
|
+
"dash.owner",
|
|
48
|
+
];
|
|
49
|
+
/** Lead-broker workload permission keys. */
|
|
50
|
+
export const LEADBROKER_PERMISSIONS = [
|
|
51
|
+
"leadbroker.finance.manage",
|
|
52
|
+
"leadbroker.leads.dispatch",
|
|
53
|
+
"leadbroker.leads.review",
|
|
54
|
+
"leadbroker.leads.view",
|
|
55
|
+
"leadbroker.policy.manage",
|
|
56
|
+
];
|
|
57
|
+
/** Workspace-staff permission keys. */
|
|
58
|
+
export const WORKSPACE_PERMISSIONS = [
|
|
59
|
+
"workspace.channels.manage",
|
|
60
|
+
"workspace.channels.secrets",
|
|
61
|
+
"workspace.channels.view",
|
|
62
|
+
"workspace.crm.manage",
|
|
63
|
+
"workspace.crm.view",
|
|
64
|
+
"workspace.customers.manage",
|
|
65
|
+
"workspace.customers.view",
|
|
66
|
+
"workspace.orders.manage",
|
|
67
|
+
"workspace.orders.view",
|
|
68
|
+
"workspace.payments.record",
|
|
69
|
+
"workspace.site.edit",
|
|
70
|
+
"workspace.site.view",
|
|
71
|
+
"workspace.staff.manage",
|
|
72
|
+
];
|
|
73
|
+
/** Assignable workspace-staff role bundles (grant keys at workspace scope). */
|
|
74
|
+
export const WORKSPACE_ROLES = [
|
|
75
|
+
"workspace.editor",
|
|
76
|
+
"workspace.frontdesk",
|
|
77
|
+
"workspace.manager",
|
|
78
|
+
];
|
|
@@ -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,48 @@
|
|
|
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
|
+
/**
|
|
26
|
+
* The dashes this subject runs, from the mirrored dash bundle grants — the
|
|
27
|
+
* admission list for the Admin Center's dash area. Platform managers are not
|
|
28
|
+
* listed: their reach is platform-wide, so admit them with
|
|
29
|
+
* `hasPermission(snapshot, "platform.tenants.manage")` instead.
|
|
30
|
+
*/
|
|
31
|
+
export declare function managedDashes(snapshot: AccessSnapshot): Array<{
|
|
32
|
+
saasApp: string;
|
|
33
|
+
role: string;
|
|
34
|
+
}>;
|
|
35
|
+
/** The dash bundle held on one dash, or "" when the subject holds none there. */
|
|
36
|
+
export declare function dashRole(snapshot: AccessSnapshot, saasApp: string): string;
|
|
37
|
+
export declare function isPlatformOwner(snapshot: AccessSnapshot): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* The saas_app scopes under which `key` is held. Returns `{ all: true }` when a
|
|
40
|
+
* platform-scoped or "*" entry grants it everywhere; otherwise the explicit
|
|
41
|
+
* app slugs (empty = nowhere). Mirrors authz.AppPermissionScope.
|
|
42
|
+
*/
|
|
43
|
+
export declare function appScopes(snapshot: AccessSnapshot, key: string): {
|
|
44
|
+
all: boolean;
|
|
45
|
+
apps: string[];
|
|
46
|
+
};
|
|
47
|
+
/** Distinct permission keys held for `saasApp` (or anywhere). Handy for nav/badges. */
|
|
48
|
+
export declare function permissionsFor(snapshot: AccessSnapshot, opts?: PermissionCheckOptions): string[];
|
|
@@ -0,0 +1,95 @@
|
|
|
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
|
+
/**
|
|
43
|
+
* The dashes this subject runs, from the mirrored dash bundle grants — the
|
|
44
|
+
* admission list for the Admin Center's dash area. Platform managers are not
|
|
45
|
+
* listed: their reach is platform-wide, so admit them with
|
|
46
|
+
* `hasPermission(snapshot, "platform.tenants.manage")` instead.
|
|
47
|
+
*/
|
|
48
|
+
export function managedDashes(snapshot) {
|
|
49
|
+
if (!snapshot.active)
|
|
50
|
+
return [];
|
|
51
|
+
return Object.entries(snapshot.dash_roles)
|
|
52
|
+
.map(([saasApp, role]) => ({ saasApp, role }))
|
|
53
|
+
.sort((a, b) => a.saasApp.localeCompare(b.saasApp));
|
|
54
|
+
}
|
|
55
|
+
/** The dash bundle held on one dash, or "" when the subject holds none there. */
|
|
56
|
+
export function dashRole(snapshot, saasApp) {
|
|
57
|
+
if (!snapshot.active)
|
|
58
|
+
return "";
|
|
59
|
+
return snapshot.dash_roles[saasApp] ?? "";
|
|
60
|
+
}
|
|
61
|
+
export function isPlatformOwner(snapshot) {
|
|
62
|
+
return snapshot.active && snapshot.is_owner;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The saas_app scopes under which `key` is held. Returns `{ all: true }` when a
|
|
66
|
+
* platform-scoped or "*" entry grants it everywhere; otherwise the explicit
|
|
67
|
+
* app slugs (empty = nowhere). Mirrors authz.AppPermissionScope.
|
|
68
|
+
*/
|
|
69
|
+
export function appScopes(snapshot, key) {
|
|
70
|
+
if (!snapshot.active)
|
|
71
|
+
return { all: false, apps: [] };
|
|
72
|
+
const apps = new Set();
|
|
73
|
+
for (const entry of snapshot.permissions) {
|
|
74
|
+
if (entry.key !== key)
|
|
75
|
+
continue;
|
|
76
|
+
if (entry.scope_type === "platform")
|
|
77
|
+
return { all: true, apps: [] };
|
|
78
|
+
if (entry.scope_type === "saas_app") {
|
|
79
|
+
if (entry.scope_id === "*")
|
|
80
|
+
return { all: true, apps: [] };
|
|
81
|
+
apps.add(entry.scope_id.toLowerCase());
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { all: false, apps: [...apps].sort() };
|
|
85
|
+
}
|
|
86
|
+
/** Distinct permission keys held for `saasApp` (or anywhere). Handy for nav/badges. */
|
|
87
|
+
export function permissionsFor(snapshot, opts = {}) {
|
|
88
|
+
if (!snapshot.active)
|
|
89
|
+
return [];
|
|
90
|
+
const keys = new Set();
|
|
91
|
+
for (const entry of snapshot.permissions)
|
|
92
|
+
if (entryCoversApp(entry, opts.saasApp))
|
|
93
|
+
keys.add(entry.key);
|
|
94
|
+
return [...keys].sort();
|
|
95
|
+
}
|
|
@@ -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,39 @@
|
|
|
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
|
+
/**
|
|
25
|
+
* Dash bundle (`dash.owner` / `dash.admin`) per saas_app id, mirrored from
|
|
26
|
+
* the subject's dash membership. This is the admission list for the dash
|
|
27
|
+
* area: the dashes this subject runs. What they may *do* on one is a normal
|
|
28
|
+
* `hasPermission(snapshot, key, { saasApp })` check — platform managers hold
|
|
29
|
+
* their authority platform-wide and so appear here not at all.
|
|
30
|
+
*/
|
|
31
|
+
dash_roles: Record<string, string>;
|
|
32
|
+
/** Effective workspace-staff permission keys per workspace id. */
|
|
33
|
+
workspace_permissions: Record<string, string[]>;
|
|
34
|
+
/** Workspace-staff role bundle per workspace id (display only). */
|
|
35
|
+
workspace_roles: Record<string, string>;
|
|
36
|
+
}
|
|
37
|
+
export declare function emptySnapshot(subjectId?: string): AccessSnapshot;
|
|
38
|
+
/** Runtime validator for the wire payload. Throws AccessSnapshotError("invalid_snapshot"). */
|
|
39
|
+
export declare function parseAccessSnapshot(input: unknown): AccessSnapshot;
|
|
@@ -0,0 +1,92 @@
|
|
|
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
|
+
dash_roles: {},
|
|
12
|
+
workspace_permissions: {},
|
|
13
|
+
workspace_roles: {},
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function isRecord(value) {
|
|
17
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
function fail(field, why) {
|
|
20
|
+
throw new AccessSnapshotError("invalid_snapshot", `access snapshot: ${field} ${why}`);
|
|
21
|
+
}
|
|
22
|
+
/** Runtime validator for the wire payload. Throws AccessSnapshotError("invalid_snapshot"). */
|
|
23
|
+
export function parseAccessSnapshot(input) {
|
|
24
|
+
if (!isRecord(input))
|
|
25
|
+
fail("payload", "is not an object");
|
|
26
|
+
const { subject_id, access_version, is_owner, active, platform_role, permissions, dash_roles, workspace_permissions, workspace_roles } = input;
|
|
27
|
+
if (typeof subject_id !== "string" || subject_id === "")
|
|
28
|
+
fail("subject_id", "must be a non-empty string");
|
|
29
|
+
if (typeof access_version !== "number" || !Number.isInteger(access_version) || access_version < 0)
|
|
30
|
+
fail("access_version", "must be a non-negative integer");
|
|
31
|
+
if (typeof is_owner !== "boolean")
|
|
32
|
+
fail("is_owner", "must be a boolean");
|
|
33
|
+
if (typeof active !== "boolean")
|
|
34
|
+
fail("active", "must be a boolean");
|
|
35
|
+
if (platform_role !== undefined && platform_role !== null && typeof platform_role !== "string")
|
|
36
|
+
fail("platform_role", "must be a string");
|
|
37
|
+
if (!Array.isArray(permissions))
|
|
38
|
+
fail("permissions", "must be an array");
|
|
39
|
+
const entries = permissions.map((raw, i) => {
|
|
40
|
+
if (!isRecord(raw))
|
|
41
|
+
fail(`permissions[${i}]`, "is not an object");
|
|
42
|
+
const { key, scope_type, scope_id } = raw;
|
|
43
|
+
if (typeof key !== "string" || key === "")
|
|
44
|
+
fail(`permissions[${i}].key`, "must be a non-empty string");
|
|
45
|
+
if (typeof scope_type !== "string" || !SCOPE_TYPES.has(scope_type))
|
|
46
|
+
fail(`permissions[${i}].scope_type`, "must be platform|saas_app|workspace");
|
|
47
|
+
if (scope_id !== undefined && scope_id !== null && typeof scope_id !== "string")
|
|
48
|
+
fail(`permissions[${i}].scope_id`, "must be a string");
|
|
49
|
+
return { key, scope_type: scope_type, scope_id: typeof scope_id === "string" ? scope_id : "" };
|
|
50
|
+
});
|
|
51
|
+
const dashRoles = {};
|
|
52
|
+
if (dash_roles !== undefined && dash_roles !== null) {
|
|
53
|
+
if (!isRecord(dash_roles))
|
|
54
|
+
fail("dash_roles", "must be an object");
|
|
55
|
+
for (const [dash, role] of Object.entries(dash_roles)) {
|
|
56
|
+
if (typeof role !== "string")
|
|
57
|
+
fail(`dash_roles[${dash}]`, "must be a string");
|
|
58
|
+
dashRoles[dash] = role;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const wsPerms = {};
|
|
62
|
+
if (workspace_permissions !== undefined && workspace_permissions !== null) {
|
|
63
|
+
if (!isRecord(workspace_permissions))
|
|
64
|
+
fail("workspace_permissions", "must be an object");
|
|
65
|
+
for (const [ws, keys] of Object.entries(workspace_permissions)) {
|
|
66
|
+
if (!Array.isArray(keys) || keys.some((k) => typeof k !== "string"))
|
|
67
|
+
fail(`workspace_permissions[${ws}]`, "must be a string array");
|
|
68
|
+
wsPerms[ws] = [...keys];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const wsRoles = {};
|
|
72
|
+
if (workspace_roles !== undefined && workspace_roles !== null) {
|
|
73
|
+
if (!isRecord(workspace_roles))
|
|
74
|
+
fail("workspace_roles", "must be an object");
|
|
75
|
+
for (const [ws, role] of Object.entries(workspace_roles)) {
|
|
76
|
+
if (typeof role !== "string")
|
|
77
|
+
fail(`workspace_roles[${ws}]`, "must be a string");
|
|
78
|
+
wsRoles[ws] = role;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
subject_id,
|
|
83
|
+
access_version,
|
|
84
|
+
is_owner,
|
|
85
|
+
active,
|
|
86
|
+
platform_role: typeof platform_role === "string" ? platform_role : "",
|
|
87
|
+
permissions: entries,
|
|
88
|
+
dash_roles: dashRoles,
|
|
89
|
+
workspace_permissions: wsPerms,
|
|
90
|
+
workspace_roles: wsRoles,
|
|
91
|
+
};
|
|
92
|
+
}
|
package/dist/handoff-errors.d.ts
CHANGED
|
@@ -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;
|
package/dist/handoff-errors.js
CHANGED
|
@@ -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 ??
|
|
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(
|
|
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
|
|
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 ??
|
|
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(
|
|
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
|
|
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>;
|
package/dist/internal.js
ADDED
|
@@ -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:
|
|
12
|
+
apiUrl: Resolvable;
|
|
12
13
|
refreshPath: string;
|
|
13
14
|
clientType: string;
|
|
14
15
|
audience: string;
|
package/dist/session-refresh.js
CHANGED
|
@@ -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 ??
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"description": "
|
|
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,61 @@
|
|
|
12
12
|
"main": "./dist/index.js",
|
|
13
13
|
"types": "./dist/index.d.ts",
|
|
14
14
|
"exports": {
|
|
15
|
-
".": {
|
|
16
|
-
|
|
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"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"server-only": "0.0.1"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/react": "^19",
|
|
51
|
+
"@types/react-dom": "^19",
|
|
52
|
+
"next": "16.3.1",
|
|
53
|
+
"react": "19.2.4",
|
|
54
|
+
"react-dom": "19.2.4",
|
|
55
|
+
"typescript": "5.9.3",
|
|
56
|
+
"vitest": "^4.1.4"
|
|
57
|
+
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"next": ">=15",
|
|
60
|
+
"react": ">=18"
|
|
61
|
+
},
|
|
62
|
+
"peerDependenciesMeta": {
|
|
63
|
+
"next": {
|
|
64
|
+
"optional": true
|
|
65
|
+
},
|
|
66
|
+
"react": {
|
|
67
|
+
"optional": true
|
|
68
|
+
}
|
|
17
69
|
},
|
|
18
|
-
"files": ["dist", "README.md"],
|
|
19
|
-
"engines": { "node": ">=20" },
|
|
20
|
-
"publishConfig": { "access": "public" },
|
|
21
70
|
"scripts": {
|
|
22
71
|
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
23
72
|
"build": "pnpm clean && tsc -p tsconfig.build.json",
|
|
@@ -25,7 +74,5 @@
|
|
|
25
74
|
"test": "vitest",
|
|
26
75
|
"test:run": "vitest run",
|
|
27
76
|
"type-check": "tsc -p tsconfig.json --noEmit"
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
"devDependencies": { "typescript": "5.9.3", "vitest": "^4.1.4" }
|
|
31
|
-
}
|
|
77
|
+
}
|
|
78
|
+
}
|