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
|
@@ -178,6 +178,19 @@ describe('local-cli error paths', () => {
|
|
|
178
178
|
await first.close();
|
|
179
179
|
});
|
|
180
180
|
|
|
181
|
+
it('validates the supplied config through the shared ServerConfigSchema at boot', async () => {
|
|
182
|
+
// nickLen=0 is rejected by the schema (must be a positive integer).
|
|
183
|
+
await expect(
|
|
184
|
+
startLocalServer({
|
|
185
|
+
port: 0,
|
|
186
|
+
hostname: '127.0.0.1',
|
|
187
|
+
serverName: 'irc.local.example.com',
|
|
188
|
+
networkName: 'LocalNet',
|
|
189
|
+
nickLen: 0,
|
|
190
|
+
}),
|
|
191
|
+
).rejects.toThrowError(/invalid server config/iu);
|
|
192
|
+
});
|
|
193
|
+
|
|
181
194
|
it('logs and recovers from a per-socket error without crashing the server', async () => {
|
|
182
195
|
const srv = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
|
|
183
196
|
const client = new TestClient(`ws://127.0.0.1:${srv.port}/`);
|
|
@@ -344,3 +357,116 @@ describe('local-cli error paths', () => {
|
|
|
344
357
|
await srv.close();
|
|
345
358
|
});
|
|
346
359
|
});
|
|
360
|
+
|
|
361
|
+
describe('local-cli chathistory end-to-end', () => {
|
|
362
|
+
const CHATHISTORY_CAPS = 'draft/chathistory server-time message-tags batch';
|
|
363
|
+
|
|
364
|
+
/** Registers a client that has negotiated the chathistory cap set. */
|
|
365
|
+
async function registerWithCaps(srv: LocalServer, nick: string): Promise<TestClient> {
|
|
366
|
+
const client = new TestClient(`ws://127.0.0.1:${srv.port}/`);
|
|
367
|
+
await client.opened();
|
|
368
|
+
client.send(`CAP REQ :${CHATHISTORY_CAPS}\r\n`);
|
|
369
|
+
await client.waitFor((l) => / CAP \S+ ACK :/.test(l));
|
|
370
|
+
client.send(`NICK ${nick}\r\n`);
|
|
371
|
+
client.send(`USER ${nick} 0 * :${nick}\r\n`);
|
|
372
|
+
client.send('CAP END\r\n');
|
|
373
|
+
await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
|
|
374
|
+
return client;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
it('records a PRIVMSG and replays it via CHATHISTORY LATEST in a BATCH', async () => {
|
|
378
|
+
const srv = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
|
|
379
|
+
const alice = await registerWithCaps(srv, 'ch-alice');
|
|
380
|
+
|
|
381
|
+
alice.send('JOIN #chathist-e2e\r\n');
|
|
382
|
+
await alice.waitFor((l) => l.includes(' 366 '));
|
|
383
|
+
|
|
384
|
+
alice.send('PRIVMSG #chathist-e2e :backlog hello\r\n');
|
|
385
|
+
|
|
386
|
+
alice.send('CHATHISTORY LATEST #chathist-e2e * 10\r\n');
|
|
387
|
+
const batchOpen = await alice.waitFor((l) => /^BATCH \+\S+ chathistory #chathist-e2e/.test(l));
|
|
388
|
+
expect(batchOpen).toMatch(/^BATCH \+\S+ chathistory #chathist-e2e/);
|
|
389
|
+
|
|
390
|
+
const replay = await alice.waitFor((l) => l.includes('PRIVMSG #chathist-e2e :backlog hello'));
|
|
391
|
+
expect(replay).toContain('PRIVMSG #chathist-e2e :backlog hello');
|
|
392
|
+
|
|
393
|
+
await alice.close();
|
|
394
|
+
await srv.close();
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('prepends a chathistory BATCH on rejoin after messages were sent', async () => {
|
|
398
|
+
const srv = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
|
|
399
|
+
|
|
400
|
+
// Seed history as alice: join + send a message that bob has missed.
|
|
401
|
+
const alice = await registerWithCaps(srv, 'ch-seed');
|
|
402
|
+
alice.send('JOIN #rejoin-e2e\r\n');
|
|
403
|
+
await alice.waitFor((l) => l.includes(' 366 ') && l.includes('#rejoin-e2e'));
|
|
404
|
+
alice.send('PRIVMSG #rejoin-e2e :while you were away\r\n');
|
|
405
|
+
// Give the frame a moment to record before bob joins.
|
|
406
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
407
|
+
|
|
408
|
+
// A second cap-enabled client joins the same channel and must receive
|
|
409
|
+
// the auto-playback BATCH before the normal JOIN/353/366 sequence.
|
|
410
|
+
const bob = await registerWithCaps(srv, 'ch-rejoin');
|
|
411
|
+
bob.send('JOIN #rejoin-e2e\r\n');
|
|
412
|
+
const playback = await bob.waitFor((l) => /^BATCH \+\S+ chathistory #rejoin-e2e/.test(l));
|
|
413
|
+
expect(playback).toMatch(/^BATCH \+\S+ chathistory #rejoin-e2e/);
|
|
414
|
+
await bob.waitFor((l) => l.includes('PRIVMSG #rejoin-e2e :while you were away'));
|
|
415
|
+
|
|
416
|
+
await alice.close();
|
|
417
|
+
await bob.close();
|
|
418
|
+
await srv.close();
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
describe('local-cli SASL PLAIN end-to-end', () => {
|
|
423
|
+
/** base64(`\0authcid\0password`) — the PLAIN payload format. */
|
|
424
|
+
function plainB64(authcid: string, password: string): string {
|
|
425
|
+
return Buffer.from(`\0${authcid}\0${password}`, 'utf8').toString('base64');
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
it('completes SASL PLAIN with 900/903 for valid seeded creds', async () => {
|
|
429
|
+
const srv = await startLocalServer({
|
|
430
|
+
port: 0,
|
|
431
|
+
hostname: '127.0.0.1',
|
|
432
|
+
saslAccounts: [{ username: 'sasl-alice', password: 's3cret' }],
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
const client = new TestClient(`ws://127.0.0.1:${srv.port}/`);
|
|
436
|
+
await client.opened();
|
|
437
|
+
client.send('CAP REQ :sasl\r\n');
|
|
438
|
+
await client.waitFor((l) => / CAP \S+ ACK :sasl/.test(l));
|
|
439
|
+
client.send('AUTHENTICATE PLAIN\r\n');
|
|
440
|
+
await client.waitFor((l) => l === 'AUTHENTICATE +');
|
|
441
|
+
client.send(`AUTHENTICATE ${plainB64('sasl-alice', 's3cret')}\r\n`);
|
|
442
|
+
await client.waitFor((l) => l.includes(' 900 '));
|
|
443
|
+
const success = await client.waitFor((l) => l.includes(' 903 '));
|
|
444
|
+
expect(success).toContain(' 903 ');
|
|
445
|
+
client.send('NICK sasl-alice\r\n');
|
|
446
|
+
client.send('USER sasl-alice 0 * :Sasl Alice\r\n');
|
|
447
|
+
client.send('CAP END\r\n');
|
|
448
|
+
await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
|
|
449
|
+
await client.close();
|
|
450
|
+
await srv.close();
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('rejects SASL PLAIN with 904 for invalid creds', async () => {
|
|
454
|
+
const srv = await startLocalServer({
|
|
455
|
+
port: 0,
|
|
456
|
+
hostname: '127.0.0.1',
|
|
457
|
+
saslAccounts: [{ username: 'sasl-bob', password: 'right' }],
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
const client = new TestClient(`ws://127.0.0.1:${srv.port}/`);
|
|
461
|
+
await client.opened();
|
|
462
|
+
client.send('CAP REQ :sasl\r\n');
|
|
463
|
+
await client.waitFor((l) => / CAP \S+ ACK :sasl/.test(l));
|
|
464
|
+
client.send('AUTHENTICATE PLAIN\r\n');
|
|
465
|
+
await client.waitFor((l) => l === 'AUTHENTICATE +');
|
|
466
|
+
client.send(`AUTHENTICATE ${plainB64('sasl-bob', 'wrong')}\r\n`);
|
|
467
|
+
const fail = await client.waitFor((l) => l.includes(' 904 '));
|
|
468
|
+
expect(fail).toContain(' 904 ');
|
|
469
|
+
await client.close();
|
|
470
|
+
await srv.close();
|
|
471
|
+
});
|
|
472
|
+
});
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end tests for the production-hardening security features:
|
|
3
|
+
* server password enforcement, hostmask cloaking, and
|
|
4
|
+
* the connection-admission gate.
|
|
5
|
+
*
|
|
6
|
+
* These tests boot a real `startLocalServer` with the relevant config
|
|
7
|
+
* knobs and drive it over WebSocket — the same path a real IRC client
|
|
8
|
+
* takes. The unit-test coverage for each limit lives next to the
|
|
9
|
+
* implementation; this file verifies the wiring through the local CLI.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
13
|
+
import { WebSocket } from 'ws';
|
|
14
|
+
import { type LocalServer, startLocalServer } from '../src/server';
|
|
15
|
+
|
|
16
|
+
class TestClient {
|
|
17
|
+
readonly ws: WebSocket;
|
|
18
|
+
readonly received: string[] = [];
|
|
19
|
+
|
|
20
|
+
constructor(url: string) {
|
|
21
|
+
this.ws = new WebSocket(url);
|
|
22
|
+
this.ws.on('message', (data) => {
|
|
23
|
+
const text = data.toString('utf8');
|
|
24
|
+
for (const line of text.split(/\r?\n/u)) {
|
|
25
|
+
if (line.length > 0) this.received.push(line);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
opened(): Promise<void> {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
if (this.ws.readyState === WebSocket.OPEN) resolve();
|
|
33
|
+
else if (this.ws.readyState === WebSocket.CLOSED)
|
|
34
|
+
reject(new Error('socket closed before open'));
|
|
35
|
+
else {
|
|
36
|
+
this.ws.once('open', () => resolve());
|
|
37
|
+
this.ws.once('error', reject);
|
|
38
|
+
this.ws.once('close', () => reject(new Error('socket closed before open')));
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Resolves once the underlying socket closes. */
|
|
44
|
+
closed(): Promise<void> {
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
if (this.ws.readyState === WebSocket.CLOSED) resolve();
|
|
47
|
+
else this.ws.once('close', () => resolve());
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
send(line: string): Promise<void> {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
this.ws.send(`${line}\r\n`, (err) => (err ? reject(err) : resolve()));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
waitFor(predicate: (line: string) => boolean, timeoutMs = 1_000): Promise<string> {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const timeout = setTimeout(() => {
|
|
60
|
+
reject(new Error(`timeout; received: ${JSON.stringify(this.received)}`));
|
|
61
|
+
}, timeoutMs);
|
|
62
|
+
const check = (line: string) => {
|
|
63
|
+
if (predicate(line)) {
|
|
64
|
+
clearTimeout(timeout);
|
|
65
|
+
this.ws.off('message', onMessage);
|
|
66
|
+
resolve(line);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const onMessage = (data: { toString: (enc: string) => string }): void => {
|
|
70
|
+
const text = data.toString('utf8');
|
|
71
|
+
for (const line of text.split(/\r?\n/u)) {
|
|
72
|
+
if (line.length > 0) check(line);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
for (const line of this.received) {
|
|
76
|
+
if (predicate(line)) {
|
|
77
|
+
clearTimeout(timeout);
|
|
78
|
+
resolve(line);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
this.ws.on('message', onMessage);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
close(): Promise<void> {
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
this.ws.once('close', () => resolve());
|
|
89
|
+
this.ws.close();
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Server password
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
describe('local-cli e2e — server password', () => {
|
|
99
|
+
let server: LocalServer;
|
|
100
|
+
|
|
101
|
+
beforeAll(async () => {
|
|
102
|
+
server = await startLocalServer({
|
|
103
|
+
port: 0,
|
|
104
|
+
hostname: '127.0.0.1',
|
|
105
|
+
serverName: 'pwd.example.com',
|
|
106
|
+
networkName: 'PwdNet',
|
|
107
|
+
serverPassword: 's3cret',
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
afterAll(async () => {
|
|
112
|
+
await server.close();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('rejects a client that registers without the password', async () => {
|
|
116
|
+
const client = new TestClient(`ws://127.0.0.1:${server.port}/`);
|
|
117
|
+
await client.opened();
|
|
118
|
+
await client.send('NICK alice');
|
|
119
|
+
await client.send('USER alice 0 * :Alice');
|
|
120
|
+
// 464 ERR_PASSWDMISMATCH followed by ERROR :Closing link.
|
|
121
|
+
await client.waitFor((l) => l.includes(' 464 '));
|
|
122
|
+
await client.waitFor((l) => l.startsWith('ERROR :Closing link'));
|
|
123
|
+
await client.close();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('rejects a client that supplies the wrong password', async () => {
|
|
127
|
+
const client = new TestClient(`ws://127.0.0.1:${server.port}/`);
|
|
128
|
+
await client.opened();
|
|
129
|
+
await client.send('PASS wrong');
|
|
130
|
+
await client.send('NICK bob');
|
|
131
|
+
await client.send('USER bob 0 * :Bob');
|
|
132
|
+
await client.waitFor((l) => l.includes(' 464 '));
|
|
133
|
+
await client.close();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('admits a client that supplies the correct password', async () => {
|
|
137
|
+
const client = new TestClient(`ws://127.0.0.1:${server.port}/`);
|
|
138
|
+
await client.opened();
|
|
139
|
+
await client.send('PASS s3cret');
|
|
140
|
+
await client.send('NICK carol');
|
|
141
|
+
await client.send('USER carol 0 * :Carol');
|
|
142
|
+
await client.waitFor((l) => l.startsWith(':pwd.example.com 001 '));
|
|
143
|
+
await client.close();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Hostmask cloaking
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
describe('local-cli e2e — hostmask cloaking', () => {
|
|
152
|
+
let server: LocalServer;
|
|
153
|
+
|
|
154
|
+
beforeAll(async () => {
|
|
155
|
+
server = await startLocalServer({
|
|
156
|
+
port: 0,
|
|
157
|
+
hostname: '127.0.0.1',
|
|
158
|
+
serverName: 'cloak.example.com',
|
|
159
|
+
networkName: 'CloakNet',
|
|
160
|
+
cloaking: { enabled: true, secret: 'hunter2', cloakedSuffix: 'hidden.lan' },
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
afterAll(async () => {
|
|
165
|
+
await server.close();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('emits the cloaked host in the 001 welcome line', async () => {
|
|
169
|
+
const client = new TestClient(`ws://127.0.0.1:${server.port}/`);
|
|
170
|
+
await client.opened();
|
|
171
|
+
await client.send('NICK alice');
|
|
172
|
+
await client.send('USER alice 0 * :Alice');
|
|
173
|
+
const welcome = await client.waitFor((l) => l.startsWith(':cloak.example.com 001 '));
|
|
174
|
+
// The cloaked host ends in the configured suffix and is NOT the raw IP.
|
|
175
|
+
expect(welcome.endsWith('.hidden.lan')).toBe(true);
|
|
176
|
+
// 001 format: :server 001 nick :Welcome to the Network IRC Network, nick!user@host
|
|
177
|
+
const hostSegment = welcome.split('@').slice(-1)[0] ?? '';
|
|
178
|
+
expect(hostSegment.endsWith('.hidden.lan')).toBe(true);
|
|
179
|
+
await client.close();
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Admission gate — per-IP connection cap
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
describe('local-cli e2e — admission gate (per-IP cap)', () => {
|
|
188
|
+
let server: LocalServer;
|
|
189
|
+
|
|
190
|
+
beforeAll(async () => {
|
|
191
|
+
server = await startLocalServer({
|
|
192
|
+
port: 0,
|
|
193
|
+
hostname: '127.0.0.1',
|
|
194
|
+
serverName: 'cap.example.com',
|
|
195
|
+
networkName: 'CapNet',
|
|
196
|
+
// Tight caps so the e2e test can saturate them quickly.
|
|
197
|
+
maxConnectionsPerIp: 2,
|
|
198
|
+
maxConnectionsPerUser: 100,
|
|
199
|
+
perIpConnectionRate: { max: 1_000, windowMs: 60_000 },
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
afterAll(async () => {
|
|
204
|
+
await server.close();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('admits connections up to the cap then refuses further admissions', async () => {
|
|
208
|
+
const url = `ws://127.0.0.1:${server.port}/`;
|
|
209
|
+
const c1 = new TestClient(url);
|
|
210
|
+
const c2 = new TestClient(url);
|
|
211
|
+
await c1.opened();
|
|
212
|
+
await c2.opened();
|
|
213
|
+
// First two register fine.
|
|
214
|
+
await c1.send('NICK a1');
|
|
215
|
+
await c1.send('USER a1 0 * :A');
|
|
216
|
+
await c1.waitFor((l) => l.startsWith(':cap.example.com 001 '));
|
|
217
|
+
await c2.send('NICK a2');
|
|
218
|
+
await c2.send('USER a2 0 * :A');
|
|
219
|
+
await c2.waitFor((l) => l.startsWith(':cap.example.com 001 '));
|
|
220
|
+
|
|
221
|
+
// Third exceeds the per-IP cap (maxConnectionsPerIp: 2). The server
|
|
222
|
+
// sends an ERROR notice and closes the socket. Either opened() rejects
|
|
223
|
+
// (close-before-open) or the ERROR line arrives before close.
|
|
224
|
+
const c3 = new TestClient(url);
|
|
225
|
+
// Wait for either rejection path without racing into an unhandled
|
|
226
|
+
// rejection: opened() rejects on close, and we catch it.
|
|
227
|
+
await c3.opened().catch(() => {
|
|
228
|
+
// Expected: server closed the socket during/before handshake.
|
|
229
|
+
});
|
|
230
|
+
// If the socket did open, the server still sends ERROR then closes.
|
|
231
|
+
if (c3.ws.readyState === WebSocket.OPEN) {
|
|
232
|
+
await c3.waitFor((l) => l.startsWith('ERROR :Closing link'));
|
|
233
|
+
}
|
|
234
|
+
await c3.closed();
|
|
235
|
+
|
|
236
|
+
await c1.close();
|
|
237
|
+
await c2.close();
|
|
238
|
+
});
|
|
239
|
+
});
|
package/biome.json
CHANGED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# ADR-001: Pure reducers and the effect system
|
|
2
|
+
|
|
3
|
+
**Date:** 2025-01-15 (Phase 1)
|
|
4
|
+
**Status:** Accepted
|
|
5
|
+
**Supersedes:** —
|
|
6
|
+
**Superseded by:** —
|
|
7
|
+
|
|
8
|
+
## Context
|
|
9
|
+
|
|
10
|
+
Every IRC daemon must decide where side effects happen. A traditional ircd
|
|
11
|
+
threads mutable state and IO through command handlers — the handler reads a
|
|
12
|
+
channel roster, sends messages, mutates a user record, and returns. That makes
|
|
13
|
+
handlers hard to unit-test (they require a live socket, a real timer, a
|
|
14
|
+
populated data store) and hard to reason about (the handler's observable
|
|
15
|
+
behaviour is inseparable from its IO).
|
|
16
|
+
|
|
17
|
+
ServerlessIRCd targets two cloud adapters (Cloudflare Workers, AWS Lambda)
|
|
18
|
+
that share one protocol core. If the core performed IO directly, each adapter
|
|
19
|
+
would need its own mock surface for every core test — or worse, the core
|
|
20
|
+
would grow adapter-specific code paths.
|
|
21
|
+
|
|
22
|
+
## Decision
|
|
23
|
+
|
|
24
|
+
Every command handler is a **pure function** with the signature:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
type Reducer<S> = (state: S, msg: IrcMessage, ctx: Ctx) => { state: S; effects: Effect[] };
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- **No IO in the reducer.** No `await`, no `fetch`, no `setTimeout`, no
|
|
31
|
+
`Date.now()`, no `Math.random()`. The reducer reads `ctx.clock.now()` and
|
|
32
|
+
`ctx.ids.nonce()` instead (see ADR-007).
|
|
33
|
+
- **Side effects are values.** The reducer returns an `Effect[]` — a
|
|
34
|
+
discriminated union (`Send`, `Broadcast`, `Disconnect`, `ReserveNick`,
|
|
35
|
+
`ChangeNick`, `ReleaseNick`, `ApplyChannelDelta`, `SendToNick`, …). Each
|
|
36
|
+
effect tag maps one-to-one to an `IrcRuntime` method.
|
|
37
|
+
- **A single `dispatch(effects, runtime)` function** in `irc-server`
|
|
38
|
+
interprets the effects against the bound runtime. Only `dispatch` awaits.
|
|
39
|
+
- **Reducer purity is enforced by convention and testability, not by a
|
|
40
|
+
sandbox.** The type system carries the contract; the 100% coverage gate on
|
|
41
|
+
`irc-core` verifies it empirically.
|
|
42
|
+
|
|
43
|
+
## Consequences
|
|
44
|
+
|
|
45
|
+
**Positive:**
|
|
46
|
+
- Every reducer is a trivial unit test: arrange state, apply message, assert
|
|
47
|
+
`state` and `effects`. No mocks, no fakes beyond an injected `Clock` and
|
|
48
|
+
`IdFactory`.
|
|
49
|
+
- The same reducer code runs unchanged on in-memory, CF, and AWS runtimes.
|
|
50
|
+
Adapter work is "wire the runtime", not "rewrite the handler".
|
|
51
|
+
- Reducer behaviour is deterministic and reproducible — critical for the
|
|
52
|
+
parametrized contract suite (TICKET-032) that runs identical scenarios
|
|
53
|
+
across all three runtimes.
|
|
54
|
+
- Mutation testing (Stryker, TICKET-048) is meaningful because reducers have
|
|
55
|
+
no hidden IO to mask a mutation's effect.
|
|
56
|
+
|
|
57
|
+
**Negative:**
|
|
58
|
+
- The `Effect` union grows with every new side-effect type. Adding a novel
|
|
59
|
+
effect (e.g. `QueryHistory` for chathistory) requires touching `effects.ts`,
|
|
60
|
+
`dispatch.ts`, and every runtime implementation. This is by design (the
|
|
61
|
+
type system makes missing-runtime-support a compile error) but adds
|
|
62
|
+
ceremony.
|
|
63
|
+
- Reducers cannot directly observe the result of a side effect they emit
|
|
64
|
+
(e.g. "did the broadcast actually reach anyone?"). Cross-effect
|
|
65
|
+
coordination is deferred to the actor layer, which can pre-fetch runtime
|
|
66
|
+
state before invoking the reducer.
|
|
67
|
+
|
|
68
|
+
## References
|
|
69
|
+
|
|
70
|
+
- PLAN §2.1 — "The key idea: pure reducers + location-of-authority"
|
|
71
|
+
- PLAN §2.2 — `IrcRuntime` port
|
|
72
|
+
- `packages/irc-core/src/effects.ts` — `Effect` union
|
|
73
|
+
- `packages/irc-core/src/types.ts` — `Reducer<S>` signature
|
|
74
|
+
- `packages/irc-server/src/dispatch.ts` — `dispatch(effects, runtime)`
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# ADR-002: Location-of-authority state split
|
|
2
|
+
|
|
3
|
+
**Date:** 2025-01-15 (Phase 1)
|
|
4
|
+
**Status:** Accepted
|
|
5
|
+
**Supersedes:** —
|
|
6
|
+
**Superseded by:** —
|
|
7
|
+
|
|
8
|
+
## Context
|
|
9
|
+
|
|
10
|
+
An IRC server manages several kinds of state: connection records (nick, caps,
|
|
11
|
+
away status), channel rosters (who is in `#foo`, with what prefix), channel
|
|
12
|
+
metadata (topic, modes, ban lists), and the nick registry (nick → connection
|
|
13
|
+
map). In a single-process ircd all of this lives in one address space, so
|
|
14
|
+
read-modify-write races are handled with locks or a single event loop.
|
|
15
|
+
|
|
16
|
+
ServerlessIRCd runs on stateless-per-request compute (Lambda) or
|
|
17
|
+
per-entity-isolated compute (Durable Objects). There is no global lock. If
|
|
18
|
+
two connections race to JOIN the same channel, or two clients race to claim
|
|
19
|
+
the same nick, the outcome must still be correct. Traditional locking is
|
|
20
|
+
impossible across instances.
|
|
21
|
+
|
|
22
|
+
## Decision
|
|
23
|
+
|
|
24
|
+
Each piece of state has **exactly one authority** — the single entity that
|
|
25
|
+
owns the canonical copy and serializes all mutations to it. Reducers are
|
|
26
|
+
routed to the authority that owns the state they mutate:
|
|
27
|
+
|
|
28
|
+
| Command family | Authority | Primary state |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| Registration (NICK/USER/PASS) | Connection entity | `ConnectionState` |
|
|
31
|
+
| PING/PONG, QUIT, AWAY, user MODE | Connection entity | `ConnectionState` |
|
|
32
|
+
| JOIN/PART/channel MODE/TOPIC/KICK/INVITE | Channel entity | `ChannelState` + roster |
|
|
33
|
+
| PRIVMSG/NOTICE to channel | Channel entity | Fanout (reads roster) |
|
|
34
|
+
| PRIVMSG/NOTICE to user | Sender Connection | Nick → Connection route |
|
|
35
|
+
| Nick reservation | Registry entity | Nick → Connection map |
|
|
36
|
+
|
|
37
|
+
Cross-authority coordination is done via **effects**, not direct mutation.
|
|
38
|
+
When a connection accepts a JOIN, the connection-authority reducer emits an
|
|
39
|
+
`ApplyChannelDelta` effect that the channel authority consumes. The channel
|
|
40
|
+
authority is the sole writer of its roster; the connection never touches it
|
|
41
|
+
directly.
|
|
42
|
+
|
|
43
|
+
Platform mapping:
|
|
44
|
+
- **Cloudflare:** each authority is a Durable Object (`ConnectionDO`,
|
|
45
|
+
`ChannelDO`, `RegistryDO`). DO single-threaded serial execution gives the
|
|
46
|
+
uniqueness invariant for free — two concurrent requests to the same DO are
|
|
47
|
+
queued, not racing.
|
|
48
|
+
- **AWS:** each authority is a DynamoDB table with conditional writes
|
|
49
|
+
(`ConditionExpression`) or `TransactWriteItems` for multi-row atomicity
|
|
50
|
+
(e.g. JOIN spans `Connections` and `ChannelMembers`).
|
|
51
|
+
|
|
52
|
+
## Consequences
|
|
53
|
+
|
|
54
|
+
**Positive:**
|
|
55
|
+
- Races are structurally impossible, not defended against. Two clients
|
|
56
|
+
claiming the same nick both route to the same `RegistryDO` shard (or the
|
|
57
|
+
same `Nicks` row with a conditional PutItem) — exactly one wins.
|
|
58
|
+
- The reducer signature stays uniform (`Reducer<S>`) regardless of which
|
|
59
|
+
authority it runs in. The actor layer routes; the reducer does not know or
|
|
60
|
+
care whether it is running in a DO or a Lambda.
|
|
61
|
+
- Each authority can be tested in isolation. The channel reducer tests
|
|
62
|
+
arrange a `ChannelState` and assert effects; they never need a live
|
|
63
|
+
connection registry.
|
|
64
|
+
|
|
65
|
+
**Negative:**
|
|
66
|
+
- Cross-authority coordination requires a round-trip (DO `stub()` call on CF;
|
|
67
|
+
extra DynamoDB write on AWS). A JOIN that updates both the connection's
|
|
68
|
+
joined-list and the channel roster is two writes, not one.
|
|
69
|
+
- The actor layer must pre-fetch authoritative state before invoking a
|
|
70
|
+
reducer (e.g. WHOIS needs the target connection's state from a different
|
|
71
|
+
authority). This adds a `prefetchChannelState` step in the actor pipeline.
|
|
72
|
+
- Listing all channels (LIST) or resolving every member of a channel (WHO)
|
|
73
|
+
requires aggregating across authorities — there is no global view. The CF
|
|
74
|
+
adapter needs a channel registry (TICKET-073); the AWS adapter does a
|
|
75
|
+
DynamoDB scan.
|
|
76
|
+
|
|
77
|
+
## References
|
|
78
|
+
|
|
79
|
+
- PLAN §2.1 — location-of-authority table
|
|
80
|
+
- PLAN §4 — state model & authoritative ownership
|
|
81
|
+
- ADR-001 — pure reducers (how effects flow between authorities)
|
|
82
|
+
- `packages/irc-server/src/actor.ts` — `route()` switch (authority routing)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# ADR-003: Cloudflare Durable Object sharding strategy
|
|
2
|
+
|
|
3
|
+
**Date:** 2025-02-01 (Phase 3)
|
|
4
|
+
**Status:** Accepted
|
|
5
|
+
**Supersedes:** —
|
|
6
|
+
**Superseded by:** —
|
|
7
|
+
|
|
8
|
+
## Context
|
|
9
|
+
|
|
10
|
+
Cloudflare Durable Objects (DOs) are single-threaded per-instance: all
|
|
11
|
+
requests to a given DO instance are serialized. This is ideal for enforcing
|
|
12
|
+
uniqueness invariants (two racing requests land on the same DO, one after the
|
|
13
|
+
other), but a single DO instance is a throughput ceiling. ServerlessIRCd needs
|
|
14
|
+
three DO classes:
|
|
15
|
+
|
|
16
|
+
1. **ConnectionDO** — one per WebSocket connection. Keyed by `connectionId`.
|
|
17
|
+
No sharding needed: each connection is its own instance.
|
|
18
|
+
2. **ChannelDO** — one per channel. Keyed by lowercased channel name.
|
|
19
|
+
No sharding needed: one DO per channel is the natural unit; a hot channel
|
|
20
|
+
(1k+ members) is bounded by DO fanout latency, not by DO count.
|
|
21
|
+
3. **RegistryDO** — holds the global nick → connectionId map. If this were a
|
|
22
|
+
single DO, every nick registration on the entire server would serialize
|
|
23
|
+
through one instance. That is an unacceptable bottleneck for any non-trivial
|
|
24
|
+
deployment.
|
|
25
|
+
|
|
26
|
+
The open question (PLAN §9): *"Channel DO sharding granularity (one DO per
|
|
27
|
+
channel vs. bucketed)?"* and *"Registry DO: how to shard?"*
|
|
28
|
+
|
|
29
|
+
## Decision
|
|
30
|
+
|
|
31
|
+
**ConnectionDO:** one instance per connection (`idFromName(connectionId)`).
|
|
32
|
+
No sharding.
|
|
33
|
+
|
|
34
|
+
**ChannelDO:** one instance per channel
|
|
35
|
+
(`idFromName(lowercasedChannelName)`). No bucketing. If a hot channel exceeds
|
|
36
|
+
DO throughput limits, the mitigation is fanout batching (one `stub()` call
|
|
37
|
+
carrying multiple lines) rather than splitting the channel across DOs. A
|
|
38
|
+
single channel's roster and modes live in one DO so that all mutations are
|
|
39
|
+
serialized correctly.
|
|
40
|
+
|
|
41
|
+
**RegistryDO:** sharded by `hash(lowercased(nick)) % N`, where `N` defaults
|
|
42
|
+
to **32** (`DEFAULT_REGISTRY_SHARDS`). The hash function is FNV-1a 32-bit,
|
|
43
|
+
chosen for its uniform distribution, deterministic output (no randomness),
|
|
44
|
+
and trivial implementation.
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
shard = fnv1a32(nick.toLowerCase()) % N
|
|
48
|
+
key = `s${shard}` // e.g. "s7", "s31"
|
|
49
|
+
stub = env.REGISTRY_DO.get(env.REGISTRY_DO.idFromName(key))
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Two clients racing on the same nick always land on the same shard (because
|
|
53
|
+
lowercasing collapses case variants), so the single-shard uniqueness check
|
|
54
|
+
covers all case variants. Different nicks spread across shards, so
|
|
55
|
+
registration throughput scales with the fleet size.
|
|
56
|
+
|
|
57
|
+
Cross-shard nick changes (old nick and new nick hash to different shards)
|
|
58
|
+
use a two-phase reserve-new-then-release-old. The new-nick shard wins the
|
|
59
|
+
race; the old-nick shard is released only after the new reservation succeeds.
|
|
60
|
+
|
|
61
|
+
`N` is configurable via the `REGISTRY_SHARDS` environment variable. Changing
|
|
62
|
+
`N` in production requires a migration (re-sharding every existing nick entry
|
|
63
|
+
to its new shard).
|
|
64
|
+
|
|
65
|
+
## Consequences
|
|
66
|
+
|
|
67
|
+
**Positive:**
|
|
68
|
+
- Nick uniqueness is race-free within the DO model. No conditional writes,
|
|
69
|
+
no optimistic concurrency — the DO's serial execution is the lock.
|
|
70
|
+
- Registration throughput scales horizontally: 32 shards handle 32× the
|
|
71
|
+
single-DO throughput for distinct nicks.
|
|
72
|
+
- One DO per channel keeps channel semantics simple: JOIN/PART/MODE/KICK all
|
|
73
|
+
serialize through the same instance.
|
|
74
|
+
|
|
75
|
+
**Negative:**
|
|
76
|
+
- Changing `N` (the shard count) requires migrating every existing nick to
|
|
77
|
+
its new shard. This is an offline migration, not a live reshard. The shard
|
|
78
|
+
count should be chosen conservatively at deployment time.
|
|
79
|
+
- The channel registry problem: there is no DO that "knows" all channels
|
|
80
|
+
(LIST, TICKET-073). A dedicated registry DO or KV index is needed — this is
|
|
81
|
+
a known gap tracked in TICKET-073.
|
|
82
|
+
- Hot-nick collisions (many nicks hashing to the same shard) are possible but
|
|
83
|
+
statistically rare with FNV-1a and `N = 32` for typical populations.
|
|
84
|
+
|
|
85
|
+
## References
|
|
86
|
+
|
|
87
|
+
- PLAN §9 — open question: "Channel DO sharding granularity"
|
|
88
|
+
- PLAN §6.1 — `RegistryDO` sharded by `hash(nick) % N`
|
|
89
|
+
- `packages/cf-adapter/src/sharding.ts` — `shardNick`, `registryKeyForNick`,
|
|
90
|
+
`DEFAULT_REGISTRY_SHARDS = 32`
|
|
91
|
+
- `packages/cf-adapter/src/registry-do.ts` — per-shard registry authority
|
|
92
|
+
- TICKET-034 — RegistryDO implementation
|
|
93
|
+
- TICKET-073 — CF runtime stubbed lookups (channel registry gap)
|