backd-js 0.1.15 → 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 -201
- package/README.md +32 -26
- package/package.json +47 -40
- package/src/admin.js +597 -0
- package/src/auth.js +374 -0
- package/src/client.js +214 -0
- package/src/data.js +278 -0
- package/src/errors.js +135 -0
- package/src/functions.js +143 -0
- package/src/index.js +51 -0
- package/src/storage.js +43 -0
- package/types/admin.d.ts +709 -0
- package/types/auth.d.ts +281 -0
- package/types/client.d.ts +132 -0
- package/types/data.d.ts +260 -0
- package/types/errors.d.ts +106 -0
- package/types/functions.d.ts +135 -0
- package/types/index.d.ts +59 -0
- package/types/storage.d.ts +26 -0
- package/.babelrc +0 -4
- package/.editorconfig +0 -12
- package/.eslintrc.js +0 -28
- package/.npmignore +0 -9
- package/.nvmrc +0 -1
- package/.travis.yml +0 -33
- package/lib/backd.js +0 -13625
- package/lib/backd.js.map +0 -1
- package/lib/backd.min.js +0 -7
package/src/auth.js
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { AuthenticationError, VerificationRequiredError } from './errors.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {import('./client.js').Client} Client
|
|
5
|
+
* @typedef {import('./client.js').RequestOptions} RequestOptions
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A realm user as the user sees themselves.
|
|
10
|
+
* @typedef {object} User
|
|
11
|
+
* @property {string} id
|
|
12
|
+
* @property {string} email
|
|
13
|
+
* @property {boolean} email_verified
|
|
14
|
+
* @property {string[]} roles
|
|
15
|
+
* @property {string} locale The user's language, one the realm lists (used for their emails).
|
|
16
|
+
* @property {string} created_at RFC3339 timestamp.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A new session.
|
|
21
|
+
* @typedef {object} Session
|
|
22
|
+
* @property {string} token Session token (`bds_…`); already stored by the client.
|
|
23
|
+
* @property {'Bearer'} token_type
|
|
24
|
+
* @property {string} session_id
|
|
25
|
+
* @property {string} expires_at RFC3339; pushed forward as the session is used.
|
|
26
|
+
* @property {User} user
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {object} SessionInfo
|
|
31
|
+
* @property {string} id
|
|
32
|
+
* @property {string} created_at
|
|
33
|
+
* @property {string} last_used_at
|
|
34
|
+
* @property {string} expires_at
|
|
35
|
+
* @property {boolean} current The session making this request.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* What changed: a sign-in (signup or login), a sign-out (logout, logout
|
|
40
|
+
* everywhere, account deleted), or the server refusing the stored token
|
|
41
|
+
* (expired, revoked, or the user disabled).
|
|
42
|
+
* @typedef {'SIGNED_IN' | 'SIGNED_OUT' | 'SESSION_EXPIRED'} AuthEvent
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @callback AuthListener
|
|
47
|
+
* @param {AuthEvent} event
|
|
48
|
+
* @param {Session | null} session The new session for SIGNED_IN, otherwise null.
|
|
49
|
+
* @returns {void}
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/** A body without the fields that were not given. @param {Record<string, unknown>} fields */
|
|
53
|
+
function compact(fields) {
|
|
54
|
+
return Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Sign-up, login and session management: `client.auth`. */
|
|
58
|
+
export class Auth {
|
|
59
|
+
/** @param {Client} client */
|
|
60
|
+
constructor(client) {
|
|
61
|
+
/** @internal */
|
|
62
|
+
this.client = client
|
|
63
|
+
/** @internal */
|
|
64
|
+
this.listeners = new Set()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Creates an account and signs in. Realms with `signup: invite` need an
|
|
69
|
+
* invitation token. `locale` (such as `es` or `es-MX`) is the language to
|
|
70
|
+
* use for the user; the server maps it silently to one the realm lists. A
|
|
71
|
+
* browser also sends its `Accept-Language`, which is used when no `locale`
|
|
72
|
+
* is given. `redirectTo` is where the page after the verification link may
|
|
73
|
+
* send the user (it must be within the realm's `email.allowed_redirects`).
|
|
74
|
+
*
|
|
75
|
+
* A realm that requires verified addresses (`account.require_verified_email`)
|
|
76
|
+
* creates the account but starts no session: this rejects with a
|
|
77
|
+
* {@link VerificationRequiredError}, nothing is stored, and the user signs
|
|
78
|
+
* in after following the link in the email they were sent.
|
|
79
|
+
* @param {{ email: string, password: string, invitation?: string, locale?: string, redirectTo?: string }} input
|
|
80
|
+
* @param {RequestOptions} [opts]
|
|
81
|
+
* @returns {Promise<Session>}
|
|
82
|
+
*/
|
|
83
|
+
async signup({ email, password, invitation, locale, redirectTo }, opts) {
|
|
84
|
+
/** @type {Record<string, string>} */
|
|
85
|
+
const body = { email, password }
|
|
86
|
+
if (invitation) body.invitation = invitation
|
|
87
|
+
if (locale) body.locale = locale
|
|
88
|
+
if (redirectTo) body.redirect_to = redirectTo
|
|
89
|
+
const { status, data } = await this.client.request({ method: 'POST', path: ['_auth', 'signup'], body, auth: false, ...opts })
|
|
90
|
+
if (status === 202) {
|
|
91
|
+
throw new VerificationRequiredError({ status, code: 'verification_required', message: 'the account was created: verify the email address, then sign in' })
|
|
92
|
+
}
|
|
93
|
+
return this.signedIn(data)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Signs in with email and password.
|
|
98
|
+
* @param {{ email: string, password: string }} input
|
|
99
|
+
* @param {RequestOptions} [opts]
|
|
100
|
+
* @returns {Promise<Session>}
|
|
101
|
+
*/
|
|
102
|
+
async login({ email, password }, opts) {
|
|
103
|
+
const { data } = await this.client.request({ method: 'POST', path: ['_auth', 'login'], body: { email, password }, auth: false, ...opts })
|
|
104
|
+
return this.signedIn(data)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Asks for the verification email again. Always resolves, whatever the
|
|
109
|
+
* address is (unknown, disabled, already verified), so it can't be used to
|
|
110
|
+
* find out who is registered. `redirectTo` is where the page after the link
|
|
111
|
+
* may send the user (within the realm's `email.allowed_redirects`).
|
|
112
|
+
* @param {{ email: string, redirectTo?: string }} input
|
|
113
|
+
* @param {RequestOptions} [opts]
|
|
114
|
+
* @returns {Promise<void>}
|
|
115
|
+
*/
|
|
116
|
+
async resendVerification({ email, redirectTo }, opts) {
|
|
117
|
+
await this.accepted(['_auth', 'verify-email', 'resend'], { email, redirect_to: redirectTo }, opts)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Verifies an address with the token of the link in the email: for apps
|
|
122
|
+
* that host their own page (`email.links` in `realm.yaml`). Starts no
|
|
123
|
+
* session. An expired, used or unknown token rejects with a
|
|
124
|
+
* `ValidationError` whose `code` is `invalid_token`.
|
|
125
|
+
* @param {string} token
|
|
126
|
+
* @param {RequestOptions} [opts]
|
|
127
|
+
* @returns {Promise<void>}
|
|
128
|
+
*/
|
|
129
|
+
async verifyEmail(token, opts) {
|
|
130
|
+
await this.accepted(['_auth', 'verify-email'], { token }, opts)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Asks for a password reset email. Always resolves, whatever the address is.
|
|
135
|
+
* Rejects with a `RetryableError` once the realm's limits for reset
|
|
136
|
+
* requests (per address and per client) are reached.
|
|
137
|
+
* @param {{ email: string, redirectTo?: string }} input
|
|
138
|
+
* @param {RequestOptions} [opts]
|
|
139
|
+
* @returns {Promise<void>}
|
|
140
|
+
*/
|
|
141
|
+
async requestPasswordReset({ email, redirectTo }, opts) {
|
|
142
|
+
await this.accepted(['_auth', 'reset-password', 'request'], { email, redirect_to: redirectTo }, opts)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Sets a new password with the token of a reset link. Ends every session of
|
|
147
|
+
* the user, verifies their address and starts no session: log in afterwards.
|
|
148
|
+
* A password the policy refuses rejects with a `ValidationError` and leaves
|
|
149
|
+
* the token usable; a bad token has the `invalid_token` code.
|
|
150
|
+
* @param {{ token: string, password: string }} input
|
|
151
|
+
* @param {RequestOptions} [opts]
|
|
152
|
+
* @returns {Promise<void>}
|
|
153
|
+
*/
|
|
154
|
+
async resetPassword({ token, password }, opts) {
|
|
155
|
+
await this.accepted(['_auth', 'reset-password'], { token, password }, opts)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Asks to change the signed-in user's email address (realms with
|
|
160
|
+
* `account.allow_email_change`). Needs the current password. Resolves
|
|
161
|
+
* whether or not the new address is free; nothing changes until the link
|
|
162
|
+
* sent to the new address is used (see {@link Auth#confirmEmailChange}).
|
|
163
|
+
* @param {{ newEmail: string, password: string, redirectTo?: string }} input
|
|
164
|
+
* @param {RequestOptions} [opts]
|
|
165
|
+
* @returns {Promise<void>}
|
|
166
|
+
*/
|
|
167
|
+
async requestEmailChange({ newEmail, password, redirectTo }, opts) {
|
|
168
|
+
await this.client.request({ method: 'POST', path: ['_auth', 'email'], body: compact({ new_email: newEmail, password, redirect_to: redirectTo }), ...opts })
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Confirms an email change with the token sent to the new address: the
|
|
173
|
+
* address changes and every session of the user ends.
|
|
174
|
+
* @param {string} token
|
|
175
|
+
* @param {RequestOptions} [opts]
|
|
176
|
+
* @returns {Promise<void>}
|
|
177
|
+
*/
|
|
178
|
+
async confirmEmailChange(token, opts) {
|
|
179
|
+
await this.accepted(['_auth', 'confirm-email-change'], { token }, opts)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Undoes an email change with the token sent to the old address: restores
|
|
184
|
+
* it, ends every session and makes the password unusable until it is reset
|
|
185
|
+
* (a reset email is sent to the restored address).
|
|
186
|
+
* @param {string} token
|
|
187
|
+
* @param {RequestOptions} [opts]
|
|
188
|
+
* @returns {Promise<void>}
|
|
189
|
+
*/
|
|
190
|
+
async revertEmailChange(token, opts) {
|
|
191
|
+
await this.accepted(['_auth', 'revert-email-change'], { token }, opts)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Accepts an invitation that was emailed (admin `invitations.send`), with
|
|
196
|
+
* the token of its link: creates the account for the invited address,
|
|
197
|
+
* already verified, and starts no session. `locale` is the user's language.
|
|
198
|
+
* @param {{ token: string, password: string, locale?: string }} input
|
|
199
|
+
* @param {RequestOptions} [opts]
|
|
200
|
+
* @returns {Promise<void>}
|
|
201
|
+
*/
|
|
202
|
+
async acceptInvitation({ token, password, locale }, opts) {
|
|
203
|
+
await this.accepted(['_auth', 'accept-invitation'], { token, password, locale }, opts)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* A call that needs no session and answers with no body.
|
|
208
|
+
* @internal
|
|
209
|
+
* @param {string[]} path
|
|
210
|
+
* @param {Record<string, unknown>} body
|
|
211
|
+
* @param {RequestOptions} [opts]
|
|
212
|
+
*/
|
|
213
|
+
async accepted(path, body, opts) {
|
|
214
|
+
await this.client.request({ method: 'POST', path, body: compact(body), auth: false, ...opts })
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Ends the current session. The stored token is removed even if the
|
|
219
|
+
* server can't be reached.
|
|
220
|
+
* @param {RequestOptions} [opts]
|
|
221
|
+
* @returns {Promise<void>}
|
|
222
|
+
*/
|
|
223
|
+
async logout(opts) {
|
|
224
|
+
try {
|
|
225
|
+
await this.client.request({ method: 'POST', path: ['_auth', 'logout'], expire: false, ...opts })
|
|
226
|
+
} catch (err) {
|
|
227
|
+
// A session the server no longer knows is logged out already.
|
|
228
|
+
if (!(err instanceof AuthenticationError)) throw err
|
|
229
|
+
} finally {
|
|
230
|
+
await this.signedOut('SIGNED_OUT')
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Ends every session of the user, on every device.
|
|
236
|
+
* @param {RequestOptions} [opts]
|
|
237
|
+
* @returns {Promise<void>}
|
|
238
|
+
*/
|
|
239
|
+
async logoutAll(opts) {
|
|
240
|
+
await this.client.request({ method: 'POST', path: ['_auth', 'logout-all'], ...opts })
|
|
241
|
+
await this.signedOut('SIGNED_OUT')
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The signed-in user.
|
|
246
|
+
* @param {RequestOptions} [opts]
|
|
247
|
+
* @returns {Promise<User>}
|
|
248
|
+
*/
|
|
249
|
+
async me(opts) {
|
|
250
|
+
return (await this.client.request({ method: 'GET', path: ['_auth', 'me'], ...opts })).data
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Changes the signed-in user's own settings: today their language, which
|
|
255
|
+
* must be one the realm lists (case is ignored). Anything else is a
|
|
256
|
+
* `ValidationError` with code `invalid_locale` and the allowed languages in
|
|
257
|
+
* `details`.
|
|
258
|
+
* @param {{ locale?: string }} changes
|
|
259
|
+
* @param {RequestOptions} [opts]
|
|
260
|
+
* @returns {Promise<User>}
|
|
261
|
+
*/
|
|
262
|
+
async updateMe(changes, opts) {
|
|
263
|
+
return (await this.client.request({ method: 'PATCH', path: ['_auth', 'me'], body: changes, ...opts })).data
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Deletes the signed-in user's account: it is **deactivated** (disabled, its
|
|
268
|
+
* sessions end) and all data is kept. Erasing a user's data is an
|
|
269
|
+
* administrator's action (`admin.users.delete`).
|
|
270
|
+
* @param {{ password: string }} input
|
|
271
|
+
* @param {RequestOptions} [opts]
|
|
272
|
+
* @returns {Promise<void>}
|
|
273
|
+
*/
|
|
274
|
+
async deleteAccount({ password }, opts) {
|
|
275
|
+
await this.client.request({ method: 'DELETE', path: ['_auth', 'me'], body: { password }, ...opts })
|
|
276
|
+
await this.signedOut('SIGNED_OUT')
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Changes the password. This session stays valid; all others end.
|
|
281
|
+
* @param {{ currentPassword: string, newPassword: string }} input
|
|
282
|
+
* @param {RequestOptions} [opts]
|
|
283
|
+
* @returns {Promise<void>}
|
|
284
|
+
*/
|
|
285
|
+
async changePassword({ currentPassword, newPassword }, opts) {
|
|
286
|
+
await this.client.request({
|
|
287
|
+
method: 'POST',
|
|
288
|
+
path: ['_auth', 'password'],
|
|
289
|
+
body: { current_password: currentPassword, new_password: newPassword },
|
|
290
|
+
...opts,
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The user's active sessions, newest first.
|
|
296
|
+
* @param {RequestOptions} [opts]
|
|
297
|
+
* @returns {Promise<SessionInfo[]>}
|
|
298
|
+
*/
|
|
299
|
+
async sessions(opts) {
|
|
300
|
+
return (await this.client.request({ method: 'GET', path: ['_auth', 'sessions'], ...opts })).data.items
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Ends one of the user's sessions, for example a lost device.
|
|
305
|
+
* @param {string} id
|
|
306
|
+
* @param {RequestOptions} [opts]
|
|
307
|
+
* @returns {Promise<void>}
|
|
308
|
+
*/
|
|
309
|
+
async revokeSession(id, opts) {
|
|
310
|
+
await this.client.request({ method: 'DELETE', path: ['_auth', 'sessions', id], ...opts })
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The stored session token, if any.
|
|
315
|
+
* @returns {Promise<string | null>}
|
|
316
|
+
*/
|
|
317
|
+
async token() {
|
|
318
|
+
return (await this.client.storage.get()) ?? null
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Calls listener on sign-in, sign-out and session expiry.
|
|
323
|
+
* @param {AuthListener} listener
|
|
324
|
+
* @returns {() => void} Stops listening.
|
|
325
|
+
*/
|
|
326
|
+
onAuthChange(listener) {
|
|
327
|
+
this.listeners.add(listener)
|
|
328
|
+
return () => this.listeners.delete(listener)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* @internal
|
|
333
|
+
* @param {Session} session
|
|
334
|
+
* @returns {Promise<Session>}
|
|
335
|
+
*/
|
|
336
|
+
async signedIn(session) {
|
|
337
|
+
await this.client.storage.set(session.token)
|
|
338
|
+
this.emit('SIGNED_IN', session)
|
|
339
|
+
return session
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* @internal
|
|
344
|
+
* @param {AuthEvent} event
|
|
345
|
+
*/
|
|
346
|
+
async signedOut(event) {
|
|
347
|
+
await this.client.storage.remove()
|
|
348
|
+
this.emit(event, null)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Called by the client when the server refuses the stored token.
|
|
353
|
+
* @internal
|
|
354
|
+
*/
|
|
355
|
+
async _expired() {
|
|
356
|
+
await this.signedOut('SESSION_EXPIRED')
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* @internal
|
|
361
|
+
* @param {AuthEvent} event
|
|
362
|
+
* @param {Session | null} session
|
|
363
|
+
*/
|
|
364
|
+
emit(event, session) {
|
|
365
|
+
for (const l of this.listeners) {
|
|
366
|
+
try {
|
|
367
|
+
l(event, session)
|
|
368
|
+
} catch (err) {
|
|
369
|
+
// A failing listener must not break the client or other listeners.
|
|
370
|
+
console.error('backd: an onAuthChange listener failed:', err)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { Admin } from './admin.js'
|
|
2
|
+
import { Auth } from './auth.js'
|
|
3
|
+
import { Database } from './data.js'
|
|
4
|
+
import { NetworkError, RetryableError, errorFromResponse } from './errors.js'
|
|
5
|
+
import { memoryStorage } from './storage.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {import('./storage.js').TokenStorage} TokenStorage
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Retry settings. Only 429 and 503 answers are retried, and only for safe
|
|
13
|
+
* requests: GET, or writes with If-Match.
|
|
14
|
+
* @typedef {object} RetryOptions
|
|
15
|
+
* @property {number} attempts Extra attempts after the first (0 disables).
|
|
16
|
+
* @property {number} [maxDelayMs] Longest wait between attempts; default 30000.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {object} ClientOptions
|
|
21
|
+
* @property {string} url Base URL of backd, e.g. "https://api.example.com".
|
|
22
|
+
* @property {string} realm The realm to talk to.
|
|
23
|
+
* @property {string} [apiKey] Server-side only: an API key (`bdk_…`). Full access to the realm.
|
|
24
|
+
* @property {boolean} [dangerouslyAllowBrowser] Allow `apiKey` in a browser. Anyone who loads the page gets the key.
|
|
25
|
+
* @property {TokenStorage} [storage] Where the session token lives; memory by default.
|
|
26
|
+
* @property {RetryOptions} [retry] Retries for 429/503; off by default.
|
|
27
|
+
* @property {typeof fetch} [fetch] A fetch implementation; the global one by default.
|
|
28
|
+
* @property {Record<string, string>} [headers] Extra headers for every request.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Per-request options.
|
|
33
|
+
* @typedef {object} RequestOptions
|
|
34
|
+
* @property {AbortSignal} [signal]
|
|
35
|
+
* @property {RetryOptions} [retry]
|
|
36
|
+
* @property {Record<string, string>} [headers]
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @typedef {object} RequestInit
|
|
41
|
+
* @property {string} method
|
|
42
|
+
* @property {string[]} path Path segments after /v1/{realm}, encoded here.
|
|
43
|
+
* @property {Record<string, string | number | boolean | undefined>} [query]
|
|
44
|
+
* @property {unknown} [body] Sent as JSON.
|
|
45
|
+
* @property {string} [contentType] Defaults to application/json when there's a body.
|
|
46
|
+
* @property {Record<string, string>} [headers]
|
|
47
|
+
* @property {boolean} [auth] Send credentials; default true.
|
|
48
|
+
* @property {boolean} [expire] Treat a refused session token as expired; default true.
|
|
49
|
+
* @property {AbortSignal} [signal]
|
|
50
|
+
* @property {RetryOptions} [retry]
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The raw answer of a successful request.
|
|
55
|
+
* @typedef {object} RawResponse
|
|
56
|
+
* @property {number} status
|
|
57
|
+
* @property {Headers} headers
|
|
58
|
+
* @property {any} data Parsed JSON, or undefined for empty bodies.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
const isBrowser = () => typeof window !== 'undefined' && typeof window.document !== 'undefined'
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Creates a client for one realm.
|
|
65
|
+
* @param {ClientOptions} options
|
|
66
|
+
* @returns {Client}
|
|
67
|
+
*/
|
|
68
|
+
export function createClient(options) {
|
|
69
|
+
return new Client(options)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class Client {
|
|
73
|
+
/** @param {ClientOptions} options */
|
|
74
|
+
constructor(options) {
|
|
75
|
+
if (!options?.url) throw new TypeError('createClient: `url` is required')
|
|
76
|
+
if (!options.realm) throw new TypeError('createClient: `realm` is required')
|
|
77
|
+
if (options.apiKey && isBrowser() && !options.dangerouslyAllowBrowser) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
'createClient: API keys give full access to the realm and must not be used in browsers, ' +
|
|
80
|
+
'where every visitor can read them. Use sessions (client.auth) instead, or pass ' +
|
|
81
|
+
'`dangerouslyAllowBrowser: true` if you really mean it.',
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
/** @readonly */
|
|
85
|
+
this.url = options.url.replace(/\/+$/, '')
|
|
86
|
+
/** @readonly */
|
|
87
|
+
this.realm = options.realm
|
|
88
|
+
/** @internal */
|
|
89
|
+
this.apiKey = options.apiKey
|
|
90
|
+
/** @internal */
|
|
91
|
+
this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis)
|
|
92
|
+
/** @internal */
|
|
93
|
+
this.retry = options.retry ?? { attempts: 0 }
|
|
94
|
+
/** @internal */
|
|
95
|
+
this.headers = options.headers ?? {}
|
|
96
|
+
/** @readonly */
|
|
97
|
+
this.storage = options.storage ?? memoryStorage()
|
|
98
|
+
/** Sign-up, login and sessions. */
|
|
99
|
+
this.auth = new Auth(this)
|
|
100
|
+
/** Users, roles, invitations and API keys; needs an admin `apiKey` or an admin user's session. */
|
|
101
|
+
this.admin = new Admin(this)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Whether the client was created with an API key. */
|
|
105
|
+
get hasApiKey() {
|
|
106
|
+
return Boolean(this.apiKey)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A database of the realm, to reach its collections:
|
|
111
|
+
* `client.db('main').collection('posts')`.
|
|
112
|
+
* @param {string} name
|
|
113
|
+
* @returns {Database}
|
|
114
|
+
*/
|
|
115
|
+
db(name) {
|
|
116
|
+
return new Database(this, name)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Sends a request to /v1/{realm}/…, adding credentials, and returns the
|
|
121
|
+
* parsed answer. Non-2xx answers throw a BackdError.
|
|
122
|
+
* @param {RequestInit} req
|
|
123
|
+
* @returns {Promise<RawResponse>}
|
|
124
|
+
*/
|
|
125
|
+
async request(req) {
|
|
126
|
+
const url = new URL(this.url + '/v1/' + [this.realm, ...req.path].map(encodeURIComponent).join('/'))
|
|
127
|
+
for (const [k, v] of Object.entries(req.query ?? {})) {
|
|
128
|
+
if (v !== undefined) url.searchParams.set(k, String(v))
|
|
129
|
+
}
|
|
130
|
+
/** @type {Record<string, string>} */
|
|
131
|
+
const headers = { Accept: 'application/json', ...this.headers, ...req.headers }
|
|
132
|
+
if (req.body !== undefined) headers['Content-Type'] = req.contentType ?? 'application/json'
|
|
133
|
+
|
|
134
|
+
let sentSession = false
|
|
135
|
+
if (req.auth !== false) {
|
|
136
|
+
if (this.apiKey) {
|
|
137
|
+
headers.Authorization = 'Bearer ' + this.apiKey
|
|
138
|
+
} else {
|
|
139
|
+
const token = await this.storage.get()
|
|
140
|
+
if (token) {
|
|
141
|
+
headers.Authorization = 'Bearer ' + token
|
|
142
|
+
sentSession = true
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const retry = req.retry ?? this.retry
|
|
148
|
+
const safe = req.method === 'GET' || Object.keys(headers).some((h) => h.toLowerCase() === 'if-match')
|
|
149
|
+
const attempts = safe ? Math.max(0, retry.attempts) : 0
|
|
150
|
+
for (let attempt = 0; ; attempt++) {
|
|
151
|
+
/** @type {Response} */
|
|
152
|
+
let res
|
|
153
|
+
try {
|
|
154
|
+
res = await this.fetchImpl(url, {
|
|
155
|
+
method: req.method,
|
|
156
|
+
headers,
|
|
157
|
+
body: req.body === undefined ? undefined : JSON.stringify(req.body),
|
|
158
|
+
signal: req.signal,
|
|
159
|
+
})
|
|
160
|
+
} catch (cause) {
|
|
161
|
+
throw new NetworkError({
|
|
162
|
+
status: 0,
|
|
163
|
+
code: req.signal?.aborted ? 'aborted' : 'network_error',
|
|
164
|
+
message: req.signal?.aborted ? 'request aborted' : 'network error: ' + String(cause),
|
|
165
|
+
cause,
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
if (res.ok) {
|
|
169
|
+
const text = await res.text()
|
|
170
|
+
return { status: res.status, headers: res.headers, data: text ? JSON.parse(text) : undefined }
|
|
171
|
+
}
|
|
172
|
+
const err = await errorFromResponse(res)
|
|
173
|
+
if (err.code === 'unauthenticated' && sentSession && req.expire !== false) {
|
|
174
|
+
await this.auth._expired()
|
|
175
|
+
}
|
|
176
|
+
if (err instanceof RetryableError && attempt < attempts) {
|
|
177
|
+
await sleep(backoff(attempt, err.retryAfter, retry.maxDelayMs ?? 30000), req.signal)
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
throw err
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Wait before the next attempt: what the server asked, else exponential
|
|
187
|
+
* backoff from 500 ms; never more than max.
|
|
188
|
+
* @param {number} attempt
|
|
189
|
+
* @param {number | undefined} retryAfter
|
|
190
|
+
* @param {number} max
|
|
191
|
+
*/
|
|
192
|
+
function backoff(attempt, retryAfter, max) {
|
|
193
|
+
return Math.min(retryAfter ?? 500 * 2 ** attempt, max)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* @param {number} ms
|
|
198
|
+
* @param {AbortSignal} [signal]
|
|
199
|
+
* @returns {Promise<void>}
|
|
200
|
+
*/
|
|
201
|
+
function sleep(ms, signal) {
|
|
202
|
+
return new Promise((resolve, reject) => {
|
|
203
|
+
if (signal?.aborted) return reject(signal.reason)
|
|
204
|
+
const t = setTimeout(resolve, ms)
|
|
205
|
+
signal?.addEventListener(
|
|
206
|
+
'abort',
|
|
207
|
+
() => {
|
|
208
|
+
clearTimeout(t)
|
|
209
|
+
reject(signal.reason)
|
|
210
|
+
},
|
|
211
|
+
{ once: true },
|
|
212
|
+
)
|
|
213
|
+
})
|
|
214
|
+
}
|