@uniweb/api 0.3.7 → 0.4.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/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,49 @@ 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.** The default `scope` is everything the viewer may read, and on a
87
+ site's `api` service **members can read each other's entities**. For "my submissions",
88
+ "my progress", pass `scope: 'mine'`.
89
+
90
+ A record is a summary: `record.brief` holds the fields of the Model's brief section. It
91
+ carries no items — read the entity for those. `hasMore` is true when a page came back
92
+ full (`limit`, default 50); `matched` counts the records in this answer, not a total.
93
+ `all: true` reads the whole list in one request.
94
+
95
+ ```jsx
96
+ const { status, entity } = useEntity({ schema: '@acme/lesson', uuid, via: course.uuid })
97
+ // entity.hydrated.items — the content: { id, section_id, data, … } each
98
+ // entity.hydrated.entity.brief — the summary
99
+ // entity.can_edit — whether this viewer may write to it
100
+ ```
101
+
102
+ Its `absent` covers both not-found and not-permitted, on purpose: render your paywall
103
+ or sign-in prompt on it and never say "deleted".
73
104
 
74
105
  ## Writing
75
106
 
107
+ An entity's content is **items, each in a section** of its Model. A section that holds
108
+ one item (the brief) takes one `create` and is `update`d after that; a many-item section
109
+ takes as many as you add.
110
+
76
111
  ```jsx
77
112
  import { useEntityWriter } from '@uniweb/api'
78
113
 
79
- const programme = useEntityWriter({ schema: '@/track', uuid: track.uuid })
114
+ const programme = useEntityWriter({ schema: '@acme/track', uuid: track.uuid })
80
115
 
81
116
  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)
117
+ await programme.update(item.id, { ...item.data, room: 'Hall A' })
118
+ await programme.move(item.id, { after: other.id })
119
+ await programme.remove(item.id)
85
120
  await programme.batch([...]) // one transaction: all of them, or none
86
121
  ```
87
122
 
88
- Three things it does for you, and one it deliberately does not:
123
+ `item.id` is the `id` of an item on a read. Name a section as the Model does — the
124
+ package resolves it to what the backend's item route takes.
89
125
 
90
- - **Concurrency is handled.** Every write carries the item's last-seen version and the
91
- response updates it. You never touch a token.
126
+ - **Concurrency is handled.** Every write carries the version of the item the viewer
127
+ last saw — from a read or from the previous write — and the response updates it. You
128
+ never touch a token.
92
129
  - **`section` is required on `create`.** An entity has several, and a rule declared on
93
130
  one — insert-only, say — does not reach an item that landed in another.
94
131
  - **A successful write refreshes what it changed**, so a list you are showing reflects
@@ -97,9 +134,27 @@ Three things it does for you, and one it deliberately does not:
97
134
  else changed the item first. A retry would *succeed*, by overwriting a change nobody
98
135
  looked at — so what happens next is your application's decision, and usually it is
99
136
  to tell the person.
137
+ - A write the Model's rules refuse — an insert-only section, a second item in a one-item
138
+ section — is an error of `kind: 'rule'`, not a conflict: nobody else is involved, and
139
+ the same write fails the same way again.
100
140
 
101
141
  ⚠️ `update` replaces the item's data whole. Spread what you are not editing.
102
142
 
143
+ To create an entity, name each item's section:
144
+
145
+ ```js
146
+ import { createEntity } from '@uniweb/api/client'
147
+
148
+ await createEntity({ schema: '@acme/course', items: [{ section: 'course', data: { title: 'Intro' } }] })
149
+ ```
150
+
151
+ ### Errors
152
+
153
+ Every failure is an `ApiError` with a `kind`: `auth` (sign in), `unverified`, `absent`,
154
+ `forbidden` (`error.extensions.op` names what was refused), `invalid`
155
+ (`error.extensions.field` names a field the Model refused), `conflict`, `rule`,
156
+ `rate-limited`, `unavailable`, `disabled`. Branch on `kind`; `detail` is prose.
157
+
103
158
  ## The service on your machine
104
159
 
105
160
  Building against a live service is slow and puts a shared database behind your
@@ -116,9 +171,26 @@ import { createMockBackend } from '@uniweb/api/mock'
116
171
 
117
172
  export default createMockBackend({
118
173
  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: [] }],
174
+ accounts: [
175
+ { username: 'me', password: 'me', operator: true }, // the site's operator
176
+ { username: 'member', password: 'member' },
177
+ ],
178
+ schemas: {
179
+ '@acme/track': {
180
+ creatable_by: 'unit_members', // the operator only; 'any_user' is the default
181
+ sections: {
182
+ track: { kind: 'single', brief: true, fields: { name: { type: 'string', required: true } } },
183
+ sessions: { kind: 'multi', fields: { title: { type: 'string', required: true } } },
184
+ },
185
+ },
186
+ },
187
+ entities: [
188
+ {
189
+ uuid: '01926d5e-0000-7000-8000-000000000001', // a UUID, as on the wire
190
+ model: '@acme/track',
191
+ items: [{ section: 'track', data: { name: 'Main hall' } }],
192
+ },
193
+ ],
122
194
  },
123
195
  }).fetch
124
196
  ```
@@ -126,9 +198,13 @@ export default createMockBackend({
126
198
  `uniweb dev` mounts it at your `api:` address — same origin, so cookies behave as they
127
199
  will in production, and your site's configuration is identical either way.
128
200
 
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.
201
+ ⭐ **The mock answers what the backend answers** — the same statuses, bodies and
202
+ refusals, for every route this package uses. It enforces what your schemas declare —
203
+ who may create entries, which sections are insert-only, the fields a section takes —
204
+ and it is as strict as the backend about requests, so a mistake fails here rather
205
+ than in front of a user. Members read each other's entities and write their own; the
206
+ operator writes everything. A new sign-up must be verified: the link is in
207
+ `mock.outbox` (the standalone server prints it). State is in memory: restart to reset.
132
208
 
133
209
  There is also a standalone server, for a frontend that is not a Uniweb site:
134
210
 
@@ -136,9 +212,6 @@ There is also a standalone server, for a frontend that is not a Uniweb site:
136
212
  npx uniweb-api-mock --port 8787
137
213
  ```
138
214
 
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
215
  ## Outside React
143
216
 
144
217
  `@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.0",
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": {