serverless-ircd 0.8.0 → 0.10.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 (179) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +11 -9
  4. package/.github/workflows/deploy-cf.yml +14 -14
  5. package/CHANGELOG.md +550 -0
  6. package/README.md +275 -222
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +82 -9
  9. package/apps/aws-stack/cdk.json +0 -3
  10. package/apps/aws-stack/package.json +3 -4
  11. package/apps/aws-stack/src/aws-stack.ts +177 -52
  12. package/apps/aws-stack/src/static-site.ts +323 -0
  13. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  14. package/apps/aws-stack/tests/stack.test.ts +267 -92
  15. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  16. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  17. package/apps/cf-tcp-container/package.json +2 -3
  18. package/apps/cf-tcp-container/src/container-server.ts +33 -10
  19. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  20. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  21. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  22. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  23. package/apps/cf-tcp-container/wrangler.toml +1 -10
  24. package/apps/cf-worker/package.json +3 -4
  25. package/apps/cf-worker/wrangler.toml +12 -71
  26. package/apps/local-cli/package.json +1 -1
  27. package/apps/local-cli/src/server.ts +115 -48
  28. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  29. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  30. package/apps/local-cli/tests/rehash.test.ts +147 -0
  31. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  32. package/apps/local-cli/tests/tcp.test.ts +89 -0
  33. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  34. package/apps/web/landing/favicon.ico +0 -0
  35. package/apps/web/landing/index.html +227 -3
  36. package/apps/web/package.json +3 -2
  37. package/apps/web/scripts/build.mjs +91 -6
  38. package/apps/web/src/build-env.ts +125 -4
  39. package/apps/web/src/config-schema.ts +20 -6
  40. package/apps/web/src/render-docs.ts +292 -0
  41. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  42. package/apps/web/tests/build-env.test.ts +210 -9
  43. package/apps/web/tests/build-smoke.test.ts +33 -4
  44. package/apps/web/tests/config-schema.test.ts +149 -25
  45. package/apps/web/tests/landing-content.test.ts +103 -0
  46. package/apps/web/tests/render-docs.test.ts +198 -0
  47. package/docs/AWS-Adapter-Architecture.md +3 -2
  48. package/docs/AWS-Deployment.md +670 -96
  49. package/docs/AWS-TCP-Deployment.md +20 -45
  50. package/docs/Cloudflare-Deployment-Guide.md +87 -113
  51. package/docs/Cloudflare-TCP-Deployment.md +25 -49
  52. package/docs/Release-Process.md +27 -23
  53. package/docs/Services.md +102 -23
  54. package/docs/WebClientGuide.md +35 -26
  55. package/package.json +7 -10
  56. package/packages/aws-adapter/package.json +1 -1
  57. package/packages/aws-adapter/src/aws-runtime.ts +15 -1
  58. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
  59. package/packages/aws-adapter/src/config-loader.ts +19 -2
  60. package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +26 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +190 -123
  63. package/packages/aws-adapter/src/handlers/index.ts +67 -23
  64. package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
  65. package/packages/aws-adapter/src/index.ts +5 -7
  66. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  67. package/packages/aws-adapter/src/serialize.ts +15 -0
  68. package/packages/aws-adapter/src/tables.ts +2 -12
  69. package/packages/aws-adapter/tests/aws-harness.ts +0 -1
  70. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  71. package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
  72. package/packages/aws-adapter/tests/connect.test.ts +124 -1
  73. package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
  74. package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
  75. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
  76. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  77. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  78. package/packages/aws-adapter/tests/handlers.test.ts +117 -11
  79. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  80. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  81. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  82. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  83. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  84. package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
  85. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  86. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  87. package/packages/cf-adapter/package.json +1 -5
  88. package/packages/cf-adapter/src/cf-runtime.ts +59 -8
  89. package/packages/cf-adapter/src/channel-do.ts +13 -3
  90. package/packages/cf-adapter/src/connection-do.ts +284 -115
  91. package/packages/cf-adapter/src/d1-services-store.ts +63 -26
  92. package/packages/cf-adapter/src/env.ts +11 -10
  93. package/packages/cf-adapter/src/index.ts +0 -6
  94. package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
  95. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  96. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  97. package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
  98. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  99. package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
  100. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  101. package/packages/in-memory-runtime/package.json +1 -1
  102. package/packages/irc-core/package.json +1 -1
  103. package/packages/irc-core/src/account-migration.ts +140 -0
  104. package/packages/irc-core/src/commands/account-auth.ts +60 -35
  105. package/packages/irc-core/src/commands/chanserv.ts +288 -4
  106. package/packages/irc-core/src/commands/hostserv.ts +38 -3
  107. package/packages/irc-core/src/commands/index.ts +1 -0
  108. package/packages/irc-core/src/commands/join.ts +41 -35
  109. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  110. package/packages/irc-core/src/commands/nickserv.ts +138 -15
  111. package/packages/irc-core/src/commands/registration.ts +28 -17
  112. package/packages/irc-core/src/commands/sasl.ts +22 -31
  113. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  114. package/packages/irc-core/src/commands/topic.ts +23 -10
  115. package/packages/irc-core/src/config.ts +35 -9
  116. package/packages/irc-core/src/credential-hashing.ts +11 -54
  117. package/packages/irc-core/src/index.ts +1 -0
  118. package/packages/irc-core/src/ports.ts +159 -179
  119. package/packages/irc-core/src/state/channel.ts +17 -0
  120. package/packages/irc-core/src/types.ts +38 -10
  121. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  122. package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
  123. package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
  124. package/packages/irc-core/tests/commands/join.test.ts +179 -0
  125. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  126. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  127. package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
  128. package/packages/irc-core/tests/commands/oper.test.ts +15 -0
  129. package/packages/irc-core/tests/commands/registration.test.ts +336 -108
  130. package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
  131. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  132. package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
  133. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  134. package/packages/irc-core/tests/message-store.test.ts +5 -0
  135. package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
  136. package/packages/irc-core/tests/ports.test.ts +71 -0
  137. package/packages/irc-core/tests/services-store.test.ts +204 -0
  138. package/packages/irc-core/vitest.config.ts +6 -1
  139. package/packages/irc-server/package.json +1 -1
  140. package/packages/irc-server/src/actor.ts +80 -44
  141. package/packages/irc-server/tests/actor.test.ts +384 -50
  142. package/packages/irc-test-support/package.json +1 -1
  143. package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
  144. package/packages/irc-test-support/src/scenarios.ts +21 -6
  145. package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
  146. package/packages/irc-test-support/vitest.config.ts +6 -1
  147. package/pnpm-workspace.yaml +1 -0
  148. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  149. package/scripts/deploy-web-aws.mjs +290 -0
  150. package/scripts/package.json +23 -0
  151. package/scripts/tsconfig.test.json +12 -0
  152. package/scripts/vitest.config.ts +19 -0
  153. package/tools/ci-hardening/package.json +1 -1
  154. package/tools/ci-hardening/src/index.ts +2 -0
  155. package/tools/ci-hardening/src/validate.ts +57 -0
  156. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  157. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  158. package/tools/load-test/package.json +1 -1
  159. package/tools/load-test/src/client.ts +13 -13
  160. package/tools/load-test/tests/client.test.ts +258 -2
  161. package/tools/load-test/tests/config.test.ts +39 -0
  162. package/tools/load-test/tests/harness.test.ts +21 -0
  163. package/tools/load-test/tests/metrics.test.ts +7 -0
  164. package/tools/migrate-accounts-to-services.ts +270 -0
  165. package/tools/package.json +2 -1
  166. package/tools/seed-aws-accounts.ts +35 -10
  167. package/tools/seed-cf-accounts.ts +42 -9
  168. package/tools/tcp-ws-forwarder/package.json +1 -1
  169. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  170. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  171. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
  172. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  173. package/packages/aws-adapter/src/account-store.ts +0 -121
  174. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  175. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  176. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  177. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  178. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  179. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Pure helpers for the one-shot `accounts` → `nickserv_accounts` /
