@uniweb/api 0.3.7 → 0.4.1

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/README.md CHANGED
@@ -43,22 +43,38 @@ function Account() {
43
43
  </SignedIn>
44
44
  <SignedOut>
45
45
  <button onClick={() => signIn({ username, password })}>Sign in</button>
46
- {error && <span>{error.detail}</span>}
46
+ {error?.kind === 'unverified' && <span>Check your email to confirm your address.</span>}
47
+ {error?.kind === 'auth' && <span>Wrong username or password.</span>}
47
48
  </SignedOut>
48
49
  </>
49
50
  )
50
51
  }
51
52
  ```
52
53
 
53
- `viewer` is flat — `viewer.handle`, `viewer.uuid`, `viewer.roles`. Also
54
- `useSignUp`, `usePasswordReset`, and `completeChallenge` for two-factor sign-in.
54
+ `viewer` is flat — `viewer.uuid`, `viewer.username`, `viewer.handle`, and `viewer.roles`,
55
+ a list of `{ role, scope_unit_id }`. A member holds no roles (`[]`); the site's operator
56
+ holds `system_admin`. ⚠️ `viewer.actingUnitId` is the same for every member of a site, so
57
+ it cannot tell an operator from a member. To decide whether to show an edit control for
58
+ a particular record, read that record: `entity.can_edit` is the backend's own answer.
59
+
60
+ **Signing up** — `useSignUp().signUp({ username, email, password })` answers
61
+ `{ status: 'verification_required', email }`, the same answer for an address already in
62
+ use. The account cannot sign in until the address is confirmed from the email the
63
+ backend sends; until then `signIn` fails with `error.kind === 'unverified'`. A username
64
+ already taken fails with status `409`.
65
+
66
+ **Password reset** — `usePasswordReset()`: `request({ email })`, then
67
+ `confirm({ token, new_password })` with the token the viewer was sent (and `code` when a
68
+ second factor is enrolled). **Second factor** — when `signIn` answers
69
+ `{ ok: false, challenge: { kind: 'totp' } }`, finish with `completeChallenge(code)`.
55
70
 
56
71
  ## Reading
57
72
 
58
73
  ```jsx
59
74
  import { useRecords, useEntity } from '@uniweb/api'
60
75
 
61
- const { status, records, matched, hasMore } = useRecords({ schema: '@/session' })
76
+ const { status, records, hasMore } = useRecords({ schema: '@acme/session', scope: 'mine' })
77
+ // records[i].brief — the entity's summary; records[i].uuid — its id
62
78
  ```
63
79
 
64
80
  ⭐ **`absent` and an empty `ready` are different answers.** `absent` means there is no
@@ -67,28 +83,50 @@ live source — no `api` service, or nobody signed in — so render your site's
67
83
  Showing "nothing yet" for the first case tells a visitor their content is missing
68
84
  when it is simply not being asked for.
69
85
 
70
- `useEntity({ schema, uuid, via })` reads one record. Its `absent` covers both
71
- not-found and not-permitted, on purpose: render your paywall or sign-in prompt on it
72
- and never say "deleted".
86
+ **Whose records.** A member reads their own entities and what was shared with them;
87
+ other members' are private unless the site's service was set up to let members read
88
+ each other's. The site's operator reads everything. `scope: 'mine'` lists only the
89
+ viewer's own.
90
+
91
+ A record is a summary: `record.brief` holds the fields of the Model's brief section. It
92
+ carries no items — read the entity for those. `hasMore` is true when a page came back
93
+ full (`limit`, default 50); `matched` counts the records in this answer, not a total.
94
+ `all: true` reads the whole list in one request.
95
+
96
+ ```jsx
97
+ const { status, entity } = useEntity({ schema: '@acme/lesson', uuid, via: course.uuid })
98
+ // entity.hydrated.items — the content: { id, section_id, data, … } each
99
+ // entity.hydrated.entity.brief — the summary
100
+ // entity.can_edit — whether this viewer may write to it
101
+ ```
102
+
103
+ Its `absent` covers both not-found and not-permitted, on purpose: render your paywall
104
+ or sign-in prompt on it and never say "deleted".
73
105
 
74
106
  ## Writing
75
107
 
108
+ An entity's content is **items, each in a section** of its Model. A section that holds
109
+ one item (the brief) takes one `create` and is `update`d after that; a many-item section
110
+ takes as many as you add.
111
+
76
112
  ```jsx
