@nxgt/janus 0.1.0 → 0.1.2

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 (42) hide show
  1. package/README.md +121 -25
  2. package/dist/auth/config.d.ts +3 -3
  3. package/dist/auth/config.d.ts.map +1 -1
  4. package/dist/auth/context.d.ts +1 -1
  5. package/dist/auth/context.d.ts.map +1 -1
  6. package/dist/auth/janus.d.ts +2 -2
  7. package/dist/auth/janus.d.ts.map +1 -1
  8. package/dist/auth/outage.d.ts +11 -17
  9. package/dist/auth/outage.d.ts.map +1 -1
  10. package/dist/auth/port/types.d.ts +6 -6
  11. package/dist/chunks/index-6p56fpbe.js.map +2 -2
  12. package/dist/chunks/{index-6dytvy3h.js → index-hm4v76kd.js} +7 -29
  13. package/dist/chunks/index-hm4v76kd.js.map +11 -0
  14. package/dist/conformance/assert.d.ts +1 -1
  15. package/dist/conformance/index.js +3 -3
  16. package/dist/conformance/index.js.map +5 -5
  17. package/dist/errors/janus-error.d.ts +1 -1
  18. package/dist/index.d.ts +9 -8
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +33 -12
  21. package/dist/index.js.map +7 -6
  22. package/dist/permissions/index.d.ts +1 -1
  23. package/dist/permissions/index.d.ts.map +1 -1
  24. package/dist/permissions/index.js +3 -3
  25. package/dist/permissions/index.js.map +5 -5
  26. package/dist/permissions/model.d.ts +36 -30
  27. package/dist/permissions/model.d.ts.map +1 -1
  28. package/dist/stores/guard.d.ts +24 -0
  29. package/dist/stores/guard.d.ts.map +1 -0
  30. package/dist/subjects/subject.d.ts +2 -2
  31. package/docs/README.md +19 -3
  32. package/docs/guide/adapters.md +4 -4
  33. package/docs/guide/errors.md +3 -3
  34. package/docs/guide/passwords.md +1 -1
  35. package/docs/guide/permissions.md +23 -6
  36. package/docs/guide/sessions.md +2 -2
  37. package/docs/guide/users.md +8 -7
  38. package/docs/guide/vocabulary.md +75 -3
  39. package/docs/roadmap.md +13 -5
  40. package/docs/troubleshooting.md +9 -9
  41. package/package.json +2 -2
  42. package/dist/chunks/index-6dytvy3h.js.map +0 -11
package/docs/README.md CHANGED
@@ -3,15 +3,31 @@
3
3
  The [README](../README.md) shows that it works; these pages show how, one area
4
4
  at a time, with an example for every option.
5
5
 
