serverless-ircd 0.7.0 → 0.8.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 (135) hide show
  1. package/.github/workflows/ci.yml +3 -3
  2. package/.gitmodules +1 -1
  3. package/CHANGELOG.md +273 -29
  4. package/README.md +155 -55
  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-worker/package.json +1 -1
  10. package/apps/cf-worker/src/worker.ts +4 -4
  11. package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
  12. package/apps/cf-worker/tests/smoke.test.ts +5 -5
  13. package/apps/cf-worker/wrangler.test.toml +6 -6
  14. package/apps/cf-worker/wrangler.toml +7 -5
  15. package/apps/local-cli/package.json +1 -1
  16. package/apps/local-cli/src/config-loader.ts +8 -0
  17. package/apps/local-cli/src/main.ts +16 -0
  18. package/apps/local-cli/src/server.ts +1 -0
  19. package/apps/local-cli/tests/config-loader.test.ts +14 -0
  20. package/apps/web/landing/index.html +14 -16
  21. package/apps/web/package.json +2 -2
  22. package/apps/web/scripts/build.mjs +23 -16
  23. package/apps/web/src/build-env.ts +1 -1
  24. package/apps/web/src/config-schema.ts +6 -6
  25. package/apps/web/tests/build-smoke.test.ts +14 -14
  26. package/apps/web/tests/config-schema.test.ts +1 -1
  27. package/docs/AWS-Deployment.md +21 -8
  28. package/docs/Cloudflare-Deployment-Guide.md +22 -6
  29. package/docs/PlanExtensions.md +113 -3
  30. package/docs/Release-Process.md +23 -13
  31. package/docs/Services.md +546 -0
  32. package/docs/WebClientGuide.md +32 -31
  33. package/package.json +2 -2
  34. package/packages/aws-adapter/package.json +1 -1
  35. package/packages/aws-adapter/src/aws-runtime.ts +5 -0
  36. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
  37. package/packages/aws-adapter/src/config-loader.ts +11 -0
  38. package/packages/aws-adapter/src/connection-counter.ts +89 -0
  39. package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
  40. package/packages/aws-adapter/src/handlers/connect.ts +55 -51
  41. package/packages/aws-adapter/src/handlers/default.ts +36 -4
  42. package/packages/aws-adapter/src/handlers/index.ts +15 -0
  43. package/packages/aws-adapter/src/handlers/nlb-stream.ts +15 -0
  44. package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
  45. package/packages/aws-adapter/src/index.ts +4 -0
  46. package/packages/aws-adapter/src/stats.ts +6 -1
  47. package/packages/aws-adapter/src/tables.ts +34 -4
  48. package/packages/aws-adapter/tests/aws-harness.ts +3 -0
  49. package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
  50. package/packages/aws-adapter/tests/connect.test.ts +158 -32
  51. package/packages/aws-adapter/tests/connection-counter.test.ts +127 -0
  52. package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
  53. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
  54. package/packages/aws-adapter/tests/handlers.test.ts +105 -3
  55. package/packages/aws-adapter/tests/tables.test.ts +6 -1
  56. package/packages/cf-adapter/package.json +1 -1
  57. package/packages/cf-adapter/src/config-loader.ts +11 -0
  58. package/packages/cf-adapter/src/connection-do.ts +112 -2
  59. package/packages/cf-adapter/src/d1-services-store.ts +703 -0
  60. package/packages/cf-adapter/src/env.ts +8 -0
  61. package/packages/cf-adapter/src/index.ts +5 -0
  62. package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
  63. package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
  64. package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
  65. package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
  66. package/packages/cf-adapter/tests/serialize.test.ts +1 -0
  67. package/packages/in-memory-runtime/package.json +1 -1
  68. package/packages/irc-core/package.json +1 -1
  69. package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
  70. package/packages/irc-core/src/commands/account-auth.ts +172 -0
  71. package/packages/irc-core/src/commands/chanserv.ts +882 -0
  72. package/packages/irc-core/src/commands/hostserv.ts +487 -0
  73. package/packages/irc-core/src/commands/index.ts +12 -0
  74. package/packages/irc-core/src/commands/join.ts +164 -8
  75. package/packages/irc-core/src/commands/markread.ts +202 -0
  76. package/packages/irc-core/src/commands/memoserv.ts +319 -0
  77. package/packages/irc-core/src/commands/mode.ts +96 -4
  78. package/packages/irc-core/src/commands/nickserv.ts +390 -0
  79. package/packages/irc-core/src/commands/oper.ts +18 -1
  80. package/packages/irc-core/src/commands/operserv.ts +346 -0
  81. package/packages/irc-core/src/commands/pre-away.ts +3 -1
  82. package/packages/irc-core/src/commands/privmsg.ts +42 -0
  83. package/packages/irc-core/src/commands/read-marker.ts +8 -8
  84. package/packages/irc-core/src/commands/registration.ts +61 -6
  85. package/packages/irc-core/src/commands/sasl.ts +18 -49
  86. package/packages/irc-core/src/commands/tagmsg.ts +41 -6
  87. package/packages/irc-core/src/commands/topic.ts +37 -0
  88. package/packages/irc-core/src/config.ts +36 -5
  89. package/packages/irc-core/src/effects.ts +56 -1
  90. package/packages/irc-core/src/ports.ts +1653 -84
  91. package/packages/irc-core/src/protocol/numerics.ts +8 -0
  92. package/packages/irc-core/src/state/channel.ts +21 -1
  93. package/packages/irc-core/src/state/connection.ts +25 -1
  94. package/packages/irc-core/src/types.ts +48 -12
  95. package/packages/irc-core/tests/commands/chanserv.test.ts +1668 -0
  96. package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
  97. package/packages/irc-core/tests/commands/hostserv.test.ts +935 -0
  98. package/packages/irc-core/tests/commands/join.test.ts +393 -1
  99. package/packages/irc-core/tests/commands/markread.test.ts +361 -0
  100. package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
  101. package/packages/irc-core/tests/commands/mode.test.ts +381 -2
  102. package/packages/irc-core/tests/commands/nickserv.test.ts +807 -0
  103. package/packages/irc-core/tests/commands/oper.test.ts +13 -0
  104. package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
  105. package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
  106. package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
  107. package/packages/irc-core/tests/commands/registration.test.ts +788 -14
  108. package/packages/irc-core/tests/commands/sasl.test.ts +185 -12
  109. package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
  110. package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
  111. package/packages/irc-core/tests/commands/topic.test.ts +94 -2
  112. package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
  113. package/packages/irc-core/tests/config.test.ts +49 -5
  114. package/packages/irc-core/tests/effects.test.ts +19 -0
  115. package/packages/irc-core/tests/message-store.test.ts +63 -0
  116. package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
  117. package/packages/irc-core/tests/services-store.test.ts +1289 -0
  118. package/packages/irc-core/tests/state/channel.test.ts +3 -0
  119. package/packages/irc-server/package.json +1 -1
  120. package/packages/irc-server/src/actor.ts +71 -16
  121. package/packages/irc-server/src/dispatch.ts +94 -7
  122. package/packages/irc-server/src/routing.ts +19 -0
  123. package/packages/irc-server/tests/actor.test.ts +623 -12
  124. package/packages/irc-server/tests/dispatch.test.ts +270 -2
  125. package/packages/irc-server/tests/routing.test.ts +6 -0
  126. package/packages/irc-test-support/package.json +1 -1
  127. package/packages/irc-test-support/src/in-memory-harness.ts +29 -3
  128. package/packages/irc-test-support/src/index.ts +1 -0
  129. package/packages/irc-test-support/tests/in-memory-harness.test.ts +32 -0
  130. package/tools/ci-hardening/package.json +1 -1
  131. package/tools/load-test/package.json +1 -1
  132. package/tools/tcp-ws-forwarder/package.json +1 -1
  133. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +2 -2
  134. package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
  135. package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
