serverless-ircd 0.8.0 → 0.10.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 (179) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +11 -9
  4. package/.github/workflows/deploy-cf.yml +14 -14
  5. package/CHANGELOG.md +550 -0
  6. package/README.md +275 -222
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +82 -9
  9. package/apps/aws-stack/cdk.json +0 -3
  10. package/apps/aws-stack/package.json +3 -4
  11. package/apps/aws-stack/src/aws-stack.ts +177 -52
  12. package/apps/aws-stack/src/static-site.ts +323 -0
  13. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  14. package/apps/aws-stack/tests/stack.test.ts +267 -92
  15. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  16. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  17. package/apps/cf-tcp-container/package.json +2 -3
  18. package/apps/cf-tcp-container/src/container-server.ts +33 -10
  19. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  20. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  21. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  22. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  23. package/apps/cf-tcp-container/wrangler.toml +1 -10
  24. package/apps/cf-worker/package.json +3 -4
  25. package/apps/cf-worker/wrangler.toml +12 -71
  26. package/apps/local-cli/package.json +1 -1
  27. package/apps/local-cli/src/server.ts +115 -48
  28. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  29. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  30. package/apps/local-cli/tests/rehash.test.ts +147 -0
  31. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  32. package/apps/local-cli/tests/tcp.test.ts +89 -0
  33. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  34. package/apps/web/landing/favicon.ico +0 -0
  35. package/apps/web/landing/index.html +227 -3
  36. package/apps/web/package.json +3 -2
  37. package/apps/web/scripts/build.mjs +91 -6
  38. package/apps/web/src/build-env.ts +125 -4
  39. package/apps/web/src/config-schema.ts +20 -6
  40. package/apps/web/src/render-docs.ts +292 -0
  41. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  42. package/apps/web/tests/build-env.test.ts +210 -9
  43. package/apps/web/tests/build-smoke.test.ts +33 -4
  44. package/apps/web/tests/config-schema.test.ts +149 -25
  45. package/apps/web/tests/landing-content.test.ts +103 -0
  46. package/apps/web/tests/render-docs.test.ts +198 -0
  47. package/docs/AWS-Adapter-Architecture.md +3 -2
  48. package/docs/AWS-Deployment.md +670 -96
  49. package/docs/AWS-TCP-Deployment.md +20 -45
  50. package/docs/Cloudflare-Deployment-Guide.md +87 -113
  51. package/docs/Cloudflare-TCP-Deployment.md +25 -49
  52. package/docs/Release-Process.md +27 -23
  53. package/docs/Services.md +102 -23
  54. package/docs/WebClientGuide.md +35 -26
  55. package/package.json +7 -10
  56. package/packages/aws-adapter/package.json +1 -1
  57. package/packages/aws-adapter/src/aws-runtime.ts +15 -1
  58. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
  59. package/packages/aws-adapter/src/config-loader.ts +19 -2
  60. package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +26 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +190 -123
  63. package/packages/aws-adapter/src/handlers/index.ts +67 -23
  64. package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
  65. package/packages/aws-adapter/src/index.ts +5 -7
  66. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  67. package/packages/aws-adapter/src/serialize.ts +15 -0
  68. package/packages/aws-adapter/src/tables.ts +2 -12
  69. package/packages/aws-adapter/tests/aws-harness.ts +0 -1
  70. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  71. package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
  72. package/packages/aws-adapter/tests/connect.test.ts +124 -1
  73. package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
  74. package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
  75. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
  76. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  77. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  78. package/packages/aws-adapter/tests/handlers.test.ts +117 -11
  79. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  80. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  81. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  82. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  83. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  84. package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
  85. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  86. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  87. package/packages/cf-adapter/package.json +1 -5
  88. package/packages/cf-adapter/src/cf-runtime.ts +59 -8
  89. package/packages/cf-adapter/src/channel-do.ts +13 -3
  90. package/packages/cf-adapter/src/connection-do.ts +284 -115
  91. package/packages/cf-adapter/src/d1-services-store.ts +63 -26
  92. package/packages/cf-adapter/src/env.ts +11 -10
  93. package/packages/cf-adapter/src/index.ts +0 -6
  94. package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
  95. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  96. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  97. package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
  98. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  99. package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
  100. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  101. package/packages/in-memory-runtime/package.json +1 -1
  102. package/packages/irc-core/package.json +1 -1
  103. package/packages/irc-core/src/account-migration.ts +140 -0
  104. package/packages/irc-core/src/commands/account-auth.ts +60 -35
  105. package/packages/irc-core/src/commands/chanserv.ts +288 -4
  106. package/packages/irc-core/src/commands/hostserv.ts +38 -3
  107. package/packages/irc-core/src/commands/index.ts +1 -0
  108. package/packages/irc-core/src/commands/join.ts +41 -35
  109. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  110. package/packages/irc-core/src/commands/nickserv.ts +138 -15
  111. package/packages/irc-core/src/commands/registration.ts +28 -17
  112. package/packages/irc-core/src/commands/sasl.ts +22 -31
  113. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  114. package/packages/irc-core/src/commands/topic.ts +23 -10
  115. package/packages/irc-core/src/config.ts +35 -9
  116. package/packages/irc-core/src/credential-hashing.ts +11 -54
  117. package/packages/irc-core/src/index.ts +1 -0
  118. package/packages/irc-core/src/ports.ts +159 -179
  119. package/packages/irc-core/src/state/channel.ts +17 -0
  120. package/packages/irc-core/src/types.ts +38 -10
  121. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  122. package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
  123. package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
  124. package/packages/irc-core/tests/commands/join.test.ts +179 -0
  125. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  126. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  127. package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
  128. package/packages/irc-core/tests/commands/oper.test.ts +15 -0
  129. package/packages/irc-core/tests/commands/registration.test.ts +336 -108
  130. package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
  131. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  132. package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
  133. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  134. package/packages/irc-core/tests/message-store.test.ts +5 -0
  135. package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
  136. package/packages/irc-core/tests/ports.test.ts +71 -0
  137. package/packages/irc-core/tests/services-store.test.ts +204 -0
  138. package/packages/irc-core/vitest.config.ts +6 -1
  139. package/packages/irc-server/package.json +1 -1
  140. package/packages/irc-server/src/actor.ts +80 -44
  141. package/packages/irc-server/tests/actor.test.ts +384 -50
  142. package/packages/irc-test-support/package.json +1 -1
  143. package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
  144. package/packages/irc-test-support/src/scenarios.ts +21 -6
  145. package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
  146. package/packages/irc-test-support/vitest.config.ts +6 -1
  147. package/pnpm-workspace.yaml +1 -0
  148. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  149. package/scripts/deploy-web-aws.mjs +290 -0
  150. package/scripts/package.json +23 -0
  151. package/scripts/tsconfig.test.json +12 -0
  152. package/scripts/vitest.config.ts +19 -0
  153. package/tools/ci-hardening/package.json +1 -1
  154. package/tools/ci-hardening/src/index.ts +2 -0
  155. package/tools/ci-hardening/src/validate.ts +57 -0
  156. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  157. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  158. package/tools/load-test/package.json +1 -1
  159. package/tools/load-test/src/client.ts +13 -13
  160. package/tools/load-test/tests/client.test.ts +258 -2
  161. package/tools/load-test/tests/config.test.ts +39 -0
  162. package/tools/load-test/tests/harness.test.ts +21 -0
  163. package/tools/load-test/tests/metrics.test.ts +7 -0
  164. package/tools/migrate-accounts-to-services.ts +270 -0
  165. package/tools/package.json +2 -1
  166. package/tools/seed-aws-accounts.ts +35 -10
  167. package/tools/seed-cf-accounts.ts +42 -9
  168. package/tools/tcp-ws-forwarder/package.json +1 -1
  169. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  170. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  171. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
  172. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  173. package/packages/aws-adapter/src/account-store.ts +0 -121
  174. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  175. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  176. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  177. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  178. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  179. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { CHANSERV_NICK } from '../../src/commands/chanserv';
