serverless-ircd 0.1.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 (165) hide show
  1. package/.github/workflows/ci.yml +47 -0
  2. package/.github/workflows/deploy-cf.yml +97 -0
  3. package/LICENSE +29 -0
  4. package/README.md +323 -0
  5. package/apps/cf-worker/package.json +37 -0
  6. package/apps/cf-worker/scripts/smoke.mjs +175 -0
  7. package/apps/cf-worker/src/worker.ts +59 -0
  8. package/apps/cf-worker/tests/smoke.test.ts +150 -0
  9. package/apps/cf-worker/tsconfig.build.json +17 -0
  10. package/apps/cf-worker/tsconfig.test.json +18 -0
  11. package/apps/cf-worker/vitest.config.ts +31 -0
  12. package/apps/cf-worker/wrangler.test.toml +33 -0
  13. package/apps/cf-worker/wrangler.toml +98 -0
  14. package/apps/local-cli/package.json +35 -0
  15. package/apps/local-cli/src/line-scanner.ts +60 -0
  16. package/apps/local-cli/src/main.ts +145 -0
  17. package/apps/local-cli/src/motd-file.ts +45 -0
  18. package/apps/local-cli/src/server.ts +409 -0
  19. package/apps/local-cli/tests/e2e.test.ts +346 -0
  20. package/apps/local-cli/tests/id-factory.test.ts +25 -0
  21. package/apps/local-cli/tests/line-scanner.test.ts +81 -0
  22. package/apps/local-cli/tests/motd-file.test.ts +71 -0
  23. package/apps/local-cli/tests/tcp.test.ts +358 -0
  24. package/apps/local-cli/tsconfig.build.json +17 -0
  25. package/apps/local-cli/tsconfig.test.json +15 -0
  26. package/apps/local-cli/vitest.config.ts +24 -0
  27. package/biome.json +52 -0
  28. package/package.json +35 -0
  29. package/packages/cf-adapter/package.json +38 -0
  30. package/packages/cf-adapter/src/cf-runtime.ts +308 -0
  31. package/packages/cf-adapter/src/channel-do.ts +374 -0
  32. package/packages/cf-adapter/src/connection-do.ts +542 -0
  33. package/packages/cf-adapter/src/env.ts +67 -0
  34. package/packages/cf-adapter/src/index.ts +38 -0
  35. package/packages/cf-adapter/src/registry-do.ts +130 -0
  36. package/packages/cf-adapter/src/serialize.ts +142 -0
  37. package/packages/cf-adapter/src/sharding.ts +101 -0
  38. package/packages/cf-adapter/tests/cf-harness.ts +264 -0
  39. package/packages/cf-adapter/tests/cf-integration.test.ts +73 -0
  40. package/packages/cf-adapter/tests/cf-runtime.test.ts +527 -0
  41. package/packages/cf-adapter/tests/channel-do.test.ts +480 -0
  42. package/packages/cf-adapter/tests/connection-do.test.ts +329 -0
  43. package/packages/cf-adapter/tests/integration/harness.test.ts +229 -0
  44. package/packages/cf-adapter/tests/integration/harness.ts +102 -0
  45. package/packages/cf-adapter/tests/integration/in-memory-harness.ts +242 -0
  46. package/packages/cf-adapter/tests/integration/index.ts +17 -0
  47. package/packages/cf-adapter/tests/integration/scenarios.test.ts +20 -0
  48. package/packages/cf-adapter/tests/integration/scenarios.ts +495 -0
  49. package/packages/cf-adapter/tests/registry-do.test.ts +335 -0
  50. package/packages/cf-adapter/tests/sharding.test.ts +100 -0
  51. package/packages/cf-adapter/tests/worker/main.ts +33 -0
  52. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +50 -0
  53. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +57 -0
  54. package/packages/cf-adapter/tsconfig.build.json +16 -0
  55. package/packages/cf-adapter/tsconfig.test.json +18 -0
  56. package/packages/cf-adapter/vitest.config.ts +32 -0
  57. package/packages/cf-adapter/wrangler.test.toml +50 -0
  58. package/packages/in-memory-runtime/package.json +29 -0
  59. package/packages/in-memory-runtime/src/in-memory-runtime.ts +245 -0
  60. package/packages/in-memory-runtime/src/index.ts +9 -0
  61. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +502 -0
  62. package/packages/in-memory-runtime/tsconfig.build.json +16 -0
  63. package/packages/in-memory-runtime/tsconfig.test.json +14 -0
  64. package/packages/in-memory-runtime/vitest.config.ts +21 -0
  65. package/packages/irc-core/package.json +29 -0
  66. package/packages/irc-core/src/caps/capabilities.ts +96 -0
  67. package/packages/irc-core/src/caps/index.ts +1 -0
  68. package/packages/irc-core/src/commands/away.ts +82 -0
  69. package/packages/irc-core/src/commands/cap.ts +257 -0
  70. package/packages/irc-core/src/commands/chghost.ts +30 -0
  71. package/packages/irc-core/src/commands/index.ts +40 -0
  72. package/packages/irc-core/src/commands/invite.ts +156 -0
  73. package/packages/irc-core/src/commands/isupport.ts +133 -0
  74. package/packages/irc-core/src/commands/join.ts +309 -0
  75. package/packages/irc-core/src/commands/kick.ts +162 -0
  76. package/packages/irc-core/src/commands/list.ts +126 -0
  77. package/packages/irc-core/src/commands/mode.ts +655 -0
  78. package/packages/irc-core/src/commands/motd.ts +146 -0
  79. package/packages/irc-core/src/commands/names.ts +164 -0
  80. package/packages/irc-core/src/commands/part.ts +118 -0
  81. package/packages/irc-core/src/commands/ping.ts +47 -0
  82. package/packages/irc-core/src/commands/privmsg.ts +256 -0
  83. package/packages/irc-core/src/commands/quit.ts +70 -0
  84. package/packages/irc-core/src/commands/registration.ts +251 -0
  85. package/packages/irc-core/src/commands/topic.ts +171 -0
  86. package/packages/irc-core/src/commands/who.ts +169 -0
  87. package/packages/irc-core/src/commands/whois.ts +165 -0
  88. package/packages/irc-core/src/effects.ts +184 -0
  89. package/packages/irc-core/src/index.ts +20 -0
  90. package/packages/irc-core/src/ports.ts +153 -0
  91. package/packages/irc-core/src/protocol/batch.ts +85 -0
  92. package/packages/irc-core/src/protocol/index.ts +18 -0
  93. package/packages/irc-core/src/protocol/messages.ts +36 -0
  94. package/packages/irc-core/src/protocol/numerics.ts +155 -0
  95. package/packages/irc-core/src/protocol/outbound.ts +145 -0
  96. package/packages/irc-core/src/protocol/parser.ts +175 -0
  97. package/packages/irc-core/src/protocol/serializer.ts +71 -0
  98. package/packages/irc-core/src/state/channel.ts +293 -0
  99. package/packages/irc-core/src/state/connection.ts +153 -0
  100. package/packages/irc-core/src/state/index.ts +25 -0
  101. package/packages/irc-core/src/state/registry.ts +22 -0
  102. package/packages/irc-core/src/types.ts +83 -0
  103. package/packages/irc-core/tests/batch.test.ts +79 -0
  104. package/packages/irc-core/tests/caps/capabilities.test.ts +148 -0
  105. package/packages/irc-core/tests/commands/away.test.ts +233 -0
  106. package/packages/irc-core/tests/commands/batch.test.ts +178 -0
  107. package/packages/irc-core/tests/commands/cap.test.ts +499 -0
  108. package/packages/irc-core/tests/commands/chghost.test.ts +186 -0
  109. package/packages/irc-core/tests/commands/echo-message.test.ts +212 -0
  110. package/packages/irc-core/tests/commands/extended-join.test.ts +147 -0
  111. package/packages/irc-core/tests/commands/invite-notify.test.ts +160 -0
  112. package/packages/irc-core/tests/commands/invite.test.ts +321 -0
  113. package/packages/irc-core/tests/commands/isupport.test.ts +209 -0
  114. package/packages/irc-core/tests/commands/join.test.ts +687 -0
  115. package/packages/irc-core/tests/commands/kick.test.ts +316 -0
  116. package/packages/irc-core/tests/commands/list.test.ts +452 -0
  117. package/packages/irc-core/tests/commands/mode.test.ts +1048 -0
  118. package/packages/irc-core/tests/commands/motd.test.ts +322 -0
  119. package/packages/irc-core/tests/commands/names.test.ts +342 -0
  120. package/packages/irc-core/tests/commands/part.test.ts +265 -0
  121. package/packages/irc-core/tests/commands/ping.test.ts +144 -0
  122. package/packages/irc-core/tests/commands/privmsg.test.ts +665 -0
  123. package/packages/irc-core/tests/commands/quit.test.ts +220 -0
  124. package/packages/irc-core/tests/commands/registration.test.ts +599 -0
  125. package/packages/irc-core/tests/commands/topic.test.ts +337 -0
  126. package/packages/irc-core/tests/commands/who.test.ts +441 -0
  127. package/packages/irc-core/tests/commands/whois.test.ts +422 -0
  128. package/packages/irc-core/tests/effects.test.ts +147 -0
  129. package/packages/irc-core/tests/message-tags.test.ts +182 -0
  130. package/packages/irc-core/tests/numerics.test.ts +98 -0
  131. package/packages/irc-core/tests/outbound.test.ts +232 -0
  132. package/packages/irc-core/tests/parser.test.ts +162 -0
  133. package/packages/irc-core/tests/ports.test.ts +101 -0
  134. package/packages/irc-core/tests/serializer.test.ts +164 -0
  135. package/packages/irc-core/tests/state/channel.test.ts +351 -0
  136. package/packages/irc-core/tests/state/connection.test.ts +122 -0
  137. package/packages/irc-core/tests/state/registry.test.ts +20 -0
  138. package/packages/irc-core/tests/types.test.ts +127 -0
  139. package/packages/irc-core/tsconfig.build.json +12 -0
  140. package/packages/irc-core/tsconfig.test.json +10 -0
  141. package/packages/irc-core/vitest.config.ts +21 -0
  142. package/packages/irc-server/package.json +28 -0
  143. package/packages/irc-server/src/actor.ts +449 -0
  144. package/packages/irc-server/src/dispatch.ts +120 -0
  145. package/packages/irc-server/src/index.ts +11 -0
  146. package/packages/irc-server/src/runtime.ts +61 -0
  147. package/packages/irc-server/tests/actor.test.ts +816 -0
  148. package/packages/irc-server/tests/dispatch.test.ts +512 -0
  149. package/packages/irc-server/tests/runtime.test.ts +52 -0
  150. package/packages/irc-server/tsconfig.build.json +13 -0
  151. package/packages/irc-server/tsconfig.test.json +11 -0
  152. package/packages/irc-server/vitest.config.ts +21 -0
  153. package/pnpm-workspace.yaml +4 -0
  154. package/tools/tcp-ws-forwarder/package.json +32 -0
  155. package/tools/tcp-ws-forwarder/src/forwarder.ts +244 -0
  156. package/tools/tcp-ws-forwarder/src/line-scanner.ts +55 -0
  157. package/tools/tcp-ws-forwarder/src/main.ts +114 -0
  158. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +618 -0
  159. package/tools/tcp-ws-forwarder/tests/framing.test.ts +51 -0
  160. package/tools/tcp-ws-forwarder/tests/line-scanner.test.ts +75 -0
  161. package/tools/tcp-ws-forwarder/tsconfig.build.json +12 -0
  162. package/tools/tcp-ws-forwarder/tsconfig.test.json +10 -0
  163. package/tools/tcp-ws-forwarder/vitest.config.ts +27 -0
  164. package/tsconfig.base.json +25 -0
  165. package/turbo.json +29 -0