@@ -0,0 +1,361 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { markreadChannelReducer } from '../../src/commands/markread';
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
+ InMemoryServicesStore,
10
+ type MessageStore,
11
+ SequentialIdFactory,
12
+ type ServicesStore,
13
+ type StoredMessage,
14
+ } from '../../src/ports';
15
+ import { formatServerTime } from '../../src/protocol/outbound';
16
+ import { type ChannelState, createChannel } from '../../src/state/channel';
17
+ import { type ConnectionState, createConnection } from '../../src/state/connection';
18
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
19
+
20
+ const serverConfig: ServerConfig = {
21
+ serverName: 'irc.example.com',
22
+ networkName: 'ExampleNet',
23
+ maxChannelsPerUser: 30,
24
+ maxTargetsPerCommand: 10,
25
+ maxListEntries: 50,
26
+ nickLen: 30,
27
+ channelLen: 50,
28
+ topicLen: 390,
29
+ quitMessage: 'Client Quit',
30
+ };
31
+
32
+ function makeCtx(
33
+ conn: ConnectionState,
34
+ clock = new FakeClock(10_000),
35
+ messages?: MessageStore,
36
+ services?: ServicesStore,
37
+ ): Ctx {
38
+ return buildCtx({
39
+ serverConfig,
40
+ clock,
41
+ ids: new SequentialIdFactory(),
42
+ motd: EmptyMotdProvider,
43
+ connection: conn,
44
+ ...(messages !== undefined ? { messages } : {}),
45
+ ...(services !== undefined ? { services } : {}),
46
+ });
47
+ }
48
+
49
+ function makeConn(id = 'c1', nick = 'alice', account?: string): ConnectionState {
50
+ const s = createConnection({ id, connectedSince: 0 });
51
+ s.nick = nick;
52
+ s.user = nick;
53
+ s.host = 'example.com';
54
+ s.realname = nick.charAt(0).toUpperCase() + nick.slice(1);
55
+ s.registration = 'registered';
56
+ s.caps.add('draft/read-marker');
57
+ if (account !== undefined) s.account = account;
58
+ return s;
59
+ }
60
+
61
+ function makeChan(name = '#foo'): ChannelState {
62
+ const chan = createChannel({ name, nameLower: name.toLowerCase(), createdAt: 0 });
63
+ return chan;
64
+ }
65
+
66
+ function addMember(chan: ChannelState, connId: string, nick: string): void {
67
+ chan.members.set(connId, { conn: connId, nick, op: false, voice: false });
68
+ }
69
+
70
+ /** Seeds N chronological messages at 1s, 2s, … into `store` for `chan`. */
71
+ function seed(store: MessageStore, chan: string, count: number): StoredMessage[] {
72
+ const out: StoredMessage[] = [];
73
+ for (let i = 1; i <= count; i++) {
74
+ const m: StoredMessage = {
75
+ msgid: `m${i}`,
76
+ time: i * 1_000,
77
+ chan: chan.toLowerCase(),
78
+ command: 'PRIVMSG',
79
+ nick: 'bob',
80
+ user: 'bob',
81
+ host: 'ex.org',
82
+ text: `msg ${i}`,
83
+ };
84
+ store.record(m);
85
+ out.push(m);
86
+ }
87
+ return out;
88
+ }
89
+
90
+ const L = (text: string): RawLine => ({ text });
91
+ const ts = (ms: number): string => `timestamp=${formatServerTime(ms)}`;
92
+ const READ_MARKER_FAIL = (code: string, desc: string): RawLine =>
93
+ L(`:irc.example.com FAIL MARKREAD ${code} :${desc}`);
94
+ const MARKREAD_REPLY = (target: string, timestamp: string): RawLine =>
95
+ L(`:irc.example.com MARKREAD ${target} ${timestamp}`);
96
+
97
+ // ============================================================================
98
+ // cap gating
99
+ // ============================================================================
100
+ describe('markreadChannelReducer — cap gating', () => {
101
+ it('emits 421 ERR_UNKNOWNCOMMAND when draft/read-marker was not negotiated', () => {
102
+ const chan = makeChan();
103
+ addMember(chan, 'c1', 'alice');
104
+ const conn = makeConn();
105
+ conn.caps.delete('draft/read-marker');
106
+ const ctx = makeCtx(conn);
107
+
108
+ const out = markreadChannelReducer(
109
+ chan,
110
+ { command: 'MARKREAD', params: ['#foo', ts(2_000)], tags: {} },
111
+ ctx,
112
+ );
113
+
114
+ expect(out.effects).toEqual<EffectType[]>([
115
+ Effect.send('c1', [L(':irc.example.com 421 alice MARKREAD :Unknown command')]),
116
+ ]);
117
+ });
118
+ });
119
+
120
+ // ============================================================================
121
+ // missing / invalid parameters
122
+ // ============================================================================
123
+ describe('markreadChannelReducer — parameter errors', () => {
124
+ it('FAILs with NEED_MORE_PARAMS when the target is missing', () => {
125
+ const chan = makeChan();
126
+ addMember(chan, 'c1', 'alice');
127
+ const ctx = makeCtx(makeConn());
128
+
129
+ const out = markreadChannelReducer(chan, { command: 'MARKREAD', params: [], tags: {} }, ctx);
130
+
131
+ expect(out.effects).toEqual<EffectType[]>([
132
+ Effect.send('c1', [READ_MARKER_FAIL('NEED_MORE_PARAMS', 'Missing parameters')]),
133
+ ]);
134
+ });
135
+
136
+ it('FAILs with INVALID_PARAMS when the set timestamp is the literal *', () => {
137
+ const chan = makeChan();
138
+ addMember(chan, 'c1', 'alice');
139
+ const store = new InMemoryMessageStore();
140
+ seed(store, '#foo', 3);
141
+ const ctx = makeCtx(makeConn(), new FakeClock(10_000), store);
142
+
143
+ const out = markreadChannelReducer(
144
+ chan,
145
+ { command: 'MARKREAD', params: ['#foo', '*'], tags: {} },
146
+ ctx,
147
+ );
148
+
149
+ expect(out.effects).toEqual<EffectType[]>([
150
+ Effect.send('c1', [READ_MARKER_FAIL('INVALID_PARAMS', 'Invalid parameters')]),
151
+ ]);
152
+ });
153
+
154
+ it('FAILs with INVALID_PARAMS when the timestamp param is malformed', () => {
155
+ const chan = makeChan();
156
+ addMember(chan, 'c1', 'alice');
157
+ const store = new InMemoryMessageStore();
158
+ seed(store, '#foo', 3);
159
+ const ctx = makeCtx(makeConn(), new FakeClock(10_000), store);
160
+
161
+ const out = markreadChannelReducer(
162
+ chan,
163
+ { command: 'MARKREAD', params: ['#foo', 'not-a-timestamp'], tags: {} },
164
+ ctx,
165
+ );
166
+
167
+ expect(out.effects).toEqual<EffectType[]>([
168
+ Effect.send('c1', [READ_MARKER_FAIL('INVALID_PARAMS', 'Invalid parameters')]),
169
+ ]);
170
+ });
171
+ });
172
+
173
+ // ============================================================================
174
+ // GET command (MARKREAD <target>)
175
+ // ============================================================================
176
+ describe('markreadChannelReducer — GET command', () => {
177
+ it('replies with * when no marker is stored for the channel', () => {
178
+ const chan = makeChan();
179
+ addMember(chan, 'c1', 'alice');
180
+ const store = new InMemoryMessageStore();
181
+ seed(store, '#foo', 3);
182
+ const ctx = makeCtx(makeConn(), new FakeClock(10_000), store);
183
+
184
+ const out = markreadChannelReducer(
185
+ chan,
186
+ { command: 'MARKREAD', params: ['#foo'], tags: {} },
187
+ ctx,
188
+ );
189
+
190
+ expect(out.effects).toEqual<EffectType[]>([Effect.send('c1', [MARKREAD_REPLY('#foo', '*')])]);
191
+ });
192
+
193
+ it('replies with the stored marker message timestamp', () => {
194
+ const chan = makeChan();
195
+ addMember(chan, 'c1', 'alice');
196
+ const store = new InMemoryMessageStore();
197
+ seed(store, '#foo', 3);
198
+ const conn = makeConn();
199
+ conn.lastReadMarkers = new Map([['#foo', 'm2']]);
200
+ const ctx = makeCtx(conn, new FakeClock(10_000), store);
201
+
202
+ const out = markreadChannelReducer(
203
+ chan,
204
+ { command: 'MARKREAD', params: ['#foo'], tags: {} },
205
+ ctx,
206
+ );
207
+
208
+ expect(out.effects).toEqual<EffectType[]>([
209
+ Effect.send('c1', [MARKREAD_REPLY('#foo', ts(2_000))]),
210
+ ]);
211
+ });
212
+ });
213
+
214
+ // ============================================================================
215
+ // SET command (MARKREAD <target> <timestamp>)
216
+ // ============================================================================
217
+ describe('markreadChannelReducer — SET command', () => {
218
+ it('translates the timestamp to a msgid, persists it, and echoes the resolved timestamp', () => {
219
+ const chan = makeChan();
220
+ addMember(chan, 'c1', 'alice');
221
+ const store = new InMemoryMessageStore();
222
+ seed(store, '#foo', 3);
223
+ const conn = makeConn();
224
+ const ctx = makeCtx(conn, new FakeClock(10_000), store);
225
+
226
+ const out = markreadChannelReducer(
227
+ chan,
228
+ { command: 'MARKREAD', params: ['#foo', ts(2_000)], tags: {} },
229
+ ctx,
230
+ );
231
+
232
+ expect(out.effects).toEqual<EffectType[]>([
233
+ Effect.send('c1', [MARKREAD_REPLY('#foo', ts(2_000))]),
234
+ ]);
235
+ expect(conn.lastReadMarkers?.get('#foo')).toBe('m2');
236
+ });
237
+
238
+ it('resolves an in-between timestamp to the most recent message at or before it', () => {
239
+ const chan = makeChan();
240
+ addMember(chan, 'c1', 'alice');
241
+ const store = new InMemoryMessageStore();
242
+ seed(store, '#foo', 3); // m1=1s, m2=2s, m3=3s
243
+ const conn = makeConn();
244
+ const ctx = makeCtx(conn, new FakeClock(10_000), store);
245
+
246
+ // 2.5s sits between m2 and m3; resolves to m2.
247
+ const out = markreadChannelReducer(
248
+ chan,
249
+ { command: 'MARKREAD', params: ['#foo', ts(2_500)], tags: {} },
250
+ ctx,
251
+ );
252
+
253
+ expect(out.effects).toEqual<EffectType[]>([
254
+ Effect.send('c1', [MARKREAD_REPLY('#foo', ts(2_000))]),
255
+ ]);
256
+ expect(conn.lastReadMarkers?.get('#foo')).toBe('m2');
257
+ });
258
+
259
+ it('never moves the marker backwards: an older-or-equal timestamp replies with the stored value', () => {
260
+ const chan = makeChan();
261
+ addMember(chan, 'c1', 'alice');
262
+ const store = new InMemoryMessageStore();
263
+ seed(store, '#foo', 3);
264
+ const conn = makeConn();
265
+ conn.lastReadMarkers = new Map([['#foo', 'm3']]); // already read up to 3s
266
+ const ctx = makeCtx(conn, new FakeClock(10_000), store);
267
+
268
+ const out = markreadChannelReducer(
269
+ chan,
270
+ { command: 'MARKREAD', params: ['#foo', ts(2_000)], tags: {} }, // older
271
+ ctx,
272
+ );
273
+
274
+ // Replies with the newer stored value; marker is not regressed.
275
+ expect(out.effects).toEqual<EffectType[]>([
276
+ Effect.send('c1', [MARKREAD_REPLY('#foo', ts(3_000))]),
277
+ ]);
278
+ expect(conn.lastReadMarkers?.get('#foo')).toBe('m3');
279
+ });
280
+
281
+ it('replies with the current marker (no advance) when the timestamp matches no retained message', () => {
282
+ const chan = makeChan();
283
+ addMember(chan, 'c1', 'alice');
284
+ const store = new InMemoryMessageStore();
285
+ seed(store, '#foo', 3); // earliest retained is 1s
286
+ const conn = makeConn();
287
+ conn.lastReadMarkers = new Map([['#foo', 'm2']]);
288
+ const ctx = makeCtx(conn, new FakeClock(10_000), store);
289
+
290
+ // 500ms predates every retained message; cannot translate.
291
+ const out = markreadChannelReducer(
292
+ chan,
293
+ { command: 'MARKREAD', params: ['#foo', ts(500)], tags: {} },
294
+ ctx,
295
+ );
296
+
297
+ expect(out.effects).toEqual<EffectType[]>([
298
+ Effect.send('c1', [MARKREAD_REPLY('#foo', ts(2_000))]),
299
+ ]);
300
+ expect(conn.lastReadMarkers?.get('#foo')).toBe('m2');
301
+ });
302
+
303
+ it('persists the marker through ServicesStore for an identified connection', () => {
304
+ const chan = makeChan();
305
+ addMember(chan, 'c1', 'alice');
306
+ const store = new InMemoryMessageStore();
307
+ seed(store, '#foo', 3);
308
+ const services = new InMemoryServicesStore();
309
+ const conn = makeConn('c1', 'alice', 'alice');
310
+ const ctx = makeCtx(conn, new FakeClock(10_000), store, services);
311
+
312
+ markreadChannelReducer(
313
+ chan,
314
+ { command: 'MARKREAD', params: ['#foo', ts(2_000)], tags: {} },
315
+ ctx,
316
+ );
317
+
318
+ expect(services.listReadMarkers('alice')).toEqual([
319
+ { account: 'alice', channel: '#foo', msgid: 'm2' },
320
+ ]);
321
+ });
322
+ });
323
+
324
+ // ============================================================================
325
+ // graceful degradation + membership
326
+ // ============================================================================
327
+ describe('markreadChannelReducer — degradation + membership', () => {
328
+ it('SET without a bound MessageStore replies with the current marker (no advance)', () => {
329
+ const chan = makeChan();
330
+ addMember(chan, 'c1', 'alice');
331
+ const conn = makeConn();
332
+ // No messages store: no way to translate timestamp -> msgid.
333
+ const ctx = makeCtx(conn);
334
+
335
+ const out = markreadChannelReducer(
336
+ chan,
337
+ { command: 'MARKREAD', params: ['#foo', ts(2_000)], tags: {} },
338
+ ctx,
339
+ );
340
+
341
+ expect(out.effects).toEqual<EffectType[]>([Effect.send('c1', [MARKREAD_REPLY('#foo', '*')])]);
342
+ expect(conn.lastReadMarkers).toBeUndefined();
343
+ });
344
+
345
+ it('is silent (no reply, no state change) for a non-member on a +n channel', () => {
346
+ const chan = makeChan();
347
+ chan.modes.noExternal = true;
348
+ // c1 is NOT a member.
349
+ const store = new InMemoryMessageStore();
350
+ seed(store, '#foo', 3);
351
+ const ctx = makeCtx(makeConn(), new FakeClock(10_000), store);
352
+
353
+ const out = markreadChannelReducer(
354
+ chan,
355
+ { command: 'MARKREAD', params: ['#foo', ts(2_000)], tags: {} },
356
+ ctx,
357
+ );
358
+
359
+ expect(out.effects).toEqual<EffectType[]>([]);
360
+ });
361
+ });