serverless-ircd 0.7.0 → 0.8.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 (135) hide show
  1. package/.github/workflows/ci.yml +3 -3
  2. package/.gitmodules +1 -1
  3. package/CHANGELOG.md +273 -29
  4. package/README.md +155 -55
  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-worker/package.json +1 -1
  10. package/apps/cf-worker/src/worker.ts +4 -4
  11. package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
  12. package/apps/cf-worker/tests/smoke.test.ts +5 -5
  13. package/apps/cf-worker/wrangler.test.toml +6 -6
  14. package/apps/cf-worker/wrangler.toml +7 -5
  15. package/apps/local-cli/package.json +1 -1
  16. package/apps/local-cli/src/config-loader.ts +8 -0
  17. package/apps/local-cli/src/main.ts +16 -0
  18. package/apps/local-cli/src/server.ts +1 -0
  19. package/apps/local-cli/tests/config-loader.test.ts +14 -0
  20. package/apps/web/landing/index.html +14 -16
  21. package/apps/web/package.json +2 -2
  22. package/apps/web/scripts/build.mjs +23 -16
  23. package/apps/web/src/build-env.ts +1 -1
  24. package/apps/web/src/config-schema.ts +6 -6
  25. package/apps/web/tests/build-smoke.test.ts +14 -14
  26. package/apps/web/tests/config-schema.test.ts +1 -1
  27. package/docs/AWS-Deployment.md +21 -8
  28. package/docs/Cloudflare-Deployment-Guide.md +22 -6
  29. package/docs/PlanExtensions.md +113 -3
  30. package/docs/Release-Process.md +23 -13
  31. package/docs/Services.md +546 -0
  32. package/docs/WebClientGuide.md +32 -31
  33. package/package.json +2 -2
  34. package/packages/aws-adapter/package.json +1 -1
  35. package/packages/aws-adapter/src/aws-runtime.ts +5 -0
  36. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
  37. package/packages/aws-adapter/src/config-loader.ts +11 -0
  38. package/packages/aws-adapter/src/connection-counter.ts +89 -0
  39. package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
  40. package/packages/aws-adapter/src/handlers/connect.ts +55 -51
  41. package/packages/aws-adapter/src/handlers/default.ts +36 -4
  42. package/packages/aws-adapter/src/handlers/index.ts +15 -0
  43. package/packages/aws-adapter/src/handlers/nlb-stream.ts +15 -0
  44. package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
  45. package/packages/aws-adapter/src/index.ts +4 -0
  46. package/packages/aws-adapter/src/stats.ts +6 -1
  47. package/packages/aws-adapter/src/tables.ts +34 -4
  48. package/packages/aws-adapter/tests/aws-harness.ts +3 -0
  49. package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
  50. package/packages/aws-adapter/tests/connect.test.ts +158 -32
  51. package/packages/aws-adapter/tests/connection-counter.test.ts +127 -0
  52. package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
  53. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
  54. package/packages/aws-adapter/tests/handlers.test.ts +105 -3
  55. package/packages/aws-adapter/tests/tables.test.ts +6 -1
  56. package/packages/cf-adapter/package.json +1 -1
  57. package/packages/cf-adapter/src/config-loader.ts +11 -0
  58. package/packages/cf-adapter/src/connection-do.ts +112 -2
  59. package/packages/cf-adapter/src/d1-services-store.ts +703 -0
  60. package/packages/cf-adapter/src/env.ts +8 -0
  61. package/packages/cf-adapter/src/index.ts +5 -0
  62. package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
  63. package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
  64. package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
  65. package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
  66. package/packages/cf-adapter/tests/serialize.test.ts +1 -0
  67. package/packages/in-memory-runtime/package.json +1 -1
  68. package/packages/irc-core/package.json +1 -1
  69. package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
  70. package/packages/irc-core/src/commands/account-auth.ts +172 -0
  71. package/packages/irc-core/src/commands/chanserv.ts +882 -0
  72. package/packages/irc-core/src/commands/hostserv.ts +487 -0
  73. package/packages/irc-core/src/commands/index.ts +12 -0
  74. package/packages/irc-core/src/commands/join.ts +164 -8
  75. package/packages/irc-core/src/commands/markread.ts +202 -0
  76. package/packages/irc-core/src/commands/memoserv.ts +319 -0
  77. package/packages/irc-core/src/commands/mode.ts +96 -4
  78. package/packages/irc-core/src/commands/nickserv.ts +390 -0
  79. package/packages/irc-core/src/commands/oper.ts +18 -1
  80. package/packages/irc-core/src/commands/operserv.ts +346 -0
  81. package/packages/irc-core/src/commands/pre-away.ts +3 -1
  82. package/packages/irc-core/src/commands/privmsg.ts +42 -0
  83. package/packages/irc-core/src/commands/read-marker.ts +8 -8
  84. package/packages/irc-core/src/commands/registration.ts +61 -6
  85. package/packages/irc-core/src/commands/sasl.ts +18 -49
  86. package/packages/irc-core/src/commands/tagmsg.ts +41 -6
  87. package/packages/irc-core/src/commands/topic.ts +37 -0
  88. package/packages/irc-core/src/config.ts +36 -5
  89. package/packages/irc-core/src/effects.ts +56 -1
  90. package/packages/irc-core/src/ports.ts +1653 -84
  91. package/packages/irc-core/src/protocol/numerics.ts +8 -0
  92. package/packages/irc-core/src/state/channel.ts +21 -1
  93. package/packages/irc-core/src/state/connection.ts +25 -1
  94. package/packages/irc-core/src/types.ts +48 -12
  95. package/packages/irc-core/tests/commands/chanserv.test.ts +1668 -0
  96. package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
  97. package/packages/irc-core/tests/commands/hostserv.test.ts +935 -0
  98. package/packages/irc-core/tests/commands/join.test.ts +393 -1
  99. package/packages/irc-core/tests/commands/markread.test.ts +361 -0
  100. package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
  101. package/packages/irc-core/tests/commands/mode.test.ts +381 -2
  102. package/packages/irc-core/tests/commands/nickserv.test.ts +807 -0
  103. package/packages/irc-core/tests/commands/oper.test.ts +13 -0
  104. package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
  105. package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
  106. package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
  107. package/packages/irc-core/tests/commands/registration.test.ts +788 -14
  108. package/packages/irc-core/tests/commands/sasl.test.ts +185 -12
  109. package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
  110. package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
  111. package/packages/irc-core/tests/commands/topic.test.ts +94 -2
  112. package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
  113. package/packages/irc-core/tests/config.test.ts +49 -5
  114. package/packages/irc-core/tests/effects.test.ts +19 -0
  115. package/packages/irc-core/tests/message-store.test.ts +63 -0
  116. package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
  117. package/packages/irc-core/tests/services-store.test.ts +1289 -0
  118. package/packages/irc-core/tests/state/channel.test.ts +3 -0
  119. package/packages/irc-server/package.json +1 -1
  120. package/packages/irc-server/src/actor.ts +71 -16
  121. package/packages/irc-server/src/dispatch.ts +94 -7
  122. package/packages/irc-server/src/routing.ts +19 -0
  123. package/packages/irc-server/tests/actor.test.ts +623 -12
  124. package/packages/irc-server/tests/dispatch.test.ts +270 -2
  125. package/packages/irc-server/tests/routing.test.ts +6 -0
  126. package/packages/irc-test-support/package.json +1 -1
  127. package/packages/irc-test-support/src/in-memory-harness.ts +29 -3
  128. package/packages/irc-test-support/src/index.ts +1 -0
  129. package/packages/irc-test-support/tests/in-memory-harness.test.ts +32 -0
  130. package/tools/ci-hardening/package.json +1 -1
  131. package/tools/load-test/package.json +1 -1
  132. package/tools/tcp-ws-forwarder/package.json +1 -1
  133. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +2 -2
  134. package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
  135. package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
