@winwinmbs/portal-api 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,29 +1,384 @@
1
- ## @winwinmbs/portal-api
1
+ # `@winwinmbs/portal-api` — Implementation Guide
2
2
 
3
- Business API client for WINWIN Portal. Decoupled from authentication — accepts a `getToken` callback from any auth source.
3
+ Typed REST client for the WINWIN Portal API. Wraps every public endpoint
4
+ (users, roles, permissions, workflows, files, todos, etc.) with type-safe
5
+ methods. Uses axios under the hood; bearer-token + API-key injection are
6
+ handled automatically.
4
7
 
5
- ## Install
8
+ > **For AI agents:** this package is **just an HTTP client**. It does not
9
+ > manage auth state, refresh tokens, or persist anything. Pair it with
10
+ > [`@winwinmbs/portal-auth`](https://www.npmjs.com/package/@winwinmbs/portal-auth) (browser) or
11
+ > a service-to-service token (backend) for the auth side.
12
+
13
+ ---
14
+
15
+ ## 1. Install
6
16
 
7
17
  ```bash
18
+ npm install @winwinmbs/portal-api
19
+ # or
8
20
  pnpm add @winwinmbs/portal-api
9
21
  ```
10
22
 
11
- ## Usage with @winwinmbs/portal-auth
23
+ Peer requirement: `axios` (declared as dep — installed automatically).
24
+
25
+ ---
26
+
27
+ ## 2. The whole shape
28
+
29
+ ```ts
30
+ import { PortalApiClient } from '@winwinmbs/portal-api';
31
+
32
+ const portal = new PortalApiClient({
33
+ apiUrl: 'https://api.winwinmbs.com',
34
+ getToken: () => localStorage.getItem('access_token'), // sync read
35
+ apiKey: 'app_xxx', // optional
36
+ timeout: 30_000, // optional, ms
37
+ });
38
+
39
+ // All API surfaces are properties on the client:
40
+ const me = await portal.user.findById(userId);
41
+ const orgs = await portal.organization.tree();
42
+ const todos = await portal.todo.search({ /* ... */ });
43
+ ```
44
+
45
+ ---
46
+
47
+ ## 3. Available API namespaces
48
+
49
+ Each namespace maps to a portal feature module. Consult the IDE's autocomplete
50
+ for the full method list — types are exported from the package.
51
+
52
+ | Namespace | Endpoint group | Common methods |
53
+ |-----------|----------------|----------------|
54
+ | `portal.user` | `/users/*` | `search`, `findById`, `create`, `update`, `delete`, `setRoles`, `setPositions` |
55
+ | `portal.organization` | `/organizations/*` | `tree`, `findById`, `create`, `update`, `move`, `delete` |
56
+ | `portal.role` | `/roles/*` | `search`, `findById`, `create`, `update`, `setPermissions` |
57
+ | `portal.permission` | `/permissions/*` | `search`, `findById`, `create`, `update` |
58
+ | `portal.permissionCategory` | `/permission-categories/*` | `search`, `tree`, `create` |
59
+ | `portal.workflow` | `/workflows/*` | `search`, `findById`, `instances`, `tasks`, `approve`, `reject` |
60
+ | `portal.todo` | `/todos/*` | `search`, `findById`, `create`, `update`, `complete` |
61
+ | `portal.files` | `/files/*` | `upload`, `download`, `findById`, `delete` |
62
+ | `portal.email` | `/emails/*` | `send`, `templates`, `history` |
63
+ | `portal.otp` | `/otp/*` | `request`, `verify` |
64
+ | `portal.line` | `/line/*` | `link`, `unlink`, `notify` |
65
+ | `portal.webhook` | `/webhooks/*` | `search`, `findById`, `create`, `delete` |
66
+ | `portal.systemConfig` | `/system-configs/*` | `getCategory`, `getByKey`, `update` |
67
+ | `portal.eventLog` | `/event-logs/*` | `search`, `findById` |
68
+ | `portal.health` | `/health` | `check` |
69
+
70
+ Every method returns the underlying portal response shape (typed via
71
+ `@win-portal/shared` DTOs). Most search/list endpoints return
72
+ `ApiPaginatedResponse<T>`; single-resource endpoints return `ApiResponse<T>`.
73
+
74
+ ---
75
+
76
+ ## 4. Configuration reference
77
+
78
+ ```ts
79
+ interface PortalApiConfig {
80
+ /** Portal API base URL (no trailing slash) */
81
+ apiUrl: string;
82
+
83
+ /**
84
+ * Sync getter for the current access token. Called on every request.
85
+ * Return null when there's no session — requests will go out without
86
+ * Authorization, and the portal will respond 401 if auth is required.
87
+ */
88
+ getToken: () => string | null;
89
+
90
+ /**
91
+ * Optional app-scoped API key. Required for /v1/auth/* endpoints and any
92
+ * other route that gates access via x-api-key. Sent as 'X-API-Key' header.
93
+ */
94
+ apiKey?: string;
95
+
96
+ /** Per-request timeout in ms. Default 30_000. */
97
+ timeout?: number;
98
+ }
99
+ ```
100
+
101
+ `getToken` is **synchronous on purpose**. Most consumers cache the current
102
+ access token in memory or `localStorage` and read it cheaply. If you only
103
+ have an async source, do the refresh out-of-band (e.g. on app boot or via
104
+ `portal-auth.getAccessToken()`) and write the result somewhere sync-readable.
105
+
106
+ ---
12
107
 
13
- ```typescript
108
+ ## 5. Quickstart — browser app paired with `portal-auth`
109
+
110
+ ```ts
14
111
  import { AuthClient } from '@winwinmbs/portal-auth';
15
112
  import { PortalApiClient } from '@winwinmbs/portal-api';
16
113
 
17
- const auth = new AuthClient({ apiUrl, mode: 'sso', portalUrl, appId });
18
- const api = new PortalApiClient({
114
+ // 1) Auth side manages tokens, refresh, storage
115
+ const auth = new AuthClient({
116
+ apiUrl: 'https://api.winwinmbs.com',
117
+ mode: 'sso',
118
+ portalUrl: 'https://portal.winwinmbs.com',
119
+ appId: 'your-app-id',
120
+ });
121
+ await auth.start();
122
+
123
+ // 2) API client — reads the token written by auth
124
+ let cachedToken: string | null = null;
125
+ auth.on('token_refreshed', ({ token }) => { cachedToken = token; });
126
+ auth.on('authenticated', () => { auth.getAccessToken().then(t => cachedToken = t); });
127
+
128
+ const portal = new PortalApiClient({
129
+ apiUrl: 'https://api.winwinmbs.com',
130
+ getToken: () => cachedToken,
131
+ });
132
+
133
+ // 3) Use it
134
+ const users = await portal.user.search({ limit: 20 });
135
+ ```
136
+
137
+ Or (simpler, but does an async read every request):
138
+
139
+ ```ts
140
+ const portal = new PortalApiClient({
19
141
  apiUrl,
20
- getToken: () => auth.getAccessToken(),
142
+ // axios wrapper is sync, so we can't await here. Pre-cache and let auth
143
+ // events keep the local token current.
144
+ getToken: () => auth['token'] ?? null, // 👈 if you want to read internal cache
145
+ });
146
+ ```
147
+
148
+ The recommended pattern is the event-listener one above — it keeps the
149
+ `getToken` callback predictably synchronous.
150
+
151
+ ---
152
+
153
+ ## 6. Quickstart — backend service-to-service
154
+
155
+ ```ts
156
+ import { PortalApiClient } from '@winwinmbs/portal-api';
157
+
158
+ // Backend has its own long-lived API key + can mint its own service token
159
+ // (or proxy the user's token through). Simplest: read both from env.
160
+ const portal = new PortalApiClient({
161
+ apiUrl: process.env.PORTAL_API_URL!,
162
+ apiKey: process.env.PORTAL_API_KEY!,
163
+ getToken: () => process.env.PORTAL_SERVICE_TOKEN ?? null,
21
164
  });
22
165
 
23
- await api.files.upload(/* ... */);
24
- await api.workflow.list();
166
+ // Hit any read endpoint:
167
+ const apps = await portal.organization.tree();
168
+ ```
169
+
170
+ For per-request user-scoped calls (e.g. an Express handler forwarding the
171
+ caller's token), close over the request:
172
+
173
+ ```ts
174
+ app.get('/me/orgs', async (req, res) => {
175
+ const portal = new PortalApiClient({
176
+ apiUrl: process.env.PORTAL_API_URL!,
177
+ apiKey: process.env.PORTAL_API_KEY!,
178
+ getToken: () => req.token ?? null, // populated by portal-auth-server
179
+ });
180
+ const orgs = await portal.organization.tree();
181
+ res.json(orgs);
182
+ });
25
183
  ```
26
184
 
27
- ## Available API modules
185
+ The `PortalApiClient` is cheap to construct — instantiating per-request is
186
+ fine. Or hoist + close over a mutable token holder if you prefer.
187
+
188
+ ---
189
+
190
+ ## 7. Common scenarios
191
+
192
+ ### 7.1 Search with pagination
193
+
194
+ ```ts
195
+ const page = await portal.user.search({
196
+ page: 1,
197
+ limit: 20,
198
+ filter: { status: ['active'] },
199
+ sort: [{ field: 'created_at', order: 'DESC' }],
200
+ });
201
+
202
+ // Returned shape (ApiPaginatedResponse<UserResponseDto>):
203
+ // { success, data: User[], pagination: { page, limit, total, total_pages } }
204
+ ```
205
+
206
+ ### 7.2 File upload
207
+
208
+ ```ts
209
+ const formData = new FormData();
210
+ formData.append('file', file);
211
+ const result = await portal.files.upload(formData, { category: 'avatar' });
212
+ // result.data.id is the file_id you can store on user.avatar_file_id
213
+ ```
214
+
215
+ (Browser `FormData` for browser; Node consumers use `FormData` from
216
+ `undici` or `form-data`.)
217
+
218
+ ### 7.3 Workflow approval
219
+
220
+ ```ts
221
+ const tasks = await portal.workflow.tasks({ assignee_user_id: meUserId });
222
+ const myPending = tasks.data.filter(t => t.status === 'pending');
223
+
224
+ await portal.workflow.approve(taskId, {
225
+ comment: 'LGTM',
226
+ metadata: { /* per-workflow custom data */ },
227
+ });
228
+ ```
229
+
230
+ ### 7.4 Composing typed responses
231
+
232
+ The DTOs are exported by `@win-portal/shared` (the portal's source of truth).
233
+ This client re-uses those types directly:
234
+
235
+ ```ts
236
+ import type { UserResponseDto } from '@win-portal/shared/user';
237
+
238
+ async function findUser(id: string): Promise<UserResponseDto | null> {
239
+ try {
240
+ const res = await portal.user.findById(id);
241
+ return res.data;
242
+ } catch (err) {
243
+ if (axios.isAxiosError(err) && err.response?.status === 404) return null;
244
+ throw err;
245
+ }
246
+ }
247
+ ```
248
+
249
+ ---
250
+
251
+ ## 8. Error handling
252
+
253
+ The client lets axios errors propagate. Patterns:
254
+
255
+ ```ts
256
+ import axios from 'axios';
257
+
258
+ try {
259
+ await portal.user.findById(id);
260
+ } catch (err) {
261
+ if (axios.isAxiosError(err)) {
262
+ const status = err.response?.status;
263
+ const body = err.response?.data; // ApiResponse-style error envelope
264
+ if (status === 401) /* token expired or invalid */;
265
+ if (status === 403) /* forbidden — wrong app or missing permission */;
266
+ if (status === 404) /* not found */;
267
+ if (status === 429) /* rate limited; body.retry_after_ms tells you when */;
268
+ throw err;
269
+ }
270
+ throw err;
271
+ }
272
+ ```
273
+
274
+ The portal's error envelope (post-2026-05-05) is:
275
+
276
+ ```json
277
+ {
278
+ "success": false,
279
+ "error": {
280
+ "code": "UNAUTHORIZED" | "FORBIDDEN" | "NOT_FOUND" | "VALIDATION_ERROR" | ...,
281
+ "message": "<localized human message>",
282
+ "details": { ... },
283
+ "timestamp": "2026-05-05T...",
284
+ "path": "/users/abc",
285
+ "method": "GET",
286
+ "statusCode": 401,
287
+ "requestId": "req_..."
288
+ }
289
+ }
290
+ ```
291
+
292
+ `requestId` is searchable in the portal's audit log — pass it to portal
293
+ support when reporting issues.
294
+
295
+ ---
296
+
297
+ ## 9. Don'ts
298
+
299
+ - ❌ **Don't manage tokens inside this client.** It's a transport, not an
300
+ auth library. Refresh, storage, and lifecycle live in `portal-auth`.
301
+ - ❌ **Don't decode `getToken`'s return value to check expiry.** Just return
302
+ it. If the portal says 401, deal with it then.
303
+ - ❌ **Don't omit `apiKey` for `/v1/auth/*` calls.** That whole surface
304
+ requires it; you'll get 401 with `MISSING_API_KEY`.
305
+ - ❌ **Don't share one `PortalApiClient` across users on a backend.** It
306
+ closes over `getToken`, so per-request construction (or a mutable holder)
307
+ is the correct pattern.
308
+ - ❌ **Don't write your own retry logic on top of this.** The portal's rate
309
+ limiter returns 429 with `retry_after_ms`; respect that. Generic
310
+ exponential backoff against any 5xx is reasonable, but skip 4xx (it won't
311
+ recover by retrying).
312
+
313
+ ---
314
+
315
+ ## 10. Cross-package integration
316
+
317
+ | Combine with | Why |
318
+ |--------------|-----|
319
+ | [`@winwinmbs/portal-auth`](https://www.npmjs.com/package/@winwinmbs/portal-auth) | Browser auth — feeds `getToken` from the SDK's storage |
320
+ | [`@winwinmbs/portal-auth-react`](https://www.npmjs.com/package/@winwinmbs/portal-auth-react) | React app structure — `useSession().getAccessToken` for token reads (note: that's async, see §5) |
321
+ | [`@winwinmbs/portal-auth-server`](https://www.npmjs.com/package/@winwinmbs/portal-auth-server) | Backend that validates AND forwards the user's token via this client |
322
+
323
+ Two-package combo (browser):
324
+
325
+ ```
326
+ React component
327
+ └─ useApi() {
328
+ const { getAccessToken } = useSession();
329
+ return useMemo(() => new PortalApiClient({
330
+ apiUrl,
331
+ getToken: () => /* sync mirror of getAccessToken result */,
332
+ }), [...]);
333
+ }
334
+ ```
335
+
336
+ The trick: React renders synchronously, but `getAccessToken` is async
337
+ (because it may refresh). Either:
338
+
339
+ 1. Maintain a sync mirror updated by the `'token_refreshed'` event
340
+ (recommended — illustrated in §5).
341
+ 2. Or wrap your fetch helpers in `async (path, init) => { const t = await
342
+ getAccessToken(); return fetch(...) }` and skip `PortalApiClient` for
343
+ that one call site.
344
+
345
+ ---
346
+
347
+ ## 11. Where to look in source
348
+
349
+ | File | Contents |
350
+ |------|----------|
351
+ | `src/portal-api-client.ts` | The `PortalApiClient` class, axios setup, interceptors |
352
+ | `src/api/user.api.ts` | User CRUD + role/position management |
353
+ | `src/api/organization.api.ts` | Org tree CRUD + move |
354
+ | `src/api/role.api.ts` | Role CRUD + permission attachment |
355
+ | `src/api/permission.api.ts` | Permission CRUD |
356
+ | `src/api/workflow.api.ts` | Workflow definition + instance + task |
357
+ | `src/api/files.api.ts` | File upload/download |
358
+ | `src/api/email.api.ts` | Email send + templates |
359
+ | `src/api/otp.api.ts` | OTP request/verify |
360
+ | `src/api/todo.api.ts` | Todo CRUD |
361
+ | `src/api/system-config.api.ts` | System config read/write (admin-scoped) |
362
+ | `src/api/health.api.ts` | Health check |
363
+ | `src/api/event-log.api.ts` | Audit log search |
364
+ | `src/api/line.api.ts` | LINE link/unlink/notify |
365
+ | `src/api/webhook.api.ts` | Webhook subscriptions |
366
+ | `src/api/permission-category.api.ts` | Permission grouping tree |
367
+ | `src/types/` | Re-exported DTO types from `@win-portal/shared` |
368
+ | `src/portal-api-client.spec.ts` | Reference for instantiation + interceptor behaviour |
369
+
370
+ ---
371
+
372
+ ## 12. Reference: API surface contract
373
+
374
+ This client tracks the portal's HTTP API. Every method here corresponds to a
375
+ NestJS controller route in `apps/api/src/modules/features/<domain>/`. If the
376
+ portal adds an endpoint, it must be added here too — but the portal team
377
+ keeps the two in sync via a shared DTO package, so renames or signature
378
+ changes break compilation immediately rather than at runtime.
379
+
380
+ For a list of capabilities (high-level inventory of what the portal can do),
381
+ see [`docs/PORTAL_CAPABILITIES.md`](https://github.com/MBS-Business-Solutions/win-portal/blob/main/docs/PORTAL_CAPABILITIES.md).
28
382
 
29
- email, eventLog, files, health, line, organization, otp, permission, permissionCategory, role, systemConfig, todo, user, webhook, workflow
383
+ For low-level type definitions, browse `packages/shared/src/dto/` the
384
+ client re-exports those types unchanged.
@@ -305,6 +305,72 @@ export { ClearAllDataResponse }
305
305
  export { ClearAllDataResponse as ClearAllDataResponse_alias_1 }
306
306
  export { ClearAllDataResponse as ClearAllDataResponse_alias_2 }
307
307
 
308
+ /**
309
+ * Machine-to-machine (M2M) token provider — OAuth 2.0 `client_credentials` grant.
310
+ *
311
+ * For SERVER-side / service-to-service use only. The client secret is a
312
+ * confidential credential and must never run in a browser.
313
+ *
314
+ * Obtain a client_id / client_secret from the portal
315
+ * (Settings → Developer → Machine-to-Machine), then:
316
+ *
317
+ * ```ts
318
+ * const tokens = new ClientCredentialsTokenProvider({
319
+ * apiUrl: 'https://portal.example.com',
320
+ * clientId: 'app_...',
321
+ * clientSecret: 'secret_...',
322
+ * scope: 'applications:read', // optional down-scope
323
+ * });
324
+ *
325
+ * const api = new PortalApiClient({
326
+ * apiUrl: 'https://portal.example.com',
327
+ * getToken: () => tokens.getAccessToken(), // async getToken is supported
328
+ * });
329
+ * ```
330
+ *
331
+ * The provider caches the access token and refreshes it shortly before expiry
332
+ * (`expires_in`), de-duplicating concurrent refreshes.
333
+ */
334
+ declare interface ClientCredentialsConfig {
335
+ /** Portal API base URL, e.g. `https://portal.example.com`. */
336
+ apiUrl: string;
337
+ /** M2M client id (`app_...`). */
338
+ clientId: string;
339
+ /** M2M client secret (`secret_...`) — confidential, server-side only. */
340
+ clientSecret: string;
341
+ /** Optional space-separated scope to down-scope the token (RFC 6749 §3.3). */
342
+ scope?: string;
343
+ /** Refresh this many seconds before expiry (default 60). */
344
+ refreshSkewSeconds?: number;
345
+ /** Override the token endpoint (default `${apiUrl}/oauth/token`). */
346
+ tokenEndpoint?: string;
347
+ /** Custom fetch implementation (defaults to global `fetch`). */
348
+ fetchFn?: typeof fetch;
349
+ }
350
+ export { ClientCredentialsConfig }
351
+ export { ClientCredentialsConfig as ClientCredentialsConfig_alias_1 }
352
+
353
+ declare class ClientCredentialsTokenProvider {
354
+ private readonly config;
355
+ private token;
356
+ private expiresAtMs;
357
+ private inflight;
358
+ constructor(config: ClientCredentialsConfig);
359
+ /**
360
+ * Returns a valid access token, fetching or refreshing as needed. Safe to call
361
+ * on every request — cached until shortly before expiry. Concurrent calls
362
+ * share a single in-flight request.
363
+ */
364
+ getAccessToken(): Promise<string>;
365
+ /** Last cached token without triggering a fetch (may be stale or null). */
366
+ getCachedToken(): string | null;
367
+ /** Forget the cached token so the next `getAccessToken()` fetches a fresh one. */
368
+ reset(): void;
369
+ private fetchToken;
370
+ }
371
+ export { ClientCredentialsTokenProvider }
372
+ export { ClientCredentialsTokenProvider as ClientCredentialsTokenProvider_alias_1 }
373
+
308
374
  /**
309
375
  * Create Event Log Request DTO
310
376
  *
@@ -2128,7 +2194,13 @@ export { PortalApiClient as PortalApiClient_alias_1 }
2128
2194
 
2129
2195
  declare interface PortalApiConfig {
2130
2196
  apiUrl: string;
2131
- getToken: () => string | null;
2197
+ /**
2198
+ * Bearer token source. May be sync or async — pass a
2199
+ * `ClientCredentialsTokenProvider.getAccessToken` for service-to-server (M2M)
2200
+ * auth, or any function returning the current user token. Optional when
2201
+ * authenticating purely via `apiKey`.
2202
+ */
2203
+ getToken?: () => string | null | Promise<string | null>;
2132
2204
  apiKey?: string;
