@c1m-public/platform-auth 0.16.0 → 0.18.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
@@ -11,7 +11,6 @@ need — the boundary is the subpath.
11
11
  ```
12
12
  . session: token reads, handoff, proxy gate, refresh (Web Request/Response)
13
13
  ./next session, server-only guard (Next route handlers)
14
- ./service-accounts operator-owned service accounts and credentials (framework-neutral)
15
14
  ./access authorization: snapshot, keys, predicates (framework-neutral)
16
15
  ./access/next access loader: require / forbidden / invalidate (Next, server-only)
17
16
  ./access/react <Can>, useCan, AccessProvider (client)
@@ -20,34 +19,11 @@ need — the boundary is the subpath.
20
19
  `next` and `react` are **optional** peer dependencies: a BFF that only reads
21
20
  sessions never needs them.
22
21
 
23
- ## `./service-accounts` — operator service accounts
24
-
25
- `createServiceAccountClient` manages the operator-owned service accounts under
26
- `/api/v1/platform/identity/service-accounts`: list, create, update, replace
27
- grants, and list, mint or revoke credentials. Reads need
28
- `SERVICE_ACCOUNTS_VIEW_PERMISSION`; every mutation needs
29
- `SERVICE_ACCOUNTS_MANAGE_PERMISSION` and is never retried.
30
-
31
- A credential is minted from **reviewed operation ids**, not from arbitrary
32
- permissions: callers name `operation_ids` and the backend derives the
33
- permission ceiling, so a caller cannot widen its own grant. The client also
34
- holds the caller to the wire contract before a request leaves the process — a
35
- `dash:<id>` audience must carry exactly its route Dash in `dash_ids`, a Dash
36
- grant must name one Dash rather than `*`, `expires_in_days` is 1–30, and
37
- `max_output_bytes` is 1–1048576.
38
-
39
- Every mutation carries `expected_version`; a `409` with
40
- `service_account_version_conflict` means the record moved under the caller and
41
- the read has to be repeated. A `recent_authentication_required` API error is a
42
- step-up challenge, not an expired session. The minted `secret` is one-time and
43
- is checked to correlate with the credential it belongs to: keep it out of URLs,
44
- logs, storage, and telemetry — the server never returns it again.
45
-
46
22
  ## `.` — session
47
23
 
48
24
  | Export | Purpose |
49
25
  |---|---|
50
- | `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. |
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 `impersonation_target`, so a support session resolves as the impersonated user. |
51
27
  | `parseAuthResponse`, `AuthResponse` | Validate a backend auth envelope before trusting it. |
52
28
  | `readCookie`, `serializeCookie`, `setCookie`, `deleteCookie` | Cookie helpers over Web `Request`/`Response`. `__Host-` names get `Secure` automatically. |
53
29
  | `createDeviceIdentity` | The `X-Client-Device-ID` cookie the backend requires to create a session. |
@@ -1 +1 @@
1
- export { a as AccessEntry, b as AccessErrorCode, c as AccessScopeType, A as AccessSnapshot, d as AccessSnapshotError, D as DASH_PERMISSIONS, e as DASH_ROLES, f as DEFAULT_ACCESS_ENDPOINT_PATH, g as DashPermissionKey, h as DashRole, F as FetchAccessSnapshotOptions, L as LEADBROKER_PERMISSIONS, i as LeadBrokerPermissionKey, j as PIPELINE_STAGES, k as PLATFORM_PERMISSIONS, P as PermissionCheckOptions, l as PermissionKey, m as PipelineStage, n as PlatformPermissionKey, W as WORKSPACE_PERMISSIONS, o as WORKSPACE_ROLES, p as WorkspacePermissionKey, q as WorkspaceRole, r as appScopes, s as dashRole, t as emptySnapshot, u as entryCoversApp, v as fetchAccessSnapshot, w as hasAllPermissions, x as hasAnyPermission, y as hasPermission, z as hasWorkspacePermission, B as isAccessSnapshotError, C as isPipelineStage, E as isPlatformOwner, G as managedDashes, H as parseAccessSnapshot, I as permissionsFor } from '../index-BoZch6LF.js';
1
+ export { a as AccessEntry, b as AccessErrorCode, c as AccessScopeType, A as AccessSnapshot, d as AccessSnapshotError, D as DASH_PERMISSIONS, e as DASH_ROLES, f as DEFAULT_ACCESS_ENDPOINT_PATH, g as DashPermissionKey, h as DashRole, F as FetchAccessSnapshotOptions, L as LEADBROKER_PERMISSIONS, i as LeadBrokerPermissionKey, j as PIPELINE_STAGES, k as PLATFORM_PERMISSIONS, P as PermissionCheckOptions, l as PermissionKey, m as PipelineStage, n as PlatformPermissionKey, W as WORKSPACE_PERMISSIONS, o as WORKSPACE_ROLES, p as WorkspacePermissionKey, q as WorkspaceRole, r as appScopes, s as dashRole, t as emptySnapshot, u as entryCoversApp, v as fetchAccessSnapshot, w as hasAllPermissions, x as hasAnyPermission, y as hasPermission, z as hasWorkspacePermission, B as isAccessSnapshotError, C as isPipelineStage, E as isPlatformOwner, G as managedDashes, H as parseAccessSnapshot, I as permissionsFor } from '../index-DieAxYYh.js';
@@ -30,18 +30,17 @@ var PLATFORM_PERMISSIONS = [
30
30
  "platform.offboarding.view",
31
31
  "platform.ops.manage",
32
32
  "platform.ops.view",
33
+ "platform.ownership.transfer",
33
34
  "platform.service_accounts.manage",
34
35
  "platform.service_accounts.view",
35
36
  "platform.support.impersonate",
37
+ "platform.support.impersonation.delegate",
36
38
  "platform.support.sessions",
37
- "platform.tenants.archive",
38
- "platform.tenants.manage",
39
- "platform.tenants.restore",
40
- "platform.tenants.view",
41
39
  "platform.users.deactivate",
42
40
  "platform.users.delete",
43
41
  "platform.users.invite",
44
42
  "platform.users.manage_access",
43
+ "platform.users.manage_profile",
45
44
  "platform.users.reactivate",
46
45
  "platform.users.view"
47
46
  ];
