serverless-ircd 0.3.0 → 0.4.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 (159) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/deploy-aws.yml +1 -3
  3. package/.github/workflows/deploy-cf-tcp.yml +87 -0
  4. package/.github/workflows/deploy-cf.yml +1 -1
  5. package/.node-version +1 -0
  6. package/.nvmrc +1 -0
  7. package/CHANGELOG.md +174 -18
  8. package/README.md +63 -30
  9. package/apps/aws-stack/README.md +2 -2
  10. package/apps/aws-stack/bin/aws.ts +7 -0
  11. package/apps/aws-stack/package.json +4 -4
  12. package/apps/aws-stack/src/aws-stack.ts +118 -6
  13. package/apps/aws-stack/tests/stack.test.ts +98 -3
  14. package/apps/cf-tcp-container/Dockerfile +69 -0
  15. package/apps/cf-tcp-container/package.json +34 -0
  16. package/apps/cf-tcp-container/src/config-loader.ts +145 -0
  17. package/apps/cf-tcp-container/src/container-do.ts +38 -0
  18. package/apps/cf-tcp-container/src/container-server.ts +363 -0
  19. package/apps/cf-tcp-container/src/main.ts +77 -0
  20. package/apps/cf-tcp-container/src/persistence.ts +144 -0
  21. package/apps/cf-tcp-container/src/worker.ts +41 -0
  22. package/apps/cf-tcp-container/terraform/provider.tf +24 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
  25. package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
  26. package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
  27. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
  28. package/apps/cf-tcp-container/tsconfig.build.json +17 -0
  29. package/apps/cf-tcp-container/tsconfig.test.json +15 -0
  30. package/apps/cf-tcp-container/vitest.config.ts +26 -0
  31. package/apps/cf-tcp-container/wrangler.toml +63 -0
  32. package/apps/cf-worker/package.json +1 -1
  33. package/apps/cf-worker/wrangler.test.toml +6 -0
  34. package/apps/cf-worker/wrangler.toml +28 -2
  35. package/apps/local-cli/package.json +1 -1
  36. package/apps/local-cli/src/config-loader.ts +10 -0
  37. package/apps/local-cli/src/server.ts +20 -3
  38. package/package.json +14 -10
  39. package/packages/aws-adapter/package.json +3 -3
  40. package/packages/aws-adapter/src/account-store.ts +1 -1
  41. package/packages/aws-adapter/src/admission.ts +74 -0
  42. package/packages/aws-adapter/src/aws-runtime.ts +6 -4
  43. package/packages/aws-adapter/src/config-loader.ts +32 -0
  44. package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
  45. package/packages/aws-adapter/src/handlers/connect.ts +64 -8
  46. package/packages/aws-adapter/src/handlers/default.ts +35 -2
  47. package/packages/aws-adapter/src/handlers/index.ts +69 -3
  48. package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
  49. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  50. package/packages/aws-adapter/src/index.ts +6 -0
  51. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
  52. package/packages/aws-adapter/tests/account-store.test.ts +19 -20
  53. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  54. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  55. package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
  56. package/packages/aws-adapter/tests/connect.test.ts +78 -0
  57. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
  58. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  59. package/packages/aws-adapter/tests/handlers.test.ts +154 -53
  60. package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
  61. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  62. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  63. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  64. package/packages/cf-adapter/package.json +1 -1
  65. package/packages/cf-adapter/src/cf-runtime.ts +2 -4
  66. package/packages/cf-adapter/src/config-loader.ts +33 -0
  67. package/packages/cf-adapter/src/connection-do.ts +111 -46
  68. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  69. package/packages/cf-adapter/src/env.ts +33 -9
  70. package/packages/cf-adapter/src/index.ts +9 -8
  71. package/packages/cf-adapter/tests/cf-harness.ts +11 -1
  72. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
  73. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  74. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  75. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  76. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  77. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  78. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  79. package/packages/cf-adapter/tests/worker/main.ts +8 -1
  80. package/packages/cf-adapter/wrangler.test.toml +8 -0
  81. package/packages/in-memory-runtime/package.json +1 -1
  82. package/packages/irc-core/package.json +6 -1
  83. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  84. package/packages/irc-core/src/caps/capabilities.ts +23 -2
  85. package/packages/irc-core/src/cloak.ts +1 -1
  86. package/packages/irc-core/src/commands/cap.ts +8 -1
  87. package/packages/irc-core/src/commands/index.ts +9 -0
  88. package/packages/irc-core/src/commands/ison.ts +61 -0
  89. package/packages/irc-core/src/commands/quit.ts +12 -0
  90. package/packages/irc-core/src/commands/registration.ts +18 -12
  91. package/packages/irc-core/src/commands/sasl.ts +72 -9
  92. package/packages/irc-core/src/commands/server-info.ts +129 -0
  93. package/packages/irc-core/src/commands/userhost.ts +84 -0
  94. package/packages/irc-core/src/commands/whowas.ts +113 -0
  95. package/packages/irc-core/src/config.ts +58 -0
  96. package/packages/irc-core/src/credential-hashing.ts +124 -0
  97. package/packages/irc-core/src/effects.ts +6 -29
  98. package/packages/irc-core/src/index.ts +1 -0
  99. package/packages/irc-core/src/ports.ts +181 -12
  100. package/packages/irc-core/src/protocol/numerics.ts +14 -8
  101. package/packages/irc-core/src/types.ts +38 -1
  102. package/packages/irc-core/tests/account-store.test.ts +45 -2
  103. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  104. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  105. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  106. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  107. package/packages/irc-core/tests/commands/registration.test.ts +151 -6
  108. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  109. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  110. package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
  111. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  112. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  113. package/packages/irc-core/tests/config.test.ts +95 -1
  114. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  115. package/packages/irc-core/tests/effects.test.ts +0 -27
  116. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  117. package/packages/irc-core/tests/numerics.test.ts +12 -0
  118. package/packages/irc-core/tests/types.test.ts +35 -1
  119. package/packages/irc-core/tsconfig.build.json +1 -1
  120. package/packages/irc-core/tsconfig.test.json +1 -1
  121. package/packages/irc-server/package.json +1 -1
  122. package/packages/irc-server/src/actor.ts +158 -14
  123. package/packages/irc-server/src/dispatch.ts +0 -3
  124. package/packages/irc-server/src/index.ts +10 -2
  125. package/packages/irc-server/src/routing.ts +12 -0
  126. package/packages/irc-server/src/transport.ts +101 -0
  127. package/packages/irc-server/tests/actor.test.ts +400 -3
  128. package/packages/irc-server/tests/dispatch.test.ts +0 -17
  129. package/packages/irc-server/tests/routing.test.ts +4 -0
  130. package/packages/irc-server/tests/transport.test.ts +230 -0
  131. package/packages/irc-test-support/package.json +1 -1
  132. package/packages/irc-test-support/src/harness.ts +44 -9
  133. package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
  134. package/packages/irc-test-support/src/index.ts +3 -0
  135. package/packages/irc-test-support/src/scenarios.ts +132 -2
  136. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  137. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  138. package/pnpm-workspace.yaml +9 -1
  139. package/tools/ci-hardening/package.json +1 -1
  140. package/tools/package.json +4 -0
  141. package/tools/seed-aws-accounts.ts +5 -6
  142. package/tools/seed-cf-accounts.ts +104 -0
  143. package/tools/tcp-ws-forwarder/package.json +1 -1
  144. package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
  145. package/docs/ADR-002-location-of-authority.md +0 -82
  146. package/docs/ADR-003-durable-object-sharding.md +0 -93
  147. package/docs/ADR-004-dynamodb-schema.md +0 -96
  148. package/docs/ADR-005-wss-only-transport-v1.md +0 -83
  149. package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
  150. package/docs/ADR-007-deterministic-ports.md +0 -82
  151. package/docs/ADR-008-monorepo-tooling.md +0 -60
  152. package/docs/AWS-Adapter-Architecture.md +0 -496
  153. package/docs/AWS-Deployment.md +0 -1186
  154. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  155. package/docs/Home.md +0 -11
  156. package/docs/Observability.md +0 -87
  157. package/docs/PlanIRCv3Websocket.md +0 -489
  158. package/docs/PlanWebClient.md +0 -451
  159. package/docs/Release-Process.md +0 -443
