serverless-ircd 0.2.0 → 0.4.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 (221) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/deploy-aws.yml +1 -3
  3. package/.github/workflows/deploy-cf-tcp.yml +87 -0
  4. package/.github/workflows/deploy-cf.yml +1 -1
  5. package/.node-version +1 -0
  6. package/.nvmrc +1 -0
  7. package/CHANGELOG.md +258 -18
  8. package/README.md +72 -30
  9. package/apps/aws-stack/README.md +2 -2
  10. package/apps/aws-stack/bin/aws.ts +7 -0
  11. package/apps/aws-stack/package.json +4 -4
  12. package/apps/aws-stack/src/aws-stack.ts +118 -6
  13. package/apps/aws-stack/tests/stack.test.ts +98 -3
  14. package/apps/cf-tcp-container/Dockerfile +69 -0
  15. package/apps/cf-tcp-container/package.json +34 -0
  16. package/apps/cf-tcp-container/src/config-loader.ts +145 -0
  17. package/apps/cf-tcp-container/src/container-do.ts +38 -0
  18. package/apps/cf-tcp-container/src/container-server.ts +363 -0
  19. package/apps/cf-tcp-container/src/main.ts +77 -0
  20. package/apps/cf-tcp-container/src/persistence.ts +144 -0
  21. package/apps/cf-tcp-container/src/worker.ts +41 -0
  22. package/apps/cf-tcp-container/terraform/provider.tf +24 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
  25. package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
  26. package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
  27. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
  28. package/apps/cf-tcp-container/tsconfig.build.json +17 -0
  29. package/apps/cf-tcp-container/tsconfig.test.json +15 -0
  30. package/apps/cf-tcp-container/vitest.config.ts +26 -0
  31. package/apps/cf-tcp-container/wrangler.toml +63 -0
  32. package/apps/cf-worker/package.json +1 -1
  33. package/apps/cf-worker/scripts/smoke.mjs +0 -0
  34. package/apps/cf-worker/src/worker.ts +8 -2
  35. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  36. package/apps/cf-worker/wrangler.test.toml +11 -1
  37. package/apps/cf-worker/wrangler.toml +69 -19
  38. package/apps/local-cli/package.json +1 -1
  39. package/apps/local-cli/src/config-loader.ts +11 -0
  40. package/apps/local-cli/src/main.ts +1 -1
  41. package/apps/local-cli/src/server.ts +46 -3
  42. package/apps/local-cli/tests/e2e.test.ts +52 -0
  43. package/package.json +19 -16
  44. package/packages/aws-adapter/package.json +3 -3
  45. package/packages/aws-adapter/src/account-store.ts +121 -0
  46. package/packages/aws-adapter/src/admission.ts +74 -0
  47. package/packages/aws-adapter/src/aws-runtime.ts +124 -34
  48. package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
  49. package/packages/aws-adapter/src/config-loader.ts +62 -0
  50. package/packages/aws-adapter/src/dynamo-account-store.ts +95 -0
  51. package/packages/aws-adapter/src/handlers/connect.ts +64 -8
  52. package/packages/aws-adapter/src/handlers/default.ts +43 -2
  53. package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
  54. package/packages/aws-adapter/src/handlers/index.ts +120 -9
  55. package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
  56. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  57. package/packages/aws-adapter/src/index.ts +13 -0
  58. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +182 -0
  59. package/packages/aws-adapter/tests/account-store.test.ts +279 -0
  60. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  61. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  62. package/packages/aws-adapter/tests/config-loader.test.ts +75 -0
  63. package/packages/aws-adapter/tests/connect.test.ts +78 -0
  64. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +343 -0
  65. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  66. package/packages/aws-adapter/tests/handlers.test.ts +194 -47
  67. package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
  68. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  69. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  70. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  71. package/packages/cf-adapter/package.json +1 -1
  72. package/packages/cf-adapter/src/cf-runtime.ts +40 -22
  73. package/packages/cf-adapter/src/channel-do.ts +40 -1
  74. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  75. package/packages/cf-adapter/src/config-loader.ts +62 -0
  76. package/packages/cf-adapter/src/connection-do.ts +181 -31
  77. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  78. package/packages/cf-adapter/src/env.ts +58 -8
  79. package/packages/cf-adapter/src/index.ts +11 -9
  80. package/packages/cf-adapter/tests/cf-harness.ts +11 -1
  81. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
  82. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  83. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  84. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  85. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  86. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  87. package/packages/cf-adapter/tests/connection-do.test.ts +40 -0
  88. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  89. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  90. package/packages/cf-adapter/tests/worker/main.ts +9 -1
  91. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  92. package/packages/cf-adapter/wrangler.test.toml +18 -1
  93. package/packages/in-memory-runtime/package.json +1 -1
  94. package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
  95. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
  96. package/packages/irc-core/package.json +8 -2
  97. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  98. package/packages/irc-core/src/caps/capabilities.ts +23 -2
  99. package/packages/irc-core/src/case-fold.ts +64 -0
  100. package/packages/irc-core/src/cloak.ts +1 -1
  101. package/packages/irc-core/src/commands/cap.ts +8 -1
  102. package/packages/irc-core/src/commands/index.ts +11 -0
  103. package/packages/irc-core/src/commands/invite.ts +6 -9
  104. package/packages/irc-core/src/commands/ison.ts +61 -0
  105. package/packages/irc-core/src/commands/isupport.ts +1 -1
  106. package/packages/irc-core/src/commands/join.ts +4 -3
  107. package/packages/irc-core/src/commands/kick.ts +4 -11
  108. package/packages/irc-core/src/commands/list.ts +5 -4
  109. package/packages/irc-core/src/commands/mode.ts +5 -9
  110. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  111. package/packages/irc-core/src/commands/motd.ts +6 -110
  112. package/packages/irc-core/src/commands/oper.ts +151 -0
  113. package/packages/irc-core/src/commands/quit.ts +12 -0
  114. package/packages/irc-core/src/commands/registration.ts +26 -19
  115. package/packages/irc-core/src/commands/sasl.ts +72 -9
  116. package/packages/irc-core/src/commands/server-info.ts +129 -0
  117. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  118. package/packages/irc-core/src/commands/userhost.ts +84 -0
  119. package/packages/irc-core/src/commands/whowas.ts +113 -0
  120. package/packages/irc-core/src/config.ts +84 -3
  121. package/packages/irc-core/src/credential-hashing.ts +124 -0
  122. package/packages/irc-core/src/effects.ts +6 -29
  123. package/packages/irc-core/src/index.ts +3 -0
  124. package/packages/irc-core/src/ports.ts +240 -7
  125. package/packages/irc-core/src/protocol/numerics.ts +14 -8
  126. package/packages/irc-core/src/types.ts +60 -1
  127. package/packages/irc-core/tests/account-store.test.ts +131 -0
  128. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  129. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  130. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  131. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  132. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  133. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  134. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  135. package/packages/irc-core/tests/commands/registration.test.ts +256 -19
  136. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  137. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  138. package/packages/irc-core/tests/commands/tagmsg.test.ts +662 -0
  139. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  140. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  141. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  142. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  143. package/packages/irc-core/tests/config.test.ts +170 -1
  144. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  145. package/packages/irc-core/tests/effects.test.ts +0 -27
  146. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  147. package/packages/irc-core/tests/numerics.test.ts +12 -0
  148. package/packages/irc-core/tests/types.test.ts +35 -1
  149. package/packages/irc-core/tsconfig.build.json +1 -1
  150. package/packages/irc-core/tsconfig.test.json +1 -1
  151. package/packages/irc-server/package.json +1 -1
  152. package/packages/irc-server/src/actor.ts +182 -14
  153. package/packages/irc-server/src/dispatch.ts +0 -3
  154. package/packages/irc-server/src/index.ts +10 -2
  155. package/packages/irc-server/src/routing.ts +21 -0
  156. package/packages/irc-server/src/transport.ts +101 -0
  157. package/packages/irc-server/tests/actor.test.ts +617 -1
  158. package/packages/irc-server/tests/dispatch.test.ts +0 -17
  159. package/packages/irc-server/tests/routing.test.ts +7 -0
  160. package/packages/irc-server/tests/transport.test.ts +230 -0
  161. package/packages/irc-test-support/package.json +1 -1
  162. package/packages/irc-test-support/src/harness.ts +44 -9
  163. package/packages/irc-test-support/src/in-memory-harness.ts +78 -13
  164. package/packages/irc-test-support/src/index.ts +3 -0
  165. package/packages/irc-test-support/src/scenarios.ts +132 -2
  166. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  167. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  168. package/pnpm-workspace.yaml +9 -0
  169. package/tools/ci-hardening/package.json +1 -1
  170. package/tools/package.json +4 -0
  171. package/tools/seed-aws-accounts.ts +79 -0
  172. package/tools/seed-cf-accounts.ts +104 -0
  173. package/tools/tcp-ws-forwarder/package.json +1 -1
  174. package/AGENTS.md +0 -5
  175. package/MOTD.txt +0 -3
  176. package/PLAN-FIXES.md +0 -358
  177. package/PLAN.md +0 -420
  178. package/dashboards/cloudwatch-irc.json +0 -139
  179. package/docs/AWS-Adapter-Architecture.md +0 -494
  180. package/docs/AWS-Deployment.md +0 -1107
  181. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  182. package/docs/Home.md +0 -1
  183. package/docs/Observability.md +0 -87
  184. package/docs/PlanIRCv3Websocket.md +0 -489
  185. package/docs/PlanWebClient.md +0 -451
  186. package/docs/Release-Process.md +0 -440
  187. package/progress.md +0 -107
  188. package/tickets.md +0 -2485
  189. package/webircgateway/LICENSE +0 -201
  190. package/webircgateway/Makefile +0 -44
  191. package/webircgateway/README.md +0 -134
  192. package/webircgateway/config.conf.example +0 -135
  193. package/webircgateway/go.mod +0 -16
  194. package/webircgateway/go.sum +0 -89
  195. package/webircgateway/main.go +0 -118
  196. package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
  197. package/webircgateway/pkg/identd/identd.go +0 -86
  198. package/webircgateway/pkg/identd/rpcclient.go +0 -59
  199. package/webircgateway/pkg/irc/isupport.go +0 -56
  200. package/webircgateway/pkg/irc/message.go +0 -217
  201. package/webircgateway/pkg/irc/state.go +0 -79
  202. package/webircgateway/pkg/proxy/proxy.go +0 -129
  203. package/webircgateway/pkg/proxy/server.go +0 -237
  204. package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
  205. package/webircgateway/pkg/webircgateway/client.go +0 -741
  206. package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
  207. package/webircgateway/pkg/webircgateway/config.go +0 -385
  208. package/webircgateway/pkg/webircgateway/gateway.go +0 -278
  209. package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
  210. package/webircgateway/pkg/webircgateway/hooks.go +0 -152
  211. package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
  212. package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
  213. package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
  214. package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
  215. package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
  216. package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
  217. package/webircgateway/pkg/webircgateway/utils.go +0 -147
  218. package/webircgateway/plugins/example/plugin.go +0 -11
  219. package/webircgateway/plugins/stats/plugin.go +0 -52
  220. package/webircgateway/staticcheck.conf +0 -1
  221. package/webircgateway/webircgateway.svg +0 -3
