serverless-ircd 0.1.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/.github/workflows/ci.yml +96 -2
- package/.github/workflows/deploy-aws.yml +129 -0
- package/.github/workflows/deploy-cf.yml +0 -2
- package/.gitmodules +3 -0
- package/CHANGELOG.md +436 -0
- package/README.md +127 -84
- package/apps/aws-stack/README.md +73 -0
- package/apps/aws-stack/bin/aws.ts +49 -0
- package/apps/aws-stack/cdk.json +10 -0
- package/apps/aws-stack/package.json +41 -0
- package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
- package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
- package/apps/aws-stack/scripts/smoke.mjs +142 -0
- package/apps/aws-stack/src/aws-stack.ts +263 -0
- package/apps/aws-stack/src/tables.ts +10 -0
- package/apps/aws-stack/tests/localstack.test.ts +46 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
- package/apps/aws-stack/tests/stack.test.ts +464 -0
- package/apps/aws-stack/tsconfig.build.json +11 -0
- package/apps/aws-stack/tsconfig.test.json +8 -0
- package/apps/aws-stack/vitest.config.ts +20 -0
- 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 +66 -17
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +57 -0
- package/apps/local-cli/src/main.ts +1 -1
- package/apps/local-cli/src/server.ts +267 -32
- package/apps/local-cli/tests/config-loader.test.ts +107 -0
- package/apps/local-cli/tests/e2e.test.ts +126 -0
- package/apps/local-cli/tests/security-e2e.test.ts +239 -0
- package/biome.json +3 -1
- 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 +496 -0
- package/docs/AWS-Deployment.md +1186 -0
- package/docs/Cloudflare-Deployment-Guide.md +660 -0
- package/docs/Home.md +11 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanIRCv3Websocket.md +489 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +443 -0
- package/package.json +19 -14
- package/packages/aws-adapter/README.md +35 -0
- package/packages/aws-adapter/package.json +52 -0
- package/packages/aws-adapter/src/account-store.ts +121 -0
- package/packages/aws-adapter/src/aws-runtime.ts +871 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +73 -0
- package/packages/aws-adapter/src/config-loader.ts +126 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
- package/packages/aws-adapter/src/dynamo.ts +61 -0
- package/packages/aws-adapter/src/handlers/connect.ts +44 -0
- package/packages/aws-adapter/src/handlers/default.ts +330 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +48 -0
- package/packages/aws-adapter/src/handlers/index.ts +293 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
- package/packages/aws-adapter/src/handlers/state.ts +13 -0
- package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
- package/packages/aws-adapter/src/index.ts +74 -0
- package/packages/aws-adapter/src/message-store.ts +34 -0
- package/packages/aws-adapter/src/serialize.ts +283 -0
- package/packages/aws-adapter/src/tables.ts +53 -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/aws-harness.ts +408 -0
- package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
- package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +213 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
- package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
- package/packages/aws-adapter/tests/global-setup.ts +301 -0
- package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
- package/packages/aws-adapter/tests/handlers.test.ts +473 -0
- package/packages/aws-adapter/tests/message-store.test.ts +55 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
- package/packages/aws-adapter/tests/serialize.test.ts +249 -0
- package/packages/aws-adapter/tests/smoke.test.ts +48 -0
- package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
- package/packages/aws-adapter/tests/tables.test.ts +74 -0
- package/packages/aws-adapter/tests/transactions.test.ts +446 -0
- package/packages/aws-adapter/tsconfig.build.json +16 -0
- package/packages/aws-adapter/tsconfig.test.json +14 -0
- package/packages/aws-adapter/vitest.config.ts +23 -0
- package/packages/cf-adapter/package.json +2 -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 +135 -0
- package/packages/cf-adapter/src/connection-do.ts +119 -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-harness.ts +2 -2
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
- package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +82 -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 +107 -16
- package/packages/in-memory-runtime/src/index.ts +1 -1
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +134 -0
- package/packages/irc-core/package.json +13 -2
- package/packages/irc-core/src/admission.ts +216 -0
- package/packages/irc-core/src/caps/capabilities.ts +1 -0
- package/packages/irc-core/src/case-fold.ts +64 -0
- package/packages/irc-core/src/cloak.ts +81 -0
- package/packages/irc-core/src/commands/cap.ts +1 -2
- package/packages/irc-core/src/commands/chathistory.ts +305 -0
- package/packages/irc-core/src/commands/index.ts +9 -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 +63 -10
- 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/privmsg.ts +24 -0
- package/packages/irc-core/src/commands/registration.ts +79 -21
- package/packages/irc-core/src/commands/sasl.ts +251 -0
- package/packages/irc-core/src/commands/tagmsg.ts +205 -0
- package/packages/irc-core/src/config.ts +270 -0
- package/packages/irc-core/src/flood-control.ts +175 -0
- package/packages/irc-core/src/index.ts +7 -0
- package/packages/irc-core/src/ports.ts +719 -0
- package/packages/irc-core/src/protocol/base64.ts +16 -0
- package/packages/irc-core/src/protocol/index.ts +2 -1
- package/packages/irc-core/src/protocol/numerics.ts +11 -0
- package/packages/irc-core/src/protocol/parser.ts +27 -2
- package/packages/irc-core/src/state/connection.ts +41 -0
- package/packages/irc-core/src/types.ts +88 -2
- package/packages/irc-core/stryker.commands.conf.json +41 -0
- package/packages/irc-core/stryker.protocol.conf.json +26 -0
- package/packages/irc-core/tests/account-store.test.ts +88 -0
- package/packages/irc-core/tests/admission.test.ts +229 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
- package/packages/irc-core/tests/case-fold.test.ts +80 -0
- package/packages/irc-core/tests/cloak.test.ts +78 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
- package/packages/irc-core/tests/commands/join.test.ts +246 -2
- 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/privmsg.test.ts +146 -2
- package/packages/irc-core/tests/commands/registration.test.ts +380 -20
- package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
- 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 +721 -0
- package/packages/irc-core/tests/flood-control.test.ts +394 -0
- package/packages/irc-core/tests/message-store.test.ts +530 -0
- package/packages/irc-core/tests/parser.test.ts +44 -1
- package/packages/irc-core/tests/ports.test.ts +263 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +186 -44
- package/packages/irc-server/src/dispatch.ts +89 -5
- package/packages/irc-server/src/index.ts +2 -0
- package/packages/irc-server/src/routing.ts +160 -0
- package/packages/irc-server/tests/actor.test.ts +690 -15
- package/packages/irc-server/tests/dispatch.test.ts +84 -0
- package/packages/irc-server/tests/routing.test.ts +204 -0
- package/packages/irc-test-support/README.md +32 -0
- package/packages/irc-test-support/package.json +37 -0
- package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +6 -5
- package/packages/irc-test-support/src/index.ts +24 -0
- package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
- package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
- package/packages/irc-test-support/tests/smoke.test.ts +11 -0
- package/packages/irc-test-support/tsconfig.build.json +16 -0
- package/packages/irc-test-support/tsconfig.test.json +14 -0
- package/packages/irc-test-support/vitest.config.ts +20 -0
- package/pnpm-workspace.yaml +1 -0
- package/tools/ci-hardening/package.json +30 -0
- package/tools/ci-hardening/src/index.ts +6 -0
- package/tools/ci-hardening/src/validate.ts +103 -0
- package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
- package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
- package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
- package/tools/ci-hardening/tests/validate.test.ts +177 -0
- package/tools/ci-hardening/tsconfig.build.json +12 -0
- package/tools/ci-hardening/tsconfig.test.json +10 -0
- package/tools/ci-hardening/vitest.config.ts +21 -0
- package/tools/seed-aws-accounts.ts +80 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
- package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
- package/tools/tcp-ws-forwarder/src/main.ts +33 -14
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
- package/packages/cf-adapter/tests/integration/index.ts +0 -17
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config loading for the cf-adapter.
|
|
3
|
+
*
|
|
4
|
+
* Verifies the shared `ServerConfigSchema` (from `irc-core`) is consumed
|
|
5
|
+
* by the Cloudflare adapter — both adapters (CF + AWS) build the same
|
|
6
|
+
* shape from their platform-native sources.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from 'vitest';
|
|
10
|
+
import { loadServerConfigFromCfEnv } from '../src/config-loader';
|
|
11
|
+
|
|
12
|
+
interface CfEnvLike {
|
|
13
|
+
SERVER_NAME?: string;
|
|
14
|
+
NETWORK_NAME?: string;
|
|
15
|
+
MOTD_LINES?: string;
|
|
16
|
+
MAX_CLIENTS?: string;
|
|
17
|
+
CHANNEL_PREFIXES?: string;
|
|
18
|
+
OPER_USER?: string;
|
|
19
|
+
OPER_PASSWORD?: string;
|
|
20
|
+
MAX_CHANNELS_PER_USER?: string;
|
|
21
|
+
MAX_TARGETS_PER_COMMAND?: string;
|
|
22
|
+
NICK_LEN?: string;
|
|
23
|
+
CHANNEL_LEN?: string;
|
|
24
|
+
TOPIC_LEN?: string;
|
|
25
|
+
MAX_LIST_ENTRIES?: string;
|
|
26
|
+
QUIT_MESSAGE?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function baseEnv(): CfEnvLike {
|
|
30
|
+
return { SERVER_NAME: 'irc.cf.example.com', NETWORK_NAME: 'CfNet' };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('loadServerConfigFromCfEnv — valid env', () => {
|
|
34
|
+
it('returns a parsed config derived from the minimal required vars', () => {
|
|
35
|
+
const cfg = loadServerConfigFromCfEnv(baseEnv());
|
|
36
|
+
expect(cfg.serverName).toBe('irc.cf.example.com');
|
|
37
|
+
expect(cfg.networkName).toBe('CfNet');
|
|
38
|
+
// Defaults still apply.
|
|
39
|
+
expect(cfg.channelPrefixes.length).toBeGreaterThan(0);
|
|
40
|
+
expect(cfg.maxClients).toBeGreaterThan(0);
|
|
41
|
+
expect(cfg.operCreds).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('splits MOTD_LINES on newlines into motdLines', () => {
|
|
45
|
+
const cfg = loadServerConfigFromCfEnv({
|
|
46
|
+
...baseEnv(),
|
|
47
|
+
MOTD_LINES: 'line 1\nline 2\nline 3',
|
|
48
|
+
});
|
|
49
|
+
expect(cfg.motdLines).toEqual(['line 1', 'line 2', 'line 3']);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('treats an undefined MOTD_LINES as the default empty MOTD', () => {
|
|
53
|
+
const cfg = loadServerConfigFromCfEnv(baseEnv());
|
|
54
|
+
expect(cfg.motdLines).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('parses MAX_CLIENTS as an integer', () => {
|
|
58
|
+
const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), MAX_CLIENTS: '4242' });
|
|
59
|
+
expect(cfg.maxClients).toBe(4242);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('parses CHANNEL_PREFIXES verbatim', () => {
|
|
63
|
+
const cfg = loadServerConfigFromCfEnv({
|
|
64
|
+
...baseEnv(),
|
|
65
|
+
CHANNEL_PREFIXES: '#&+',
|
|
66
|
+
});
|
|
67
|
+
expect(cfg.channelPrefixes).toBe('#&+');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('builds operCreds from OPER_USER + OPER_PASSWORD when both set', () => {
|
|
71
|
+
const cfg = loadServerConfigFromCfEnv({
|
|
72
|
+
...baseEnv(),
|
|
73
|
+
OPER_USER: 'admin',
|
|
74
|
+
OPER_PASSWORD: 'hunter2',
|
|
75
|
+
});
|
|
76
|
+
expect(cfg.operCreds).toEqual([{ user: 'admin', password: 'hunter2' }]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('parses numeric limit overrides from string env vars', () => {
|
|
80
|
+
const cfg = loadServerConfigFromCfEnv({
|
|
81
|
+
...baseEnv(),
|
|
82
|
+
MAX_CHANNELS_PER_USER: '12',
|
|
83
|
+
MAX_TARGETS_PER_COMMAND: '3',
|
|
84
|
+
NICK_LEN: '20',
|
|
85
|
+
CHANNEL_LEN: '40',
|
|
86
|
+
TOPIC_LEN: '300',
|
|
87
|
+
MAX_LIST_ENTRIES: '7',
|
|
88
|
+
});
|
|
89
|
+
expect(cfg.maxChannelsPerUser).toBe(12);
|
|
90
|
+
expect(cfg.maxTargetsPerCommand).toBe(3);
|
|
91
|
+
expect(cfg.nickLen).toBe(20);
|
|
92
|
+
expect(cfg.channelLen).toBe(40);
|
|
93
|
+
expect(cfg.topicLen).toBe(300);
|
|
94
|
+
expect(cfg.maxListEntries).toBe(7);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('honours a custom QUIT_MESSAGE', () => {
|
|
98
|
+
const cfg = loadServerConfigFromCfEnv({
|
|
99
|
+
...baseEnv(),
|
|
100
|
+
QUIT_MESSAGE: 'Bye from CF',
|
|
101
|
+
});
|
|
102
|
+
expect(cfg.quitMessage).toBe('Bye from CF');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('loadServerConfigFromCfEnv — failure modes', () => {
|
|
107
|
+
it('throws when SERVER_NAME is missing', () => {
|
|
108
|
+
expect(() => loadServerConfigFromCfEnv({ NETWORK_NAME: 'CfNet' })).toThrowError(/serverName/u);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('throws when NETWORK_NAME is missing', () => {
|
|
112
|
+
expect(() => loadServerConfigFromCfEnv({ SERVER_NAME: 's' })).toThrowError(/networkName/u);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('throws when MAX_CLIENTS is non-numeric', () => {
|
|
116
|
+
expect(() => loadServerConfigFromCfEnv({ ...baseEnv(), MAX_CLIENTS: 'lots' })).toThrowError(
|
|
117
|
+
/maxClients/u,
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('throws when OPER_USER is set but OPER_PASSWORD is missing', () => {
|
|
122
|
+
expect(() => loadServerConfigFromCfEnv({ ...baseEnv(), OPER_USER: 'admin' })).toThrowError(
|
|
123
|
+
/password/u,
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('throws when OPER_PASSWORD is set but OPER_USER is missing', () => {
|
|
128
|
+
expect(() => loadServerConfigFromCfEnv({ ...baseEnv(), OPER_PASSWORD: 'p' })).toThrowError(
|
|
129
|
+
/user/u,
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('aggregates multiple errors into a single readable Error', () => {
|
|
134
|
+
try {
|
|
135
|
+
loadServerConfigFromCfEnv({
|
|
136
|
+
MAX_CLIENTS: 'oops',
|
|
137
|
+
OPER_USER: 'admin',
|
|
138
|
+
});
|
|
139
|
+
expect.fail('expected throw');
|
|
140
|
+
} catch (err) {
|
|
141
|
+
expect(err).toBeInstanceOf(Error);
|
|
142
|
+
const msg = (err as Error).message;
|
|
143
|
+
expect(msg).toMatch(/invalid server config/iu);
|
|
144
|
+
// Both bad fields are surfaced.
|
|
145
|
+
expect(msg).toMatch(/serverName/u);
|
|
146
|
+
expect(msg).toMatch(/maxClients/u);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -123,6 +123,23 @@ async function register(client: IrcWsClient, nick: string): Promise<void> {
|
|
|
123
123
|
await client.waitForMessage((l) => l.includes(' 001 '));
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Negotiates the chathistory cap set then registers. Mirrors the
|
|
128
|
+
* pre-registration `CAP REQ` → `NICK`/`USER` → `CAP END` flow a real
|
|
129
|
+
* client uses.
|
|
130
|
+
*/
|
|
131
|
+
async function registerWithChathistory(client: IrcWsClient, nick: string): Promise<void> {
|
|
132
|
+
// `echo-message` is included so tests can deterministically wait for a
|
|
133
|
+
// PRIVMSG to be recorded before issuing a follow-up CHATHISTORY query
|
|
134
|
+
// (otherwise the two async webSocketMessage handlers race).
|
|
135
|
+
client.send('CAP REQ :draft/chathistory server-time message-tags batch echo-message\r\n');
|
|
136
|
+
await client.waitForMessage((l) => / CAP \S+ ACK :/.test(l));
|
|
137
|
+
client.send(`NICK ${nick}\r\n`);
|
|
138
|
+
client.send(`USER ${nick} 0 * :${nick}\r\n`);
|
|
139
|
+
client.send('CAP END\r\n');
|
|
140
|
+
await client.waitForMessage((l) => l.includes(' 001 '));
|
|
141
|
+
}
|
|
142
|
+
|
|
126
143
|
/**
|
|
127
144
|
* Forces the ConnectionDO to drop its in-memory cache of the
|
|
128
145
|
* {@link ConnectionState}, simulating wake-from-hibernation. The next
|
|
@@ -324,6 +341,71 @@ describe('ConnectionDO — alarms', () => {
|
|
|
324
341
|
});
|
|
325
342
|
});
|
|
326
343
|
|
|
344
|
+
describe('ConnectionDO — chathistory end-to-end', () => {
|
|
345
|
+
it('records a PRIVMSG and replays it via CHATHISTORY LATEST in a BATCH', async () => {
|
|
346
|
+
const client = await connect('conn-chathist-1');
|
|
347
|
+
await registerWithChathistory(client, 'cf-alice');
|
|
348
|
+
|
|
349
|
+
client.send('JOIN #cf-chathist\r\n');
|
|
350
|
+
await client.waitForMessage((l) => l.includes(' 366 ') && l.includes('#cf-chathist'));
|
|
351
|
+
|
|
352
|
+
client.send('PRIVMSG #cf-chathist :recorded via cf\r\n');
|
|
353
|
+
// Wait for the echo (echo-message cap) so the PRIVMSG is recorded
|
|
354
|
+
// before the CHATHISTORY query arrives as a separate frame.
|
|
355
|
+
await client.waitForMessage((l) => l.includes('PRIVMSG #cf-chathist :recorded via cf'));
|
|
356
|
+
|
|
357
|
+
client.send('CHATHISTORY LATEST #cf-chathist * 10\r\n');
|
|
358
|
+
const batchOpen = await client.waitForMessage((l) =>
|
|
359
|
+
/^BATCH \+\S+ chathistory #cf-chathist/.test(l),
|
|
360
|
+
);
|
|
361
|
+
expect(batchOpen).toMatch(/^BATCH \+\S+ chathistory #cf-chathist/);
|
|
362
|
+
|
|
363
|
+
await client.waitForMessage((l) => l.includes('PRIVMSG #cf-chathist :recorded via cf'));
|
|
364
|
+
|
|
365
|
+
client.ws.close();
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
// SASL PLAIN end-to-end (AccountStore plumbing)
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
describe('ConnectionDO — SASL PLAIN end-to-end', () => {
|
|
374
|
+
/** base64(`\0authcid\0password`) — the PLAIN payload format. */
|
|
375
|
+
function plainB64(authcid: string, password: string): string {
|
|
376
|
+
// workerd has no `Buffer`; use btoa on a Latin1-encoded string.
|
|
377
|
+
const raw = `\0${authcid}\0${password}`;
|
|
378
|
+
let latin1 = '';
|
|
379
|
+
for (let i = 0; i < raw.length; i++) latin1 += String.fromCharCode(raw.charCodeAt(i));
|
|
380
|
+
return (globalThis as unknown as { btoa: (s: string) => string }).btoa(latin1);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
it('completes SASL PLAIN with 900/903 for valid seeded creds', async () => {
|
|
384
|
+
const client = await connect('conn-sasl-1');
|
|
385
|
+
client.send('CAP REQ :sasl\r\n');
|
|
386
|
+
await client.waitForMessage((l) => / CAP \S+ ACK :sasl/.test(l));
|
|
387
|
+
client.send('AUTHENTICATE PLAIN\r\n');
|
|
388
|
+
await client.waitForMessage((l) => l === 'AUTHENTICATE +');
|
|
389
|
+
client.send(`AUTHENTICATE ${plainB64('cf-sasl', 's3cret')}\r\n`);
|
|
390
|
+
await client.waitForMessage((l) => l.includes(' 900 '));
|
|
391
|
+
const success = await client.waitForMessage((l) => l.includes(' 903 '));
|
|
392
|
+
expect(success).toContain(' 903 ');
|
|
393
|
+
client.ws.close();
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('rejects SASL PLAIN with 904 for invalid creds', async () => {
|
|
397
|
+
const client = await connect('conn-sasl-2');
|
|
398
|
+
client.send('CAP REQ :sasl\r\n');
|
|
399
|
+
await client.waitForMessage((l) => / CAP \S+ ACK :sasl/.test(l));
|
|
400
|
+
client.send('AUTHENTICATE PLAIN\r\n');
|
|
401
|
+
await client.waitForMessage((l) => l === 'AUTHENTICATE +');
|
|
402
|
+
client.send(`AUTHENTICATE ${plainB64('cf-sasl', 'wrong')}\r\n`);
|
|
403
|
+
const fail = await client.waitForMessage((l) => l.includes(' 904 '));
|
|
404
|
+
expect(fail).toContain(' 904 ');
|
|
405
|
+
client.ws.close();
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
327
409
|
// ---------------------------------------------------------------------------
|
|
328
410
|
// End of file
|
|
329
411
|
// ---------------------------------------------------------------------------
|
|
@@ -19,12 +19,13 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { ChannelDO } from '../../src/channel-do';
|
|
22
|
+
import { ChannelRegistryDO } from '../../src/channel-registry-do';
|
|
22
23
|
import { ConnectionDO } from '../../src/connection-do';
|
|
23
24
|
import { RegistryDO } from '../../src/registry-do';
|
|
24
25
|
import { RecordingChannelDO } from './stubs/channel-stub';
|
|
25
26
|
import { RecordingRegistryDO } from './stubs/registry-stub';
|
|
26
27
|
|
|
27
|
-
export { ChannelDO, ConnectionDO, RegistryDO, RecordingRegistryDO, RecordingChannelDO };
|
|
28
|
+
export { ChannelDO, ChannelRegistryDO, ConnectionDO, RegistryDO, RecordingRegistryDO, RecordingChannelDO };
|
|
28
29
|
|
|
29
30
|
export default {
|
|
30
31
|
async fetch(): Promise<Response> {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { DurableObject } from 'cloudflare:workers';
|
|
13
13
|
|
|
14
14
|
export interface RecordedChannelCall {
|
|
15
|
-
method: 'broadcast' | 'applyChannelDelta' | 'getChannelSnapshot';
|
|
15
|
+
method: 'broadcast' | 'applyChannelDelta' | 'getChannelSnapshot' | 'listMembers';
|
|
16
16
|
args: unknown[];
|
|
17
17
|
}
|
|
18
18
|
|
|
@@ -20,6 +20,7 @@ interface ChannelRpc {
|
|
|
20
20
|
broadcast(lines: unknown[], except?: string): Promise<void>;
|
|
21
21
|
applyChannelDelta(delta: unknown): Promise<void>;
|
|
22
22
|
getChannelSnapshot(): Promise<unknown>;
|
|
23
|
+
listMembers(): Promise<string[]>;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
const LOG_KEY = 'channel-call-log';
|
|
@@ -47,4 +48,9 @@ export class RecordingChannelDO extends DurableObject implements ChannelRpc {
|
|
|
47
48
|
await this.append({ method: 'getChannelSnapshot', args: [] });
|
|
48
49
|
return null;
|
|
49
50
|
}
|
|
51
|
+
|
|
52
|
+
async listMembers(): Promise<string[]> {
|
|
53
|
+
await this.append({ method: 'listMembers', args: [] });
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
50
56
|
}
|
|
@@ -9,6 +9,10 @@ compatibility_flags = ["nodejs_compat"]
|
|
|
9
9
|
# body text in every runtime factory.
|
|
10
10
|
[vars]
|
|
11
11
|
MOTD_LINES = "Welcome to the ServerlessIRCd integration-test harness.\nAll scenarios run parametrized over the IrcRuntime factory matrix."
|
|
12
|
+
# SASL PLAIN accounts seeded into the in-worker AccountStore. Newline-
|
|
13
|
+
# delimited `username:password` pairs; the connection-do parses these into
|
|
14
|
+
# an InMemoryAccountStore so AUTHENTICATE PLAIN works end-to-end in tests.
|
|
15
|
+
SASL_ACCOUNTS = "cf-sasl:s3cret"
|
|
12
16
|
|
|
13
17
|
# Production bindings: REGISTRY_DO and CHANNEL_DO point at the real DO
|
|
14
18
|
# classes so CfRuntime, ConnectionDO, and the integration suite all
|
|
@@ -25,6 +29,11 @@ class_name = "RegistryDO"
|
|
|
25
29
|
name = "CHANNEL_DO"
|
|
26
30
|
class_name = "ChannelDO"
|
|
27
31
|
|
|
32
|
+
# Channel registry — single DO tracking all channel names for LIST.
|
|
33
|
+
[[durable_objects.bindings]]
|
|
34
|
+
name = "CHANNEL_REGISTRY_DO"
|
|
35
|
+
class_name = "ChannelRegistryDO"
|
|
36
|
+
|
|
28
37
|
# Recording stubs (kept under separate names for any test that wants to
|
|
29
38
|
# assert "this exact RPC was emitted" rather than the end-to-end effect).
|
|
30
39
|
[[durable_objects.bindings]]
|
|
@@ -47,4 +56,4 @@ class_name = "ChannelDO"
|
|
|
47
56
|
|
|
48
57
|
[[migrations]]
|
|
49
58
|
tag = "v1"
|
|
50
|
-
new_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "RecordingRegistryDO", "RecordingChannelDO"]
|
|
59
|
+
new_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO", "RecordingRegistryDO", "RecordingChannelDO"]
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import {
|
|
16
|
+
type AdmissionConfig,
|
|
17
|
+
type AdmissionDecision,
|
|
18
|
+
AdmissionStats,
|
|
16
19
|
type ChanName,
|
|
17
20
|
type ChanSnapshot,
|
|
18
21
|
type ChannelDelta,
|
|
@@ -24,7 +27,9 @@ import {
|
|
|
24
27
|
type Nick,
|
|
25
28
|
type RawLine,
|
|
26
29
|
applyChannelDelta as applyDelta,
|
|
30
|
+
caseFold,
|
|
27
31
|
createChannel,
|
|
32
|
+
decideAdmission,
|
|
28
33
|
toChannelSnapshot,
|
|
29
34
|
toSnapshot as toConnSnapshot,
|
|
30
35
|
} from '@serverless-ircd/irc-core';
|
|
@@ -41,10 +46,24 @@ export interface ConnectionHandlers {
|
|
|
41
46
|
interface TrackedConnection {
|
|
42
47
|
state: ConnectionState;
|
|
43
48
|
handlers: ConnectionHandlers;
|
|
49
|
+
/**
|
|
50
|
+
* Optional admission record id paired with this connection at register
|
|
51
|
+
* time. When set, {@link InMemoryRuntime.unregisterConnection} releases
|
|
52
|
+
* it so per-IP / per-user connection counters stay accurate.
|
|
53
|
+
*/
|
|
54
|
+
admissionRecordId?: string;
|
|
44
55
|
}
|
|
45
56
|
|
|
46
|
-
|
|
47
|
-
|
|
57
|
+
export interface InMemoryRuntimeOptions {
|
|
58
|
+
clock: Clock;
|
|
59
|
+
/**
|
|
60
|
+
* Connection-admission policy. When omitted the admission gate is
|
|
61
|
+
* disabled — every {@link InMemoryRuntime.admitConnection} call returns
|
|
62
|
+
* an admit decision. Adapters that want to enforce per-IP / per-user /
|
|
63
|
+
* rate limits pass the same {@link AdmissionConfig} derived from the
|
|
64
|
+
* deployment's `ServerConfig`.
|
|
65
|
+
*/
|
|
66
|
+
readonly admission?: AdmissionConfig;
|
|
48
67
|
}
|
|
49
68
|
|
|
50
69
|
export class InMemoryRuntime implements IrcRuntime {
|
|
@@ -52,9 +71,53 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
52
71
|
private readonly connections = new Map<ConnId, TrackedConnection>();
|
|
53
72
|
private readonly channels = new Map<string, ChannelState>();
|
|
54
73
|
private readonly nicks = new Map<string, ConnId>();
|
|
74
|
+
private readonly admissionStats: AdmissionStats | undefined;
|
|
75
|
+
private readonly admissionConfig: AdmissionConfig | undefined;
|
|
55
76
|
|
|
56
|
-
constructor(opts:
|
|
77
|
+
constructor(opts: InMemoryRuntimeOptions) {
|
|
57
78
|
this.clock = opts.clock;
|
|
79
|
+
if (opts.admission !== undefined) {
|
|
80
|
+
this.admissionConfig = opts.admission;
|
|
81
|
+
this.admissionStats = new AdmissionStats(this.clock);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ------------------------------------------------------------------
|
|
86
|
+
// Admission gate (NOT part of the IrcRuntime port — adapter only)
|
|
87
|
+
// ------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Decides whether a new connection should be admitted under the
|
|
91
|
+
* configured {@link AdmissionConfig}. Returns `{ ok: true, recordId }`
|
|
92
|
+
* when the connection may proceed; the caller MUST then call
|
|
93
|
+
* {@link commitAdmission} exactly once with the returned recordId.
|
|
94
|
+
*
|
|
95
|
+
* Returns `{ ok: false, reason }` when the connection must be refused.
|
|
96
|
+
*
|
|
97
|
+
* When no admission policy was supplied at construction, this method
|
|
98
|
+
* unconditionally admits (the gate is disabled).
|
|
99
|
+
*/
|
|
100
|
+
admitConnection(ip: string, user: string | undefined): AdmissionDecision {
|
|
101
|
+
if (this.admissionConfig === undefined || this.admissionStats === undefined) {
|
|
102
|
+
return { ok: true, recordId: '' };
|
|
103
|
+
}
|
|
104
|
+
return decideAdmission(ip, user, this.admissionStats, this.admissionConfig);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Records an accepted admission. MUST be called exactly once per
|
|
109
|
+
* successful {@link admitConnection}. No-op when admission is disabled.
|
|
110
|
+
*/
|
|
111
|
+
commitAdmission(ip: string, user: string | undefined, recordId: string): void {
|
|
112
|
+
this.admissionStats?.recordAdmission(ip, user, recordId);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Releases a previously-committed admission record. Safe to call with
|
|
117
|
+
* an unknown id. No-op when admission is disabled.
|
|
118
|
+
*/
|
|
119
|
+
releaseAdmission(recordId: string): void {
|
|
120
|
+
this.admissionStats?.release(recordId);
|
|
58
121
|
}
|
|
59
122
|
|
|
60
123
|
// ------------------------------------------------------------------
|
|
@@ -65,13 +128,31 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
65
128
|
* Tracks a connection and its transport handlers. The runtime keeps a
|
|
66
129
|
* reference to `state`; the actor may continue mutating it in place and
|
|
67
130
|
* the runtime will observe the changes via {@link getConnectionInfo}.
|
|
131
|
+
*
|
|
132
|
+
* @param admissionRecordId Optional record id returned by
|
|
133
|
+
* {@link admitConnection} + {@link commitAdmission}. When supplied,
|
|
134
|
+
* {@link unregisterConnection} releases it automatically so the
|
|
135
|
+
* per-IP / per-user connection counters stay accurate without the
|
|
136
|
+
* adapter remembering to call {@link releaseAdmission} itself.
|
|
68
137
|
*/
|
|
69
|
-
registerConnection(
|
|
70
|
-
|
|
138
|
+
registerConnection(
|
|
139
|
+
state: ConnectionState,
|
|
140
|
+
handlers: ConnectionHandlers,
|
|
141
|
+
admissionRecordId?: string,
|
|
142
|
+
): void {
|
|
143
|
+
const tracked: TrackedConnection =
|
|
144
|
+
admissionRecordId !== undefined
|
|
145
|
+
? { state, handlers, admissionRecordId }
|
|
146
|
+
: { state, handlers };
|
|
147
|
+
this.connections.set(state.id, tracked);
|
|
71
148
|
}
|
|
72
149
|
|
|
73
150
|
/** Stops tracking a connection. Idempotent. Does NOT release its nick. */
|
|
74
151
|
unregisterConnection(conn: ConnId): void {
|
|
152
|
+
const tracked = this.connections.get(conn);
|
|
153
|
+
if (tracked !== undefined && tracked.admissionRecordId !== undefined) {
|
|
154
|
+
this.releaseAdmission(tracked.admissionRecordId);
|
|
155
|
+
}
|
|
75
156
|
this.connections.delete(conn);
|
|
76
157
|
}
|
|
77
158
|
|
|
@@ -90,7 +171,7 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
90
171
|
* return the same object.
|
|
91
172
|
*/
|
|
92
173
|
getOrCreateChannel(name: ChanName): ChannelState {
|
|
93
|
-
const key =
|
|
174
|
+
const key = caseFold('rfc1459', name);
|
|
94
175
|
const existing = this.channels.get(key);
|
|
95
176
|
if (existing !== undefined) return existing;
|
|
96
177
|
const created = createChannel({
|
|
@@ -102,6 +183,16 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
102
183
|
return created;
|
|
103
184
|
}
|
|
104
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Peek-only channel lookup that does NOT create the channel. Returns the
|
|
188
|
+
* authoritative {@link ChannelState} when it exists, or `undefined`.
|
|
189
|
+
* Used by query-only commands (CHATHISTORY) that must distinguish a
|
|
190
|
+
* missing channel from an empty one.
|
|
191
|
+
*/
|
|
192
|
+
getChannel(name: ChanName): ChannelState | undefined {
|
|
193
|
+
return this.channels.get(caseFold('rfc1459', name));
|
|
194
|
+
}
|
|
195
|
+
|
|
105
196
|
// ------------------------------------------------------------------
|
|
106
197
|
// IrcRuntime — transport
|
|
107
198
|
// ------------------------------------------------------------------
|
|
@@ -113,7 +204,7 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
113
204
|
}
|
|
114
205
|
|
|
115
206
|
async broadcast(chan: ChanName, lines: RawLine[], except?: ConnId): Promise<void> {
|
|
116
|
-
const channel = this.channels.get(
|
|
207
|
+
const channel = this.channels.get(caseFold('rfc1459', chan));
|
|
117
208
|
if (channel === undefined) return;
|
|
118
209
|
for (const member of channel.members.keys()) {
|
|
119
210
|
if (member === except) continue;
|
|
@@ -135,7 +226,7 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
135
226
|
lines: RawLine[],
|
|
136
227
|
notFoundLines?: RawLine[],
|
|
137
228
|
): Promise<void> {
|
|
138
|
-
const owner = this.nicks.get(
|
|
229
|
+
const owner = this.nicks.get(caseFold('rfc1459', nick));
|
|
139
230
|
if (owner !== undefined) {
|
|
140
231
|
const conn = this.connections.get(owner);
|
|
141
232
|
if (conn !== undefined) {
|
|
@@ -156,7 +247,7 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
156
247
|
// ------------------------------------------------------------------
|
|
157
248
|
|
|
158
249
|
async reserveNick(nick: Nick, conn: ConnId): Promise<{ ok: true } | { ok: false }> {
|
|
159
|
-
const key =
|
|
250
|
+
const key = caseFold('rfc1459', nick);
|
|
160
251
|
const existing = this.nicks.get(key);
|
|
161
252
|
if (existing !== undefined && existing !== conn) {
|
|
162
253
|
return { ok: false };
|
|
@@ -166,8 +257,8 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
166
257
|
}
|
|
167
258
|
|
|
168
259
|
async changeNick(conn: ConnId, oldNick: Nick, newNick: Nick): Promise<boolean> {
|
|
169
|
-
const newKey =
|
|
170
|
-
const oldKey =
|
|
260
|
+
const newKey = caseFold('rfc1459', newNick);
|
|
261
|
+
const oldKey = caseFold('rfc1459', oldNick);
|
|
171
262
|
const owner = this.nicks.get(newKey);
|
|
172
263
|
if (owner !== undefined && owner !== conn) {
|
|
173
264
|
return false;
|
|
@@ -182,7 +273,7 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
182
273
|
}
|
|
183
274
|
|
|
184
275
|
async releaseNick(nick: Nick): Promise<void> {
|
|
185
|
-
this.nicks.delete(
|
|
276
|
+
this.nicks.delete(caseFold('rfc1459', nick));
|
|
186
277
|
}
|
|
187
278
|
|
|
188
279
|
// ------------------------------------------------------------------
|
|
@@ -195,7 +286,7 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
195
286
|
// replace the stored object so subsequent snapshots see the new data
|
|
196
287
|
// while any outstanding references to the prior state stay coherent.
|
|
197
288
|
const next = applyDelta(channel, delta);
|
|
198
|
-
this.channels.set(
|
|
289
|
+
this.channels.set(caseFold('rfc1459', chan), next);
|
|
199
290
|
}
|
|
200
291
|
|
|
201
292
|
// ------------------------------------------------------------------
|
|
@@ -203,7 +294,7 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
203
294
|
// ------------------------------------------------------------------
|
|
204
295
|
|
|
205
296
|
async lookupNick(nick: Nick): Promise<ConnId | null> {
|
|
206
|
-
return this.nicks.get(
|
|
297
|
+
return this.nicks.get(caseFold('rfc1459', nick)) ?? null;
|
|
207
298
|
}
|
|
208
299
|
|
|
209
300
|
async getConnectionInfo(conn: ConnId): Promise<ConnSnapshot | null> {
|
|
@@ -223,13 +314,13 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
223
314
|
}
|
|
224
315
|
|
|
225
316
|
async getChannelSnapshot(chan: ChanName): Promise<ChanSnapshot | null> {
|
|
226
|
-
const channel = this.channels.get(
|
|
317
|
+
const channel = this.channels.get(caseFold('rfc1459', chan));
|
|
227
318
|
if (channel === undefined) return null;
|
|
228
319
|
return toChannelSnapshot(channel);
|
|
229
320
|
}
|
|
230
321
|
|
|
231
322
|
async getChannelConnections(chan: ChanName): Promise<ReadonlyMap<ConnId, ConnectionState>> {
|
|
232
|
-
const channel = this.channels.get(
|
|
323
|
+
const channel = this.channels.get(caseFold('rfc1459', chan));
|
|
233
324
|
const out = new Map<ConnId, ConnectionState>();
|
|
234
325
|
if (channel === undefined) return out;
|
|
235
326
|
for (const connId of channel.members.keys()) {
|