@@ -0,0 +1,160 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { inviteReducer } from '../../src/commands/invite';
3
+ import { Effect } from '../../src/effects';
4
+ import type { Effect as EffectType, RawLine } from '../../src/effects';
5
+ import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
6
+ import { type ChannelState, createChannel } from '../../src/state/channel';
7
+ import { type ConnectionState, createConnection } from '../../src/state/connection';
8
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
9
+
10
+ const serverConfig: ServerConfig = {
11
+ serverName: 'irc.example.com',
12
+ networkName: 'ExampleNet',
13
+ maxChannelsPerUser: 30,
14
+ maxTargetsPerCommand: 10,
15
+ maxListEntries: 50,
16
+ nickLen: 30,
17
+ channelLen: 50,
18
+ topicLen: 390,
19
+ quitMessage: 'Client Quit',
20
+ };
21
+
22
+ function makeCtx(conn: ConnectionState, clock = new FakeClock(1_000)): Ctx {
23
+ return buildCtx({
24
+ serverConfig,
25
+ clock,
26
+ ids: new SequentialIdFactory(),
27
+ motd: EmptyMotdProvider,
28
+ connection: conn,
29
+ });
30
+ }
31
+
32
+ function makeConn(id = 'c1', nick = 'alice'): ConnectionState {
33
+ const s = createConnection({ id, connectedSince: 0 });
34
+ s.nick = nick;
35
+ s.user = nick;
36
+ s.host = 'example.com';
37
+ s.realname = nick.charAt(0).toUpperCase() + nick.slice(1);
38
+ s.registration = 'registered';
39
+ return s;
40
+ }
41
+
42
+ function makeChan(name = '#foo'): ChannelState {
43
+ return createChannel({ name, nameLower: name.toLowerCase(), createdAt: 0 });
44
+ }
45
+
46
+ function addMember(chan: ChannelState, connId: string, nick: string, op = false): void {
47
+ chan.members.set(connId, { conn: connId, nick, op, voice: false });
48
+ }
49
+
50
+ const L = (text: string): RawLine => ({ text });
51
+
52
+ // ============================================================================
53
+ // inviteReducer — invite-notify cap fanout
54
+ // ============================================================================
55
+
56
+ describe('inviteReducer — invite-notify cap fanout', () => {
57
+ it('broadcasts the INVITE line to invite-notify cap-enabled channel members', () => {
58
+ const chan = makeChan('#foo');
59
+ addMember(chan, 'c1', 'alice', true);
60
+ addMember(chan, 'c2', 'bob');
61
+ addMember(chan, 'c3', 'carol');
62
+ const conn = makeConn();
63
+ const ctx = makeCtx(conn);
64
+
65
+ const out = inviteReducer(chan, { command: 'INVITE', params: ['dave', '#foo'], tags: {} }, ctx);
66
+
67
+ // The 341 numeric and the SendToNick to the invitee come first; the
68
+ // invite-notify broadcast is the trailing effect.
69
+ const broadcast = out.effects.find(
70
+ (e): e is Extract<EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',
71
+ );
72
+ expect(broadcast).toBeDefined();
73
+ expect(broadcast?.cap).toBe('invite-notify');
74
+ expect(broadcast?.lines).toEqual<RawLine[]>([L(':alice!alice@example.com INVITE dave #foo')]);
75
+ expect(broadcast?.capLines).toBeUndefined();
76
+ });
77
+
78
+ it('emits exactly one INVITE to the invitee via SendToNick, regardless of invite-notify', () => {
79
+ const chan = makeChan('#foo');
80
+ addMember(chan, 'c1', 'alice', true);
81
+ addMember(chan, 'c2', 'bob'); // invite-notify peer on-channel
82
+ const conn = makeConn();
83
+ const ctx = makeCtx(conn);
84
+
85
+ const out = inviteReducer(chan, { command: 'INVITE', params: ['dave', '#foo'], tags: {} }, ctx);
86
+
87
+ const sendToNicks = out.effects.filter(
88
+ (e): e is Extract<EffectType, { tag: 'SendToNick' }> => e.tag === 'SendToNick',
89
+ );
90
+ expect(sendToNicks).toHaveLength(1);
91
+ expect(sendToNicks[0]?.nick).toBe('dave');
92
+ expect(sendToNicks[0]?.lines).toEqual<RawLine[]>([
93
+ L(':alice!alice@example.com INVITE dave #foo'),
94
+ ]);
95
+ });
96
+
97
+ it('places the invite-notify broadcast AFTER the SendToNick to the invitee', () => {
98
+ const chan = makeChan('#foo');
99
+ addMember(chan, 'c1', 'alice', true);
100
+ const conn = makeConn();
101
+ const ctx = makeCtx(conn);
102
+
103
+ const out = inviteReducer(chan, { command: 'INVITE', params: ['dave', '#foo'], tags: {} }, ctx);
104
+
105
+ const tags = out.effects.map((e) => e.tag);
106
+ const sendToNickIdx = tags.indexOf('SendToNick');
107
+ const broadcastIdx = tags.indexOf('Broadcast');
108
+ expect(sendToNickIdx).toBeGreaterThanOrEqual(0);
109
+ expect(broadcastIdx).toBeGreaterThan(sendToNickIdx);
110
+ });
111
+
112
+ it('does NOT emit an invite-notify broadcast when there are no other channel members', () => {
113
+ const chan = makeChan('#foo');
114
+ addMember(chan, 'c1', 'alice', true);
115
+ // No other members.
116
+ const conn = makeConn();
117
+ const ctx = makeCtx(conn);
118
+
119
+ const out = inviteReducer(chan, { command: 'INVITE', params: ['dave', '#foo'], tags: {} }, ctx);
120
+
121
+ const broadcasts = out.effects.filter(
122
+ (e): e is Extract<EffectType, { tag: 'Broadcast' }> => e.tag === 'Broadcast',
123
+ );
124
+ // The invite-notify broadcast is cap-only; with no peers to receive it
125
+ // the reducer still emits a (no-op) cap-only broadcast so dispatch can
126
+ // route per-recipient. The test asserts the cap is set correctly.
127
+ expect(broadcasts).toHaveLength(1);
128
+ expect(broadcasts[0]?.cap).toBe('invite-notify');
129
+ });
130
+
131
+ it('does not affect error paths (invitee already on channel)', () => {
132
+ const chan = makeChan('#foo');
133
+ addMember(chan, 'c1', 'alice', true);
134
+ addMember(chan, 'c2', 'bob');
135
+ const conn = makeConn();
136
+ const ctx = makeCtx(conn);
137
+
138
+ const out = inviteReducer(chan, { command: 'INVITE', params: ['bob', '#foo'], tags: {} }, ctx);
139
+
140
+ // 443 only; no SendToNick, no broadcast.
141
+ expect(out.effects).toEqual<EffectType[]>([
142
+ Effect.send('c1', [L(':irc.example.com 443 alice bob #foo :is already on channel')]),
143
+ ]);
144
+ });
145
+
146
+ it('does not affect the +i non-op rejection path', () => {
147
+ const chan = makeChan('#foo');
148
+ chan.modes.inviteOnly = true;
149
+ addMember(chan, 'c1', 'alice', false);
150
+ addMember(chan, 'c2', 'bob');
151
+ const conn = makeConn();
152
+ const ctx = makeCtx(conn);
153
+
154
+ const out = inviteReducer(chan, { command: 'INVITE', params: ['dave', '#foo'], tags: {} }, ctx);
155
+
156
+ expect(out.effects).toEqual<EffectType[]>([
157
+ Effect.send('c1', [L(":irc.example.com 482 alice #foo :You're not channel operator")]),
158
+ ]);
159
+ });
160
+ });
@@ -0,0 +1,321 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { inviteReducer } from '../../src/commands/invite';
3
+ import { Effect } from '../../src/effects';
4
+ import type { Effect as EffectType, RawLine } from '../../src/effects';
5
+ import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
6
+ import { type ChannelState, createChannel } from '../../src/state/channel';
7
+ import { type ConnectionState, createConnection } from '../../src/state/connection';
8
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
9
+
10
+ const serverConfig: ServerConfig = {
11
+ serverName: 'irc.example.com',
12
+ networkName: 'ExampleNet',
13
+ maxChannelsPerUser: 30,
14
+ maxTargetsPerCommand: 10,
15
+ maxListEntries: 50,
16
+ nickLen: 30,
17
+ channelLen: 50,
18
+ topicLen: 390,
19
+ quitMessage: 'Client Quit',
20
+ };
21
+
22
+ function makeCtx(conn: ConnectionState, clock = new FakeClock(1_000)): Ctx {
23
+ return buildCtx({
24
+ serverConfig,
25
+ clock,
26
+ ids: new SequentialIdFactory(),
27
+ motd: EmptyMotdProvider,
28
+ connection: conn,
29
+ });
30
+ }
31
+
32
+ function makeConn(id = 'c1', nick = 'alice'): ConnectionState {
33
+ const s = createConnection({ id, connectedSince: 0 });
34
+ s.nick = nick;
35
+ s.user = nick;
36
+ s.host = 'example.com';
37
+ s.realname = nick.charAt(0).toUpperCase() + nick.slice(1);
38
+ s.registration = 'registered';
39
+ return s;
40
+ }
41
+
42
+ function makeChan(name = '#foo'): ChannelState {
43
+ return createChannel({ name, nameLower: name.toLowerCase(), createdAt: 0 });
44
+ }
45
+
46
+ function addMember(chan: ChannelState, connId: string, nick: string, op = false): void {
47
+ chan.members.set(connId, { conn: connId, nick, op, voice: false });
48
+ }
49
+
50
+ const L = (text: string): RawLine => ({ text });
51
+
52
+ // ============================================================================
53
+ // inviteReducer — success
54
+ // ============================================================================
55
+
56
+ describe('inviteReducer — success', () => {
57
+ it('emits 341 RPL_INVITING to the inviter and an INVITE notice to the invitee', () => {
58
+ const chan = makeChan('#foo');
59
+ addMember(chan, 'c1', 'alice', true);
60
+ // bob is offline (not on the channel) — the canonical invite case.
61
+ const conn = makeConn();
62
+ const ctx = makeCtx(conn);
63
+
64
+ const out = inviteReducer(chan, { command: 'INVITE', params: ['bob', '#foo'], tags: {} }, ctx);
65
+
66
+ expect(out.effects).toEqual<EffectType[]>([
67
+ Effect.send('c1', [L(':irc.example.com 341 alice #foo bob')]),
68
+ Effect.sendToNick('bob', 'c1', [L(':alice!alice@example.com INVITE bob #foo')]),
69
+ Effect.broadcast(
70
+ '#foo',
71
+ [L(':alice!alice@example.com INVITE bob #foo')],
72
+ undefined,
73
+ 'invite-notify',
74
+ ),
75
+ ]);
76
+ });
77
+
78
+ it('records the pending invite in channel state so the invitee can bypass +i', () => {
79
+ const chan = makeChan('#foo');
80
+ chan.modes.inviteOnly = true;
81
+ addMember(chan, 'c1', 'alice', true);
82
+ const conn = makeConn();
83
+ const ctx = makeCtx(conn);
84
+
85
+ inviteReducer(chan, { command: 'INVITE', params: ['bob', '#foo'], tags: {} }, ctx);
86
+
87
+ expect(chan.pendingInvites.has('bob')).toBe(true);
88
+ });
89
+
90
+ it('allows inviting a nick that is not currently on the channel', () => {
91
+ const chan = makeChan('#foo');
92
+ addMember(chan, 'c1', 'alice', true);
93
+ const conn = makeConn();
94
+ const ctx = makeCtx(conn);
95
+
96
+ const out = inviteReducer(chan, { command: 'INVITE', params: ['bob', '#foo'], tags: {} }, ctx);
97
+
98
+ expect(out.effects).toEqual<EffectType[]>([
99
+ Effect.send('c1', [L(':irc.example.com 341 alice #foo bob')]),
100
+ Effect.sendToNick('bob', 'c1', [L(':alice!alice@example.com INVITE bob #foo')]),
101
+ Effect.broadcast(
102
+ '#foo',
103
+ [L(':alice!alice@example.com INVITE bob #foo')],
104
+ undefined,
105
+ 'invite-notify',
106
+ ),
107
+ ]);
108
+ });
109
+
110
+ it('matches the invitee nick case-insensitively', () => {
111
+ const chan = makeChan('#foo');
112
+ addMember(chan, 'c1', 'alice', true);
113
+ const conn = makeConn();
114
+ const ctx = makeCtx(conn);
115
+
116
+ const out = inviteReducer(chan, { command: 'INVITE', params: ['BOB', '#foo'], tags: {} }, ctx);
117
+
118
+ expect(out.effects).toEqual<EffectType[]>([
119
+ Effect.send('c1', [L(':irc.example.com 341 alice #foo BOB')]),
120
+ Effect.sendToNick('BOB', 'c1', [L(':alice!alice@example.com INVITE BOB #foo')]),
121
+ Effect.broadcast(
122
+ '#foo',
123
+ [L(':alice!alice@example.com INVITE BOB #foo')],
124
+ undefined,
125
+ 'invite-notify',
126
+ ),
127
+ ]);
128
+ expect(chan.pendingInvites.has('bob')).toBe(true);
129
+ });
130
+
131
+ it('updates the connection lastSeen to ctx.clock.now()', () => {
132
+ const chan = makeChan('#foo');
133
+ addMember(chan, 'c1', 'alice', true);
134
+ const conn = makeConn();
135
+ const clock = new FakeClock(7_700);
136
+ const ctx = makeCtx(conn, clock);
137
+
138
+ inviteReducer(chan, { command: 'INVITE', params: ['bob', '#foo'], tags: {} }, ctx);
139
+
140
+ expect(conn.lastSeen).toBe(7_700);
141
+ });
142
+ });
143
+
144
+ // ============================================================================
145
+ // inviteReducer — errors
146
+ // ============================================================================
147
+
148
+ describe('inviteReducer — errors', () => {
149
+ it('emits 461 ERR_NEEDMOREPARAMS when the channel argument is missing', () => {
150
+ const chan = makeChan('#foo');
151
+ addMember(chan, 'c1', 'alice', true);
152
+ const conn = makeConn();
153
+ const ctx = makeCtx(conn);
154
+
155
+ const out = inviteReducer(chan, { command: 'INVITE', params: ['bob'], tags: {} }, ctx);
156
+
157
+ expect(out.effects).toEqual<EffectType[]>([
158
+ Effect.send('c1', [L(':irc.example.com 461 alice INVITE :Not enough parameters')]),
159
+ ]);
160
+ });
161
+
162
+ it('emits 461 ERR_NEEDMOREPARAMS when both arguments are missing', () => {
163
+ const chan = makeChan('#foo');
164
+ addMember(chan, 'c1', 'alice', true);
165
+ const conn = makeConn();
166
+ const ctx = makeCtx(conn);
167
+
168
+ const out = inviteReducer(chan, { command: 'INVITE', params: [], tags: {} }, ctx);
169
+
170
+ expect(out.effects).toEqual<EffectType[]>([
171
+ Effect.send('c1', [L(':irc.example.com 461 alice INVITE :Not enough parameters')]),
172
+ ]);
173
+ });
174
+
175
+ it('emits 403 ERR_NOSUCHCHANNEL for an invalid channel name', () => {
176
+ const chan = makeChan('foo');
177
+ addMember(chan, 'c1', 'alice', true);
178
+ const conn = makeConn();
179
+ const ctx = makeCtx(conn);
180
+
181
+ const out = inviteReducer(chan, { command: 'INVITE', params: ['bob', 'foo'], tags: {} }, ctx);
182
+
183
+ expect(out.effects).toEqual<EffectType[]>([
184
+ Effect.send('c1', [L(':irc.example.com 403 alice foo :No such channel')]),
185
+ ]);
186
+ });
187
+
188
+ it('emits 403 ERR_NOSUCHCHANNEL for an empty channel name', () => {
189
+ const chan = makeChan('#foo');
190
+ addMember(chan, 'c1', 'alice', true);
191
+ const conn = makeConn();
192
+ const ctx = makeCtx(conn);
193
+
194
+ const out = inviteReducer(chan, { command: 'INVITE', params: ['bob', ''], tags: {} }, ctx);
195
+
196
+ expect(out.effects).toEqual<EffectType[]>([
197
+ Effect.send('c1', [L(':irc.example.com 403 alice :No such channel')]),
198
+ ]);
199
+ });
200
+
201
+ it('emits 442 ERR_NOTONCHANNEL when the inviter is not on the channel', () => {
202
+ const chan = makeChan('#foo');
203
+ addMember(chan, 'c2', 'bob', true);
204
+ const conn = makeConn();
205
+ const ctx = makeCtx(conn);
206
+
207
+ const out = inviteReducer(
208
+ chan,
209
+ { command: 'INVITE', params: ['carol', '#foo'], tags: {} },
210
+ ctx,
211
+ );
212
+
213
+ expect(out.effects).toEqual<EffectType[]>([
214
+ Effect.send('c1', [L(":irc.example.com 442 alice #foo :You're not on that channel")]),
215
+ ]);
216
+ });
217
+
218
+ it('emits 482 ERR_CHANOPRIVSNEEDED when a non-op invites to a +i channel', () => {
219
+ const chan = makeChan('#foo');
220
+ chan.modes.inviteOnly = true;
221
+ addMember(chan, 'c1', 'alice', false);
222
+ addMember(chan, 'c2', 'bob');
223
+ const conn = makeConn();
224
+ const ctx = makeCtx(conn);
225
+
226
+ const out = inviteReducer(
227
+ chan,
228
+ { command: 'INVITE', params: ['carol', '#foo'], tags: {} },
229
+ ctx,
230
+ );
231
+
232
+ expect(out.effects).toEqual<EffectType[]>([
233
+ Effect.send('c1', [L(":irc.example.com 482 alice #foo :You're not channel operator")]),
234
+ ]);
235
+ expect(chan.pendingInvites.has('carol')).toBe(false);
236
+ });
237
+
238
+ it('allows a non-op to invite to a non-invite-only channel', () => {
239
+ const chan = makeChan('#foo');
240
+ addMember(chan, 'c1', 'alice', false);
241
+ const conn = makeConn();
242
+ const ctx = makeCtx(conn);
243
+
244
+ const out = inviteReducer(chan, { command: 'INVITE', params: ['bob', '#foo'], tags: {} }, ctx);
245
+
246
+ expect(out.effects).toEqual<EffectType[]>([
247
+ Effect.send('c1', [L(':irc.example.com 341 alice #foo bob')]),
248
+ Effect.sendToNick('bob', 'c1', [L(':alice!alice@example.com INVITE bob #foo')]),
249
+ Effect.broadcast(
250
+ '#foo',
251
+ [L(':alice!alice@example.com INVITE bob #foo')],
252
+ undefined,
253
+ 'invite-notify',
254
+ ),
255
+ ]);
256
+ });
257
+
258
+ it('emits 443 ERR_USERONCHANNEL when the invitee is already on the channel', () => {
259
+ const chan = makeChan('#foo');
260
+ addMember(chan, 'c1', 'alice', true);
261
+ addMember(chan, 'c2', 'bob');
262
+ const conn = makeConn();
263
+ const ctx = makeCtx(conn);
264
+
265
+ const out = inviteReducer(chan, { command: 'INVITE', params: ['bob', '#foo'], tags: {} }, ctx);
266
+
267
+ // Already a member → 443, no 341, no INVITE.
268
+ expect(out.effects).toEqual<EffectType[]>([
269
+ Effect.send('c1', [L(':irc.example.com 443 alice bob #foo :is already on channel')]),
270
+ ]);
271
+ expect(chan.pendingInvites.has('bob')).toBe(false);
272
+ });
273
+
274
+ it('emits 443 ERR_USERONCHANNEL when checking by case-insensitive nick match', () => {
275
+ const chan = makeChan('#foo');
276
+ addMember(chan, 'c1', 'alice', true);
277
+ addMember(chan, 'c2', 'Bob');
278
+ const conn = makeConn();
279
+ const ctx = makeCtx(conn);
280
+
281
+ const out = inviteReducer(chan, { command: 'INVITE', params: ['BOB', '#foo'], tags: {} }, ctx);
282
+
283
+ expect(out.effects).toEqual<EffectType[]>([
284
+ Effect.send('c1', [L(':irc.example.com 443 alice BOB #foo :is already on channel')]),
285
+ ]);
286
+ });
287
+ });
288
+
289
+ // ============================================================================
290
+ // inviteReducer — defensive paths
291
+ // ============================================================================
292
+
293
+ describe('inviteReducer — defensive', () => {
294
+ it('uses * in error replies when the connection has no nick', () => {
295
+ const chan = makeChan('#foo');
296
+ addMember(chan, 'c1', 'alice', true);
297
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
298
+ const ctx = makeCtx(conn);
299
+
300
+ const out = inviteReducer(chan, { command: 'INVITE', params: ['bob'], tags: {} }, ctx);
301
+
302
+ expect(out.effects).toEqual<EffectType[]>([
303
+ Effect.send('c1', [L(':irc.example.com 461 * INVITE :Not enough parameters')]),
304
+ ]);
305
+ });
306
+
307
+ it('uses ? fallback for the source hostmask when nick is undefined', () => {
308
+ const chan = makeChan('#foo');
309
+ addMember(chan, 'c1', 'alice', true);
310
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
311
+ const ctx = makeCtx(conn);
312
+
313
+ const out = inviteReducer(chan, { command: 'INVITE', params: ['bob', '#foo'], tags: {} }, ctx);
314
+
315
+ expect(out.effects).toEqual<EffectType[]>([
316
+ Effect.send('c1', [L(':irc.example.com 341 * #foo bob')]),
317
+ Effect.sendToNick('bob', 'c1', [L(':? INVITE bob #foo')]),
318
+ Effect.broadcast('#foo', [L(':? INVITE bob #foo')], undefined, 'invite-notify'),
319
+ ]);
320
+ });
321
+ });
@@ -0,0 +1,209 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { generateIsupport } from '../../src/commands/isupport';
3
+ import type { ServerConfig } from '../../src/types';
4
+
5
+ function makeConfig(overrides: Partial<ServerConfig> = {}): ServerConfig {
6
+ return {
7
+ serverName: 'irc.example.com',
8
+ networkName: 'ExampleNet',
9
+ maxChannelsPerUser: 30,
10
+ maxTargetsPerCommand: 10,
11
+ maxListEntries: 50,
12
+ nickLen: 30,
13
+ channelLen: 50,
14
+ topicLen: 390,
15
+ quitMessage: 'Client Quit',
16
+ ...overrides,
17
+ };
18
+ }
19
+
20
+ // ============================================================================
21
+ // generateIsupport — token set
22
+ // ============================================================================
23
+
24
+ describe('generateIsupport — token set', () => {
25
+ it('includes the canonical RFC 2812/IRCv3 base tokens', () => {
26
+ const lines = generateIsupport(makeConfig(), new Set<string>());
27
+ const all = lines.flatMap((l) => l.text.split(' '));
28
+
29
+ expect(all.some((t) => t.startsWith('CHANTYPES='))).toBe(true);
30
+ expect(all.some((t) => t.startsWith('CHANMODES='))).toBe(true);
31
+ expect(all.some((t) => t.startsWith('PREFIX='))).toBe(true);
32
+ expect(all.some((t) => t.startsWith('MODES='))).toBe(true);
33
+ expect(all.some((t) => t.startsWith('NICKLEN='))).toBe(true);
34
+ expect(all.some((t) => t.startsWith('CHANNELLEN='))).toBe(true);
35
+ expect(all.some((t) => t.startsWith('TOPICLEN='))).toBe(true);
36
+ expect(all.some((t) => t.startsWith('CASEMAPPING='))).toBe(true);
37
+ expect(all.some((t) => t.startsWith('NETWORK='))).toBe(true);
38
+ expect(all.some((t) => t.startsWith('MAXCHANNELS='))).toBe(true);
39
+ expect(all.some((t) => t.startsWith('MAXTARGETS='))).toBe(true);
40
+ });
41
+
42
+ it('ends the final line with the `:are supported by this server` trailer', () => {
43
+ const lines = generateIsupport(makeConfig(), new Set<string>());
44
+ const last = lines.at(-1);
45
+ expect(last?.text.endsWith(':are supported by this server')).toBe(true);
46
+ });
47
+
48
+ it('produces at least one 005 line', () => {
49
+ const lines = generateIsupport(makeConfig(), new Set<string>());
50
+ expect(lines.length).toBeGreaterThanOrEqual(1);
51
+ expect(lines[0]?.text).toMatch(/ 005 /u);
52
+ });
53
+
54
+ it('derives token values from serverConfig rather than hard-coding', () => {
55
+ const cfg = makeConfig({ nickLen: 15, channelLen: 64, topicLen: 300, networkName: 'Zeta' });
56
+ const lines = generateIsupport(cfg, new Set<string>());
57
+ const all = lines.flatMap((l) => l.text.split(' '));
58
+
59
+ expect(all).toContain('NICKLEN=15');
60
+ expect(all).toContain('CHANNELLEN=64');
61
+ expect(all).toContain('TOPICLEN=300');
62
+ expect(all).toContain('NETWORK=Zeta');
63
+ });
64
+ });
65
+
66
+ // ============================================================================
67
+ // generateIsupport — token values
68
+ // ============================================================================
69
+
70
+ describe('generateIsupport — token values', () => {
71
+ it('emits CHANTYPES=#', () => {
72
+ const lines = generateIsupport(makeConfig(), new Set<string>());
73
+ const all = lines.flatMap((l) => l.text.split(' '));
74
+ expect(all).toContain('CHANTYPES=#');
75
+ });
76
+
77
+ it('emits PREFIX=(ov)@+', () => {
78
+ const lines = generateIsupport(makeConfig(), new Set<string>());
79
+ const all = lines.flatMap((l) => l.text.split(' '));
80
+ expect(all).toContain('PREFIX=(ov)@+');
81
+ });
82
+
83
+ it('emits CASEMAPPING=rfc1459', () => {
84
+ const lines = generateIsupport(makeConfig(), new Set<string>());
85
+ const all = lines.flatMap((l) => l.text.split(' '));
86
+ expect(all).toContain('CASEMAPPING=rfc1459');
87
+ });
88
+
89
+ it('emits CHANMODES with the four comma-separated mode classes', () => {
90
+ const lines = generateIsupport(makeConfig(), new Set<string>());
91
+ const all = lines.flatMap((l) => l.text.split(' '));
92
+ const cm = all.find((t) => t.startsWith('CHANMODES='));
93
+ expect(cm).toBeDefined();
94
+ // Class A (list), B (param-set), C (param-always), D (boolean).
95
+ expect(cm?.split('=').at(1)?.split(',').length).toBe(4);
96
+ // Our server supports: list=b, key=k, limit=l, bool=imnpst.
97
+ expect(cm).toContain('b');
98
+ expect(cm).toContain('k');
99
+ expect(cm).toContain('l');
100
+ expect(cm).toContain('i');
101
+ expect(cm).toContain('t');
102
+ expect(cm).toContain('n');
103
+ expect(cm).toContain('m');
104
+ expect(cm).toContain('s');
105
+ expect(cm).toContain('p');
106
+ });
107
+
108
+ it('emits MODES=4 (max mode changes per command)', () => {
109
+ const lines = generateIsupport(makeConfig(), new Set<string>());
110
+ const all = lines.flatMap((l) => l.text.split(' '));
111
+ expect(all).toContain('MODES=4');
112
+ });
113
+
114
+ it('emits MAXCHANNELS from serverConfig.maxChannelsPerUser', () => {
115
+ const lines = generateIsupport(makeConfig({ maxChannelsPerUser: 20 }), new Set<string>());
116
+ const all = lines.flatMap((l) => l.text.split(' '));
117
+ expect(all).toContain('MAXCHANNELS=20');
118
+ });
119
+
120
+ it('emits MAXTARGETS from serverConfig.maxTargetsPerCommand', () => {
121
+ const lines = generateIsupport(makeConfig({ maxTargetsPerCommand: 4 }), new Set<string>());
122
+ const all = lines.flatMap((l) => l.text.split(' '));
123
+ expect(all).toContain('MAXTARGETS=4');
124
+ });
125
+
126
+ it('advertises the bare SAFELIST token (LIST is non-destructive)', () => {
127
+ const lines = generateIsupport(makeConfig(), new Set<string>());
128
+ const all = lines.flatMap((l) => l.text.split(' '));
129
+ expect(all).toContain('SAFELIST');
130
+ // SAFELIST is a boolean token (no `=` value).
131
+ expect(all.some((t) => t.startsWith('SAFELIST='))).toBe(false);
132
+ });
133
+
134
+ it('advertises SAFELIST regardless of negotiated caps (ISUPPORT is global)', () => {
135
+ const lines = generateIsupport(makeConfig(), new Set<string>(['safelist']));
136
+ const all = lines.flatMap((l) => l.text.split(' '));
137
+ expect(all).toContain('SAFELIST');
138
+
139
+ const linesNoCap = generateIsupport(makeConfig(), new Set<string>());
140
+ const allNoCap = linesNoCap.flatMap((l) => l.text.split(' '));
141
+ expect(allNoCap).toContain('SAFELIST');
142
+ });
143
+ });
144
+
145
+ // ============================================================================
146
+ // generateIsupport — line splitting
147
+ // ============================================================================
148
+
149
+ describe('generateIsupport — line splitting', () => {
150
+ it('splits tokens across multiple 005 lines when the total would exceed 510 bytes', () => {
151
+ // The realistic trigger is a long NETWORK name: NETWORK=<long> is one
152
+ // of the longer tokens, and packing it with the rest would exceed 510.
153
+ // We use a name that pushes the total past the ceiling but keeps every
154
+ // individual token short enough to fit on its own line.
155
+ const longName = `${'Z'.repeat(450)}`;
156
+ const lines = generateIsupport(makeConfig({ networkName: longName }), new Set<string>());
157
+
158
+ expect(lines.length).toBeGreaterThan(1);
159
+ for (const line of lines) {
160
+ // Each wire line is bounded by 510 bytes (incl. trailing \r\n).
161
+ expect(line.text.length).toBeLessThanOrEqual(510);
162
+ }
163
+ // Only the last line has the trailer.
164
+ const trailers = lines.filter((l) => l.text.endsWith(':are supported by this server'));
165
+ expect(trailers.length).toBe(1);
166
+ });
167
+
168
+ it('places all tokens before the trailing `:are supported by this server`', () => {
169
+ const lines = generateIsupport(makeConfig(), new Set<string>());
170
+ const last = lines.at(-1)?.text ?? '';
171
+ // Strip the leading `:<server> 005 <nick> ` prefix, then check the
172
+ // token region (between prefix and trailer) contains no stray colons.
173
+ const withoutPrefix = last.replace(/^:\S+ 005 \S+ /u, '');
174
+ const beforeTrailer = withoutPrefix.split(':are supported by this server')[0];
175
+ expect(beforeTrailer).not.toContain(':');
176
+ });
177
+
178
+ it('labels every line with the 005 numeric and the connection nick', () => {
179
+ const lines = generateIsupport(makeConfig(), new Set<string>(), 'alice');
180
+ for (const line of lines) {
181
+ expect(line.text).toMatch(/ 005 alice /u);
182
+ }
183
+ });
184
+
185
+ it('uses * as the nick when no nick is provided', () => {
186
+ const lines = generateIsupport(makeConfig(), new Set<string>());
187
+ for (const line of lines) {
188
+ expect(line.text).toMatch(/ 005 \* /u);
189
+ }
190
+ });
191
+ });
192
+
193
+ // ============================================================================
194
+ // generateIsupport — IRCv3 capability-aware tokens
195
+ // ============================================================================
196
+
197
+ describe('generateIsupport — capability-aware tokens', () => {
198
+ it('does not advertise BOT when the client lacks the bot-mode cap', () => {
199
+ const lines = generateIsupport(makeConfig(), new Set<string>());
200
+ const all = lines.flatMap((l) => l.text.split(' '));
201
+ expect(all.some((t) => t.startsWith('BOT='))).toBe(false);
202
+ });
203
+
204
+ it('returns an empty array when called with no config (defensive)', () => {
205
+ // Sanity: contract is "always returns at least one line for valid config".
206
+ const lines = generateIsupport(makeConfig(), new Set<string>());
207
+ expect(lines.length).toBeGreaterThan(0);
208
+ });
209
+ });