@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 +367 -12
- package/dist/_tsup-dts-rollup.d.mts +73 -1
- package/dist/_tsup-dts-rollup.d.ts +73 -1
- package/dist/index.d.mts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +76 -2
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +75 -2
- package/dist/index.mjs.map +1 -1
- package/package.json +18 -9
package/README.md
CHANGED
|
@@ -1,29 +1,384 @@
|
|
|
1
|
-
|
|
1
|
+
# `@winwinmbs/portal-api` — Implementation Guide
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|