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,402 @@
1
+ /**
2
+ * Pure reducer for the NickServ services pseudo-client.
3
+ *
4
+ * NickServ is invoked via `PRIVMSG NickServ :<command>`. The actor routes
5
+ * any `PRIVMSG` whose target matches {@link NICKSERV_NICK}
6
+ * (case-insensitive) to {@link nickservReducer} instead of the normal
7
+ * user-target reducer.
8
+ *
9
+ * Supported subcommands:
10
+ * - `REGISTER <password> <email>` — registers the current nick against
11
+ * the bound {@link ServicesStore}. After registration, SASL PLAIN with
12
+ * `<nick>:<password>` succeeds (the SASL reducer falls back to the
13
+ * services store). Does NOT auto-identify.
14
+ * - `IDENTIFY [nick] <password>` — verifies the credentials and, on
15
+ * success, records the account on the connection, sets read-only user
16
+ * mode `+r`, fans an `ACCOUNT` line out to `account-notify` peers, and
17
+ * delivers queued MemoServ memos.
18
+ * - `DROP [nick]` — removes the nick registration. Requires the caller
19
+ * to be identified first; clears `+r` and the account, fans `ACCOUNT *`
20
+ * out to peers.
21
+ * - `INFO [nick]` — queries the registration record.
22
+ * - `SET ENFORCE none|ghost|kill` — sets the per-account nick-enforcement
23
+ * policy. Requires identification. The policy drives
24
+ * {@link enforceRegisteredNick}, which the NICK path consults when a
25
+ * client takes a registered nick they are not identified as.
26
+ *
27
+ * The reducer is a pure `Reducer<ConnectionState>`: all side effects are
28
+ * emitted as {@link Effect}s. The services store is synchronous (mirrors
29
+ * the {@link AccountStore} / {@link MessageStore} pattern).
30
+ */
31
+
32
+ import { DEFAULT_NICK_ENFORCE_GRACE_MS } from '../config.js';
33
+ import { Effect } from '../effects.js';
34
+ import type { Effect as EffectType, RawLine } from '../effects.js';
35
+ import type { NickEnforcePolicy, ServicesStore } from '../ports.js';
36
+ import type { ConnectionState } from '../state/connection.js';
37
+ import type { Ctx, Reducer } from '../types.js';
38
+ import { emitAccountNotify } from './account-notify.js';
39
+ import { applyAssignedVhost } from './hostserv.js';
40
+ import { deliverUnreadMemos } from './memoserv.js';
41
+
42
+ /** Canonical NickServ pseudo-client nick. */
43
+ export const NICKSERV_NICK = 'NickServ';
44
+
45
+ /** Services hostmask fragment used by every services pseudo-client. */
46
+ const SERVICES_HOST = 'services';
47
+
48
+ /** Policies accepted by `SET ENFORCE` (lowercased input forms). */
49
+ const ENFORCE_POLICIES: ReadonlySet<NickEnforcePolicy> = new Set(['none', 'ghost', 'kill']);
50
+
51
+ /**
52
+ * Reducer for `PRIVMSG NickServ :<subcommand> <args…>`.
53
+ * Authority: the invoking connection's {@link ConnectionState}.
54
+ */
55
+ export const nickservReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
56
+ state.lastSeen = ctx.clock.now();
57
+
58
+ const effects: EffectType[] = [];
59
+
60
+ if (ctx.services === undefined) {
61
+ effects.push(notice(state, 'Services are not available on this server.'));
62
+ return { state, effects };
63
+ }
64
+
65
+ const body = msg.params[1] ?? '';
66
+ const tokens = body.split(/\s+/u).filter((t) => t.length > 0);
67
+ if (tokens.length === 0) {
68
+ effects.push(helpNotice(state));
69
+ return { state, effects };
70
+ }
71
+
72
+ const sub = tokens[0]?.toUpperCase();
73
+ const args = tokens.slice(1);
74
+
75
+ switch (sub) {
76
+ case 'REGISTER':
77
+ return handleRegister(state, args, ctx, effects);
78
+ case 'ID':
79
+ case 'IDENTIFY':
80
+ return handleIdentify(state, args, ctx, effects);
81
+ case 'DROP':
82
+ return handleDrop(state, args, ctx, effects);
83
+ case 'INFO':
84
+ return handleInfo(state, args, ctx, effects);
85
+ case 'SET':
86
+ return handleSet(state, args, ctx, effects);
87
+ default:
88
+ effects.push(unknownNotice(state));
89
+ return { state, effects };
90
+ }
91
+ };
92
+
93
+ // ============================================================================
94
+ // REGISTER
95
+ // ============================================================================
96
+
97
+ function handleRegister(
98
+ state: ConnectionState,
99
+ args: string[],
100
+ ctx: Ctx,
101
+ effects: EffectType[],
102
+ ): { state: ConnectionState; effects: EffectType[] } {
103
+ const password = args[0];
104
+ const email = args[1];
105
+ const nick = state.nick;
106
+ if (nick === undefined || password === undefined || email === undefined) {
107
+ effects.push(notice(state, 'Syntax: REGISTER <password> <email>'));
108
+ return { state, effects };
109
+ }
110
+
111
+ const services = ctx.services as ServicesStore;
112
+ const result = services.registerNick(nick, password, email);
113
+ if (result.ok) {
114
+ effects.push(notice(state, `Nickname ${nick} is now registered.`));
115
+ } else {
116
+ effects.push(notice(state, `Nickname ${nick} is already registered.`));
117
+ }
118
+ return { state, effects };
119
+ }
120
+
121
+ // ============================================================================
122
+ // IDENTIFY
123
+ // ============================================================================
124
+
125
+ function handleIdentify(
126
+ state: ConnectionState,
127
+ args: string[],
128
+ ctx: Ctx,
129
+ effects: EffectType[],
130
+ ): { state: ConnectionState; effects: EffectType[] } {
131
+ const services = ctx.services as ServicesStore;
132
+
133
+ if (state.account !== undefined) {
134
+ effects.push(notice(state, 'You are already identified.'));
135
+ return { state, effects };
136
+ }
137
+
138
+ // IDENTIFY <password> → identify current nick
139
+ // IDENTIFY <nick> <password> → identify named nick
140
+ let nick: string | undefined;
141
+ let password: string | undefined;
142
+ if (args.length >= 2) {
143
+ nick = args[0];
144
+ password = args[1];
145
+ } else {
146
+ nick = state.nick;
147
+ password = args[0];
148
+ }
149
+
150
+ if (nick === undefined || password === undefined) {
151
+ effects.push(notice(state, 'Syntax: IDENTIFY [nick] <password>'));
152
+ return { state, effects };
153
+ }
154
+
155
+ if (!services.isRegisteredNick(nick)) {
156
+ effects.push(notice(state, `Nick ${nick} is not registered.`));
157
+ return { state, effects };
158
+ }
159
+
160
+ const result = services.verifyNick(nick, password);
161
+ if (!result.ok) {
162
+ effects.push(notice(state, 'Invalid password.'));
163
+ return { state, effects };
164
+ }
165
+
166
+ applyIdentify(state, result.account, services, effects);
167
+ return { state, effects };
168
+ }
169
+
170
+ /**
171
+ * Applies a successful identify: records the account, sets `+r`, re-applies any
172
+ * assigned HostServ vhost (so a reconnecting user does not have to re-run
173
+ * `HostServ ON`), emits the confirmation NOTICE, fans `ACCOUNT` out to peers,
174
+ * and delivers queued memos.
175
+ */
176
+ function applyIdentify(
177
+ state: ConnectionState,
178
+ account: string,
179
+ services: ServicesStore,
180
+ effects: EffectType[],
181
+ ): void {
182
+ state.account = account;
183
+ state.userModes.registered = true;
184
+ // Re-apply an assigned HostServ vhost. At identify time the connection is
185
+ // usually pre-JOIN so the CHGHOST fanout is empty, but a post-JOIN IDENTIFY
186
+ // fans CHGHOST out to the user's current `chghost`-capable peers.
187
+ applyAssignedVhost(state, services, account, effects);
188
+ effects.push(notice(state, `You are now identified for nick ${account}.`));
189
+ effects.push(...emitAccountNotify({ conn: state, account }));
190
+ deliverUnreadMemos(state, services, account, effects);
191
+ }
192
+
193
+ // ============================================================================
194
+ // DROP
195
+ // ============================================================================
196
+
197
+ function handleDrop(
198
+ state: ConnectionState,
199
+ _args: string[],
200
+ ctx: Ctx,
201
+ effects: EffectType[],
202
+ ): { state: ConnectionState; effects: EffectType[] } {
203
+ const services = ctx.services as ServicesStore;
204
+
205
+ if (state.account === undefined) {
206
+ effects.push(notice(state, 'You must identify before dropping a nick.'));
207
+ return { state, effects };
208
+ }
209
+
210
+ const nick = state.nick;
211
+ if (nick === undefined) {
212
+ effects.push(notice(state, 'You must identify before dropping a nick.'));
213
+ return { state, effects };
214
+ }
215
+
216
+ services.dropNick(nick);
217
+ // Clear the identification state and announce the logout to peers.
218
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
219
+ delete state.account;
220
+ state.userModes.registered = false;
221
+ effects.push(notice(state, `Nickname ${nick} has been dropped.`));
222
+ effects.push(...emitAccountNotify({ conn: state }));
223
+ return { state, effects };
224
+ }
225
+
226
+ // ============================================================================
227
+ // INFO
228
+ // ============================================================================
229
+
230
+ function handleInfo(
231
+ state: ConnectionState,
232
+ args: string[],
233
+ ctx: Ctx,
234
+ effects: EffectType[],
235
+ ): { state: ConnectionState; effects: EffectType[] } {
236
+ const services = ctx.services as ServicesStore;
237
+ const target = args[0] ?? state.nick;
238
+ if (target === undefined) {
239
+ effects.push(notice(state, 'Syntax: INFO [nick]'));
240
+ return { state, effects };
241
+ }
242
+
243
+ const rec = services.getNick(target);
244
+ if (rec === undefined) {
245
+ effects.push(notice(state, `Nick ${target} is not registered.`));
246
+ return { state, effects };
247
+ }
248
+
249
+ effects.push(notice(state, `Nick: ${rec.nick}`));
250
+ effects.push(notice(state, `Account: ${rec.account}`));
251
+ // Email is private: only the account owner or an oper may see it. Mirrors
252
+ // the `state.account === rec.account` gate used by SET ENFORCE / the
253
+ // enforcement hook, extended to opers.
254
+ if (state.account === rec.account || state.userModes.oper) {
255
+ effects.push(notice(state, `Email: ${rec.email}`));
256
+ }
257
+ return { state, effects };
258
+ }
259
+
260
+ // ============================================================================
261
+ // Nick-enforcement hook (consulted by the NICK path)
262
+ // ============================================================================
263
+
264
+ /**
265
+ * Warning NOTICE text sent to every unidentified client using a registered
266
+ * nick, regardless of the account's enforcement policy.
267
+ */
268
+ const ENFORCE_WARNING_TEXT =
269
+ 'This nickname is registered and protected. If it is yours, IDENTIFY; otherwise choose another.';
270
+
271
+ /** Canonical disconnect reason the actor applies on enforcement. */
272
+ export const ENFORCE_NICK_REASON = 'Nick enforced by NickServ';
273
+
274
+ /**
275
+ * Returns the effects to emit when a client has just taken `state.nick`:
276
+ * if the nick is registered and the connection is NOT identified as the
277
+ * owning account, the {@link NickEnforcePolicy} on the registration drives
278
+ * the output:
279
+ *
280
+ * - `none` — warning NOTICE only.
281
+ * - `kill` — warning NOTICE + `EnforceNick(kill, graceMs=0)`.
282
+ * - `ghost` — warning NOTICE + `EnforceNick(ghost, graceMs=<config>)`.
283
+ *
284
+ * Returns `[]` when services are unbound, the connection has no nick, the
285
+ * nick is not registered, or the connection is identified as the nick's
286
+ * account owner. Called by the registration path on welcome-completion and
287
+ * by the post-registration `NICK` change.
288
+ */
289
+ export function enforceRegisteredNick(state: ConnectionState, ctx: Ctx): EffectType[] {
290
+ const services = ctx.services;
291
+ if (services === undefined) return [];
292
+ const nick = state.nick;
293
+ if (nick === undefined) return [];
294
+
295
+ const rec = services.getNick(nick);
296
+ if (rec === undefined) return [];
297
+ // Identified as the owning account → the use is legitimate.
298
+ if (state.account !== undefined && state.account === rec.account) return [];
299
+
300
+ const effects: EffectType[] = [notice(state, ENFORCE_WARNING_TEXT)];
301
+
302
+ if (rec.enforce === 'none') return effects;
303
+
304
+ if (rec.enforce === 'kill') {
305
+ effects.push(Effect.enforceNick(state.id, nick, 'kill', 0));
306
+ return effects;
307
+ }
308
+
309
+ // ghost: stamp the configured grace so dispatch can schedule the
310
+ // disconnect. `0` (config-disabled grace) collapses to immediate.
311
+ const graceMs = ctx.serverConfig.nickEnforceGraceMs ?? DEFAULT_NICK_ENFORCE_GRACE_MS;
312
+ effects.push(Effect.enforceNick(state.id, nick, 'ghost', graceMs));
313
+ return effects;
314
+ }
315
+
316
+ // ============================================================================
317
+ // SET
318
+ // ============================================================================
319
+
320
+ /**
321
+ * Handles `SET <subkey> <args…>`. Currently only `ENFORCE none|ghost|kill`
322
+ * is supported; unknown subkeys fall back to the ENFORCE syntax notice.
323
+ *
324
+ * Requires the caller to be identified as the account bound to the current
325
+ * nick: enforcement is a per-account policy, so an unidentified client
326
+ * (or one identified as a different account) cannot mutate it.
327
+ */
328
+ function handleSet(
329
+ state: ConnectionState,
330
+ args: string[],
331
+ ctx: Ctx,
332
+ effects: EffectType[],
333
+ ): { state: ConnectionState; effects: EffectType[] } {
334
+ const services = ctx.services as ServicesStore;
335
+ const subkey = args[0]?.toUpperCase();
336
+ if (subkey !== 'ENFORCE') {
337
+ effects.push(enforceSyntaxNotice(state));
338
+ return { state, effects };
339
+ }
340
+
341
+ const policyRaw = args[1]?.toLowerCase();
342
+ if (policyRaw === undefined || !ENFORCE_POLICIES.has(policyRaw as NickEnforcePolicy)) {
343
+ effects.push(enforceSyntaxNotice(state));
344
+ return { state, effects };
345
+ }
346
+ const policy = policyRaw as NickEnforcePolicy;
347
+
348
+ const nick = state.nick;
349
+ if (nick === undefined || state.account === undefined) {
350
+ effects.push(notice(state, 'You must identify before changing enforcement.'));
351
+ return { state, effects };
352
+ }
353
+
354
+ const rec = services.getNick(nick);
355
+ if (rec === undefined) {
356
+ effects.push(notice(state, `Nick ${nick} is not registered.`));
357
+ return { state, effects };
358
+ }
359
+ // The caller must be identified as the account that owns this nick.
360
+ if (state.account !== rec.account) {
361
+ effects.push(notice(state, 'You must identify before changing enforcement.'));
362
+ return { state, effects };
363
+ }
364
+
365
+ services.setNickEnforce(nick, policy);
366
+ effects.push(notice(state, `Enforcement for nick ${nick} is now set to ${policy}.`));
367
+ return { state, effects };
368
+ }
369
+
370
+ /** Builds the `SET ENFORCE` syntax NOTICE. */
371
+ function enforceSyntaxNotice(state: ConnectionState): EffectType {
372
+ return notice(state, 'Syntax: SET ENFORCE none|ghost|kill');
373
+ }
374
+
375
+ // ============================================================================
376
+ // Notice helpers
377
+ // ============================================================================
378
+
379
+ /** Builds a `:NickServ!NickServ@services NOTICE <nick> :<text>` line. */
380
+ function notice(state: ConnectionState, text: string): EffectType {
381
+ return Effect.send(state.id, [servicesNotice(NICKSERV_NICK, state, text)]);
382
+ }
383
+
384
+ /** Builds a help/unknown `:NickServ!NickServ@services NOTICE <nick> :…` line. */
385
+ function helpNotice(state: ConnectionState): EffectType {
386
+ return notice(state, 'Available commands: REGISTER, IDENTIFY, DROP, INFO, SET');
387
+ }
388
+
389
+ function unknownNotice(state: ConnectionState): EffectType {
390
+ return notice(state, 'Unknown command. Available: REGISTER, IDENTIFY, DROP, INFO, SET');
391
+ }
392
+
393
+ /**
394
+ * Formats a services NOTICE line. The source hostmask is
395
+ * `<svc>!<svc>@services` regardless of the deployment's serverName so the
396
+ * pseudo-clients are stable across deployments and easy to identify in
397
+ * client UIs.
398
+ */
399
+ function servicesNotice(svc: string, state: ConnectionState, text: string): RawLine {
400
+ const nick = state.nick ?? '*';
401
+ return { text: `:${svc}!${svc}@${SERVICES_HOST} NOTICE ${nick} :${text}` };
402
+ }
@@ -27,15 +27,19 @@
27
27
  * - Missing `<name>` or `<password>` → `461 ERR_NEEDMOREPARAMS`.
