serverless-ircd 0.7.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 (168) hide show
  1. package/.github/workflows/ci.yml +7 -3
  2. package/.gitmodules +1 -1
  3. package/CHANGELOG.md +518 -29
  4. package/README.md +221 -161
  5. package/apps/aws-stack/package.json +1 -1
  6. package/apps/aws-stack/src/aws-stack.ts +186 -18
  7. package/apps/aws-stack/tests/stack.test.ts +400 -56
  8. package/apps/cf-tcp-container/package.json +1 -1
  9. package/apps/cf-tcp-container/src/container-server.ts +21 -1
  10. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  11. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  12. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  13. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  14. package/apps/cf-worker/package.json +1 -1
  15. package/apps/cf-worker/src/worker.ts +4 -4
  16. package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
  17. package/apps/cf-worker/tests/smoke.test.ts +5 -5
  18. package/apps/cf-worker/wrangler.test.toml +6 -6
  19. package/apps/cf-worker/wrangler.toml +7 -5
  20. package/apps/local-cli/package.json +1 -1
  21. package/apps/local-cli/src/config-loader.ts +8 -0
  22. package/apps/local-cli/src/main.ts +16 -0
  23. package/apps/local-cli/src/server.ts +95 -31
  24. package/apps/local-cli/tests/config-loader.test.ts +14 -0
  25. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  26. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  27. package/apps/local-cli/tests/rehash.test.ts +147 -0
  28. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  29. package/apps/local-cli/tests/tcp.test.ts +89 -0
  30. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  31. package/apps/web/landing/index.html +237 -16
  32. package/apps/web/package.json +3 -2
  33. package/apps/web/scripts/build.mjs +46 -16
  34. package/apps/web/src/build-env.ts +1 -1
  35. package/apps/web/src/config-schema.ts +6 -6
  36. package/apps/web/src/render-docs.ts +292 -0
  37. package/apps/web/tests/build-smoke.test.ts +43 -14
  38. package/apps/web/tests/config-schema.test.ts +1 -1
  39. package/apps/web/tests/landing-content.test.ts +103 -0
  40. package/apps/web/tests/render-docs.test.ts +198 -0
  41. package/docs/AWS-Adapter-Architecture.md +3 -2
  42. package/docs/AWS-Deployment.md +21 -8
  43. package/docs/Cloudflare-Deployment-Guide.md +22 -6
  44. package/docs/PlanExtensions.md +113 -3
  45. package/docs/Release-Process.md +23 -13
  46. package/docs/Services.md +578 -0
  47. package/docs/WebClientGuide.md +32 -31
  48. package/package.json +2 -2
  49. package/packages/aws-adapter/package.json +1 -1
  50. package/packages/aws-adapter/src/aws-runtime.ts +20 -1
  51. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
  52. package/packages/aws-adapter/src/config-loader.ts +11 -0
  53. package/packages/aws-adapter/src/connection-counter.ts +89 -0
  54. package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
  55. package/packages/aws-adapter/src/handlers/connect.ts +55 -51
  56. package/packages/aws-adapter/src/handlers/default.ts +36 -4
  57. package/packages/aws-adapter/src/handlers/index.ts +15 -0
  58. package/packages/aws-adapter/src/handlers/nlb-stream.ts +25 -2
  59. package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
  60. package/packages/aws-adapter/src/index.ts +4 -0
  61. package/packages/aws-adapter/src/stats.ts +6 -1
  62. package/packages/aws-adapter/src/tables.ts +34 -4
  63. package/packages/aws-adapter/tests/aws-harness.ts +3 -0
  64. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  65. package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
  66. package/packages/aws-adapter/tests/connect.test.ts +158 -32
  67. package/packages/aws-adapter/tests/connection-counter.test.ts +144 -0
  68. package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
  69. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
  70. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  71. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  72. package/packages/aws-adapter/tests/handlers.test.ts +105 -3
  73. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  74. package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
  75. package/packages/aws-adapter/tests/tables.test.ts +6 -1
  76. package/packages/cf-adapter/package.json +1 -1
  77. package/packages/cf-adapter/src/config-loader.ts +11 -0
  78. package/packages/cf-adapter/src/connection-do.ts +130 -8
  79. package/packages/cf-adapter/src/d1-services-store.ts +703 -0
  80. package/packages/cf-adapter/src/env.ts +8 -0
  81. package/packages/cf-adapter/src/index.ts +5 -0
  82. package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
  83. package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
  84. package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
  85. package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
  86. package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
  87. package/packages/cf-adapter/tests/serialize.test.ts +1 -0
  88. package/packages/in-memory-runtime/package.json +1 -1
  89. package/packages/irc-core/package.json +1 -1
  90. package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
  91. package/packages/irc-core/src/commands/account-auth.ts +200 -0
  92. package/packages/irc-core/src/commands/chanserv.ts +1166 -0
  93. package/packages/irc-core/src/commands/hostserv.ts +522 -0
  94. package/packages/irc-core/src/commands/index.ts +13 -0
  95. package/packages/irc-core/src/commands/join.ts +171 -9
  96. package/packages/irc-core/src/commands/markread.ts +202 -0
  97. package/packages/irc-core/src/commands/memoserv.ts +319 -0
  98. package/packages/irc-core/src/commands/mode.ts +96 -4
  99. package/packages/irc-core/src/commands/nickserv.ts +402 -0
  100. package/packages/irc-core/src/commands/oper.ts +18 -1
  101. package/packages/irc-core/src/commands/operserv.ts +346 -0
  102. package/packages/irc-core/src/commands/pre-away.ts +3 -1
  103. package/packages/irc-core/src/commands/privmsg.ts +42 -0
  104. package/packages/irc-core/src/commands/read-marker.ts +8 -8
  105. package/packages/irc-core/src/commands/registration.ts +78 -12
  106. package/packages/irc-core/src/commands/sasl.ts +18 -49
  107. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  108. package/packages/irc-core/src/commands/tagmsg.ts +41 -6
  109. package/packages/irc-core/src/commands/topic.ts +60 -10
  110. package/packages/irc-core/src/config.ts +36 -5
  111. package/packages/irc-core/src/effects.ts +56 -1
  112. package/packages/irc-core/src/ports.ts +1653 -84
  113. package/packages/irc-core/src/protocol/numerics.ts +8 -0
  114. package/packages/irc-core/src/state/channel.ts +38 -1
  115. package/packages/irc-core/src/state/connection.ts +25 -1
  116. package/packages/irc-core/src/types.ts +48 -12
  117. package/packages/irc-core/tests/commands/chanserv.test.ts +2335 -0
  118. package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
  119. package/packages/irc-core/tests/commands/hostserv.test.ts +1006 -0
  120. package/packages/irc-core/tests/commands/join.test.ts +572 -1
  121. package/packages/irc-core/tests/commands/markread.test.ts +361 -0
  122. package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
  123. package/packages/irc-core/tests/commands/mode.test.ts +381 -2
  124. package/packages/irc-core/tests/commands/nickserv.test.ts +990 -0
  125. package/packages/irc-core/tests/commands/oper.test.ts +13 -0
  126. package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
  127. package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
  128. package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
  129. package/packages/irc-core/tests/commands/registration.test.ts +1015 -20
  130. package/packages/irc-core/tests/commands/sasl.test.ts +229 -12
  131. package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
  132. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  133. package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
  134. package/packages/irc-core/tests/commands/topic.test.ts +94 -2
  135. package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
  136. package/packages/irc-core/tests/config.test.ts +49 -5
  137. package/packages/irc-core/tests/effects.test.ts +19 -0
  138. package/packages/irc-core/tests/message-store.test.ts +63 -0
  139. package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
  140. package/packages/irc-core/tests/services-store.test.ts +1289 -0
  141. package/packages/irc-core/tests/state/channel.test.ts +3 -0
  142. package/packages/irc-server/package.json +1 -1
  143. package/packages/irc-server/src/actor.ts +121 -16
  144. package/packages/irc-server/src/dispatch.ts +94 -7
  145. package/packages/irc-server/src/routing.ts +19 -0
  146. package/packages/irc-server/tests/actor.test.ts +988 -15
  147. package/packages/irc-server/tests/dispatch.test.ts +270 -2
  148. package/packages/irc-server/tests/routing.test.ts +6 -0
  149. package/packages/irc-test-support/package.json +1 -1
  150. package/packages/irc-test-support/src/in-memory-harness.ts +37 -8
  151. package/packages/irc-test-support/src/index.ts +1 -0
  152. package/packages/irc-test-support/src/scenarios.ts +21 -6
  153. package/packages/irc-test-support/tests/in-memory-harness.test.ts +51 -0
  154. package/packages/irc-test-support/vitest.config.ts +6 -1
  155. package/tools/ci-hardening/package.json +1 -1
  156. package/tools/load-test/package.json +1 -1
  157. package/tools/load-test/src/client.ts +13 -13
  158. package/tools/load-test/tests/client.test.ts +258 -2
  159. package/tools/load-test/tests/config.test.ts +39 -0
  160. package/tools/load-test/tests/harness.test.ts +21 -0
  161. package/tools/load-test/tests/metrics.test.ts +7 -0
  162. package/tools/tcp-ws-forwarder/package.json +1 -1
  163. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  164. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  165. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +53 -2
  166. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  167. package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
  168. package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
