serverless-ircd 0.8.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 (80) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/CHANGELOG.md +245 -0
  3. package/README.md +160 -200
  4. package/apps/aws-stack/package.json +1 -1
  5. package/apps/cf-tcp-container/package.json +1 -1
  6. package/apps/cf-tcp-container/src/container-server.ts +21 -1
  7. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  8. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  9. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  10. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  11. package/apps/cf-worker/package.json +1 -1
  12. package/apps/local-cli/package.json +1 -1
  13. package/apps/local-cli/src/server.ts +94 -31
  14. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  15. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  16. package/apps/local-cli/tests/rehash.test.ts +147 -0
  17. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  18. package/apps/local-cli/tests/tcp.test.ts +89 -0
  19. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  20. package/apps/web/landing/index.html +226 -3
  21. package/apps/web/package.json +2 -1
  22. package/apps/web/scripts/build.mjs +25 -2
  23. package/apps/web/src/render-docs.ts +292 -0
  24. package/apps/web/tests/build-smoke.test.ts +31 -2
  25. package/apps/web/tests/landing-content.test.ts +103 -0
  26. package/apps/web/tests/render-docs.test.ts +198 -0
  27. package/docs/AWS-Adapter-Architecture.md +3 -2
  28. package/docs/Services.md +33 -1
  29. package/package.json +2 -2
  30. package/packages/aws-adapter/package.json +1 -1
  31. package/packages/aws-adapter/src/aws-runtime.ts +15 -1
  32. package/packages/aws-adapter/src/handlers/nlb-stream.ts +10 -2
  33. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  34. package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
  35. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  36. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  37. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  38. package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
  39. package/packages/cf-adapter/package.json +1 -1
  40. package/packages/cf-adapter/src/connection-do.ts +18 -6
  41. package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
  42. package/packages/in-memory-runtime/package.json +1 -1
  43. package/packages/irc-core/package.json +1 -1
  44. package/packages/irc-core/src/commands/account-auth.ts +46 -18
  45. package/packages/irc-core/src/commands/chanserv.ts +288 -4
  46. package/packages/irc-core/src/commands/hostserv.ts +38 -3
  47. package/packages/irc-core/src/commands/index.ts +1 -0
  48. package/packages/irc-core/src/commands/join.ts +41 -35
  49. package/packages/irc-core/src/commands/nickserv.ts +16 -4
  50. package/packages/irc-core/src/commands/registration.ts +27 -16
  51. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  52. package/packages/irc-core/src/commands/topic.ts +23 -10
  53. package/packages/irc-core/src/state/channel.ts +17 -0
  54. package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
  55. package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
  56. package/packages/irc-core/tests/commands/join.test.ts +179 -0
  57. package/packages/irc-core/tests/commands/nickserv.test.ts +185 -2
  58. package/packages/irc-core/tests/commands/registration.test.ts +227 -6
  59. package/packages/irc-core/tests/commands/sasl.test.ts +44 -0
  60. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  61. package/packages/irc-server/package.json +1 -1
  62. package/packages/irc-server/src/actor.ts +80 -30
  63. package/packages/irc-server/tests/actor.test.ts +365 -3
  64. package/packages/irc-test-support/package.json +1 -1
  65. package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
  66. package/packages/irc-test-support/src/scenarios.ts +21 -6
  67. package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
  68. package/packages/irc-test-support/vitest.config.ts +6 -1
  69. package/tools/ci-hardening/package.json +1 -1
  70. package/tools/load-test/package.json +1 -1
  71. package/tools/load-test/src/client.ts +13 -13
  72. package/tools/load-test/tests/client.test.ts +258 -2
  73. package/tools/load-test/tests/config.test.ts +39 -0
  74. package/tools/load-test/tests/harness.test.ts +21 -0
  75. package/tools/load-test/tests/metrics.test.ts +7 -0
  76. package/tools/tcp-ws-forwarder/package.json +1 -1
  77. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  78. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  79. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
  80. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
@@ -18,6 +18,7 @@ import { type ChanName, type Nick, createConnection } from '@serverless-ircd/irc
18
18
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
19
19
  import { cleanupConnection } from '../src/aws-runtime.js';
20
20
  import { TABLE_DEFS } from '../src/cdk-table-defs.js';
21
+ import { CONNECTION_COUNT_META_ID } from '../src/connection-counter.js';
21
22
  import { createDynamoDocumentClient } from '../src/dynamo.js';
22
23
  import { type SweepParams, type SweepResult, handleSweep } from '../src/handlers/sweeper.js';
23
24
  import { marshalChannelMember, marshalConnection, marshalNick } from '../src/serialize.js';