3
+ * `Services` migration (`tools/migrate-accounts-to-services.ts`).
4
+ *
5
+ * Lives in `irc-core` so both adapter test suites can import the helpers
6
+ * without a cross-adapter dependency. The CLI entry point (wrangler /
7
+ * DynamoDB IO) lives in the tools script; these helpers are side-effect-
8
+ * free and fully unit-testable.
9
+ */
10
+
11
+ import { caseFold } from './case-fold.js';
12
+ import { hashAccountCredential } from './credential-hashing.js';
13
+
14
+ /**
15
+ * DDL for the `nickserv_accounts` table (the migration target). Inlined
16
+ * rather than imported from `@serverless-ircd/cf-adapter` so the helpers
17
+ * are testable from any package. Kept in sync with
18
+ * `cf-adapter/src/d1-services-store.ts`.
19
+ */
20
+ export const MIGRATION_NICKSERV_ACCOUNTS_SQL =
21
+ "CREATE TABLE IF NOT EXISTS nickserv_accounts (nick_key TEXT PRIMARY KEY, nick TEXT NOT NULL, account TEXT NOT NULL, email TEXT NOT NULL, created_at INTEGER NOT NULL, enforce TEXT NOT NULL, cert_subjects TEXT NOT NULL DEFAULT '[]', algorithm TEXT NOT NULL, salt TEXT NOT NULL, hash TEXT NOT NULL)";
22
+
23
+ /** Parsed `SASL_ACCOUNTS` env var entry. */
24
+ export interface ParsedSaslAccount {
25
+ username: string;
26
+ password: string;
27
+ }
28
+
29
+ /** A raw legacy `accounts` row read from D1 or DynamoDB. */
30
+ export interface LegacyAccountRow {
31
+ account: string;
32
+ algorithm: string;
33
+ salt: string;
34
+ hash: string;
35
+ }
36
+
37
+ /** Parses the `SASL_ACCOUNTS` env var into credential entries. */
38
+ export function parseSaslAccountsForMigration(raw: string | undefined): ParsedSaslAccount[] {
39
+ if (raw === undefined || raw.length === 0) return [];
40
+ const out: ParsedSaslAccount[] = [];
41
+ for (const line of raw.split('\n')) {
42
+ const trimmed = line.trim();
43
+ if (trimmed.length === 0) continue;
44
+ const sep = trimmed.indexOf(':');
45
+ if (sep <= 0) continue;
46
+ const username = trimmed.slice(0, sep);
47
+ const password = trimmed.slice(sep + 1);
48
+ if (username.length === 0 || password.length === 0) continue;
49
+ out.push({ username, password });
50
+ }
51
+ return out;
52
+ }
53
+
54
+ /**
55
+ * Builds the set of `INSERT OR IGNORE` SQL statements that backfill the
56
+ * legacy `accounts` rows plus the `SASL_ACCOUNTS` env-seed entries into
57
+ * `nickserv_accounts`. Pure (no IO).
58
+ *
59
+ * - Legacy rows are copied verbatim (the pre-hashed scrypt credential is
60
+ * preserved; the plaintext is NOT re-hashed). The services row carries
61
+ * `cert_subjects = '[]'`, email `''`, enforce `'none'`, `created_at 0`.
62
+ * - `SASL_ACCOUNTS` env-seed entries are hashed fresh (the env var carries
63
+ * plaintext passwords). Only entries whose username is NOT already
64
+ * present in the legacy rows are emitted (legacy rows win over the env
65
+ * seed).
66
+ * - `INSERT OR IGNORE` guarantees idempotency: an existing
67
+ * `nickserv_accounts` row (registered via NickServ or a prior seed) is
68
+ * never clobbered.
69
+ *
70
+ * @returns The array of executable SQL statements (CREATE TABLE + INSERTs).
71
+ */
72
+ export function buildMigrationSql(
73
+ legacyRows: ReadonlyArray<LegacyAccountRow>,
74
+ saslAccountsRaw: string | undefined,
75
+ ): string[] {
76
+ const statements: string[] = [MIGRATION_NICKSERV_ACCOUNTS_SQL];
77
+
78
+ const seen = new Set<string>();
79
+
80
+ for (const row of legacyRows) {
81
+ const nickKey = caseFold('rfc1459', row.account);
82
+ seen.add(nickKey);
83
+ statements.push(buildInsertOrIgnore(row.account, row));
84
+ }
85
+
86
+ for (const acct of parseSaslAccountsForMigration(saslAccountsRaw)) {
87
+ const nickKey = caseFold('rfc1459', acct.username);
88
+ if (seen.has(nickKey)) continue;
89
+ seen.add(nickKey);
90
+ const cred = hashAccountCredential(acct.username, acct.password);
91
+ statements.push(buildInsertOrIgnore(acct.username, cred));
92
+ }
93
+
94
+ return statements;
95
+ }
96
+
97
+ function buildInsertOrIgnore(
98
+ username: string,
99
+ cred: { algorithm: string; salt: string; hash: string },
100
+ ): string {
101
+ const nickKey = caseFold('rfc1459', username);
102
+ return `INSERT OR IGNORE INTO nickserv_accounts (nick_key, nick, account, email, created_at, enforce, cert_subjects, algorithm, salt, hash) VALUES ('${sqlEscape(nickKey)}', '${sqlEscape(username)}', '${sqlEscape(username)}', '', 0, 'none', '[]', '${sqlEscape(cred.algorithm)}', '${sqlEscape(cred.salt)}', '${sqlEscape(cred.hash)}')`;
103
+ }
104
+
105
+ /** Doubles single quotes for safe embedding in a D1 SQL string literal. */
106
+ function sqlEscape(value: string): string {
107
+ return value.replace(/'/g, "''");
108
+ }
109
+
110
+ /**
111
+ * Builds the DynamoDB `PutItem` input for a legacy row, targeting the
112
+ * `Services` table's `NICK:<fold>` / `#` item. Pure.
113
+ *
114
+ * The caller wraps this in a conditional PutItem
115
+ * (`ConditionExpression: attribute_not_exists(pk)`) so an existing nick
116
+ * registration is never clobbered.
117
+ */
118
+ export function buildServicesPutItem(
119
+ row: { account: string; algorithm: string; salt: string; hash: string },
120
+ tableName: string,
121
+ ): { TableName: string; Item: Record<string, unknown> } {
122
+ const fold = caseFold('rfc1459', row.account);
123
+ return {
124
+ TableName: tableName,
125
+ Item: {
126
+ pk: `NICK:${fold}`,
127
+ sk: '#',
128
+ type: 'nick',
129
+ nick: row.account,
130
+ account: row.account,
131
+ email: '',
132
+ createdAt: 0,
133
+ enforce: 'none',
134
+ certSubjects: [],
135
+ algorithm: row.algorithm,
136
+ salt: row.salt,
137
+ hash: row.hash,
138
+ },
139
+ };
140
+ }
@@ -13,8 +13,10 @@
13
13
  *
14
14
  * {@link passBasedAccountAuth} is the legacy IRC convention of carrying
15
15
  * NickServ credentials in `PASS <nick>:<password>` at registration; it
16
- * resolves at registration completion via the injected
17
- * {@link AccountStore} and, on success, delegates to
16
+ * resolves at registration completion via the bound
17
+ * {@link ServicesStore} (`services.verifyNick` the same single
18
+ * credential home NickServ `REGISTER`, SASL PLAIN, and NickServ
19
+ * `IDENTIFY` consult) and, on success, delegates to
18
20
  * {@link applyAccountSuccess}.
19
21
  */
20
22
 
@@ -25,6 +27,7 @@ import { hostmaskOf } from '../state/connection.js';
25
27
  import type { ConnectionState } from '../state/connection.js';
26
28
  import type { Ctx } from '../types.js';
27
29
  import { emitAccountNotify } from './account-notify.js';
30
+ import { applyAssignedVhost } from './hostserv.js';
28
31
  import { deliverUnreadMemos } from './memoserv.js';
29
32
  import { nowAwayLine, replayPersistedAway } from './pre-away.js';
30
33
  import { seedReadMarkers } from './read-marker.js';
@@ -35,11 +38,14 @@ import { seedReadMarkers } from './read-marker.js';
35
38
  *
36
39
  * Mutates `state`: sets `state.account`, stamps read-only user mode `r`
37
40
  * (`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:
41
+ * services store, replays the persisted away reason, re-applies any assigned
42
+ * HostServ vhost (so a reconnecting user does not have to re-run
43
+ * `HostServ ON`), and returns the effects the caller must emit:
40
44
  * - `Send` carrying `900 RPL_LOGGEDIN` + `903 RPL_SASLSUCCESS`;
41
45
  * - an optional `306 RPL_NOWAWAY` when an away reason was replayed;
42
46
  * - any queued MemoServ `NOTICE`s delivered from the services store;
47
+ * - an optional `CHGHOST` fanout when a vhost was applied post-JOIN (empty
48
+ * at SASL / PASS-auth time — no channels joined yet);
43
49
  * - the `account-notify` `ACCOUNT` broadcasts to cap-enabled peers in
44
50
  * shared channels (empty at identify time — no channels joined yet).
45
51
  *
@@ -54,11 +60,17 @@ export function applyAccountSuccess(
54
60
  // A successful account login is equivalent to NickServ IDENTIFY: stamp
55
61
  // read-only user mode `r` so `221`/WHOIS reflect the identified state.
56
62
  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.
63
+ // HostServ vhost: re-apply any assigned vhost so a reconnecting user with
64
+ // a vhost does not have to re-run `HostServ ON` every session. Collected
65
+ // into a local array because the CHGHOST fanout is only non-empty when the
66
+ // connection has joined channels (never at SASL/PASS-auth time).
67
+ const vhostEffects: EffectType[] = [];
60
68
  if (ctx.services !== undefined) {
69
+ // IRCv3 draft/read-marker: restore the user's per-channel last-read
70
+ // markers from the persisted store so a reconnect resumes at their last
71
+ // read position. No-op when no services store is bound.
61
72
  seedReadMarkers(state, ctx.services, account);
73
+ applyAssignedVhost(state, ctx.services, account, vhostEffects);
62
74
  }
63
75
  // IRCv3 draft/pre-away: replay the user's persisted away reason onto the
64
76
  // fresh connection and emit 306 so the user sees they are still away.
@@ -78,6 +90,7 @@ export function applyAccountSuccess(
78
90
  Effect.send(ctx.connId, [loggedInLine(ctx, account), saslSuccessLine(ctx)]),
79
91
  ...awayEffects,
80
92
  ...memoEffects,
93
+ ...vhostEffects,
81
94
  ...emitAccountNotify({ conn: state, account }),
82
95
  ];
83
96
  }
@@ -101,48 +114,60 @@ export function parsePassAccountAttempt(
101
114
  /**
102
115
  * Attempts PASS-based account authentication at registration completion.
103
116
  *
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:
117
+ * Thin wrapper around {@link attemptPassAccountAuth} that reads the stashed
118
+ * `state.passAttempt`. See {@link attemptPassAccountAuth} for the full
119
+ * outcome matrix.
120
+ */
121
+ export function passBasedAccountAuth(state: ConnectionState, ctx: Ctx): EffectType[] {
122
+ return attemptPassAccountAuth(state, ctx, state.passAttempt);
123
+ }
124
+
125
+ /**
126
+ * Shared verify + {@link applyAccountSuccess} pipeline for a raw
127
+ * `<nick>:<password>` attempt string.
128
+ *
129
+ * Used by both {@link passBasedAccountAuth} (at registration completion,
130
+ * reading `state.passAttempt`) and the post-registration late-PASS login
131
+ * in `passReducer` (reading the `PASS` param directly), so the two entry
132
+ * points share one verify+success path and never diverge.
133
+ *
134
+ * Credentials are verified against the bound {@link ServicesStore} via
135
+ * `services.verifyNick` — the same single credential home NickServ
136
+ * `REGISTER`, SASL PLAIN, and NickServ `IDENTIFY` consult. Returns the
137
+ * account-login success effects (`900`/`903`/`+r`/read-marker
138
+ * seeding/away replay/memo delivery/`account-notify`), or `[]` for every
139
+ * other outcome:
108
140
  * - 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.
141
+ * - no `<nick>:<password>` form (bare value or undefined) → no-op;
142
+ * - payload nick does not match `state.nick` (when set) → no-op;
143
+ * - no {@link ServicesStore} boundno-op;
144
+ * - verify failure (unknown nick or wrong password) → no-op.
116
145
  *
117
146
  * A failed verify is followed by a verify against a fixed dummy entry so
118
- * the failure path performs the same AccountStore work whether the payload
147
+ * the failure path performs the same scrypt work whether the payload
119
148
  * nick was unknown or the password was wrong — the observable outcome
120
149
  * (no numerics, no disconnect, no state change) is identical for both
121
150
  * cases, giving no information to a remote attacker.
122
151
  */
123
- export function passBasedAccountAuth(state: ConnectionState, ctx: Ctx): EffectType[] {
152
+ export function attemptPassAccountAuth(
153
+ state: ConnectionState,
154
+ ctx: Ctx,
155
+ attempt: string | undefined,
156
+ ): EffectType[] {
124
157
  if (state.account !== undefined) return [];
125
- const parsed = parsePassAccountAttempt(state.passAttempt);
158
+ const parsed = parsePassAccountAttempt(attempt);
126
159
  if (parsed === null) return [];
127
160
  if (state.nick !== undefined && state.nick !== parsed.nick) return [];
128
- const store = ctx.accounts;
129
- if (store === undefined) return [];
130
- const result = store.verify('PLAIN', {
131
- kind: 'PLAIN',
132
- username: parsed.nick,
133
- password: parsed.password,
134
- });
161
+ const services = ctx.services;
162
+ if (services === undefined) return [];
163
+ const result = services.verifyNick(parsed.nick, parsed.password);
135
164
  if (result.ok) {
136
165
  return applyAccountSuccess(state, ctx, result.account);
137
166
  }
138
167
  // Equalise timing: perform a verify against a fixed dummy entry so a
139
- // failed PASS-auth always costs one extra AccountStore call regardless
140
- // of whether the payload nick was unknown or the password was wrong.
141
- store.verify('PLAIN', {
142
- kind: 'PLAIN',
143
- username: PASS_AUTH_DUMMY_NICK,
144
- password: PASS_AUTH_DUMMY_PASSWORD,
145
- });
168
+ // failed PASS-auth always costs one extra scrypt verify regardless of
169
+ // whether the payload nick was unknown or the password was wrong.
170
+ services.verifyNick(PASS_AUTH_DUMMY_NICK, PASS_AUTH_DUMMY_PASSWORD);
146
171
  return [];
147
172
  }
148
173
 
@@ -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