2133
2205
  timeout?: number;
2134
2206
  }
@@ -305,6 +305,72 @@ export { ClearAllDataResponse }
305
305
  export { ClearAllDataResponse as ClearAllDataResponse_alias_1 }
306
306
  export { ClearAllDataResponse as ClearAllDataResponse_alias_2 }
307
307
 
308
+ /**
309
+ * Machine-to-machine (M2M) token provider — OAuth 2.0 `client_credentials` grant.
310
+ *
311
+ * For SERVER-side / service-to-service use only. The client secret is a
312
+ * confidential credential and must never run in a browser.
313
+ *
314
+ * Obtain a client_id / client_secret from the portal
315
+ * (Settings → Developer → Machine-to-Machine), then:
316
+ *
317
+ * ```ts
318
+ * const tokens = new ClientCredentialsTokenProvider({
319
+ * apiUrl: 'https://portal.example.com',
320
+ * clientId: 'app_...',
321
+ * clientSecret: 'secret_...',
322
+ * scope: 'applications:read', // optional down-scope
323
+ * });
324
+ *
325
+ * const api = new PortalApiClient({
326
+ * apiUrl: 'https://portal.example.com',
327
+ * getToken: () => tokens.getAccessToken(), // async getToken is supported
328
+ * });
329
+ * ```
330
+ *
331
+ * The provider caches the access token and refreshes it shortly before expiry
332
+ * (`expires_in`), de-duplicating concurrent refreshes.
333
+ */
334
+ declare interface ClientCredentialsConfig {
335
+ /** Portal API base URL, e.g. `https://portal.example.com`. */
336
+ apiUrl: string;
337
+ /** M2M client id (`app_...`). */
338
+ clientId: string;
339
+ /** M2M client secret (`secret_...`) — confidential, server-side only. */
340
+ clientSecret: string;
341
+ /** Optional space-separated scope to down-scope the token (RFC 6749 §3.3). */
342
+ scope?: string;
343
+ /** Refresh this many seconds before expiry (default 60). */
344
+ refreshSkewSeconds?: number;
345
+ /** Override the token endpoint (default `${apiUrl}/oauth/token`). */
346
+ tokenEndpoint?: string;
347
+ /** Custom fetch implementation (defaults to global `fetch`). */
348
+ fetchFn?: typeof fetch;
349
+ }
350
+ export { ClientCredentialsConfig }
351
+ export { ClientCredentialsConfig as ClientCredentialsConfig_alias_1 }
352
+
353
+ declare class ClientCredentialsTokenProvider {
354
+ private readonly config;
355
+ private token;
356
+ private expiresAtMs;
357
+ private inflight;
358
+ constructor(config: ClientCredentialsConfig);
359
+ /**
360
+ * Returns a valid access token, fetching or refreshing as needed. Safe to call
361
+ * on every request — cached until shortly before expiry. Concurrent calls
362
+ * share a single in-flight request.
363
+ */
364
+ getAccessToken(): Promise<string>;
365
+ /** Last cached token without triggering a fetch (may be stale or null). */
366
+ getCachedToken(): string | null;
367
+ /** Forget the cached token so the next `getAccessToken()` fetches a fresh one. */
368
+ reset(): void;
369
+ private fetchToken;
370
+ }
371
+ export { ClientCredentialsTokenProvider }
372
+ export { ClientCredentialsTokenProvider as ClientCredentialsTokenProvider_alias_1 }
373
+
308
374
  /**
309
375
  * Create Event Log Request DTO
310
376
  *
@@ -2128,7 +2194,13 @@ export { PortalApiClient as PortalApiClient_alias_1 }
2128
2194
 
2129
2195
  declare interface PortalApiConfig {
2130
2196
  apiUrl: string;
2131
- getToken: () => string | null;
2197
+ /**
2198
+ * Bearer token source. May be sync or async — pass a
2199
+ * `ClientCredentialsTokenProvider.getAccessToken` for service-to-server (M2M)
2200
+ * auth, or any function returning the current user token. Optional when
2201
+ * authenticating purely via `apiKey`.
2202
+ */
2203
+ getToken?: () => string | null | Promise<string | null>;
2132
2204
  apiKey?: string;
