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
|
@@ -6,7 +6,9 @@ import {
|
|
|
6
6
|
EmptyMotdProvider,
|
|
7
7
|
FakeClock,
|
|
8
8
|
InMemoryMessageStore,
|
|
9
|
+
InMemoryReadMarkerStore,
|
|
9
10
|
type MessageStore,
|
|
11
|
+
type ReadMarkerStore,
|
|
10
12
|
SequentialIdFactory,
|
|
11
13
|
type StoredMessage,
|
|
12
14
|
} from '../../src/ports';
|
|
@@ -30,6 +32,7 @@ function makeCtx(
|
|
|
30
32
|
conn: ConnectionState,
|
|
31
33
|
clock = new FakeClock(1_000),
|
|
32
34
|
messages?: MessageStore,
|
|
35
|
+
readMarkers?: ReadMarkerStore,
|
|
33
36
|
): Ctx {
|
|
34
37
|
return buildCtx({
|
|
35
38
|
serverConfig,
|
|
@@ -38,6 +41,7 @@ function makeCtx(
|
|
|
38
41
|
motd: EmptyMotdProvider,
|
|
39
42
|
connection: conn,
|
|
40
43
|
...(messages !== undefined ? { messages } : {}),
|
|
44
|
+
...(readMarkers !== undefined ? { readMarkers } : {}),
|
|
41
45
|
});
|
|
42
46
|
}
|
|
43
47
|
|
|
@@ -417,6 +421,328 @@ describe('tagmsgChannelReducer — echo-message', () => {
|
|
|
417
421
|
});
|
|
418
422
|
});
|
|
419
423
|
|
|
424
|
+
// ============================================================================
|
|
425
|
+
// tagmsgChannelReducer — draft/typing broadcast
|
|
426
|
+
// ============================================================================
|
|
427
|
+
//
|
|
428
|
+
// IRCv3 draft/typing: a TAGMSG carrying `+draft/typing=<active|paused|done>`
|
|
429
|
+
// fans out to channel members that negotiated `draft/typing` OR
|
|
430
|
+
// `message-tags`. The `message-tags` path is the baseline (every TAGMSG goes
|
|
431
|
+
// to message-tags peers); `draft/typing` adds the spec's client-tag
|
|
432
|
+
// exception — peers that lack `message-tags` but negotiated `draft/typing`
|
|
433
|
+
// still receive the typing TAGMSG. The reducer annotates the Broadcast
|
|
434
|
+
// effect with `caps` (OR semantics) so dispatch routes per-recipient.
|
|
435
|
+
// ============================================================================
|
|
436
|
+
|
|
437
|
+
describe('tagmsgChannelReducer — draft/typing broadcast', () => {
|
|
438
|
+
it('emits a Broadcast with caps=[message-tags, draft/typing] for a +draft/typing TAGMSG', () => {
|
|
439
|
+
const chan = makeChan('#foo');
|
|
440
|
+
addMember(chan, 'c1', 'alice');
|
|
441
|
+
addMember(chan, 'c2', 'bob');
|
|
442
|
+
const conn = makeConn();
|
|
443
|
+
const ctx = makeCtx(conn);
|
|
444
|
+
|
|
445
|
+
const out = tagmsgChannelReducer(
|
|
446
|
+
chan,
|
|
447
|
+
{ command: 'TAGMSG', params: ['#foo'], tags: { '+draft/typing': 'active' } },
|
|
448
|
+
ctx,
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
452
|
+
{
|
|
453
|
+
tag: 'Broadcast',
|
|
454
|
+
chan: '#foo',
|
|
455
|
+
lines: [L('@+draft/typing=active :alice!alice@example.com TAGMSG #foo')],
|
|
456
|
+
except: 'c1',
|
|
457
|
+
caps: ['message-tags', 'draft/typing'],
|
|
458
|
+
},
|
|
459
|
+
]);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('still uses cap=message-tags (single cap) for a non-typing TAGMSG', () => {
|
|
463
|
+
const chan = makeChan('#foo');
|
|
464
|
+
addMember(chan, 'c1', 'alice');
|
|
465
|
+
const conn = makeConn();
|
|
466
|
+
const ctx = makeCtx(conn);
|
|
467
|
+
|
|
468
|
+
const out = tagmsgChannelReducer(
|
|
469
|
+
chan,
|
|
470
|
+
{ command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },
|
|
471
|
+
ctx,
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
475
|
+
Effect.broadcast(
|
|
476
|
+
'#foo',
|
|
477
|
+
[L('@+typing=active :alice!alice@example.com TAGMSG #foo')],
|
|
478
|
+
'c1',
|
|
479
|
+
'message-tags',
|
|
480
|
+
),
|
|
481
|
+
]);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it('treats a valueless +draft/typing tag as a typing TAGMSG', () => {
|
|
485
|
+
const chan = makeChan('#foo');
|
|
486
|
+
addMember(chan, 'c1', 'alice');
|
|
487
|
+
const conn = makeConn();
|
|
488
|
+
const ctx = makeCtx(conn);
|
|
489
|
+
|
|
490
|
+
const out = tagmsgChannelReducer(
|
|
491
|
+
chan,
|
|
492
|
+
{ command: 'TAGMSG', params: ['#foo'], tags: { '+draft/typing': '' } },
|
|
493
|
+
ctx,
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
const broadcast = out.effects[0];
|
|
497
|
+
expect(broadcast?.tag).toBe('Broadcast');
|
|
498
|
+
expect((broadcast as Extract<EffectType, { tag: 'Broadcast' }>).caps).toEqual([
|
|
499
|
+
'message-tags',
|
|
500
|
+
'draft/typing',
|
|
501
|
+
]);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it('excludes the sender unless echo-message is negotiated (typing TAGMSG)', () => {
|
|
505
|
+
const chan = makeChan('#foo');
|
|
506
|
+
addMember(chan, 'c1', 'alice');
|
|
507
|
+
const conn = makeConn();
|
|
508
|
+
const ctx = makeCtx(conn);
|
|
509
|
+
|
|
510
|
+
const out = tagmsgChannelReducer(
|
|
511
|
+
chan,
|
|
512
|
+
{ command: 'TAGMSG', params: ['#foo'], tags: { '+draft/typing': 'active' } },
|
|
513
|
+
ctx,
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
expect(out.effects).toHaveLength(1);
|
|
517
|
+
expect((out.effects[0] as Extract<EffectType, { tag: 'Broadcast' }>).except).toBe('c1');
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it('echoes the typing TAGMSG back to the sender when echo-message is negotiated', () => {
|
|
521
|
+
const chan = makeChan('#foo');
|
|
522
|
+
addMember(chan, 'c1', 'alice');
|
|
523
|
+
const conn = makeConn();
|
|
524
|
+
conn.caps.add('echo-message');
|
|
525
|
+
const ctx = makeCtx(conn);
|
|
526
|
+
|
|
527
|
+
const out = tagmsgChannelReducer(
|
|
528
|
+
chan,
|
|
529
|
+
{ command: 'TAGMSG', params: ['#foo'], tags: { '+draft/typing': 'active' } },
|
|
530
|
+
ctx,
|
|
531
|
+
);
|
|
532
|
+
|
|
533
|
+
const line = L('@+draft/typing=active :alice!alice@example.com TAGMSG #foo');
|
|
534
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
535
|
+
{
|
|
536
|
+
tag: 'Broadcast',
|
|
537
|
+
chan: '#foo',
|
|
538
|
+
lines: [line],
|
|
539
|
+
except: 'c1',
|
|
540
|
+
caps: ['message-tags', 'draft/typing'],
|
|
541
|
+
},
|
|
542
|
+
Effect.send('c1', [line]),
|
|
543
|
+
]);
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
// ============================================================================
|
|
548
|
+
// tagmsgChannelReducer — draft/read-marker broadcast + persistence
|
|
549
|
+
// ============================================================================
|
|
550
|
+
//
|
|
551
|
+
// IRCv3 draft/read-marker: a TAGMSG carrying `+draft/read-marker=<msgid>`
|
|
552
|
+
// marks `<msgid>` as the last read message for the sender's account in the
|
|
553
|
+
// target channel. The reducer (1) persists the marker via the bound
|
|
554
|
+
// ReadMarkerStore keyed by (account, channel), (2) advances the connection's
|
|
555
|
+
// in-memory lastReadMarkers, and (3) fans the TAGMSG out — carrying the
|
|
556
|
+
// `+draft/read-marker` tag — to members that negotiated `draft/read-marker`
|
|
557
|
+
// OR `message-tags` (OR-semantics, matching the draft/typing precedent).
|
|
558
|
+
// ============================================================================
|
|
559
|
+
|
|
560
|
+
describe('tagmsgChannelReducer — draft/read-marker broadcast + persistence', () => {
|
|
561
|
+
it('emits a Broadcast with caps=[message-tags, draft/read-marker] carrying the tag', () => {
|
|
562
|
+
const chan = makeChan('#foo');
|
|
563
|
+
addMember(chan, 'c1', 'alice');
|
|
564
|
+
addMember(chan, 'c2', 'bob');
|
|
565
|
+
const conn = makeConn();
|
|
566
|
+
const ctx = makeCtx(conn);
|
|
567
|
+
|
|
568
|
+
const out = tagmsgChannelReducer(
|
|
569
|
+
chan,
|
|
570
|
+
{ command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': 'm42' } },
|
|
571
|
+
ctx,
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
575
|
+
{
|
|
576
|
+
tag: 'Broadcast',
|
|
577
|
+
chan: '#foo',
|
|
578
|
+
lines: [L('@+draft/read-marker=m42 :alice!alice@example.com TAGMSG #foo')],
|
|
579
|
+
except: 'c1',
|
|
580
|
+
caps: ['message-tags', 'draft/read-marker'],
|
|
581
|
+
},
|
|
582
|
+
]);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it('persists the marker to the ReadMarkerStore keyed by (account, channel)', () => {
|
|
586
|
+
const chan = makeChan('#foo');
|
|
587
|
+
addMember(chan, 'c1', 'alice');
|
|
588
|
+
const conn = makeConn();
|
|
589
|
+
conn.account = 'alice';
|
|
590
|
+
const store = new InMemoryReadMarkerStore();
|
|
591
|
+
const ctx = makeCtx(conn, undefined, undefined, store);
|
|
592
|
+
|
|
593
|
+
tagmsgChannelReducer(
|
|
594
|
+
chan,
|
|
595
|
+
{ command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': 'm7' } },
|
|
596
|
+
ctx,
|
|
597
|
+
);
|
|
598
|
+
|
|
599
|
+
expect(store.get('alice', '#foo')).toBe('m7');
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it('advances the connection in-memory lastReadMarkers', () => {
|
|
603
|
+
const chan = makeChan('#foo');
|
|
604
|
+
addMember(chan, 'c1', 'alice');
|
|
605
|
+
const conn = makeConn();
|
|
606
|
+
conn.account = 'alice';
|
|
607
|
+
const ctx = makeCtx(conn, undefined, undefined, new InMemoryReadMarkerStore());
|
|
608
|
+
|
|
609
|
+
tagmsgChannelReducer(
|
|
610
|
+
chan,
|
|
611
|
+
{ command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': 'm7' } },
|
|
612
|
+
ctx,
|
|
613
|
+
);
|
|
614
|
+
|
|
615
|
+
expect(conn.lastReadMarkers?.get('#foo')).toBe('m7');
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it('overwrites a previously stored marker when a newer msgid arrives', () => {
|
|
619
|
+
const chan = makeChan('#foo');
|
|
620
|
+
addMember(chan, 'c1', 'alice');
|
|
621
|
+
const conn = makeConn();
|
|
622
|
+
conn.account = 'alice';
|
|
623
|
+
const store = new InMemoryReadMarkerStore();
|
|
624
|
+
const ctx = makeCtx(conn, undefined, undefined, store);
|
|
625
|
+
|
|
626
|
+
tagmsgChannelReducer(
|
|
627
|
+
chan,
|
|
628
|
+
{ command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': 'm1' } },
|
|
629
|
+
ctx,
|
|
630
|
+
);
|
|
631
|
+
tagmsgChannelReducer(
|
|
632
|
+
chan,
|
|
633
|
+
{ command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': 'm2' } },
|
|
634
|
+
ctx,
|
|
635
|
+
);
|
|
636
|
+
|
|
637
|
+
expect(store.get('alice', '#foo')).toBe('m2');
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it('does NOT persist to the store when the connection has no account', () => {
|
|
641
|
+
const chan = makeChan('#foo');
|
|
642
|
+
addMember(chan, 'c1', 'alice');
|
|
643
|
+
const conn = makeConn(); // no account
|
|
644
|
+
const store = new InMemoryReadMarkerStore();
|
|
645
|
+
const ctx = makeCtx(conn, undefined, undefined, store);
|
|
646
|
+
|
|
647
|
+
tagmsgChannelReducer(
|
|
648
|
+
chan,
|
|
649
|
+
{ command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': 'm1' } },
|
|
650
|
+
ctx,
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
expect(store.forAccount('alice')).toEqual([]);
|
|
654
|
+
// The in-memory marker still advances for the session.
|
|
655
|
+
expect(conn.lastReadMarkers?.get('#foo')).toBe('m1');
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it('does NOT persist when no ReadMarkerStore is bound but still fans out', () => {
|
|
659
|
+
const chan = makeChan('#foo');
|
|
660
|
+
addMember(chan, 'c1', 'alice');
|
|
661
|
+
const conn = makeConn();
|
|
662
|
+
conn.account = 'alice';
|
|
663
|
+
const ctx = makeCtx(conn); // no store
|
|
664
|
+
|
|
665
|
+
const out = tagmsgChannelReducer(
|
|
666
|
+
chan,
|
|
667
|
+
{ command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': 'm1' } },
|
|
668
|
+
ctx,
|
|
669
|
+
);
|
|
670
|
+
|
|
671
|
+
expect(out.effects).toHaveLength(1);
|
|
672
|
+
expect((out.effects[0] as Extract<EffectType, { tag: 'Broadcast' }>).caps).toEqual([
|
|
673
|
+
'message-tags',
|
|
674
|
+
'draft/read-marker',
|
|
675
|
+
]);
|
|
676
|
+
expect(conn.lastReadMarkers?.get('#foo')).toBe('m1');
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it('excludes the sender unless echo-message is negotiated', () => {
|
|
680
|
+
const chan = makeChan('#foo');
|
|
681
|
+
addMember(chan, 'c1', 'alice');
|
|
682
|
+
const conn = makeConn();
|
|
683
|
+
const ctx = makeCtx(conn);
|
|
684
|
+
|
|
685
|
+
const out = tagmsgChannelReducer(
|
|
686
|
+
chan,
|
|
687
|
+
{ command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': 'm1' } },
|
|
688
|
+
ctx,
|
|
689
|
+
);
|
|
690
|
+
|
|
691
|
+
expect(out.effects).toHaveLength(1);
|
|
692
|
+
expect((out.effects[0] as Extract<EffectType, { tag: 'Broadcast' }>).except).toBe('c1');
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it('echoes the read-marker TAGMSG back to the sender when echo-message is negotiated', () => {
|
|
696
|
+
const chan = makeChan('#foo');
|
|
697
|
+
addMember(chan, 'c1', 'alice');
|
|
698
|
+
const conn = makeConn();
|
|
699
|
+
conn.caps.add('echo-message');
|
|
700
|
+
const ctx = makeCtx(conn);
|
|
701
|
+
|
|
702
|
+
const out = tagmsgChannelReducer(
|
|
703
|
+
chan,
|
|
704
|
+
{ command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': 'm3' } },
|
|
705
|
+
ctx,
|
|
706
|
+
);
|
|
707
|
+
|
|
708
|
+
const line = L('@+draft/read-marker=m3 :alice!alice@example.com TAGMSG #foo');
|
|
709
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
710
|
+
{
|
|
711
|
+
tag: 'Broadcast',
|
|
712
|
+
chan: '#foo',
|
|
713
|
+
lines: [line],
|
|
714
|
+
except: 'c1',
|
|
715
|
+
caps: ['message-tags', 'draft/read-marker'],
|
|
716
|
+
},
|
|
717
|
+
Effect.send('c1', [line]),
|
|
718
|
+
]);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
it('fans out a valueless +draft/read-marker tag but persists no msgid', () => {
|
|
722
|
+
const chan = makeChan('#foo');
|
|
723
|
+
addMember(chan, 'c1', 'alice');
|
|
724
|
+
const conn = makeConn();
|
|
725
|
+
conn.account = 'alice';
|
|
726
|
+
const store = new InMemoryReadMarkerStore();
|
|
727
|
+
const ctx = makeCtx(conn, undefined, undefined, store);
|
|
728
|
+
|
|
729
|
+
const out = tagmsgChannelReducer(
|
|
730
|
+
chan,
|
|
731
|
+
{ command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': '' } },
|
|
732
|
+
ctx,
|
|
733
|
+
);
|
|
734
|
+
|
|
735
|
+
// Broadcast still widens to draft/read-marker peers (tag is present)...
|
|
736
|
+
expect((out.effects[0] as Extract<EffectType, { tag: 'Broadcast' }>).caps).toEqual([
|
|
737
|
+
'message-tags',
|
|
738
|
+
'draft/read-marker',
|
|
739
|
+
]);
|
|
740
|
+
// ...but nothing is persisted (no msgid to store).
|
|
741
|
+
expect(store.forAccount('alice')).toEqual([]);
|
|
742
|
+
expect(conn.lastReadMarkers?.get('#foo')).toBeUndefined();
|
|
743
|
+
});
|
|
744
|
+
});
|
|
745
|
+
|
|
420
746
|
// ============================================================================
|
|
421
747
|
// tagmsgChannelReducer — chathistory recording
|
|
422
748
|
// ============================================================================
|
|
@@ -631,6 +957,37 @@ describe('tagmsgUserReducer', () => {
|
|
|
631
957
|
]);
|
|
632
958
|
});
|
|
633
959
|
|
|
960
|
+
it('routes a +draft/typing TAGMSG to a single nick target', () => {
|
|
961
|
+
const conn = makeConn();
|
|
962
|
+
const ctx = makeCtx(conn);
|
|
963
|
+
|
|
964
|
+
const out = tagmsgUserReducer(
|
|
965
|
+
conn,
|
|
966
|
+
{ command: 'TAGMSG', params: ['bob'], tags: { '+draft/typing': 'active' } },
|
|
967
|
+
ctx,
|
|
968
|
+
);
|
|
969
|
+
|
|
970
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
971
|
+
Effect.sendToNick('bob', 'c1', [
|
|
972
|
+
L('@+draft/typing=active :alice!alice@example.com TAGMSG bob'),
|
|
973
|
+
]),
|
|
974
|
+
]);
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
it('excludes the sender from the echo for a +draft/typing TAGMSG without echo-message', () => {
|
|
978
|
+
const conn = makeConn();
|
|
979
|
+
const ctx = makeCtx(conn);
|
|
980
|
+
|
|
981
|
+
const out = tagmsgUserReducer(
|
|
982
|
+
conn,
|
|
983
|
+
{ command: 'TAGMSG', params: ['bob'], tags: { '+draft/typing': 'active' } },
|
|
984
|
+
ctx,
|
|
985
|
+
);
|
|
986
|
+
|
|
987
|
+
expect(out.effects).toHaveLength(1);
|
|
988
|
+
expect(out.effects[0]?.tag).toBe('SendToNick');
|
|
989
|
+
});
|
|
990
|
+
|
|
634
991
|
it('updates lastSeen to ctx.clock.now()', () => {
|
|
635
992
|
const conn = makeConn();
|
|
636
993
|
const clock = new FakeClock(12_000);
|
|
@@ -166,6 +166,40 @@ describe('whoisReducer — happy path', () => {
|
|
|
166
166
|
expect(send.lines.some((l) => l.text.endsWith(':is an IRC operator'))).toBe(true);
|
|
167
167
|
}
|
|
168
168
|
});
|
|
169
|
+
|
|
170
|
+
it('emits 276 RPL_WHOISSECURE when the target connected over TLS (user mode S)', () => {
|
|
171
|
+
const requester = makeConn();
|
|
172
|
+
const ctx = makeCtx(requester);
|
|
173
|
+
const target = makeTarget('c2', 'bob');
|
|
174
|
+
target.userModes.tls = true;
|
|
175
|
+
|
|
176
|
+
const out = whoisReducer(target, [], { command: 'WHOIS', params: ['bob'] }, ctx);
|
|
177
|
+
|
|
178
|
+
const send = out.effects[0];
|
|
179
|
+
expect(send?.tag).toBe('Send');
|
|
180
|
+
if (send?.tag === 'Send') {
|
|
181
|
+
expect(send.lines.some((l) => l.text.includes(' 276 '))).toBe(true);
|
|
182
|
+
expect(
|
|
183
|
+
send.lines.some(
|
|
184
|
+
(l) => l.text === ':irc.example.com 276 alice bob :is using a secure connection',
|
|
185
|
+
),
|
|
186
|
+
).toBe(true);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('does not emit 276 when the target is not on a secure connection', () => {
|
|
191
|
+
const requester = makeConn();
|
|
192
|
+
const ctx = makeCtx(requester);
|
|
193
|
+
const target = makeTarget('c2', 'bob');
|
|
194
|
+
// tls left at its default (false).
|
|
195
|
+
|
|
196
|
+
const out = whoisReducer(target, [], { command: 'WHOIS', params: ['bob'] }, ctx);
|
|
197
|
+
|
|
198
|
+
const send = out.effects[0];
|
|
199
|
+
if (send?.tag === 'Send') {
|
|
200
|
+
expect(send.lines.some((l) => l.text.includes(' 276 '))).toBe(false);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
169
203
|
});
|
|
170
204
|
|
|
171
205
|
// ============================================================================
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import {
|
|
3
3
|
DEFAULT_CREATED_TEXT,
|
|
4
|
-
DEFAULT_SERVER_CONFIG,
|
|
5
4
|
DEFAULT_SERVER_VERSION,
|
|
6
5
|
ServerConfigSchema,
|
|
7
6
|
formatCreatedAt,
|
|
@@ -567,22 +566,34 @@ describe('ServerConfigSchema — failure diagnostics', () => {
|
|
|
567
566
|
});
|
|
568
567
|
|
|
569
568
|
// ============================================================================
|
|
570
|
-
// ServerConfigSchema —
|
|
569
|
+
// ServerConfigSchema — no placeholder serverName baseline
|
|
571
570
|
// ============================================================================
|
|
572
571
|
|
|
573
|
-
describe('ServerConfigSchema —
|
|
574
|
-
it('
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
572
|
+
describe('ServerConfigSchema — required serverName', () => {
|
|
573
|
+
it('rejects an empty input with a serverName error (no placeholder fallback)', () => {
|
|
574
|
+
try {
|
|
575
|
+
parseServerConfig({});
|
|
576
|
+
expect.fail('expected parseServerConfig({}) to throw');
|
|
577
|
+
} catch (err) {
|
|
578
|
+
expect(err).toBeInstanceOf(Error);
|
|
579
|
+
const msg = (err as Error).message;
|
|
580
|
+
expect(msg).toMatch(/invalid server config/iu);
|
|
581
|
+
expect(msg).toMatch(/serverName/u);
|
|
582
|
+
}
|
|
581
583
|
});
|
|
582
584
|
|
|
583
|
-
it('
|
|
584
|
-
|
|
585
|
-
|
|
585
|
+
it('rejects an empty-string serverName', () => {
|
|
586
|
+
expect(() => parseServerConfig({ serverName: '', networkName: 'n' })).toThrowError(
|
|
587
|
+
/serverName/u,
|
|
588
|
+
);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it('accepts a minimal {serverName, networkName} input', () => {
|
|
592
|
+
const cfg = parseServerConfig({ serverName: 'irc.test', networkName: 'TestNet' });
|
|
593
|
+
expect(cfg.serverName).toBe('irc.test');
|
|
594
|
+
expect(cfg.networkName).toBe('TestNet');
|
|
595
|
+
expect(cfg.channelPrefixes.length).toBeGreaterThan(0);
|
|
596
|
+
expect(cfg.maxClients).toBeGreaterThan(0);
|
|
586
597
|
});
|
|
587
598
|
});
|
|
588
599
|
|
|
@@ -813,3 +824,82 @@ describe('ServerConfigSchema — perIpConnectionRate', () => {
|
|
|
813
824
|
).toThrowError(/perIpConnectionRate/u);
|
|
814
825
|
});
|
|
815
826
|
});
|
|
827
|
+
|
|
828
|
+
// ============================================================================
|
|
829
|
+
// ServerConfigSchema — sts (Strict Transport Security)
|
|
830
|
+
// ============================================================================
|
|
831
|
+
|
|
832
|
+
describe('ServerConfigSchema — sts', () => {
|
|
833
|
+
it('defaults sts to undefined when omitted (STS disabled)', () => {
|
|
834
|
+
const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
|
|
835
|
+
expect(cfg.sts).toBeUndefined();
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
it('accepts a valid sts config with duration + port', () => {
|
|
839
|
+
const cfg = parseServerConfig({
|
|
840
|
+
serverName: 's',
|
|
841
|
+
networkName: 'n',
|
|
842
|
+
sts: { duration: 600, port: 6697 },
|
|
843
|
+
});
|
|
844
|
+
expect(cfg.sts).toEqual({ duration: 600, port: 6697 });
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
it('accepts an optional preload directive', () => {
|
|
848
|
+
const cfg = parseServerConfig({
|
|
849
|
+
serverName: 's',
|
|
850
|
+
networkName: 'n',
|
|
851
|
+
sts: { duration: 600, port: 6697, preload: true },
|
|
852
|
+
});
|
|
853
|
+
expect(cfg.sts?.preload).toBe(true);
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
it('rejects an sts config missing duration', () => {
|
|
857
|
+
expect(() =>
|
|
858
|
+
parseServerConfig({
|
|
859
|
+
serverName: 's',
|
|
860
|
+
networkName: 'n',
|
|
861
|
+
sts: { port: 6697 },
|
|
862
|
+
}),
|
|
863
|
+
).toThrowError(/duration/u);
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
it('rejects an sts config missing port', () => {
|
|
867
|
+
expect(() =>
|
|
868
|
+
parseServerConfig({
|
|
869
|
+
serverName: 's',
|
|
870
|
+
networkName: 'n',
|
|
871
|
+
sts: { duration: 600 },
|
|
872
|
+
}),
|
|
873
|
+
).toThrowError(/port/u);
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
it('rejects a zero duration', () => {
|
|
877
|
+
expect(() =>
|
|
878
|
+
parseServerConfig({
|
|
879
|
+
serverName: 's',
|
|
880
|
+
networkName: 'n',
|
|
881
|
+
sts: { duration: 0, port: 6697 },
|
|
882
|
+
}),
|
|
883
|
+
).toThrowError(/duration/u);
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
it('rejects a zero port', () => {
|
|
887
|
+
expect(() =>
|
|
888
|
+
parseServerConfig({
|
|
889
|
+
serverName: 's',
|
|
890
|
+
networkName: 'n',
|
|
891
|
+
sts: { duration: 600, port: 0 },
|
|
892
|
+
}),
|
|
893
|
+
).toThrowError(/port/u);
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
it('rejects a non-object sts value', () => {
|
|
897
|
+
expect(() =>
|
|
898
|
+
parseServerConfig({
|
|
899
|
+
serverName: 's',
|
|
900
|
+
networkName: 'n',
|
|
901
|
+
sts: 'secure',
|
|
902
|
+
}),
|
|
903
|
+
).toThrowError(/sts/u);
|
|
904
|
+
});
|
|
905
|
+
});
|