@nxgt/janus 0.4.0 → 0.5.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 (55) hide show
  1. package/README.md +102 -18
  2. package/dist/auth/config.d.ts +25 -1
  3. package/dist/auth/config.d.ts.map +1 -1
  4. package/dist/auth/context.d.ts.map +1 -1
  5. package/dist/auth/index.d.ts +3 -2
  6. package/dist/auth/index.d.ts.map +1 -1
  7. package/dist/auth/one-time.d.ts +39 -0
  8. package/dist/auth/one-time.d.ts.map +1 -0
  9. package/dist/auth/port/types.d.ts +10 -7
  10. package/dist/auth/port/types.d.ts.map +1 -1
  11. package/dist/auth/sealing.d.ts +39 -0
  12. package/dist/auth/sealing.d.ts.map +1 -0
  13. package/dist/auth/second-factor/challenge.d.ts +20 -0
  14. package/dist/auth/second-factor/challenge.d.ts.map +1 -0
  15. package/dist/auth/second-factor/factor.d.ts +30 -0
  16. package/dist/auth/second-factor/factor.d.ts.map +1 -0
  17. package/dist/auth/second-factor/flows.d.ts +19 -0
  18. package/dist/auth/second-factor/flows.d.ts.map +1 -0
  19. package/dist/auth/second-factor/lifecycle.d.ts +8 -0
  20. package/dist/auth/second-factor/lifecycle.d.ts.map +1 -0
  21. package/dist/auth/sessions.d.ts.map +1 -1
  22. package/dist/auth/totp.d.ts +36 -0
  23. package/dist/auth/totp.d.ts.map +1 -0
  24. package/dist/auth/types.d.ts +91 -7
  25. package/dist/auth/types.d.ts.map +1 -1
  26. package/dist/auth/users.d.ts +2 -2
  27. package/dist/auth/users.d.ts.map +1 -1
  28. package/dist/chunks/{index-qwfkhqkk.js → index-06vp9c5r.js} +12 -3
  29. package/dist/chunks/{index-qwfkhqkk.js.map → index-06vp9c5r.js.map} +3 -3
  30. package/dist/chunks/{index-53y1afjz.js → index-5vr13kkb.js} +2 -2
  31. package/dist/chunks/{index-mgh85djb.js → index-c4v27jfr.js} +2 -2
  32. package/dist/chunks/{index-thtyq7a9.js → index-gbwn2tts.js} +2 -2
  33. package/dist/conformance/cases/tokens.d.ts.map +1 -1
  34. package/dist/conformance/index.js +15 -4
  35. package/dist/conformance/index.js.map +3 -3
  36. package/dist/errors/janus-error.d.ts +28 -2
  37. package/dist/errors/janus-error.d.ts.map +1 -1
  38. package/dist/index.d.ts +1 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +388 -41
  41. package/dist/index.js.map +14 -7
  42. package/dist/permissions/index.js +3 -3
  43. package/docs/README.md +1 -0
  44. package/docs/guide/adapters.md +12 -7
  45. package/docs/guide/errors.md +18 -6
  46. package/docs/guide/second-factor.md +569 -0
  47. package/docs/guide/sessions.md +18 -0
  48. package/docs/guide/users.md +8 -2
  49. package/docs/guide/vocabulary.md +6 -3
  50. package/docs/roadmap.md +26 -48
  51. package/docs/troubleshooting.md +260 -5
  52. package/package.json +1 -1
  53. /package/dist/chunks/{index-53y1afjz.js.map → index-5vr13kkb.js.map} +0 -0
  54. /package/dist/chunks/{index-mgh85djb.js.map → index-c4v27jfr.js.map} +0 -0
  55. /package/dist/chunks/{index-thtyq7a9.js.map → index-gbwn2tts.js.map} +0 -0
@@ -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
 
@@ -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.
@@ -79,7 +84,10 @@ that wired the library, so no handler needs to tell it apart.
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
86
  | `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) | |
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 | |
88
+ | `CODE_INVALID` | `TokenError` | 401 | A second factor's code that does not match, or was already accepted — see [the second factor](second-factor.md#confirming-the-code-at-sign-in) | `attemptsLeft` from `confirm`: what the challenge has left, `0` once it is spent. None from `activate` |
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.