@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
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# Users — `janus()`
|
|
2
|
+
|
|
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
|
|
6
|
+
[e-mail flows](email-flows.md) and [password hashing](passwords.md).
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
|
|
11
|
+
|
|
12
|
+
const User = z.object({ email: z.email(), name: z.string() });
|
|
13
|
+
|
|
14
|
+
export const auth = janus({
|
|
15
|
+
user: User,
|
|
16
|
+
password: { login: 'email' },
|
|
17
|
+
store: createMemoryStores(),
|
|
18
|
+
hasher: scryptHasher(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const { user, session, token } = await auth.signUp({
|
|
22
|
+
email: 'ada@example.com',
|
|
23
|
+
name: 'Ada Lovelace',
|
|
24
|
+
password: 'correct horse',
|
|
25
|
+
});
|
|
26
|
+
user.email; // the schema's own field, at the top level
|
|
27
|
+
user.type; // 'user'
|
|
28
|
+
session.expiresAt; // Date
|
|
29
|
+
token; // handed back once: the store holds only its sha256
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`createMemoryStores()` is the reference store, for tests and for trying the
|
|
33
|
+
package. In production, pass an adapter's stores, such as
|
|
34
|
+
`createMongoStores(db)` from `@nxgt/janus-mongo`.
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
function janus<const C extends JanusConfig>(config: C & Checked<C>): Janus<C>;
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`janus()` is **synchronous and does no I/O**. It checks that every store
|
|
41
|
+
answers every method of the port, and connects to nothing. A configuration it
|
|
42
|
+
cannot use is refused with a bare `TypeError` at that call, before any request
|
|
43
|
+
arrives.
|
|
44
|
+
|
|
45
|
+
## What a user is
|
|
46
|
+
|
|
47
|
+
A user is **your schema's fields at the top level**, plus what `janus` sets:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
interface UserBase<Type extends string = string> {
|
|
51
|
+
readonly id: Id; // a UUIDv7 the core minted
|
|
52
|
+
readonly type: Type; // 'user', or the name you gave the type
|
|
53
|
+
readonly emailVerified: boolean;
|
|
54
|
+
readonly active: boolean;
|
|
55
|
+
readonly hasPassword: boolean; // the hash itself never reaches a user
|
|
56
|
+
readonly version: number; // one more on every write
|
|
57
|
+
readonly createdAt: Date;
|
|
58
|
+
readonly updatedAt: Date;
|
|
59
|
+
}
|
|
60
|
+
type User<Type extends string = string, Fields = object> = Readonly<Fields> & UserBase<Type>;
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
A schema that declares one of those keys, or `password`, is refused at compile
|
|
64
|
+
time. A schema is any [Standard Schema](https://standardschema.dev) (Zod 4,
|
|
65
|
+
Valibot, ArkType), and its output must be JSON: a schema that produces a
|
|
66
|
+
`Date` is refused at compile time, because a `Date` round-trips through one
|
|
67
|
+
database and not the next. An optional field left `undefined` is dropped
|
|
68
|
+
before the store sees it.
|
|
69
|
+
|
|
70
|
+
## Options
|
|
71
|
+
|
|
72
|
+
| Option | Type | Default | Effect |
|
|
73
|
+
| --- | --- | --- | --- |
|
|
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) |
|
|
76
|
+
| `password.login` | a field name | — | The field users sign in with: a **top-level, required string** field. A typo is a compile error |
|
|
77
|
+
| `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
|
+
| `password.minLength` | integer ≥ 1 | `8` | Below it: `PASSWORD_TOO_SHORT` |
|
|
79
|
+
| `email` | a field name | `'email'` | The field `verifyEmail` and `resetPassword` send to. Without one, those flows are absent from the type |
|
|
80
|
+
| `session.lifespan` | `Duration` | `'7d'` | How long a session lives |
|
|
81
|
+
| `session.renewAfter` | `Duration \| false` | `'1d'` | When `authenticate` slides the session. `false` for a fixed lifespan |
|
|
82
|
+
| `schemaVersion` | string | `'1'` | Recorded on every user written. Bump it when the schema tightens |
|
|
83
|
+
| `store` | `JanusStores` | — | Required. `createMemoryStores()` or an adapter's |
|
|
84
|
+
| `relations` | `RelationStore` | — | The permission store. Wired here, deleting a user deletes every tuple naming them |
|
|
85
|
+
| `hasher` | `PasswordHasher` | — | Required as soon as a type has a password. No silent fallback |
|
|
86
|
+
| `verifiers` | `PasswordHasher[]` | `[]` | Hashers that only verify: those older hashes were written with |
|
|
87
|
+
| `clock` | `Clock` | `systemClock` | `fixedClock()` in tests |
|
|
88
|
+
| `cookie` | `CookieConfig` | strict | See [sessions](sessions.md#the-cookie) |
|
|
89
|
+
| `tokens.verifyEmail` | `Duration` | `'24h'` | How long a verification token lives |
|
|
90
|
+
| `tokens.resetPassword` | `Duration` | `'1h'` | How long a reset token lives |
|
|
91
|
+
|
|
92
|
+
A `Duration` is `'500ms'`, `'30s'`, `'15m'`, `'8h'`, `'7d'`, or a number of
|
|
93
|
+
milliseconds.
|
|
94
|
+
|
|
95
|
+
### `password.login` is checked at compile time
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
janus({
|
|
99
|
+
user: z.object({ email: z.email(), nickname: z.string().optional() }),
|
|
100
|
+
// @ts-expect-error — on login: '"emial" is not a required string field; name one of' 'email'
|
|
101
|
+
password: { login: 'emial' },
|
|
102
|
+
store: createMemoryStores(),
|
|
103
|
+
hasher: scryptHasher(),
|
|
104
|
+
});
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`nickname` is not offered either: a login read from an optional field is a user
|
|
108
|
+
who may have no way to sign in.
|
|
109
|
+
|
|
110
|
+
### `password.normalize`
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
janus({
|
|
114
|
+
user: z.object({ username: z.string() }),
|
|
115
|
+
password: { login: 'username', normalize: 'none' }, // 'Grace' and 'grace' are two users
|
|
116
|
+
store: createMemoryStores(),
|
|
117
|
+
hasher: scryptHasher(),
|
|
118
|
+
});
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
A function is accepted, and must be deterministic: the same rule normalises
|
|
122
|
+
at sign-up and at sign-in. An e-mail used by the e-mail flows is always
|
|
123
|
+
compared lower-cased and trimmed, whatever the login's rule.
|
|
124
|
+
|
|
125
|
+
## Several kinds of user
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
const Patient = z.object({ email: z.email(), birthDate: z.string() });
|
|
129
|
+
const Staff = z.object({ username: z.string(), service: z.string() });
|
|
130
|
+
|
|
131
|
+
export const clinic = janus({
|
|
132
|
+
users: {
|
|
133
|
+
patient: { schema: Patient, password: { login: 'email' } },
|
|
134
|
+
staff: {
|
|
135
|
+
schema: Staff,
|
|
136
|
+
password: { login: 'username', normalize: 'none', minLength: 12 },
|
|
137
|
+
session: { lifespan: '8h', renewAfter: false },
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
store: createMemoryStores(),
|
|
141
|
+
hasher: scryptHasher(),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
await clinic.patient.signUp({ email: 'p@example.com', birthDate: '1990-01-01', password: 'correct horse' });
|
|
145
|
+
await clinic.staff.signIn({ username: 'grace', password: 'a long passphrase' });
|
|
146
|
+
clinic.types; // readonly ('patient' | 'staff')[]
|
|
147
|
+
|
|
148
|
+
const current = await clinic.authenticate(request);
|
|
149
|
+
if (current?.user.type === 'staff') current.user.service; // narrowed by type
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Each type carries `schema` and, optionally, `password`, `email`, `session`
|
|
153
|
+
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
|
+
camelCase, and may not be one of `janus()`'s own methods (`authenticate`,
|
|
156
|
+
`signOut`, `signOutEverywhere`, `findUser`, `getUser`, `cookie`,
|
|
157
|
+
`collectExpired`, `types`) — a compile error, and a `TypeError` for a
|
|
158
|
+
JavaScript caller.
|
|
159
|
+
|
|
160
|
+
## What each user type answers
|
|
161
|
+
|
|
162
|
+
In the one-type form these are on `auth` itself; with several types, on
|
|
163
|
+
`clinic.patient`, `clinic.staff`.
|
|
164
|
+
|
|
165
|
+
| Method | Answers | Rejects with |
|
|
166
|
+
| --- | --- | --- |
|
|
167
|
+
| `create(fields & { password?, active? })` | the user; no session | `USER_INVALID`, `PASSWORD_TOO_SHORT`, `LOGIN_TAKEN` |
|
|
168
|
+
| `find(id)` | the user, or `null` — for a malformed id, an unknown one, or one of another type | |
|
|
169
|
+
| `get(id)` | the user | `NOT_FOUND` |
|
|
170
|
+
| `list({ after?, limit? })` | `CursorPage<User>`, in creation order | `INVALID_CURSOR` |
|
|
171
|
+
| `update(user, patch, { ifVersion? })` | the user as written | `USER_INVALID`, `LOGIN_TAKEN`, `VERSION_CONFLICT`, `NOT_FOUND` |
|
|
172
|
+
| `setActive(user, active, { ifVersion? })` | the user as written | `VERSION_CONFLICT`, `NOT_FOUND` |
|
|
173
|
+
| `delete(user)` | `true`, or `false` for an unknown id or one of another type | |
|
|
174
|
+
|
|
175
|
+
With a `password`, besides:
|
|
176
|
+
|
|
177
|
+
| Method | Answers | Rejects with |
|
|
178
|
+
| --- | --- | --- |
|
|
179
|
+
| `signUp(fields & { password })` | `{ user, session, token }` | `USER_INVALID`, `PASSWORD_TOO_SHORT`, `LOGIN_TAKEN` |
|
|
180
|
+
| `signIn({ [login]: string, password })` | `{ user, session, token }` | `CREDENTIALS_INVALID`, `USER_INACTIVE`, `HASH_UNSUPPORTED` |
|
|
181
|
+
| `findByLogin(login)` | the user, or `null`; the login is normalised first | |
|
|
182
|
+
| `setPassword(user, password, { ifVersion? })` | the user — an admin's call | `PASSWORD_TOO_SHORT` |
|
|
183
|
+
| `changePassword(user, { current, next }, { ifVersion? })` | the user — the user's own call | `CREDENTIALS_INVALID`, `PASSWORD_TOO_SHORT` |
|
|
184
|
+
|
|
185
|
+
Every method may also reject with `STORE_FAILED`. A `user` argument is a user
|
|
186
|
+
or its id (`UserRef = string | { id: string }`).
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
const grace = await auth.create({ email: 'grace@example.com', name: 'Grace', active: false });
|
|
190
|
+
await auth.find(grace.id); // User | null
|
|
191
|
+
await auth.get(grace.id); // User, or NOT_FOUND
|
|
192
|
+
const renamed = await auth.update(grace, { name: 'Grace Hopper' }, { ifVersion: grace.version });
|
|
193
|
+
await auth.setActive(renamed, true);
|
|
194
|
+
await auth.findByLogin(' GRACE@example.com '); // found: normalised as sign-up normalised it
|
|
195
|
+
await auth.setPassword(renamed, 'a new password');
|
|
196
|
+
await auth.changePassword(renamed, { current: 'a new password', next: 'another password' });
|
|
197
|
+
await auth.delete(renamed); // true
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### `update` merges, then validates the whole
|
|
201
|
+
|
|
202
|
+
The patch is spread over the stored fields and the result is checked against
|
|
203
|
+
the schema, so a patch can never leave a user the schema would refuse.
|
|
204
|
+
Changing the e-mail sets `emailVerified` back to `false`, and moves the login
|
|
205
|
+
with it when the e-mail is the login.
|
|
206
|
+
|
|
207
|
+
### `ifVersion`
|
|
208
|
+
|
|
209
|
+
Every write but `delete` takes `{ ifVersion }`. A user who changed since you
|
|
210
|
+
read them is `VERSION_CONFLICT`, and nothing is written: read again and retry.
|
|
211
|
+
A sign-in that rewrites a stale password hash moves `version` too, so an object
|
|
212
|
+
read before that sign-in conflicts — see [passwords](passwords.md#rehash-on-sign-in).
|
|
213
|
+
|
|
214
|
+
### `delete`
|
|
215
|
+
|
|
216
|
+
Deletes the user **with every session and one-time token they had**, and
|
|
217
|
+
every tuple naming them when `relations` is wired. The user goes first, so an
|
|
218
|
+
outage half-way leaves only sessions and tokens that authenticate nobody. It is
|
|
219
|
+
idempotent: calling it again finishes the job.
|
|
220
|
+
|
|
221
|
+
### Paging every user
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
let cursor: string | null = null;
|
|
225
|
+
do {
|
|
226
|
+
const page = await auth.list({ after: cursor, limit: 100 });
|
|
227
|
+
for (const user of page.items) console.log(user.email);
|
|
228
|
+
cursor = page.nextCursor;
|
|
229
|
+
} while (cursor);
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
`limit` defaults to 20 and is capped at 100. There is no `total`.
|
|
233
|
+
|
|
234
|
+
### Across types
|
|
235
|
+
|
|
236
|
+
`findUser(id)` and `getUser(id)` are on the instance itself and find a user
|
|
237
|
+
whatever their type; the answer is a union narrowed by `user.type`.
|
|
238
|
+
|
|
239
|
+
## A sign-up route
|
|
240
|
+
|
|
241
|
+
A fetch-style handler — the shape Bun, Hono and most frameworks hand you:
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
import { JanusError } from '@nxgt/janus';
|
|
245
|
+
|
|
246
|
+
export async function signUpRoute(request: Request): Promise<Response> {
|
|
247
|
+
const body = (await request.json()) as { email: string; name: string; password: string };
|
|
248
|
+
try {
|
|
249
|
+
const { user, session, token } = await auth.signUp(body);
|
|
250
|
+
return Response.json(
|
|
251
|
+
{ id: user.id },
|
|
252
|
+
{ status: 201, headers: { 'Set-Cookie': auth.cookie.serialize(token, session) } },
|
|
253
|
+
);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
if (!(error instanceof JanusError)) throw error;
|
|
256
|
+
switch (error.code) {
|
|
257
|
+
case 'USER_INVALID':
|
|
258
|
+
return Response.json({ issues: error.issues }, { status: 400 });
|
|
259
|
+
case 'PASSWORD_TOO_SHORT':
|
|
260
|
+
return Response.json({ minLength: error.minLength }, { status: 400 });
|
|
261
|
+
case 'LOGIN_TAKEN':
|
|
262
|
+
return Response.json({ error: 'taken' }, { status: 409 });
|
|
263
|
+
default:
|
|
264
|
+
throw error; // STORE_FAILED: your 503
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Validate the body's shape yourself in a real route; `janus` validates the
|
|
271
|
+
fields against your schema, not that `password` is a string.
|
|
272
|
+
|
|
273
|
+
## See also
|
|
274
|
+
|
|
275
|
+
- [Sessions](sessions.md) — `authenticate`, the cookie, signing out
|
|
276
|
+
- [Errors](errors.md) — every code, and the status it deserves
|
|
277
|
+
- [Writing an adapter](adapters.md) — the store port behind `store`
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# The shared vocabulary
|
|
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,
|
|
5
|
+
pagination, durations and clocks. All of it is imported from `@nxgt/janus`.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { formatTuple, mintId, parseDuration, subjectOf } from '@nxgt/janus';
|
|
9
|
+
|
|
10
|
+
const user = { type: 'staff', id: mintId(), username: 'grace' };
|
|
11
|
+
|
|
12
|
+
subjectOf(user); // { type: 'staff', id: '…' }: the user IS the subject
|
|
13
|
+
formatTuple({
|
|
14
|
+
object: { type: 'record', id: 'r1' },
|
|
15
|
+
relation: 'viewer',
|
|
16
|
+
subject: { type: 'team', id: 't1', relation: 'member' },
|
|
17
|
+
}); // 'record:r1#viewer@team:t1#member'
|
|
18
|
+
parseDuration('8h', 'session.lifespan'); // 28800000
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Subjects and the tuple notation
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
interface Entity { readonly type: string; readonly id: string }
|
|
25
|
+
interface SubjectSet extends Entity { readonly relation: string } // every `relation` of an entity
|
|
26
|
+
type Subject = Entity | SubjectSet;
|
|
27
|
+
interface RelationTuple { readonly object: Entity; readonly relation: string; readonly subject: Subject }
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**Subjects are typed**: `{ type, id }` for one entity, `{ type, id, relation }`
|
|
31
|
+
for a subject set — every `member` of `team:t1`. `type` is the same word as a
|
|
32
|
+
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.
|
|
34
|
+
It copies `type` and `id` only, so none of the user's own fields ever reaches
|
|
35
|
+
a tuple.
|
|
36
|
+
|
|
37
|
+
| Function | Answers |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| `subjectOf(user)` | `{ type, id }` of any object carrying both |
|
|
40
|
+
| `isSubjectSet(subject)` | whether `relation` is a string |
|
|
41
|
+
| `formatEntity(entity)` | `'record:r1'` |
|
|
42
|
+
| `formatSubject(subject)` | `'staff:u1'`, or `'team:t1#member'` |
|
|
43
|
+
| `formatTuple(tuple)` | `'record:r1#viewer@team:t1#member'` |
|
|
44
|
+
| `parseSubject(text)` | the `Subject` back |
|
|
45
|
+
| `parseTuple(text)` | the `RelationTuple` back |
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { isSubjectSet, parseSubject, parseTuple } from '@nxgt/janus';
|
|
49
|
+
|
|
50
|
+
parseTuple('record:r1#viewer@staff:u1');
|
|
51
|
+
// { object: { type: 'record', id: 'r1' }, relation: 'viewer', subject: { type: 'staff', id: 'u1' } }
|
|
52
|
+
|
|
53
|
+
const subject = parseSubject('team:t1#member');
|
|
54
|
+
if (isSubjectSet(subject)) subject.relation; // 'member'
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The notation is for messages, logs and tests, not a wire format. No part may
|
|
58
|
+
hold `@`, `#` or a parenthesis, and a type may not hold a `:`, so every string
|
|
59
|
+
reads one way. `parseTuple` and `parseSubject` refuse anything else with a
|
|
60
|
+
**bare `TypeError`** — including Keto's untyped subject, `record:r1#viewer@alice`,
|
|
61
|
+
and the message says what a subject is. Nothing in this package reads a tuple
|
|
62
|
+
off the network, so a malformed string came from your own code.
|
|
63
|
+
|
|
64
|
+
## Ids
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { type Id, isId, mintedAt, mintId } from '@nxgt/janus';
|
|
68
|
+
|
|
69
|
+
const id: Id = mintId(); // a UUIDv7
|
|
70
|
+
isId(id); // true
|
|
71
|
+
isId('42'); // false: not an id this package could have minted
|
|
72
|
+
mintedAt(id); // the Date it was minted, to the millisecond
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Ids are **minted by the core, not by the store**. They sort in creation order
|
|
76
|
+
as strings, so a pagination cursor is simply the last id of a page. Ids are
|
|
77
|
+
strictly increasing within a process: inside one millisecond a sequence orders
|
|
78
|
+
them, and past 4096 in a millisecond the next millisecond is borrowed.
|
|
79
|
+
|
|
80
|
+
- **`mintedAt` is not `createdAt`.** A borrowed millisecond, or a clock that
|
|
81
|
+
stepped backwards and was held rather than followed, makes it accurate to
|
|
82
|
+
the millisecond and no further.
|
|
83
|
+
- **`mintId(now)` steers ids forward, never back.** Passing a `now` earlier
|
|
84
|
+
than an id already minted in this process holds the last one and keeps
|
|
85
|
+
counting, because a decreasing id would break the cursor. A test that needs
|
|
86
|
+
a fixed instant wants `fixedClock`, not this argument.
|
|
87
|
+
|
|
88
|
+
The price for an adapter: it cannot reuse an existing numeric primary key. It
|
|
89
|
+
stores a `uuid` column, or a 36-character string.
|
|
90
|
+
|
|
91
|
+
## Pagination
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
import { type CursorPage, DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE, invalidCursor, pageLimit } from '@nxgt/janus';
|
|
95
|
+
|
|
96
|
+
interface CursorPage<T> {
|
|
97
|
+
readonly items: readonly T[];
|
|
98
|
+
readonly nextCursor: string | null; // null on the last page, never undefined
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
pageLimit(undefined, 'listThings'); // 20 — DEFAULT_PAGE_SIZE
|
|
102
|
+
pageLimit(500, 'listThings'); // 100 — MAX_PAGE_SIZE, the cap
|
|
103
|
+
pageLimit(0, 'listThings'); // TypeError: listThings: limit must be an integer of at least 1, or absent
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
There is **no `total`**: a count over a cursor-paged collection is a second
|
|
107
|
+
query whose answer is stale by the time you read it. `while (cursor)` is the
|
|
108
|
+
loop:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
let cursor: string | null = null;
|
|
112
|
+
do {
|
|
113
|
+
const page = await auth.list({ after: cursor, limit: 100 });
|
|
114
|
+
cursor = page.nextCursor;
|
|
115
|
+
} while (cursor);
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`invalidCursor(where, cursor)` builds the `INVALID_CURSOR` error an adapter
|
|
119
|
+
throws for a cursor it did not mint — never a silent first page, which would
|
|
120
|
+
make a caller paging a list loop for ever. The message holds the cursor's
|
|
121
|
+
length, not its bytes.
|
|
122
|
+
|
|
123
|
+
## Durations
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
type Duration = number | `${number}${'ms' | 's' | 'm' | 'h' | 'd'}`;
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
import { parseDuration } from '@nxgt/janus';
|
|
131
|
+
|
|
132
|
+
parseDuration('15m', 'tokens.resetPassword'); // 900000
|
|
133
|
+
parseDuration(1500, 'session.renewAfter'); // 1500 — a number is milliseconds
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Every duration option of `janus()` takes this. `'2w'` is a compile error; a
|
|
137
|
+
value the type cannot see through — from an environment variable, say — is a
|
|
138
|
+
`TypeError` at run time, and the second argument names the option in its
|
|
139
|
+
message. One gap is known and written down: `'30 m'`
|
|
140
|
+
satisfies the type — TypeScript's `${number}` tolerates the space — and
|
|
141
|
+
`parseDuration` refuses it at run time.
|
|
142
|
+
|
|
143
|
+
## Clocks
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
import { type Clock, fixedClock, systemClock } from '@nxgt/janus';
|
|
147
|
+
|
|
148
|
+
interface Clock { now(): Date }
|
|
149
|
+
|
|
150
|
+
systemClock.now(); // the default
|
|
151
|
+
const clock = fixedClock(Date.UTC(2026, 0, 1)); // a Date or milliseconds; 0 when absent
|
|
152
|
+
clock.advance(60_000);
|
|
153
|
+
clock.set(new Date('2026-02-01T00:00:00Z'));
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`fixedClock` is **shipped, not test-only**: testing session expiry needs it,
|
|
157
|
+
and so do your own tests. Pass it as `janus({ clock })` — see
|
|
158
|
+
[sessions](sessions.md#testing-expiry).
|
|
159
|
+
|
|
160
|
+
## See also
|
|
161
|
+
|
|
162
|
+
- [Permissions](permissions.md) — where subjects and tuples are used
|
|
163
|
+
- [Errors](errors.md) — the error classes, also exported from `@nxgt/janus`
|
package/docs/roadmap.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Roadmap
|
|
2
|
+
|
|
3
|
+
Where `@nxgt/janus` is heading. A direction, not a commitment: there are no
|
|
4
|
+
dates here, and the version something shipped in is the only number.
|
|
5
|
+
|
|
6
|
+
## Now
|
|
7
|
+
|
|
8
|
+
Nothing in progress.
|
|
9
|
+
|
|
10
|
+
## Next
|
|
11
|
+
|
|
12
|
+
Nothing yet.
|
|
13
|
+
|
|
14
|
+
## Later
|
|
15
|
+
|
|
16
|
+
- **More official adapters** — the store port is cut where atomicity is not
|
|
17
|
+
required, so users, sessions and permission tuples can each live in the
|
|
18
|
+
database that suits them. MongoDB is the first adapter
|
|
19
|
+
([`@nxgt/janus-mongo`](https://www.npmjs.com/package/@nxgt/janus-mongo)).
|
|
20
|
+
- **A Redis adapter for sessions and tokens** — both are ephemeral and read on
|
|
21
|
+
every request, which is why the port gives them slots of their own: they can
|
|
22
|
+
live in Redis, with a native expiry, while users stay in another store.
|
|
23
|
+
|
|
24
|
+
## Not planned
|
|
25
|
+
|
|
26
|
+
- **`moduleResolution: "nodenext"`** — the package, its sources and its
|
|
27
|
+
emitted declarations import without extensions, and resolve as Bun and every
|
|
28
|
+
bundler do. Rewriting the declarations for `nodenext` was tried and reverted:
|
|
29
|
+
it breaks the same contract one step later. Use `"moduleResolution":
|
|
30
|
+
"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,
|
|
33
|
+
and the flows are calls (`signUp`, `signIn`, `authenticate`).
|
|
34
|
+
- **`snake_case` keys** — every key, option and record field is `camelCase`,
|
|
35
|
+
and a lint rule holds it. Error codes are `SCREAMING_SNAKE` because they are
|
|
36
|
+
values, not keys.
|
|
37
|
+
- **Emitting a Kratos identity schema** — it would bring Ory's `snake_case`
|
|
38
|
+
vocabulary into this package. If it ever exists, it is a separate package
|
|
39
|
+
whose job is to speak that format.
|
|
40
|
+
- **A `total` on `CursorPage`** — a count over a cursor-paged collection is a
|
|
41
|
+
second query, stale by the time you read it. `nextCursor` is the loop.
|
|
42
|
+
- **Store-assigned or numeric ids** — ids are UUIDv7 minted by the core, so
|
|
43
|
+
they sort in creation order, the cursor is the last id, and an insert is
|
|
44
|
+
idempotent under retry. An adapter cannot reuse an existing numeric key.
|
|
45
|
+
- **A required validation library** — schemas are any Standard Schema (Zod 4,
|
|
46
|
+
Valibot, ArkType); none is imposed as a peer.
|
|
47
|
+
- **A silent hasher fallback** — a user type with a password and no `hasher`
|
|
48
|
+
is refused at wiring, rather than hashed with something you did not choose.
|
|
49
|
+
- **Answering `null` or `false` on an outage** — a store that cannot answer
|
|
50
|
+
throws `STORE_FAILED`, and a permission walk past `maxDepth` throws
|
|
51
|
+
`PERMISSION_DEPTH`. Neither will become a denial: that turns an outage into
|
|
52
|
+
a silent lockout.
|
|
53
|
+
- **Zanzibar's infrastructure** — no consistency tokens, no distributed
|
|
54
|
+
cache. The tuples live in your own database, so a read already follows a
|
|
55
|
+
write.
|
|
56
|
+
- **Deciding between 404 and 403** — `can()` answers one question; what a
|
|
57
|
+
route reveals about an object it refuses is the application's decision.
|
|
58
|
+
|
|
59
|
+
## Shipped
|
|
60
|
+
|
|
61
|
+
The first public release, v0.1.
|
|
62
|
+
|
|
63
|
+
- **The model decides what a stored tuple grants** — `can()` and `list()` follow
|
|
64
|
+
only the holders a relation admits, as `grant()` writes only those: a tuple
|
|
65
|
+
stored past `grant()`, by an older model or by hand, grants nothing. — v0.1
|
|
66
|
+
- **Guides and troubleshooting pages** — a `docs/` folder shipped in the
|
|
67
|
+
package: detailed guides with examples, and the errors you can meet, each
|
|
68
|
+
with its cause and fix. — v0.1
|
|
69
|
+
- **Permissions at `@nxgt/janus/permissions`** — and `janus({ relations })`,
|
|
70
|
+
so deleting a user also deletes every tuple naming them. — v0.1
|
|
71
|
+
- **`list()`** — the ids of every object a subject holds a permission on, as a
|
|
72
|
+
cursor page, `fromField` relations included through their `lookup`. — v0.1
|
|
73
|
+
- **`can()`, `grant()` and `revoke()`** — a permission check that answers
|
|
74
|
+
`true` or `false` and throws on an outage, and tuple writes refused at
|
|
75
|
+
compile time when the model does not admit them. — v0.1
|
|
76
|
+
- **Typed subjects and the `RelationStore` port** — `{ type, id }` subjects,
|
|
77
|
+
the tuple notation, `createMemoryRelations()`, and
|
|
78
|
+
`describeRelationStores` for adapter authors. — v0.1
|
|
79
|
+
- **A permission model typed from itself** — `defineModel` with subject sets,
|
|
80
|
+
arrows, `fromField` relations read from your data, and `when` conditions
|
|
81
|
+
written in TypeScript. — v0.1
|
|
82
|
+
- **Delete a user, and everything of theirs** — `delete(user)` removes the
|
|
83
|
+
user with every session and one-time token they had, idempotently. — v0.1
|
|
84
|
+
- **Rehash a stale password on sign-in** — moving hashers, or raising a cost,
|
|
85
|
+
reaches every active user with no migration to run. — v0.1
|
|
86
|
+
- **`janus()`** — sign-up, sign-in, sessions, e-mail verification and password
|
|
87
|
+
reset, with several user types in one instance, typed from your schemas.
|
|
88
|
+
— v0.1
|
|
89
|
+
- **The conformance suite** — `@nxgt/janus/conformance`, the suite an adapter
|
|
90
|
+
runs, outages included. — v0.1
|
|
91
|
+
- **The store port and its in-memory reference** — `JanusStores` and
|
|
92
|
+
`createMemoryStores()`, for your tests and as the model for an adapter.
|
|
93
|
+
— v0.1
|