serverless-ircd 0.8.0 → 0.9.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 +4 -0
- package/CHANGELOG.md +245 -0
- package/README.md +160 -200
- package/apps/aws-stack/package.json +1 -1
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-tcp-container/src/container-server.ts +21 -1
- package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
- package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
- package/apps/cf-worker/package.json +1 -1
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +94 -31
- package/apps/local-cli/tests/config-resolution.test.ts +65 -0
- package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
- package/apps/local-cli/tests/rehash.test.ts +147 -0
- package/apps/local-cli/tests/server-helpers.test.ts +63 -0
- package/apps/local-cli/tests/tcp.test.ts +89 -0
- package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
- package/apps/web/landing/index.html +226 -3
- package/apps/web/package.json +2 -1
- package/apps/web/scripts/build.mjs +25 -2
- package/apps/web/src/render-docs.ts +292 -0
- package/apps/web/tests/build-smoke.test.ts +31 -2
- package/apps/web/tests/landing-content.test.ts +103 -0
- package/apps/web/tests/render-docs.test.ts +198 -0
- package/docs/AWS-Adapter-Architecture.md +3 -2
- package/docs/Services.md +33 -1
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +15 -1
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +10 -2
- package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
- package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
- package/packages/aws-adapter/tests/global-setup.ts +28 -1
- package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
- package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
- package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/connection-do.ts +18 -6
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/commands/account-auth.ts +46 -18
- package/packages/irc-core/src/commands/chanserv.ts +288 -4
- package/packages/irc-core/src/commands/hostserv.ts +38 -3
- package/packages/irc-core/src/commands/index.ts +1 -0
- package/packages/irc-core/src/commands/join.ts +41 -35
- package/packages/irc-core/src/commands/nickserv.ts +16 -4
- package/packages/irc-core/src/commands/registration.ts +27 -16
- package/packages/irc-core/src/commands/service-aliases.ts +52 -0
- package/packages/irc-core/src/commands/topic.ts +23 -10
- package/packages/irc-core/src/state/channel.ts +17 -0
- package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
- package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
- package/packages/irc-core/tests/commands/join.test.ts +179 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +185 -2
- package/packages/irc-core/tests/commands/registration.test.ts +227 -6
- package/packages/irc-core/tests/commands/sasl.test.ts +44 -0
- package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +80 -30
- package/packages/irc-server/tests/actor.test.ts +365 -3
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
- package/packages/irc-test-support/src/scenarios.ts +21 -6
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
- package/packages/irc-test-support/vitest.config.ts +6 -1
- package/tools/ci-hardening/package.json +1 -1
- package/tools/load-test/package.json +1 -1
- package/tools/load-test/src/client.ts +13 -13
- package/tools/load-test/tests/client.test.ts +258 -2
- package/tools/load-test/tests/config.test.ts +39 -0
- package/tools/load-test/tests/harness.test.ts +21 -0
- package/tools/load-test/tests/metrics.test.ts +7 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
- package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
|
@@ -577,12 +577,15 @@ describe('passReducer', () => {
|
|
|
577
577
|
expect(out.effects).toEqual([]);
|
|
578
578
|
});
|
|
579
579
|
|
|
580
|
-
it('
|
|
580
|
+
it('emits 462 when PASS is sent twice before registration completes', () => {
|
|
581
581
|
const state = makeState();
|
|
582
582
|
state.passAttempt = 'first';
|
|
583
583
|
const ctx = makeCtx(state);
|
|
584
584
|
const out = passReducer(state, { command: 'PASS', params: ['second'], tags: {} }, ctx);
|
|
585
|
-
expect(out.
|
|
585
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
586
|
+
Effect.send('c1', [L(':irc.example.com 462 * :You may not reregister')]),
|
|
587
|
+
]);
|
|
588
|
+
expect(out.state.passAttempt).toBe('first');
|
|
586
589
|
});
|
|
587
590
|
|
|
588
591
|
it('emits 461 when no password is supplied', () => {
|
|
@@ -595,14 +598,119 @@ describe('passReducer', () => {
|
|
|
595
598
|
expect(out.state.passAttempt).toBeUndefined();
|
|
596
599
|
});
|
|
597
600
|
|
|
598
|
-
it('emits
|
|
601
|
+
it('emits 461 when no password is supplied even after registration', () => {
|
|
599
602
|
const state = registeredState();
|
|
600
603
|
const ctx = makeCtx(state);
|
|
601
|
-
const out = passReducer(state, { command: 'PASS', params: [
|
|
604
|
+
const out = passReducer(state, { command: 'PASS', params: [], tags: {} }, ctx);
|
|
602
605
|
expect(out.effects).toEqual<EffectType[]>([
|
|
603
|
-
Effect.send('c1', [L(':irc.example.com
|
|
606
|
+
Effect.send('c1', [L(':irc.example.com 461 alice PASS :Not enough parameters')]),
|
|
604
607
|
]);
|
|
605
|
-
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
// ============================================================================
|
|
612
|
+
// passReducer — post-registration PASS (late account login)
|
|
613
|
+
// ============================================================================
|
|
614
|
+
|
|
615
|
+
describe('passReducer — post-registration PASS <nick>:<password>', () => {
|
|
616
|
+
it('logs in when PASS <nick>:<password> matches an account after registration', () => {
|
|
617
|
+
const state = registeredState();
|
|
618
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
619
|
+
const ctx = makeCtxWithAccounts(state, accounts);
|
|
620
|
+
const out = passReducer(state, { command: 'PASS', params: ['alice:hunter2'], tags: {} }, ctx);
|
|
621
|
+
|
|
622
|
+
expect(out.state.account).toBe('alice');
|
|
623
|
+
expect(out.state.userModes.registered).toBe(true);
|
|
624
|
+
const texts = sentTexts(out.effects);
|
|
625
|
+
expect(texts).toContain(
|
|
626
|
+
`:${SV} 900 alice alice!alice@example.com alice :You are now logged in as alice`,
|
|
627
|
+
);
|
|
628
|
+
expect(texts).toContain(`:${SV} 903 alice :SASL authentication successful`);
|
|
629
|
+
expect(texts.some((t) => t.includes(' 462 '))).toBe(false);
|
|
630
|
+
expect(out.effects.some((e) => e.tag === 'Disconnect')).toBe(false);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it('does not log in or disconnect when the password is wrong', () => {
|
|
634
|
+
const state = registeredState();
|
|
635
|
+
const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
|
|
636
|
+
const ctx = makeCtxWithAccounts(state, accounts);
|
|
637
|
+
const out = passReducer(state, { command: 'PASS', params: ['alice:wrong'], tags: {} }, ctx);
|
|
638
|
+
|
|
639
|
+
expect(out.state.account).toBeUndefined();
|
|
640
|
+
expect(out.state.userModes.registered).toBe(false);
|
|
641
|
+
expect(out.effects).toEqual([]);
|
|
642
|
+
expect(out.effects.some((e) => e.tag === 'Disconnect')).toBe(false);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it('is indistinguishable from an unknown nick (no auth numerics, no disconnect)', () => {
|
|
646
|
+
const state = registeredState();
|
|
647
|
+
state.nick = 'nobody';
|
|
648
|
+
const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
|
|
649
|
+
const ctx = makeCtxWithAccounts(state, accounts);
|
|
650
|
+
const out = passReducer(state, { command: 'PASS', params: ['nobody:wrong'], tags: {} }, ctx);
|
|
651
|
+
|
|
652
|
+
expect(out.state.account).toBeUndefined();
|
|
653
|
+
expect(out.effects).toEqual([]);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it('equalises timing with an extra dummy verify on failure', () => {
|
|
657
|
+
const state = registeredState();
|
|
658
|
+
const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
|
|
659
|
+
const ctx = makeCtxWithAccounts(state, accounts);
|
|
660
|
+
passReducer(state, { command: 'PASS', params: ['alice:wrong'], tags: {} }, ctx);
|
|
661
|
+
|
|
662
|
+
expect(accounts.calls).toHaveLength(2);
|
|
663
|
+
expect(accounts.calls[0]?.payload).toEqual({
|
|
664
|
+
kind: 'PLAIN',
|
|
665
|
+
username: 'alice',
|
|
666
|
+
password: 'wrong',
|
|
667
|
+
});
|
|
668
|
+
expect(accounts.calls[1]?.payload.kind).toBe('PLAIN');
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it('does not log in when the payload nick does not match state.nick', () => {
|
|
672
|
+
const state = registeredState();
|
|
673
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'bob' });
|
|
674
|
+
const ctx = makeCtxWithAccounts(state, accounts);
|
|
675
|
+
const out = passReducer(state, { command: 'PASS', params: ['bob:hunter2'], tags: {} }, ctx);
|
|
676
|
+
|
|
677
|
+
expect(out.state.account).toBeUndefined();
|
|
678
|
+
expect(accounts.calls).toHaveLength(0);
|
|
679
|
+
expect(out.effects).toEqual([]);
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
it('ignores a bare PASS value (no colon) after registration — no 462, no disconnect', () => {
|
|
683
|
+
const state = registeredState();
|
|
684
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
685
|
+
const ctx = makeCtxWithAccounts(state, accounts);
|
|
686
|
+
const out = passReducer(state, { command: 'PASS', params: ['serverpw'], tags: {} }, ctx);
|
|
687
|
+
|
|
688
|
+
expect(out.state.account).toBeUndefined();
|
|
689
|
+
expect(out.effects).toEqual([]);
|
|
690
|
+
expect(accounts.calls).toHaveLength(0);
|
|
691
|
+
expect(out.effects.some((e) => e.tag === 'Disconnect')).toBe(false);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it('ignores a late PASS when the connection is already identified', () => {
|
|
695
|
+
const state = registeredState();
|
|
696
|
+
state.account = 'alice';
|
|
697
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
698
|
+
const ctx = makeCtxWithAccounts(state, accounts);
|
|
699
|
+
const out = passReducer(state, { command: 'PASS', params: ['alice:hunter2'], tags: {} }, ctx);
|
|
700
|
+
|
|
701
|
+
expect(accounts.calls).toHaveLength(0);
|
|
702
|
+
expect(out.effects).toEqual([]);
|
|
703
|
+
expect(out.effects.some((e) => e.tag === 'Disconnect')).toBe(false);
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it('ignores a late PASS <nick>:<password> when no AccountStore is bound', () => {
|
|
707
|
+
const state = registeredState();
|
|
708
|
+
const ctx = makeCtx(state);
|
|
709
|
+
const out = passReducer(state, { command: 'PASS', params: ['alice:hunter2'], tags: {} }, ctx);
|
|
710
|
+
|
|
711
|
+
expect(out.state.account).toBeUndefined();
|
|
712
|
+
expect(out.effects).toEqual([]);
|
|
713
|
+
expect(out.effects.some((e) => e.tag === 'Disconnect')).toBe(false);
|
|
606
714
|
});
|
|
607
715
|
});
|
|
608
716
|
|
|
@@ -1668,6 +1776,76 @@ describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity
|
|
|
1668
1776
|
expect(sentTexts(effects).some((t) => t.includes('[Memo #') && t.includes('hi'))).toBe(true);
|
|
1669
1777
|
});
|
|
1670
1778
|
|
|
1779
|
+
it('applies the assigned HostServ vhost on PASS-auth success so 001 carries the vhost', () => {
|
|
1780
|
+
const state = makeState();
|
|
1781
|
+
state.nick = 'alice';
|
|
1782
|
+
state.user = 'alice';
|
|
1783
|
+
state.host = 'real.example.net';
|
|
1784
|
+
state.passAttempt = 'alice:hunter2';
|
|
1785
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1786
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1787
|
+
services.setVhost('alice', 'cool.example.net');
|
|
1788
|
+
const ctx = makeCtxPassAuth(state, { accounts, services });
|
|
1789
|
+
|
|
1790
|
+
const effects = emitWelcomeIfReady(state, ctx);
|
|
1791
|
+
|
|
1792
|
+
expect(state.account).toBe('alice');
|
|
1793
|
+
expect(state.vhostActive).toBe(true);
|
|
1794
|
+
expect(state.host).toBe('cool.example.net');
|
|
1795
|
+
expect(state.preVhostHost).toBe('real.example.net');
|
|
1796
|
+
const welcome = sentTexts(effects).find((t) => t.startsWith(`:${SV} 001 `));
|
|
1797
|
+
expect(welcome).toBeDefined();
|
|
1798
|
+
expect(welcome).toContain('cool.example.net');
|
|
1799
|
+
});
|
|
1800
|
+
|
|
1801
|
+
it('does not apply a vhost on PASS-auth success when none is assigned', () => {
|
|
1802
|
+
const state = makeState();
|
|
1803
|
+
state.nick = 'alice';
|
|
1804
|
+
state.user = 'alice';
|
|
1805
|
+
state.host = 'real.example.net';
|
|
1806
|
+
state.passAttempt = 'alice:hunter2';
|
|
1807
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1808
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1809
|
+
const ctx = makeCtxPassAuth(state, { accounts, services });
|
|
1810
|
+
|
|
1811
|
+
emitWelcomeIfReady(state, ctx);
|
|
1812
|
+
|
|
1813
|
+
expect(state.vhostActive).toBeUndefined();
|
|
1814
|
+
expect(state.host).toBe('real.example.net');
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1817
|
+
it('does not cloak over an active vhost applied during PASS-auth (vhost wins)', () => {
|
|
1818
|
+
const state = makeState();
|
|
1819
|
+
state.nick = 'alice';
|
|
1820
|
+
state.user = 'alice';
|
|
1821
|
+
state.host = '203.0.113.5';
|
|
1822
|
+
state.passAttempt = 'alice:hunter2';
|
|
1823
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
1824
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
1825
|
+
services.setVhost('alice', 'cool.example.net');
|
|
1826
|
+
const ctx: Ctx = buildCtx({
|
|
1827
|
+
serverConfig: { ...serverConfig, cloaking: { enabled: true, secret: 's3cret' } },
|
|
1828
|
+
clock: new FakeClock(1_000),
|
|
1829
|
+
ids: new SequentialIdFactory(),
|
|
1830
|
+
motd: EmptyMotdProvider,
|
|
1831
|
+
connection: state,
|
|
1832
|
+
accounts,
|
|
1833
|
+
services,
|
|
1834
|
+
});
|
|
1835
|
+
|
|
1836
|
+
const effects = emitWelcomeIfReady(state, ctx);
|
|
1837
|
+
|
|
1838
|
+
// The vhost was applied by the PASS-auth success chain; cloaking must not
|
|
1839
|
+
// clobber it, and the 001 welcome must carry the vhost, not the cloak.
|
|
1840
|
+
expect(state.vhostActive).toBe(true);
|
|
1841
|
+
expect(state.host).toBe('cool.example.net');
|
|
1842
|
+
const welcome = sentTexts(effects).find((t) => t.startsWith(`:${SV} 001 `));
|
|
1843
|
+
expect(welcome).toContain('cool.example.net');
|
|
1844
|
+
expect(welcome).not.toContain(
|
|
1845
|
+
cloakHost('203.0.113.5', { enabled: true, secret: 's3cret' }, 'ExampleNet'),
|
|
1846
|
+
);
|
|
1847
|
+
});
|
|
1848
|
+
|
|
1671
1849
|
it('records the payload nick as the canonical account name', () => {
|
|
1672
1850
|
const state = makeState();
|
|
1673
1851
|
state.nick = 'alice';
|
|
@@ -1940,3 +2118,46 @@ describe('applyAccountSuccess — shared account-login helper', () => {
|
|
|
1940
2118
|
expect(texts).toContain(`:${SV} 903 alice :SASL authentication successful`);
|
|
1941
2119
|
});
|
|
1942
2120
|
});
|
|
2121
|
+
|
|
2122
|
+
// ============================================================================
|
|
2123
|
+
// applyAccountSuccess — HostServ vhost auto-apply (SASL + PASS-auth share it)
|
|
2124
|
+
// ============================================================================
|
|
2125
|
+
|
|
2126
|
+
describe('applyAccountSuccess — HostServ vhost auto-apply', () => {
|
|
2127
|
+
it('applies an assigned vhost to state.host and sets vhostActive when services hold a vhost for the account', () => {
|
|
2128
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
2129
|
+
services.setVhost('alice', 'cool.example.net');
|
|
2130
|
+
const state = registeredState();
|
|
2131
|
+
state.host = 'real.example.net';
|
|
2132
|
+
const ctx = makeCtxWithServices(state, services);
|
|
2133
|
+
|
|
2134
|
+
applyAccountSuccess(state, ctx, 'alice');
|
|
2135
|
+
|
|
2136
|
+
expect(state.vhostActive).toBe(true);
|
|
2137
|
+
expect(state.host).toBe('cool.example.net');
|
|
2138
|
+
expect(state.preVhostHost).toBe('real.example.net');
|
|
2139
|
+
});
|
|
2140
|
+
|
|
2141
|
+
it('leaves state.host unchanged when no vhost is assigned to the account', () => {
|
|
2142
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
2143
|
+
const state = registeredState();
|
|
2144
|
+
state.host = 'real.example.net';
|
|
2145
|
+
const ctx = makeCtxWithServices(state, services);
|
|
2146
|
+
|
|
2147
|
+
applyAccountSuccess(state, ctx, 'alice');
|
|
2148
|
+
|
|
2149
|
+
expect(state.vhostActive).toBeUndefined();
|
|
2150
|
+
expect(state.host).toBe('real.example.net');
|
|
2151
|
+
});
|
|
2152
|
+
|
|
2153
|
+
it('does not emit a CHGHOST broadcast when no channels are joined (pre-JOIN identify)', () => {
|
|
2154
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
|
|
2155
|
+
services.setVhost('alice', 'cool.example.net');
|
|
2156
|
+
const state = registeredState();
|
|
2157
|
+
const ctx = makeCtxWithServices(state, services);
|
|
2158
|
+
|
|
2159
|
+
const effects = applyAccountSuccess(state, ctx, 'alice');
|
|
2160
|
+
|
|
2161
|
+
expect(effects.filter((e) => e.tag === 'Broadcast')).toEqual<EffectType[]>([]);
|
|
2162
|
+
});
|
|
2163
|
+
});
|
|
@@ -302,6 +302,50 @@ describe('authenticateReducer — PLAIN success', () => {
|
|
|
302
302
|
expect(texts.some((l) => l.text.includes('[Memo #1'))).toBe(false);
|
|
303
303
|
});
|
|
304
304
|
|
|
305
|
+
it('applies the assigned HostServ vhost on SASL PLAIN success', () => {
|
|
306
|
+
const state = saslReadyState();
|
|
307
|
+
state.host = 'real.example.net';
|
|
308
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
309
|
+
const services = new InMemoryServicesStore();
|
|
310
|
+
services.setVhost('alice', 'cool.example.net');
|
|
311
|
+
const ctx = makeCtx(state, accounts, undefined, services);
|
|
312
|
+
|
|
313
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
314
|
+
authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
315
|
+
|
|
316
|
+
expect(state.vhostActive).toBe(true);
|
|
317
|
+
expect(state.host).toBe('cool.example.net');
|
|
318
|
+
expect(state.preVhostHost).toBe('real.example.net');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('does not emit a CHGHOST broadcast on SASL success (pre-registration, no peers)', () => {
|
|
322
|
+
const state = saslReadyState();
|
|
323
|
+
state.host = 'real.example.net';
|
|
324
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
325
|
+
const services = new InMemoryServicesStore();
|
|
326
|
+
services.setVhost('alice', 'cool.example.net');
|
|
327
|
+
const ctx = makeCtx(state, accounts, undefined, services);
|
|
328
|
+
|
|
329
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
330
|
+
const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
331
|
+
|
|
332
|
+
expect(out.effects.filter((e) => e.tag === 'Broadcast')).toEqual<EffectType[]>([]);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('leaves host unchanged on SASL success when no vhost is assigned', () => {
|
|
336
|
+
const state = saslReadyState();
|
|
337
|
+
state.host = 'real.example.net';
|
|
338
|
+
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
339
|
+
const services = new InMemoryServicesStore();
|
|
340
|
+
const ctx = makeCtx(state, accounts, undefined, services);
|
|
341
|
+
|
|
342
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
343
|
+
authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
344
|
+
|
|
345
|
+
expect(state.vhostActive).toBeUndefined();
|
|
346
|
+
expect(state.host).toBe('real.example.net');
|
|
347
|
+
});
|
|
348
|
+
|
|
305
349
|
it('delivers nothing on a SASL login when the account has no memos', () => {
|
|
306
350
|
const state = saslReadyState();
|
|
307
351
|
const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { CHANSERV_NICK } from '../../src/commands/chanserv';
|
|
3
|
+
import { HOSTSERV_NICK } from '../../src/commands/hostserv';
|
|
4
|
+
import { MEMOSERV_NICK } from '../../src/commands/memoserv';
|
|
5
|
+
import { NICKSERV_NICK } from '../../src/commands/nickserv';
|
|
6
|
+
import { OPERSERV_NICK } from '../../src/commands/operserv';
|
|
7
|
+
import { SERVICE_NICKS, SHORTCUT_TO_SERVICE } from '../../src/commands/service-aliases';
|
|
8
|
+
|
|
9
|
+
describe('service-aliases — single source of truth for shortcut verbs', () => {
|
|
10
|
+
it('maps every Atheme/Anope shortcut verb to its canonical service nick', () => {
|
|
11
|
+
const expected: ReadonlyArray<[string, string]> = [
|
|
12
|
+
['NICKSERV', NICKSERV_NICK],
|
|
13
|
+
['NS', NICKSERV_NICK],
|
|
14
|
+
['CHANSERV', CHANSERV_NICK],
|
|
15
|
+
['CS', CHANSERV_NICK],
|
|
16
|
+
['HOSTSERV', HOSTSERV_NICK],
|
|
17
|
+
['HS', HOSTSERV_NICK],
|
|
18
|
+
['MEMOSERV', MEMOSERV_NICK],
|
|
19
|
+
['MS', MEMOSERV_NICK],
|
|
20
|
+
['OPERSERV', OPERSERV_NICK],
|
|
21
|
+
['OS', OPERSERV_NICK],
|
|
22
|
+
];
|
|
23
|
+
expect(SHORTCUT_TO_SERVICE.size).toBe(10);
|
|
24
|
+
for (const [alias, nick] of expected) {
|
|
25
|
+
expect(SHORTCUT_TO_SERVICE.get(alias)).toBe(nick);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('keys every alias uppercased so the dispatcher can do a case-insensitive lookup', () => {
|
|
30
|
+
for (const alias of SHORTCUT_TO_SERVICE.keys()) {
|
|
31
|
+
expect(alias).toBe(alias.toUpperCase());
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('SERVICE_NICKS is the de-duplicated set of service nicks from the map values', () => {
|
|
36
|
+
const expected = Array.from(new Set(SHORTCUT_TO_SERVICE.values())).sort();
|
|
37
|
+
expect([...SERVICE_NICKS].sort()).toEqual(expected);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('every value in SHORTCUT_TO_SERVICE is one of the canonical *_NICK constants', () => {
|
|
41
|
+
const canonical = new Set<string>([
|
|
42
|
+
NICKSERV_NICK,
|
|
43
|
+
CHANSERV_NICK,
|
|
44
|
+
HOSTSERV_NICK,
|
|
45
|
+
MEMOSERV_NICK,
|
|
46
|
+
OPERSERV_NICK,
|
|
47
|
+
]);
|
|
48
|
+
for (const nick of SHORTCUT_TO_SERVICE.values()) {
|
|
49
|
+
expect(canonical.has(nick)).toBe(true);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -56,6 +56,8 @@ import {
|
|
|
56
56
|
Numerics,
|
|
57
57
|
OPERSERV_NICK,
|
|
58
58
|
type RawLine,
|
|
59
|
+
type Reducer,
|
|
60
|
+
SHORTCUT_TO_SERVICE,
|
|
59
61
|
type ServerConfig,
|
|
60
62
|
type ServerStats,
|
|
61
63
|
type ServicesStore,
|
|
@@ -527,6 +529,28 @@ export class ConnectionActor {
|
|
|
527
529
|
return this.accumulateMultilineLine(msg);
|
|
528
530
|
}
|
|
529
531
|
|
|
532
|
+
// Service shortcut commands (Atheme/Anope convention): rewrite verbs
|
|
533
|
+
// like `NICKSERV IDENTIFY pw` / `NS INFO` / `CS REGISTER #home` to the
|
|
534
|
+
// canonical `PRIVMSG <ServiceNick> :<joined args>` and delegate to the
|
|
535
|
+
// existing service-routing path (`routeMessageTarget` →
|
|
536
|
+
// `serviceReducerFor`). `SHORTCUT_TO_SERVICE` is the single source of
|
|
537
|
+
// truth for the alias → service nick mapping; the service reducers run
|
|
538
|
+
// unchanged against the rewritten `params=[serviceNick, body]` shape.
|
|
539
|
+
const serviceShortcutTarget = SHORTCUT_TO_SERVICE.get(msg.command.toUpperCase());
|
|
540
|
+
if (serviceShortcutTarget !== undefined) {
|
|
541
|
+
const rewritten: IrcMessage = {
|
|
542
|
+
...msg,
|
|
543
|
+
command: 'PRIVMSG',
|
|
544
|
+
params: [serviceShortcutTarget, msg.params.join(' ')],
|
|
545
|
+
};
|
|
546
|
+
return this.routeMessageTarget(
|
|
547
|
+
rewritten,
|
|
548
|
+
this.reducers.privmsgChannel,
|
|
549
|
+
this.reducers.privmsgUser,
|
|
550
|
+
ctx,
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
530
554
|
switch (msg.command) {
|
|
531
555
|
// -------- Connection-authority commands --------
|
|
532
556
|
case 'PING':
|
|
@@ -1348,39 +1372,22 @@ export class ConnectionActor {
|
|
|
1348
1372
|
}
|
|
1349
1373
|
for (const target of targets) {
|
|
1350
1374
|
const singleParamMsg: IrcMessage = { ...msg, params: [target, ...tail] };
|
|
1375
|
+
const serviceReducer = this.serviceReducerFor(target, msg.command);
|
|
1351
1376
|
if (isChannelTarget(target)) {
|
|
1352
1377
|
const chan = this.channels.getOrCreateChannel(target);
|
|
1353
1378
|
effects.push(...channelReducer(chan, singleParamMsg, ctx).effects);
|
|
1354
|
-
} else if (
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
effects.push(...
|
|
1366
|
-
} else if (
|
|
1367
|
-
msg.command === 'PRIVMSG' &&
|
|
1368
|
-
this.services !== undefined &&
|
|
1369
|
-
caseFold('rfc1459', target) === caseFold('rfc1459', HOSTSERV_NICK)
|
|
1370
|
-
) {
|
|
1371
|
-
effects.push(...this.reducers.hostserv(this.state, singleParamMsg, ctx).effects);
|
|
1372
|
-
} else if (
|
|
1373
|
-
msg.command === 'PRIVMSG' &&
|
|
1374
|
-
this.services !== undefined &&
|
|
1375
|
-
caseFold('rfc1459', target) === caseFold('rfc1459', MEMOSERV_NICK)
|
|
1376
|
-
) {
|
|
1377
|
-
effects.push(...this.reducers.memoserv(this.state, singleParamMsg, ctx).effects);
|
|
1378
|
-
} else if (
|
|
1379
|
-
msg.command === 'PRIVMSG' &&
|
|
1380
|
-
this.services !== undefined &&
|
|
1381
|
-
caseFold('rfc1459', target) === caseFold('rfc1459', OPERSERV_NICK)
|
|
1382
|
-
) {
|
|
1383
|
-
effects.push(...this.reducers.operserv(this.state, singleParamMsg, ctx).effects);
|
|
1379
|
+
} else if (serviceReducer !== undefined) {
|
|
1380
|
+
// Echo the sender's own PRIVMSG back BEFORE the service reducer's NOTICE
|
|
1381
|
+
// reply — matching how every other PRIVMSG renders (your outgoing line
|
|
1382
|
+
// first, then any reply). Without this ordering a client sees the
|
|
1383
|
+
// service's response above its own message, which reads as a bug.
|
|
1384
|
+
if (ctx.connection.caps.has('echo-message')) {
|
|
1385
|
+
const hostmask = hostmaskOf(this.state) ?? this.state.nick ?? '?';
|
|
1386
|
+
const text = singleParamMsg.params[1] ?? '';
|
|
1387
|
+
const echoLine: RawLine = { text: `:${hostmask} PRIVMSG ${target} :${text}` };
|
|
1388
|
+
effects.push(Effect.send(ctx.connId, [echoLine]));
|
|
1389
|
+
}
|
|
1390
|
+
effects.push(...serviceReducer(this.state, singleParamMsg, ctx).effects);
|
|
1384
1391
|
} else {
|
|
1385
1392
|
effects.push(...userReducer(this.state, singleParamMsg, ctx).effects);
|
|
1386
1393
|
}
|
|
@@ -1388,6 +1395,26 @@ export class ConnectionActor {
|
|
|
1388
1395
|
return effects;
|
|
1389
1396
|
}
|
|
1390
1397
|
|
|
1398
|
+
/**
|
|
1399
|
+
* Resolves the service pseudo-client reducer for a nick target, or
|
|
1400
|
+
* `undefined` when the target is not a service nick / the command is not
|
|
1401
|
+
* `PRIVMSG` / no services store is bound. Centralised so the
|
|
1402
|
+
* `echo-message` echo (and any future service-routing concern) lives in a
|
|
1403
|
+
* single place rather than being duplicated across five near-identical
|
|
1404
|
+
* branches. `NOTICE` / `TAGMSG` to a service nick intentionally return
|
|
1405
|
+
* `undefined` so they fall through to the normal user-target reducer.
|
|
1406
|
+
*
|
|
1407
|
+
* The membership test is a table lookup against {@link SERVICE_REDUCER_KEYS}
|
|
1408
|
+
* (built from the same `*_NICK` constants that back
|
|
1409
|
+
* {@link SHORTCUT_TO_SERVICE}), so the service-nick list has one source of
|
|
1410
|
+
* truth rather than a per-service `if`-chain here.
|
|
1411
|
+
*/
|
|
1412
|
+
private serviceReducerFor(target: string, command: string): Reducer<ConnectionState> | undefined {
|
|
1413
|
+
if (command !== 'PRIVMSG' || this.services === undefined) return undefined;
|
|
1414
|
+
const key = SERVICE_REDUCER_KEYS.get(caseFold('rfc1459', target));
|
|
1415
|
+
return key === undefined ? undefined : this.reducers[key];
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1391
1418
|
private unknownCommandEffect(command: string): EffectType {
|
|
1392
1419
|
const nick = this.state.nick ?? '*';
|
|
1393
1420
|
const code = Numerics.ERR_UNKNOWNCOMMAND.toString().padStart(3, '0');
|
|
@@ -1416,6 +1443,29 @@ function splitCsv(param: string): string[] {
|
|
|
1416
1443
|
return param.split(',').filter((p) => p.length > 0);
|
|
1417
1444
|
}
|
|
1418
1445
|
|
|
1446
|
+
/**
|
|
1447
|
+
* Service reducer keys on {@link RoutedReducers}. Narrower than
|
|
1448
|
+
* `keyof RoutedReducers` so the table lookup resolves to a
|
|
1449
|
+
* `Reducer<ConnectionState>` without a widening cast.
|
|
1450
|
+
*/
|
|
1451
|
+
type ServiceReducerKey = 'nickserv' | 'chanserv' | 'hostserv' | 'memoserv' | 'operserv';
|
|
1452
|
+
|
|
1453
|
+
/**
|
|
1454
|
+
* rfc1459 case-folded service nick → reducer key. The single source of
|
|
1455
|
+
* truth for "which targets are service nicks" — built from the canonical
|
|
1456
|
+
* `*_NICK` constants that also back {@link SHORTCUT_TO_SERVICE}, so the
|
|
1457
|
+
* dispatcher's shortcut rewrite and `serviceReducerFor`'s routing consult
|
|
1458
|
+
* one set of service nicks rather than each maintaining its own
|
|
1459
|
+
* hand-written `if`-chain.
|
|
1460
|
+
*/
|
|
1461
|
+
const SERVICE_REDUCER_KEYS: ReadonlyMap<string, ServiceReducerKey> = new Map([
|
|
1462
|
+
[caseFold('rfc1459', NICKSERV_NICK), 'nickserv'],
|
|
1463
|
+
[caseFold('rfc1459', CHANSERV_NICK), 'chanserv'],
|
|
1464
|
+
[caseFold('rfc1459', HOSTSERV_NICK), 'hostserv'],
|
|
1465
|
+
[caseFold('rfc1459', MEMOSERV_NICK), 'memoserv'],
|
|
1466
|
+
[caseFold('rfc1459', OPERSERV_NICK), 'operserv'],
|
|
1467
|
+
]);
|
|
1468
|
+
|
|
1419
1469
|
/**
|
|
1420
1470
|
* Resolves the `<server>` parameter shared by `LUSERS` / `STATS` (and any
|
|
1421
1471
|
* future single-server-aware query verb). Returns `undefined` when the
|