@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.
@@ -0,0 +1,78 @@
1
+ import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
2
+ import { getClient } from '../client.js'
3
+
4
+ const noSubscribe = () => () => {}
5
+ const noSnapshot = () => null
6
+ const DISABLED = Object.freeze({ status: 'absent', entity: null, error: null })
7
+
8
+ /**
9
+ * One entity by id — through a container the viewer holds an entitlement on,
10
+ * when `via` names one.
11
+ *
12
+ * ```jsx
13
+ * const { status, entity } = useEntity({ schema: '@/lesson', uuid, via: course.uuid })
14
+ * // status: 'loading' | 'ready' | 'absent' | 'error'
15
+ * if (status === 'absent') return <EnrolWall /> // not found OR not permitted — one word, by design
16
+ * ```
17
+ *
18
+ * Cached in the site's data store under a key scoped to the viewer, so a
19
+ * sign-in or sign-out changes the key and the record is read again for who
20
+ * is now looking. On a site with no backend the answer is `absent`: there is
21
+ * nothing to read.
22
+ *
23
+ * @param {{ schema: string, uuid: string, via?: string } | null} ref - pass null to skip
24
+ * @returns {{ status: string, entity: object|null, error: Error|null, refresh: Function }}
25
+ */
26
+ export function useEntity(ref) {
27
+ const client = getClient()
28
+ const website = client?.website ?? null
29
+ const store = website?.dataStore ?? null
30
+
31
+ // Re-key on a viewer change: the session is part of the key.
32
+ useSyncExternalStore(
33
+ client ? client.subscribe : noSubscribe,
34
+ client ? () => client.session : noSnapshot,
35
+ client ? () => client.session : noSnapshot,
36
+ )
37
+
38
+ const active = !!(client && client.enabled && store && ref && ref.uuid)
39
+ const key = active
40
+ ? client.cacheKey({ endpoint: `/entities/${ref.uuid}`, schema: ref.schema, via: ref.via })
41
+ : null
42
+
43
+ const subscribe = useCallback((fn) => (key ? store.subscribe(key, fn) : noSubscribe()), [store, key])
44
+ const entry = useSyncExternalStore(
45
+ subscribe,
46
+ () => (key ? store.get(key) : null),
47
+ () => (key ? store.get(key) : null),
48
+ )
49
+
50
+ const [error, setError] = useState(null)
51
+ const [attempt, setAttempt] = useState(0)
52
+
53
+ useEffect(() => {
54
+ if (!key || entry) return undefined
55
+ let live = true
56
+ setError(null)
57
+ client.load(key, () => client.readEntity(ref)).catch((err) => {
58
+ if (live) setError(err)
59
+ })
60
+ return () => {
61
+ live = false
62
+ }
63
+ // `ref` is read through `key`, which already encodes schema, uuid and via.
64
+ // eslint-disable-next-line react-hooks/exhaustive-deps
65
+ }, [client, key, entry, attempt])
66
+
67
+ const refresh = useCallback(() => {
68
+ if (key && store) store.delete(key)
69
+ setAttempt((n) => n + 1)
70
+ }, [store, key])
71
+
72
+ if (!active) return { ...DISABLED, refresh }
73
+ if (entry) return { status: entry.data.status, entity: entry.data.entity, error: null, refresh }
74
+ if (error) return { status: 'error', entity: null, error, refresh }
75
+ return { status: 'loading', entity: null, error: null, refresh }
76
+ }
77
+
78
+ export default useEntity
@@ -0,0 +1,30 @@
1
+ import { useCallback } from 'react'
2
+ import { getClient } from '../client.js'
3
+ import { ApiError } from '../errors.js'
4
+ import { useAction } from './useAction.js'
5
+
6
+ /**
7
+ * Password reset in two steps: `request(fields)` asks for one — the backend
8
+ * answers `202` whether or not the account exists, deliberately — and
9
+ * `confirm(fields)` completes it with the token the viewer received. One
10
+ * lifecycle covers whichever step ran last.
11
+ *
12
+ * @returns {{ request: Function, confirm: Function, status: string, error: Error|null, response: any, reset: Function }}
13
+ */
14
+ export function usePasswordReset() {
15
+ const client = getClient()
16
+ const { run, status, error, response, reset } = useAction(
17
+ useCallback(
18
+ (step, fields) => {
19
+ if (!client) throw ApiError.disabled()
20
+ return step === 'confirm' ? client.confirmPasswordReset(fields) : client.requestPasswordReset(fields)
21
+ },
22
+ [client],
23
+ ),
24
+ )
25
+ const request = useCallback((fields) => run('request', fields), [run])
26
+ const confirm = useCallback((fields) => run('confirm', fields), [run])
27
+ return { request, confirm, status, error, response, reset }
28
+ }
29
+
30
+ export default usePasswordReset
@@ -0,0 +1,56 @@
1
+ import { useCallback, useEffect, useSyncExternalStore } from 'react'
2
+ import { getClient } from '../client.js'
3
+
4
+ const NONE = Object.freeze({ status: 'anonymous', viewer: null, error: null })
5
+ const noSubscribe = () => () => {}
6
+ const noSnapshot = () => NONE
7
+ const noop = async () => NONE
8
+
9
+ /**
10
+ * The viewer's session.
11
+ *
12
+ * `status` is `anonymous` synchronously on a site that declares no backend —
13
+ * the ordinary case — and `loading` on one that does, until the backend has
14
+ * answered. `canSignIn` is false when there is nothing to sign in to: draw no
15
+ * affordance on false. `error` is set when the backend could not be asked;
16
+ * `refresh()` asks again.
17
+ *
18
+ * Reads the shared snapshot through `useSyncExternalStore`, so every copy of
19
+ * this package on the page sees one session, and the first render on the
20
+ * server matches the first render in the browser.
21
+ *
22
+ * @returns {{
23
+ * status: 'loading' | 'anonymous' | 'authenticated',
24
+ * viewer: object | null,
25
+ * error: Error | null,
26
+ * canSignIn: boolean,
27
+ * signOut: () => Promise<void>,
28
+ * refresh: () => Promise<object>,
29
+ * }}
30
+ */
31
+ export function useSession() {
32
+ const client = getClient()
33
+ const session = useSyncExternalStore(
34
+ client ? client.subscribe : noSubscribe,
35
+ client ? () => client.session : noSnapshot,
36
+ client ? () => client.session : noSnapshot,
37
+ )
38
+
39
+ useEffect(() => {
40
+ if (client && client.enabled) client.ensureSession()
41
+ }, [client])
42
+
43
+ const signOut = useCallback(() => (client ? client.signOut() : noop()), [client])
44
+ const refresh = useCallback(() => (client ? client.refresh() : noop()), [client])
45
+
46
+ return {
47
+ status: session.status,
48
+ viewer: session.viewer,
49
+ error: session.error,
50
+ canSignIn: !!(client && client.enabled),
51
+ signOut,
52
+ refresh,
53
+ }
54
+ }
55
+
56
+ export default useSession
@@ -0,0 +1,54 @@
1
+ import { useCallback, useState } from 'react'
2
+ import { getClient } from '../client.js'
3
+ import { ApiError } from '../errors.js'
4
+ import { useAction } from './useAction.js'
5
+
6
+ /**
7
+ * Sign in, with the second factor parked as a `challenge` when the backend
8
+ * asks for one.
9
+ *
10
+ * ```jsx
11
+ * const { signIn, completeChallenge, status, error, challenge, canSignIn } = useSignIn()
12
+ * if (!canSignIn) return null
13
+ * // status: 'idle' | 'submitting' | 'success' | 'error'
14
+ * // challenge: null | { kind: 'totp' } — render the code field and call completeChallenge(code)
15
+ * ```
16
+ *
17
+ * The credentials object goes to the backend as the request body, unchanged.
18
+ * A refused credential is an `ApiError` with `kind: 'auth'`.
19
+ */
20
+ export function useSignIn() {
21
+ const client = getClient()
22
+ const [challenge, setChallenge] = useState(null)
23
+
24
+ const { run, status, error, reset: resetAction } = useAction(
25
+ useCallback(
26
+ async (step, arg) => {
27
+ if (!client) throw ApiError.disabled()
28
+ const result = step === 'challenge' ? await client.completeChallenge(arg) : await client.signIn(arg)
29
+ setChallenge(result.ok ? null : (result.challenge ?? null))
30
+ return result
31
+ },
32
+ [client],
33
+ ),
34
+ )
35
+
36
+ const signIn = useCallback((credentials) => run('credentials', credentials), [run])
37
+ const completeChallenge = useCallback((code) => run('challenge', code), [run])
38
+ const reset = useCallback(() => {
39
+ setChallenge(null)
40
+ resetAction()
41
+ }, [resetAction])
42
+
43
+ return {
44
+ signIn,
45
+ completeChallenge,
46
+ status,
47
+ error,
48
+ challenge,
49
+ canSignIn: !!(client && client.enabled),
50
+ reset,
51
+ }
52
+ }
53
+
54
+ export default useSignIn
@@ -0,0 +1,28 @@
1
+ import { useCallback } from 'react'
2
+ import { getClient } from '../client.js'
3
+ import { ApiError } from '../errors.js'
4
+ import { useAction } from './useAction.js'
5
+
6
+ /**
7
+ * Sign up. The fields object goes to the backend as the request body,
8
+ * unchanged. The backend answers `202`: the account is inert until the viewer
9
+ * verifies it, so `status: 'success'` means "check your email", and that copy
10
+ * is the foundation's to write.
11
+ *
12
+ * @returns {{ signUp: Function, status: string, error: Error|null, response: any, canSignUp: boolean, reset: Function }}
13
+ */
14
+ export function useSignUp() {
15
+ const client = getClient()
16
+ const { run, status, error, response, reset } = useAction(
17
+ useCallback(
18
+ (fields) => {
19
+ if (!client) throw ApiError.disabled()
20
+ return client.signUp(fields)
21
+ },
22
+ [client],
23
+ ),
24
+ )
25
+ return { signUp: run, status, error, response, canSignUp: !!(client && client.enabled), reset }
26
+ }
27
+
28
+ export default useSignUp
package/src/http.js ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The wire, below the client: URL composition, credentials, body reading.
3
+ * Pure functions; the client calls them and nothing else does.
4
+ */
5
+
6
+ const ABSOLUTE_URL_RE = /^[a-z][a-z0-9+.-]*:/i
7
+
8
+ /** Methods that carry a body, or change state — the ones the CSRF header rides on. */
9
+ export const UNSAFE = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
10
+
11
+ /**
12
+ * Is the base another origin than the page's?
13
+ *
14
+ * A relative base (`/_uw`) is the page's own origin by definition. An absolute
15
+ * one is compared against `location.origin`; where there is no location — a
16
+ * server, a test — an absolute base is treated as cross-origin, which only
17
+ * makes the request carry credentials it would otherwise carry anyway.
18
+ *
19
+ * @param {string} base
20
+ * @returns {boolean}
21
+ */
22
+ export function isCrossOrigin(base) {
23
+ if (!ABSOLUTE_URL_RE.test(base)) return false
24
+ const origin = globalThis.location?.origin
25
+ if (!origin) return true
26
+ try {
27
+ return new URL(base).origin !== origin
28
+ } catch {
29
+ return true
30
+ }
31
+ }
32
+
33
+ /**
34
+ * `${base}/api${path}?…` — the one composition this package makes.
35
+ *
36
+ * The base is the prefix under which the backend's own route space appears:
37
+ * the passthrough path on the site's origin, an origin under the subdomain
38
+ * shape, or empty on a deployment where the page's own server is the backend.
39
+ * `null` and `undefined` query values are omitted.
40
+ *
41
+ * @param {string} base
42
+ * @param {string} path - the route, with or without a leading slash
43
+ * @param {object} [query]
44
+ * @returns {string}
45
+ */
46
+ export function composeUrl(base, path, query) {
47
+ const root = base.replace(/\/+$/, '')
48
+ const p = path.startsWith('/') ? path : `/${path}`
49
+ const params = new URLSearchParams()
50
+ for (const [key, value] of Object.entries(query || {})) {
51
+ if (value === undefined || value === null) continue
52
+ params.set(key, String(value))
53
+ }
54
+ const qs = params.toString()
55
+ return `${root}/api${p}${qs ? `?${qs}` : ''}`
56
+ }
57
+
58
+ /**
59
+ * The parsed body of a response: JSON when the response says so, `null` on
60
+ * `204`, and `{ detail: text }` for a non-JSON body so a refusal without
61
+ * problem-JSON still carries what the server said.
62
+ *
63
+ * @param {Response} res
64
+ * @returns {Promise<*>}
65
+ */
66
+ export async function readBody(res) {
67
+ if (res.status === 204) return null
68
+ const type = res.headers?.get?.('content-type') || ''
69
+ if (/json/i.test(type)) {
70
+ try {
71
+ return await res.json()
72
+ } catch {
73
+ return null
74
+ }
75
+ }
76
+ try {
77
+ const text = await res.text()
78
+ return text ? { detail: text } : null
79
+ } catch {
80
+ return null
81
+ }
82
+ }
package/src/index.js ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @uniweb/api
3
+ *
4
+ * A foundation's client for the site's own backend: session, records,
5
+ * entities, writes — in the site's vocabulary, never in routes. Imported the
6
+ * way `@uniweb/kit` is, bundled into the foundation, inert on a site that
7
+ * declares no backend.
8
+ *
9
+ * This entry carries the React hooks and the headless gates.
10
+ * `@uniweb/api/client` carries the plain functions and imports no React.
11
+ */
12
+
13
+ export {
14
+ SERVICE_NAME,
15
+ resolveBase,
16
+ isEnabled,
17
+ probeSession,
18
+ signIn,
19
+ completeChallenge,
20
+ signOut,
21
+ signUp,
22
+ requestPasswordReset,
23
+ confirmPasswordReset,
24
+ readEntity,
25
+ ApiError,
26
+ Ledger,
27
+ } from './client.js'
28
+
29
+ export { useSession } from './hooks/useSession.js'
30
+ export { useSignIn } from './hooks/useSignIn.js'
31
+ export { useSignUp } from './hooks/useSignUp.js'
32
+ export { usePasswordReset } from './hooks/usePasswordReset.js'
33
+ export { useEntity } from './hooks/useEntity.js'
34
+ export { SignedIn, SignedOut } from './components/gates.js'
package/src/ledger.js ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The concurrency ledger — the last-seen `item_updated_at` per item, and the
3
+ * stamping of `if_unmodified_since` onto the ops that need one.
4
+ *
5
+ * The backend guards writes at the item grain: `update`, `delete` and `move`
6
+ * each carry the target item's last-seen `updated_at`; `create` carries none.
7
+ * A mismatch is a `409` whose `current_updated_at` extension names the item's
8
+ * current token, and every write response carries `item_updated_at` — the
9
+ * next precondition to chain forward. Three sources, one token.
10
+ *
11
+ * That is the single most reinventable thing on the wire, so it lives here
12
+ * once, as a pure structure with no route knowledge. The writer that composes
13
+ * the request is the next slice; it will `stamp()` before sending, `absorb()`
14
+ * what comes back, and `rebase()` on a conflict.
15
+ *
16
+ * Two field names are read from responses — the item's id and its
17
+ * `item_updated_at` — and the first of those is a reading of the design, not
18
+ * yet a pinned wire fact. It is one constant.
19
+ */
20
+
21
+ const ID_FIELDS = ['item', 'item_id', 'id']
22
+
23
+ function itemIdOf(record) {
24
+ for (const field of ID_FIELDS) {
25
+ if (record?.[field] != null) return String(record[field])
26
+ }
27
+ return null
28
+ }
29
+
30
+ export class Ledger {
31
+ constructor() {
32
+ this._at = new Map()
33
+ }
34
+
35
+ /** Record an item's token, from a read or a write response. */
36
+ note(itemId, updatedAt) {
37
+ if (itemId == null || updatedAt == null) return
38
+ this._at.set(String(itemId), updatedAt)
39
+ }
40
+
41
+ /** The last-seen token for an item, or null when none was recorded. */
42
+ get(itemId) {
43
+ return this._at.get(String(itemId)) ?? null
44
+ }
45
+
46
+ forget(itemId) {
47
+ this._at.delete(String(itemId))
48
+ }
49
+
50
+ /**
51
+ * Stamp an op with the precondition it needs. `create` is tokenless by
52
+ * design; an op on an item this ledger has never seen goes out unguarded
53
+ * — last-writer-wins — exactly as the wire treats an absent token.
54
+ *
55
+ * @param {{ kind: string, item?: string|number }} op
56
+ * @returns {object} the op, with `if_unmodified_since` when known
57
+ */
58
+ stamp(op) {
59
+ if (!op || op.kind === 'create' || op.item == null) return op
60
+ const at = this.get(op.item)
61
+ return at == null ? op : { ...op, if_unmodified_since: at }
62
+ }
63
+
64
+ /**
65
+ * Absorb a write response — one result or a batch of them — recording each
66
+ * item's next token, and forgetting an item whose token came back `null`,
67
+ * which is how a delete reports itself.
68
+ *
69
+ * @param {object} result
70
+ */
71
+ absorb(result) {
72
+ if (!result || typeof result !== 'object') return
73
+ if (Array.isArray(result.results)) {
74
+ for (const r of result.results) this.absorb(r)
75
+ return
76
+ }
77
+ const id = itemIdOf(result)
78
+ if (id == null || !('item_updated_at' in result)) return
79
+ if (result.item_updated_at === null) this.forget(id)
80
+ else this.note(id, result.item_updated_at)
81
+ }
82
+
83
+ /**
84
+ * On a `409`, take the item's current token from the error so the next
85
+ * attempt is guarded by the truth rather than by what this ledger believed.
86
+ *
87
+ * @param {string|number} itemId
88
+ * @param {{ extensions?: { current_updated_at?: * } }} error
89
+ * @returns {boolean} whether a token was recorded
90
+ */
91
+ rebase(itemId, error) {
92
+ const current = error?.extensions?.current_updated_at
93
+ if (current == null) return false
94
+ this.note(itemId, current)
95
+ return true
96
+ }
97
+ }