serverless-ircd 0.4.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 +435 -0
- package/README.md +206 -27
- package/apps/aws-stack/README.md +37 -3
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +106 -13
- package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
- package/apps/aws-stack/tests/stack.test.ts +49 -3
- 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 +138 -28
- package/apps/local-cli/tests/e2e.test.ts +113 -29
- package/apps/local-cli/tests/tcp.test.ts +71 -19
- package/apps/local-cli/tests/ws-subprotocol.test.ts +262 -0
- 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/aws-runtime.ts +69 -0
- package/packages/aws-adapter/src/handlers/connect.ts +36 -5
- package/packages/aws-adapter/src/handlers/default.ts +66 -5
- package/packages/aws-adapter/src/handlers/index.ts +41 -2
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +18 -0
- package/packages/aws-adapter/src/index.ts +2 -0
- package/packages/aws-adapter/src/serialize.ts +40 -2
- package/packages/aws-adapter/src/stats.ts +80 -0
- 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 +140 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
- package/packages/aws-adapter/tests/connect.test.ts +100 -4
- 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 +238 -4
- package/packages/aws-adapter/tests/message-store.test.ts +6 -5
- package/packages/aws-adapter/tests/nlb-stream.test.ts +62 -8
- package/packages/aws-adapter/tests/stats.test.ts +317 -0
- package/packages/cf-adapter/package.json +6 -1
- package/packages/cf-adapter/src/cf-runtime.ts +66 -1
- package/packages/cf-adapter/src/channel-do.ts +2 -2
- package/packages/cf-adapter/src/connection-do.ts +185 -54
- package/packages/cf-adapter/src/env.ts +25 -6
- package/packages/cf-adapter/src/index.ts +2 -0
- package/packages/cf-adapter/src/registry-do.ts +22 -3
- package/packages/cf-adapter/src/serialize.ts +25 -4
- package/packages/cf-adapter/src/sharding.ts +1 -2
- package/packages/cf-adapter/src/stats.ts +65 -0
- package/packages/cf-adapter/tests/cf-harness.ts +1 -1
- package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
- package/packages/cf-adapter/tests/cf-runtime.test.ts +307 -2
- package/packages/cf-adapter/tests/channel-do.test.ts +119 -2
- package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
- package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
- package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
- package/packages/cf-adapter/tests/registry-do.test.ts +108 -4
- package/packages/cf-adapter/tests/serialize.test.ts +308 -0
- package/packages/cf-adapter/tests/sharding.test.ts +1 -1
- package/packages/cf-adapter/tests/stats.test.ts +120 -0
- package/packages/cf-adapter/tests/worker/main.ts +7 -7
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
- package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
- package/packages/cf-adapter/vitest.config.ts +1 -1
- package/packages/cf-adapter/wrangler.test.toml +7 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/admission.ts +16 -15
- package/packages/irc-core/src/caps/capabilities.ts +38 -4
- 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 +42 -0
- package/packages/irc-core/src/commands/invite.ts +2 -4
- package/packages/irc-core/src/commands/isupport.ts +59 -2
- package/packages/irc-core/src/commands/kick.ts +2 -4
- package/packages/irc-core/src/commands/kill.ts +127 -0
- package/packages/irc-core/src/commands/list.ts +1 -1
- package/packages/irc-core/src/commands/lusers.ts +204 -0
- package/packages/irc-core/src/commands/mode.ts +12 -9
- 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/names.ts +3 -5
- package/packages/irc-core/src/commands/part.ts +2 -4
- 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/rehash.ts +119 -0
- package/packages/irc-core/src/commands/sasl.ts +24 -1
- package/packages/irc-core/src/commands/setname.ts +109 -0
- package/packages/irc-core/src/commands/stats.ts +152 -0
- package/packages/irc-core/src/commands/tagmsg.ts +71 -1
- package/packages/irc-core/src/commands/topic.ts +2 -4
- package/packages/irc-core/src/commands/trace.ts +137 -0
- package/packages/irc-core/src/commands/wallops.ts +118 -0
- package/packages/irc-core/src/commands/whois.ts +5 -0
- package/packages/irc-core/src/config.ts +72 -10
- package/packages/irc-core/src/effects.ts +41 -1
- package/packages/irc-core/src/index.ts +2 -0
- package/packages/irc-core/src/ports.ts +568 -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 +57 -11
- package/packages/irc-core/src/protocol/outbound.ts +36 -4
- 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 +120 -1
- package/packages/irc-core/src/ws-framing.ts +132 -0
- package/packages/irc-core/src/ws-subprotocol.ts +66 -0
- package/packages/irc-core/stryker.commands.conf.json +1 -2
- package/packages/irc-core/tests/admission.test.ts +18 -0
- 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/kill.test.ts +243 -0
- package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
- package/packages/irc-core/tests/commands/mode.test.ts +129 -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/rehash.test.ts +171 -0
- package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
- package/packages/irc-core/tests/commands/setname.test.ts +225 -0
- package/packages/irc-core/tests/commands/stats.test.ts +294 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
- package/packages/irc-core/tests/commands/trace.test.ts +282 -0
- package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
- package/packages/irc-core/tests/commands/whois.test.ts +34 -0
- package/packages/irc-core/tests/config.test.ts +103 -13
- package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/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/numerics.test.ts +90 -0
- package/packages/irc-core/tests/outbound.test.ts +51 -0
- package/packages/irc-core/tests/ports.test.ts +22 -0
- package/packages/irc-core/tests/raw-modules.d.ts +11 -0
- package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
- package/packages/irc-core/tests/standard-replies.test.ts +412 -0
- package/packages/irc-core/tests/stats-store.test.ts +222 -0
- package/packages/irc-core/tests/types.test.ts +27 -0
- package/packages/irc-core/tests/ws-framing.test.ts +213 -0
- package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +699 -19
- package/packages/irc-server/src/dispatch.ts +109 -16
- package/packages/irc-server/src/routing.ts +3 -0
- package/packages/irc-server/src/runtime.ts +31 -0
- package/packages/irc-server/src/transport.ts +10 -7
- package/packages/irc-server/tests/actor.test.ts +2523 -42
- package/packages/irc-server/tests/dispatch.test.ts +300 -2
- package/packages/irc-server/tests/raw-modules.d.ts +11 -0
- package/packages/irc-server/tests/routing.test.ts +1 -0
- package/packages/irc-server/tests/runtime.test.ts +7 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/index.ts +6 -0
- package/packages/irc-test-support/src/scenarios.ts +9 -1
- package/packages/irc-test-support/src/test-config.ts +54 -0
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
- package/packages/irc-test-support/tests/test-config.test.ts +51 -0
- package/pnpm-workspace.yaml +1 -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/package.json +6 -1
- package/tools/seed-cf-accounts.ts +4 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
- package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { chathistoryReducer } from '../../src/commands/chathistory';
|
|
2
|
+
import { chathistoryReducer, formatReplayLine } from '../../src/commands/chathistory';
|
|
3
3
|
import { Effect } from '../../src/effects';
|
|
4
4
|
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
5
5
|
import {
|
|
@@ -396,6 +396,113 @@ describe('chathistoryReducer — BEFORE / AFTER / AROUND / BETWEEN', () => {
|
|
|
396
396
|
});
|
|
397
397
|
});
|
|
398
398
|
|
|
399
|
+
// ============================================================================
|
|
400
|
+
// BEFORE — draft/read-marker fallback (no explicit pivot)
|
|
401
|
+
// ============================================================================
|
|
402
|
+
//
|
|
403
|
+
// IRCv3 draft/read-marker integration: when a cap-enabled client omits the
|
|
404
|
+
// pivot msgid on CHATHISTORY BEFORE, the reducer falls back to the
|
|
405
|
+
// connection's per-channel last-read marker. This lets a client resume
|
|
406
|
+
// reading "everything before my last read position" without re-supplying the
|
|
407
|
+
// msgid. When no marker is recorded either, the reducer keeps the legacy
|
|
408
|
+
// 461 ERR_NEEDMOREPARAMS behaviour.
|
|
409
|
+
// ============================================================================
|
|
410
|
+
|
|
411
|
+
describe('chathistoryReducer — BEFORE read-marker fallback', () => {
|
|
412
|
+
it('falls back to the last-read marker when BEFORE omits the pivot', () => {
|
|
413
|
+
const store = new InMemoryMessageStore();
|
|
414
|
+
const seeded = seed(store, '#foo', 5);
|
|
415
|
+
const conn = makeConn();
|
|
416
|
+
conn.lastReadMarkers = new Map<string, string>([['#foo', 'm4']]);
|
|
417
|
+
const chan = makeChan();
|
|
418
|
+
chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
|
|
419
|
+
const ctx = makeCtx(conn, store);
|
|
420
|
+
|
|
421
|
+
const out = chathistoryReducer(
|
|
422
|
+
chan,
|
|
423
|
+
{ command: 'CHATHISTORY', params: ['BEFORE', '#foo'], tags: {} },
|
|
424
|
+
ctx,
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
expect(out.effects).toEqual<EffectType[]>(
|
|
428
|
+
expectedBatch(
|
|
429
|
+
[
|
|
430
|
+
replayLine(seeded[0] as StoredMessage, '#foo'),
|
|
431
|
+
replayLine(seeded[1] as StoredMessage, '#foo'),
|
|
432
|
+
replayLine(seeded[2] as StoredMessage, '#foo'),
|
|
433
|
+
],
|
|
434
|
+
'batch-0',
|
|
435
|
+
'#foo',
|
|
436
|
+
),
|
|
437
|
+
);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('honours an explicit pivot over the marker when both are supplied', () => {
|
|
441
|
+
const store = new InMemoryMessageStore();
|
|
442
|
+
const seeded = seed(store, '#foo', 5);
|
|
443
|
+
const conn = makeConn();
|
|
444
|
+
// Marker points at m4, but the client explicitly asks for messages before m3.
|
|
445
|
+
conn.lastReadMarkers = new Map<string, string>([['#foo', 'm4']]);
|
|
446
|
+
const chan = makeChan();
|
|
447
|
+
chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
|
|
448
|
+
const ctx = makeCtx(conn, store);
|
|
449
|
+
|
|
450
|
+
const out = chathistoryReducer(
|
|
451
|
+
chan,
|
|
452
|
+
{ command: 'CHATHISTORY', params: ['BEFORE', '#foo', 'm3', '10'], tags: {} },
|
|
453
|
+
ctx,
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
expect(out.effects).toEqual<EffectType[]>(
|
|
457
|
+
expectedBatch(
|
|
458
|
+
[
|
|
459
|
+
replayLine(seeded[0] as StoredMessage, '#foo'),
|
|
460
|
+
replayLine(seeded[1] as StoredMessage, '#foo'),
|
|
461
|
+
],
|
|
462
|
+
'batch-0',
|
|
463
|
+
'#foo',
|
|
464
|
+
),
|
|
465
|
+
);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('returns 461 when BEFORE omits the pivot and no marker is recorded', () => {
|
|
469
|
+
const store = new InMemoryMessageStore();
|
|
470
|
+
seed(store, '#foo', 3);
|
|
471
|
+
const conn = makeConn(); // no marker
|
|
472
|
+
const chan = makeChan();
|
|
473
|
+
chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
|
|
474
|
+
const ctx = makeCtx(conn, store);
|
|
475
|
+
|
|
476
|
+
const out = chathistoryReducer(
|
|
477
|
+
chan,
|
|
478
|
+
{ command: 'CHATHISTORY', params: ['BEFORE', '#foo'], tags: {} },
|
|
479
|
+
ctx,
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
expect((out.effects[0] as { lines: RawLine[] }).lines[0]?.text).toContain('461');
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('returns an empty batch when the marker points at an evicted msgid', () => {
|
|
486
|
+
const store = new InMemoryMessageStore();
|
|
487
|
+
seed(store, '#foo', 3);
|
|
488
|
+
const conn = makeConn();
|
|
489
|
+
// Marker references a message the store no longer retains; BEFORE must
|
|
490
|
+
// not surface an INVALID_PARAMS error, just an empty result.
|
|
491
|
+
conn.lastReadMarkers = new Map<string, string>([['#foo', 'evicted']]);
|
|
492
|
+
const chan = makeChan();
|
|
493
|
+
chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
|
|
494
|
+
const ctx = makeCtx(conn, store);
|
|
495
|
+
|
|
496
|
+
const out = chathistoryReducer(
|
|
497
|
+
chan,
|
|
498
|
+
{ command: 'CHATHISTORY', params: ['BEFORE', '#foo'], tags: {} },
|
|
499
|
+
ctx,
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
expect(out.effects).toEqual([]);
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
|
|
399
506
|
// ============================================================================
|
|
400
507
|
// TARGETS
|
|
401
508
|
// ============================================================================
|
|
@@ -994,3 +1101,43 @@ describe('chathistoryReducer — unregistered connection', () => {
|
|
|
994
1101
|
);
|
|
995
1102
|
});
|
|
996
1103
|
});
|
|
1104
|
+
|
|
1105
|
+
// ===========================================================================
|
|
1106
|
+
// formatReplayLine — clientTags on chathistory replay
|
|
1107
|
+
//
|
|
1108
|
+
// A stored message carrying `clientTags` (e.g. `+draft/multiline` from a
|
|
1109
|
+
// multi-line batch) must surface those tags in the chathistory replay so
|
|
1110
|
+
// message-tags-aware clients can recognise the message kind.
|
|
1111
|
+
// ===========================================================================
|
|
1112
|
+
|
|
1113
|
+
describe('formatReplayLine — clientTags', () => {
|
|
1114
|
+
const baseMsg: StoredMessage = {
|
|
1115
|
+
msgid: 'abc123',
|
|
1116
|
+
time: 1_700_000_000_000,
|
|
1117
|
+
chan: '#foo',
|
|
1118
|
+
command: 'PRIVMSG',
|
|
1119
|
+
nick: 'alice',
|
|
1120
|
+
user: 'alice',
|
|
1121
|
+
host: 'example.com',
|
|
1122
|
+
text: 'line one\nline two',
|
|
1123
|
+
};
|
|
1124
|
+
|
|
1125
|
+
it('appends +draft/multiline to the tag section when clientTags are present', () => {
|
|
1126
|
+
const out = formatReplayLine({ ...baseMsg, clientTags: ['+draft/multiline'] }, '#foo');
|
|
1127
|
+
expect(out.text).toBe(
|
|
1128
|
+
'@time=2023-11-14T22:13:20.000Z;msgid=abc123;+draft/multiline :alice!alice@example.com PRIVMSG #foo :line one\nline two',
|
|
1129
|
+
);
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
it('omits the client-tag section entirely when clientTags is undefined', () => {
|
|
1133
|
+
const out = formatReplayLine(baseMsg, '#foo');
|
|
1134
|
+
expect(out.text).toBe(
|
|
1135
|
+
'@time=2023-11-14T22:13:20.000Z;msgid=abc123 :alice!alice@example.com PRIVMSG #foo :line one\nline two',
|
|
1136
|
+
);
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
it('omits the client-tag section when clientTags is empty', () => {
|
|
1140
|
+
const out = formatReplayLine({ ...baseMsg, clientTags: [] }, '#foo');
|
|
1141
|
+
expect(out.text).not.toContain('+draft/multiline');
|
|
1142
|
+
});
|
|
1143
|
+
});
|
|
@@ -73,7 +73,9 @@ describe('echo-message — channel PRIVMSG', () => {
|
|
|
73
73
|
);
|
|
74
74
|
|
|
75
75
|
expect(out.effects).toEqual<EffectType[]>([
|
|
76
|
-
Effect.broadcast('#foo', [L(':alice!alice@example.com PRIVMSG #foo :hi')], 'c1'
|
|
76
|
+
Effect.broadcast('#foo', [L(':alice!alice@example.com PRIVMSG #foo :hi')], 'c1', 'msgid', [
|
|
77
|
+
L('@msgid=nonce-0 :alice!alice@example.com PRIVMSG #foo :hi'),
|
|
78
|
+
]),
|
|
77
79
|
]);
|
|
78
80
|
});
|
|
79
81
|
|
|
@@ -90,7 +92,9 @@ describe('echo-message — channel PRIVMSG', () => {
|
|
|
90
92
|
);
|
|
91
93
|
|
|
92
94
|
expect(out.effects).toEqual<EffectType[]>([
|
|
93
|
-
Effect.broadcast('#foo', [L(':alice!alice@example.com PRIVMSG #foo :hi')], 'c1'
|
|
95
|
+
Effect.broadcast('#foo', [L(':alice!alice@example.com PRIVMSG #foo :hi')], 'c1', 'msgid', [
|
|
96
|
+
L('@msgid=nonce-0 :alice!alice@example.com PRIVMSG #foo :hi'),
|
|
97
|
+
]),
|
|
94
98
|
Effect.send('c1', [L(':alice!alice@example.com PRIVMSG #foo :hi')]),
|
|
95
99
|
]);
|
|
96
100
|
});
|
|
@@ -108,7 +112,9 @@ describe('echo-message — channel PRIVMSG', () => {
|
|
|
108
112
|
);
|
|
109
113
|
|
|
110
114
|
expect(out.effects).toEqual<EffectType[]>([
|
|
111
|
-
Effect.broadcast('#foo', [L(':alice!alice@example.com NOTICE #foo :hi')], 'c1'
|
|
115
|
+
Effect.broadcast('#foo', [L(':alice!alice@example.com NOTICE #foo :hi')], 'c1', 'msgid', [
|
|
116
|
+
L('@msgid=nonce-0 :alice!alice@example.com NOTICE #foo :hi'),
|
|
117
|
+
]),
|
|
112
118
|
Effect.send('c1', [L(':alice!alice@example.com NOTICE #foo :hi')]),
|
|
113
119
|
]);
|
|
114
120
|
});
|
|
@@ -140,6 +140,12 @@ describe('generateIsupport — token values', () => {
|
|
|
140
140
|
const allNoCap = linesNoCap.flatMap((l) => l.text.split(' '));
|
|
141
141
|
expect(allNoCap).toContain('SAFELIST');
|
|
142
142
|
});
|
|
143
|
+
|
|
144
|
+
it('advertises MONITOR=<n> with the default monitor cap ceiling', () => {
|
|
145
|
+
const lines = generateIsupport(makeConfig(), new Set<string>());
|
|
146
|
+
const all = lines.flatMap((l) => l.text.split(' '));
|
|
147
|
+
expect(all).toContain('MONITOR=30');
|
|
148
|
+
});
|
|
143
149
|
});
|
|
144
150
|
|
|
145
151
|
// ============================================================================
|
|
@@ -207,3 +213,77 @@ describe('generateIsupport — capability-aware tokens', () => {
|
|
|
207
213
|
expect(lines.length).toBeGreaterThan(0);
|
|
208
214
|
});
|
|
209
215
|
});
|
|
216
|
+
|
|
217
|
+
// ============================================================================
|
|
218
|
+
// generateIsupport — cap/mode-derived ISUPPORT tokens
|
|
219
|
+
// ============================================================================
|
|
220
|
+
|
|
221
|
+
describe('generateIsupport — MONITOR token (config-derived)', () => {
|
|
222
|
+
it('derives MONITOR=<n> from serverConfig.monitorLimit when set', () => {
|
|
223
|
+
const lines = generateIsupport(makeConfig({ monitorLimit: 50 }), new Set<string>());
|
|
224
|
+
const all = lines.flatMap((l) => l.text.split(' '));
|
|
225
|
+
expect(all).toContain('MONITOR=50');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('falls back to the default monitor ceiling when monitorLimit is unset', () => {
|
|
229
|
+
const lines = generateIsupport(makeConfig(), new Set<string>());
|
|
230
|
+
const all = lines.flatMap((l) => l.text.split(' '));
|
|
231
|
+
expect(all).toContain('MONITOR=30');
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe('generateIsupport — STATUSMSG token', () => {
|
|
236
|
+
it('advertises STATUSMSG=@+ (status-msg prefixes mirror PREFIX)', () => {
|
|
237
|
+
const lines = generateIsupport(makeConfig(), new Set<string>());
|
|
238
|
+
const all = lines.flatMap((l) => l.text.split(' '));
|
|
239
|
+
expect(all).toContain('STATUSMSG=@+');
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe('generateIsupport — EXTBAN token (config-derived prefix)', () => {
|
|
244
|
+
it('advertises EXTBAN=<prefix>,q with the default extban prefix', () => {
|
|
245
|
+
const lines = generateIsupport(makeConfig(), new Set<string>());
|
|
246
|
+
const all = lines.flatMap((l) => l.text.split(' '));
|
|
247
|
+
expect(all).toContain('EXTBAN=~,q');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('derives the EXTBAN prefix from serverConfig.extbanPrefix', () => {
|
|
251
|
+
const lines = generateIsupport(makeConfig({ extbanPrefix: '$' }), new Set<string>());
|
|
252
|
+
const all = lines.flatMap((l) => l.text.split(' '));
|
|
253
|
+
expect(all).toContain('EXTBAN=$,q');
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe('generateIsupport — ACCOUNTEXTBAN token (services-gated)', () => {
|
|
258
|
+
it('omits ACCOUNTEXTBAN when services are not enabled', () => {
|
|
259
|
+
const lines = generateIsupport(makeConfig(), new Set<string>());
|
|
260
|
+
const all = lines.flatMap((l) => l.text.split(' '));
|
|
261
|
+
expect(all.some((t) => t.startsWith('ACCOUNTEXTBAN'))).toBe(false);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('advertises ACCOUNTEXTBAN=a when services are enabled', () => {
|
|
265
|
+
const lines = generateIsupport(makeConfig({ servicesEnabled: true }), new Set<string>());
|
|
266
|
+
const all = lines.flatMap((l) => l.text.split(' '));
|
|
267
|
+
expect(all).toContain('ACCOUNTEXTBAN=a');
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe('generateIsupport — draft cap advertisement tokens', () => {
|
|
272
|
+
it('advertises the TYPING token (draft/typing is supported)', () => {
|
|
273
|
+
const lines = generateIsupport(makeConfig(), new Set<string>());
|
|
274
|
+
const all = lines.flatMap((l) => l.text.split(' '));
|
|
275
|
+
expect(all).toContain('TYPING');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('derives MULTILINE=<n> from serverConfig.multilineMaxBytes (draft/multiline)', () => {
|
|
279
|
+
const lines = generateIsupport(makeConfig({ multilineMaxBytes: 8192 }), new Set<string>());
|
|
280
|
+
const all = lines.flatMap((l) => l.text.split(' '));
|
|
281
|
+
expect(all).toContain('MULTILINE=8192');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('falls back to the default multiline byte budget when multilineMaxBytes is unset', () => {
|
|
285
|
+
const lines = generateIsupport(makeConfig(), new Set<string>());
|
|
286
|
+
const all = lines.flatMap((l) => l.text.split(' '));
|
|
287
|
+
expect(all).toContain('MULTILINE=4096');
|
|
288
|
+
});
|
|
289
|
+
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { killReducer } from '../../src/commands/kill';
|
|
3
|
+
import { Effect } from '../../src/effects';
|
|
4
|
+
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
5
|
+
import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
|
|
6
|
+
import { type ConnectionState, createConnection } from '../../src/state/connection';
|
|
7
|
+
import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
|
|
8
|
+
|
|
9
|
+
const serverConfig: ServerConfig = {
|
|
10
|
+
serverName: 'irc.example.com',
|
|
11
|
+
networkName: 'ExampleNet',
|
|
12
|
+
maxChannelsPerUser: 30,
|
|
13
|
+
maxTargetsPerCommand: 10,
|
|
14
|
+
maxListEntries: 50,
|
|
15
|
+
nickLen: 30,
|
|
16
|
+
channelLen: 50,
|
|
17
|
+
topicLen: 390,
|
|
18
|
+
quitMessage: 'Client Quit',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function makeCtx(conn: ConnectionState, clock = new FakeClock(5_000)): Ctx {
|
|
22
|
+
return buildCtx({
|
|
23
|
+
serverConfig,
|
|
24
|
+
clock,
|
|
25
|
+
ids: new SequentialIdFactory(),
|
|
26
|
+
motd: EmptyMotdProvider,
|
|
27
|
+
connection: conn,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeOper(id = 'c1', nick = 'alice'): ConnectionState {
|
|
32
|
+
const s = createConnection({ id, connectedSince: 0 });
|
|
33
|
+
s.nick = nick;
|
|
34
|
+
s.user = nick;
|
|
35
|
+
s.host = 'example.com';
|
|
36
|
+
s.realname = 'Alice';
|
|
37
|
+
s.registration = 'registered';
|
|
38
|
+
s.userModes.oper = true;
|
|
39
|
+
return s;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeTarget(id = 'c2', nick = 'bob'): ConnectionState {
|
|
43
|
+
const s = createConnection({ id, connectedSince: 0 });
|
|
44
|
+
s.nick = nick;
|
|
45
|
+
s.user = nick;
|
|
46
|
+
s.host = 'example.com';
|
|
47
|
+
s.realname = 'Bob';
|
|
48
|
+
s.registration = 'registered';
|
|
49
|
+
return s;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const L = (text: string): RawLine => ({ text });
|
|
53
|
+
|
|
54
|
+
const kill = (nick?: string, comment?: string) =>
|
|
55
|
+
({
|
|
56
|
+
command: 'KILL',
|
|
57
|
+
params: nick === undefined ? [] : comment === undefined ? [nick] : [nick, comment],
|
|
58
|
+
tags: {},
|
|
59
|
+
}) as const;
|
|
60
|
+
|
|
61
|
+
describe('killReducer — oper happy path', () => {
|
|
62
|
+
it('emits a Disconnect effect for the target with a kill-formatted reason', () => {
|
|
63
|
+
const oper = makeOper();
|
|
64
|
+
const target = makeTarget();
|
|
65
|
+
const ctx = makeCtx(oper);
|
|
66
|
+
|
|
67
|
+
const out = killReducer(target, kill('bob', 'flooding'), ctx);
|
|
68
|
+
|
|
69
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
70
|
+
Effect.disconnect('c2', 'Killed (alice (flooding))'),
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('uses the oper display nick in the reason, not the target nick', () => {
|
|
75
|
+
const oper = makeOper('c1', 'theOper');
|
|
76
|
+
const target = makeTarget('c2', 'bob');
|
|
77
|
+
const ctx = makeCtx(oper);
|
|
78
|
+
|
|
79
|
+
const out = killReducer(target, kill('bob', 'bye'), ctx);
|
|
80
|
+
|
|
81
|
+
expect(out.effects).toEqual<EffectType[]>([Effect.disconnect('c2', 'Killed (theOper (bye))')]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('formats multi-word comments verbatim inside the inner parens', () => {
|
|
85
|
+
const oper = makeOper();
|
|
86
|
+
const target = makeTarget();
|
|
87
|
+
const ctx = makeCtx(oper);
|
|
88
|
+
|
|
89
|
+
const out = killReducer(target, kill('bob', 'stop flooding the channel'), ctx);
|
|
90
|
+
|
|
91
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
92
|
+
Effect.disconnect('c2', 'Killed (alice (stop flooding the channel))'),
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('updates the oper lastSeen to ctx.clock.now()', () => {
|
|
97
|
+
const oper = makeOper();
|
|
98
|
+
expect(oper.lastSeen).toBe(0);
|
|
99
|
+
const ctx = makeCtx(oper);
|
|
100
|
+
|
|
101
|
+
killReducer(makeTarget(), kill('bob', 'flooding'), ctx);
|
|
102
|
+
|
|
103
|
+
expect(oper.lastSeen).toBe(5_000);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('falls back to "*" for the oper nick in the reason when the oper has none', () => {
|
|
107
|
+
const oper = createConnection({ id: 'c1', connectedSince: 0 });
|
|
108
|
+
oper.userModes.oper = true;
|
|
109
|
+
const target = makeTarget();
|
|
110
|
+
const ctx = makeCtx(oper);
|
|
111
|
+
|
|
112
|
+
const out = killReducer(target, kill('bob', 'x'), ctx);
|
|
113
|
+
|
|
114
|
+
expect(out.effects).toEqual<EffectType[]>([Effect.disconnect('c2', 'Killed (* (x))')]);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('killReducer — self-kill (RFC-permitted)', () => {
|
|
119
|
+
it('allows an oper to KILL themselves, disconnecting their own connection', () => {
|
|
120
|
+
const oper = makeOper('c1', 'alice');
|
|
121
|
+
const ctx = makeCtx(oper);
|
|
122
|
+
|
|
123
|
+
const out = killReducer(oper, kill('alice', 'self-terminate'), ctx);
|
|
124
|
+
|
|
125
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
126
|
+
Effect.disconnect('c1', 'Killed (alice (self-terminate))'),
|
|
127
|
+
]);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('killReducer — non-oper (481 ERR_NOPRIVILEGES)', () => {
|
|
132
|
+
it('emits 481 when the sender is not an oper', () => {
|
|
133
|
+
const oper = makeOper();
|
|
134
|
+
oper.userModes.oper = false;
|
|
135
|
+
const target = makeTarget();
|
|
136
|
+
const ctx = makeCtx(oper);
|
|
137
|
+
|
|
138
|
+
const out = killReducer(target, kill('bob', 'flooding'), ctx);
|
|
139
|
+
|
|
140
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
141
|
+
Effect.send('c1', [
|
|
142
|
+
L(":irc.example.com 481 alice :Permission Denied - You're not an IRC operator"),
|
|
143
|
+
]),
|
|
144
|
+
]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('does not disconnect the target when the sender is not an oper', () => {
|
|
148
|
+
const oper = makeOper();
|
|
149
|
+
oper.userModes.oper = false;
|
|
150
|
+
const target = makeTarget();
|
|
151
|
+
const ctx = makeCtx(oper);
|
|
152
|
+
|
|
153
|
+
const out = killReducer(target, kill('bob', 'flooding'), ctx);
|
|
154
|
+
|
|
155
|
+
expect(out.effects.some((e) => e.tag === 'Disconnect')).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('gates before param validation: a non-oper with missing params still gets 481', () => {
|
|
159
|
+
const oper = makeOper();
|
|
160
|
+
oper.userModes.oper = false;
|
|
161
|
+
const ctx = makeCtx(oper);
|
|
162
|
+
|
|
163
|
+
const out = killReducer(undefined, kill(), ctx);
|
|
164
|
+
|
|
165
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
166
|
+
Effect.send('c1', [
|
|
167
|
+
L(":irc.example.com 481 alice :Permission Denied - You're not an IRC operator"),
|
|
168
|
+
]),
|
|
169
|
+
]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('uses "*" as the nick in 481 when the connection has no nick', () => {
|
|
173
|
+
const oper = createConnection({ id: 'c1', connectedSince: 0 });
|
|
174
|
+
oper.userModes.oper = false;
|
|
175
|
+
const ctx = makeCtx(oper);
|
|
176
|
+
|
|
177
|
+
const out = killReducer(undefined, kill('bob', 'x'), ctx);
|
|
178
|
+
|
|
179
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
180
|
+
Effect.send('c1', [
|
|
181
|
+
L(":irc.example.com 481 * :Permission Denied - You're not an IRC operator"),
|
|
182
|
+
]),
|
|
183
|
+
]);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('killReducer — missing parameters (461 ERR_NEEDMOREPARAMS)', () => {
|
|
188
|
+
it('emits 461 when no parameters are supplied', () => {
|
|
189
|
+
const oper = makeOper();
|
|
190
|
+
const ctx = makeCtx(oper);
|
|
191
|
+
|
|
192
|
+
const out = killReducer(undefined, kill(), ctx);
|
|
193
|
+
|
|
194
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
195
|
+
Effect.send('c1', [L(':irc.example.com 461 alice KILL :Not enough parameters')]),
|
|
196
|
+
]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('emits 461 when only the nick is supplied (no comment)', () => {
|
|
200
|
+
const oper = makeOper();
|
|
201
|
+
const ctx = makeCtx(oper);
|
|
202
|
+
|
|
203
|
+
const out = killReducer(makeTarget(), kill('bob'), ctx);
|
|
204
|
+
|
|
205
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
206
|
+
Effect.send('c1', [L(':irc.example.com 461 alice KILL :Not enough parameters')]),
|
|
207
|
+
]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('uses "*" as the nick in 461 when the connection has no nick', () => {
|
|
211
|
+
const oper = createConnection({ id: 'c1', connectedSince: 0 });
|
|
212
|
+
oper.userModes.oper = true;
|
|
213
|
+
const ctx = makeCtx(oper);
|
|
214
|
+
|
|
215
|
+
const out = killReducer(undefined, kill('bob'), ctx);
|
|
216
|
+
|
|
217
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
218
|
+
Effect.send('c1', [L(':irc.example.com 461 * KILL :Not enough parameters')]),
|
|
219
|
+
]);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe('killReducer — unknown nick (401 ERR_NOSUCHNICK)', () => {
|
|
224
|
+
it('emits 401 when the target resolves to undefined', () => {
|
|
225
|
+
const oper = makeOper();
|
|
226
|
+
const ctx = makeCtx(oper);
|
|
227
|
+
|
|
228
|
+
const out = killReducer(undefined, kill('ghost', 'flooding'), ctx);
|
|
229
|
+
|
|
230
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
231
|
+
Effect.send('c1', [L(':irc.example.com 401 alice ghost :No such nick/channel')]),
|
|
232
|
+
]);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('does not emit a Disconnect when the target is unknown', () => {
|
|
236
|
+
const oper = makeOper();
|
|
237
|
+
const ctx = makeCtx(oper);
|
|
238
|
+
|
|
239
|
+
const out = killReducer(undefined, kill('ghost', 'flooding'), ctx);
|
|
240
|
+
|
|
241
|
+
expect(out.effects.some((e) => e.tag === 'Disconnect')).toBe(false);
|
|
242
|
+
});
|
|
243
|
+
});
|