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,319 @@
1
+ /**
2
+ * Pure reducer for the MemoServ services pseudo-client.
3
+ *
4
+ * MemoServ is invoked via `PRIVMSG MemoServ :<command>`. The actor routes
5
+ * any `PRIVMSG` whose target matches {@link MEMOSERV_NICK}
6
+ * (case-insensitive) to {@link memoservReducer} instead of the normal
7
+ * user-target reducer.
8
+ *
9
+ * Supported subcommands:
10
+ * - `SEND <nick> <message>` — records a memo against the recipient's
11
+ * registered account. The caller must be identified (NickServ `+r`).
12
+ * The memo is queued and delivered as a MemoServ NOTICE on the
13
+ * recipient's next NickServ IDENTIFY (handled by the NickServ
14
+ * reducer's `applyIdentify` path).
15
+ * - `LIST` — enumerates the caller's unread memos (id, sender, body).
16
+ * - `READ <n>` — delivers memo #n to the caller as a NOTICE and marks
17
+ * it read.
18
+ * - `DEL <n>` — removes memo #n from the caller's inbox.
19
+ *
20
+ * The reducer is a pure `Reducer<ConnectionState>`: all side effects are
21
+ * emitted as {@link Effect}s. The services store is synchronous (mirrors
22
+ * the {@link AccountStore} / {@link MessageStore} pattern).
23
+ */
24
+
25
+ import { caseFold } from '../case-fold.js';
26
+ import { Effect } from '../effects.js';
27
+ import type { Effect as EffectType, RawLine } from '../effects.js';
28
+ import type { MemoEntry, ServicesStore } from '../ports.js';
29
+ import type { ConnectionState } from '../state/connection.js';
30
+ import type { Ctx, Reducer } from '../types.js';
31
+
32
+ /** Canonical MemoServ pseudo-client nick. */
33
+ export const MEMOSERV_NICK = 'MemoServ';
34
+
35
+ /** Services hostmask fragment used by every services pseudo-client. */
36
+ const SERVICES_HOST = 'services';
37
+
38
+ /**
39
+ * Reducer for `PRIVMSG MemoServ :<subcommand> <args…>`.
40
+ * Authority: the invoking connection's {@link ConnectionState}.
41
+ */
42
+ export const memoservReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
43
+ state.lastSeen = ctx.clock.now();
44
+
45
+ const effects: EffectType[] = [];
46
+
47
+ if (ctx.services === undefined) {
48
+ effects.push(notice(state, 'Services are not available on this server.'));
49
+ return { state, effects };
50
+ }
51
+
52
+ const body = msg.params[1] ?? '';
53
+ // The subcommand is the first whitespace token; the remainder (incl. any
54
+ // embedded spaces) is left intact so memo bodies can contain spaces.
55
+ const firstSpace = body.search(/\s/u);
56
+ let subRaw: string;
57
+ let rest: string;
58
+ if (firstSpace === -1) {
59
+ subRaw = body;
60
+ rest = '';
61
+ } else {
62
+ subRaw = body.slice(0, firstSpace);
63
+ rest = body.slice(firstSpace + 1).trimStart();
64
+ }
65
+
66
+ if (subRaw.length === 0) {
67
+ effects.push(helpNotice(state));
68
+ return { state, effects };
69
+ }
70
+
71
+ const sub = subRaw.toUpperCase();
72
+
73
+ switch (sub) {
74
+ case 'SEND':
75
+ return handleSend(state, rest, ctx, effects);
76
+ case 'LIST':
77
+ return handleList(state, ctx, effects);
78
+ case 'READ':
79
+ return handleRead(state, rest, ctx, effects);
80
+ case 'DEL':
81
+ return handleDel(state, rest, ctx, effects);
82
+ default:
83
+ effects.push(unknownNotice(state));
84
+ return { state, effects };
85
+ }
86
+ };
87
+
88
+ // ============================================================================
89
+ // Queue delivery (NickServ IDENTIFY / SASL login)
90
+ // ============================================================================
91
+
92
+ /**
93
+ * Delivers every queued unread memo for `account` as MemoServ NOTICEs on the
94
+ * given connection, marking each one read as it goes.
95
+ *
96
+ * Invoked from the NickServ `IDENTIFY` path and the SASL `applyResult`
97
+ * success path so an offline `SEND` reaches the recipient on their next
98
+ * login regardless of which authentication route they took. No-op when the
99
+ * account has no unread memos. Mutates `effects` in place by appending.
100
+ */
101
+ export function deliverUnreadMemos(
102
+ state: ConnectionState,
103
+ services: ServicesStore,
104
+ account: string,
105
+ effects: EffectType[],
106
+ ): void {
107
+ const memos = services.listMemos(account);
108
+ const unread = memos.filter((m) => !m.read);
109
+ for (const memo of unread) {
110
+ effects.push(memoNotice(state, memo));
111
+ services.markMemoRead(account, memo.id);
112
+ }
113
+ }
114
+
115
+ // ============================================================================
116
+ // SEND
117
+ // ============================================================================
118
+
119
+ function handleSend(
120
+ state: ConnectionState,
121
+ rest: string,
122
+ ctx: Ctx,
123
+ effects: EffectType[],
124
+ ): { state: ConnectionState; effects: EffectType[] } {
125
+ const services = ctx.services as ServicesStore;
126
+ const account = state.account;
127
+
128
+ if (account === undefined) {
129
+ effects.push(notice(state, 'You must identify before sending a memo.'));
130
+ return { state, effects };
131
+ }
132
+
133
+ // Split into recipient + body. The body keeps its embedded spaces.
134
+ const sep = rest.search(/\s/u);
135
+ let recipientRaw: string;
136
+ let bodyText: string;
137
+ if (sep === -1) {
138
+ recipientRaw = rest;
139
+ bodyText = '';
140
+ } else {
141
+ recipientRaw = rest.slice(0, sep);
142
+ bodyText = rest.slice(sep + 1).trimStart();
143
+ }
144
+
145
+ if (recipientRaw.length === 0 || bodyText.length === 0) {
146
+ effects.push(notice(state, 'Syntax: SEND <nick> <message>'));
147
+ return { state, effects };
148
+ }
149
+
150
+ if (caseFold('rfc1459', recipientRaw) === caseFold('rfc1459', account)) {
151
+ effects.push(notice(state, 'You cannot send a memo to yourself.'));
152
+ return { state, effects };
153
+ }
154
+
155
+ if (!services.isRegisteredNick(recipientRaw)) {
156
+ effects.push(notice(state, `Nick ${recipientRaw} is not registered.`));
157
+ return { state, effects };
158
+ }
159
+
160
+ services.recordMemo({
161
+ from: account,
162
+ to: recipientRaw,
163
+ body: bodyText,
164
+ sentAt: ctx.clock.now(),
165
+ });
166
+ effects.push(notice(state, `Memo sent to ${recipientRaw}.`));
167
+ return { state, effects };
168
+ }
169
+
170
+ // ============================================================================
171
+ // LIST
172
+ // ============================================================================
173
+
174
+ function handleList(
175
+ state: ConnectionState,
176
+ ctx: Ctx,
177
+ effects: EffectType[],
178
+ ): { state: ConnectionState; effects: EffectType[] } {
179
+ const services = ctx.services as ServicesStore;
180
+ const account = state.account;
181
+
182
+ if (account === undefined) {
183
+ effects.push(notice(state, 'You must identify before listing memos.'));
184
+ return { state, effects };
185
+ }
186
+
187
+ const unread = services.listMemos(account).filter((m) => !m.read);
188
+
189
+ if (unread.length === 0) {
190
+ effects.push(notice(state, 'You have no memos.'));
191
+ return { state, effects };
192
+ }
193
+
194
+ effects.push(notice(state, 'Memos:'));
195
+ for (const memo of unread) {
196
+ effects.push(notice(state, `#${memo.id} ${memo.from}: ${memo.body}`));
197
+ }
198
+ return { state, effects };
199
+ }
200
+
201
+ // ============================================================================
202
+ // READ
203
+ // ============================================================================
204
+
205
+ function handleRead(
206
+ state: ConnectionState,
207
+ rest: string,
208
+ ctx: Ctx,
209
+ effects: EffectType[],
210
+ ): { state: ConnectionState; effects: EffectType[] } {
211
+ const services = ctx.services as ServicesStore;
212
+ const account = state.account;
213
+
214
+ if (account === undefined) {
215
+ effects.push(notice(state, 'You must identify before reading memos.'));
216
+ return { state, effects };
217
+ }
218
+
219
+ const parsed = parseMemoNumber(rest);
220
+ if (parsed === undefined) {
221
+ effects.push(notice(state, 'Syntax: READ <number>'));
222
+ return { state, effects };
223
+ }
224
+
225
+ const memo = services.listMemos(account).find((m) => m.id === parsed);
226
+ if (memo === undefined) {
227
+ effects.push(notice(state, `No such memo #${parsed}.`));
228
+ return { state, effects };
229
+ }
230
+
231
+ effects.push(memoNotice(state, memo));
232
+ services.markMemoRead(account, memo.id);
233
+ return { state, effects };
234
+ }
235
+
236
+ // ============================================================================
237
+ // DEL
238
+ // ============================================================================
239
+
240
+ function handleDel(
241
+ state: ConnectionState,
242
+ rest: string,
243
+ ctx: Ctx,
244
+ effects: EffectType[],
245
+ ): { state: ConnectionState; effects: EffectType[] } {
246
+ const services = ctx.services as ServicesStore;
247
+ const account = state.account;
248
+
249
+ if (account === undefined) {
250
+ effects.push(notice(state, 'You must identify before deleting memos.'));
251
+ return { state, effects };
252
+ }
253
+
254
+ const parsed = parseMemoNumber(rest);
255
+ if (parsed === undefined) {
256
+ effects.push(notice(state, 'Syntax: DEL <number>'));
257
+ return { state, effects };
258
+ }
259
+
260
+ const removed = services.deleteMemo(account, parsed);
261
+ if (!removed) {
262
+ effects.push(notice(state, `No such memo #${parsed}.`));
263
+ return { state, effects };
264
+ }
265
+
266
+ effects.push(notice(state, `Deleted memo #${parsed}.`));
267
+ return { state, effects };
268
+ }
269
+
270
+ // ============================================================================
271
+ // Helpers
272
+ // ============================================================================
273
+
274
+ /**
275
+ * Parses a positive-integer memo number from the trailing argument.
276
+ * Returns `undefined` for empty / non-numeric / non-positive input.
277
+ */
278
+ function parseMemoNumber(rest: string): number | undefined {
279
+ const trimmed = rest.trim();
280
+ if (trimmed.length === 0) return undefined;
281
+ if (!/^[1-9][0-9]*$/u.test(trimmed)) return undefined;
282
+ return Number.parseInt(trimmed, 10);
283
+ }
284
+
285
+ // ============================================================================
286
+ // Notice helpers
287
+ // ============================================================================
288
+
289
+ /** Builds a `:MemoServ!MemoServ@services NOTICE <nick> :<text>` line. */
290
+ function notice(state: ConnectionState, text: string): EffectType {
291
+ return Effect.send(state.id, [servicesNotice(text, state.nick)]);
292
+ }
293
+
294
+ /** Builds a help/unknown `:MemoServ!MemoServ@services NOTICE <nick> :…` line. */
295
+ function helpNotice(state: ConnectionState): EffectType {
296
+ return notice(state, 'Available commands: SEND, LIST, READ, DEL');
297
+ }
298
+
299
+ function unknownNotice(state: ConnectionState): EffectType {
300
+ return notice(state, 'Unknown command. Available: SEND, LIST, READ, DEL');
301
+ }
302
+
303
+ /** Builds a `:MemoServ!MemoServ@services NOTICE <nick> :…` line for a memo. */
304
+ function memoNotice(state: ConnectionState, memo: MemoEntry): EffectType {
305
+ return Effect.send(state.id, [
306
+ servicesNotice(`[Memo #${memo.id} from ${memo.from}] ${memo.body}`, state.nick),
307
+ ]);
308
+ }
309
+
310
+ /**
311
+ * Formats a services NOTICE line. The source hostmask is
312
+ * `<svc>!<svc>@services` regardless of the deployment's serverName so the
313
+ * pseudo-clients are stable across deployments and easy to identify in
314
+ * client UIs.
315
+ */
316
+ function servicesNotice(text: string, nick: string | undefined): RawLine {
317
+ const target = nick ?? '*';
318
+ return { text: `:${MEMOSERV_NICK}!${MEMOSERV_NICK}@${SERVICES_HOST} NOTICE ${target} :${text}` };
319
+ }
@@ -43,6 +43,7 @@ import type {
43
43
  import { hostmaskOf } from '../state/connection.js';
44
44
  import type { ConnectionState } from '../state/connection.js';
45
45
  import type { Ctx, Reducer } from '../types.js';
46
+ import { positiveMlockLetters } from './chanserv.js';
46
47
 
47
48
  /** Channel-name grammar; mirrors {@link isValidChannelName} in `join.ts`. */
48
49
  const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
@@ -77,9 +78,24 @@ const BOOLEAN_LETTERS: Readonly<Record<string, BooleanChannelMode>> = {
77
78
  p: 'private',
78
79
  };
79
80
 
81
+ /**
82
+ * Services-only boolean channel mode letters. These appear in the 324 read
83
+ * reply and persist on the channel state, but {@link channelModeReducer}
84
+ * rejects any attempt to set them via `MODE` with `472 ERR_UNKNOWNMODE`
85
+ * (only ChanServ may toggle them via an `ApplyChannelDelta`).
86
+ */
87
+ const SERVICES_BOOLEAN_LETTERS: Readonly<Record<string, BooleanChannelMode>> = {
88
+ r: 'registered',
89
+ R: 'blockUnidentified',
90
+ M: 'moderatedIdent',
91
+ };
92
+
80
93
  /** Boolean channel mode letters in canonical 324 output order. */
81
94
  const BOOLEAN_LETTER_ORDER = ['i', 't', 'n', 'm', 's', 'p'] as const;
82
95
 
96
+ /** Services-only boolean channel mode letters in canonical 324 output order. */
97
+ const SERVICES_BOOLEAN_LETTER_ORDER = ['r', 'R', 'M'] as const;
98
+
83
99
  /** Prefix channel mode letters (always take a nick arg). */
84
100
  const PREFIX_LETTERS = new Set<string>(['o', 'v']);
85
101
 
@@ -199,6 +215,13 @@ export const channelModeReducer: Reducer<ChannelState> = (state, msg, ctx) => {
199
215
  effects.push(Effect.broadcast(state.name, [buildModeBroadcast(hostmask, state.name, applied)]));
200
216
  }
201
217
 
218
+ // ChanServ MLOCK reapplication: after the op's MODE applied, check the
219
+ // registered channel's locked modes. Any positively-locked boolean mode
220
+ // that is now unset (whether by this MODE or by a prior state) is
221
+ // reapplied on top via a ChanServ ApplyChannelDelta + broadcast, so an
222
+ // op cannot bypass the lock.
223
+ effects.push(...reapplyMlock(state, ctx));
224
+
202
225
  return { state, effects };
203
226
  };
