serverless-ircd 0.2.0 → 0.3.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 (134) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +28 -19
  3. package/apps/cf-worker/package.json +1 -1
  4. package/apps/cf-worker/scripts/smoke.mjs +0 -0
  5. package/apps/cf-worker/src/worker.ts +8 -2
  6. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  7. package/apps/cf-worker/wrangler.test.toml +5 -1
  8. package/apps/cf-worker/wrangler.toml +41 -17
  9. package/apps/local-cli/package.json +1 -1
  10. package/apps/local-cli/src/config-loader.ts +1 -0
  11. package/apps/local-cli/src/main.ts +1 -1
  12. package/apps/local-cli/src/server.ts +28 -2
  13. package/apps/local-cli/tests/e2e.test.ts +52 -0
  14. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  15. package/docs/ADR-002-location-of-authority.md +82 -0
  16. package/docs/ADR-003-durable-object-sharding.md +93 -0
  17. package/docs/ADR-004-dynamodb-schema.md +96 -0
  18. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  19. package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
  20. package/docs/ADR-007-deterministic-ports.md +82 -0
  21. package/docs/ADR-008-monorepo-tooling.md +60 -0
  22. package/docs/AWS-Adapter-Architecture.md +6 -4
  23. package/docs/AWS-Deployment.md +86 -7
  24. package/docs/Cloudflare-Deployment-Guide.md +5 -5
  25. package/docs/Home.md +11 -1
  26. package/docs/Release-Process.md +9 -6
  27. package/package.json +14 -15
  28. package/packages/aws-adapter/package.json +1 -1
  29. package/packages/aws-adapter/src/account-store.ts +121 -0
  30. package/packages/aws-adapter/src/aws-runtime.ts +118 -30
  31. package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
  32. package/packages/aws-adapter/src/config-loader.ts +30 -0
  33. package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
  34. package/packages/aws-adapter/src/handlers/default.ts +8 -0
  35. package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
  36. package/packages/aws-adapter/src/handlers/index.ts +51 -6
  37. package/packages/aws-adapter/src/index.ts +7 -0
  38. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
  39. package/packages/aws-adapter/tests/account-store.test.ts +280 -0
  40. package/packages/aws-adapter/tests/config-loader.test.ts +55 -0
  41. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
  42. package/packages/aws-adapter/tests/handlers.test.ts +46 -0
  43. package/packages/cf-adapter/package.json +1 -1
  44. package/packages/cf-adapter/src/cf-runtime.ts +42 -22
  45. package/packages/cf-adapter/src/channel-do.ts +40 -1
  46. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  47. package/packages/cf-adapter/src/config-loader.ts +29 -0
  48. package/packages/cf-adapter/src/connection-do.ts +85 -0
  49. package/packages/cf-adapter/src/env.ts +27 -1
  50. package/packages/cf-adapter/src/index.ts +2 -1
  51. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  52. package/packages/cf-adapter/tests/connection-do.test.ts +40 -0
  53. package/packages/cf-adapter/tests/worker/main.ts +2 -1
  54. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  55. package/packages/cf-adapter/wrangler.test.toml +10 -1
  56. package/packages/in-memory-runtime/package.json +1 -1
  57. package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
  58. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
  59. package/packages/irc-core/package.json +3 -2
  60. package/packages/irc-core/src/case-fold.ts +64 -0
  61. package/packages/irc-core/src/commands/index.ts +2 -0
  62. package/packages/irc-core/src/commands/invite.ts +6 -9
  63. package/packages/irc-core/src/commands/isupport.ts +1 -1
  64. package/packages/irc-core/src/commands/join.ts +4 -3
  65. package/packages/irc-core/src/commands/kick.ts +4 -11
  66. package/packages/irc-core/src/commands/list.ts +5 -4
  67. package/packages/irc-core/src/commands/mode.ts +5 -9
  68. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  69. package/packages/irc-core/src/commands/motd.ts +6 -110
  70. package/packages/irc-core/src/commands/oper.ts +151 -0
  71. package/packages/irc-core/src/commands/registration.ts +8 -7
  72. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  73. package/packages/irc-core/src/config.ts +26 -3
  74. package/packages/irc-core/src/index.ts +2 -0
  75. package/packages/irc-core/src/ports.ts +68 -4
  76. package/packages/irc-core/src/types.ts +22 -0
  77. package/packages/irc-core/tests/account-store.test.ts +88 -0
  78. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  79. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  80. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  81. package/packages/irc-core/tests/commands/registration.test.ts +106 -14
  82. package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
  83. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  84. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  85. package/packages/irc-core/tests/config.test.ts +75 -0
  86. package/packages/irc-server/package.json +1 -1
  87. package/packages/irc-server/src/actor.ts +24 -0
  88. package/packages/irc-server/src/routing.ts +9 -0
  89. package/packages/irc-server/tests/actor.test.ts +220 -1
  90. package/packages/irc-server/tests/routing.test.ts +3 -0
  91. package/packages/irc-test-support/package.json +1 -1
  92. package/packages/irc-test-support/src/in-memory-harness.ts +5 -4
  93. package/pnpm-workspace.yaml +1 -0
  94. package/tools/seed-aws-accounts.ts +80 -0
  95. package/AGENTS.md +0 -5
  96. package/MOTD.txt +0 -3
  97. package/PLAN-FIXES.md +0 -358
  98. package/PLAN.md +0 -420
  99. package/dashboards/cloudwatch-irc.json +0 -139
  100. package/progress.md +0 -107
  101. package/tickets.md +0 -2485
  102. package/webircgateway/LICENSE +0 -201
  103. package/webircgateway/Makefile +0 -44
  104. package/webircgateway/README.md +0 -134
  105. package/webircgateway/config.conf.example +0 -135
  106. package/webircgateway/go.mod +0 -16
  107. package/webircgateway/go.sum +0 -89
  108. package/webircgateway/main.go +0 -118
  109. package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
  110. package/webircgateway/pkg/identd/identd.go +0 -86
  111. package/webircgateway/pkg/identd/rpcclient.go +0 -59
  112. package/webircgateway/pkg/irc/isupport.go +0 -56
  113. package/webircgateway/pkg/irc/message.go +0 -217
  114. package/webircgateway/pkg/irc/state.go +0 -79
  115. package/webircgateway/pkg/proxy/proxy.go +0 -129
  116. package/webircgateway/pkg/proxy/server.go +0 -237
  117. package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
  118. package/webircgateway/pkg/webircgateway/client.go +0 -741
  119. package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
  120. package/webircgateway/pkg/webircgateway/config.go +0 -385
  121. package/webircgateway/pkg/webircgateway/gateway.go +0 -278
  122. package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
  123. package/webircgateway/pkg/webircgateway/hooks.go +0 -152
  124. package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
  125. package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
  126. package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
  127. package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
  128. package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
  129. package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
  130. package/webircgateway/pkg/webircgateway/utils.go +0 -147
  131. package/webircgateway/plugins/example/plugin.go +0 -11
  132. package/webircgateway/plugins/stats/plugin.go +0 -52
  133. package/webircgateway/staticcheck.conf +0 -1
  134. package/webircgateway/webircgateway.svg +0 -3
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
+ import { operReducer } from '../../src/commands/oper';
2
3
  import { whoReducer } from '../../src/commands/who';
