@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
package/README.md ADDED
@@ -0,0 +1,11 @@
1
+ # iam-client
2
+
3
+ This library was generated with [Nx](https://nx.dev).
4
+
5
+ ## Building
6
+
7
+ Run `nx build iam-client` to build the library.
8
+
9
+ ## Running unit tests
10
+
11
+ Run `nx test iam-client` to execute the unit tests via [Jest](https://jestjs.io).
package/dist/auth.d.ts ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The token lifecycle: where credentials are kept, and when they are renewed.
3
+ *
4
+ * Separated from the `/auth/*` endpoint methods in `endpoints/auth.ts` on
5
+ * purpose. Those are HTTP; this is state — and it is the state two very
6
+ * different consumers need to control. A future operational module holds one
7
+ * service-account token in memory for the process's lifetime; `admin-web` holds
8
+ * a human's pair somewhere that survives a page reload, and would rather that
9
+ * somewhere were its own decision than this library's (Doc 09 §1). So the store
10
+ * is a two-method port, {@link MemoryTokenStore} is the default, and nothing
11
+ * here ever names `localStorage`, a cookie, or a file.
12
+ *
13
+ * ## Single-flight refresh
14
+ *
15
+ * The property that matters, and the reason this is a class rather than a
16
+ * closure over a variable. A screen that loads six panels issues six requests
17
+ * with the same expired access token and gets six `401`s within a few
18
+ * milliseconds of each other. Six refreshes would follow — and because
19
+ * `POST /auth/refresh` **rotates** (Doc 03 §4), five of them would present a
20
+ * token the first has already consumed, which the server is right to treat as
21
+ * replay. One in-flight refresh is therefore not an optimisation but a
22
+ * correctness requirement: {@link TokenSession.refresh} hands every concurrent
23
+ * caller the same promise, and the reuse-detection grace window of Doc 03 §4.1
24
+ * covers only what this cannot.
25
+ */
26
+ import type { AccessTokenResponse, TokenPairResponse } from '@plantops/contracts';
27
+ /** What a store holds. Access token, its renewal, and when it lapses. */
28
+ export interface StoredTokens {
29
+ accessToken: string;
30
+ /** `null` for a service account: `POST /auth/token` issues no refresh token. */
31
+ refreshToken: string | null;
32
+ /**
33
+ * Epoch milliseconds, derived from `expires_in` when the pair was issued, or
34
+ * `null` when the tokens were adopted from elsewhere without one. `null`
35
+ * disables proactive renewal — the token is then used until a `401` says
36
+ * otherwise, which is correct, just one round trip slower.
37
+ */
38
+ expiresAt: number | null;
39
+ }
40
+ /**
41
+ * Where tokens live between calls.
42
+ *
43
+ * Both methods may be async so that a store can be a keychain, an encrypted
44
+ * file, or an `IndexedDB` handle. `write(null)` clears.
45
+ */
46
+ export interface TokenStore {
47
+ read(): StoredTokens | null | Promise<StoredTokens | null>;
48
+ write(tokens: StoredTokens | null): void | Promise<void>;
49
+ }
50
+ /** The default: tokens last as long as the client object does. */
51
+ export declare class MemoryTokenStore implements TokenStore {
52
+ private tokens;
53
+ read(): StoredTokens | null;
54
+ write(tokens: StoredTokens | null): void;
55
+ }
56
+ /** Why {@link TokenSessionOptions.onSessionEnded} fired. */
57
+ export type SessionEndReason = 'logout' | 'refresh_failed';
58
+ export interface TokenSessionOptions {
59
+ store?: TokenStore;
60
+ /** Injectable clock, so the expiry tests need no timers. */
61
+ now?: () => number;
62
+ /**
63
+ * Renew this many seconds before the access token actually lapses.
64
+ *
65
+ * Zero would mean every renewal costs a wasted round trip — the request that
66
+ * discovers the `401`. Thirty seconds is comfortably more than a slow request
67
+ * plus the 60-second clock skew the server already tolerates
68
+ * (`CLOCK_SKEW_LEEWAY_SECONDS`), and comfortably less than the 900-second
69
+ * access-token lifetime, so it neither renews constantly nor cuts it fine.
70
+ */
71
+ refreshLeewaySeconds?: number;
72
+ /**
73
+ * Fired when the session is over: the caller logged out, or a refresh failed
74
+ * and the stored tokens were dropped. `admin-web` sends the user to the login
75
+ * screen from here (Doc 09 §1).
76
+ */
77
+ onSessionEnded?: (reason: SessionEndReason) => void;
78
+ }
79
+ export declare class TokenSession {
80
+ /** `POST /auth/refresh`, injected because it goes back through the transport. */
81
+ private readonly exchangeRefreshToken;
82
+ private readonly store;
83
+ private readonly now;
84
+ private readonly leewayMs;
85
+ private readonly onSessionEnded;
86
+ /** The one in-flight refresh every concurrent caller shares. */
87
+ private pending;
88
+ constructor(
89
+ /** `POST /auth/refresh`, injected because it goes back through the transport. */
90
+ exchangeRefreshToken: (refreshToken: string) => Promise<TokenPairResponse>, options?: TokenSessionOptions);
91
+ /** What is stored right now, without renewing anything. */
92
+ tokens(): Promise<StoredTokens | null>;
93
+ isAuthenticated(): Promise<boolean>;
94
+ /**
95
+ * The token to send, renewed first if it is about to lapse.
96
+ *
97
+ * A failed proactive renewal answers `null` rather than the token it could
98
+ * not replace: the stored pair is gone by then, and sending a token this
99
+ * object no longer holds would produce a `401` whose retry has nothing left
100
+ * to refresh with.
101
+ */
102
+ accessToken(): Promise<string | null>;
103
+ /**
104
+ * The transport's `401` hook.
105
+ *
106
+ * Two things happen here that a plain "refresh on 401" would get wrong. The
107
+ * first is the check that the stored token is still the one that failed: when
108
+ * six requests race, five of them arrive after the refresh has already landed
109
+ * and simply need retrying with what is now stored — no second refresh, and
110
+ * no rotation of a token nobody has used yet. The second is that failure is
111
+ * `false`, not an exception: the caller's original `401` is the honest answer
112
+ * to their request, and replacing it with a refresh error would report the
113
+ * wrong failed call.
114
+ */
115
+ reauthorize(usedToken: string | null): Promise<boolean>;
116
+ /**
117
+ * Renews the pair, sharing one exchange with every concurrent caller.
118
+ *
119
+ * A failed refresh clears the store: the refresh token is either expired,
120
+ * revoked, or has been replayed, and all three mean this session is over
121
+ * (Doc 03 §4.1). Keeping it would guarantee that every later call spends a
122
+ * round trip rediscovering the same thing.
123
+ */
124
+ refresh(): Promise<TokenPairResponse>;
125
+ private runRefresh;
126
+ /** Takes the tokens a login, refresh or service-token exchange returned. */
127
+ adopt(tokens: TokenPairResponse | AccessTokenResponse): Promise<void>;
128
+ /** Adopts tokens from somewhere else — a server-rendered page, another tab. */
129
+ restore(tokens: StoredTokens | null): Promise<void>;
130
+ /** Drops the tokens locally. The server-side revocation is `POST /auth/logout`. */
131
+ clear(): Promise<void>;
132
+ private forget;
133
+ private expiringSoon;
134
+ }
135
+ //# sourceMappingURL=auth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,KAAK,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAElF,yEAAyE;AACzE,MAAM,WAAW,YAAY;IAC3B,WAAW,EAAE,MAAM,CAAC;IACpB,gFAAgF;IAChF,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B;;;;;OAKG;IACH,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IACzB,IAAI,IAAI,YAAY,GAAG,IAAI,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAAC;IAC3D,KAAK,CAAC,MAAM,EAAE,YAAY,GAAG,IAAI,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1D;AAED,kEAAkE;AAClE,qBAAa,gBAAiB,YAAW,UAAU;IACjD,OAAO,CAAC,MAAM,CAA6B;IAE3C,IAAI,IAAI,YAAY,GAAG,IAAI;IAI3B,KAAK,CAAC,MAAM,EAAE,YAAY,GAAG,IAAI,GAAG,IAAI;CAGzC;AAED,4DAA4D;AAC5D,MAAM,MAAM,gBAAgB,GAAG,QAAQ,GAAG,gBAAgB,CAAC;AAE3D,MAAM,WAAW,mBAAmB;IAClC,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB,4DAA4D;IAC5D,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB;;;;;;;;OAQG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;OAIG;IACH,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,gBAAgB,KAAK,IAAI,CAAC;CACrD;AAID,qBAAa,YAAY;IAYrB,iFAAiF;IACjF,OAAO,CAAC,QAAQ,CAAC,oBAAoB;IAZvC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAa;IACnC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IACnC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,cAAc,CAEjB;IAEd,gEAAgE;IAChE,OAAO,CAAC,OAAO,CAA2C;;IAGxD,iFAAiF;IAChE,oBAAoB,EAAE,CACrC,YAAY,EAAE,MAAM,KACjB,OAAO,CAAC,iBAAiB,CAAC,EAC/B,OAAO,GAAE,mBAAwB;IASnC,2DAA2D;IACrD,MAAM,IAAI,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC;IAItC,eAAe,IAAI,OAAO,CAAC,OAAO,CAAC;IAIzC;;;;;;;OAOG;IACG,WAAW,IAAI,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAe3C;;;;;;;;;;;OAWG;IACG,WAAW,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC;IAc7D;;;;;;;OAOG;IACG,OAAO,IAAI,OAAO,CAAC,iBAAiB,CAAC;YAS7B,UAAU;IAgBxB,4EAA4E;IACtE,KAAK,CAAC,MAAM,EAAE,iBAAiB,GAAG,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;IAW3E,+EAA+E;IACzE,OAAO,CAAC,MAAM,EAAE,YAAY,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAIzD,mFAAmF;IAC7E,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;YAId,MAAM;IAKpB,OAAO,CAAC,YAAY;CAGrB"}
package/dist/auth.js ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * The token lifecycle: where credentials are kept, and when they are renewed.
3
+ *
4
+ * Separated from the `/auth/*` endpoint methods in `endpoints/auth.ts` on
5
+ * purpose. Those are HTTP; this is state — and it is the state two very
6
+ * different consumers need to control. A future operational module holds one
7
+ * service-account token in memory for the process's lifetime; `admin-web` holds
8
+ * a human's pair somewhere that survives a page reload, and would rather that
9
+ * somewhere were its own decision than this library's (Doc 09 §1). So the store
10
+ * is a two-method port, {@link MemoryTokenStore} is the default, and nothing
11
+ * here ever names `localStorage`, a cookie, or a file.
12
+ *
13
+ * ## Single-flight refresh
14
+ *
15
+ * The property that matters, and the reason this is a class rather than a
16
+ * closure over a variable. A screen that loads six panels issues six requests
17
+ * with the same expired access token and gets six `401`s within a few
18
+ * milliseconds of each other. Six refreshes would follow — and because
19
+ * `POST /auth/refresh` **rotates** (Doc 03 §4), five of them would present a
20
+ * token the first has already consumed, which the server is right to treat as
21
+ * replay. One in-flight refresh is therefore not an optimisation but a
22
+ * correctness requirement: {@link TokenSession.refresh} hands every concurrent
23
+ * caller the same promise, and the reuse-detection grace window of Doc 03 §4.1
24
+ * covers only what this cannot.
25
+ */
26
+ /** The default: tokens last as long as the client object does. */
27
+ export class MemoryTokenStore {
28
+ tokens = null;
29
+ read() {
30
+ return this.tokens;
31
+ }
32
+ write(tokens) {
33
+ this.tokens = tokens;
34
+ }
35
+ }
36
+ const DEFAULT_REFRESH_LEEWAY_SECONDS = 30;
37
+ export class TokenSession {
38
+ exchangeRefreshToken;
39
+ store;
40
+ now;
41
+ leewayMs;
42
+ onSessionEnded;
43
+ /** The one in-flight refresh every concurrent caller shares. */
44
+ pending = null;
45
+ constructor(
46
+ /** `POST /auth/refresh`, injected because it goes back through the transport. */
47
+ exchangeRefreshToken, options = {}) {
48
+ this.exchangeRefreshToken = exchangeRefreshToken;
49
+ this.store = options.store ?? new MemoryTokenStore();
50
+ this.now = options.now ?? (() => Date.now());
51
+ this.leewayMs =
52
+ (options.refreshLeewaySeconds ?? DEFAULT_REFRESH_LEEWAY_SECONDS) * 1000;
53
+ this.onSessionEnded = options.onSessionEnded;
54
+ }
55
+ /** What is stored right now, without renewing anything. */
56
+ async tokens() {
57
+ return (await this.store.read()) ?? null;
58
+ }
59
+ async isAuthenticated() {
60
+ return (await this.tokens()) !== null;
61
+ }
62
+ /**
63
+ * The token to send, renewed first if it is about to lapse.
64
+ *
65
+ * A failed proactive renewal answers `null` rather than the token it could
66
+ * not replace: the stored pair is gone by then, and sending a token this
67
+ * object no longer holds would produce a `401` whose retry has nothing left
68
+ * to refresh with.
69
+ */
70
+ async accessToken() {
71
+ const current = await this.tokens();
72
+ if (current === null)
73
+ return null;
74
+ if (current.refreshToken !== null && this.expiringSoon(current)) {
75
+ try {
76
+ return (await this.refresh()).access_token;
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ }
82
+ return current.accessToken;
83
+ }
84
+ /**
85
+ * The transport's `401` hook.
86
+ *
87
+ * Two things happen here that a plain "refresh on 401" would get wrong. The
88
+ * first is the check that the stored token is still the one that failed: when
89
+ * six requests race, five of them arrive after the refresh has already landed
90
+ * and simply need retrying with what is now stored — no second refresh, and
91
+ * no rotation of a token nobody has used yet. The second is that failure is
92
+ * `false`, not an exception: the caller's original `401` is the honest answer
93
+ * to their request, and replacing it with a refresh error would report the
94
+ * wrong failed call.
95
+ */
96
+ async reauthorize(usedToken) {
97
+ const current = await this.tokens();
98
+ if (current === null)
99
+ return false;
100
+ if (usedToken !== null && current.accessToken !== usedToken)
101
+ return true;
102
+ if (current.refreshToken === null)
103
+ return false;
104
+ try {
105
+ await this.refresh();
106
+ return true;
107
+ }
108
+ catch {
109
+ return false;
110
+ }
111
+ }
112
+ /**
113
+ * Renews the pair, sharing one exchange with every concurrent caller.
114
+ *
115
+ * A failed refresh clears the store: the refresh token is either expired,
116
+ * revoked, or has been replayed, and all three mean this session is over
117
+ * (Doc 03 §4.1). Keeping it would guarantee that every later call spends a
118
+ * round trip rediscovering the same thing.
119
+ */
120
+ async refresh() {
121
+ if (this.pending !== null)
122
+ return this.pending;
123
+ this.pending = this.runRefresh().finally(() => {
124
+ this.pending = null;
125
+ });
126
+ return this.pending;
127
+ }
128
+ async runRefresh() {
129
+ const current = await this.tokens();
130
+ if (current === null || current.refreshToken === null) {
131
+ throw new Error('No refresh token: nothing to renew.');
132
+ }
133
+ try {
134
+ const pair = await this.exchangeRefreshToken(current.refreshToken);
135
+ await this.adopt(pair);
136
+ return pair;
137
+ }
138
+ catch (error) {
139
+ await this.forget('refresh_failed');
140
+ throw error;
141
+ }
142
+ }
143
+ /** Takes the tokens a login, refresh or service-token exchange returned. */
144
+ async adopt(tokens) {
145
+ await this.store.write({
146
+ accessToken: tokens.access_token,
147
+ refreshToken: 'refresh_token' in tokens ? tokens.refresh_token : null,
148
+ expiresAt: typeof tokens.expires_in === 'number'
149
+ ? this.now() + tokens.expires_in * 1000
150
+ : null,
151
+ });
152
+ }
153
+ /** Adopts tokens from somewhere else — a server-rendered page, another tab. */
154
+ async restore(tokens) {
155
+ await this.store.write(tokens);
156
+ }
157
+ /** Drops the tokens locally. The server-side revocation is `POST /auth/logout`. */
158
+ async clear() {
159
+ await this.forget('logout');
160
+ }
161
+ async forget(reason) {
162
+ await this.store.write(null);
163
+ this.onSessionEnded?.(reason);
164
+ }
165
+ expiringSoon(tokens) {
166
+ return tokens.expiresAt !== null && tokens.expiresAt - this.leewayMs <= this.now();
167
+ }
168
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * `IamClient` — one import gives a consumer authenticated, typed IAM access
3
+ * (Doc 08 §2).
4
+ *
5
+ * The composition root, and deliberately the only file that knows all the parts
6
+ * exist: the transport, the token lifecycle, the grants cache and the ten
7
+ * endpoint modules. Everything it wires together is separately constructible, so
8
+ * a consumer with an unusual need — a script that only talks to the registry, a
9
+ * test that wants the cache without a socket — is never forced through this
10
+ * class.
11
+ *
12
+ * ## What it is responsible for that nothing else is
13
+ *
14
+ * Three couplings live here because they exist only when the parts are
15
+ * assembled:
16
+ *
17
+ * 1. The transport asks {@link TokenSession} for a token, and hands it back on a
18
+ * `401` — which is what makes the automatic, single-flight refresh work
19
+ * without any endpoint module knowing about tokens.
20
+ * 2. The refresh call itself goes back out through the transport, so it gets the
21
+ * same base URL, headers, timeout and error mapping as everything else.
22
+ * 3. Any change of identity — login, service-token exchange, logout, refresh —
23
+ * empties the grants cache. Serving one subject's grants to the next is the
24
+ * one caching mistake that would hand a user somebody else's menu.
25
+ *
26
+ * ## Every Doc 06 surface has a typed method
27
+ *
28
+ * `/iam/audit` was the one gap while Session 25's endpoint and its contract
29
+ * types did not exist — a method here would have had to invent both, and that
30
+ * session would then have been implementing against a client rather than against
31
+ * Doc 06. Both landed, and `endpoints/audit.ts` closed it in Session 37.
32
+ */
33
+ import type { ResolvedGrants, ResolveQuery } from '@plantops/contracts';
34
+ import { TokenSession, type SessionEndReason, type TokenStore, type TokenSessionOptions } from './auth.js';
35
+ import { type ApplicationsApi, type AuditApi, type EntitlementsApi, type AuthApi, type ClientsApi, type NavigationApi, type PermissionsApi, type RoleBindingsApi, type RolesApi, type ScopesApi, type ServiceAccountsApi, type UsersApi } from './endpoints/index.js';
36
+ import { type FetchLike, type Requester } from './http.js';
37
+ export interface IamClientOptions {
38
+ /** The API root — the origin, without the `/iam` or `/auth` prefix. */
39
+ baseUrl: string;
40
+ /** Defaults to the runtime's global. Supply one to test, or to instrument. */
41
+ fetch?: FetchLike;
42
+ /** Sent on every request — a correlation header, a user agent. */
43
+ headers?: Readonly<Record<string, string>>;
44
+ /** Abort a request that has taken this long. Omit for no client-side limit. */
45
+ timeoutMs?: number;
46
+ /** Where tokens live. Defaults to memory; a browser supplies its own. */
47
+ tokenStore?: TokenStore;
48
+ /** Renew this many seconds before the access token lapses. Default 30. */
49
+ refreshLeewaySeconds?: TokenSessionOptions['refreshLeewaySeconds'];
50
+ /** How long {@link IamClient.grants} may serve a cached answer. Default 60. */
51
+ resolveCacheTtlSeconds?: number;
52
+ /** Injectable clock, shared by the token expiry and the cache. */
53
+ now?: () => number;
54
+ /** The session ended: the caller logged out, or a refresh was refused. */
55
+ onSessionEnded?: (reason: SessionEndReason) => void;
56
+ }
57
+ export declare class IamClient {
58
+ /** The token lifecycle: read, restore or clear credentials directly. */
59
+ readonly session: TokenSession;
60
+ readonly auth: AuthApi;
61
+ readonly applications: ApplicationsApi;
62
+ readonly clients: ClientsApi;
63
+ readonly scopes: ScopesApi;
64
+ readonly roles: RolesApi;
65
+ readonly users: UsersApi;
66
+ readonly roleBindings: RoleBindingsApi;
67
+ readonly serviceAccounts: ServiceAccountsApi;
68
+ readonly permissions: PermissionsApi;
69
+ readonly navigation: NavigationApi;
70
+ readonly audit: AuditApi;
71
+ /**
72
+ * Term, ceilings and per-application entitlement.
73
+ *
74
+ * Deliberately not folded into {@link IamClient.grants}: grants invalidate on
75
+ * a version counter, entitlements go stale on a clock, and one cache cannot
76
+ * serve both. See `endpoints/entitlements.ts`.
77
+ */
78
+ readonly entitlements: EntitlementsApi;
79
+ /**
80
+ * The raw, authenticated request function.
81
+ *
82
+ * The escape hatch for an endpoint this library does not yet type. Nothing on
83
+ * Doc 06's surface needs it today; it stays because the alternative, when the
84
+ * next route arrives ahead of its method, is a consumer abandoning the token
85
+ * handling and error mapping for all of them.
86
+ */
87
+ readonly request: Requester;
88
+ private readonly grantsCache;
89
+ constructor(options: IamClientOptions);
90
+ /**
91
+ * The bearer's grants, cached (Doc 06 §11).
92
+ *
93
+ * What a module's `PermissionGuard` should call on every gated request: the
94
+ * burst of authorizations one request fan-out produces collapses into a single
95
+ * resolve, and the answer is reused for the cache's lifetime.
96
+ */
97
+ grants(query?: ResolveQuery): Promise<ResolvedGrants>;
98
+ /** Re-resolves now, ignoring the cache — after a change the caller just made. */
99
+ refreshGrants(query?: ResolveQuery): Promise<ResolvedGrants>;
100
+ /**
101
+ * Drops cached grants — for one application, or all of them.
102
+ *
103
+ * The hook a consumer subscribed to `perms.invalidated` (Doc 04 §7) calls, and
104
+ * the reason such a consumer may safely raise `resolveCacheTtlSeconds`.
105
+ */
106
+ invalidateGrants(applicationId?: string): void;
107
+ }
108
+ /** `createIamClient({ baseUrl })` — the same thing, for consumers that prefer it. */
109
+ export declare function createIamClient(options: IamClientOptions): IamClient;
110
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAqB,MAAM,qBAAqB,CAAC;AAG3F,OAAO,EACL,YAAY,EACZ,KAAK,gBAAgB,EACrB,KAAK,UAAU,EACf,KAAK,mBAAmB,EACzB,MAAM,WAAW,CAAC;AACnB,OAAO,EAaL,KAAK,eAAe,EACpB,KAAK,QAAQ,EACb,KAAK,eAAe,EACpB,KAAK,OAAO,EACZ,KAAK,UAAU,EACf,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,eAAe,EACpB,KAAK,QAAQ,EACb,KAAK,SAAS,EACd,KAAK,kBAAkB,EACvB,KAAK,QAAQ,EACd,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAiB,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,WAAW,CAAC;AAG1E,MAAM,WAAW,gBAAgB;IAC/B,uEAAuE;IACvE,OAAO,EAAE,MAAM,CAAC;IAChB,8EAA8E;IAC9E,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,kEAAkE;IAClE,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAC3C,+EAA+E;IAC/E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,yEAAyE;IACzE,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,0EAA0E;IAC1E,oBAAoB,CAAC,EAAE,mBAAmB,CAAC,sBAAsB,CAAC,CAAC;IACnE,+EAA+E;IAC/E,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,kEAAkE;IAClE,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,0EAA0E;IAC1E,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,gBAAgB,KAAK,IAAI,CAAC;CACrD;AAED,qBAAa,SAAS;IACpB,wEAAwE;IACxE,QAAQ,CAAC,OAAO,EAAE,YAAY,CAAC;IAE/B,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,YAAY,EAAE,eAAe,CAAC;IACvC,QAAQ,CAAC,OAAO,EAAE,UAAU,CAAC;IAC7B,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC;IACzB,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC;IACzB,QAAQ,CAAC,YAAY,EAAE,eAAe,CAAC;IACvC,QAAQ,CAAC,eAAe,EAAE,kBAAkB,CAAC;IAC7C,QAAQ,CAAC,WAAW,EAAE,cAAc,CAAC;IACrC,QAAQ,CAAC,UAAU,EAAE,aAAa,CAAC;IACnC,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC;IACzB;;;;;;OAMG;IACH,QAAQ,CAAC,YAAY,EAAE,eAAe,CAAC;IAEvC;;;;;;;OAOG;IACH,QAAQ,CAAC,OAAO,EAAE,SAAS,CAAC;IAE5B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAe;gBAE/B,OAAO,EAAE,gBAAgB;IAgDrC;;;;;;OAMG;IACH,MAAM,CAAC,KAAK,GAAE,YAAiB,GAAG,OAAO,CAAC,cAAc,CAAC;IAIzD,iFAAiF;IACjF,aAAa,CAAC,KAAK,GAAE,YAAiB,GAAG,OAAO,CAAC,cAAc,CAAC;IAIhE;;;;;OAKG;IACH,gBAAgB,CAAC,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI;CAG/C;AAED,qFAAqF;AACrF,wBAAgB,eAAe,CAAC,OAAO,EAAE,gBAAgB,GAAG,SAAS,CAEpE"}
package/dist/client.js ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `IamClient` — one import gives a consumer authenticated, typed IAM access
3
+ * (Doc 08 §2).
4
+ *
5
+ * The composition root, and deliberately the only file that knows all the parts
6
+ * exist: the transport, the token lifecycle, the grants cache and the ten
7
+ * endpoint modules. Everything it wires together is separately constructible, so
8
+ * a consumer with an unusual need — a script that only talks to the registry, a
9
+ * test that wants the cache without a socket — is never forced through this
10
+ * class.
11
+ *
12
+ * ## What it is responsible for that nothing else is
13
+ *
14
+ * Three couplings live here because they exist only when the parts are
15
+ * assembled:
16
+ *
17
+ * 1. The transport asks {@link TokenSession} for a token, and hands it back on a
18
+ * `401` — which is what makes the automatic, single-flight refresh work
19
+ * without any endpoint module knowing about tokens.
20
+ * 2. The refresh call itself goes back out through the transport, so it gets the
21
+ * same base URL, headers, timeout and error mapping as everything else.
22
+ * 3. Any change of identity — login, service-token exchange, logout, refresh —
23
+ * empties the grants cache. Serving one subject's grants to the next is the
24
+ * one caching mistake that would hand a user somebody else's menu.
25
+ *
26
+ * ## Every Doc 06 surface has a typed method
27
+ *
28
+ * `/iam/audit` was the one gap while Session 25's endpoint and its contract
29
+ * types did not exist — a method here would have had to invent both, and that
30
+ * session would then have been implementing against a client rather than against
31
+ * Doc 06. Both landed, and `endpoints/audit.ts` closed it in Session 37.
32
+ */
33
+ import { AUTH_ROUTE_PREFIX } from '@plantops/contracts';
34
+ import { TokenSession, } from './auth.js';
35
+ import { applicationsEndpoints, auditEndpoints, entitlementsEndpoints, authEndpoints, clientsEndpoints, navigationEndpoints, permissionsEndpoints, roleBindingsEndpoints, rolesEndpoints, scopesEndpoints, serviceAccountsEndpoints, usersEndpoints, } from './endpoints/index.js';
36
+ import { HttpTransport } from './http.js';
37
+ import { ResolveCache } from './resolve-cache.js';
38
+ export class IamClient {
39
+ /** The token lifecycle: read, restore or clear credentials directly. */
40
+ session;
41
+ auth;
42
+ applications;
43
+ clients;
44
+ scopes;
45
+ roles;
46
+ users;
47
+ roleBindings;
48
+ serviceAccounts;
49
+ permissions;
50
+ navigation;
51
+ audit;
52
+ /**
53
+ * Term, ceilings and per-application entitlement.
54
+ *
55
+ * Deliberately not folded into {@link IamClient.grants}: grants invalidate on
56
+ * a version counter, entitlements go stale on a clock, and one cache cannot
57
+ * serve both. See `endpoints/entitlements.ts`.
58
+ */
59
+ entitlements;
60
+ /**
61
+ * The raw, authenticated request function.
62
+ *
63
+ * The escape hatch for an endpoint this library does not yet type. Nothing on
64
+ * Doc 06's surface needs it today; it stays because the alternative, when the
65
+ * next route arrives ahead of its method, is a consumer abandoning the token
66
+ * handling and error mapping for all of them.
67
+ */
68
+ request;
69
+ grantsCache;
70
+ constructor(options) {
71
+ const transport = new HttpTransport({
72
+ baseUrl: options.baseUrl,
73
+ fetch: options.fetch,
74
+ headers: options.headers,
75
+ timeoutMs: options.timeoutMs,
76
+ authorize: () => this.session.accessToken(),
77
+ reauthorize: (usedToken) => this.session.reauthorize(usedToken),
78
+ });
79
+ this.request = transport.request;
80
+ this.session = new TokenSession((refreshToken) => this.request({
81
+ method: 'POST',
82
+ path: `${AUTH_ROUTE_PREFIX}/refresh`,
83
+ body: { refresh_token: refreshToken },
84
+ auth: 'none',
85
+ }), {
86
+ store: options.tokenStore,
87
+ now: options.now,
88
+ refreshLeewaySeconds: options.refreshLeewaySeconds,
89
+ onSessionEnded: options.onSessionEnded,
90
+ });
91
+ this.permissions = permissionsEndpoints(this.request);
92
+ this.grantsCache = new ResolveCache((query) => this.permissions.resolve(query), {
93
+ ttlSeconds: options.resolveCacheTtlSeconds,
94
+ now: options.now,
95
+ });
96
+ this.auth = authEndpoints(this.request, this.session, () => this.grantsCache.clear());
97
+ this.applications = applicationsEndpoints(this.request);
98
+ this.clients = clientsEndpoints(this.request);
99
+ this.scopes = scopesEndpoints(this.request);
100
+ this.roles = rolesEndpoints(this.request);
101
+ this.users = usersEndpoints(this.request);
102
+ this.roleBindings = roleBindingsEndpoints(this.request);
103
+ this.serviceAccounts = serviceAccountsEndpoints(this.request);
104
+ this.navigation = navigationEndpoints(this.request);
105
+ this.audit = auditEndpoints(this.request);
106
+ this.entitlements = entitlementsEndpoints(this.request);
107
+ }
108
+ /**
109
+ * The bearer's grants, cached (Doc 06 §11).
110
+ *
111
+ * What a module's `PermissionGuard` should call on every gated request: the
112
+ * burst of authorizations one request fan-out produces collapses into a single
113
+ * resolve, and the answer is reused for the cache's lifetime.
114
+ */
115
+ grants(query = {}) {
116
+ return this.grantsCache.get(query);
117
+ }
118
+ /** Re-resolves now, ignoring the cache — after a change the caller just made. */
119
+ refreshGrants(query = {}) {
120
+ return this.grantsCache.reload(query);
121
+ }
122
+ /**
123
+ * Drops cached grants — for one application, or all of them.
124
+ *
125
+ * The hook a consumer subscribed to `perms.invalidated` (Doc 04 §7) calls, and
126
+ * the reason such a consumer may safely raise `resolveCacheTtlSeconds`.
127
+ */
128
+ invalidateGrants(applicationId) {
129
+ this.grantsCache.invalidate(applicationId);
130
+ }
131
+ }
132
+ /** `createIamClient({ baseUrl })` — the same thing, for consumers that prefer it. */
133
+ export function createIamClient(options) {
134
+ return new IamClient(options);
135
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `/iam/applications/*` — the platform application registry, Doc 06 §4.
3
+ *
4
+ * The four catalog routes and the manifest that replaces them are all here
5
+ * because they are one screen's worth of API (Doc 09 §2): an application, its
6
+ * permissions, its nav nodes, and the mapping between the last two.
7
+ */
8
+ import type { ApplicationDTO, ApplicationManifest, CreateApplicationRequest, CreateNavNodesRequest, CreatePermissionsRequest, ManifestUpsertResponse, NavCatalogResponse, NavNodeCatalogDTO, NavPermissionsRequest, NavPermissionsResult, Paginated, PaginationQuery, PermissionDTO, UpdateApplicationRequest } from '@plantops/contracts';
9
+ import type { Requester } from '../http.js';
10
+ export interface ApplicationsApi {
11
+ create(body: CreateApplicationRequest): Promise<ApplicationDTO>;
12
+ list(query?: PaginationQuery): Promise<Paginated<ApplicationDTO>>;
13
+ /** Update, or the global on/off switch of Doc 02 §7. */
14
+ update(id: string, body: UpdateApplicationRequest): Promise<ApplicationDTO>;
15
+ addPermissions(id: string, body: CreatePermissionsRequest): Promise<PermissionDTO[]>;
16
+ listPermissions(id: string, query?: PaginationQuery): Promise<Paginated<PermissionDTO>>;
17
+ addNavNodes(id: string, body: CreateNavNodesRequest): Promise<NavNodeCatalogDTO[]>;
18
+ navTree(id: string): Promise<NavCatalogResponse>;
19
+ mapNavPermissions(id: string, body: NavPermissionsRequest): Promise<NavPermissionsResult>;
20
+ /**
21
+ * Removes mappings. Doc 06 §4's table stops at the POST; the DELETE exists
22
+ * because a mapping added by mistake has to be removable without a manifest
23
+ * upload (see `registry/nav.service.ts`).
24
+ */
25
+ unmapNavPermissions(id: string, body: NavPermissionsRequest): Promise<NavPermissionsResult>;
26
+ /**
27
+ * Idempotent upsert of the whole catalog from an application's manifest
28
+ * (Doc 02 §2). The body is the manifest document itself, unwrapped, so the
29
+ * file on disk and the body on the wire are the same thing.
30
+ */
31
+ upsertManifest(id: string, manifest: ApplicationManifest): Promise<ManifestUpsertResponse>;
32
+ /**
33
+ * What {@link ApplicationsApi.upsertManifest} *would* do — `?dryRun=true`.
34
+ *
35
+ * The preview behind Doc 09 §2.1's upload screen: the same validation, the
36
+ * same refusals and the same `ManifestDiff`, computed against the catalog as
37
+ * it stands and applied to nothing. `dry_run` comes back `true`, and `changed`
38
+ * answers whether confirming would do anything at all.
39
+ *
40
+ * A separate method rather than an options argument, because the difference
41
+ * between the two calls is whether they write. A boolean parameter that
42
+ * decides that reads the same at both call sites and is the wrong thing to get
43
+ * backwards.
44
+ */
45
+ previewManifest(id: string, manifest: ApplicationManifest): Promise<ManifestUpsertResponse>;
46
+ }
47
+ export declare function applicationsEndpoints(request: Requester): ApplicationsApi;
48
+ //# sourceMappingURL=applications.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"applications.d.ts","sourceRoot":"","sources":["../../src/endpoints/applications.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EACV,cAAc,EACd,mBAAmB,EACnB,wBAAwB,EACxB,qBAAqB,EACrB,wBAAwB,EACxB,sBAAsB,EACtB,kBAAkB,EAClB,iBAAiB,EACjB,qBAAqB,EACrB,oBAAoB,EACpB,SAAS,EACT,eAAe,EACf,aAAa,EACb,wBAAwB,EACzB,MAAM,qBAAqB,CAAC;AAG7B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE5C,MAAM,WAAW,eAAe;IAC9B,MAAM,CAAC,IAAI,EAAE,wBAAwB,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;IAChE,IAAI,CAAC,KAAK,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,SAAS,CAAC,cAAc,CAAC,CAAC,CAAC;IAClE,wDAAwD;IACxD,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,wBAAwB,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;IAE5E,cAAc,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,wBAAwB,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC,CAAC;IACrF,eAAe,CACb,EAAE,EAAE,MAAM,EACV,KAAK,CAAC,EAAE,eAAe,GACtB,OAAO,CAAC,SAAS,CAAC,aAAa,CAAC,CAAC,CAAC;IAErC,WAAW,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,qBAAqB,GAAG,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAAC;IACnF,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAAC;IACjD,iBAAiB,CACf,EAAE,EAAE,MAAM,EACV,IAAI,EAAE,qBAAqB,GAC1B,OAAO,CAAC,oBAAoB,CAAC,CAAC;IACjC;;;;OAIG;IACH,mBAAmB,CACjB,EAAE,EAAE,MAAM,EACV,IAAI,EAAE,qBAAqB,GAC1B,OAAO,CAAC,oBAAoB,CAAC,CAAC;IAEjC;;;;OAIG;IACH,cAAc,CACZ,EAAE,EAAE,MAAM,EACV,QAAQ,EAAE,mBAAmB,GAC5B,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAEnC;;;;;;;;;;;;OAYG;IACH,eAAe,CACb,EAAE,EAAE,MAAM,EACV,QAAQ,EAAE,mBAAmB,GAC5B,OAAO,CAAC,sBAAsB,CAAC,CAAC;CACpC;AAED,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,SAAS,GAAG,eAAe,CAiCzE"}
@@ -0,0 +1,30 @@
1
+ /**
2
+ * `/iam/applications/*` — the platform application registry, Doc 06 §4.
3
+ *
4
+ * The four catalog routes and the manifest that replaces them are all here
5
+ * because they are one screen's worth of API (Doc 09 §2): an application, its
6
+ * permissions, its nav nodes, and the mapping between the last two.
7
+ */
8
+ import { IAM_ROUTE_PREFIX } from '@plantops/contracts';
9
+ export function applicationsEndpoints(request) {
10
+ const base = `${IAM_ROUTE_PREFIX}/applications`;
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
+ addPermissions: (id, body) => request({ method: 'POST', path: at(id, '/permissions'), body }),
17
+ listPermissions: (id, query) => request({ method: 'GET', path: at(id, '/permissions'), query: { ...query } }),
18
+ addNavNodes: (id, body) => request({ method: 'POST', path: at(id, '/nav'), body }),
19
+ navTree: (id) => request({ method: 'GET', path: at(id, '/nav') }),
20
+ mapNavPermissions: (id, body) => request({ method: 'POST', path: at(id, '/nav-permissions'), body }),
21
+ unmapNavPermissions: (id, body) => request({ method: 'DELETE', path: at(id, '/nav-permissions'), body }),
22
+ upsertManifest: (id, manifest) => request({ method: 'POST', path: at(id, '/manifest'), body: manifest }),
23
+ previewManifest: (id, manifest) => request({
24
+ method: 'POST',
25
+ path: at(id, '/manifest'),
26
+ query: { dryRun: true },
27
+ body: manifest,
28
+ }),
29
+ };
30
+ }