@bentoforge/umami-iam 0.1.0 → 0.2.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/src/client.ts CHANGED
@@ -2,7 +2,7 @@ import type {
2
2
  AccessClaims,
3
3
  ApiErrorBody,
4
4
  ApiKeyView,
5
- AuditEntry,
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 keeps that
145
- * audience across refreshes. */
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
- /** Update the caller's own profile (name/locale). Blocked for `self:readonly` users. */
189
- patchMe(body: { name?: string; locale?: string }): Promise<MeResponse> {
190
- return this.request<MeResponse>("/auth/me", { method: "PATCH", body: JSON.stringify(body) });
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
- /** Downstream token exchange: mints a token for a product API (`api` from the config catalog)
288
- * for the currently-logged-in user, WITHOUT replacing the stored umami token. Returns the
289
- * downstream token for the caller to use against that API. */
290
- exchange(api: string): Promise<ExchangeResponse> {
291
- return this.request<ExchangeResponse>("/auth/exchange", {
292
- method: "POST",
293
- body: JSON.stringify({ api }),
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
- const qs = q ? `?q=${encodeURIComponent(q)}` : "";
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" | "plan" | "customFields">>,
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<UserView> {
408
- return this.request<UserView>("/users", { method: "POST", body: JSON.stringify(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
- const qs = q ? `?q=${encodeURIComponent(q)}` : "";
414
- return this.request<{ users: UserView[]; truncated: boolean }>(`/users${qs}`);
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
- /** The current user's own audit trail (newest first). */
445
- async myAudit(limit?: number): Promise<AuditEntry[]> {
446
- const qs = limit ? `?limit=${limit}` : "";
447
- const data = await this.request<{ entries: AuditEntry[] }>(`/auth/me/audit${qs}`);
448
- return data.entries;
449
- }
450
- /** A tenant's audit trail (requires `admin:tenant`; own tenant). */
451
- async tenantAudit(tenantId: string, limit?: number): Promise<AuditEntry[]> {
452
- const qs = limit ? `?limit=${limit}` : "";
453
- const data = await this.request<{ entries: AuditEntry[] }>(
454
- `/tenants/${enc(tenantId)}/audit${qs}`,
455
- );
456
- return data.entries;
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
+ }