@bentoforge/umami-iam 0.1.1 → 0.2.1
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/LICENSE +21 -0
- package/dist/client.d.ts +44 -36
- package/dist/client.js +125 -72
- package/dist/types.d.ts +126 -96
- package/package.json +1 -1
- package/src/client.ts +144 -91
- package/src/types.ts +134 -104
package/src/client.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type {
|
|
|
2
2
|
AccessClaims,
|
|
3
3
|
ApiErrorBody,
|
|
4
4
|
ApiKeyView,
|
|
5
|
-
|
|
5
|
+
AuditPage,
|
|
6
6
|
Config,
|
|
7
7
|
CreateApiKeyRequest,
|
|
8
8
|
CreateApiKeyResponse,
|
|
@@ -10,24 +10,22 @@ import type {
|
|
|
10
10
|
CreateTenantRequest,
|
|
11
11
|
CreateTenantResponse,
|
|
12
12
|
CreateUserRequest,
|
|
13
|
+
CreateUserResponse,
|
|
13
14
|
CustomFieldsSchema,
|
|
14
|
-
EntitlementsResponse,
|
|
15
15
|
ExchangeResponse,
|
|
16
|
-
FeatureToggle,
|
|
17
16
|
LoginResponse,
|
|
18
17
|
MeResponse,
|
|
19
18
|
MessagingCodeResponse,
|
|
20
19
|
MessagingLink,
|
|
21
|
-
MetricUsage,
|
|
22
20
|
MfaStatus,
|
|
21
|
+
NameInput,
|
|
23
22
|
PatchUserRequest,
|
|
24
23
|
ResetPasswordResponse,
|
|
25
24
|
ResolvedMessagingUser,
|
|
25
|
+
SessionView,
|
|
26
26
|
Tenant,
|
|
27
|
-
TenantStatus,
|
|
28
27
|
TokenResponse,
|
|
29
28
|
TotpSetup,
|
|
30
|
-
UsageResponse,
|
|
31
29
|
UserView,
|
|
32
30
|
} from "./types.js";
|
|
33
31
|
import {
|
|
@@ -65,6 +63,8 @@ export class UmamiClient {
|
|
|
65
63
|
private readonly baseUrl: string;
|
|
66
64
|
private readonly onTokenChange?: (token: string | null) => void;
|
|
67
65
|
private accessToken: string | null = null;
|
|
66
|
+
/** In-flight refresh, if any — coalesces concurrent 401s into a single rotation. */
|
|
67
|
+
private refreshing: Promise<boolean> | null = null;
|
|
68
68
|
|
|
69
69
|
constructor(options: UmamiClientOptions) {
|
|
70
70
|
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
@@ -141,8 +141,8 @@ export class UmamiClient {
|
|
|
141
141
|
|
|
142
142
|
/** Password login by username. On success the access token is stored; if MFA is enabled and no
|
|
143
143
|
* `totpCode` is given, the response has `mfaRequired: true` and no token. Pass `api` to mint the
|
|
144
|
-
* token for a product API directly (default: the umami admin API); the session
|
|
145
|
-
* audience
|
|
144
|
+
* first token for a product API directly (default: the umami admin API); the session itself is
|
|
145
|
+
* audience-agnostic, so later `refresh` calls choose their own `api`. */
|
|
146
146
|
async login(
|
|
147
147
|
username: string,
|
|
148
148
|
password: string,
|
|
@@ -158,8 +158,20 @@ export class UmamiClient {
|
|
|
158
158
|
return data;
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
/** Silent refresh via the cookie. Returns whether a fresh token was obtained.
|
|
161
|
+
/** Silent refresh via the cookie. Returns whether a fresh token was obtained.
|
|
162
|
+
*
|
|
163
|
+
* Single-flighted: concurrent callers (e.g. several requests that 401 at once) all await one
|
|
164
|
+
* rotation. Without this, the second refresh would send the just-rotated-out cookie secret, which
|
|
165
|
+
* the server treats as token reuse and revokes the whole session. */
|
|
162
166
|
async refresh(): Promise<boolean> {
|
|
167
|
+
if (this.refreshing) return this.refreshing;
|
|
168
|
+
this.refreshing = this.doRefresh().finally(() => {
|
|
169
|
+
this.refreshing = null;
|
|
170
|
+
});
|
|
171
|
+
return this.refreshing;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private async doRefresh(): Promise<boolean> {
|
|
163
175
|
const response = await this.doFetch("/auth/refresh", { method: "POST" }, false);
|
|
164
176
|
if (!response.ok) {
|
|
165
177
|
this.setToken(null);
|
|
@@ -181,16 +193,29 @@ export class UmamiClient {
|
|
|
181
193
|
await this.request("/auth/logout-all", { method: "POST" }, true);
|
|
182
194
|
}
|
|
183
195
|
|
|
196
|
+
/** Lists the caller's active login sessions (the current one is flagged). */
|
|
197
|
+
listSessions(): Promise<SessionView[]> {
|
|
198
|
+
return this.request<SessionView[]>("/auth/sessions");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Revokes one of the caller's own sessions (single-device logout). */
|
|
202
|
+
async deleteSession(sessionId: string): Promise<void> {
|
|
203
|
+
await this.request(`/auth/sessions/${enc(sessionId)}`, { method: "DELETE" });
|
|
204
|
+
}
|
|
205
|
+
|
|
184
206
|
/** Current profile (user + tenant). */
|
|
185
207
|
getMe(): Promise<MeResponse> {
|
|
186
208
|
return this.request<MeResponse>("/auth/me");
|
|
187
209
|
}
|
|
188
|
-
/**
|
|
189
|
-
|
|
190
|
-
|
|
210
|
+
/** Self-service profile edit: the structured name parts are always editable; custom fields only
|
|
211
|
+
* when marked `selfEditable`. Blocked for `self:readonly` users. */
|
|
212
|
+
patchMe(body: NameInput & { customFields?: Record<string, unknown> }): Promise<MeResponse> {
|
|
213
|
+
return this.request<MeResponse>("/auth/me", {
|
|
214
|
+
method: "PATCH",
|
|
215
|
+
body: JSON.stringify(body),
|
|
216
|
+
});
|
|
191
217
|
}
|
|
192
|
-
|
|
193
|
-
/** Re-scope the access token to another tenant (requires `admin:system`). Access-token only —
|
|
218
|
+
/** Re-scope the access token to another tenant (requires `switch:tenant`). Access-token only —
|
|
194
219
|
* a later silent refresh returns to the home tenant. Returns the active tenant id. */
|
|
195
220
|
async switchTenant(tenantId: string): Promise<string> {
|
|
196
221
|
const data = await this.request<TokenResponse>("/auth/switch-tenant", {
|
|
@@ -284,14 +309,18 @@ export class UmamiClient {
|
|
|
284
309
|
return data;
|
|
285
310
|
}
|
|
286
311
|
|
|
287
|
-
/**
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
312
|
+
/** Mode 2 exchange: prove possession of `keyId`/`secret` with an HMAC over the current hour bucket
|
|
313
|
+
* instead of sending the secret. Same result as {@link exchangeApiKey}; the raw secret never
|
|
314
|
+
* leaves the process. `secret` is the part after `umk_<keyId>_`. Uses WebCrypto (Node 18+/browser). */
|
|
315
|
+
async exchangeApiKeyHmac(keyId: string, secret: string, api?: string): Promise<ExchangeResponse> {
|
|
316
|
+
const mac = await apiKeyMac(keyId, secret);
|
|
317
|
+
const data = await this.request<ExchangeResponse>(
|
|
318
|
+
"/auth/token",
|
|
319
|
+
{ method: "POST", body: JSON.stringify(api ? { keyId, mac, api } : { keyId, mac }) },
|
|
320
|
+
false,
|
|
321
|
+
);
|
|
322
|
+
this.setToken(data.accessToken);
|
|
323
|
+
return data;
|
|
295
324
|
}
|
|
296
325
|
|
|
297
326
|
// ── tenants ────────────────────────────────────────────────────────────────────
|
|
@@ -299,9 +328,8 @@ export class UmamiClient {
|
|
|
299
328
|
/** List every tenant (system-admin only; sorted newest-updated first, capped at 250). `q` is an
|
|
300
329
|
* optional case-insensitive search: whitespace-separated terms must all match (over name / slug /
|
|
301
330
|
* custom fields). `truncated` is true when more than 250 matched. */
|
|
302
|
-
listTenants(q?: string): Promise<{ tenants: Tenant[]; truncated: boolean }> {
|
|
303
|
-
|
|
304
|
-
return this.request<{ tenants: Tenant[]; truncated: boolean }>(`/tenants${qs}`);
|
|
331
|
+
listTenants(q?: string, limit?: number): Promise<{ tenants: Tenant[]; truncated: boolean }> {
|
|
332
|
+
return this.request<{ tenants: Tenant[]; truncated: boolean }>(`/tenants${listQs(q, limit)}`);
|
|
305
333
|
}
|
|
306
334
|
/** Create a tenant and its first owner (system-admin only). */
|
|
307
335
|
createTenant(request: CreateTenantRequest): Promise<CreateTenantResponse> {
|
|
@@ -320,51 +348,13 @@ export class UmamiClient {
|
|
|
320
348
|
}
|
|
321
349
|
patchTenant(
|
|
322
350
|
tenantId: string,
|
|
323
|
-
body: Partial<Pick<Tenant, "name" | "
|
|
351
|
+
body: Partial<Pick<Tenant, "name" | "customFields">>,
|
|
324
352
|
): Promise<Tenant> {
|
|
325
353
|
return this.request<Tenant>(`/tenants/${enc(tenantId)}`, {
|
|
326
354
|
method: "PATCH",
|
|
327
355
|
body: JSON.stringify(body),
|
|
328
356
|
});
|
|
329
357
|
}
|
|
330
|
-
patchStatus(tenantId: string, status: TenantStatus): Promise<Tenant> {
|
|
331
|
-
return this.request<Tenant>(`/tenants/${enc(tenantId)}/status`, {
|
|
332
|
-
method: "PATCH",
|
|
333
|
-
body: JSON.stringify({ status }),
|
|
334
|
-
});
|
|
335
|
-
}
|
|
336
|
-
patchLicense(
|
|
337
|
-
tenantId: string,
|
|
338
|
-
body: { plan?: string; billedUntil?: string; seatsLimit?: number },
|
|
339
|
-
): Promise<Tenant> {
|
|
340
|
-
return this.request<Tenant>(`/tenants/${enc(tenantId)}/license`, {
|
|
341
|
-
method: "PATCH",
|
|
342
|
-
body: JSON.stringify(body),
|
|
343
|
-
});
|
|
344
|
-
}
|
|
345
|
-
getEntitlements(tenantId: string): Promise<EntitlementsResponse> {
|
|
346
|
-
return this.request<EntitlementsResponse>(`/tenants/${enc(tenantId)}/entitlements`);
|
|
347
|
-
}
|
|
348
|
-
assignPackage(
|
|
349
|
-
tenantId: string,
|
|
350
|
-
request: { code: string; monthlyPrice?: string },
|
|
351
|
-
): Promise<Tenant> {
|
|
352
|
-
return this.request<Tenant>(`/tenants/${enc(tenantId)}/packages`, {
|
|
353
|
-
method: "POST",
|
|
354
|
-
body: JSON.stringify(request),
|
|
355
|
-
});
|
|
356
|
-
}
|
|
357
|
-
removePackage(tenantId: string, assignmentId: string): Promise<Tenant> {
|
|
358
|
-
return this.request<Tenant>(`/tenants/${enc(tenantId)}/packages/${enc(assignmentId)}`, {
|
|
359
|
-
method: "DELETE",
|
|
360
|
-
});
|
|
361
|
-
}
|
|
362
|
-
setFeature(tenantId: string, code: string, value: FeatureToggle): Promise<Tenant> {
|
|
363
|
-
return this.request<Tenant>(`/tenants/${enc(tenantId)}/features/${enc(code)}`, {
|
|
364
|
-
method: "PUT",
|
|
365
|
-
body: JSON.stringify({ value }),
|
|
366
|
-
});
|
|
367
|
-
}
|
|
368
358
|
|
|
369
359
|
// ── authorization: assignable roles/scopes/features + feature grant/revoke ─────
|
|
370
360
|
|
|
@@ -392,26 +382,23 @@ export class UmamiClient {
|
|
|
392
382
|
method: "DELETE",
|
|
393
383
|
});
|
|
394
384
|
}
|
|
395
|
-
getUsage(tenantId: string): Promise<UsageResponse> {
|
|
396
|
-
return this.request<UsageResponse>(`/tenants/${enc(tenantId)}/usage`);
|
|
397
|
-
}
|
|
398
|
-
incrementUsage(tenantId: string, metric: string, amount = 1): Promise<MetricUsage> {
|
|
399
|
-
return this.request<MetricUsage>(`/tenants/${enc(tenantId)}/usage/${enc(metric)}`, {
|
|
400
|
-
method: "POST",
|
|
401
|
-
body: JSON.stringify({ amount }),
|
|
402
|
-
});
|
|
403
|
-
}
|
|
404
385
|
|
|
405
386
|
// ── users ────────────────────────────────────────────────────────────────────
|
|
406
387
|
|
|
407
|
-
createUser(request: CreateUserRequest): Promise<
|
|
408
|
-
return this.request<
|
|
388
|
+
createUser(request: CreateUserRequest): Promise<CreateUserResponse> {
|
|
389
|
+
return this.request<CreateUserResponse>("/users", {
|
|
390
|
+
method: "POST",
|
|
391
|
+
body: JSON.stringify(request),
|
|
392
|
+
});
|
|
409
393
|
}
|
|
410
394
|
/** List the caller's tenant's users (sorted by recent activity, capped at 250). `q` is an
|
|
411
395
|
* optional case-insensitive search over username / email / name / custom fields. */
|
|
412
|
-
listUsers(q?: string): Promise<{ users: UserView[]; truncated: boolean }> {
|
|
413
|
-
|
|
414
|
-
|
|
396
|
+
listUsers(q?: string, limit?: number): Promise<{ users: UserView[]; truncated: boolean }> {
|
|
397
|
+
return this.request<{ users: UserView[]; truncated: boolean }>(`/users${listQs(q, limit)}`);
|
|
398
|
+
}
|
|
399
|
+
/** Read one user in the caller's tenant (requires `manage:users`). */
|
|
400
|
+
getUser(userId: string): Promise<UserView> {
|
|
401
|
+
return this.request<UserView>(`/users/${enc(userId)}`);
|
|
415
402
|
}
|
|
416
403
|
patchUser(userId: string, body: PatchUserRequest): Promise<UserView> {
|
|
417
404
|
return this.request<UserView>(`/users/${enc(userId)}`, {
|
|
@@ -441,19 +428,25 @@ export class UmamiClient {
|
|
|
441
428
|
body: JSON.stringify({ currentPassword, newPassword }),
|
|
442
429
|
});
|
|
443
430
|
}
|
|
444
|
-
/**
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
431
|
+
/** One page of the current user's own audit trail (newest first). Pass `cursor` to page. */
|
|
432
|
+
myAudit(limit?: number, cursor?: string): Promise<AuditPage> {
|
|
433
|
+
return this.request<AuditPage>(`/auth/me/audit${auditQs(limit, cursor)}`);
|
|
434
|
+
}
|
|
435
|
+
/** One page of a tenant's audit trail (requires `admin:tenant`; own tenant). */
|
|
436
|
+
tenantAudit(tenantId: string, limit?: number, cursor?: string): Promise<AuditPage> {
|
|
437
|
+
return this.request<AuditPage>(`/tenants/${enc(tenantId)}/audit${auditQs(limit, cursor)}`);
|
|
438
|
+
}
|
|
439
|
+
/** One page of a tenant user's audit trail (requires `manage:users`; own tenant). */
|
|
440
|
+
userAudit(userId: string, limit?: number, cursor?: string): Promise<AuditPage> {
|
|
441
|
+
return this.request<AuditPage>(`/users/${enc(userId)}/audit${auditQs(limit, cursor)}`);
|
|
442
|
+
}
|
|
443
|
+
/** A tenant user's active login sessions (requires `manage:users`; own tenant). */
|
|
444
|
+
userSessions(userId: string): Promise<SessionView[]> {
|
|
445
|
+
return this.request<SessionView[]>(`/users/${enc(userId)}/sessions`);
|
|
446
|
+
}
|
|
447
|
+
/** Revokes all of a tenant user's sessions by bumping their tokenVersion (requires `manage:users`). */
|
|
448
|
+
async logoutUser(userId: string): Promise<void> {
|
|
449
|
+
await this.request(`/users/${enc(userId)}/logout-all`, { method: "POST" });
|
|
457
450
|
}
|
|
458
451
|
|
|
459
452
|
// ── messaging links ─────────────────────────────────────────────────────────
|
|
@@ -479,6 +472,13 @@ export class UmamiClient {
|
|
|
479
472
|
{ method: "DELETE" },
|
|
480
473
|
);
|
|
481
474
|
}
|
|
475
|
+
/** A tenant user's linked identities, read-only (requires `manage:users`; own tenant). */
|
|
476
|
+
async listUserMessagingLinks(userId: string): Promise<MessagingLink[]> {
|
|
477
|
+
const data = await this.request<{ links: MessagingLink[] }>(
|
|
478
|
+
`/users/${enc(userId)}/messaging-links`,
|
|
479
|
+
);
|
|
480
|
+
return data.links;
|
|
481
|
+
}
|
|
482
482
|
/** Machine (`messaging:link`): claim a `(platform, externalId)` mapping from a link code. */
|
|
483
483
|
createMessagingLink(
|
|
484
484
|
code: string,
|
|
@@ -553,8 +553,61 @@ export class UmamiClient {
|
|
|
553
553
|
async deleteMyPat(keyId: string): Promise<void> {
|
|
554
554
|
await this.request(`/auth/me/api-keys/${enc(keyId)}`, { method: "DELETE" });
|
|
555
555
|
}
|
|
556
|
+
/** A tenant user's personal access tokens, read-only (requires `manage:users`; own tenant). */
|
|
557
|
+
async listUserPats(userId: string): Promise<ApiKeyView[]> {
|
|
558
|
+
const data = await this.request<{ keys: ApiKeyView[] }>(`/users/${enc(userId)}/pats`);
|
|
559
|
+
return data.keys;
|
|
560
|
+
}
|
|
556
561
|
}
|
|
557
562
|
|
|
558
563
|
function enc(value: string): string {
|
|
559
564
|
return encodeURIComponent(value);
|
|
560
565
|
}
|
|
566
|
+
|
|
567
|
+
/** base64url (no padding) of raw bytes. */
|
|
568
|
+
function b64url(bytes: Uint8Array): string {
|
|
569
|
+
let bin = "";
|
|
570
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
571
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Computes the Mode-2 API-key MAC: `HMAC-SHA256(key = SHA-256(secret), "umami:apikey:<keyId>:<hour>")`,
|
|
575
|
+
* base64url. The HMAC key is the SHA-256 of the secret — exactly the digest umami stores — so the
|
|
576
|
+
* server verifies without ever holding the raw secret. Matches `verify_key_hmac` on the server. */
|
|
577
|
+
async function apiKeyMac(keyId: string, secret: string): Promise<string> {
|
|
578
|
+
const subtle = globalThis.crypto.subtle;
|
|
579
|
+
const enc8 = new TextEncoder();
|
|
580
|
+
const secretHash = new Uint8Array(await subtle.digest("SHA-256", enc8.encode(secret)));
|
|
581
|
+
const key = await subtle.importKey("raw", secretHash, { name: "HMAC", hash: "SHA-256" }, false, [
|
|
582
|
+
"sign",
|
|
583
|
+
]);
|
|
584
|
+
const bucket = Math.floor(Date.now() / 3_600_000); // unix ms → whole hours
|
|
585
|
+
const mac = await subtle.sign("HMAC", key, enc8.encode(`umami:apikey:${keyId}:${bucket}`));
|
|
586
|
+
return b64url(new Uint8Array(mac));
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** Builds a `?limit=&cursor=` query string for the audit endpoints (omitting absent params). */
|
|
590
|
+
function auditQs(limit?: number, cursor?: string): string {
|
|
591
|
+
const params = new URLSearchParams();
|
|
592
|
+
if (limit != null) {
|
|
593
|
+
params.set("limit", String(limit));
|
|
594
|
+
}
|
|
595
|
+
if (cursor) {
|
|
596
|
+
params.set("cursor", cursor);
|
|
597
|
+
}
|
|
598
|
+
const s = params.toString();
|
|
599
|
+
return s ? `?${s}` : "";
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** Builds a `?q=&limit=` query string for the list endpoints (omitting absent params). */
|
|
603
|
+
function listQs(q?: string, limit?: number): string {
|
|
604
|
+
const params = new URLSearchParams();
|
|
605
|
+
if (q) {
|
|
606
|
+
params.set("q", q);
|
|
607
|
+
}
|
|
608
|
+
if (limit != null) {
|
|
609
|
+
params.set("limit", String(limit));
|
|
610
|
+
}
|
|
611
|
+
const s = params.toString();
|
|
612
|
+
return s ? `?${s}` : "";
|
|
613
|
+
}
|