serverless-ircd 0.7.0 → 0.9.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 (168) hide show
  1. package/.github/workflows/ci.yml +7 -3
  2. package/.gitmodules +1 -1
  3. package/CHANGELOG.md +518 -29
  4. package/README.md +221 -161
  5. package/apps/aws-stack/package.json +1 -1
  6. package/apps/aws-stack/src/aws-stack.ts +186 -18
  7. package/apps/aws-stack/tests/stack.test.ts +400 -56
  8. package/apps/cf-tcp-container/package.json +1 -1
  9. package/apps/cf-tcp-container/src/container-server.ts +21 -1
  10. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  11. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  12. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  13. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  14. package/apps/cf-worker/package.json +1 -1
  15. package/apps/cf-worker/src/worker.ts +4 -4
  16. package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
  17. package/apps/cf-worker/tests/smoke.test.ts +5 -5
  18. package/apps/cf-worker/wrangler.test.toml +6 -6
  19. package/apps/cf-worker/wrangler.toml +7 -5
  20. package/apps/local-cli/package.json +1 -1
  21. package/apps/local-cli/src/config-loader.ts +8 -0
  22. package/apps/local-cli/src/main.ts +16 -0
  23. package/apps/local-cli/src/server.ts +95 -31
  24. package/apps/local-cli/tests/config-loader.test.ts +14 -0
  25. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  26. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  27. package/apps/local-cli/tests/rehash.test.ts +147 -0
  28. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  29. package/apps/local-cli/tests/tcp.test.ts +89 -0
  30. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  31. package/apps/web/landing/index.html +237 -16
  32. package/apps/web/package.json +3 -2
  33. package/apps/web/scripts/build.mjs +46 -16
  34. package/apps/web/src/build-env.ts +1 -1
  35. package/apps/web/src/config-schema.ts +6 -6
  36. package/apps/web/src/render-docs.ts +292 -0
  37. package/apps/web/tests/build-smoke.test.ts +43 -14
  38. package/apps/web/tests/config-schema.test.ts +1 -1
  39. package/apps/web/tests/landing-content.test.ts +103 -0
  40. package/apps/web/tests/render-docs.test.ts +198 -0
  41. package/docs/AWS-Adapter-Architecture.md +3 -2
  42. package/docs/AWS-Deployment.md +21 -8
  43. package/docs/Cloudflare-Deployment-Guide.md +22 -6
  44. package/docs/PlanExtensions.md +113 -3
  45. package/docs/Release-Process.md +23 -13
  46. package/docs/Services.md +578 -0
  47. package/docs/WebClientGuide.md +32 -31
  48. package/package.json +2 -2
  49. package/packages/aws-adapter/package.json +1 -1
  50. package/packages/aws-adapter/src/aws-runtime.ts +20 -1
  51. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
  52. package/packages/aws-adapter/src/config-loader.ts +11 -0
  53. package/packages/aws-adapter/src/connection-counter.ts +89 -0
  54. package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
  55. package/packages/aws-adapter/src/handlers/connect.ts +55 -51
  56. package/packages/aws-adapter/src/handlers/default.ts +36 -4
  57. package/packages/aws-adapter/src/handlers/index.ts +15 -0
  58. package/packages/aws-adapter/src/handlers/nlb-stream.ts +25 -2
  59. package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
  60. package/packages/aws-adapter/src/index.ts +4 -0
  61. package/packages/aws-adapter/src/stats.ts +6 -1
  62. package/packages/aws-adapter/src/tables.ts +34 -4
  63. package/packages/aws-adapter/tests/aws-harness.ts +3 -0
  64. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  65. package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
  66. package/packages/aws-adapter/tests/connect.test.ts +158 -32
  67. package/packages/aws-adapter/tests/connection-counter.test.ts +144 -0
  68. package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
  69. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
  70. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  71. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  72. package/packages/aws-adapter/tests/handlers.test.ts +105 -3
  73. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  74. package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
  75. package/packages/aws-adapter/tests/tables.test.ts +6 -1
  76. package/packages/cf-adapter/package.json +1 -1
  77. package/packages/cf-adapter/src/config-loader.ts +11 -0
  78. package/packages/cf-adapter/src/connection-do.ts +130 -8
  79. package/packages/cf-adapter/src/d1-services-store.ts +703 -0
  80. package/packages/cf-adapter/src/env.ts +8 -0
  81. package/packages/cf-adapter/src/index.ts +5 -0
  82. package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
  83. package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
  84. package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
  85. package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
  86. package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
  87. package/packages/cf-adapter/tests/serialize.test.ts +1 -0
  88. package/packages/in-memory-runtime/package.json +1 -1
  89. package/packages/irc-core/package.json +1 -1
  90. package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
  91. package/packages/irc-core/src/commands/account-auth.ts +200 -0
  92. package/packages/irc-core/src/commands/chanserv.ts +1166 -0
  93. package/packages/irc-core/src/commands/hostserv.ts +522 -0
  94. package/packages/irc-core/src/commands/index.ts +13 -0
  95. package/packages/irc-core/src/commands/join.ts +171 -9
  96. package/packages/irc-core/src/commands/markread.ts +202 -0
  97. package/packages/irc-core/src/commands/memoserv.ts +319 -0
  98. package/packages/irc-core/src/commands/mode.ts +96 -4
  99. package/packages/irc-core/src/commands/nickserv.ts +402 -0
  100. package/packages/irc-core/src/commands/oper.ts +18 -1
  101. package/packages/irc-core/src/commands/operserv.ts +346 -0
  102. package/packages/irc-core/src/commands/pre-away.ts +3 -1
  103. package/packages/irc-core/src/commands/privmsg.ts +42 -0
  104. package/packages/irc-core/src/commands/read-marker.ts +8 -8
  105. package/packages/irc-core/src/commands/registration.ts +78 -12
  106. package/packages/irc-core/src/commands/sasl.ts +18 -49
  107. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  108. package/packages/irc-core/src/commands/tagmsg.ts +41 -6
  109. package/packages/irc-core/src/commands/topic.ts +60 -10
  110. package/packages/irc-core/src/config.ts +36 -5
  111. package/packages/irc-core/src/effects.ts +56 -1
  112. package/packages/irc-core/src/ports.ts +1653 -84
  113. package/packages/irc-core/src/protocol/numerics.ts +8 -0
  114. package/packages/irc-core/src/state/channel.ts +38 -1
  115. package/packages/irc-core/src/state/connection.ts +25 -1
  116. package/packages/irc-core/src/types.ts +48 -12
  117. package/packages/irc-core/tests/commands/chanserv.test.ts +2335 -0
  118. package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
  119. package/packages/irc-core/tests/commands/hostserv.test.ts +1006 -0
  120. package/packages/irc-core/tests/commands/join.test.ts +572 -1
  121. package/packages/irc-core/tests/commands/markread.test.ts +361 -0
  122. package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
  123. package/packages/irc-core/tests/commands/mode.test.ts +381 -2
  124. package/packages/irc-core/tests/commands/nickserv.test.ts +990 -0
  125. package/packages/irc-core/tests/commands/oper.test.ts +13 -0
  126. package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
  127. package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
  128. package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
  129. package/packages/irc-core/tests/commands/registration.test.ts +1015 -20
  130. package/packages/irc-core/tests/commands/sasl.test.ts +229 -12
  131. package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
  132. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  133. package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
  134. package/packages/irc-core/tests/commands/topic.test.ts +94 -2
  135. package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
  136. package/packages/irc-core/tests/config.test.ts +49 -5
  137. package/packages/irc-core/tests/effects.test.ts +19 -0
  138. package/packages/irc-core/tests/message-store.test.ts +63 -0
  139. package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
  140. package/packages/irc-core/tests/services-store.test.ts +1289 -0
  141. package/packages/irc-core/tests/state/channel.test.ts +3 -0
  142. package/packages/irc-server/package.json +1 -1
  143. package/packages/irc-server/src/actor.ts +121 -16
  144. package/packages/irc-server/src/dispatch.ts +94 -7
  145. package/packages/irc-server/src/routing.ts +19 -0
  146. package/packages/irc-server/tests/actor.test.ts +988 -15
  147. package/packages/irc-server/tests/dispatch.test.ts +270 -2
  148. package/packages/irc-server/tests/routing.test.ts +6 -0
  149. package/packages/irc-test-support/package.json +1 -1
  150. package/packages/irc-test-support/src/in-memory-harness.ts +37 -8
  151. package/packages/irc-test-support/src/index.ts +1 -0
  152. package/packages/irc-test-support/src/scenarios.ts +21 -6
  153. package/packages/irc-test-support/tests/in-memory-harness.test.ts +51 -0
  154. package/packages/irc-test-support/vitest.config.ts +6 -1
  155. package/tools/ci-hardening/package.json +1 -1
  156. package/tools/load-test/package.json +1 -1
  157. package/tools/load-test/src/client.ts +13 -13
  158. package/tools/load-test/tests/client.test.ts +258 -2
  159. package/tools/load-test/tests/config.test.ts +39 -0
  160. package/tools/load-test/tests/harness.test.ts +21 -0
  161. package/tools/load-test/tests/metrics.test.ts +7 -0
  162. package/tools/tcp-ws-forwarder/package.json +1 -1
  163. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  164. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  165. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +53 -2
  166. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  167. package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
  168. package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
