@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The umami authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AccessClaims, ApiErrorBody, ApiKeyView, AuditEntry, Config, CreateApiKeyRequest, CreateApiKeyResponse, CreatePatRequest, CreateTenantRequest, CreateTenantResponse, CreateUserRequest, CustomFieldsSchema, EntitlementsResponse, ExchangeResponse, FeatureToggle, LoginResponse, MeResponse, MessagingCodeResponse, MessagingLink, MetricUsage, MfaStatus, PatchUserRequest, ResetPasswordResponse, ResolvedMessagingUser, Tenant, TenantStatus, TotpSetup, UsageResponse, UserView } from "./types.js";
1
+ import type { AccessClaims, ApiErrorBody, ApiKeyView, AuditPage, Config, CreateApiKeyRequest, CreateApiKeyResponse, CreatePatRequest, CreateTenantRequest, CreateTenantResponse, CreateUserRequest, CreateUserResponse, CustomFieldsSchema, ExchangeResponse, LoginResponse, MeResponse, MessagingCodeResponse, MessagingLink, MfaStatus, NameInput, PatchUserRequest, ResetPasswordResponse, ResolvedMessagingUser, SessionView, Tenant, TotpSetup, UserView } from "./types.js";
2
2
  /** An error carrying the server's HTTP status and (parsed) body. */