@@ -424,4 +425,23 @@ describe('gone-connection sweeper — scan edge cases', () => {
424
425
  expect(result.scanned).toBe(1);
425
426
  expect(result.swept).toBe(0);
426
427
  });
428
+
429
+ it('skips the admission-counter meta row (never swept, never counted)', async () => {
430
+ // The atomic admission counter lives in the Connections table under a
431
+ // sentinel id. The sweeper must not treat it as a stale connection —
432
+ // it carries no idleSince and must never be torn down by the sweeper.
433
+ const client = new FakeScanDynamo([
434
+ {
435
+ items: [
436
+ { connectionId: CONNECTION_COUNT_META_ID, count: 7 },
437
+ { connectionId: 'real', idleSince: 0 },
438
+ ],
439
+ },
440
+ ]);
441
+ const result = await handleSweep(fakeSweepParams(client));
442
+ // The meta row is excluded from both `scanned` and `swept`; only the
443
+ // genuine stale connection is counted.
444
+ expect(result.scanned).toBe(1);
445
+ expect(result.swept).toBe(1);
446
+ });
427
447
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/cf-adapter",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "description": "Cloudflare Workers adapter: Durable Objects (ConnectionDO, RegistryDO, ChannelDO) and CfRuntime implementing IrcRuntime",
6
6
  "license": "BSD-3-Clause",