28
28
  * - Already an operator → graceful idempotent no-op that re-confirms with
29
29
  * `381` (no re-authentication required; the connection is already privy).
30
+ * No `MODE` echo is emitted because the user mode did not change.
30
31
  * - No operator credentials configured (absent or empty) →
31
32
  * `491 ERR_NOOPERHOST`.
32
- * - Matching credential pair → grant oper, emit `381`.
33
+ * - Matching credential pair → grant oper, emit `381`, then emit a
34
+ * `:<hostmask> MODE <nick> +o` notification to the connection so the
35
+ * client learns its user mode changed (mirrors the user `MODE` echo).
33
36
  * - Wrong password / unknown name → `464 ERR_PASSWDMISMATCH`.
34
37
  */
35
38
 
36
39
  import { Effect } from '../effects.js';
37
40
  import type { RawLine } from '../effects.js';
38
41
  import { Numerics } from '../protocol/numerics.js';
42
+ import { hostmaskOf } from '../state/connection.js';
39
43
  import type { ConnectionState } from '../state/connection.js';
40
44
  import type { OperCred } from '../types.js';
41
45
  import type { Ctx, Reducer } from '../types.js';
@@ -122,6 +126,7 @@ export const operReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
122
126
  Effect.send(ctx.connId, [
123
127
  numericLine(ctx, Numerics.RPL_YOUREOPER, YOUREOPER_TRAILING_TEXT),
124
128
  ]),
129
+ Effect.send(ctx.connId, [modeEchoLine(state)]),
125
130
  ],
126
131
  };
127
132
  }
@@ -149,3 +154,15 @@ function numericLine(ctx: Ctx, code: number, trailing: string, middle?: string):
149
154
  parts.push(`:${trailing}`);
150
155
  return { text: parts.join(' ') };
151
156
  }
157
+
158
+ /**
159
+ * Builds the `:<hostmask> MODE <nick> +o` echo emitted to the connection
160
+ * when oper is granted. Mirrors the user `MODE` echo format so clients can
161
+ * treat oper-up as a regular user-mode change. The hostmask falls back to
162
+ * the nick (then `?`) to match the rest of the codebase's source formatting.
163
+ */
164
+ function modeEchoLine(state: ConnectionState): RawLine {
165
+ const nick = state.nick ?? '*';
166
+ const source = hostmaskOf(state) ?? state.nick ?? '?';
167
+ return { text: `:${source} MODE ${nick} +o` };
168
+ }