@nxgt/janus 0.2.0 → 0.2.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.
@@ -66,7 +66,11 @@ time. A schema is any [Standard Schema](https://standardschema.dev) (Zod 4,
66
66
  Valibot, ArkType), and its output must be JSON: a schema that produces a
67
67
  `Date` is refused at compile time, because a `Date` round-trips through one
68
68
  database and not the next. An optional field left `undefined` is dropped
69
- before the store sees it.
69
+ before the store sees it. A string or a key holding a NUL character (`\u0000`)
70
+ or a lone surrogate is refused with `USER_INVALID`, on every adapter: PostgreSQL
71
+ keeps neither, so janus refuses them everywhere rather than fail on one. So is
72
+ a login your own `password.normalize` function turns into one — a `slice`
73
+ that cuts an emoji in half, say.
70
74
 
71
75
  ## Options
72
76
 
@@ -179,7 +183,7 @@ With a `password`, besides:
179
183
  | --- | --- | --- |
180
184
  | `signUp(fields & { password })` | `{ user, session, token }` | `USER_INVALID`, `PASSWORD_TOO_SHORT`, `LOGIN_TAKEN` |
181
185
  | `signIn({ [login]: string, password })` | `{ user, session, token }` | `CREDENTIALS_INVALID`, `USER_INACTIVE`, `HASH_UNSUPPORTED` |
182
- | `findByLogin(login)` | the user, or `null`; the login is normalised first | |
186
+ | `findByLogin(login)` | the user, or `null`; the login is normalised first, and one holding a NUL or a lone surrogate is nobody's | |
183
187
  | `setPassword(user, password, { ifVersion? })` | the user — an admin's call | `PASSWORD_TOO_SHORT` |
184
188
  | `changePassword(user, { current, next }, { ifVersion? })` | the user — the user's own call | `CREDENTIALS_INVALID`, `PASSWORD_TOO_SHORT` |
185
189
 
@@ -46,7 +46,8 @@ either finds this row.
46
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
47
  | **anonymous** | A request that presents no session credential, or one that authenticates nobody: `authenticate` answers `null` | "unauthenticated", "guest", "logged out" |