77
113
  import { useEntityWriter } from '@uniweb/api'
78
114
 
79
- const programme = useEntityWriter({ schema: '@/track', uuid: track.uuid })
115
+ const programme = useEntityWriter({ schema: '@acme/track', uuid: track.uuid })
80
116
 
81
117
  await programme.create({ title: 'Keynote' }, { section: 'sessions', position: 'last' })
82
- await programme.update(itemId, { ...item.data, room: 'Hall A' })
83
- await programme.move(itemId, { after: otherItemId })
84
- await programme.remove(itemId)
118
+ await programme.update(item.id, { ...item.data, room: 'Hall A' })
119
+ await programme.move(item.id, { after: other.id })
120
+ await programme.remove(item.id)
85
121
  await programme.batch([...]) // one transaction: all of them, or none
86
122
  ```
87
123
 
88
- Three things it does for you, and one it deliberately does not:
124
+ `item.id` is the `id` of an item on a read. Name a section as the Model does — the
125
+ package resolves it to what the backend's item route takes.
89
126
 
90
- - **Concurrency is handled.** Every write carries the item's last-seen version and the
91
- response updates it. You never touch a token.
127
+ - **Concurrency is handled.** Every write carries the version of the item the viewer
128
+ last saw — from a read or from the previous write — and the response updates it. You
129
+ never touch a token.
92
130
  - **`section` is required on `create`.** An entity has several, and a rule declared on
93
131
  one — insert-only, say — does not reach an item that landed in another.
94
132
  - **A successful write refreshes what it changed**, so a list you are showing reflects
@@ -97,9 +135,27 @@ Three things it does for you, and one it deliberately does not:
97
135
  else changed the item first. A retry would *succeed*, by overwriting a change nobody
98
136
  looked at — so what happens next is your application's decision, and usually it is
99
137
  to tell the person.
138
+ - A write the Model's rules refuse — an insert-only section, a second item in a one-item
139
+ section — is an error of `kind: 'rule'`, not a conflict: nobody else is involved, and
140
+ the same write fails the same way again.
100
141
 
101
142
  ⚠️ `update` replaces the item's data whole. Spread what you are not editing.
102
143
 
144
+ To create an entity, name each item's section:
145
+
146
+ ```js
147
+ import { createEntity } from '@uniweb/api/client'
148
+
149
+ await createEntity({ schema: '@acme/course', items: [{ section: 'course', data: { title: 'Intro' } }] })
150
+ ```
151
+
152
+ ### Errors
153
+
154
+ Every failure is an `ApiError` with a `kind`: `auth` (sign in), `unverified`, `absent`,
155
+ `forbidden` (`error.extensions.op` names what was refused), `invalid`
156
+ (`error.extensions.field` names a field the Model refused), `conflict`, `rule`,
157
+ `rate-limited`, `unavailable`, `disabled`. Branch on `kind`; `detail` is prose.
158
+
103
159
  ## The service on your machine
104
160
 
105
161
  Building against a live service is slow and puts a shared database behind your
@@ -116,9 +172,26 @@ import { createMockBackend } from '@uniweb/api/mock'
116
172
 
117
173
  export default createMockBackend({
118
174
  seed: {
119
- accounts: [{ username: 'me', password: 'me', units: ['staff'] }],
120
- schemas: { '@/session': { creatable_by: 'unit_members' } },
121
- entities: [{ uuid: 't-1', model: '@/track', data: { name: 'Main hall' }, items: [] }],
175
+ accounts: [
176
+ { username: 'me', password: 'me', operator: true }, // the site's operator
177
+ { username: 'member', password: 'member' },
178
+ ],
179
+ schemas: {
180
+ '@acme/track': {
181
+ creatable_by: 'unit_members', // the operator only; 'any_user' is the default
182
+ sections: {
183
+ track: { kind: 'single', brief: true, fields: { name: { type: 'string', required: true } } },
184
+ sessions: { kind: 'multi', fields: { title: { type: 'string', required: true } } },
185
+ },
186
+ },
187
+ },
188
+ entities: [
189
+ {
190
+ uuid: '01926d5e-0000-7000-8000-000000000001', // a UUID, as on the wire
191
+ model: '@acme/track',
192
+ items: [{ section: 'track', data: { name: 'Main hall' } }],
193
+ },
194
+ ],
122
195
  },
123
196
  }).fetch
124
197
  ```