@@ -28,14 +28,18 @@ import { randomUUID } from 'node:crypto';
28
28
  import { type Server, type Socket, createServer } from 'node:net';
29
29
  import { InMemoryRuntime } from '@serverless-ircd/in-memory-runtime';
30
30
  import {
31
+ type AccountStore,
31
32
  type AdmissionConfig,
32
33
  type ChanName,
33
34
  type ConnectionState,
34
35
  ConsoleLogger,
36
+ InMemoryAccountStore,
35
37
  InMemoryMessageStore,
38
+ InMemoryNickHistoryStore,
36
39
  LogLevel,
37
40
  type Logger,
38
41
  type MessageStore,
42
+ type NickHistoryStore,
39
43
  type RawLine,
40
44
  createConnection,
41
45
  } from '@serverless-ircd/irc-core';
@@ -57,6 +61,7 @@ type ResolvedServerConfig = Omit<
57
61
  | 'maxConnectionsPerUser'
58
62
  | 'perIpConnectionRate'
59
63
  | 'historyMaxPerChannel'
64
+ | 'saslAccounts'
60
65
  > &
61
66
  Pick<
62
67
  LocalServerConfig,
@@ -65,6 +70,7 @@ type ResolvedServerConfig = Omit<
65
70
  | 'maxConnectionsPerIp'
