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.
- package/CHANGELOG.md +84 -0
- package/README.md +28 -19
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/scripts/smoke.mjs +0 -0
- package/apps/cf-worker/src/worker.ts +8 -2
- package/apps/cf-worker/tests/smoke.test.ts +1 -0
- package/apps/cf-worker/wrangler.test.toml +5 -1
- package/apps/cf-worker/wrangler.toml +41 -17
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +1 -0
- package/apps/local-cli/src/main.ts +1 -1
- package/apps/local-cli/src/server.ts +28 -2
- package/apps/local-cli/tests/e2e.test.ts +52 -0
- package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
- package/docs/ADR-002-location-of-authority.md +82 -0
- package/docs/ADR-003-durable-object-sharding.md +93 -0
- package/docs/ADR-004-dynamodb-schema.md +96 -0
- package/docs/ADR-005-wss-only-transport-v1.md +83 -0
- package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
- package/docs/ADR-007-deterministic-ports.md +82 -0
- package/docs/ADR-008-monorepo-tooling.md +60 -0
- package/docs/AWS-Adapter-Architecture.md +6 -4
- package/docs/AWS-Deployment.md +86 -7
- package/docs/Cloudflare-Deployment-Guide.md +5 -5
- package/docs/Home.md +11 -1
- package/docs/Release-Process.md +9 -6
- package/package.json +14 -15
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/account-store.ts +121 -0
- package/packages/aws-adapter/src/aws-runtime.ts +118 -30
- package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
- package/packages/aws-adapter/src/config-loader.ts +30 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
- package/packages/aws-adapter/src/handlers/default.ts +8 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
- package/packages/aws-adapter/src/handlers/index.ts +51 -6
- package/packages/aws-adapter/src/index.ts +7 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
- package/packages/aws-adapter/tests/account-store.test.ts +280 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +55 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
- package/packages/aws-adapter/tests/handlers.test.ts +46 -0
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/cf-runtime.ts +42 -22
- package/packages/cf-adapter/src/channel-do.ts +40 -1
- package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
- package/packages/cf-adapter/src/config-loader.ts +29 -0
- package/packages/cf-adapter/src/connection-do.ts +85 -0
- package/packages/cf-adapter/src/env.ts +27 -1
- package/packages/cf-adapter/src/index.ts +2 -1
- package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +40 -0
- package/packages/cf-adapter/tests/worker/main.ts +2 -1
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
- package/packages/cf-adapter/wrangler.test.toml +10 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
- package/packages/irc-core/package.json +3 -2
- package/packages/irc-core/src/case-fold.ts +64 -0
- package/packages/irc-core/src/commands/index.ts +2 -0
- package/packages/irc-core/src/commands/invite.ts +6 -9
- package/packages/irc-core/src/commands/isupport.ts +1 -1
- package/packages/irc-core/src/commands/join.ts +4 -3
- package/packages/irc-core/src/commands/kick.ts +4 -11
- package/packages/irc-core/src/commands/list.ts +5 -4
- package/packages/irc-core/src/commands/mode.ts +5 -9
- package/packages/irc-core/src/commands/motd-lines.ts +127 -0
- package/packages/irc-core/src/commands/motd.ts +6 -110
- package/packages/irc-core/src/commands/oper.ts +151 -0
- package/packages/irc-core/src/commands/registration.ts +8 -7
- package/packages/irc-core/src/commands/tagmsg.ts +205 -0
- package/packages/irc-core/src/config.ts +26 -3
- package/packages/irc-core/src/index.ts +2 -0
- package/packages/irc-core/src/ports.ts +68 -4
- package/packages/irc-core/src/types.ts +22 -0
- package/packages/irc-core/tests/account-store.test.ts +88 -0
- package/packages/irc-core/tests/case-fold.test.ts +80 -0
- package/packages/irc-core/tests/commands/kick.test.ts +15 -0
- package/packages/irc-core/tests/commands/oper.test.ts +257 -0
- package/packages/irc-core/tests/commands/registration.test.ts +106 -14
- package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
- package/packages/irc-core/tests/commands/who.test.ts +22 -4
- package/packages/irc-core/tests/commands/whois.test.ts +8 -1
- package/packages/irc-core/tests/config.test.ts +75 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +24 -0
- package/packages/irc-server/src/routing.ts +9 -0
- package/packages/irc-server/tests/actor.test.ts +220 -1
- package/packages/irc-server/tests/routing.test.ts +3 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +5 -4
- package/pnpm-workspace.yaml +1 -0
- package/tools/seed-aws-accounts.ts +80 -0
- package/AGENTS.md +0 -5
- package/MOTD.txt +0 -3
- package/PLAN-FIXES.md +0 -358
- package/PLAN.md +0 -420
- package/dashboards/cloudwatch-irc.json +0 -139
- package/progress.md +0 -107
- package/tickets.md +0 -2485
- package/webircgateway/LICENSE +0 -201
- package/webircgateway/Makefile +0 -44
- package/webircgateway/README.md +0 -134
- package/webircgateway/config.conf.example +0 -135
- package/webircgateway/go.mod +0 -16
- package/webircgateway/go.sum +0 -89
- package/webircgateway/main.go +0 -118
- package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
- package/webircgateway/pkg/identd/identd.go +0 -86
- package/webircgateway/pkg/identd/rpcclient.go +0 -59
- package/webircgateway/pkg/irc/isupport.go +0 -56
- package/webircgateway/pkg/irc/message.go +0 -217
- package/webircgateway/pkg/irc/state.go +0 -79
- package/webircgateway/pkg/proxy/proxy.go +0 -129
- package/webircgateway/pkg/proxy/server.go +0 -237
- package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
- package/webircgateway/pkg/webircgateway/client.go +0 -741
- package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
- package/webircgateway/pkg/webircgateway/config.go +0 -385
- package/webircgateway/pkg/webircgateway/gateway.go +0 -278
- package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
- package/webircgateway/pkg/webircgateway/hooks.go +0 -152
- package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
- package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
- package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
- package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
- package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
- package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
- package/webircgateway/pkg/webircgateway/utils.go +0 -147
- package/webircgateway/plugins/example/plugin.go +0 -11
- package/webircgateway/plugins/stats/plugin.go +0 -52
- package/webircgateway/staticcheck.conf +0 -1
- 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
|
|
133
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
// ============================================================================
|
|
@@ -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
|
-
|
|
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',
|
|
@@ -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
|
|
56
|
-
*
|
|
57
|
-
* command after registration reads
|
|
58
|
-
* port. Tests assert against this content
|
|
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.',
|
package/pnpm-workspace.yaml
CHANGED