serverless-ircd 0.1.0 → 0.2.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 (198) hide show
  1. package/.github/workflows/ci.yml +96 -2
  2. package/.github/workflows/deploy-aws.yml +129 -0
  3. package/.github/workflows/deploy-cf.yml +0 -2
  4. package/.gitmodules +3 -0
  5. package/AGENTS.md +5 -0
  6. package/CHANGELOG.md +352 -0
  7. package/MOTD.txt +3 -0
  8. package/PLAN-FIXES.md +358 -0
  9. package/PLAN.md +420 -0
  10. package/README.md +115 -81
  11. package/apps/aws-stack/README.md +73 -0
  12. package/apps/aws-stack/bin/aws.ts +49 -0
  13. package/apps/aws-stack/cdk.json +10 -0
  14. package/apps/aws-stack/package.json +41 -0
  15. package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
  16. package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
  17. package/apps/aws-stack/scripts/smoke.mjs +142 -0
  18. package/apps/aws-stack/src/aws-stack.ts +263 -0
  19. package/apps/aws-stack/src/tables.ts +10 -0
  20. package/apps/aws-stack/tests/localstack.test.ts +46 -0
  21. package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
  22. package/apps/aws-stack/tests/stack.test.ts +464 -0
  23. package/apps/aws-stack/tsconfig.build.json +11 -0
  24. package/apps/aws-stack/tsconfig.test.json +8 -0
  25. package/apps/aws-stack/vitest.config.ts +20 -0
  26. package/apps/cf-worker/package.json +1 -1
  27. package/apps/cf-worker/wrangler.toml +25 -0
  28. package/apps/local-cli/package.json +1 -1
  29. package/apps/local-cli/src/config-loader.ts +56 -0
  30. package/apps/local-cli/src/server.ts +241 -32
  31. package/apps/local-cli/tests/config-loader.test.ts +107 -0
  32. package/apps/local-cli/tests/e2e.test.ts +74 -0
  33. package/apps/local-cli/tests/security-e2e.test.ts +239 -0
  34. package/biome.json +3 -1
  35. package/dashboards/cloudwatch-irc.json +139 -0
  36. package/docs/AWS-Adapter-Architecture.md +494 -0
  37. package/docs/AWS-Deployment.md +1107 -0
  38. package/docs/Cloudflare-Deployment-Guide.md +660 -0
  39. package/docs/Home.md +1 -0
  40. package/docs/Observability.md +87 -0
  41. package/docs/PlanIRCv3Websocket.md +489 -0
  42. package/docs/PlanWebClient.md +451 -0
  43. package/docs/Release-Process.md +440 -0
  44. package/package.json +8 -2
  45. package/packages/aws-adapter/README.md +35 -0
  46. package/packages/aws-adapter/package.json +52 -0
  47. package/packages/aws-adapter/src/aws-runtime.ts +783 -0
  48. package/packages/aws-adapter/src/cdk-table-defs.ts +69 -0
  49. package/packages/aws-adapter/src/config-loader.ts +96 -0
  50. package/packages/aws-adapter/src/dynamo.ts +61 -0
  51. package/packages/aws-adapter/src/handlers/connect.ts +44 -0
  52. package/packages/aws-adapter/src/handlers/default.ts +322 -0
  53. package/packages/aws-adapter/src/handlers/disconnect.ts +29 -0
  54. package/packages/aws-adapter/src/handlers/index.ts +248 -0
  55. package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
  56. package/packages/aws-adapter/src/handlers/state.ts +13 -0
  57. package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
  58. package/packages/aws-adapter/src/index.ts +67 -0
  59. package/packages/aws-adapter/src/message-store.ts +34 -0
  60. package/packages/aws-adapter/src/serialize.ts +283 -0
  61. package/packages/aws-adapter/src/tables.ts +53 -0
  62. package/packages/aws-adapter/tests/aws-harness.ts +408 -0
  63. package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
  64. package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
  65. package/packages/aws-adapter/tests/config-loader.test.ts +158 -0
  66. package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
  67. package/packages/aws-adapter/tests/global-setup.ts +301 -0
  68. package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
  69. package/packages/aws-adapter/tests/handlers.test.ts +427 -0
  70. package/packages/aws-adapter/tests/message-store.test.ts +55 -0
  71. package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
  72. package/packages/aws-adapter/tests/serialize.test.ts +249 -0
  73. package/packages/aws-adapter/tests/smoke.test.ts +48 -0
  74. package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
  75. package/packages/aws-adapter/tests/tables.test.ts +74 -0
  76. package/packages/aws-adapter/tests/transactions.test.ts +446 -0
  77. package/packages/aws-adapter/tsconfig.build.json +16 -0
  78. package/packages/aws-adapter/tsconfig.test.json +14 -0
  79. package/packages/aws-adapter/vitest.config.ts +23 -0
  80. package/packages/cf-adapter/package.json +2 -1
  81. package/packages/cf-adapter/src/config-loader.ts +106 -0
  82. package/packages/cf-adapter/src/connection-do.ts +34 -0
  83. package/packages/cf-adapter/tests/cf-harness.ts +2 -2
  84. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
  85. package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
  86. package/packages/cf-adapter/tests/connection-do.test.ts +42 -0
  87. package/packages/in-memory-runtime/package.json +1 -1
  88. package/packages/in-memory-runtime/src/in-memory-runtime.ts +108 -3
  89. package/packages/in-memory-runtime/src/index.ts +1 -1
  90. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +115 -0
  91. package/packages/irc-core/package.json +12 -2
  92. package/packages/irc-core/src/admission.ts +216 -0
  93. package/packages/irc-core/src/caps/capabilities.ts +1 -0
  94. package/packages/irc-core/src/cloak.ts +81 -0
  95. package/packages/irc-core/src/commands/cap.ts +1 -2
  96. package/packages/irc-core/src/commands/chathistory.ts +305 -0
  97. package/packages/irc-core/src/commands/index.ts +7 -0
  98. package/packages/irc-core/src/commands/join.ts +59 -7
  99. package/packages/irc-core/src/commands/privmsg.ts +24 -0
  100. package/packages/irc-core/src/commands/registration.ts +71 -14
  101. package/packages/irc-core/src/commands/sasl.ts +251 -0
  102. package/packages/irc-core/src/config.ts +247 -0
  103. package/packages/irc-core/src/flood-control.ts +175 -0
  104. package/packages/irc-core/src/index.ts +5 -0
  105. package/packages/irc-core/src/ports.ts +655 -0
  106. package/packages/irc-core/src/protocol/base64.ts +16 -0
  107. package/packages/irc-core/src/protocol/index.ts +2 -1
  108. package/packages/irc-core/src/protocol/numerics.ts +11 -0
  109. package/packages/irc-core/src/protocol/parser.ts +27 -2
  110. package/packages/irc-core/src/state/connection.ts +41 -0
  111. package/packages/irc-core/src/types.ts +66 -2
  112. package/packages/irc-core/stryker.commands.conf.json +41 -0
  113. package/packages/irc-core/stryker.protocol.conf.json +26 -0
  114. package/packages/irc-core/tests/admission.test.ts +229 -0
  115. package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
  116. package/packages/irc-core/tests/cloak.test.ts +78 -0
  117. package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
  118. package/packages/irc-core/tests/commands/join.test.ts +246 -2
  119. package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
  120. package/packages/irc-core/tests/commands/registration.test.ts +277 -9
  121. package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
  122. package/packages/irc-core/tests/config.test.ts +646 -0
  123. package/packages/irc-core/tests/flood-control.test.ts +394 -0
  124. package/packages/irc-core/tests/message-store.test.ts +530 -0
  125. package/packages/irc-core/tests/parser.test.ts +44 -1
  126. package/packages/irc-core/tests/ports.test.ts +263 -0
  127. package/packages/irc-server/package.json +1 -1
  128. package/packages/irc-server/src/actor.ts +162 -44
  129. package/packages/irc-server/src/dispatch.ts +89 -5
  130. package/packages/irc-server/src/index.ts +2 -0
  131. package/packages/irc-server/src/routing.ts +151 -0
  132. package/packages/irc-server/tests/actor.test.ts +470 -14
  133. package/packages/irc-server/tests/dispatch.test.ts +84 -0
  134. package/packages/irc-server/tests/routing.test.ts +201 -0
  135. package/packages/irc-test-support/README.md +32 -0
  136. package/packages/irc-test-support/package.json +37 -0
  137. package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +1 -1
  138. package/packages/irc-test-support/src/index.ts +24 -0
  139. package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
  140. package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
  141. package/packages/irc-test-support/tests/smoke.test.ts +11 -0
  142. package/packages/irc-test-support/tsconfig.build.json +16 -0
  143. package/packages/irc-test-support/tsconfig.test.json +14 -0
  144. package/packages/irc-test-support/vitest.config.ts +20 -0
  145. package/progress.md +107 -0
  146. package/tickets.md +2485 -0
  147. package/tools/ci-hardening/package.json +30 -0
  148. package/tools/ci-hardening/src/index.ts +6 -0
  149. package/tools/ci-hardening/src/validate.ts +103 -0
  150. package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
  151. package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
  152. package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
  153. package/tools/ci-hardening/tests/validate.test.ts +177 -0
  154. package/tools/ci-hardening/tsconfig.build.json +12 -0
  155. package/tools/ci-hardening/tsconfig.test.json +10 -0
  156. package/tools/ci-hardening/vitest.config.ts +21 -0
  157. package/tools/tcp-ws-forwarder/package.json +1 -1
  158. package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
  159. package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
  160. package/tools/tcp-ws-forwarder/src/main.ts +33 -14
  161. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
  162. package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
  163. package/webircgateway/LICENSE +201 -0
  164. package/webircgateway/Makefile +44 -0
  165. package/webircgateway/README.md +134 -0
  166. package/webircgateway/config.conf.example +135 -0
  167. package/webircgateway/go.mod +16 -0
  168. package/webircgateway/go.sum +89 -0
  169. package/webircgateway/main.go +118 -0
  170. package/webircgateway/pkg/dnsbl/dnsbl.go +121 -0
  171. package/webircgateway/pkg/identd/identd.go +86 -0
  172. package/webircgateway/pkg/identd/rpcclient.go +59 -0
  173. package/webircgateway/pkg/irc/isupport.go +56 -0
  174. package/webircgateway/pkg/irc/message.go +217 -0
  175. package/webircgateway/pkg/irc/state.go +79 -0
  176. package/webircgateway/pkg/proxy/proxy.go +129 -0
  177. package/webircgateway/pkg/proxy/server.go +237 -0
  178. package/webircgateway/pkg/recaptcha/recaptcha.go +59 -0
  179. package/webircgateway/pkg/webircgateway/client.go +741 -0
  180. package/webircgateway/pkg/webircgateway/client_command_handlers.go +495 -0
  181. package/webircgateway/pkg/webircgateway/config.go +385 -0
  182. package/webircgateway/pkg/webircgateway/gateway.go +278 -0
  183. package/webircgateway/pkg/webircgateway/gateway_utils.go +133 -0
  184. package/webircgateway/pkg/webircgateway/hooks.go +152 -0
  185. package/webircgateway/pkg/webircgateway/letsencrypt.go +41 -0
  186. package/webircgateway/pkg/webircgateway/messagetags.go +103 -0
  187. package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +206 -0
  188. package/webircgateway/pkg/webircgateway/transport_sockjs.go +107 -0
  189. package/webircgateway/pkg/webircgateway/transport_tcp.go +113 -0
  190. package/webircgateway/pkg/webircgateway/transport_websocket.go +126 -0
  191. package/webircgateway/pkg/webircgateway/utils.go +147 -0
  192. package/webircgateway/plugins/example/plugin.go +11 -0
  193. package/webircgateway/plugins/stats/plugin.go +52 -0
  194. package/webircgateway/staticcheck.conf +1 -0
  195. package/webircgateway/webircgateway.svg +3 -0
  196. package/packages/cf-adapter/tests/integration/index.ts +0 -17
  197. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
  198. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
