backd-js 0.1.14 → 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/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
+ }