@nxgt/janus 0.4.0 → 0.6.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.
Files changed (60) hide show
  1. package/README.md +159 -20
  2. package/dist/auth/config.d.ts +28 -1
  3. package/dist/auth/config.d.ts.map +1 -1
  4. package/dist/auth/context.d.ts +6 -0
  5. package/dist/auth/context.d.ts.map +1 -1
  6. package/dist/auth/index.d.ts +3 -2
  7. package/dist/auth/index.d.ts.map +1 -1
  8. package/dist/auth/one-time.d.ts +92 -0
  9. package/dist/auth/one-time.d.ts.map +1 -0
  10. package/dist/auth/port/types.d.ts +10 -7
  11. package/dist/auth/port/types.d.ts.map +1 -1
  12. package/dist/auth/sealing.d.ts +39 -0
  13. package/dist/auth/sealing.d.ts.map +1 -0
  14. package/dist/auth/second-factor/challenge.d.ts +14 -0
  15. package/dist/auth/second-factor/challenge.d.ts.map +1 -0
  16. package/dist/auth/second-factor/factor.d.ts +27 -0
  17. package/dist/auth/second-factor/factor.d.ts.map +1 -0
  18. package/dist/auth/second-factor/flows.d.ts +23 -0
  19. package/dist/auth/second-factor/flows.d.ts.map +1 -0
  20. package/dist/auth/second-factor/lifecycle.d.ts +8 -0
  21. package/dist/auth/second-factor/lifecycle.d.ts.map +1 -0
  22. package/dist/auth/sessions.d.ts.map +1 -1
  23. package/dist/auth/sign-in-code.d.ts +17 -0
  24. package/dist/auth/sign-in-code.d.ts.map +1 -0
  25. package/dist/auth/totp.d.ts +36 -0
  26. package/dist/auth/totp.d.ts.map +1 -0
  27. package/dist/auth/types.d.ts +140 -7
  28. package/dist/auth/types.d.ts.map +1 -1
  29. package/dist/auth/users.d.ts +2 -2
  30. package/dist/auth/users.d.ts.map +1 -1
  31. package/dist/chunks/{index-qwfkhqkk.js → index-06vp9c5r.js} +12 -3
  32. package/dist/chunks/{index-qwfkhqkk.js.map → index-06vp9c5r.js.map} +3 -3
  33. package/dist/chunks/{index-53y1afjz.js → index-5vr13kkb.js} +2 -2
  34. package/dist/chunks/{index-mgh85djb.js → index-c4v27jfr.js} +2 -2
  35. package/dist/chunks/{index-thtyq7a9.js → index-gbwn2tts.js} +2 -2
  36. package/dist/conformance/cases/tokens.d.ts.map +1 -1
  37. package/dist/conformance/index.js +15 -4
  38. package/dist/conformance/index.js.map +3 -3
  39. package/dist/errors/janus-error.d.ts +28 -2
  40. package/dist/errors/janus-error.d.ts.map +1 -1
  41. package/dist/index.d.ts +1 -1
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +478 -51
  44. package/dist/index.js.map +15 -7
  45. package/dist/permissions/index.js +3 -3
  46. package/docs/README.md +2 -0
  47. package/docs/guide/adapters.md +12 -7
  48. package/docs/guide/email-flows.md +4 -0
  49. package/docs/guide/errors.md +19 -7
  50. package/docs/guide/second-factor.md +571 -0
  51. package/docs/guide/sessions.md +18 -0
  52. package/docs/guide/sign-in-code.md +471 -0
  53. package/docs/guide/users.md +10 -3
  54. package/docs/guide/vocabulary.md +9 -6
  55. package/docs/roadmap.md +39 -54
  56. package/docs/troubleshooting.md +414 -5
  57. package/package.json +1 -1
  58. /package/dist/chunks/{index-53y1afjz.js.map → index-5vr13kkb.js.map} +0 -0
  59. /package/dist/chunks/{index-mgh85djb.js.map → index-c4v27jfr.js.map} +0 -0
  60. /package/dist/chunks/{index-thtyq7a9.js.map → index-gbwn2tts.js.map} +0 -0