3
+ import { HOSTSERV_NICK } from '../../src/commands/hostserv';
4
+ import { MEMOSERV_NICK } from '../../src/commands/memoserv';
5
+ import { NICKSERV_NICK } from '../../src/commands/nickserv';
6
+ import { OPERSERV_NICK } from '../../src/commands/operserv';
7
+ import { SERVICE_NICKS, SHORTCUT_TO_SERVICE } from '../../src/commands/service-aliases';
8
+
9
+ describe('service-aliases — single source of truth for shortcut verbs', () => {
10
+ it('maps every Atheme/Anope shortcut verb to its canonical service nick', () => {
11
+ const expected: ReadonlyArray<[string, string]> = [
12
+ ['NICKSERV', NICKSERV_NICK],
13
+ ['NS', NICKSERV_NICK],
14
+ ['CHANSERV', CHANSERV_NICK],
15
+ ['CS', CHANSERV_NICK],
16
+ ['HOSTSERV', HOSTSERV_NICK],
17
+ ['HS', HOSTSERV_NICK],
18
+ ['MEMOSERV', MEMOSERV_NICK],
19
+ ['MS', MEMOSERV_NICK],
20
+ ['OPERSERV', OPERSERV_NICK],
21
+ ['OS', OPERSERV_NICK],
22
+ ];
23
+ expect(SHORTCUT_TO_SERVICE.size).toBe(10);
24
+ for (const [alias, nick] of expected) {
25
+ expect(SHORTCUT_TO_SERVICE.get(alias)).toBe(nick);
26
+ }
27
+ });
28
+
29
+ it('keys every alias uppercased so the dispatcher can do a case-insensitive lookup', () => {
30
+ for (const alias of SHORTCUT_TO_SERVICE.keys()) {
31
+ expect(alias).toBe(alias.toUpperCase());
32
+ }
33
+ });
34
+
35
+ it('SERVICE_NICKS is the de-duplicated set of service nicks from the map values', () => {
36
+ const expected = Array.from(new Set(SHORTCUT_TO_SERVICE.values())).sort();
37
+ expect([...SERVICE_NICKS].sort()).toEqual(expected);
38
+ });
39
+
40
+ it('every value in SHORTCUT_TO_SERVICE is one of the canonical *_NICK constants', () => {
41
+ const canonical = new Set<string>([
42
+ NICKSERV_NICK,
43
+ CHANSERV_NICK,
44
+ HOSTSERV_NICK,
45
+ MEMOSERV_NICK,
46
+ OPERSERV_NICK,
47
+ ]);
48
+ for (const nick of SHORTCUT_TO_SERVICE.values()) {
49
+ expect(canonical.has(nick)).toBe(true);
50
+ }
51
+ });
52
+ });
@@ -15,12 +15,15 @@
15
15
  */