3
4
  import { Effect } from '../../src/effects';
4
5
  import type { Effect as EffectType, RawLine } from '../../src/effects';
@@ -10,6 +11,10 @@ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
10
11
  const serverConfig: ServerConfig = {
11
12
  serverName: 'irc.example.com',
12
13
  networkName: 'ExampleNet',
14
+ operCreds: [
15
+ { user: 'alice', password: 'oppass' },
16
+ { user: 'bob', password: 'oppass' },
17
+ ],
13
18
  maxChannelsPerUser: 30,
14
19
  maxTargetsPerCommand: 10,
15
20
  maxListEntries: 50,
@@ -19,6 +24,13 @@ const serverConfig: ServerConfig = {
19
24
  quitMessage: 'Client Quit',
20
25
  };
21
26
 
27
+ /** Grants oper to a connection via the real OPER command flow. */
28
+ function grantOper(conn: ConnectionState): void {
29
+ const ctx = makeCtx(conn);
30
+ const user = conn.nick ?? 'oper';
31
+ operReducer(conn, { command: 'OPER', params: [user, 'oppass'], tags: {} }, ctx);
32
+ }
33
+
22
34
  function makeCtx(conn: ConnectionState, clock = new FakeClock(1_000)): Ctx {
23
35
  return buildCtx({
24
36
  serverConfig,
@@ -129,9 +141,11 @@ describe('whoReducer — channel target', () => {
129
141
  addMember(chan, 'c1', 'alice');
130
142
  const conn = makeConn();
131
143
  const ctx = makeCtx(conn);
132
- const connStates = new Map<string, ConnectionState>([
133
- ['c1', makeConnState('c1', 'alice', { oper: true })],
134
- ]);
144
+ const alice = makeConnState('c1', 'alice');
145
+ // Grant oper via the real OPER command flow, not by poking state.
146
+ grantOper(alice);
147
+ expect(alice.userModes.oper).toBe(true);
148
+ const connStates = new Map<string, ConnectionState>([['c1', alice]]);
135
149
 
136
150
  const out = whoReducer(chan, connStates, { command: 'WHO', params: ['#foo'] }, ctx);
137
151
 
@@ -333,9 +347,13 @@ describe('whoReducer — operators-only filter', () => {
333
347
  addMember(chan, 'c2', 'bob');
334
348
  const conn = makeConn();
335
349
  const ctx = makeCtx(conn);
350
+ const bob = makeConnState('c2', 'bob');
351
+ // Grant oper via the real OPER command flow, not by poking state.
352
+ grantOper(bob);
353
+ expect(bob.userModes.oper).toBe(true);
336
354
  const connStates = new Map<string, ConnectionState>([
337
355
  ['c1', makeConnState('c1', 'alice')],
338
- ['c2', makeConnState('c2', 'bob', { oper: true })],
356
+ ['c2', bob],
339
357
  ]);
340
358
 
341
359
  const out = whoReducer(chan, connStates, { command: 'WHO', params: ['#foo', 'o'] }, ctx);
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
+ import { operReducer } from '../../src/commands/oper';
2
3
  import { whoisReducer } from '../../src/commands/whois';
3
4
  import { Effect } from '../../src/effects';
4
5
  import type { Effect as EffectType, RawLine } from '../../src/effects';
@@ -10,6 +11,7 @@ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
10
11
  const serverConfig: ServerConfig = {
11
12
  serverName: 'irc.example.com',
12
13
  networkName: 'ExampleNet',
14
+ operCreds: [{ user: 'bob', password: 'oppass' }],
13
15
  maxChannelsPerUser: 30,
14
16
  maxTargetsPerCommand: 10,
15
17
  maxListEntries: 50,
@@ -150,13 +152,18 @@ describe('whoisReducer — happy path', () => {
150
152
  it('emits 313 RPL_WHOISOPERATOR when the target is an IRC operator', () => {
151
153
  const requester = makeConn();
152
154
  const ctx = makeCtx(requester);
153
- const target = makeTarget('c2', 'bob', { oper: true });
155
+ // Grant oper via the real OPER command flow rather than poking state directly.
156
+ const target = makeTarget('c2', 'bob');
157
+ const targetCtx = makeCtx(target);
158
+ operReducer(target, { command: 'OPER', params: ['bob', 'oppass'], tags: {} }, targetCtx);
159
+ expect(target.userModes.oper).toBe(true);
154
160
 
155
161
  const out = whoisReducer(target, [], { command: 'WHOIS', params: ['bob'] }, ctx);
156
162
 
157
163
  const send = out.effects[0];
158
164
  if (send?.tag === 'Send') {
159
165
  expect(send.lines.some((l) => l.text.includes(' 313 '))).toBe(true);
166
+ expect(send.lines.some((l) => l.text.endsWith(':is an IRC operator'))).toBe(true);
160
167
  }
161
168
  });
162
169
  });
@@ -288,6 +288,81 @@ describe('ServerConfigSchema — operCreds', () => {
288
288
  });
289
289
  });
290
290
 
291
+ // ============================================================================
292
+ // ServerConfigSchema — saslAccounts
293
+ // ============================================================================
294
+
295
+ describe('ServerConfigSchema — saslAccounts', () => {
296
+ it('accepts an array of {username,password} entries', () => {
297
+ const cfg = parseServerConfig({
298
+ serverName: 's',
299
+ networkName: 'n',
300
+ saslAccounts: [
301
+ { username: 'alice', password: 'p1' },
302
+ { username: 'bob', password: 'p2' },
303
+ ],
304
+ });
305
+ expect(cfg.saslAccounts).toHaveLength(2);
306
+ expect(cfg.saslAccounts[0]?.username).toBe('alice');
307
+ expect(cfg.saslAccounts[1]?.password).toBe('p2');
308
+ });
309
+
310
+ it('defaults saslAccounts to an empty array when omitted', () => {
311
+ const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
312
+ expect(cfg.saslAccounts).toEqual([]);
313
+ });
314
+
315
+ it('rejects saslAccounts entries missing username', () => {
316
+ expect(() =>
317
+ parseServerConfig({
318
+ serverName: 's',
319
+ networkName: 'n',
320
+ saslAccounts: [{ password: 'p' }],
321
+ }),
322
+ ).toThrowError(/username/u);
323
+ });
324
+
325
+ it('rejects saslAccounts entries missing password', () => {
326
+ expect(() =>
327
+ parseServerConfig({
328
+ serverName: 's',
329
+ networkName: 'n',
330
+ saslAccounts: [{ username: 'u' }],
331
+ }),
332
+ ).toThrowError(/password/u);
333
+ });
334
+
335
+ it('rejects saslAccounts entries with empty username', () => {
336
+ expect(() =>
337
+ parseServerConfig({
338
+ serverName: 's',
339
+ networkName: 'n',
340
+ saslAccounts: [{ username: '', password: 'p' }],
341
+ }),
342
+ ).toThrowError(/username/u);
343
+ });
344
+
345
+ it('rejects saslAccounts entries with empty password', () => {
346
+ expect(() =>
347
+ parseServerConfig({
348
+ serverName: 's',
349
+ networkName: 'n',
350
+ saslAccounts: [{ username: 'u', password: '' }],
351
+ }),
352
+ ).toThrowError(/password/u);
353
+ });
354
+
355
+ it('rejects a non-array saslAccounts', () => {
356
+ expect(() =>
357
+ parseServerConfig({
358
+ serverName: 's',
359
+ networkName: 'n',
360
+ saslAccounts: { username: 'u', password: 'p' },
361
+ }),
362
+ ).toThrowError(/saslAccounts/u);
363
+ });
364
+ });
365
+
291
366
  // ============================================================================
292
367
  // ServerConfigSchema — limit knobs
293
368
  // ============================================================================
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/irc-server",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": true,
5
5
  "description": "Orchestration layer: IrcRuntime port, dispatch interpreter, ConnectionActor",
6
6
  "license": "BSD-3-Clause",
@@ -26,6 +26,7 @@
26
26
  */
27
27
 
28
28
  import {
29
+ type AccountStore,
29
30
  type ChanName,
30
31
  type ChannelState,
31
32
  type Clock,
@@ -103,6 +104,16 @@ export interface ConnectionActorOptions {
103
104
  * opt in.
104
105
  */
105
106
  readonly messages?: MessageStore;
107
+ /**
108
+ * SASL account verification source (absent when no accounts are
109
+ * configured). When bound, `ctx.accounts` is defined so the
110
+ * `AUTHENTICATE` reducer can verify PLAIN credentials end-to-end.
111
+ * Omitted (→ `ctx.accounts` undefined) preserves the existing
112
+ * behaviour for callers/tests that do not opt in: a client completing
113
+ * `AUTHENTICATE PLAIN` receives `904 ERR_SASLFAIL` because no store
114
+ * can vouch for the credentials.
115
+ */
116
+ readonly accounts?: AccountStore;
106
117
  /**
107
118
  * Structured-logger port. When omitted the actor runs
108
119
  * silently — observability is purely opt-in. When supplied, every
@@ -127,6 +138,7 @@ export class ConnectionActor {
127
138
  private readonly ids: IdFactory;
128
139
  private readonly motd: MotdProvider;
129
140
  private readonly messages: MessageStore | undefined;
141
+ private readonly accounts: AccountStore | undefined;
130
142
  private readonly reducers: RoutedReducers;
131
143
  /**
132
144
  * Bound logger. Defaults to a no-op so the actor pipeline has no
@@ -150,6 +162,7 @@ export class ConnectionActor {
150
162
  this.ids = opts.ids;
151
163
  this.motd = opts.motd ?? EmptyMotdProvider;
152
164
  this.messages = opts.messages;
165
+ this.accounts = opts.accounts;
153
166
  this.logger = opts.logger ?? NoopLoggerInstance;
154
167
  this.pinnedTraceId = opts.traceId;
155
168
  // Bind the connection id once so every record the actor emits carries
@@ -242,6 +255,7 @@ export class ConnectionActor {
242
255
  motd: this.motd,
243
256
  connection: this.state,
244
257
  ...(this.messages !== undefined ? { messages: this.messages } : {}),
258
+ ...(this.accounts !== undefined ? { accounts: this.accounts } : {}),
245
259
  });
246
260
 
247
261
  switch (msg.command) {
@@ -260,6 +274,8 @@ export class ConnectionActor {
260
274
  return this.reducers.quit(this.state, msg, ctx).effects;
261
275
  case 'MOTD':
262
276
  return this.reducers.motd(this.state, msg, ctx).effects;
277
+ case 'OPER':
278
+ return this.reducers.oper(this.state, msg, ctx).effects;
263
279
  case 'CAP':
264
280
  return this.reducers.cap(this.state, msg, ctx).effects;
265
281
  case 'AUTHENTICATE':
@@ -307,6 +323,13 @@ export class ConnectionActor {
307
323
  this.reducers.noticeUser,
308
324
  ctx,
309
325
  );
326
+ case 'TAGMSG':
327
+ return this.routeMessageTarget(
328
+ msg,
329
+ this.reducers.tagmsgChannel,
330
+ this.reducers.tagmsgUser,
331
+ ctx,
332
+ );
310
333
  case 'MODE':
311
334
  return isChannelTarget(msg.params[0] ?? '')
312
335
  ? this.routeSingleChannel(
@@ -557,6 +580,7 @@ function channelTargetsForMessage(msg: IrcMessage): ChanName[] {
557
580
  case 'MODE':
558
581
  case 'PRIVMSG':
559
582
  case 'NOTICE':
583
+ case 'TAGMSG':
560
584
  return splitCsv(msg.params[0] ?? '').filter(isChannelTarget) as ChanName[];
561
585
  case 'INVITE':
562
586
  // INVITE <nick> <channel> — the channel is the second param.
@@ -42,6 +42,7 @@ import {
42
42
  nickReducer,
43
43
  noticeChannelReducer,
44
44
  noticeUserReducer,
45
+ operReducer,
45
46
  partReducer,
46
47
  passReducer,
47
48
  pingReducer,
@@ -49,6 +50,8 @@ import {
49
50
  privmsgChannelReducer,
50
51
  privmsgUserReducer,
51
52
  quitReducer,
53
+ tagmsgChannelReducer,
54
+ tagmsgUserReducer,
52
55
  topicReducer,
53
56
  userModeReducer,
54
57
  userReducer,
@@ -69,6 +72,7 @@ export interface RoutedReducers {
69
72
  readonly pass: Reducer<ConnectionState>;
70
73
  readonly quit: Reducer<ConnectionState>;
71
74
  readonly motd: Reducer<ConnectionState>;
75
+ readonly oper: Reducer<ConnectionState>;
72
76
  readonly cap: Reducer<ConnectionState>;
73
77
  readonly authenticate: Reducer<ConnectionState>;
74
78
  readonly away: Reducer<ConnectionState>;
@@ -84,6 +88,8 @@ export interface RoutedReducers {
84
88
  readonly privmsgUser: Reducer<ConnectionState>;
85
89
  readonly noticeChannel: Reducer<ChannelState>;
86
90
  readonly noticeUser: Reducer<ConnectionState>;
91
+ readonly tagmsgChannel: Reducer<ChannelState>;
92
+ readonly tagmsgUser: Reducer<ConnectionState>;
87
93
  readonly channelMode: Reducer<ChannelState>;
88
94
  readonly userMode: Reducer<ConnectionState>;
89
95
  }
@@ -132,6 +138,7 @@ export function buildRoutedReducers(serverConfig: ServerConfig): RoutedReducers
132
138
  pass: wrap(passReducer),
133
139
  quit: wrap(quitReducer),
134
140
  motd: wrap(motdReducer),
141
+ oper: wrap(operReducer),
135
142
  cap: wrap(capReducer),
136
143
  authenticate: wrap(authenticateReducer),
137
144
  away: wrap(awayReducer),
@@ -145,6 +152,8 @@ export function buildRoutedReducers(serverConfig: ServerConfig): RoutedReducers
145
152
  privmsgUser: wrap(privmsgUserReducer),
146
153
  noticeChannel: wrap(noticeChannelReducer),
147
154
  noticeUser: wrap(noticeUserReducer),
155
+ tagmsgChannel: wrap(tagmsgChannelReducer),
156
+ tagmsgUser: wrap(tagmsgUserReducer),
148
157
  channelMode: wrap(channelModeReducer),
149
158
  userMode: wrap(userModeReducer),
150
159
  };
@@ -1,4 +1,5 @@
1
1
  import {
2
+ type AccountStore,
2
3
  CapturingLogger,
3
4
  type ChanName,
4
5
  type ChanSnapshot,
@@ -17,14 +18,18 @@ import {
17
18
  type Nick,
18
19
  Numerics,
19
20
  type RawLine,
21
+ type SaslPayload,
22
+ type SaslResult,
20
23
  SequentialIdFactory,
21
24
  type ServerConfig,
22
25
  type StoredMessage,
23
26
  applyChannelDelta as applyDelta,
24
27
  createChannel,
25
28
  createConnection,
29
+ encodeBase64,
26
30
  isChannelTarget,
27
31
  toChannelSnapshot,
32
+ toSnapshot,
28
33
  } from '@serverless-ircd/irc-core';
29
34
  import { describe, expect, it } from 'vitest';
30
35
  import { ConnectionActor } from '../src/actor';
@@ -116,7 +121,8 @@ class FakeRuntime implements IrcRuntime {
116
121
  }
117
122
  async getConnectionInfo(conn: ConnId): Promise<ConnSnapshot | null> {
118
123
  this.calls.push({ method: 'getConnectionInfo', args: [conn] });
119
- return null;
124
+ const state = this.testConnections.get(conn);
125
+ return state === undefined ? null : toSnapshot(state);
120
126
  }
121
127
  async getConnection(conn: ConnId): Promise<ConnectionState | null> {
122
128
  this.calls.push({ method: 'getConnection', args: [conn] });
@@ -154,6 +160,8 @@ function makeActor(opts: {
154
160
  logger?: Logger;
155
161
  traceId?: string;
156
162
  messages?: MessageStore;
163
+ accounts?: AccountStore;
164
+ operCreds?: ReadonlyArray<{ user: string; password: string }>;
157
165
  }): { actor: ConnectionActor; runtime: FakeRuntime; conn: ConnectionState } {
158
166
  const clock = opts.clock ?? new FakeClock(1_000);
159
167
  const conn = createConnection({ id: 'c1', connectedSince: 0 });
@@ -179,6 +187,7 @@ function makeActor(opts: {
179
187
  topicLen: 390,
180
188
  quitMessage: 'Client Quit',
181
189
  ...(opts.floodControl !== undefined ? { floodControl: opts.floodControl } : {}),
190
+ ...(opts.operCreds !== undefined ? { operCreds: opts.operCreds } : {}),
182
191
  };
183
192
  const ids = new SequentialIdFactory();
184
193
  type ActorOpts = ConstructorParameters<typeof ConnectionActor>[0];
@@ -203,6 +212,9 @@ function makeActor(opts: {
203
212
  if (opts.messages !== undefined) {
204
213
  finalOpts.messages = opts.messages;
205
214
  }
215
+ if (opts.accounts !== undefined) {
216
+ finalOpts.accounts = opts.accounts;
217
+ }
206
218
  const actor = new ConnectionActor(finalOpts);
207
219
  return { actor, runtime, conn };
208
220
  }
@@ -478,6 +490,59 @@ describe('ConnectionActor — additional routing', () => {
478
490
  expect(runtime.calls.filter((c) => c.method === 'broadcast')).toHaveLength(1);
479
491
  });
480
492
 
493
+ it('routes TAGMSG to a channel broadcast (never 421) when message-tags cap is present', async () => {
494
+ const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
495
+ bobConn.nick = 'bob';
496
+ bobConn.user = 'bob';
497
+ bobConn.host = 'example.org';
498
+ bobConn.registration = 'registered';
499
+ bobConn.caps = new Set<string>(['message-tags']);
500
+ const { actor, runtime } = makeActor({
501
+ conn: { caps: new Set<string>(['message-tags']) },
502
+ });
503
+ runtime.addConnection(bobConn);
504
+ await runtime.applyChannelDelta('#foo', {
505
+ memberships: [
506
+ { type: 'add', conn: 'c1', nick: 'alice' },
507
+ { type: 'add', conn: 'c2', nick: 'bob' },
508
+ ],
509
+ });
510
+ runtime.calls.length = 0;
511
+ await actor.receiveTextFrame('@+typing=active TAGMSG #foo\r\n');
512
+
513
+ // Cap-gated broadcast dispatches per-recipient via send (only to
514
+ // message-tags-enabled members, excluding the sender).
515
+ const sends = runtime.calls.filter(
516
+ (c) => c.method === 'send' && c.args[0] === 'c2',
517
+ );
518
+ expect(sends).toHaveLength(1);
519
+ const lines = sends[0]?.args[1] as RawLine[];
520
+ expect(lines[0]?.text).toBe('@+typing=active :alice!alice@example.com TAGMSG #foo');
521
+ // Critical: never 421 for a cap-enabled TAGMSG.
522
+ for (const call of runtime.calls) {
523
+ if (call.method === 'send') {
524
+ for (const line of call.args[1] as RawLine[]) {
525
+ expect(line.text).not.toContain(' 421 ');
526
+ }
527
+ }
528
+ }
529
+ });
530
+
531
+ it('still emits 421 for TAGMSG when message-tags cap is not negotiated', async () => {
532
+ const { actor, runtime } = makeActor({});
533
+ await runtime.applyChannelDelta('#foo', {
534
+ memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
535
+ });
536
+ runtime.calls.length = 0;
537
+ await actor.receiveTextFrame('TAGMSG #foo\r\n');
538
+
539
+ const sends = runtime.calls.filter((c) => c.method === 'send');
540
+ expect(sends).toHaveLength(1);
541
+ const lines = sends[0]?.args[1] as RawLine[];
542
+ expect(lines[0]?.text).toContain(' 421 ');
543
+ expect(lines[0]?.text).toContain('TAGMSG');
544
+ });
545
+
481
546
  it('routes PRIVMSG with mixed targets (#chan,nick) into 2 invocations', async () => {
482
547
  const { actor, runtime } = makeActor({});
483
548
  await runtime.applyChannelDelta('#foo', {
@@ -712,6 +777,63 @@ describe('ConnectionActor — additional routing', () => {
712
777
  }
713
778
  });
714
779
 
780
+ it('routes OPER with matching creds through operReducer (381, never 421)', async () => {
781
+ const { actor, runtime, conn } = makeActor({
782
+ operCreds: [{ user: 'alice', password: 'secret' }],
783
+ });
784
+ runtime.calls.length = 0;
785
+
786
+ await actor.receiveTextFrame('OPER alice secret\r\n');
787
+
788
+ const sends = runtime.calls.filter((c) => c.method === 'send');
789
+ expect(sends).toHaveLength(1);
790
+ const lines = sends[0]?.args[1] as RawLine[];
791
+ expect(lines[0]?.text).toBe(':irc.example.com 381 alice :You are now an IRC operator');
792
+ expect(conn.userModes.oper).toBe(true);
793
+ // Critical: never 421 for OPER.
794
+ for (const s of sends) {
795
+ for (const line of s.args[1] as RawLine[]) {
796
+ expect(line.text).not.toContain(' 421 ');
797
+ }
798
+ }
799
+ });
800
+
801
+ it('routes OPER with wrong creds through operReducer (464, never 421)', async () => {
802
+ const { actor, runtime } = makeActor({
803
+ operCreds: [{ user: 'alice', password: 'secret' }],
804
+ });
805
+ runtime.calls.length = 0;
806
+
807
+ await actor.receiveTextFrame('OPER alice wrong\r\n');
808
+
809
+ const sends = runtime.calls.filter((c) => c.method === 'send');
810
+ expect(sends).toHaveLength(1);
811
+ const lines = sends[0]?.args[1] as RawLine[];
812
+ expect(lines[0]?.text).toContain(' 464 ');
813
+ for (const s of sends) {
814
+ for (const line of s.args[1] as RawLine[]) {
815
+ expect(line.text).not.toContain(' 421 ');
816
+ }
817
+ }
818
+ });
819
+
820
+ it('routes OPER with no configured creds through operReducer (491, never 421)', async () => {
821
+ const { actor, runtime } = makeActor({});
822
+ runtime.calls.length = 0;
823
+
824
+ await actor.receiveTextFrame('OPER alice secret\r\n');
825
+
826
+ const sends = runtime.calls.filter((c) => c.method === 'send');
827
+ expect(sends).toHaveLength(1);
828
+ const lines = sends[0]?.args[1] as RawLine[];
829
+ expect(lines[0]?.text).toContain(' 491 ');
830
+ for (const s of sends) {
831
+ for (const line of s.args[1] as RawLine[]) {
832
+ expect(line.text).not.toContain(' 421 ');
833
+ }
834
+ }
835
+ });
836
+
715
837
  it('emits 421 with empty command when routeSingleChannel gets no channel (TOPIC)', async () => {
716
838
  const { actor, runtime } = makeActor({});
717
839
  await actor.receiveTextFrame('TOPIC\r\n');
@@ -1270,3 +1392,100 @@ describe('ConnectionActor — CHATHISTORY', () => {
1270
1392
  // …but there is no store to record into.
1271
1393
  });
1272
1394
  });
1395
+
1396
+ // ===========================================================================
1397
+ // SASL / AccountStore — actor plumbing (ctx.accounts threaded through buildCtx)
1398
+ // ===========================================================================
1399
+
1400
+ describe('ConnectionActor — SASL AccountStore plumbing', () => {
1401
+ /** Capture-and-respond fake AccountStore (mirrors the irc-core sasl tests). */
1402
+ class FakeAccountStore implements AccountStore {
1403
+ readonly calls: Array<{ mech: string; payload: SaslPayload }> = [];
1404
+ constructor(private readonly result: SaslResult) {}
1405
+ verify(mech: string, payload: SaslPayload): SaslResult {
1406
+ this.calls.push({ mech, payload });
1407
+ return this.result;
1408
+ }
1409
+ }
1410
+
1411
+ /** Builds the PLAIN base64 payload `authzid\0authcid\0password`. */
1412
+ function plainPayload(authcid: string, password: string): string {
1413
+ return encodeBase64(`\0${authcid}\0${password}`);
1414
+ }
1415
+
1416
+ /** A connection that has opened CAP negotiation and requested `sasl`. */
1417
+ function saslReadyConn(): Partial<ConnectionState> {
1418
+ return {
1419
+ registration: 'pre-registration',
1420
+ capNegotiating: true,
1421
+ caps: new Set<string>(['sasl']),
1422
+ };
1423
+ }
1424
+
1425
+ it('completes SASL PLAIN with 900/903 when a bound AccountStore accepts creds', async () => {
1426
+ const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1427
+ const { actor, runtime, conn } = makeActor({
1428
+ conn: saslReadyConn(),
1429
+ accounts,
1430
+ });
1431
+ conn.nick = 'alice';
1432
+ runtime.calls.length = 0;
1433
+
1434
+ await actor.receiveTextFrame('AUTHENTICATE PLAIN\r\n');
1435
+ await actor.receiveTextFrame(`AUTHENTICATE ${plainPayload('alice', 'secret')}\r\n`);
1436
+
1437
+ const sends = runtime.calls.filter((c) => c.method === 'send');
1438
+ // Second frame emits the success numerics; gather every sent line.
1439
+ const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
1440
+ expect(lines.some((l) => l.text.includes(' 900 '))).toBe(true);
1441
+ expect(lines.some((l) => l.text.includes(' 903 '))).toBe(true);
1442
+ // Critical: never 904 for valid creds.
1443
+ expect(lines.some((l) => l.text.includes(' 904 '))).toBe(false);
1444
+ // The AccountStore was consulted with the parsed PLAIN credentials.
1445
+ expect(accounts.calls).toEqual([
1446
+ { mech: 'PLAIN', payload: { kind: 'PLAIN', username: 'alice', password: 'secret' } },
1447
+ ]);
1448
+ // Account name recorded on the connection for extended-join/account-tag.
1449
+ expect(conn.account).toBe('alice');
1450
+ });
1451
+
1452
+ it('emits 904 ERR_SASLFAIL when no AccountStore is bound (regression guard)', async () => {
1453
+ const { actor, runtime, conn } = makeActor({
1454
+ conn: saslReadyConn(),
1455
+ // No `accounts` option → ctx.accounts is undefined.
1456
+ });
1457
+ conn.nick = 'alice';
1458
+ runtime.calls.length = 0;
1459
+
1460
+ await actor.receiveTextFrame('AUTHENTICATE PLAIN\r\n');
1461
+ await actor.receiveTextFrame(`AUTHENTICATE ${plainPayload('alice', 'secret')}\r\n`);
1462
+
1463
+ const sends = runtime.calls.filter((c) => c.method === 'send');
1464
+ const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
1465
+ expect(lines.some((l) => l.text.includes(' 904 '))).toBe(true);
1466
+ // No success numerics.
1467
+ expect(lines.some((l) => l.text.includes(' 900 '))).toBe(false);
1468
+ expect(lines.some((l) => l.text.includes(' 903 '))).toBe(false);
1469
+ // Account name never recorded.
1470
+ expect(conn.account).toBeUndefined();
1471
+ });
1472
+
1473
+ it('emits 904 when a bound AccountStore rejects the credentials', async () => {
1474
+ const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
1475
+ const { actor, runtime, conn } = makeActor({
1476
+ conn: saslReadyConn(),
1477
+ accounts,
1478
+ });
1479
+ conn.nick = 'alice';
1480
+ runtime.calls.length = 0;
1481
+
1482
+ await actor.receiveTextFrame('AUTHENTICATE PLAIN\r\n');
1483
+ await actor.receiveTextFrame(`AUTHENTICATE ${plainPayload('alice', 'wrong')}\r\n`);
1484
+
1485
+ const sends = runtime.calls.filter((c) => c.method === 'send');
1486
+ const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
1487
+ expect(lines.some((l) => l.text.includes(' 904 '))).toBe(true);
1488
+ expect(lines.some((l) => l.text.includes(' 903 '))).toBe(false);
1489
+ expect(conn.account).toBeUndefined();
1490
+ });
1491
+ });
@@ -121,6 +121,7 @@ describe('buildRoutedReducers', () => {
121
121
  'nick',
122
122
  'noticeChannel',
123
123
  'noticeUser',
124
+ 'oper',
124
125
  'part',
125
126
  'pass',
126
127
  'ping',
@@ -128,6 +129,8 @@ describe('buildRoutedReducers', () => {
128
129
  'privmsgChannel',
129
130
  'privmsgUser',
130
131
  'quit',
132
+ 'tagmsgChannel',
133
+ 'tagmsgUser',
131
134
  'topic',
132
135
  'user',
133
136
  'userMode',
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/irc-test-support",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": true,
5
5
  "description": "Shared IRC scenario runner + harness seam used by every adapter's integration suite",
6
6
  "license": "BSD-3-Clause",
@@ -52,10 +52,11 @@ export const HARNESS_SERVER_CONFIG = {
52
52
  } as const;
53
53
 
54
54
  /**
55
- * Default MOTD lines exposed to every spawned client. The welcome block
56
- * always emits the placeholder 375/372/376 numerics; an explicit `MOTD`
57
- * command after registration reads these lines via the {@link MotdProvider}
58
- * port. Tests assert against this content (scenario: MOTD).
55
+ * Default MOTD lines exposed to every spawned client. The registration
56
+ * welcome block renders these lines as `375`/`372`×N/`376` numerics, and
57
+ * an explicit `MOTD` command after registration reads them again via the
58
+ * {@link MotdProvider} port. Tests assert against this content
59
+ * (scenario: MOTD).
59
60
  */
60
61
  export const HARNESS_MOTD_LINES = [
61
62
  'Welcome to the ServerlessIRCd integration-test harness.',
@@ -1,3 +1,4 @@
1
+ linkedWorkspacePackages: true
1
2
  packages:
2
3
  - "packages/*"
3
4
  - "apps/*"