204
227
 
@@ -436,6 +459,64 @@ function buildModeBroadcast(hostmask: string, chanName: string, applied: Applied
436
459
  return { text: `${lead} ${args.join(' ')}` };
437
460
  }
438
461
 
462
+ // ============================================================================
463
+ // ChanServ MLOCK reapplication
464
+ // ============================================================================
465
+
466
+ /**
467
+ * Hostmask used as the source of ChanServ-emitted MODE reapplies. Mirrors
468
+ * the `:ChanServ!ChanServ@services` pseudo-client source used by the
469
+ * ChanServ reducer's NOTICE lines.
470
+ */
471
+ const CHANSERV_HOSTMASK = 'ChanServ!ChanServ@services';
472
+
473
+ /**
474
+ * Returns the effects to emit when ChanServ's MLOCK must reapply locked
475
+ * boolean modes after an op's MODE has applied.
476
+ *
477
+ * Loads the channel's locked modestring from the bound {@link ServicesStore}
478
+ * (no-op when services are unbound, the channel is not registered, or the
479
+ * MLOCK is empty). For every positively-locked boolean mode letter that is
480
+ * currently unset on the channel state, emits:
481
+ * - an `ApplyChannelDelta` re-applying the mode,
482
+ * - a `Broadcast` of `:ChanServ!ChanServ@services MODE <chan> +<letters>`
483
+ * so clients observe ChanServ reasserting the lock.
484
+ *
485
+ * Services-only modes (r/R/M) are also defended here, so a registered
486
+ * channel's `+r` survives even if a future code path applies an `ApplyChannelDelta`
487
+ * clearing it.
488
+ *
489
+ * Returns an empty array when nothing needs to be reapplied.
490
+ */
491
+ function reapplyMlock(state: ChannelState, ctx: Ctx): EffectType[] {
492
+ const services = ctx.services;
493
+ if (services === undefined) return [];
494
+ const rec = services.getChannel(state.nameLower);
495
+ if (rec === undefined) return [];
496
+ if (rec.mlock.length === 0) return [];
497
+
498
+ const locked = positiveMlockLetters(rec.mlock);
499
+ if (locked.size === 0) return [];
500
+
501
+ const toReapply: AppliedMode[] = [];
502
+ const delta: ChannelDelta = {};
503
+ for (const letter of locked) {
504
+ const field = BOOLEAN_LETTERS[letter] ?? SERVICES_BOOLEAN_LETTERS[letter];
505
+ if (field === undefined) continue;
506
+ if (state.modes[field]) continue;
507
+ state.modes[field] = true;
508
+ pushModeChange(delta, field, true);
509
+ toReapply.push({ sign: '+', letter });
510
+ }
511
+
512
+ if (toReapply.length === 0) return [];
513
+
514
+ return [
515
+ Effect.applyChannelDelta(state.name, delta),
516
+ Effect.broadcast(state.name, [buildModeBroadcast(CHANSERV_HOSTMASK, state.name, toReapply)]),
517
+ ];
518
+ }
519
+
439
520
  // ============================================================================
