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
@@ -11,6 +11,7 @@ import {
11
11
  type ReadMarkerEntry,
12
12
  type RegisteredNick,
13
13
  type ServicesAuthResult,
14
+ type ServicesSnapshot,
14
15
  type ServicesStore,
15
16
  } from '../src/ports';
16
17
 
@@ -116,6 +117,185 @@ describe('InMemoryServicesStore — NickServ IDENTIFY (verifyNick)', () => {
116
117
  });
117
118
  });
118
119
 
120
+ // ============================================================================
121
+ // NickServ CertFP — hydrate round-trip (legacy snapshot without certSubjects)
122
+ // ============================================================================
123
+
124
+ describe('InMemoryServicesStore — CertFP snapshot hydration', () => {
125
+ it('defaults certSubjects to [] when a legacy snapshot row omits the field', () => {
126
+ // Simulate a pre-certSubjects snapshot: the row is missing the field
127
+ // entirely (as if loaded from JSON written before the column existed).
128
+ const legacySnap: ServicesSnapshot = {
129
+ nicks: [
130
+ {
131
+ nick: 'alice',
132
+ account: 'alice',
133
+ email: 'a@x',
134
+ createdAt: NOW,
135
+ enforce: 'none',
136
+ certSubjects: undefined as unknown as string[],
137
+ credential: { account: 'alice', algorithm: 'scrypt', salt: 's', hash: 'aGVsbG8=' },
138
+ },
139
+ ],
140
+ channels: [],
141
+ access: [],
142
+ levels: [],
143
+ vhosts: [],
144
+ pendingVhosts: [],
145
+ memos: [],
146
+ readMarkers: [],
147
+ akills: [],
148
+ jupes: [],
149
+ };
150
+ const s = new InMemoryServicesStore({ clock: new FakeClock(NOW), snapshot: legacySnap });
151
+ const rec = s.getNick('alice');
152
+ expect(rec).toBeDefined();
153
+ if (rec !== undefined) {
154
+ expect(rec.certSubjects).toEqual([]);
155
+ }
156
+ });
157
+ });
158
+
159
+ // ============================================================================
160
+ // NickServ CertFP — SASL EXTERNAL surface (verifyCertFP / addCertFP / removeCertFP)
161
+ // ============================================================================
162
+
163
+ describe('InMemoryServicesStore — NickServ CertFP (verifyCertFP)', () => {
164
+ it('returns ok with the account name for a known subject', () => {
165
+ const s = store();
166
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
167
+ s.addCertFP('alice', 'CN=alice');
168
+ expect(s.verifyCertFP('CN=alice')).toEqual<ServicesAuthResult>({
169
+ ok: true,
170
+ account: 'alice',
171
+ });
172
+ });
173
+
174
+ it('returns ok:false for an unknown subject', () => {
175
+ const s = store();
176
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
177
+ s.addCertFP('alice', 'CN=alice');
178
+ const out = s.verifyCertFP('CN=evil');
179
+ expect(out.ok).toBe(false);
180
+ if (out.ok) return;
181
+ expect(out.reason.length).toBeGreaterThan(0);
182
+ });
183
+
184
+ it('returns ok:false when no nick has any cert subject registered', () => {
185
+ const s = store();
186
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
187
+ expect(s.verifyCertFP('CN=alice').ok).toBe(false);
188
+ });
189
+
190
+ it('matches the subject case-sensitively (byte-exact)', () => {
191
+ const s = store();
192
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
193
+ s.addCertFP('alice', 'CN=Alice');
194
+ expect(s.verifyCertFP('CN=alice').ok).toBe(false);
195
+ expect(s.verifyCertFP('CN=Alice').ok).toBe(true);
196
+ });
197
+
198
+ it('resolves the account that owns the subject (multiple accounts)', () => {
199
+ const s = store();
200
+ s.registerNick('alice', 'pw', 'a@e');
201
+ s.registerNick('bob', 'pw', 'b@e');
202
+ s.addCertFP('bob', 'CN=bob');
203
+ expect(s.verifyCertFP('CN=bob')).toEqual<ServicesAuthResult>({
204
+ ok: true,
205
+ account: 'bob',
206
+ });
207
+ });
208
+
209
+ it('exposes the registered subjects on the RegisteredNick record', () => {
210
+ const s = store();
211
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
212
+ s.addCertFP('alice', 'CN=alice');
213
+ s.addCertFP('alice', 'CN=alice-2');
214
+ const rec = s.getNick('alice');
215
+ expect(rec).toBeDefined();
216
+ if (rec === undefined) return;
217
+ expect(rec.certSubjects).toEqual(['CN=alice', 'CN=alice-2']);
218
+ });
219
+
220
+ it('defaults certSubjects to an empty array on a fresh registration', () => {
221
+ const s = store();
222
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
223
+ const rec = s.getNick('alice');
224
+ expect(rec).toBeDefined();
225
+ if (rec === undefined) return;
226
+ expect(rec.certSubjects).toEqual([]);
227
+ });
228
+ });
229
+
230
+ describe('InMemoryServicesStore — NickServ CertFP mutators (addCertFP / removeCertFP)', () => {
231
+ it('addCertFP attaches a subject to a registered nick', () => {
232
+ const s = store();
233
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
234
+ s.addCertFP('alice', 'CN=alice');
235
+ expect(s.getNick('alice')?.certSubjects).toEqual(['CN=alice']);
236
+ });
237
+
238
+ it('addCertFP is a no-op on an unregistered nick', () => {
239
+ const s = store();
240
+ s.addCertFP('ghost', 'CN=ghost');
241
+ expect(s.verifyCertFP('CN=ghost').ok).toBe(false);
242
+ });
243
+
244
+ it('addCertFP does not duplicate an already-attached subject', () => {
245
+ const s = store();
246
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
247
+ s.addCertFP('alice', 'CN=alice');
248
+ s.addCertFP('alice', 'CN=alice');
249
+ expect(s.getNick('alice')?.certSubjects).toEqual(['CN=alice']);
250
+ });
251
+
252
+ it('addCertFP folds the nick case-insensitively', () => {
253
+ const s = store();
254
+ s.registerNick('Alice', 'hunter2', 'alice@example.com');
255
+ s.addCertFP('alice', 'CN=alice');
256
+ expect(s.verifyCertFP('CN=alice')).toEqual<ServicesAuthResult>({
257
+ ok: true,
258
+ account: 'Alice',
259
+ });
260
+ });
261
+
262
+ it('removeCertFP detaches a subject from a registered nick', () => {
263
+ const s = store();
264
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
265
+ s.addCertFP('alice', 'CN=alice');
266
+ expect(s.removeCertFP('alice', 'CN=alice')).toBe(true);
267
+ expect(s.getNick('alice')?.certSubjects).toEqual([]);
268
+ expect(s.verifyCertFP('CN=alice').ok).toBe(false);
269
+ });
270
+
271
+ it('removeCertFP returns false when the subject is not attached', () => {
272
+ const s = store();
273
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
274
+ expect(s.removeCertFP('alice', 'CN=alice')).toBe(false);
275
+ });
276
+
277
+ it('removeCertFP returns false on an unregistered nick', () => {
278
+ const s = store();
279
+ expect(s.removeCertFP('ghost', 'CN=ghost')).toBe(false);
280
+ });
281
+
282
+ it('removeCertFP folds the nick case-insensitively but matches the subject byte-exactly', () => {
283
+ const s = store();
284
+ s.registerNick('Alice', 'hunter2', 'alice@example.com');
285
+ s.addCertFP('alice', 'CN=Alice');
286
+ expect(s.removeCertFP('ALICE', 'CN=alice')).toBe(false);
287
+ expect(s.removeCertFP('ALICE', 'CN=Alice')).toBe(true);
288
+ });
289
+
290
+ it('dropNick removes the attached cert subjects along with the registration', () => {
291
+ const s = store();
292
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
293
+ s.addCertFP('alice', 'CN=alice');
294
+ s.dropNick('alice');
295
+ expect(s.verifyCertFP('CN=alice').ok).toBe(false);
296
+ });
297
+ });
298
+
119
299
  describe('InMemoryServicesStore — NickServ DROP (dropNick)', () => {
120
300
  it('returns true and removes the registration for a known nick', () => {
121
301
  const s = store();
@@ -175,6 +355,29 @@ describe('InMemoryServicesStore — NickServ SET ENFORCE (setNickEnforce)', () =
175
355
  });
176
356
  });
177
357
 
358
+ describe('InMemoryServicesStore — NickServ SET PASSWORD (setNickPassword)', () => {
359
+ it('overwrites the credential so the new password verifies and the old does not', () => {
360
+ const s = store();
361
+ s.registerNick('alice', 'hunter2', 'alice@example.com');
362
+ s.setNickPassword('alice', 'newpassword');
363
+ expect(s.verifyNick('alice', 'newpassword').ok).toBe(true);
364
+ expect(s.verifyNick('alice', 'hunter2').ok).toBe(false);
365
+ });
366
+
367
+ it('folds the nick case-insensitively', () => {
368
+ const s = store();
369
+ s.registerNick('Alice', 'hunter2', 'alice@example.com');
370
+ s.setNickPassword('alice', 'newpassword');
371
+ expect(s.verifyNick('ALICE', 'newpassword').ok).toBe(true);
372
+ });
373
+
374
+ it('is a no-op on an unregistered nick (the original credential, if any, is untouched)', () => {
375
+ const s = store();
376
+ s.setNickPassword('ghost', 'newpassword');
377
+ expect(s.isRegisteredNick('ghost')).toBe(false);
378
+ });
379
+ });
380
+
178
381
  describe('InMemoryServicesStore — NickServ INFO (getNick)', () => {
179
382
  it('returns the full record for a registered nick', () => {
180
383
  const s = store();
@@ -186,6 +389,7 @@ describe('InMemoryServicesStore — NickServ INFO (getNick)', () => {
186
389
  email: 'alice@example.com',
187
390
  createdAt: NOW,
188
391
  enforce: 'none',
392
+ certSubjects: [],
189
393
  });
190
394
  });
191
395
 
@@ -8,7 +8,12 @@ export default defineConfig({
8
8
  provider: 'v8',
9
9
  all: false,
10
10
  include: ['src/**/*.ts'],
11
- exclude: ['src/**/index.ts'],
11
+ // `index.ts` files are barrel re-exports (no runtime logic);
12
+ // `messages.ts` is type-only (`interface` declarations that
13
+ // TypeScript erases to an empty module). v8 scores both 0%
14
+ // because they contain no executable statements — excluding
15
+ // them keeps the 100% threshold honest about real coverage.
16
+ exclude: ['src/**/index.ts', 'src/**/messages.ts'],
12
17
  reporter: ['text', 'html', 'json-summary'],
13
18
  thresholds: {
14
19
  lines: 100,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/irc-server",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "private": true,
5
5
  "description": "Orchestration layer: IrcRuntime port, dispatch interpreter, ConnectionActor",
6
6
  "license": "BSD-3-Clause",
@@ -26,7 +26,6 @@
26
26
  */
27
27
 
28
28
  import {
29
- type AccountStore,
30
29
  type AwayStore,
31
30
  CHANSERV_NICK,
32
31
  type ChanName,
@@ -56,6 +55,8 @@ import {
56
55
  Numerics,
57
56
  OPERSERV_NICK,
58
57
  type RawLine,
58
+ type Reducer,
59
+ SHORTCUT_TO_SERVICE,
59
60
  type ServerConfig,
60
61
  type ServerStats,
61
62
  type ServicesStore,
@@ -148,16 +149,6 @@ export interface ConnectionActorOptions {
148
149
  * opt in.
149
150
  */
150
151
  readonly messages?: MessageStore;
151
- /**
152
- * SASL account verification source (absent when no accounts are
153
- * configured). When bound, `ctx.accounts` is defined so the
154
- * `AUTHENTICATE` reducer can verify PLAIN credentials end-to-end.
155
- * Omitted (→ `ctx.accounts` undefined) preserves the existing
156
- * behaviour for callers/tests that do not opt in: a client completing
157
- * `AUTHENTICATE PLAIN` receives `904 ERR_SASLFAIL` because no store
158
- * can vouch for the credentials.
159
- */
160
- readonly accounts?: AccountStore;
161
152
  /**
162
153
  * mTLS client-cert identity source for SASL EXTERNAL (absent when mTLS
163
154
  * is not configured). When bound, `ctx.mtlsIdentity` is defined so the
@@ -269,7 +260,6 @@ export class ConnectionActor {
269
260
  private readonly ids: IdFactory;
270
261
  private readonly motd: MotdProvider;
271
262
  private readonly messages: MessageStore | undefined;
272
- private readonly accounts: AccountStore | undefined;
273
263
  private readonly mtlsIdentity: MtlsIdentityProvider | undefined;
274
264
  private readonly history: NickHistoryStore | undefined;
275
265
  private readonly away: AwayStore | undefined;
@@ -323,7 +313,6 @@ export class ConnectionActor {
323
313
  this.ids = opts.ids;
324
314
  this.motd = opts.motd ?? EmptyMotdProvider;
325
315
  this.messages = opts.messages;
326
- this.accounts = opts.accounts;
327
316
  this.mtlsIdentity = opts.mtlsIdentity;
328
317
  this.history = opts.history;
329
318
  this.away = opts.away;
@@ -509,7 +498,6 @@ export class ConnectionActor {
509
498
  motd: this.motd,
510
499
  connection: this.state,
511
500
  ...(this.messages !== undefined ? { messages: this.messages } : {}),
512
- ...(this.accounts !== undefined ? { accounts: this.accounts } : {}),
513
501
  ...(this.mtlsIdentity !== undefined ? { mtlsIdentity: this.mtlsIdentity } : {}),
514
502
  ...(this.history !== undefined ? { history: this.history } : {}),
515
503
  ...(this.away !== undefined ? { away: this.away } : {}),
@@ -527,6 +515,28 @@ export class ConnectionActor {
527
515
  return this.accumulateMultilineLine(msg);
528
516
  }
529
517
 
518
+ // Service shortcut commands (Atheme/Anope convention): rewrite verbs
519
+ // like `NICKSERV IDENTIFY pw` / `NS INFO` / `CS REGISTER #home` to the
520
+ // canonical `PRIVMSG <ServiceNick> :<joined args>` and delegate to the
521
+ // existing service-routing path (`routeMessageTarget` →
522
+ // `serviceReducerFor`). `SHORTCUT_TO_SERVICE` is the single source of
523
+ // truth for the alias → service nick mapping; the service reducers run
524
+ // unchanged against the rewritten `params=[serviceNick, body]` shape.
525
+ const serviceShortcutTarget = SHORTCUT_TO_SERVICE.get(msg.command.toUpperCase());
526
+ if (serviceShortcutTarget !== undefined) {
527
+ const rewritten: IrcMessage = {
528
+ ...msg,
529
+ command: 'PRIVMSG',
530
+ params: [serviceShortcutTarget, msg.params.join(' ')],
531
+ };
532
+ return this.routeMessageTarget(
533
+ rewritten,
534
+ this.reducers.privmsgChannel,
535
+ this.reducers.privmsgUser,
536
+ ctx,
537
+ );
538
+ }
539
+
530
540
  switch (msg.command) {
531
541
  // -------- Connection-authority commands --------
532
542
  case 'PING':
@@ -1348,39 +1358,22 @@ export class ConnectionActor {
1348
1358
  }
1349
1359
  for (const target of targets) {
1350
1360
  const singleParamMsg: IrcMessage = { ...msg, params: [target, ...tail] };
1361
+ const serviceReducer = this.serviceReducerFor(target, msg.command);
1351
1362
  if (isChannelTarget(target)) {
1352
1363
  const chan = this.channels.getOrCreateChannel(target);
1353
1364
  effects.push(...channelReducer(chan, singleParamMsg, ctx).effects);
1354
- } else if (
1355
- msg.command === 'PRIVMSG' &&
1356
- this.services !== undefined &&
1357
- caseFold('rfc1459', target) === caseFold('rfc1459', NICKSERV_NICK)
1358
- ) {
1359
- effects.push(...this.reducers.nickserv(this.state, singleParamMsg, ctx).effects);
1360
- } else if (
1361
- msg.command === 'PRIVMSG' &&
1362
- this.services !== undefined &&
1363
- caseFold('rfc1459', target) === caseFold('rfc1459', CHANSERV_NICK)
1364
- ) {
1365
- effects.push(...this.reducers.chanserv(this.state, singleParamMsg, ctx).effects);
1366
- } else if (
1367
- msg.command === 'PRIVMSG' &&
1368
- this.services !== undefined &&
1369
- caseFold('rfc1459', target) === caseFold('rfc1459', HOSTSERV_NICK)
1370
- ) {
1371
- effects.push(...this.reducers.hostserv(this.state, singleParamMsg, ctx).effects);
1372
- } else if (
1373
- msg.command === 'PRIVMSG' &&
1374
- this.services !== undefined &&
1375
- caseFold('rfc1459', target) === caseFold('rfc1459', MEMOSERV_NICK)
1376
- ) {
1377
- effects.push(...this.reducers.memoserv(this.state, singleParamMsg, ctx).effects);
1378
- } else if (
1379
- msg.command === 'PRIVMSG' &&
1380
- this.services !== undefined &&
1381
- caseFold('rfc1459', target) === caseFold('rfc1459', OPERSERV_NICK)
1382
- ) {
1383
- effects.push(...this.reducers.operserv(this.state, singleParamMsg, ctx).effects);
1365
+ } else if (serviceReducer !== undefined) {
1366
+ // Echo the sender's own PRIVMSG back BEFORE the service reducer's NOTICE
1367
+ // reply matching how every other PRIVMSG renders (your outgoing line
1368
+ // first, then any reply). Without this ordering a client sees the
1369
+ // service's response above its own message, which reads as a bug.
1370
+ if (ctx.connection.caps.has('echo-message')) {
1371
+ const hostmask = hostmaskOf(this.state) ?? this.state.nick ?? '?';
1372
+ const text = singleParamMsg.params[1] ?? '';
1373
+ const echoLine: RawLine = { text: `:${hostmask} PRIVMSG ${target} :${text}` };
1374
+ effects.push(Effect.send(ctx.connId, [echoLine]));
1375
+ }
1376
+ effects.push(...serviceReducer(this.state, singleParamMsg, ctx).effects);
1384
1377
  } else {
1385
1378
  effects.push(...userReducer(this.state, singleParamMsg, ctx).effects);
1386
1379
  }
@@ -1388,6 +1381,26 @@ export class ConnectionActor {
1388
1381
  return effects;
1389
1382
  }
1390
1383
 
1384
+ /**
1385
+ * Resolves the service pseudo-client reducer for a nick target, or
1386
+ * `undefined` when the target is not a service nick / the command is not
1387
+ * `PRIVMSG` / no services store is bound. Centralised so the
1388
+ * `echo-message` echo (and any future service-routing concern) lives in a
1389
+ * single place rather than being duplicated across five near-identical
1390
+ * branches. `NOTICE` / `TAGMSG` to a service nick intentionally return
1391
+ * `undefined` so they fall through to the normal user-target reducer.
1392
+ *
1393
+ * The membership test is a table lookup against {@link SERVICE_REDUCER_KEYS}
1394
+ * (built from the same `*_NICK` constants that back
1395
+ * {@link SHORTCUT_TO_SERVICE}), so the service-nick list has one source of
1396
+ * truth rather than a per-service `if`-chain here.
1397
+ */
1398
+ private serviceReducerFor(target: string, command: string): Reducer<ConnectionState> | undefined {
1399
+ if (command !== 'PRIVMSG' || this.services === undefined) return undefined;
1400
+ const key = SERVICE_REDUCER_KEYS.get(caseFold('rfc1459', target));
1401
+ return key === undefined ? undefined : this.reducers[key];
1402
+ }
1403
+
1391
1404
  private unknownCommandEffect(command: string): EffectType {
1392
1405
  const nick = this.state.nick ?? '*';
1393
1406
  const code = Numerics.ERR_UNKNOWNCOMMAND.toString().padStart(3, '0');
@@ -1416,6 +1429,29 @@ function splitCsv(param: string): string[] {
1416
1429
  return param.split(',').filter((p) => p.length > 0);
1417
1430
  }
1418
1431
 
1432
+ /**
1433
+ * Service reducer keys on {@link RoutedReducers}. Narrower than
1434
+ * `keyof RoutedReducers` so the table lookup resolves to a
1435
+ * `Reducer<ConnectionState>` without a widening cast.
1436
+ */
1437
+ type ServiceReducerKey = 'nickserv' | 'chanserv' | 'hostserv' | 'memoserv' | 'operserv';
1438
+
1439
+ /**
1440
+ * rfc1459 case-folded service nick → reducer key. The single source of
1441
+ * truth for "which targets are service nicks" — built from the canonical
1442
+ * `*_NICK` constants that also back {@link SHORTCUT_TO_SERVICE}, so the
1443
+ * dispatcher's shortcut rewrite and `serviceReducerFor`'s routing consult
1444
+ * one set of service nicks rather than each maintaining its own
1445
+ * hand-written `if`-chain.
1446
+ */
1447
+ const SERVICE_REDUCER_KEYS: ReadonlyMap<string, ServiceReducerKey> = new Map([
1448
+ [caseFold('rfc1459', NICKSERV_NICK), 'nickserv'],
1449
+ [caseFold('rfc1459', CHANSERV_NICK), 'chanserv'],
1450
+ [caseFold('rfc1459', HOSTSERV_NICK), 'hostserv'],
1451
+ [caseFold('rfc1459', MEMOSERV_NICK), 'memoserv'],
1452
+ [caseFold('rfc1459', OPERSERV_NICK), 'operserv'],
1453
+ ]);
1454
+
1419
1455
  /**
1420
1456
  * Resolves the `<server>` parameter shared by `LUSERS` / `STATS` (and any
1421
1457
  * future single-server-aware query verb). Returns `undefined` when the