@nxgt/janus 0.23.1 → 0.25.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 (78) hide show
  1. package/README.md +119 -21
  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 +17 -6
  7. package/dist/auth/outage.d.ts.map +1 -1
  8. package/dist/auth/port/assert-stores.d.ts.map +1 -1
  9. package/dist/auth/port/memory/tokens.d.ts.map +1 -1
  10. package/dist/auth/port/types/stores.d.ts +2 -0
  11. package/dist/auth/port/types/stores.d.ts.map +1 -1
  12. package/dist/auth/port/types/tokens.d.ts +12 -0
  13. package/dist/auth/port/types/tokens.d.ts.map +1 -1
  14. package/dist/auth/sessions/collect-expired.d.ts +14 -0
  15. package/dist/auth/sessions/collect-expired.d.ts.map +1 -0
  16. package/dist/auth/sessions/shared-api.d.ts +1 -1
  17. package/dist/auth/sessions/shared-api.d.ts.map +1 -1
  18. package/dist/auth/types/email-flows.d.ts +13 -2
  19. package/dist/auth/types/email-flows.d.ts.map +1 -1
  20. package/dist/auth/types/find-or-create.d.ts +59 -0
  21. package/dist/auth/types/find-or-create.d.ts.map +1 -0
  22. package/dist/auth/types/index.d.ts +1 -0
  23. package/dist/auth/types/index.d.ts.map +1 -1
  24. package/dist/auth/types/janus.d.ts +6 -4
  25. package/dist/auth/types/janus.d.ts.map +1 -1
  26. package/dist/auth/users/any-type-api.d.ts +2 -2
  27. package/dist/auth/users/any-type-api.d.ts.map +1 -1
  28. package/dist/auth/users/find-or-create.d.ts +12 -0
  29. package/dist/auth/users/find-or-create.d.ts.map +1 -0
  30. package/dist/auth/users/insert.d.ts +8 -3
  31. package/dist/auth/users/insert.d.ts.map +1 -1
  32. package/dist/auth/users/type-api.d.ts +1 -1
  33. package/dist/auth/users/type-api.d.ts.map +1 -1
  34. package/dist/chunks/{index-6bjfv4xv.js → index-q3w3vr69.js} +13 -3
  35. package/dist/chunks/{index-6bjfv4xv.js.map → index-q3w3vr69.js.map} +3 -3
  36. package/dist/chunks/{index-cntabs9d.js → index-rpp0bw6e.js} +2 -2
  37. package/dist/chunks/{index-kppkcbn5.js → index-spwh8b9z.js} +2 -5
  38. package/dist/chunks/{index-kppkcbn5.js.map → index-spwh8b9z.js.map} +2 -2
  39. package/dist/chunks/{index-4khsm7mw.js → index-y9b327j8.js} +18 -18
  40. package/dist/chunks/{index-4khsm7mw.js.map → index-y9b327j8.js.map} +5 -5
  41. package/dist/conformance/cases/outage.d.ts.map +1 -1
  42. package/dist/conformance/cases/tokens/deletion.d.ts.map +1 -1
  43. package/dist/conformance/describe.d.ts +1 -0
  44. package/dist/conformance/describe.d.ts.map +1 -1
  45. package/dist/conformance/index.js +39 -5
  46. package/dist/conformance/index.js.map +5 -5
  47. package/dist/conformance/types.d.ts +1 -1
  48. package/dist/conformance/types.d.ts.map +1 -1
  49. package/dist/index.js +167 -44
  50. package/dist/index.js.map +11 -9
  51. package/dist/permissions/engine.d.ts.map +1 -1
  52. package/dist/permissions/index.d.ts +1 -1
  53. package/dist/permissions/index.d.ts.map +1 -1
  54. package/dist/permissions/index.js +52 -19
  55. package/dist/permissions/index.js.map +6 -5
  56. package/dist/permissions/input.d.ts +13 -1
  57. package/dist/permissions/input.d.ts.map +1 -1
  58. package/dist/permissions/model/grant.d.ts +14 -1
  59. package/dist/permissions/model/grant.d.ts.map +1 -1
  60. package/dist/permissions/model/permissions.d.ts +13 -1
  61. package/dist/permissions/model/permissions.d.ts.map +1 -1
  62. package/dist/permissions/port/types.d.ts +8 -2
  63. package/dist/permissions/port/types.d.ts.map +1 -1
  64. package/dist/permissions/replace.d.ts +25 -0
  65. package/dist/permissions/replace.d.ts.map +1 -0
  66. package/docs/README.md +2 -2
  67. package/docs/guide/adapters.md +40 -3
  68. package/docs/guide/email-flows.md +35 -5
  69. package/docs/guide/errors.md +2 -2
  70. package/docs/guide/passwords.md +7 -10
  71. package/docs/guide/permissions.md +42 -1
  72. package/docs/guide/sessions.md +21 -4
  73. package/docs/guide/users.md +44 -0
  74. package/docs/guide/vocabulary.md +1 -1
  75. package/docs/roadmap.md +33 -38
  76. package/docs/troubleshooting.md +50 -21
  77. package/package.json +1 -1
  78. /package/dist/chunks/{index-cntabs9d.js.map → index-rpp0bw6e.js.map} +0 -0