@@ -0,0 +1,882 @@
1
+ /**
2
+ * Pure reducer for the ChanServ services pseudo-client.
3
+ *
4
+ * ChanServ is invoked via `PRIVMSG ChanServ :<command>`. The actor routes
5
+ * any `PRIVMSG` whose target matches {@link CHANSERV_NICK}
6
+ * (case-insensitive) to {@link chanservReducer} instead of the normal
7
+ * user-target reducer.
8
+ *
9
+ * Supported subcommands:
10
+ * - `REGISTER <#channel>` — registers the channel against the bound
11
+ * {@link ServicesStore} with the invoking connection's account as the
12
+ * founder. On success ChanServ sets the channel mode `+r` (registered).
13
+ * The connection must be identified (NickServ `+r` user mode).
14
+ * - `DROP <#channel>` — removes the channel registration (founder-only).
15
+ * Clears the persisted `+r`/`+R`/`+M` channel modes via an
16
+ * `ApplyChannelDelta`.
17
+ * - `INFO <#channel>` — queries the registration record.
18
+ * - `SET <subkey> <args…>` — updates the persisted channel record:
19
+ * - `SET FOUNDER <account>` — transfer founder (identified target
20
+ * that has a registered nick).
21
+ * - `SET MLOCK <modestring>` — lock modes so the MODE reducer
22
+ * reapplies them if an op tries to unset them.
23
+ * - `SET RESTRICTED ON|OFF` — toggle the channel's `+R` mode
24
+ * (block join/PRIVMSG from unidentified).
25
+ * - `SET KEEPTOPIC ON|OFF` — persist the channel topic across
26
+ * empty-recreate via {@link ServicesStore.setChannelTopic}.
27
+ * - `ACCESS <#channel> ADD|DEL|LIST [args]` — manages the per-account
28
+ * access list. `ADD <account> <level>` records a positive integer
29
+ * level; `DEL <account>` removes; `LIST` enumerates founder + every
30
+ * entry. The JOIN reducer consults the list to apply channel
31
+ * prefixes (`@`/`+`) automatically.
32
+ * - `SOP|AOP|HOP|VOP <#channel> ADD|DEL|LIST [account]` — shorthand
33
+ * for the matching numeric level from {@link SHORTHAND_LEVELS}
34
+ * (mirrors the Atheme convention).
35
+ * - `LEVELS <#channel> SET|LIST|RESET [args]` — redefines the numeric
36
+ * threshold the JOIN hook compares against for the `AUTO*` ops.
37
+ * `SET <op> <level>` overrides; `LIST` enumerates; `RESET` clears
38
+ * every override so the {@link DEFAULT_CHANNEL_LEVELS} take effect.
39
+ *
40
+ * The reducer is a pure `Reducer<ConnectionState>`: all side effects are
41
+ * emitted as {@link Effect}s. Channel-mode mutations are emitted as
42
+ * `ApplyChannelDelta` effects (the actor layer applies them to the
43
+ * authoritative {@link ChannelState}).
44
+ */
45
+
46
+ import { caseFold } from '../case-fold.js';
47
+ import { Effect } from '../effects.js';
48
+ import type { Effect as EffectType, RawLine } from '../effects.js';
49
+ import type { ChannelLevelOp, ServicesStore } from '../ports.js';
50
+ import type { ChannelDelta } from '../state/channel.js';
51
+ import type { ConnectionState } from '../state/connection.js';
52
+ import type { Ctx, Reducer } from '../types.js';
53
+
54
+ /** Canonical ChanServ pseudo-client nick. */
55
+ export const CHANSERV_NICK = 'ChanServ';
56
+
57
+ /** Services hostmask fragment used by every services pseudo-client. */
58
+ const SERVICES_HOST = 'services';
59
+
60
+ /** Hostmask used as the source of ChanServ-emitted MODE prefix grants. */
61
+ export const CHANSERV_HOSTMASK = `${CHANSERV_NICK}!${CHANSERV_NICK}@${SERVICES_HOST}`;
62
+
63
+ /** Channel-name grammar; mirrors {@link isValidChannelName} in `join.ts`. */
64
+ const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
65
+
66
+ /** Returns true iff `name` looks like a channel target. */
67
+ function isValidChannelName(name: string, maxLen: number): boolean {
68
+ if (name.length === 0 || name.length > maxLen) return false;
69
+ return CHANNEL_NAME_RE.test(name);
70
+ }
71
+
72
+ /** ON / OFF values accepted by SET RESTRICTED / SET KEEPTOPIC. */
73
+ const ON_OFF = new Set<string>(['ON', 'OFF']);
74
+
75
+ /**
76
+ * Default numeric thresholds for the ChanServ `AUTO*` level checks. The
77
+ * JOIN hook consults these (or the founder-redefined overrides from
78
+ * {@link ServicesStore.getChannelLevel}) to decide whether to grant a
79
+ * channel prefix automatically.
80
+ *
81
+ * The values follow the Atheme convention: VOP (auto-voice) sorts below
82
+ * HOP (auto-halfop; this server has no `+h` prefix, but the level is
83
+ * preserved so founders can tier access) sorts below AOP (auto-op).
84
+ * Channel founders implicitly carry the maximum level (`Infinity`) and
85
+ * always receive `+o` on JOIN.
86
+ */
87
+ export const DEFAULT_CHANNEL_LEVELS: Readonly<Record<ChannelLevelOp, number>> = {
88
+ AUTOVOICE: 3,
89
+ AUTOHALFOP: 4,
90
+ AUTOOP: 5,
91
+ };
92
+
93
+ /**
94
+ * Numeric access levels applied by the `SOP` / `AOP` / `HOP` / `VOP`
95
+ * shorthand commands. The shorthand subcommands exist purely as
96
+ * founder-friendly aliases for the matching numeric level; their values
97
+ * mirror the Atheme defaults so `AOP nick ADD` produces the same access
98
+ * level an explicit `ACCESS nick ADD 5` would.
99
+ *
100
+ * `SOP` has no `AUTO*` threshold wired to it (this server has no `+a`
101
+ * protect prefix); it is nonetheless tracked so founders can tier
102
+ * senior ops above AOPs without a numeric incantation.
103
+ */
104
+ export const SHORTHAND_LEVELS: Readonly<Record<'SOP' | 'AOP' | 'HOP' | 'VOP', number>> = {
105
+ VOP: DEFAULT_CHANNEL_LEVELS.AUTOVOICE,
106
+ HOP: DEFAULT_CHANNEL_LEVELS.AUTOHALFOP,
107
+ AOP: DEFAULT_CHANNEL_LEVELS.AUTOOP,
108
+ SOP: 10,
109
+ };
110
+
111
+ /** Set of recognised shorthand verbs (uppercased). */
112
+ const SHORTHAND_VERBS = new Set<string>(['SOP', 'AOP', 'HOP', 'VOP']);
113
+
114
+ /** Ordered list of level op names, for LEVELS LIST and validation. */
115
+ const LEVEL_OPS: readonly ChannelLevelOp[] = ['AUTOOP', 'AUTOHALFOP', 'AUTOVOICE'];
116
+
117
+ /**
118
+ * Reducer for `PRIVMSG ChanServ :<subcommand> <args…>`.
119
+ * Authority: the invoking connection's {@link ConnectionState}.
120
+ */
121
+ export const chanservReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
122
+ state.lastSeen = ctx.clock.now();
123
+
124
+ const effects: EffectType[] = [];
125
+
126
+ if (ctx.services === undefined) {
127
+ effects.push(notice(state, 'Services are not available on this server.'));
128
+ return { state, effects };
129
+ }
130
+
131
+ const body = msg.params[1] ?? '';
132
+ const tokens = body.split(/\s+/u).filter((t) => t.length > 0);
133
+ if (tokens.length === 0) {
134
+ effects.push(helpNotice(state));
135
+ return { state, effects };
136
+ }
137
+
138
+ const sub = tokens[0]?.toUpperCase();
139
+ const args = tokens.slice(1);
140
+
141
+ switch (sub) {
142
+ case 'REGISTER':
143
+ return handleRegister(state, args, ctx, effects);
144
+ case 'DROP':
145
+ return handleDrop(state, args, ctx, effects);
146
+ case 'INFO':
147
+ return handleInfo(state, args, ctx, effects);
148
+ case 'SET':
149
+ return handleSet(state, args, ctx, effects);
150
+ case 'ACCESS':
151
+ return handleAccess(state, args, ctx, effects);
152
+ case 'LEVELS':
153
+ return handleLevels(state, args, ctx, effects);
154
+ default:
155
+ if (sub !== undefined && SHORTHAND_VERBS.has(sub)) {
156
+ return handleShorthand(state, sub, args, ctx, effects);
157
+ }
158
+ effects.push(unknownNotice(state));
159
+ return { state, effects };
160
+ }
161
+ };
162
+
163
+ // ============================================================================
164
+ // REGISTER
165
+ // ============================================================================
166
+
167
+ function handleRegister(
168
+ state: ConnectionState,
169
+ args: string[],
170
+ ctx: Ctx,
171
+ effects: EffectType[],
172
+ ): { state: ConnectionState; effects: EffectType[] } {
173
+ const services = ctx.services as ServicesStore;
174
+ const channel = args[0];
175
+
176
+ if (channel === undefined || !isValidChannelName(channel, ctx.serverConfig.channelLen)) {
177
+ effects.push(notice(state, 'Syntax: REGISTER <#channel>'));
178
+ return { state, effects };
179
+ }
180
+
181
+ if (state.account === undefined) {
182
+ effects.push(notice(state, 'You must identify before registering a channel.'));
183
+ return { state, effects };
184
+ }
185
+
186
+ const result = services.registerChannel(channel, state.account);
187
+ if (!result.ok) {
188
+ effects.push(notice(state, `Channel ${channel} is already registered.`));
189
+ return { state, effects };
190
+ }
191
+
192
+ // Set channel mode +r via an ApplyChannelDelta effect. The actor layer
193
+ // applies this to the authoritative ChannelState.
194
+ effects.push(Effect.applyChannelDelta(channel, setRegisteredDelta()));
195
+ effects.push(notice(state, `Channel ${channel} is now registered.`));
196
+ return { state, effects };
197
+ }
198
+
199
+ // ============================================================================
200
+ // DROP
201
+ // ============================================================================
202
+
203
+ function handleDrop(
204
+ state: ConnectionState,
205
+ args: string[],
206
+ ctx: Ctx,
207
+ effects: EffectType[],
208
+ ): { state: ConnectionState; effects: EffectType[] } {
209
+ const services = ctx.services as ServicesStore;
210
+ const channel = args[0];
211
+
212
+ if (channel === undefined || !isValidChannelName(channel, ctx.serverConfig.channelLen)) {
213
+ effects.push(notice(state, 'Syntax: DROP <#channel>'));
214
+ return { state, effects };
215
+ }
216
+
217
+ if (state.account === undefined) {
218
+ effects.push(notice(state, 'You must identify before dropping a channel.'));
219
+ return { state, effects };
220
+ }
221
+
222
+ const founder = services.getChannelFounder(channel);
223
+ if (founder === undefined) {
224
+ effects.push(notice(state, `Channel ${channel} is not registered.`));
225
+ return { state, effects };
226
+ }
227
+ if (caseFold('rfc1459', founder) !== caseFold('rfc1459', state.account)) {
228
+ effects.push(notice(state, `Only the founder of ${channel} may drop it.`));
229
+ return { state, effects };
230
+ }
231
+
232
+ services.dropChannel(channel);
233
+ // Clear the services-derived channel modes (r/R/M) on drop so the channel
234
+ // goes back to unregistered behaviour.
235
+ effects.push(Effect.applyChannelDelta(channel, clearServicesModesDelta()));
236
+ effects.push(notice(state, `Channel ${channel} has been dropped.`));
237
+ return { state, effects };
238
+ }
239
+
240
+ // ============================================================================
241
+ // INFO
242
+ // ============================================================================
243
+
244
+ function handleInfo(
245
+ state: ConnectionState,
246
+ args: string[],
247
+ ctx: Ctx,
248
+ effects: EffectType[],
249
+ ): { state: ConnectionState; effects: EffectType[] } {
250
+ const services = ctx.services as ServicesStore;
251
+ const channel = args[0];
252
+
253
+ if (channel === undefined || !isValidChannelName(channel, ctx.serverConfig.channelLen)) {
254
+ effects.push(notice(state, 'Syntax: INFO <#channel>'));
255
+ return { state, effects };
256
+ }
257
+
258
+ const founder = services.getChannelFounder(channel);
259
+ if (founder === undefined) {
260
+ effects.push(notice(state, `Channel ${channel} is not registered.`));
261
+ return { state, effects };
262
+ }
263
+
264
+ effects.push(notice(state, `Channel: ${channel}`));
265
+ effects.push(notice(state, `Founder: ${founder}`));
266
+ return { state, effects };
267
+ }
268
+
269
+ // ============================================================================
270
+ // SET
271
+ // ============================================================================
272
+
273
+ /**
274
+ * Handles `SET <subkey> <args…>`. Routes to the matching subcommand handler.
275
+ * All subcommands require the caller to be the channel founder.
276
+ */
277
+ function handleSet(
278
+ state: ConnectionState,
279
+ args: string[],
280
+ ctx: Ctx,
281
+ effects: EffectType[],
282
+ ): { state: ConnectionState; effects: EffectType[] } {
283
+ const services = ctx.services as ServicesStore;
284
+ const subkey = args[0]?.toUpperCase();
285
+ const channelArg = args[1];
286
+ const rest = args.slice(2);
287
+
288
+ // Validate channel name first; every SET subcommand requires one.
289
+ if (channelArg === undefined || !isValidChannelName(channelArg, ctx.serverConfig.channelLen)) {
290
+ effects.push(setSyntaxNotice(state, subkey));
291
+ return { state, effects };
292
+ }
293
+
294
+ if (state.account === undefined) {
295
+ effects.push(notice(state, 'You must identify before changing channel settings.'));
296
+ return { state, effects };
297
+ }
298
+
299
+ const founder = services.getChannelFounder(channelArg);
300
+ if (founder === undefined) {
301
+ effects.push(notice(state, `Channel ${channelArg} is not registered.`));
302
+ return { state, effects };
303
+ }
304
+ if (caseFold('rfc1459', founder) !== caseFold('rfc1459', state.account)) {
305
+ effects.push(notice(state, `Only the founder of ${channelArg} may change its settings.`));
306
+ return { state, effects };
307
+ }
308
+
309
+ switch (subkey) {
310
+ case 'FOUNDER':
311
+ return handleSetFounder(state, channelArg, rest, ctx, effects);
312
+ case 'MLOCK':
313
+ return handleSetMlock(state, channelArg, rest, ctx, effects);
314
+ case 'RESTRICTED':
315
+ return handleSetRestricted(state, channelArg, rest, ctx, effects);
316
+ case 'KEEPTOPIC':
317
+ return handleSetKeepTopic(state, channelArg, rest, ctx, effects);
318
+ default:
319
+ effects.push(setSyntaxNotice(state, undefined));
320
+ return { state, effects };
321
+ }
322
+ }
323
+
324
+ function handleSetFounder(
325
+ state: ConnectionState,
326
+ channel: string,
327
+ args: string[],
328
+ ctx: Ctx,
329
+ effects: EffectType[],
330
+ ): { state: ConnectionState; effects: EffectType[] } {
331
+ const services = ctx.services as ServicesStore;
332
+ const newFounder = args[0];
333
+ if (newFounder === undefined) {
334
+ effects.push(notice(state, 'Syntax: SET FOUNDER <#channel> <account>'));
335
+ return { state, effects };
336
+ }
337
+ // The new founder must have a registered nick (account exists).
338
+ if (!services.isRegisteredNick(newFounder)) {
339
+ effects.push(notice(state, `Nick ${newFounder} is not registered.`));
340
+ return { state, effects };
341
+ }
342
+ services.setChannelFounder(channel, newFounder);
343
+ effects.push(notice(state, `Founder for ${channel} is now set to ${newFounder}.`));
344
+ return { state, effects };
345
+ }
346
+
347
+ function handleSetMlock(
348
+ state: ConnectionState,
349
+ channel: string,
350
+ args: string[],
351
+ ctx: Ctx,
352
+ effects: EffectType[],
353
+ ): { state: ConnectionState; effects: EffectType[] } {
354
+ const services = ctx.services as ServicesStore;
355
+ // MLOCK accepts a single modestring argument; an empty modestring (or the
356
+ // literal `*` per Atheme convention) clears the lock. The token arrives as
357
+ // args[0]; the splitter would have dropped a truly-empty token, so a `*`
358
+ // sentinel or absent arg means clear.
359
+ const modestring = args[0];
360
+ if (modestring === undefined || modestring === '*') {
361
+ services.setChannelMlock(channel, '');
362
+ effects.push(notice(state, `Mode lock for ${channel} has been cleared.`));
363
+ return { state, effects };
364
+ }
365
+ if (!isValidMlockString(modestring)) {
366
+ effects.push(notice(state, `Invalid mode lock: ${modestring}`));
367
+ return { state, effects };
368
+ }
369
+ services.setChannelMlock(channel, modestring);
370
+ effects.push(notice(state, `Mode lock for ${channel} is now set to ${modestring}.`));
371
+ return { state, effects };
372
+ }
373
+
374
+ function handleSetRestricted(
375
+ state: ConnectionState,
376
+ channel: string,
377
+ args: string[],
378
+ ctx: Ctx,
379
+ effects: EffectType[],
380
+ ): { state: ConnectionState; effects: EffectType[] } {
381
+ const services = ctx.services as ServicesStore;
382
+ const value = args[0]?.toUpperCase();
383
+ if (value === undefined || !ON_OFF.has(value)) {
384
+ effects.push(notice(state, 'Syntax: SET RESTRICTED <#channel> ON|OFF'));
385
+ return { state, effects };
386
+ }
387
+ const on = value === 'ON';
388
+ services.setChannelRestricted(channel, on);
389
+ // SET RESTRICTED toggles the +R channel mode (ChanServ-driven).
390
+ effects.push(
391
+ Effect.applyChannelDelta(channel, { modeChanges: [{ mode: 'blockUnidentified', set: on }] }),
392
+ );
393
+ effects.push(notice(state, `Restricted (block unidentified) for ${channel} is now ${value}.`));
394
+ return { state, effects };
395
+ }
396
+
397
+ function handleSetKeepTopic(
398
+ state: ConnectionState,
399
+ channel: string,
400
+ args: string[],
401
+ ctx: Ctx,
402
+ effects: EffectType[],
403
+ ): { state: ConnectionState; effects: EffectType[] } {
404
+ const services = ctx.services as ServicesStore;
405
+ const value = args[0]?.toUpperCase();
406
+ if (value === undefined || !ON_OFF.has(value)) {
407
+ effects.push(notice(state, 'Syntax: SET KEEPTOPIC <#channel> ON|OFF'));
408
+ return { state, effects };
409
+ }
410
+ const on = value === 'ON';
411
+ services.setChannelKeepTopic(channel, on);
412
+ effects.push(notice(state, `Keep-topic for ${channel} is now ${value}.`));
413
+ return { state, effects };
414
+ }
415
+
416
+ // ============================================================================
417
+ // ACCESS
418
+ // ============================================================================
419
+
420
+ /**
421
+ * Validates the founder invariant for an ACCESS / LEVELS / shorthand
422
+ * subcommand. On success returns the bound {@link ServicesStore}; on
423
+ * failure pushes the matching notice into `effects` and returns `null`.
424
+ *
425
+ * Mirrors the founder-precondition block in {@link handleSet} so the
426
+ * access surface shares a single error-message shape with `SET`.
427
+ */
428
+ function requireFounderForChannel(
429
+ state: ConnectionState,
430
+ channel: string,
431
+ ctx: Ctx,
432
+ effects: EffectType[],
433
+ ): ServicesStore | null {
434
+ const services = ctx.services as ServicesStore;
435
+ if (state.account === undefined) {
436
+ effects.push(notice(state, 'You must identify before changing channel settings.'));
437
+ return null;
438
+ }
439
+ const founder = services.getChannelFounder(channel);
440
+ if (founder === undefined) {
441
+ effects.push(notice(state, `Channel ${channel} is not registered.`));
442
+ return null;
443
+ }
444
+ if (caseFold('rfc1459', founder) !== caseFold('rfc1459', state.account)) {
445
+ effects.push(notice(state, `Only the founder of ${channel} may change its settings.`));
446
+ return null;
447
+ }
448
+ return services;
449
+ }
450
+
451
+ /**
452
+ * Handles `ACCESS <#channel> ADD|DEL|LIST [args]`.
453
+ *
454
+ * - `ACCESS <chan> ADD <account> <level>` — records (or replaces) the
455
+ * numeric access level for `account`. `<level>` must be a positive
456
+ * integer.
457
+ * - `ACCESS <chan> DEL <account>` — removes the access entry.
458
+ * - `ACCESS <chan> LIST` — enumerates the founder and every access
459
+ * entry with its level.
460
+ */
461
+ function handleAccess(
462
+ state: ConnectionState,
463
+ args: string[],
464
+ ctx: Ctx,
465
+ effects: EffectType[],
466
+ ): { state: ConnectionState; effects: EffectType[] } {
467
+ const channel = args[0];
468
+ if (channel === undefined || !isValidChannelName(channel, ctx.serverConfig.channelLen)) {
469
+ effects.push(notice(state, 'Syntax: ACCESS <#channel> ADD|DEL|LIST [args].'));
470
+ return { state, effects };
471
+ }
472
+
473
+ const action = args[1]?.toUpperCase();
474
+ const rest = args.slice(2);
475
+ const services = ctx.services as ServicesStore;
476
+
477
+ if (action === 'LIST') {
478
+ const founder = services.getChannelFounder(channel);
479
+ if (founder === undefined) {
480
+ effects.push(notice(state, `Channel ${channel} is not registered.`));
481
+ return { state, effects };
482
+ }
483
+ effects.push(notice(state, `Channel: ${channel}`));
484
+ effects.push(notice(state, `Founder: ${founder}`));
485
+ for (const { account, level } of services.listChannelAccess(channel)) {
486
+ effects.push(notice(state, `Nick ${account} (level ${level})`));
487
+ }
488
+ effects.push(notice(state, 'End of access list.'));
489
+ return { state, effects };
490
+ }
491
+
492
+ const founderServices = requireFounderForChannel(state, channel, ctx, effects);
493
+ if (founderServices === null) return { state, effects };
494
+
495
+ switch (action) {
496
+ case 'ADD':
497
+ return handleAccessAdd(state, channel, rest, founderServices, effects);
498
+ case 'DEL':
499
+ return handleAccessDel(state, channel, rest, founderServices, effects);
500
+ default:
501
+ effects.push(notice(state, 'Syntax: ACCESS <#channel> ADD|DEL|LIST [args].'));
502
+ return { state, effects };
503
+ }
504
+ }
505
+
506
+ function handleAccessAdd(
507
+ state: ConnectionState,
508
+ channel: string,
509
+ args: string[],
510
+ services: ServicesStore,
511
+ effects: EffectType[],
512
+ ): { state: ConnectionState; effects: EffectType[] } {
513
+ const target = args[0];
514
+ const levelToken = args[1];
515
+ if (target === undefined || levelToken === undefined) {
516
+ effects.push(notice(state, 'Syntax: ACCESS <#channel> ADD <account> <level>.'));
517
+ return { state, effects };
518
+ }
519
+ const level = Number.parseInt(levelToken, 10);
520
+ if (!Number.isInteger(level) || level <= 0) {
521
+ effects.push(notice(state, 'Syntax: ACCESS <#channel> ADD <account> <level>.'));
522
+ return { state, effects };
523
+ }
524
+ if (!services.isRegisteredNick(target)) {
525
+ effects.push(notice(state, `Nick ${target} is not registered.`));
526
+ return { state, effects };
527
+ }
528
+ services.setChannelAccess(channel, target, level);
529
+ effects.push(
530
+ notice(
531
+ state,
532
+ `${capitalize(target)} added to the access list for ${channel} at level ${level}.`,
533
+ ),
534
+ );
535
+ return { state, effects };
536
+ }
537
+
538
+ function handleAccessDel(
539
+ state: ConnectionState,
540
+ channel: string,
541
+ args: string[],
542
+ services: ServicesStore,
543
+ effects: EffectType[],
544
+ ): { state: ConnectionState; effects: EffectType[] } {
545
+ const target = args[0];
546
+ if (target === undefined) {
547
+ effects.push(notice(state, 'Syntax: ACCESS <#channel> DEL <account>.'));
548
+ return { state, effects };
549
+ }
550
+ services.setChannelAccess(channel, target, 0);
551
+ effects.push(notice(state, `${capitalize(target)} removed from the access list for ${channel}.`));
552
+ return { state, effects };
553
+ }
554
+
555
+ // ============================================================================
556
+ // SOP / AOP / HOP / VOP shorthand
557
+ // ============================================================================
558
+
559
+ /**
560
+ * Handles `SOP|AOP|HOP|VOP <#channel> ADD|DEL|LIST [account]` shorthand.
561
+ *
562
+ * The verb selects a fixed numeric level from {@link SHORTHAND_LEVELS}
563
+ * (mirroring the Atheme convention) and routes the request through the
564
+ * canonical {@link handleAccessAdd} / {@link handleAccessDel} helpers so
565
+ * the only divergence between `AOP #chan ADD nick` and the explicit
566
+ * `ACCESS #chan ADD nick 5` is the level source.
567
+ */
568
+ function handleShorthand(
569
+ state: ConnectionState,
570
+ verb: string,
571
+ args: string[],
572
+ ctx: Ctx,
573
+ effects: EffectType[],
574
+ ): { state: ConnectionState; effects: EffectType[] } {
575
+ const channel = args[0];
576
+ if (channel === undefined || !isValidChannelName(channel, ctx.serverConfig.channelLen)) {
577
+ effects.push(notice(state, `Syntax: ${verb} <#channel> ADD|DEL|LIST [account].`));
578
+ return { state, effects };
579
+ }
580
+
581
+ const action = args[1]?.toUpperCase();
582
+ const rest = args.slice(2);
583
+
584
+ if (action === 'LIST') {
585
+ return handleAccess(state, [channel, 'LIST'], ctx, effects);
586
+ }
587
+
588
+ const services = requireFounderForChannel(state, channel, ctx, effects);
589
+ if (services === null) return { state, effects };
590
+
591
+ // `verb` is one of SOP/AOP/HOP/VOP at the dispatch site, so the lookup
592
+ // is total; the cast keeps TS happy without a runtime branch.
593
+ const verbKey = verb as keyof typeof SHORTHAND_LEVELS;
594
+ const level = SHORTHAND_LEVELS[verbKey];
595
+
596
+ switch (action) {
597
+ case 'ADD':
598
+ return handleShorthandAdd(state, verb, channel, rest, level, services, effects);
599
+ case 'DEL':
600
+ return handleShorthandDel(state, verb, channel, rest, services, effects);
601
+ default:
602
+ effects.push(notice(state, `Syntax: ${verb} <#channel> ADD|DEL|LIST [account].`));
603
+ return { state, effects };
604
+ }
605
+ }
606
+
607
+ function handleShorthandAdd(
608
+ state: ConnectionState,
609
+ verb: string,
610
+ channel: string,
611
+ args: string[],
612
+ level: number,
613
+ services: ServicesStore,
614
+ effects: EffectType[],
615
+ ): { state: ConnectionState; effects: EffectType[] } {
616
+ const target = args[0];
617
+ if (target === undefined) {
618
+ effects.push(notice(state, `Syntax: ${verb} <#channel> ADD|DEL|LIST [account].`));
619
+ return { state, effects };
620
+ }
621
+ if (!services.isRegisteredNick(target)) {
622
+ effects.push(notice(state, `Nick ${target} is not registered.`));
623
+ return { state, effects };
624
+ }
625
+ services.setChannelAccess(channel, target, level);
626
+ effects.push(
627
+ notice(
628
+ state,
629
+ `${capitalize(target)} added to the access list for ${channel} at level ${level}.`,
630
+ ),
631
+ );
632
+ return { state, effects };
633
+ }
634
+
635
+ function handleShorthandDel(
636
+ state: ConnectionState,
637
+ verb: string,
638
+ channel: string,
639
+ args: string[],
640
+ services: ServicesStore,
641
+ effects: EffectType[],
642
+ ): { state: ConnectionState; effects: EffectType[] } {
643
+ const target = args[0];
644
+ if (target === undefined) {
645
+ effects.push(notice(state, `Syntax: ${verb} <#channel> ADD|DEL|LIST [account].`));
646
+ return { state, effects };
647
+ }
648
+ services.setChannelAccess(channel, target, 0);
649
+ effects.push(notice(state, `${capitalize(target)} removed from the access list for ${channel}.`));
650
+ return { state, effects };
651
+ }
652
+
653
+ // ============================================================================
654
+ // LEVELS
655
+ // ============================================================================
656
+
657
+ /**
658
+ * Handles `LEVELS <#channel> SET|LIST|RESET [args]`.
659
+ *
660
+ * - `LEVELS <chan> SET <op> <level>` — redefines the numeric threshold
661
+ * the JOIN hook compares against for the `AUTO*` op. `<op>` must be
662
+ * one of {@link LEVEL_OPS}.
663
+ * - `LEVELS <chan> LIST` — enumerates every threshold, falling back
664
+ * to the {@link DEFAULT_CHANNEL_LEVELS} default for ops the founder
665
+ * has not overridden.
666
+ * - `LEVELS <chan> RESET` — drops every override.
667
+ */
668
+ function handleLevels(
669
+ state: ConnectionState,
670
+ args: string[],
671
+ ctx: Ctx,
672
+ effects: EffectType[],
673
+ ): { state: ConnectionState; effects: EffectType[] } {
674
+ const channel = args[0];
675
+ if (channel === undefined || !isValidChannelName(channel, ctx.serverConfig.channelLen)) {
676
+ effects.push(notice(state, 'Syntax: LEVELS <#channel> SET|LIST|RESET [args].'));
677
+ return { state, effects };
678
+ }
679
+
680
+ const action = args[1]?.toUpperCase();
681
+ const services = ctx.services as ServicesStore;
682
+
683
+ if (action === 'LIST') {
684
+ const founder = services.getChannelFounder(channel);
685
+ if (founder === undefined) {
686
+ effects.push(notice(state, `Channel ${channel} is not registered.`));
687
+ return { state, effects };
688
+ }
689
+ effects.push(notice(state, `Channel: ${channel}`));
690
+ const overrides = new Map<ChannelLevelOp, number>();
691
+ for (const { op, level } of services.listChannelLevels(channel)) overrides.set(op, level);
692
+ for (const op of LEVEL_OPS) {
693
+ const level = overrides.get(op) ?? DEFAULT_CHANNEL_LEVELS[op];
694
+ effects.push(notice(state, `${op} ${level}`));
695
+ }
696
+ effects.push(notice(state, 'End of level list.'));
697
+ return { state, effects };
698
+ }
699
+
700
+ const founderServices = requireFounderForChannel(state, channel, ctx, effects);
701
+ if (founderServices === null) return { state, effects };
702
+
703
+ if (action === 'RESET') {
704
+ founderServices.resetChannelLevels(channel);
705
+ effects.push(notice(state, `Levels for ${channel} reset to defaults.`));
706
+ return { state, effects };
707
+ }
708
+
709
+ if (action === 'SET') {
710
+ const opName = args[2]?.toUpperCase();
711
+ const levelToken = args[3];
712
+ if (opName === undefined || levelToken === undefined) {
713
+ effects.push(notice(state, 'Syntax: LEVELS <#channel> SET <op> <level>.'));
714
+ return { state, effects };
715
+ }
716
+ if (!isKnownLevelOp(opName)) {
717
+ effects.push(notice(state, `Unknown level ${opName}. Available: ${LEVEL_OPS.join(', ')}.`));
718
+ return { state, effects };
719
+ }
720
+ const level = Number.parseInt(levelToken, 10);
721
+ if (!Number.isInteger(level)) {
722
+ effects.push(notice(state, 'Syntax: LEVELS <#channel> SET <op> <level>.'));
723
+ return { state, effects };
724
+ }
725
+ founderServices.setChannelLevel(channel, opName, level);
726
+ effects.push(notice(state, `Level ${opName} for ${channel} is now ${level}.`));
727
+ return { state, effects };
728
+ }
729
+
730
+ effects.push(notice(state, 'Syntax: LEVELS <#channel> SET|LIST|RESET [args].'));
731
+ return { state, effects };
732
+ }
733
+
734
+ /** Returns true iff `op` is a recognised level-threshold name. */
735
+ function isKnownLevelOp(op: string): op is ChannelLevelOp {
736
+ return LEVEL_OPS.includes(op as ChannelLevelOp);
737
+ }
738
+
739
+ /**
740
+ * Capitalises the first character of `value` (display helper). Callers
741
+ * pre-validate that `value` is non-empty (the token splitter drops empty
742
+ * args), so the empty-string degenerate case returns the empty string
743
+ * via `charAt(0)` returning `''` — no explicit length check needed.
744
+ */
745
+ function capitalize(value: string): string {
746
+ return value.charAt(0).toUpperCase() + value.slice(1);
747
+ }
748
+
749
+ // ============================================================================
750
+ // Notice helpers
751
+ // ============================================================================
752
+
753
+ /** Builds a `:ChanServ!ChanServ@services NOTICE <nick> :<text>` line. */
754
+ function notice(state: ConnectionState, text: string): EffectType {
755
+ return Effect.send(state.id, [servicesNotice(CHANSERV_NICK, state, text)]);
756
+ }
757
+
758
+ /** Builds a help `:ChanServ!ChanServ@services NOTICE <nick> :…` line. */
759
+ function helpNotice(state: ConnectionState): EffectType {
760
+ return notice(
761
+ state,
762
+ 'Available commands: REGISTER, DROP, INFO, SET, ACCESS, LEVELS, SOP, AOP, HOP, VOP',
763
+ );
764
+ }
765
+
766
+ function unknownNotice(state: ConnectionState): EffectType {
767
+ return notice(
768
+ state,
769
+ 'Unknown command. Available: REGISTER, DROP, INFO, SET, ACCESS, LEVELS, SOP, AOP, HOP, VOP',
770
+ );
771
+ }
772
+
773
+ /** Builds a `SET <subkey>` syntax NOTICE; `subkey === undefined` lists every subcommand. */
774
+ function setSyntaxNotice(state: ConnectionState, subkey: string | undefined): EffectType {
775
+ if (subkey === 'FOUNDER') return notice(state, 'Syntax: SET FOUNDER <#channel> <account>');
776
+ if (subkey === 'MLOCK') return notice(state, 'Syntax: SET MLOCK <#channel> <modestring>');
777
+ if (subkey === 'RESTRICTED') return notice(state, 'Syntax: SET RESTRICTED <#channel> ON|OFF');
778
+ if (subkey === 'KEEPTOPIC') return notice(state, 'Syntax: SET KEEPTOPIC <#channel> ON|OFF');
779
+ return notice(state, 'Available SET subcommands: FOUNDER, MLOCK, RESTRICTED, KEEPTOPIC');
780
+ }
781
+
782
+ /**
783
+ * Formats a services NOTICE line. The source hostmask is
784
+ * `<svc>!<svc>@services` regardless of the deployment's serverName so the
785
+ * pseudo-clients are stable across deployments and easy to identify in
786
+ * client UIs.
787
+ */
788
+ function servicesNotice(svc: string, state: ConnectionState, text: string): RawLine {
789
+ const nick = state.nick ?? '*';
790
+ return { text: `:${svc}!${svc}@${SERVICES_HOST} NOTICE ${nick} :${text}` };
791
+ }
792
+
793
+ // ============================================================================
794
+ // Mode-delta helpers
795
+ // ============================================================================
796
+
797
+ /** Builds a delta that sets the `+r` (registered) channel mode. */
798
+ export function setRegisteredDelta(): ChannelDelta {
799
+ return { modeChanges: [{ mode: 'registered', set: true }] };
800
+ }
801
+
802
+ /** Builds a delta that clears every services-derived channel mode (r/R/M). */
803
+ export function clearServicesModesDelta(): ChannelDelta {
804
+ return {
805
+ modeChanges: [
806
+ { mode: 'registered', set: false },
807
+ { mode: 'blockUnidentified', set: false },
808
+ { mode: 'moderatedIdent', set: false },
809
+ ],
810
+ };
811
+ }
812
+
813
+ // ============================================================================
814
+ // MLOCK validation
815
+ // ============================================================================
816
+
817
+ /**
818
+ * Set of channel-mode letters that may appear in an MLOCK string. Booleans
819
+ * (i/t/n/m/s/p/r/R/M), parameterized-on-set (k/l), and prefix (o/v) plus the
820
+ * list mode (b) are all permitted;ChanServ reapplies only the boolean ones
821
+ * (parameterized modes are sticky via ChanServ-set state, not auto-set on
822
+ * every op MODE).
823
+ */
824
+ const MLOCK_ALLOWED_LETTERS = new Set<string>([
825
+ 'i',
826
+ 't',
827
+ 'n',
828
+ 'm',
829
+ 's',
830
+ 'p',
831
+ 'r',
832
+ 'R',
833
+ 'M',
834
+ 'k',
835
+ 'l',
836
+ 'o',
837
+ 'v',
838
+ 'b',
839
+ ]);
840
+
841
+ /**
842
+ * Returns true iff `modestring` is a syntactically-valid MLOCK argument: an
843
+ * optional leading sign, then one or more allowed mode letters, optionally
844
+ * followed by a space and arguments for parameterized modes (passed through
845
+ * verbatim; ChanServ does not currently reapply them).
846
+ */
847
+ function isValidMlockString(modestring: string): boolean {
848
+ // Allow a bare sign-prefixed modestring (the common case). A leading `+`
849
+ // is implicit when omitted; the canonical form stores the explicit sign.
850
+ let s = modestring;
851
+ if (s.length > 0 && (s[0] === '+' || s[0] === '-')) {
852
+ s = s.slice(1);
853
+ }
854
+ if (s.length === 0) return false;
855
+ for (const ch of s) {
856
+ if (!MLOCK_ALLOWED_LETTERS.has(ch)) return false;
857
+ }
858
+ return true;
859
+ }
860
+
861
+ /**
862
+ * Parses the locked-mode letters out of a stored MLOCK modestring. Returns
863
+ * the set of boolean-mode letters that are positively locked (i.e. signed
864
+ * `+` or unsigned). Used by the MODE reducer to reapply modes an op tried to
865
+ * unset.
866
+ */
867
+ export function positiveMlockLetters(mlock: string): Set<string> {
868
+ const out = new Set<string>();
869
+ let positive = true;
870
+ for (const ch of mlock) {
871
+ if (ch === '+') {
872
+ positive = true;
873
+ continue;
874
+ }
875
+ if (ch === '-') {
876
+ positive = false;
877
+ continue;
878
+ }
879
+ if (positive) out.add(ch);
880
+ }
881
+ return out;
882
+ }