16
16
 
17
17
  import { describe, expect, it } from 'vitest';
18
- import { applyAccountSuccess } from '../../src/commands/account-auth';
18
+ import {
19
+ applyAccountSuccess,
20
+ attemptPassAccountAuth,
21
+ passBasedAccountAuth,
22
+ } from '../../src/commands/account-auth';
19
23
  import { NICKSERV_NICK, nickservReducer } from '../../src/commands/nickserv';
24
+ import { passReducer } from '../../src/commands/registration';
20
25
  import { authenticateReducer } from '../../src/commands/sasl';
21
- import { type HashedAccountCredential, hashAccountCredential } from '../../src/credential-hashing';
22
26
  import type { Effect as EffectType } from '../../src/effects';
23
- import type { AccountStore } from '../../src/ports';
24
27
  import {
25
28
  EmptyMotdProvider,
26
29
  FakeClock,
@@ -71,18 +74,13 @@ function saslReadyState(nick = 'alice'): ConnectionState {
71
74
  return s;
72
75
  }
73
76
 
74
- function makeCtx(
75
- state: ConnectionState,
76
- accounts?: AccountStore,
77
- services?: InMemoryServicesStore,
78
- ): Ctx {
77
+ function makeCtx(state: ConnectionState, services?: InMemoryServicesStore): Ctx {
79
78
  return buildCtx({
80
79
  serverConfig,
81
80
  clock: new FakeClock(NOW),
82
81
  ids: new SequentialIdFactory(),
83
82
  motd: EmptyMotdProvider,
84
83
  connection: state,
85
- ...(accounts !== undefined ? { accounts } : {}),
86
84
  ...(services !== undefined ? { services } : {}),
87
85
  });
88
86
  }
@@ -119,6 +117,80 @@ function emptySnapshot(): ServicesSnapshot {
119
117
  };
120
118
  }
121
119
 
120
+ // ============================================================================
121
+ // AC: NickServ REGISTER → PASS <nick>:<password> succeeds post-registration
122
+ // (T178: PASS-auth consults ServicesStore.verifyNick — the bug fix)
123
+ // ============================================================================
124
+
125
+ describe('T178 unified account: NickServ REGISTER → PASS-auth (post-registration)', () => {
126
+ it('a freshly NickServ-registered nick authenticates via PASS <nick>:<password>', () => {
127
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
128
+ const registrar = makeConn('c-reg', 'alice');
129
+ nickservReducer(
130
+ registrar,
131
+ privmsg('REGISTER hunter2 alice@example.com'),
132
+ makeCtx(registrar, services),
133
+ );
134
+
135
+ // A second connection sending PASS after registration (no AccountStore).
136
+ const conn = makeConn('c1', 'alice');
137
+ const ctx = makeCtx(conn, services);
138
+ const out = passReducer(conn, { command: 'PASS', params: ['alice:hunter2'], tags: {} }, ctx);
139
+
140
+ expect(conn.account).toBe('alice');
141
+ expect(conn.userModes.registered).toBe(true);
142
+ const texts = sentTexts(out.effects);
143
+ expect(texts).toContain(
144
+ `:${SV} 900 alice alice!alice@example.com alice :You are now logged in as alice`,
145
+ );
146
+ expect(texts).toContain(`:${SV} 903 alice :SASL authentication successful`);
147
+ });
148
+
149
+ it('PASS-auth with the wrong password after NickServ REGISTER is a silent no-op', () => {
150
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
151
+ const registrar = makeConn('c-reg', 'alice');
152
+ nickservReducer(
153
+ registrar,
154
+ privmsg('REGISTER hunter2 alice@example.com'),
155
+ makeCtx(registrar, services),
156
+ );
157
+
158
+ const conn = makeConn('c1', 'alice');
159
+ const ctx = makeCtx(conn, services);
160
+ const out = passReducer(conn, { command: 'PASS', params: ['alice:wrong'], tags: {} }, ctx);
161
+
162
+ expect(conn.account).toBeUndefined();
163
+ expect(out.effects).toEqual<EffectType[]>([]);
164
+ });
165
+
166
+ it('passBasedAccountAuth identifies via services.verifyNick at registration completion', () => {
167
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
168
+ const registrar = makeConn('c-reg', 'alice');
169
+ nickservReducer(
170
+ registrar,
171
+ privmsg('REGISTER hunter2 alice@example.com'),
172
+ makeCtx(registrar, services),
173
+ );
174
+
175
+ const conn = makeConn('c1', 'alice');
176
+ conn.registration = 'registering';
177
+ conn.passAttempt = 'alice:hunter2';
178
+ const effects = passBasedAccountAuth(conn, makeCtx(conn, services));
179
+
180
+ expect(conn.account).toBe('alice');
181
+ expect(sentTexts(effects).some((t) => t.includes(' 900 '))).toBe(true);
182
+ });
183
+
184
+ it('attemptPassAccountAuth consults services.verifyNick directly', () => {
185
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
186
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
187
+ const conn = makeConn('c1', 'alice');
188
+ const effects = attemptPassAccountAuth(conn, makeCtx(conn, services), 'alice:hunter2');
189
+ expect(conn.account).toBe('alice');
190
+ expect(sentTexts(effects).some((t) => t.includes(' 900 '))).toBe(true);
191
+ });
192
+ });
193
+
122
194
  // ============================================================================
123
195
  // AC: NickServ REGISTER → SASL PLAIN succeeds with the same password
124
196
  // ============================================================================
@@ -127,12 +199,12 @@ describe('T156 unified account: NickServ REGISTER → SASL PLAIN', () => {
127
199
  it('a freshly NickServ-registered nick authenticates via SASL PLAIN with no second registration', () => {
128
200
  const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
129
201
  const registrar = makeConn('c-reg', 'alice');
130
- const regCtx = makeCtx(registrar, undefined, services);
202
+ const regCtx = makeCtx(registrar, services);
131
203
  nickservReducer(registrar, privmsg('REGISTER hunter2 alice@example.com'), regCtx);
132
204
 
133
205
  // A second connection speaking SASL PLAIN with the same nick:password.
134
206
  const saslConn = saslReadyState('alice');
135
- const saslCtx = makeCtx(saslConn, undefined, services);
207
+ const saslCtx = makeCtx(saslConn, services);
136
208
  authenticateReducer(saslConn, authenticate('PLAIN'), saslCtx);
137
209
  const out = authenticateReducer(
138
210
  saslConn,
@@ -155,11 +227,11 @@ describe('T156 unified account: NickServ REGISTER → SASL PLAIN', () => {
155
227
  nickservReducer(
156
228
  registrar,
157
229
  privmsg('REGISTER hunter2 alice@example.com'),
158
- makeCtx(registrar, undefined, services),
230
+ makeCtx(registrar, services),
159
231
  );
160
232
 
161
233
  const saslConn = saslReadyState('alice');
162
- const saslCtx = makeCtx(saslConn, undefined, services);
234
+ const saslCtx = makeCtx(saslConn, services);
163
235
  authenticateReducer(saslConn, authenticate('PLAIN'), saslCtx);
164
236
  const out = authenticateReducer(
165
237
  saslConn,
@@ -179,10 +251,10 @@ describe('T156 unified account: NickServ REGISTER → SASL PLAIN', () => {
179
251
  describe('T156 unified account: SASL account → NickServ INFO / SET ENFORCE', () => {
180
252
  it('NickServ INFO resolves an account seeded into the unified store', () => {
181
253
  const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
182
- services.ingestAccountCredential(hashAccountCredential('alice', 'hunter2'));
254
+ services.registerNick('alice', 'hunter2', '');
183
255
 
184
256
  const conn = makeConn('c1', 'bob');
185
- const out = nickservReducer(conn, privmsg('INFO alice'), makeCtx(conn, undefined, services));
257
+ const out = nickservReducer(conn, privmsg('INFO alice'), makeCtx(conn, services));
186
258
 
187
259
  const texts = sentTexts(out.effects);
188
260
  expect(texts).toContain(':NickServ!NickServ@services NOTICE bob :Nick: alice');
@@ -191,22 +263,18 @@ describe('T156 unified account: SASL account → NickServ INFO / SET ENFORCE', (
191
263
 
192
264
  it('NickServ isRegisteredNick resolves a seeded SASL account', () => {
193
265
  const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
194
- services.ingestAccountCredential(hashAccountCredential('alice', 'hunter2'));
266
+ services.registerNick('alice', 'hunter2', '');
195
267
  expect(services.isRegisteredNick('alice')).toBe(true);
196
268
  expect(services.isRegisteredNick('ALICE')).toBe(true);
197
269
  });
198
270
 
199
271
  it('NickServ SET ENFORCE applies to a seeded SASL account once identified', () => {
200
272
  const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
201
- services.ingestAccountCredential(hashAccountCredential('alice', 'hunter2'));
273
+ services.registerNick('alice', 'hunter2', '');
202
274
  const conn = makeConn('c1', 'alice');
203
275
  conn.account = 'alice';
204
276
  conn.userModes.registered = true;
205
- const out = nickservReducer(
206
- conn,
207
- privmsg('SET ENFORCE kill'),
208
- makeCtx(conn, undefined, services),
209
- );
277
+ const out = nickservReducer(conn, privmsg('SET ENFORCE kill'), makeCtx(conn, services));
210
278
 
211
279
  expect(services.getNickEnforce('alice')).toBe<NickEnforcePolicy>('kill');
212
280
  expect(sentTexts(out.effects)).toContain(
@@ -216,13 +284,9 @@ describe('T156 unified account: SASL account → NickServ INFO / SET ENFORCE', (
216
284
 
217
285
  it('NickServ IDENTIFY succeeds against a seeded SASL account', () => {
218
286
  const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
219
- services.ingestAccountCredential(hashAccountCredential('alice', 'hunter2'));
287
+ services.registerNick('alice', 'hunter2', '');
220
288
  const conn = makeConn('c1', 'alice');
221
- const out = nickservReducer(
222
- conn,
223
- privmsg('IDENTIFY hunter2'),
224
- makeCtx(conn, undefined, services),
225
- );
289
+ const out = nickservReducer(conn, privmsg('IDENTIFY hunter2'), makeCtx(conn, services));
226
290
 
227
291
  expect(conn.account).toBe('alice');
228
292
  expect(conn.userModes.registered).toBe(true);
@@ -233,9 +297,9 @@ describe('T156 unified account: SASL account → NickServ INFO / SET ENFORCE', (
233
297
 
234
298
  it('NickServ IDENTIFY with the wrong password fails against a seeded SASL account', () => {
235
299
  const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
236
- services.ingestAccountCredential(hashAccountCredential('alice', 'hunter2'));
300
+ services.registerNick('alice', 'hunter2', '');
237
301
  const conn = makeConn('c1', 'alice');
238
- const out = nickservReducer(conn, privmsg('IDENTIFY nope'), makeCtx(conn, undefined, services));
302
+ const out = nickservReducer(conn, privmsg('IDENTIFY nope'), makeCtx(conn, services));
239
303
 
240
304
  expect(conn.account).toBeUndefined();
241
305
  expect(sentTexts(out.effects)).toContain(
@@ -243,14 +307,14 @@ describe('T156 unified account: SASL account → NickServ INFO / SET ENFORCE', (
243
307
  );
244
308
  });
245
309
 
246
- it('a NickServ REGISTER does not overwrite an ingested SASL credential', () => {
310
+ it('a NickServ REGISTER does not overwrite an existing credential', () => {
247
311
  const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
248
- services.ingestAccountCredential(hashAccountCredential('alice', 'hunter2'));
312
+ services.registerNick('alice', 'hunter2', '');
249
313
  const conn = makeConn('c1', 'alice');
250
314
  const out = nickservReducer(
251
315
  conn,
252
316
  privmsg('REGISTER otherpw alice@example.com'),
253
- makeCtx(conn, undefined, services),
317
+ makeCtx(conn, services),
254
318
  );
255
319
 
256
320
  expect(sentTexts(out.effects)).toContain(
@@ -260,14 +324,6 @@ describe('T156 unified account: SASL account → NickServ INFO / SET ENFORCE', (
260
324
  expect(services.verifyNick('alice', 'hunter2')).toEqual({ ok: true, account: 'alice' });
261
325
  expect(services.verifyNick('alice', 'otherpw').ok).toBe(false);
262
326
  });
263
-
264
- it('ingestAccountCredential returns false when the nick is already registered', () => {
265
- const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
266
- services.registerNick('alice', 'hunter2', 'alice@example.com');
267
- expect(services.ingestAccountCredential(hashAccountCredential('alice', 'otherpw'))).toBe(false);
268
- // The original credential still verifies.
269
- expect(services.verifyNick('alice', 'hunter2')).toEqual({ ok: true, account: 'alice' });
270
- });
271
327
  });
272
328
 
273
329
  // ============================================================================
@@ -277,15 +333,11 @@ describe('T156 unified account: SASL account → NickServ INFO / SET ENFORCE', (
277
333
  describe('T156 unified account: IDENTIFY vs SASL downstream parity', () => {
278
334
  it('IDENTIFY delivers queued memos (parity with SASL success)', () => {
279
335
  const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
280
- services.ingestAccountCredential(hashAccountCredential('alice', 'hunter2'));
336
+ services.registerNick('alice', 'hunter2', '');
281
337
  services.recordMemo({ from: 'bob', to: 'alice', body: 'hello', sentAt: NOW });
282
338
 
283
339
  const conn = makeConn('c1', 'alice');
284
- const out = nickservReducer(
285
- conn,
286
- privmsg('IDENTIFY hunter2'),
287
- makeCtx(conn, undefined, services),
288
- );
340
+ const out = nickservReducer(conn, privmsg('IDENTIFY hunter2'), makeCtx(conn, services));
289
341
 
290
342
  expect(sentTexts(out.effects)).toContain(
291
343
  ':MemoServ!MemoServ@services NOTICE alice :[Memo #1 from bob] hello',
@@ -294,9 +346,9 @@ describe('T156 unified account: IDENTIFY vs SASL downstream parity', () => {
294
346
 
295
347
  it('both paths share applyAccountSuccess for +r stamping', () => {
296
348
  const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
297
- services.ingestAccountCredential(hashAccountCredential('alice', 'hunter2'));
349
+ services.registerNick('alice', 'hunter2', '');
298
350
  const conn = makeConn('c1', 'alice');
299
- const ctx = makeCtx(conn, undefined, services);
351
+ const ctx = makeCtx(conn, services);
300
352
 
301
353
  const effects = applyAccountSuccess(conn, ctx, 'alice');
302
354
 
@@ -356,6 +408,7 @@ describe('T156 unified account: scrypt-hashed credentials at rest', () => {
356
408
  email: 'l@x',
357
409
  createdAt: NOW,
358
410
  enforce: 'none',
411
+ certSubjects: [],
359
412
  // Malformed credential — empty hash, rejected by verifyHashedPassword.
360
413
  credential: { account: 'legacy', algorithm: 'scrypt', salt: 's', hash: '' },
361
414
  },
@@ -379,38 +432,3 @@ describe('T156 unified account: scrypt-hashed credentials at rest', () => {
379
432
  expect(JSON.stringify(snap.nicks[0])).not.toContain('hunter2');
380
433
  });
381
434
  });
382
-
383
- // ============================================================================
384
- // Adapter ingestion contract: ingestAccountCredential accepts HashedAccountCredential
385
- // ============================================================================
386
-
387
- describe('T156 unified account: ingestAccountCredential', () => {
388
- it('creates a registered nick with default email / enforce from a hashed credential', () => {
389
- const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
390
- const cred = hashAccountCredential('alice', 'hunter2');
391
- expect(services.ingestAccountCredential(cred)).toBe(true);
392
- expect(services.isRegisteredNick('alice')).toBe(true);
393
- const rec = services.getNick('alice');
394
- expect(rec).toBeDefined();
395
- if (rec !== undefined) {
396
- expect(rec.account).toBe('alice');
397
- expect(rec.enforce).toBe<NickEnforcePolicy>('none');
398
- }
399
- // verifyNick goes through the same scrypt path as registerNick.
400
- expect(services.verifyNick('alice', 'hunter2')).toEqual({ ok: true, account: 'alice' });
401
- });
402
-
403
- it('preserves the original spelling of the account name', () => {
404
- const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
405
- const cred: HashedAccountCredential = {
406
- account: 'Alice',
407
- algorithm: 'scrypt',
408
- salt: 's',
409
- hash: 'aGVsbG8=',
410
- };
411
- services.ingestAccountCredential(cred);
412
- const rec = services.getNick('Alice');
413
- expect(rec?.nick).toBe('Alice');
414
- expect(rec?.account).toBe('Alice');
415
- });
416
- });
@@ -1,11 +1,9 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import {
3
3
  type HashedAccountCredential,
4
- HashedAccountStore,
5
4
  hashAccountCredential,
6
5
  verifyHashedPassword,
7
6
  } from '../src/credential-hashing';
8
- import type { AccountStore, SaslPayload } from '../src/ports';
9
7
 
10
8
  describe('hashAccountCredential', () => {
11
9
  it('produces an entry keyed by the username with scrypt', () => {
@@ -92,79 +90,3 @@ describe('verifyHashedPassword', () => {
92
90
  expect(verifyHashedPassword('s3cret', entry)).toBe(false);
93
91
  });
94
92
  });
95
-
96
- describe('HashedAccountStore', () => {
97
- function plain(username: string, password: string): SaslPayload {
98
- return { kind: 'PLAIN', username, password };
99
- }
100
-
101
- it('implements the AccountStore port', () => {
102
- const store: AccountStore = new HashedAccountStore([hashAccountCredential('alice', 's3cret')]);
103
- expect(store).toBeDefined();
104
- });
105
-
106
- it('accepts valid PLAIN credentials and returns the account name', () => {
107
- const store = new HashedAccountStore([hashAccountCredential('alice', 's3cret')]);
108
- expect(store.verify('PLAIN', plain('alice', 's3cret'))).toEqual({
109
- ok: true,
110
- account: 'alice',
111
- });
112
- });
113
-
114
- it('rejects a wrong password', () => {
115
- const store = new HashedAccountStore([hashAccountCredential('alice', 's3cret')]);
116
- expect(store.verify('PLAIN', plain('alice', 'wrong')).ok).toBe(false);
117
- });
118
-
119
- it('rejects an unknown username', () => {
120
- const store = new HashedAccountStore([hashAccountCredential('alice', 's3cret')]);
121
- expect(store.verify('PLAIN', plain('bob', 's3cret')).ok).toBe(false);
122
- });
123
-
124
- it('rejects a non-PLAIN mechanism', () => {
125
- const store = new HashedAccountStore([hashAccountCredential('alice', 's3cret')]);
126
- expect(store.verify('EXTERNAL', plain('alice', 's3cret')).ok).toBe(false);
127
- });
128
-
129
- it('rejects a RAW payload', () => {
130
- const store = new HashedAccountStore([hashAccountCredential('alice', 's3cret')]);
131
- expect(store.verify('PLAIN', { kind: 'RAW', data: 'whatever' }).ok).toBe(false);
132
- });
133
-
134
- it('handles multiple loaded accounts', () => {
135
- const store = new HashedAccountStore([
136
- hashAccountCredential('alice', 'a-secret'),
137
- hashAccountCredential('bob', 'b-secret'),
138
- ]);
139
- expect(store.verify('PLAIN', plain('alice', 'a-secret'))).toEqual({
140
- ok: true,
141
- account: 'alice',
142
- });
143
- expect(store.verify('PLAIN', plain('bob', 'b-secret'))).toEqual({
144
- ok: true,
145
- account: 'bob',
146
- });
147
- });
148
-
149
- it('fails every verify when constructed with no entries', () => {
150
- const store = new HashedAccountStore([]);
151
- expect(store.verify('PLAIN', plain('alice', 's3cret')).ok).toBe(false);
152
- });
153
-
154
- it('reports the number of loaded entries via size', () => {
155
- const store = new HashedAccountStore([
156
- hashAccountCredential('alice', 'a'),
157
- hashAccountCredential('bob', 'b'),
158
- ]);
159
- expect(store.size).toBe(2);
160
- });
161
-
162
- it('round-trips an externally-constructed HashedAccountCredential', () => {
163
- const row: HashedAccountCredential = hashAccountCredential('carol', 'p@ss');
164
- const store = new HashedAccountStore([row]);
165
- expect(store.verify('PLAIN', plain('carol', 'p@ss'))).toEqual({
166
- ok: true,
167
- account: 'carol',
168
- });
169
- });
170
- });
@@ -590,4 +590,9 @@ describe('InMemoryMessageStore — getMsg (msgid -> message)', () => {
590
590
  const store = seededStore();
591
591
  expect(store.getMsg('#FoO', 'm2')?.msgid).toBe('m2');
592
592
  });
593
+
594
+ it('returns undefined for a channel with no recorded messages', () => {
595
+ const store = seededStore();
596
+ expect(store.getMsg('#empty', 'm1')).toBeUndefined();
597
+ });
593
598
  });
@@ -10,7 +10,6 @@
10
10
  */
11
11
 
12
12
  import { describe, expect, it } from 'vitest';
13
- import { hashAccountCredential } from '../src/credential-hashing';
14
13
  import {
15
14
  FakeClock,
16
15
  InMemoryServicesStore,
@@ -265,25 +264,85 @@ describe('PersistentServicesStore — per-mutator ops', () => {
265
264
  expect(s.batches).toEqual([]);
266
265
  });
267
266
 
268
- it('ingestAccountCredential enqueues upsertNick when a new credential is adopted', async () => {
267
+ it('setNickPassword upsertNick with the re-hashed credential', async () => {
269
268
  const s = recordingStore();
270
- const cred = hashAccountCredential('alice', 'hunter2');
271
- s.ingestAccountCredential(cred);
269
+ s.registerNick('alice', 'hunter2', 'a@x');
270
+ s.setNickPassword('alice', 'newpassword');
272
271
  await s.flush();
273
- expect(ops(s).some((w) => w.kind === 'upsertNick')).toBe(true);
274
- const op = ops(s).find((w) => w.kind === 'upsertNick');
275
- if (op?.kind === 'upsertNick') {
276
- expect(op.row.account).toBe('alice');
277
- expect(op.row.credential.algorithm).toBe('scrypt');
272
+ const upserts = ops(s).filter((w) => w.kind === 'upsertNick');
273
+ // registerNick enqueues one upsert; setNickPassword enqueues a second.
274
+ expect(upserts).toHaveLength(2);
275
+ const last = upserts[upserts.length - 1];
276
+ if (last?.kind === 'upsertNick') {
277
+ // The plaintext never lands on the queued row.
278
+ expect(JSON.stringify(last.row)).not.toContain('newpassword');
279
+ expect(last.row.credential.algorithm).toBe('scrypt');
280
+ }
281
+ // The in-memory credential now verifies the new password.
282
+ expect(s.verifyNick('alice', 'newpassword').ok).toBe(true);
283
+ expect(s.verifyNick('alice', 'hunter2').ok).toBe(false);
284
+ });
285
+
286
+ it('setNickPassword on an unregistered nick enqueues nothing', async () => {
287
+ const s = recordingStore();
288
+ s.setNickPassword('ghost', 'newpassword');
289
+ await s.flush();
290
+ expect(s.batches).toEqual([]);
291
+ });
292
+
293
+ it('addCertFP → upsertNick when a new subject is attached', async () => {
294
+ const s = recordingStore();
295
+ s.registerNick('alice', 'pw', 'a@x');
296
+ s.addCertFP('alice', 'CN=alice');
297
+ await s.flush();
298
+ const upserts = ops(s).filter((w) => w.kind === 'upsertNick');
299
+ // registerNick + addCertFP → two upserts.
300
+ expect(upserts).toHaveLength(2);
301
+ const certUpsert = upserts[1];
302
+ if (certUpsert?.kind === 'upsertNick') {
303
+ expect(certUpsert.row.certSubjects).toEqual(['CN=alice']);
304
+ }
305
+ });
306
+
307
+ it('addCertFP on an already-attached subject enqueues nothing (idempotent)', async () => {
308
+ const s = recordingStore();
309
+ s.registerNick('alice', 'pw', 'a@x');
310
+ s.addCertFP('alice', 'CN=alice');
311
+ s.addCertFP('alice', 'CN=alice');
312
+ await s.flush();
313
+ const upserts = ops(s).filter((w) => w.kind === 'upsertNick');
314
+ // registerNick + first addCertFP → two upserts. Second addCertFP is a no-op.
315
+ expect(upserts).toHaveLength(2);
316
+ });
317
+
318
+ it('addCertFP on an unregistered nick enqueues nothing', async () => {
319
+ const s = recordingStore();
320
+ s.addCertFP('ghost', 'CN=ghost');
321
+ await s.flush();
322
+ expect(s.batches).toEqual([]);
323
+ });
324
+
325
+ it('removeCertFP → upsertNick when a subject is detached', async () => {
326
+ const s = recordingStore();
327
+ s.registerNick('alice', 'pw', 'a@x');
328
+ s.addCertFP('alice', 'CN=alice');
329
+ s.removeCertFP('alice', 'CN=alice');
330
+ await s.flush();
331
+ const upserts = ops(s).filter((w) => w.kind === 'upsertNick');
332
+ // registerNick + addCertFP + removeCertFP → three upserts.
333
+ expect(upserts).toHaveLength(3);
334
+ const last = upserts[2];
335
+ if (last?.kind === 'upsertNick') {
336
+ expect(last.row.certSubjects).toEqual([]);
278
337
  }
279
338
  });
280
339
 
281
- it('ingestAccountCredential enqueues nothing when the nick is already registered', async () => {
340
+ it('removeCertFP on a non-attached subject enqueues nothing', async () => {
282
341
  const s = recordingStore();
283
342
  s.registerNick('alice', 'pw', 'a@x');
284
- const cred = hashAccountCredential('alice', 'otherpw');
285
- s.ingestAccountCredential(cred);
343
+ s.removeCertFP('alice', 'CN=alice');
286
344
  await s.flush();
345
+ // Only the registerNick upsert.
287
346
  expect(ops(s).filter((w) => w.kind === 'upsertNick')).toHaveLength(1);
288
347
  });
289
348
 
@@ -54,6 +54,11 @@ describe('FakeClock', () => {
54
54
  });
55
55
  });
56
56
 
57
+ // RFC 4122 v4 layout: 8-4-4-4-12 hex chars with the version nibble (M) fixed
58
+ // to 4 and the variant nibble (N) constrained to 8-b. Used by the shape and
59
+ // CSPRNG-sourcing tests below.
60
+ const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
61
+
57
62
  describe('UuidIdFactory', () => {
58
63
  it('implements the IdFactory port', () => {
59
64
  const f: IdFactory = new UuidIdFactory();
@@ -74,6 +79,72 @@ describe('UuidIdFactory', () => {
74
79
  const f = new UuidIdFactory();
75
80
  expect(f.sessionId()).not.toBe(f.sessionId());
76
81
  });
82
+
83
+ it('emits RFC 4122 v4-shaped strings from every method', () => {
84
+ const f = new UuidIdFactory();
85
+ expect(f.batchId()).toMatch(UUID_V4_RE);
86
+ expect(f.nonce()).toMatch(UUID_V4_RE);
87
+ expect(f.sessionId()).toMatch(UUID_V4_RE);
88
+ expect(f.traceId()).toMatch(UUID_V4_RE);
89
+ });
90
+
91
+ it('does not consult Math.random (randomness comes from the CSPRNG)', () => {
92
+ // Regression guard: if a future change reverts to Math.random, mocking it
93
+ // to a constant would make the output deterministic. A CSPRNG-backed
94
+ // factory must not touch Math.random at all.
95
+ const original = Math.random;
96
+ let calls = 0;
97
+ Math.random = () => {
98
+ calls++;
99
+ return 0.5;
100
+ };
101
+ try {
102
+ const f = new UuidIdFactory();
103
+ const a = f.nonce();
104
+ const b = f.sessionId();
105
+ expect(calls).toBe(0);
106
+ expect(a).toMatch(UUID_V4_RE);
107
+ expect(b).toMatch(UUID_V4_RE);
108
+ // With Math.random pinned, a Math.random-backed impl would emit the
109
+ // same value every call; the CSPRNG emits two distinct values.
110
+ expect(a).not.toBe(b);
111
+ } finally {
112
+ Math.random = original;
113
+ }
114
+ });
115
+
116
+ it('produces hex digits indistinguishable from uniform (chi-square, CSPRNG regression guard)', () => {
117
+ // Regression guard against a biased or constant RNG. Counts the trailing
118
+ // 12 hex chars of each id — those bytes are 100% random in a UUIDv4 (the
119
+ // version nibble at index 14 and variant nibble at index 19 are
120
+ // structural and excluded from the bucket count). Buckets the chars by
121
+ // nibble value (0-15) and chi-squares against the uniform expectation.
122
+ //
123
+ // Non-determinism guard: the chi-square statistic for genuinely uniform
124
+ // input follows chi2 with 15 degrees of freedom (mean 15, stddev ~5.5).
125
+ // The chosen threshold (70) sits ~10σ above the mean — P(chi2_15 > 70) ≈
126
+ // 2e-9, i.e. effectively zero false-positives across billions of CI
127
+ // runs. A catastrophic revert (Math.random pinned to a constant) drives
128
+ // chi into the hundreds of millions, so headroom is enormous.
129
+ const f = new UuidIdFactory();
130
+ const N = 10_000;
131
+ const buckets = new Array<number>(16).fill(0);
132
+ for (let i = 0; i < N; i++) {
133
+ // Slice(24) is the trailing 'xxxxxxxxxxxx' group of the UUID string —
134
+ // 12 hex chars, all sourced from getRandomValues.
135
+ const tail = f.nonce().slice(24);
136
+ for (const c of tail) {
137
+ const idx = Number.parseInt(c, 16);
138
+ buckets[idx] = (buckets[idx] ?? 0) + 1;
139
+ }
140
+ }
141
+ const expected = (N * 12) / 16;
142
+ let chi = 0;
143
+ for (const observed of buckets) {
144
+ chi += (observed - expected) ** 2 / expected;
145
+ }
146
+ expect(chi).toBeLessThan(70);
147
+ });
77
148
  });
78
149
 
79
150
  describe('SequentialIdFactory', () => {