@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 +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/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,
|
|
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
|
|
37
|
-
* audience
|
|
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
|
-
/**
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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 `
|
|
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
|
-
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
|
|
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" | "
|
|
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
|
-
|
|
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
|
-
/**
|
|
141
|
-
myAudit(limit?: number): Promise<
|
|
142
|
-
/**
|
|
143
|
-
tenantAudit(tenantId: string, limit?: number): Promise<
|
|
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
|
|
89
|
-
* audience
|
|
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
|
-
/**
|
|
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", {
|
|
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 `
|
|
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
|
-
/**
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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", {
|
|
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
|
-
|
|
308
|
-
|
|
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
|
-
/**
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
/**
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
+
}
|