2133
2205
  timeout?: number;
2134
2206
  }
package/dist/index.d.mts CHANGED
@@ -1,5 +1,7 @@
1
1
  export { PortalApiClient } from './_tsup-dts-rollup.mjs';
2
2
  export { PortalApiConfig } from './_tsup-dts-rollup.mjs';
3
+ export { ClientCredentialsTokenProvider_alias_1 as ClientCredentialsTokenProvider } from './_tsup-dts-rollup.mjs';
4
+ export { ClientCredentialsConfig_alias_1 as ClientCredentialsConfig } from './_tsup-dts-rollup.mjs';
3
5
  export { HealthAPI } from './_tsup-dts-rollup.mjs';
4
6
  export { SystemConfigAPI } from './_tsup-dts-rollup.mjs';
5
7
  export { FilesAPI } from './_tsup-dts-rollup.mjs';
package/dist/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export { PortalApiClient } from './_tsup-dts-rollup.js';
2
2
  export { PortalApiConfig } from './_tsup-dts-rollup.js';
3
+ export { ClientCredentialsTokenProvider_alias_1 as ClientCredentialsTokenProvider } from './_tsup-dts-rollup.js';
4
+ export { ClientCredentialsConfig_alias_1 as ClientCredentialsConfig } from './_tsup-dts-rollup.js';
3
5
  export { HealthAPI } from './_tsup-dts-rollup.js';
