serverless-ircd 0.4.0 → 0.5.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 +175 -0
- package/README.md +88 -19
- package/apps/aws-stack/README.md +4 -3
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +32 -4
- package/apps/aws-stack/tests/stack.test.ts +47 -1
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-worker/package.json +1 -1
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +129 -21
- package/apps/local-cli/tests/e2e.test.ts +1 -1
- package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +69 -0
- package/packages/aws-adapter/src/handlers/connect.ts +36 -5
- package/packages/aws-adapter/src/handlers/default.ts +63 -5
- package/packages/aws-adapter/src/handlers/index.ts +41 -2
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +9 -0
- package/packages/aws-adapter/src/index.ts +2 -0
- package/packages/aws-adapter/src/serialize.ts +11 -1
- package/packages/aws-adapter/src/stats.ts +80 -0
- package/packages/aws-adapter/tests/aws-integration.test.ts +1 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +61 -0
- package/packages/aws-adapter/tests/connect.test.ts +97 -1
- package/packages/aws-adapter/tests/handlers.test.ts +148 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +2 -0
- package/packages/aws-adapter/tests/stats.test.ts +317 -0
- package/packages/cf-adapter/package.json +5 -1
- package/packages/cf-adapter/src/cf-runtime.ts +66 -1
- package/packages/cf-adapter/src/channel-do.ts +2 -2
- package/packages/cf-adapter/src/connection-do.ts +182 -54
- package/packages/cf-adapter/src/env.ts +25 -6
- package/packages/cf-adapter/src/index.ts +2 -0
- package/packages/cf-adapter/src/registry-do.ts +22 -3
- package/packages/cf-adapter/src/sharding.ts +1 -2
- package/packages/cf-adapter/src/stats.ts +65 -0
- package/packages/cf-adapter/tests/cf-harness.ts +1 -1
- package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
- package/packages/cf-adapter/tests/cf-runtime.test.ts +38 -2
- package/packages/cf-adapter/tests/channel-do.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
- package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
- package/packages/cf-adapter/tests/registry-do.test.ts +4 -4
- package/packages/cf-adapter/tests/sharding.test.ts +1 -1
- package/packages/cf-adapter/tests/stats.test.ts +120 -0
- package/packages/cf-adapter/tests/worker/main.ts +7 -7
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
- package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
- package/packages/cf-adapter/wrangler.test.toml +7 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/admission.ts +16 -15
- package/packages/irc-core/src/caps/capabilities.ts +1 -1
- package/packages/irc-core/src/commands/index.ts +8 -0
- package/packages/irc-core/src/commands/invite.ts +2 -4
- package/packages/irc-core/src/commands/isupport.ts +6 -2
- package/packages/irc-core/src/commands/kick.ts +2 -4
- package/packages/irc-core/src/commands/kill.ts +127 -0
- package/packages/irc-core/src/commands/list.ts +1 -1
- package/packages/irc-core/src/commands/lusers.ts +204 -0
- package/packages/irc-core/src/commands/mode.ts +4 -8
- package/packages/irc-core/src/commands/names.ts +3 -5
- package/packages/irc-core/src/commands/part.ts +2 -4
- package/packages/irc-core/src/commands/rehash.ts +119 -0
- package/packages/irc-core/src/commands/setname.ts +109 -0
- package/packages/irc-core/src/commands/stats.ts +152 -0
- package/packages/irc-core/src/commands/topic.ts +2 -4
- package/packages/irc-core/src/commands/trace.ts +137 -0
- package/packages/irc-core/src/commands/wallops.ts +118 -0
- package/packages/irc-core/src/config.ts +7 -0
- package/packages/irc-core/src/effects.ts +27 -1
- package/packages/irc-core/src/index.ts +2 -0
- package/packages/irc-core/src/ports.ts +179 -0
- package/packages/irc-core/src/protocol/numerics.ts +42 -11
- package/packages/irc-core/src/protocol/outbound.ts +20 -3
- package/packages/irc-core/src/types.ts +8 -1
- package/packages/irc-core/src/ws-framing.ts +132 -0
- package/packages/irc-core/src/ws-subprotocol.ts +66 -0
- package/packages/irc-core/tests/admission.test.ts +18 -0
- package/packages/irc-core/tests/commands/kill.test.ts +243 -0
- package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
- package/packages/irc-core/tests/commands/mode.test.ts +57 -0
- package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
- package/packages/irc-core/tests/commands/setname.test.ts +225 -0
- package/packages/irc-core/tests/commands/stats.test.ts +294 -0
- package/packages/irc-core/tests/commands/trace.test.ts +282 -0
- package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
- package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/numerics.test.ts +90 -0
- package/packages/irc-core/tests/outbound.test.ts +51 -0
- package/packages/irc-core/tests/ports.test.ts +22 -0
- package/packages/irc-core/tests/raw-modules.d.ts +11 -0
- package/packages/irc-core/tests/stats-store.test.ts +222 -0
- package/packages/irc-core/tests/ws-framing.test.ts +213 -0
- package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +249 -16
- package/packages/irc-server/src/dispatch.ts +1 -0
- package/packages/irc-server/src/routing.ts +3 -0
- package/packages/irc-server/src/runtime.ts +31 -0
- package/packages/irc-server/src/transport.ts +10 -7
- package/packages/irc-server/tests/actor.test.ts +1089 -1
- package/packages/irc-server/tests/dispatch.test.ts +37 -0
- package/packages/irc-server/tests/raw-modules.d.ts +11 -0
- package/packages/irc-server/tests/routing.test.ts +1 -0
- package/packages/irc-server/tests/runtime.test.ts +7 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/scenarios.ts +9 -1
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
- package/pnpm-workspace.yaml +1 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/package.json +6 -1
- package/tools/seed-cf-accounts.ts +4 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
- package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* `ConnectionDO` (hibernatable WS), state migration, alarms.
|
|
3
3
|
*
|
|
4
|
-
* TDD outline
|
|
4
|
+
* TDD outline:
|
|
5
5
|
* "Red via `@cloudflare/vitest-pool-workers` on 'hibernate then wake
|
|
6
6
|
* preserves nick'; green; then alarm-driven PING."
|
|
7
7
|
*
|
|
@@ -406,6 +406,31 @@ describe('ConnectionDO — SASL PLAIN end-to-end', () => {
|
|
|
406
406
|
});
|
|
407
407
|
});
|
|
408
408
|
|
|
409
|
+
describe('ConnectionDO — OPER command (env-thread operCreds)', () => {
|
|
410
|
+
it('grants oper with 381 RPL_YOUREOPER for matching OPER_USER/OPER_PASSWORD', async () => {
|
|
411
|
+
const client = await connect('conn-oper-ok');
|
|
412
|
+
await register(client, 'alice');
|
|
413
|
+
|
|
414
|
+
client.send('OPER cf-oper oper-secret\r\n');
|
|
415
|
+
const reply = await client.waitForMessage((l) => / 381 | 491 | 464 | 461 /.test(l));
|
|
416
|
+
|
|
417
|
+
expect(reply).toContain(' 381 ');
|
|
418
|
+
expect(reply).toContain('You are now an IRC operator');
|
|
419
|
+
client.ws.close();
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('rejects oper with 464 ERR_PASSWDMISMATCH for a wrong password', async () => {
|
|
423
|
+
const client = await connect('conn-oper-bad');
|
|
424
|
+
await register(client, 'bob');
|
|
425
|
+
|
|
426
|
+
client.send('OPER cf-oper wrong\r\n');
|
|
427
|
+
const reply = await client.waitForMessage((l) => / 381 | 491 | 464 | 461 /.test(l));
|
|
428
|
+
|
|
429
|
+
expect(reply).toContain(' 464 ');
|
|
430
|
+
client.ws.close();
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
409
434
|
// ---------------------------------------------------------------------------
|
|
410
435
|
// End of file
|
|
411
436
|
// ---------------------------------------------------------------------------
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* `RegistryDO` with sharding plan.
|
|
3
3
|
*
|
|
4
|
-
* TDD outline
|
|
4
|
+
* TDD outline:
|
|
5
5
|
* "Red on concurrent reserve from two stub ConnectionDOs; green."
|
|
6
6
|
*
|
|
7
7
|
* Tests run inside real `workerd` via miniflare so DO storage
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* The binding `REGISTRY_DO_REAL` (declared in `wrangler.test.toml`)
|
|
13
13
|
* points at the real {@link RegistryDO} class. Tests address a
|
|
14
14
|
* specific shard by name (the same `registryKeyForNick` result the
|
|
15
|
-
* CfRuntime will compute
|
|
15
|
+
* CfRuntime will compute), so a single DO instance
|
|
16
16
|
* stands in for one shard of the production fleet.
|
|
17
17
|
*/
|
|
18
18
|
|
|
@@ -34,7 +34,7 @@ declare global {
|
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
36
|
* Returns a stub for the registry shard that owns `nick` under the
|
|
37
|
-
* default shard count. Mirrors what CfRuntime will do
|
|
37
|
+
* default shard count. Mirrors what CfRuntime will do.
|
|
38
38
|
*/
|
|
39
39
|
function shardForNick(nick: string, shards = DEFAULT_REGISTRY_SHARDS): DurableObjectStub {
|
|
40
40
|
const key = registryKeyForNick(nick, shards);
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { type ConnId, type ConnectionState, createConnection } from '@serverless-ircd/irc-core';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { CfStats } from '../src/stats.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Minimal stub satisfying the structural interface CfStats needs (just
|
|
7
|
+
* `listChannels` + `getChannelConnections`). Keeps the unit test free of
|
|
8
|
+
* the workerd/miniflare harness the full CfRuntime integration suite uses.
|
|
9
|
+
*/
|
|
10
|
+
interface StatsRuntimeShape {
|
|
11
|
+
listChannels(): Promise<{ nameLower: string }[]>;
|
|
12
|
+
getChannelConnections(name: string): Promise<ReadonlyMap<ConnId, ConnectionState>>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
class StubRuntime implements StatsRuntimeShape {
|
|
16
|
+
constructor(
|
|
17
|
+
private readonly channels: ReadonlyMap<string, ReadonlyMap<ConnId, ConnectionState>>,
|
|
18
|
+
) {}
|
|
19
|
+
async listChannels(): Promise<{ nameLower: string }[]> {
|
|
20
|
+
return Array.from(this.channels.keys(), (nameLower) => ({ nameLower }));
|
|
21
|
+
}
|
|
22
|
+
async getChannelConnections(name: string): Promise<ReadonlyMap<ConnId, ConnectionState>> {
|
|
23
|
+
return this.channels.get(name) ?? new Map();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeConn(
|
|
28
|
+
id: ConnId,
|
|
29
|
+
opts: { oper?: boolean; invisible?: boolean; registered?: boolean } = {},
|
|
30
|
+
): ConnectionState {
|
|
31
|
+
const s = createConnection({ id, connectedSince: 0 });
|
|
32
|
+
s.nick = id;
|
|
33
|
+
s.registration = opts.registered === false ? 'pre-registration' : 'registered';
|
|
34
|
+
if (opts.oper !== undefined) s.userModes.oper = opts.oper;
|
|
35
|
+
if (opts.invisible !== undefined) s.userModes.invisible = opts.invisible;
|
|
36
|
+
return s;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('CfStats', () => {
|
|
40
|
+
it('returns zero counts for an empty deployment', async () => {
|
|
41
|
+
const rt = new StubRuntime(new Map());
|
|
42
|
+
const stats = new CfStats(rt, 1_000);
|
|
43
|
+
const snap = await stats.getStats();
|
|
44
|
+
expect(snap.users).toBe(0);
|
|
45
|
+
expect(snap.channels).toBe(0);
|
|
46
|
+
expect(snap.servers).toBe(1);
|
|
47
|
+
expect(snap.localConns).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('counts channels and their members (cross-channel dedup)', async () => {
|
|
51
|
+
const alice = makeConn('c1');
|
|
52
|
+
const bob = makeConn('c2', { invisible: true });
|
|
53
|
+
const carol = makeConn('c3', { oper: true });
|
|
54
|
+
// Alice and Bob share #a; Carol is alone in #b. Alice is also in #b
|
|
55
|
+
// (dup across channels) — must be counted once.
|
|
56
|
+
const channels = new Map<string, ReadonlyMap<ConnId, ConnectionState>>([
|
|
57
|
+
[
|
|
58
|
+
'#a',
|
|
59
|
+
new Map<ConnId, ConnectionState>([
|
|
60
|
+
['c1', alice],
|
|
61
|
+
['c2', bob],
|
|
62
|
+
]),
|
|
63
|
+
],
|
|
64
|
+
[
|
|
65
|
+
'#b',
|
|
66
|
+
new Map<ConnId, ConnectionState>([
|
|
67
|
+
['c1', alice],
|
|
68
|
+
['c3', carol],
|
|
69
|
+
]),
|
|
70
|
+
],
|
|
71
|
+
]);
|
|
72
|
+
const rt = new StubRuntime(channels);
|
|
73
|
+
|
|
74
|
+
const stats = new CfStats(rt, 1_000);
|
|
75
|
+
const snap = await stats.getStats();
|
|
76
|
+
|
|
77
|
+
expect(snap.channels).toBe(2);
|
|
78
|
+
// 3 unique connections (alice, bob, carol).
|
|
79
|
+
expect(snap.localConns).toBe(3);
|
|
80
|
+
// 3 registered, 1 invisible → 2 visible.
|
|
81
|
+
expect(snap.users).toBe(2);
|
|
82
|
+
expect(snap.invisible).toBe(1);
|
|
83
|
+
expect(snap.opers).toBe(1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('counts a pre-registration member as an unknown connection', async () => {
|
|
87
|
+
const a = makeConn('c1');
|
|
88
|
+
const b = makeConn('c2', { registered: false });
|
|
89
|
+
const channels = new Map<string, ReadonlyMap<ConnId, ConnectionState>>([
|
|
90
|
+
[
|
|
91
|
+
'#a',
|
|
92
|
+
new Map<ConnId, ConnectionState>([
|
|
93
|
+
['c1', a],
|
|
94
|
+
['c2', b],
|
|
95
|
+
]),
|
|
96
|
+
],
|
|
97
|
+
]);
|
|
98
|
+
const rt = new StubRuntime(channels);
|
|
99
|
+
|
|
100
|
+
const stats = new CfStats(rt, 1_000);
|
|
101
|
+
const snap = await stats.getStats();
|
|
102
|
+
expect(snap.users).toBe(1);
|
|
103
|
+
expect(snap.unknownConnections).toBe(1);
|
|
104
|
+
expect(snap.localConns).toBe(2);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('echoes the supplied uptimeStartedAt', async () => {
|
|
108
|
+
const rt = new StubRuntime(new Map());
|
|
109
|
+
const stats = new CfStats(rt, 9_999);
|
|
110
|
+
const snap = await stats.getStats();
|
|
111
|
+
expect(snap.uptimeStartedAt).toBe(9_999);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('returns servers=1 (single-server deployment)', async () => {
|
|
115
|
+
const rt = new StubRuntime(new Map());
|
|
116
|
+
const stats = new CfStats(rt, 0);
|
|
117
|
+
const snap = await stats.getStats();
|
|
118
|
+
expect(snap.servers).toBe(1);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -7,15 +7,15 @@
|
|
|
7
7
|
* and assert cross-DO interactions.
|
|
8
8
|
*
|
|
9
9
|
* Bindings:
|
|
10
|
-
* - `CONNECTION_DO` → `ConnectionDO` (real
|
|
10
|
+
* - `CONNECTION_DO` → `ConnectionDO` (real).
|
|
11
11
|
* - `REGISTRY_DO` → `RecordingRegistryDO` stub used by the
|
|
12
|
-
* ConnectionDO tests
|
|
12
|
+
* ConnectionDO tests. The stub records every RPC call
|
|
13
13
|
* into a per-instance log exposed via `runInDurableObject`.
|
|
14
|
-
* - `REGISTRY_DO_REAL` → `RegistryDO` (real
|
|
15
|
-
* the RegistryDO unit tests.
|
|
16
|
-
* over to this class once
|
|
17
|
-
* - `CHANNEL_DO` → `RecordingChannelDO` stub (real impl
|
|
18
|
-
*
|
|
14
|
+
* - `REGISTRY_DO_REAL` → `RegistryDO` (real). Used by
|
|
15
|
+
* the RegistryDO unit tests. The CfRuntime swaps `REGISTRY_DO`
|
|
16
|
+
* over to this class once it lands.
|
|
17
|
+
* - `CHANNEL_DO` → `RecordingChannelDO` stub (real impl in
|
|
18
|
+
* `ChannelDO`).
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { ChannelDO } from '../../src/channel-do';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Test-only {@link DurableObject} that records every cross-DO call from
|
|
3
|
-
* {@link ConnectionDO}. The real ChannelDO ships in
|
|
4
|
-
*
|
|
3
|
+
* {@link ConnectionDO}. The real ChannelDO ships in `channel-do.ts`;
|
|
4
|
+
* we only need to verify that ConnectionDO fan-outs (broadcast,
|
|
5
5
|
* applyChannelDelta) reach it.
|
|
6
6
|
*
|
|
7
7
|
* Each instance is keyed by lowercased channel name (the convention the
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* {@link ConnectionDO} into its `state.storage`. Tests inspect the call
|
|
4
4
|
* log via `runInDurableObject` after exercising the connection.
|
|
5
5
|
*
|
|
6
|
-
* PLAN §2.1 — the real RegistryDO ships
|
|
6
|
+
* PLAN §2.1 — the real RegistryDO ships separately. We
|
|
7
7
|
* only need enough surface area to assert that ConnectionDO closes
|
|
8
8
|
* release the nick and fan out QUIT correctly. The stub records the
|
|
9
9
|
* requested operations verbatim; it does not enforce uniqueness.
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { DurableObject } from 'cloudflare:workers';
|
|
13
13
|
|
|
14
14
|
export interface RecordedRegistryCall {
|
|
15
|
-
method: 'reserveNick' | 'changeNick' | 'releaseNick' | 'lookupNick';
|
|
15
|
+
method: 'reserveNick' | 'changeNick' | 'releaseNick' | 'lookupNick' | 'listEntries';
|
|
16
16
|
args: unknown[];
|
|
17
17
|
}
|
|
18
18
|
|
|
@@ -21,6 +21,7 @@ interface RegistryRpc {
|
|
|
21
21
|
changeNick(conn: string, oldNick: string, newNick: string): Promise<boolean>;
|
|
22
22
|
releaseNick(nick: string): Promise<void>;
|
|
23
23
|
lookupNick(nick: string): Promise<string | null>;
|
|
24
|
+
listEntries(): Promise<Array<[nickLower: string, connId: string]>>;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
const LOG_KEY = 'registry-call-log';
|
|
@@ -54,4 +55,9 @@ export class RecordingRegistryDO extends DurableObject implements RegistryRpc {
|
|
|
54
55
|
await this.append({ method: 'lookupNick', args: [nick] });
|
|
55
56
|
return null;
|
|
56
57
|
}
|
|
58
|
+
|
|
59
|
+
async listEntries(): Promise<Array<[nickLower: string, connId: string]>> {
|
|
60
|
+
await this.append({ method: 'listEntries', args: [] });
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
57
63
|
}
|
|
@@ -8,11 +8,18 @@ compatibility_flags = ["nodejs_compat"]
|
|
|
8
8
|
# integration-test harness so scenario 18 (MOTD) asserts against the same
|
|
9
9
|
# body text in every runtime factory.
|
|
10
10
|
[vars]
|
|
11
|
+
SERVER_NAME = "irc.example.com"
|
|
12
|
+
NETWORK_NAME = "ServerlessIRCd"
|
|
11
13
|
MOTD_LINES = "Welcome to the ServerlessIRCd integration-test harness.\nAll scenarios run parametrized over the IrcRuntime factory matrix."
|
|
12
14
|
# SASL PLAIN accounts seeded into the in-worker AccountStore. Newline-
|
|
13
15
|
# delimited `username:password` pairs; the connection-do parses these into
|
|
14
16
|
# an InMemoryAccountStore so AUTHENTICATE PLAIN works end-to-end in tests.
|
|
15
17
|
SASL_ACCOUNTS = "cf-sasl:s3cret"
|
|
18
|
+
# IRC operator credentials consulted by the OPER reducer. The DO must
|
|
19
|
+
# thread these from env into the reducer-facing ServerConfig so
|
|
20
|
+
# `OPER <user> <pass>` returns 381 rather than 491 ERR_NOOPERHOST.
|
|
21
|
+
OPER_USER = "cf-oper"
|
|
22
|
+
OPER_PASSWORD = "oper-secret"
|
|
16
23
|
|
|
17
24
|
# D1 database backing persistent SASL accounts. The D1 account-store suite
|
|
18
25
|
# creates the `accounts` table in a beforeEach and exercises the real
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
type ConnectionState,
|
|
27
27
|
type Nick,
|
|
28
28
|
type RawLine,
|
|
29
|
+
type ServerConfig,
|
|
29
30
|
applyChannelDelta as applyDelta,
|
|
30
31
|
caseFold,
|
|
31
32
|
createChannel,
|
|
@@ -64,6 +65,15 @@ export interface InMemoryRuntimeOptions {
|
|
|
64
65
|
* deployment's `ServerConfig`.
|
|
65
66
|
*/
|
|
66
67
|
readonly admission?: AdmissionConfig;
|
|
68
|
+
/**
|
|
69
|
+
* Config-reload source for `REHASH`. When bound, the runtime re-invokes
|
|
70
|
+
* it on every {@link InMemoryRuntime.reloadConfig} call and returns the
|
|
71
|
+
* fresh {@link ServerConfig}; the local-cli binds the loader that
|
|
72
|
+
* re-derives config from its original options. When omitted
|
|
73
|
+
* `reloadConfig` rejects so the actor's graceful failure path applies a
|
|
74
|
+
* consistent error-suffixed `382`.
|
|
75
|
+
*/
|
|
76
|
+
readonly configLoader?: () => Promise<ServerConfig>;
|
|
67
77
|
}
|
|
68
78
|
|
|
69
79
|
export class InMemoryRuntime implements IrcRuntime {
|
|
@@ -73,9 +83,11 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
73
83
|
private readonly nicks = new Map<string, ConnId>();
|
|
74
84
|
private readonly admissionStats: AdmissionStats | undefined;
|
|
75
85
|
private readonly admissionConfig: AdmissionConfig | undefined;
|
|
86
|
+
private readonly configLoader: (() => Promise<ServerConfig>) | undefined;
|
|
76
87
|
|
|
77
88
|
constructor(opts: InMemoryRuntimeOptions) {
|
|
78
89
|
this.clock = opts.clock;
|
|
90
|
+
this.configLoader = opts.configLoader;
|
|
79
91
|
if (opts.admission !== undefined) {
|
|
80
92
|
this.admissionConfig = opts.admission;
|
|
81
93
|
this.admissionStats = new AdmissionStats(this.clock);
|
|
@@ -242,6 +254,22 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
242
254
|
}
|
|
243
255
|
}
|
|
244
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Global cross-connection fanout for `WALLOPS`. Scans the connection map
|
|
259
|
+
* and delivers `lines` to every live connection whose `+w` user mode is
|
|
260
|
+
* set, skipping `except` (the originator). Unlike {@link broadcast} this
|
|
261
|
+
* is NOT channel-scoped — it reaches connections with no shared channel
|
|
262
|
+
* membership. This is the reference implementation the CF and AWS
|
|
263
|
+
* adapters mirror against their own enumeration primitives.
|
|
264
|
+
*/
|
|
265
|
+
async broadcastWallops(lines: RawLine[], except?: ConnId): Promise<void> {
|
|
266
|
+
for (const [connId, tracked] of this.connections) {
|
|
267
|
+
if (connId === except) continue;
|
|
268
|
+
if (!tracked.state.userModes.wallops) continue;
|
|
269
|
+
tracked.handlers.send(lines);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
245
273
|
// ------------------------------------------------------------------
|
|
246
274
|
// IrcRuntime — nick registry
|
|
247
275
|
// ------------------------------------------------------------------
|
|
@@ -333,4 +361,15 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
333
361
|
async listChannels(): Promise<ChanSnapshot[]> {
|
|
334
362
|
return Array.from(this.channels.values(), (c) => toChannelSnapshot(c));
|
|
335
363
|
}
|
|
364
|
+
|
|
365
|
+
// ------------------------------------------------------------------
|
|
366
|
+
// IrcRuntime — config reload (REHASH)
|
|
367
|
+
// ------------------------------------------------------------------
|
|
368
|
+
|
|
369
|
+
async reloadConfig(): Promise<ServerConfig> {
|
|
370
|
+
if (this.configLoader === undefined) {
|
|
371
|
+
throw new Error('no config loader bound to InMemoryRuntime');
|
|
372
|
+
}
|
|
373
|
+
return this.configLoader();
|
|
374
|
+
}
|
|
336
375
|
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type ConnectionState,
|
|
6
6
|
FakeClock,
|
|
7
7
|
type RawLine,
|
|
8
|
+
type ServerConfig,
|
|
8
9
|
createConnection,
|
|
9
10
|
} from '@serverless-ircd/irc-core';
|
|
10
11
|
import { describe, expect, it } from 'vitest';
|
|
@@ -124,6 +125,26 @@ describe('InMemoryRuntime — changeNick', () => {
|
|
|
124
125
|
expect(await rt.lookupNick('alice')).toBe('c1');
|
|
125
126
|
expect(await rt.lookupNick('bob')).toBe('c2');
|
|
126
127
|
});
|
|
128
|
+
|
|
129
|
+
it('does not clobber a third party that grabbed the old nick via re-reserve (stale-release guard)', async () => {
|
|
130
|
+
// Covers the defensive branch on the old-mapping clear: c1 held
|
|
131
|
+
// `alice`, released it, c2 re-reserved it, then c1 reports a nick
|
|
132
|
+
// change FROM `alice` (stale state). The guard must NOT delete the
|
|
133
|
+
// alice mapping that now belongs to c2.
|
|
134
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
135
|
+
rt.registerConnection(makeConn('c1'), { send: () => {}, disconnect: () => {} });
|
|
136
|
+
rt.registerConnection(makeConn('c2'), { send: () => {}, disconnect: () => {} });
|
|
137
|
+
|
|
138
|
+
await rt.reserveNick('alice', 'c1');
|
|
139
|
+
await rt.releaseNick('alice'); // nicks[alice] deleted (still pointed at c1)
|
|
140
|
+
await rt.reserveNick('alice', 'c2'); // nicks[alice] = c2
|
|
141
|
+
|
|
142
|
+
const ok = await rt.changeNick('c1', 'alice', 'bob');
|
|
143
|
+
expect(ok).toBe(true);
|
|
144
|
+
// c1 got `bob`, c2 still holds `alice` (the stale clear was skipped).
|
|
145
|
+
expect(await rt.lookupNick('alice')).toBe('c2');
|
|
146
|
+
expect(await rt.lookupNick('bob')).toBe('c1');
|
|
147
|
+
});
|
|
127
148
|
});
|
|
128
149
|
|
|
129
150
|
describe('InMemoryRuntime — releaseNick', () => {
|
|
@@ -255,6 +276,100 @@ describe('InMemoryRuntime — applyChannelDelta / getChannelSnapshot', () => {
|
|
|
255
276
|
});
|
|
256
277
|
});
|
|
257
278
|
|
|
279
|
+
describe('InMemoryRuntime — getChannel (peek-only)', () => {
|
|
280
|
+
it('returns the authoritative ChannelState when the channel exists', async () => {
|
|
281
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
282
|
+
await rt.applyChannelDelta('#foo', {
|
|
283
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const chan = rt.getChannel('#foo');
|
|
287
|
+
expect(chan).toBeDefined();
|
|
288
|
+
expect(chan?.name).toBe('#foo');
|
|
289
|
+
expect(chan?.members.has('c1')).toBe(true);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('returns undefined for an unknown channel (does not create it)', async () => {
|
|
293
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
294
|
+
expect(rt.getChannel('#nope')).toBeUndefined();
|
|
295
|
+
// Peek-only: the channel map is still empty.
|
|
296
|
+
expect(await rt.listChannels()).toEqual([]);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('folds the channel name with rfc1459 case-mapping on lookup', async () => {
|
|
300
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
301
|
+
await rt.applyChannelDelta('#Foo', {
|
|
302
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
303
|
+
});
|
|
304
|
+
expect(rt.getChannel('#foo')).toBeDefined();
|
|
305
|
+
expect(rt.getChannel('#FOO')).toBeDefined();
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
describe('InMemoryRuntime — getChannelConnections', () => {
|
|
310
|
+
it('returns the live ConnectionState for each registered member', async () => {
|
|
311
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
312
|
+
rt.registerConnection(makeConn('c1', 'alice'), { send: () => {}, disconnect: () => {} });
|
|
313
|
+
rt.registerConnection(makeConn('c2', 'bob'), { send: () => {}, disconnect: () => {} });
|
|
314
|
+
await rt.applyChannelDelta('#foo', {
|
|
315
|
+
memberships: [
|
|
316
|
+
{ type: 'add', conn: 'c1', nick: 'alice' },
|
|
317
|
+
{ type: 'add', conn: 'c2', nick: 'bob' },
|
|
318
|
+
],
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
const conns = await rt.getChannelConnections('#foo');
|
|
322
|
+
expect(conns.size).toBe(2);
|
|
323
|
+
expect(conns.get('c1')?.nick).toBe('alice');
|
|
324
|
+
expect(conns.get('c2')?.nick).toBe('bob');
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('returns an empty map for an unknown channel', async () => {
|
|
328
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
329
|
+
expect(await rt.getChannelConnections('#nope')).toEqual(new Map());
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('skips members whose connection is no longer tracked', async () => {
|
|
333
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
334
|
+
rt.registerConnection(makeConn('c1', 'alice'), { send: () => {}, disconnect: () => {} });
|
|
335
|
+
// c2 is referenced by a membership but was never registered (and c1's
|
|
336
|
+
// registration is dropped mid-test to exercise the filter branch).
|
|
337
|
+
await rt.applyChannelDelta('#foo', {
|
|
338
|
+
memberships: [
|
|
339
|
+
{ type: 'add', conn: 'c1', nick: 'alice' },
|
|
340
|
+
{ type: 'add', conn: 'c2', nick: 'bob' },
|
|
341
|
+
],
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const conns = await rt.getChannelConnections('#foo');
|
|
345
|
+
expect(conns.size).toBe(1);
|
|
346
|
+
expect(conns.get('c1')?.nick).toBe('alice');
|
|
347
|
+
expect(conns.has('c2')).toBe(false);
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
describe('InMemoryRuntime — listChannels', () => {
|
|
352
|
+
it('returns an empty array when no channels exist', async () => {
|
|
353
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
354
|
+
expect(await rt.listChannels()).toEqual([]);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('returns a snapshot per channel, preserving original casing', async () => {
|
|
358
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
359
|
+
await rt.applyChannelDelta('#Foo', {
|
|
360
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
361
|
+
});
|
|
362
|
+
await rt.applyChannelDelta('#bar', {
|
|
363
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const list = await rt.listChannels();
|
|
367
|
+
expect(list).toHaveLength(2);
|
|
368
|
+
const names = list.map((c) => c.name).sort();
|
|
369
|
+
expect(names).toEqual(['#Foo', '#bar']);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
258
373
|
describe('InMemoryRuntime — send', () => {
|
|
259
374
|
it('delivers lines to the target connection send handler', async () => {
|
|
260
375
|
const sent: RawLine[][] = [];
|
|
@@ -430,6 +545,28 @@ describe('InMemoryRuntime — sendToNick', () => {
|
|
|
430
545
|
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
431
546
|
await expect(rt.sendToNick('ghost', 'c1', [L('hi')], [L('401')])).resolves.toBeUndefined();
|
|
432
547
|
});
|
|
548
|
+
|
|
549
|
+
it('falls through to notFoundLines when the owner nick is registered but its connection is gone', async () => {
|
|
550
|
+
// Covers the stale-nick defensive branch in sendToNick: `this.nicks`
|
|
551
|
+
// still maps the nick to a connId, but that conn is no longer tracked
|
|
552
|
+
// (unregisterConnection deletes the conn without releasing its nick —
|
|
553
|
+
// the comment on unregisterConnection documents this split).
|
|
554
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
555
|
+
const senderOut: RawLine[][] = [];
|
|
556
|
+
rt.registerConnection(makeConn('c1', 'alice'), { send: () => {}, disconnect: () => {} });
|
|
557
|
+
rt.registerConnection(makeConn('c2', 'bob'), {
|
|
558
|
+
send: (lines) => {
|
|
559
|
+
senderOut.push(lines);
|
|
560
|
+
},
|
|
561
|
+
disconnect: () => {},
|
|
562
|
+
});
|
|
563
|
+
await rt.reserveNick('alice', 'c1');
|
|
564
|
+
// Remove c1 from the connection map while leaving the nick mapping intact.
|
|
565
|
+
rt.unregisterConnection('c1');
|
|
566
|
+
|
|
567
|
+
await rt.sendToNick('alice', 'c2', [L('hi')], [L('401 bob alice :No such nick')]);
|
|
568
|
+
expect(senderOut).toEqual([[{ text: '401 bob alice :No such nick' }]]);
|
|
569
|
+
});
|
|
433
570
|
});
|
|
434
571
|
|
|
435
572
|
describe('InMemoryRuntime — disconnect', () => {
|
|
@@ -634,3 +771,125 @@ describe('InMemoryRuntime — admission gate', () => {
|
|
|
634
771
|
}
|
|
635
772
|
});
|
|
636
773
|
});
|
|
774
|
+
|
|
775
|
+
describe('InMemoryRuntime — reloadConfig (REHASH)', () => {
|
|
776
|
+
const baseConfig: ServerConfig = {
|
|
777
|
+
serverName: 'irc.example.com',
|
|
778
|
+
networkName: 'ExampleNet',
|
|
779
|
+
maxChannelsPerUser: 30,
|
|
780
|
+
maxTargetsPerCommand: 10,
|
|
781
|
+
maxListEntries: 50,
|
|
782
|
+
nickLen: 30,
|
|
783
|
+
channelLen: 50,
|
|
784
|
+
topicLen: 390,
|
|
785
|
+
quitMessage: 'Client Quit',
|
|
786
|
+
};
|
|
787
|
+
|
|
788
|
+
it('re-invokes the bound loader and returns the fresh config', async () => {
|
|
789
|
+
let version = 1;
|
|
790
|
+
const rt = new InMemoryRuntime({
|
|
791
|
+
clock: new FakeClock(0),
|
|
792
|
+
configLoader: async () => ({ ...baseConfig, serverName: `v${version}.example.com` }),
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
const first = await rt.reloadConfig();
|
|
796
|
+
expect(first.serverName).toBe('v1.example.com');
|
|
797
|
+
version = 2;
|
|
798
|
+
const second = await rt.reloadConfig();
|
|
799
|
+
expect(second.serverName).toBe('v2.example.com');
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
it('propagates a loader rejection so the actor can apply the graceful failure path', async () => {
|
|
803
|
+
const rt = new InMemoryRuntime({
|
|
804
|
+
clock: new FakeClock(0),
|
|
805
|
+
configLoader: async () => {
|
|
806
|
+
throw new Error('store unreachable');
|
|
807
|
+
},
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
await expect(rt.reloadConfig()).rejects.toThrow('store unreachable');
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
it('rejects when no loader was bound at construction', async () => {
|
|
814
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
815
|
+
|
|
816
|
+
await expect(rt.reloadConfig()).rejects.toThrow('no config loader bound');
|
|
817
|
+
});
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
describe('InMemoryRuntime — broadcastWallops', () => {
|
|
821
|
+
it('delivers lines to every connection with the +w user mode set', async () => {
|
|
822
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
823
|
+
const received: Record<string, RawLine[][]> = { c1: [], c2: [], c3: [] };
|
|
824
|
+
const push = (id: string) => (lines: RawLine[]) => received[id]?.push(lines);
|
|
825
|
+
// c1 has NO +w; c2 and c3 do.
|
|
826
|
+
rt.registerConnection(makeConn('c1', 'alice'), { send: push('c1'), disconnect: () => {} });
|
|
827
|
+
const c2 = makeConn('c2', 'bob');
|
|
828
|
+
c2.userModes.wallops = true;
|
|
829
|
+
rt.registerConnection(c2, { send: push('c2'), disconnect: () => {} });
|
|
830
|
+
const c3 = makeConn('c3', 'carol');
|
|
831
|
+
c3.userModes.wallops = true;
|
|
832
|
+
rt.registerConnection(c3, { send: push('c3'), disconnect: () => {} });
|
|
833
|
+
|
|
834
|
+
await rt.broadcastWallops([L(':alice WALLOPS :reboot')]);
|
|
835
|
+
|
|
836
|
+
// c1 has no +w → no delivery.
|
|
837
|
+
expect(received.c1).toHaveLength(0);
|
|
838
|
+
expect(received.c2).toHaveLength(1);
|
|
839
|
+
expect(received.c3).toHaveLength(1);
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
it('skips the except connection even when it has +w', async () => {
|
|
843
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
844
|
+
const received: Record<string, RawLine[][]> = { c1: [], c2: [] };
|
|
845
|
+
const push = (id: string) => (lines: RawLine[]) => received[id]?.push(lines);
|
|
846
|
+
const oper = makeConn('c1', 'alice');
|
|
847
|
+
oper.userModes.wallops = true;
|
|
848
|
+
oper.userModes.oper = true;
|
|
849
|
+
rt.registerConnection(oper, { send: push('c1'), disconnect: () => {} });
|
|
850
|
+
const listener = makeConn('c2', 'bob');
|
|
851
|
+
listener.userModes.wallops = true;
|
|
852
|
+
rt.registerConnection(listener, { send: push('c2'), disconnect: () => {} });
|
|
853
|
+
|
|
854
|
+
await rt.broadcastWallops([L(':alice WALLOPS :hi')], 'c1');
|
|
855
|
+
|
|
856
|
+
// The oper (except) never receives their own wallops; the +w peer does.
|
|
857
|
+
expect(received.c1).toHaveLength(0);
|
|
858
|
+
expect(received.c2).toHaveLength(1);
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
it('does not deliver to +w connections that have unregistered', async () => {
|
|
862
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
863
|
+
let c2Received = 0;
|
|
864
|
+
rt.registerConnection(makeConn('c1', 'alice'), {
|
|
865
|
+
send: () => {},
|
|
866
|
+
disconnect: () => {},
|
|
867
|
+
});
|
|
868
|
+
const c2 = makeConn('c2', 'bob');
|
|
869
|
+
c2.userModes.wallops = true;
|
|
870
|
+
rt.registerConnection(c2, {
|
|
871
|
+
send: () => {
|
|
872
|
+
c2Received++;
|
|
873
|
+
},
|
|
874
|
+
disconnect: () => {},
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
rt.unregisterConnection('c2');
|
|
878
|
+
await rt.broadcastWallops([L(':alice WALLOPS :hi')]);
|
|
879
|
+
expect(c2Received).toBe(0);
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
it('is a no-op when no connection has +w', async () => {
|
|
883
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
884
|
+
let c1Received = 0;
|
|
885
|
+
rt.registerConnection(makeConn('c1', 'alice'), {
|
|
886
|
+
send: () => {
|
|
887
|
+
c1Received++;
|
|
888
|
+
},
|
|
889
|
+
disconnect: () => {},
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
await rt.broadcastWallops([L(':a WALLOPS :hi')]);
|
|
893
|
+
expect(c1Received).toBe(0);
|
|
894
|
+
});
|
|
895
|
+
});
|