@@ -0,0 +1,996 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { chathistoryReducer } from '../../src/commands/chathistory';
3
+ import { Effect } from '../../src/effects';
4
+ import type { Effect as EffectType, RawLine } from '../../src/effects';
5
+ import {
6
+ EmptyMotdProvider,
7
+ FakeClock,
8
+ InMemoryMessageStore,
9
+ type MessageStore,
10
+ SequentialIdFactory,
11
+ type StoredMessage,
12
+ } from '../../src/ports';
13
+ import { formatServerTime } from '../../src/protocol/outbound';
14
+ import { type ChannelState, createChannel } from '../../src/state/channel';
15
+ import { type ConnectionState, createConnection } from '../../src/state/connection';
16
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
17
+
18
+ const serverConfig: ServerConfig = {
19
+ serverName: 'irc.example.com',
20
+ networkName: 'ExampleNet',
21
+ maxChannelsPerUser: 30,
22
+ maxTargetsPerCommand: 10,
23
+ maxListEntries: 50,
24
+ nickLen: 30,
25
+ channelLen: 50,
26
+ topicLen: 390,
27
+ quitMessage: 'Client Quit',
28
+ };
29
+
30
+ /** Caps the connection has negotiated; defaults to the chathistory cap set. */
31
+ function makeConn(
32
+ id = 'c1',
33
+ nick = 'alice',
34
+ caps: string[] = ['draft/chathistory', 'server-time', 'message-tags', 'batch'],
35
+ ): ConnectionState {
36
+ const s = createConnection({ id, connectedSince: 0 });
37
+ s.nick = nick;
38
+ s.user = nick;
39
+ s.host = 'example.com';
40
+ s.realname = nick.charAt(0).toUpperCase() + nick.slice(1);
41
+ s.registration = 'registered';
42
+ for (const c of caps) s.caps.add(c);
43
+ return s;
44
+ }
45
+
46
+ function makeCtx(conn: ConnectionState, store: MessageStore, clock = new FakeClock(10_000)): Ctx {
47
+ return buildCtx({
48
+ serverConfig,
49
+ clock,
50
+ ids: new SequentialIdFactory(),
51
+ motd: EmptyMotdProvider,
52
+ messages: store,
53
+ connection: conn,
54
+ });
55
+ }
56
+
57
+ function makeChan(name = '#foo'): ChannelState {
58
+ return createChannel({ name, nameLower: name.toLowerCase(), createdAt: 0 });
59
+ }
60
+
61
+ /** Records N chronological PRIVMSG messages into the store. */
62
+ function seed(store: MessageStore, chan: string, count: number): StoredMessage[] {
63
+ const out: StoredMessage[] = [];
64
+ for (let i = 1; i <= count; i++) {
65
+ const m: StoredMessage = {
66
+ msgid: `m${i}`,
67
+ time: i * 1_000,
68
+ chan: chan.toLowerCase(),
69
+ command: 'PRIVMSG',
70
+ nick: 'bob',
71
+ user: 'bob',
72
+ host: 'ex.org',
73
+ text: `msg ${i}`,
74
+ };
75
+ store.record(m);
76
+ out.push(m);
77
+ }
78
+ return out;
79
+ }
80
+
81
+ const L = (text: string): RawLine => ({ text });
82
+
83
+ /** Expected replay line for a stored PRIVMSG. */
84
+ function replayLine(m: StoredMessage, displayChan: string): RawLine {
85
+ const stamp = formatServerTime(m.time);
86
+ return {
87
+ text: `@time=${stamp};msgid=${m.msgid} :bob!bob@ex.org PRIVMSG ${displayChan} :${m.text}`,
88
+ };
89
+ }
90
+
91
+ /** Builds the expected BATCH frame for a list of stored messages. */
92
+ function expectedBatch(body: RawLine[], batchId: string, displayChan: string): EffectType[] {
93
+ if (body.length === 0) return [];
94
+ const start = L(`BATCH +${batchId} chathistory ${displayChan}`);
95
+ const end = L(`BATCH -${batchId}`);
96
+ return [Effect.send('c1', [start, ...body, end])];
97
+ }
98
+
99
+ // ============================================================================
100
+ // cap gating
101
+ // ============================================================================
102
+
103
+ describe('chathistoryReducer — cap gating', () => {
104
+ it('rejects with 421 ERR_UNKNOWNCOMMAND when the cap is not negotiated', () => {
105
+ const conn = makeConn('c1', 'alice', []); // no caps
106
+ const store = new InMemoryMessageStore();
107
+ const chan = makeChan();
108
+ const ctx = makeCtx(conn, store);
109
+
110
+ const out = chathistoryReducer(
111
+ chan,
112
+ { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '2'], tags: {} },
113
+ ctx,
114
+ );
115
+
116
+ expect(out.effects).toEqual<EffectType[]>([
117
+ Effect.send('c1', [L(':irc.example.com 421 alice CHATHISTORY :Unknown command')]),
118
+ ]);
119
+ });
120
+
121
+ it('does not query the store when the cap is missing (no leak / no work)', () => {
122
+ let queried = false;
123
+ const spyStore: MessageStore = {
124
+ record(): void {
125
+ /* noop */
126
+ },
127
+ query(): StoredMessage[] {
128
+ queried = true;
129
+ return [];
130
+ },
131
+ recent(): StoredMessage[] {
132
+ queried = true;
133
+ return [];
134
+ },
135
+ hasMsgid(): boolean {
136
+ return true;
137
+ },
138
+ targets(): { chan: string; time: number }[] {
139
+ return [];
140
+ },
141
+ };
142
+ const conn = makeConn('c1', 'alice', []);
143
+ const chan = makeChan();
144
+ const ctx = makeCtx(conn, spyStore);
145
+
146
+ chathistoryReducer(
147
+ chan,
148
+ { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '2'], tags: {} },
149
+ ctx,
150
+ );
151
+
152
+ expect(queried).toBe(false);
153
+ });
154
+ });
155
+
156
+ // ============================================================================
157
+ // LATEST
158
+ // ============================================================================
159
+
160
+ describe('chathistoryReducer — LATEST', () => {
161
+ it('returns the most recent N messages in a chathistory BATCH, oldest first, each tagged @time+msgid', () => {
162
+ const store = new InMemoryMessageStore();
163
+ const seeded = seed(store, '#foo', 5);
164
+ const conn = makeConn();
165
+ const chan = makeChan();
166
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
167
+ const ctx = makeCtx(conn, store);
168
+
169
+ const out = chathistoryReducer(
170
+ chan,
171
+ { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '2'], tags: {} },
172
+ ctx,
173
+ );
174
+
175
+ const body = [
176
+ replayLine(seeded[3] as StoredMessage, '#foo'),
177
+ replayLine(seeded[4] as StoredMessage, '#foo'),
178
+ ];
179
+ expect(out.effects).toEqual<EffectType[]>(expectedBatch(body, 'batch-0', '#foo'));
180
+ });
181
+
182
+ it('honors a known msgid pivot for LATEST (returns up to and including the pivot)', () => {
183
+ const store = new InMemoryMessageStore();
184
+ const seeded = seed(store, '#foo', 5);
185
+ const conn = makeConn();
186
+ const chan = makeChan();
187
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
188
+ const ctx = makeCtx(conn, store);
189
+
190
+ const out = chathistoryReducer(
191
+ chan,
192
+ // Real msgid pivot (m3) exercises the pivotArg !== undefined branch.
193
+ { command: 'CHATHISTORY', params: ['LATEST', '#foo', 'm3', '5'], tags: {} },
194
+ ctx,
195
+ );
196
+
197
+ // Spec: LATEST with a known pivot returns the most recent N messages
198
+ // up to *and including* the pivot, oldest first.
199
+ const body = [
200
+ replayLine(seeded[0] as StoredMessage, '#foo'),
201
+ replayLine(seeded[1] as StoredMessage, '#foo'),
202
+ replayLine(seeded[2] as StoredMessage, '#foo'),
203
+ ];
204
+ expect(out.effects).toEqual<EffectType[]>(expectedBatch(body, 'batch-0', '#foo'));
205
+ });
206
+
207
+ it('uses the channel display name (original case) in replay lines and batch args', () => {
208
+ const store = new InMemoryMessageStore();
209
+ const seeded = seed(store, '#Foo', 1);
210
+ const conn = makeConn();
211
+ const chan = makeChan('#Foo');
212
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
213
+ const ctx = makeCtx(conn, store);
214
+
215
+ const out = chathistoryReducer(
216
+ chan,
217
+ { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '5'], tags: {} },
218
+ ctx,
219
+ );
220
+
221
+ expect(out.effects).toEqual<EffectType[]>(
222
+ expectedBatch([replayLine(seeded[0] as StoredMessage, '#Foo')], 'batch-0', '#Foo'),
223
+ );
224
+ });
225
+
226
+ it('returns fewer than the limit when history is shorter (no error)', () => {
227
+ const store = new InMemoryMessageStore();
228
+ const seeded = seed(store, '#foo', 2);
229
+ const conn = makeConn();
230
+ const chan = makeChan();
231
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
232
+ const ctx = makeCtx(conn, store);
233
+
234
+ const out = chathistoryReducer(
235
+ chan,
236
+ { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '50'], tags: {} },
237
+ ctx,
238
+ );
239
+
240
+ expect(out.effects).toEqual<EffectType[]>(
241
+ expectedBatch(
242
+ [
243
+ replayLine(seeded[0] as StoredMessage, '#foo'),
244
+ replayLine(seeded[1] as StoredMessage, '#foo'),
245
+ ],
246
+ 'batch-0',
247
+ '#foo',
248
+ ),
249
+ );
250
+ });
251
+
252
+ it('emits nothing when the channel has no history (empty batch elided)', () => {
253
+ const store = new InMemoryMessageStore();
254
+ const conn = makeConn();
255
+ const chan = makeChan();
256
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
257
+ const ctx = makeCtx(conn, store);
258
+
259
+ const out = chathistoryReducer(
260
+ chan,
261
+ { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '50'], tags: {} },
262
+ ctx,
263
+ );
264
+
265
+ expect(out.effects).toEqual([]);
266
+ });
267
+ });
268
+
269
+ // ============================================================================
270
+ // BEFORE / AFTER / AROUND / BETWEEN
271
+ // ============================================================================
272
+
273
+ describe('chathistoryReducer — BEFORE / AFTER / AROUND / BETWEEN', () => {
274
+ it('BEFORE returns the N messages preceding the pivot msgid', () => {
275
+ const store = new InMemoryMessageStore();
276
+ const seeded = seed(store, '#foo', 5);
277
+ const conn = makeConn();
278
+ const chan = makeChan();
279
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
280
+ const ctx = makeCtx(conn, store);
281
+
282
+ const out = chathistoryReducer(
283
+ chan,
284
+ { command: 'CHATHISTORY', params: ['BEFORE', '#foo', 'm4', '2'], tags: {} },
285
+ ctx,
286
+ );
287
+
288
+ expect(out.effects).toEqual<EffectType[]>(
289
+ expectedBatch(
290
+ [
291
+ replayLine(seeded[1] as StoredMessage, '#foo'),
292
+ replayLine(seeded[2] as StoredMessage, '#foo'),
293
+ ],
294
+ 'batch-0',
295
+ '#foo',
296
+ ),
297
+ );
298
+ });
299
+
300
+ it('AFTER returns the N messages following the pivot msgid', () => {
301
+ const store = new InMemoryMessageStore();
302
+ const seeded = seed(store, '#foo', 5);
303
+ const conn = makeConn();
304
+ const chan = makeChan();
305
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
306
+ const ctx = makeCtx(conn, store);
307
+
308
+ const out = chathistoryReducer(
309
+ chan,
310
+ { command: 'CHATHISTORY', params: ['AFTER', '#foo', 'm2', '2'], tags: {} },
311
+ ctx,
312
+ );
313
+
314
+ expect(out.effects).toEqual<EffectType[]>(
315
+ expectedBatch(
316
+ [
317
+ replayLine(seeded[2] as StoredMessage, '#foo'),
318
+ replayLine(seeded[3] as StoredMessage, '#foo'),
319
+ ],
320
+ 'batch-0',
321
+ '#foo',
322
+ ),
323
+ );
324
+ });
325
+
326
+ it('AROUND returns messages spanning the pivot', () => {
327
+ const store = new InMemoryMessageStore();
328
+ const seeded = seed(store, '#foo', 5);
329
+ const conn = makeConn();
330
+ const chan = makeChan();
331
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
332
+ const ctx = makeCtx(conn, store);
333
+
334
+ const out = chathistoryReducer(
335
+ chan,
336
+ { command: 'CHATHISTORY', params: ['AROUND', '#foo', 'm3', '3'], tags: {} },
337
+ ctx,
338
+ );
339
+
340
+ expect(out.effects).toEqual<EffectType[]>(
341
+ expectedBatch(
342
+ [
343
+ replayLine(seeded[1] as StoredMessage, '#foo'),
344
+ replayLine(seeded[2] as StoredMessage, '#foo'),
345
+ replayLine(seeded[3] as StoredMessage, '#foo'),
346
+ ],
347
+ 'batch-0',
348
+ '#foo',
349
+ ),
350
+ );
351
+ });
352
+
353
+ it('BETWEEN returns messages after m1 up to and including m2', () => {
354
+ const store = new InMemoryMessageStore();
355
+ const seeded = seed(store, '#foo', 5);
356
+ const conn = makeConn();
357
+ const chan = makeChan();
358
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
359
+ const ctx = makeCtx(conn, store);
360
+
361
+ const out = chathistoryReducer(
362
+ chan,
363
+ { command: 'CHATHISTORY', params: ['BETWEEN', '#foo', 'm1', 'm4'], tags: {} },
364
+ ctx,
365
+ );
366
+
367
+ expect(out.effects).toEqual<EffectType[]>(
368
+ expectedBatch(
369
+ [
370
+ replayLine(seeded[1] as StoredMessage, '#foo'),
371
+ replayLine(seeded[2] as StoredMessage, '#foo'),
372
+ replayLine(seeded[3] as StoredMessage, '#foo'),
373
+ ],
374
+ 'batch-0',
375
+ '#foo',
376
+ ),
377
+ );
378
+ });
379
+
380
+ it('bounds at the start/end return fewer than the limit with no error', () => {
381
+ const store = new InMemoryMessageStore();
382
+ const seeded = seed(store, '#foo', 5);
383
+ const conn = makeConn();
384
+ const chan = makeChan();
385
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
386
+ const ctx = makeCtx(conn, store);
387
+
388
+ const out = chathistoryReducer(
389
+ chan,
390
+ { command: 'CHATHISTORY', params: ['BEFORE', '#foo', 'm1', '10'], tags: {} },
391
+ ctx,
392
+ );
393
+
394
+ expect(out.effects).toEqual([]); // nothing before the first message
395
+ void seeded;
396
+ });
397
+ });
398
+
399
+ // ============================================================================
400
+ // TARGETS
401
+ // ============================================================================
402
+
403
+ describe('chathistoryReducer — TARGETS', () => {
404
+ it('enumerates channels with recent activity for TARGETS * *', () => {
405
+ const store = new InMemoryMessageStore();
406
+ seed(store, '#foo', 1); // time 1000
407
+ store.record({
408
+ msgid: 'bx',
409
+ time: 5_000,
410
+ chan: '#bar',
411
+ command: 'PRIVMSG',
412
+ nick: 'b',
413
+ text: 'x',
414
+ });
415
+ const conn = makeConn();
416
+ const ctx = makeCtx(conn, store, new FakeClock(100_000));
417
+
418
+ const out = chathistoryReducer(
419
+ chan0(),
420
+ { command: 'CHATHISTORY', params: ['TARGETS', '*', '*'], tags: {} },
421
+ ctx,
422
+ );
423
+
424
+ expect(out.effects).toEqual<EffectType[]>([
425
+ Effect.send('c1', [
426
+ L('BATCH +batch-0 chathistory'),
427
+ L(`:irc.example.com CHATHISTORY TARGETS ${formatServerTime(5_000)} #bar`),
428
+ L(`:irc.example.com CHATHISTORY TARGETS ${formatServerTime(1_000)} #foo`),
429
+ L('BATCH -batch-0'),
430
+ ]),
431
+ ]);
432
+ });
433
+
434
+ it('honors an explicit ISO-timestamp window', () => {
435
+ const store = new InMemoryMessageStore();
436
+ seed(store, '#foo', 1); // time 1000
437
+ store.record({
438
+ msgid: 'bx',
439
+ time: 9_000,
440
+ chan: '#bar',
441
+ command: 'PRIVMSG',
442
+ nick: 'b',
443
+ text: 'x',
444
+ });
445
+ const conn = makeConn();
446
+ const ctx = makeCtx(conn, store, new FakeClock(100_000));
447
+
448
+ const sinceIso = formatServerTime(2_000);
449
+ const untilIso = formatServerTime(5_000);
450
+ const out = chathistoryReducer(
451
+ chan0(),
452
+ { command: 'CHATHISTORY', params: ['TARGETS', sinceIso, untilIso], tags: {} },
453
+ ctx,
454
+ );
455
+
456
+ // Only #foo@1000 is outside [2000,5000]; #bar@9000 is outside too → empty.
457
+ expect(out.effects).toEqual([]);
458
+ });
459
+
460
+ it('emits nothing when no channel has activity', () => {
461
+ const store = new InMemoryMessageStore();
462
+ const conn = makeConn();
463
+ const ctx = makeCtx(conn, store);
464
+
465
+ const out = chathistoryReducer(
466
+ chan0(),
467
+ { command: 'CHATHISTORY', params: ['TARGETS', '*', '*'], tags: {} },
468
+ ctx,
469
+ );
470
+
471
+ expect(out.effects).toEqual([]);
472
+ });
473
+ });
474
+
475
+ /** Channel arg is unused for TARGETS; supply a throwaway. */
476
+ function chan0(): ChannelState {
477
+ return makeChan('#0');
478
+ }
479
+
480
+ // ============================================================================
481
+ // error paths
482
+ // ============================================================================
483
+
484
+ describe('chathistoryReducer — error paths', () => {
485
+ it('returns 403 ERR_NOSUCHCHANNEL for a non-existent channel', () => {
486
+ const store = new InMemoryMessageStore();
487
+ const conn = makeConn();
488
+ const ctx = makeCtx(conn, store);
489
+
490
+ const out = chathistoryReducer(
491
+ undefined,
492
+ { command: 'CHATHISTORY', params: ['LATEST', '#nope', '*', '10'], tags: {} },
493
+ ctx,
494
+ );
495
+
496
+ expect(out.effects).toEqual<EffectType[]>([
497
+ Effect.send('c1', [L(':irc.example.com 403 alice #nope :No such channel')]),
498
+ ]);
499
+ });
500
+
501
+ it('returns 461 ERR_NEEDMOREPARAMS for an unknown pivot msgid (BEFORE)', () => {
502
+ const store = new InMemoryMessageStore();
503
+ seed(store, '#foo', 3);
504
+ const conn = makeConn();
505
+ const chan = makeChan();
506
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
507
+ const ctx = makeCtx(conn, store);
508
+
509
+ const out = chathistoryReducer(
510
+ chan,
511
+ { command: 'CHATHISTORY', params: ['BEFORE', '#foo', 'missing', '10'], tags: {} },
512
+ ctx,
513
+ );
514
+
515
+ expect(out.effects).toEqual<EffectType[]>([
516
+ Effect.send('c1', [L(':irc.example.com 461 alice CHATHISTORY :Invalid parameters')]),
517
+ ]);
518
+ });
519
+
520
+ it('returns 461 for an unknown msgid in AFTER', () => {
521
+ const store = new InMemoryMessageStore();
522
+ seed(store, '#foo', 3);
523
+ const conn = makeConn();
524
+ const chan = makeChan();
525
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
526
+ const ctx = makeCtx(conn, store);
527
+
528
+ const out = chathistoryReducer(
529
+ chan,
530
+ { command: 'CHATHISTORY', params: ['AFTER', '#foo', 'ghost', '10'], tags: {} },
531
+ ctx,
532
+ );
533
+
534
+ expect(out.effects).toHaveLength(1);
535
+ expect((out.effects[0] as { lines: RawLine[] }).lines[0]?.text).toContain('461');
536
+ });
537
+
538
+ it('returns 461 for an unknown msgid in BETWEEN (either bound)', () => {
539
+ const store = new InMemoryMessageStore();
540
+ seed(store, '#foo', 3);
541
+ const conn = makeConn();
542
+ const chan = makeChan();
543
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
544
+ const ctx = makeCtx(conn, store);
545
+
546
+ const out = chathistoryReducer(
547
+ chan,
548
+ { command: 'CHATHISTORY', params: ['BETWEEN', '#foo', 'm1', 'ghost'], tags: {} },
549
+ ctx,
550
+ );
551
+
552
+ expect((out.effects[0] as { lines: RawLine[] }).lines[0]?.text).toContain('461');
553
+ });
554
+
555
+ it('returns 461 ERR_NEEDMOREPARAMS when the subcommand is missing', () => {
556
+ const store = new InMemoryMessageStore();
557
+ const conn = makeConn();
558
+ const ctx = makeCtx(conn, store);
559
+
560
+ const out = chathistoryReducer(chan0(), { command: 'CHATHISTORY', params: [], tags: {} }, ctx);
561
+
562
+ expect(out.effects).toEqual<EffectType[]>([
563
+ Effect.send('c1', [L(':irc.example.com 461 alice CHATHISTORY :Not enough parameters')]),
564
+ ]);
565
+ });
566
+
567
+ it('returns 461 when a channel subcommand is missing its target parameter', () => {
568
+ const store = new InMemoryMessageStore();
569
+ const conn = makeConn();
570
+ const ctx = makeCtx(conn, store);
571
+
572
+ const out = chathistoryReducer(
573
+ chan0(),
574
+ { command: 'CHATHISTORY', params: ['LATEST'], tags: {} },
575
+ ctx,
576
+ );
577
+
578
+ expect((out.effects[0] as { lines: RawLine[] }).lines[0]?.text).toContain('461');
579
+ });
580
+
581
+ it('returns 461 for an unknown subcommand', () => {
582
+ const store = new InMemoryMessageStore();
583
+ const conn = makeConn();
584
+ const ctx = makeCtx(conn, store);
585
+
586
+ const out = chathistoryReducer(
587
+ chan0(),
588
+ { command: 'CHATHISTORY', params: ['BOGUS', '#foo', '*', '10'], tags: {} },
589
+ ctx,
590
+ );
591
+
592
+ expect((out.effects[0] as { lines: RawLine[] }).lines[0]?.text).toContain('461');
593
+ });
594
+
595
+ it('returns 461 for an unparseable TARGETS timestamp', () => {
596
+ const store = new InMemoryMessageStore();
597
+ const conn = makeConn();
598
+ const ctx = makeCtx(conn, store);
599
+
600
+ const out = chathistoryReducer(
601
+ chan0(),
602
+ { command: 'CHATHISTORY', params: ['TARGETS', 'not-a-date', '*'], tags: {} },
603
+ ctx,
604
+ );
605
+
606
+ expect((out.effects[0] as { lines: RawLine[] }).lines[0]?.text).toContain('461');
607
+ });
608
+
609
+ it('returns 461 when BETWEEN is missing one of its two msgid bounds', () => {
610
+ const store = new InMemoryMessageStore();
611
+ seed(store, '#foo', 3);
612
+ const conn = makeConn();
613
+ const chan = makeChan();
614
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
615
+ const ctx = makeCtx(conn, store);
616
+
617
+ const out = chathistoryReducer(
618
+ chan,
619
+ { command: 'CHATHISTORY', params: ['BETWEEN', '#foo', 'm1'], tags: {} },
620
+ ctx,
621
+ );
622
+
623
+ expect((out.effects[0] as { lines: RawLine[] }).lines[0]?.text).toContain('461');
624
+ });
625
+
626
+ it('returns 461 when BEFORE is missing its pivot msgid', () => {
627
+ const store = new InMemoryMessageStore();
628
+ seed(store, '#foo', 3);
629
+ const conn = makeConn();
630
+ const chan = makeChan();
631
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
632
+ const ctx = makeCtx(conn, store);
633
+
634
+ const out = chathistoryReducer(
635
+ chan,
636
+ { command: 'CHATHISTORY', params: ['BEFORE', '#foo'], tags: {} },
637
+ ctx,
638
+ );
639
+
640
+ expect((out.effects[0] as { lines: RawLine[] }).lines[0]?.text).toContain('461');
641
+ });
642
+
643
+ it('returns 461 when the limit argument is not a non-negative integer', () => {
644
+ const store = new InMemoryMessageStore();
645
+ seed(store, '#foo', 3);
646
+ const conn = makeConn();
647
+ const chan = makeChan();
648
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
649
+ const ctx = makeCtx(conn, store);
650
+
651
+ const out = chathistoryReducer(
652
+ chan,
653
+ { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', 'abc'], tags: {} },
654
+ ctx,
655
+ );
656
+
657
+ expect((out.effects[0] as { lines: RawLine[] }).lines[0]?.text).toContain('461');
658
+ });
659
+
660
+ it('returns 461 when LATEST is asked to pivot on an unknown msgid', () => {
661
+ const store = new InMemoryMessageStore();
662
+ seed(store, '#foo', 3);
663
+ const conn = makeConn();
664
+ const chan = makeChan();
665
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
666
+ const ctx = makeCtx(conn, store);
667
+
668
+ const out = chathistoryReducer(
669
+ chan,
670
+ { command: 'CHATHISTORY', params: ['LATEST', '#foo', 'ghost', '5'], tags: {} },
671
+ ctx,
672
+ );
673
+
674
+ expect((out.effects[0] as { lines: RawLine[] }).lines[0]?.text).toContain('461');
675
+ });
676
+
677
+ it('returns 461 when TARGETS is missing its until-bound argument', () => {
678
+ const store = new InMemoryMessageStore();
679
+ const conn = makeConn();
680
+ const ctx = makeCtx(conn, store);
681
+
682
+ const out = chathistoryReducer(
683
+ chan0(),
684
+ { command: 'CHATHISTORY', params: ['TARGETS', '*'], tags: {} },
685
+ ctx,
686
+ );
687
+
688
+ expect((out.effects[0] as { lines: RawLine[] }).lines[0]?.text).toContain('461');
689
+ });
690
+ });
691
+
692
+ // ============================================================================
693
+ // secret / private leak prevention
694
+ // ============================================================================
695
+
696
+ describe('chathistoryReducer — +s/+p leak prevention', () => {
697
+ it('returns an empty result (no batch) when the requester is not on a +s channel', () => {
698
+ const store = new InMemoryMessageStore();
699
+ seed(store, '#foo', 3);
700
+ const conn = makeConn();
701
+ const chan = makeChan();
702
+ chan.modes.secret = true;
703
+ // requester NOT a member
704
+ const ctx = makeCtx(conn, store);
705
+
706
+ const out = chathistoryReducer(
707
+ chan,
708
+ { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '10'], tags: {} },
709
+ ctx,
710
+ );
711
+
712
+ expect(out.effects).toEqual([]);
713
+ });
714
+
715
+ it('returns an empty result when the requester is not on a +p channel', () => {
716
+ const store = new InMemoryMessageStore();
717
+ seed(store, '#foo', 3);
718
+ const conn = makeConn();
719
+ const chan = makeChan();
720
+ chan.modes.private = true;
721
+ const ctx = makeCtx(conn, store);
722
+
723
+ const out = chathistoryReducer(
724
+ chan,
725
+ { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '10'], tags: {} },
726
+ ctx,
727
+ );
728
+
729
+ expect(out.effects).toEqual([]);
730
+ });
731
+
732
+ it('serves history to a +s member normally', () => {
733
+ const store = new InMemoryMessageStore();
734
+ const seeded = seed(store, '#foo', 1);
735
+ const conn = makeConn();
736
+ const chan = makeChan();
737
+ chan.modes.secret = true;
738
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
739
+ const ctx = makeCtx(conn, store);
740
+
741
+ const out = chathistoryReducer(
742
+ chan,
743
+ { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '10'], tags: {} },
744
+ ctx,
745
+ );
746
+
747
+ expect(out.effects).toEqual<EffectType[]>(
748
+ expectedBatch([replayLine(seeded[0] as StoredMessage, '#foo')], 'batch-0', '#foo'),
749
+ );
750
+ });
751
+ });
752
+
753
+ // ============================================================================
754
+ // TAGMSG replay formatting
755
+ // ============================================================================
756
+
757
+ describe('chathistoryReducer — TAGMSG replay', () => {
758
+ it('replays a stored TAGMSG without a trailing text parameter', () => {
759
+ const store = new InMemoryMessageStore();
760
+ const tagmsg: StoredMessage = {
761
+ msgid: 't1',
762
+ time: 1_000,
763
+ chan: '#foo',
764
+ command: 'TAGMSG',
765
+ nick: 'bob',
766
+ user: 'bob',
767
+ host: 'ex.org',
768
+ text: '',
769
+ };
770
+ store.record(tagmsg);
771
+ const conn = makeConn();
772
+ const chan = makeChan();
773
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
774
+ const ctx = makeCtx(conn, store);
775
+
776
+ const out = chathistoryReducer(
777
+ chan,
778
+ { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '5'], tags: {} },
779
+ ctx,
780
+ );
781
+
782
+ const stamp = formatServerTime(tagmsg.time);
783
+ expect(out.effects).toEqual<EffectType[]>(
784
+ expectedBatch([L(`@time=${stamp};msgid=t1 :bob!bob@ex.org TAGMSG #foo`)], 'batch-0', '#foo'),
785
+ );
786
+ });
787
+
788
+ it('replays a message whose stored hostmask has only a user (no host)', () => {
789
+ const store = new InMemoryMessageStore();
790
+ const userOnly: StoredMessage = {
791
+ msgid: 'u1',
792
+ time: 1_000,
793
+ chan: '#foo',
794
+ command: 'PRIVMSG',
795
+ nick: 'eve',
796
+ user: 'eve',
797
+ // host omitted → replay line source is `eve!eve`, not `eve!eve@...`
798
+ text: 'hi',
799
+ };
800
+ store.record(userOnly);
801
+ const conn = makeConn();
802
+ const chan = makeChan();
803
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
804
+ const ctx = makeCtx(conn, store);
805
+
806
+ const out = chathistoryReducer(
807
+ chan,
808
+ { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '5'], tags: {} },
809
+ ctx,
810
+ );
811
+
812
+ const stamp = formatServerTime(userOnly.time);
813
+ expect(out.effects).toEqual<EffectType[]>(
814
+ expectedBatch([L(`@time=${stamp};msgid=u1 :eve!eve PRIVMSG #foo :hi`)], 'batch-0', '#foo'),
815
+ );
816
+ });
817
+
818
+ it('replays a message whose stored source is a bare nick (no user, no host)', () => {
819
+ const store = new InMemoryMessageStore();
820
+ const bare: StoredMessage = {
821
+ msgid: 'b1',
822
+ time: 2_000,
823
+ chan: '#foo',
824
+ command: 'PRIVMSG',
825
+ nick: 'webchat',
826
+ // user + host both omitted → replay line source is just `webchat`
827
+ text: 'hello',
828
+ };
829
+ store.record(bare);
830
+ const conn = makeConn();
831
+ const chan = makeChan();
832
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
833
+ const ctx = makeCtx(conn, store);
834
+
835
+ const out = chathistoryReducer(
836
+ chan,
837
+ { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '5'], tags: {} },
838
+ ctx,
839
+ );
840
+
841
+ const stamp = formatServerTime(bare.time);
842
+ expect(out.effects).toEqual<EffectType[]>(
843
+ expectedBatch([L(`@time=${stamp};msgid=b1 :webchat PRIVMSG #foo :hello`)], 'batch-0', '#foo'),
844
+ );
845
+ });
846
+
847
+ it('replays a message whose stored source has a host but no user (cloaked webchat shape)', () => {
848
+ const store = new InMemoryMessageStore();
849
+ const hostOnly: StoredMessage = {
850
+ msgid: 'h1',
851
+ time: 3_000,
852
+ chan: '#foo',
853
+ command: 'PRIVMSG',
854
+ nick: 'guest',
855
+ // user omitted, host defined → exercises the else of `if (m.user !== undefined)`
856
+ host: 'gateway.example.com',
857
+ text: 'hi',
858
+ };
859
+ store.record(hostOnly);
860
+ const conn = makeConn();
861
+ const chan = makeChan();
862
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
863
+ const ctx = makeCtx(conn, store);
864
+
865
+ const out = chathistoryReducer(
866
+ chan,
867
+ { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '5'], tags: {} },
868
+ ctx,
869
+ );
870
+
871
+ const stamp = formatServerTime(hostOnly.time);
872
+ expect(out.effects).toEqual<EffectType[]>(
873
+ expectedBatch(
874
+ [L(`@time=${stamp};msgid=h1 :guest@gateway.example.com PRIVMSG #foo :hi`)],
875
+ 'batch-0',
876
+ '#foo',
877
+ ),
878
+ );
879
+ });
880
+
881
+ it('LATEST without an explicit limit argument uses the default 50 cap', () => {
882
+ const store = new InMemoryMessageStore();
883
+ seed(store, '#foo', 2);
884
+ const conn = makeConn();
885
+ const chan = makeChan();
886
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
887
+ const ctx = makeCtx(conn, store);
888
+
889
+ const out = chathistoryReducer(
890
+ chan,
891
+ // Three params only — exercises `parseLimit(undefined) → DEFAULT_QUERY_LIMIT`.
892
+ { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*'], tags: {} },
893
+ ctx,
894
+ );
895
+
896
+ expect(out.effects).toHaveLength(1);
897
+ const send = out.effects[0] as { lines: RawLine[] };
898
+ // BATCH start + 2 replay lines + BATCH end = 4 lines.
899
+ expect(send.lines).toHaveLength(4);
900
+ });
901
+ });
902
+
903
+ // ============================================================================
904
+ // No MessageStore bound (deployment disabled chathistory persistence)
905
+ // ============================================================================
906
+
907
+ describe('chathistoryReducer — no MessageStore bound', () => {
908
+ /** Builds a Ctx with no `messages` port, exercising the `?? fallback` branches. */
909
+ function makeCtxNoStore(conn: ConnectionState): Ctx {
910
+ return buildCtx({
911
+ serverConfig,
912
+ clock: new FakeClock(10_000),
913
+ ids: new SequentialIdFactory(),
914
+ motd: EmptyMotdProvider,
915
+ connection: conn,
916
+ });
917
+ }
918
+
919
+ it('LATEST on an empty store elides the batch (no replay, no error)', () => {
920
+ const conn = makeConn();
921
+ const chan = makeChan();
922
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
923
+ const ctx = makeCtxNoStore(conn);
924
+
925
+ const out = chathistoryReducer(
926
+ chan,
927
+ { command: 'CHATHISTORY', params: ['LATEST', '#foo', '*', '5'], tags: {} },
928
+ ctx,
929
+ );
930
+
931
+ expect(out.effects).toEqual([]);
932
+ });
933
+
934
+ it('TARGETS on an empty store emits nothing', () => {
935
+ const conn = makeConn();
936
+ const ctx = makeCtxNoStore(conn);
937
+
938
+ const out = chathistoryReducer(
939
+ makeChan(),
940
+ { command: 'CHATHISTORY', params: ['TARGETS', '*', '*'], tags: {} },
941
+ ctx,
942
+ );
943
+
944
+ expect(out.effects).toEqual([]);
945
+ });
946
+
947
+ it('BEFORE on an unbound store returns invalid-params (storeHas fallback)', () => {
948
+ const conn = makeConn();
949
+ const chan = makeChan();
950
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
951
+ const ctx = makeCtxNoStore(conn);
952
+
953
+ const out = chathistoryReducer(
954
+ chan,
955
+ { command: 'CHATHISTORY', params: ['BEFORE', '#foo', 'm1', '5'], tags: {} },
956
+ ctx,
957
+ );
958
+
959
+ // No store → storeHas returns false → invalid-params 461 line.
960
+ expect((out.effects[0] as { lines: RawLine[] }).lines[0]?.text).toContain('461');
961
+ });
962
+ });
963
+
964
+ // ============================================================================
965
+ // Unregistered connection (numericErr fallback to `*`)
966
+ // ============================================================================
967
+
968
+ describe('chathistoryReducer — unregistered connection', () => {
969
+ it('formats numeric errors with `*` when the connection has no nick', () => {
970
+ const store = new InMemoryMessageStore();
971
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
972
+ // Caps negotiated but no NICK yet — exercises numericErr's `?? '*'` branch.
973
+ conn.caps.add('draft/chathistory');
974
+ conn.caps.add('server-time');
975
+ conn.caps.add('message-tags');
976
+ conn.caps.add('batch');
977
+ const ctx = buildCtx({
978
+ serverConfig,
979
+ clock: new FakeClock(10_000),
980
+ ids: new SequentialIdFactory(),
981
+ motd: EmptyMotdProvider,
982
+ messages: store,
983
+ connection: conn,
984
+ });
985
+
986
+ const out = chathistoryReducer(
987
+ makeChan(),
988
+ { command: 'CHATHISTORY', params: ['LATEST'], tags: {} },
989
+ ctx,
990
+ );
991
+
992
+ expect((out.effects[0] as { lines: RawLine[] }).lines[0]?.text).toBe(
993
+ ':irc.example.com 461 * CHATHISTORY :Not enough parameters',
994
+ );
995
+ });
996
+ });