440
521
  // Channel MODE — read / query helpers
441
522
  // ============================================================================
@@ -479,6 +560,12 @@ function buildReadModeString(state: ChannelState): string {
479
560
  const field = BOOLEAN_LETTERS[letter];
480
561
  if (field !== undefined && state.modes[field]) modeStr += letter;
481
562
  }
563
+ // Services-only boolean modes (r/R/M) surface in the read reply when
564
+ // ChanServ has set them; they cannot be toggled via MODE.
565
+ for (const letter of SERVICES_BOOLEAN_LETTER_ORDER) {
566
+ const field = SERVICES_BOOLEAN_LETTERS[letter];
567
+ if (field !== undefined && state.modes[field]) modeStr += letter;
568
+ }
482
569
  if (state.modes.key !== undefined) {
483
570
  modeStr += 'k';
484
571
  args.push(state.modes.key);
@@ -528,7 +615,7 @@ const USER_MODE_LETTERS: Readonly<Record<string, keyof ConnectionState['userMode
528
615
  };
529
616
 
530
617
  /** User-mode letters in canonical 221 output order. */
531
- const USER_MODE_LETTER_ORDER = ['i', 'w', 's', 'o', 'S'] as const;
618
+ const USER_MODE_LETTER_ORDER = ['i', 'w', 's', 'o', 'r', 'S'] as const;
532
619
 
533
620
  /**
534
621
  * Handles `MODE <nick> [<modestring>]`.
@@ -640,13 +727,18 @@ export const userModeReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
640
727
  function buildUserModeString(state: ConnectionState): string {
641
728
  let modeStr = '+';
642
729
  for (const letter of USER_MODE_LETTER_ORDER) {
643
- // `S` (TLS connected) is read-only: it is not in USER_MODE_LETTERS
644
- // (the write-side map the parser consults) so `MODE nick +S` rejects
645
- // with 501. Surface it on the read side directly from its field.
730
+ // `S` (TLS connected) and `r` (registered) are read-only: neither is
731
+ // in USER_MODE_LETTERS (the write-side map the parser consults) so
732
+ // `MODE nick +S` / `MODE nick +r` reject with 501. Surface them on
733
+ // the read side directly from their fields.
646
734
  if (letter === 'S') {
647
735
  if (state.userModes.tls) modeStr += 'S';
648
736
  continue;
649
737
  }
738
+ if (letter === 'r') {
739
+ if (state.userModes.registered) modeStr += 'r';
740
+ continue;
741
+ }
650
742
  const field = USER_MODE_LETTERS[letter];
651
743
  if (field !== undefined && state.userModes[field]) modeStr += letter;
652
744
  }