@@ -96,6 +95,8 @@ var WORKSPACE_PERMISSIONS = [
96
95
  "workspace.crm.view",
97
96
  "workspace.customers.manage",
98
97
  "workspace.customers.view",
98
+ "workspace.drafts.delete",
99
+ "workspace.lifecycle.manage",
99
100
  "workspace.orders.manage",
100
101
  "workspace.orders.view",
101
102
  "workspace.payments.record",
@@ -1,5 +1,5 @@
1
- import { A as AccessSnapshot, P as PermissionCheckOptions, R as Resolvable } from '../index-BoZch6LF.js';
2
- export { a as AccessEntry, b as AccessErrorCode, c as AccessScopeType, d as AccessSnapshotError, D as DASH_PERMISSIONS, e as DASH_ROLES, f as DEFAULT_ACCESS_ENDPOINT_PATH, g as DashPermissionKey, h as DashRole, F as FetchAccessSnapshotOptions, L as LEADBROKER_PERMISSIONS, i as LeadBrokerPermissionKey, j as PIPELINE_STAGES, k as PLATFORM_PERMISSIONS, l as PermissionKey, m as PipelineStage, n as PlatformPermissionKey, W as WORKSPACE_PERMISSIONS, o as WORKSPACE_ROLES, p as WorkspacePermissionKey, q as WorkspaceRole, r as appScopes, s as dashRole, t as emptySnapshot, u as entryCoversApp, v as fetchAccessSnapshot, w as hasAllPermissions, x as hasAnyPermission, y as hasPermission, z as hasWorkspacePermission, B as isAccessSnapshotError, C as isPipelineStage, E as isPlatformOwner, G as managedDashes, H as parseAccessSnapshot, I as permissionsFor } from '../index-BoZch6LF.js';
1
+ import { A as AccessSnapshot, P as PermissionCheckOptions, R as Resolvable } from '../index-DieAxYYh.js';
2
+ export { a as AccessEntry, b as AccessErrorCode, c as AccessScopeType, d as AccessSnapshotError, D as DASH_PERMISSIONS, e as DASH_ROLES, f as DEFAULT_ACCESS_ENDPOINT_PATH, g as DashPermissionKey, h as DashRole, F as FetchAccessSnapshotOptions, L as LEADBROKER_PERMISSIONS, i as LeadBrokerPermissionKey, j as PIPELINE_STAGES, k as PLATFORM_PERMISSIONS, l as PermissionKey, m as PipelineStage, n as PlatformPermissionKey, W as WORKSPACE_PERMISSIONS, o as WORKSPACE_ROLES, p as WorkspacePermissionKey, q as WorkspaceRole, r as appScopes, s as dashRole, t as emptySnapshot, u as entryCoversApp, v as fetchAccessSnapshot, w as hasAllPermissions, x as hasAnyPermission, y as hasPermission, z as hasWorkspacePermission, B as isAccessSnapshotError, C as isPipelineStage, E as isPlatformOwner, G as managedDashes, H as parseAccessSnapshot, I as permissionsFor } from '../index-DieAxYYh.js';
3
3
 
4
4
  /** Default cache tag for one subject's snapshot: `access:<subjectId>`. */
5
5
  declare function accessCacheTag(subjectId: string): string;
@@ -288,18 +288,17 @@ var PLATFORM_PERMISSIONS = [
288
288
  "platform.offboarding.view",
289
289
  "platform.ops.manage",
290
290
  "platform.ops.view",
291
+ "platform.ownership.transfer",
291
292
  "platform.service_accounts.manage",
292
293
  "platform.service_accounts.view",
293
294
  "platform.support.impersonate",
295
+ "platform.support.impersonation.delegate",
294
296
  "platform.support.sessions",
295
- "platform.tenants.archive",
296
- "platform.tenants.manage",
297
- "platform.tenants.restore",
298
- "platform.tenants.view",
299
297
  "platform.users.deactivate",
300
298
  "platform.users.delete",
301
299
  "platform.users.invite",
302
300
  "platform.users.manage_access",
301
+ "platform.users.manage_profile",
303
302
  "platform.users.reactivate",
304
303
  "platform.users.view"
305
304
  ];
@@ -354,6 +353,8 @@ var WORKSPACE_PERMISSIONS = [
354
353
  "workspace.crm.view",
355
354
  "workspace.customers.manage",
356
355
  "workspace.customers.view",
356
+ "workspace.drafts.delete",
357
+ "workspace.lifecycle.manage",
357
358
  "workspace.orders.manage",
358
359
  "workspace.orders.view",
359
360
  "workspace.payments.record",
@@ -20,7 +20,7 @@ type TokenPayload = Record<string, unknown> & {
20
20
  exp?: number;
21
21
  iat?: number;
22
22
  actor_id?: string;
23
- effective_user_id?: string;
23
+ impersonation_target?: string;
24
24
  customer_id?: string;
25
25
  user_id?: string;
26
26
  aud?: string | string[];
@@ -178,6 +178,27 @@ interface RefreshedSession {
178
178
  accessToken: string;
179
179
  refreshToken?: string;
180
180
  }
181
+ /**
182
+ * Why a refresh did not produce a session.
183
+ *
184
+ * `rejected` and `unavailable` must never be merged. A caller acts on them
185
+ * oppositely: a rejected token means clear the cookies and send the user to
186
+ * log in; an unreachable backend means keep the cookies and say "try again".
187
+ * `refresh()` cannot express this (a truthy failure object
188
+ * would break every `if (refreshed)` caller), so the distinction lives here.
189
+ */
190
+ type RefreshOutcome = {
191
+ status: "refreshed";
192
+ session: RefreshedSession;
193
+ }
194
+ /** The backend answered: this token is no good. Definitive. */
195
+ | {
196
+ status: "rejected";
197
+ }
198
+ /** We could not ask, or could not trust the answer. Says nothing about the token. */
199
+ | {
200
+ status: "unavailable";
201
+ };
181
202
  interface SessionRefresherConfig {
182
203
  /** Backend origin, no /api/v1. May resolve asynchronously (a tenant-scoped origin). */
183
204
  apiUrl: AsyncResolvable<string>;
@@ -202,7 +223,17 @@ interface SessionRefresherConfig {
202
223
  */
203
224
  declare function parseRefreshEnvelope(value: unknown): RefreshedSession | null;
204
225
  declare function createSessionRefresher(config: SessionRefresherConfig): {
226
+ /**
227
+ * The session, or null for any failure.
228
+ *
229
+ * Kept for callers that cannot act on the difference. Anything deciding
230
+ * whether to CLEAR COOKIES must use {@link attemptRefresh} instead — null
231
+ * here means "rejected or unreachable", and treating that as rejected
232
+ * signs a user out because the backend restarted.
233
+ */
205
234
  refresh(refreshToken: string): Promise<RefreshedSession | null>;
235
+ /** The refresh outcome, keeping a rejected token apart from an unreachable backend. */
236
+ attemptRefresh: (refreshToken: string) => Promise<RefreshOutcome>;
206
237
  size(): number;
207
238
  };
208
239
  type SessionRefresher = ReturnType<typeof createSessionRefresher>;
@@ -220,12 +251,20 @@ type SessionGateDecision = {
220
251
  kind: "unauthorized_api";
221
252
  } | {
222
253
  kind: "redirect_login";
254
+ } | {
255
+ kind: "unavailable";
223
256
  };
224
257
  interface SessionGateInput {
225
258
  accessToken?: string | null;
226
259
  refreshToken?: string | null;
227
260
  audience: string;
228
- refresher: Pick<SessionRefresher, "refresh">;
261
+ /**
262
+ * `attemptRefresh` is what distinguishes a rejected token from an
263
+ * unreachable backend. A refresher supplying only `refresh` still works and
264
+ * keeps the old behaviour — an outage reads as "signed out" — so pass a
265
+ * refresher that has it wherever a session is at stake.
266
+ */
267
+ refresher: Pick<SessionRefresher, "refresh"> & Partial<Pick<SessionRefresher, "attemptRefresh">>;
229
268
  isPublic: boolean;
230
269
  isLoginPath: boolean;
231
270
  isApiPath: boolean;
@@ -233,4 +272,4 @@ interface SessionGateInput {
233
272
  }
234
273
  declare function evaluateSessionGate(input: SessionGateInput): Promise<SessionGateDecision>;
235
274
 
236
- export { type AsyncResolvable as A, decodeTokenPayload as B, type CookieOptions as C, type DeviceIdentity as D, deleteCookie as E, evaluateSessionGate as F, forwardedRequestHeaders as G, HANDOFF_ERROR_FALLBACK as H, handoffErrorMessage as I, hasSessionShape as J, hasTokenAudience as K, isAuthErrorCode as L, isHandoffErrorCode as M, isTokenExpired as N, isTokenExpiringSoon as O, parseAuthResponse as P, parseRefreshEnvelope as Q, type Resolvable as R, type SessionData as S, type TokenPayload as T, readCookie as U, safeInternalPath as V, serializeCookie as W, setCookie as X, sourceStartPath as Y, tokenMaxAgeSeconds as Z, AUTH_ERROR_MESSAGES as a, type AuthResponse as b, type DeviceIdentityConfig as c, type DeviceIdentityStore as d, HANDOFF_ERROR_MESSAGES as e, type HandoffSource as f, type HandoffSourceConfig as g, type HandoffSourceTarget as h, type HandoffTarget as i, type HandoffTargetConfig as j, type RefreshedSession as k, SSO_ERROR_MESSAGES as l, type SessionGateDecision as m, type SessionGateInput as n, type SessionRefresher as o, type SessionRefresherConfig as p, type SessionUser as q, applyForwardedClientHeaders as r, authErrorMessage as s, brokerPaths as t, createDeviceIdentity as u, createHandoffSource as v, createHandoffTarget as w, createSessionRefresher as x, decodeBase64Url as y, decodeDefaultSession as z };
275
+ export { type AsyncResolvable as A, decodeDefaultSession as B, type CookieOptions as C, type DeviceIdentity as D, decodeTokenPayload as E, deleteCookie as F, evaluateSessionGate as G, HANDOFF_ERROR_FALLBACK as H, forwardedRequestHeaders as I, handoffErrorMessage as J, hasSessionShape as K, hasTokenAudience as L, isAuthErrorCode as M, isHandoffErrorCode as N, isTokenExpired as O, isTokenExpiringSoon as P, parseAuthResponse as Q, type Resolvable as R, type SessionData as S, type TokenPayload as T, parseRefreshEnvelope as U, readCookie as V, safeInternalPath as W, serializeCookie as X, setCookie as Y, sourceStartPath as Z, tokenMaxAgeSeconds as _, AUTH_ERROR_MESSAGES as a, type AuthResponse as b, type DeviceIdentityConfig as c, type DeviceIdentityStore as d, HANDOFF_ERROR_MESSAGES as e, type HandoffSource as f, type HandoffSourceConfig as g, type HandoffSourceTarget as h, type HandoffTarget as i, type HandoffTargetConfig as j, type RefreshOutcome as k, type RefreshedSession as l, SSO_ERROR_MESSAGES as m, type SessionGateDecision as n, type SessionGateInput as o, type SessionRefresher as p, type SessionRefresherConfig as q, type SessionUser as r, applyForwardedClientHeaders as s, authErrorMessage as t, brokerPaths as u, createDeviceIdentity as v, createHandoffSource as w, createHandoffTarget as x, createSessionRefresher as y, decodeBase64Url as z };
@@ -1,5 +1,5 @@
1
1
  /** Platform-operator permission keys. */
2
- declare const PLATFORM_PERMISSIONS: readonly ["affiliates.enrollments.manage", "affiliates.finance.manage", "affiliates.partners.manage", "affiliates.programmes.manage", "affiliates.view", "linkservice.domains.manage", "linkservice.links.manage", "linkservice.qr.manage", "linkservice.view", "platform.admin.view", "platform.audit.view", "platform.auth.manage", "platform.billing.manage", "platform.billing.view", "platform.channels.manage", "platform.channels.view", "platform.config.manage", "platform.dashes.archive", "platform.dashes.manage", "platform.dashes.purge", "platform.dashes.restore", "platform.dashes.view", "platform.docs.manage", "platform.docs.view", "platform.mcp.access", "platform.observability.manage", "platform.observability.view", "platform.offboarding.view", "platform.ops.manage", "platform.ops.view", "platform.service_accounts.manage", "platform.service_accounts.view", "platform.support.impersonate", "platform.support.sessions", "platform.tenants.archive", "platform.tenants.manage", "platform.tenants.restore", "platform.tenants.view", "platform.users.deactivate", "platform.users.delete", "platform.users.invite", "platform.users.manage_access", "platform.users.reactivate", "platform.users.view"];
2
+ declare const PLATFORM_PERMISSIONS: readonly ["affiliates.enrollments.manage", "affiliates.finance.manage", "affiliates.partners.manage", "affiliates.programmes.manage", "affiliates.view", "linkservice.domains.manage", "linkservice.links.manage", "linkservice.qr.manage", "linkservice.view", "platform.admin.view", "platform.audit.view", "platform.auth.manage", "platform.billing.manage", "platform.billing.view", "platform.channels.manage", "platform.channels.view", "platform.config.manage", "platform.dashes.archive", "platform.dashes.manage", "platform.dashes.purge", "platform.dashes.restore", "platform.dashes.view", "platform.docs.manage", "platform.docs.view", "platform.mcp.access", "platform.observability.manage", "platform.observability.view", "platform.offboarding.view", "platform.ops.manage", "platform.ops.view", "platform.ownership.transfer", "platform.service_accounts.manage", "platform.service_accounts.view", "platform.support.impersonate", "platform.support.impersonation.delegate", "platform.support.sessions", "platform.users.deactivate", "platform.users.delete", "platform.users.invite", "platform.users.manage_access", "platform.users.manage_profile", "platform.users.reactivate", "platform.users.view"];
3
3
  type PlatformPermissionKey = (typeof PLATFORM_PERMISSIONS)[number];
4
4
  /** Dash-owner permission keys (granted at dash scope, mirrored from the membership role). */
5
5
  declare const DASH_PERMISSIONS: readonly ["dash.activity.view", "dash.apps.manage", "dash.apps.view", "dash.billing.manage", "dash.billing.view", "dash.channels.manage", "dash.customers.manage", "dash.customers.view", "dash.domains.manage", "dash.domains.view", "dash.leads.export", "dash.leads.view", "dash.learning.records.view", "dash.learning.view", "dash.marketing.view", "dash.mcp.access", "dash.members.manage", "dash.members.view", "dash.ops_library.view", "dash.overview.view", "dash.ownership.transfer", "dash.plans.manage", "dash.plans.view", "dash.publish", "dash.settings.manage", "dash.settings.view", "dash.shop.view", "dash.workspaces.admin", "dash.workspaces.manage", "dash.workspaces.view"];
@@ -11,7 +11,7 @@ type DashRole = (typeof DASH_ROLES)[number];
11
11
  declare const LEADBROKER_PERMISSIONS: readonly ["leadbroker.finance.manage", "leadbroker.leads.dispatch", "leadbroker.leads.review", "leadbroker.leads.view", "leadbroker.policy.manage"];
12
12
  type LeadBrokerPermissionKey = (typeof LEADBROKER_PERMISSIONS)[number];
13
13
  /** Workspace-staff permission keys. */
14
- 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"];
14
+ 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.drafts.delete", "workspace.lifecycle.manage", "workspace.orders.manage", "workspace.orders.view", "workspace.payments.record", "workspace.site.edit", "workspace.site.view", "workspace.staff.manage"];
15
15
  type WorkspacePermissionKey = (typeof WORKSPACE_PERMISSIONS)[number];
16
16
  /** Assignable workspace-staff role bundles (grant keys at workspace scope). */
17
17
  declare const WORKSPACE_ROLES: readonly ["workspace.editor", "workspace.frontdesk", "workspace.manager"];
package/dist/index.d.ts CHANGED
@@ -1 +1 @@
1
- export { a as AUTH_ERROR_MESSAGES, b as AuthResponse, C as CookieOptions, D as DeviceIdentity, c as DeviceIdentityConfig, d as DeviceIdentityStore, H as HANDOFF_ERROR_FALLBACK, e as HANDOFF_ERROR_MESSAGES, f as HandoffSource, g as HandoffSourceConfig, h as HandoffSourceTarget, i as HandoffTarget, j as HandoffTargetConfig, k as RefreshedSession, l as SSO_ERROR_MESSAGES, S as SessionData, m as SessionGateDecision, n as SessionGateInput, o as SessionRefresher, p as SessionRefresherConfig, q as SessionUser, T as TokenPayload, r as applyForwardedClientHeaders, s as authErrorMessage, t as brokerPaths, u as createDeviceIdentity, v as createHandoffSource, w as createHandoffTarget, x as createSessionRefresher, y as decodeBase64Url, z as decodeDefaultSession, B as decodeTokenPayload, E as deleteCookie, F as evaluateSessionGate, G as forwardedRequestHeaders, I as handoffErrorMessage, J as hasSessionShape, K as hasTokenAudience, L as isAuthErrorCode, M as isHandoffErrorCode, N as isTokenExpired, O as isTokenExpiringSoon, P as parseAuthResponse, Q as parseRefreshEnvelope, U as readCookie, V as safeInternalPath, W as serializeCookie, X as setCookie, Y as sourceStartPath, Z as tokenMaxAgeSeconds } from './index-aKe58m6n.js';
1
+ export { a as AUTH_ERROR_MESSAGES, b as AuthResponse, C as CookieOptions, D as DeviceIdentity, c as DeviceIdentityConfig, d as DeviceIdentityStore, H as HANDOFF_ERROR_FALLBACK, e as HANDOFF_ERROR_MESSAGES, f as HandoffSource, g as HandoffSourceConfig, h as HandoffSourceTarget, i as HandoffTarget, j as HandoffTargetConfig, k as RefreshOutcome, l as RefreshedSession, m as SSO_ERROR_MESSAGES, S as SessionData, n as SessionGateDecision, o as SessionGateInput, p as SessionRefresher, q as SessionRefresherConfig, r as SessionUser, T as TokenPayload, s as applyForwardedClientHeaders, t as authErrorMessage, u as brokerPaths, v as createDeviceIdentity, w as createHandoffSource, x as createHandoffTarget, y as createSessionRefresher, z as decodeBase64Url, B as decodeDefaultSession, E as decodeTokenPayload, F as deleteCookie, G as evaluateSessionGate, I as forwardedRequestHeaders, J as handoffErrorMessage, K as hasSessionShape, L as hasTokenAudience, M as isAuthErrorCode, N as isHandoffErrorCode, O as isTokenExpired, P as isTokenExpiringSoon, Q as parseAuthResponse, U as parseRefreshEnvelope, V as readCookie, W as safeInternalPath, X as serializeCookie, Y as setCookie, Z as sourceStartPath, _ as tokenMaxAgeSeconds } from './index-BxyVV6Wt.js';
package/dist/index.js CHANGED
@@ -53,7 +53,7 @@ function isTokenExpiringSoon(token, bufferSeconds = 120) {
53
53
  function decodeDefaultSession(token) {
54
54
  const decoded = decodeTokenPayload(token);
55
55
  if (!decoded) return null;
56
- const actorId = decoded.effective_user_id || decoded.actor_id || decoded.customer_id || decoded.user_id || (typeof decoded.sub === "string" ? decoded.sub : void 0);
56
+ const actorId = decoded.impersonation_target || decoded.actor_id || decoded.customer_id || decoded.user_id || (typeof decoded.sub === "string" ? decoded.sub : void 0);
57
57
  if (!actorId) return null;
58
58
  return {
59
59
  user: {
@@ -462,6 +462,7 @@ function createHandoffSource(config) {
462
462
  }
463
463
 
464
464
  // src/session-refresh.ts
465
+ var REJECTION_STATUSES = /* @__PURE__ */ new Set([400, 401, 403]);
465
466
  var DEFAULT_TTL_MS = 5e3;
466
467
  var DEFAULT_MAX = 1e3;
467
468
  function parseRefreshEnvelope(value) {
@@ -495,11 +496,22 @@ function createSessionRefresher(config) {
495
496
  timeoutMs,
496
497
  fetch: config.fetch
497
498
  });
498
- if (!response.ok) return null;
499
- const refreshed = parseRefreshEnvelope(await response.json());
500
- return refreshed && hasTokenAudience(refreshed.accessToken, config.audience) ? refreshed : null;
499
+ if (!response.ok) {
500
+ return REJECTION_STATUSES.has(response.status) ? { status: "rejected" } : { status: "unavailable" };
501
+ }
502
+ let parsed;
503
+ try {
504
+ parsed = await response.json();
505
+ } catch {
506
+ return { status: "unavailable" };
507
+ }
508
+ const refreshed = parseRefreshEnvelope(parsed);
509
+ if (!refreshed || !hasTokenAudience(refreshed.accessToken, config.audience)) {
510
+ return { status: "rejected" };
511
+ }
512
+ return { status: "refreshed", session: refreshed };
501
513
  } catch {
502
- return null;
514
+ return { status: "unavailable" };
503
515
  }
504
516
  }
505
517
  function prune(now) {
@@ -512,19 +524,32 @@ function createSessionRefresher(config) {
512
524
  inflight.delete(oldest);
513
525
  }
514
526
  }
527
+ function attempt(refreshToken) {
528
+ const now = Date.now();
529
+ prune(now);
530
+ const existing = inflight.get(refreshToken);
531
+ if (existing && existing.expiresAt > now) return existing.promise;
532
+ const promise = perform(refreshToken).then((outcome) => {
533
+ if (outcome.status !== "refreshed") inflight.delete(refreshToken);
534
+ return outcome;
535
+ });
536
+ inflight.set(refreshToken, { expiresAt: now + ttl, promise });
537
+ return promise;
538
+ }
515
539
  return {
540
+ /**
541
+ * The session, or null for any failure.
542
+ *
543
+ * Kept for callers that cannot act on the difference. Anything deciding
544
+ * whether to CLEAR COOKIES must use {@link attemptRefresh} instead — null
545
+ * here means "rejected or unreachable", and treating that as rejected
546
+ * signs a user out because the backend restarted.
547
+ */
516
548
  refresh(refreshToken) {
517
- const now = Date.now();
518
- prune(now);
519
- const existing = inflight.get(refreshToken);
520
- if (existing && existing.expiresAt > now) return existing.promise;
521
- const promise = perform(refreshToken).then((result) => {
522
- if (!result) inflight.delete(refreshToken);
523
- return result;
524
- });
525
- inflight.set(refreshToken, { expiresAt: now + ttl, promise });
526
- return promise;
549
+ return attempt(refreshToken).then((outcome) => outcome.status === "refreshed" ? outcome.session : null);
527
550
  },
551
+ /** The refresh outcome, keeping a rejected token apart from an unreachable backend. */
552
+ attemptRefresh: attempt,
528
553
  size() {
529
554
  return inflight.size;
530
555
  }
@@ -540,17 +565,23 @@ function hasSessionShape(token) {
540
565
  async function evaluateSessionGate(input) {
541
566
  const { accessToken, refreshToken, audience, refresher, isPublic, isLoginPath, isApiPath } = input;
542
567
  const live = !!accessToken && hasSessionShape(accessToken) && hasTokenAudience(accessToken, audience) && !isTokenExpired(accessToken);
568
+ const attempt = async (token) => refresher.attemptRefresh ? await refresher.attemptRefresh(token) : await refresher.refresh(token).then(
569
+ (session) => session ? { status: "refreshed", session } : { status: "rejected" }
570
+ );
543
571
  if (live) {
544
572
  if (isPublic && isLoginPath) return { kind: "redirect_home" };
545
573
  if (refreshToken && isTokenExpiringSoon(accessToken, input.expiringSoonBufferSeconds)) {
546
- const refreshed = await refresher.refresh(refreshToken);
547
- if (refreshed) return { kind: "pass", refreshed };
574
+ const outcome = await attempt(refreshToken);
575
+ if (outcome.status === "refreshed") return { kind: "pass", refreshed: outcome.session };
548
576
  }
549
577
  return { kind: "pass" };
550
578
  }
551
579
  if (refreshToken) {
552
- const refreshed = await refresher.refresh(refreshToken);
553
- if (refreshed) return isPublic ? { kind: "redirect_home", refreshed } : { kind: "pass", refreshed };
580
+ const outcome = await attempt(refreshToken);
581
+ if (outcome.status === "refreshed") {
582
+ return isPublic ? { kind: "redirect_home", refreshed: outcome.session } : { kind: "pass", refreshed: outcome.session };
583
+ }
584
+ if (outcome.status === "unavailable" && !isPublic) return { kind: "unavailable" };
554
585
  }
555
586
  if (isPublic) return { kind: "anonymous_public" };
556
587
  if (isApiPath) return { kind: "unauthorized_api" };
package/dist/next.d.ts CHANGED
@@ -1,11 +1,9 @@
1
- import { R as Resolvable, A as AsyncResolvable, S as SessionData, C as CookieOptions } from './index-aKe58m6n.js';
2
- export { a as AUTH_ERROR_MESSAGES, b as AuthResponse, D as DeviceIdentity, c as DeviceIdentityConfig, d as DeviceIdentityStore, H as HANDOFF_ERROR_FALLBACK, e as HANDOFF_ERROR_MESSAGES, f as HandoffSource, g as HandoffSourceConfig, h as HandoffSourceTarget, i as HandoffTarget, j as HandoffTargetConfig, k as RefreshedSession, l as SSO_ERROR_MESSAGES, m as SessionGateDecision, n as SessionGateInput, o as SessionRefresher, p as SessionRefresherConfig, q as SessionUser, T as TokenPayload, r as applyForwardedClientHeaders, s as authErrorMessage, t as brokerPaths, u as createDeviceIdentity, v as createHandoffSource, w as createHandoffTarget, x as createSessionRefresher, y as decodeBase64Url, z as decodeDefaultSession, B as decodeTokenPayload, E as deleteCookie, F as evaluateSessionGate, G as forwardedRequestHeaders, I as handoffErrorMessage, J as hasSessionShape, K as hasTokenAudience, L as isAuthErrorCode, M as isHandoffErrorCode, N as isTokenExpired, O as isTokenExpiringSoon, P as parseAuthResponse, Q as parseRefreshEnvelope, U as readCookie, V as safeInternalPath, W as serializeCookie, X as setCookie, Y as sourceStartPath, Z as tokenMaxAgeSeconds } from './index-aKe58m6n.js';
1
+ import { R as Resolvable, A as AsyncResolvable, S as SessionData, C as CookieOptions } from './index-BxyVV6Wt.js';
2
+ export { a as AUTH_ERROR_MESSAGES, b as AuthResponse, D as DeviceIdentity, c as DeviceIdentityConfig, d as DeviceIdentityStore, H as HANDOFF_ERROR_FALLBACK, e as HANDOFF_ERROR_MESSAGES, f as HandoffSource, g as HandoffSourceConfig, h as HandoffSourceTarget, i as HandoffTarget, j as HandoffTargetConfig, k as RefreshOutcome, l as RefreshedSession, m as SSO_ERROR_MESSAGES, n as SessionGateDecision, o as SessionGateInput, p as SessionRefresher, q as SessionRefresherConfig, r as SessionUser, T as TokenPayload, s as applyForwardedClientHeaders, t as authErrorMessage, u as brokerPaths, v as createDeviceIdentity, w as createHandoffSource, x as createHandoffTarget, y as createSessionRefresher, z as decodeBase64Url, B as decodeDefaultSession, E as decodeTokenPayload, F as deleteCookie, G as evaluateSessionGate, I as forwardedRequestHeaders, J as handoffErrorMessage, K as hasSessionShape, L as hasTokenAudience, M as isAuthErrorCode, N as isHandoffErrorCode, O as isTokenExpired, P as isTokenExpiringSoon, Q as parseAuthResponse, U as parseRefreshEnvelope, V as readCookie, W as safeInternalPath, X as serializeCookie, Y as setCookie, Z as sourceStartPath, _ as tokenMaxAgeSeconds } from './index-BxyVV6Wt.js';
3
3
 
4
4
  /**
5
- * The error vocabulary every first-party platform surface uses at its edge.
6
- * Platform Admin, SaaSDash, the Affiliate Portal and the Knowledge Library
7
- * each carried a private copy of these classes; they are the same four
8
- * everywhere because the backend's 401 codes are the same everywhere.
5
+ * The error vocabulary every first-party platform surface uses at its edge,
6
+ * mirroring the backend's 401 codes.
9
7
  */
10
8
  declare class SessionExpiredError extends Error {
11
9
  constructor();
@@ -132,11 +130,9 @@ interface SessionWriteOptions {
132
130
  cookieDomain?: string;
133
131
  }
134
132
  /**
135
- * Session cookies for a first-party Next.js platform surface, written once.
133
+ * Session cookies for a first-party Next.js platform surface.
136
134
  *
137
- * Every consumer had reimplemented this — Platform Admin, SaaSDash (via
138
- * shared-auth), the Affiliate Portal and the Knowledge Library — around the
139
- * same primitives, with the same three cookies and the same rules:
135
+ * Three cookies, with these rules:
140
136
  *
141
137
  * - rememberMe=false → no Max-Age, so the cookies die with the browser; true →
142
138
  * the configured cap, shortened to the token's own expiry.
package/dist/next.js CHANGED
@@ -58,7 +58,7 @@ function isTokenExpiringSoon(token, bufferSeconds = 120) {
58
58
  function decodeDefaultSession(token) {
59
59
  const decoded = decodeTokenPayload(token);
60
60
  if (!decoded) return null;
61
- const actorId = decoded.effective_user_id || decoded.actor_id || decoded.customer_id || decoded.user_id || (typeof decoded.sub === "string" ? decoded.sub : void 0);
61
+ const actorId = decoded.impersonation_target || decoded.actor_id || decoded.customer_id || decoded.user_id || (typeof decoded.sub === "string" ? decoded.sub : void 0);
62
62
  if (!actorId) return null;
63
63
  return {
64
64
  user: {
@@ -467,6 +467,7 @@ function createHandoffSource(config) {
467
467
  }
468
468
 
469
469
  // src/session-refresh.ts
470
+ var REJECTION_STATUSES = /* @__PURE__ */ new Set([400, 401, 403]);
470
471
  var DEFAULT_TTL_MS = 5e3;
471
472
  var DEFAULT_MAX = 1e3;
472
473
  function parseRefreshEnvelope(value) {
@@ -500,11 +501,22 @@ function createSessionRefresher(config) {
500
501
  timeoutMs,
501
502
  fetch: config.fetch
502
503
  });
503
- if (!response.ok) return null;
504
- const refreshed = parseRefreshEnvelope(await response.json());
505
- return refreshed && hasTokenAudience(refreshed.accessToken, config.audience) ? refreshed : null;
504
+ if (!response.ok) {
505
+ return REJECTION_STATUSES.has(response.status) ? { status: "rejected" } : { status: "unavailable" };
506
+ }
507
+ let parsed;
508
+ try {
509
+ parsed = await response.json();
510
+ } catch {
511
+ return { status: "unavailable" };
512
+ }
513
+ const refreshed = parseRefreshEnvelope(parsed);
514
+ if (!refreshed || !hasTokenAudience(refreshed.accessToken, config.audience)) {
515
+ return { status: "rejected" };
516
+ }
517
+ return { status: "refreshed", session: refreshed };
506
518
  } catch {
507
- return null;
519
+ return { status: "unavailable" };
508
520
  }
509
521
  }
510
522
  function prune(now) {
@@ -517,19 +529,32 @@ function createSessionRefresher(config) {
517
529
  inflight.delete(oldest);
518
530
  }
519
531
  }
532
+ function attempt(refreshToken) {
533
+ const now = Date.now();
534
+ prune(now);
535
+ const existing = inflight.get(refreshToken);
536
+ if (existing && existing.expiresAt > now) return existing.promise;
537
+ const promise = perform(refreshToken).then((outcome) => {
538
+ if (outcome.status !== "refreshed") inflight.delete(refreshToken);
539
+ return outcome;
540
+ });
541
+ inflight.set(refreshToken, { expiresAt: now + ttl, promise });
542
+ return promise;
543
+ }
520
544
  return {
545
+ /**
546
+ * The session, or null for any failure.
547
+ *
548
+ * Kept for callers that cannot act on the difference. Anything deciding
549
+ * whether to CLEAR COOKIES must use {@link attemptRefresh} instead — null
550
+ * here means "rejected or unreachable", and treating that as rejected
551
+ * signs a user out because the backend restarted.
552
+ */
521
553
  refresh(refreshToken) {
522
- const now = Date.now();
523
- prune(now);
524
- const existing = inflight.get(refreshToken);
525
- if (existing && existing.expiresAt > now) return existing.promise;
526
- const promise = perform(refreshToken).then((result) => {
527
- if (!result) inflight.delete(refreshToken);
528
- return result;
529
- });
530
- inflight.set(refreshToken, { expiresAt: now + ttl, promise });
531
- return promise;
554
+ return attempt(refreshToken).then((outcome) => outcome.status === "refreshed" ? outcome.session : null);
532
555
  },
556
+ /** The refresh outcome, keeping a rejected token apart from an unreachable backend. */
557
+ attemptRefresh: attempt,
533
558
  size() {
534
559
  return inflight.size;
535
560
  }
@@ -545,17 +570,23 @@ function hasSessionShape(token) {
545
570
  async function evaluateSessionGate(input) {
546
571
  const { accessToken, refreshToken, audience, refresher, isPublic, isLoginPath, isApiPath } = input;
547
572
  const live = !!accessToken && hasSessionShape(accessToken) && hasTokenAudience(accessToken, audience) && !isTokenExpired(accessToken);
573
+ const attempt = async (token) => refresher.attemptRefresh ? await refresher.attemptRefresh(token) : await refresher.refresh(token).then(
574
+ (session) => session ? { status: "refreshed", session } : { status: "rejected" }
575
+ );
548
576
  if (live) {
549
577
  if (isPublic && isLoginPath) return { kind: "redirect_home" };
550
578
  if (refreshToken && isTokenExpiringSoon(accessToken, input.expiringSoonBufferSeconds)) {
551
- const refreshed = await refresher.refresh(refreshToken);
552
- if (refreshed) return { kind: "pass", refreshed };
579
+ const outcome = await attempt(refreshToken);
580
+ if (outcome.status === "refreshed") return { kind: "pass", refreshed: outcome.session };
553
581
  }
554
582
  return { kind: "pass" };
555
583
  }
556
584
  if (refreshToken) {
557
- const refreshed = await refresher.refresh(refreshToken);
558
- if (refreshed) return isPublic ? { kind: "redirect_home", refreshed } : { kind: "pass", refreshed };
585
+ const outcome = await attempt(refreshToken);
586
+ if (outcome.status === "refreshed") {
587
+ return isPublic ? { kind: "redirect_home", refreshed: outcome.session } : { kind: "pass", refreshed: outcome.session };
588
+ }
589
+ if (outcome.status === "unavailable" && !isPublic) return { kind: "unavailable" };
559
590
  }
560
591
  if (isPublic) return { kind: "anonymous_public" };
561
592
  if (isApiPath) return { kind: "unauthorized_api" };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c1m-public/platform-auth",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Session and authorization helpers for C1M platform surfaces. The root is runtime-neutral (Web Request/Response): token/session reads, the first-party handoff (PKCE target + source), proxy session gate and coalesced refresh; ./next adds the server-only guard; ./access carries the resolved access snapshot, typed permission keys, predicates, a Next loader and React bindings",
@@ -20,10 +20,6 @@
20
20
  "types": "./dist/next.d.ts",
21
21
  "default": "./dist/next.js"
22
22
  },
23
- "./service-accounts": {
24
- "types": "./dist/service-accounts.d.ts",
25
- "default": "./dist/service-accounts.js"
26
- },
27
23
  "./access": {
28
24
  "types": "./dist/access/index.d.ts",
29
25
  "default": "./dist/access/index.js"
@@ -56,9 +52,10 @@
56
52
  "next": "16.3.1",
57
53
  "react": "19.2.4",
58
54
  "react-dom": "19.2.4",
55
+ "tsup": "^8.5.1",
59
56
  "typescript": "5.9.3",
60
57
  "vitest": "^4.1.4",
61
- "tsup": "^8.5.1",
58
+ "@c1m-public/resilience": "0.1.0",
62
59
  "@c1m-public/transport": "0.0.0"
63
60
  },
64
61
  "peerDependencies": {
@@ -1,149 +0,0 @@
1
- declare const SERVICE_ACCOUNTS_VIEW_PERMISSION: "platform.service_accounts.view";
2
- declare const SERVICE_ACCOUNTS_MANAGE_PERMISSION: "platform.service_accounts.manage";
3
- type ServiceAccountAudience = "platform" | `dash:${string}`;
4
- type ServiceAccountGrantScope = "platform" | "dash_id";
5
- type ServiceAccountOperation = "platform.identity.service_accounts.list" | "platform.identity.service_accounts.create" | "platform.identity.service_accounts.get" | "platform.identity.service_accounts.update" | "platform.identity.service_accounts.grants.list" | "platform.identity.service_accounts.grants.replace" | "platform.identity.service_accounts.credentials.list" | "platform.identity.service_accounts.credentials.mint" | "platform.identity.service_accounts.credentials.revoke";
6
- type ServiceAccountRequestOutcome = "success" | "api_error" | "network_error" | "timeout" | "invalid_response" | "cancelled";
7
- interface ServiceAccountView {
8
- id: string;
9
- name: string;
10
- purpose: string;
11
- owner_user_id: string;
12
- enabled: boolean;
13
- version: number;
14
- created_by: string;
15
- updated_by: string;
16
- created_at: string;
17
- updated_at: string;
18
- }
19
- interface ServiceAccountEnvelope {
20
- service_account: ServiceAccountView;
21
- }
22
- interface ServiceAccountPage {
23
- items: ServiceAccountView[];
24
- next_cursor?: string;
25
- has_more: boolean;
26
- }
27
- interface ServiceAccountGrant {
28
- grant_key: string;
29
- scope_type: string;
30
- scope_id: string;
31
- id?: string;
32
- subject_id?: string;
33
- subject_type?: string;
34
- granted_by?: string;
35
- created_at?: string;
36
- }
37
- interface ServiceAccountGrantInput {
38
- grant_key: string;
39
- scope_type: ServiceAccountGrantScope;
40
- scope_id: string;
41
- }
42
- interface ServiceAccountGrantPage {
43
- items: ServiceAccountGrant[];
44
- }
45
- interface ServiceAccountCredential {
46
- id: string;
47
- service_account_id: string;
48
- name: string;
49
- audience: ServiceAccountAudience;
50
- permissions: string[];
51
- operation_ids: string[];
52
- dash_ids: string[];
53
- max_output_bytes: number;
54
- created_by: string;
55
- created_at: string;
56
- expires_at: string;
57
- last_used_at?: string;
58
- revoked_at?: string;
59
- }
60
- interface ServiceAccountCredentialPage {
61
- items: ServiceAccountCredential[];
62
- next_cursor?: string;
63
- has_more: boolean;
64
- }
65
- interface CreateServiceAccountInput {
66
- name: string;
67
- purpose: string;
68
- owner_user_id: string;
69
- reason: string;
70
- }
71
- interface UpdateServiceAccountInput extends CreateServiceAccountInput {
72
- enabled: boolean;
73
- expected_version: number;
74
- }
75
- interface ReplaceServiceAccountGrantsInput {
76
- grants: ServiceAccountGrantInput[];
77
- expected_version: number;
78
- reason: string;
79
- }
80
- type ServiceAccountCredentialTemplate = "read_only" | "full" | "custom";
81
- interface MintServiceAccountCredentialInput {
82
- name: string;
83
- audience: ServiceAccountAudience;
84
- /** Authority template; `custom` names the permissions to carry. Operations are derived server-side. */
85
- template: ServiceAccountCredentialTemplate;
86
- permissions?: string[];
87
- dash_ids: string[];
88
- expires_in_days: number;
89
- expected_version: number;
90
- reason: string;
91
- max_output_bytes?: number;
92
- }
93
- interface MintServiceAccountCredentialResponse {
94
- credential: ServiceAccountCredential;
95
- /** One-time presented secret. The server never returns it again. */
96
- secret: string;
97
- }
98
- interface RevokeServiceAccountCredentialInput {
99
- expected_version: number;
100
- reason: string;
101
- }
102
- interface ServiceAccountListParams {
103
- cursor?: string;
104
- limit?: number;
105
- }
106
- interface ServiceAccountRequestObservation {
107
- operation: ServiceAccountOperation;
108
- method: string;
109
- outcome: ServiceAccountRequestOutcome;
110
- status: number;
111
- requestId: string;
112
- durationMs: number;
113
- attempts: number;
114
- }
115
- interface CreateServiceAccountClientOptions {
116
- baseUrl: string;
117
- getAccessToken: () => Promise<string | null | undefined> | string | null | undefined;
118
- getHeaders?: () => Promise<HeadersInit | undefined> | HeadersInit | undefined;
119
- fetch?: typeof globalThis.fetch;
120
- /** Per attempt (default 10000). */
121
- timeoutMs?: number;
122
- /** Additional attempts for GET only (default 1). Mutations are never retried. */
123
- retryAttempts?: number;
124
- /** Cap on one retry delay (default 2000). */
125
- maxRetryDelayMs?: number;
126
- observe?: (event: ServiceAccountRequestObservation) => void;
127
- signal?: AbortSignal;
128
- }
129
- declare class ServiceAccountAPIError extends Error {
130
- readonly status: number;
131
- readonly code: string;
132
- readonly requestId: string;
133
- constructor(message: string, status: number, code: string, requestId: string);
134
- }
135
- declare function isServiceAccountAPIError(error: unknown): error is ServiceAccountAPIError;
136
- declare function createServiceAccountClient(options: CreateServiceAccountClientOptions): {
137
- readonly list: (params?: ServiceAccountListParams) => Promise<ServiceAccountPage>;
138
- readonly get: (serviceAccountID: string) => Promise<ServiceAccountEnvelope>;
139
- readonly create: (input: CreateServiceAccountInput) => Promise<ServiceAccountEnvelope>;
140
- readonly update: (serviceAccountID: string, input: UpdateServiceAccountInput) => Promise<ServiceAccountEnvelope>;
141
- readonly listGrants: (serviceAccountID: string) => Promise<ServiceAccountGrantPage>;
142
- readonly replaceGrants: (serviceAccountID: string, input: ReplaceServiceAccountGrantsInput) => Promise<ServiceAccountEnvelope>;
143
- readonly listCredentials: (serviceAccountID: string, params?: ServiceAccountListParams) => Promise<ServiceAccountCredentialPage>;
144
- readonly mintCredential: (serviceAccountID: string, input: MintServiceAccountCredentialInput) => Promise<MintServiceAccountCredentialResponse>;
145
- readonly revokeCredential: (serviceAccountID: string, credentialID: string, input: RevokeServiceAccountCredentialInput) => Promise<ServiceAccountEnvelope>;
146
- };
147
- type ServiceAccountClient = ReturnType<typeof createServiceAccountClient>;
148
-
149
- export { type CreateServiceAccountClientOptions, type CreateServiceAccountInput, type MintServiceAccountCredentialInput, type MintServiceAccountCredentialResponse, type ReplaceServiceAccountGrantsInput, type RevokeServiceAccountCredentialInput, SERVICE_ACCOUNTS_MANAGE_PERMISSION, SERVICE_ACCOUNTS_VIEW_PERMISSION, ServiceAccountAPIError, type ServiceAccountAudience, type ServiceAccountClient, type ServiceAccountCredential, type ServiceAccountCredentialPage, type ServiceAccountCredentialTemplate, type ServiceAccountEnvelope, type ServiceAccountGrant, type ServiceAccountGrantInput, type ServiceAccountGrantPage, type ServiceAccountGrantScope, type ServiceAccountListParams, type ServiceAccountOperation, type ServiceAccountPage, type ServiceAccountRequestObservation, type ServiceAccountRequestOutcome, type ServiceAccountView, type UpdateServiceAccountInput, createServiceAccountClient, isServiceAccountAPIError };
@@ -1,484 +0,0 @@
1
- // ../transport/src/index.ts
2
- var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([429, 502, 503, 504]);
3
- var TransportError = class extends Error {
4
- constructor(failure) {
5
- super(failure.message || failure.outcome);
6
- this.failure = failure;
7
- this.name = "TransportError";
8
- }
9
- failure;
10
- };
11
- function isTransportError(error) {
12
- return error instanceof TransportError || typeof error === "object" && error !== null && error.name === "TransportError";
13
- }
14
- function positiveNumber(value, label, fail) {
15
- if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) throw fail(`${label} must be a positive finite number`);
16
- return value;
17
- }
18
- function nonNegativeInteger(value, label, fail) {
19
- if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw fail(`${label} must be a non-negative safe integer`);
20
- return value;
21
- }
22
- function requiredString(value, label, fail) {
23
- const normalized = typeof value === "string" ? value.trim() : "";
24
- if (!normalized) throw fail(`${label} is required`);
25
- return normalized;
26
- }
27
- function backoffDelay(attempt, maxDelayMs) {
28
- const base = Math.min(250 * 2 ** Math.max(attempt, 0), maxDelayMs);
29
- return Math.floor(Math.random() * base);
30
- }
31
- function retryDelay(response, attempt, maxDelayMs) {
32
- const value = response.headers.get("Retry-After");
33
- if (!value) return backoffDelay(attempt, maxDelayMs);
34
- const seconds = Number(value);
35
- if (Number.isFinite(seconds)) return Math.min(Math.max(seconds * 1e3, 0), maxDelayMs);
36
- const date = Date.parse(value);
37
- return Number.isNaN(date) ? backoffDelay(attempt, maxDelayMs) : Math.min(Math.max(date - Date.now(), 0), maxDelayMs);
38
- }
39
- function wait(ms) {
40
- return ms <= 0 ? Promise.resolve() : new Promise((resolve) => setTimeout(resolve, ms));
41
- }
42
- function combineSignals(...signals) {
43
- const active = signals.filter((signal) => Boolean(signal));
44
- if (active.length === 0) return void 0;
45
- if (active.length === 1) return active[0];
46
- return AbortSignal.any(active);
47
- }
48
- function isRetryableRequest(method, headers, retry) {
49
- if (method === "GET") return true;
50
- return retry.idempotentWrites === true && headers.has("Idempotency-Key");
51
- }
52
- async function performRequest(transport, url, init, observe, decode) {
53
- const method = (init.method ?? "GET").toUpperCase();
54
- const headers = new Headers(init.headers);
55
- const startedAt = Date.now();
56
- const retryable = isRetryableRequest(method, headers, transport.retry);
57
- const maxAttempts = retryable ? transport.retry.attempts : 0;
58
- let attempts = 0;
59
- let status = 0;
60
- let requestId = "";
61
- let outcome = "network_error";
62
- const fail = (failure) => {
63
- outcome = failure.outcome;
64
- throw new TransportError({ requestId, ...failure });
65
- };
66
- try {
67
- let response;
68
- for (let attempt = 0; attempt <= maxAttempts; attempt += 1) {
69
- attempts = attempt + 1;
70
- const controller = new AbortController();
71
- const timeout = setTimeout(() => controller.abort(), transport.timeoutMs);
72
- try {
73
- response = await transport.fetch(url, {
74
- ...init,
75
- method,
76
- headers,
77
- signal: combineSignals(transport.signal, init.signal, controller.signal)
78
- });
79
- } catch {
80
- if (controller.signal.aborted) fail({ outcome: "timeout", status: 0, code: "", message: "" });
81
- if (transport.signal?.aborted || init.signal?.aborted) fail({ outcome: "cancelled", status: 0, code: "", message: "" });
82
- if (attempt < maxAttempts) {
83
- await wait(backoffDelay(attempt, transport.retry.maxDelayMs));
84
- continue;
85
- }
86
- fail({ outcome: "network_error", status: 0, code: "", message: "" });
87
- } finally {
88
- clearTimeout(timeout);
89
- }
90
- if (!RETRYABLE_STATUSES.has(response.status) || attempt === maxAttempts) break;
91
- await wait(retryDelay(response, attempt, transport.retry.maxDelayMs));
92
- }
93
- if (!response) return fail({ outcome: "network_error", status: 0, code: "", message: "" });
94
- status = response.status;
95
- requestId = response.headers.get("X-Request-ID") ?? "";
96
- if (!response.ok) {
97
- const envelope = await response.json().catch(() => ({}));
98
- if (typeof envelope.request_id === "string") requestId = envelope.request_id;
99
- return fail({
100
- outcome: "api_error",
101
- status,
102
- code: typeof envelope.code === "string" ? envelope.code : "",
103
- message: typeof envelope.message === "string" ? envelope.message : "",
104
- requestId
105
- });
106
- }
107
- let payload;
108
- try {
109
- if (status === 204) {
110
- payload = void 0;
111
- } else {
112
- const text = await response.text();
113
- const parsed = text ? JSON.parse(text) : void 0;
114
- payload = decode ? decode(parsed) : parsed;
115
- }
116
- } catch {
117
- return fail({ outcome: "invalid_response", status, code: "", message: "", requestId });
118
- }
119
- outcome = "success";
120
- return { payload, status, requestId };
121
- } finally {
122
- if (observe) {
123
- try {
124
- observe({ method, outcome, status, requestId, durationMs: Math.max(0, Date.now() - startedAt), attempts });
125
- } catch {
126
- }
127
- }
128
- }
129
- }
130
-
131
- // src/service-accounts.ts
132
- var SERVICE_ACCOUNTS_VIEW_PERMISSION = "platform.service_accounts.view";
133
- var SERVICE_ACCOUNTS_MANAGE_PERMISSION = "platform.service_accounts.manage";
134
- var ServiceAccountAPIError = class extends Error {
135
- constructor(message, status, code, requestId) {
136
- super(message);
137
- this.status = status;
138
- this.code = code;
139
- this.requestId = requestId;
140
- this.name = "ServiceAccountAPIError";
141
- }
142
- status;
143
- code;
144
- requestId;
145
- };
146
- function isServiceAccountAPIError(error) {
147
- return error instanceof ServiceAccountAPIError || typeof error === "object" && error !== null && error.name === "ServiceAccountAPIError" && typeof error.status === "number" && typeof error.code === "string" && typeof error.requestId === "string";
148
- }
149
- var basePath = "/api/v1/platform/identity/service-accounts";
150
- var uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
151
- var credentialIDPattern = /^[0-9a-f]{32}$/;
152
- var timestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
153
- var secretPattern = /^mml_sa_([0-9a-f]{32})_([0-9a-f]{64})$/;
154
- var configurationError = (message) => new Error(message);
155
- function invalidResponse() {
156
- throw new Error("invalid Service Account response");
157
- }
158
- function objectValue(value) {
159
- if (typeof value !== "object" || value === null || Array.isArray(value)) invalidResponse();
160
- return value;
161
- }
162
- function responseString(value, options = {}) {
163
- if (value === void 0 && options.optional) return void 0;
164
- if (typeof value !== "string" || value.length === 0) invalidResponse();
165
- if (options.pattern && !options.pattern.test(value)) invalidResponse();
166
- if (options.timestamp && (!timestampPattern.test(value) || !Number.isFinite(Date.parse(value)))) invalidResponse();
167
- return value;
168
- }
169
- function responseStrings(value) {
170
- if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || entry.length === 0)) {
171
- invalidResponse();
172
- }
173
- return [...value];
174
- }
175
- function positiveIntegerResponse(value) {
176
- if (!Number.isSafeInteger(value) || Number(value) < 1) invalidResponse();
177
- return Number(value);
178
- }
179
- function parseServiceAccount(value) {
180
- const account = objectValue(value);
181
- if (typeof account.enabled !== "boolean") invalidResponse();
182
- return {
183
- id: responseString(account.id, { pattern: uuidPattern }),
184
- name: responseString(account.name),
185
- purpose: responseString(account.purpose),
186
- owner_user_id: responseString(account.owner_user_id),
187
- enabled: account.enabled,
188
- version: positiveIntegerResponse(account.version),
189
- created_by: responseString(account.created_by),
190
- updated_by: responseString(account.updated_by),
191
- created_at: responseString(account.created_at, { timestamp: true }),
192
- updated_at: responseString(account.updated_at, { timestamp: true })
193
- };
194
- }
195
- function parseServiceAccountEnvelope(value) {
196
- return { service_account: parseServiceAccount(objectValue(value).service_account) };
197
- }
198
- function parsePage(value, parseItem) {
199
- const page = objectValue(value);
200
- if (!Array.isArray(page.items) || typeof page.has_more !== "boolean") invalidResponse();
201
- const nextCursor = responseString(page.next_cursor, { optional: true });
202
- if (Boolean(nextCursor) !== page.has_more) invalidResponse();
203
- return { items: page.items.map(parseItem), next_cursor: nextCursor, has_more: page.has_more };
204
- }
205
- function parseGrant(value) {
206
- const grant = objectValue(value);
207
- return {
208
- grant_key: responseString(grant.grant_key),
209
- scope_type: responseString(grant.scope_type),
210
- scope_id: responseString(grant.scope_id),
211
- id: responseString(grant.id, { optional: true }),
212
- subject_id: responseString(grant.subject_id, { optional: true }),
213
- subject_type: responseString(grant.subject_type, { optional: true }),
214
- granted_by: responseString(grant.granted_by, { optional: true }),
215
- created_at: responseString(grant.created_at, { optional: true, timestamp: true })
216
- };
217
- }
218
- function parseCredential(value) {
219
- const credential = objectValue(value);
220
- const audience = responseString(credential.audience);
221
- if (audience !== "platform" && !audience.startsWith("dash:")) invalidResponse();
222
- return {
223
- id: responseString(credential.id, { pattern: credentialIDPattern }),
224
- service_account_id: responseString(credential.service_account_id, { pattern: uuidPattern }),
225
- name: responseString(credential.name),
226
- audience,
227
- permissions: responseStrings(credential.permissions),
228
- operation_ids: responseStrings(credential.operation_ids),
229
- dash_ids: responseStrings(credential.dash_ids),
230
- max_output_bytes: positiveIntegerResponse(credential.max_output_bytes),
231
- created_by: responseString(credential.created_by),
232
- created_at: responseString(credential.created_at, { timestamp: true }),
233
- expires_at: responseString(credential.expires_at, { timestamp: true }),
234
- last_used_at: responseString(credential.last_used_at, { optional: true, timestamp: true }),
235
- revoked_at: responseString(credential.revoked_at, { optional: true, timestamp: true })
236
- };
237
- }
238
- function parseMintedCredential(value) {
239
- const minted = objectValue(value);
240
- const credential = parseCredential(minted.credential);
241
- const secret = responseString(minted.secret);
242
- const match = secret.match(secretPattern);
243
- if (!match || match[1] !== credential.id) invalidResponse();
244
- return { credential, secret };
245
- }
246
- function canonicalID(value, label, pattern) {
247
- const id = requiredString(value, label, configurationError);
248
- if (!pattern.test(id)) throw configurationError(`${label} is invalid`);
249
- return id;
250
- }
251
- function version(value) {
252
- if (!Number.isSafeInteger(value) || value < 1) throw configurationError("expected_version must be a positive integer");
253
- return value;
254
- }
255
- function reason(value) {
256
- const normalized = requiredString(value, "reason", configurationError);
257
- if (normalized.length > 500) throw configurationError("reason must be 500 characters or fewer");
258
- return normalized;
259
- }
260
- function accountFields(input) {
261
- const name = requiredString(input.name, "name", configurationError);
262
- const purpose = requiredString(input.purpose, "purpose", configurationError);
263
- const ownerUserID = requiredString(input.owner_user_id, "owner_user_id", configurationError);
264
- if (name.length > 64) throw configurationError("name must be 64 characters or fewer");
265
- if (purpose.length > 500) throw configurationError("purpose must be 500 characters or fewer");
266
- return { name, purpose, owner_user_id: ownerUserID, reason: reason(input.reason) };
267
- }
268
- function listQuery(params) {
269
- const query = new URLSearchParams();
270
- const cursor = params.cursor?.trim();
271
- if (cursor) query.set("cursor", cursor);
272
- if (params.limit !== void 0) {
273
- const limit = positiveNumber(params.limit, "limit", configurationError);
274
- if (!Number.isSafeInteger(limit) || limit > 100) throw configurationError("limit must be an integer from 1 to 100");
275
- query.set("limit", String(limit));
276
- }
277
- return query.size ? `?${query}` : "";
278
- }
279
- function grantInput(value) {
280
- const grantKey = requiredString(value.grant_key, "grant_key", configurationError);
281
- if (value.scope_type !== "platform" && value.scope_type !== "dash_id") {
282
- throw configurationError("scope_type must be platform or dash_id");
283
- }
284
- const scopeID = requiredString(value.scope_id, "scope_id", configurationError);
285
- if (value.scope_type === "platform" && scopeID !== "*") {
286
- throw configurationError('platform grants must use scope_id "*"');
287
- }
288
- if (value.scope_type === "dash_id" && scopeID === "*") {
289
- throw configurationError("service-account Dash grants must name one Dash");
290
- }
291
- return { grant_key: grantKey, scope_type: value.scope_type, scope_id: scopeID };
292
- }
293
- function mintInput(value) {
294
- const name = requiredString(value.name, "name", configurationError);
295
- if (name.length > 64) throw configurationError("name must be 64 characters or fewer");
296
- const audience = requiredString(value.audience, "audience", configurationError);
297
- if (audience !== "platform" && !audience.startsWith("dash:")) {
298
- throw configurationError("audience must be platform or dash:<dash_id>");
299
- }
300
- if (value.template !== "read_only" && value.template !== "full" && value.template !== "custom") {
301
- throw configurationError("template must be read_only, full or custom");
302
- }
303
- const permissions = [...new Set((value.permissions ?? []).map((item) => requiredString(item, "permission", configurationError)))];
304
- if (value.template === "custom" && (permissions.length < 1 || permissions.length > 64)) {
305
- throw configurationError("a custom template must name 1 to 64 permissions");
306
- }
307
- const dashIDs = [...new Set(value.dash_ids.map((item) => requiredString(item, "dash_id", configurationError)))];
308
- if (dashIDs.length > 32 || dashIDs.includes("*")) throw configurationError("dash_ids must name at most 32 explicit Dashes");
309
- if (audience.startsWith("dash:") && (dashIDs.length !== 1 || dashIDs[0] !== audience.slice(5))) {
310
- throw configurationError("a Dash audience requires exactly its route Dash in dash_ids");
311
- }
312
- if (!Number.isSafeInteger(value.expires_in_days) || value.expires_in_days < 1 || value.expires_in_days > 30) {
313
- throw configurationError("expires_in_days must be an integer from 1 to 30");
314
- }
315
- if (value.max_output_bytes !== void 0 && (!Number.isSafeInteger(value.max_output_bytes) || value.max_output_bytes < 1 || value.max_output_bytes > 1048576)) {
316
- throw configurationError("max_output_bytes must be an integer from 1 to 1048576");
317
- }
318
- return {
319
- name,
320
- audience,
321
- template: value.template,
322
- ...value.template === "custom" ? { permissions } : {},
323
- dash_ids: dashIDs,
324
- expires_in_days: value.expires_in_days,
325
- expected_version: version(value.expected_version),
326
- reason: reason(value.reason),
327
- ...value.max_output_bytes === void 0 ? {} : { max_output_bytes: value.max_output_bytes }
328
- };
329
- }
330
- function createServiceAccountClient(options) {
331
- const baseUrl = requiredString(options.baseUrl, "baseUrl", configurationError).replace(/\/+$/, "");
332
- const transport = {
333
- fetch: (input, init) => (options.fetch ?? globalThis.fetch)(input, init),
334
- timeoutMs: positiveNumber(options.timeoutMs ?? 1e4, "timeoutMs", configurationError),
335
- retry: {
336
- attempts: nonNegativeInteger(options.retryAttempts ?? 1, "retryAttempts", configurationError),
337
- maxDelayMs: positiveNumber(options.maxRetryDelayMs ?? 2e3, "maxRetryDelayMs", configurationError),
338
- idempotentWrites: false
339
- },
340
- signal: options.signal
341
- };
342
- async function request(operation, path, decode, init = {}) {
343
- const method = (init.method ?? "GET").toUpperCase();
344
- const report = options.observe && ((event) => {
345
- options.observe?.({ operation, ...event });
346
- });
347
- let headers;
348
- try {
349
- const accessToken = requiredString(await options.getAccessToken() ?? "", "access token", configurationError);
350
- headers = new Headers(await options.getHeaders?.());
351
- new Headers(init.headers).forEach((value, key) => headers.set(key, value));
352
- headers.set("Authorization", `Bearer ${accessToken}`);
353
- headers.set("Accept", "application/json");
354
- if (init.body !== void 0) headers.set("Content-Type", "application/json");
355
- } catch (error) {
356
- try {
357
- report?.({ method, outcome: "network_error", status: 0, requestId: "", durationMs: 0, attempts: 0 });
358
- } catch {
359
- }
360
- throw error;
361
- }
362
- try {
363
- const result = await performRequest(
364
- transport,
365
- `${baseUrl}${path}`,
366
- { ...init, method, headers, cache: "no-store" },
367
- report,
368
- decode
369
- );
370
- return result.payload;
371
- } catch (error) {
372
- if (!isTransportError(error)) throw error;
373
- const { outcome, status, code, message, requestId } = error.failure;
374
- switch (outcome) {
375
- case "timeout":
376
- throw new ServiceAccountAPIError("Service Account request timed out", 0, "service_account_request_timeout", "");
377
- case "cancelled":
378
- throw new ServiceAccountAPIError("Service Account request was cancelled", 0, "service_account_request_cancelled", "");
379
- case "network_error":
380
- throw new ServiceAccountAPIError("Service Account service is unavailable", 0, "service_account_service_unavailable", "");
381
- case "invalid_response":
382
- throw new ServiceAccountAPIError("Service Account service returned an invalid response", status, "service_account_invalid_response", requestId);
383
- case "api_error":
384
- throw new ServiceAccountAPIError(message || code || "Service Account request failed", status, code, requestId);
385
- }
386
- }
387
- }
388
- function accountPath(serviceAccountID) {
389
- return `${basePath}/${canonicalID(serviceAccountID, "serviceAccountId", uuidPattern)}`;
390
- }
391
- return {
392
- list(params = {}) {
393
- return request(
394
- "platform.identity.service_accounts.list",
395
- `${basePath}${listQuery(params)}`,
396
- (payload) => parsePage(payload, parseServiceAccount)
397
- );
398
- },
399
- get(serviceAccountID) {
400
- return request(
401
- "platform.identity.service_accounts.get",
402
- accountPath(serviceAccountID),
403
- parseServiceAccountEnvelope
404
- );
405
- },
406
- create(input) {
407
- return request(
408
- "platform.identity.service_accounts.create",
409
- basePath,
410
- parseServiceAccountEnvelope,
411
- { method: "POST", body: JSON.stringify(accountFields(input)) }
412
- );
413
- },
414
- update(serviceAccountID, input) {
415
- const fields = accountFields(input);
416
- return request(
417
- "platform.identity.service_accounts.update",
418
- accountPath(serviceAccountID),
419
- parseServiceAccountEnvelope,
420
- {
421
- method: "PUT",
422
- body: JSON.stringify({ ...fields, enabled: input.enabled, expected_version: version(input.expected_version) })
423
- }
424
- );
425
- },
426
- listGrants(serviceAccountID) {
427
- return request(
428
- "platform.identity.service_accounts.grants.list",
429
- `${accountPath(serviceAccountID)}/grants`,
430
- (payload) => {
431
- const page = objectValue(payload);
432
- if (!Array.isArray(page.items)) invalidResponse();
433
- return { items: page.items.map(parseGrant) };
434
- }
435
- );
436
- },
437
- replaceGrants(serviceAccountID, input) {
438
- if (!Array.isArray(input.grants) || input.grants.length > 32) {
439
- throw configurationError("grants must contain at most 32 entries");
440
- }
441
- return request(
442
- "platform.identity.service_accounts.grants.replace",
443
- `${accountPath(serviceAccountID)}/grants`,
444
- parseServiceAccountEnvelope,
445
- {
446
- method: "PUT",
447
- body: JSON.stringify({
448
- grants: input.grants.map(grantInput),
449
- expected_version: version(input.expected_version),
450
- reason: reason(input.reason)
451
- })
452
- }
453
- );
454
- },
455
- listCredentials(serviceAccountID, params = {}) {
456
- return request(
457
- "platform.identity.service_accounts.credentials.list",
458
- `${accountPath(serviceAccountID)}/credentials${listQuery(params)}`,
459
- (payload) => parsePage(payload, parseCredential)
460
- );
461
- },
462
- mintCredential(serviceAccountID, input) {
463
- return request(
464
- "platform.identity.service_accounts.credentials.mint",
465
- `${accountPath(serviceAccountID)}/credentials`,
466
- parseMintedCredential,
467
- { method: "POST", body: JSON.stringify(mintInput(input)) }
468
- );
469
- },
470
- revokeCredential(serviceAccountID, credentialID, input) {
471
- return request(
472
- "platform.identity.service_accounts.credentials.revoke",
473
- `${accountPath(serviceAccountID)}/credentials/${canonicalID(credentialID, "credentialId", credentialIDPattern)}`,
474
- parseServiceAccountEnvelope,
475
- {
476
- method: "DELETE",
477
- body: JSON.stringify({ expected_version: version(input.expected_version), reason: reason(input.reason) })
478
- }
479
- );
480
- }
481
- };
482
- }
483
-
484
- export { SERVICE_ACCOUNTS_MANAGE_PERMISSION, SERVICE_ACCOUNTS_VIEW_PERMISSION, ServiceAccountAPIError, createServiceAccountClient, isServiceAccountAPIError };