@uniweb/api 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/LICENSE +201 -0
- package/README.md +70 -0
- package/package.json +49 -0
- package/src/client.js +496 -0
- package/src/components/gates.js +22 -0
- package/src/errors.js +112 -0
- package/src/hooks/useAction.js +57 -0
- package/src/hooks/useEntity.js +78 -0
- package/src/hooks/usePasswordReset.js +30 -0
- package/src/hooks/useSession.js +56 -0
- package/src/hooks/useSignIn.js +54 -0
- package/src/hooks/useSignUp.js +28 -0
- package/src/http.js +82 -0
- package/src/index.js +34 -0
- package/src/ledger.js +97 -0
package/src/client.js
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @uniweb/api — the client half. No React in this module.
|
|
3
|
+
*
|
|
4
|
+
* A foundation's client for the site's own backend — the one a host declares
|
|
5
|
+
* as the site service `api`. Absent that declaration the site has no such
|
|
6
|
+
* backend and everything here is inert: no request leaves, and a component
|
|
7
|
+
* renders for that state rather than retrying it.
|
|
8
|
+
*
|
|
9
|
+
* What lives here: the service name, the base, the one client instance a page
|
|
10
|
+
* holds — its session, its request primitive, the cache keys it scopes to a
|
|
11
|
+
* viewer — and the plain functions a foundation calls outside React.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { getUniweb, deriveCacheKey } from '@uniweb/core'
|
|
15
|
+
import { resolveService } from '@uniweb/core/services'
|
|
16
|
+
import { ApiError } from './errors.js'
|
|
17
|
+
import { composeUrl, isCrossOrigin, readBody, UNSAFE } from './http.js'
|
|
18
|
+
|
|
19
|
+
/** The site service this package reads its base from — the only name it owns. */
|
|
20
|
+
export const SERVICE_NAME = 'api'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The shape of the shared instance, as a contract between copies of this
|
|
24
|
+
* package on one page. Within a major, changes are additive, so a copy built
|
|
25
|
+
* against an older package works with a newer instance.
|
|
26
|
+
*/
|
|
27
|
+
export const CONTRACT = 1
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Where the site's backend is, if it has one.
|
|
31
|
+
*
|
|
32
|
+
* `resolveService` answers with the site's own declaration first (`api:` in
|
|
33
|
+
* `site.yml`), then the host's (`config.services.api`), and `null` when neither
|
|
34
|
+
* names an address. Absence is the ordinary state of a site with no backend,
|
|
35
|
+
* not an error.
|
|
36
|
+
*
|
|
37
|
+
* @param {object} website - the active Website, or anything shaped `{ config, basePath }`
|
|
38
|
+
* @returns {string|null} the base every request is made against, or null
|
|
39
|
+
*/
|
|
40
|
+
export function resolveBase(website) {
|
|
41
|
+
return resolveService(website, SERVICE_NAME).url || null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Does this site have a backend the package can talk to?
|
|
46
|
+
*
|
|
47
|
+
* The question to ask before drawing a sign-in affordance or any control only a
|
|
48
|
+
* backend can answer. False means: draw nothing, or the static alternative the
|
|
49
|
+
* site already carries.
|
|
50
|
+
*
|
|
51
|
+
* @param {object} website
|
|
52
|
+
* @returns {boolean}
|
|
53
|
+
*/
|
|
54
|
+
export function isEnabled(website) {
|
|
55
|
+
return resolveBase(website) !== null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const ANONYMOUS = Object.freeze({ status: 'anonymous', viewer: null, error: null })
|
|
59
|
+
const LOADING = Object.freeze({ status: 'loading', viewer: null, error: null })
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The one client instance per page.
|
|
63
|
+
*
|
|
64
|
+
* Holds what has identity or lifetime — the session snapshot and its
|
|
65
|
+
* subscribers, the in-flight table, the cache keys written for the current
|
|
66
|
+
* viewer, a pending sign-in challenge — and nothing a second copy of this
|
|
67
|
+
* package could disagree with. The snapshot is a frozen value replaced on
|
|
68
|
+
* change, so React reads it through `useSyncExternalStore` with stable
|
|
69
|
+
* identity, and it works under `renderToString`, where no effect runs and
|
|
70
|
+
* nothing is fetched.
|
|
71
|
+
*
|
|
72
|
+
* Everything else is read at use, never captured at creation: the base comes
|
|
73
|
+
* from `website.config` on each call, so the editor's `Website.rebuild()`
|
|
74
|
+
* needs no hook here.
|
|
75
|
+
*/
|
|
76
|
+
export class ApiClient {
|
|
77
|
+
/**
|
|
78
|
+
* @param {object} uniweb - the page's `Uniweb` singleton
|
|
79
|
+
* @param {object} [options]
|
|
80
|
+
* @param {typeof fetch} [options.fetchFn] - a `fetch` to use instead of the
|
|
81
|
+
* global one — a test's, or a server tool's with a cookie jar
|
|
82
|
+
*/
|
|
83
|
+
constructor(uniweb, { fetchFn = null } = {}) {
|
|
84
|
+
this.v = CONTRACT
|
|
85
|
+
this._uniweb = uniweb
|
|
86
|
+
this.fetchFn = fetchFn
|
|
87
|
+
this._listeners = new Set()
|
|
88
|
+
this._pending = null
|
|
89
|
+
this._challenge = null
|
|
90
|
+
this._keys = new Set()
|
|
91
|
+
this._inflight = new Map()
|
|
92
|
+
this._session = this.enabled ? LOADING : ANONYMOUS
|
|
93
|
+
// Stable identity: `useSyncExternalStore` re-subscribes when this changes.
|
|
94
|
+
this.subscribe = this.subscribe.bind(this)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The active Website, read at use. */
|
|
98
|
+
get website() {
|
|
99
|
+
return this._uniweb?.activeWebsite ?? null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The base every request is made against, or null. Read at use. */
|
|
103
|
+
get base() {
|
|
104
|
+
return resolveBase(this.website)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Whether the site declares a backend at all. */
|
|
108
|
+
get enabled() {
|
|
109
|
+
return this.base !== null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The current session snapshot — `{ status, viewer, error }`, frozen. */
|
|
113
|
+
get session() {
|
|
114
|
+
return this._session
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** What scopes a cache key to the current viewer. */
|
|
118
|
+
get viewerId() {
|
|
119
|
+
return this._session.viewer?.uuid ?? 'anonymous'
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Observe the session. Fires after every change of the snapshot.
|
|
124
|
+
*
|
|
125
|
+
* @param {Function} fn
|
|
126
|
+
* @returns {Function} unsubscribe
|
|
127
|
+
*/
|
|
128
|
+
subscribe(fn) {
|
|
129
|
+
this._listeners.add(fn)
|
|
130
|
+
return () => {
|
|
131
|
+
this._listeners.delete(fn)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Replace the snapshot and wake subscribers. The probe, sign-in and sign-out
|
|
137
|
+
* all land here; a snapshot equal in status, viewer and error is a no-op.
|
|
138
|
+
*
|
|
139
|
+
* @param {{ status: 'loading'|'anonymous'|'authenticated', viewer?: object|null, error?: Error|null }} next
|
|
140
|
+
* @returns {object} the snapshot now current
|
|
141
|
+
*/
|
|
142
|
+
setSession(next) {
|
|
143
|
+
const cur = this._session
|
|
144
|
+
const viewer = next.viewer ?? null
|
|
145
|
+
const error = next.error ?? null
|
|
146
|
+
if (cur.status === next.status && cur.viewer === viewer && cur.error === error) return cur
|
|
147
|
+
this._session = Object.freeze({ status: next.status, viewer, error })
|
|
148
|
+
for (const fn of this._listeners) fn()
|
|
149
|
+
return this._session
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── The wire ──────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* One request to the backend. The only place a URL is composed.
|
|
156
|
+
*
|
|
157
|
+
* Sends `Accept: application/json`; a JSON body when one is given; the CSRF
|
|
158
|
+
* header on every unsafe method, which cookie-authenticated mutations
|
|
159
|
+
* require; and credentials only when the base is another origin. The locale
|
|
160
|
+
* rides only on reads that return localized values — the backend refuses a
|
|
161
|
+
* parameter a route does not take (`400 "Unexpected parameters: locale"`,
|
|
162
|
+
* measured on `/auth/me`). A non-2xx answer becomes an `ApiError`, and a `401` —
|
|
163
|
+
* unless the caller says otherwise — means the session is gone: the viewer's
|
|
164
|
+
* cache entries leave memory and the session turns anonymous.
|
|
165
|
+
*
|
|
166
|
+
* @param {string} method
|
|
167
|
+
* @param {string} path - the route under `/api`
|
|
168
|
+
* @param {object} [options]
|
|
169
|
+
* @param {object} [options.query]
|
|
170
|
+
* @param {*} [options.body]
|
|
171
|
+
* @param {AbortSignal} [options.signal]
|
|
172
|
+
* @param {object} [options.headers]
|
|
173
|
+
* @param {'session-lost'|'ignore'} [options.onUnauthorized] - what a `401`
|
|
174
|
+
* means. The login family and the probe pass `ignore`: there a `401` is an
|
|
175
|
+
* answer about the credential offered, not about the session held
|
|
176
|
+
* @returns {Promise<*>} the parsed body
|
|
177
|
+
* @throws {ApiError}
|
|
178
|
+
*/
|
|
179
|
+
async request(method, path, { query, body, signal, headers, onUnauthorized = 'session-lost' } = {}) {
|
|
180
|
+
const base = this.base
|
|
181
|
+
if (base === null) throw ApiError.disabled()
|
|
182
|
+
const fetchFn = this.fetchFn ?? globalThis.fetch
|
|
183
|
+
if (typeof fetchFn !== 'function') {
|
|
184
|
+
throw new ApiError({ status: 0, title: 'No fetch', detail: 'fetch is unavailable in this environment', kind: 'unavailable' })
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const url = composeUrl(base, path, query)
|
|
188
|
+
const init = {
|
|
189
|
+
method,
|
|
190
|
+
signal,
|
|
191
|
+
credentials: isCrossOrigin(base) ? 'include' : 'same-origin',
|
|
192
|
+
headers: { accept: 'application/json', ...(headers || {}) },
|
|
193
|
+
}
|
|
194
|
+
if (UNSAFE.has(method)) init.headers['x-uniweb-csrf'] = '1'
|
|
195
|
+
if (body !== undefined) {
|
|
196
|
+
init.headers['content-type'] = 'application/json'
|
|
197
|
+
init.body = JSON.stringify(body)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let res
|
|
201
|
+
try {
|
|
202
|
+
res = await fetchFn(url, init)
|
|
203
|
+
} catch (err) {
|
|
204
|
+
throw ApiError.network(err)
|
|
205
|
+
}
|
|
206
|
+
const payload = await readBody(res)
|
|
207
|
+
if (res.ok) return payload
|
|
208
|
+
|
|
209
|
+
const error = ApiError.fromResponse(res, payload)
|
|
210
|
+
if (error.status === 401 && onUnauthorized === 'session-lost') this._sessionLost()
|
|
211
|
+
throw error
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
_localeQuery() {
|
|
215
|
+
const website = this.website
|
|
216
|
+
const locale = website?.getActiveLocale?.() ?? website?.activeLocale ?? null
|
|
217
|
+
return locale ? { locale } : null
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── The session ───────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Settle the session once. Idempotent and shared: every caller of an
|
|
224
|
+
* in-flight probe gets the same promise.
|
|
225
|
+
*
|
|
226
|
+
* On a site with no backend this resolves to anonymous and makes no request
|
|
227
|
+
* — the ordinary case. With a backend declared, it asks once who the viewer
|
|
228
|
+
* is; a `401` is the answer "nobody", and anything else that goes wrong
|
|
229
|
+
* leaves the session `loading` with the error attached, for `refresh()` to
|
|
230
|
+
* retry.
|
|
231
|
+
*
|
|
232
|
+
* @returns {Promise<object>} the snapshot
|
|
233
|
+
*/
|
|
234
|
+
ensureSession() {
|
|
235
|
+
if (!this.enabled) return Promise.resolve(this.setSession(ANONYMOUS))
|
|
236
|
+
if (this._session.status !== 'loading') return Promise.resolve(this._session)
|
|
237
|
+
return this._share(() => this._probe())
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Ask again who the viewer is — after a sign-in elsewhere, or on focus. */
|
|
241
|
+
refresh() {
|
|
242
|
+
if (!this.enabled) return Promise.resolve(this.setSession(ANONYMOUS))
|
|
243
|
+
return this._share(() => this._probe())
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
_share(run) {
|
|
247
|
+
if (this._pending) return this._pending
|
|
248
|
+
this._pending = run().finally(() => {
|
|
249
|
+
this._pending = null
|
|
250
|
+
})
|
|
251
|
+
return this._pending
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async _probe() {
|
|
255
|
+
try {
|
|
256
|
+
const me = await this.request('GET', '/auth/me', { onUnauthorized: 'ignore' })
|
|
257
|
+
return this._authenticated(me)
|
|
258
|
+
} catch (err) {
|
|
259
|
+
if (err instanceof ApiError && err.status === 401) return this._sessionLost()
|
|
260
|
+
const cur = this._session
|
|
261
|
+
return this.setSession({ status: cur.status, viewer: cur.viewer, error: err })
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
_authenticated(me) {
|
|
266
|
+
const account = me?.account && typeof me.account === 'object' ? me.account : {}
|
|
267
|
+
const viewer = Object.freeze({
|
|
268
|
+
...account,
|
|
269
|
+
roles: Array.isArray(me?.roles) ? me.roles : [],
|
|
270
|
+
actingUnitId: me?.acting_unit_id ?? null,
|
|
271
|
+
})
|
|
272
|
+
if (this._session.viewer && this._session.viewer.uuid !== viewer.uuid) this.forgetViewer()
|
|
273
|
+
return this.setSession({ status: 'authenticated', viewer, error: null })
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
_sessionLost() {
|
|
277
|
+
this.forgetViewer()
|
|
278
|
+
this._challenge = null
|
|
279
|
+
return this.setSession(ANONYMOUS)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Sign in. The credentials object is handed to the backend as the request
|
|
284
|
+
* body, unchanged — this package does not decide its field names.
|
|
285
|
+
*
|
|
286
|
+
* @param {object} credentials
|
|
287
|
+
* @returns {Promise<{ ok: boolean, viewer?: object|null, challenge?: { kind: 'totp' } }>}
|
|
288
|
+
* `ok: false` with a `challenge` when a second factor is required — finish
|
|
289
|
+
* with `completeChallenge(code)`. A refused credential throws (`kind: 'auth'`).
|
|
290
|
+
*/
|
|
291
|
+
async signIn(credentials) {
|
|
292
|
+
const body = await this.request('POST', '/auth/login', { body: credentials, onUnauthorized: 'ignore' })
|
|
293
|
+
if (body?.status === 'totp_required') {
|
|
294
|
+
this._challenge = body.challenge_token ?? null
|
|
295
|
+
return { ok: false, challenge: { kind: 'totp' } }
|
|
296
|
+
}
|
|
297
|
+
this._challenge = null
|
|
298
|
+
const session = await this._probe()
|
|
299
|
+
return { ok: session.status === 'authenticated', viewer: session.viewer }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Finish a sign-in that asked for a second factor.
|
|
304
|
+
*
|
|
305
|
+
* @param {string} code
|
|
306
|
+
* @returns {Promise<{ ok: boolean, viewer: object|null }>}
|
|
307
|
+
*/
|
|
308
|
+
async completeChallenge(code) {
|
|
309
|
+
if (!this._challenge) {
|
|
310
|
+
throw new ApiError({ status: 0, title: 'No Challenge', detail: 'no sign-in challenge is pending', kind: 'invalid' })
|
|
311
|
+
}
|
|
312
|
+
await this.request('POST', '/auth/login/challenge', {
|
|
313
|
+
body: { challenge_token: this._challenge, code },
|
|
314
|
+
onUnauthorized: 'ignore',
|
|
315
|
+
})
|
|
316
|
+
this._challenge = null
|
|
317
|
+
const session = await this._probe()
|
|
318
|
+
return { ok: session.status === 'authenticated', viewer: session.viewer }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Sign out. The session turns anonymous locally whatever the backend
|
|
323
|
+
* answers — the viewer asked to leave — and the viewer's entries leave the
|
|
324
|
+
* cache.
|
|
325
|
+
*/
|
|
326
|
+
async signOut() {
|
|
327
|
+
try {
|
|
328
|
+
await this.request('POST', '/auth/logout', { onUnauthorized: 'ignore' })
|
|
329
|
+
} finally {
|
|
330
|
+
this._sessionLost()
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Sign up. `202` semantics: the account is inert until verified. */
|
|
335
|
+
signUp(fields) {
|
|
336
|
+
return this.request('POST', '/auth/register', { body: fields, onUnauthorized: 'ignore' })
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Ask for a password reset. The backend answers `202` whether or not the account exists. */
|
|
340
|
+
requestPasswordReset(fields) {
|
|
341
|
+
return this.request('POST', '/auth/reset/request', { body: fields, onUnauthorized: 'ignore' })
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Confirm a password reset with the token the viewer received. */
|
|
345
|
+
confirmPasswordReset(fields) {
|
|
346
|
+
return this.request('POST', '/auth/reset/confirm', { body: fields, onUnauthorized: 'ignore' })
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ── The cache ─────────────────────────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* A cache key scoped to the current viewer, derived the way every other key
|
|
353
|
+
* in the site's `DataStore` is — so kit's `useCacheEntry` can observe it
|
|
354
|
+
* given the same spec. A viewer change changes every key, so mounted hooks
|
|
355
|
+
* refetch by themselves.
|
|
356
|
+
*
|
|
357
|
+
* @param {object} spec - `{ endpoint, schema, … }`
|
|
358
|
+
* @returns {string}
|
|
359
|
+
*/
|
|
360
|
+
cacheKey(spec) {
|
|
361
|
+
return deriveCacheKey({ ...spec, endpoint: `api:${this.viewerId}:${spec.endpoint ?? ''}` })
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Note a key this client wrote, so sign-out can remove it. */
|
|
365
|
+
remember(key) {
|
|
366
|
+
this._keys.add(key)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Remove every entry written for the current viewer. */
|
|
370
|
+
forgetViewer() {
|
|
371
|
+
const store = this.website?.dataStore
|
|
372
|
+
for (const key of this._keys) {
|
|
373
|
+
store?.delete(key)
|
|
374
|
+
this._inflight.delete(key)
|
|
375
|
+
}
|
|
376
|
+
this._keys.clear()
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Read through the cache: a hit answers at once, a miss runs `run` once for
|
|
381
|
+
* every concurrent caller and writes what it returns.
|
|
382
|
+
*
|
|
383
|
+
* @param {string} key
|
|
384
|
+
* @param {() => Promise<*>} run
|
|
385
|
+
* @returns {Promise<*>}
|
|
386
|
+
*/
|
|
387
|
+
load(key, run) {
|
|
388
|
+
const store = this.website?.dataStore
|
|
389
|
+
if (store?.has(key)) return Promise.resolve(store.get(key).data)
|
|
390
|
+
if (this._inflight.has(key)) return this._inflight.get(key)
|
|
391
|
+
const pending = run()
|
|
392
|
+
.then((data) => {
|
|
393
|
+
store?.set(key, { data })
|
|
394
|
+
this.remember(key)
|
|
395
|
+
return data
|
|
396
|
+
})
|
|
397
|
+
.finally(() => {
|
|
398
|
+
this._inflight.delete(key)
|
|
399
|
+
})
|
|
400
|
+
this._inflight.set(key, pending)
|
|
401
|
+
return pending
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ── Entities ──────────────────────────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Read one entity by id — through a container the viewer holds an
|
|
408
|
+
* entitlement on, when `via` names one.
|
|
409
|
+
*
|
|
410
|
+
* `absent` is one word for not-found-and-not-permitted, by the backend's
|
|
411
|
+
* design; a component renders its enrol or paywall on it and never says
|
|
412
|
+
* "deleted". Any other refusal throws.
|
|
413
|
+
*
|
|
414
|
+
* @param {object} args
|
|
415
|
+
* @param {string} args.schema - the entity's Model, e.g. `@/lesson`
|
|
416
|
+
* @param {string} args.uuid
|
|
417
|
+
* @param {string} [args.via] - the granting container's uuid
|
|
418
|
+
* @param {AbortSignal} [args.signal]
|
|
419
|
+
* @returns {Promise<{ status: 'ready'|'absent', entity: object|null }>}
|
|
420
|
+
*/
|
|
421
|
+
async readEntity({ schema, uuid, via, signal } = {}) {
|
|
422
|
+
if (!uuid) throw new ApiError({ status: 0, title: 'No Entity', detail: 'readEntity needs a uuid', kind: 'invalid' })
|
|
423
|
+
try {
|
|
424
|
+
const entity = await this.request('GET', `/entities/${encodeURIComponent(uuid)}`, {
|
|
425
|
+
query: { model: schema, via, ...this._localeQuery() },
|
|
426
|
+
signal,
|
|
427
|
+
})
|
|
428
|
+
return { status: 'ready', entity }
|
|
429
|
+
} catch (err) {
|
|
430
|
+
if (err instanceof ApiError && err.kind === 'absent') return { status: 'absent', entity: null }
|
|
431
|
+
throw err
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Reached only on a `@uniweb/core` older than the `api` slot, where the sealed
|
|
437
|
+
// singleton refuses the assignment. Keyed by the singleton so one page still
|
|
438
|
+
// gets one client per copy of this package — correct on a page with one
|
|
439
|
+
// foundation, and the reason to update core on one with more.
|
|
440
|
+
const fallback = new WeakMap()
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* The client for this page — created on first use, parked on `uniweb.api`, and
|
|
444
|
+
* adopted by every later copy of this package. Returns `null` when no runtime
|
|
445
|
+
* is present, which every caller treats as "no backend".
|
|
446
|
+
*
|
|
447
|
+
* @returns {ApiClient|null}
|
|
448
|
+
*/
|
|
449
|
+
export function getClient() {
|
|
450
|
+
const uniweb = getUniweb()
|
|
451
|
+
if (!uniweb) return null
|
|
452
|
+
if (uniweb.api) return uniweb.api
|
|
453
|
+
const held = fallback.get(uniweb)
|
|
454
|
+
if (held) return held
|
|
455
|
+
|
|
456
|
+
const client = new ApiClient(uniweb)
|
|
457
|
+
try {
|
|
458
|
+
uniweb.api = client
|
|
459
|
+
} catch {
|
|
460
|
+
fallback.set(uniweb, client)
|
|
461
|
+
if (typeof console !== 'undefined') {
|
|
462
|
+
console.warn(
|
|
463
|
+
'@uniweb/api: this @uniweb/core has no `api` slot; update core so one client is shared per page.',
|
|
464
|
+
)
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return client
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ── The functions — the same client, outside React ───────────────────────────
|
|
471
|
+
|
|
472
|
+
function required() {
|
|
473
|
+
const client = getClient()
|
|
474
|
+
if (!client) throw ApiError.disabled()
|
|
475
|
+
return client
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Settle the session once; resolves to the snapshot. */
|
|
479
|
+
export const probeSession = () => required().ensureSession()
|
|
480
|
+
/** @see ApiClient#signIn */
|
|
481
|
+
export const signIn = (credentials) => required().signIn(credentials)
|
|
482
|
+
/** @see ApiClient#completeChallenge */
|
|
483
|
+
export const completeChallenge = (code) => required().completeChallenge(code)
|
|
484
|
+
/** @see ApiClient#signOut */
|
|
485
|
+
export const signOut = () => required().signOut()
|
|
486
|
+
/** @see ApiClient#signUp */
|
|
487
|
+
export const signUp = (fields) => required().signUp(fields)
|
|
488
|
+
/** @see ApiClient#requestPasswordReset */
|
|
489
|
+
export const requestPasswordReset = (fields) => required().requestPasswordReset(fields)
|
|
490
|
+
/** @see ApiClient#confirmPasswordReset */
|
|
491
|
+
export const confirmPasswordReset = (fields) => required().confirmPasswordReset(fields)
|
|
492
|
+
/** @see ApiClient#readEntity */
|
|
493
|
+
export const readEntity = (args) => required().readEntity(args)
|
|
494
|
+
|
|
495
|
+
export { ApiError, kindOf } from './errors.js'
|
|
496
|
+
export { Ledger } from './ledger.js'
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { useSession } from '../hooks/useSession.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless gates — control flow, not screens. Each renders its children in
|
|
5
|
+
* one session state and `fallback` (default: nothing) otherwise. While the
|
|
6
|
+
* session is still `loading`, both render the fallback, so a page never
|
|
7
|
+
* flashes a sign-in affordance at a viewer who is signed in.
|
|
8
|
+
*
|
|
9
|
+
* ```jsx
|
|
10
|
+
* <SignedIn fallback={<SignInPrompt />}><Roster /></SignedIn>
|
|
11
|
+
* <SignedOut><Link href="/join">Join</Link></SignedOut>
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export function SignedIn({ children, fallback = null }) {
|
|
15
|
+
const { status } = useSession()
|
|
16
|
+
return status === 'authenticated' ? children : fallback
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function SignedOut({ children, fallback = null }) {
|
|
20
|
+
const { status } = useSession()
|
|
21
|
+
return status === 'anonymous' ? children : fallback
|
|
22
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Errors — one class, branched by `kind`.
|
|
3
|
+
*
|
|
4
|
+
* The backend answers a refusal with problem-JSON: `{ status, title, detail,
|
|
5
|
+
* …extensions }`. `title` is its stable discriminator and `detail` is prose that
|
|
6
|
+
* changes; this module is the only reader of `title`, and a component branches
|
|
7
|
+
* on `kind`.
|
|
8
|
+
*
|
|
9
|
+
* What each kind means to a component:
|
|
10
|
+
*
|
|
11
|
+
* auth not signed in, or the credential is dead → offer sign-in
|
|
12
|
+
* absent nothing here for you — not found OR not permitted, which the
|
|
13
|
+
* backend keeps indistinguishable on purpose. Never say "deleted"
|
|
14
|
+
* forbidden you may see it, not do this to it → a capability message
|
|
15
|
+
* invalid a malformed request — a bug in the caller, raised before auth
|
|
16
|
+
* conflict a stale concurrency token; `extensions.current_updated_at`
|
|
17
|
+
* carries the item's current one
|
|
18
|
+
* csrf the mutation lacked the header this package always sends —
|
|
19
|
+
* a bug, not a state to handle
|
|
20
|
+
* step-up the credential must be re-proven for this mutation
|
|
21
|
+
* rate-limited `retryAfter` says when to try again
|
|
22
|
+
* unavailable the backend could not be reached, or failed
|
|
23
|
+
* disabled this site declares no backend at all
|
|
24
|
+
* unknown none of the above
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const TITLE_KINDS = {
|
|
28
|
+
'CSRF Header Required': 'csrf',
|
|
29
|
+
'Step-Up Required': 'step-up',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The kind for a refusal. Title first — the two titles that refine a `403` —
|
|
34
|
+
* then the status.
|
|
35
|
+
*
|
|
36
|
+
* @param {number} status
|
|
37
|
+
* @param {string} [title]
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
export function kindOf(status, title) {
|
|
41
|
+
if (title && TITLE_KINDS[title]) return TITLE_KINDS[title]
|
|
42
|
+
if (status === 401) return 'auth'
|
|
43
|
+
if (status === 403) return 'forbidden'
|
|
44
|
+
if (status === 404) return 'absent'
|
|
45
|
+
if (status === 400 || status === 422) return 'invalid'
|
|
46
|
+
if (status === 409) return 'conflict'
|
|
47
|
+
if (status === 429) return 'rate-limited'
|
|
48
|
+
if (status === 0 || status >= 500) return 'unavailable'
|
|
49
|
+
return 'unknown'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class ApiError extends Error {
|
|
53
|
+
/**
|
|
54
|
+
* @param {object} fields
|
|
55
|
+
* @param {number} [fields.status]
|
|
56
|
+
* @param {string} [fields.title]
|
|
57
|
+
* @param {string} [fields.detail]
|
|
58
|
+
* @param {object} [fields.extensions] - every problem-JSON key beyond the three above
|
|
59
|
+
* @param {string} [fields.kind] - derived from status and title when omitted
|
|
60
|
+
* @param {Error} [fields.cause]
|
|
61
|
+
*/
|
|
62
|
+
constructor({ status = 0, title = '', detail = '', extensions = {}, kind, cause } = {}) {
|
|
63
|
+
super(detail || title || `HTTP ${status}`, cause ? { cause } : undefined)
|
|
64
|
+
this.name = 'ApiError'
|
|
65
|
+
this.status = status
|
|
66
|
+
this.title = title
|
|
67
|
+
this.detail = detail
|
|
68
|
+
this.extensions = extensions
|
|
69
|
+
this.kind = kind ?? kindOf(status, title)
|
|
70
|
+
this.retryAfter =
|
|
71
|
+
typeof extensions.retry_after_seconds === 'number' ? extensions.retry_after_seconds : null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* From a non-2xx response and its parsed body. A body that is not
|
|
76
|
+
* problem-JSON still yields a usable error: the status decides the kind.
|
|
77
|
+
*
|
|
78
|
+
* @param {{ status: number, statusText?: string }} res
|
|
79
|
+
* @param {*} payload - the parsed body, or null
|
|
80
|
+
*/
|
|
81
|
+
static fromResponse(res, payload) {
|
|
82
|
+
const p = payload && typeof payload === 'object' ? payload : {}
|
|
83
|
+
const { status: _ignored, title, detail, ...extensions } = p
|
|
84
|
+
return new ApiError({
|
|
85
|
+
status: res.status,
|
|
86
|
+
title: typeof title === 'string' ? title : res.statusText || '',
|
|
87
|
+
detail: typeof detail === 'string' ? detail : '',
|
|
88
|
+
extensions,
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The request did not complete — no response to read. */
|
|
93
|
+
static network(cause) {
|
|
94
|
+
return new ApiError({
|
|
95
|
+
status: 0,
|
|
96
|
+
title: 'Network',
|
|
97
|
+
detail: cause?.message || 'the request did not complete',
|
|
98
|
+
kind: 'unavailable',
|
|
99
|
+
cause,
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The site declares no backend; nothing was attempted. */
|
|
104
|
+
static disabled() {
|
|
105
|
+
return new ApiError({
|
|
106
|
+
status: 0,
|
|
107
|
+
title: 'No Backend',
|
|
108
|
+
detail: 'this site declares no backend',
|
|
109
|
+
kind: 'disabled',
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The `idle → submitting → success | error` lifecycle every action hook
|
|
5
|
+
* shares — the shape `useFormSubmit` gave kit's forms, applied to a call on the
|
|
6
|
+
* client. Not exported from the package; the named hooks are.
|
|
7
|
+
*
|
|
8
|
+
* `run` rejects as the action does, so a caller may `await` it and branch; the
|
|
9
|
+
* state carries the same outcome for the render path. Results that land after
|
|
10
|
+
* unmount are dropped.
|
|
11
|
+
*
|
|
12
|
+
* @param {(...args: any[]) => Promise<any>} action
|
|
13
|
+
* @returns {{ run: Function, status: string, error: Error|null, response: any, reset: Function }}
|
|
14
|
+
*/
|
|
15
|
+
export function useAction(action) {
|
|
16
|
+
const [status, setStatus] = useState('idle')
|
|
17
|
+
const [error, setError] = useState(null)
|
|
18
|
+
const [response, setResponse] = useState(null)
|
|
19
|
+
|
|
20
|
+
const live = useRef(true)
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
live.current = true
|
|
23
|
+
return () => {
|
|
24
|
+
live.current = false
|
|
25
|
+
}
|
|
26
|
+
}, [])
|
|
27
|
+
|
|
28
|
+
const actionRef = useRef(action)
|
|
29
|
+
actionRef.current = action
|
|
30
|
+
|
|
31
|
+
const run = useCallback(async (...args) => {
|
|
32
|
+
setStatus('submitting')
|
|
33
|
+
setError(null)
|
|
34
|
+
try {
|
|
35
|
+
const result = await actionRef.current(...args)
|
|
36
|
+
if (live.current) {
|
|
37
|
+
setResponse(result)
|
|
38
|
+
setStatus('success')
|
|
39
|
+
}
|
|
40
|
+
return result
|
|
41
|
+
} catch (err) {
|
|
42
|
+
if (live.current) {
|
|
43
|
+
setError(err)
|
|
44
|
+
setStatus('error')
|
|
45
|
+
}
|
|
46
|
+
throw err
|
|
47
|
+
}
|
|
48
|
+
}, [])
|
|
49
|
+
|
|
50
|
+
const reset = useCallback(() => {
|
|
51
|
+
setStatus('idle')
|
|
52
|
+
setError(null)
|
|
53
|
+
setResponse(null)
|
|
54
|
+
}, [])
|
|
55
|
+
|
|
56
|
+
return { run, status, error, response, reset }
|
|
57
|
+
}
|