@@ -827,7 +827,7 @@ const DEFAULT_MOTD: string[] = ['Welcome to the ServerlessIRCd Cloudflare adapte
827
827
  * synchronous reducers can mutate `members` (a Map) and `banMasks` /
828
828
  * `pendingInvites` (Sets) in place.
829
829
  */
830
- class PassthroughChannelAccess {
830
+ export class PassthroughChannelAccess {
831
831
  private readonly cache = new Map<string, ChannelState>();
832
832
  constructor(
833
833
  private readonly now: number,
@@ -879,11 +879,23 @@ class PassthroughChannelAccess {
879
879
  createdAt: number;
880
880
  } | null;
881
881
  if (dto === null) return;
882
- // Don't replace the cache with an empty snapshot the channel
883
- // effectively doesn't exist yet, and the local reducer should lazily
884
- // seed it via getOrCreateChannel. This also avoids clobbering the
885
- // local cache with ChannelDO's "name unknown" default state.
886
- if (dto.members.length === 0) return;
882
+ // When the authoritative ChannelDO reports zero members (the
883
+ // post-deploy state), don't replace the cache with an empty roster
884
+ // the local reducer should lazily seed memberships via
885
+ // getOrCreateChannel. But DO propagate the persisted topic / modes /
886
+ // ban list onto an existing cache entry so the JOIN reducer sees the
887
+ // survived topic and emits 332/333. Mirrors the AWS adapter's
888
+ // LambdaChannelAccess.refreshChannel empty-members branch.
889
+ if (dto.members.length === 0) {
890
+ const existing = this.cache.get(key);
891
+ if (existing !== undefined) {
892
+ existing.modes = { ...dto.modes };
893
+ existing.banMasks = new Set(dto.banMasks);
894
+ existing.pendingInvites = new Set(dto.pendingInvites);
895
+ if (dto.topic !== undefined) existing.topic = dto.topic;
896
+ }
897
+ return;
898
+ }
887
899
  const members = new Map<ConnId, { conn: ConnId; nick: string; op: boolean; voice: boolean }>();
888
900
  for (const entry of dto.members) {
889
901
  members.set(entry.conn, {
@@ -89,3 +89,133 @@ describe('wsFrameModeFromTags', () => {
89
89
  expect(wsFrameModeFromTags(['ws:spec-text', 'ws:legacy'])).toBe('spec-text');
90
90
  });
91
91
  });
92
+
93
+ // ============================================================================
94
+ // PassthroughChannelAccess.refreshChannel
95
+ // ============================================================================
96
+ //
97
+ // refreshChannel pulls the authoritative snapshot from the ChannelDO and
98
+ // rebuilds (or updates) the local cache entry. The zero-members branch is
99
+ // the post-deploy state: the ChannelDO persisted a topic across the
100
+ // deploy but its roster is empty, so the local cache must still absorb
101
+ // the topic/modes/banMasks or the JOIN reducer runs against a topic-less
102
+ // state and never emits 332/333.
103
+ // ============================================================================
104
+
105
+ import { PassthroughChannelAccess } from '../src/connection-do';
106
+ import type { Env } from '../src/env';
107
+
108
+ const EMPTY_MODES = {
109
+ inviteOnly: false,
110
+ topicLock: false,
111
+ noExternal: false,
112
+ moderated: false,
113
+ secret: false,
114
+ private: false,
115
+ registered: false,
116
+ blockUnidentified: false,
117
+ moderatedIdent: false,
118
+ };
119
+
120
+ /** Builds a fake Env whose CHANNEL_DO returns `snapshot` from getChannelSnapshot. */
121
+ function makeEnvWithSnapshot(snapshot: unknown): Env {
122
+ const fakeChannelDo = {
123
+ idFromName: (key: string) => `id-${key}`,
124
+ get: () => ({ getChannelSnapshot: async () => snapshot }),
125
+ };
126
+ return { CHANNEL_DO: fakeChannelDo } as unknown as Env;
127
+ }
128
+
129
+ describe('PassthroughChannelAccess.refreshChannel', () => {
130
+ it('propagates topic/modes/banMasks onto the cached entry when the snapshot reports zero members', async () => {
131
+ const snap = {
132
+ modes: { ...EMPTY_MODES, topicLock: true },
133
+ banMasks: ['bad!*@*'],
134
+ topic: { text: 'survived deploy', setter: 'alice!alice@example.com', setAt: 1234 },
135
+ members: [],
136
+ pendingInvites: [],
137
+ createdAt: 0,
138
+ };
139
+ const env = makeEnvWithSnapshot(snap);
140
+ const access = new PassthroughChannelAccess(0, env);
141
+ const chan = access.getOrCreateChannel('#foo');
142
+ expect(chan.topic).toBeUndefined();
143
+
144
+ await access.refreshChannel('#foo');
145
+
146
+ expect(chan.topic?.text).toBe('survived deploy');
147
+ expect(chan.topic?.setter).toBe('alice!alice@example.com');
148
+ expect(chan.topic?.setAt).toBe(1234);
149
+ expect(chan.modes.topicLock).toBe(true);
150
+ expect(chan.banMasks.has('bad!*@*')).toBe(true);
151
+ });
152
+
153
+ it('leaves the cached topic unset when a zero-members snapshot carries no topic', async () => {
154
+ const snap = {
155
+ modes: { ...EMPTY_MODES },
156
+ banMasks: [],
157
+ members: [],
158
+ pendingInvites: [],
159
+ createdAt: 0,
160
+ };
161
+ const env = makeEnvWithSnapshot(snap);
162
+ const access = new PassthroughChannelAccess(0, env);
163
+ const chan = access.getOrCreateChannel('#foo');
164
+
165
+ await access.refreshChannel('#foo');
166
+
167
+ expect(chan.topic).toBeUndefined();
168
+ });
169
+
170
+ it('does not seed a cache entry when the snapshot reports zero members and none exists', async () => {
171
+ const snap = {
172
+ modes: { ...EMPTY_MODES },
173
+ banMasks: [],
174
+ topic: { text: 'x', setter: 's', setAt: 0 },
175
+ members: [],
176
+ pendingInvites: [],
177
+ createdAt: 0,
178
+ };
179
+ const env = makeEnvWithSnapshot(snap);
180
+ const access = new PassthroughChannelAccess(0, env);
181
+
182
+ await access.refreshChannel('#never');
183
+
184
+ // No cache entry was created: getOrCreateChannel now seeds a fresh one
185
+ // with no topic (the zero-members refresh must not seed a new entry).
186
+ const chan = access.getOrCreateChannel('#never');
187
+ expect(chan.topic).toBeUndefined();
188
+ });
189
+
190
+ it('rebuilds the cached entry from a snapshot with members', async () => {
191
+ const snap = {
192
+ modes: { ...EMPTY_MODES, secret: true },
193
+ banMasks: [],
194
+ topic: { text: 'hi', setter: 's', setAt: 9 },
195
+ members: [{ conn: 'c0', nick: 'alice', op: true, voice: false }],
196
+ pendingInvites: [],
197
+ createdAt: 7,
198
+ };
199
+ const env = makeEnvWithSnapshot(snap);
200
+ const access = new PassthroughChannelAccess(0, env);
201
+
202
+ await access.refreshChannel('#foo');
203
+
204
+ const chan = access.getOrCreateChannel('#foo');
205
+ expect(chan.topic?.text).toBe('hi');
206
+ expect(chan.modes.secret).toBe(true);
207
+ expect(chan.members.size).toBe(1);
208
+ expect(chan.members.get('c0')?.nick).toBe('alice');
209
+ });
210
+
211
+ it('is a no-op when the ChannelDO snapshot is null', async () => {
212
+ const env = makeEnvWithSnapshot(null);
213
+ const access = new PassthroughChannelAccess(0, env);
214
+ const chan = access.getOrCreateChannel('#foo');
215
+ chan.topic = { text: 'keep', setter: 's', setAt: 1 };
216
+
217
+ await access.refreshChannel('#foo');
218
+
219
+ expect(chan.topic?.text).toBe('keep');
220
+ });
221
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/in-memory-runtime",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "description": "Single-process reference implementation of the IrcRuntime port, backed by Maps",
6
6
  "license": "BSD-3-Clause",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/irc-core",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "description": "Platform-agnostic IRC protocol core: parser, serializer, command reducers, state shapes",
6
6
  "license": "BSD-3-Clause",
@@ -25,6 +25,7 @@ import { hostmaskOf } from '../state/connection.js';
25
25
  import type { ConnectionState } from '../state/connection.js';
26
26
  import type { Ctx } from '../types.js';
27
27
  import { emitAccountNotify } from './account-notify.js';
28
+ import { applyAssignedVhost } from './hostserv.js';
28
29
  import { deliverUnreadMemos } from './memoserv.js';
29
30
  import { nowAwayLine, replayPersistedAway } from './pre-away.js';
30
31
  import { seedReadMarkers } from './read-marker.js';
@@ -35,11 +36,14 @@ import { seedReadMarkers } from './read-marker.js';
35
36
  *
36
37
  * Mutates `state`: sets `state.account`, stamps read-only user mode `r`
37
38
  * (`userModes.registered = true`), seeds `lastReadMarkers` from the bound
38
- * services store, replays the persisted away reason, and returns the
39
- * effects the caller must emit:
39
+ * services store, replays the persisted away reason, re-applies any assigned
40
+ * HostServ vhost (so a reconnecting user does not have to re-run
41
+ * `HostServ ON`), and returns the effects the caller must emit:
40
42
  * - `Send` carrying `900 RPL_LOGGEDIN` + `903 RPL_SASLSUCCESS`;
41
43
  * - an optional `306 RPL_NOWAWAY` when an away reason was replayed;
42
44
  * - any queued MemoServ `NOTICE`s delivered from the services store;
45
+ * - an optional `CHGHOST` fanout when a vhost was applied post-JOIN (empty
46
+ * at SASL / PASS-auth time — no channels joined yet);
43
47
  * - the `account-notify` `ACCOUNT` broadcasts to cap-enabled peers in
44
48
  * shared channels (empty at identify time — no channels joined yet).
45
49
  *
@@ -54,11 +58,17 @@ export function applyAccountSuccess(
54
58
  // A successful account login is equivalent to NickServ IDENTIFY: stamp
55
59
  // read-only user mode `r` so `221`/WHOIS reflect the identified state.
56
60
  state.userModes.registered = true;
57
- // IRCv3 draft/read-marker: restore the user's per-channel last-read
58
- // markers from the persisted store so a reconnect resumes at their last
59
- // read position. No-op when no services store is bound.
61
+ // HostServ vhost: re-apply any assigned vhost so a reconnecting user with
62
+ // a vhost does not have to re-run `HostServ ON` every session. Collected
63
+ // into a local array because the CHGHOST fanout is only non-empty when the
64
+ // connection has joined channels (never at SASL/PASS-auth time).
65
+ const vhostEffects: EffectType[] = [];
60
66
  if (ctx.services !== undefined) {
67
+ // IRCv3 draft/read-marker: restore the user's per-channel last-read
68
+ // markers from the persisted store so a reconnect resumes at their last
69
+ // read position. No-op when no services store is bound.
61
70
  seedReadMarkers(state, ctx.services, account);
71
+ applyAssignedVhost(state, ctx.services, account, vhostEffects);
62
72
  }
63
73
  // IRCv3 draft/pre-away: replay the user's persisted away reason onto the
64
74
  // fresh connection and emit 306 so the user sees they are still away.
@@ -78,6 +88,7 @@ export function applyAccountSuccess(
78
88
  Effect.send(ctx.connId, [loggedInLine(ctx, account), saslSuccessLine(ctx)]),
79
89
  ...awayEffects,
80
90
  ...memoEffects,
91
+ ...vhostEffects,
81
92
  ...emitAccountNotify({ conn: state, account }),
82
93
  ];
83
94
  }
@@ -101,18 +112,31 @@ export function parsePassAccountAttempt(
101
112
  /**
102
113
  * Attempts PASS-based account authentication at registration completion.
103
114
  *
104
- * Recognises the `<nick>:<password>` form in `state.passAttempt`, verifies
105
- * it against the bound {@link AccountStore} (PLAIN), and on success runs
106
- * the shared {@link applyAccountSuccess} chain. Returns the success
107
- * effects, or `[]` for every other outcome:
115
+ * Thin wrapper around {@link attemptPassAccountAuth} that reads the stashed
116
+ * `state.passAttempt`. See {@link attemptPassAccountAuth} for the full
117
+ * outcome matrix.
118
+ */
119
+ export function passBasedAccountAuth(state: ConnectionState, ctx: Ctx): EffectType[] {
120
+ return attemptPassAccountAuth(state, ctx, state.passAttempt);
121
+ }
122
+
123
+ /**
124
+ * Shared verify + {@link applyAccountSuccess} pipeline for a raw
125
+ * `<nick>:<password>` attempt string.
126
+ *
127
+ * Used by both {@link passBasedAccountAuth} (at registration completion,
128
+ * reading `state.passAttempt`) and the post-registration late-PASS login
129
+ * in `passReducer` (reading the `PASS` param directly), so the two entry
130
+ * points share one verify+success path and never diverge.
131
+ *
132
+ * Returns the account-login success effects (`900`/`903`/`+r`/read-marker
133
+ * seeding/away replay/memo delivery/`account-notify`), or `[]` for every
134
+ * other outcome:
108
135
  * - already identified (`state.account` set, e.g. via SASL) → no-op;
109
- * - no `<nick>:<password>` form (bare value) → left to the server-password
110
- * gate;
111
- * - payload nick does not match `state.nick` (when set) not a coherent
112
- * account attempt, treated as failed auth;
113
- * - no {@link AccountStore} bound → the `<nick>:<password>` form is
114
- * ignored (collapses to the server-password gate);
115
- * - verify failure (unknown nick or wrong password) → no account effects.
136
+ * - no `<nick>:<password>` form (bare value or undefined) → no-op;
137
+ * - payload nick does not match `state.nick` (when set) → no-op;
138
+ * - no {@link AccountStore} boundno-op;
139
+ * - verify failure (unknown nick or wrong password) → no-op.
116
140
  *
117
141
  * A failed verify is followed by a verify against a fixed dummy entry so
118
142
  * the failure path performs the same AccountStore work whether the payload
@@ -120,9 +144,13 @@ export function parsePassAccountAttempt(
120
144
  * (no numerics, no disconnect, no state change) is identical for both
121
145
  * cases, giving no information to a remote attacker.
122
146
  */
123
- export function passBasedAccountAuth(state: ConnectionState, ctx: Ctx): EffectType[] {
147
+ export function attemptPassAccountAuth(
148
+ state: ConnectionState,
149
+ ctx: Ctx,
150
+ attempt: string | undefined,
151
+ ): EffectType[] {
124
152
  if (state.account !== undefined) return [];
125
- const parsed = parsePassAccountAttempt(state.passAttempt);
153
+ const parsed = parsePassAccountAttempt(attempt);
126
154
  if (parsed === null) return [];
127
155
  if (state.nick !== undefined && state.nick !== parsed.nick) return [];
128
156
  const store = ctx.accounts;
@@ -47,8 +47,8 @@ import { caseFold } from '../case-fold.js';
47
47
  import { Effect } from '../effects.js';
48
48
  import type { Effect as EffectType, RawLine } from '../effects.js';
49
49
  import type { ChannelLevelOp, ServicesStore } from '../ports.js';
50
- import type { ChannelDelta } from '../state/channel.js';
51
- import type { ConnectionState } from '../state/connection.js';
50
+ import type { ChannelDelta, Roster } from '../state/channel.js';
51
+ import type { ConnId, ConnectionState } from '../state/connection.js';
52
52
  import type { Ctx, Reducer } from '../types.js';
53
53
 
54
54
  /** Canonical ChanServ pseudo-client nick. */
@@ -151,6 +151,32 @@ export const chanservReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
151
151
  return handleAccess(state, args, ctx, effects);
152
152
  case 'LEVELS':
153
153
  return handleLevels(state, args, ctx, effects);
154
+ case 'OP':
155
+ return handlePrefixCommand(state, args, ctx, effects, { verb: 'OP', field: 'op', set: true });
156
+ case 'DEOP':
157
+ return handlePrefixCommand(state, args, ctx, effects, {
158
+ verb: 'DEOP',
159
+ field: 'op',
160
+ set: false,
161
+ });
162
+ case 'VOICE':
163
+ return handlePrefixCommand(state, args, ctx, effects, {
164
+ verb: 'VOICE',
165
+ field: 'voice',
166
+ set: true,
167
+ });
168
+ case 'DEVOICE':
169
+ return handlePrefixCommand(state, args, ctx, effects, {
170
+ verb: 'DEVOICE',
171
+ field: 'voice',
172
+ set: false,
173
+ });
174
+ case 'KICK':
175
+ return handleKick(state, args, ctx, effects);
176
+ case 'BAN':
177
+ return handleBanMask(state, args, ctx, effects, { verb: 'BAN', add: true });
178
+ case 'UNBAN':
179
+ return handleBanMask(state, args, ctx, effects, { verb: 'UNBAN', add: false });
154
180
  default:
155
181
  if (sub !== undefined && SHORTHAND_VERBS.has(sub)) {
156
182
  return handleShorthand(state, sub, args, ctx, effects);
@@ -736,6 +762,264 @@ function isKnownLevelOp(op: string): op is ChannelLevelOp {
736
762
  return LEVEL_OPS.includes(op as ChannelLevelOp);
737
763
  }
738
764
 
765
+ // ============================================================================
766
+ // OP / DEOP / VOICE / DEVOICE / KICK / BAN / UNBAN
767
+ // ============================================================================
768
+ //
769
+ // These six commands emit `ApplyChannelDelta` effects the actor layer applies
770
+ // to the authoritative `ChannelState`. They operate on `ConnectionState`
771
+ // (the ChanServ reducer's authority), so they cannot read or mutate the
772
+ // roster directly — prefix/kick deltas carry a `targetNick` hint the actor
773
+ // resolves against the channel roster at apply time. See
774
+ // `resolveMembershipTarget` below and the {@link MembershipDelta.targetNick}
775
+ // docstring in `state/channel.ts` for the resolution + patch contract.
776
+
777
+ /** Roster field touched by a prefix command. */
778
+ type PrefixField = 'op' | 'voice';
779
+
780
+ interface PrefixCommandOpts {
781
+ verb: 'OP' | 'DEOP' | 'VOICE' | 'DEVOICE';
782
+ field: PrefixField;
783
+ set: boolean;
784
+ }
785
+
786
+ /**
787
+ * Authorisation gate for the prefix / kick / ban commands. Returns the bound
788
+ * {@link ServicesStore} on success; pushes the matching notice into `effects`
789
+ * and returns `null` on failure.
790
+ *
791
+ * - Unidentified caller → "You must identify …"
792
+ * - Unregistered channel → "Channel <chan> is not registered."
793
+ * - Non-founder caller:
794
+ * - `founderOnly: true` → "Permission denied."
795
+ * - `founderOnly: false` → allowed when caller's access level meets the
796
+ * founder-configured `AUTOOP` threshold, otherwise "Permission denied."
797
+ *
798
+ * Privilege model: OP/DEOP/VOICE/DEVOICE accept the founder OR any
799
+ * AUTOOP-level access entry; KICK/BAN/UNBAN are founder-only.
800
+ */
801
+ function requirePrivilegedChanServ(
802
+ state: ConnectionState,
803
+ channel: string,
804
+ ctx: Ctx,
805
+ effects: EffectType[],
806
+ opts: { founderOnly: boolean },
807
+ ): ServicesStore | null {
808
+ const services = ctx.services as ServicesStore;
809
+ if (state.account === undefined) {
810
+ effects.push(notice(state, 'You must identify before changing channel settings.'));
811
+ return null;
812
+ }
813
+ const founder = services.getChannelFounder(channel);
814
+ if (founder === undefined) {
815
+ effects.push(notice(state, `Channel ${channel} is not registered.`));
816
+ return null;
817
+ }
818
+ if (caseFold('rfc1459', founder) === caseFold('rfc1459', state.account)) {
819
+ return services;
820
+ }
821
+ if (!opts.founderOnly) {
822
+ const level = services.getChannelAccess(channel, state.account);
823
+ const autoOp = services.getChannelLevel(channel, 'AUTOOP') ?? DEFAULT_CHANNEL_LEVELS.AUTOOP;
824
+ if (level >= autoOp) return services;
825
+ }
826
+ effects.push(notice(state, 'Permission denied.'));
827
+ return null;
828
+ }
829
+
830
+ /**
831
+ * Handles `OP|DEOP|VOICE|DEVOICE <#channel> <nick>`.
832
+ *
833
+ * Emits a `:ChanServ!ChanServ@services MODE <chan> ±<o|v> <nick>` broadcast
834
+ * (mirroring the ChanServ prefix grant broadcast in `join.ts`) and an
835
+ * `ApplyChannelDelta({ memberships: [{ type: 'add', targetNick, op|voice }] })`
836
+ * carrying a `targetNick` hint. The actor layer resolves the nick against the
837
+ * channel roster at apply time:
838
+ * - resolved → the present `op`/`voice` field is *patched* onto the
839
+ * existing entry (so op-ing a voiced user does not clear voice);
840
+ * - absent → the delta is a no-op and ChanServ NOTICEs the caller.
841
+ *
842
+ * `conn: ''` is a placeholder overwritten by the actor on resolution; the
843
+ * {@link MembershipDelta.targetNick} docstring describes the patch contract.
844
+ */
845
+ function handlePrefixCommand(
846
+ state: ConnectionState,
847
+ args: string[],
848
+ ctx: Ctx,
849
+ effects: EffectType[],
850
+ opts: PrefixCommandOpts,
851
+ ): { state: ConnectionState; effects: EffectType[] } {
852
+ const channel = args[0];
853
+ const targetNick = args[1];
854
+ if (
855
+ channel === undefined ||
856
+ targetNick === undefined ||
857
+ !isValidChannelName(channel, ctx.serverConfig.channelLen)
858
+ ) {
859
+ effects.push(notice(state, `Syntax: ${opts.verb} <#channel> <nick>.`));
860
+ return { state, effects };
861
+ }
862
+
863
+ const services = requirePrivilegedChanServ(state, channel, ctx, effects, {
864
+ founderOnly: false,
865
+ });
866
+ if (services === null) return { state, effects };
867
+
868
+ const letter = opts.field === 'op' ? 'o' : 'v';
869
+ const sign = opts.set ? '+' : '-';
870
+ const membership = {
871
+ type: 'add' as const,
872
+ conn: '',
873
+ nick: targetNick,
874
+ targetNick,
875
+ ...(opts.field === 'op' ? { op: opts.set } : { voice: opts.set }),
876
+ };
877
+
878
+ effects.push(Effect.applyChannelDelta(channel, { memberships: [membership] }));
879
+ effects.push(
880
+ Effect.broadcast(channel, [
881
+ { text: `:${CHANSERV_HOSTMASK} MODE ${channel} ${sign}${letter} ${targetNick}` },
882
+ ]),
883
+ );
884
+ effects.push(notice(state, `Set mode ${sign}${letter} on ${targetNick} on ${channel}.`));
885
+ return { state, effects };
886
+ }
887
+
888
+ /**
889
+ * Handles `KICK <#channel> <nick> [:<reason>]`.
890
+ *
891
+ * Founder-only. Emits a `:ChanServ!ChanServ@services KICK <chan> <nick>
892
+ * [:<reason>]` broadcast and an `ApplyChannelDelta({ memberships: [{ type:
893
+ * 'remove', targetNick }] })`, mirroring `kick.ts:134-150`. The actor layer
894
+ * resolves `targetNick` against the roster and either removes the matching
895
+ * entry or no-ops with a NOTICE to the caller when the nick is absent.
896
+ *
897
+ * `conn: ''` is a placeholder overwritten by the actor on resolution.
898
+ */
899
+ function handleKick(
900
+ state: ConnectionState,
901
+ args: string[],
902
+ ctx: Ctx,
903
+ effects: EffectType[],
904
+ ): { state: ConnectionState; effects: EffectType[] } {
905
+ const channel = args[0];
906
+ const targetNick = args[1];
907
+ if (
908
+ channel === undefined ||
909
+ targetNick === undefined ||
910
+ !isValidChannelName(channel, ctx.serverConfig.channelLen)
911
+ ) {
912
+ effects.push(notice(state, 'Syntax: KICK <#channel> <nick> [:<reason>].'));
913
+ return { state, effects };
914
+ }
915
+
916
+ const services = requirePrivilegedChanServ(state, channel, ctx, effects, {
917
+ founderOnly: true,
918
+ });
919
+ if (services === null) return { state, effects };
920
+
921
+ // Reason is everything after the channel + nick, with an optional leading
922
+ // `:` (RFC 2812 trailing form). The trailing splitter already removed the
923
+ // `:` prefix when present in a single token; rebuild from the original
924
+ // args to preserve spaces.
925
+ const reason = args.slice(2).join(' ').replace(/^:/u, '');
926
+
927
+ const line =
928
+ reason.length > 0
929
+ ? { text: `:${CHANSERV_HOSTMASK} KICK ${channel} ${targetNick} :${reason}` }
930
+ : { text: `:${CHANSERV_HOSTMASK} KICK ${channel} ${targetNick}` };
931
+
932
+ effects.push(Effect.broadcast(channel, [line]));
933
+ effects.push(
934
+ Effect.applyChannelDelta(channel, {
935
+ memberships: [{ type: 'remove', conn: '', targetNick }],
936
+ }),
937
+ );
938
+ effects.push(notice(state, `Kicked ${targetNick} from ${channel}.`));
939
+ return { state, effects };
940
+ }
941
+
942
+ interface BanCommandOpts {
943
+ verb: 'BAN' | 'UNBAN';
944
+ add: boolean;
945
+ }
946
+
947
+ /**
948
+ * Handles `BAN <#channel> <mask>` and `UNBAN <#channel> <mask>`.
949
+ *
950
+ * Founder-only. Emits an `ApplyChannelDelta({ banMaskChanges: [{ type, mask }] })`
951
+ * mirroring the ban-mask handling in `mode.ts`, plus a
952
+ * `:ChanServ!ChanServ@services MODE <chan> ±b <mask>` broadcast so clients
953
+ * update their ban-list views. The mask persists in `ChannelState.banMasks`
954
+ * via the actor's delta application.
955
+ *
956
+ * Kick-on-ban (Atheme's default) is intentionally NOT performed: ChanServ
957
+ * cannot enumerate the roster from `ConnectionState`, and a mask-based
958
+ * kick requires an actor-side enforcement pass. Tracked as a follow-up.
959
+ */
960
+ function handleBanMask(
961
+ state: ConnectionState,
962
+ args: string[],
963
+ ctx: Ctx,
964
+ effects: EffectType[],
965
+ opts: BanCommandOpts,
966
+ ): { state: ConnectionState; effects: EffectType[] } {
967
+ const channel = args[0];
968
+ const mask = args[1];
969
+ if (
970
+ channel === undefined ||
971
+ mask === undefined ||
972
+ !isValidChannelName(channel, ctx.serverConfig.channelLen)
973
+ ) {
974
+ effects.push(notice(state, `Syntax: ${opts.verb} <#channel> <mask>.`));
975
+ return { state, effects };
976
+ }
977
+
978
+ const services = requirePrivilegedChanServ(state, channel, ctx, effects, {
979
+ founderOnly: true,
980
+ });
981
+ if (services === null) return { state, effects };
982
+
983
+ const sign = opts.add ? '+' : '-';
984
+ const changeType = opts.add ? 'add' : 'remove';
985
+
986
+ effects.push(
987
+ Effect.applyChannelDelta(channel, {
988
+ banMaskChanges: [{ type: changeType, mask }],
989
+ }),
990
+ );
991
+ effects.push(
992
+ Effect.broadcast(channel, [{ text: `:${CHANSERV_HOSTMASK} MODE ${channel} ${sign}b ${mask}` }]),
993
+ );
994
+ const confirmation = opts.add
995
+ ? `Set ban ${mask} on ${channel}.`
996
+ : `Removed ban ${mask} from ${channel}.`;
997
+ effects.push(notice(state, confirmation));
998
+ return { state, effects };
999
+ }
1000
+
1001
+ /**
1002
+ * Resolves a target nick (case-insensitive under `CASEMAPPING=rfc1459`) to the
1003
+ * connection id of the matching roster entry, or `undefined` when the nick is
1004
+ * not on the channel.
1005
+ *
1006
+ * The actor layer calls this when applying an `ApplyChannelDelta` whose
1007
+ * membership carries a {@link MembershipDelta.targetNick} hint (emitted by
1008
+ * ChanServ OP/DEOP/VOICE/DEVOICE/KICK). A return value of `undefined` signals
1009
+ * "target not on channel" → the actor treats the delta as a no-op and emits a
1010
+ * ChanServ NOTICE back to the caller.
1011
+ *
1012
+ * Exposed so the dispatch layer (irc-server) and the ChanServ tests share a
1013
+ * single resolution function, keeping the nick-folding rules in one place.
1014
+ */
1015
+ export function resolveMembershipTarget(members: Roster, nick: string): ConnId | undefined {
1016
+ const targetLower = caseFold('rfc1459', nick);
1017
+ for (const entry of members.values()) {
1018
+ if (caseFold('rfc1459', entry.nick) === targetLower) return entry.conn;
1019
+ }
1020
+ return undefined;
1021
+ }
1022
+
739
1023
  /**
740
1024
  * Capitalises the first character of `value` (display helper). Callers
741
1025
  * pre-validate that `value` is non-empty (the token splitter drops empty
@@ -759,14 +1043,14 @@ function notice(state: ConnectionState, text: string): EffectType {
759
1043
  function helpNotice(state: ConnectionState): EffectType {
760
1044
  return notice(
761
1045
  state,
762
- 'Available commands: REGISTER, DROP, INFO, SET, ACCESS, LEVELS, SOP, AOP, HOP, VOP',
1046
+ 'Available commands: REGISTER, DROP, INFO, SET, ACCESS, LEVELS, SOP, AOP, HOP, VOP, OP, DEOP, VOICE, DEVOICE, KICK, BAN, UNBAN',
763
1047
  );
764
1048
  }
765
1049
 
766
1050
  function unknownNotice(state: ConnectionState): EffectType {
767
1051
  return notice(
768
1052
  state,
769
- 'Unknown command. Available: REGISTER, DROP, INFO, SET, ACCESS, LEVELS, SOP, AOP, HOP, VOP',
1053
+ 'Unknown command. Available: REGISTER, DROP, INFO, SET, ACCESS, LEVELS, SOP, AOP, HOP, VOP, OP, DEOP, VOICE, DEVOICE, KICK, BAN, UNBAN',
770
1054
  );
771
1055
  }
772
1056