6
+ Janus has two sides, each usable alone — see
7
+ [Three ways to use it](../README.md#three-ways-to-use-it) — and the words
8
+ below are defined once, in [Words](guide/vocabulary.md#words).
9
+
10
+ ### Identities — `janus()`
11
+
6
12
  | Page | Read it when |
7
13
  | --- | --- |
8
- | [Users — `janus()`](guide/users.md) | You are wiring `janus()`, declaring one or several kinds of user, or calling `create`, `update`, `list`, `delete` |
14
+ | [Users](guide/users.md) | You are wiring `janus()`, declaring one or several user types, or calling `create`, `update`, `list`, `delete` |
9
15
  | [Sessions](guide/sessions.md) | You need to know who a request belongs to, set or clear the cookie, renew, sign out, or test expiry |
10
16
  | [E-mail verification and password reset](guide/email-flows.md) | You are sending a verification or reset link, and handling what comes back |
11
17
  | [Password hashing](guide/passwords.md) | You are choosing a hasher, raising its cost, moving to argon2id, or importing hashes from another system |
18
+
19
+ ### Permissions — `@nxgt/janus/permissions`
20
+
21
+ | Page | Read it when |
22
+ | --- | --- |
12
23
  | [Permissions](guide/permissions.md) | You are modelling access with `defineModel`, `fromField` and `when`, and calling `can`, `list`, `grant`, `revoke` |
24
+
25
+ ### Shared by both sides
26
+
27
+ | Page | Read it when |
28
+ | --- | --- |
29
+ | [The shared vocabulary](guide/vocabulary.md) | You want the words the documentation uses, or subjects and the tuple notation, ids, cursors, durations or `fixedClock` |
13
30
  | [Errors](guide/errors.md) | You are turning what the package throws into a status code, and want every code and what it carries |
14
- | [The shared vocabulary](guide/vocabulary.md) | You need subjects and the tuple notation, ids, cursors, durations or `fixedClock` |
15
- | [Writing an adapter](guide/adapters.md) | You are implementing the store port or the relation store for your database, and running the conformance suites |
31
+ | [Writing an adapter](guide/adapters.md) | You are implementing the identity stores or the relation store for your database, and running the conformance suites |
16
32
  | [Troubleshooting](troubleshooting.md) | You have an error message and want its cause and its fix |
17
33
  | [Roadmap](roadmap.md) | You want to know what is coming, what shipped, and what is deliberately not planned |
@@ -1,4 +1,4 @@
1
- # Writing an adapter — the store port and `@nxgt/janus/conformance`
1
+ # Writing an adapter — the ports and `@nxgt/janus/conformance`
2
2
 
3
3
  This page is for putting `janus()` or `permissions()` on a database of your
4
4
  choice: the two ports you implement, the rules they carry, and the conformance
@@ -100,7 +100,7 @@ interface TokenStore {
100
100
  and answers it **as it was before the call**, in **one conditional write**.
101
101
  Twenty concurrent calls must produce exactly one answer with `spentAt: null`;
102
102
  in MongoDB that is one `findOneAndUpdate` returning the document before the
103
- update. A read followed by a write lets two requests redeem one reset code.
103
+ update. A read followed by a write lets two requests redeem one reset token.
104
104
 
105
105
  Expiry is the core's decision: a read answers a stored session verbatim,
106
106
  lapsed or revoked, and never a record it has changed.
@@ -278,10 +278,10 @@ for (const conformanceCase of allCases) {
278
278
 
279
279
  | Export | What it is |
280
280
  | --- | --- |
281
- | `allCases`, `userStoreCases`, `sessionStoreCases`, `tokenStoreCases`, `outageCases` | The user-port cases, as `ConformanceCase` objects with a stable `id` |
281
+ | `allCases`, `userStoreCases`, `sessionStoreCases`, `tokenStoreCases`, `outageCases` | The identity stores' cases, as `ConformanceCase` objects with a stable `id` |
282
282
  | `runCase(case, harness)` | Runs one; throws on failure, answers `{ passed: true }` or `{ skipped }` |
283
283
  | `SKIP_REASONS` | The reasons the suite gives itself |
284
- | `allRelationCases`, `relationStoreCases`, `relationOutageCases`, `runRelationCase` | The same for the relation port |
284
+ | `allRelationCases`, `relationStoreCases`, `relationOutageCases`, `runRelationCase` | The same for the relation store |
285
285
  | `referenceHarness()`, `referenceRelationHarness()` | The suites against the reference stores: the examples to copy |
286
286
 
287
287
  Types: `ConformanceHarness`, `OpenedStores`, `StoreFaults`, `ConformanceCase`,
@@ -53,8 +53,8 @@ instead of answering `undefined`.
53
53
  answers `null`, `false` or an empty page. A store that cannot answer — a
54
54
  refused connection, a timeout, a primary stepping down, a bug in the adapter —
55
55
  rejects with `STORE_FAILED`. Answer 503. Mapping it to a 404, to `null` or to
56
- `false` turns an outage into a silent lockout: everybody who has an account is
57
- told they do not.
56
+ `false` turns an outage into a silent lockout: every user is
57
+ told they do not exist.
58
58
 
59
59
  ## Two kinds of refusal
60
60
 
@@ -109,7 +109,7 @@ async function signIn(email: string, password: string): Promise<Response> {
109
109
  ```
110
110
 
111
111
  - **Never put `reason` in a response body.** `unknownLogin` tells an attacker
112
- which accounts exist.
112
+ which users exist.
113
113
  - **`STORE_FAILED` is never a 401 or a 404.** Test for it before anything that
114
114
  would read as "no".
115
115
  - **`VERSION_CONFLICT` is a retry**: read the user again, reapply, write with
@@ -127,7 +127,7 @@ per prefix the old system wrote (`$2a$`, `$2b$`, `$2y$` for bcrypt).
127
127
  - The plain password is never stored, and never appears in an error message.
128
128
  - The hash never reaches a `User` — `hasPassword` says whether there is one.
129
129
  - `signIn` compares against a dummy hash when nobody holds the login, so the
130
- time taken does not reveal which accounts exist. The store's own latency
130
+ time taken does not reveal which users exist. The store's own latency
131
131
  still can, and that limit is stated rather than denied.
132
132
 
133
133
  ## See also
@@ -6,6 +6,11 @@ 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.
8
8
 
9
+ It is the **permissions** side, and it is usable alone: the example below wires
10
+ it to `janus()` so the user types become the subjects, but `subjects` takes
11
+ any list of names — `defineModel({ subjects: ['user'], … })` — when your users
12
+ live elsewhere. Importing `@nxgt/janus/permissions` loads no identity code.
13
+
9
14
  ```ts
10
15
  import { z } from 'zod';
11
16
  import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
@@ -46,7 +51,11 @@ await access.can(grace, 'view', team); // false
46
51
  ## The model
47
52
 
48
53
  ```ts
49
- function defineModel<const C extends ModelConfig>(config: C & CheckedModel<C>): PermissionModel<C>;
54
+ function defineModel<
55
+ const Subjects extends readonly string[], // auth.types
56
+ const Ts extends ModelConfig['types'] & ModelTypesOf<Subjects[number], Ts>, // what your editor completes
57
+ >(config: { readonly subjects: Subjects; readonly types: Ts }): PermissionModel<{ readonly subjects: Subjects; readonly types: Ts }>;
58
+ // ModelTypesOf<S, Ts>: the names each relation and rule of each type may take
50
59
 
51
60
  interface ModelConfig {
52
61
  readonly subjects: readonly string[]; // pass auth.types
@@ -84,15 +93,23 @@ like a permission.
84
93
 
85
94
  A relation naming a type that does not exist, a rule naming nothing, an arrow
86
95
  to a permission its target lacks, a name that is both a relation and a
87
- permission: each is a compile error **on the offending key**, and the error
88
- lists what you could have written.
96
+ permission: each is a compile error **on the offending name** — on the whole
97
+ `fromField(…)` or `when(…)` call for those two. Except for that last one,
98
+ which says to rename one, the error lists what you could have written, with
99
+ "Did you mean" when one is close.
100
+
101
+ Your editor offers those names as you type — subject types and subject sets
102
+ in a relation, subject types in `fromField`, relations, permissions and arrows
103
+ in a rule and in `when` — because `defineModel` types its `types` with a
104
+ constraint an editor reads, not only with a check. A spec asks the TypeScript
105
+ language service what it completes, so a change that loses it fails.
89
106
 
90
107
  ```ts
91
108
  defineModel({
92
109
  subjects: ['staff'],
93
110
  types: {
94
111
  team: {
95
- // @ts-expect-error — '"staf" is not a subject type or a subject set; name one of' …
112
+ // @ts-expect-error — Type '"staf"' is not assignable to type '"staff" | "team" | "team#member"'. Did you mean '"staff"'?
96
113
  relations: { member: ['staf'] },
97
114
  },
98
115
  },
@@ -257,8 +274,8 @@ own for that.
257
274
  ### `grant` and `revoke`
258
275
 
259
276
  ```ts
260
- grant(object, relation, holder): Promise<void>;
261
- revoke(object, relation, holder): Promise<void>;
277
+ grant(object, relation, subject): Promise<void>;
278
+ revoke(object, relation, subject): Promise<void>;
262
279
  ```
263
280
 
264
281
  Both are typed from the model: only a stored relation (never a `fromField`),
@@ -53,11 +53,11 @@ await auth.authenticate({ headers: { cookie: `janus-session=${token}` } });
53
53
  ```
54
54
 
55
55
  It reads `Authorization: Bearer`, then `X-Session-Token`, then the cookie.
56
- **The first credential present wins, not the first valid one**: a client that
56
+ **The first session credential present wins, not the first valid one**: a client that
57
57
  sends a lapsed bearer beside a live cookie is anonymous. An `Authorization`
58
58
  header of another scheme (`Basic`) is not a session credential.
59
59
 
60
- It answers `null` — anonymous — for no credential, an unknown token, a lapsed
60
+ It answers `null` — anonymous — for no session credential, an unknown token, a lapsed
61
61
  or revoked session, a user deleted or inactive, or a user of another type than
62
62
  `options.type`:
63
63
 
@@ -1,8 +1,9 @@
1
1
  # Users — `janus()`
2
2
 
3
3
  This page is for wiring `janus()` and managing users with it: the
4
- configuration, one or several kinds of user, and every method a user type
5
- answers. Sessions have [their own page](sessions.md), and so do the
4
+ configuration, one or several user types, and every method a user type
5
+ answers. It is the **identities** side, usable alone: nothing here needs
6
+ [permissions](permissions.md), and `@nxgt/janus` loads none of their code. Sessions have [their own page](sessions.md), and so do the
6
7
  [e-mail flows](email-flows.md) and [password hashing](passwords.md).
7
8
 
8
9
  ```ts
@@ -71,8 +72,8 @@ before the store sees it.
71
72
 
72
73
  | Option | Type | Default | Effect |
73
74
  | --- | --- | --- | --- |
74
- | `user` | Standard Schema | — | One kind of user, named `'user'`. Exactly one of `user` and `users` |
75
- | `users` | `{ [type]: UserTypeConfig }` | — | Several kinds of user. See [Several kinds of user](#several-kinds-of-user) |
75
+ | `user` | Standard Schema | — | One user type, named `'user'`. Exactly one of `user` and `users` |
76
+ | `users` | `{ [type]: UserTypeConfig }` | — | Several user types. See [Several user types](#several-user-types) |
76
77
  | `password.login` | a field name | — | The field users sign in with: a **top-level, required string** field. A typo is a compile error |
77
78
  | `password.normalize` | `'none' \| 'lowercase' \| 'lowercaseTrim' \| 'nfkcLowercaseTrim' \| (value) => string` | `'lowercaseTrim'` | Applied to the login before any store sees it, at sign-up and at sign-in alike |
78
79
  | `password.minLength` | integer ≥ 1 | `8` | Below it: `PASSWORD_TOO_SHORT` |
@@ -122,7 +123,7 @@ A function is accepted, and must be deterministic: the same rule normalises
122
123
  at sign-up and at sign-in. An e-mail used by the e-mail flows is always
123
124
  compared lower-cased and trimmed, whatever the login's rule.
124
125
 
125
- ## Several kinds of user
126
+ ## Several user types
126
127
 
127
128
  ```ts
128
129
  const Patient = z.object({ email: z.email(), birthDate: z.string() });
@@ -151,7 +152,7 @@ if (current?.user.type === 'staff') current.user.service; // narrowed by type
151
152
 
152
153
  Each type carries `schema` and, optionally, `password`, `email`, `session`
153
154
  and `schemaVersion`, with the defaults above. A login is unique **per type**:
154
- the same e-mail may hold a patient account and a staff account. A type name is
155
+ the same e-mail may hold a patient user and a staff user. A type name is
155
156
  camelCase, and may not be one of `janus()`'s own methods (`authenticate`,
156
157
  `signOut`, `signOutEverywhere`, `findUser`, `getUser`, `cookie`,
157
158
  `collectExpired`, `types`) — a compile error, and a `TypeError` for a
@@ -274,4 +275,4 @@ fields against your schema, not that `password` is a string.
274
275
 
275
276
  - [Sessions](sessions.md) — `authenticate`, the cookie, signing out
276
277
  - [Errors](errors.md) — every code, and the status it deserves
277
- - [Writing an adapter](adapters.md) — the store port behind `store`
278
+ - [Writing an adapter](adapters.md) — the identity stores behind `store`
@@ -1,7 +1,7 @@
1
1
  # The shared vocabulary
2
2
 
3
- This page is for the small pieces `@nxgt/janus` exports beside `janus()` and
4
- the permissions, because both use them: subjects and the tuple notation, ids,
3
+ This page defines the words the documentation uses, then the small pieces
4
+ `@nxgt/janus` exports for both sides: subjects and the tuple notation, ids,
5
5
  pagination, durations and clocks. All of it is imported from `@nxgt/janus`.
6
6
 
7
7
  ```ts
@@ -18,6 +18,78 @@ formatTuple({
18
18
  parseDuration('8h', 'session.lifespan'); // 28800000
19
19
  ```
20
20
 
21
+ ## Words
22
+
23
+ One word per idea, the same in the README, these guides, the error messages
24
+ and the code. The **Not** column lists the words it replaces, so a search for
25
+ either finds this row.
26
+
27
+ ### The two sides
28
+
29
+ | Word | Means | Not |
30
+ | --- | --- | --- |
31
+ | **side** | One of the two things Janus does, each usable alone: identities or permissions | "half", "module" |
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" |
34
+
35
+ ### Identities
36
+
37
+ | Word | Means | Not |
38
+ | --- | --- | --- |
39
+ | **user** | One stored person or machine, of one user type | "account" — kept only in *account takeover* and *account enumeration*, the names of those attacks |
40
+ | **user type** | A kind of user with its own schema and login: `staff`, `patient`. Wired to permissions, its name is a subject type | "role": a role is a relation in the model |
41
+ | **schema** | A user type's Standard Schema: the fields a user carries | the model |
42
+ | **login** | The value a user signs in with: an e-mail, a username. **sign in** is the verb, **sign-in** the noun | "identifier" |
43
+ | **credential** | What a caller presents to prove who they are: a login and a password at sign-in (`CredentialError`, `CREDENTIALS_INVALID`), or a token on a request — always written *session credential* | |
44
+ | **password policy** | The rules a new password must meet: `minLength`, `normalize` | the model |
45
+ | **session** | A signed-in period, carried by a **session token** | |
46
+ | **lapsed**, **revoked**, **renewed** | A session past its `expiresAt`; one ended by a sign-out or a password reset; one whose `expiresAt` moved in passing (`renewAfter`) | "expired" — kept for `TOKEN_EXPIRED`, a one-time token |
47
+ | **anonymous** | A request that presents no session credential, or one that authenticates nobody: `authenticate` answers `null` | "unauthenticated", "guest", "logged out" |
48
+ | **bearer client** | A client that sends its session token as `Authorization: Bearer` rather than in a cookie | |
49
+ | **one-time token** | A single-use token sent by e-mail, for verification or a password reset | "code" — `code` is an error's code |
50
+ | **token** | Never alone in prose: a *session token* or a *one-time token*. The `tokens` store and the `TOKEN_*` codes are one-time tokens only | |
51
+ | **e-mail flow** | `verifyEmail` or `resetPassword`: send a one-time token, then confirm it | |
52
+
53
+ ### Permissions
54
+
55
+ | Word | Means | Not |
56
+ | --- | --- | --- |
57
+ | **model** | What `defineModel()` answers: the subject types, the object types, their relations and permissions | "schema", "policy" |
58
+ | **subject type** | A name listed in `subjects`: `auth.types` when wired to `janus()`, your own names otherwise | |
59
+ | **object** | What a permission is about: `{ type: 'document', id }` | "resource" |
60
+ | **subject** | Who a permission is about: a user, an object, or a subject set | "principal", "actor" |
61
+ | **entity** | `{ type, id }`: a user or an object — a subject that is not a set (`Entity`, `deleteEntity`) | |
62
+ | **subject set** | Everyone holding one relation on one object: `team:t1#member` | "group" — a group is an object with a `member` relation |
63
+ | **relation** | A named link, stored as tuples or read from a field (`fromField`) | |
64
+ | **holder** | What a relation admits: `'staff'`, or the subject set `'team#member'` | |
65
+ | **permission** | A name computed from relations and other permissions by its rules | |
66
+ | **rule** | One entry of a permission: a relation, another permission, an arrow, or one of them under a condition | |
67
+ | **arrow** | A rule that follows a relation to another object's permission: `'team->view'` | |
68
+ | **condition** | A predicate on a rule, written with `when`, run on the `ctx` passed to `can()` and `list()` | |
69
+ | **tuple** | One stored fact — object, relation, subject: `document:d1#viewer@user:u1` | "grant", "ACL entry" |
70
+ | **guarded route** | A route that runs only when its subject holds a permission on the object it serves — `permission()` in `@nxgt/janus-hono` | |
71
+
72
+ ### Stores
73
+
74
+ | Word | Means | Not |
75
+ | --- | --- | --- |
76
+ | **store** | Where a side keeps its data, behind a port. The **identity stores** are `users`, `sessions` and `tokens`; the **relation store** holds the tuples | "database", "repository" |
77
+ | **port** | The interface a store implements: `JanusStores` for the identity stores — named after the package, not the side — and `RelationStore` | "driver" |
78
+ | **adapter** | A package implementing the ports for one database: `@nxgt/janus-mongo` | "plugin", "connector" |
79
+ | **integration** | A package fitting Janus into one web framework: `@nxgt/janus-hono`. It implements no port | "plugin", "adapter" |
80
+
81
+ ### Answers
82
+
83
+ The words of [the one rule](../../README.md#the-one-rule): each is a different
84
+ answer, and none stands in for another.
85
+
86
+ | Word | Means |
87
+ | --- | --- |
88
+ | **absence** | Nothing there: `null`, `[]`, an empty page, `0` |
89
+ | **denial** | A permission not held: `false` |
90
+ | **failure** | A store that could not answer — an **outage**. It throws `STORE_FAILED`: never an absence, never a denial |
91
+ | **refusal** | Anything thrown: a `JanusError` at call time — a taken login, a wrong password, a failure — or a `TypeError` at wiring time, from how the library was called. See [Two kinds of refusal](errors.md#two-kinds-of-refusal) |
92
+
21
93
  ## Subjects and the tuple notation
22
94
 
23
95
  ```ts
@@ -30,7 +102,7 @@ interface RelationTuple { readonly object: Entity; readonly relation: string; re
30
102
  **Subjects are typed**: `{ type, id }` for one entity, `{ type, id, relation }`
31
103
  for a subject set — every `member` of `team:t1`. `type` is the same word as a
32
104
  user's own, so a user id and a subject id are the same thing, and
33
- `subjectOf(user)` is the one-line join between the two halves of the package.
105
+ `subjectOf(user)` is the one-line join between the two sides of the package.
34
106
  It copies `type` and `id` only, so none of the user's own fields ever reaches
35
107
  a tuple.
36
108
 
package/docs/roadmap.md CHANGED
@@ -5,7 +5,10 @@ dates here, and the version something shipped in is the only number.
5
5
 
6
6
  ## Now
7
7
 
8
- Nothing in progress.
8
+ - **A Hono integration** — in a package of its own, `@nxgt/janus-hono`:
9
+ the session middleware, the cookie, a route guarded by a permission, the
10
+ instances on the context, and every error as its status. Built, not yet
11
+ published.
9
12
 
10
13
  ## Next
11
14
 
@@ -13,7 +16,7 @@ Nothing yet.
13
16
 
14
17
  ## Later
15
18
 
16
- - **More official adapters** — the store port is cut where atomicity is not
19
+ - **More official adapters** — the ports are cut where atomicity is not
17
20
  required, so users, sessions and permission tuples can each live in the
18
21
  database that suits them. MongoDB is the first adapter
19
22
  ([`@nxgt/janus-mongo`](https://www.npmjs.com/package/@nxgt/janus-mongo)).
@@ -28,8 +31,8 @@ Nothing yet.
28
31
  bundler do. Rewriting the declarations for `nodenext` was tried and reverted:
29
32
  it breaks the same contract one step later. Use `"moduleResolution":
30
33
  "bundler"`.
31
- - **A Kratos-shaped surface** — no `identity.traits`, no identifier derived
32
- from a schema annotation. A user is your schema's fields at the top level,
34
+ - **A Kratos-shaped surface** — no `identity.traits`, no login derived from a
35
+ schema annotation (Kratos's `identifier`). A user is your schema's fields at the top level,
33
36
  and the flows are calls (`signUp`, `signIn`, `authenticate`).
34
37
  - **`snake_case` keys** — every key, option and record field is `camelCase`,
35
38
  and a lint rule holds it. Error codes are `SCREAMING_SNAKE` because they are
@@ -60,6 +63,11 @@ Nothing yet.
60
63
 
61
64
  The first public release, v0.1.
62
65
 
66
+ - **`defineModel` completed by your editor** — subject types and subject sets
67
+ in a relation, subject types in `fromField`, relations, permissions and
68
+ arrows in a rule and in `when`; a wrong name's error lists the names it
69
+ could have been. — v0.1.2
70
+
63
71
  - **The model decides what a stored tuple grants** — `can()` and `list()` follow
64
72
  only the holders a relation admits, as `grant()` writes only those: a tuple
65
73
  stored past `grant()`, by an older model or by hand, grants nothing. — v0.1
@@ -88,6 +96,6 @@ The first public release, v0.1.
88
96
  — v0.1
89
97
  - **The conformance suite** — `@nxgt/janus/conformance`, the suite an adapter
90
98
  runs, outages included. — v0.1
91
- - **The store port and its in-memory reference** — `JanusStores` and
99
+ - **The identity stores' port and its in-memory reference** — `JanusStores` and
92
100
  `createMemoryStores()`, for your tests and as the model for an adapter.
93
101
  — v0.1
@@ -127,14 +127,14 @@ Every entry here is a `TypeError` thrown by `janus(...)` itself, before any
127
127
  request. With several user types, the option is prefixed by the type:
128
128
  `janus: users.staff: password.login …`.
129
129
 
130
- ### `janus: pass either user (one kind of user) or users (several kinds), and exactly one of them`
130
+ ### `janus: pass either user (one user type) or users (several user types), and exactly one of them`
131
131
 
132
132
  **When:** `janus({...})`, with both `user` and `users`, or neither.
133
- **Why:** `user` is the shorthand for one kind of user; `users` declares several, each with its own schema and options. The two cannot be mixed.
133
+ **Why:** `user` is the shorthand for one user type; `users` declares several, each with its own schema and options. The two cannot be mixed.
134
134
  **Fix:**
135
135
 
136
136
  ```ts
137
- // One kind of user
137
+ // One user type
138
138
  janus({ user: User, password: { login: 'email' }, store, hasher });
139
139
 
140
140
  // Several
@@ -294,7 +294,7 @@ Also `janus: cookie.name must be a cookie-name token — letters, digits and !#$
294
294
 
295
295
  **When:** any call that reaches the store — `authenticate`, `signIn`, `get`, `can` — while the database is down, times out, or the adapter throws.
296
296
  **Why:** a store that cannot answer throws; it never answers `null`. The driver's own error is on `error.cause` — not in the message, because a driver message can hold a connection string, and a connection string holds a password.
297
- **Fix:** answer **503**, and log `cause`. Never map it to 401, 404, `null` or `false`: that turns an outage into a silent lockout, where everybody with an account is told they do not have one.
297
+ **Fix:** answer **503**, and log `cause`. Never map it to 401, 404, `null` or `false`: that turns an outage into a silent lockout, where every user is told they do not exist.
298
298
 
299
299
  ```ts
300
300
  import { JanusError } from '@nxgt/janus';
@@ -333,7 +333,7 @@ const user = await auth.find(id); // null when there is nobody
333
333
  `StoreConflict` with `on: 'login'`, carrying `login` and `userType`.
334
334
 
335
335
  **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).
336
- **Why:** the store's unique constraint refused the write. A login is unique per user type: one e-mail may hold a patient account and a staff account.
336
+ **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.
337
337
  **Fix:** answer 409. If two concurrent sign-ups with one login both succeed, the unique index is missing — with `@nxgt/janus-mongo`, run `syncMongoStores(db)`.
338
338
 
339
339
  ### `VERSION_CONFLICT` — `<call>: expected version <n>, found <m>`
@@ -378,7 +378,7 @@ Also `changePassword: the current password does not match`.
378
378
  **Fix:** answer 401 with the same body whatever the reason:
379
379
 
380
380
  ```ts
381
- // Never: { reason: error.reason } — `unknownLogin` tells an attacker which accounts exist.
381
+ // Never: { reason: error.reason } — `unknownLogin` tells an attacker which users exist.
382
382
  return new Response('Wrong e-mail or password', { status: 401 });
383
383
  ```
384
384
 
@@ -401,7 +401,7 @@ janus({ ..., hasher: scryptHasher(), verifiers: [bcryptVerifier] }); // a Passwo
401
401
  ### `USER_INACTIVE` — `<call>: the user is inactive`
402
402
 
403
403
  **When:** `signIn`, with the **right** password, for a user set inactive.
404
- **Why:** an inactive user keeps their record and password, and every sign-in is refused. It is checked after the password, so only somebody who knows the password learns the account is inactive.
404
+ **Why:** an inactive user keeps their record and password, and every sign-in is refused. It is checked after the password, so only somebody who knows the password learns the user is inactive.
405
405
  **Fix:** answer 403, or reactivate: `await auth.setActive(user, true)`.
406
406
 
407
407
  ### `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`
@@ -469,7 +469,7 @@ do {
469
469
  Also `list: listing <type>#<permission> crossed more than <n> relations without an answer`. `PermissionDepthError`, carrying `permission` and `maxDepth`.
470
470
 
471
471
  **When:** `access.can(...)` or `access.list(...)`.
472
- **Why:** the walk crossed more than `maxDepth` relations (25 by default) and found no grant. It is not a denial: a check that stopped half-way decided nothing. A cycle in the data — a team member of itself — is cut silently and never causes this.
472
+ **Why:** the walk crossed more than `maxDepth` relations (25 by default) and reached no decision. It is not a denial: a check that stopped half-way decided nothing. A cycle in the data — a team member of itself — is cut silently and never causes this.
473
473
  **Fix:** answer 500 and look at the model or the data: a very deep hierarchy, or a chain of subject sets. If the depth is genuine, raise it:
474
474
 
475
475
  ```ts
@@ -579,7 +579,7 @@ most common:
579
579
 
580
580
  | Message | Fix |
581
581
  | --- | --- |
582
- | `defineModel: subjects must be an array of user types — pass auth.types from janus()` | `defineModel({ subjects: auth.types, types })`. |
582
+ | `defineModel: subjects must be an array of subject type names — auth.types from janus(), or your own` | `defineModel({ subjects: auth.types, types })` with `janus()`, or your own names alone: `subjects: ['user']`. |
583
583
  | `defineModel: types declares no object type` | Declare at least one type under `types`. |
584
584
  | `defineModel: the object type "<name>" must be a camelCase name — letters and digits, starting with a lowercase letter` | Also for relation and permission names. |
585
585
  | `defineModel: "<name>" names a user type and an object type; a subject of type "<name>" would be ambiguous` | Rename the object type. |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nxgt/janus",
3
- "version": "0.1.0",
4
- "description": "Embeddable, type-safe authentication and permissions: bring your own database",
3
+ "version": "0.1.2",
4
+ "description": "Embeddable, type-safe identities and permissions: bring your own database",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",
@@ -1,11 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": ["../src/pagination/cursor-page.ts", "../src/auth/outage.ts"],
4
- "sourcesContent": [
5
- "import { InvalidCursorError } from '../errors/janus-error';\n\n/**\n * One page of results, and where the next one starts.\n *\n * `nextCursor` is `null` on the last page and a string on every other, so\n * `while (cursor)` is the loop and there is no separate \"done\" flag to forget.\n *\n * **There is no `total`**, and that is deliberate — the same choice\n * `@nxgt/mongo`'s cursor page makes, for the same reason: an exact count over a\n * large table is a second scan, and no adapter should be made to promise one.\n * An application that needs a count asks its own store for one, where it can\n * decide what the count is allowed to cost.\n *\n * This type is **redefined here rather than imported** from `@nxgt/mongo`. That\n * is deliberate duplication, recorded in AGENTS.md: a port that every adapter\n * implements cannot make one database library a dependency of the contract.\n */\nexport interface CursorPage<T> {\n\treadonly items: readonly T[];\n\treadonly nextCursor: string | null;\n}\n\n/** How many a page holds when the caller did not say. */\nexport const DEFAULT_PAGE_SIZE = 20;\n\n/**\n * The most a page may hold, whatever the caller asked for.\n *\n * Bounded in the core rather than left to each adapter, so a caller cannot ask\n * one store for a million rows and be refused by another. An application that\n * wants everything pages for it.\n */\nexport const MAX_PAGE_SIZE = 100;\n\n/**\n * The limit a store will be given, from the limit a caller asked for.\n *\n * `where` names the call the consumer wrote — `listIdentities`, not an internal\n * function — because several calls in this package take a `limit` and a message\n * that does not say which one leaves the reader to guess. That naming rule is\n * `nxgt-data`'s, and it is the reason its errors read the way they do.\n *\n * A bare `TypeError`: a limit is written in the application's own code, so this\n * cannot come from a request.\n */\nexport function pageLimit(limit: number | undefined, where: string): number {\n\tif (limit === undefined) return DEFAULT_PAGE_SIZE;\n\n\tif (!Number.isInteger(limit) || limit < 1) {\n\t\tthrow new TypeError(\n\t\t\t`${where}: limit must be an integer of at least 1, or absent`,\n\t\t);\n\t}\n\n\treturn Math.min(limit, MAX_PAGE_SIZE);\n}\n\n/**\n * Refuses a cursor this store did not mint.\n *\n * Exported for adapter authors: a cursor from another store, another ordering\n * or another version of the adapter must be refused, **never treated as an\n * absent cursor**. A silent fall back to the first page makes a caller paging a\n * list loop for ever, and the loop looks like a slow query rather than a bug.\n */\nexport function invalidCursor(\n\twhere: string,\n\tcursor: string,\n): InvalidCursorError {\n\t// The cursor's own bytes are not in the message: it is opaque, it may be\n\t// long, and printing it tells the reader nothing they can act on. Its length\n\t// is enough to tell \"truncated in transit\" from \"written by another store\".\n\treturn new InvalidCursorError(\n\t\t`${where}: this cursor was not minted by this store, or was minted for another ordering (${cursor.length} characters)`,\n\t);\n}\n",
6
- "/**\n * **The one place a store call is caught, and the one place a `null` becomes an\n * error.**\n *\n * The transposition of `call.ts` in `nxgt-ory`'s SDK — \"the only place a call\n * is unwrapped\" — into a core with no status codes. The invariant this whole\n * package is built around is checked by reading this file:\n *\n * > An absence is `null`. A failure throws.\n *\n * `outage.spec.ts` reads every other file of `src/auth/` and fails if a\n * `catch` appears in one, because the failure this design exists to prevent is\n * a single careless `catch { return null }`. There are two `catch`es below: the\n * guard's always rethrows, and {@link unlessVersionConflict}'s absorbs one\n * named conflict and rethrows everything else. The spec holds both to that.\n */\n\nimport {\n\ttype JanusError,\n\tJanusError as JanusErrorClass,\n\tStoreConflict,\n\tStoreFailure,\n} from '../errors/janus-error';\nimport type { RelationStore } from '../permissions/port/types';\nimport type { JanusStores } from './port/types';\n\ntype Slot = keyof JanusStores | 'relations';\n\n/**\n * The methods whose answer is legitimately nothing: `undefined` from them is\n * not a forgotten `return`. Every other method answers a value, `null`,\n * `false`, `0` or a page — **never `undefined`**.\n */\nconst ANSWERS_NOTHING = new Set(['insertSession', 'insertToken', 'write']);\n\n/**\n * The stores, with every method guarded.\n *\n * - A `JanusError` the adapter threw — `StoreFailure`, `StoreConflict`,\n * `NotFoundError` — passes through untouched, so `instanceof` still holds.\n * - **Anything else it threw is a failure**: a driver error, a `TypeError` from\n * a bug in the adapter, a string. It becomes `StoreFailure` with the original\n * as `cause`, naming the slot and the method. It is never an absence.\n * - A method that answers `undefined` where the port says `null` is a store\n * that forgot to answer, and becomes `StoreFailure` rather than \"not found\".\n * Rule 2 of the port, enforced at run time for the JavaScript adapter the\n * compiler never saw.\n *\n * The optional `deleteExpiredSessions` is guarded when present and left absent\n * when absent, so capability detection still reads the truth.\n */\nexport function guardStores(stores: JanusStores): JanusStores {\n\treturn {\n\t\tusers: guardSlot('users', stores.users),\n\t\tsessions: guardSlot('sessions', stores.sessions),\n\t\ttokens: guardSlot('tokens', stores.tokens),\n\t};\n}\n\n/**\n * The relation store, guarded the same way: the permission engine's only way\n * to a store, so a failure there is `STORE_FAILED` and never a denial.\n */\nexport function guardRelations(store: RelationStore): RelationStore {\n\treturn guardSlot('relations', store);\n}\n\nfunction guardSlot<S extends object>(slot: Slot, store: S): S {\n\tconst guarded: Record<string, unknown> = {};\n\n\tfor (const method of methodsOf(store)) {\n\t\tconst original = (store as Record<string, unknown>)[method];\n\t\tif (typeof original !== 'function') continue;\n\n\t\tguarded[method] = async (...args: unknown[]) => {\n\t\t\tlet answer: unknown;\n\n\t\t\ttry {\n\t\t\t\tanswer = await original.apply(store, args);\n\t\t\t} catch (error) {\n\t\t\t\t// Rethrown, always. The only question is under which class.\n\t\t\t\tthrow asFailure(error, slot, method);\n\t\t\t}\n\n\t\t\tif (answer === undefined && !ANSWERS_NOTHING.has(method)) {\n\t\t\t\tthrow new StoreFailure(\n\t\t\t\t\t`${slot}.${method} answered undefined: an absence is null, so this store forgot to answer`,\n\t\t\t\t\t{ slot, operation: method },\n\t\t\t\t);\n\t\t\t}\n\n\t\t\treturn answer;\n\t\t};\n\t}\n\n\treturn guarded as S;\n}\n\n/** Own and prototype methods, so a class-based store is guarded like a literal. */\nfunction methodsOf(store: object): string[] {\n\tconst names = new Set<string>();\n\n\tfor (\n\t\tlet proto: object | null = store;\n\t\tproto !== null && proto !== Object.prototype;\n\t\tproto = Object.getPrototypeOf(proto)\n\t) {\n\t\tfor (const name of Object.getOwnPropertyNames(proto)) {\n\t\t\tif (name !== 'constructor') names.add(name);\n\t\t}\n\t}\n\n\treturn [...names];\n}\n\nfunction asFailure(error: unknown, slot: Slot, method: string): JanusError {\n\tif (error instanceof JanusErrorClass) return error;\n\n\t// The message names where, never what: a driver's message may hold a\n\t// connection string, and a connection string holds a password. It is kept\n\t// as `cause`, for the operator's logs.\n\treturn new StoreFailure(`${slot}.${method}: the store could not answer`, {\n\t\tslot,\n\t\toperation: method,\n\t\tcause: error,\n\t});\n}\n\n/**\n * A value the caller requires, or the refusal an absence deserves.\n *\n * The only place in the core where a `null` from a store turns into an error —\n * `get` becoming `NOT_FOUND`. Written as a function so that the conversion is\n * visible at every call site and nowhere else.\n */\nexport function required<T>(value: T | null, absent: () => JanusError): T {\n\tif (value === null) throw absent();\n\treturn value;\n}\n\n/**\n * A write that may lose a race, and is allowed to: its answer, or `null` when\n * the record's version moved under it.\n *\n * **Only `StoreConflict('version')` is absorbed.** A failure still throws, a\n * taken login still throws, `NOT_FOUND` still throws: an outage never reads as\n * \"somebody else wrote first\". It exists for writes the caller did not ask for\n * — rewriting a password hash on sign-in — where losing to a concurrent update\n * means only that the next sign-in tries again.\n */\nexport async function unlessVersionConflict<T>(\n\twrite: Promise<T>,\n): Promise<T | null> {\n\ttry {\n\t\treturn await write;\n\t} catch (error) {\n\t\tif (error instanceof StoreConflict && error.on === 'version') return null;\n\t\tthrow error;\n\t}\n}\n"
7
- ],
8
- "mappings": ";;;;;;;;AAwBO,IAAM,qBAAoB;AAS1B,IAAM,iBAAgB;AAatB,SAAS,UAAS,CAAC,OAA2B,OAAuB;AAAA,EAC3E,IAAI,UAAU;AAAA,IAAW,OAAO;AAAA,EAEhC,IAAI,CAAC,OAAO,UAAU,KAAK,KAAK,QAAQ,GAAG;AAAA,IAC1C,MAAM,IAAI,UACT,GAAG,0DACJ;AAAA,EACD;AAAA,EAEA,OAAO,KAAK,IAAI,OAAO,cAAa;AAAA;AAW9B,SAAS,cAAa,CAC5B,OACA,QACqB;AAAA,EAIrB,OAAO,IAAI,oBACV,GAAG,wFAAwF,OAAO,oBACnG;AAAA;;;AC1CD,IAAM,kBAAkB,IAAI,IAAI,CAAC,iBAAiB,eAAe,OAAO,CAAC;AAkBlE,SAAS,WAAW,CAAC,QAAkC;AAAA,EAC7D,OAAO;AAAA,IACN,OAAO,UAAU,SAAS,OAAO,KAAK;AAAA,IACtC,UAAU,UAAU,YAAY,OAAO,QAAQ;AAAA,IAC/C,QAAQ,UAAU,UAAU,OAAO,MAAM;AAAA,EAC1C;AAAA;AAOM,SAAS,cAAc,CAAC,OAAqC;AAAA,EACnE,OAAO,UAAU,aAAa,KAAK;AAAA;AAGpC,SAAS,SAA2B,CAAC,MAAY,OAAa;AAAA,EAC7D,MAAM,UAAmC,CAAC;AAAA,EAE1C,WAAW,UAAU,UAAU,KAAK,GAAG;AAAA,IACtC,MAAM,WAAY,MAAkC;AAAA,IACpD,IAAI,OAAO,aAAa;AAAA,MAAY;AAAA,IAEpC,QAAQ,UAAU,UAAU,SAAoB;AAAA,MAC/C,IAAI;AAAA,MAEJ,IAAI;AAAA,QACH,SAAS,MAAM,SAAS,MAAM,OAAO,IAAI;AAAA,QACxC,OAAO,OAAO;AAAA,QAEf,MAAM,UAAU,OAAO,MAAM,MAAM;AAAA;AAAA,MAGpC,IAAI,WAAW,aAAa,CAAC,gBAAgB,IAAI,MAAM,GAAG;AAAA,QACzD,MAAM,IAAI,cACT,GAAG,QAAQ,iFACX,EAAE,MAAM,WAAW,OAAO,CAC3B;AAAA,MACD;AAAA,MAEA,OAAO;AAAA;AAAA,EAET;AAAA,EAEA,OAAO;AAAA;AAIR,SAAS,SAAS,CAAC,OAAyB;AAAA,EAC3C,MAAM,QAAQ,IAAI;AAAA,EAElB,SACK,QAAuB,MAC3B,UAAU,QAAQ,UAAU,OAAO,WACnC,QAAQ,OAAO,eAAe,KAAK,GAClC;AAAA,IACD,WAAW,QAAQ,OAAO,oBAAoB,KAAK,GAAG;AAAA,MACrD,IAAI,SAAS;AAAA,QAAe,MAAM,IAAI,IAAI;AAAA,IAC3C;AAAA,EACD;AAAA,EAEA,OAAO,CAAC,GAAG,KAAK;AAAA;AAGjB,SAAS,SAAS,CAAC,OAAgB,MAAY,QAA4B;AAAA,EAC1E,IAAI,iBAAiB;AAAA,IAAiB,OAAO;AAAA,EAK7C,OAAO,IAAI,cAAa,GAAG,QAAQ,sCAAsC;AAAA,IACxE;AAAA,IACA,WAAW;AAAA,IACX,OAAO;AAAA,EACR,CAAC;AAAA;AAUK,SAAS,QAAW,CAAC,OAAiB,QAA6B;AAAA,EACzE,IAAI,UAAU;AAAA,IAAM,MAAM,OAAO;AAAA,EACjC,OAAO;AAAA;AAaR,eAAsB,qBAAwB,CAC7C,OACoB;AAAA,EACpB,IAAI;AAAA,IACH,OAAO,MAAM;AAAA,IACZ,OAAO,OAAO;AAAA,IACf,IAAI,iBAAiB,kBAAiB,MAAM,OAAO;AAAA,MAAW,OAAO;AAAA,IACrE,MAAM;AAAA;AAAA;",
9
- "debugId": "047FF5FD235ABA3764756E2164756E21",
10
- "names": []
11
- }