backd-js 0.1.15 → 0.3.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/admin.js ADDED
@@ -0,0 +1,597 @@
1
+ import { Job } from './functions.js'
2
+
3
+ /**
4
+ * @typedef {import('./client.js').Client} Client
5
+ * @typedef {import('./client.js').RequestOptions} RequestOptions
6
+ */
7
+
8
+ /**
9
+ * A user as the admin API shows it.
10
+ * @typedef {object} AdminUser
11
+ * @property {string} id
12
+ * @property {string} email
13
+ * @property {boolean} email_verified
14
+ * @property {string} locale
15
+ * @property {string[]} roles
16
+ * @property {boolean} disabled
17
+ * @property {string[]} admin_networks CIDR networks the user's admin requests must come from; empty: no own restriction.
18
+ * @property {string[]} login_networks CIDR networks the user's login and session must be used from; empty: anywhere.
19
+ * @property {string} created_at
20
+ * @property {string} updated_at
21
+ * @property {string | null} erased_at When the user was erased; a tombstone keeps only its id (and a placeholder email).
22
+ */
23
+
24
+ /**
25
+ * @typedef {object} UserPage
26
+ * @property {AdminUser[]} items
27
+ * @property {number} limit
28
+ * @property {number} skip
29
+ * @property {boolean} has_more
30
+ */
31
+
32
+ /**
33
+ * @typedef {object} Invitation
34
+ * @property {string} id
35
+ * @property {string | null} email Only this email may use it; null for anyone.
36
+ * @property {string} created_by
37
+ * @property {string} created_at
38
+ * @property {string} expires_at
39
+ */
40
+
41
+ /**
42
+ * What erasing a user would do, for the collections that declare a policy
43
+ * (`collection.yaml`); the others are only named in `without_policy`.
44
+ * @typedef {object} OwnedReport
45
+ * @property {{ id: string, status: 'active' | 'deactivated' | 'erased' }} user
46
+ * @property {Array<{ database: string, collection: string, action: 'delete' | 'anonymize' | null, owned: number, remove?: string[], replace?: string[], pull?: Record<string, number>, unset?: Record<string, number> }>} collections
47
+ * `owned` counts the documents the user owns; `pull` and `unset` count, per field, the documents that hold the user.
48
+ * @property {string[]} without_policy `<database>.<collection>` of the collections an erase leaves alone.
49
+ */
50
+
51
+ /**
52
+ * @typedef {Invitation & { token: string }} NewInvitation
53
+ * `token` (`bdi_…`) is returned only when the invitation is created.
54
+ */
55
+
56
+ /**
57
+ * @typedef {Invitation & { sent: true }} SentInvitation
58
+ * An invitation that was emailed: nobody holds its token.
59
+ */
60
+
61
+ /**
62
+ * An API key as the admin API lists it (never the key itself).
63
+ * @typedef {object} APIKeyInfo
64
+ * @property {string} name
65
+ * @property {'data' | 'admin'} role
66
+ * @property {string} prefix First characters of the key.
67
+ * @property {string[]} networks Where it may be used from; empty: anywhere.
68
+ * @property {string} created_at
69
+ * @property {string | null} last_used_at
70
+ * @property {string | null} expires_at
71
+ */
72
+
73
+ /**
74
+ * @typedef {APIKeyInfo & { key: string }} NewAPIKey
75
+ * `key` (`bdk_…`) is returned only when the key is created.
76
+ */
77
+
78
+ /**
79
+ * One entry of the realm's audit trail. Never holds secrets or emails.
80
+ * @typedef {object} AuditRecord
81
+ * @property {string} id
82
+ * @property {string} at
83
+ * @property {string} action Such as `user.create`, `role.add`, `apikey.create`, `admin.login`.
84
+ * @property {string} actor `user:<id>`, `key:<name>`, `anonymous`, `config:realm.yaml` or `cli:bootstrap`.
85
+ * @property {string | null} target `user:<id>`, `key:<name>`, `invitation:<id>`, or null.
86
+ * @property {Record<string, unknown>} details
87
+ * @property {string | null} request_id
88
+ * @property {string | null} client_ip
89
+ */
90
+
91
+ /**
92
+ * A function secret's metadata, as listed: never its value.
93
+ * @typedef {object} SecretInfo
94
+ * @property {string} database Its database scope; empty means the realm scope.
95
+ * @property {string} name
96
+ * @property {string} created_at
97
+ * @property {string} updated_at
98
+ * @property {string} updated_by Who last set it, such as `user:<id>` or `key:<name>`.
99
+ */
100
+
101
+ /**
102
+ * One function call in the invocation history: what happened and the
103
+ * function's own console lines, never its input or output.
104
+ * @typedef {object} InvocationRecord
105
+ * @property {string} id
106
+ * @property {string} at
107
+ * @property {string} function `<database>/<name>`.
108
+ * @property {string} actor `user:<id>`, `key:<name>`, `anonymous`, ...
109
+ * @property {string} mode `sync` or `async`.
110
+ * @property {string} status `ok`, `function_error`, `timeout`, `memory`, `cpu`, `crash`, `output_too_large`, `busy` or `bundle`.
111
+ * @property {string | null} code The function's own error code.
112
+ * @property {number} duration_ms
113
+ * @property {string | null} request_id
114
+ * @property {string | null} job_id The async job this call ran for.
115
+ * @property {string | null} parent_id The invocation that called this one with `ctx.call`.
116
+ * @property {string | null} origin `http`, `function`, `cron`, `admin` or `backd:<event>`.
117
+ * @property {{ level: string, line: string }[]} logs
118
+ */
119
+
120
+ /**
121
+ * @typedef {object} InvocationsPage
122
+ * @property {InvocationRecord[]} items Newest first.
123
+ * @property {number} limit
124
+ * @property {number} skip
125
+ * @property {boolean} has_more
126
+ */
127
+
128
+ /**
129
+ * @typedef {object} AuditPage
130
+ * @property {AuditRecord[]} items Newest first.
131
+ * @property {number} limit
132
+ * @property {number} skip
133
+ * @property {boolean} has_more
134
+ */
135
+
136
+ /**
137
+ * An async or scheduled job as the admin listing shows it: its state and
138
+ * how it ended, never its input or output.
139
+ * @typedef {object} JobSummary
140
+ * @property {string} id For cron runs, `cron_<database>_<function>_<yyyymmddhhmm>` (UTC).
141
+ * @property {string} function `<database>/<name>`.
142
+ * @property {'queued' | 'running' | 'done'} status
143
+ * @property {boolean} scheduled True for a cron run.
144
+ * @property {number} attempts More than 1 after a worker was lost mid-run.
145
+ * @property {string} created_at
146
+ * @property {string | null} completed_at
147
+ * @property {{ status: string, code: string | null, duration_ms: number } | null} result Null until `done`.
148
+ */
149
+
150
+ /**
151
+ * @typedef {object} JobsPage
152
+ * @property {JobSummary[]} items Newest first.
153
+ * @property {number} limit
154
+ * @property {number} skip
155
+ * @property {boolean} has_more
156
+ */
157
+
158
+ /**
159
+ * The admin API: `client.admin`. Needs a client created with an admin API
160
+ * key, or signed in as a user holding one of the realm's admin roles.
161
+ */
162
+ export class Admin {
163
+ /** @param {Client} client */
164
+ constructor(client) {
165
+ /** @internal */
166
+ this.client = client
167
+ /** Users and their roles. */
168
+ this.users = new AdminUsers(this)
169
+ /** Invitations for realms with `signup: invite`. */
170
+ this.invitations = new AdminInvitations(this)
171
+ /** The realm's API keys. */
172
+ this.apiKeys = new AdminAPIKeys(this)
173
+ /** The realm's audit trail (read-only). */
174
+ this.audit = new AdminAudit(this)
175
+ /** The realm's async and scheduled jobs (read-only). */
176
+ this.jobs = new AdminJobs(this)
177
+ /** Function secrets: set and delete their values, list their metadata. */
178
+ this.secrets = new AdminSecrets(this)
179
+ /** The realm's function invocation history (read-only). */
180
+ this.invocations = new AdminInvocations(this)
181
+ }
182
+
183
+ /**
184
+ * Runs a function by hand, internal ones included: to re-run a clean-up
185
+ * that failed, or to test a scheduled function. `function` is
186
+ * `<database>/<name>`. With `as` (a user's email) the function runs with
187
+ * that user as `ctx.user`; without it there is no user. Returns the
188
+ * output of a `sync` function, and a `Job` handle for an `async` one,
189
+ * like `db.fn()`. The function's `invoke` rule and `rate_limit` don't
190
+ * apply; every run is audited.
191
+ * @param {string} fn
192
+ * @param {{ input?: unknown, as?: string, idempotencyKey?: string }} [params]
193
+ * @param {RequestOptions} [opts]
194
+ * @returns {Promise<unknown | Job>}
195
+ */
196
+ async invokeFunction(fn, { input, as, idempotencyKey } = {}, opts) {
197
+ const [database, name, ...rest] = fn.split('/')
198
+ if (!database || !name || rest.length > 0) throw new TypeError('invokeFunction: the function must be "<database>/<name>"')
199
+ /** @type {Record<string, unknown>} */
200
+ const body = { input: input === undefined ? null : input }
201
+ if (as !== undefined) body.as = as
202
+ const headers = idempotencyKey === undefined ? undefined : { 'Idempotency-Key': idempotencyKey }
203
+ const { status, data } = await this._request({ method: 'POST', path: ['functions', database, name, 'invoke'], body, headers, ...opts })
204
+ if (status === 202) return new Job(this.client, database, /** @type {import('./functions.js').JobData} */ (data))
205
+ return data
206
+ }
207
+
208
+ /**
209
+ * @internal
210
+ * @param {import('./client.js').RequestInit} req
211
+ */
212
+ async _request(req) {
213
+ if (!this.client.hasApiKey && !(await this.client.auth.token())) {
214
+ throw new Error('client.admin needs a client created with an `apiKey` (admin role), or a signed-in user with an admin role')
215
+ }
216
+ return this.client.request({ ...req, path: ['_admin', ...req.path] })
217
+ }
218
+ }
219
+
220
+ class AdminUsers {
221
+ /** @param {Admin} admin */
222
+ constructor(admin) {
223
+ /** @internal */
224
+ this.admin = admin
225
+ }
226
+
227
+ /**
228
+ * A page of users, sorted by email.
229
+ * @param {{ limit?: number, skip?: number }} [params]
230
+ * @param {RequestOptions} [opts]
231
+ * @returns {Promise<UserPage>}
232
+ */
233
+ async list(params = {}, opts) {
234
+ return (await this.admin._request({ method: 'GET', path: ['users'], query: { limit: params.limit, skip: params.skip }, ...opts })).data
235
+ }
236
+
237
+ /**
238
+ * The user with this email (case-insensitive), or null.
239
+ * @param {string} email
240
+ * @param {RequestOptions} [opts]
241
+ * @returns {Promise<AdminUser | null>}
242
+ */
243
+ async find(email, opts) {
244
+ const { data } = await this.admin._request({ method: 'GET', path: ['users'], query: { email }, ...opts })
245
+ return data.items[0] ?? null
246
+ }
247
+
248
+ /**
249
+ * @param {string} id
250
+ * @param {RequestOptions} [opts]
251
+ * @returns {Promise<AdminUser>}
252
+ */
253
+ async get(id, opts) {
254
+ return (await this.admin._request({ method: 'GET', path: ['users', id], ...opts })).data
255
+ }
256
+
257
+ /**
258
+ * Creates a user. Without a password they can't sign in until one is set.
259
+ * @param {{ email: string, password?: string }} input
260
+ * @param {RequestOptions} [opts]
261
+ * @returns {Promise<AdminUser>}
262
+ */
263
+ async create({ email, password }, opts) {
264
+ /** @type {Record<string, string>} */
265
+ const body = { email }
266
+ if (password !== undefined) body.password = password
267
+ return (await this.admin._request({ method: 'POST', path: ['users'], body, ...opts })).data
268
+ }
269
+
270
+ /**
271
+ * Changes a user's flags. Disabling ends all their sessions. Emails can't change.
272
+ * @param {string} id
273
+ * @param {{ emailVerified?: boolean, disabled?: boolean }} changes
274
+ * @param {RequestOptions} [opts]
275
+ * @returns {Promise<AdminUser>}
276
+ */
277
+ async update(id, { emailVerified, disabled }, opts) {
278
+ /** @type {Record<string, boolean>} */
279
+ const body = {}
280
+ if (emailVerified !== undefined) body.email_verified = emailVerified
281
+ if (disabled !== undefined) body.disabled = disabled
282
+ return (await this.admin._request({ method: 'PATCH', path: ['users', id], body, ...opts })).data
283
+ }
284
+
285
+ /**
286
+ * Erases a user. **Irreversible.** The user becomes a tombstone at once (the
287
+ * id stays, the email becomes `erased-<id>@erased.invalid`, and their
288
+ * sessions, sign-in methods and email tokens are deleted); a worker then
289
+ * applies the `collection.yaml` policy of every collection that declares one.
290
+ * Resolves with the erase job (`origin: backd:account.erase` in
291
+ * `admin.jobs.list()`); the counts land in the audit trail as `user.erased`.
292
+ * To keep the data, deactivate with `update(id, { disabled: true })`.
293
+ * @param {string} id
294
+ * @param {RequestOptions} [opts]
295
+ * @returns {Promise<{ id: string, status: 'queued' | 'running' | 'done' }>}
296
+ */
297
+ async delete(id, opts) {
298
+ return (await this.admin._request({ method: 'DELETE', path: ['users', id], ...opts })).data
299
+ }
300
+
301
+ /**
302
+ * Sets a user's password and ends all their sessions.
303
+ * @param {string} id
304
+ * @param {string} password
305
+ * @param {RequestOptions} [opts]
306
+ * @returns {Promise<void>}
307
+ */
308
+ async setPassword(id, password, opts) {
309
+ await this.admin._request({ method: 'POST', path: ['users', id, 'password'], body: { password }, ...opts })
310
+ }
311
+
312
+ /**
313
+ * What erasing a user would do: counts per collection that declares a policy,
314
+ * and the collections an erase leaves alone. No document content.
315
+ * @param {string} id
316
+ * @param {RequestOptions} [opts]
317
+ * @returns {Promise<OwnedReport>}
318
+ */
319
+ async owned(id, opts) {
320
+ return (await this.admin._request({ method: 'GET', path: ['users', id, 'owned'], ...opts })).data
321
+ }
322
+
323
+ /**
324
+ * Changes a user's email address at once, in a realm with `email`: the new
325
+ * address counts as verified, the user's sessions end, the old address is
326
+ * sent a link to undo the change and the new one is told.
327
+ * @param {string} id
328
+ * @param {string} email
329
+ * @param {RequestOptions} [opts]
330
+ * @returns {Promise<AdminUser>}
331
+ */
332
+ async changeEmail(id, email, opts) {
333
+ return (await this.admin._request({ method: 'POST', path: ['users', id, 'email'], body: { email }, ...opts })).data
334
+ }
335
+
336
+ /**
337
+ * Assigns a role declared in realm.yaml.
338
+ * @param {string} id
339
+ * @param {string} role
340
+ * @param {RequestOptions} [opts]
341
+ * @returns {Promise<AdminUser>}
342
+ */
343
+ async addRole(id, role, opts) {
344
+ return (await this.admin._request({ method: 'PUT', path: ['users', id, 'roles', role], ...opts })).data
345
+ }
346
+
347
+ /**
348
+ * Takes a role away.
349
+ * @param {string} id
350
+ * @param {string} role
351
+ * @param {RequestOptions} [opts]
352
+ * @returns {Promise<AdminUser>}
353
+ */
354
+ async removeRole(id, role, opts) {
355
+ return (await this.admin._request({ method: 'DELETE', path: ['users', id, 'roles', role], ...opts })).data
356
+ }
357
+
358
+ /**
359
+ * Replaces the user's network restrictions (IP addresses or CIDR
360
+ * networks); empty lists remove them. Settings in realm.yaml win at the
361
+ * next startup.
362
+ * @param {string} id
363
+ * @param {{ adminNetworks?: string[], loginNetworks?: string[] }} networks
364
+ * @param {RequestOptions} [opts]
365
+ * @returns {Promise<AdminUser>}
366
+ */
367
+ async setNetworks(id, { adminNetworks = [], loginNetworks = [] }, opts) {
368
+ const body = { admin_networks: adminNetworks, login_networks: loginNetworks }
369
+ return (await this.admin._request({ method: 'PUT', path: ['users', id, 'networks'], body, ...opts })).data
370
+ }
371
+ }
372
+
373
+ class AdminAPIKeys {
374
+ /** @param {Admin} admin */
375
+ constructor(admin) {
376
+ /** @internal */
377
+ this.admin = admin
378
+ }
379
+
380
+ /**
381
+ * The realm's API keys, by name; never the keys themselves.
382
+ * @param {RequestOptions} [opts]
383
+ * @returns {Promise<APIKeyInfo[]>}
384
+ */
385
+ async list(opts) {
386
+ return (await this.admin._request({ method: 'GET', path: ['apikeys'], ...opts })).data.items
387
+ }
388
+
389
+ /**
390
+ * Creates a key; store its `key` now, it is never shown again.
391
+ * @param {{ name: string, role?: 'data' | 'admin', expiresIn?: string, networks?: string[] }} input
392
+ * `role` defaults to data; `expiresIn`: days (`90d`) or Go durations (`12h`).
393
+ * @param {RequestOptions} [opts]
394
+ * @returns {Promise<NewAPIKey>}
395
+ */
396
+ async create({ name, role, expiresIn, networks }, opts) {
397
+ /** @type {Record<string, unknown>} */
398
+ const body = { name }
399
+ if (role !== undefined) body.role = role
400
+ if (expiresIn !== undefined) body.expires_in = expiresIn
401
+ if (networks !== undefined) body.networks = networks
402
+ return (await this.admin._request({ method: 'POST', path: ['apikeys'], body, ...opts })).data
403
+ }
404
+
405
+ /**
406
+ * Revokes a key: it stops working at once.
407
+ * @param {string} name
408
+ * @param {RequestOptions} [opts]
409
+ * @returns {Promise<void>}
410
+ */
411
+ async revoke(name, opts) {
412
+ await this.admin._request({ method: 'DELETE', path: ['apikeys', name], ...opts })
413
+ }
414
+ }
415
+
416
+ class AdminAudit {
417
+ /** @param {Admin} admin */
418
+ constructor(admin) {
419
+ /** @internal */
420
+ this.admin = admin
421
+ }
422
+
423
+ /**
424
+ * A page of the audit trail, newest first. `since` and `until` are dates
425
+ * or RFC 3339 strings.
426
+ * @param {{ action?: string, actor?: string, target?: string, since?: Date | string, until?: Date | string, limit?: number, skip?: number }} [params]
427
+ * @param {RequestOptions} [opts]
428
+ * @returns {Promise<AuditPage>}
429
+ */
430
+ async list(params = {}, opts) {
431
+ const time = (/** @type {Date | string | undefined} */ t) => (t instanceof Date ? t.toISOString() : t)
432
+ const query = {
433
+ action: params.action, actor: params.actor, target: params.target,
434
+ since: time(params.since), until: time(params.until), limit: params.limit, skip: params.skip,
435
+ }
436
+ return (await this.admin._request({ method: 'GET', path: ['audit'], query, ...opts })).data
437
+ }
438
+ }
439
+
440
+ class AdminSecrets {
441
+ /** @param {Admin} admin */
442
+ constructor(admin) {
443
+ /** @internal */
444
+ this.admin = admin
445
+ }
446
+
447
+ /**
448
+ * The realm's secrets: scope, name and who last changed them. Values are
449
+ * write-only and never returned.
450
+ * @param {RequestOptions} [opts]
451
+ * @returns {Promise<SecretInfo[]>}
452
+ */
453
+ async list(opts) {
454
+ return (await this.admin._request({ method: 'GET', path: ['secrets'], ...opts })).data.items
455
+ }
456
+
457
+ /**
458
+ * Creates a secret or replaces its value; functions read it as
459
+ * `ctx.secrets` within about a minute. Without `database` it is the
460
+ * realm's secret (`realm.NAME` in function.yaml); with it, that
461
+ * database's (`NAME`).
462
+ * @param {string} name Upper-case letters, digits and `_`.
463
+ * @param {string} value
464
+ * @param {{ database?: string }} [scope]
465
+ * @param {RequestOptions} [opts]
466
+ * @returns {Promise<void>}
467
+ */
468
+ async set(name, value, { database } = {}, opts) {
469
+ /** @type {Record<string, string>} */
470
+ const body = { value }
471
+ if (database) body.database = database
472
+ await this.admin._request({ method: 'PUT', path: ['secrets', name], body, ...opts })
473
+ }
474
+
475
+ /**
476
+ * Removes a secret's value: a function that declares it answers
477
+ * `secret_missing` again.
478
+ * @param {string} name
479
+ * @param {{ database?: string }} [scope]
480
+ * @param {RequestOptions} [opts]
481
+ * @returns {Promise<void>}
482
+ */
483
+ async delete(name, { database } = {}, opts) {
484
+ await this.admin._request({ method: 'DELETE', path: ['secrets', name], query: { database }, ...opts })
485
+ }
486
+ }
487
+
488
+ class AdminInvocations {
489
+ /** @param {Admin} admin */
490
+ constructor(admin) {
491
+ /** @internal */
492
+ this.admin = admin
493
+ }
494
+
495
+ /**
496
+ * A page of the function invocation history, newest first. `function` is
497
+ * `<database>/<name>`; `since` and `until` are dates or RFC 3339 strings.
498
+ * @param {{ function?: string, requestId?: string, since?: Date | string, until?: Date | string, limit?: number, skip?: number }} [params]
499
+ * @param {RequestOptions} [opts]
500
+ * @returns {Promise<InvocationsPage>}
501
+ */
502
+ async list(params = {}, opts) {
503
+ const time = (/** @type {Date | string | undefined} */ t) => (t instanceof Date ? t.toISOString() : t)
504
+ const query = {
505
+ function: params.function, request_id: params.requestId,
506
+ since: time(params.since), until: time(params.until), limit: params.limit, skip: params.skip,
507
+ }
508
+ return (await this.admin._request({ method: 'GET', path: ['invocations'], query, ...opts })).data
509
+ }
510
+ }
511
+
512
+ class AdminJobs {
513
+ /** @param {Admin} admin */
514
+ constructor(admin) {
515
+ /** @internal */
516
+ this.admin = admin
517
+ }
518
+
519
+ /**
520
+ * A page of jobs, newest first: their state and outcome, never their
521
+ * input or output (read one job in full with `Job.status()`/`wait()`).
522
+ * `function` is `<database>/<name>`; `since` and `until` are dates or
523
+ * RFC 3339 strings, on the job's creation time.
524
+ * @param {{ function?: string, status?: 'queued' | 'running' | 'done', scheduled?: boolean, since?: Date | string, until?: Date | string, limit?: number, skip?: number }} [params]
525
+ * @param {RequestOptions} [opts]
526
+ * @returns {Promise<JobsPage>}
527
+ */
528
+ async list(params = {}, opts) {
529
+ const time = (/** @type {Date | string | undefined} */ t) => (t instanceof Date ? t.toISOString() : t)
530
+ const query = {
531
+ function: params.function, status: params.status, scheduled: params.scheduled,
532
+ since: time(params.since), until: time(params.until), limit: params.limit, skip: params.skip,
533
+ }
534
+ return (await this.admin._request({ method: 'GET', path: ['jobs'], query, ...opts })).data
535
+ }
536
+ }
537
+
538
+ class AdminInvitations {
539
+ /** @param {Admin} admin */
540
+ constructor(admin) {
541
+ /** @internal */
542
+ this.admin = admin
543
+ }
544
+
545
+ /**
546
+ * Creates an invitation; deliver its `token` to the invitee.
547
+ * @param {{ email?: string, expiresIn?: string }} [input] `expiresIn`: days (`7d`) or Go durations (`12h`).
548
+ * @param {RequestOptions} [opts]
549
+ * @returns {Promise<NewInvitation>}
550
+ */
551
+ async create({ email, expiresIn } = {}, opts) {
552
+ /** @type {Record<string, string>} */
553
+ const body = {}
554
+ if (email !== undefined) body.email = email
555
+ if (expiresIn !== undefined) body.expires_in = expiresIn
556
+ return (await this.admin._request({ method: 'POST', path: ['invitations'], body, ...opts })).data
557
+ }
558
+
559
+ /**
560
+ * Creates an invitation and has `backd` email it to `email` (needs `email`
561
+ * in the realm's `realm.yaml`): the link opens a page where the person
562
+ * chooses a password, or your own page with `email.links.invitation` (see
563
+ * `auth.acceptInvitation`). There is no token to deliver. `redirectTo` is
564
+ * where the page after accepting may send them (within
565
+ * `email.allowed_redirects`); `locale` is the language of the email.
566
+ * @param {{ email: string, expiresIn?: string, redirectTo?: string, locale?: string }} input
567
+ * @param {RequestOptions} [opts]
568
+ * @returns {Promise<SentInvitation>}
569
+ */
570
+ async send({ email, expiresIn, redirectTo, locale }, opts) {
571
+ /** @type {Record<string, unknown>} */
572
+ const body = { email, send: true }
573
+ if (expiresIn !== undefined) body.expires_in = expiresIn
574
+ if (redirectTo !== undefined) body.redirect_to = redirectTo
575
+ if (locale !== undefined) body.locale = locale
576
+ return (await this.admin._request({ method: 'POST', path: ['invitations'], body, ...opts })).data
577
+ }
578
+
579
+ /**
580
+ * Unused, unexpired invitations, newest first. Tokens are never listed.
581
+ * @param {RequestOptions} [opts]
582
+ * @returns {Promise<Invitation[]>}
583
+ */
584
+ async list(opts) {
585
+ return (await this.admin._request({ method: 'GET', path: ['invitations'], ...opts })).data.items
586
+ }
587
+
588
+ /**
589
+ * Revokes an invitation.
590
+ * @param {string} id
591
+ * @param {RequestOptions} [opts]
592
+ * @returns {Promise<void>}
593
+ */
594
+ async revoke(id, opts) {
595
+ await this.admin._request({ method: 'DELETE', path: ['invitations', id], ...opts })
596
+ }
597
+ }