@@ -8,15 +8,15 @@ import type {
8
8
  AccountStore,
9
9
  AwayStore,
10
10
  MtlsIdentityProvider,
11
- ReadMarkerStore,
12
11
  SaslPayload,
13
12
  SaslResult,
13
+ ServicesStore,
14
14
  } from '../../src/ports';
15
15
  import {
16
16
  EmptyMotdProvider,
17
17
  FakeClock,
18
18
  InMemoryAwayStore,
19
- InMemoryReadMarkerStore,
19
+ InMemoryServicesStore,
20
20
  SequentialIdFactory,
21
21
  } from '../../src/ports';
22
22
  import { encodeBase64 } from '../../src/protocol/base64';
@@ -57,7 +57,7 @@ function makeCtx(
57
57
  state: ConnectionState,
58
58
  accounts?: AccountStore,
59
59
  mtlsIdentity?: MtlsIdentityProvider,
60
- readMarkers?: ReadMarkerStore,
60
+ services?: ServicesStore,
61
61
  away?: AwayStore,
62
62
  ): Ctx {
63
63
  return buildCtx({
@@ -68,7 +68,7 @@ function makeCtx(
68
68
  connection: state,
69
69
  ...(accounts !== undefined ? { accounts } : {}),
70
70
  ...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
71
- ...(readMarkers !== undefined ? { readMarkers } : {}),
71
+ ...(services !== undefined ? { services } : {}),
72
72
  ...(away !== undefined ? { away } : {}),
73
73
  });
74
74
  }
@@ -161,13 +161,13 @@ describe('authenticateReducer — PLAIN success', () => {
161
161
  expect(state.account).toBe('alice');
162
162
  });
163
163
 
164
- it('seeds lastReadMarkers from the ReadMarkerStore at identify', () => {
164
+ it('seeds lastReadMarkers from the ServicesStore at identify', () => {
165
165
  const state = saslReadyState();
166
166
  const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
167
- const readMarkers = new InMemoryReadMarkerStore();
168
- readMarkers.set('alice', '#foo', 'm1');
169
- readMarkers.set('alice', '#bar', 'm2');
170
- const ctx = makeCtx(state, accounts, undefined, readMarkers);
167
+ const services = new InMemoryServicesStore();
168
+ services.setLastReadMarker('alice', '#foo', 'm1');
169
+ services.setLastReadMarker('alice', '#bar', 'm2');
170
+ const ctx = makeCtx(state, accounts, undefined, services);
171
171
 
172
172
  authenticateReducer(state, authenticate('PLAIN'), ctx);
173
173
  authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -179,9 +179,9 @@ describe('authenticateReducer — PLAIN success', () => {
179
179
  it('does not seed markers for a different account', () => {
180
180
  const state = saslReadyState();
181
181
  const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
182
- const readMarkers = new InMemoryReadMarkerStore();
183
- readMarkers.set('bob', '#foo', 'm9');
184
- const ctx = makeCtx(state, accounts, undefined, readMarkers);
182
+ const services = new InMemoryServicesStore();
183
+ services.setLastReadMarker('bob', '#foo', 'm9');
184
+ const ctx = makeCtx(state, accounts, undefined, services);
185
185
 
186
186
  authenticateReducer(state, authenticate('PLAIN'), ctx);
187
187
  authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -258,6 +258,135 @@ describe('authenticateReducer — PLAIN success', () => {
258
258
  expect(state.away).toBe('brb');
259
259
  });
260
260
 
261
+ it('delivers queued unread memos on a successful SASL login', () => {
262
+ const state = saslReadyState();
263
+ const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
264
+ const services = new InMemoryServicesStore();
265
+ services.recordMemo({ from: 'bob', to: 'alice', body: 'Hi alice!', sentAt: 1_000 });
266
+ const ctx = makeCtx(state, accounts, undefined, services);
267
+
268
+ authenticateReducer(state, authenticate('PLAIN'), ctx);
269
+ const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
270
+
271
+ const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines : []));
272
+ expect(texts.map((l) => l.text)).toContain(
273
+ ':MemoServ!MemoServ@services NOTICE alice :[Memo #1 from bob] Hi alice!',
274
+ );
275
+ });
276
+
277
+ it('marks delivered memos as read after a successful SASL login', () => {
278
+ const state = saslReadyState();
279
+ const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
280
+ const services = new InMemoryServicesStore();
281
+ services.recordMemo({ from: 'bob', to: 'alice', body: 'Hi alice!', sentAt: 1_000 });
282
+ const ctx = makeCtx(state, accounts, undefined, services);
283
+
284
+ authenticateReducer(state, authenticate('PLAIN'), ctx);
285
+ authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
286
+
287
+ expect(services.listMemos('alice')[0]?.read).toBe(true);
288
+ });
289
+
290
+ it('does not deliver already-read memos on a SASL login', () => {
291
+ const state = saslReadyState();
292
+ const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
293
+ const services = new InMemoryServicesStore();
294
+ services.recordMemo({ from: 'bob', to: 'alice', body: 'old', sentAt: 1_000 });
295
+ services.markMemoRead('alice', 1);
296
+ const ctx = makeCtx(state, accounts, undefined, services);
297
+
298
+ authenticateReducer(state, authenticate('PLAIN'), ctx);
299
+ const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
300
+
301
+ const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines : []));
302
+ expect(texts.some((l) => l.text.includes('[Memo #1'))).toBe(false);
303
+ });
304
+
305
+ it('applies the assigned HostServ vhost on SASL PLAIN success', () => {
306
+ const state = saslReadyState();
307
+ state.host = 'real.example.net';
308
+ const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
309
+ const services = new InMemoryServicesStore();
310
+ services.setVhost('alice', 'cool.example.net');
311
+ const ctx = makeCtx(state, accounts, undefined, services);
312
+
313
+ authenticateReducer(state, authenticate('PLAIN'), ctx);
314
+ authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
315
+
316
+ expect(state.vhostActive).toBe(true);
317
+ expect(state.host).toBe('cool.example.net');
318
+ expect(state.preVhostHost).toBe('real.example.net');
319
+ });
320
+
321
+ it('does not emit a CHGHOST broadcast on SASL success (pre-registration, no peers)', () => {
322
+ const state = saslReadyState();
323
+ state.host = 'real.example.net';
324
+ const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
325
+ const services = new InMemoryServicesStore();
326
+ services.setVhost('alice', 'cool.example.net');
327
+ const ctx = makeCtx(state, accounts, undefined, services);
328
+
329
+ authenticateReducer(state, authenticate('PLAIN'), ctx);
330
+ const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
331
+
332
+ expect(out.effects.filter((e) => e.tag === 'Broadcast')).toEqual<EffectType[]>([]);
333
+ });
334
+
335
+ it('leaves host unchanged on SASL success when no vhost is assigned', () => {
336
+ const state = saslReadyState();
337
+ state.host = 'real.example.net';
338
+ const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
339
+ const services = new InMemoryServicesStore();
340
+ const ctx = makeCtx(state, accounts, undefined, services);
341
+
342
+ authenticateReducer(state, authenticate('PLAIN'), ctx);
343
+ authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
344
+
345
+ expect(state.vhostActive).toBeUndefined();
346
+ expect(state.host).toBe('real.example.net');
347
+ });
348
+
349
+ it('delivers nothing on a SASL login when the account has no memos', () => {
350
+ const state = saslReadyState();
351
+ const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
352
+ const services = new InMemoryServicesStore();
353
+ const ctx = makeCtx(state, accounts, undefined, services);
354
+
355
+ authenticateReducer(state, authenticate('PLAIN'), ctx);
356
+ const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
357
+
358
+ const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines : []));
359
+ expect(texts.some((l) => l.text.includes('MemoServ'))).toBe(false);
360
+ });
361
+
362
+ it('looks up queued memos case-insensitively on a SASL login', () => {
363
+ const state = saslReadyState();
364
+ const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
365
+ const services = new InMemoryServicesStore();
366
+ services.recordMemo({ from: 'bob', to: 'ALICE', body: 'folded', sentAt: 1_000 });
367
+ const ctx = makeCtx(state, accounts, undefined, services);
368
+
369
+ authenticateReducer(state, authenticate('PLAIN'), ctx);
370
+ const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
371
+
372
+ const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines : []));
373
+ expect(texts.map((l) => l.text)).toContain(
374
+ ':MemoServ!MemoServ@services NOTICE alice :[Memo #1 from bob] folded',
375
+ );
376
+ });
377
+
378
+ it('does not deliver memos when no ServicesStore is bound', () => {
379
+ const state = saslReadyState();
380
+ const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
381
+ const ctx = makeCtx(state, accounts);
382
+
383
+ authenticateReducer(state, authenticate('PLAIN'), ctx);
384
+ const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
385
+
386
+ const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines : []));
387
+ expect(texts.some((l) => l.text.includes('MemoServ'))).toBe(false);
388
+ });
389
+
261
390
  it('clears the in-progress mechanism after a successful authentication', () => {
262
391
  const state = saslReadyState();
263
392
  const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
@@ -432,6 +561,24 @@ describe('authenticateReducer — EXTERNAL with mTLS', () => {
432
561
  expect(state.saslBuffer).toBeUndefined();
433
562
  });
434
563
 
564
+ it('delivers queued memos on a successful EXTERNAL login', () => {
565
+ const state = saslReadyState();
566
+ const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
567
+ const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
568
+ const services = new InMemoryServicesStore();
569
+ services.recordMemo({ from: 'bob', to: 'alice', body: 'via mTLS', sentAt: 1_000 });
570
+ const ctx = makeCtx(state, accounts, provider, services);
571
+
572
+ authenticateReducer(state, authenticate('EXTERNAL'), ctx);
573
+ const out = authenticateReducer(state, authenticate('+'), ctx);
574
+
575
+ const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines : []));
576
+ expect(texts.map((l) => l.text)).toContain(
577
+ ':MemoServ!MemoServ@services NOTICE alice :[Memo #1 from bob] via mTLS',
578
+ );
579
+ expect(services.listMemos('alice')[0]?.read).toBe(true);
580
+ });
581
+
435
582
  it('fails with 904 when the provider has no identity for the connection', () => {
436
583
  const state = saslReadyState();
437
584
  const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
@@ -756,3 +903,73 @@ describe('authenticateReducer — CAP REQ sasl integration', () => {
756
903
  expect(advertised).not.toContain('sasl=PLAIN,EXTERNAL');
757
904
  });
758
905
  });
