serverless-ircd 0.5.0 → 0.6.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 +34 -2
- package/.github/workflows/deploy-aws.yml +1 -2
- package/.github/workflows/deploy-cf-tcp.yml +1 -5
- package/.github/workflows/deploy-cf.yml +1 -2
- package/CHANGELOG.md +260 -0
- package/README.md +132 -22
- package/apps/aws-stack/README.md +33 -0
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +74 -9
- package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
- package/apps/aws-stack/tests/stack.test.ts +2 -2
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
- package/apps/aws-stack/tests/vitest.setup.ts +18 -0
- package/apps/aws-stack/vitest.config.ts +3 -0
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-tcp-container/src/config-loader.ts +6 -7
- package/apps/cf-tcp-container/src/container-server.ts +3 -0
- package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
- package/apps/cf-tcp-container/wrangler.toml +1 -1
- package/apps/cf-worker/package.json +2 -1
- package/apps/cf-worker/vitest.config.ts +1 -1
- package/apps/cf-worker/wrangler.toml +5 -3
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +6 -6
- package/apps/local-cli/src/main.ts +20 -0
- package/apps/local-cli/src/server.ts +9 -7
- package/apps/local-cli/tests/e2e.test.ts +112 -28
- package/apps/local-cli/tests/tcp.test.ts +71 -19
- package/apps/local-cli/tests/ws-subprotocol.test.ts +12 -7
- package/biome.json +1 -0
- package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
- package/docs/ADR-002-location-of-authority.md +82 -0
- package/docs/ADR-003-durable-object-sharding.md +110 -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 +96 -0
- package/docs/ADR-007-deterministic-ports.md +82 -0
- package/docs/ADR-008-monorepo-tooling.md +60 -0
- package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
- package/docs/ADR-Index.md +15 -0
- package/docs/AWS-Adapter-Architecture.md +496 -0
- package/docs/AWS-Deployment.md +1275 -0
- package/docs/AWS-TCP-Deployment.md +410 -0
- package/docs/Cloudflare-Deployment-Guide.md +719 -0
- package/docs/Cloudflare-TCP-Deployment.md +510 -0
- package/docs/Home.md +15 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanExtensions.md +812 -0
- package/docs/PlanIRCv3Websocket.md +503 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +443 -0
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/handlers/default.ts +3 -0
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +9 -0
- package/packages/aws-adapter/src/serialize.ts +29 -1
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
- package/packages/aws-adapter/tests/account-store.test.ts +4 -7
- package/packages/aws-adapter/tests/aws-harness.ts +5 -2
- package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
- package/packages/aws-adapter/tests/aws-runtime.test.ts +79 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
- package/packages/aws-adapter/tests/connect.test.ts +9 -9
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
- package/packages/aws-adapter/tests/global-setup.ts +55 -30
- package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
- package/packages/aws-adapter/tests/handlers.test.ts +90 -4
- package/packages/aws-adapter/tests/message-store.test.ts +6 -5
- package/packages/aws-adapter/tests/nlb-stream.test.ts +60 -8
- package/packages/cf-adapter/package.json +2 -1
- package/packages/cf-adapter/src/connection-do.ts +3 -0
- package/packages/cf-adapter/src/serialize.ts +25 -4
- package/packages/cf-adapter/tests/cf-runtime.test.ts +269 -0
- package/packages/cf-adapter/tests/channel-do.test.ts +117 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
- package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
- package/packages/cf-adapter/tests/registry-do.test.ts +104 -0
- package/packages/cf-adapter/tests/serialize.test.ts +308 -0
- package/packages/cf-adapter/vitest.config.ts +1 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/caps/capabilities.ts +37 -3
- package/packages/irc-core/src/caps/index.ts +1 -0
- package/packages/irc-core/src/caps/sts.ts +84 -0
- package/packages/irc-core/src/commands/account-notify.ts +53 -0
- package/packages/irc-core/src/commands/away.ts +9 -3
- package/packages/irc-core/src/commands/cap.ts +23 -5
- package/packages/irc-core/src/commands/chathistory.ts +40 -11
- package/packages/irc-core/src/commands/index.ts +34 -0
- package/packages/irc-core/src/commands/isupport.ts +53 -0
- package/packages/irc-core/src/commands/mode.ts +8 -1
- package/packages/irc-core/src/commands/monitor.ts +327 -0
- package/packages/irc-core/src/commands/multiline.ts +256 -0
- package/packages/irc-core/src/commands/pre-away.ts +112 -0
- package/packages/irc-core/src/commands/privmsg.ts +19 -12
- package/packages/irc-core/src/commands/read-marker.ts +108 -0
- package/packages/irc-core/src/commands/registration.ts +8 -0
- package/packages/irc-core/src/commands/sasl.ts +24 -1
- package/packages/irc-core/src/commands/tagmsg.ts +71 -1
- package/packages/irc-core/src/commands/whois.ts +5 -0
- package/packages/irc-core/src/config.ts +65 -10
- package/packages/irc-core/src/effects.ts +14 -0
- package/packages/irc-core/src/ports.ts +389 -0
- package/packages/irc-core/src/protocol/index.ts +14 -0
- package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
- package/packages/irc-core/src/protocol/numerics.ts +15 -0
- package/packages/irc-core/src/protocol/outbound.ts +16 -1
- package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
- package/packages/irc-core/src/state/connection.ts +32 -1
- package/packages/irc-core/src/types.ts +112 -0
- package/packages/irc-core/stryker.commands.conf.json +1 -2
- package/packages/irc-core/tests/away-store.test.ts +73 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
- package/packages/irc-core/tests/caps/sts.test.ts +123 -0
- package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
- package/packages/irc-core/tests/commands/away.test.ts +85 -2
- package/packages/irc-core/tests/commands/cap.test.ts +165 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
- package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
- package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
- package/packages/irc-core/tests/commands/mode.test.ts +72 -0
- package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
- package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
- package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
- package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
- package/packages/irc-core/tests/commands/registration.test.ts +64 -0
- package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
- package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
- package/packages/irc-core/tests/commands/whois.test.ts +34 -0
- package/packages/irc-core/tests/config.test.ts +103 -13
- package/packages/irc-core/tests/labeled-response.test.ts +181 -0
- package/packages/irc-core/tests/message-tags.test.ts +114 -0
- package/packages/irc-core/tests/monitor-store.test.ts +200 -0
- package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
- package/packages/irc-core/tests/standard-replies.test.ts +412 -0
- package/packages/irc-core/tests/types.test.ts +27 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +450 -3
- package/packages/irc-server/src/dispatch.ts +108 -16
- package/packages/irc-server/tests/actor.test.ts +1432 -39
- package/packages/irc-server/tests/dispatch.test.ts +263 -2
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/index.ts +6 -0
- package/packages/irc-test-support/src/test-config.ts +54 -0
- package/packages/irc-test-support/tests/test-config.test.ts +51 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
- package/tools/load-test/package.json +33 -0
- package/tools/load-test/src/client.ts +351 -0
- package/tools/load-test/src/config.ts +313 -0
- package/tools/load-test/src/harness.ts +116 -0
- package/tools/load-test/src/main.ts +120 -0
- package/tools/load-test/src/metrics.ts +168 -0
- package/tools/load-test/src/report.ts +106 -0
- package/tools/load-test/tests/client.test.ts +212 -0
- package/tools/load-test/tests/config.test.ts +152 -0
- package/tools/load-test/tests/framing.test.ts +37 -0
- package/tools/load-test/tests/harness.test.ts +165 -0
- package/tools/load-test/tests/metrics.test.ts +174 -0
- package/tools/load-test/tests/report.test.ts +161 -0
- package/tools/load-test/tests/smoke.test.ts +67 -0
- package/tools/load-test/tsconfig.build.json +12 -0
- package/tools/load-test/tsconfig.test.json +10 -0
- package/tools/load-test/vitest.config.ts +29 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
|
@@ -53,6 +53,7 @@ interface RegistryRpc {
|
|
|
53
53
|
changeNick(conn: string, oldNick: string, newNick: string): Promise<boolean>;
|
|
54
54
|
releaseNick(nick: string): Promise<void>;
|
|
55
55
|
lookupNick(nick: string): Promise<string | null>;
|
|
56
|
+
listEntries(): Promise<Array<[nickLower: string, connId: string]>>;
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
function rpc(stub: DurableObjectStub): RegistryRpc {
|
|
@@ -333,3 +334,106 @@ describe('RegistryDO — shard isolation', () => {
|
|
|
333
334
|
expect(await rpc(i).lookupNick('in-shard-31')).toBeNull();
|
|
334
335
|
});
|
|
335
336
|
});
|
|
337
|
+
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
// changeNick — direct contract tests (sharding is a CfRuntime concern; the
|
|
340
|
+
// DO method itself operates on whatever keys it is given, so we address a
|
|
341
|
+
// single DO instance directly to exercise every branch without depending on
|
|
342
|
+
// the FNV-1a hash landing two nicks in the same shard).
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
describe('RegistryDO — changeNick branch coverage (direct on one shard)', () => {
|
|
346
|
+
// Pin every test to a single, stable shard instance. The DO method is
|
|
347
|
+
// agnostic to which shard it lives on; what matters is the storage
|
|
348
|
+
// state we put in place before the call.
|
|
349
|
+
const shard = () => shardByIndex(1);
|
|
350
|
+
|
|
351
|
+
it('returns false when the new nick is held by a different conn', async () => {
|
|
352
|
+
const s = shard();
|
|
353
|
+
// Seed storage so both `taken` and `current` are owned by different
|
|
354
|
+
// conns on this same DO.
|
|
355
|
+
expect(await rpc(s).reserveNick('taken-new', 'conn-owner')).toEqual({ ok: true });
|
|
356
|
+
expect(await rpc(s).reserveNick('current-old', 'conn-changer')).toEqual({ ok: true });
|
|
357
|
+
|
|
358
|
+
const ok = await rpc(s).changeNick('conn-changer', 'current-old', 'taken-new');
|
|
359
|
+
expect(ok).toBe(false);
|
|
360
|
+
// State untouched: owners stay as they were.
|
|
361
|
+
expect(await rpc(s).lookupNick('taken-new')).toBe('conn-owner');
|
|
362
|
+
expect(await rpc(s).lookupNick('current-old')).toBe('conn-changer');
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('clears the old nick when it still points at the caller', async () => {
|
|
366
|
+
const s = shard();
|
|
367
|
+
expect(await rpc(s).reserveNick('cn-clear-old', 'conn-self')).toEqual({ ok: true });
|
|
368
|
+
|
|
369
|
+
const ok = await rpc(s).changeNick('conn-self', 'cn-clear-old', 'cn-clear-new');
|
|
370
|
+
expect(ok).toBe(true);
|
|
371
|
+
expect(await rpc(s).lookupNick('cn-clear-old')).toBeNull();
|
|
372
|
+
expect(await rpc(s).lookupNick('cn-clear-new')).toBe('conn-self');
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('leaves a stale old nick alone when it no longer points at the caller', async () => {
|
|
376
|
+
// Crash-recovery / race defence: another conn grabbed `old` between
|
|
377
|
+
// our reserve and our changeNick. The conditional delete must not
|
|
378
|
+
// clobber that third party.
|
|
379
|
+
const s = shard();
|
|
380
|
+
// `old` is currently held by `conn-other`, not by `conn-caller`.
|
|
381
|
+
expect(await rpc(s).reserveNick('cn-stale-old', 'conn-other')).toEqual({ ok: true });
|
|
382
|
+
|
|
383
|
+
const ok = await rpc(s).changeNick('conn-caller', 'cn-stale-old', 'cn-stale-new');
|
|
384
|
+
expect(ok).toBe(true);
|
|
385
|
+
// New nick reserved for the caller...
|
|
386
|
+
expect(await rpc(s).lookupNick('cn-stale-new')).toBe('conn-caller');
|
|
387
|
+
// ...but `old` is untouched because it never pointed at the caller.
|
|
388
|
+
expect(await rpc(s).lookupNick('cn-stale-old')).toBe('conn-other');
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
// listEntries — backs the global WALLOPS fan-out (CfRuntime.broadcastWallops
|
|
394
|
+
// walks every shard via this method).
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
describe('RegistryDO — listEntries', () => {
|
|
398
|
+
it('returns an empty array for a fresh shard', async () => {
|
|
399
|
+
expect(await rpc(shardByIndex(2)).listEntries()).toEqual([]);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('enumerates every (nickLower, connId) pair in this shard', async () => {
|
|
403
|
+
const s = shardByIndex(3);
|
|
404
|
+
await rpc(s).reserveNick('Alice', 'conn-a');
|
|
405
|
+
await rpc(s).reserveNick('Bob', 'conn-b');
|
|
406
|
+
await rpc(s).reserveNick('Carol', 'conn-c');
|
|
407
|
+
|
|
408
|
+
const entries = await rpc(s).listEntries();
|
|
409
|
+
// Keys are lowercased in storage; the strip recovers the lowercased
|
|
410
|
+
// nick (matches what CfRuntime expects when deduping conn ids).
|
|
411
|
+
const sorted = [...entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
412
|
+
expect(sorted).toEqual([
|
|
413
|
+
['alice', 'conn-a'],
|
|
414
|
+
['bob', 'conn-b'],
|
|
415
|
+
['carol', 'conn-c'],
|
|
416
|
+
]);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('reflects releases: a freed nick drops out of the listing', async () => {
|
|
420
|
+
const s = shardByIndex(4);
|
|
421
|
+
await rpc(s).reserveNick('keep', 'conn-k');
|
|
422
|
+
await rpc(s).reserveNick('drop', 'conn-d');
|
|
423
|
+
await rpc(s).releaseNick('drop');
|
|
424
|
+
|
|
425
|
+
const entries = await rpc(s).listEntries();
|
|
426
|
+
expect(entries).toEqual([['keep', 'conn-k']]);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it('strips the storage prefix so callers see a bare nickLower', async () => {
|
|
430
|
+
const s = shardByIndex(5);
|
|
431
|
+
await rpc(s).reserveNick('prefixed', 'conn-p');
|
|
432
|
+
const entries = await rpc(s).listEntries();
|
|
433
|
+
const nickLower = entries[0]?.[0];
|
|
434
|
+
// The internal key is `nick:prefixed`; the public API must not leak
|
|
435
|
+
// that implementation detail.
|
|
436
|
+
expect(nickLower).toBe('prefixed');
|
|
437
|
+
expect(nickLower?.startsWith('nick:')).toBe(false);
|
|
438
|
+
});
|
|
439
|
+
});
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `serialize` / `deserialize` / `migrate`.
|
|
3
|
+
*
|
|
4
|
+
* These helpers bridge {@link ConnectionState} (which carries `Set` and
|
|
5
|
+
* `Map` fields that degrade under `JSON.stringify`) and the JSON-safe
|
|
6
|
+
* {@link PersistedConnectionState} shape stored in DO transactional KV.
|
|
7
|
+
*
|
|
8
|
+
* Coverage focus: every optional-field branch in both directions, the
|
|
9
|
+
* `secure` / `userModes.tls` backfill paths in `migrate`, and the
|
|
10
|
+
* rejection paths (non-object record, too-new version).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { type ConnectionState, createConnection } from '@serverless-ircd/irc-core';
|
|
14
|
+
import { describe, expect, it } from 'vitest';
|
|
15
|
+
import { PERSISTED_STATE_VERSION, deserialize, migrate, serialize } from '../src/serialize';
|
|
16
|
+
|
|
17
|
+
/** Builds a fully-populated connection state for round-trip assertions. */
|
|
18
|
+
function fullState(): ConnectionState {
|
|
19
|
+
const s = createConnection({ id: 'conn-1', connectedSince: 100 });
|
|
20
|
+
s.registration = 'registered';
|
|
21
|
+
s.capNegotiating = false;
|
|
22
|
+
s.secure = true;
|
|
23
|
+
s.nick = 'Alice';
|
|
24
|
+
s.user = 'alice';
|
|
25
|
+
s.host = 'example.com';
|
|
26
|
+
s.realname = 'Alice Example';
|
|
27
|
+
s.passAttempt = 'hunter2';
|
|
28
|
+
s.account = 'alice-account';
|
|
29
|
+
s.away = 'brb';
|
|
30
|
+
s.caps = new Set(['server-time', 'message-tags']);
|
|
31
|
+
s.joinedChannels = new Set(['#one', '#two']);
|
|
32
|
+
s.userModes = {
|
|
33
|
+
invisible: true,
|
|
34
|
+
oper: false,
|
|
35
|
+
wallops: true,
|
|
36
|
+
serverNotices: false,
|
|
37
|
+
tls: true,
|
|
38
|
+
};
|
|
39
|
+
s.lastSeen = 200;
|
|
40
|
+
return s;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('serialize — round trip', () => {
|
|
44
|
+
it('survives serialize → deserialize for a fully-populated state', () => {
|
|
45
|
+
const original = fullState();
|
|
46
|
+
const round = deserialize(serialize(original));
|
|
47
|
+
// Sets are rebuilt with the same members.
|
|
48
|
+
expect(round.caps).toEqual(new Set(['server-time', 'message-tags']));
|
|
49
|
+
expect(round.joinedChannels).toEqual(new Set(['#one', '#two']));
|
|
50
|
+
// Optional fields preserved.
|
|
51
|
+
expect(round.nick).toBe('Alice');
|
|
52
|
+
expect(round.user).toBe('alice');
|
|
53
|
+
expect(round.host).toBe('example.com');
|
|
54
|
+
expect(round.realname).toBe('Alice Example');
|
|
55
|
+
expect(round.passAttempt).toBe('hunter2');
|
|
56
|
+
expect(round.account).toBe('alice-account');
|
|
57
|
+
expect(round.away).toBe('brb');
|
|
58
|
+
// userModes cloned (mutating the round trip must not leak back).
|
|
59
|
+
expect(round.userModes).toEqual(original.userModes);
|
|
60
|
+
expect(round.userModes).not.toBe(original.userModes);
|
|
61
|
+
expect(round.secure).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('is JSON-stable: serialize → JSON.stringify → JSON.parse → deserialize', () => {
|
|
65
|
+
// This is the actual production path through DO storage: the runtime
|
|
66
|
+
// structured-clones the value, but the persisted shape must also be
|
|
67
|
+
// JSON-tolerant for older storage rows and snapshots written to R2.
|
|
68
|
+
const original = fullState();
|
|
69
|
+
const json = JSON.stringify(serialize(original));
|
|
70
|
+
const round = deserialize(JSON.parse(json));
|
|
71
|
+
expect(round.caps).toEqual(original.caps);
|
|
72
|
+
expect(round.nick).toBe(original.nick);
|
|
73
|
+
expect(round.away).toBe(original.away);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('stamps the current persisted schema version', () => {
|
|
77
|
+
expect(serialize(fullState()).version).toBe(PERSISTED_STATE_VERSION);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('serialize — optional-field branches', () => {
|
|
82
|
+
it('omits host when undefined', () => {
|
|
83
|
+
const s = createConnection({ id: 'c', connectedSince: 0 });
|
|
84
|
+
s.nick = 'n';
|
|
85
|
+
expect(serialize(s).host).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('omits passAttempt when undefined', () => {
|
|
89
|
+
const s = createConnection({ id: 'c', connectedSince: 0 });
|
|
90
|
+
expect(serialize(s).passAttempt).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('omits away when undefined', () => {
|
|
94
|
+
const s = createConnection({ id: 'c', connectedSince: 0 });
|
|
95
|
+
expect(serialize(s).away).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('clones caps and joinedChannels arrays away from the live Set', () => {
|
|
99
|
+
const s = createConnection({ id: 'c', connectedSince: 0 });
|
|
100
|
+
s.caps = new Set(['a']);
|
|
101
|
+
s.joinedChannels = new Set(['#x']);
|
|
102
|
+
const out = serialize(s);
|
|
103
|
+
expect(out.caps).toEqual(['a']);
|
|
104
|
+
expect(out.joinedChannels).toEqual(['#x']);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('deserialize — optional-field branches', () => {
|
|
109
|
+
it('populates host when the persisted record carries it', () => {
|
|
110
|
+
const s = createConnection({ id: 'c', connectedSince: 0 });
|
|
111
|
+
s.host = 'host.example';
|
|
112
|
+
expect(deserialize(serialize(s)).host).toBe('host.example');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('populates passAttempt when the persisted record carries it', () => {
|
|
116
|
+
const s = createConnection({ id: 'c', connectedSince: 0 });
|
|
117
|
+
s.passAttempt = 'pw';
|
|
118
|
+
expect(deserialize(serialize(s)).passAttempt).toBe('pw');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('populates account when the persisted record carries it', () => {
|
|
122
|
+
const s = createConnection({ id: 'c', connectedSince: 0 });
|
|
123
|
+
s.account = 'acc';
|
|
124
|
+
expect(deserialize(serialize(s)).account).toBe('acc');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('populates away when the persisted record carries it', () => {
|
|
128
|
+
const s = createConnection({ id: 'c', connectedSince: 0 });
|
|
129
|
+
s.away = 'afk';
|
|
130
|
+
expect(deserialize(serialize(s)).away).toBe('afk');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('leaves optional fields absent on a minimal pre-registration state', () => {
|
|
134
|
+
const s = createConnection({ id: 'c', connectedSince: 0 });
|
|
135
|
+
const round = deserialize(serialize(s));
|
|
136
|
+
expect(round.nick).toBeUndefined();
|
|
137
|
+
expect(round.host).toBeUndefined();
|
|
138
|
+
expect(round.away).toBeUndefined();
|
|
139
|
+
expect(round.account).toBeUndefined();
|
|
140
|
+
expect(round.passAttempt).toBeUndefined();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('migrate — backfills additive fields', () => {
|
|
145
|
+
it('defaults `secure` to false when the field is absent', () => {
|
|
146
|
+
// Pre-feature record: no `secure` key at all.
|
|
147
|
+
const legacy = {
|
|
148
|
+
version: 1,
|
|
149
|
+
id: 'c',
|
|
150
|
+
registration: 'registered' as const,
|
|
151
|
+
capNegotiating: false,
|
|
152
|
+
caps: [],
|
|
153
|
+
joinedChannels: [],
|
|
154
|
+
userModes: {
|
|
155
|
+
invisible: false,
|
|
156
|
+
oper: false,
|
|
157
|
+
wallops: false,
|
|
158
|
+
serverNotices: false,
|
|
159
|
+
},
|
|
160
|
+
lastSeen: 0,
|
|
161
|
+
connectedSince: 0,
|
|
162
|
+
};
|
|
163
|
+
const migrated = migrate(legacy);
|
|
164
|
+
expect(migrated.secure).toBe(false);
|
|
165
|
+
// The mutation landed on the original object reference (defensive
|
|
166
|
+
// backfill writes through to the record).
|
|
167
|
+
expect((legacy as Record<string, unknown>).secure).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('defaults `userModes.tls` to false when the field is absent', () => {
|
|
171
|
+
const legacy = {
|
|
172
|
+
version: 1,
|
|
173
|
+
id: 'c',
|
|
174
|
+
registration: 'registered' as const,
|
|
175
|
+
capNegotiating: false,
|
|
176
|
+
caps: [],
|
|
177
|
+
joinedChannels: [],
|
|
178
|
+
userModes: {
|
|
179
|
+
invisible: false,
|
|
180
|
+
oper: false,
|
|
181
|
+
wallops: false,
|
|
182
|
+
serverNotices: false,
|
|
183
|
+
},
|
|
184
|
+
lastSeen: 0,
|
|
185
|
+
connectedSince: 0,
|
|
186
|
+
};
|
|
187
|
+
const migrated = migrate(legacy);
|
|
188
|
+
expect(migrated.userModes.tls).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('leaves an explicit `secure: true` untouched', () => {
|
|
192
|
+
const rec = {
|
|
193
|
+
version: 1,
|
|
194
|
+
id: 'c',
|
|
195
|
+
registration: 'registered' as const,
|
|
196
|
+
capNegotiating: false,
|
|
197
|
+
secure: true,
|
|
198
|
+
caps: [],
|
|
199
|
+
joinedChannels: [],
|
|
200
|
+
userModes: {
|
|
201
|
+
invisible: false,
|
|
202
|
+
oper: false,
|
|
203
|
+
wallops: false,
|
|
204
|
+
serverNotices: false,
|
|
205
|
+
tls: true,
|
|
206
|
+
},
|
|
207
|
+
lastSeen: 0,
|
|
208
|
+
connectedSince: 0,
|
|
209
|
+
};
|
|
210
|
+
expect(migrate(rec).secure).toBe(true);
|
|
211
|
+
expect(migrate(rec).userModes.tls).toBe(true);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('tolerates a missing `userModes` object without throwing', () => {
|
|
215
|
+
// A record corrupted in storage (or hand-built by an older code
|
|
216
|
+
// path) might not carry userModes at all. Migrate must not crash.
|
|
217
|
+
const rec = {
|
|
218
|
+
version: 1,
|
|
219
|
+
id: 'c',
|
|
220
|
+
registration: 'registered' as const,
|
|
221
|
+
capNegotiating: false,
|
|
222
|
+
caps: [],
|
|
223
|
+
joinedChannels: [],
|
|
224
|
+
lastSeen: 0,
|
|
225
|
+
connectedSince: 0,
|
|
226
|
+
};
|
|
227
|
+
expect(() => migrate(rec)).not.toThrow();
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe('migrate — rejection paths', () => {
|
|
232
|
+
it('rejects a non-object record', () => {
|
|
233
|
+
for (const bad of [null, 'string', 42, true, undefined]) {
|
|
234
|
+
expect(() => migrate(bad)).toThrow(/not an object/u);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('rejects an array record (arrays are not plain objects)', () => {
|
|
239
|
+
expect(() => migrate([])).toThrow(/not an object/u);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('rejects a record persisted with a newer schema version', () => {
|
|
243
|
+
const future = {
|
|
244
|
+
version: PERSISTED_STATE_VERSION + 1,
|
|
245
|
+
id: 'c',
|
|
246
|
+
registration: 'registered' as const,
|
|
247
|
+
capNegotiating: false,
|
|
248
|
+
caps: [],
|
|
249
|
+
joinedChannels: [],
|
|
250
|
+
userModes: {
|
|
251
|
+
invisible: false,
|
|
252
|
+
oper: false,
|
|
253
|
+
wallops: false,
|
|
254
|
+
serverNotices: false,
|
|
255
|
+
tls: false,
|
|
256
|
+
},
|
|
257
|
+
lastSeen: 0,
|
|
258
|
+
connectedSince: 0,
|
|
259
|
+
};
|
|
260
|
+
expect(() => migrate(future)).toThrow(/newer than/u);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('treats a missing version as the legacy default (0)', () => {
|
|
264
|
+
// No `version` field — old shape. Migrate must accept it rather
|
|
265
|
+
// than fail the boot path; the backfill covers the additive keys.
|
|
266
|
+
const legacy = {
|
|
267
|
+
id: 'c',
|
|
268
|
+
registration: 'registered' as const,
|
|
269
|
+
capNegotiating: false,
|
|
270
|
+
caps: [],
|
|
271
|
+
joinedChannels: [],
|
|
272
|
+
userModes: {
|
|
273
|
+
invisible: false,
|
|
274
|
+
oper: false,
|
|
275
|
+
wallops: false,
|
|
276
|
+
serverNotices: false,
|
|
277
|
+
},
|
|
278
|
+
lastSeen: 0,
|
|
279
|
+
connectedSince: 0,
|
|
280
|
+
};
|
|
281
|
+
expect(() => migrate(legacy)).not.toThrow();
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
describe('deserialize — migrate integration', () => {
|
|
286
|
+
it('backfills `secure` and `userModes.tls` on a legacy record, then materializes', () => {
|
|
287
|
+
const legacy = {
|
|
288
|
+
id: 'c',
|
|
289
|
+
registration: 'registered' as const,
|
|
290
|
+
capNegotiating: false,
|
|
291
|
+
caps: ['server-time'],
|
|
292
|
+
joinedChannels: ['#x'],
|
|
293
|
+
userModes: {
|
|
294
|
+
invisible: false,
|
|
295
|
+
oper: false,
|
|
296
|
+
wallops: true,
|
|
297
|
+
serverNotices: false,
|
|
298
|
+
},
|
|
299
|
+
lastSeen: 5,
|
|
300
|
+
connectedSince: 1,
|
|
301
|
+
};
|
|
302
|
+
const state = deserialize(legacy);
|
|
303
|
+
expect(state.secure).toBe(false);
|
|
304
|
+
expect(state.userModes.tls).toBe(false);
|
|
305
|
+
expect(state.userModes.wallops).toBe(true);
|
|
306
|
+
expect(state.caps).toEqual(new Set(['server-time']));
|
|
307
|
+
});
|
|
308
|
+
});
|
|
@@ -21,29 +21,63 @@ export interface Capability {
|
|
|
21
21
|
value?: string;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/** Wire name of the SASL capability. */
|
|
25
|
+
export const SASL_CAP_NAME = 'sasl';
|
|
26
|
+
|
|
27
|
+
/** Wire name of the IRCv3 `draft/multiline` capability. */
|
|
28
|
+
export const MULTILINE_CAP_NAME = 'draft/multiline';
|
|
29
|
+
|
|
30
|
+
/** Wire name of the IRCv3 `draft/read-marker` capability. */
|
|
31
|
+
export const READ_MARKER_CAP_NAME = 'draft/read-marker';
|
|
32
|
+
|
|
33
|
+
/** Wire name of the IRCv3 `draft/pre-away` capability. */
|
|
34
|
+
export const PRE_AWAY_CAP_NAME = 'draft/pre-away';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Default byte budget advertised in `draft/multiline=<n>` and enforced on
|
|
38
|
+
* every finalized multi-line batch. Mirrors the IRCv3 spec's canonical
|
|
39
|
+
* recommendation; deployments override via `ServerConfig.multilineMaxBytes`.
|
|
40
|
+
*/
|
|
41
|
+
export const DEFAULT_MULTILINE_MAX_BYTES = 4096;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Builds the `draft/multiline` cap *value* (the substring after `=`) for the
|
|
45
|
+
* advertised byte budget. The {@link CapReducer} and the multi-line reducer
|
|
46
|
+
* share this helper so the wire token has one source of truth.
|
|
47
|
+
*/
|
|
48
|
+
export function multilineCapValue(maxBytes: number): string {
|
|
49
|
+
return String(maxBytes);
|
|
50
|
+
}
|
|
51
|
+
|
|
24
52
|
/**
|
|
25
53
|
* Every cap advertised by this server in PLAN §3, in canonical (alpha) order
|
|
26
54
|
* so wire output is deterministic across runs.
|
|
27
55
|
*/
|
|
28
56
|
export const SUPPORTED_CAPABILITIES: ReadonlyArray<Capability> = Object.freeze([
|
|
57
|
+
{ name: 'account-notify' },
|
|
29
58
|
{ name: 'account-tag' },
|
|
30
59
|
{ name: 'away-notify' },
|
|
31
60
|
{ name: 'batch' },
|
|
32
61
|
{ name: 'chghost' },
|
|
33
62
|
{ name: 'draft/chathistory' },
|
|
63
|
+
{ name: 'draft/multiline', value: multilineCapValue(DEFAULT_MULTILINE_MAX_BYTES) },
|
|
64
|
+
{ name: 'draft/pre-away' },
|
|
65
|
+
{ name: 'draft/read-marker' },
|
|
66
|
+
{ name: 'draft/typing' },
|
|
34
67
|
{ name: 'echo-message' },
|
|
35
68
|
{ name: 'extended-join' },
|
|
36
69
|
{ name: 'invite-notify' },
|
|
70
|
+
{ name: 'labeled-response' },
|
|
37
71
|
{ name: 'message-tags' },
|
|
72
|
+
{ name: 'msgid' },
|
|
73
|
+
{ name: 'monitor' },
|
|
38
74
|
{ name: 'multi-prefix' },
|
|
39
75
|
{ name: 'safelist' },
|
|
40
76
|
{ name: 'sasl', value: 'PLAIN' },
|
|
41
77
|
{ name: 'server-time' },
|
|
78
|
+
{ name: 'standard-replies' },
|
|
42
79
|
]);
|
|
43
80
|
|
|
44
|
-
/** Wire name of the SASL capability. */
|
|
45
|
-
export const SASL_CAP_NAME = 'sasl';
|
|
46
|
-
|
|
47
81
|
/**
|
|
48
82
|
* Returns the `sasl` cap value to advertise given whether an mTLS identity
|
|
49
83
|
* provider is bound for the connection.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IRCv3 `sts` (Strict Transport Security) policy.
|
|
3
|
+
*
|
|
4
|
+
* Spec: https://ircv3.net/specs/extensions/sts
|
|
5
|
+
*
|
|
6
|
+
* The server advertises an `sts` capability whose value is a
|
|
7
|
+
* comma-separated `key=value` policy. Compliant clients inspect the
|
|
8
|
+
* policy and:
|
|
9
|
+
*
|
|
10
|
+
* - On a plaintext connection: reconnect over TLS to the advertised
|
|
11
|
+
* `port` and pin it for `duration` seconds. `preload` (optional)
|
|
12
|
+
* signals the client may hard-code the policy into a preload list.
|
|
13
|
+
* - On a TLS connection: only the `duration` is locked in — `port`
|
|
14
|
+
* is meaningless once the client is already on the secure listener,
|
|
15
|
+
* and `preload` is an upgrade-time directive.
|
|
16
|
+
*
|
|
17
|
+
* Config-driven only: there is no per-connection protocol state. The
|
|
18
|
+
* deployment supplies the policy through {@link StsConfig} on
|
|
19
|
+
* {@link ServerConfig}; when omitted entirely the cap is not advertised.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* IRC-over-TLS port per RFC 7193 / IRCv3 STS spec examples. Used as the
|
|
24
|
+
* fallback `port` when a deployment turns on STS without pinning a port.
|
|
25
|
+
*/
|
|
26
|
+
export const DEFAULT_STS_PORT = 6697;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Default pin duration (30 days, in seconds). Mirrors the IRCv3 spec's
|
|
30
|
+
* canonical example and what mainstream IRC networks publish. Deployments
|
|
31
|
+
* override via {@link StsConfig.duration}.
|
|
32
|
+
*/
|
|
33
|
+
export const DEFAULT_STS_DURATION_SECONDS = 30 * 24 * 60 * 60;
|
|
34
|
+
|
|
35
|
+
/** Wire name of the STS capability. */
|
|
36
|
+
export const STS_CAP_NAME = 'sts';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Raw STS policy as supplied by {@link ServerConfig}. Every field is
|
|
40
|
+
* optional; {@link stsCapValue} applies the documented defaults when a
|
|
41
|
+
* field is omitted. `undefined` (the whole object) disables STS.
|
|
42
|
+
*
|
|
43
|
+
* Optional fields are widened to `T | undefined` to mirror Zod's
|
|
44
|
+
* `.optional()` output under `exactOptionalPropertyTypes: true` so the
|
|
45
|
+
* parsed config is structurally assignable here.
|
|
46
|
+
*/
|
|
47
|
+
export interface StsPolicy {
|
|
48
|
+
/** Pin duration in seconds. Defaults to 30 days. */
|
|
49
|
+
readonly duration?: number;
|
|
50
|
+
/** TLS listener port advertised on plaintext connections. Defaults to 6697. */
|
|
51
|
+
readonly port?: number;
|
|
52
|
+
/** Optional preload directive (plaintext advertisements only). */
|
|
53
|
+
readonly preload?: boolean | undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Builds the IRCv3 `sts` capability *value* (the substring after `sts=`)
|
|
58
|
+
* for the given policy and transport.
|
|
59
|
+
*
|
|
60
|
+
* Returns `undefined` when STS is disabled (`policy` undefined), so the cap
|
|
61
|
+
* table consumer can omit the token entirely from `CAP LS`.
|
|
62
|
+
*
|
|
63
|
+
* On a plaintext transport the full policy is advertised:
|
|
64
|
+
* `duration=<seconds>,port=<tls-port>[,preload]`
|
|
65
|
+
*
|
|
66
|
+
* On a TLS transport only `duration=<seconds>` is sent — the port is
|
|
67
|
+
* meaningless (the client is already on the secure listener) and
|
|
68
|
+
* `preload` is an upgrade-time directive that does not apply.
|
|
69
|
+
*
|
|
70
|
+
* Missing `duration`/`port` fall back to {@link DEFAULT_STS_DURATION_SECONDS}
|
|
71
|
+
* and {@link DEFAULT_STS_PORT} so a deployment can enable STS with a bare
|
|
72
|
+
* `{}` policy and still publish a sensible value.
|
|
73
|
+
*/
|
|
74
|
+
export function stsCapValue(policy: StsPolicy | undefined, secure: boolean): string | undefined {
|
|
75
|
+
if (policy === undefined) return undefined;
|
|
76
|
+
const duration = policy.duration ?? DEFAULT_STS_DURATION_SECONDS;
|
|
77
|
+
if (secure) {
|
|
78
|
+
return `duration=${duration}`;
|
|
79
|
+
}
|
|
80
|
+
const port = policy.port ?? DEFAULT_STS_PORT;
|
|
81
|
+
const parts = [`duration=${duration}`, `port=${port}`];
|
|
82
|
+
if (policy.preload === true) parts.push('preload');
|
|
83
|
+
return parts.join(',');
|
|
84
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Effect } from '../effects.js';
|
|
2
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
3
|
+
import type { ChanName } from '../state/channel.js';
|
|
4
|
+
import { hostmaskOf } from '../state/connection.js';
|
|
5
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
6
|
+
|
|
7
|
+
export interface AccountNotifyInput {
|
|
8
|
+
conn: ConnectionState;
|
|
9
|
+
/**
|
|
10
|
+
* New account name on login. Pass `undefined` to signal a logout, which
|
|
11
|
+
* serializes the account field as `*` per IRCv3 `account-notify`.
|
|
12
|
+
*/
|
|
13
|
+
account?: string | undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Builds the cap-split Broadcast effects that announce an `ACCOUNT` login
|
|
18
|
+
* (or logout) to peers in every shared channel.
|
|
19
|
+
*
|
|
20
|
+
* Mirrors the `emitChghost` broadcast helper: each joined channel produces
|
|
21
|
+
* one {@link Effect.broadcast} carrying empty `lines` (so non-cap peers
|
|
22
|
+
* receive nothing) and the `ACCOUNT` line in `capLines` for peers that
|
|
23
|
+
* negotiated `account-notify`. The sender is excluded via `except` unless
|
|
24
|
+
* they negotiated `echo-message`, in which case a single echo
|
|
25
|
+
* {@link Effect.send} is appended (one echo regardless of how many shared
|
|
26
|
+
* channels the user occupies).
|
|
27
|
+
*
|
|
28
|
+
* Returns `[]` before registration completes or when the connection holds
|
|
29
|
+
* no joined channels — there is no audience to notify.
|
|
30
|
+
*/
|
|
31
|
+
export function emitAccountNotify(input: AccountNotifyInput): EffectType[] {
|
|
32
|
+
const { conn, account } = input;
|
|
33
|
+
|
|
34
|
+
if (conn.registration !== 'registered') return [];
|
|
35
|
+
if (conn.joinedChannels.size === 0) return [];
|
|
36
|
+
|
|
37
|
+
const source = hostmaskOf(conn) ?? conn.nick ?? '?';
|
|
38
|
+
const accountField = account ?? '*';
|
|
39
|
+
const line: RawLine = { text: `:${source} ACCOUNT ${accountField}` };
|
|
40
|
+
|
|
41
|
+
const effects: EffectType[] = [];
|
|
42
|
+
for (const chan of conn.joinedChannels) {
|
|
43
|
+
effects.push(Effect.broadcast(chan as ChanName, [], conn.id, 'account-notify', [line]));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// IRCv3 echo-message: the sender observes its own ACCOUNT change exactly
|
|
47
|
+
// once, regardless of how many channels it shares with peers.
|
|
48
|
+
if (conn.caps.has('echo-message')) {
|
|
49
|
+
effects.push(Effect.send(conn.id, [line]));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return effects;
|
|
53
|
+
}
|