@@ -126,9 +199,15 @@ export default createMockBackend({
126
199
  `uniweb dev` mounts it at your `api:` address — same origin, so cookies behave as they
127
200
  will in production, and your site's configuration is identical either way.
128
201
 
129
- It **enforces** what your schemas declare — who may create entries, and which sections
130
- are insert-only — so a permission you are relying on fails here rather than in front
131
- of a user. State is in memory: restart to reset.
202
+ ⭐ **The mock answers what the backend answers** — the same statuses, bodies and
203
+ refusals, for every route this package uses. It enforces what your schemas declare —
204
+ who may create entries, which sections are insert-only, the fields a section takes —
205
+ and it is as strict as the backend about requests, so a mistake fails here rather
206
+ than in front of a user. Members read and write their own entities and nothing of each
207
+ other's; the operator reads and writes everything (`memberFloor: 'read'` in the seed
208
+ models a service set up to let members read each other's). A new sign-up must be
209
+ verified: the link is in `mock.outbox` (the standalone server prints it). State is in
210
+ memory: restart to reset.
132
211
 
133
212
  There is also a standalone server, for a frontend that is not a Uniweb site:
134
213
 
@@ -136,9 +215,6 @@ There is also a standalone server, for a frontend that is not a Uniweb site:
136
215
  npx uniweb-api-mock --port 8787
137
216
  ```
138
217
 
139
- ⛔ **The mock is a fixture of what this package expects, not a model of any real
140
- server.** Behaviour it happens to have is evidence about the mock and nothing else.
141
-
142
218
  ## Outside React
143
219
 
144
220
  `@uniweb/api/client` carries the same operations as plain functions and imports no
@@ -7,6 +7,9 @@
7
7
  * this when the frontend is not a Uniweb site, or when proxying to it.
8
8
  *
9
9
  * uniweb-api-mock [--port 8787] [--prefix /_api] [--seed ./seed.js]
10
+ *
11
+ * The site's `api` address for it is the URL it prints. Mail the backend would
12
+ * send — a sign-up's verification, a password reset — is printed here instead.
10
13
  */
11
14
  import { createMockBackend } from '../src/mock/index.js'
12
15
  import { serve } from '../src/mock/node.js'
@@ -21,10 +24,25 @@ const seedPath = flag('seed', null)
21
24
  const seed = seedPath ? (await import(new URL(seedPath, `file://${process.cwd()}/`).href)).default : undefined
22
25
 
23
26
  const mock = createMockBackend({ ...(seed ? { seed } : {}) })
24
- const server = await serve(mock, { port: Number(flag('port', 8787)), prefix: flag('prefix', '') })
27
+ const prefix = flag('prefix', '')
28
+
29
+ // Print each message the moment it is "sent", with what finishes it.
30
+ let printed = 0
31
+ const serving = {
32
+ fetch: async (request) => {
33
+ const response = await mock.fetch(request)
34
+ for (const message of mock.outbox.slice(printed)) {
35
+ console.log(` ✉ ${message.subject} → ${message.to}`)
36
+ console.log(message.verify ? ` verify: GET ${prefix}${message.verify}` : ` token: ${message.token}`)
37
+ }
38
+ printed = mock.outbox.length
39
+ return response
40
+ },
41
+ }
42
+ const server = await serve(serving, { port: Number(flag('port', 8787)), prefix })
25
43
 
26
44
  console.log(`uniweb-api-mock listening on ${server.url}`)
27
- console.log(` accounts: ${mock.store.accounts.map((a) => a.username).join(', ')}`)
45
+ console.log(` accounts: ${mock.store.accounts.map((a) => `${a.username}${a.operator ? ' (operator)' : ''}`).join(', ')}`)
28
46
  console.log(' state is in memory — restart to reset')
29
47
 
30
48
  for (const signal of ['SIGINT', 'SIGTERM']) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/api",
3
- "version": "0.3.7",
3
+ "version": "0.4.1",
4
4
  "description": "The client for a Uniweb site's own backend — session, records, entities — imported by a foundation the way it imports @uniweb/kit.",
5
5
  "type": "module",
6
6
  "exports": {