3
3
  export declare class UmamiError extends Error {
4
4
  readonly status: number;
@@ -19,6 +19,8 @@ export declare class UmamiClient {
19
19
  private readonly baseUrl;
20
20
  private readonly onTokenChange?;
21
21
  private accessToken;
22
+ /** In-flight refresh, if any — coalesces concurrent 401s into a single rotation. */
23
+ private refreshing;
22
24
  constructor(options: UmamiClientOptions);
23
25
  /** The current in-memory access token, if any. */
24
26
  getAccessToken(): string | null;
@@ -33,23 +35,32 @@ export declare class UmamiClient {
33
35
  private handle;
34
36
  /** Password login by username. On success the access token is stored; if MFA is enabled and no
35
37
  * `totpCode` is given, the response has `mfaRequired: true` and no token. Pass `api` to mint the
36
- * token for a product API directly (default: the umami admin API); the session keeps that
37
- * audience across refreshes. */
38
+ * first token for a product API directly (default: the umami admin API); the session itself is
39
+ * audience-agnostic, so later `refresh` calls choose their own `api`. */
38
40
  login(username: string, password: string, totpCode?: string, api?: string): Promise<LoginResponse>;
39
- /** Silent refresh via the cookie. Returns whether a fresh token was obtained. */
41
+ /** Silent refresh via the cookie. Returns whether a fresh token was obtained.
42
+ *
43
+ * Single-flighted: concurrent callers (e.g. several requests that 401 at once) all await one
44
+ * rotation. Without this, the second refresh would send the just-rotated-out cookie secret, which
45
+ * the server treats as token reuse and revokes the whole session. */
40
46
  refresh(): Promise<boolean>;
47
+ private doRefresh;
41
48
  /** Logs out this device and clears the in-memory token. */
42
49
  logout(): Promise<void>;
43
50
  /** Revokes all of the user's sessions (bumps `tokenVersion`). */
44
51
  logoutAll(): Promise<void>;
52
+ /** Lists the caller's active login sessions (the current one is flagged). */
53
+ listSessions(): Promise<SessionView[]>;
54
+ /** Revokes one of the caller's own sessions (single-device logout). */
55
+ deleteSession(sessionId: string): Promise<void>;
45
56
  /** Current profile (user + tenant). */
46
57
  getMe(): Promise<MeResponse>;
47
- /** Update the caller's own profile (name/locale). Blocked for `self:readonly` users. */
48
- patchMe(body: {
49
- name?: string;
50
- locale?: string;
58
+ /** Self-service profile edit: the structured name parts are always editable; custom fields only
59
+ * when marked `selfEditable`. Blocked for `self:readonly` users. */
60
+ patchMe(body: NameInput & {
61
+ customFields?: Record<string, unknown>;
51
62
  }): Promise<MeResponse>;
52
- /** Re-scope the access token to another tenant (requires `admin:system`). Access-token only —
63
+ /** Re-scope the access token to another tenant (requires `switch:tenant`). Access-token only —
53
64
  * a later silent refresh returns to the home tenant. Returns the active tenant id. */
54
65
  switchTenant(tenantId: string): Promise<string>;
55
66
  totpSetup(): Promise<TotpSetup>;
@@ -66,14 +77,14 @@ export declare class UmamiClient {
66
77
  /** Exchanges an `umk_…` API key for a short-lived token (stores it). Server-side/BFF use.
67
78
  * `api` selects the target API when the key allows more than one (see `docs/AUDIENCES.md`). */
68
79
  exchangeApiKey(apiKey: string, api?: string): Promise<ExchangeResponse>;
69
- /** Downstream token exchange: mints a token for a product API (`api` from the config catalog)
70
- * for the currently-logged-in user, WITHOUT replacing the stored umami token. Returns the
71
- * downstream token for the caller to use against that API. */
72
- exchange(api: string): Promise<ExchangeResponse>;
80
+ /** Mode 2 exchange: prove possession of `keyId`/`secret` with an HMAC over the current hour bucket
81
+ * instead of sending the secret. Same result as {@link exchangeApiKey}; the raw secret never
82
+ * leaves the process. `secret` is the part after `umk_<keyId>_`. Uses WebCrypto (Node 18+/browser). */
83
+ exchangeApiKeyHmac(keyId: string, secret: string, api?: string): Promise<ExchangeResponse>;
73
84
  /** List every tenant (system-admin only; sorted newest-updated first, capped at 250). `q` is an
74
85
  * optional case-insensitive search: whitespace-separated terms must all match (over name / slug /
75
86
  * custom fields). `truncated` is true when more than 250 matched. */
76
- listTenants(q?: string): Promise<{
87
+ listTenants(q?: string, limit?: number): Promise<{
77
88
  tenants: Tenant[];
78
89
  truncated: boolean;
79
90
  }>;
@@ -84,20 +95,7 @@ export declare class UmamiClient {
84
95
  status: string;
85
96
  }>;
86
97
  getTenant(tenantId: string): Promise<Tenant>;
87
- patchTenant(tenantId: string, body: Partial<Pick<Tenant, "name" | "plan" | "customFields">>): Promise<Tenant>;
88
- patchStatus(tenantId: string, status: TenantStatus): Promise<Tenant>;
89
- patchLicense(tenantId: string, body: {
90
- plan?: string;
91
- billedUntil?: string;
92
- seatsLimit?: number;
93
- }): Promise<Tenant>;
94
- getEntitlements(tenantId: string): Promise<EntitlementsResponse>;
95
- assignPackage(tenantId: string, request: {
96
- code: string;
97
- monthlyPrice?: string;
98
- }): Promise<Tenant>;
99
- removePackage(tenantId: string, assignmentId: string): Promise<Tenant>;
100
- setFeature(tenantId: string, code: string, value: FeatureToggle): Promise<Tenant>;
98
+ patchTenant(tenantId: string, body: Partial<Pick<Tenant, "name" | "customFields">>): Promise<Tenant>;
101
99
  /** Role codes assignable to a user given their tenant's features (feeds the UI role picker). */
102
100
  assignableRoles(userId: string): Promise<{
103
101
  codes: string[];
@@ -118,15 +116,15 @@ export declare class UmamiClient {
118
116
  revokeFeature(tenantId: string, code: string): Promise<{
119
117
  status: string;
120
118
  }>;
121
- getUsage(tenantId: string): Promise<UsageResponse>;
122
- incrementUsage(tenantId: string, metric: string, amount?: number): Promise<MetricUsage>;
123
- createUser(request: CreateUserRequest): Promise<UserView>;
119
+ createUser(request: CreateUserRequest): Promise<CreateUserResponse>;
124
120
  /** List the caller's tenant's users (sorted by recent activity, capped at 250). `q` is an
125
121
  * optional case-insensitive search over username / email / name / custom fields. */
126
- listUsers(q?: string): Promise<{
122
+ listUsers(q?: string, limit?: number): Promise<{
127
123
  users: UserView[];
128
124
  truncated: boolean;
129
125
  }>;
126
+ /** Read one user in the caller's tenant (requires `manage:users`). */
127
+ getUser(userId: string): Promise<UserView>;
130
128
  patchUser(userId: string, body: PatchUserRequest): Promise<UserView>;
131
129
  /** Hard-delete a user in the caller's tenant (cannot delete your own account). */
132
130
  deleteUser(userId: string): Promise<{
@@ -137,10 +135,16 @@ export declare class UmamiClient {
137
135
  resetPassword(userId: string, newPassword?: string): Promise<ResetPasswordResponse>;
138
136
  /** Change the current user's own password (verifies the current one; logs out other sessions). */
139
137
  changePassword(currentPassword: string, newPassword: string): Promise<void>;
140
- /** The current user's own audit trail (newest first). */
141
- myAudit(limit?: number): Promise<AuditEntry[]>;
142
- /** A tenant's audit trail (requires `admin:tenant`; own tenant). */
143
- tenantAudit(tenantId: string, limit?: number): Promise<AuditEntry[]>;
138
+ /** One page of the current user's own audit trail (newest first). Pass `cursor` to page. */
139
+ myAudit(limit?: number, cursor?: string): Promise<AuditPage>;
140
+ /** One page of a tenant's audit trail (requires `admin:tenant`; own tenant). */
141
+ tenantAudit(tenantId: string, limit?: number, cursor?: string): Promise<AuditPage>;
142
+ /** One page of a tenant user's audit trail (requires `manage:users`; own tenant). */
143
+ userAudit(userId: string, limit?: number, cursor?: string): Promise<AuditPage>;
144
+ /** A tenant user's active login sessions (requires `manage:users`; own tenant). */
145
+ userSessions(userId: string): Promise<SessionView[]>;
146
+ /** Revokes all of a tenant user's sessions by bumping their tokenVersion (requires `manage:users`). */
147
+ logoutUser(userId: string): Promise<void>;
144
148
  /** The caller's current link code (rotated if expired), with deep links when configured. */
145
149
  getMessagingCode(): Promise<MessagingCodeResponse>;
146
150
  /** Replace the caller's link code (invalidates the old). */
@@ -153,6 +157,8 @@ export declare class UmamiClient {
153
157
  deleteMessagingLink(platform: string, externalId: string): Promise<{
154
158
  status: string;
155
159
  }>;
160
+ /** A tenant user's linked identities, read-only (requires `manage:users`; own tenant). */
161
+ listUserMessagingLinks(userId: string): Promise<MessagingLink[]>;
156
162
  /** Machine (`messaging:link`): claim a `(platform, externalId)` mapping from a link code. */
157
163
  createMessagingLink(code: string, platform: string, externalId: string): Promise<{
158
164
  userId: string;
@@ -177,4 +183,6 @@ export declare class UmamiClient {
177
183
  createMyPat(request: CreatePatRequest): Promise<CreateApiKeyResponse>;
178
184
  listMyPats(): Promise<ApiKeyView[]>;
179
185
  deleteMyPat(keyId: string): Promise<void>;
186
+ /** A tenant user's personal access tokens, read-only (requires `manage:users`; own tenant). */
187
+ listUserPats(userId: string): Promise<ApiKeyView[]>;
180
188
  }
package/dist/client.js CHANGED
@@ -15,6 +15,8 @@ export class UmamiError extends Error {
15
15
  export class UmamiClient {
16
16
  constructor(options) {
17
17
  this.accessToken = null;
18
+ /** In-flight refresh, if any — coalesces concurrent 401s into a single rotation. */
19
+ this.refreshing = null;
18
20
  this.baseUrl = options.baseUrl.replace(/\/+$/, "");
19
21
  this.onTokenChange = options.onTokenChange;
20
22
  }
@@ -85,16 +87,28 @@ export class UmamiClient {
85
87
  // ── auth ────────────────────────────────────────────────────────────────────
86
88
  /** Password login by username. On success the access token is stored; if MFA is enabled and no
87
89
  * `totpCode` is given, the response has `mfaRequired: true` and no token. Pass `api` to mint the
88
- * token for a product API directly (default: the umami admin API); the session keeps that
89
- * audience across refreshes. */
90
+ * first token for a product API directly (default: the umami admin API); the session itself is
91
+ * audience-agnostic, so later `refresh` calls choose their own `api`. */
90
92
  async login(username, password, totpCode, api) {
91
93
  const data = await this.request("/auth/login", { method: "POST", body: JSON.stringify({ username, password, totpCode, api }) }, false);
92
94
  if (data.accessToken)
93
95
  this.setToken(data.accessToken);
94
96
  return data;
95
97
  }
96
- /** Silent refresh via the cookie. Returns whether a fresh token was obtained. */
98
+ /** Silent refresh via the cookie. Returns whether a fresh token was obtained.
99
+ *
100
+ * Single-flighted: concurrent callers (e.g. several requests that 401 at once) all await one
101
+ * rotation. Without this, the second refresh would send the just-rotated-out cookie secret, which
102
+ * the server treats as token reuse and revokes the whole session. */
97
103
  async refresh() {
104
+ if (this.refreshing)
105
+ return this.refreshing;
106
+ this.refreshing = this.doRefresh().finally(() => {
107
+ this.refreshing = null;
108
+ });
109
+ return this.refreshing;
110
+ }
111
+ async doRefresh() {
98
112
  const response = await this.doFetch("/auth/refresh", { method: "POST" }, false);
99
113
  if (!response.ok) {
100
114
  this.setToken(null);
@@ -113,15 +127,27 @@ export class UmamiClient {
113
127
  async logoutAll() {
114
128
  await this.request("/auth/logout-all", { method: "POST" }, true);
115
129
  }
130
+ /** Lists the caller's active login sessions (the current one is flagged). */
131
+ listSessions() {
132
+ return this.request("/auth/sessions");
133
+ }
134
+ /** Revokes one of the caller's own sessions (single-device logout). */
135
+ async deleteSession(sessionId) {
136
+ await this.request(`/auth/sessions/${enc(sessionId)}`, { method: "DELETE" });
137
+ }
116
138
  /** Current profile (user + tenant). */
117
139
  getMe() {
118
140
  return this.request("/auth/me");
119
141
  }
120
- /** Update the caller's own profile (name/locale). Blocked for `self:readonly` users. */
142
+ /** Self-service profile edit: the structured name parts are always editable; custom fields only
143
+ * when marked `selfEditable`. Blocked for `self:readonly` users. */
121
144
  patchMe(body) {
122
- return this.request("/auth/me", { method: "PATCH", body: JSON.stringify(body) });
145
+ return this.request("/auth/me", {
146
+ method: "PATCH",
147
+ body: JSON.stringify(body),
148
+ });
123
149
  }
124
- /** Re-scope the access token to another tenant (requires `admin:system`). Access-token only —
150
+ /** Re-scope the access token to another tenant (requires `switch:tenant`). Access-token only —
125
151
  * a later silent refresh returns to the home tenant. Returns the active tenant id. */
126
152
  async switchTenant(tenantId) {
127
153
  const data = await this.request("/auth/switch-tenant", {
@@ -194,22 +220,21 @@ export class UmamiClient {
194
220
  this.setToken(data.accessToken);
195
221
  return data;
196
222
  }
197
- /** Downstream token exchange: mints a token for a product API (`api` from the config catalog)
198
- * for the currently-logged-in user, WITHOUT replacing the stored umami token. Returns the
199
- * downstream token for the caller to use against that API. */
200
- exchange(api) {
201
- return this.request("/auth/exchange", {
202
- method: "POST",
203
- body: JSON.stringify({ api }),
204
- });
223
+ /** Mode 2 exchange: prove possession of `keyId`/`secret` with an HMAC over the current hour bucket
224
+ * instead of sending the secret. Same result as {@link exchangeApiKey}; the raw secret never
225
+ * leaves the process. `secret` is the part after `umk_<keyId>_`. Uses WebCrypto (Node 18+/browser). */
226
+ async exchangeApiKeyHmac(keyId, secret, api) {
227
+ const mac = await apiKeyMac(keyId, secret);
228
+ const data = await this.request("/auth/token", { method: "POST", body: JSON.stringify(api ? { keyId, mac, api } : { keyId, mac }) }, false);
229
+ this.setToken(data.accessToken);
230
+ return data;
205
231
  }
206
232
  // ── tenants ────────────────────────────────────────────────────────────────────
207
233
  /** List every tenant (system-admin only; sorted newest-updated first, capped at 250). `q` is an
208
234
  * optional case-insensitive search: whitespace-separated terms must all match (over name / slug /
209
235
  * custom fields). `truncated` is true when more than 250 matched. */
210
- listTenants(q) {
211
- const qs = q ? `?q=${encodeURIComponent(q)}` : "";
212
- return this.request(`/tenants${qs}`);
236
+ listTenants(q, limit) {
237
+ return this.request(`/tenants${listQs(q, limit)}`);
213
238
  }
214
239
  /** Create a tenant and its first owner (system-admin only). */
215
240
  createTenant(request) {
@@ -231,38 +256,6 @@ export class UmamiClient {
231
256
  body: JSON.stringify(body),
232
257
  });
233
258
  }
234
- patchStatus(tenantId, status) {
235
- return this.request(`/tenants/${enc(tenantId)}/status`, {
236
- method: "PATCH",
237
- body: JSON.stringify({ status }),
238
- });
239
- }
240
- patchLicense(tenantId, body) {
241
- return this.request(`/tenants/${enc(tenantId)}/license`, {
242
- method: "PATCH",
243
- body: JSON.stringify(body),
244
- });
245
- }
246
- getEntitlements(tenantId) {
247
- return this.request(`/tenants/${enc(tenantId)}/entitlements`);
248
- }
249
- assignPackage(tenantId, request) {
250
- return this.request(`/tenants/${enc(tenantId)}/packages`, {
251
- method: "POST",
252
- body: JSON.stringify(request),
253
- });
254
- }
255
- removePackage(tenantId, assignmentId) {
256
- return this.request(`/tenants/${enc(tenantId)}/packages/${enc(assignmentId)}`, {
257
- method: "DELETE",
258
- });
259
- }
260
- setFeature(tenantId, code, value) {
261
- return this.request(`/tenants/${enc(tenantId)}/features/${enc(code)}`, {
262
- method: "PUT",
263
- body: JSON.stringify({ value }),
264
- });
265
- }
266
259
  // ── authorization: assignable roles/scopes/features + feature grant/revoke ─────
267
260
  /** Role codes assignable to a user given their tenant's features (feeds the UI role picker). */
268
261
  assignableRoles(userId) {
@@ -288,24 +281,21 @@ export class UmamiClient {
288
281
  method: "DELETE",
289
282
  });
290
283
  }
291
- getUsage(tenantId) {
292
- return this.request(`/tenants/${enc(tenantId)}/usage`);
293
- }
294
- incrementUsage(tenantId, metric, amount = 1) {
295
- return this.request(`/tenants/${enc(tenantId)}/usage/${enc(metric)}`, {
296
- method: "POST",
297
- body: JSON.stringify({ amount }),
298
- });
299
- }
300
284
  // ── users ────────────────────────────────────────────────────────────────────
301
285
  createUser(request) {
302
- return this.request("/users", { method: "POST", body: JSON.stringify(request) });
286
+ return this.request("/users", {
287
+ method: "POST",
288
+ body: JSON.stringify(request),
289
+ });
303
290
  }
304
291
  /** List the caller's tenant's users (sorted by recent activity, capped at 250). `q` is an
305
292
  * optional case-insensitive search over username / email / name / custom fields. */
306
- listUsers(q) {
307
- const qs = q ? `?q=${encodeURIComponent(q)}` : "";
308
- return this.request(`/users${qs}`);
293
+ listUsers(q, limit) {
294
+ return this.request(`/users${listQs(q, limit)}`);
295
+ }
296
+ /** Read one user in the caller's tenant (requires `manage:users`). */
297
+ getUser(userId) {
298
+ return this.request(`/users/${enc(userId)}`);
309
299
  }
310
300
  patchUser(userId, body) {
311
301
  return this.request(`/users/${enc(userId)}`, {
@@ -333,17 +323,25 @@ export class UmamiClient {
333
323
  body: JSON.stringify({ currentPassword, newPassword }),
334
324
  });
335
325
  }
336
- /** The current user's own audit trail (newest first). */
337
- async myAudit(limit) {
338
- const qs = limit ? `?limit=${limit}` : "";
339
- const data = await this.request(`/auth/me/audit${qs}`);
340
- return data.entries;
326
+ /** One page of the current user's own audit trail (newest first). Pass `cursor` to page. */
327
+ myAudit(limit, cursor) {
328
+ return this.request(`/auth/me/audit${auditQs(limit, cursor)}`);
329
+ }
330
+ /** One page of a tenant's audit trail (requires `admin:tenant`; own tenant). */
331
+ tenantAudit(tenantId, limit, cursor) {
332
+ return this.request(`/tenants/${enc(tenantId)}/audit${auditQs(limit, cursor)}`);
341
333
  }
342
- /** A tenant's audit trail (requires `admin:tenant`; own tenant). */
343
- async tenantAudit(tenantId, limit) {
344
- const qs = limit ? `?limit=${limit}` : "";
345
- const data = await this.request(`/tenants/${enc(tenantId)}/audit${qs}`);
346
- return data.entries;
334
+ /** One page of a tenant user's audit trail (requires `manage:users`; own tenant). */
335
+ userAudit(userId, limit, cursor) {
336
+ return this.request(`/users/${enc(userId)}/audit${auditQs(limit, cursor)}`);
337
+ }
338
+ /** A tenant user's active login sessions (requires `manage:users`; own tenant). */
339
+ userSessions(userId) {
340
+ return this.request(`/users/${enc(userId)}/sessions`);
341
+ }
342
+ /** Revokes all of a tenant user's sessions by bumping their tokenVersion (requires `manage:users`). */
343
+ async logoutUser(userId) {
344
+ await this.request(`/users/${enc(userId)}/logout-all`, { method: "POST" });
347
345
  }
348
346
  // ── messaging links ─────────────────────────────────────────────────────────
349
347
  /** The caller's current link code (rotated if expired), with deep links when configured. */
@@ -364,6 +362,11 @@ export class UmamiClient {
364
362
  deleteMessagingLink(platform, externalId) {
365
363
  return this.request(`/auth/me/messaging-links/${enc(platform)}/${enc(externalId)}`, { method: "DELETE" });
366
364
  }
365
+ /** A tenant user's linked identities, read-only (requires `manage:users`; own tenant). */
366
+ async listUserMessagingLinks(userId) {
367
+ const data = await this.request(`/users/${enc(userId)}/messaging-links`);
368
+ return data.links;
369
+ }
367
370
  /** Machine (`messaging:link`): claim a `(platform, externalId)` mapping from a link code. */
368
371
  createMessagingLink(code, platform, externalId) {
369
372
  return this.request("/messaging/links", {
@@ -423,7 +426,57 @@ export class UmamiClient {
423
426
  async deleteMyPat(keyId) {
424
427
  await this.request(`/auth/me/api-keys/${enc(keyId)}`, { method: "DELETE" });
425
428
  }
429
+ /** A tenant user's personal access tokens, read-only (requires `manage:users`; own tenant). */
430
+ async listUserPats(userId) {
431
+ const data = await this.request(`/users/${enc(userId)}/pats`);
432
+ return data.keys;
433
+ }
426
434
  }
427
435
  function enc(value) {
428
436
  return encodeURIComponent(value);
429
437
  }
438
+ /** base64url (no padding) of raw bytes. */
439
+ function b64url(bytes) {
440
+ let bin = "";
441
+ for (const b of bytes)
442
+ bin += String.fromCharCode(b);
443
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
444
+ }
445
+ /** Computes the Mode-2 API-key MAC: `HMAC-SHA256(key = SHA-256(secret), "umami:apikey:<keyId>:<hour>")`,
446
+ * base64url. The HMAC key is the SHA-256 of the secret — exactly the digest umami stores — so the
447
+ * server verifies without ever holding the raw secret. Matches `verify_key_hmac` on the server. */
448
+ async function apiKeyMac(keyId, secret) {
449
+ const subtle = globalThis.crypto.subtle;
450
+ const enc8 = new TextEncoder();
451
+ const secretHash = new Uint8Array(await subtle.digest("SHA-256", enc8.encode(secret)));
452
+ const key = await subtle.importKey("raw", secretHash, { name: "HMAC", hash: "SHA-256" }, false, [
453
+ "sign",
454
+ ]);
455
+ const bucket = Math.floor(Date.now() / 3_600_000); // unix ms → whole hours
456
+ const mac = await subtle.sign("HMAC", key, enc8.encode(`umami:apikey:${keyId}:${bucket}`));
457
+ return b64url(new Uint8Array(mac));
458
+ }
459
+ /** Builds a `?limit=&cursor=` query string for the audit endpoints (omitting absent params). */
460
+ function auditQs(limit, cursor) {
461
+ const params = new URLSearchParams();
462
+ if (limit != null) {
463
+ params.set("limit", String(limit));
464
+ }
465
+ if (cursor) {
466
+ params.set("cursor", cursor);
467
+ }
468
+ const s = params.toString();
469
+ return s ? `?${s}` : "";
470
+ }
471
+ /** Builds a `?q=&limit=` query string for the list endpoints (omitting absent params). */
472
+ function listQs(q, limit) {
473
+ const params = new URLSearchParams();
474
+ if (q) {
475
+ params.set("q", q);
476
+ }
477
+ if (limit != null) {
478
+ params.set("limit", String(limit));
479
+ }
480
+ const s = params.toString();
481
+ return s ? `?${s}` : "";
482
+ }