package/docs/README.md CHANGED
@@ -14,6 +14,8 @@ below are defined once, in [Words](guide/vocabulary.md#words).
14
14
  | [Users](guide/users.md) | You are wiring `janus()`, declaring one or several user types, or calling `create`, `update`, `list`, `delete` |
15
15
  | [Sessions](guide/sessions.md) | You need to know who a request belongs to, set or clear the cookie, renew, sign out, or test expiry |
16
16
  | [E-mail verification and password reset](guide/email-flows.md) | You are sending a verification or reset link, and handling what comes back |
17
+ | [Signing in with an e-mailed code](guide/sign-in-code.md) | You are signing users in with a six-digit code sent by e-mail — with no password, or beside one: requesting it without telling who exists, keeping the challenge, the attempts and the errors |
18
+ | [The second factor](guide/second-factor.md) | You are turning on TOTP codes: making and rotating the sealing keys, the QR code, `signIn`'s `status`, confirming a challenge, disabling |
17
19
  | [Password hashing](guide/passwords.md) | You are choosing a hasher, raising its cost, moving to argon2id, or importing hashes from another system |
18
20
 
19
21
  ### Permissions — `@nxgt/janus/permissions`
@@ -97,10 +97,12 @@ interface UserRecord {
97
97
  }
98
98
  ```
99
99
 
100
- `secret` is **opaque to a store**: once the second factor ships, the core
101
- will seal it with a key the application holds before a store sees it, so a
102
- dump of the users cannot produce a code. Keep it like a password hash — byte
103
- for byte, no parsing, no trimming. Store
100
+ `secret` is **opaque to a store**: the core seals it with a key the
101
+ application holds — AES-256-GCM, written `v1.<key id>.<iv>.<ciphertext>` —
102
+ before a store sees it, so a dump of the users cannot produce a code. Keep it
103
+ like a password hash — byte for byte, no parsing, no trimming. The core
104
+ rewrites it, sealed under another key, when the application
105
+ [rotates its keys](second-factor.md#rotating-the-keys). Store
104
106
  the second factor whole: a method without a secret, or a `lastStep` without a
105
107
  method, is a record the core never writes.
106
108
 
@@ -150,7 +152,7 @@ interface TokenRecord {
150
152
  readonly tokenHash: string;
151
153
  readonly kind: TokenKind;
152
154
  readonly userId: Id;
153
- readonly address: string;
155
+ readonly address: string; // '' for a secondFactor challenge: nothing was sent
154
156
  readonly codeHash: string | null; // a signInCode's code, hashed; null for every other kind
155
157
  readonly attempts: number; // 0 at insertion
156
158
  readonly expiresAt: Date;
@@ -160,7 +162,9 @@ interface TokenRecord {
160
162
  ```
161
163
 
162
164
  A token redeemed for another kind is unknown: every method that takes a
163
- `kind` matches on it. `codeHash` and `attempts` round-trip like every other
165
+ `kind` matches on it. A `secondFactor` token is the challenge `signIn`
166
+ answers, and its `address` is `''`: a column or a validator that refuses an
167
+ empty string refuses every sign-in with a code. `codeHash` and `attempts` round-trip like every other
164
168
  field. An adapter whose stored tokens predate them reads them as `null` and
165
169
  `0`, as the three published adapters do, so no data migration is needed for
166
170
  them.
@@ -294,7 +298,7 @@ compile error naming the missing method.
294
298
 
295
299
  | Suite | Cases | Harness opens |
296
300
  | --- | --- | --- |
297
- | `describeJanusStores({ name, harness, runner?, faults?, skip? })` | 44: users, sessions, tokens, and one outage per method whose honest answer can be "nothing" — twelve of them | `{ stores, faults?, close? }` |
301
+ | `describeJanusStores({ name, harness, runner?, faults?, skip? })` | 45: users, sessions, tokens, and one outage per method whose honest answer can be "nothing" — twelve of them | `{ stores, faults?, close? }` |
298
302
  | `describeRelationStores({ name, harness, runner?, faults?, skip? })` | 15: the relation store, and one outage per method | `{ store, faults?, close? }` |
299
303
 
300
304
  `harness.open()` is called **once per case** and must answer fresh, empty
@@ -320,6 +324,7 @@ while you work on it, never to ship:
320
324
  | `tokens.countAttempt` | two calls answer `attempts` 1 then 2, `codeHash` as written; `consumeToken` answers the count |
321
325
  | `tokens.countAttemptConcurrency` | twenty concurrent calls answer 1 to 20, each once |
322
326
  | `tokens.countAttemptRace` | attempts racing one redemption: the counts answered unspent are 1 to the final count, and every answer after the spend carries that final count |
327
+ | `tokens.challenge` | a second-factor challenge, whose `address` is `''`, kept, counted and spent like any token |
323
328
  | `tokens.countAttemptSpent` | a spent token answered unchanged; another kind and an unknown hash answer `null` and count nothing |
324
329
  | `outage.countAttempt` | a store that cannot answer rejects, never `null` |
325
330
 
@@ -51,6 +51,9 @@ clinic.patient.verifyEmail.send; // exists: `email` names the field
51
51
  clinic.staff.verifyEmail;
52
52
  ```
53
53
 
54
+ A type with an e-mail can also sign in with a code sent to it, with or
55
+ without a password — see [sign-in codes](sign-in-code.md).
56
+
54
57
  ## Options
55
58
 
56
59
  | Option | Type | Default | Effect |
@@ -142,5 +145,6 @@ never the token, and no refusal's message contains it.
142
145
  ## See also
143
146
 
144
147
  - [Users](users.md) — `email`, `update`, and the other per-type methods
148
+ - [Sign-in codes](sign-in-code.md) — the third flow that sends an e-mail: a code, not a link
145
149
  - [Sessions](sessions.md) — `signOutEverywhere`, which `resetPassword.confirm` calls for you
146
150
  - [Errors](errors.md) — every code, and the status it deserves
@@ -27,7 +27,11 @@ export function statusOf(code: JanusErrorCode): number {
27
27
  case 'TOKEN_STALE':
28
28
  return 400;
29
29
  case 'CREDENTIALS_INVALID':
30
+ case 'CODE_INVALID':
30
31
  return 401;
32
+ case 'SECOND_FACTOR_NOT_ENROLLED':
33
+ case 'SECOND_FACTOR_ACTIVE':
34
+ return 409;
31
35
  case 'USER_INACTIVE':
32
36
  return 403;
33
37
  case 'UNSUPPORTED':
@@ -39,11 +43,12 @@ export function statusOf(code: JanusErrorCode): number {
39
43
 
40
44
  export function toResponse(error: unknown): Response {
41
45
  if (!(error instanceof JanusError)) throw error;
42
- return Response.json({ code: error.code }, { status: statusOf(error.code) });
46
+ const body = error.code === 'CODE_INVALID' ? { code: error.code, attemptsLeft: error.attemptsLeft } : { code: error.code };
47
+ return Response.json(body, { status: statusOf(error.code) });
43
48
  }
44
49
  ```
45
50
 
46
- `JanusErrorCode` is a union of sixteen string literals, so that `switch` is
51
+ `JanusErrorCode` is a union of nineteen string literals, so that `switch` is
47
52
  exhaustive: when a code is added, a function like `statusOf` stops compiling
48
53
  instead of answering `undefined`.
49
54
 
@@ -61,7 +66,7 @@ told they do not exist.
61
66
  | Thrown | When | Class |
62
67
  | --- | --- | --- |
63
68
  | At **call** time, on a value that could have come from a request | a taken login, a wrong password, a spent token, an outage | a `JanusError` subclass, with a `code` |
64
- | At **wiring** time, from how you called the library | a lifespan that is not a duration, a store missing a method, a model with a loop, a malformed tuple string | a bare `TypeError` |
69
+ | At **wiring** time, from how you called the library | a lifespan that is not a duration, a store missing a method, a model with a loop, a malformed tuple string, a sealing key removed while secrets sealed with it are stored, a user with an active second factor signing in through a `janus()` given no `secondFactor` | a bare `TypeError` |
65
70
 
66
71
  No request handler should ever answer a `TypeError` — it is a bug in the code
67
72
  that wired the library, so no handler needs to tell it apart.
@@ -78,8 +83,11 @@ that wired the library, so no handler needs to tell it apart.
78
83
  | `PASSWORD_TOO_SHORT` | `CredentialError` | 400 | Below `password.minLength` | `minLength` — never the password |
79
84
  | `CREDENTIALS_INVALID` | `CredentialError` | 401 | Unknown login, no password, or the wrong one — **one code for the three** | `reason`, for your logs only |
80
85
  | `HASH_UNSUPPORTED` | `CredentialError` | 400 | A stored hash no wired hasher reads | `hashPrefix` — never the hash |
81
- | `USER_INACTIVE` | `UserInactiveError` | 403 | Deactivated; told only to someone who gave the right password | `userId` |
82
- | `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`, `TOKEN_STALE` | `TokenError` | 400 | See [e-mail flows](email-flows.md#what-a-token-refusal-means) | |
86
+ | `USER_INACTIVE` | `UserInactiveError` | 403 | Deactivated; told only to someone who gave the right password, or the right code | `userId` |
87
+ | `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`, `TOKEN_STALE` | `TokenError` | 400 | See [e-mail flows](email-flows.md#what-a-token-refusal-means). For a second factor's challenge: sign in again; for a sign-in code's: request a new code — see [sign-in codes](sign-in-code.md#what-confirm-refuses) | `userId` on `TOKEN_STALE` |
88
+ | `CODE_INVALID` | `TokenError` | 401 | A one-time code that does not match: a second factor's, or one already accepted — see [the second factor](second-factor.md#confirming-the-code-at-sign-in) — or a sign-in code sent by e-mail — see [sign-in codes](sign-in-code.md#attempts) | `attemptsLeft` from either `confirm`: what the challenge has left, `0` once it is spent. None from `activate`. `userId` |
89
+ | `SECOND_FACTOR_NOT_ENROLLED` | `SecondFactorError` | 409 | `activate` before `enroll`, or `confirm` after the factor was disabled | `userId` |
90
+ | `SECOND_FACTOR_ACTIVE` | `SecondFactorError` | 409 | `enroll` or `activate` on a factor already active: `disable` it first | `userId` |
83
91
  | `INVALID_CURSOR` | `InvalidCursorError` | 400 | A cursor this store did not mint. Never a silent first page | |
84
92
  | `UNSUPPORTED` | `UnsupportedError` | 501 | The wired store lacks an optional capability — `collectExpired` without `deleteExpiredSessions` | `slot`, `operation` |
85
93
  | `PERMISSION_DEPTH` | `PermissionDepthError` | 500 | A check or list walked past `maxDepth`. **Not a denial** | `permission`, `maxDepth` |
@@ -114,13 +122,17 @@ async function signIn(email: string, password: string): Promise<Response> {
114
122
  would read as "no".
115
123
  - **`VERSION_CONFLICT` is a retry**: read the user again, reapply, write with
116
124
  the new `version`.
125
+ - **`CODE_INVALID`'s `attemptsLeft`** belongs in the body — the form can say
126
+ how many attempts are left. `0` means the challenge is spent: send the visitor
127
+ back to the password.
117
128
  - **`USER_INVALID`'s `issues`** have the schema's own paths
118
129
  (`['address', 'city']`), so a form can show each next to its field.
119
130
 
120
131
  ## No message holds a secret
121
132
 
122
- Not a password, not a hash, not a session token, not a token's hash, and not a
123
- connection URI — a connection string holds a password. Nor a login: a message
133
+ Not a password, not a hash, not a session token, not a token's hash, not a
134
+ challenge, a second factor's code or its secret, and not a connection URI — a
135
+ connection string holds a password. Nor a login: a message
124
136
  reports a shape, never a value, so `LOGIN_TAKEN` carries the login in
125
137
  `error.login` and not in its message. A message names the
126
138
  call you wrote (`signIn`, `users.findUser`) so you know where to look.