@@ -0,0 +1,166 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { isonReducer } from '../../src/commands/ison';
3
+ import { Effect } from '../../src/effects';
4
+ import type { Effect as EffectType, RawLine } from '../../src/effects';
5
+ import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
6
+ import { type ConnectionState, createConnection } from '../../src/state/connection';
7
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
8
+
9
+ const baseServerConfig: ServerConfig = {
10
+ serverName: 'irc.example.com',
11
+ networkName: 'ExampleNet',
12
+ maxChannelsPerUser: 30,
13
+ maxTargetsPerCommand: 10,
14
+ maxListEntries: 50,
15
+ nickLen: 30,
16
+ channelLen: 50,
17
+ topicLen: 390,
18
+ quitMessage: 'Client Quit',
19
+ };
20
+
21
+ function makeCtx(state: ConnectionState): Ctx {
22
+ return buildCtx({
23
+ serverConfig: baseServerConfig,
24
+ clock: new FakeClock(5_000),
25
+ ids: new SequentialIdFactory(),
26
+ motd: EmptyMotdProvider,
27
+ connection: state,
28
+ });
29
+ }
30
+
31
+ function makeState(): ConnectionState {
32
+ const s = createConnection({ id: 'c1', connectedSince: 0 });
33
+ s.nick = 'alice';
34
+ s.user = 'alice';
35
+ s.host = 'example.com';
36
+ s.realname = 'Alice';
37
+ s.registration = 'registered';
38
+ return s;
39
+ }
40
+
41
+ const L = (text: string): RawLine => ({ text });
42
+
43
+ const ison = (...nicks: string[]) => ({ command: 'ISON', params: nicks, tags: {} }) as const;
44
+
45
+ /** Builds an online map from display-case nicks (auto-folds the key). */
46
+ const onlineMap = (...nicks: string[]): Map<string, string> =>
47
+ new Map(nicks.map((n) => [n.toLowerCase(), n]));
48
+
49
+ describe('isonReducer', () => {
50
+ it('emits 303 RPL_ISON with the online nicks from the requested set', () => {
51
+ const state = makeState();
52
+ const ctx = makeCtx(state);
53
+
54
+ const out = isonReducer(onlineMap('bob', 'carol'), ison('bob', 'carol', 'dave'), ctx);
55
+
56
+ expect(out.effects).toEqual<EffectType[]>([
57
+ Effect.send('c1', [L(':irc.example.com 303 alice :bob carol')]),
58
+ ]);
59
+ });
60
+
61
+ it('only includes nicks that are online', () => {
62
+ const state = makeState();
63
+ const ctx = makeCtx(state);
64
+
65
+ const out = isonReducer(onlineMap('bob'), ison('bob', 'carol', 'dave'), ctx);
66
+
67
+ expect(out.effects).toEqual<EffectType[]>([
68
+ Effect.send('c1', [L(':irc.example.com 303 alice :bob')]),
69
+ ]);
70
+ });
71
+
72
+ it('emits an empty trailing when no requested nicks are online', () => {
73
+ const state = makeState();
74
+ const ctx = makeCtx(state);
75
+
76
+ const out = isonReducer(new Map(), ison('nobody'), ctx);
77
+
78
+ expect(out.effects).toEqual<EffectType[]>([
79
+ Effect.send('c1', [L(':irc.example.com 303 alice :')]),
80
+ ]);
81
+ });
82
+
83
+ it('preserves the registered display spelling from the online map', () => {
84
+ const state = makeState();
85
+ const ctx = makeCtx(state);
86
+
87
+ const out = isonReducer(onlineMap('Bob'), ison('Bob'), ctx);
88
+
89
+ expect(out.effects).toEqual<EffectType[]>([
90
+ Effect.send('c1', [L(':irc.example.com 303 alice :Bob')]),
91
+ ]);
92
+ });
93
+
94
+ it('matches case-insensitively (rfc1459 case-mapping)', () => {
95
+ const state = makeState();
96
+ const ctx = makeCtx(state);
97
+
98
+ const out = isonReducer(onlineMap('bob'), ison('BOB'), ctx);
99
+
100
+ expect(out.effects).toEqual<EffectType[]>([
101
+ Effect.send('c1', [L(':irc.example.com 303 alice :bob')]),
102
+ ]);
103
+ });
104
+
105
+ it('reports the registered spelling even when requested with different case', () => {
106
+ const state = makeState();
107
+ const ctx = makeCtx(state);
108
+
109
+ const out = isonReducer(onlineMap('Bob'), ison('BOB'), ctx);
110
+
111
+ expect(out.effects).toEqual<EffectType[]>([
112
+ Effect.send('c1', [L(':irc.example.com 303 alice :Bob')]),
113
+ ]);
114
+ });
115
+
116
+ it('uses * as nick placeholder for unregistered connections', () => {
117
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
118
+ const ctx = makeCtx(state);
119
+
120
+ const out = isonReducer(onlineMap('bob'), ison('bob'), ctx);
121
+
122
+ expect(out.effects[0]).toEqual<EffectType>(
123
+ Effect.send('c1', [L(':irc.example.com 303 * :bob')]),
124
+ );
125
+ });
126
+
127
+ it('updates lastSeen to ctx.clock.now()', () => {
128
+ const state = makeState();
129
+ const ctx = makeCtx(state);
130
+
131
+ isonReducer(new Map(), ison('bob'), ctx);
132
+
133
+ expect(state.lastSeen).toBe(5_000);
134
+ });
135
+
136
+ it('returns the same state reference', () => {
137
+ const state = makeState();
138
+ const ctx = makeCtx(state);
139
+
140
+ const out = isonReducer(new Map(), ison(), ctx);
141
+
142
+ expect(out.state).toBe(state);
143
+ });
144
+
145
+ it('handles no-nick argument (empty request)', () => {
146
+ const state = makeState();
147
+ const ctx = makeCtx(state);
148
+
149
+ const out = isonReducer(onlineMap('bob'), ison(), ctx);
150
+
151
+ expect(out.effects).toEqual<EffectType[]>([
152
+ Effect.send('c1', [L(':irc.example.com 303 alice :')]),
153
+ ]);
154
+ });
155
+
156
+ it('skips empty-string params in the nick list', () => {
157
+ const state = makeState();
158
+ const ctx = makeCtx(state);
159
+
160
+ const out = isonReducer(onlineMap('bob'), ison('', 'bob'), ctx);
161
+
162
+ expect(out.effects).toEqual<EffectType[]>([
163
+ Effect.send('c1', [L(':irc.example.com 303 alice :bob')]),
164
+ ]);
165
+ });
166
+ });
@@ -2,7 +2,12 @@ import { describe, expect, it } from 'vitest';
2
2
  import { quitReducer } from '../../src/commands/quit';
