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.
Files changed (80) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/CHANGELOG.md +245 -0
  3. package/README.md +160 -200
  4. package/apps/aws-stack/package.json +1 -1
  5. package/apps/cf-tcp-container/package.json +1 -1
  6. package/apps/cf-tcp-container/src/container-server.ts +21 -1
  7. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  8. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  9. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  10. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  11. package/apps/cf-worker/package.json +1 -1
  12. package/apps/local-cli/package.json +1 -1
  13. package/apps/local-cli/src/server.ts +94 -31
  14. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  15. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  16. package/apps/local-cli/tests/rehash.test.ts +147 -0
  17. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  18. package/apps/local-cli/tests/tcp.test.ts +89 -0
  19. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  20. package/apps/web/landing/index.html +226 -3
  21. package/apps/web/package.json +2 -1
  22. package/apps/web/scripts/build.mjs +25 -2
  23. package/apps/web/src/render-docs.ts +292 -0
  24. package/apps/web/tests/build-smoke.test.ts +31 -2
  25. package/apps/web/tests/landing-content.test.ts +103 -0
  26. package/apps/web/tests/render-docs.test.ts +198 -0
  27. package/docs/AWS-Adapter-Architecture.md +3 -2
  28. package/docs/Services.md +33 -1
  29. package/package.json +2 -2
  30. package/packages/aws-adapter/package.json +1 -1
  31. package/packages/aws-adapter/src/aws-runtime.ts +15 -1
  32. package/packages/aws-adapter/src/handlers/nlb-stream.ts +10 -2
  33. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  34. package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
  35. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  36. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  37. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  38. package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
  39. package/packages/cf-adapter/package.json +1 -1
  40. package/packages/cf-adapter/src/connection-do.ts +18 -6
  41. package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
  42. package/packages/in-memory-runtime/package.json +1 -1
  43. package/packages/irc-core/package.json +1 -1
  44. package/packages/irc-core/src/commands/account-auth.ts +46 -18
  45. package/packages/irc-core/src/commands/chanserv.ts +288 -4
  46. package/packages/irc-core/src/commands/hostserv.ts +38 -3
  47. package/packages/irc-core/src/commands/index.ts +1 -0
  48. package/packages/irc-core/src/commands/join.ts +41 -35
  49. package/packages/irc-core/src/commands/nickserv.ts +16 -4
  50. package/packages/irc-core/src/commands/registration.ts +27 -16
  51. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  52. package/packages/irc-core/src/commands/topic.ts +23 -10
  53. package/packages/irc-core/src/state/channel.ts +17 -0
  54. package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
  55. package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
  56. package/packages/irc-core/tests/commands/join.test.ts +179 -0
  57. package/packages/irc-core/tests/commands/nickserv.test.ts +185 -2
  58. package/packages/irc-core/tests/commands/registration.test.ts +227 -6
  59. package/packages/irc-core/tests/commands/sasl.test.ts +44 -0
  60. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  61. package/packages/irc-server/package.json +1 -1
  62. package/packages/irc-server/src/actor.ts +80 -30
  63. package/packages/irc-server/tests/actor.test.ts +365 -3
  64. package/packages/irc-test-support/package.json +1 -1
  65. package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
  66. package/packages/irc-test-support/src/scenarios.ts +21 -6
  67. package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
  68. package/packages/irc-test-support/vitest.config.ts +6 -1
  69. package/tools/ci-hardening/package.json +1 -1
  70. package/tools/load-test/package.json +1 -1
  71. package/tools/load-test/src/client.ts +13 -13
  72. package/tools/load-test/tests/client.test.ts +258 -2
  73. package/tools/load-test/tests/config.test.ts +39 -0
  74. package/tools/load-test/tests/harness.test.ts +21 -0
  75. package/tools/load-test/tests/metrics.test.ts +7 -0
  76. package/tools/tcp-ws-forwarder/package.json +1 -1
  77. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  78. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  79. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
  80. 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('overwrites a previous passAttempt on repeated PASS', () => {
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.state.passAttempt).toBe('second');
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 462 when already registered', () => {
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: ['secret'], tags: {} }, ctx);
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 462 alice :You may not reregister')]),
606
+ Effect.send('c1', [L(':irc.example.com 461 alice PASS :Not enough parameters')]),
604
607
  ]);
605
- expect(out.state.passAttempt).toBeUndefined();
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
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/irc-server",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "description": "Orchestration layer: IrcRuntime port, dispatch interpreter, ConnectionActor",
6
6
  "license": "BSD-3-Clause",
@@ -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
- msg.command === 'PRIVMSG' &&
1356
- this.services !== undefined &&
1357
- caseFold('rfc1459', target) === caseFold('rfc1459', NICKSERV_NICK)
1358
- ) {
1359
- effects.push(...this.reducers.nickserv(this.state, singleParamMsg, ctx).effects);
1360
- } else if (
1361
- msg.command === 'PRIVMSG' &&
1362
- this.services !== undefined &&
1363
- caseFold('rfc1459', target) === caseFold('rfc1459', CHANSERV_NICK)
1364
- ) {
1365
- effects.push(...this.reducers.chanserv(this.state, singleParamMsg, ctx).effects);
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