@plantops/iam-client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +11 -0
  2. package/dist/auth.d.ts +135 -0
  3. package/dist/auth.d.ts.map +1 -0
  4. package/dist/auth.js +168 -0
  5. package/dist/client.d.ts +110 -0
  6. package/dist/client.d.ts.map +1 -0
  7. package/dist/client.js +135 -0
  8. package/dist/endpoints/applications.d.ts +48 -0
  9. package/dist/endpoints/applications.d.ts.map +1 -0
  10. package/dist/endpoints/applications.js +30 -0
  11. package/dist/endpoints/audit.d.ts +43 -0
  12. package/dist/endpoints/audit.d.ts.map +1 -0
  13. package/dist/endpoints/audit.js +41 -0
  14. package/dist/endpoints/auth.d.ts +56 -0
  15. package/dist/endpoints/auth.d.ts.map +1 -0
  16. package/dist/endpoints/auth.js +88 -0
  17. package/dist/endpoints/authz.d.ts +31 -0
  18. package/dist/endpoints/authz.d.ts.map +1 -0
  19. package/dist/endpoints/authz.js +39 -0
  20. package/dist/endpoints/bindings.d.ts +17 -0
  21. package/dist/endpoints/bindings.d.ts.map +1 -0
  22. package/dist/endpoints/bindings.js +16 -0
  23. package/dist/endpoints/clients.d.ts +35 -0
  24. package/dist/endpoints/clients.d.ts.map +1 -0
  25. package/dist/endpoints/clients.js +31 -0
  26. package/dist/endpoints/entitlements.d.ts +40 -0
  27. package/dist/endpoints/entitlements.d.ts.map +1 -0
  28. package/dist/endpoints/entitlements.js +43 -0
  29. package/dist/endpoints/index.d.ts +22 -0
  30. package/dist/endpoints/index.d.ts.map +1 -0
  31. package/dist/endpoints/index.js +21 -0
  32. package/dist/endpoints/navigation.d.ts +25 -0
  33. package/dist/endpoints/navigation.d.ts.map +1 -0
  34. package/dist/endpoints/navigation.js +24 -0
  35. package/dist/endpoints/roles.d.ts +25 -0
  36. package/dist/endpoints/roles.d.ts.map +1 -0
  37. package/dist/endpoints/roles.js +21 -0
  38. package/dist/endpoints/scopes.d.ts +19 -0
  39. package/dist/endpoints/scopes.d.ts.map +1 -0
  40. package/dist/endpoints/scopes.js +18 -0
  41. package/dist/endpoints/service-accounts.d.ts +19 -0
  42. package/dist/endpoints/service-accounts.d.ts.map +1 -0
  43. package/dist/endpoints/service-accounts.js +19 -0
  44. package/dist/endpoints/users.d.ts +39 -0
  45. package/dist/endpoints/users.d.ts.map +1 -0
  46. package/dist/endpoints/users.js +24 -0
  47. package/dist/errors.d.ts +70 -0
  48. package/dist/errors.d.ts.map +1 -0
  49. package/dist/errors.js +113 -0
  50. package/dist/http.d.ts +113 -0
  51. package/dist/http.d.ts.map +1 -0
  52. package/dist/http.js +164 -0
  53. package/dist/index.d.ts +36 -0
  54. package/dist/index.d.ts.map +1 -0
  55. package/dist/index.js +35 -0
  56. package/dist/lib/iam-client.d.ts +2 -0
  57. package/dist/lib/iam-client.d.ts.map +1 -0
  58. package/dist/lib/iam-client.js +3 -0
  59. package/dist/resolve-cache.d.ts +65 -0
  60. package/dist/resolve-cache.d.ts.map +1 -0
  61. package/dist/resolve-cache.js +98 -0
  62. package/dist/testing/mock-server.d.ts +65 -0
  63. package/dist/testing/mock-server.d.ts.map +1 -0
  64. package/dist/testing/mock-server.js +107 -0
  65. package/dist/tsconfig.lib.tsbuildinfo +1 -0
  66. package/package.json +40 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"roles.d.ts","sourceRoot":"","sources":["../../src/endpoints/roles.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EACV,iBAAiB,EACjB,SAAS,EACT,eAAe,EACf,yBAAyB,EACzB,OAAO,EACP,uBAAuB,EACvB,yBAAyB,EACzB,iBAAiB,EAClB,MAAM,qBAAqB,CAAC;AAG7B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE5C,MAAM,WAAW,QAAQ;IACvB,MAAM,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAClD,IAAI,CAAC,KAAK,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,CAAC;IAC3D,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC9D,+EAA+E;IAC/E,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClC;;;OAGG;IACH,iBAAiB,IAAI,OAAO,CAAC,yBAAyB,CAAC,CAAC;IACxD,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,uBAAuB,CAAC,CAAC;IAC1D,cAAc,CACZ,EAAE,EAAE,MAAM,EACV,IAAI,EAAE,yBAAyB,GAC9B,OAAO,CAAC,uBAAuB,CAAC,CAAC;CACrC;AAED,wBAAgB,cAAc,CAAC,OAAO,EAAE,SAAS,GAAG,QAAQ,CAgB3D"}
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `/iam/roles/*` — the WHAT dimension, Doc 06 §7.
3
+ *
4
+ * A role is a bag of permissions drawn from the tenant's *enabled* applications;
5
+ * `setPermissions` is a PUT because the screen behind it is a checklist, and a
6
+ * checklist submits a set, not a diff.
7
+ */
8
+ import { IAM_ROUTE_PREFIX } from '@plantops/contracts';
9
+ export function rolesEndpoints(request) {
10
+ const base = `${IAM_ROUTE_PREFIX}/roles`;
11
+ const at = (id, suffix = '') => `${base}/${encodeURIComponent(id)}${suffix}`;
12
+ return {
13
+ create: (body) => request({ method: 'POST', path: base, body }),
14
+ list: (query) => request({ method: 'GET', path: base, query: { ...query } }),
15
+ update: (id, body) => request({ method: 'PATCH', path: at(id), body }),
16
+ remove: (id) => request({ method: 'DELETE', path: at(id) }),
17
+ permissionCatalog: () => request({ method: 'GET', path: `${base}/permission-catalog` }),
18
+ permissions: (id) => request({ method: 'GET', path: at(id, '/permissions') }),
19
+ setPermissions: (id, body) => request({ method: 'PUT', path: at(id, '/permissions'), body }),
20
+ };
21
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `/iam/scopes/*` — the WHERE dimension, Doc 06 §6.
3
+ *
4
+ * The tree comes back whole rather than paginated: it is one tenant's org
5
+ * structure, it is drawn as a tree, and a page of nodes is not a tree
6
+ * (`ScopeTreeResponse`).
7
+ */
8
+ import type { CreateScopeNodeRequest, ScopeNodeDTO, ScopeTreeResponse, UpdateScopeNodeRequest } from '@plantops/contracts';
9
+ import type { Requester } from '../http.js';
10
+ export interface ScopesApi {
11
+ create(body: CreateScopeNodeRequest): Promise<ScopeNodeDTO>;
12
+ tree(): Promise<ScopeTreeResponse>;
13
+ /** Rename, or move — a move rewrites the subtree's paths and invalidates. */
14
+ update(id: string, body: UpdateScopeNodeRequest): Promise<ScopeNodeDTO>;
15
+ /** Refused while bindings still hang off the node (Doc 06 §6). */
16
+ remove(id: string): Promise<void>;
17
+ }
18
+ export declare function scopesEndpoints(request: Requester): ScopesApi;
19
+ //# sourceMappingURL=scopes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scopes.d.ts","sourceRoot":"","sources":["../../src/endpoints/scopes.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EACV,sBAAsB,EACtB,YAAY,EACZ,iBAAiB,EACjB,sBAAsB,EACvB,MAAM,qBAAqB,CAAC;AAG7B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE5C,MAAM,WAAW,SAAS;IACxB,MAAM,CAAC,IAAI,EAAE,sBAAsB,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;IAC5D,IAAI,IAAI,OAAO,CAAC,iBAAiB,CAAC,CAAC;IACnC,6EAA6E;IAC7E,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,sBAAsB,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;IACxE,kEAAkE;IAClE,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACnC;AAED,wBAAgB,eAAe,CAAC,OAAO,EAAE,SAAS,GAAG,SAAS,CAU7D"}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `/iam/scopes/*` — the WHERE dimension, Doc 06 §6.
3
+ *
4
+ * The tree comes back whole rather than paginated: it is one tenant's org
5
+ * structure, it is drawn as a tree, and a page of nodes is not a tree
6
+ * (`ScopeTreeResponse`).
7
+ */
8
+ import { IAM_ROUTE_PREFIX } from '@plantops/contracts';
9
+ export function scopesEndpoints(request) {
10
+ const base = `${IAM_ROUTE_PREFIX}/scopes`;
11
+ const at = (id) => `${base}/${encodeURIComponent(id)}`;
12
+ return {
13
+ create: (body) => request({ method: 'POST', path: base, body }),
14
+ tree: () => request({ method: 'GET', path: base }),
15
+ update: (id, body) => request({ method: 'PATCH', path: at(id), body }),
16
+ remove: (id) => request({ method: 'DELETE', path: at(id) }),
17
+ };
18
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `/iam/service-accounts/*` — non-human subjects, Doc 06 §10.
3
+ *
4
+ * `create` and `rotate` are the only two calls in this library that return a
5
+ * secret, and they return it exactly once (`ServiceAccountSecretDTO`). A caller
6
+ * that discards it cannot ask for it again — only rotate, which invalidates the
7
+ * one it lost.
8
+ */
9
+ import type { CreateServiceAccountRequest, Paginated, PaginationQuery, ServiceAccountDTO, ServiceAccountSecretDTO, UpdateServiceAccountRequest } from '@plantops/contracts';
10
+ import type { Requester } from '../http.js';
11
+ export interface ServiceAccountsApi {
12
+ create(body: CreateServiceAccountRequest): Promise<ServiceAccountSecretDTO>;
13
+ list(query?: PaginationQuery): Promise<Paginated<ServiceAccountDTO>>;
14
+ rotate(id: string): Promise<ServiceAccountSecretDTO>;
15
+ /** Revoke or reactivate. */
16
+ update(id: string, body: UpdateServiceAccountRequest): Promise<ServiceAccountDTO>;
17
+ }
18
+ export declare function serviceAccountsEndpoints(request: Requester): ServiceAccountsApi;
19
+ //# sourceMappingURL=service-accounts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-accounts.d.ts","sourceRoot":"","sources":["../../src/endpoints/service-accounts.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EACV,2BAA2B,EAC3B,SAAS,EACT,eAAe,EACf,iBAAiB,EACjB,uBAAuB,EACvB,2BAA2B,EAC5B,MAAM,qBAAqB,CAAC;AAG7B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE5C,MAAM,WAAW,kBAAkB;IACjC,MAAM,CAAC,IAAI,EAAE,2BAA2B,GAAG,OAAO,CAAC,uBAAuB,CAAC,CAAC;IAC5E,IAAI,CAAC,KAAK,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,SAAS,CAAC,iBAAiB,CAAC,CAAC,CAAC;IACrE,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,uBAAuB,CAAC,CAAC;IACrD,4BAA4B;IAC5B,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,2BAA2B,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;CACnF;AAED,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,SAAS,GAAG,kBAAkB,CAW/E"}
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `/iam/service-accounts/*` — non-human subjects, Doc 06 §10.
3
+ *
4
+ * `create` and `rotate` are the only two calls in this library that return a
5
+ * secret, and they return it exactly once (`ServiceAccountSecretDTO`). A caller
6
+ * that discards it cannot ask for it again — only rotate, which invalidates the
7
+ * one it lost.
8
+ */
9
+ import { IAM_ROUTE_PREFIX } from '@plantops/contracts';
10
+ export function serviceAccountsEndpoints(request) {
11
+ const base = `${IAM_ROUTE_PREFIX}/service-accounts`;
12
+ const at = (id, suffix = '') => `${base}/${encodeURIComponent(id)}${suffix}`;
13
+ return {
14
+ create: (body) => request({ method: 'POST', path: base, body }),
15
+ list: (query) => request({ method: 'GET', path: base, query: { ...query } }),
16
+ rotate: (id) => request({ method: 'POST', path: at(id, '/rotate') }),
17
+ update: (id, body) => request({ method: 'PATCH', path: at(id), body }),
18
+ };
19
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * `/iam/users/*` — the WHO dimension, Doc 06 §8.
3
+ *
4
+ * Note what `bulk` returns: a `200` with a per-row report, not a `201` and not a
5
+ * `207`. Valid rows commit even when others do not, so the response is the only
6
+ * place a caller learns which addresses landed — see `BulkUserUploadResponse`.
7
+ */
8
+ import type { BulkUserUploadRequest, BulkUserUploadResponse, CreateUserRequest, Paginated, PaginationQuery, UpdateUserRequest, UserByRoleDTO, UserDetailDTO, UserDTO, UserStatus } from '@plantops/contracts';
9
+ import type { Requester } from '../http.js';
10
+ /**
11
+ * `GET /iam/users`'s filters (Doc 06 §8, Doc 09 §3.3).
12
+ *
13
+ * Declared here rather than imported: `@plantops/contracts` types the bodies
14
+ * and the responses of this surface, not every query string, and the roadmap
15
+ * freezes `contracts` for this session. `q` is the free-text search over name
16
+ * and email; `status` is the "locked users" filter the console opens with.
17
+ */
18
+ export interface UsersQuery extends PaginationQuery {
19
+ status?: UserStatus;
20
+ q?: string;
21
+ }
22
+ export interface UsersApi {
23
+ create(body: CreateUserRequest): Promise<UserDTO>;
24
+ list(query?: UsersQuery): Promise<Paginated<UserDTO>>;
25
+ /** Profile plus the bindings behind it. */
26
+ detail(id: string): Promise<UserDetailDTO>;
27
+ /** Update, lock, unlock or disable (Doc 03 §8). */
28
+ update(id: string, body: UpdateUserRequest): Promise<UserDetailDTO>;
29
+ /** The roster upload and its per-row report. At most 500 rows. */
30
+ bulk(body: BulkUserUploadRequest): Promise<BulkUserUploadResponse>;
31
+ /**
32
+ * "Users by Role" — a holder appears once, with every scope they hold the
33
+ * role at gathered into `scopes`, expired bindings flagged rather than
34
+ * dropped.
35
+ */
36
+ byRole(roleId: string, query?: PaginationQuery): Promise<Paginated<UserByRoleDTO>>;
37
+ }
38
+ export declare function usersEndpoints(request: Requester): UsersApi;
39
+ //# sourceMappingURL=users.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"users.d.ts","sourceRoot":"","sources":["../../src/endpoints/users.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EACV,qBAAqB,EACrB,sBAAsB,EACtB,iBAAiB,EACjB,SAAS,EACT,eAAe,EACf,iBAAiB,EACjB,aAAa,EACb,aAAa,EACb,OAAO,EACP,UAAU,EACX,MAAM,qBAAqB,CAAC;AAG7B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE5C;;;;;;;GAOG;AACH,MAAM,WAAW,UAAW,SAAQ,eAAe;IACjD,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,CAAC,CAAC,EAAE,MAAM,CAAC;CACZ;AAED,MAAM,WAAW,QAAQ;IACvB,MAAM,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAClD,IAAI,CAAC,KAAK,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,CAAC;IACtD,2CAA2C;IAC3C,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;IAC3C,mDAAmD;IACnD,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;IACpE,kEAAkE;IAClE,IAAI,CAAC,IAAI,EAAE,qBAAqB,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAAC;IACnE;;;;OAIG;IACH,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,SAAS,CAAC,aAAa,CAAC,CAAC,CAAC;CACpF;AAED,wBAAgB,cAAc,CAAC,OAAO,EAAE,SAAS,GAAG,QAAQ,CAiB3D"}
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `/iam/users/*` — the WHO dimension, Doc 06 §8.
3
+ *
4
+ * Note what `bulk` returns: a `200` with a per-row report, not a `201` and not a
5
+ * `207`. Valid rows commit even when others do not, so the response is the only
6
+ * place a caller learns which addresses landed — see `BulkUserUploadResponse`.
7
+ */
8
+ import { IAM_ROUTE_PREFIX } from '@plantops/contracts';
9
+ export function usersEndpoints(request) {
10
+ const base = `${IAM_ROUTE_PREFIX}/users`;
11
+ const at = (id) => `${base}/${encodeURIComponent(id)}`;
12
+ return {
13
+ create: (body) => request({ method: 'POST', path: base, body }),
14
+ list: (query) => request({ method: 'GET', path: base, query: { ...query } }),
15
+ detail: (id) => request({ method: 'GET', path: at(id) }),
16
+ update: (id, body) => request({ method: 'PATCH', path: at(id), body }),
17
+ bulk: (body) => request({ method: 'POST', path: `${base}/bulk`, body }),
18
+ byRole: (roleId, query) => request({
19
+ method: 'GET',
20
+ path: `${base}/by-role/${encodeURIComponent(roleId)}`,
21
+ query: { ...query },
22
+ }),
23
+ };
24
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * What a call through this client throws (Doc 06 §2).
3
+ *
4
+ * Two classes, because a caller has two genuinely different decisions to make.
5
+ * {@link IamApiError} means the IAM answered and refused: there is a status, a
6
+ * closed-table `code` to branch on, and a `requestId` that correlates the
7
+ * refusal with the server's logs and its audit trail. {@link IamTransportError}
8
+ * means no answer came back at all — DNS, TLS, a cut connection, a timeout, or
9
+ * a body that was not the JSON it claimed to be. The first is a decision the
10
+ * server made about the request; the second is not, and retrying it is
11
+ * sometimes right where retrying the first never is.
12
+ *
13
+ * Both extend {@link IamClientError} so that `catch (e) { if (e instanceof
14
+ * IamClientError) … }` covers everything this library throws, and nothing else.
15
+ */
16
+ import { IamErrorCode, type IamErrorDetail } from '@plantops/contracts';
17
+ /** Base of every error this library throws. */
18
+ export declare class IamClientError extends Error {
19
+ constructor(message: string, options?: {
20
+ cause?: unknown;
21
+ });
22
+ }
23
+ /** A response the IAM refused, in the terms Doc 06 §2 defines. */
24
+ export declare class IamApiError extends IamClientError {
25
+ readonly status: number;
26
+ readonly code: IamErrorCode;
27
+ /**
28
+ * The correlation handle from the envelope, or the `X-Request-Id` header when
29
+ * the body was not an envelope. `null` when neither was present — which is
30
+ * itself a sign that the answer did not come from the IAM.
31
+ */
32
+ readonly requestId: string | null;
33
+ /** Field-level complaints; only `VALIDATION_FAILED` carries them. */
34
+ readonly details: readonly IamErrorDetail[];
35
+ /** True when {@link code} was inferred from the status, not read from a body. */
36
+ readonly inferred: boolean;
37
+ constructor(init: {
38
+ status: number;
39
+ code: IamErrorCode;
40
+ message: string;
41
+ requestId?: string | null;
42
+ details?: readonly IamErrorDetail[];
43
+ inferred?: boolean;
44
+ });
45
+ /**
46
+ * Builds the error from what a failed response actually carried.
47
+ *
48
+ * `body` is the already-parsed JSON, or `undefined` when the body was not
49
+ * JSON at all — an HTML error page from something in front of the API being
50
+ * the ordinary case.
51
+ */
52
+ static from(status: number, body: unknown, fallback?: {
53
+ requestId?: string | null;
54
+ text?: string;
55
+ }): IamApiError;
56
+ /** `if (error.is(IamErrorCode.NOT_FOUND))` — the common branch, spelled once. */
57
+ is(code: IamErrorCode): boolean;
58
+ }
59
+ /**
60
+ * The request never produced an answer to refuse it: a network fault, an abort,
61
+ * a timeout, or a success body that would not parse as JSON.
62
+ */
63
+ export declare class IamTransportError extends IamClientError {
64
+ constructor(message: string, options?: {
65
+ cause?: unknown;
66
+ });
67
+ }
68
+ export declare function isIamApiError(value: unknown): value is IamApiError;
69
+ export declare function isIamClientError(value: unknown): value is IamClientError;
70
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EACL,YAAY,EAEZ,KAAK,cAAc,EACpB,MAAM,qBAAqB,CAAC;AAE7B,+CAA+C;AAC/C,qBAAa,cAAe,SAAQ,KAAK;gBAC3B,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE;CAI3D;AAyBD,kEAAkE;AAClE,qBAAa,WAAY,SAAQ,cAAc;IAC7C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAC;IAC5B;;;;OAIG;IACH,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,qEAAqE;IACrE,QAAQ,CAAC,OAAO,EAAE,SAAS,cAAc,EAAE,CAAC;IAC5C,iFAAiF;IACjF,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;gBAEf,IAAI,EAAE;QAChB,MAAM,EAAE,MAAM,CAAC;QACf,IAAI,EAAE,YAAY,CAAC;QACnB,OAAO,EAAE,MAAM,CAAC;QAChB,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAC1B,OAAO,CAAC,EAAE,SAAS,cAAc,EAAE,CAAC;QACpC,QAAQ,CAAC,EAAE,OAAO,CAAC;KACpB;IAUD;;;;;;OAMG;IACH,MAAM,CAAC,IAAI,CACT,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,OAAO,EACb,QAAQ,GAAE;QAAE,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAO,GAC1D,WAAW;IAoBd,iFAAiF;IACjF,EAAE,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO;CAGhC;AAED;;;GAGG;AACH,qBAAa,iBAAkB,SAAQ,cAAc;gBACvC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE;CAI3D;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,WAAW,CAElE;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,cAAc,CAExE"}
package/dist/errors.js ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * What a call through this client throws (Doc 06 §2).
3
+ *
4
+ * Two classes, because a caller has two genuinely different decisions to make.
5
+ * {@link IamApiError} means the IAM answered and refused: there is a status, a
6
+ * closed-table `code` to branch on, and a `requestId` that correlates the
7
+ * refusal with the server's logs and its audit trail. {@link IamTransportError}
8
+ * means no answer came back at all — DNS, TLS, a cut connection, a timeout, or
9
+ * a body that was not the JSON it claimed to be. The first is a decision the
10
+ * server made about the request; the second is not, and retrying it is
11
+ * sometimes right where retrying the first never is.
12
+ *
13
+ * Both extend {@link IamClientError} so that `catch (e) { if (e instanceof
14
+ * IamClientError) … }` covers everything this library throws, and nothing else.
15
+ */
16
+ import { IamErrorCode, isIamErrorResponse, } from '@plantops/contracts';
17
+ /** Base of every error this library throws. */
18
+ export class IamClientError extends Error {
19
+ constructor(message, options) {
20
+ super(message, options);
21
+ this.name = 'IamClientError';
22
+ }
23
+ }
24
+ /**
25
+ * Status → code for a response that was *not* the Doc 06 §2 envelope.
26
+ *
27
+ * The IAM always sends the envelope — `INTERNAL_ERROR` was added to the table
28
+ * in Session 6 precisely so that an unhandled exception still parses. But a
29
+ * client does not only talk to the IAM: a reverse proxy's own 502, a load
30
+ * balancer's 504 or a captive portal's HTML all arrive on the same socket, and
31
+ * a consumer branching on `error.code` should not have to special-case the
32
+ * shape it gets when something in between answered first. So a non-envelope
33
+ * failure is still an {@link IamApiError}, with the nearest code the status
34
+ * justifies and {@link IamApiError.inferred} set to say the code was not the
35
+ * server's own word.
36
+ */
37
+ const CODE_FOR_STATUS = Object.freeze({
38
+ 400: IamErrorCode.VALIDATION_FAILED,
39
+ 401: IamErrorCode.AUTH_REQUIRED,
40
+ 403: IamErrorCode.PERMISSION_DENIED,
41
+ 404: IamErrorCode.NOT_FOUND,
42
+ 409: IamErrorCode.CONFLICT,
43
+ 423: IamErrorCode.ACCOUNT_LOCKED,
44
+ 429: IamErrorCode.RATE_LIMITED,
45
+ });
46
+ /** A response the IAM refused, in the terms Doc 06 §2 defines. */
47
+ export class IamApiError extends IamClientError {
48
+ status;
49
+ code;
50
+ /**
51
+ * The correlation handle from the envelope, or the `X-Request-Id` header when
52
+ * the body was not an envelope. `null` when neither was present — which is
53
+ * itself a sign that the answer did not come from the IAM.
54
+ */
55
+ requestId;
56
+ /** Field-level complaints; only `VALIDATION_FAILED` carries them. */
57
+ details;
58
+ /** True when {@link code} was inferred from the status, not read from a body. */
59
+ inferred;
60
+ constructor(init) {
61
+ super(init.message);
62
+ this.name = 'IamApiError';
63
+ this.status = init.status;
64
+ this.code = init.code;
65
+ this.requestId = init.requestId ?? null;
66
+ this.details = init.details ?? [];
67
+ this.inferred = init.inferred ?? false;
68
+ }
69
+ /**
70
+ * Builds the error from what a failed response actually carried.
71
+ *
72
+ * `body` is the already-parsed JSON, or `undefined` when the body was not
73
+ * JSON at all — an HTML error page from something in front of the API being
74
+ * the ordinary case.
75
+ */
76
+ static from(status, body, fallback = {}) {
77
+ if (isIamErrorResponse(body)) {
78
+ const { code, message, requestId, details } = body.error;
79
+ return new IamApiError({ status, code, message, requestId, details });
80
+ }
81
+ const code = CODE_FOR_STATUS[status] ?? IamErrorCode.INTERNAL_ERROR;
82
+ const excerpt = (fallback.text ?? '').trim().slice(0, 200);
83
+ return new IamApiError({
84
+ status,
85
+ code,
86
+ message: excerpt === ''
87
+ ? `HTTP ${status} from the IAM, with no error envelope.`
88
+ : `HTTP ${status} from the IAM, with no error envelope: ${excerpt}`,
89
+ requestId: fallback.requestId ?? null,
90
+ inferred: true,
91
+ });
92
+ }
93
+ /** `if (error.is(IamErrorCode.NOT_FOUND))` — the common branch, spelled once. */
94
+ is(code) {
95
+ return this.code === code;
96
+ }
97
+ }
98
+ /**
99
+ * The request never produced an answer to refuse it: a network fault, an abort,
100
+ * a timeout, or a success body that would not parse as JSON.
101
+ */
102
+ export class IamTransportError extends IamClientError {
103
+ constructor(message, options) {
104
+ super(message, options);
105
+ this.name = 'IamTransportError';
106
+ }
107
+ }
108
+ export function isIamApiError(value) {
109
+ return value instanceof IamApiError;
110
+ }
111
+ export function isIamClientError(value) {
112
+ return value instanceof IamClientError;
113
+ }
package/dist/http.d.ts ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The one place this library touches the network.
3
+ *
4
+ * Everything above it — the `endpoints/` modules, the token lifecycle in
5
+ * `auth.ts`, the cache in `resolve-cache.ts` — is transport-free and therefore
6
+ * testable without a socket. What lives here is the small, unavoidable set of
7
+ * decisions about HTTP itself: how a query string is built, when a body is
8
+ * JSON, what an empty `204` deserialises to, how a failure becomes an
9
+ * {@link IamApiError}, and the single re-authentication retry that hides an
10
+ * expired access token from every caller.
11
+ *
12
+ * ## Why the fetch types are declared here rather than imported
13
+ *
14
+ * The library must run in Node and in a browser (Doc 08 §2 — `admin-web` and
15
+ * every future module), and those two disagree about where the types for
16
+ * `fetch` come from: `lib.dom` in one, the undici typings of `@types/node` in
17
+ * the other. Naming either would make this package unbuildable in the other
18
+ * context. The structural minimum both satisfy is four properties and one
19
+ * method, so that is what {@link FetchLike} asks for — and it is also exactly
20
+ * what a test double has to implement, which is why the mocked-server suite
21
+ * needs no HTTP library at all.
22
+ */
23
+ export type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
24
+ /** A query-string value; `undefined` means "omit this parameter". */
25
+ export type QueryValue = string | number | boolean | undefined;
26
+ export type QueryParams = Readonly<Record<string, QueryValue>>;
27
+ /** The structural subset of `RequestInit` this client ever sends. */
28
+ export interface HttpRequestInit {
29
+ method: string;
30
+ headers: Record<string, string>;
31
+ body?: string;
32
+ signal?: AbortSignal;
33
+ }
34
+ /** The structural subset of `Response` this client ever reads. */
35
+ export interface HttpResponseLike {
36
+ readonly ok: boolean;
37
+ readonly status: number;
38
+ readonly headers: {
39
+ get(name: string): string | null;
40
+ };
41
+ text(): Promise<string>;
42
+ }
43
+ export type FetchLike = (url: string, init: HttpRequestInit) => Promise<HttpResponseLike>;
44
+ /** One call, described in the terms the endpoint modules think in. */
45
+ export interface RequestSpec {
46
+ method: HttpMethod;
47
+ /** Absolute path from the API root, e.g. `/iam/users`. */
48
+ path: string;
49
+ query?: QueryParams;
50
+ /** Serialised as JSON when present. `undefined` sends no body at all. */
51
+ body?: unknown;
52
+ /**
53
+ * `'none'` for the routes that cannot carry a token — login, refresh, the
54
+ * service-account exchange, the JWKS document. Everything else defaults to
55
+ * `'bearer'` and takes part in the refresh-on-401 retry.
56
+ */
57
+ auth?: 'bearer' | 'none';
58
+ /**
59
+ * What the successful body is.
60
+ *
61
+ * `'json'` everywhere but one route: `GET /iam/audit/export` answers
62
+ * `text/csv` (Doc 06 §12), and a client that insisted on JSON would have to
63
+ * either special-case it outside the transport — losing the token handling and
64
+ * the error mapping — or hand callers a `Response` this library never exposes.
65
+ * Failures are unaffected: the Doc 06 §2 envelope is JSON on every route,
66
+ * including that one.
67
+ */
68
+ accept?: 'json' | 'text';
69
+ signal?: AbortSignal;
70
+ }
71
+ /** What every endpoint module is handed, and the only thing it may use. */
72
+ export type Requester = <T>(spec: RequestSpec) => Promise<T>;
73
+ export interface HttpTransportOptions {
74
+ /** API root — the origin, not the `/iam` prefix. */
75
+ baseUrl: string;
76
+ fetch?: FetchLike;
77
+ /** Sent on every request. */
78
+ headers?: Readonly<Record<string, string>>;
79
+ /** Abort a request that has taken this long. Omit for no client-side limit. */
80
+ timeoutMs?: number;
81
+ /** The current access token, or `null` when there is none. */
82
+ authorize?: () => Promise<string | null>;
83
+ /**
84
+ * Called once per request that came back `401`, with the token that failed.
85
+ * Resolving `true` means a usable token now exists and the request should be
86
+ * retried exactly once; `false` means the `401` stands.
87
+ */
88
+ reauthorize?: (usedToken: string | null) => Promise<boolean>;
89
+ }
90
+ /** Trailing slashes on the base would double up against a leading-slash path. */
91
+ export declare const stripTrailingSlash: (url: string) => string;
92
+ export declare function buildQuery(query: QueryParams | undefined): string;
93
+ export declare class HttpTransport {
94
+ private readonly baseUrl;
95
+ private readonly fetchImpl;
96
+ private readonly headers;
97
+ private readonly timeoutMs;
98
+ private readonly authorize;
99
+ private readonly reauthorize;
100
+ constructor(options: HttpTransportOptions);
101
+ /** The {@link Requester} handed to every endpoint module. */
102
+ readonly request: Requester;
103
+ private attempt;
104
+ /**
105
+ * A timeout and a caller's own `AbortSignal`, combined without
106
+ * `AbortSignal.any` — which is newer than some runtimes this library has to
107
+ * work in, and which would be the only reason to require one.
108
+ */
109
+ private deadline;
110
+ /** Body to value, or to the error of Doc 06 §2. */
111
+ private interpret;
112
+ }
113
+ //# sourceMappingURL=http.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../src/http.ts"],"names":[],"mappings":"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"}
package/dist/http.js ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The one place this library touches the network.
3
+ *
4
+ * Everything above it — the `endpoints/` modules, the token lifecycle in
5
+ * `auth.ts`, the cache in `resolve-cache.ts` — is transport-free and therefore
6
+ * testable without a socket. What lives here is the small, unavoidable set of
7
+ * decisions about HTTP itself: how a query string is built, when a body is
8
+ * JSON, what an empty `204` deserialises to, how a failure becomes an
9
+ * {@link IamApiError}, and the single re-authentication retry that hides an
10
+ * expired access token from every caller.
11
+ *
12
+ * ## Why the fetch types are declared here rather than imported
13
+ *
14
+ * The library must run in Node and in a browser (Doc 08 §2 — `admin-web` and
15
+ * every future module), and those two disagree about where the types for
16
+ * `fetch` come from: `lib.dom` in one, the undici typings of `@types/node` in
17
+ * the other. Naming either would make this package unbuildable in the other
18
+ * context. The structural minimum both satisfy is four properties and one
19
+ * method, so that is what {@link FetchLike} asks for — and it is also exactly
20
+ * what a test double has to implement, which is why the mocked-server suite
21
+ * needs no HTTP library at all.
22
+ */
23
+ import { IamApiError, IamTransportError } from './errors.js';
24
+ /** Trailing slashes on the base would double up against a leading-slash path. */
25
+ export const stripTrailingSlash = (url) => url.replace(/\/+$/, '');
26
+ export function buildQuery(query) {
27
+ if (query === undefined)
28
+ return '';
29
+ const search = new URLSearchParams();
30
+ for (const [key, value] of Object.entries(query)) {
31
+ if (value !== undefined)
32
+ search.append(key, String(value));
33
+ }
34
+ const encoded = search.toString();
35
+ return encoded === '' ? '' : `?${encoded}`;
36
+ }
37
+ export class HttpTransport {
38
+ baseUrl;
39
+ fetchImpl;
40
+ headers;
41
+ timeoutMs;
42
+ authorize;
43
+ reauthorize;
44
+ constructor(options) {
45
+ const fetchImpl = options.fetch ?? globalThis.fetch;
46
+ if (typeof fetchImpl !== 'function') {
47
+ throw new TypeError('No fetch implementation. This runtime has no global fetch — pass one ' +
48
+ 'as `fetch` in the client options.');
49
+ }
50
+ this.baseUrl = stripTrailingSlash(options.baseUrl);
51
+ // Wrapped rather than stored bare: an unbound global `fetch` throws
52
+ // "Illegal invocation" in a browser once it is detached from `window`.
53
+ this.fetchImpl = (url, init) => fetchImpl(url, init);
54
+ this.headers = options.headers ?? {};
55
+ this.timeoutMs = options.timeoutMs;
56
+ this.authorize = options.authorize;
57
+ this.reauthorize = options.reauthorize;
58
+ }
59
+ /** The {@link Requester} handed to every endpoint module. */
60
+ request = async (spec) => {
61
+ const bearer = spec.auth !== 'none';
62
+ const token = bearer && this.authorize ? await this.authorize() : null;
63
+ const first = await this.attempt(spec, token);
64
+ if (first.status !== 401 ||
65
+ !bearer ||
66
+ this.reauthorize === undefined ||
67
+ !(await this.reauthorize(token))) {
68
+ return this.interpret(first, spec.accept);
69
+ }
70
+ // The refused response is being thrown away, so drain it: an unread body
71
+ // holds its connection open until the socket is collected.
72
+ await first.text().catch(() => undefined);
73
+ // Exactly one retry. A second 401 after a successful refresh is the server
74
+ // saying this subject may not do this at all, and looping on it would turn
75
+ // a revoked session into an unbounded refresh storm.
76
+ const retryToken = this.authorize ? await this.authorize() : null;
77
+ return this.interpret(await this.attempt(spec, retryToken), spec.accept);
78
+ };
79
+ async attempt(spec, token) {
80
+ const headers = {
81
+ accept: 'application/json',
82
+ ...this.headers,
83
+ };
84
+ if (spec.body !== undefined)
85
+ headers['content-type'] = 'application/json';
86
+ if (token !== null)
87
+ headers['authorization'] = `Bearer ${token}`;
88
+ const url = `${this.baseUrl}${spec.path}${buildQuery(spec.query)}`;
89
+ const { signal, done } = this.deadline(spec.signal);
90
+ try {
91
+ return await this.fetchImpl(url, {
92
+ method: spec.method,
93
+ headers,
94
+ ...(spec.body === undefined ? {} : { body: JSON.stringify(spec.body) }),
95
+ ...(signal === undefined ? {} : { signal }),
96
+ });
97
+ }
98
+ catch (cause) {
99
+ throw new IamTransportError(`${spec.method} ${spec.path} did not complete: ${describe(cause)}`, { cause });
100
+ }
101
+ finally {
102
+ done();
103
+ }
104
+ }
105
+ /**
106
+ * A timeout and a caller's own `AbortSignal`, combined without
107
+ * `AbortSignal.any` — which is newer than some runtimes this library has to
108
+ * work in, and which would be the only reason to require one.
109
+ */
110
+ deadline(caller) {
111
+ if (this.timeoutMs === undefined) {
112
+ return { signal: caller, done: () => undefined };
113
+ }
114
+ const controller = new AbortController();
115
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
116
+ const forward = () => controller.abort();
117
+ caller?.addEventListener('abort', forward);
118
+ if (caller?.aborted === true)
119
+ forward();
120
+ return {
121
+ signal: controller.signal,
122
+ done: () => {
123
+ clearTimeout(timer);
124
+ caller?.removeEventListener('abort', forward);
125
+ },
126
+ };
127
+ }
128
+ /** Body to value, or to the error of Doc 06 §2. */
129
+ async interpret(response, accept = 'json') {
130
+ const text = await response.text();
131
+ const parsed = parseJson(text);
132
+ if (!response.ok) {
133
+ throw IamApiError.from(response.status, parsed, {
134
+ requestId: response.headers.get('x-request-id'),
135
+ text,
136
+ });
137
+ }
138
+ // A caller that asked for text gets the body as it arrived — including an
139
+ // empty one, which for a CSV export is a header row that matched nothing
140
+ // rather than a missing answer.
141
+ if (accept === 'text')
142
+ return text;
143
+ // The routes that answer 204 with nothing at all (Doc 06 §3, §6, §7, §9).
144
+ if (text.trim() === '')
145
+ return undefined;
146
+ if (parsed === undefined) {
147
+ throw new IamTransportError(`HTTP ${response.status} carried a body that is not JSON.`);
148
+ }
149
+ return parsed;
150
+ }
151
+ }
152
+ function parseJson(text) {
153
+ if (text.trim() === '')
154
+ return undefined;
155
+ try {
156
+ return JSON.parse(text);
157
+ }
158
+ catch {
159
+ return undefined;
160
+ }
161
+ }
162
+ function describe(cause) {
163
+ return cause instanceof Error ? cause.message : String(cause);
164
+ }