3
3
  import { Effect } from '../../src/effects';
4
4
  import type { Effect as EffectType, RawLine } from '../../src/effects';
5
- import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
5
+ import {
6
+ EmptyMotdProvider,
7
+ FakeClock,
8
+ InMemoryNickHistoryStore,
9
+ SequentialIdFactory,
10
+ } from '../../src/ports';
6
11
  import { type ConnectionState, createConnection } from '../../src/state/connection';
7
12
  import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
8
13
 
@@ -18,13 +23,18 @@ const serverConfig: ServerConfig = {
18
23
  quitMessage: 'Client Quit',
19
24
  };
20
25
 
21
- function makeCtx(state: ConnectionState, clock = new FakeClock(1_000)): Ctx {
26
+ function makeCtx(
27
+ state: ConnectionState,
28
+ clock = new FakeClock(1_000),
29
+ history?: InMemoryNickHistoryStore,
30
+ ): Ctx {
22
31
  return buildCtx({
23
32
  serverConfig,
24
33
  clock,
25
34
  ids: new SequentialIdFactory(),
26
35
  motd: EmptyMotdProvider,
27
36
  connection: state,
37
+ ...(history !== undefined ? { history } : {}),
28
38
  });
29
39
  }
30
40
 
@@ -217,4 +227,61 @@ describe('quitReducer', () => {
217
227
  Effect.disconnect('c1'),
218
228
  ]);
219
229
  });
