@nxgt/janus 0.1.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/LICENSE +21 -0
- package/README.md +554 -0
- package/dist/auth/config.d.ts +201 -0
- package/dist/auth/config.d.ts.map +1 -0
- package/dist/auth/context.d.ts +87 -0
- package/dist/auth/context.d.ts.map +1 -0
- package/dist/auth/hashers.d.ts +35 -0
- package/dist/auth/hashers.d.ts.map +1 -0
- package/dist/auth/index.d.ts +9 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/janus.d.ts +38 -0
- package/dist/auth/janus.d.ts.map +1 -0
- package/dist/auth/outage.d.ts +61 -0
- package/dist/auth/outage.d.ts.map +1 -0
- package/dist/auth/port/assert-stores.d.ts +16 -0
- package/dist/auth/port/assert-stores.d.ts.map +1 -0
- package/dist/auth/port/memory.d.ts +25 -0
- package/dist/auth/port/memory.d.ts.map +1 -0
- package/dist/auth/port/types.d.ts +393 -0
- package/dist/auth/port/types.d.ts.map +1 -0
- package/dist/auth/secrets.d.ts +17 -0
- package/dist/auth/secrets.d.ts.map +1 -0
- package/dist/auth/sessions.d.ts +31 -0
- package/dist/auth/sessions.d.ts.map +1 -0
- package/dist/auth/standard-schema.d.ts +41 -0
- package/dist/auth/standard-schema.d.ts.map +1 -0
- package/dist/auth/types.d.ts +353 -0
- package/dist/auth/types.d.ts.map +1 -0
- package/dist/auth/users.d.ts +9 -0
- package/dist/auth/users.d.ts.map +1 -0
- package/dist/chunks/index-658b6mr2.js +269 -0
- package/dist/chunks/index-658b6mr2.js.map +11 -0
- package/dist/chunks/index-6dytvy3h.js +93 -0
- package/dist/chunks/index-6dytvy3h.js.map +11 -0
- package/dist/chunks/index-6p56fpbe.js +147 -0
- package/dist/chunks/index-6p56fpbe.js.map +12 -0
- package/dist/chunks/index-fgb3t64y.js +77 -0
- package/dist/chunks/index-fgb3t64y.js.map +10 -0
- package/dist/conformance/assert.d.ts +36 -0
- package/dist/conformance/assert.d.ts.map +1 -0
- package/dist/conformance/cases/outage.d.ts +3 -0
- package/dist/conformance/cases/outage.d.ts.map +1 -0
- package/dist/conformance/cases/sessions.d.ts +3 -0
- package/dist/conformance/cases/sessions.d.ts.map +1 -0
- package/dist/conformance/cases/tokens.d.ts +3 -0
- package/dist/conformance/cases/tokens.d.ts.map +1 -0
- package/dist/conformance/cases/users.d.ts +3 -0
- package/dist/conformance/cases/users.d.ts.map +1 -0
- package/dist/conformance/describe.d.ts +74 -0
- package/dist/conformance/describe.d.ts.map +1 -0
- package/dist/conformance/fixtures.d.ts +11 -0
- package/dist/conformance/fixtures.d.ts.map +1 -0
- package/dist/conformance/index.d.ts +33 -0
- package/dist/conformance/index.d.ts.map +1 -0
- package/dist/conformance/index.js +1113 -0
- package/dist/conformance/index.js.map +18 -0
- package/dist/conformance/reference.d.ts +13 -0
- package/dist/conformance/reference.d.ts.map +1 -0
- package/dist/conformance/relations.d.ts +67 -0
- package/dist/conformance/relations.d.ts.map +1 -0
- package/dist/conformance/types.d.ts +71 -0
- package/dist/conformance/types.d.ts.map +1 -0
- package/dist/errors/janus-error.d.ts +259 -0
- package/dist/errors/janus-error.d.ts.map +1 -0
- package/dist/ids/id.d.ts +55 -0
- package/dist/ids/id.d.ts.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +962 -0
- package/dist/index.js.map +19 -0
- package/dist/pagination/cursor-page.d.ts +53 -0
- package/dist/pagination/cursor-page.d.ts.map +1 -0
- package/dist/permissions/engine.d.ts +46 -0
- package/dist/permissions/engine.d.ts.map +1 -0
- package/dist/permissions/index.d.ts +24 -0
- package/dist/permissions/index.d.ts.map +1 -0
- package/dist/permissions/index.js +685 -0
- package/dist/permissions/index.js.map +15 -0
- package/dist/permissions/input.d.ts +29 -0
- package/dist/permissions/input.d.ts.map +1 -0
- package/dist/permissions/model.d.ts +368 -0
- package/dist/permissions/model.d.ts.map +1 -0
- package/dist/permissions/port/memory.d.ts +17 -0
- package/dist/permissions/port/memory.d.ts.map +1 -0
- package/dist/permissions/port/types.d.ts +84 -0
- package/dist/permissions/port/types.d.ts.map +1 -0
- package/dist/permissions/resolve.d.ts +59 -0
- package/dist/permissions/resolve.d.ts.map +1 -0
- package/dist/permissions/reverse.d.ts +53 -0
- package/dist/permissions/reverse.d.ts.map +1 -0
- package/dist/permissions/walk.d.ts +29 -0
- package/dist/permissions/walk.d.ts.map +1 -0
- package/dist/subjects/notation.d.ts +35 -0
- package/dist/subjects/notation.d.ts.map +1 -0
- package/dist/subjects/subject.d.ts +75 -0
- package/dist/subjects/subject.d.ts.map +1 -0
- package/dist/time/clock.d.ts +31 -0
- package/dist/time/clock.d.ts.map +1 -0
- package/dist/time/duration.d.ts +23 -0
- package/dist/time/duration.d.ts.map +1 -0
- package/docs/README.md +17 -0
- package/docs/guide/adapters.md +296 -0
- package/docs/guide/email-flows.md +146 -0
- package/docs/guide/errors.md +146 -0
- package/docs/guide/passwords.md +136 -0
- package/docs/guide/permissions.md +347 -0
- package/docs/guide/sessions.md +211 -0
- package/docs/guide/users.md +277 -0
- package/docs/guide/vocabulary.md +163 -0
- package/docs/roadmap.md +93 -0
- package/docs/troubleshooting.md +648 -0
- package/package.json +70 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Steve Tsala
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
# @nxgt/janus
|
|
2
|
+
|
|
3
|
+
Authentication and permissions as an **embeddable** TypeScript library: your
|
|
4
|
+
process, your database, behind a port you can implement.
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
|
|
9
|
+
|
|
10
|
+
const auth = janus({
|
|
11
|
+
user: z.object({ email: z.email(), name: z.string() }),
|
|
12
|
+
password: { login: 'email' },
|
|
13
|
+
store: createMemoryStores(),
|
|
14
|
+
hasher: scryptHasher(),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const { user, token } = await auth.signUp({ email, name, password });
|
|
18
|
+
const current = await auth.authenticate(request); // { user, session, token, renewed } | null
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
> **0.x.** A minor version may still change the surface. `.` is `janus()` and the vocabulary it shares with the
|
|
22
|
+
> permissions — errors, subjects, pagination, time, ids. `./permissions` is the
|
|
23
|
+
> ReBAC engine. `./conformance` is the suite an adapter runs. A subpath appears in `exports`
|
|
24
|
+
> only once it exports something you should call, because a published entry
|
|
25
|
+
> point is a promise.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
bun add @nxgt/janus
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
No runtime dependency. `typescript` (6) is a required peer. Your tsconfig resolves as a
|
|
34
|
+
bundler does (`"moduleResolution": "bundler"`, which Bun and every bundler
|
|
35
|
+
use): the declarations import without extensions, so `nodenext` is not
|
|
36
|
+
supported.
|
|
37
|
+
|
|
38
|
+
## Subpaths
|
|
39
|
+
|
|
40
|
+
| Import | What it holds |
|
|
41
|
+
| --- | --- |
|
|
42
|
+
| `@nxgt/janus` | `janus()`, the store port and its reference store (`createMemoryStores`), the hashers, and the vocabulary shared with the permissions: errors, subjects and the tuple notation, ids, pagination, time |
|
|
43
|
+
| `@nxgt/janus/permissions` | `defineModel`, `fromField`, `when`, `permissions()` — `can`, `list`, `grant`, `revoke` — the `RelationStore` port and `createMemoryRelations()` |
|
|
44
|
+
| `@nxgt/janus/conformance` | The suites an adapter runs — `describeJanusStores`, `describeRelationStores` — their cases as data, and the reference harnesses |
|
|
45
|
+
|
|
46
|
+
## The one rule
|
|
47
|
+
|
|
48
|
+
**An absence is `null`. A failure throws.**
|
|
49
|
+
|
|
50
|
+
Everything else in this package is downstream of that sentence. A store that
|
|
51
|
+
cannot answer — a refused connection, a timeout, a primary stepping down, a bug
|
|
52
|
+
in the adapter — **throws**, and a caller answers 503. Mapping that to a 404, to
|
|
53
|
+
`null` or to `false` turns an outage into a silent lockout: everybody who has an
|
|
54
|
+
account is told they do not. That has been measured twice in this organisation,
|
|
55
|
+
two days apart, which is why it is a term of the port here rather than a note in
|
|
56
|
+
the documentation.
|
|
57
|
+
|
|
58
|
+
## API
|
|
59
|
+
|
|
60
|
+
### Errors
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
import { JanusError, StoreFailure, NotFoundError, type JanusErrorCode } from '@nxgt/janus';
|
|
64
|
+
|
|
65
|
+
async function signIn(email: string, password: string): Promise<Response> {
|
|
66
|
+
try {
|
|
67
|
+
const { token } = await auth.signIn({ email, password });
|
|
68
|
+
return Response.json({ token });
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error instanceof JanusError && error.code === 'CREDENTIALS_INVALID') {
|
|
71
|
+
return new Response(null, { status: 401 });
|
|
72
|
+
}
|
|
73
|
+
throw error; // STORE_FAILED included: that is your 503, never a 401
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`JanusError` is the base of everything thrown at call time. It extends `Error`,
|
|
79
|
+
so no consumer has to order their `catch` blocks. `code` is a union of sixteen
|
|
80
|
+
string literals, so a `switch` over it is exhaustive and adding a code breaks the
|
|
81
|
+
compilation of callers that exhaust it:
|
|
82
|
+
|
|
83
|
+
| Code | Answer it deserves |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| `STORE_FAILED` | **503.** Never a negative answer |
|
|
86
|
+
| `NOT_FOUND` | 404 |
|
|
87
|
+
| `LOGIN_TAKEN`, `VERSION_CONFLICT` | 409 |
|
|
88
|
+
| `USER_INVALID` | 400, field by field from `issues` |
|
|
89
|
+
| `PASSWORD_TOO_SHORT`, `HASH_UNSUPPORTED` | 400 |
|
|
90
|
+
| `CREDENTIALS_INVALID` | 401 — one code for an unknown login, no password and a wrong one |
|
|
91
|
+
| `USER_INACTIVE` | 403 |
|
|
92
|
+
| `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`, `TOKEN_STALE` | 400 |
|
|
93
|
+
| `INVALID_CURSOR` | 400 |
|
|
94
|
+
| `UNSUPPORTED` | 501 — a wiring mistake, and the message names the store to change |
|
|
95
|
+
| `PERMISSION_DEPTH` | 500 — a permission check or list walked past `maxDepth`; not a denial |
|
|
96
|
+
|
|
97
|
+
Each code has its class, all exported: `StoreFailure`, `StoreConflict` (`on:
|
|
98
|
+
'login' | 'version'`), `NotFoundError`, `UserInvalidError`, `CredentialError`,
|
|
99
|
+
`UserInactiveError`, `TokenError`, `InvalidCursorError`, `UnsupportedError`
|
|
100
|
+
and `PermissionDepthError`. `StoreFailure` and `StoreConflict` are exported
|
|
101
|
+
**because an adapter throws them**. An adapter defines no error class of its own, so `instanceof` holds
|
|
102
|
+
across the two packages.
|
|
103
|
+
|
|
104
|
+
**No message ever holds a secret** — not a password, not a hash, not a session
|
|
105
|
+
token, not a token's hash, and not a connection URI, because a connection string
|
|
106
|
+
holds a password. A `login` may appear in a `LOGIN_TAKEN` message, since the
|
|
107
|
+
caller just sent it.
|
|
108
|
+
|
|
109
|
+
A refusal that can only come from how you wired the library — a lifespan that is
|
|
110
|
+
not a duration, a store missing a method — throws a bare `TypeError` instead. No
|
|
111
|
+
request handler should ever answer one, so no handler needs to tell it apart.
|
|
112
|
+
|
|
113
|
+
### Subjects
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
import { type Subject, subjectOf, formatTuple, parseTuple, isSubjectSet } from '@nxgt/janus';
|
|
117
|
+
|
|
118
|
+
subjectOf(user); // { type: 'staff', id: '…' }: the user IS the subject
|
|
119
|
+
formatTuple({
|
|
120
|
+
object: { type: 'record', id: 'r1' },
|
|
121
|
+
relation: 'viewer',
|
|
122
|
+
subject: { type: 'team', id: 't1', relation: 'member' },
|
|
123
|
+
});
|
|
124
|
+
// 'record:r1#viewer@team:t1#member'
|
|
125
|
+
parseTuple('record:r1#viewer@staff:u1'); // the RelationTuple back
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`formatEntity`, `formatSubject` and `parseSubject` do the same for one part,
|
|
129
|
+
and `isSubjectSet` tells `{ type, id, relation }` from `{ type, id }`. The
|
|
130
|
+
types are `Entity`, `SubjectSet`, `Subject` (either) and `RelationTuple`.
|
|
131
|
+
|
|
132
|
+
In Ory, the equality between a Kratos identity id and Keto's `subject_id` is a
|
|
133
|
+
comment and a convention, restated in three repositories and enforced nowhere.
|
|
134
|
+
Here it is a type and a one-line function — and that shared vocabulary is the
|
|
135
|
+
reason users and permissions are one package rather than two.
|
|
136
|
+
|
|
137
|
+
**Subjects are typed**, unlike Keto's: `{ type, id }` for one entity, and
|
|
138
|
+
`{ type, id, relation }` for a subject set. One application has patients and
|
|
139
|
+
staff, and an object can hold a relation too, so a bare id does not say who.
|
|
140
|
+
`type` is the same word as a user's own.
|
|
141
|
+
|
|
142
|
+
### Ids
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
import { type Id, mintId, isId, mintedAt } from '@nxgt/janus';
|
|
146
|
+
|
|
147
|
+
const id: Id = mintId(); // '0199…': a UUIDv7
|
|
148
|
+
isId(id); // true — and false for anything this package could not have minted
|
|
149
|
+
mintedAt(id); // a Date, to the millisecond
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
UUIDv7, **minted by the core and not by the store**. Ids sort in creation order
|
|
153
|
+
as strings, so the pagination cursor *is* the last id: one index, and the
|
|
154
|
+
ordering is already total. `insertUser` becomes idempotent under retry, and
|
|
155
|
+
every adapter reports the same shape. The price, stated plainly: an adapter
|
|
156
|
+
cannot reuse an existing numeric primary key.
|
|
157
|
+
|
|
158
|
+
### Pagination and time
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
import { fixedClock, parseDuration } from '@nxgt/janus';
|
|
162
|
+
|
|
163
|
+
let cursor: string | null = null;
|
|
164
|
+
do {
|
|
165
|
+
const page = await auth.list({ after: cursor, limit: 100 }); // CursorPage<User>
|
|
166
|
+
cursor = page.nextCursor;
|
|
167
|
+
} while (cursor);
|
|
168
|
+
|
|
169
|
+
const clock = fixedClock(Date.UTC(2026, 0, 1)); // .now(), .advance(ms), .set(at)
|
|
170
|
+
parseDuration('8h', 'session.lifespan'); // 28800000
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The types are `CursorPage<T>`, `Clock` and `Duration` (`'15m'`, `'8h'`, `'7d'`,
|
|
174
|
+
or milliseconds). `DEFAULT_PAGE_SIZE` (20), `MAX_PAGE_SIZE` (100), `pageLimit` and
|
|
175
|
+
`invalidCursor` are what an adapter uses to page the way the core does;
|
|
176
|
+
`systemClock` is the default `Clock`.
|
|
177
|
+
|
|
178
|
+
`CursorPage` has `items` and `nextCursor`, and **no `total`**: a count over a
|
|
179
|
+
cursor-paged collection is a second query whose answer is stale by the time you
|
|
180
|
+
read it. `nextCursor` is `string | null` with no `undefined`, so `while (cursor)`
|
|
181
|
+
is the loop.
|
|
182
|
+
|
|
183
|
+
`fixedClock` is **shipped, not test-only** — testing session expiry needs it, and
|
|
184
|
+
so do your own tests.
|
|
185
|
+
|
|
186
|
+
### Users — `janus()`
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
import { z } from 'zod';
|
|
190
|
+
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
|
|
191
|
+
|
|
192
|
+
// One kind of user
|
|
193
|
+
const auth = janus({
|
|
194
|
+
user: z.object({ email: z.email(), name: z.string() }),
|
|
195
|
+
password: { login: 'email' },
|
|
196
|
+
store: createMemoryStores(),
|
|
197
|
+
hasher: scryptHasher(),
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
await auth.signUp({ email, name, password }); // { user, session, token }
|
|
201
|
+
await auth.signIn({ email, password }); // { user, session, token }
|
|
202
|
+
await auth.authenticate(request); // { user, session, token, renewed } | null
|
|
203
|
+
await auth.signOut(request);
|
|
204
|
+
await auth.verifyEmail.send(user); // { token, email, expiresAt } — sending it is yours
|
|
205
|
+
await auth.verifyEmail.confirm(token);
|
|
206
|
+
await auth.resetPassword.request(email); // … | null
|
|
207
|
+
await auth.resetPassword.confirm(token, newPassword);
|
|
208
|
+
|
|
209
|
+
// Several kinds of user
|
|
210
|
+
const clinic = janus({
|
|
211
|
+
users: {
|
|
212
|
+
patient: { schema: Patient, password: { login: 'email' } },
|
|
213
|
+
staff: {
|
|
214
|
+
schema: Staff,
|
|
215
|
+
password: { login: 'username' },
|
|
216
|
+
session: { lifespan: '8h', renewAfter: false },
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
store,
|
|
220
|
+
hasher,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
await clinic.staff.signIn({ username, password });
|
|
224
|
+
const current = await clinic.authenticate(request);
|
|
225
|
+
if (current?.user.type === 'staff') current.user.service; // narrowed by type
|
|
226
|
+
await clinic.authenticate(request, { type: 'staff' }); // a patient's session → null
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
`janus()` assembles **synchronously and with no I/O**: it checks that every
|
|
230
|
+
store answers every method of the port, and connects to nothing. Everything
|
|
231
|
+
else reaches the store and is asynchronous.
|
|
232
|
+
|
|
233
|
+
- **A user is your schema's fields, at the top level**, plus what `janus` sets:
|
|
234
|
+
`id`, `type`, `emailVerified`, `active`, `hasPassword`, `version`,
|
|
235
|
+
`createdAt`, `updatedAt`. A schema declaring one of those, or a `password`, is
|
|
236
|
+
refused at compile time. The password hash never reaches a user.
|
|
237
|
+
- **Schemas** are any [Standard Schema](https://standardschema.dev) — Zod 4,
|
|
238
|
+
Valibot, ArkType. There is no validation peer. The output must be JSON, and a
|
|
239
|
+
schema producing a `Date` is refused at compile time.
|
|
240
|
+
- **Several user types** live in one instance: `auth.patient.*`, `auth.staff.*`,
|
|
241
|
+
and one `authenticate` whose answer is a union narrowed by `user.type`. A
|
|
242
|
+
login is unique **per type**: the same e-mail may hold a patient account and
|
|
243
|
+
a staff account.
|
|
244
|
+
- **`password.login`** names a top-level, required string field. A typo is a
|
|
245
|
+
compile error on `login`, and the message lists the fields you could have
|
|
246
|
+
meant. It is normalised with `'lowercaseTrim'` unless you say otherwise.
|
|
247
|
+
- **`email`** defaults to the field named `email`. A type without one has no
|
|
248
|
+
`verifyEmail` and no `resetPassword` — they are absent from its type, not
|
|
249
|
+
failing at run time. Changing the e-mail sets `emailVerified` back to `false`.
|
|
250
|
+
- **Per type**: `create`, `find` (or `null`), `get` (or `NOT_FOUND`), `list`,
|
|
251
|
+
`update(user, patch)` — merged over the stored fields, then validated whole —
|
|
252
|
+
`setActive` and `delete`; with a password, `signUp`, `signIn`, `findByLogin`,
|
|
253
|
+
`setPassword` and `changePassword`. Every write but `delete` takes an
|
|
254
|
+
optional `ifVersion`.
|
|
255
|
+
- **`delete(user)`** deletes the user together with every session and one-time
|
|
256
|
+
token they had, so nothing of theirs is kept: a token holds the e-mail it was
|
|
257
|
+
sent to. The user goes first, so an outage half-way leaves only sessions and
|
|
258
|
+
tokens that authenticate nobody. It is idempotent, and calling it again
|
|
259
|
+
finishes the job. It answers `false` for an unknown id, or for one of another
|
|
260
|
+
type, and leaves that user untouched.
|
|
261
|
+
- **Shared**: `authenticate`, `signOut`, `signOutEverywhere(user, { except })`,
|
|
262
|
+
`findUser` and `getUser` across types, `cookie.serialize(token, session)` and
|
|
263
|
+
`cookie.clear()` — `HttpOnly; SameSite=Lax; Secure` unless you say otherwise
|
|
264
|
+
— and `collectExpired`.
|
|
265
|
+
- **Sessions** last `'7d'` and slide: `authenticate` renews one once `renewAfter`
|
|
266
|
+
(`'1d'`) has passed, writing at most once per period, and says so with
|
|
267
|
+
`renewed`. The token is handed back once; the store only holds its `sha256`.
|
|
268
|
+
|
|
269
|
+
**Hashers.** `scryptHasher()` runs on Node and on Bun, with no dependency and
|
|
270
|
+
OWASP's parameters (N = 2^17, r = 8, p = 1). `bunHasher()` is argon2id through
|
|
271
|
+
`Bun.password`, on Bun only. There is no silent fallback: a user type with a
|
|
272
|
+
password and no `hasher` is refused at wiring. Hashes describe themselves
|
|
273
|
+
(`$scrypt$ln=17,r=8,p=1$…`, `$argon2id$…`). Wire the hashers a database was
|
|
274
|
+
written with as `verifiers`, and every one of them can verify while exactly one
|
|
275
|
+
hashes.
|
|
276
|
+
|
|
277
|
+
**Rehash on sign-in.** When a password matches a stale hash, `signIn` rewrites
|
|
278
|
+
it with `hasher`. A hash is stale when a `verifiers` hasher wrote it, or when
|
|
279
|
+
`hasher` wrote it with other parameters than it uses now: a raised scrypt
|
|
280
|
+
`cost`, or argon2id parameters other than the pinned `m=65536,t=2,p=1`. Moving
|
|
281
|
+
off a hasher, or raising its cost, therefore reaches every active user with no
|
|
282
|
+
migration to run. The password's `updatedAt` is kept, since the password did not
|
|
283
|
+
change; the user's `version` moves. The write happens only at the version just
|
|
284
|
+
read. If a concurrent update wins, the sign-in still succeeds and the next
|
|
285
|
+
sign-in tries again. An outage on that write still fails the sign-in.
|
|
286
|
+
|
|
287
|
+
**The port.** `JanusStores` is three stores — `UserStore`, `SessionStore`,
|
|
288
|
+
`TokenStore`, whose records are `UserRecord`, `SessionRecord` and
|
|
289
|
+
`TokenRecord` — in the slots `users`, `sessions`, `tokens`,
|
|
290
|
+
cut where atomicity is not required, so sessions can live in Redis while users
|
|
291
|
+
live in MongoDB. `createMemoryStores()` is the reference implementation. It is
|
|
292
|
+
shipped for your own tests, and it is what to compare against when writing an
|
|
293
|
+
adapter. `assertStores(store, where)` is the check `janus()` runs on it, for an
|
|
294
|
+
adapter that wants to fail as early. The six rules an adapter keeps are
|
|
295
|
+
written on the port's types.
|
|
296
|
+
|
|
297
|
+
### Permissions — `@nxgt/janus/permissions`
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
import { defineModel, fromField, when, permissions, createMemoryRelations } from '@nxgt/janus/permissions';
|
|
301
|
+
|
|
302
|
+
export const model = defineModel({
|
|
303
|
+
subjects: clinic.types, // 'patient' | 'staff': a user type is a subject type
|
|
304
|
+
types: {
|
|
305
|
+
team: {
|
|
306
|
+
relations: { member: ['staff', 'team#member'], lead: ['staff'] },
|
|
307
|
+
permissions: { manage: ['lead'], view: ['member', 'manage'] },
|
|
308
|
+
},
|
|
309
|
+
record: {
|
|
310
|
+
relations: {
|
|
311
|
+
doctor: fromField('doctorId', 'staff', { lookup: (id) => db.records.ids({ doctorId: id }) }),
|
|
312
|
+
team: ['team'],
|
|
313
|
+
},
|
|
314
|
+
permissions: {
|
|
315
|
+
view: ['doctor', 'team->view'],
|
|
316
|
+
edit: [when('doctor', (ctx: { onShift: boolean }) => ctx.onShift)],
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const access = permissions({ model, store: createMemoryRelations() });
|
|
323
|
+
await access.grant({ type: 'team', id: 't1' }, 'member', staff);
|
|
324
|
+
await access.can(staff, 'edit', { type: 'record', ...record }, { ctx: { onShift } }); // boolean
|
|
325
|
+
await access.list(staff, 'view', 'record', { limit: 50 }); // CursorPage<string>; view reaches no condition
|
|
326
|
+
await access.revoke({ type: 'team', id: 't1' }, 'member', staff); // idempotent
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
Zanzibar's model — relations between objects and subjects, permissions
|
|
330
|
+
computed from them — **without its infrastructure**: the tuples live in your
|
|
331
|
+
database, so a read follows a write and there is nothing to cache or to
|
|
332
|
+
sequence. Subject sets (`'team#member'`), arrows (`'team->view'`: whoever can
|
|
333
|
+
view the record's team) and permissions naming permissions are Zanzibar's. Two
|
|
334
|
+
things are not:
|
|
335
|
+
|
|
336
|
+
- **`fromField`** reads a relation from the object's own data — a record's
|
|
337
|
+
`doctorId` — instead of a tuple kept in sync with it. `can()` is given the
|
|
338
|
+
object, and the compiler requires every field a `fromField` of its type
|
|
339
|
+
reads. `list()` cannot read a field of objects it has not found, so it asks
|
|
340
|
+
the `lookup`;
|
|
341
|
+
- **`when`** puts a condition written in TypeScript on a rule. Its `ctx` is
|
|
342
|
+
what `can()` and `list()` then require — and only for the permissions whose
|
|
343
|
+
rules reach it.
|
|
344
|
+
|
|
345
|
+
**A denial is `false`, a failure throws.** A relation store that cannot answer
|
|
346
|
+
is `STORE_FAILED`; a walk that crosses more than `maxDepth` relations (`25`) is
|
|
347
|
+
`PERMISSION_DEPTH`. Neither is ever `false`, which would deny everybody
|
|
348
|
+
everything during an outage and say nothing. A cycle in the data — a team
|
|
349
|
+
member of itself — is cut, and is not an error. `null` is anonymous: `false`,
|
|
350
|
+
or an empty page, before any store call.
|
|
351
|
+
|
|
352
|
+
**Everything is typed from the model.** A relation naming a type that does not
|
|
353
|
+
exist, a rule naming nothing, an arrow to a permission its target lacks, a
|
|
354
|
+
permission asked of the wrong type, an object missing a field, a missing
|
|
355
|
+
`ctx`, a `grant` of a relation read from a field or to a holder it does not
|
|
356
|
+
admit, a `list()` through a `fromField` without a `lookup`: each is a compile
|
|
357
|
+
error, on the offending argument. `defineModel` refuses with a `TypeError`
|
|
358
|
+
what only running it can see: names that are not camelCase, a permission that
|
|
359
|
+
reaches itself without crossing a relation, a subject set or an arrow that
|
|
360
|
+
would have to read another object's field.
|
|
361
|
+
|
|
362
|
+
**Wire the relation store into `janus()` too** — `janus({ …, relations })` —
|
|
363
|
+
and deleting a user deletes every tuple naming them. Deleting an object's
|
|
364
|
+
tuples is `store.deleteEntity({ type, id })`, from your own code.
|
|
365
|
+
|
|
366
|
+
The port is `RelationStore`: six methods answering one-hop questions about
|
|
367
|
+
stored tuples (`write`, `has`, `findSubjectSets`, `findEntities`,
|
|
368
|
+
`findObjects`, `deleteEntity`). The traversal is the core's, written once.
|
|
369
|
+
|
|
370
|
+
### Conformance — `@nxgt/janus/conformance`
|
|
371
|
+
|
|
372
|
+
If you write an adapter, you run this suite against it:
|
|
373
|
+
|
|
374
|
+
```ts
|
|
375
|
+
import { describe, it } from 'bun:test';
|
|
376
|
+
import { describeJanusStores } from '@nxgt/janus/conformance';
|
|
377
|
+
|
|
378
|
+
describeJanusStores({
|
|
379
|
+
name: 'my adapter',
|
|
380
|
+
runner: { describe, it },
|
|
381
|
+
harness: {
|
|
382
|
+
async open() {
|
|
383
|
+
const db = await freshDatabase(); // one per case, never shared
|
|
384
|
+
return {
|
|
385
|
+
stores: myStores(db),
|
|
386
|
+
faults: { fail: (slot, method) => db.failNext(method) },
|
|
387
|
+
close: () => db.drop(),
|
|
388
|
+
};
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
There are 37 cases. They cover:
|
|
395
|
+
- round-trip, byte for byte;
|
|
396
|
+
- uniqueness, as a constraint: of twenty concurrent inserts of one login,
|
|
397
|
+
exactly one is accepted — and a login is unique per user type;
|
|
398
|
+
- versions: a refused update writes nothing;
|
|
399
|
+
- **omission**, named after the Kratos `PUT` trap;
|
|
400
|
+
- pagination;
|
|
401
|
+
- sessions;
|
|
402
|
+
- one-time tokens: of twenty concurrent redemptions, exactly one succeeds;
|
|
403
|
+
- deletion: a user's logins are freed, and every session and token of theirs
|
|
404
|
+
goes, with a replay answering `false` or `0` rather than failing;
|
|
405
|
+
- **outages**, one case for each of the eleven methods whose honest answer can
|
|
406
|
+
be "nothing".
|
|
407
|
+
|
|
408
|
+
The suite imports no test framework and no assertion library. It runs under
|
|
409
|
+
`bun test`, vitest and jest. Its cases are also exported as data
|
|
410
|
+
(`allCases`, or by group: `userStoreCases`, `sessionStoreCases`,
|
|
411
|
+
`tokenStoreCases`, `outageCases`), with `runCase` to run one without any
|
|
412
|
+
runner. `skip: { [caseId]: reason }` skips a case and reports why;
|
|
413
|
+
`SKIP_REASONS` holds the reasons the suite gives itself.
|
|
414
|
+
|
|
415
|
+
**`faults` is optional, and its absence is reported, never passed over.**
|
|
416
|
+
Without it, the outage cases are skipped under the reason *"the outage
|
|
417
|
+
invariant is not proven for this adapter"*. Make your database fail the way it
|
|
418
|
+
really fails — for MongoDB, the `failCommand` failpoint with code 91. A wrapper
|
|
419
|
+
that throws in front of your adapter proves the wrapper, not the adapter.
|
|
420
|
+
Fail **only the method named**: `outage.write` reads the store back afterwards,
|
|
421
|
+
to prove the rejected write changed nothing.
|
|
422
|
+
|
|
423
|
+
`referenceHarness()` runs the suite against the reference store, and is the
|
|
424
|
+
example to copy.
|
|
425
|
+
|
|
426
|
+
A relation store has its own suite, `describeRelationStores({ name, harness })`
|
|
427
|
+
— 15 cases: round-trip, a subject whose `relation` is `undefined` read as its
|
|
428
|
+
entity, absence, idempotent writes, a tuple stored once, one write's removals
|
|
429
|
+
and additions applied together, the one-hop reads, the reverse index in pages,
|
|
430
|
+
`deleteEntity`, and an outage for each of the six methods — a write that
|
|
431
|
+
rejects must have changed nothing. `referenceRelationHarness()` is its
|
|
432
|
+
example; `allRelationCases`, `relationStoreCases`, `relationOutageCases` and
|
|
433
|
+
`runRelationCase` are the runner-less layer.
|
|
434
|
+
|
|
435
|
+
## Traps
|
|
436
|
+
|
|
437
|
+
**Narrowing a model hides stored tuples; it does not delete them.** A tuple
|
|
438
|
+
the model no longer admits grants nothing, and `revoke()` refuses it — remove
|
|
439
|
+
it with `relations.write({ remove: [tuple] })`, or widening the model again
|
|
440
|
+
brings it back.
|
|
441
|
+
|
|
442
|
+
**The first session credential present wins, not the first valid one.**
|
|
443
|
+
`Authorization: Bearer`, then `X-Session-Token`, then the cookie. A client that
|
|
444
|
+
sends a lapsed bearer beside a live cookie is anonymous, and it should fix its
|
|
445
|
+
header rather than be rescued in silence.
|
|
446
|
+
|
|
447
|
+
**An outage is not anonymous.** `authenticate` rejects with `STORE_FAILED` when
|
|
448
|
+
the store cannot answer. Answer 503: a 401 would sign everybody out during an
|
|
449
|
+
outage, and send them to a sign-in page that cannot work either.
|
|
450
|
+
|
|
451
|
+
**Never put a `CREDENTIALS_INVALID`'s `reason` in a response body.**
|
|
452
|
+
`unknownLogin` is an account-enumeration oracle. `signIn` compares against a
|
|
453
|
+
dummy hash when nobody holds the login, so the hashing time does not tell.
|
|
454
|
+
**The store's own latency still does**, and that limit is stated rather than
|
|
455
|
+
denied. `resetPassword.request` answers `null` for an unknown e-mail for the
|
|
456
|
+
same reason: answer the visitor the same page either way.
|
|
457
|
+
|
|
458
|
+
**`resetPassword.confirm` signs the user out everywhere, and opens no session.**
|
|
459
|
+
Whoever had the old password loses their sessions; what the visitor does next is
|
|
460
|
+
your policy. A password refused for its length does not spend the token.
|
|
461
|
+
|
|
462
|
+
**A sign-in can move a user's `version`.** Rewriting a stale hash is a write. A
|
|
463
|
+
user object read before that sign-in, and then passed as `ifVersion`, gets
|
|
464
|
+
`VERSION_CONFLICT`. That is the conflict doing its job: read the user again.
|
|
465
|
+
|
|
466
|
+
**Expiry is decided by the core, not by the store.** A store may still hold a
|
|
467
|
+
lapsed session, and `authenticate` answers it as anonymous. A TTL index keeps
|
|
468
|
+
storage tidy; it is not the expiry mechanism.
|
|
469
|
+
|
|
470
|
+
**`update` merges, then validates the whole.** The patch is spread over the
|
|
471
|
+
stored fields and the result is checked against the schema, so a patch can
|
|
472
|
+
never leave a user that the schema would refuse. Pass `ifVersion` to make the
|
|
473
|
+
write conditional on what you read.
|
|
474
|
+
|
|
475
|
+
**Under `bun test`, pass `runner: { describe, it }`.** Measured: Bun gives a
|
|
476
|
+
test file `describe` and `it` as bare identifiers, not as properties of
|
|
477
|
+
`globalThis`. jest, and vitest with `globals: true`, are found without it.
|
|
478
|
+
|
|
479
|
+
**`undefined` is not an absence here.** Every method that can find nothing
|
|
480
|
+
answers `null`. `undefined` is what a missing property *and* a function with no
|
|
481
|
+
`return` both produce, so a store that forgot to answer would report "not found"
|
|
482
|
+
by accident. `null` has to be written on purpose.
|
|
483
|
+
|
|
484
|
+
**The notation is typed, and refuses Keto's untyped subject.**
|
|
485
|
+
`record:r1#viewer@staff:u1`, and `record:r1#viewer@team:t1#member` for a subject
|
|
486
|
+
set. No part may hold `@`, `#` or a parenthesis, and a type may not hold a `:`,
|
|
487
|
+
so every string reads one way. `parseTuple` refuses `record:r1#viewer@alice`,
|
|
488
|
+
and its message says what a subject is.
|
|
489
|
+
|
|
490
|
+
**`parseTuple` throws a bare `TypeError`, not a `JanusError`.** Nothing in this
|
|
491
|
+
package reads a tuple off the network, so a malformed string came from your own
|
|
492
|
+
code — a wiring mistake, and no handler should answer one.
|
|
493
|
+
|
|
494
|
+
**`mintedAt` is not `createdAt`.** The sequence may have borrowed a millisecond
|
|
495
|
+
and a clock that stepped backwards is held rather than followed, so it is
|
|
496
|
+
accurate to the millisecond and no further.
|
|
497
|
+
|
|
498
|
+
**`mintId(now)` steers ids forward, never back.** The last millisecond is
|
|
499
|
+
module state, so passing a `now` earlier than an id already minted in this
|
|
500
|
+
process does not produce an earlier id — it holds the last one and keeps counting,
|
|
501
|
+
because a decreasing id would break the pagination cursor, which is the whole
|
|
502
|
+
reason the core mints ids at all. A test that needs a fixed instant wants
|
|
503
|
+
`fixedClock`, not this argument.
|
|
504
|
+
|
|
505
|
+
**Error codes are `SCREAMING_SNAKE`, everything else is `camelCase`.** The codes
|
|
506
|
+
are data values, not keys. There is no `snake_case` key anywhere in this package,
|
|
507
|
+
unlike Ory — a Biome naming-convention rule holds it.
|
|
508
|
+
|
|
509
|
+
**`list()` costs what the subject can reach, every round.** It walks backwards
|
|
510
|
+
from the subject — every page of `findObjects` for every id each step reaches —
|
|
511
|
+
and repeats a round whenever a relation loops back on itself (a folder
|
|
512
|
+
viewable through its parent) until a round finds nothing new. Fine for what one
|
|
513
|
+
user can see; not for a subject set holding most of the database, which wants
|
|
514
|
+
a query of your own.
|
|
515
|
+
|
|
516
|
+
**`can()` wants the loaded object, spread.** `{ type: 'record', ...record }`: a
|
|
517
|
+
`fromField` reads its field there, and a field missing at run time is a
|
|
518
|
+
`TypeError`, never a denial. `null` in the field holds nobody.
|
|
519
|
+
|
|
520
|
+
**A `lookup` is your code, and not guarded.** A lookup that throws rejects
|
|
521
|
+
`list()` with its own error, as it threw. Never answer `[]` for a database that
|
|
522
|
+
could not answer: that is a denial made of an outage.
|
|
523
|
+
|
|
524
|
+
## Documentation
|
|
525
|
+
|
|
526
|
+
- [Guides](docs/README.md) — one page per area, every option with an example
|
|
527
|
+
- [Troubleshooting](docs/troubleshooting.md) — by the error message you see
|
|
528
|
+
- [Roadmap](docs/roadmap.md) — what is next, and what is not planned
|
|
529
|
+
|
|
530
|
+
## Type safety, counted
|
|
531
|
+
|
|
532
|
+
**Seventy-five plausible mistakes, seventy-five refused at compile time — and
|
|
533
|
+
one gap, named.**
|
|
534
|
+
|
|
535
|
+
The lists are typechecked and never run, with one `@ts-expect-error` per
|
|
536
|
+
mistake beside the shapes that must keep compiling:
|
|
537
|
+
`test/types/refusals.ts` (fourteen, on the shared vocabulary),
|
|
538
|
+
`test/types/port.ts` (fifteen, on the store port, from the side of the person
|
|
539
|
+
implementing it), `test/types/auth.ts` (twenty, on `janus()`, from the side
|
|
540
|
+
of the application) and `test/types/permissions.ts` (twenty-six, on the
|
|
541
|
+
permission model and the questions asked of it). The rule
|
|
542
|
+
comes from `nxgt-data`, and so does the reason to
|
|
543
|
+
distrust the claim without the files: when it was last measured on
|
|
544
|
+
`@nxgt/mongo`, *seven of twelve plausible mistakes still compiled*. A count
|
|
545
|
+
that goes down is a visible regression.
|
|
546
|
+
|
|
547
|
+
The gap, since a measurement that only reports wins is not a measurement:
|
|
548
|
+
`'30 m'` **satisfies `Duration`**, because TypeScript's `${number}` placeholder
|
|
549
|
+
tolerates trailing whitespace inside the number. `parseDuration` refuses it, and
|
|
550
|
+
`duration.spec.ts` asserts that. It is written down rather than omitted.
|
|
551
|
+
|
|
552
|
+
## Licence
|
|
553
|
+
|
|
554
|
+
MIT
|