66
71
  | 'maxConnectionsPerUser'
67
72
  | 'perIpConnectionRate'
73
+ | 'saslAccounts'
68
74
  >;
69
75
 
70
76
  /** Default per-IP / per-user caps used when callers don't override. */
@@ -121,6 +127,13 @@ export interface LocalServerConfig {
121
127
  * CLI — the store is bound unconditionally).
122
128
  */
123
129
  historyMaxPerChannel?: number;
130
+ /**
131
+ * SASL PLAIN account credentials. When non-empty the server seeds an
132
+ * in-memory {@link AccountStore} so `AUTHENTICATE PLAIN` succeeds for the
133
+ * listed accounts. When omitted/empty, SASL account verification is
134
+ * disabled (`ctx.accounts` undefined → `904 ERR_SASLFAIL`).
135
+ */
136
+ saslAccounts?: Array<{ username: string; password: string }>;
124
137
  }
125
138
 
126
139
  export interface StartServerOptions extends LocalServerConfig {
@@ -151,6 +164,12 @@ export interface LocalServer {
151
164
  * one client is replayable by another. Exposed for test assertions.
152
165
  */
153
166
  readonly messages: MessageStore;
167
+ /**
168
+ * The per-process nick-history store backing `WHOWAS`. Shared across
169
+ * every connection so a nick signed off by one client is queryable by
170
+ * another. Exposed for test assertions.
171
+ */
172
+ readonly history: NickHistoryStore;
154
173
  close(): Promise<void>;
155
174
  /**
156
175
  * Test-only seam: live server-side WebSockets. Exposed so tests can
@@ -178,6 +197,7 @@ const DEFAULT_CONFIG: Omit<
178
197
  | 'maxConnectionsPerUser'
179
198
  | 'perIpConnectionRate'
180
199
  | 'historyMaxPerChannel'
200
+ | 'saslAccounts'
181
201
  > = {
182
202
  serverName: 'irc.example.com',
183
203
  networkName: 'ExampleNet',
@@ -242,6 +262,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
242
262
  ...(opts.channelLen !== undefined ? { channelLen: opts.channelLen } : {}),
243
263
  ...(opts.topicLen !== undefined ? { topicLen: opts.topicLen } : {}),
244
264
  ...(opts.quitMessage !== undefined ? { quitMessage: opts.quitMessage } : {}),
265
+ ...(opts.saslAccounts !== undefined ? { saslAccounts: opts.saslAccounts } : {}),
245
266
  });
246
267
  } catch (err) {
247
268
  return Promise.reject(err);
@@ -268,6 +289,9 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
268
289
  ...(opts.perIpConnectionRate !== undefined
269
290
  ? { perIpConnectionRate: opts.perIpConnectionRate }
270
291
  : {}),
292
+ ...(opts.saslAccounts !== undefined && opts.saslAccounts.length > 0
293
+ ? { saslAccounts: opts.saslAccounts }
294
+ : {}),
271
295
  };
272
296
  const hostname = opts.hostname ?? '127.0.0.1';
273
297
 
@@ -295,6 +319,20 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
295
319
  // is replayable when bob queries CHATHISTORY). The cap is config-driven.
296
320
  const messages = new InMemoryMessageStore(opts.historyMaxPerChannel);
297
321
 
322
+ // Per-process nick-history store: one TTL-buffer shared by every
323
+ // connection so cross-connection WHOWAS works (a nick alice quits is
324
+ // queryable by bob). Driven by the real-system clock so TTL eviction
325
+ // matches wall time.
326
+ const history = new InMemoryNickHistoryStore({ now: () => Date.now() });
327
+
328
+ // SASL account store: seeded from config so `AUTHENTICATE PLAIN` works
329
+ // end-to-end. `undefined` (no accounts configured) leaves the actor's
330
+ // `ctx.accounts` unset, preserving the no-store behaviour.
331
+ const accounts =
332
+ cfg.saslAccounts !== undefined && cfg.saslAccounts.length > 0
333
+ ? new InMemoryAccountStore(cfg.saslAccounts)
334
+ : undefined;
335
+
298
336
  const wss = new WebSocketServer({ port: opts.port, host: hostname });
299
337
  const wsConnections = new Map<WebSocket, ConnectionBindings>();
300
338
 
@@ -312,7 +350,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
312
350
  const admissionRecordId = decision.recordId;
313
351
  runtime.commitAdmission(ip, undefined, admissionRecordId);
314
352
 
315
- const { state, actor } = attachConnection(runtime, cfg, messages, {
353
+ const { state, actor } = attachConnection(runtime, cfg, messages, accounts, history, {
316
354
  sendText: (text) => {
317
355
  if (ws.readyState === WebSocket.OPEN) ws.send(text);
318
356
  },
@@ -389,7 +427,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
389
427
  const admissionRecordId = decision.recordId;
390
428
  runtime.commitAdmission(ip, undefined, admissionRecordId);
391
429
 
392
- const { state, actor } = attachConnection(runtime, cfg, messages, {
430
+ const { state, actor } = attachConnection(runtime, cfg, messages, accounts, history, {
393
431
  sendText: (text) => {
394
432
  if (socket.writable) socket.write(text);
395
433
  },
@@ -454,6 +492,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
454
492
  hostname,
455
493
  runtime,
456
494
  messages,
495
+ history,
457
496
  testSockets: wss.clients,
458
497
  testTcpSockets: tcpSockets,
459
498
  close: () =>
@@ -481,6 +520,8 @@ function attachConnection(
481
520
  runtime: InMemoryRuntime,
482
521
  cfg: ResolvedServerConfig,
483
522
  messages: MessageStore,
523
+ accounts: AccountStore | undefined,
524
+ history: NickHistoryStore,
484
525
  transport: {
485
526
  sendText: (text: string) => void;
486
527
  closeTransport: () => void;
@@ -523,6 +564,8 @@ function attachConnection(
523
564
  ids: DEFAULT_ID_FACTORY,
524
565
  motd: { lines: () => cfg.motdLines },
525
566
  messages,
567
+ ...(accounts !== undefined ? { accounts } : {}),
568
+ history,
526
569
  logger,
527
570
  });
528
571
 
@@ -615,4 +658,4 @@ function stringifyErr(err: unknown): unknown {
615
658
  }
616
659
 
617
660
  // Suppress "ChanName imported but unused" — kept for type re-export ergonomics.
618
- export type { ChanName, ConnectionState };
661
+ export type { ChanName, ConnectionState, NickHistoryStore };
@@ -418,3 +418,55 @@ describe('local-cli chathistory end-to-end', () => {
418
418
  await srv.close();
419
419
  });
420
420
  });
421
+
422
+ describe('local-cli SASL PLAIN end-to-end', () => {
423
+ /** base64(`\0authcid\0password`) — the PLAIN payload format. */
424
+ function plainB64(authcid: string, password: string): string {
425
+ return Buffer.from(`\0${authcid}\0${password}`, 'utf8').toString('base64');
426
+ }
427
+
428
+ it('completes SASL PLAIN with 900/903 for valid seeded creds', async () => {
429
+ const srv = await startLocalServer({
430
+ port: 0,
431
+ hostname: '127.0.0.1',
432
+ saslAccounts: [{ username: 'sasl-alice', password: 's3cret' }],
433
+ });
434
+
435
+ const client = new TestClient(`ws://127.0.0.1:${srv.port}/`);
436
+ await client.opened();
437
+ client.send('CAP REQ :sasl\r\n');
438
+ await client.waitFor((l) => / CAP \S+ ACK :sasl/.test(l));
439
+ client.send('AUTHENTICATE PLAIN\r\n');
440
+ await client.waitFor((l) => l === 'AUTHENTICATE +');
441
+ client.send(`AUTHENTICATE ${plainB64('sasl-alice', 's3cret')}\r\n`);
442
+ await client.waitFor((l) => l.includes(' 900 '));
443
+ const success = await client.waitFor((l) => l.includes(' 903 '));
444
+ expect(success).toContain(' 903 ');
445
+ client.send('NICK sasl-alice\r\n');
446
+ client.send('USER sasl-alice 0 * :Sasl Alice\r\n');
447
+ client.send('CAP END\r\n');
448
+ await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
449
+ await client.close();
450
+ await srv.close();
451
+ });
452
+
453
+ it('rejects SASL PLAIN with 904 for invalid creds', async () => {
454
+ const srv = await startLocalServer({
455
+ port: 0,
456
+ hostname: '127.0.0.1',
457
+ saslAccounts: [{ username: 'sasl-bob', password: 'right' }],
458
+ });
459
+
460
+ const client = new TestClient(`ws://127.0.0.1:${srv.port}/`);
461
+ await client.opened();
462
+ client.send('CAP REQ :sasl\r\n');
463
+ await client.waitFor((l) => / CAP \S+ ACK :sasl/.test(l));
464
+ client.send('AUTHENTICATE PLAIN\r\n');
465
+ await client.waitFor((l) => l === 'AUTHENTICATE +');
466
+ client.send(`AUTHENTICATE ${plainB64('sasl-bob', 'wrong')}\r\n`);
467
+ const fail = await client.waitFor((l) => l.includes(' 904 '));
468
+ expect(fail).toContain(' 904 ');
469
+ await client.close();
470
+ await srv.close();
471
+ });
472
+ });
package/package.json CHANGED
@@ -1,12 +1,23 @@
1
1
  {
2
2
  "name": "serverless-ircd",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "description": "Serverless IRC daemon with a platform-agnostic core and Cloudflare Workers + AWS adapters",
6
6
  "license": "BSD-3-Clause",
7
- "packageManager": "pnpm@9.15.9",
8
7
  "engines": {
9
- "node": ">=20"
8
+ "node": ">=24"
9
+ },
10
+ "devDependencies": {
11
+ "@biomejs/biome": "^1.9.4",
12
+ "@vitest/coverage-v8": "^4.1.10",
13
+ "fast-check": "^3.23.2",
14
+ "rimraf": "^6.1.3",
15
+ "tsx": "^4.19.0",
16
+ "turbo": "^2.10.7",
17
+ "typescript": "^5.9.3",
18
+ "vite": "^7.3.6",
19
+ "vitest": "^4.1.10",
20
+ "@serverless-ircd/aws-adapter": "0.4.0"
10
21
  },
11
22
  "scripts": {
12
23
  "build": "turbo run build",
@@ -24,18 +35,10 @@
24
35
  "deploy:cf:staging": "pnpm --filter @serverless-ircd/cf-worker deploy:staging",
25
36
  "deploy:cf:prod": "pnpm --filter @serverless-ircd/cf-worker deploy:prod",
26
37
  "smoke:cf:staging": "pnpm --filter @serverless-ircd/cf-worker smoke:staging",
27
- "deploy:aws:staging": "pnpm --filter @serverless-ircd/aws-stack deploy:staging",
28
- "deploy:aws:prod": "pnpm --filter @serverless-ircd/aws-stack deploy:prod",
38
+ "deploy:cf-tcp:staging": "pnpm build && pnpm --filter @serverless-ircd/cf-tcp-container deploy:staging",
39
+ "deploy:cf-tcp:prod": "pnpm build && pnpm --filter @serverless-ircd/cf-tcp-container deploy:prod",
40
+ "deploy:aws:staging": "pnpm build && pnpm --filter @serverless-ircd/aws-stack deploy:staging",
41
+ "deploy:aws:prod": "pnpm build && pnpm --filter @serverless-ircd/aws-stack deploy:prod",
29
42
  "smoke:aws:staging": "pnpm --filter @serverless-ircd/aws-stack smoke:staging"
30
- },
31
- "devDependencies": {
32
- "@biomejs/biome": "^1.9.0",
33
- "@vitest/coverage-v8": "^4.1.0",
34
- "fast-check": "^3.19.0",
35
- "rimraf": "^6.0.0",
36
- "turbo": "^2.0.0",
37
- "typescript": "^5.6.0",
38
- "vite": "^7.3.0",
39
- "vitest": "^4.1.0"
40
43
  }
41
- }
44
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/aws-adapter",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "private": true,
5
5
  "description": "AWS Lambda + DynamoDB adapter: AwsRuntime implementing IrcRuntime + $connect/$disconnect/$default handlers",