230
+
231
+ it('records a nick-history entry when ctx.history is bound', () => {
232
+ const state = makeRegisteredState();
233
+ const clock = new FakeClock(5_000);
234
+ const history = new InMemoryNickHistoryStore(clock);
235
+ const ctx = makeCtx(state, clock, history);
236
+
237
+ quitReducer(state, { command: 'QUIT', params: ['bye'], tags: {} }, ctx);
238
+
239
+ const entries = history.query('alice', 10);
240
+ expect(entries).toHaveLength(1);
241
+ expect(entries[0]?.nick).toBe('alice');
242
+ expect(entries[0]?.connId).toBe('c1');
243
+ expect(entries[0]?.username).toBe('alice');
244
+ expect(entries[0]?.hostname).toBe('example.com');
245
+ expect(entries[0]?.realname).toBe('Alice');
246
+ expect(entries[0]?.signoffTime).toBe(5_000);
247
+ });
248
+
249
+ it('does not record history when no store is bound (ctx.history undefined)', () => {
250
+ const state = makeRegisteredState();
251
+ const ctx = makeCtx(state);
252
+
253
+ const out = quitReducer(state, { command: 'QUIT', params: [], tags: {} }, ctx);
254
+
255
+ expect(out.effects).toContainEqual<EffectType>(Effect.disconnect('c1'));
256
+ // No crash; the no-store path is a graceful no-op.
257
+ });
258
+
259
+ it('does not record history when the connection was never registered', () => {
260
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
261
+ const clock = new FakeClock(5_000);
262
+ const history = new InMemoryNickHistoryStore(clock);
263
+ const ctx = makeCtx(state, clock, history);
264
+
265
+ quitReducer(state, { command: 'QUIT', params: [], tags: {} }, ctx);
266
+
267
+ expect(history.query('ghost', 10)).toEqual([]);
268
+ });
269
+
270
+ it('records a history entry with only nick/connId when user/host/realname are absent', () => {
271
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
272
+ state.nick = 'alice';
273
+ state.registration = 'registered';
274
+ const clock = new FakeClock(5_000);
275
+ const history = new InMemoryNickHistoryStore(clock);
276
+ const ctx = makeCtx(state, clock, history);
277
+
278
+ quitReducer(state, { command: 'QUIT', params: [], tags: {} }, ctx);
279
+
280
+ const entries = history.query('alice', 10);
281
+ expect(entries).toHaveLength(1);
282
+ expect(entries[0]?.nick).toBe('alice');
283
+ expect(entries[0]?.username).toBeUndefined();
284
+ expect(entries[0]?.hostname).toBeUndefined();
285
+ expect(entries[0]?.realname).toBeUndefined();
286
+ });
220
287
  });
@@ -9,11 +9,13 @@ import {
9
9
  passReducer,
10
10
  userReducer,
11
11
  } from '../../src/commands/registration';