906
+
907
+ // ============================================================================
908
+ // SASL PLAIN — NickServ-registered accounts (ServicesStore fallback)
909
+ // ============================================================================
910
+
911
+ describe('authenticateReducer — NickServ-registered nick via ServicesStore', () => {
912
+ it('succeeds with credentials registered via NickServ when no AccountStore is bound', () => {
913
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
914
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
915
+ const state = saslReadyState();
916
+ const ctx = makeCtx(state, undefined, undefined, services);
917
+
918
+ authenticateReducer(state, authenticate('PLAIN'), ctx);
919
+ const out = authenticateReducer(state, authenticate(plainPayload('alice', 'hunter2')), ctx);
920
+
921
+ expect(state.account).toBe('alice');
922
+ const lines = sendLinesOf(out.effects[0]);
923
+ expect(lines.map((l) => l.text)).toContain(
924
+ `:${SV} 900 alice alice!alice@example.com alice :You are now logged in as alice`,
925
+ );
926
+ });
927
+
928
+ it('succeeds with NickServ credentials even when a static AccountStore is also bound', () => {
929
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
930
+ services.registerNick('bob', 'bobpass', 'bob@example.com');
931
+ // Static AccountStore does not know about bob; the services store does.
932
+ const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
933
+ const state = saslReadyState();
934
+ state.nick = 'bob';
935
+ state.user = 'bob';
936
+ state.host = 'example.com';
937
+ const ctx = makeCtx(state, accounts, undefined, services);
938
+
939
+ authenticateReducer(state, authenticate('PLAIN'), ctx);
940
+ const out = authenticateReducer(state, authenticate(plainPayload('bob', 'bobpass')), ctx);
941
+
942
+ expect(state.account).toBe('bob');
943
+ expect(sendLinesOf(out.effects[0]).map((l) => l.text)).toContain(
944
+ `:${SV} 903 bob :SASL authentication successful`,
945
+ );
946
+ });
947
+
948
+ it('prefers the static AccountStore over the services store on a match', () => {
949
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
950
+ services.registerNick('alice', 'servicespw', 'alice@example.com');
951
+ const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
952
+ const state = saslReadyState();
953
+ const ctx = makeCtx(state, accounts, undefined, services);
954
+
955
+ authenticateReducer(state, authenticate('PLAIN'), ctx);
956
+ authenticateReducer(state, authenticate(plainPayload('alice', 'staticpw')), ctx);
957
+
958
+ // The static store vouched for the account; it is used.
959
+ expect(accounts.calls).toHaveLength(1);
960
+ expect(state.account).toBe('alice');
961
+ });
962
+
963
+ it('fails when neither the AccountStore nor the ServicesStore verifies the credential', () => {
964
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
965
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
966
+ const state = saslReadyState();
967
+ const ctx = makeCtx(state, undefined, undefined, services);
968
+
969
+ authenticateReducer(state, authenticate('PLAIN'), ctx);
970
+ const out = authenticateReducer(state, authenticate(plainPayload('alice', 'wrong')), ctx);
971
+
972
+ expect(state.account).toBeUndefined();
973
+ expect(firstLineText(out.effects[0])).toBe(`:${SV} 904 alice :SASL authentication failed`);
974
+ });
975
+ });
@@ -5,16 +5,20 @@ import {
5
5
  timeReducer,
6
6
  versionReducer,
7
7
  } from '../../src/commands/server-info';
