@bentoforge/umami-iam 0.1.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 +60 -0
- package/dist/client.d.ts +180 -0
- package/dist/client.js +429 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/types.d.ts +371 -0
- package/dist/types.js +2 -0
- package/dist/webauthn.d.ts +12 -0
- package/dist/webauthn.js +73 -0
- package/package.json +50 -0
- package/src/client.ts +560 -0
- package/src/index.ts +11 -0
- package/src/types.ts +428 -0
- package/src/webauthn.ts +83 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AccessClaims,
|
|
3
|
+
ApiErrorBody,
|
|
4
|
+
ApiKeyView,
|
|
5
|
+
AuditEntry,
|
|
6
|
+
Config,
|
|
7
|
+
CreateApiKeyRequest,
|
|
8
|
+
CreateApiKeyResponse,
|
|
9
|
+
CreatePatRequest,
|
|
10
|
+
CreateTenantRequest,
|
|
11
|
+
CreateTenantResponse,
|
|
12
|
+
CreateUserRequest,
|
|
13
|
+
CustomFieldsSchema,
|
|
14
|
+
EntitlementsResponse,
|
|
15
|
+
ExchangeResponse,
|
|
16
|
+
FeatureToggle,
|
|
17
|
+
LoginResponse,
|
|
18
|
+
MeResponse,
|
|
19
|
+
MessagingCodeResponse,
|
|
20
|
+
MessagingLink,
|
|
21
|
+
MetricUsage,
|
|
22
|
+
MfaStatus,
|
|
23
|
+
PatchUserRequest,
|
|
24
|
+
ResetPasswordResponse,
|
|
25
|
+
ResolvedMessagingUser,
|
|
26
|
+
Tenant,
|
|
27
|
+
TenantStatus,
|
|
28
|
+
TokenResponse,
|
|
29
|
+
TotpSetup,
|
|
30
|
+
UsageResponse,
|
|
31
|
+
UserView,
|
|
32
|
+
} from "./types.js";
|
|
33
|
+
import {
|
|
34
|
+
assertionToJSON,
|
|
35
|
+
b64urlToBuffer,
|
|
36
|
+
registrationToJSON,
|
|
37
|
+
toCreationOptions,
|
|
38
|
+
toRequestOptions,
|
|
39
|
+
} from "./webauthn.js";
|
|
40
|
+
|
|
41
|
+
/** An error carrying the server's HTTP status and (parsed) body. */
|
|
42
|
+
export class UmamiError extends Error {
|
|
43
|
+
readonly status: number;
|
|
44
|
+
readonly body?: ApiErrorBody;
|
|
45
|
+
constructor(status: number, message: string, body?: ApiErrorBody) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.name = "UmamiError";
|
|
48
|
+
this.status = status;
|
|
49
|
+
this.body = body;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface UmamiClientOptions {
|
|
54
|
+
/** Base URL of the umami service, e.g. `https://umami.example.com`. */
|
|
55
|
+
baseUrl: string;
|
|
56
|
+
/** Called whenever the in-memory access token changes (login/refresh/logout). */
|
|
57
|
+
onTokenChange?: (token: string | null) => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Typed client for the umami API. Holds the access token **in memory only** and silently refreshes
|
|
62
|
+
* it via the `HttpOnly` cookie on a 401. Never touches the refresh cookie's value.
|
|
63
|
+
*/
|
|
64
|
+
export class UmamiClient {
|
|
65
|
+
private readonly baseUrl: string;
|
|
66
|
+
private readonly onTokenChange?: (token: string | null) => void;
|
|
67
|
+
private accessToken: string | null = null;
|
|
68
|
+
|
|
69
|
+
constructor(options: UmamiClientOptions) {
|
|
70
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
71
|
+
this.onTokenChange = options.onTokenChange;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The current in-memory access token, if any. */
|
|
75
|
+
getAccessToken(): string | null {
|
|
76
|
+
return this.accessToken;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Decodes the current access token's claims (no signature verification). */
|
|
80
|
+
getClaims(): AccessClaims | null {
|
|
81
|
+
if (!this.accessToken) return null;
|
|
82
|
+
const parts = this.accessToken.split(".");
|
|
83
|
+
if (parts.length < 2) return null;
|
|
84
|
+
try {
|
|
85
|
+
const json = new TextDecoder().decode(b64urlToBuffer(parts[1] as string));
|
|
86
|
+
return JSON.parse(json) as AccessClaims;
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Whether the current token grants a permission. */
|
|
93
|
+
hasPermission(permission: string): boolean {
|
|
94
|
+
return this.getClaims()?.permissions?.includes(permission) ?? false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private setToken(token: string | null): void {
|
|
98
|
+
this.accessToken = token;
|
|
99
|
+
this.onTokenChange?.(token);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── transport ───────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
private doFetch(path: string, init: RequestInit, useAuth: boolean): Promise<Response> {
|
|
105
|
+
const headers = new Headers(init.headers);
|
|
106
|
+
if (init.body != null && !headers.has("content-type")) {
|
|
107
|
+
headers.set("content-type", "application/json");
|
|
108
|
+
}
|
|
109
|
+
if (useAuth && this.accessToken) {
|
|
110
|
+
headers.set("authorization", `Bearer ${this.accessToken}`);
|
|
111
|
+
}
|
|
112
|
+
return fetch(`${this.baseUrl}${path}`, { ...init, headers, credentials: "include" });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Performs a request, refreshing once on a 401 for authenticated calls. */
|
|
116
|
+
private async request<T>(path: string, init: RequestInit = {}, useAuth = true): Promise<T> {
|
|
117
|
+
let response = await this.doFetch(path, init, useAuth);
|
|
118
|
+
if (response.status === 401 && useAuth) {
|
|
119
|
+
const refreshed = await this.refresh().catch(() => false);
|
|
120
|
+
if (refreshed) response = await this.doFetch(path, init, useAuth);
|
|
121
|
+
}
|
|
122
|
+
return this.handle<T>(response);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private async handle<T>(response: Response): Promise<T> {
|
|
126
|
+
if (!response.ok) {
|
|
127
|
+
let body: ApiErrorBody | undefined;
|
|
128
|
+
try {
|
|
129
|
+
body = (await response.json()) as ApiErrorBody;
|
|
130
|
+
} catch {
|
|
131
|
+
// non-JSON error body
|
|
132
|
+
}
|
|
133
|
+
throw new UmamiError(response.status, body?.message ?? response.statusText, body);
|
|
134
|
+
}
|
|
135
|
+
if (response.status === 204) return undefined as T;
|
|
136
|
+
const text = await response.text();
|
|
137
|
+
return (text ? JSON.parse(text) : undefined) as T;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── auth ────────────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
/** Password login by username. On success the access token is stored; if MFA is enabled and no
|
|
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. */
|
|
146
|
+
async login(
|
|
147
|
+
username: string,
|
|
148
|
+
password: string,
|
|
149
|
+
totpCode?: string,
|
|
150
|
+
api?: string,
|
|
151
|
+
): Promise<LoginResponse> {
|
|
152
|
+
const data = await this.request<LoginResponse>(
|
|
153
|
+
"/auth/login",
|
|
154
|
+
{ method: "POST", body: JSON.stringify({ username, password, totpCode, api }) },
|
|
155
|
+
false,
|
|
156
|
+
);
|
|
157
|
+
if (data.accessToken) this.setToken(data.accessToken);
|
|
158
|
+
return data;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Silent refresh via the cookie. Returns whether a fresh token was obtained. */
|
|
162
|
+
async refresh(): Promise<boolean> {
|
|
163
|
+
const response = await this.doFetch("/auth/refresh", { method: "POST" }, false);
|
|
164
|
+
if (!response.ok) {
|
|
165
|
+
this.setToken(null);
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
const data = (await response.json()) as TokenResponse;
|
|
169
|
+
this.setToken(data.accessToken);
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Logs out this device and clears the in-memory token. */
|
|
174
|
+
async logout(): Promise<void> {
|
|
175
|
+
await this.request("/auth/logout", { method: "POST" }, false).catch(() => undefined);
|
|
176
|
+
this.setToken(null);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Revokes all of the user's sessions (bumps `tokenVersion`). */
|
|
180
|
+
async logoutAll(): Promise<void> {
|
|
181
|
+
await this.request("/auth/logout-all", { method: "POST" }, true);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Current profile (user + tenant). */
|
|
185
|
+
getMe(): Promise<MeResponse> {
|
|
186
|
+
return this.request<MeResponse>("/auth/me");
|
|
187
|
+
}
|
|
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) });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Re-scope the access token to another tenant (requires `admin:system`). Access-token only —
|
|
194
|
+
* a later silent refresh returns to the home tenant. Returns the active tenant id. */
|
|
195
|
+
async switchTenant(tenantId: string): Promise<string> {
|
|
196
|
+
const data = await this.request<TokenResponse>("/auth/switch-tenant", {
|
|
197
|
+
method: "POST",
|
|
198
|
+
body: JSON.stringify({ tenantId }),
|
|
199
|
+
});
|
|
200
|
+
this.setToken(data.accessToken);
|
|
201
|
+
return this.getClaims()?.tenant ?? tenantId;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── MFA: TOTP ─────────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
totpSetup(): Promise<TotpSetup> {
|
|
207
|
+
return this.request<TotpSetup>("/auth/mfa/totp/setup", { method: "POST" });
|
|
208
|
+
}
|
|
209
|
+
totpVerify(code: string): Promise<MfaStatus> {
|
|
210
|
+
return this.request<MfaStatus>("/auth/mfa/totp/verify", {
|
|
211
|
+
method: "POST",
|
|
212
|
+
body: JSON.stringify({ code }),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
totpDisable(code: string): Promise<MfaStatus> {
|
|
216
|
+
return this.request<MfaStatus>("/auth/mfa/totp/disable", {
|
|
217
|
+
method: "POST",
|
|
218
|
+
body: JSON.stringify({ code }),
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── MFA: WebAuthn (passkeys) ──────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
/** Enrols a passkey for the authenticated user via `navigator.credentials.create`. */
|
|
225
|
+
async registerPasskey(): Promise<{ credentialId: string }> {
|
|
226
|
+
const start = await this.request<{ ceremonyId: string; options: any }>(
|
|
227
|
+
"/auth/webauthn/register/start",
|
|
228
|
+
{ method: "POST" },
|
|
229
|
+
);
|
|
230
|
+
const publicKey = toCreationOptions(start.options.publicKey);
|
|
231
|
+
const credential = (await navigator.credentials.create({
|
|
232
|
+
publicKey,
|
|
233
|
+
})) as PublicKeyCredential | null;
|
|
234
|
+
if (!credential) throw new Error("Passkey registration was cancelled");
|
|
235
|
+
return this.request<{ credentialId: string }>("/auth/webauthn/register/finish", {
|
|
236
|
+
method: "POST",
|
|
237
|
+
body: JSON.stringify({
|
|
238
|
+
ceremonyId: start.ceremonyId,
|
|
239
|
+
credential: registrationToJSON(credential),
|
|
240
|
+
}),
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Passwordless login with a passkey via `navigator.credentials.get`; stores the token. Pass
|
|
245
|
+
* `api` to mint the token for a product API directly (default: umami); the session keeps that
|
|
246
|
+
* audience across refreshes. */
|
|
247
|
+
async loginWithPasskey(username: string, api?: string): Promise<void> {
|
|
248
|
+
const start = await this.request<{ ceremonyId: string; options: any }>(
|
|
249
|
+
"/auth/webauthn/login/start",
|
|
250
|
+
{ method: "POST", body: JSON.stringify({ username }) },
|
|
251
|
+
false,
|
|
252
|
+
);
|
|
253
|
+
const publicKey = toRequestOptions(start.options.publicKey);
|
|
254
|
+
const credential = (await navigator.credentials.get({
|
|
255
|
+
publicKey,
|
|
256
|
+
})) as PublicKeyCredential | null;
|
|
257
|
+
if (!credential) throw new Error("Passkey login was cancelled");
|
|
258
|
+
const data = await this.request<TokenResponse>(
|
|
259
|
+
"/auth/webauthn/login/finish",
|
|
260
|
+
{
|
|
261
|
+
method: "POST",
|
|
262
|
+
body: JSON.stringify({
|
|
263
|
+
ceremonyId: start.ceremonyId,
|
|
264
|
+
credential: assertionToJSON(credential),
|
|
265
|
+
api,
|
|
266
|
+
}),
|
|
267
|
+
},
|
|
268
|
+
false,
|
|
269
|
+
);
|
|
270
|
+
this.setToken(data.accessToken);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── API-key exchange (M2M / BFF) ──────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
/** Exchanges an `umk_…` API key for a short-lived token (stores it). Server-side/BFF use.
|
|
276
|
+
* `api` selects the target API when the key allows more than one (see `docs/AUDIENCES.md`). */
|
|
277
|
+
async exchangeApiKey(apiKey: string, api?: string): Promise<ExchangeResponse> {
|
|
278
|
+
const data = await this.request<ExchangeResponse>(
|
|
279
|
+
"/auth/token",
|
|
280
|
+
{ method: "POST", body: JSON.stringify(api ? { apiKey, api } : { apiKey }) },
|
|
281
|
+
false,
|
|
282
|
+
);
|
|
283
|
+
this.setToken(data.accessToken);
|
|
284
|
+
return data;
|
|
285
|
+
}
|
|
286
|
+
|
|
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
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── tenants ────────────────────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
/** List every tenant (system-admin only; sorted newest-updated first, capped at 250). `q` is an
|
|
300
|
+
* optional case-insensitive search: whitespace-separated terms must all match (over name / slug /
|
|
301
|
+
* 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}`);
|
|
305
|
+
}
|
|
306
|
+
/** Create a tenant and its first owner (system-admin only). */
|
|
307
|
+
createTenant(request: CreateTenantRequest): Promise<CreateTenantResponse> {
|
|
308
|
+
return this.request<CreateTenantResponse>("/tenants", {
|
|
309
|
+
method: "POST",
|
|
310
|
+
body: JSON.stringify(request),
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
/** Delete a tenant — only succeeds when it has no users (system-admin only). */
|
|
314
|
+
deleteTenant(tenantId: string): Promise<{ status: string }> {
|
|
315
|
+
return this.request<{ status: string }>(`/tenants/${enc(tenantId)}`, { method: "DELETE" });
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
getTenant(tenantId: string): Promise<Tenant> {
|
|
319
|
+
return this.request<Tenant>(`/tenants/${enc(tenantId)}`);
|
|
320
|
+
}
|
|
321
|
+
patchTenant(
|
|
322
|
+
tenantId: string,
|
|
323
|
+
body: Partial<Pick<Tenant, "name" | "plan" | "customFields">>,
|
|
324
|
+
): Promise<Tenant> {
|
|
325
|
+
return this.request<Tenant>(`/tenants/${enc(tenantId)}`, {
|
|
326
|
+
method: "PATCH",
|
|
327
|
+
body: JSON.stringify(body),
|
|
328
|
+
});
|
|
329
|
+
}
|
|
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
|
+
|
|
369
|
+
// ── authorization: assignable roles/scopes/features + feature grant/revoke ─────
|
|
370
|
+
|
|
371
|
+
/** Role codes assignable to a user given their tenant's features (feeds the UI role picker). */
|
|
372
|
+
assignableRoles(userId: string): Promise<{ codes: string[] }> {
|
|
373
|
+
return this.request<{ codes: string[] }>(`/users/${enc(userId)}/assignable-roles`);
|
|
374
|
+
}
|
|
375
|
+
/** Scope codes assignable to a service key in the given tenant. */
|
|
376
|
+
assignableScopes(tenantId: string): Promise<{ codes: string[] }> {
|
|
377
|
+
return this.request<{ codes: string[] }>(`/tenants/${enc(tenantId)}/assignable-scopes`);
|
|
378
|
+
}
|
|
379
|
+
/** Authorization features grantable to the given tenant right now (system admin). */
|
|
380
|
+
assignableFeatures(tenantId: string): Promise<{ codes: string[] }> {
|
|
381
|
+
return this.request<{ codes: string[] }>(`/tenants/${enc(tenantId)}/assignable-features`);
|
|
382
|
+
}
|
|
383
|
+
/** Grant an authorization feature to a tenant (system admin). */
|
|
384
|
+
grantFeature(tenantId: string, code: string): Promise<{ status: string }> {
|
|
385
|
+
return this.request<{ status: string }>(`/tenants/${enc(tenantId)}/features/${enc(code)}`, {
|
|
386
|
+
method: "POST",
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
/** Revoke an authorization feature from a tenant (system admin). */
|
|
390
|
+
revokeFeature(tenantId: string, code: string): Promise<{ status: string }> {
|
|
391
|
+
return this.request<{ status: string }>(`/tenants/${enc(tenantId)}/features/${enc(code)}`, {
|
|
392
|
+
method: "DELETE",
|
|
393
|
+
});
|
|
394
|
+
}
|
|
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
|
+
|
|
405
|
+
// ── users ────────────────────────────────────────────────────────────────────
|
|
406
|
+
|
|
407
|
+
createUser(request: CreateUserRequest): Promise<UserView> {
|
|
408
|
+
return this.request<UserView>("/users", { method: "POST", body: JSON.stringify(request) });
|
|
409
|
+
}
|
|
410
|
+
/** List the caller's tenant's users (sorted by recent activity, capped at 250). `q` is an
|
|
411
|
+
* 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}`);
|
|
415
|
+
}
|
|
416
|
+
patchUser(userId: string, body: PatchUserRequest): Promise<UserView> {
|
|
417
|
+
return this.request<UserView>(`/users/${enc(userId)}`, {
|
|
418
|
+
method: "PATCH",
|
|
419
|
+
body: JSON.stringify(body),
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
/** Hard-delete a user in the caller's tenant (cannot delete your own account). */
|
|
423
|
+
deleteUser(userId: string): Promise<{ status: string }> {
|
|
424
|
+
return this.request<{ status: string }>(`/users/${enc(userId)}`, { method: "DELETE" });
|
|
425
|
+
}
|
|
426
|
+
/** Admin reset of a user's password. Omit `newPassword` to have a temporary one generated and
|
|
427
|
+
* returned once. Invalidates the user's existing sessions/tokens. */
|
|
428
|
+
resetPassword(userId: string, newPassword?: string): Promise<ResetPasswordResponse> {
|
|
429
|
+
return this.request<ResetPasswordResponse>(`/users/${enc(userId)}/password`, {
|
|
430
|
+
method: "POST",
|
|
431
|
+
body: JSON.stringify(newPassword ? { newPassword } : {}),
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ── Self-service password + audit ──────────────────────────────────────────────
|
|
436
|
+
|
|
437
|
+
/** Change the current user's own password (verifies the current one; logs out other sessions). */
|
|
438
|
+
async changePassword(currentPassword: string, newPassword: string): Promise<void> {
|
|
439
|
+
await this.request("/auth/me/password", {
|
|
440
|
+
method: "POST",
|
|
441
|
+
body: JSON.stringify({ currentPassword, newPassword }),
|
|
442
|
+
});
|
|
443
|
+
}
|
|
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;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ── messaging links ─────────────────────────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
/** The caller's current link code (rotated if expired), with deep links when configured. */
|
|
462
|
+
getMessagingCode(): Promise<MessagingCodeResponse> {
|
|
463
|
+
return this.request<MessagingCodeResponse>("/auth/me/messaging-code");
|
|
464
|
+
}
|
|
465
|
+
/** Replace the caller's link code (invalidates the old). */
|
|
466
|
+
regenerateMessagingCode(): Promise<MessagingCodeResponse> {
|
|
467
|
+
return this.request<MessagingCodeResponse>("/auth/me/messaging-code/regenerate", {
|
|
468
|
+
method: "POST",
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
/** The caller's linked external identities. */
|
|
472
|
+
listMessagingLinks(): Promise<{ links: MessagingLink[] }> {
|
|
473
|
+
return this.request<{ links: MessagingLink[] }>("/auth/me/messaging-links");
|
|
474
|
+
}
|
|
475
|
+
/** Remove one of the caller's linked identities. */
|
|
476
|
+
deleteMessagingLink(platform: string, externalId: string): Promise<{ status: string }> {
|
|
477
|
+
return this.request<{ status: string }>(
|
|
478
|
+
`/auth/me/messaging-links/${enc(platform)}/${enc(externalId)}`,
|
|
479
|
+
{ method: "DELETE" },
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
/** Machine (`messaging:link`): claim a `(platform, externalId)` mapping from a link code. */
|
|
483
|
+
createMessagingLink(
|
|
484
|
+
code: string,
|
|
485
|
+
platform: string,
|
|
486
|
+
externalId: string,
|
|
487
|
+
): Promise<{ userId: string; tenantId: string }> {
|
|
488
|
+
return this.request<{ userId: string; tenantId: string }>("/messaging/links", {
|
|
489
|
+
method: "POST",
|
|
490
|
+
body: JSON.stringify({ code, platform, externalId }),
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
/** Machine (`messaging:resolve`): resolve an identity to user info. */
|
|
494
|
+
resolveMessaging(platform: string, externalId: string): Promise<ResolvedMessagingUser> {
|
|
495
|
+
const qs = `?platform=${encodeURIComponent(platform)}&externalId=${encodeURIComponent(externalId)}`;
|
|
496
|
+
return this.request<ResolvedMessagingUser>(`/messaging/resolve${qs}`);
|
|
497
|
+
}
|
|
498
|
+
/** Machine (`messaging:resolve`): resolve an identity to a minted token for `api`. */
|
|
499
|
+
resolveMessagingToken(
|
|
500
|
+
platform: string,
|
|
501
|
+
externalId: string,
|
|
502
|
+
api: string,
|
|
503
|
+
): Promise<{ accessToken: string; expiresIn: number }> {
|
|
504
|
+
const qs =
|
|
505
|
+
`?platform=${encodeURIComponent(platform)}&externalId=${encodeURIComponent(externalId)}` +
|
|
506
|
+
`&format=jwt&api=${encodeURIComponent(api)}`;
|
|
507
|
+
return this.request<{ accessToken: string; expiresIn: number }>(`/messaging/resolve${qs}`);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ── config ────────────────────────────────────────────────────────────────────
|
|
511
|
+
|
|
512
|
+
getConfig(): Promise<Config> {
|
|
513
|
+
return this.request<Config>("/config");
|
|
514
|
+
}
|
|
515
|
+
putConfig(config: Config): Promise<Config> {
|
|
516
|
+
return this.request<Config>("/config", { method: "PUT", body: JSON.stringify(config) });
|
|
517
|
+
}
|
|
518
|
+
/** The user + tenant custom-field schemas (any authenticated admin; no `manage:config` needed). */
|
|
519
|
+
getCustomFields(): Promise<CustomFieldsSchema> {
|
|
520
|
+
return this.request<CustomFieldsSchema>("/config/custom-fields");
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// ── API keys: tenant service keys (write:members) ──────────────────────────────
|
|
524
|
+
|
|
525
|
+
createApiKey(tenantId: string, request: CreateApiKeyRequest): Promise<CreateApiKeyResponse> {
|
|
526
|
+
return this.request<CreateApiKeyResponse>(`/tenants/${enc(tenantId)}/api-keys`, {
|
|
527
|
+
method: "POST",
|
|
528
|
+
body: JSON.stringify(request),
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
async listApiKeys(tenantId: string): Promise<ApiKeyView[]> {
|
|
532
|
+
const data = await this.request<{ keys: ApiKeyView[] }>(`/tenants/${enc(tenantId)}/api-keys`);
|
|
533
|
+
return data.keys;
|
|
534
|
+
}
|
|
535
|
+
async deleteApiKey(tenantId: string, keyId: string): Promise<void> {
|
|
536
|
+
await this.request(`/tenants/${enc(tenantId)}/api-keys/${enc(keyId)}`, { method: "DELETE" });
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// ── Personal access tokens: your own (self-service) ────────────────────────────
|
|
540
|
+
|
|
541
|
+
/** Create a personal access token that acts as the current user (optionally down-scoped).
|
|
542
|
+
* The `apiKey` secret in the response is shown only once. */
|
|
543
|
+
createMyPat(request: CreatePatRequest): Promise<CreateApiKeyResponse> {
|
|
544
|
+
return this.request<CreateApiKeyResponse>("/auth/me/api-keys", {
|
|
545
|
+
method: "POST",
|
|
546
|
+
body: JSON.stringify(request),
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
async listMyPats(): Promise<ApiKeyView[]> {
|
|
550
|
+
const data = await this.request<{ keys: ApiKeyView[] }>("/auth/me/api-keys");
|
|
551
|
+
return data.keys;
|
|
552
|
+
}
|
|
553
|
+
async deleteMyPat(keyId: string): Promise<void> {
|
|
554
|
+
await this.request(`/auth/me/api-keys/${enc(keyId)}`, { method: "DELETE" });
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function enc(value: string): string {
|
|
559
|
+
return encodeURIComponent(value);
|
|
560
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type { UmamiClientOptions } from "./client.js";
|
|
2
|
+
export { UmamiClient, UmamiError } from "./client.js";
|
|
3
|
+
export * from "./types.js";
|
|
4
|
+
export {
|
|
5
|
+
assertionToJSON,
|
|
6
|
+
b64urlToBuffer,
|
|
7
|
+
bufferToB64url,
|
|
8
|
+
registrationToJSON,
|
|
9
|
+
toCreationOptions,
|
|
10
|
+
toRequestOptions,
|
|
11
|
+
} from "./webauthn.js";
|