12
+ import { DEFAULT_CREATED_TEXT, DEFAULT_SERVER_VERSION } from '../../src/config';
12
13
  import { Effect } from '../../src/effects';
13
14
  import type { Effect as EffectType, RawLine } from '../../src/effects';
14
15
  import {
15
16
  EmptyMotdProvider,
16
17
  FakeClock,
18
+ InMemoryNickHistoryStore,
17
19
  SequentialIdFactory,
18
20
  StaticMotdProvider,
19
21
  } from '../../src/ports';
@@ -47,6 +49,22 @@ function makeCtx(
47
49
  });
48
50
  }
49
51
 
52
+ /** Like {@link makeCtx} but also threads a bound nick-history store. */
53
+ function makeCtxWithHistory(
54
+ state: ConnectionState,
55
+ history: InMemoryNickHistoryStore,
56
+ clock = new FakeClock(1_000),
57
+ ): Ctx {
58
+ return buildCtx({
59
+ serverConfig,
60
+ clock,
61
+ ids: new SequentialIdFactory(),
62
+ motd: EmptyMotdProvider,
63
+ connection: state,
64
+ history,
65
+ });
66
+ }
67
+
50
68
  function makeState(): ConnectionState {
51
69
  return createConnection({ id: 'c1', connectedSince: 0 });
52
70
  }
@@ -86,9 +104,9 @@ function expectedWelcome(nick: string, user: string, host: string | undefined):
86
104
  );
87
105
  return [
88
106
  L(`${prefix} 001 ${nick} :Welcome to the ExampleNet IRC Network, ${hostmask}`),
89
- L(`${prefix} 002 ${nick} :Your host is ${sv}, running version 0.2.0`),
90
- L(`${prefix} 003 ${nick} :This server was created in Phase 1`),
91
- L(`${prefix} 004 ${nick} ${sv} 0.2.0 iosw biklmnstp`),
107
+ L(`${prefix} 002 ${nick} :Your host is ${sv}, running version ${DEFAULT_SERVER_VERSION}`),
108
+ L(`${prefix} 003 ${nick} :This server was created in ${DEFAULT_CREATED_TEXT}`),
109
+ L(`${prefix} 004 ${nick} ${sv} ${DEFAULT_SERVER_VERSION} iosw biklmnstp`),
92
110
  ...isupport,
93
111
  L(`${prefix} 422 ${nick} :MOTD File is missing`),
94
112
  ];
@@ -327,6 +345,63 @@ describe('nickReducer', () => {
327
345
  expect(out.effects).toEqual([]);
328
346
  expect(out.state.nick).toBeUndefined();
329
347
  });
348
+
349
+ it('records the old nick into ctx.history on a post-registration nick change', () => {
350
+ const state = registeredState();
351
+ const clock = new FakeClock(5_000);
352
+ const history = new InMemoryNickHistoryStore(clock);
353
+ const ctx = makeCtxWithHistory(state, history, clock);
354
+
355
+ nickReducer(state, { command: 'NICK', params: ['bob'], tags: {} }, ctx);
356
+
357
+ const entries = history.query('alice', 10);
358
+ expect(entries).toHaveLength(1);
359
+ expect(entries[0]?.nick).toBe('alice');
360
+ expect(entries[0]?.connId).toBe('c1');
361
+ expect(entries[0]?.username).toBe('alice');
362
+ expect(entries[0]?.hostname).toBe('example.com');
363
+ expect(entries[0]?.realname).toBe('Alice');
364
+ expect(entries[0]?.signoffTime).toBe(5_000);
365
+ });
366
+
367
+ it('does not record history on a pre-registration NICK', () => {
368
+ const state = makeState();
369
+ const clock = new FakeClock(5_000);
370
+ const history = new InMemoryNickHistoryStore(clock);
371
+ const ctx = makeCtxWithHistory(state, history, clock);
372
+
373
+ nickReducer(state, { command: 'NICK', params: ['alice'], tags: {} }, ctx);
374
+
375
+ expect(history.query('alice', 10)).toEqual([]);
376
+ });
377
+
378
+ it('does not record history on a nick change when no store is bound', () => {
379
+ const state = registeredState();
380
+ const ctx = makeCtx(state);
381
+
382
+ const out = nickReducer(state, { command: 'NICK', params: ['bob'], tags: {} }, ctx);
383
+
384
+ // No crash; the no-store path is a graceful no-op.
385
+ expect(out.state.nick).toBe('bob');
386
+ });
387
+
388
+ it('records a history entry with only nick/connId when user/host/realname are absent', () => {
389
+ const state = makeState();
390
+ state.nick = 'alice';
391
+ state.registration = 'registered';
392
+ const clock = new FakeClock(5_000);
393
+ const history = new InMemoryNickHistoryStore(clock);
394
+ const ctx = makeCtxWithHistory(state, history, clock);
395
+
396
+ nickReducer(state, { command: 'NICK', params: ['bob'], tags: {} }, ctx);
397
+
398
+ const entries = history.query('alice', 10);
399
+ expect(entries).toHaveLength(1);
400
+ expect(entries[0]?.nick).toBe('alice');
401
+ expect(entries[0]?.username).toBeUndefined();
402
+ expect(entries[0]?.hostname).toBeUndefined();
403
+ expect(entries[0]?.realname).toBeUndefined();
404
+ });
330
405
  });