8
- import { DEFAULT_SERVER_VERSION } from '../../src/config';
9
8
  import { Effect } from '../../src/effects';
10
9
  import type { Effect as EffectType, RawLine } from '../../src/effects';
11
10
  import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
12
11
  import { type ConnectionState, createConnection } from '../../src/state/connection';
13
12
  import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
14
13
 
14
+ // Explicit version pinned in the fixture so the assertions below verify that
15
+ // the configured version is what reaches the wire — not a specific default.
16
+ const FIXTURE_VERSION = '9.9.9';
17
+
15
18
  const baseServerConfig: ServerConfig = {
16
19
  serverName: 'irc.example.com',
17
20
  networkName: 'ExampleNet',
21
+ serverVersion: FIXTURE_VERSION,
18
22
  maxChannelsPerUser: 30,
19
23
  maxTargetsPerCommand: 10,
20
24
  maxListEntries: 50,
@@ -71,7 +75,7 @@ describe('versionReducer', () => {
71
75
 
72
76
  expect(out.effects).toEqual<EffectType[]>([
73
77
  Effect.send('c1', [
74
- L(`:irc.example.com 351 alice ${DEFAULT_SERVER_VERSION} irc.example.com :ExampleNet`),
78
+ L(`:irc.example.com 351 alice ${FIXTURE_VERSION} irc.example.com :ExampleNet`),
75
79
  ]),
76
80
  ]);
77
81
  });
@@ -93,7 +97,7 @@ describe('versionReducer', () => {
93
97
 
94
98
  expect(out.effects[0]).toEqual<EffectType>(
95
99
  Effect.send('c1', [
96
- L(`:irc.example.com 351 * ${DEFAULT_SERVER_VERSION} irc.example.com :ExampleNet`),
100
+ L(`:irc.example.com 351 * ${FIXTURE_VERSION} irc.example.com :ExampleNet`),
97
101
  ]),
98
102
  );
99
103
  });
@@ -106,7 +110,7 @@ describe('versionReducer', () => {
106
110
 
107
111
  expect(out.effects[0]).toEqual<EffectType>(
108
112
  Effect.send('c1', [
109
- L(`:irc.example.com 351 alice ${DEFAULT_SERVER_VERSION} irc.example.com :ExampleNet`),
113
+ L(`:irc.example.com 351 alice ${FIXTURE_VERSION} irc.example.com :ExampleNet`),
110
114
  ]),
111
115
  );
112
116
  });
@@ -234,7 +238,7 @@ describe('infoReducer', () => {
234
238
 
235
239
  const lines = (out.effects[0] as { lines: RawLine[] }).lines;
236
240
  expect(lines[0]).toEqual(
237
- L(`:irc.example.com 371 alice :ServerlessIRCd - version ${DEFAULT_SERVER_VERSION}`),
241
+ L(`:irc.example.com 371 alice :ServerlessIRCd - version ${FIXTURE_VERSION}`),
238
242
  );
239
243
  expect(lines[1]).toEqual(L(':irc.example.com 371 alice :Running on network ExampleNet'));
240
244
  expect(lines[lines.length - 1]).toEqual(L(':irc.example.com 374 alice :End of INFO list'));
@@ -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
+ });
@@ -6,10 +6,10 @@ import {
6
6
  EmptyMotdProvider,
7
7
  FakeClock,
8
8
  InMemoryMessageStore,
9
- InMemoryReadMarkerStore,
9
+ InMemoryServicesStore,
10
10
  type MessageStore,
11
- type ReadMarkerStore,
12
11
  SequentialIdFactory,
12
+ type ServicesStore,
13
13
  type StoredMessage,
14
14
  } from '../../src/ports';
15
15
  import { type ChannelState, createChannel } from '../../src/state/channel';
@@ -32,7 +32,7 @@ function makeCtx(
32
32
  conn: ConnectionState,
33
33
  clock = new FakeClock(1_000),
34
34
  messages?: MessageStore,
35
- readMarkers?: ReadMarkerStore,
35
+ services?: ServicesStore,
36
36
  ): Ctx {
37
37
  return buildCtx({
38
38
  serverConfig,
@@ -41,7 +41,7 @@ function makeCtx(
41
41
  motd: EmptyMotdProvider,
42
42
  connection: conn,
43
43
  ...(messages !== undefined ? { messages } : {}),
44
- ...(readMarkers !== undefined ? { readMarkers } : {}),
44
+ ...(services !== undefined ? { services } : {}),
45
45
  });
46
46
  }
47
47
 
@@ -551,18 +551,19 @@ describe('tagmsgChannelReducer — draft/typing broadcast', () => {
551
551
  // IRCv3 draft/read-marker: a TAGMSG carrying `+draft/read-marker=<msgid>`
552
552
  // marks `<msgid>` as the last read message for the sender's account in the
553
553
  // target channel. The reducer (1) persists the marker via the bound
554
- // ReadMarkerStore keyed by (account, channel), (2) advances the connection's
554
+ // ServicesStore keyed by (account, channel), (2) advances the connection's
555
555
  // in-memory lastReadMarkers, and (3) fans the TAGMSG out — carrying the
556
556
  // `+draft/read-marker` tag — to members that negotiated `draft/read-marker`
557
557
  // OR `message-tags` (OR-semantics, matching the draft/typing precedent).
558
558
  // ============================================================================
559
559
 
560
560
  describe('tagmsgChannelReducer — draft/read-marker broadcast + persistence', () => {
561
- it('emits a Broadcast with caps=[message-tags, draft/read-marker] carrying the tag', () => {
561
+ it('emits an account-scoped Broadcast (caps + account) for an identified originator', () => {
562
562
  const chan = makeChan('#foo');
563
563
  addMember(chan, 'c1', 'alice');
564
564
  addMember(chan, 'c2', 'bob');
565
565
  const conn = makeConn();
566
+ conn.account = 'alice';
566
567
  const ctx = makeCtx(conn);
567
568
 
568
569
  const out = tagmsgChannelReducer(
@@ -578,17 +579,54 @@ describe('tagmsgChannelReducer — draft/read-marker broadcast + persistence', (
578
579
  lines: [L('@+draft/read-marker=m42 :alice!alice@example.com TAGMSG #foo')],
579
580
  except: 'c1',
580
581
  caps: ['message-tags', 'draft/read-marker'],
582
+ account: 'alice',
581
583
  },
582
584
  ]);
583
585
  });
584
586
 
585
- it('persists the marker to the ReadMarkerStore keyed by (account, channel)', () => {
587
+ it('does NOT fan out a read-marker mark when the originator is unidentified (spec privacy)', () => {
588
+ const chan = makeChan('#foo');
589
+ addMember(chan, 'c1', 'alice');
590
+ addMember(chan, 'c2', 'bob');
591
+ const conn = makeConn(); // no account
592
+ const ctx = makeCtx(conn);
593
+
594
+ const out = tagmsgChannelReducer(
595
+ chan,
596
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': 'm42' } },
597
+ ctx,
598
+ );
599
+
600
+ // Unidentified: the mark is session-local. No Broadcast reaches anyone.
601
+ expect(out.effects).toEqual([]);
602
+ });
603
+
604
+ it('still self-echoes for an unidentified originator when echo-message is negotiated', () => {
605
+ const chan = makeChan('#foo');
606
+ addMember(chan, 'c1', 'alice');
607
+ const conn = makeConn(); // no account
608
+ conn.caps.add('echo-message');
609
+ const ctx = makeCtx(conn);
610
+
611
+ const out = tagmsgChannelReducer(
612
+ chan,
613
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': 'm3' } },
614
+ ctx,
615
+ );
616
+
617
+ // No Broadcast (unidentified), but the originator's own client still
618
+ // sees its mark reflected back via echo-message.
619
+ const line = L('@+draft/read-marker=m3 :alice!alice@example.com TAGMSG #foo');
620
+ expect(out.effects).toEqual<EffectType[]>([Effect.send('c1', [line])]);
621
+ });
622
+
623
+ it('persists the marker to the ServicesStore keyed by (account, channel)', () => {
586
624
  const chan = makeChan('#foo');
587
625
  addMember(chan, 'c1', 'alice');
588
626
  const conn = makeConn();
589
627
  conn.account = 'alice';
590
- const store = new InMemoryReadMarkerStore();
591
- const ctx = makeCtx(conn, undefined, undefined, store);
628
+ const services = new InMemoryServicesStore();
629
+ const ctx = makeCtx(conn, undefined, undefined, services);
592
630
 
593
631
  tagmsgChannelReducer(
594
632
  chan,
@@ -596,7 +634,7 @@ describe('tagmsgChannelReducer — draft/read-marker broadcast + persistence', (
596
634
  ctx,
597
635
  );
598
636
 
599
- expect(store.get('alice', '#foo')).toBe('m7');
637
+ expect(services.getLastReadMarker('alice', '#foo')).toBe('m7');
600
638
  });
601
639
 
602
640
  it('advances the connection in-memory lastReadMarkers', () => {
@@ -604,7 +642,7 @@ describe('tagmsgChannelReducer — draft/read-marker broadcast + persistence', (
604
642
  addMember(chan, 'c1', 'alice');
605
643
  const conn = makeConn();
606
644
  conn.account = 'alice';
607
- const ctx = makeCtx(conn, undefined, undefined, new InMemoryReadMarkerStore());
645
+ const ctx = makeCtx(conn, undefined, undefined, new InMemoryServicesStore());
608
646
 
609
647
  tagmsgChannelReducer(
610
648
  chan,
@@ -620,8 +658,8 @@ describe('tagmsgChannelReducer — draft/read-marker broadcast + persistence', (
620
658
  addMember(chan, 'c1', 'alice');
621
659
  const conn = makeConn();
622
660
  conn.account = 'alice';
623
- const store = new InMemoryReadMarkerStore();
624
- const ctx = makeCtx(conn, undefined, undefined, store);
661
+ const services = new InMemoryServicesStore();
662
+ const ctx = makeCtx(conn, undefined, undefined, services);
625
663
 
626
664
  tagmsgChannelReducer(
627
665
  chan,
@@ -634,15 +672,15 @@ describe('tagmsgChannelReducer — draft/read-marker broadcast + persistence', (
634
672
  ctx,
635
673
  );
636
674
 
637
- expect(store.get('alice', '#foo')).toBe('m2');
675
+ expect(services.getLastReadMarker('alice', '#foo')).toBe('m2');
638
676
  });
639
677
 
640
678
  it('does NOT persist to the store when the connection has no account', () => {
641
679
  const chan = makeChan('#foo');
642
680
  addMember(chan, 'c1', 'alice');
643
681
  const conn = makeConn(); // no account
644
- const store = new InMemoryReadMarkerStore();
645
- const ctx = makeCtx(conn, undefined, undefined, store);
682
+ const services = new InMemoryServicesStore();
683
+ const ctx = makeCtx(conn, undefined, undefined, services);
646
684
 
647
685
  tagmsgChannelReducer(
648
686
  chan,
@@ -650,12 +688,12 @@ describe('tagmsgChannelReducer — draft/read-marker broadcast + persistence', (
650
688
  ctx,
651
689
  );
652
690
 
653
- expect(store.forAccount('alice')).toEqual([]);
691
+ expect(services.listReadMarkers('alice')).toEqual([]);
654
692
  // The in-memory marker still advances for the session.
655
693
  expect(conn.lastReadMarkers?.get('#foo')).toBe('m1');
656
694
  });
657
695
 
658
- it('does NOT persist when no ReadMarkerStore is bound but still fans out', () => {
696
+ it('does NOT persist when no ServicesStore is bound but still fans out (identified)', () => {
659
697
  const chan = makeChan('#foo');
660
698
  addMember(chan, 'c1', 'alice');
661
699
  const conn = makeConn();
@@ -669,17 +707,17 @@ describe('tagmsgChannelReducer — draft/read-marker broadcast + persistence', (
669
707
  );
670
708
 
671
709
  expect(out.effects).toHaveLength(1);
672
- expect((out.effects[0] as Extract<EffectType, { tag: 'Broadcast' }>).caps).toEqual([
673
- 'message-tags',
674
- 'draft/read-marker',
675
- ]);
710
+ const broadcast = out.effects[0] as Extract<EffectType, { tag: 'Broadcast' }>;
711
+ expect(broadcast.caps).toEqual(['message-tags', 'draft/read-marker']);
712
+ expect(broadcast.account).toBe('alice');
676
713
  expect(conn.lastReadMarkers?.get('#foo')).toBe('m1');
677
714
  });
678
715
 
679
- it('excludes the sender unless echo-message is negotiated', () => {
716
+ it('excludes the sender unless echo-message is negotiated (identified)', () => {
680
717
  const chan = makeChan('#foo');
681
718
  addMember(chan, 'c1', 'alice');
682
719
  const conn = makeConn();
720
+ conn.account = 'alice';
683
721
  const ctx = makeCtx(conn);
684
722
 
685
723
  const out = tagmsgChannelReducer(
@@ -692,10 +730,11 @@ describe('tagmsgChannelReducer — draft/read-marker broadcast + persistence', (
692
730
  expect((out.effects[0] as Extract<EffectType, { tag: 'Broadcast' }>).except).toBe('c1');
693
731
  });
694
732
 
695
- it('echoes the read-marker TAGMSG back to the sender when echo-message is negotiated', () => {
733
+ it('echoes the read-marker TAGMSG back to the sender when echo-message is negotiated (identified)', () => {
696
734
  const chan = makeChan('#foo');
697
735
  addMember(chan, 'c1', 'alice');
698
736
  const conn = makeConn();
737
+ conn.account = 'alice';
699
738
  conn.caps.add('echo-message');
700
739
  const ctx = makeCtx(conn);
701
740
 
@@ -713,18 +752,19 @@ describe('tagmsgChannelReducer — draft/read-marker broadcast + persistence', (
713
752
  lines: [line],
714
753
  except: 'c1',
715
754
  caps: ['message-tags', 'draft/read-marker'],
755
+ account: 'alice',
716
756
  },
717
757
  Effect.send('c1', [line]),
718
758
  ]);
719
759
  });
720
760
 
721
- it('fans out a valueless +draft/read-marker tag but persists no msgid', () => {
761
+ it('fans out a valueless +draft/read-marker tag (identified) but persists no msgid', () => {
722
762
  const chan = makeChan('#foo');
723
763
  addMember(chan, 'c1', 'alice');
724
764
  const conn = makeConn();
725
765
  conn.account = 'alice';
726
- const store = new InMemoryReadMarkerStore();
727
- const ctx = makeCtx(conn, undefined, undefined, store);
766
+ const services = new InMemoryServicesStore();
767
+ const ctx = makeCtx(conn, undefined, undefined, services);
728
768
 
729
769
  const out = tagmsgChannelReducer(
730
770
  chan,
@@ -732,13 +772,13 @@ describe('tagmsgChannelReducer — draft/read-marker broadcast + persistence', (
732
772
  ctx,
733
773
  );
734
774
 
735
- // Broadcast still widens to draft/read-marker peers (tag is present)...
736
- expect((out.effects[0] as Extract<EffectType, { tag: 'Broadcast' }>).caps).toEqual([
737
- 'message-tags',
738
- 'draft/read-marker',
739
- ]);
775
+ // Broadcast still widens to draft/read-marker peers (tag is present) and
776
+ // is account-scoped for the identified originator...
777
+ const broadcast = out.effects[0] as Extract<EffectType, { tag: 'Broadcast' }>;
778
+ expect(broadcast.caps).toEqual(['message-tags', 'draft/read-marker']);
779
+ expect(broadcast.account).toBe('alice');
740
780
  // ...but nothing is persisted (no msgid to store).
741
- expect(store.forAccount('alice')).toEqual([]);
781
+ expect(services.listReadMarkers('alice')).toEqual([]);
742
782
  expect(conn.lastReadMarkers?.get('#foo')).toBeUndefined();
743
783
  });
744
784
  });