6
6
  "license": "BSD-3-Clause",
@@ -38,7 +38,7 @@
38
38
  "@serverless-ircd/irc-test-support": "workspace:*",
39
39
  "@types/node": "^26.1.1",
40
40
  "@vitest/coverage-v8": "^4.1.0",
41
- "aws-cdk-lib": "^2.160.0",
41
+ "aws-cdk-lib": "^2.262.0",
42
42
  "aws-sdk-client-mock": "^4.1.0",
43
43
  "constructs": "^10.4.0",
44
44
  "rimraf": "^6.0.0",
@@ -47,6 +47,6 @@
47
47
  "vitest": "^4.1.0"
48
48
  },
49
49
  "engines": {
50
- "node": ">=20"
50
+ "node": ">=24"
51
51
  }
52
52
  }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * SASL account store construction + resolution for the AWS adapter.
3
+ *
4
+ * Centralises `AccountStore` creation so the actor-construction site
5
+ * (`handleDefault`) binds a single helper rather than constructing the
6
+ * store inline. Two sources of credentials are reconciled here:
7
+ *
8
+ * 1. The DynamoDB `Accounts` table (authoritative when populated) —
9
+ * loaded at cold start into a {@link DynamoAccountStore} by
10
+ * {@link resolveAccountStore}. Credentials are stored as scrypt
11
+ * hashes, never plaintext.
12
+ * 2. The parsed server config (`saslAccounts`, sourced from the
13
+ * `SASL_ACCOUNTS` env var) — the legacy/config fallback used only
14
+ * when the table is empty, preserving the config-seed behaviour for
15
+ * deployments that have not migrated to the table.
16
+ *
17
+ * The resolved store is scoped to the Lambda execution context: it
18
+ * persists across warm invocations (via the memoised `HandlerDeps`) but
19
+ * is rebuilt on cold start, picking up account changes made since the
20
+ * last cold start.
21
+ */
22
+
23
+ import { PutCommand } from '@aws-sdk/lib-dynamodb';
24
+ import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
25
+ import {
26
+ type AccountStore,
27
+ InMemoryAccountStore,
28
+ type ParsedServerConfig,
29
+ } from '@serverless-ircd/irc-core';
30
+ import {
31
+ DynamoAccountStore,
32
+ type HashedAccountCredential,
33
+ hashAccountCredential,
34
+ loadDynamoAccountStore,
35
+ } from './dynamo-account-store.js';
36
+
37
+ // Re-export so callers can import everything from one module.
38
+ export {
39
+ DynamoAccountStore,
40
+ type HashedAccountCredential,
41
+ hashAccountCredential,
42
+ loadDynamoAccountStore,
43
+ };
44
+
45
+ /**
46
+ * Constructs the config-seeded in-memory account store (the legacy
47
+ * fallback). Returns `undefined` when no accounts are configured so the
48
+ * actor's `ctx.accounts` stays unset (preserving the no-store behaviour:
49
+ * `AUTHENTICATE PLAIN` → `904`).
50
+ *
51
+ * @param serverConfig Parsed config carrying the `saslAccounts` seed list.
52
+ */
53
+ export function bindAccountStore(serverConfig?: ParsedServerConfig): AccountStore | undefined {
54
+ if (serverConfig === undefined || serverConfig.saslAccounts.length === 0) {
55
+ return undefined;
56
+ }
57
+ return new InMemoryAccountStore(serverConfig.saslAccounts);
58
+ }
59
+
60
+ /**
61
+ * Resolves the SASL account store for a deployment, applying the
62
+ * table-then-config precedence.
63
+ *
64
+ * 1. Scans the DynamoDB `Accounts` table (pre-loading every hashed
65
+ * credential into a {@link DynamoAccountStore}). When the table has
66
+ * one or more rows it is authoritative — the table wins and the
67
+ * config seed is ignored.
68
+ * 2. When the table is empty, falls back to {@link bindAccountStore}
69
+ * (the `SASL_ACCOUNTS` env-var seed) so existing deployments that
70
+ * have not migrated to the table keep working unchanged.
71
+ * 3. Returns `undefined` when neither source has accounts (the default)
72
+ * so `ctx.accounts` stays unset and `AUTHENTICATE PLAIN` → `904`.
73
+ *
74
+ * Scan failures (table not found, network error) are swallowed and
75
+ * treated as an empty table, falling through to the config seed.
76
+ *
77
+ * @param docClient DynamoDB client addressed at the deployment's tables.
78
+ * @param tableName Physical name of the `Accounts` table for this env.
79
+ * @param serverConfig Parsed config (the config-seed fallback source).
80
+ */
81
+ export async function resolveAccountStore(
82
+ docClient: DynamoDBDocumentClient,
83
+ tableName: string,
84
+ serverConfig?: ParsedServerConfig,
85
+ ): Promise<AccountStore | undefined> {
86
+ try {
87
+ const store = await loadDynamoAccountStore(docClient, tableName);
88
+ if (store !== undefined) return store;
89
+ } catch {
90
+ // Table might not exist or be unreachable; fall through to config seed.
91
+ }
92
+ return bindAccountStore(serverConfig);
93
+ }
94
+
95
+ /**
96
+ * Writes (or re-seeds) a SASL PLAIN account into the `Accounts` table.
97
+ *
98
+ * The password is hashed via {@link hashAccountCredential} (scrypt,
99
+ * random salt) and stored as a {@link HashedAccountCredential} row —
100
+ * plaintext is never written. A repeat call for the same `username`
101
+ * overwrites the prior row (the PK is `account`), so this is the
102
+ * canonical CRUD/seed entry point for oper and SASL account
103
+ * provisioning.
104
+ *
105
+ * @returns The stored {@link HashedAccountCredential} (for tooling/tests).
106
+ */
107
+ export async function putAccountCredential(
108
+ docClient: DynamoDBDocumentClient,
109
+ tableName: string,
110
+ username: string,
111
+ password: string,
112
+ ): Promise<HashedAccountCredential> {
113
+ const entry = hashAccountCredential(username, password);
114
+ await docClient.send(
115
+ new PutCommand({
116
+ TableName: tableName,
117
+ Item: entry,
118
+ }),
119
+ );
120
+ return entry;
121
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Pure admission policy for the AWS `$connect` handler.
3
+ *
4
+ * The I/O (counting live rows in DynamoDB) lives in `handlers/connect.ts`;
5
+ * the *decision* — "does this count fit under the caps?" — is a pure
6
+ * function extracted here so it is trivial to unit-test every branch
7
+ * without standing up DynamoDB Local.
8
+ *
9
+ * This is deliberately separate from irc-core's `decideAdmission` /
10
+ * `AdmissionStats`: that helper is an in-memory counter design that
11
+ * assumes a single long-lived process. AWS Lambda invocations are
12
+ * independent processes with no shared memory, so the authoritative
13
+ * counts MUST come from DynamoDB (the shared `Connections` table) and
14
+ * are fed into this pure policy.
15
+ */
16
+
17
+ /**
18
+ * Live connection counts gathered from DynamoDB.
19
+ *
20
+ * - `total` is always supplied (a paginated `Scan COUNT` over
21
+ * `Connections`).
22
+ * - `perIp` is optional: counting connections per source IP needs a
23
+ * GSI on `sourceIp` (see `handlers/connect.ts` for the deferred
24
+ * per-IP follow-up). Until that lands, callers omit `perIp` and the
25
+ * per-IP branch of {@link decideConnectAdmission} is skipped.
26
+ */
27
+ export interface ConnectionCounts {
28
+ readonly total: number;
29
+ readonly perIp?: number;
30
+ }
31
+
32
+ /**
33
+ * Effective caps sourced from {@link ParsedServerConfig}. `perIp` is
34
+ * optional because per-IP enforcement is not yet wired on the AWS path.
35
+ */
36
+ export interface AdmissionLimits {
37
+ readonly maxClients: number;
38
+ readonly maxConnectionsPerIp?: number;
39
+ }
40
+
41
+ /**
42
+ * Outcome of an admission decision. Mirrors the {@link ConnectOutcome}
43
+ * shape returned by `handleConnect` so the handler can return the
44
+ * decision verbatim.
45
+ */
46
+ export type AdmissionOutcome =
47
+ | { readonly admitted: true }
48
+ | { readonly admitted: false; readonly statusCode: number; readonly reason: string };
49
+
50
+ /**
51
+ * Decides whether a new connection should be admitted given the live
52
+ * counts and the configured caps. Pure: same inputs ⇒ same output, no
53
+ * ambient state.
54
+ *
55
+ * Precedence: the global `maxClients` cap is checked first (it is the
56
+ * cheap, always-available gate), then the optional per-IP cap when both
57
+ * the limit and the `perIp` count are present.
58
+ */
59
+ export function decideConnectAdmission(
60
+ counts: ConnectionCounts,
61
+ limits: AdmissionLimits,
62
+ ): AdmissionOutcome {
63
+ if (counts.total >= limits.maxClients) {
64
+ return { admitted: false, statusCode: 429, reason: 'server full' };
65
+ }
66
+ if (
67
+ limits.maxConnectionsPerIp !== undefined &&
68
+ counts.perIp !== undefined &&
69
+ counts.perIp >= limits.maxConnectionsPerIp
70
+ ) {
71
+ return { admitted: false, statusCode: 429, reason: 'too many connections from this IP' };
72
+ }
73
+ return { admitted: true };
74
+ }
@@ -80,7 +80,8 @@ import type { TablesConfig } from './tables.js';
80
80
  * is the in-memory `received` array.
81
81
  * - `disconnect(reason?)` closes the bound transport. In Lambda this
82
82
  * is a no-op — the canonical disconnect path is the APIGW `$disconnect`
83
- * route, which fires after the socket is already gone.
83
+ * route, which fires after the socket is already gone. See
84
+ * `docs/AWS-Deployment.md` §14.1 for the full platform-limit write-up.
84
85
  * - `snapshot()` returns the in-memory ConnectionState the actor is
85
86
  * mutating this frame. Critical so `getConnection(boundId)` returns
86
87
  * the live view (and dispatch's reducer-observable effects see
@@ -185,11 +186,12 @@ export class AwsRuntime implements IrcRuntime {
185
186
  this.handlers.disconnect(reason);
186
187
  return;
187
188
  }
188
- // Cross-connection disconnect is a no-op in 040: the Lambda runtime
189
- // cannot directly close another connection's WebSocket. The canonical
189
+ // Cross-connection disconnect is a no-op: the Lambda runtime cannot
190
+ // directly close another connection's WebSocket. The canonical
190
191
  // disconnect path is the APIGW `$disconnect` route, which fires when
191
192
  // the socket is already gone. KICK/PART remove membership rows but
192
- // do not need to force-close the socket.
193
+ // do not need to force-close the socket. See docs/AWS-Deployment.md
194
+ // §14.1 for the full platform-limit write-up and the mitigations.
193
195
  }
194
196
 
195
197
  async sendToNick(
@@ -627,56 +629,144 @@ export class AwsRuntime implements IrcRuntime {
627
629
 
628
630
  /**
629
631
  * Tears down a connection: deletes its Connections row, removes every
630
- * ChannelMembers row owned by it, and releases its nick (if any).
632
+ * ChannelMembers row owned by it, releases its nick (if any), and — when
633
+ * a `managementApi` is supplied — broadcasts a `QUIT` line to every peer
634
+ * that shares a channel with the disconnecting connection.
631
635
  *
632
636
  * Idempotent: every step tolerates a missing row. Safe to call from
633
- * `$disconnect`, from the `send` GoneException path, and from the
634
- * future sweeper Lambda.
637
+ * `$disconnect`, from the `send`/`broadcast` `GoneException` path, and
638
+ * from the sweeper Lambda.
635
639
  *
636
- * `managementApi` is optional because the cleanup path itself does not
637
- * send WebSocket bytes (the socket is, by definition, gone).
640
+ * Recursion safety: the Connections row is deleted *before* the QUIT
641
+ * fanout so a recursive `cleanupConnection` triggered by a gone peer
642
+ * during fanout finds the row already gone and returns immediately —
643
+ * no unbounded recursion.
644
+ *
645
+ * `managementApi` is optional because callers that never need fanout
646
+ * (the sweeper Lambda, or a no-op test) may pass `null`.
638
647
  */
639
648
  export async function cleanupConnection(
640
649
  dynamo: DynamoDBDocumentClient,
641
650
  tables: TablesConfig,
642
651
  connId: ConnId,
643
652
  managementApi: ApiGatewayManagementApi | PostToConnection | null,
653
+ quitMessage = 'Client Quit',
644
654
  ): Promise<void> {
645
- void managementApi; // reserved for a future "broadcast QUIT to peers" step
646
655
  const connRow = await dynamo.send(
647
656
  new GetCommand({ TableName: tables.Connections, Key: { connectionId: connId } }),
648
657
  );
649
658
  const item = connRow.Item as unknown as MarshalledConnection | undefined;
650
- if (item !== undefined) {
651
- if (item.nick !== undefined) {
652
- try {
653
- await dynamo.send(
654
- new DeleteCommand({
655
- TableName: tables.Nicks,
656
- Key: { nickLower: item.nick.toLowerCase() },
657
- }),
658
- );
659
- } catch {
660
- // Idempotent — the nick may have been released already.
661
- }
659
+ if (item === undefined) return; // idempotent — already cleaned up
660
+
661
+ // Delete the Connections row FIRST. A recursive cleanup triggered by
662
+ // a gone peer during QUIT fanout reads this same row, finds it gone,
663
+ // and returns — bounding the recursion at one extra hop.
664
+ await dynamo.send(
665
+ new DeleteCommand({ TableName: tables.Connections, Key: { connectionId: connId } }),
666
+ );
667
+
668
+ if (item.nick !== undefined) {
669
+ try {
670
+ await dynamo.send(
671
+ new DeleteCommand({
672
+ TableName: tables.Nicks,
673
+ Key: { nickLower: item.nick.toLowerCase() },
674
+ }),
675
+ );
676
+ } catch {
677
+ // Idempotent — the nick may have been released already.
678
+ }
679
+ }
680
+
681
+ // Broadcast QUIT to peers in shared channels *before* removing the
682
+ // disconnecting connection's membership rows — the roster query needs
683
+ // those rows to resolve the fanout targets.
684
+ if (item.nick !== undefined && managementApi !== null) {
685
+ await broadcastQuitToChannels(dynamo, tables, connId, item, managementApi, quitMessage);
686
+ }
687
+
688
+ const joined = item.joinedChannels ?? [];
689
+ for (const chan of joined) {
690
+ try {
691
+ await dynamo.send(
692
+ new DeleteCommand({
693
+ TableName: tables.ChannelMembers,
694
+ Key: { channelName: chan.toLowerCase(), connectionId: connId },
695
+ }),
696
+ );
697
+ } catch {
698
+ // Idempotent — the membership may have been removed already.
662
699
  }
663
- const joined = item.joinedChannels ?? [];
664
- for (const chan of joined) {
700
+ }
701
+ }
702
+
703
+ /**
704
+ * Builds the canonical `:nick!user@host QUIT :<reason>` line for the
705
+ * disconnecting connection and fans it out to every *other* member of
706
+ * each channel the connection had joined. A peer whose APIGW socket is
707
+ * already gone raises `GoneException`, which is caught and routed back
708
+ * through {@link cleanupConnection} for lazy cleanup — a partially
709
+ * torn-down roster never crashes the fanout loop.
710
+ *
711
+ * Shared by both the `$disconnect` route and the `send()`/`broadcast()`
712
+ * `GoneException` cleanup path.
713
+ */
714
+ async function broadcastQuitToChannels(
715
+ dynamo: DynamoDBDocumentClient,
716
+ tables: TablesConfig,
717
+ connId: ConnId,
718
+ item: MarshalledConnection,
719
+ managementApi: ApiGatewayManagementApi | PostToConnection,
720
+ quitMessage: string,
721
+ ): Promise<void> {
722
+ const hostmask = buildHostmask(item);
723
+ if (hostmask === undefined) return; // no nick → nothing to attribute the QUIT to
724
+ const line = `:${hostmask} QUIT :${quitMessage}`;
725
+ const joined = item.joinedChannels ?? [];
726
+ for (const chan of joined) {
727
+ const members = await queryChannelMembersRows(dynamo, tables, chan.toLowerCase());
728
+ for (const member of members) {
729
+ if (member.connectionId === connId) continue; // never echo to self
665
730
  try {
666
- await dynamo.send(
667
- new DeleteCommand({
668
- TableName: tables.ChannelMembers,
669
- Key: { channelName: chan.toLowerCase(), connectionId: connId },
670
- }),
671
- );
672
- } catch {
673
- // Idempotent — the membership may have been removed already.
731
+ await managementApi.postToConnection({ ConnectionId: member.connectionId, Data: line });
732
+ } catch (err: unknown) {
733
+ if (err instanceof GoneException || isGone(err)) {
734
+ await cleanupConnection(dynamo, tables, member.connectionId, managementApi, quitMessage);
735
+ } else {
736
+ throw err;
737
+ }
674
738
  }
675
739
  }
676
740
  }
677
- await dynamo.send(
678
- new DeleteCommand({ TableName: tables.Connections, Key: { connectionId: connId } }),
741
+ }
742
+
743
+ /**
744
+ * Builds the `nick!user@host` hostmask from a stored connection row,
745
+ * omitting the absent parts. Mirrors {@link hostmaskOf} in irc-core but
746
+ * operates on the marshalled shape (no full unmarshal needed).
747
+ */
748
+ function buildHostmask(item: MarshalledConnection): string | undefined {
749
+ if (item.nick === undefined) return undefined;
750
+ let out: string = item.nick;
751
+ if (item.user !== undefined) out = `${out}!${item.user}`;
752
+ if (item.host !== undefined) out = `${out}@${item.host}`;
753
+ return out;
754
+ }
755
+
756
+ /** Queries the raw `ChannelMembers` rows for one channel (by lowercased key). */
757
+ async function queryChannelMembersRows(
758
+ dynamo: DynamoDBDocumentClient,
759
+ tables: TablesConfig,
760
+ key: string,
761
+ ): Promise<MarshalledChannelMember[]> {
762
+ const result = await dynamo.send(
763
+ new QueryCommand({
764
+ TableName: tables.ChannelMembers,
765
+ KeyConditionExpression: 'channelName = :k',
766
+ ExpressionAttributeValues: { ':k': key },
767
+ }),
679
768
  );
769
+ return (result.Items ?? []) as unknown as MarshalledChannelMember[];
680
770
  }
681
771
 
682
772
  // ---------------------------------------------------------------------------