@@ -31,7 +31,7 @@ describeJanusStores({
31
31
 
32
32
  | Port | Taken by | Methods |
33
33
  | --- | --- | --- |
34
- | `JanusStores` — `{ users: UserStore, sessions: SessionStore, tokens: TokenStore }` | `janus({ store })` | 6 (+ 1 optional) + 7 (+ 1 optional) + 5 |
34
+ | `JanusStores` — `{ users: UserStore, sessions: SessionStore, tokens: TokenStore }` | `janus({ store })` | 6 (+ 1 optional) + 7 (+ 1 optional) + 5 (+ 1 optional) |
35
35
  | `RelationStore` | `permissions({ store })`, `janus({ relations })` | 6 |
36
36
 
37
37
  They are separate on purpose: an application that only authenticates
@@ -184,6 +184,7 @@ interface TokenStore {
184
184
  countAttempt(tokenHash: string, kind: TokenKind): Promise<TokenRecord | null>;
185
185
  spendUserTokens(userId: Id, kind: TokenKind, at: Date, except?: string): Promise<number>; // the unspent ones, but except
186
186
  deleteUserTokens(userId: Id): Promise<number>;
187
+ deleteExpiredTokens?(before: Date): Promise<number>; // optional: omit it if the database expires on its own
187
188
  }
188
189
  ```
189
190
 
@@ -368,6 +369,41 @@ of the right `kind` whose `spentAt` is empty, skipping the one named by
368
369
  `except`. `spendUserTokens` has its own
369
370
  outage case.
370
371
 
372
+ ### `TokenStore.deleteExpiredTokens` (optional)
373
+
374
+ What `collectExpired()` asks for, beside `SessionStore.deleteExpiredSessions`:
375
+ **delete every token whose `expiresAt` is at or before `before`**, and answer
376
+ how many. Leave it out when your database expires records on its own (a TTL
377
+ index, a key TTL) — nothing breaks, the core compares `expiresAt` on every
378
+ redemption — and implement it when it does not, or the rows pile up.
379
+
380
+ | The stored token | Written |
381
+ | --- | --- |
382
+ | `expiresAt` at or before `before`, spent or not, of any kind and any user | deleted, and counted |
383
+ | `expiresAt` later than `before`, spent or not | **kept** |
384
+ | none at all | `0`, an absence — never a failure |
385
+
386
+ **Never delete a token whose `expiresAt` is later, spent or not**: a spent
387
+ token is kept, answered as spent, until it lapses, or a count would start
388
+ over. That includes the throttles' counts — ordinary `secondFactor` tokens
389
+ under a keyed-hash user id, which `deleteUserTokens` never reaches and this
390
+ method is the only thing to collect. The core calls it with `clock.now()`, no
391
+ margin: a link collected answers `TOKEN_UNKNOWN`.
392
+
393
+ ```ts
394
+ import type { TokenStore } from '@nxgt/janus';
395
+
396
+ // PostgreSQL through Drizzle — one statement, over an index on expires_at
397
+ deleteExpiredTokens: async (before) =>
398
+ (await db.delete(tokens).where(lte(tokens.expiresAt, before)).returning({ tokenHash: tokens.tokenHash })).length,
399
+ ```
400
+
401
+ `janus()` reads its presence once (`StoreCapabilities.collectExpiredTokens`),
402
+ and refuses a `deleteExpiredTokens` that is there but not a function with a
403
+ `TypeError`. The suite's `tokens.deleteExpired` and `outage.deleteExpiredTokens`
404
+ cases check it, and are skipped with their reason for a store that leaves it
405
+ out. A failure throws, as everywhere on the port.
406
+
371
407
  ### `RelationStore`
372
408
 
373
409
  ```ts
@@ -449,7 +485,7 @@ compile error naming the missing method.
449
485
 
450
486
  | Suite | Cases | Harness opens |
451
487
  | --- | --- | --- |
452
- | `describeJanusStores({ name, harness, runner?, faults?, skip? })` | 59: users, sessions, tokens, and one outage per method whose honest answer can be "nothing" — fifteen of them | `{ stores, faults?, close? }` |
488
+ | `describeJanusStores({ name, harness, runner?, faults?, skip? })` | 61: users, sessions, tokens, and one outage per method whose honest answer can be "nothing" — sixteen of them | `{ stores, faults?, close? }` |
453
489
  | `describeRelationStores({ name, harness, runner?, faults?, skip? })` | 16: the relation store, ids of edge characters, and one outage per method | `{ store, faults?, close? }` |
454
490
 
455
491
  `harness.open()` is called **once per case** and must answer fresh, empty
@@ -546,7 +582,8 @@ describeJanusStores({
546
582
 
547
583
  A skipped case still appears in the run, with its reason in its name. A case
548
584
  that cannot run on the stores it was given — an outage case with no `faults`,
549
- `collectExpired` on a store without `deleteExpiredSessions`, a `findUsers`
585
+ `sessions.deleteExpired` on a store without `deleteExpiredSessions`,
586
+ `tokens.deleteExpired` on one without `deleteExpiredTokens`, a `findUsers`
550
587
  case on a store without `findUsers` — passes, and emits a
551
588
  `JANUS_CONFORMANCE_SKIPPED` warning with the reason. A case's `needs` says what
552
589
  it cannot run without — one `CaseNeed` or a list: `outage.findUsers` needs
@@ -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.
@@ -481,8 +511,8 @@ answers again. A refused request is counted too but cannot extend the wait.
481
511
  **Where the counts live.** In the tokens store, as `secondFactor` tokens named
482
512
  by a keyed hash of the flow and the address or user id: no port change, no new
483
513
  infrastructure, and never the address. That is one row per flow per address or
484
- user per window. On PostgreSQL, `collectExpired()` does not collect lapsed
485
- ones: schedule the delete, as for the sign-in throttle. A flushed or evicting
514
+ user per window. `collectExpired()` collects the lapsed ones on a tokens store that implements
515
+ `deleteExpiredTokens`; a store with a TTL expires them on its own. A flushed or evicting
486
516
  Redis forgets the counts, and a tokens store that cannot count throws
487
517
  `STORE_FAILED` with nothing issued: it fails closed.
488
518
 
@@ -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 |
@@ -64,7 +64,7 @@ that wired the library, so no handler needs to tell it apart.
64
64
  | `SECOND_FACTOR_NOT_ENROLLED` | `SecondFactorError` | 409 | `activate` before `enroll`; `regenerateRecoveryCodes` without an active factor; `confirm` or `recover` after the factor was disabled | `userId` |
65
65
  | `SECOND_FACTOR_ACTIVE` | `SecondFactorError` | 409 | `enroll` or `activate` on a factor already active: `disable` it first | `userId` |
66
66
  | `INVALID_CURSOR` | `InvalidCursorError` | 400 | A cursor this store did not mint. Never a silent first page | |
67
- | `UNSUPPORTED` | `UnsupportedError` | 501 | The wired store lacks an optional capability — `collectExpired` without `deleteExpiredSessions` | `slot`, `operation` |
67
+ | `UNSUPPORTED` | `UnsupportedError` | 501 | The wired store lacks an optional capability — `collectExpired` with neither `deleteExpiredSessions` nor `deleteExpiredTokens` | `slot`, `operation` |
68
68
  | `PERMISSION_DEPTH` | `PermissionDepthError` | 500 | A check or list walked past `maxDepth`. **Not a denial** | `permission`, `maxDepth` |
69
69
 
70
70
  Every class extends `JanusError`, which extends `Error`, so no `catch` block
@@ -195,16 +195,13 @@ counts correctly. Each is named by a keyed hash of the login, never the login
195
195
  otherwise, which only keeps the login out of plain sight: a dump of the
196
196
  tokens then tells which logins were tried, for a guessed list. Each expires
197
197
  two windows after its window starts — half an hour by default — **Redis and MongoDB drop it then;
198
- PostgreSQL keeps it** until something deletes it, and every login tried,
199
- registered or not, adds a row per window. Schedule a delete with
200
- `@nxgt/janus-drizzle`:
201
-
202
- ```sql
203
- delete from tokens where expires_at < now() - interval '1 hour'; -- janus.tokens when your tables have a schema of their own
204
- ```
205
-
206
- `tokens` has no index on `expires_at`, so the delete scans the table: add
207
- `create index on tokens (expires_at)` in a migration of yours if it grows.
198
+ a store with no TTL keeps it** until `collectExpired()` deletes it (the
199
+ tokens store's optional `deleteExpiredTokens`), and every login tried,
200
+ registered or not, adds a row per window. Schedule `collectExpired()` on such
201
+ a store: `@nxgt/janus-drizzle` implements `deleteExpiredTokens` (since 0.9),
202
+ with an index on `expires_at` that its
203
+ [migration](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus-drizzle/docs/guide/migrations.md#to-09-an-index-on-tokensexpires_at)
204
+ adds. A store of your own implements the method, or its rows stay.
208
205
 
209
206
  The key is your first `secondFactor` key: wiring
210
207
  `secondFactor` for the first time, or putting a new key first, starts every
@@ -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
@@ -323,10 +329,21 @@ a link or a code already in the person's inbox still works afterwards.
323
329
  const removed: number = await auth.collectExpired();
324
330
  ```
325
331
 
326
- Deletes lapsed sessions, for a store that keeps them. It rejects with
327
- `UNSUPPORTED` when the sessions store does not implement the optional
328
- `deleteExpiredSessions` — a store with its own TTL, such as
329
- `@nxgt/janus-mongo`'s, does not. The reference store implements it.
332
+ Deletes lapsed sessions and lapsed one-time tokens, from the stores that keep
333
+ them, and answers how many in all. The tokens include the sign-in and mail
334
+ throttles' counts, which are ordinary tokens. It runs whichever of the
335
+ optional `sessions.deleteExpiredSessions` and `tokens.deleteExpiredTokens` the
336
+ stores implement, sessions first, and rejects with `UNSUPPORTED` only when
337
+ neither does — a store with its own TTL, such as `@nxgt/janus-mongo`'s, does
338
+ not implement them. The reference store implements both.
339
+
340
+ A token is deleted once its `expiresAt` is at or before now, spent or not;
341
+ one that has not lapsed is never deleted, so a throttle keeps counting and a
342
+ link still redeems until its time is up. After a token is collected, its link
343
+ answers `TOKEN_UNKNOWN` rather than `TOKEN_EXPIRED` or `TOKEN_SPENT`, as it
344
+ already does on a store with a TTL. A failure in the tokens store, after the
345
+ sessions went, is `STORE_FAILED`; both deletions are idempotent, so call it
346
+ again.
330
347
 
331
348
  ## Testing expiry
332
349
 
@@ -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
@@ -5,7 +5,7 @@ dates here, and the version something shipped in is the only number.
5
5
 
6
6
  ## Now
7
7
 
8
- Nothing between releases.
8
+ Nothing yet.
9
9
 
10
10
  ## Next
11
11
 
@@ -13,13 +13,6 @@ Nothing yet.
13
13
 
14
14
  ## Later
15
15
 
16
- - **Collecting lapsed one-time tokens** — `collectExpired()` deletes lapsed
17
- sessions only. On a store with no TTL (PostgreSQL), lapsed tokens stay
18
- until you delete them, and the sign-in throttle adds one per login tried
19
- per window, and the mail throttle one per flow per address or user per
20
- window. A port method to collect them, as sessions are, would let the
21
- core do it.
22
-
23
16
  - **A batch read across types** — `findMany` reads one user type, as `find`
24
17
  does. The shared `findUser(id)` has no batch counterpart yet; one on the
25
18
  instance, answering a union narrowed by `user.type`, would serve a list that
@@ -76,6 +69,38 @@ Nothing yet.
76
69
  The last ten, newest first, each with the version it came in. Everything
77
70
  before is in the [CHANGELOG](../CHANGELOG.md).
78
71
 
72
+ - **Collecting lapsed one-time tokens, v0.25.0** — `collectExpired()` deletes
73
+ lapsed sessions and, through a new optional `TokenStore.deleteExpiredTokens`,
74
+ lapsed one-time tokens too: the sign-in and mail throttles' counts
75
+ included, which a store with no TTL (PostgreSQL) kept for ever, one per
76
+ login tried per window and one per flow per address or user per window.
77
+ The reference store and `@nxgt/janus-drizzle` 0.9 implement it; MongoDB and
78
+ Redis expire tokens themselves and do not.
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.
@@ -75,7 +75,7 @@ How the messages are shaped:
75
75
  - [`TOKEN_STALE` — `<call>: the token was sent to an e-mail the user no longer has`](#token_stale--call-the-token-was-sent-to-an-e-mail-the-user-no-longer-has)
76
76
  - [`INVALID_CURSOR` — `<call>: this cursor was not minted by this store …`](#invalid_cursor--call-this-cursor-was-not-minted-by-this-store-or-was-minted-for-another-ordering-n-characters)
77
77
  - [`<call>: limit must be an integer of at least 1, or absent`](#call-limit-must-be-an-integer-of-at-least-1-or-absent)
78
- - [`UNSUPPORTED` — `collectExpired: store.sessions does not implement deleteExpiredSessions …`](#unsupported--collectexpired-storesessions-does-not-implement-deleteexpiredsessions--its-store-expires-sessions-on-its-own-or-implement-the-method)
78
+ - [`UNSUPPORTED` — `collectExpired: store.sessions does not implement deleteExpiredSessions, nor store.tokens deleteExpiredTokens …`](#unsupported--collectexpired-storesessions-does-not-implement-deleteexpiredsessions-nor-storetokens-deleteexpiredtokens--their-stores-expire-records-on-their-own-or-implement-the-methods)
79
79
  - [`<call>: the <type> type does not sign in with a password …`](#call-the-type-type-does-not-sign-in-with-a-password--add-password--login--to-it)
80
80
  - [`authenticate()` answers `null` although a valid cookie was sent](#authenticate-answers-null-although-a-valid-cookie-was-sent)
81
81
 
@@ -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**
@@ -294,10 +297,10 @@ janus({ ..., hasher: scryptHasher(), verifiers: [legacyBcrypt] }); // not [scryp
294
297
 
295
298
  ### `janus: store.<slot> has no method <method>, which the port requires`
296
299
 
297
- Also: `janus: store.<slot> is missing`, `janus: store must be an object with users, sessions and tokens`, `janus: store.sessions.deleteExpiredSessions must be a function or absent`, `janus: store.users.findUsers must be a function or absent`.
300
+ Also: `janus: store.<slot> is missing`, `janus: store must be an object with users, sessions and tokens`, `janus: store.sessions.deleteExpiredSessions must be a function or absent`, `janus: store.tokens.deleteExpiredTokens must be a function or absent`, `janus: store.users.findUsers must be a function or absent`.
298
301
 
299
302
  **When:** `janus({...})`, from JavaScript or with a store typed loosely. TypeScript refuses a partial store at compile time and names the method.
300
- **Why:** `store` is `{ users, sessions, tokens }`, and each slot must answer every method of the port. Two methods are optional — `sessions.deleteExpiredSessions` and `users.findUsers` — and each is absent, or a function: a value in its place (`findUsers: true`, a property copied from a config) is a wiring mistake, not an absent capability.
303
+ **Why:** `store` is `{ users, sessions, tokens }`, and each slot must answer every method of the port. Three methods are optional — `sessions.deleteExpiredSessions`, `tokens.deleteExpiredTokens` and `users.findUsers` — and each is absent, or a function: a value in its place (`findUsers: true`, a property copied from a config) is a wiring mistake, not an absent capability.
301
304
  An adapter written for an earlier `@nxgt/janus` reports the method a later
302
305
  release added to the port: `store.tokens has no method countAttempt` for one
303
306
  written against 0.3 (the method came in 0.4), and
@@ -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
@@ -1058,11 +1069,11 @@ const page = await access.list(user, 'view', 'record', {
1058
1069
  });
1059
1070
  ```
1060
1071
 
1061
- ### `UNSUPPORTED` — `collectExpired: store.sessions does not implement deleteExpiredSessions — its store expires sessions on its own, or implement the method`
1072
+ ### `UNSUPPORTED` — `collectExpired: store.sessions does not implement deleteExpiredSessions, nor store.tokens deleteExpiredTokens — their stores expire records on their own, or implement the methods`
1062
1073
 
1063
1074
  **When:** `auth.collectExpired()`.
1064
- **Why:** the sessions store does not implement the optional `deleteExpiredSessions` — usually because it expires sessions itself (a MongoDB TTL index, a Redis `EXPIRE`).
1065
- **Fix:** do not schedule `collectExpired` for that store. Expiry does not depend on it: the core compares `expiresAt` on every `authenticate`.
1075
+ **Why:** neither the sessions store implements the optional `deleteExpiredSessions` nor the tokens store the optional `deleteExpiredTokens` — usually because they expire records themselves (a MongoDB TTL index, a Redis `EXPIRE`). With one of the two, `collectExpired` collects that one and does not throw.
1076
+ **Fix:** do not schedule `collectExpired` for those stores. Expiry does not depend on it: the core compares `expiresAt` on every `authenticate` and on every redemption. If your database has no TTL, implement the methods ([adapters](guide/adapters.md#tokenstoredeleteexpiredtokens-optional)).
1066
1077
 
1067
1078
  ### `<call>: the <type> type does not sign in with a password — add password: { login } to it`
1068
1079
 
@@ -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.25.0",
4
4
  "description": "Embeddable, type-safe identities and permissions: bring your own database",
5
5
  "license": "MIT",
6
6
  "type": "module",