331
406
 
332
407
  // ============================================================================
@@ -568,6 +643,76 @@ describe('buildWelcomeLines', () => {
568
643
  });
569
644
  });
570
645
 
646
+ // ============================================================================
647
+ // buildWelcomeLines — server version / created text driven from ServerConfig
648
+ // ============================================================================
649
+
650
+ describe('buildWelcomeLines — config-driven version & created text', () => {
651
+ /** Builds a ctx whose serverConfig carries the supplied version/created. */
652
+ function makeCtxWithMeta(
653
+ state: ConnectionState,
654
+ serverVersion: string | undefined,
655
+ createdAt: string | number | undefined,
656
+ ): Ctx {
657
+ const cfg: ServerConfig = {
658
+ ...serverConfig,
659
+ ...(serverVersion !== undefined ? { serverVersion } : {}),
660
+ ...(createdAt !== undefined ? { createdAt } : {}),
661
+ };
662
+ return buildCtx({
663
+ serverConfig: cfg,
664
+ clock: new FakeClock(1_000),
665
+ ids: new SequentialIdFactory(),
666
+ motd: EmptyMotdProvider,
667
+ connection: state,
668
+ });
669
+ }
670
+
671
+ function registeredConn(): ConnectionState {
672
+ const s = makeState();
673
+ s.nick = 'alice';
674
+ s.user = 'alice';
675
+ s.host = 'example.com';
676
+ return s;
677
+ }
678
+
679
+ it('reflects the configured serverVersion in the 002 and 004 lines', () => {
680
+ const ctx = makeCtxWithMeta(registeredConn(), '9.9.9', undefined);
681
+ const lines = buildWelcomeLines(ctx.connection, ctx);
682
+ const line002 = lines.find((l) => l.text.split(' ')[1] === '002');
683
+ const line004 = lines.find((l) => l.text.split(' ')[1] === '004');
684
+ expect(line002?.text).toContain('9.9.9');
685
+ expect(line004?.text).toContain('9.9.9');
686
+ });
687
+
688
+ it('reflects the configured createdAt string in the 003 line', () => {
689
+ const ctx = makeCtxWithMeta(registeredConn(), undefined, '2024-06-01');
690
+ const lines = buildWelcomeLines(ctx.connection, ctx);
691
+ const line003 = lines.find((l) => l.text.split(' ')[1] === '003');
692
+ expect(line003?.text).toContain('2024-06-01');
693
+ });
694
+
695
+ it('formats a numeric createdAt (epoch ms) into the 003 line', () => {
696
+ const ctx = makeCtxWithMeta(registeredConn(), undefined, 1_700_000_000_000);
697
+ const lines = buildWelcomeLines(ctx.connection, ctx);
698
+ const line003 = lines.find((l) => l.text.split(' ')[1] === '003');
699
+ // 1700000000000ms → 2023-11-14T22:13:20.000Z ; the formatted text must
700
+ // contain a human-readable rendering of that instant, not the raw number.
701
+ expect(line003?.text).not.toContain('1700000000000');
702
+ expect(line003?.text).toContain('2023');
703
+ });
704
+
705
+ it('falls back to the default version when serverVersion is omitted', () => {
706
+ const ctx = makeCtxWithMeta(registeredConn(), undefined, undefined);
707
+ const lines = buildWelcomeLines(ctx.connection, ctx);
708
+ const line002 = lines.find((l) => l.text.split(' ')[1] === '002');
709
+ // The hardcoded placeholder '0.2.0' must no longer appear; the default
710
+ // is the irc-core package version (DEFAULT_SERVER_VERSION).
711
+ expect(line002?.text).not.toContain('0.2.0');
712
+ expect(line002?.text).toContain(DEFAULT_SERVER_VERSION);
713
+ });
714
+ });
715
+
571
716
  // ============================================================================
572
717
  // buildWelcomeLines — MOTD driven from MotdProvider
573
718
  // ============================================================================
@@ -950,9 +1095,9 @@ function expectedWelcomeWithHost(nick: string, hostmask: string): RawLine[] {
950
1095
  );
