@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.
- package/README.md +119 -21
- package/dist/auth/email-flows/prove-first.d.ts +23 -1
- package/dist/auth/email-flows/prove-first.d.ts.map +1 -1
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/outage.d.ts +17 -6
- package/dist/auth/outage.d.ts.map +1 -1
- package/dist/auth/port/assert-stores.d.ts.map +1 -1
- package/dist/auth/port/memory/tokens.d.ts.map +1 -1
- package/dist/auth/port/types/stores.d.ts +2 -0
- package/dist/auth/port/types/stores.d.ts.map +1 -1
- package/dist/auth/port/types/tokens.d.ts +12 -0
- package/dist/auth/port/types/tokens.d.ts.map +1 -1
- package/dist/auth/sessions/collect-expired.d.ts +14 -0
- package/dist/auth/sessions/collect-expired.d.ts.map +1 -0
- package/dist/auth/sessions/shared-api.d.ts +1 -1
- package/dist/auth/sessions/shared-api.d.ts.map +1 -1
- package/dist/auth/types/email-flows.d.ts +13 -2
- package/dist/auth/types/email-flows.d.ts.map +1 -1
- package/dist/auth/types/find-or-create.d.ts +59 -0
- package/dist/auth/types/find-or-create.d.ts.map +1 -0
- package/dist/auth/types/index.d.ts +1 -0
- package/dist/auth/types/index.d.ts.map +1 -1
- package/dist/auth/types/janus.d.ts +6 -4
- package/dist/auth/types/janus.d.ts.map +1 -1
- package/dist/auth/users/any-type-api.d.ts +2 -2
- package/dist/auth/users/any-type-api.d.ts.map +1 -1
- package/dist/auth/users/find-or-create.d.ts +12 -0
- package/dist/auth/users/find-or-create.d.ts.map +1 -0
- package/dist/auth/users/insert.d.ts +8 -3
- package/dist/auth/users/insert.d.ts.map +1 -1
- package/dist/auth/users/type-api.d.ts +1 -1
- package/dist/auth/users/type-api.d.ts.map +1 -1
- package/dist/chunks/{index-6bjfv4xv.js → index-q3w3vr69.js} +13 -3
- package/dist/chunks/{index-6bjfv4xv.js.map → index-q3w3vr69.js.map} +3 -3
- package/dist/chunks/{index-cntabs9d.js → index-rpp0bw6e.js} +2 -2
- package/dist/chunks/{index-kppkcbn5.js → index-spwh8b9z.js} +2 -5
- package/dist/chunks/{index-kppkcbn5.js.map → index-spwh8b9z.js.map} +2 -2
- package/dist/chunks/{index-4khsm7mw.js → index-y9b327j8.js} +18 -18
- package/dist/chunks/{index-4khsm7mw.js.map → index-y9b327j8.js.map} +5 -5
- package/dist/conformance/cases/outage.d.ts.map +1 -1
- package/dist/conformance/cases/tokens/deletion.d.ts.map +1 -1
- package/dist/conformance/describe.d.ts +1 -0
- package/dist/conformance/describe.d.ts.map +1 -1
- package/dist/conformance/index.js +39 -5
- package/dist/conformance/index.js.map +5 -5
- package/dist/conformance/types.d.ts +1 -1
- package/dist/conformance/types.d.ts.map +1 -1
- package/dist/index.js +167 -44
- package/dist/index.js.map +11 -9
- package/dist/permissions/engine.d.ts.map +1 -1
- package/dist/permissions/index.d.ts +1 -1
- package/dist/permissions/index.d.ts.map +1 -1
- package/dist/permissions/index.js +52 -19
- package/dist/permissions/index.js.map +6 -5
- package/dist/permissions/input.d.ts +13 -1
- package/dist/permissions/input.d.ts.map +1 -1
- package/dist/permissions/model/grant.d.ts +14 -1
- package/dist/permissions/model/grant.d.ts.map +1 -1
- package/dist/permissions/model/permissions.d.ts +13 -1
- package/dist/permissions/model/permissions.d.ts.map +1 -1
- package/dist/permissions/port/types.d.ts +8 -2
- package/dist/permissions/port/types.d.ts.map +1 -1
- package/dist/permissions/replace.d.ts +25 -0
- package/dist/permissions/replace.d.ts.map +1 -0
- package/docs/README.md +2 -2
- package/docs/guide/adapters.md +40 -3
- package/docs/guide/email-flows.md +35 -5
- package/docs/guide/errors.md +2 -2
- package/docs/guide/passwords.md +7 -10
- package/docs/guide/permissions.md +42 -1
- package/docs/guide/sessions.md +21 -4
- package/docs/guide/users.md +44 -0
- package/docs/guide/vocabulary.md +1 -1
- package/docs/roadmap.md +33 -38
- package/docs/troubleshooting.md +50 -21
- package/package.json +1 -1
- /package/dist/chunks/{index-cntabs9d.js.map → index-rpp0bw6e.js.map} +0 -0
package/docs/guide/adapters.md
CHANGED
|
@@ -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? })` |
|
|
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
|
-
`
|
|
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`
|
|
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`;
|
|
199
|
-
|
|
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.
|
|
485
|
-
|
|
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
|
|
package/docs/guide/errors.md
CHANGED
|
@@ -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
|
|
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`
|
|
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
|
package/docs/guide/passwords.md
CHANGED
|
@@ -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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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 `
|
|
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
|
package/docs/guide/sessions.md
CHANGED
|
@@ -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
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
|
package/docs/guide/users.md
CHANGED
|
@@ -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
|
package/docs/guide/vocabulary.md
CHANGED
|
@@ -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
|
|
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.
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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--
|
|
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
|
-
- [
|
|
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.
|
|
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
|
|
647
|
-
|
|
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.
|
|
650
|
-
|
|
651
|
-
|
|
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`,
|
|
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 —
|
|
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
|
|
1065
|
-
**Fix:** do not schedule `collectExpired` for
|
|
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.
|
|
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
|
-
###
|
|
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
|
File without changes
|