4
6
  export { SystemConfigAPI } from './_tsup-dts-rollup.js';
5
7
  export { FilesAPI } from './_tsup-dts-rollup.js';
package/dist/index.js CHANGED
@@ -31,6 +31,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
33
  ActorType: () => ActorType,
34
+ ClientCredentialsTokenProvider: () => ClientCredentialsTokenProvider,
34
35
  EmailAPI: () => EmailAPI,
35
36
  EventCategory: () => EventCategory,
36
37
  EventLogApi: () => EventLogApi,
@@ -2011,8 +2012,8 @@ var PortalApiClient = class {
2011
2012
  timeout: config.timeout ?? 3e4,
2012
2013
  withCredentials: true
2013
2014
  });
2014
- this.axios.interceptors.request.use((cfg) => {
2015
- const token = config.getToken();
2015
+ this.axios.interceptors.request.use(async (cfg) => {
2016
+ const token = config.getToken ? await config.getToken() : null;
2016
2017
  if (token) cfg.headers.Authorization = `Bearer ${token}`;
2017
2018
  if (config.apiKey) cfg.headers["X-API-Key"] = config.apiKey;
2018
2019
  return cfg;
@@ -2050,6 +2051,78 @@ var PortalApiClient = class {
2050
2051
  }
2051
2052
  };
2052
2053
 
2054
+ // src/client-credentials.ts
2055
+ var ClientCredentialsTokenProvider = class {
2056
+ constructor(config) {
2057
+ this.config = config;
2058
+ this.token = null;
2059
+ this.expiresAtMs = 0;
2060
+ this.inflight = null;
2061
+ if (!config.clientId || !config.clientSecret) {
2062
+ throw new Error("ClientCredentialsTokenProvider requires clientId and clientSecret");
2063
+ }
2064
+ }
2065
+ /**
2066
+ * Returns a valid access token, fetching or refreshing as needed. Safe to call
2067
+ * on every request — cached until shortly before expiry. Concurrent calls
2068
+ * share a single in-flight request.
2069
+ */
2070
+ async getAccessToken() {
2071
+ const skewMs = (this.config.refreshSkewSeconds ?? 60) * 1e3;
2072
+ if (this.token && Date.now() < this.expiresAtMs - skewMs) {
2073
+ return this.token;
2074
+ }
2075
+ if (!this.inflight) {
2076
+ this.inflight = this.fetchToken().finally(() => {
2077
+ this.inflight = null;
2078
+ });
2079
+ }
2080
+ return this.inflight;
2081
+ }
2082
+ /** Last cached token without triggering a fetch (may be stale or null). */
2083
+ getCachedToken() {
2084
+ return this.token;
2085
+ }
2086
+ /** Forget the cached token so the next `getAccessToken()` fetches a fresh one. */
2087
+ reset() {
2088
+ this.token = null;
2089
+ this.expiresAtMs = 0;
2090
+ }
2091
+ async fetchToken() {
2092
+ const fetchFn = this.config.fetchFn ?? globalThis.fetch;
2093
+ if (typeof fetchFn !== "function") {
2094
+ throw new Error("No fetch implementation available \u2014 pass config.fetchFn");
2095
+ }
2096
+ const endpoint = this.config.tokenEndpoint ?? `${this.config.apiUrl.replace(/\/$/, "")}/oauth/token`;
2097
+ const body = {
2098
+ grant_type: "client_credentials",
2099
+ client_id: this.config.clientId,
2100
+ client_secret: this.config.clientSecret
2101
+ };
2102
+ if (this.config.scope) body.scope = this.config.scope;
2103
+ const res = await fetchFn(endpoint, {
2104
+ method: "POST",
2105
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
2106
+ body: JSON.stringify(body)
2107
+ });
2108
+ if (!res.ok) {
2109
+ let detail = "";
2110
+ try {
2111
+ detail = JSON.stringify(await res.json());
2112
+ } catch {
2113
+ }
2114
+ throw new Error(`client_credentials token request failed (HTTP ${res.status}) ${detail}`.trim());
2115
+ }
2116
+ const data = await res.json();
2117
+ if (!data.access_token) {
2118
+ throw new Error("client_credentials response did not include an access_token");
2119
+ }
2120
+ this.token = data.access_token;
2121
+ this.expiresAtMs = Date.now() + (data.expires_in ?? 3600) * 1e3;
2122
+ return this.token;
2123
+ }
2124
+ };
2125
+
2053
2126
  // ../../shared/src/enums/common.enums.ts
2054
2127
  var OrganizationType = /* @__PURE__ */ ((OrganizationType2) => {
2055
2128
  OrganizationType2["GROUP"] = "group";
@@ -2118,6 +2191,7 @@ var TodoPriority = /* @__PURE__ */ ((TodoPriority2) => {
2118
2191
  // Annotate the CommonJS export names for ESM import in node:
2119
2192
  0 && (module.exports = {
2120
2193
  ActorType,
2194
+ ClientCredentialsTokenProvider,
2121
2195
  EmailAPI,
2122
2196
  EventCategory,
2123
2197
  EventLogApi,