951
1096
  return [
952
1097
  L(`${prefix} 001 ${nick} :Welcome to the ExampleNet IRC Network, ${hostmask}`),
953
- L(`${prefix} 002 ${nick} :Your host is ${sv}, running version 0.2.0`),
954
- L(`${prefix} 003 ${nick} :This server was created in Phase 1`),
955
- L(`${prefix} 004 ${nick} ${sv} 0.2.0 iosw biklmnstp`),
1098
+ L(`${prefix} 002 ${nick} :Your host is ${sv}, running version ${DEFAULT_SERVER_VERSION}`),
1099
+ L(`${prefix} 003 ${nick} :This server was created in ${DEFAULT_CREATED_TEXT}`),
1100
+ L(`${prefix} 004 ${nick} ${sv} ${DEFAULT_SERVER_VERSION} iosw biklmnstp`),
956
1101
  ...isupport,
957
1102
  L(`${prefix} 422 ${nick} :MOTD File is missing`),
958
1103
  ];
@@ -4,7 +4,7 @@ import { capReducer } from '../../src/commands/cap';
4
4
  import { authenticateReducer } from '../../src/commands/sasl';
5
5
  import { Effect } from '../../src/effects';
6
6
  import type { Effect as EffectType, RawLine } from '../../src/effects';
7
- import type { AccountStore, SaslPayload, SaslResult } from '../../src/ports';
7
+ import type { AccountStore, MtlsIdentityProvider, SaslPayload, SaslResult } from '../../src/ports';
8
8
  import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
9
9
  import { encodeBase64 } from '../../src/protocol/base64';
10
10
  import type { IrcMessage } from '../../src/protocol/messages';
@@ -40,7 +40,11 @@ function saslReadyState(): ConnectionState {
40
40
  return s;
41
41
  }
42
42
 
43
- function makeCtx(state: ConnectionState, accounts?: AccountStore): Ctx {
43
+ function makeCtx(
44
+ state: ConnectionState,
45
+ accounts?: AccountStore,
46
+ mtlsIdentity?: MtlsIdentityProvider,
47
+ ): Ctx {
44
48
  return buildCtx({
45
49
  serverConfig,
46
50
  clock: new FakeClock(1_000),
@@ -48,6 +52,7 @@ function makeCtx(state: ConnectionState, accounts?: AccountStore): Ctx {
48
52
  motd: EmptyMotdProvider,
49
53
  connection: state,
50
54
  ...(accounts !== undefined ? { accounts } : {}),
55
+ ...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
51
56
  });
52
57
  }
53
58
 
@@ -61,6 +66,19 @@ class FakeAccountStore implements AccountStore {
61
66
  }
62
67
  }
63
68
 
69
+ /** A fake MtlsIdentityProvider that returns a canned identity for one connId. */
70
+ class FakeMtlsIdentityProvider implements MtlsIdentityProvider {
71
+ readonly calls: string[] = [];
72
+ constructor(
73
+ private readonly connId: string,
74
+ private readonly identity: string | undefined,
75
+ ) {}
76
+ getIdentity(connId: string): string | undefined {
77
+ this.calls.push(connId);
78
+ return connId === this.connId ? this.identity : undefined;
79
+ }
80
+ }
81
+
64
82
  /** Builds the PLAIN base64 payload `authzid\0authcid\0password`. */
65
83
  function plainPayload(authcid: string, password: string, authzid = ''): string {
66
84
  return encodeBase64(`${authzid}\0${authcid}\0${password}`);
@@ -243,25 +261,103 @@ describe('authenticateReducer — no AccountStore configured', () => {
243
261
  });
244
262
 
245
263
  // ============================================================================
246
- // AUTHENTICATE EXTERNAL — stubbed
264
+ // AUTHENTICATE EXTERNAL — mTLS-backed SASL EXTERNAL
247
265
  // ============================================================================
248
266
 
249
- describe('authenticateReducer — EXTERNAL stubbed', () => {
250
- it('rejects EXTERNAL immediately with 904 (not yet supported)', () => {
267
+ describe('authenticateReducer — EXTERNAL with mTLS', () => {
268
+ it('enters the payload phase when an mTLS provider is configured', () => {
269
+ const state = saslReadyState();
270
+ const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
271
+ const ctx = makeCtx(state, undefined, provider);
272
+ const out = authenticateReducer(state, authenticate('EXTERNAL'), ctx);
273
+
274
+ expect(state.saslMech).toBe('EXTERNAL');
275
+ expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([L('AUTHENTICATE +')]);
276
+ });
277
+
278
+ it('returns 908 ERR_SASLMECHS when no mTLS provider is configured', () => {
251
279
  const state = saslReadyState();
252
280
  const ctx = makeCtx(state);
253
281
  const out = authenticateReducer(state, authenticate('EXTERNAL'), ctx);
254
282
 
255
283
  expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
256
- L(`:${SV} 904 alice :SASL authentication failed`),
284
+ L(`:${SV} 908 alice PLAIN :are the available SASL mechanisms`),
257
285
  ]);
286
+ expect(state.saslMech).toBeUndefined();
258
287
  });
259
288
 
260
- it('does not enter the payload phase for EXTERNAL', () => {
289
+ it('succeeds (900/903) when a valid cert identity is presented', () => {
261
290
  const state = saslReadyState();
262
- const ctx = makeCtx(state);
291
+ const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
292
+ const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
293
+ const ctx = makeCtx(state, accounts, provider);
294
+
263
295
  authenticateReducer(state, authenticate('EXTERNAL'), ctx);
296
+ const out = authenticateReducer(state, authenticate('+'), ctx);
297
+
298
+ expect(state.account).toBe('alice');
299
+ expect(accounts.calls).toEqual([
300
+ { mech: 'EXTERNAL', payload: { kind: 'EXTERNAL', identity: 'CN=alice' } },
301
+ ]);
302
+ expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
303
+ L(`:${SV} 900 alice alice!alice@example.com alice :You are now logged in as alice`),
304
+ L(`:${SV} 903 alice :SASL authentication successful`),
305
+ ]);
306
+ });
307
+
308
+ it('clears saslMech/saslBuffer after a successful EXTERNAL', () => {
309
+ const state = saslReadyState();
310
+ const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
311
+ const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
312
+ const ctx = makeCtx(state, accounts, provider);
313
+
314
+ authenticateReducer(state, authenticate('EXTERNAL'), ctx);
315
+ authenticateReducer(state, authenticate('+'), ctx);
316
+
264
317
  expect(state.saslMech).toBeUndefined();
318
+ expect(state.saslBuffer).toBeUndefined();
319
+ });
320
+
321
+ it('fails with 904 when the provider has no identity for the connection', () => {
322
+ const state = saslReadyState();
323
+ const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
324
+ const provider = new FakeMtlsIdentityProvider('c1', undefined);
325
+ const ctx = makeCtx(state, accounts, provider);
326
+
327
+ authenticateReducer(state, authenticate('EXTERNAL'), ctx);
328
+ const out = authenticateReducer(state, authenticate('+'), ctx);
329
+
330
+ expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
331
+ L(`:${SV} 904 alice :SASL authentication failed`),
332
+ ]);
333
+ expect(state.account).toBeUndefined();
334
+ });
335
+
336
+ it('fails with 904 when the account store rejects the identity', () => {
337
+ const state = saslReadyState();
338
+ const accounts = new FakeAccountStore({ ok: false, reason: 'untrusted certificate' });
339
+ const provider = new FakeMtlsIdentityProvider('c1', 'CN=evil');
340
+ const ctx = makeCtx(state, accounts, provider);
341
+
342
+ authenticateReducer(state, authenticate('EXTERNAL'), ctx);
343
+ const out = authenticateReducer(state, authenticate('+'), ctx);
344
+
345
+ expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
346
+ L(`:${SV} 904 alice :SASL authentication failed`),
347
+ ]);
348
+ });
349
+
350
+ it('fails with 904 when no account store is configured', () => {
351
+ const state = saslReadyState();
352
+ const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
353
+ const ctx = makeCtx(state, undefined, provider);
354
+
355
+ authenticateReducer(state, authenticate('EXTERNAL'), ctx);
356
+ const out = authenticateReducer(state, authenticate('+'), ctx);
357
+
358
+ expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
359
+ L(`:${SV} 904 alice :SASL authentication failed`),
360
+ ]);
265
361
  });