@@ -0,0 +1,1006 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { emitChghost } from '../../src/commands/chghost';
3
+ import { HOSTSERV_NICK, hostservReducer } from '../../src/commands/hostserv';
4
+ import type { Effect as EffectType } from '../../src/effects';
5
+ import type { ServicesStore } from '../../src/ports';
6
+ import {
7
+ EmptyMotdProvider,
8
+ FakeClock,
9
+ InMemoryServicesStore,
10
+ SequentialIdFactory,
11
+ } from '../../src/ports';
12
+ import type { IrcMessage } from '../../src/protocol/messages';
13
+ import { type ConnectionState, createConnection } from '../../src/state/connection';
14
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
15
+
16
+ const NOW = 1_700_000_000_000;
17
+
18
+ const serverConfig: ServerConfig = {
19
+ serverName: 'irc.example.com',
20
+ networkName: 'ExampleNet',
21
+ maxChannelsPerUser: 30,
22
+ maxTargetsPerCommand: 10,
23
+ maxListEntries: 50,
24
+ nickLen: 30,
25
+ channelLen: 50,
26
+ topicLen: 390,
27
+ quitMessage: 'Client Quit',
28
+ };
29
+
30
+ /** ServerConfig with the HostServ oper-approval queue enabled. */
31
+ const queueServerConfig: ServerConfig = {
32
+ ...serverConfig,
33
+ hostservAutoApproveVhosts: false,
34
+ };
35
+
36
+ /** ServerConfig with HostServ auto-approve explicitly on. */
37
+ const autoApproveServerConfig: ServerConfig = {
38
+ ...serverConfig,
39
+ hostservAutoApproveVhosts: true,
40
+ };
41
+
42
+ function makeConn(id = 'c1', nick = 'alice'): ConnectionState {
43
+ const s = createConnection({ id, connectedSince: 0 });
44
+ s.nick = nick;
45
+ s.user = nick;
46
+ s.host = 'real.cloak.example.net';
47
+ s.realname = nick;
48
+ s.registration = 'registered';
49
+ return s;
50
+ }
51
+
52
+ function makeCtx(
53
+ state: ConnectionState,
54
+ services?: ServicesStore,
55
+ clock = new FakeClock(NOW),
56
+ cfg: ServerConfig = serverConfig,
57
+ ): Ctx {
58
+ return buildCtx({
59
+ serverConfig: cfg,
60
+ clock,
61
+ ids: new SequentialIdFactory(),
62
+ motd: EmptyMotdProvider,
63
+ connection: state,
64
+ ...(services !== undefined ? { services } : {}),
65
+ });
66
+ }
67
+
68
+ function privmsg(body: string): IrcMessage {
69
+ return { command: 'PRIVMSG', params: [HOSTSERV_NICK, body], tags: {} };
70
+ }
71
+
72
+ /** Extracts the text of every Send line emitted by the reducer. */
73
+ function sentTexts(effects: EffectType[]): string[] {
74
+ return effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
75
+ }
76
+
77
+ /** Builds an identified connection for the given account. */
78
+ function identifiedConn(account: string, id = 'c1', nick = account): ConnectionState {
79
+ const conn = makeConn(id, nick);
80
+ conn.account = account;
81
+ conn.userModes.registered = true;
82
+ return conn;
83
+ }
84
+
85
+ /** Builds an oper + identified connection. */
86
+ function operConn(account: string, id = 'c1', nick = account): ConnectionState {
87
+ const conn = identifiedConn(account, id, nick);
88
+ conn.userModes.oper = true;
89
+ return conn;
90
+ }
91
+
92
+ describe('hostservReducer — dispatch', () => {
93
+ it('exports the canonical HostServ pseudo-client nick', () => {
94
+ expect(HOSTSERV_NICK).toBe('HostServ');
95
+ });
96
+
97
+ it('returns a help NOTICE when no body is supplied', () => {
98
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
99
+ const conn = makeConn();
100
+ const ctx = makeCtx(conn, services);
101
+
102
+ const out = hostservReducer(
103
+ conn,
104
+ { command: 'PRIVMSG', params: [HOSTSERV_NICK], tags: {} },
105
+ ctx,
106
+ );
107
+
108
+ expect(sentTexts(out.effects).some((t) => t.includes('Available commands'))).toBe(true);
109
+ });
110
+
111
+ it('returns a help NOTICE when the body is only whitespace', () => {
112
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
113
+ const conn = makeConn();
114
+ const ctx = makeCtx(conn, services);
115
+
116
+ const out = hostservReducer(conn, privmsg(' '), ctx);
117
+
118
+ expect(sentTexts(out.effects).some((t) => t.includes('Available commands'))).toBe(true);
119
+ });
120
+
121
+ it('returns a NOTICE when no services store is bound', () => {
122
+ const conn = makeConn();
123
+ const ctx = makeCtx(conn);
124
+
125
+ const out = hostservReducer(conn, privmsg('ON'), ctx);
126
+
127
+ expect(sentTexts(out.effects)).toContain(
128
+ ':HostServ!HostServ@services NOTICE alice :Services are not available on this server.',
129
+ );
130
+ });
131
+
132
+ it('emits an unknown-command NOTICE for an unrecognised subcommand', () => {
133
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
134
+ const conn = makeConn();
135
+ const ctx = makeCtx(conn, services);
136
+
137
+ const out = hostservReducer(conn, privmsg('FROBNICATE'), ctx);
138
+
139
+ expect(sentTexts(out.effects).some((t) => t.includes('Unknown command'))).toBe(true);
140
+ });
141
+
142
+ it('hides oper-only commands from a non-oper on empty body', () => {
143
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
144
+ const conn = identifiedConn('bob');
145
+ const ctx = makeCtx(conn, services);
146
+
147
+ const out = hostservReducer(conn, privmsg(''), ctx);
148
+
149
+ const lines = sentTexts(out.effects);
150
+ expect(lines).toContain(
151
+ ':HostServ!HostServ@services NOTICE bob :Available commands: ON, OFF, REQUEST',
152
+ );
153
+ const helpLine = lines.find((t) => t.includes('Available commands'));
154
+ for (const cmd of ['SET', 'APPROVE', 'REJECT', 'LIST']) {
155
+ expect(helpLine).not.toContain(cmd);
156
+ }
157
+ });
158
+
159
+ it('lists the full command set to an oper on empty body', () => {
160
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
161
+ const conn = operConn('admin');
162
+ const ctx = makeCtx(conn, services);
163
+
164
+ const out = hostservReducer(conn, privmsg(''), ctx);
165
+
166
+ expect(sentTexts(out.effects)).toContain(
167
+ ':HostServ!HostServ@services NOTICE admin :Available commands: ON, OFF, REQUEST, SET, APPROVE, REJECT, LIST',
168
+ );
169
+ });
170
+
171
+ it('hides oper-only commands from a non-oper on an unknown subcommand', () => {
172
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
173
+ const conn = identifiedConn('bob');
174
+ const ctx = makeCtx(conn, services);
175
+
176
+ const out = hostservReducer(conn, privmsg('FOOBAR'), ctx);
177
+
178
+ const lines = sentTexts(out.effects);
179
+ expect(lines).toContain(
180
+ ':HostServ!HostServ@services NOTICE bob :Unknown command. Available: ON, OFF, REQUEST',
181
+ );
182
+ const unknownLine = lines.find((t) => t.includes('Unknown command'));
183
+ for (const cmd of ['SET', 'APPROVE', 'REJECT', 'LIST']) {
184
+ expect(unknownLine).not.toContain(cmd);
185
+ }
186
+ });
187
+
188
+ it('lists the full command set to an oper on an unknown subcommand', () => {
189
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
190
+ const conn = operConn('admin');
191
+ const ctx = makeCtx(conn, services);
192
+
193
+ const out = hostservReducer(conn, privmsg('FOOBAR'), ctx);
194
+
195
+ expect(sentTexts(out.effects)).toContain(
196
+ ':HostServ!HostServ@services NOTICE admin :Unknown command. Available: ON, OFF, REQUEST, SET, APPROVE, REJECT, LIST',
197
+ );
198
+ });
199
+
200
+ it('still rejects a non-oper who guesses an oper-only command (LIST)', () => {
201
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
202
+ services.requestVhost('alice', 'cool.example.net');
203
+ const conn = identifiedConn('bob');
204
+ const ctx = makeCtx(conn, services);
205
+
206
+ const out = hostservReducer(conn, privmsg('LIST'), ctx);
207
+
208
+ expect(sentTexts(out.effects)).toContain(
209
+ ':HostServ!HostServ@services NOTICE bob :You must be an oper to use LIST.',
210
+ );
211
+ });
212
+
213
+ it('updates lastSeen to ctx.clock.now()', () => {
214
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
215
+ const conn = identifiedConn('alice');
216
+ const ctx = makeCtx(conn, services, new FakeClock(NOW + 500));
217
+
218
+ hostservReducer(conn, privmsg('ON'), ctx);
219
+
220
+ expect(conn.lastSeen).toBe(NOW + 500);
221
+ });
222
+ });
223
+
224
+ // ============================================================================
225
+ // ON
226
+ // ============================================================================
227
+
228
+ describe('hostservReducer — ON', () => {
229
+ it('rejects ON when the connection is not identified', () => {
230
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
231
+ services.setVhost('alice', 'cool.example.net');
232
+ const conn = makeConn();
233
+ const ctx = makeCtx(conn, services);
234
+
235
+ const out = hostservReducer(conn, privmsg('ON'), ctx);
236
+
237
+ expect(sentTexts(out.effects)).toContain(
238
+ ':HostServ!HostServ@services NOTICE alice :You must identify before enabling a vhost.',
239
+ );
240
+ expect(conn.host).toBe('real.cloak.example.net');
241
+ expect(conn.vhostActive).toBeFalsy();
242
+ });
243
+
244
+ it('rejects ON when no vhost is recorded for the account', () => {
245
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
246
+ const conn = identifiedConn('alice');
247
+ const ctx = makeCtx(conn, services);
248
+
249
+ const out = hostservReducer(conn, privmsg('ON'), ctx);
250
+
251
+ expect(sentTexts(out.effects)).toContain(
252
+ ':HostServ!HostServ@services NOTICE alice :No vhost is assigned to your account.',
253
+ );
254
+ expect(conn.host).toBe('real.cloak.example.net');
255
+ expect(conn.vhostActive).toBeFalsy();
256
+ });
257
+
258
+ it('applies the vhost and emits CHGHOST to peers on ON', () => {
259
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
260
+ services.setVhost('alice', 'cool.example.net');
261
+ const conn = identifiedConn('alice');
262
+ conn.joinedChannels.add('#foo');
263
+ const ctx = makeCtx(conn, services);
264
+
265
+ const out = hostservReducer(conn, privmsg('ON'), ctx);
266
+
267
+ expect(conn.host).toBe('cool.example.net');
268
+ expect(conn.vhostActive).toBe(true);
269
+ expect(conn.preVhostHost).toBe('real.cloak.example.net');
270
+ expect(sentTexts(out.effects)).toContain(
271
+ ':HostServ!HostServ@services NOTICE alice :Your vhost is now active.',
272
+ );
273
+ const broadcasts = out.effects.filter((e) => e.tag === 'Broadcast');
274
+ expect(broadcasts.length).toBe(1);
275
+ });
276
+
277
+ it('is a no-op NOTICE when the vhost is already active', () => {
278
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
279
+ services.setVhost('alice', 'cool.example.net');
280
+ const conn = identifiedConn('alice');
281
+ conn.vhostActive = true;
282
+ conn.preVhostHost = 'real.cloak.example.net';
283
+ conn.host = 'cool.example.net';
284
+ const ctx = makeCtx(conn, services);
285
+
286
+ const out = hostservReducer(conn, privmsg('ON'), ctx);
287
+
288
+ expect(sentTexts(out.effects)).toContain(
289
+ ':HostServ!HostServ@services NOTICE alice :Your vhost is already active.',
290
+ );
291
+ expect(conn.host).toBe('cool.example.net');
292
+ });
293
+ });
294
+
295
+ // ============================================================================
296
+ // OFF
297
+ // ============================================================================
298
+
299
+ describe('hostservReducer — OFF', () => {
300
+ it('rejects OFF when the connection is not identified', () => {
301
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
302
+ const conn = makeConn();
303
+ conn.vhostActive = true;
304
+ conn.preVhostHost = 'real.cloak.example.net';
305
+ conn.host = 'cool.example.net';
306
+ const ctx = makeCtx(conn, services);
307
+
308
+ const out = hostservReducer(conn, privmsg('OFF'), ctx);
309
+
310
+ expect(sentTexts(out.effects)).toContain(
311
+ ':HostServ!HostServ@services NOTICE alice :You must identify before changing your vhost.',
312
+ );
313
+ expect(conn.host).toBe('cool.example.net');
314
+ });
315
+
316
+ it('restores the real host and emits CHGHOST on OFF', () => {
317
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
318
+ services.setVhost('alice', 'cool.example.net');
319
+ const conn = identifiedConn('alice');
320
+ conn.joinedChannels.add('#foo');
321
+ conn.vhostActive = true;
322
+ conn.preVhostHost = 'real.cloak.example.net';
323
+ conn.host = 'cool.example.net';
324
+ const ctx = makeCtx(conn, services);
325
+
326
+ const out = hostservReducer(conn, privmsg('OFF'), ctx);
327
+
328
+ expect(conn.host).toBe('real.cloak.example.net');
329
+ expect(conn.vhostActive).toBe(false);
330
+ expect(conn.preVhostHost).toBeUndefined();
331
+ expect(sentTexts(out.effects)).toContain(
332
+ ':HostServ!HostServ@services NOTICE alice :Your vhost has been disabled.',
333
+ );
334
+ const broadcasts = out.effects.filter((e) => e.tag === 'Broadcast');
335
+ expect(broadcasts.length).toBe(1);
336
+ });
337
+
338
+ it('is a no-op NOTICE when no vhost is active', () => {
339
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
340
+ const conn = identifiedConn('alice');
341
+ conn.host = 'real.cloak.example.net';
342
+ const ctx = makeCtx(conn, services);
343
+
344
+ const out = hostservReducer(conn, privmsg('OFF'), ctx);
345
+
346
+ expect(sentTexts(out.effects)).toContain(
347
+ ':HostServ!HostServ@services NOTICE alice :No vhost is currently active.',
348
+ );
349
+ expect(conn.host).toBe('real.cloak.example.net');
350
+ });
351
+ });
352
+
353
+ // ============================================================================
354
+ // REQUEST
355
+ // ============================================================================
356
+
357
+ describe('hostservReducer — REQUEST', () => {
358
+ it('rejects REQUEST when the connection is not identified', () => {
359
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
360
+ const conn = makeConn();
361
+ const ctx = makeCtx(conn, services);
362
+
363
+ const out = hostservReducer(conn, privmsg('REQUEST cool.example.net'), ctx);
364
+
365
+ expect(sentTexts(out.effects)).toContain(
366
+ ':HostServ!HostServ@services NOTICE alice :You must identify before requesting a vhost.',
367
+ );
368
+ expect(services.getVhost('alice')).toBeUndefined();
369
+ });
370
+
371
+ it('rejects REQUEST with no vhost argument', () => {
372
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
373
+ const conn = identifiedConn('alice');
374
+ const ctx = makeCtx(conn, services);
375
+
376
+ const out = hostservReducer(conn, privmsg('REQUEST'), ctx);
377
+
378
+ expect(sentTexts(out.effects)).toContain(
379
+ ':HostServ!HostServ@services NOTICE alice :Syntax: REQUEST <vhost>',
380
+ );
381
+ });
382
+
383
+ it('rejects REQUEST with an invalid vhost (bad char)', () => {
384
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
385
+ const conn = identifiedConn('alice');
386
+ const ctx = makeCtx(conn, services);
387
+
388
+ const out = hostservReducer(conn, privmsg('REQUEST bad!host'), ctx);
389
+
390
+ expect(sentTexts(out.effects)).toContain(
391
+ ':HostServ!HostServ@services NOTICE alice :Invalid vhost: bad!host',
392
+ );
393
+ expect(services.getVhost('alice')).toBeUndefined();
394
+ });
395
+
396
+ it('rejects REQUEST with an invalid vhost (single label only)', () => {
397
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
398
+ const conn = identifiedConn('alice');
399
+ const ctx = makeCtx(conn, services);
400
+
401
+ const out = hostservReducer(conn, privmsg('REQUEST localhost'), ctx);
402
+
403
+ expect(sentTexts(out.effects)).toContain(
404
+ ':HostServ!HostServ@services NOTICE alice :Invalid vhost: localhost',
405
+ );
406
+ expect(services.getVhost('alice')).toBeUndefined();
407
+ });
408
+
409
+ it('records the requested vhost against the account (auto-approve)', () => {
410
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
411
+ const conn = identifiedConn('alice');
412
+ const ctx = makeCtx(conn, services, new FakeClock(NOW), autoApproveServerConfig);
413
+
414
+ const out = hostservReducer(conn, privmsg('REQUEST cool.example.net'), ctx);
415
+
416
+ expect(services.getVhost('alice')).toBe('cool.example.net');
417
+ expect(sentTexts(out.effects)).toContain(
418
+ ':HostServ!HostServ@services NOTICE alice :Vhost cool.example.net has been requested and approved.',
419
+ );
420
+ });
421
+
422
+ it('overwrites a previously-stored vhost on a subsequent REQUEST', () => {
423
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
424
+ services.setVhost('alice', 'one.example.net');
425
+ const conn = identifiedConn('alice');
426
+ const ctx = makeCtx(conn, services, new FakeClock(NOW), autoApproveServerConfig);
427
+
428
+ hostservReducer(conn, privmsg('REQUEST two.example.net'), ctx);
429
+
430
+ expect(services.getVhost('alice')).toBe('two.example.net');
431
+ });
432
+ });
433
+
434
+ // ============================================================================
435
+ // REQUEST — oper-queue mode (hostservAutoApproveVhosts: false)
436
+ // ============================================================================
437
+
438
+ describe('hostservReducer — REQUEST (queue mode)', () => {
439
+ it('queues a pending request and replies pending-approval (no activation)', () => {
440
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
441
+ const conn = identifiedConn('alice');
442
+ const ctx = makeCtx(conn, services, new FakeClock(NOW), queueServerConfig);
443
+
444
+ const out = hostservReducer(conn, privmsg('REQUEST cool.example.net'), ctx);
445
+
446
+ expect(services.getPendingVhost('alice')).toBe('cool.example.net');
447
+ expect(services.getVhost('alice')).toBeUndefined();
448
+ expect(sentTexts(out.effects)).toContain(
449
+ ':HostServ!HostServ@services NOTICE alice :Vhost cool.example.net is pending oper approval.',
450
+ );
451
+ });
452
+
453
+ it('does NOT emit any CHGHOST broadcast when queueing a pending request', () => {
454
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
455
+ const conn = identifiedConn('alice');
456
+ conn.joinedChannels.add('#foo');
457
+ const ctx = makeCtx(conn, services, new FakeClock(NOW), queueServerConfig);
458
+
459
+ const out = hostservReducer(conn, privmsg('REQUEST cool.example.net'), ctx);
460
+
461
+ const broadcasts = out.effects.filter((e) => e.tag === 'Broadcast');
462
+ expect(broadcasts).toEqual([]);
463
+ expect(conn.host).toBe('real.cloak.example.net');
464
+ expect(conn.vhostActive).toBeFalsy();
465
+ });
466
+
467
+ it('overwrites a previously pending vhost on a subsequent queue-mode REQUEST', () => {
468
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
469
+ services.requestVhost('alice', 'one.example.net');
470
+ const conn = identifiedConn('alice');
471
+ const ctx = makeCtx(conn, services, new FakeClock(NOW), queueServerConfig);
472
+
473
+ hostservReducer(conn, privmsg('REQUEST two.example.net'), ctx);
474
+
475
+ expect(services.getPendingVhost('alice')).toBe('two.example.net');
476
+ expect(services.getVhost('alice')).toBeUndefined();
477
+ });
478
+
479
+ it('rejects a queue-mode REQUEST when not identified', () => {
480
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
481
+ const conn = makeConn();
482
+ const ctx = makeCtx(conn, services, new FakeClock(NOW), queueServerConfig);
483
+
484
+ const out = hostservReducer(conn, privmsg('REQUEST cool.example.net'), ctx);
485
+
486
+ expect(sentTexts(out.effects)).toContain(
487
+ ':HostServ!HostServ@services NOTICE alice :You must identify before requesting a vhost.',
488
+ );
489
+ expect(services.getPendingVhost('alice')).toBeUndefined();
490
+ });
491
+
492
+ it('rejects a queue-mode REQUEST with no vhost argument', () => {
493
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
494
+ const conn = identifiedConn('alice');
495
+ const ctx = makeCtx(conn, services, new FakeClock(NOW), queueServerConfig);
496
+
497
+ const out = hostservReducer(conn, privmsg('REQUEST'), ctx);
498
+
499
+ expect(sentTexts(out.effects)).toContain(
500
+ ':HostServ!HostServ@services NOTICE alice :Syntax: REQUEST <vhost>',
501
+ );
502
+ expect(services.getPendingVhost('alice')).toBeUndefined();
503
+ });
504
+
505
+ it('rejects a queue-mode REQUEST with an invalid vhost', () => {
506
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
507
+ const conn = identifiedConn('alice');
508
+ const ctx = makeCtx(conn, services, new FakeClock(NOW), queueServerConfig);
509
+
510
+ const out = hostservReducer(conn, privmsg('REQUEST bad!host'), ctx);
511
+
512
+ expect(sentTexts(out.effects)).toContain(
513
+ ':HostServ!HostServ@services NOTICE alice :Invalid vhost: bad!host',
514
+ );
515
+ expect(services.getPendingVhost('alice')).toBeUndefined();
516
+ });
517
+ });
518
+
519
+ describe('hostservReducer — REQUEST (auto-approve knob explicitly on)', () => {
520
+ it('auto-approves when hostservAutoApproveVhosts is true (explicit)', () => {
521
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
522
+ const conn = identifiedConn('alice');
523
+ const ctx = makeCtx(conn, services, new FakeClock(NOW), autoApproveServerConfig);
524
+
525
+ const out = hostservReducer(conn, privmsg('REQUEST cool.example.net'), ctx);
526
+
527
+ expect(services.getVhost('alice')).toBe('cool.example.net');
528
+ expect(services.getPendingVhost('alice')).toBeUndefined();
529
+ expect(sentTexts(out.effects)).toContain(
530
+ ':HostServ!HostServ@services NOTICE alice :Vhost cool.example.net has been requested and approved.',
531
+ );
532
+ });
533
+
534
+ it('queues when hostservAutoApproveVhosts is undefined (default queue mode)', () => {
535
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
536
+ const conn = identifiedConn('alice');
537
+ // The base serverConfig literal omits the knob entirely; queue mode is
538
+ // the default, so undefined must route through the oper queue.
539
+ const ctx = makeCtx(conn, services, new FakeClock(NOW), serverConfig);
540
+
541
+ const out = hostservReducer(conn, privmsg('REQUEST cool.example.net'), ctx);
542
+
543
+ expect(services.getVhost('alice')).toBeUndefined();
544
+ expect(services.getPendingVhost('alice')).toBe('cool.example.net');
545
+ expect(sentTexts(out.effects)).toContain(
546
+ ':HostServ!HostServ@services NOTICE alice :Vhost cool.example.net is pending oper approval.',
547
+ );
548
+ });
549
+ });
550
+
551
+ // ============================================================================
552
+ // APPROVE / ACTIVATE (oper-only)
553
+ // ============================================================================
554
+
555
+ describe('hostservReducer — APPROVE (oper)', () => {
556
+ it('rejects APPROVE when the connection is not an oper', () => {
557
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
558
+ services.requestVhost('alice', 'cool.example.net');
559
+ const conn = identifiedConn('bob');
560
+ const ctx = makeCtx(conn, services);
561
+
562
+ const out = hostservReducer(conn, privmsg('APPROVE alice'), ctx);
563
+
564
+ expect(sentTexts(out.effects)).toContain(
565
+ ':HostServ!HostServ@services NOTICE bob :You must be an oper to use APPROVE.',
566
+ );
567
+ expect(services.getPendingVhost('alice')).toBe('cool.example.net');
568
+ expect(services.getVhost('alice')).toBeUndefined();
569
+ });
570
+
571
+ it('rejects APPROVE with no account argument', () => {
572
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
573
+ const conn = operConn('admin');
574
+ const ctx = makeCtx(conn, services);
575
+
576
+ const out = hostservReducer(conn, privmsg('APPROVE'), ctx);
577
+
578
+ expect(sentTexts(out.effects)).toContain(
579
+ ':HostServ!HostServ@services NOTICE admin :Syntax: APPROVE <account>',
580
+ );
581
+ });
582
+
583
+ it('rejects APPROVE when no pending request exists for the account', () => {
584
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
585
+ const conn = operConn('admin');
586
+ const ctx = makeCtx(conn, services);
587
+
588
+ const out = hostservReducer(conn, privmsg('APPROVE alice'), ctx);
589
+
590
+ expect(sentTexts(out.effects)).toContain(
591
+ ':HostServ!HostServ@services NOTICE admin :No pending vhost request for alice.',
592
+ );
593
+ expect(services.getVhost('alice')).toBeUndefined();
594
+ });
595
+
596
+ it('activates the pending vhost and replies to the oper', () => {
597
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
598
+ services.requestVhost('alice', 'cool.example.net');
599
+ const conn = operConn('admin');
600
+ const ctx = makeCtx(conn, services);
601
+
602
+ const out = hostservReducer(conn, privmsg('APPROVE alice'), ctx);
603
+
604
+ expect(services.getVhost('alice')).toBe('cool.example.net');
605
+ expect(services.getPendingVhost('alice')).toBeUndefined();
606
+ expect(sentTexts(out.effects)).toContain(
607
+ ':HostServ!HostServ@services NOTICE admin :Vhost cool.example.net for alice has been approved and activated.',
608
+ );
609
+ });
610
+
611
+ it('emits a SendToNick NOTICE to the owner when online (best-effort delivery)', () => {
612
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
613
+ services.requestVhost('alice', 'cool.example.net');
614
+ const conn = operConn('admin');
615
+ const ctx = makeCtx(conn, services);
616
+
617
+ const out = hostservReducer(conn, privmsg('APPROVE alice'), ctx);
618
+
619
+ const sendToNicks = out.effects.filter((e) => e.tag === 'SendToNick');
620
+ expect(sendToNicks).toHaveLength(1);
621
+ if (sendToNicks[0]?.tag === 'SendToNick') {
622
+ expect(sendToNicks[0].nick).toBe('alice');
623
+ expect(sendToNicks[0].lines[0]?.text).toBe(
624
+ ':HostServ!HostServ@services NOTICE alice :Your vhost cool.example.net has been approved and activated. /msg HostServ ON to apply it.',
625
+ );
626
+ }
627
+ });
628
+
629
+ it('treats ACTIVATE as an alias for APPROVE', () => {
630
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
631
+ services.requestVhost('alice', 'cool.example.net');
632
+ const conn = operConn('admin');
633
+ const ctx = makeCtx(conn, services);
634
+
635
+ const out = hostservReducer(conn, privmsg('ACTIVATE alice'), ctx);
636
+
637
+ expect(services.getVhost('alice')).toBe('cool.example.net');
638
+ expect(sentTexts(out.effects)).toContain(
639
+ ':HostServ!HostServ@services NOTICE admin :Vhost cool.example.net for alice has been approved and activated.',
640
+ );
641
+ });
642
+
643
+ it('folds the account name case-insensitively', () => {
644
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
645
+ services.requestVhost('Alice', 'cool.example.net');
646
+ const conn = operConn('admin');
647
+ const ctx = makeCtx(conn, services);
648
+
649
+ hostservReducer(conn, privmsg('APPROVE ALICE'), ctx);
650
+
651
+ expect(services.getVhost('alice')).toBe('cool.example.net');
652
+ });
653
+ });
654
+
655
+ // ============================================================================
656
+ // REJECT (oper-only)
657
+ // ============================================================================
658
+
659
+ describe('hostservReducer — REJECT (oper)', () => {
660
+ it('rejects REJECT when the connection is not an oper', () => {
661
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
662
+ services.requestVhost('alice', 'cool.example.net');
663
+ const conn = identifiedConn('bob');
664
+ const ctx = makeCtx(conn, services);
665
+
666
+ const out = hostservReducer(conn, privmsg('REJECT alice'), ctx);
667
+
668
+ expect(sentTexts(out.effects)).toContain(
669
+ ':HostServ!HostServ@services NOTICE bob :You must be an oper to use REJECT.',
670
+ );
671
+ expect(services.getPendingVhost('alice')).toBe('cool.example.net');
672
+ });
673
+
674
+ it('rejects REJECT with no account argument', () => {
675
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
676
+ const conn = operConn('admin');
677
+ const ctx = makeCtx(conn, services);
678
+
679
+ const out = hostservReducer(conn, privmsg('REJECT'), ctx);
680
+
681
+ expect(sentTexts(out.effects)).toContain(
682
+ ':HostServ!HostServ@services NOTICE admin :Syntax: REJECT <account>',
683
+ );
684
+ });
685
+
686
+ it('rejects REJECT when no pending request exists for the account', () => {
687
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
688
+ const conn = operConn('admin');
689
+ const ctx = makeCtx(conn, services);
690
+
691
+ const out = hostservReducer(conn, privmsg('REJECT alice'), ctx);
692
+
693
+ expect(sentTexts(out.effects)).toContain(
694
+ ':HostServ!HostServ@services NOTICE admin :No pending vhost request for alice.',
695
+ );
696
+ });
697
+
698
+ it('drops the pending request and replies to the oper', () => {
699
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
700
+ services.requestVhost('alice', 'cool.example.net');
701
+ const conn = operConn('admin');
702
+ const ctx = makeCtx(conn, services);
703
+
704
+ const out = hostservReducer(conn, privmsg('REJECT alice'), ctx);
705
+
706
+ expect(services.getPendingVhost('alice')).toBeUndefined();
707
+ expect(services.getVhost('alice')).toBeUndefined();
708
+ expect(sentTexts(out.effects)).toContain(
709
+ ':HostServ!HostServ@services NOTICE admin :Vhost request for alice has been rejected.',
710
+ );
711
+ });
712
+
713
+ it('emits a SendToNick NOTICE to the owner when online', () => {
714
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
715
+ services.requestVhost('alice', 'cool.example.net');
716
+ const conn = operConn('admin');
717
+ const ctx = makeCtx(conn, services);
718
+
719
+ const out = hostservReducer(conn, privmsg('REJECT alice'), ctx);
720
+
721
+ const sendToNicks = out.effects.filter((e) => e.tag === 'SendToNick');
722
+ expect(sendToNicks).toHaveLength(1);
723
+ if (sendToNicks[0]?.tag === 'SendToNick') {
724
+ expect(sendToNicks[0].nick).toBe('alice');
725
+ expect(sendToNicks[0].lines[0]?.text).toBe(
726
+ ':HostServ!HostServ@services NOTICE alice :Your vhost request for cool.example.net has been rejected.',
727
+ );
728
+ }
729
+ });
730
+ });
731
+
732
+ // ============================================================================
733
+ // LIST (oper-only)
734
+ // ============================================================================
735
+
736
+ describe('hostservReducer — LIST (oper)', () => {
737
+ it('rejects LIST when the connection is not an oper', () => {
738
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
739
+ services.requestVhost('alice', 'cool.example.net');
740
+ const conn = identifiedConn('bob');
741
+ const ctx = makeCtx(conn, services);
742
+
743
+ const out = hostservReducer(conn, privmsg('LIST'), ctx);
744
+
745
+ expect(sentTexts(out.effects)).toContain(
746
+ ':HostServ!HostServ@services NOTICE bob :You must be an oper to use LIST.',
747
+ );
748
+ });
749
+
750
+ it('replies with an empty-queue NOTICE when no requests are pending', () => {
751
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
752
+ const conn = operConn('admin');
753
+ const ctx = makeCtx(conn, services);
754
+
755
+ const out = hostservReducer(conn, privmsg('LIST'), ctx);
756
+
757
+ expect(sentTexts(out.effects)).toContain(
758
+ ':HostServ!HostServ@services NOTICE admin :No pending vhost requests.',
759
+ );
760
+ });
761
+
762
+ it('enumerates each pending request as a NOTICE line', () => {
763
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
764
+ services.requestVhost('alice', 'a.example.net');
765
+ services.requestVhost('bob', 'b.example.net');
766
+ const conn = operConn('admin');
767
+ const ctx = makeCtx(conn, services);
768
+
769
+ const out = hostservReducer(conn, privmsg('LIST'), ctx);
770
+
771
+ const lines = sentTexts(out.effects);
772
+ expect(lines).toContain(':HostServ!HostServ@services NOTICE admin :alice: a.example.net');
773
+ expect(lines).toContain(':HostServ!HostServ@services NOTICE admin :bob: b.example.net');
774
+ });
775
+ });
776
+
777
+ // ============================================================================
778
+ // SET (oper-only)
779
+ // ============================================================================
780
+
781
+ describe('hostservReducer — SET (oper)', () => {
782
+ it('rejects SET when the connection is not an oper', () => {
783
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
784
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
785
+ const conn = identifiedConn('admin');
786
+ const ctx = makeCtx(conn, services);
787
+
788
+ const out = hostservReducer(conn, privmsg('SET alice cool.example.net'), ctx);
789
+
790
+ expect(sentTexts(out.effects)).toContain(
791
+ ':HostServ!HostServ@services NOTICE admin :You must be an oper to use SET.',
792
+ );
793
+ expect(services.getVhost('alice')).toBeUndefined();
794
+ });
795
+
796
+ it('rejects SET with missing arguments', () => {
797
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
798
+ const conn = operConn('admin');
799
+ const ctx = makeCtx(conn, services);
800
+
801
+ const out = hostservReducer(conn, privmsg('SET'), ctx);
802
+
803
+ expect(sentTexts(out.effects)).toContain(
804
+ ':HostServ!HostServ@services NOTICE admin :Syntax: SET <account> <vhost>',
805
+ );
806
+ });
807
+
808
+ it('rejects SET with only an account', () => {
809
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
810
+ const conn = operConn('admin');
811
+ const ctx = makeCtx(conn, services);
812
+
813
+ const out = hostservReducer(conn, privmsg('SET alice'), ctx);
814
+
815
+ expect(sentTexts(out.effects)).toContain(
816
+ ':HostServ!HostServ@services NOTICE admin :Syntax: SET <account> <vhost>',
817
+ );
818
+ });
819
+
820
+ it('rejects SET when the target account is not registered', () => {
821
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
822
+ const conn = operConn('admin');
823
+ const ctx = makeCtx(conn, services);
824
+
825
+ const out = hostservReducer(conn, privmsg('SET alice cool.example.net'), ctx);
826
+
827
+ expect(sentTexts(out.effects)).toContain(
828
+ ':HostServ!HostServ@services NOTICE admin :Nick alice is not registered.',
829
+ );
830
+ expect(services.getVhost('alice')).toBeUndefined();
831
+ });
832
+
833
+ it('sets the vhost directly when invoked by an oper', () => {
834
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
835
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
836
+ const conn = operConn('admin');
837
+ const ctx = makeCtx(conn, services);
838
+
839
+ const out = hostservReducer(conn, privmsg('SET alice cool.example.net'), ctx);
840
+
841
+ expect(services.getVhost('alice')).toBe('cool.example.net');
842
+ expect(sentTexts(out.effects)).toContain(
843
+ ':HostServ!HostServ@services NOTICE admin :Vhost for alice is now set to cool.example.net.',
844
+ );
845
+ });
846
+
847
+ it('clears the vhost when the oper sets an empty vhost', () => {
848
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
849
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
850
+ services.setVhost('alice', 'old.example.net');
851
+ const conn = operConn('admin');
852
+ const ctx = makeCtx(conn, services);
853
+
854
+ const out = hostservReducer(
855
+ conn,
856
+ { command: 'PRIVMSG', params: [HOSTSERV_NICK, 'SET alice *'], tags: {} },
857
+ ctx,
858
+ );
859
+
860
+ expect(services.getVhost('alice')).toBeUndefined();
861
+ expect(sentTexts(out.effects)).toContain(
862
+ ':HostServ!HostServ@services NOTICE admin :Vhost for alice has been cleared.',
863
+ );
864
+ });
865
+
866
+ it('rejects SET with an invalid vhost', () => {
867
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
868
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
869
+ const conn = operConn('admin');
870
+ const ctx = makeCtx(conn, services);
871
+
872
+ const out = hostservReducer(conn, privmsg('SET alice bad!host'), ctx);
873
+
874
+ expect(sentTexts(out.effects)).toContain(
875
+ ':HostServ!HostServ@services NOTICE admin :Invalid vhost: bad!host',
876
+ );
877
+ expect(services.getVhost('alice')).toBeUndefined();
878
+ });
879
+ });
880
+
881
+ // ============================================================================
882
+ // Integration: CHGHOST emission shape
883
+ // ============================================================================
884
+
885
+ describe('hostservReducer — CHGHOST emission shape', () => {
886
+ it('ON produces CHGHOST broadcasts that match emitChghost output', () => {
887
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
888
+ services.setVhost('alice', 'cool.example.net');
889
+ const conn = identifiedConn('alice');
890
+ conn.user = 'aliceuser';
891
+ conn.host = 'real.cloak.example.net';
892
+ conn.joinedChannels.add('#foo');
893
+ const ctx = makeCtx(conn, services);
894
+
895
+ const out = hostservReducer(conn, privmsg('ON'), ctx);
896
+
897
+ // The CHGHOST should be the same shape emitChghost would produce.
898
+ const expected = emitChghost({
899
+ conn,
900
+ oldUser: 'aliceuser',
901
+ oldHost: 'real.cloak.example.net',
902
+ newUser: 'aliceuser',
903
+ newHost: 'cool.example.net',
904
+ });
905
+ const broadcasts = out.effects.filter((e) => e.tag === 'Broadcast');
906
+ const expectedBroadcasts = expected.filter((e) => e.tag === 'Broadcast');
907
+ expect(broadcasts.length).toBe(expectedBroadcasts.length);
908
+ if (broadcasts.length > 0 && broadcasts[0]?.tag === 'Broadcast') {
909
+ const first = broadcasts[0];
910
+ expect(first.cap).toBe('chghost');
911
+ expect(first.capLines?.[0]?.text).toBe(
912
+ ':alice!aliceuser@real.cloak.example.net CHGHOST aliceuser :cool.example.net',
913
+ );
914
+ }
915
+ });
916
+
917
+ it('OFF produces CHGHOST broadcasting back to the real host', () => {
918
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
919
+ services.setVhost('alice', 'cool.example.net');
920
+ const conn = identifiedConn('alice');
921
+ conn.user = 'aliceuser';
922
+ conn.host = 'cool.example.net';
923
+ conn.preVhostHost = 'real.cloak.example.net';
924
+ conn.vhostActive = true;
925
+ conn.joinedChannels.add('#foo');
926
+ const ctx = makeCtx(conn, services);
927
+
928
+ const out = hostservReducer(conn, privmsg('OFF'), ctx);
929
+
930
+ const broadcasts = out.effects.filter((e) => e.tag === 'Broadcast');
931
+ expect(broadcasts.length).toBe(1);
932
+ if (broadcasts[0]?.tag === 'Broadcast') {
933
+ expect(broadcasts[0].capLines?.[0]?.text).toBe(
934
+ ':alice!aliceuser@cool.example.net CHGHOST aliceuser :real.cloak.example.net',
935
+ );
936
+ }
937
+ });
938
+
939
+ it('ON does not emit CHGHOST when the connection has no joined channels', () => {
940
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
941
+ services.setVhost('alice', 'cool.example.net');
942
+ const conn = identifiedConn('alice');
943
+ const ctx = makeCtx(conn, services);
944
+
945
+ const out = hostservReducer(conn, privmsg('ON'), ctx);
946
+
947
+ const broadcasts = out.effects.filter((e) => e.tag === 'Broadcast');
948
+ expect(broadcasts).toEqual([]);
949
+ // The vhost is still applied to state.host
950
+ expect(conn.host).toBe('cool.example.net');
951
+ expect(conn.vhostActive).toBe(true);
952
+ });
953
+ });
954
+
955
+ // ============================================================================
956
+ // Defensive fallbacks
957
+ // ============================================================================
958
+
959
+ describe('hostservReducer — defensive fallbacks', () => {
960
+ it('addresses the NOTICE to * when the connection has no nick', () => {
961
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
962
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
963
+ conn.registration = 'registered';
964
+ const ctx = makeCtx(conn, services);
965
+
966
+ const out = hostservReducer(conn, privmsg('ON'), ctx);
967
+
968
+ expect(sentTexts(out.effects)[0]).toBe(
969
+ ':HostServ!HostServ@services NOTICE * :You must identify before enabling a vhost.',
970
+ );
971
+ });
972
+
973
+ it('ON applies the vhost even when the connection has no user/host', () => {
974
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
975
+ services.setVhost('alice', 'cool.example.net');
976
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
977
+ conn.nick = 'alice';
978
+ conn.account = 'alice';
979
+ conn.userModes.registered = true;
980
+ conn.registration = 'registered';
981
+ conn.joinedChannels.add('#foo');
982
+ const ctx = makeCtx(conn, services);
983
+
984
+ hostservReducer(conn, privmsg('ON'), ctx);
985
+
986
+ expect(conn.host).toBe('cool.example.net');
987
+ expect(conn.vhostActive).toBe(true);
988
+ expect(conn.preVhostHost).toBe('');
989
+ });
990
+
991
+ it('OFF clears the vhost even when user/host/preVhostHost are all absent', () => {
992
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
993
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
994
+ conn.nick = 'alice';
995
+ conn.account = 'alice';
996
+ conn.userModes.registered = true;
997
+ conn.registration = 'registered';
998
+ conn.vhostActive = true;
999
+ const ctx = makeCtx(conn, services);
1000
+
1001
+ hostservReducer(conn, privmsg('OFF'), ctx);
1002
+
1003
+ expect(conn.host).toBe('');
1004
+ expect(conn.vhostActive).toBe(false);
1005
+ });
1006
+ });