48
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 |
49
+ | **one-time token** | A single-use token sent by e-mail, for verification or a password reset | "code" alone — `code` is an error's code |
50
+ | **one-time code** | Planned, see [the roadmap](../roadmap.md#next): a one-time token short enough to type, sent by e-mail — or, for TOTP, computed by an authenticator app and never sent | "OTP", "PIN", "code" alone |
50
51
  | **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
52
  | **e-mail flow** | `verifyEmail` or `resetPassword`: send a one-time token, then confirm it | |
52
53
 
@@ -77,7 +78,7 @@ either finds this row.
77
78
  | --- | --- | --- |
78
79
  | **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" |
79
80
  | **port** | The interface a store implements: `JanusStores` for the identity stores — named after the package, not the side — and `RelationStore` | "driver" |
80
- | **adapter** | A package implementing the ports for one database: `@nxgt/janus-mongo`. What its `create…Adapter(db)` answers is its stores, keyed as `janus()` takes them — `{ store, relations }` | "plugin", "connector" |
81
+ | **adapter** | A package implementing the ports for one database: `@nxgt/janus-mongo`, `@nxgt/janus-drizzle`, `@nxgt/janus-redis`. What its `create…Adapter(db)` answers is its stores, keyed as `janus()` takes them — `{ store, relations }` | "plugin", "connector" |
81
82
  | **integration** | A package fitting Janus into one web framework or one observability library: `@nxgt/janus-hono`, `@nxgt/janus-telemetry`. It implements no port | "plugin", "adapter" |
82
83
  | **kit** | A package that opens the connections and wires adapters and integrations into one object for an application: `@nxgt/janus-kit`'s `connectKit` answers `{ auth, access, db, redis, ping, close }`. It implements no port, and `janus()` and `permissions()` are still written by the application | "framework", "starter" |
83
84
 
package/docs/roadmap.md CHANGED
@@ -5,21 +5,37 @@ dates here, and the version something shipped in is the only number.
5
5
 
6
6
  ## Now
7
7
 
8
- - **A PostgreSQL adapter** — in a package of its own, `@nxgt/janus-drizzle`,
9
- on Drizzle: both sides over one database, the tables in your own drizzle-kit
10
- migrations. Built, not yet published.
11
- - **Tracing and an audit trail** — in a package of its own,
12
- `@nxgt/janus-telemetry`: a span per flow and per permission check, and the
13
- security events worth keeping, never a login, a password or a token. Built,
14
- not yet published.
15
- - **A Redis adapter for sessions and tokens** — in a package of its own,
16
- `@nxgt/janus-redis`: both are read on every request and ephemeral, so they
17
- live in Redis, expired by Redis itself, while users stay in another store.
18
- Built, not yet published.
8
+ Nothing yet.
19
9
 
20
10
  ## Next
21
11
 
22
- Nothing yet.
12
+ - **One-time codes** — a one-time token short enough to type, sent by e-mail
13
+ to sign in without a password, or to confirm a sensitive action, issued
14
+ and redeemed by `janus` like the verification and reset tokens today; and
15
+ TOTP, the one-time codes of an authenticator app, as a second factor.
16
+ - **Sending the e-mails** — in a package of its own, `@nxgt/janus-mail`, built
17
+ on a general mail toolkit shared with applications that are not about
18
+ sign-in: a `Mailer` port you plug your transport into (SMTP, Resend, SES…) —
19
+ a transport that fails throws, like a store — and default templates for
20
+ verification, password reset and one-time codes, in English and French. One
21
+ template per e-mail, never one HTML file per language: the layout is built
22
+ once with Maizzle and Tailwind CSS 4 — CSS inlined for mail clients — and
23
+ its text lives in ICU message catalogues, one per language, plurals and
24
+ dates included. Both are compiled when the package is built into typed
25
+ functions: `templates.verifyEmail({ locale: 'fr', link })` answers
26
+ `{ subject, html, text }`, every value escaped, a missing variable or an
27
+ unknown locale a compile error, a message that fails to format a throw —
28
+ never an e-mail sent with `{link}` in it. No template engine at run time.
29
+ The defaults are a starting point, not a requirement: add a language with a
30
+ catalogue, or replace any one template with your own function of the same
31
+ shape — built with the same toolkit, React Email or a plain string — and
32
+ keep the defaults for the rest.
33
+ - **Webhooks** — signed HTTP events when something happens to a user
34
+ (created, e-mail verified, password reset, deleted), so another service can
35
+ follow without polling: a signature it can check, retries on failure, and
36
+ the same rule as the audit trail — the user named by id, never a login, a
37
+ password, a session token or a one-time token in a payload, and every key
38
+ camelCase. Retries that run out are reported, never dropped in silence.
23
39
 
24
40
  ## Later
25
41
 
@@ -66,7 +82,19 @@ Nothing yet.
66
82
 
67
83
  ## Shipped
68
84
 
69
- The first public release, v0.1.
85
+ Each entry names the version it came in.
86
+
87
+ - **The adapters and the kit, each at its first release, v0.1.0, beside
88
+ `@nxgt/janus` 0.2.2** —
89
+ [`@nxgt/janus-drizzle`](https://www.npmjs.com/package/@nxgt/janus-drizzle),
90
+ both sides over one PostgreSQL database on Drizzle;
91
+ [`@nxgt/janus-redis`](https://www.npmjs.com/package/@nxgt/janus-redis),
92
+ sessions and one-time tokens in Redis, expired by Redis itself;
93
+ [`@nxgt/janus-telemetry`](https://www.npmjs.com/package/@nxgt/janus-telemetry),
94
+ a span per flow and per permission check, and the security events worth an
95
+ audit trail, never a login, a password, a session token or a one-time token;
96
+ and [`@nxgt/janus-kit`](https://www.npmjs.com/package/@nxgt/janus-kit), all
97
+ of it wired in one call.
70
98
 
71
99
  - **The model's keys read `related` and `permits`** — Keto's OPL words: an
72
100
  object type declares `related: { members: ['staff', 'team#members'] }` and
@@ -74,13 +102,17 @@ The first public release, v0.1.
74
102
  `relations` and `permissions` as keys are refused, by the compiler and by
75
103
  `defineModel`, with a message naming the new key —
76
104
  `types.team.relations is now related: rename the key`. The `permissions()`
77
- function and `janus({ relations })` keep their names. — next minor
105
+ function and `janus({ relations })` keep their names. — v0.2.0
78
106
  - **`LOGIN_TAKEN` no longer quotes the login in its message**, in the memory
79
107
  store and in both adapters; `error.login` still names it, and the
80
- conformance suite checks it. — next patch
108
+ conformance suite checks it. — v0.2.0
81
109
  - **The conformance suite accepts a store with its own expiry** — a store
82
110
  that drops a lapsed session at once, as a Redis TTL does, passes
83
- `sessions.deleteUser`; one that still holds it must count it. — next patch
111
+ `sessions.deleteUser`; one that still holds it must count it. — v0.2.0
112
+ - **A NUL character or a lone surrogate never reaches a store** — refused in
113
+ fields with `USER_INVALID` on every adapter, rather than `STORE_FAILED` on
114
+ PostgreSQL alone; a login holding one is nobody's. The conformance suite
115
+ holds every adapter to round-tripping every other character. — v0.2.1
84
116
 
85
117
  - **A Hono integration** — [`@nxgt/janus-hono`](https://www.npmjs.com/package/@nxgt/janus-hono):
86
118
  the session middleware, the cookie, a route guarded by a permission,
@@ -354,7 +354,7 @@ await auth.update(user, { name }, { ifVersion: user.version });
354
354
  `UserInvalidError`, carrying `issues` — each a `path` and a `message`.
355
355
 
356
356
  **When:** `signUp`, `create`, `update`.
357
- **Why:** your schema refused the fields. `update` merges the patch over the stored fields and validates the whole, so the refusal can name a field the patch did not touch. An issue whose message is `set by janus, not by a request` means the input carried a field janus sets (`id`, `active`, `version`, …) and your schema let it through.
357
+ **Why:** your schema refused the fields. `update` merges the patch over the stored fields and validates the whole, so the refusal can name a field the patch did not touch. An issue whose message is `set by janus, not by a request` means the input carried a field janus sets (`id`, `active`, `version`, …) and your schema let it through. An issue whose message is `holds a NUL character or a lone surrogate, which no store can keep` means a string — or, with `a key holds …`, an object key — carried `\u0000` or half of a surrogate pair: PostgreSQL refuses both, so janus refuses them on every adapter rather than answer `STORE_FAILED` on one. A key is reported by the path of the object holding it, never by the key itself — in janus's issues and in your schema's alike. An issue at your login field whose message is `normalises to a login that holds …` means your own `password.normalize` function produced such a login from a valid field: fix the function. On MongoDB or the memory store, a user written before janus refused these characters may already hold one: every `update` of that user is refused, whatever it patches, until the field is cleaned — patch it with a clean value in the same `update`.
358
358
  **Fix:** answer 400, field by field:
359
359
 
360
360
  ```ts
@@ -374,7 +374,7 @@ if (error instanceof UserInvalidError) {
374
374
  Also `changePassword: the current password does not match`.
375
375
 
376
376
  **When:** `signIn`, `changePassword`.
377
- **Why:** no user holds the login, the user has no password, or the password is wrong — **one code for the three**. `error.reason` (`unknownLogin`, `noPassword`, `wrongPassword`) tells them apart for your logs and your rate limiter.
377
+ **Why:** no user holds the login, the user has no password, or the password is wrong — **one code for the three**. `error.reason` (`unknownLogin`, `noPassword`, `wrongPassword`) tells them apart for your logs and your rate limiter. A login holding a NUL character or a lone surrogate is `unknownLogin`: no user can hold one.
378
378
  **Fix:** answer 401 with the same body whatever the reason:
379
379
 
380
380
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/janus",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Embeddable, type-safe identities and permissions: bring your own database",
5
5
  "license": "MIT",
6
6
  "type": "module",