266
362
  });
267
363
 
@@ -275,6 +371,17 @@ describe('authenticateReducer — unknown mechanism', () => {
275
371
  const ctx = makeCtx(state);
276
372
  const out = authenticateReducer(state, authenticate('CRAM-MD5'), ctx);
277
373
 
374
+ expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
375
+ L(`:${SV} 908 alice PLAIN :are the available SASL mechanisms`),
376
+ ]);
377
+ });
378
+
379
+ it('lists PLAIN,EXTERNAL in 908 when an mTLS provider is configured', () => {
380
+ const state = saslReadyState();
381
+ const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
382
+ const ctx = makeCtx(state, undefined, provider);
383
+ const out = authenticateReducer(state, authenticate('CRAM-MD5'), ctx);
384
+
278
385
  expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
279
386
  L(`:${SV} 908 alice PLAIN,EXTERNAL :are the available SASL mechanisms`),
280
387
  ]);
@@ -529,8 +636,9 @@ describe('authenticateReducer — CAP REQ sasl integration', () => {
529
636
  expect(state.account).toBe('alice');
530
637
  });
531
638
 
532
- it('does not affect the advertised sasl cap value (PLAIN,EXTERNAL)', () => {
639
+ it('advertises the base sasl cap value (PLAIN) without mTLS', () => {
533
640
  const advertised = getLsString();
534
- expect(advertised).toContain('sasl=PLAIN,EXTERNAL');
641
+ expect(advertised).toContain('sasl=PLAIN');
642
+ expect(advertised).not.toContain('sasl=PLAIN,EXTERNAL');
535
643
  });
536
644
  });