@nxgt/janus 0.23.1 → 0.24.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.
Files changed (50) hide show
  1. package/README.md +96 -12
  2. package/dist/auth/email-flows/prove-first.d.ts +23 -1
  3. package/dist/auth/email-flows/prove-first.d.ts.map +1 -1
  4. package/dist/auth/index.d.ts +1 -1
  5. package/dist/auth/index.d.ts.map +1 -1
  6. package/dist/auth/outage.d.ts +15 -4
  7. package/dist/auth/outage.d.ts.map +1 -1
  8. package/dist/auth/types/email-flows.d.ts +13 -2
  9. package/dist/auth/types/email-flows.d.ts.map +1 -1
  10. package/dist/auth/types/find-or-create.d.ts +59 -0
  11. package/dist/auth/types/find-or-create.d.ts.map +1 -0
  12. package/dist/auth/types/index.d.ts +1 -0
  13. package/dist/auth/types/index.d.ts.map +1 -1
  14. package/dist/auth/types/janus.d.ts +2 -1
  15. package/dist/auth/types/janus.d.ts.map +1 -1
  16. package/dist/auth/users/any-type-api.d.ts +2 -2
  17. package/dist/auth/users/any-type-api.d.ts.map +1 -1
  18. package/dist/auth/users/find-or-create.d.ts +12 -0
  19. package/dist/auth/users/find-or-create.d.ts.map +1 -0
  20. package/dist/auth/users/insert.d.ts +8 -3
  21. package/dist/auth/users/insert.d.ts.map +1 -1
  22. package/dist/auth/users/type-api.d.ts +1 -1
  23. package/dist/auth/users/type-api.d.ts.map +1 -1
  24. package/dist/index.js +149 -36
  25. package/dist/index.js.map +8 -7
  26. package/dist/permissions/engine.d.ts.map +1 -1
  27. package/dist/permissions/index.d.ts +1 -1
  28. package/dist/permissions/index.d.ts.map +1 -1
  29. package/dist/permissions/index.js +49 -16
  30. package/dist/permissions/index.js.map +6 -5
  31. package/dist/permissions/input.d.ts +13 -1
  32. package/dist/permissions/input.d.ts.map +1 -1
  33. package/dist/permissions/model/grant.d.ts +14 -1
  34. package/dist/permissions/model/grant.d.ts.map +1 -1
  35. package/dist/permissions/model/permissions.d.ts +13 -1
  36. package/dist/permissions/model/permissions.d.ts.map +1 -1
  37. package/dist/permissions/port/types.d.ts +8 -2
  38. package/dist/permissions/port/types.d.ts.map +1 -1
  39. package/dist/permissions/replace.d.ts +25 -0
  40. package/dist/permissions/replace.d.ts.map +1 -0
  41. package/docs/README.md +2 -2
  42. package/docs/guide/email-flows.md +33 -3
  43. package/docs/guide/errors.md +1 -1
  44. package/docs/guide/permissions.md +42 -1
  45. package/docs/guide/sessions.md +6 -0
  46. package/docs/guide/users.md +44 -0
  47. package/docs/guide/vocabulary.md +1 -1
  48. package/docs/roadmap.md +25 -30
  49. package/docs/troubleshooting.md +44 -15
  50. package/package.json +1 -1
@@ -182,7 +182,8 @@ confirms read it: `emailVerified`.
182
182
  **Call it before `setPassword` and before `signInProven`.** It drops the
183
183
  password and revokes every session, so in the other order it erases the
184
184
  password the invitee just chose, and signs out the session just opened.
185
- Accepting an invitation is `findByEmail` (or `create`), `proveFirst`,
185
+ Accepting an invitation is `findByEmail` (or `create`; `findOrCreate` does both
186
+ in one call), `proveFirst`,
186
187
  `setPassword` for a type with a password, then `signInProven` —
