@nxgt/janus 0.2.0 → 0.2.1

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
 
package/docs/roadmap.md CHANGED
@@ -10,16 +10,42 @@ dates here, and the version something shipped in is the only number.
10
10
  migrations. Built, not yet published.
11
11
  - **Tracing and an audit trail** — in a package of its own,
12
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.
13
+ security events worth keeping, never a login, a password, a session token
14
+ or a one-time token. Built, not yet published.
15
+ - **A Redis adapter for sessions and one-time tokens** — in a package of its
16
+ own, `@nxgt/janus-redis`: both are read on every request and ephemeral, so
17
+ they live in Redis, expired by Redis itself, while users stay in another
18
+ store. Built, not yet published.
19
19
 
20
20
  ## Next
21
21
 
22
- Nothing yet.
22
+ - **One-time codes** — a one-time token short enough to type, sent by e-mail
23
+ to sign in without a password, or to confirm a sensitive action, issued
24
+ and redeemed by `janus` like the verification and reset tokens today; and
25
+ TOTP, the one-time codes of an authenticator app, as a second factor.
26
+ - **Sending the e-mails** — in a package of its own, `@nxgt/janus-mail`, built
27
+ on a general mail toolkit shared with applications that are not about
28
+ sign-in: a `Mailer` port you plug your transport into (SMTP, Resend, SES…) —
29
+ a transport that fails throws, like a store — and default templates for
30
+ verification, password reset and one-time codes, in English and French. One
31
+ template per e-mail, never one HTML file per language: the layout is built
32
+ once with Maizzle and Tailwind CSS 4 — CSS inlined for mail clients — and
33
+ its text lives in ICU message catalogues, one per language, plurals and
34
+ dates included. Both are compiled when the package is built into typed
35
+ functions: `templates.verifyEmail({ locale: 'fr', link })` answers
36
+ `{ subject, html, text }`, every value escaped, a missing variable or an
37
+ unknown locale a compile error, a message that fails to format a throw —
38
+ never an e-mail sent with `{link}` in it. No template engine at run time.
39
+ The defaults are a starting point, not a requirement: add a language with a
40
+ catalogue, or replace any one template with your own function of the same
41
+ shape — built with the same toolkit, React Email or a plain string — and
42
+ keep the defaults for the rest.
43
+ - **Webhooks** — signed HTTP events when something happens to a user
44
+ (created, e-mail verified, password reset, deleted), so another service can
45
+ follow without polling: a signature it can check, retries on failure, and
46
+ the same rule as the audit trail — the user named by id, never a login, a
47
+ password, a session token or a one-time token in a payload, and every key
48
+ camelCase. Retries that run out are reported, never dropped in silence.
23
49
 
24
50
  ## Later
25
51
 
@@ -81,6 +107,10 @@ The first public release, v0.1.
81
107
  - **The conformance suite accepts a store with its own expiry** — a store
82
108
  that drops a lapsed session at once, as a Redis TTL does, passes
83
109
  `sessions.deleteUser`; one that still holds it must count it. — next patch
110
+ - **A NUL character or a lone surrogate never reaches a store** — refused in
111
+ fields with `USER_INVALID` on every adapter, rather than `STORE_FAILED` on
112
+ PostgreSQL alone; a login holding one is nobody's. The conformance suite
113
+ holds every adapter to round-tripping every other character. — next patch
84
114
 
85
115
  - **A Hono integration** — [`@nxgt/janus-hono`](https://www.npmjs.com/package/@nxgt/janus-hono):
86
116
  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.1",
4
4
  "description": "Embeddable, type-safe identities and permissions: bring your own database",
5
5
  "license": "MIT",
6
6
  "type": "module",