187
188
  [sessions](sessions.md#accepting-an-invitation) has the route in full.
188
189
 
@@ -195,8 +196,37 @@ whoever chooses it can drop anybody's password and second factor.
195
196
  It sends no e-mail, issues no token, and counts in neither the mail
196
197
  throttle nor the sign-in throttle. An inactive user is `USER_INACTIVE`; a
197
198
  malformed id, an unknown one or one of another type is `NOT_FOUND`, as for
198
- `signInProven`; a user written between the read and the write is
199
- `VERSION_CONFLICT`, and nothing is proved; an outage is `STORE_FAILED`.
199
+ `signInProven`; an outage is `STORE_FAILED`.
200
+
201
+ **A write landing between the read and the proof is retried once.** The
202
+ proof is written under the version read, so a write in between (the user
203
+ renaming themselves, a second acceptance of the same invitation) makes it
204
+ `VERSION_CONFLICT`. `proveFirst` then runs the whole flow again on a fresh
205
+ read, and what that read finds decides:
206
+
207
+ - the user is gone or of another type: `NOT_FOUND`; inactive:
208
+ `USER_INACTIVE`;
209
+ - **the e-mail is no longer the address first read, or the one your `User`
210
+ carries** (compared as e-mails
211
+ are, so a change of case or spacing is the same address): the original
212
+ `VERSION_CONFLICT`, with nothing proved: the address changed since the first
213
+ read, or is not the one the `User` you passed carries. The proof you hold
214
+ reached the inbox of the address you read;
215
+ - the e-mail was proved meanwhile: `{ user, proved: false }`;
216
+ - otherwise the proof runs under the new version, and one
217
+ `user.emailVerified` is sent.
218
+
219
+ **The address you hold is checked.** Pass the `User` you read (any object
220
+ carrying the type's e-mail field) and `proveFirst` compares that address,
221
+ normalised as e-mails are, with the one it reads, on the first read and on
222
+ the retry read: a stale `User` whose address changed is `VERSION_CONFLICT`
223
+ and nothing is proved. Pass a bare id string, or an object without the e-mail
224
+ field, and it proves the current address, whatever it is.
225
+
226
+ A second `VERSION_CONFLICT` propagates: nothing is proved, and calling
227
+ again is safe. Running the proof twice is harmless: a failed attempt wrote
228
+ nothing and sent no event, and the sessions it revoked and the tokens it
229
+ spent before the write are revoked and spent again to the same end.
200
230
  `@nxgt/janus-telemetry` traces it as `janus.<type>.verifyEmail.proveFirst`
201
231
  and writes `janus.email.verified` with `janus.email.proveFirst: true` when
202
232
  it proved — the user's id, never the address.
@@ -51,7 +51,7 @@ that wired the library, so no handler needs to tell it apart.
51
51
  | `STORE_FAILED` | `StoreFailure` | 503 | A store could not answer. **Never a negative answer** | `slot`, `operation`, `cause` |
52
52
  | `NOT_FOUND` | `NotFoundError` | 404 | `get`, `getUser`, `signInProven`, `verifyEmail.proveFirst`, or a write to a user who is gone. `find*` answers `null` instead | `userId` |
53
53
  | `LOGIN_TAKEN` | `StoreConflict` (`on: 'login'`) | 409 | Another user of the same type holds the login — on `changeEmail.confirm`, the new address | `login`, `userType` |
54
- | `VERSION_CONFLICT` | `StoreConflict` (`on: 'version'`) | 409 | `ifVersion` no longer matches; nothing was written. From `secondFactor.confirm` or `recover`, the same code used by two sign-ins at once: the other opened the session. From `verifyEmail.proveFirst`, the user was written between its read and its write: nothing was proved — call it again | `expectedVersion`, `actualVersion` |
54
+ | `VERSION_CONFLICT` | `StoreConflict` (`on: 'version'`) | 409 | `ifVersion` no longer matches; nothing was written. From `secondFactor.confirm` or `recover`, the same code used by two sign-ins at once: the other opened the session. From `verifyEmail.proveFirst`, which already retried once: the user was written again during the retry, or the write changed the e-mail address; nothing was proved | `expectedVersion`, `actualVersion` |
55
55
  | `USER_INVALID` | `UserInvalidError` | 400 | The fields failed the schema | `issues`, field by field |
56
56
  | `PASSWORD_TOO_SHORT` | `CredentialError` | 400 | Below `password.minLength` | `minLength` — never the password |
57
57
  | `CREDENTIALS_INVALID` | `CredentialError` | 401 | Unknown login, no password, or the wrong one — **one code for the three** — and any password, the right one included, at a login past its attempts in the window — see [throttling](passwords.md#password-guessing-is-throttled) | `reason`, for your logs only; `retryAfter`, the seconds until the next window, when throttled |
@@ -1,7 +1,7 @@
1
1
  # Permissions — `@nxgt/janus/permissions`
2
2
 
3
3
  This page is for modelling who may do what, and asking: `defineModel`,
4
- `fromField`, `when`, then `can`, `list`, `grant` and `revoke`. It is
4
+ `fromField`, `when`, then `can`, `list`, `grant`, `revoke` and `replace`. It is
5
5
  Zanzibar's model — relations between objects and subjects, permissions
6
6
  computed from them — **without its infrastructure**: the tuples live in your
7
7
  database, so a read follows a write and there is nothing to cache.
@@ -544,6 +544,47 @@ await relations.write({
544
544
  Narrowing a model therefore hides the tuples it no longer admits; it does not
545
545
  delete them, and widening it again brings them back.
546
546
 
547
+ ### `replace`
548
+
549
+ ```ts
550
+ replace(object, relation, subjects): Promise<{ added: RelationTuple[]; removed: RelationTuple[] }>;
551
+ ```
552
+
553
+ Makes `subjects` the only holders of `relation` on `object`: the use is
554
+ keeping a group's members equal to a list the application already holds.
555
+ `subjects` is typed as `grant`'s `subject`, as an array. It answers the
556
+ tuples it added and the tuples it removed, `{ object, relation, subject }`
557
+ each, in the order written, so the application can audit or mirror them.
558
+
559
+ ```ts
560
+ const wanted = await db.staff.membersOf('cardiology');
561
+ const { added, removed } = await access.replace(cardiology, 'members', wanted);
562
+ // added[0] is { object: cardiology, relation: 'members', subject: … }
563
+
564
+ await access.replace(cardiology, 'members', []); // removes every holder
565
+ ```
566
+
567
+ - Every subject is checked first, as `grant()` checks it, and one refusal
568
+ anywhere in the list is a `TypeError` that writes nothing. An empty list
569
+ still names an object and a stored relation, and those are checked.
570
+ - The current holders are read (`findEntities` and `findSubjectSets`), and the
571
+ difference is one `write`: removals and additions together, all or nothing
572
+ as the port requires, and no write when both are empty.
573
+ - A holder is told apart by its type, id and, for a subject set, its relation:
574
+ `team:t2#members` is not the entity `team:t2`. A subject listed twice counts
575
+ once.
576
+ - Tuples of other relations, and of other objects, are never touched. A tuple
577
+ stored past the model (an older model, or by hand) that the list does not
578
+ name is removed like any other holder.
579
+ - A failure is `STORE_FAILED`, as for `grant()`.
580
+ - It reads every holder of the relation, so a relation store must answer them
581
+ all through `findEntities` and `findSubjectSets` and never cap.
582
+
583
+ **Two concurrent calls on the same object and relation are not serialised.**
584
+ Each reads, then writes its own diff, so the result can hold the union of both
585
+ adds. A caller that needs one winner serialises them itself, with a lock or a
586
+ queue of its own.
587
+
547
588
  ### Deleting
548
589
 
549
590
  Wire the relation store into `janus({ relations })`, as the clinic does, and
@@ -264,6 +264,12 @@ export async function accept(request: Request): Promise<Response> {
264
264
  }
265
265
  ```
266
266
 
267
+ **`findOrCreate` is step 1 in one call.** `auth.findOrCreate(invitation.email, () => ({ … }))`
268
+ finds the holder or creates the user, losing no race to a second acceptance
269
+ of the same invitation, and its `input` may throw to refuse — "a password is
270
+ required" — before anything is written
271
+ ([users](users.md#finding-a-user-or-creating-them--findorcreate)).
272
+
267
273
  **The order is the recipe.** `verifyEmail.proveFirst` (since 0.22) applies
268
274
  the first proof of the e-mail that a sign-in link would: on an address
269
275
  never proved, somebody may have signed up with it first, with a password
@@ -203,6 +203,7 @@ With an e-mail — the field `email` names, password or not — besides:
203
203
  | Method | Answers | Rejects with |
204
204
  | --- | --- | --- |
205
205
  | `findByEmail(email)` | the user, or `null`; the address trimmed and lowercased as the e-mail flows do, whatever the login; a value that is no address is nobody's — **server-side only**, see [below](#finding-a-user-by-e-mail--findbyemail) | `STORE_FAILED` only |
206
+ | `findOrCreate(key, input)` | `{ user, created }`: the holder of `key`, or the user `input()` describes, created; every type with a login or an e-mail — **server-side only**, see [below](#finding-a-user-or-creating-them--findorcreate) | `USER_INVALID`, `PASSWORD_TOO_SHORT`, `LOGIN_TAKEN`, `STORE_FAILED`, and what `input` throws |
206
207
 
207
208
  With `secondFactor` configured, a type with a password also answers
208
209
  `secondFactor.enroll`, `activate`, `disable` and `confirm` — see
@@ -370,6 +371,49 @@ none by design.
370
371
  `@nxgt/janus-telemetry` traces it as `janus.<type>.findByEmail`, with the
371
372
  `user.id` it found and never the address.
372
373
 
374
+ ### Finding a user, or creating them — `findOrCreate`
375
+
376
+ ```ts
377
+ findOrCreate(
378
+ key: string,
379
+ input: () => Fields | Promise<Fields>, // what create takes
380
+ ): Promise<{ user: User; created: boolean }>
381
+ // typed by FindOrCreateApi<User, Fields>, exported from @nxgt/janus
382
+ ```
383
+
384
+ The accept step of an invitation, without the race between `findByEmail`
385
+ and `create`. The `key` is looked up as the type's own lookup would: the
386
+ login on a type with a password, the e-mail on a type without one — so a
387
+ type with neither has no `findOrCreate`. A user found, inactive or not, is
388
+ answered `{ user, created: false }` and `input` is never called:
389
+
390
+ ```ts
391
+ const invitation = await invitations.spend(hash(token), new Date()); // your proof, verified first
392
+ if (invitation === null) return notFound();
393
+ const { user, created } = await clinic.patient.findOrCreate(invitation.email, () => {
394
+ if (body.password === undefined) throw new Error('a password is required'); // refused before any write
395
+ return { email: invitation.email, birthDate: body.birthDate }; // no password: proveFirst would drop it
396
+ });
397
+ await clinic.patient.verifyEmail.proveFirst(user); // drops what a squatter left
398
+ if (typeof body.password === 'string') await clinic.patient.setPassword(user, body.password); // set once, here
399
+ ```
400
+
401
+ - **`input` runs only on an absence, and may refuse.** What it throws
402
+ propagates, and nothing is written.
403
+ - **The key is a string.** Any other value is `USER_INVALID` before `input`
404
+ is called.
405
+ - **The input names the key.** After validation its login (its e-mail, on a
406
+ type with no password), normalised, must equal the normalised `key`; if it
407
+ does not, the call rejects with `USER_INVALID` before the write.
408
+ - **Created exactly as `create` creates**: validation, hashing, the password
409
+ policy, `user.created`. The event is sent only when this call created.
410
+ - **A lost race answers the winner.** If the write meets `LOGIN_TAKEN`, the
411
+ key is read once more: found, it is `{ user, created: false }`; still
412
+ absent, the `LOGIN_TAKEN` is rethrown.
413
+ - **A key that is no storable string** is nobody's, as for `findByLogin`, and
414
+ goes through `input` and the creation, which refuses it with `USER_INVALID`.
415
+ - **Server-side only**, an account-existence oracle like `findByEmail`.
416
+
373
417
  ### Paging every user
374
418
 
375
419
  ```ts
@@ -30,7 +30,7 @@ either finds this row.
30
30
  | --- | --- | --- |
31
31
  | **side** | One of the two things Janus does, each usable alone: identities or permissions | "half", "module" |
32
32
  | **identities** | The side that answers *who is this?* — users, logins, passwords, sessions, one-time tokens. `janus()`, from `@nxgt/janus` | "auth", "authentication", in prose; `auth` is only the variable name in examples |
33
- | **permissions** | The side that answers *may they?* — a model, the tuples stored against it, `can`, `list`, `grant`, `revoke`. `@nxgt/janus/permissions` | "authorization", "access control", "ACL" |
33
+ | **permissions** | The side that answers *may they?* — a model, the tuples stored against it, `can`, `list`, `grant`, `revoke`, `replace`. `@nxgt/janus/permissions` | "authorization", "access control", "ACL" |
34
34
 
35
35
  ### Identities
36
36
 
package/docs/roadmap.md CHANGED
@@ -76,6 +76,31 @@ Nothing yet.
76
76
  The last ten, newest first, each with the version it came in. Everything
77
77
  before is in the [CHANGELOG](../CHANGELOG.md).
78
78
 
79
+ - **Finding a user, or creating them, v0.24.0** —
80
+ `auth.<type>.findOrCreate(key, input)` answers `{ user, created }`: the
81
+ holder of the key — the login on a type with a password, the e-mail on one
82
+ without — or the user `input()` describes, created as `create` creates
83
+ one. `input` runs only on an absence and may refuse before any write; it
84
+ must name the key, or the call is `USER_INVALID`; a lost race is
85
+ answered with the winner, `created: false`. It is the accept step of an
86
+ invitation in one call. Server-side only.
87
+ - **`replace()` on permissions, v0.24.0** — `access.replace(object, relation,
88
+ subjects)` makes a list of subjects the only holders of a relation, in one
89
+ write, and answers `{ added, removed }`, the tuples added and the tuples
90
+ removed: the call that keeps a group's members equal to a list the
91
+ application holds, and what `@nxgt/janus-telemetry` traces as a `janus.replace`
92
+ span with one audit event per tuple. Concurrent calls on the same
93
+ object and relation are not serialised.
94
+ - **Retrying `verifyEmail.proveFirst` once on a version conflict, v0.24.0** — a write
95
+ landing between the read and the proof no longer fails the call: it reads
96
+ the user again and proves under the new version, answers `proved: false`
97
+ when that write proved the e-mail, and keeps `VERSION_CONFLICT` when the
98
+ address changed since the first read, or is not the one the passed `User`
99
+ carries (the proof the caller holds is for the address it read), or when
100
+ the retry conflicts too. Given a `User`, it also compares the address
101
+ that `User` carries with the one it reads, on the first read and on the
102
+ retry: a stale `User` whose address changed is `VERSION_CONFLICT`, with
103
+ nothing proved. Given a bare id, it proves the current address.
79
104
  - **Changing a user's e-mail, v0.23.0** —
80
105
  `auth.<type>.changeEmail.send(user, email)` issues a link for the new
81
106
  address, and `changeEmail.confirm(token)` moves the user there, verified,
@@ -140,33 +165,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
140
165
  The window is no longer than the shortest default token lifetime. The counts
141
166
  live in the tokens store, with no port change. `MailConfig` and
142
167
  `MailThrottleConfig` are exported.
143
- - **A sign-in from a new device, v0.17.0** — `janus({ devices: { keys } })`,
144
- and `{ device }` as the last argument of `signUp`, `signIn`,
145
- `secondFactor.confirm`, `secondFactor.recover`, `signInCode.confirm` and
146
- `magicLink.confirm`: the device token the client holds, or `null`. Every
147
- answer that opens a session says `newDevice` and hands back a
148
- `deviceToken` for a long-lived cookie, and a new device sends
149
- `user.newDeviceSignedIn`, with the new session's `sessionId`, once the
150
- sign-in is complete. Nothing is stored: the token is a device id signed
151
- for the user, so the store ports are unchanged. A sign-up is never new, a
152
- call given no device is not tracked, and removing a key forgets every
153
- device it signed. Breaking for a `switch` that exhausts `UserEventType`,
154
- and for a `SignedIn` built by hand. See [devices](guide/devices.md).
155
- - **The first sign-in by link or code ends a squatter's password and second
156
- factor, v0.16.0** — when `signInCode.confirm` or `magicLink.confirm`
157
- proves an e-mail never verified, the password and the second factor with
158
- its recovery codes are dropped and every session signed out before the
159
- new one opens: whoever registered the address without its inbox keeps
160
- nothing, and the owner is asked for no factor they never set.
161
- `user.passwordChanged` and `user.secondFactorDisabled` report what was
162
- dropped. An e-mail already verified changes nothing.
163
- - **Sign in with an e-mailed link (magic link), v0.15.0** —
164
- `auth.magicLink.request(email)` and `magicLink.confirm(token)` on every
165
- user type with an e-mail, with or without a password: the sign-in code
166
- with nothing to type. `request` answers `null` for nobody and for an
167
- inactive user alike; only the last link sent works; a link lives fifteen
168
- minutes (`tokens.magicLink`) and is spent by its first `confirm`, which
169
- proves the e-mail and still asks for an active second factor. Confirmed
170
- from a `POST`, so a mail scanner that opens the link spends nothing.
171
- **Breaking for an adapter: `TokenKind` gains `'magicLink'`**, which a
172
- `CHECK` or a validator's enum must list.
@@ -151,7 +151,10 @@ How the messages are shaped:
151
151
  - [`list: the lookup of <type>.<relation> must answer an array of ids`](#list-the-lookup-of-typerelation-must-answer-an-array-of-ids)
152
152
  - [`grant: <type>.<relation> is read from <field>; there is nothing to store …`](#grant-typerelation-is-read-from-field-there-is-nothing-to-store--change-the-type-instead)
153
153
  - [`grant: <type>.<relation> is not held by <holder>`](#grant-typerelation-is-not-held-by-holder)
154
- - [Other `can:`, `list:`, `grant:` and `revoke:` messages](#other-can-list-grant-and-revoke-messages)
154
+ - [`replace: the subjects must be an array`](#replace-the-subjects-must-be-an-array)
155
+ - [Two `replace` calls at once leave the union of their adds](#two-replace-calls-at-once-leave-the-union-of-their-adds)
156
+ - [`replace` leaves extra holders on a capped relation store](#replace-leaves-extra-holders-on-a-capped-relation-store)
157
+ - [Other `can:`, `list:`, `grant:`, `revoke:` and `replace:` messages](#other-can-list-grant-revoke-and-replace-messages)
155
158
  - [`defineModel: …`](#definemodel-)
156
159
 
157
160
  **Subjects**
@@ -625,7 +628,7 @@ const user = await auth.find(id); // null when there is nobody
625
628
 
626
629
  `StoreConflict` with `on: 'login'`, carrying `login` and `userType`. The login is not in the message, which never carries a value: an e-mail in a log line is personal data. Read it from `error.login`.
627
630
 
628
- **When:** `signUp`, `create`, or an `update` that changes the login, when another user of **the same type** holds it after normalisation (`Ada@Example.com` and `ada@example.com` collide by default).
631
+ **When:** `signUp`, `create`, or an `update` that changes the login, when another user of **the same type** holds it after normalisation (`Ada@Example.com` and `ada@example.com` collide by default). `findOrCreate` rethrows it only when its re-read of the key still finds nobody — another login of the new user (its e-mail, beside a username) is taken; a race lost on the key itself answers `{ user, created: false }` instead.
629
632
  **Why:** the store's unique constraint refused the write. A login is unique per user type: one e-mail may hold a patient user and a staff user.
630
633
  **On `changeEmail.confirm`** the address was free when `changeEmail.send` asked — `send` never checks — and somebody took it before the link was used. The link is already spent and nothing was written: the user keeps their e-mail.
631
634
  **Fix:** answer 409. On `changeEmail.confirm`, tell the user the address is taken and ask for another with a new `changeEmail.send`. If two concurrent sign-ups with one login both succeed, the unique index is missing — with `@nxgt/janus-mongo`, run `syncMongoStores(db)`.
@@ -643,12 +646,19 @@ const user = await auth.get(id);
643
646
  await auth.update(user, { name }, { ifVersion: user.version });
644
647
  ```
645
648
 
646
- **From `verifyEmail.proveFirst`**, with no `ifVersion` of yours: the user
647
- was written between the read that found the e-mail never proved and the
649
+ **From `verifyEmail.proveFirst`**, with no `ifVersion` of yours: either the
650
+ `User` you passed carries an address that is no longer the user's (nothing is
651
+ proved, on the first read already; a bare id skips this check and proves the
652
+ current address), or the user was written between the read that found the e-mail never proved and the
648
653
  write that proves it — by the user, or by a second acceptance of the same
649
- invitation. Nothing was proved, though the sessions may already be revoked.
650
- Call it again: it reads the user anew, and answers `proved: false` when the
651
- other write proved the e-mail.
654
+ invitation. `proveFirst` already retried once on a fresh read, so you see
655
+ this error only when the user was written **again** during the retry, or when
656
+ the write **changed the e-mail address** since the first read (or it is not
657
+ the one the `User` you passed carries): the proof you hold is for the
658
+ address you read. Nothing was proved, though
659
+ the sessions may already be revoked. Read the user and check that their
660
+ e-mail is still the invitation's address; if it is, call it again — it
661
+ answers `proved: false` when another write proved the e-mail.
652
662
 
653
663
  **From `secondFactor.confirm`**, the message is the store's:
654
664
  `updateUser: expected version <n>, found <m>`.
@@ -694,8 +704,9 @@ does the first.
694
704
 
695
705
  `UserInvalidError`, carrying `issues` — each a `path` and a `message`.
696
706
 
697
- **When:** `signUp`, `create`, `update`, and `changeEmail.send`.
707
+ **When:** `signUp`, `create`, `update`, `changeEmail.send`, and `findOrCreate`.
698
708
  **Why:** your schema refused the fields. `changeEmail.send` also answers it, with one issue at the e-mail field (`is the current e-mail of the user`), for an address equal to the user's current one once normalised: nothing is counted or issued; answer 400, or offer `verifyEmail.send` to prove the current one. `update` merges the patch over the stored fields and validates the whole, so the refusal can name a field the patch did not touch. An issue whose message is `set by janus, not by a request` means the input carried a field janus sets (`id`, `active`, `version`, …) and your schema let it through. An issue whose message is `holds a NUL character or a lone surrogate, which no store can keep` means a string — or, with `a key holds …`, an object key — carried `\u0000` or half of a surrogate pair: PostgreSQL refuses both, so janus refuses them on every adapter rather than answer `STORE_FAILED` on one. A key is reported by the path of the object holding it, never by the key itself — in janus's issues and in your schema's alike. An issue at your login field whose message is `normalises to a login that holds …` means your own `password.normalize` function produced such a login from a valid field: fix the function. On MongoDB or the memory store, a user written before janus refused these characters may already hold one: every `update` of that user is refused, whatever it patches, until the field is cleaned — patch it with a clean value in the same `update`.
709
+ **`findOrCreate`** also answers it, with one issue at the login field (the e-mail field on a type with no password) and the message `is not the key findOrCreate was given`, when the validated input names another key than the one asked for; nothing is written. A username type given an e-mail nobody holds always meets it, since the input's username cannot be that e-mail. Fix: build the input from the same value as the key. A key that is not a string is answered before `input()` runs, with one issue at the empty path and the message `is not a string`: pass the login or the e-mail as a string.
699
710
  **Fix:** answer 400, field by field:
700
711
 
701
712
  ```ts
@@ -2009,22 +2020,40 @@ A `lookup` is your code and is not guarded: if it throws, `list()` rejects with
2009
2020
 
2010
2021
  ### `grant: <type>.<relation> is read from <field>; there is nothing to store — change the <type> instead`
2011
2022
 
2012
- Also with `revoke:`.
2023
+ Also with `revoke:` and `replace:`.
2013
2024
 
2014
- **When:** `access.grant(...)` or `access.revoke(...)` on a `fromField` relation.
2025
+ **When:** `access.grant(...)`, `access.revoke(...)` or `access.replace(...)` on a `fromField` relation.
2015
2026
  **Why:** that relation lives in the object's own data, not in a tuple.
2016
2027
  **Fix:** update the object — for example set `record.doctorId` — in your own database.
2017
2028
 
2018
2029
  ### `grant: <type>.<relation> is not held by <holder>`
2019
2030
 
2020
- Also with `revoke:`.
2031
+ Also with `revoke:` and `replace:`.
2021
2032
 
2022
- **When:** `access.grant(object, relation, subject)`.
2033
+ **When:** `access.grant(object, relation, subject)`, or any subject in the list of `access.replace(object, relation, subjects)`; nothing is written.
2023
2034
  **Why:** the model's relation does not admit that kind of subject: `members: ['staff']` refuses a `patient`, and refuses the subject set `team#members` unless it is listed.
2024
2035
  **Fix:** grant a subject the relation admits, or add the holder to the model: `members: ['staff', 'team#members']`.
2025
2036
  With `revoke:` on a tuple stored before the model stopped admitting it, the tuple already grants nothing; remove it through the store: `relations.write({ remove: [tuple] })`.
2026
2037
 
2027
- ### Other `can:`, `list:`, `grant:` and `revoke:` messages
2038
+ ### `replace: the subjects must be an array`
2039
+
2040
+ **When:** `access.replace(object, relation, subject)` with one subject instead of a list.
2041
+ **Why:** `replace` makes the list the only holders of the relation, so it takes the whole list.
2042
+ **Fix:** wrap the subject, `access.replace(team, 'members', [ada])`; `[]` empties the relation. See [`replace`](guide/permissions.md#replace).
2043
+
2044
+ ### Two `replace` calls at once leave the union of their adds
2045
+
2046
+ **When:** two `replace` calls on the same object and relation run concurrently.
2047
+ **Why:** each reads the holders, then writes its own difference; they are not serialised, so both lists' additions can remain.
2048
+ **Fix:** serialise them yourself (a lock or a queue) when one list must win. See [`replace`](guide/permissions.md#replace).
2049
+
2050
+ ### `replace` leaves extra holders on a capped relation store
2051
+
2052
+ **When:** you wrote a `RelationStore` adapter, and `access.replace(object, relation, subjects)` leaves holders you meant to remove, silently.
2053
+ **Why:** `replace` reads every holder of the relation through `findEntities` and `findSubjectSets` and removes those the list does not name. An adapter that caps either answer (a `LIMIT`, a first page) hides the rest from it, so they are never removed.
2054
+ **Fix:** answer every holder from both methods, unpaged, and never cap. The relation-store conformance suite is the place to check an adapter.
2055
+
2056
+ ### Other `can:`, `list:`, `grant:`, `revoke:` and `replace:` messages
2028
2057
 
2029
2058
  Each is a `TypeError` naming the call. TypeScript refuses most of them on the argument first.
2030
2059
 
@@ -2035,10 +2064,10 @@ Each is a `TypeError` naming the call. TypeScript refuses most of them on the ar
2035
2064
  | `<call>: the object must be { type, id, …its fields }` | `{ type: 'record', ...record }`. |
2036
2065
  | `<call>: the subject must be a user, or { type, id }` | Pass the user from `janus()`, or `{ type, id }`. `null` is anonymous and answers `false`. |
2037
2066
  | `<call>: the object id must be a non-empty string without @, # or parentheses` | Also for `the subject id`. Those characters belong to the tuple notation. |
2038
- | `grant: the object id holds a NUL character or a lone surrogate, which no store can keep` | Also with `revoke:`, and for `the subject id`. PostgreSQL refuses `\u0000` and half a surrogate pair, so janus refuses them on every adapter: clean the id where it was read. `can()` answers `false` and `list()` an empty page for such an id instead — no tuple can name it. |
2067
+ | `grant: the object id holds a NUL character or a lone surrogate, which no store can keep` | Also with `revoke:` and `replace:`, and for `the subject id`. PostgreSQL refuses `\u0000` and half a surrogate pair, so janus refuses them on every adapter: clean the id where it was read. `can()` answers `false` and `list()` an empty page for such an id instead — no tuple can name it. |
2039
2068
  | `<call>: "<relation>" is not a relation of <type>, so <type>#<relation> is no subject set` | A subject set names a relation of its type: `{ type: 'team', id, relation: 'members' }`. |
2040
2069
  | `<call>: <type> is a user type the model does not declare as an object type, so <type>#<relation> is no subject set` | `setOf(user, relation)` names a relation on that user: declare the user type under `types` too, with that relation — see [permissions on a user](guide/permissions.md#permissions-on-a-user). The compiler refuses it first. |
2041
- | `grant: "<relation>" is not a relation of <type>` | Grant a relation, never a permission. |
2070
+ | `grant: "<relation>" is not a relation of <type>` | Grant a relation, never a permission. Also with `revoke:` and `replace:` — `replace: "<relation>" is not a relation of <type>`, even for an empty list. |
2042
2071
  | `list: the type must be an object type of the model` | The third argument is a type name: `'record'`. |
2043
2072
  | `list: after must be the nextCursor of a page, or null` | Pass `nextCursor` back as it came. |
2044
2073
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/janus",
3
- "version": "0.23.1",
3
+ "version": "0.24.0",
4
4
  "description": "Embeddable, type-safe identities and permissions: bring your own database",
5
5
  "license": "MIT",
6
6
  "type": "module",