serverless-ircd 0.1.0 → 0.2.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 (198) hide show
  1. package/.github/workflows/ci.yml +96 -2
  2. package/.github/workflows/deploy-aws.yml +129 -0
  3. package/.github/workflows/deploy-cf.yml +0 -2
  4. package/.gitmodules +3 -0
  5. package/AGENTS.md +5 -0
  6. package/CHANGELOG.md +352 -0
  7. package/MOTD.txt +3 -0
  8. package/PLAN-FIXES.md +358 -0
  9. package/PLAN.md +420 -0
  10. package/README.md +115 -81
  11. package/apps/aws-stack/README.md +73 -0
  12. package/apps/aws-stack/bin/aws.ts +49 -0
  13. package/apps/aws-stack/cdk.json +10 -0
  14. package/apps/aws-stack/package.json +41 -0
  15. package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
  16. package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
  17. package/apps/aws-stack/scripts/smoke.mjs +142 -0
  18. package/apps/aws-stack/src/aws-stack.ts +263 -0
  19. package/apps/aws-stack/src/tables.ts +10 -0
  20. package/apps/aws-stack/tests/localstack.test.ts +46 -0
  21. package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
  22. package/apps/aws-stack/tests/stack.test.ts +464 -0
  23. package/apps/aws-stack/tsconfig.build.json +11 -0
  24. package/apps/aws-stack/tsconfig.test.json +8 -0
  25. package/apps/aws-stack/vitest.config.ts +20 -0
  26. package/apps/cf-worker/package.json +1 -1
  27. package/apps/cf-worker/wrangler.toml +25 -0
  28. package/apps/local-cli/package.json +1 -1
  29. package/apps/local-cli/src/config-loader.ts +56 -0
  30. package/apps/local-cli/src/server.ts +241 -32
  31. package/apps/local-cli/tests/config-loader.test.ts +107 -0
  32. package/apps/local-cli/tests/e2e.test.ts +74 -0
  33. package/apps/local-cli/tests/security-e2e.test.ts +239 -0
  34. package/biome.json +3 -1
  35. package/dashboards/cloudwatch-irc.json +139 -0
  36. package/docs/AWS-Adapter-Architecture.md +494 -0
  37. package/docs/AWS-Deployment.md +1107 -0
  38. package/docs/Cloudflare-Deployment-Guide.md +660 -0
  39. package/docs/Home.md +1 -0
  40. package/docs/Observability.md +87 -0
  41. package/docs/PlanIRCv3Websocket.md +489 -0
  42. package/docs/PlanWebClient.md +451 -0
  43. package/docs/Release-Process.md +440 -0
  44. package/package.json +8 -2
  45. package/packages/aws-adapter/README.md +35 -0
  46. package/packages/aws-adapter/package.json +52 -0
  47. package/packages/aws-adapter/src/aws-runtime.ts +783 -0
  48. package/packages/aws-adapter/src/cdk-table-defs.ts +69 -0
  49. package/packages/aws-adapter/src/config-loader.ts +96 -0
  50. package/packages/aws-adapter/src/dynamo.ts +61 -0
  51. package/packages/aws-adapter/src/handlers/connect.ts +44 -0
  52. package/packages/aws-adapter/src/handlers/default.ts +322 -0
  53. package/packages/aws-adapter/src/handlers/disconnect.ts +29 -0
  54. package/packages/aws-adapter/src/handlers/index.ts +248 -0
  55. package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
  56. package/packages/aws-adapter/src/handlers/state.ts +13 -0
  57. package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
  58. package/packages/aws-adapter/src/index.ts +67 -0
  59. package/packages/aws-adapter/src/message-store.ts +34 -0
  60. package/packages/aws-adapter/src/serialize.ts +283 -0
  61. package/packages/aws-adapter/src/tables.ts +53 -0
  62. package/packages/aws-adapter/tests/aws-harness.ts +408 -0
  63. package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
  64. package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
  65. package/packages/aws-adapter/tests/config-loader.test.ts +158 -0
  66. package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
  67. package/packages/aws-adapter/tests/global-setup.ts +301 -0
  68. package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
  69. package/packages/aws-adapter/tests/handlers.test.ts +427 -0
  70. package/packages/aws-adapter/tests/message-store.test.ts +55 -0
  71. package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
  72. package/packages/aws-adapter/tests/serialize.test.ts +249 -0
  73. package/packages/aws-adapter/tests/smoke.test.ts +48 -0
  74. package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
  75. package/packages/aws-adapter/tests/tables.test.ts +74 -0
  76. package/packages/aws-adapter/tests/transactions.test.ts +446 -0
  77. package/packages/aws-adapter/tsconfig.build.json +16 -0
  78. package/packages/aws-adapter/tsconfig.test.json +14 -0
  79. package/packages/aws-adapter/vitest.config.ts +23 -0
  80. package/packages/cf-adapter/package.json +2 -1
  81. package/packages/cf-adapter/src/config-loader.ts +106 -0
  82. package/packages/cf-adapter/src/connection-do.ts +34 -0
  83. package/packages/cf-adapter/tests/cf-harness.ts +2 -2
  84. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
  85. package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
  86. package/packages/cf-adapter/tests/connection-do.test.ts +42 -0
  87. package/packages/in-memory-runtime/package.json +1 -1
  88. package/packages/in-memory-runtime/src/in-memory-runtime.ts +108 -3
  89. package/packages/in-memory-runtime/src/index.ts +1 -1
  90. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +115 -0
  91. package/packages/irc-core/package.json +12 -2
  92. package/packages/irc-core/src/admission.ts +216 -0
  93. package/packages/irc-core/src/caps/capabilities.ts +1 -0
  94. package/packages/irc-core/src/cloak.ts +81 -0
  95. package/packages/irc-core/src/commands/cap.ts +1 -2
  96. package/packages/irc-core/src/commands/chathistory.ts +305 -0
  97. package/packages/irc-core/src/commands/index.ts +7 -0
  98. package/packages/irc-core/src/commands/join.ts +59 -7
  99. package/packages/irc-core/src/commands/privmsg.ts +24 -0
  100. package/packages/irc-core/src/commands/registration.ts +71 -14
  101. package/packages/irc-core/src/commands/sasl.ts +251 -0
  102. package/packages/irc-core/src/config.ts +247 -0
  103. package/packages/irc-core/src/flood-control.ts +175 -0
  104. package/packages/irc-core/src/index.ts +5 -0
  105. package/packages/irc-core/src/ports.ts +655 -0
  106. package/packages/irc-core/src/protocol/base64.ts +16 -0
  107. package/packages/irc-core/src/protocol/index.ts +2 -1
  108. package/packages/irc-core/src/protocol/numerics.ts +11 -0
  109. package/packages/irc-core/src/protocol/parser.ts +27 -2
  110. package/packages/irc-core/src/state/connection.ts +41 -0
  111. package/packages/irc-core/src/types.ts +66 -2
  112. package/packages/irc-core/stryker.commands.conf.json +41 -0
  113. package/packages/irc-core/stryker.protocol.conf.json +26 -0
  114. package/packages/irc-core/tests/admission.test.ts +229 -0
  115. package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
  116. package/packages/irc-core/tests/cloak.test.ts +78 -0
  117. package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
  118. package/packages/irc-core/tests/commands/join.test.ts +246 -2
  119. package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
  120. package/packages/irc-core/tests/commands/registration.test.ts +277 -9
  121. package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
  122. package/packages/irc-core/tests/config.test.ts +646 -0
  123. package/packages/irc-core/tests/flood-control.test.ts +394 -0
  124. package/packages/irc-core/tests/message-store.test.ts +530 -0
  125. package/packages/irc-core/tests/parser.test.ts +44 -1
  126. package/packages/irc-core/tests/ports.test.ts +263 -0
  127. package/packages/irc-server/package.json +1 -1
  128. package/packages/irc-server/src/actor.ts +162 -44
  129. package/packages/irc-server/src/dispatch.ts +89 -5
  130. package/packages/irc-server/src/index.ts +2 -0
  131. package/packages/irc-server/src/routing.ts +151 -0
  132. package/packages/irc-server/tests/actor.test.ts +470 -14
  133. package/packages/irc-server/tests/dispatch.test.ts +84 -0
  134. package/packages/irc-server/tests/routing.test.ts +201 -0
  135. package/packages/irc-test-support/README.md +32 -0
  136. package/packages/irc-test-support/package.json +37 -0
  137. package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +1 -1
  138. package/packages/irc-test-support/src/index.ts +24 -0
  139. package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
  140. package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
  141. package/packages/irc-test-support/tests/smoke.test.ts +11 -0
  142. package/packages/irc-test-support/tsconfig.build.json +16 -0
  143. package/packages/irc-test-support/tsconfig.test.json +14 -0
  144. package/packages/irc-test-support/vitest.config.ts +20 -0
  145. package/progress.md +107 -0
  146. package/tickets.md +2485 -0
  147. package/tools/ci-hardening/package.json +30 -0
  148. package/tools/ci-hardening/src/index.ts +6 -0
  149. package/tools/ci-hardening/src/validate.ts +103 -0
  150. package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
  151. package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
  152. package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
  153. package/tools/ci-hardening/tests/validate.test.ts +177 -0
  154. package/tools/ci-hardening/tsconfig.build.json +12 -0
  155. package/tools/ci-hardening/tsconfig.test.json +10 -0
  156. package/tools/ci-hardening/vitest.config.ts +21 -0
  157. package/tools/tcp-ws-forwarder/package.json +1 -1
  158. package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
  159. package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
  160. package/tools/tcp-ws-forwarder/src/main.ts +33 -14
  161. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
  162. package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
  163. package/webircgateway/LICENSE +201 -0
  164. package/webircgateway/Makefile +44 -0
  165. package/webircgateway/README.md +134 -0
  166. package/webircgateway/config.conf.example +135 -0
  167. package/webircgateway/go.mod +16 -0
  168. package/webircgateway/go.sum +89 -0
  169. package/webircgateway/main.go +118 -0
  170. package/webircgateway/pkg/dnsbl/dnsbl.go +121 -0
  171. package/webircgateway/pkg/identd/identd.go +86 -0
  172. package/webircgateway/pkg/identd/rpcclient.go +59 -0
  173. package/webircgateway/pkg/irc/isupport.go +56 -0
  174. package/webircgateway/pkg/irc/message.go +217 -0
  175. package/webircgateway/pkg/irc/state.go +79 -0
  176. package/webircgateway/pkg/proxy/proxy.go +129 -0
  177. package/webircgateway/pkg/proxy/server.go +237 -0
  178. package/webircgateway/pkg/recaptcha/recaptcha.go +59 -0
  179. package/webircgateway/pkg/webircgateway/client.go +741 -0
  180. package/webircgateway/pkg/webircgateway/client_command_handlers.go +495 -0
  181. package/webircgateway/pkg/webircgateway/config.go +385 -0
  182. package/webircgateway/pkg/webircgateway/gateway.go +278 -0
  183. package/webircgateway/pkg/webircgateway/gateway_utils.go +133 -0
  184. package/webircgateway/pkg/webircgateway/hooks.go +152 -0
  185. package/webircgateway/pkg/webircgateway/letsencrypt.go +41 -0
  186. package/webircgateway/pkg/webircgateway/messagetags.go +103 -0
  187. package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +206 -0
  188. package/webircgateway/pkg/webircgateway/transport_sockjs.go +107 -0
  189. package/webircgateway/pkg/webircgateway/transport_tcp.go +113 -0
  190. package/webircgateway/pkg/webircgateway/transport_websocket.go +126 -0
  191. package/webircgateway/pkg/webircgateway/utils.go +147 -0
  192. package/webircgateway/plugins/example/plugin.go +11 -0
  193. package/webircgateway/plugins/stats/plugin.go +52 -0
  194. package/webircgateway/staticcheck.conf +1 -0
  195. package/webircgateway/webircgateway.svg +3 -0
  196. package/packages/cf-adapter/tests/integration/index.ts +0 -17
  197. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
  198. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
@@ -17,12 +17,14 @@
17
17
 
18
18
  import { Effect } from '../effects.js';
19
19
  import type { Effect as EffectType, RawLine } from '../effects.js';
20
+ import type { StoredMessage } from '../ports.js';
20
21
  import { wrapBatch } from '../protocol/batch.js';
21
22
  import { Numerics } from '../protocol/numerics.js';
22
23
  import { type ChanName, type ChannelState, prefixOf } from '../state/channel.js';
23
24
  import { hostmaskOf } from '../state/connection.js';
24
25
  import type { ConnectionState } from '../state/connection.js';
25
26
  import type { Ctx, Reducer } from '../types.js';
27
+ import { CHATHISTORY_CAP, buildChathistoryBatch } from './chathistory.js';
26
28
 
27
29
  /**
28
30
  * RFC 1459/2812 channel-name grammar. The first character must be a channel
@@ -64,16 +66,13 @@ function buildNamesList(state: ChannelState): string {
64
66
 
65
67
  /**
66
68
  * Formats a single numeric error line addressed to the connection's current
67
- * nick (or `*` when unregistered). Optional `middle` is emitted before the
68
- * trailing `:`-prefixed text.
69
+ * nick (or `*` when unregistered). `middle` is emitted before the trailing
70
+ * `:`-prefixed text.
69
71
  */
70
- function numericErr(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
72
+ function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
71
73
  const nick = ctx.connection.nick ?? '*';
72
74
  const codeStr = code.toString().padStart(3, '0');
73
- const parts = [`:${ctx.serverName}`, codeStr, nick];
74
- if (middle !== undefined) parts.push(middle);
75
- parts.push(`:${trailing}`);
76
- return { text: parts.join(' ') };
75
+ return { text: [`:${ctx.serverName}`, codeStr, nick, middle, `:${trailing}`].join(' ') };
77
76
  }
78
77
 
79
78
  /**
@@ -107,6 +106,44 @@ function joinerHostmask(conn: ConnectionState): string {
107
106
  return hostmaskOf(conn) ?? conn.nick ?? '?';
108
107
  }
109
108
 
109
+ /** Default backlog depth replayed on JOIN when ServerConfig omits a value. */
110
+ const DEFAULT_CHATHISTORY_PLAYBACK_LIMIT = 50;
111
+
112
+ /**
113
+ * Builds the IRCv3 `draft/chathistory` auto-playback BATCH for a cap-enabled
114
+ * joiner and computes the next "last read" marker.
115
+ *
116
+ * Returns `{ effects: [], marker: undefined }` when playback is disabled (cap
117
+ * not negotiated, no MessageStore bound, or no messages newer than the
118
+ * marker); otherwise a single `Send` carrying the BATCH frame and the msgid
119
+ * of the newest replayed message. The caller is responsible for prepending
120
+ * the effects before the JOIN broadcast and for advancing the per-connection
121
+ * marker so a PART/re-JOIN cycle with no new activity replays nothing.
122
+ */
123
+ function buildChathistoryPlayback(
124
+ state: ChannelState,
125
+ ctx: Ctx,
126
+ ): { effects: EffectType[]; marker: string | undefined } {
127
+ if (!ctx.connection.caps.has(CHATHISTORY_CAP)) {
128
+ return { effects: [], marker: undefined };
129
+ }
130
+ if (ctx.messages === undefined) {
131
+ return { effects: [], marker: undefined };
132
+ }
133
+ const limit = ctx.serverConfig.chatHistoryPlaybackLimit ?? DEFAULT_CHATHISTORY_PLAYBACK_LIMIT;
134
+ const previousMarker = ctx.connection.lastReadMarkers?.get(state.nameLower);
135
+ const recent = ctx.messages.recent(state.nameLower, previousMarker, limit);
136
+ if (recent.length === 0) {
137
+ return { effects: [], marker: undefined };
138
+ }
139
+ // `recent` is non-empty here, so the last entry is defined; the assertion
140
+ // only satisfies the type-checker under `noUncheckedIndexedAccess` (no
141
+ // runtime branch, no `!` token).
142
+ const newest = recent[recent.length - 1] as StoredMessage;
143
+ const effects = buildChathistoryBatch(ctx, state.name, recent);
144
+ return { effects, marker: newest.msgid };
145
+ }
146
+
110
147
  /**
111
148
  * Handles `JOIN <chan> [<key>]` for a single channel.
112
149
  *
@@ -244,6 +281,21 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
244
281
  }),
245
282
  );
246
283
 
284
+ // IRCv3 draft/chathistory auto-playback: a cap-enabled joiner with a
285
+ // bound MessageStore receives the recent backlog as a BATCH *before*
286
+ // the JOIN broadcast, then the per-(connection, channel) "last read"
287
+ // marker advances to the newest replayed msgid. PART/re-JOIN cycles
288
+ // only replay messages newer than the marker, so a re-join with no
289
+ // new activity emits nothing.
290
+ const playback = buildChathistoryPlayback(state, ctx);
291
+ if (playback.marker !== undefined) {
292
+ if (ctx.connection.lastReadMarkers === undefined) {
293
+ ctx.connection.lastReadMarkers = new Map<string, string>();
294
+ }
295
+ ctx.connection.lastReadMarkers.set(state.nameLower, playback.marker);
296
+ }
297
+ effects.push(...playback.effects);
298
+
247
299
  const legacyJoin: RawLine = { text: `:${hostmask} JOIN ${state.name}` };
248
300
  const realname = ctx.connection.realname;
249
301
  if (realname !== undefined) {
@@ -173,6 +173,30 @@ function buildChannelReducer(command: MessageCommand): Reducer<ChannelState> {
173
173
  }
174
174
  }
175
175
 
176
+ // All checks passed: persist the message for chathistory playback when a
177
+ // MessageStore is bound. The msgid/time are generated once here and travel
178
+ // on the StoredMessage; the live broadcast line stays bare (per-recipient
179
+ // server-time/msgid decoration is the dispatch layer's job). Recording is
180
+ // gated on ctx.messages so deployments with chathistory disabled see no
181
+ // change whatsoever.
182
+ //
183
+ // By this point the sender's hostmask has already been computed and used
184
+ // in the `+b` ban check above, so the connection's nick/user/host are all
185
+ // defined for any registered PRIVMSG (registration sets all three); the
186
+ // fallbacks mirror `senderHostmask` purely for the type-checker.
187
+ if (ctx.messages !== undefined) {
188
+ ctx.messages.record({
189
+ msgid: ctx.ids.nonce(),
190
+ time: ctx.clock.now(),
191
+ chan: state.nameLower,
192
+ command,
193
+ nick: ctx.connection.nick as string,
194
+ user: ctx.connection.user as string,
195
+ host: ctx.connection.host as string,
196
+ text,
197
+ });
198
+ }
199
+
176
200
  // All checks passed: broadcast to the channel, excluding the sender.
177
201
  const line: RawLine = { text: `:${hostmask} ${command} ${state.name} :${text}` };
178
202
  effects.push(Effect.broadcast(state.name, [line], ctx.connId));
@@ -13,6 +13,7 @@
13
13
  * numeric on `{ ok: false }`.
14
14
  */
15
15
 
16
+ import { cloakHost } from '../cloak.js';
16
17
  import { Effect } from '../effects.js';
17
18
  import type { Effect as EffectType, RawLine } from '../effects.js';
18
19
  import { Numerics } from '../protocol/numerics.js';
@@ -25,7 +26,7 @@ import { generateIsupport } from './isupport.js';
25
26
  * Server version reported in `002` / `004`. Placeholder until a config-driven
26
27
  * version lands.
27
28
  */
28
- const SERVER_VERSION = '0.1.0';
29
+ const SERVER_VERSION = '0.2.0';
29
30
 
30
31
  /** "Created" text for `003 RPL_CREATED`. Placeholder. */
31
32
  const CREATED_TEXT = 'Phase 1';
@@ -88,21 +89,79 @@ export function buildWelcomeLines(state: ConnectionState, ctx: Ctx): RawLine[] {
88
89
  }
89
90
 
90
91
  /**
91
- * Attempts to finalize registration: if both `nick` and `user` are present
92
- * and the connection is not yet registered, transitions `state.registration`
93
- * to `'registered'` and returns a `Send` effect carrying the welcome block.
94
- * Otherwise returns `null` and leaves state untouched.
92
+ * Attempts to finalize registration: if both `nick` and `user` are present,
93
+ * the connection is not yet registered, and CAP negotiation is not in
94
+ * flight, transitions `state.registration` to `'registered'` and returns
95
+ * a `Send` effect carrying the welcome block.
96
+ *
97
+ * Side effects are returned as an array so the caller can `push(...)` them
98
+ * onto its own effect list. An empty array means "not ready / no work".
99
+ *
100
+ * Enforces the deployment's server-password gate (configured via
101
+ * `serverConfig.serverPassword`) before completing registration:
102
+ * - No password configured (undefined or empty) → gate skipped.
103
+ * - SASL already authenticated (`state.account` set) → gate satisfied.
104
+ * - Otherwise `state.passAttempt` must equal the configured password.
105
+ *
106
+ * On gate failure the connection is NOT registered; the returned effects
107
+ * are `[Send(464 ERR_PASSWDMISMATCH), Disconnect("Bad Password")]` per
108
+ * RFC 2812 §5.1 (numerics 464 + the canonical close reason used by
109
+ * mainstream IRC daemons).
95
110
  *
96
111
  * Reused by the CAP `END` handler.
97
112
  */
98
- export function emitWelcomeIfReady(state: ConnectionState, ctx: Ctx): EffectType | null {
99
- if (state.registration === 'registered') return null;
100
- if (state.nick === undefined || state.user === undefined) return null;
101
- if (state.capNegotiating) return null;
113
+ export function emitWelcomeIfReady(state: ConnectionState, ctx: Ctx): EffectType[] {
114
+ if (state.registration === 'registered') return [];
115
+ if (state.nick === undefined || state.user === undefined) return [];
116
+ if (state.capNegotiating) return [];
117
+
118
+ const gateFailure = passwordGateFailure(state, ctx);
119
+ if (gateFailure !== null) return gateFailure;
120
+
121
+ // Apply hostmask cloaking (if enabled) before emitting the welcome block
122
+ // so the `001` hostmask and every downstream message uses the cloak.
123
+ applyCloakIfEnabled(state, ctx);
124
+
102
125
  state.registration = 'registered';
103
- return Effect.send(ctx.connId, buildWelcomeLines(state, ctx));
126
+ return [Effect.send(ctx.connId, buildWelcomeLines(state, ctx))];
127
+ }
128
+
129
+ /**
130
+ * Replaces `state.host` with a deterministic cloak derived from the
131
+ * deployment's cloaking secret + the current (real) host. No-op when
132
+ * cloaking is disabled or no host is set yet (e.g. transport didn't supply
133
+ * one, or the connection sets it later via CHGHOST).
134
+ */
135
+ function applyCloakIfEnabled(state: ConnectionState, ctx: Ctx): void {
136
+ const cloaking = ctx.serverConfig.cloaking;
137
+ if (cloaking === undefined || !cloaking.enabled) return;
138
+ if (state.host === undefined) return;
139
+ state.host = cloakHost(state.host, cloaking, ctx.networkName);
104
140
  }
105
141
 
142
+ /**
143
+ * Returns the password-gate failure effects (`[Send 464, Disconnect]`) when
144
+ * the connection has not satisfied the deployment's server-password, or
145
+ * `null` when the gate is satisfied / disabled.
146
+ *
147
+ * Pulling this out as a named helper keeps {@link emitWelcomeIfReady}
148
+ * readable and gives tests a direct handle on the gate logic.
149
+ */
150
+ function passwordGateFailure(state: ConnectionState, ctx: Ctx): EffectType[] | null {
151
+ const configured = ctx.serverConfig.serverPassword;
152
+ if (configured === undefined || configured === '') return null;
153
+ // SASL success satisfies the password gate.
154
+ if (state.account !== undefined) return null;
155
+ if (state.passAttempt === configured) return null;
156
+ return [
157
+ Effect.send(ctx.connId, [numericErr(ctx, Numerics.ERR_PASSWDMISMATCH, 'Password mismatch')]),
158
+ Effect.disconnect(ctx.connId, PASSWORD_MISMATCH_REASON),
159
+ ];
160
+ }
161
+
162
+ /** Canonical disconnect reason paired with the 464 numeric. */
163
+ const PASSWORD_MISMATCH_REASON = 'Bad Password';
164
+
106
165
  /**
107
166
  * Formats a single numeric error line addressed to the connection's current
108
167
  * nick (or `*` when unregistered). Optional `middle` param is emitted before
@@ -166,8 +225,7 @@ export const nickReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
166
225
  }
167
226
  effects.push(Effect.reserveNick(nick, ctx.connId));
168
227
 
169
- const welcome = emitWelcomeIfReady(state, ctx);
170
- if (welcome !== null) effects.push(welcome);
228
+ effects.push(...emitWelcomeIfReady(state, ctx));
171
229
 
172
230
  return { state, effects };
173
231
  };
@@ -209,8 +267,7 @@ export const userReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
209
267
  state.registration = 'registering';
210
268
  }
211
269
 
212
- const welcome = emitWelcomeIfReady(state, ctx);
213
- if (welcome !== null) effects.push(welcome);
270
+ effects.push(...emitWelcomeIfReady(state, ctx));
214
271
 
215
272
  return { state, effects };
216
273
  };
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Pure reducer for the IRCv3 `AUTHENTICATE` command (SASL).
3
+ *
4
+ * PLAN §2.1 location-of-authority: SASL runs in the Connection entity.
5
+ * The reducer drives the multi-frame SASL exchange purely:
6
+ *
7
+ * 1. `AUTHENTICATE <MECH>` — selects a mechanism. `PLAIN` is supported and
8
+ * answers with `AUTHENTICATE +` (requesting the payload). `EXTERNAL` is
9
+ * recognized but stubbed to `904` until mTLS lands. Anything else gets
10
+ * `908 ERR_SASLMECHS` listing what is available.
11
+ * 2. `AUTHENTICATE <b64>` / `AUTHENTICATE +` — delivers the payload (whole
12
+ * or in `<=400`-byte chunks; `+` marks an empty/final chunk). The
13
+ * decoded bytes are handed to the injected {@link AccountStore}; on
14
+ * success the connection records the account name and the reducer emits
15
+ * `900 RPL_LOGGEDIN` + `903 RPL_SASLSUCCESS`, on failure `904 ERR_SASLFAIL`.
16
+ * 3. `AUTHENTICATE *` — client abort → `906 ERR_SASLABORT`.
17
+ *
18
+ * The `AccountStore` port is **synchronous** (see {@link AccountStore} docs):
19
+ * adapters pre-load credentials so the reducer stays pure. Async backends
20
+ * resolve their lookups before the actor invokes this reducer.
21
+ *
22
+ * State: {@link ConnectionState.saslMech} tracks the mechanism in flight;
23
+ * {@link ConnectionState.saslBuffer} accumulates chunked payloads. Both are
24
+ * cleared on every terminal outcome.
25
+ */
26
+
27
+ import { Effect } from '../effects.js';
28
+ import type { Effect as EffectType, RawLine } from '../effects.js';
29
+ import { decodeBase64 } from '../protocol/base64.js';
30
+ import { Numerics } from '../protocol/numerics.js';
31
+ import { hostmaskOf } from '../state/connection.js';
32
+ import type { ConnectionState } from '../state/connection.js';
33
+ import type { Ctx, Reducer } from '../types.js';
34
+
35
+ /** Per-ircdocs.horse: a single `AUTHENTICATE` payload chunk is at most 400 bytes. */
36
+ const SASL_CHUNK_BYTES = 400;
37
+
38
+ /** Total decoded-payload cap before `905 ERR_SASLTOOLONG`. */
39
+ const SASL_MAX_BYTES = 8192;
40
+
41
+ /** Mechanisms this server will accept today (advertised verbatim by the cap). */
42
+ const SUPPORTED_MECHS = 'PLAIN,EXTERNAL';
43
+
44
+ /**
45
+ * Reducer for `AUTHENTICATE [<param>]`. See module docs for the state
46
+ * machine. Authority: the invoking connection's {@link ConnectionState}.
47
+ */
48
+ export const authenticateReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
49
+ state.lastSeen = ctx.clock.now();
50
+
51
+ const param = msg.params[0];
52
+ if (param === undefined) {
53
+ return { state, effects: [Effect.send(ctx.connId, [needMoreParams(ctx)])] };
54
+ }
55
+
56
+ if (state.registration === 'registered' || !state.capNegotiating || state.account !== undefined) {
57
+ return { state, effects: [Effect.send(ctx.connId, [saslAlready(ctx)])] };
58
+ }
59
+
60
+ if (param === '*') {
61
+ return handleAbort(state, ctx);
62
+ }
63
+
64
+ if (state.saslMech === undefined) {
65
+ return handleMechanism(state, param, ctx);
66
+ }
67
+
68
+ return handlePayload(state, param, ctx);
69
+ };
70
+
71
+ /** `AUTHENTICATE *` — client-initiated abort. */
72
+ function handleAbort(
73
+ state: ConnectionState,
74
+ ctx: Ctx,
75
+ ): { state: ConnectionState; effects: EffectType[] } {
76
+ if (state.saslMech === undefined) {
77
+ return { state, effects: [] };
78
+ }
79
+ clearSasl(state);
80
+ return { state, effects: [Effect.send(ctx.connId, [saslAbort(ctx)])] };
81
+ }
82
+
83
+ /** `AUTHENTICATE <MECH>` — first frame of an exchange. */
84
+ function handleMechanism(
85
+ state: ConnectionState,
86
+ param: string,
87
+ ctx: Ctx,
88
+ ): { state: ConnectionState; effects: EffectType[] } {
89
+ const mech = param.toUpperCase();
90
+ if (mech === 'PLAIN') {
91
+ state.saslMech = 'PLAIN';
92
+ state.saslBuffer = '';
93
+ return { state, effects: [Effect.send(ctx.connId, [L('AUTHENTICATE +')])] };
94
+ }
95
+ if (mech === 'EXTERNAL') {
96
+ return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
97
+ }
98
+ return { state, effects: [Effect.send(ctx.connId, [saslMechs(ctx)])] };
99
+ }
100
+
101
+ /** `AUTHENTICATE <chunk>` / `AUTHENTICATE +` — payload delivery. */
102
+ function handlePayload(
103
+ state: ConnectionState,
104
+ param: string,
105
+ ctx: Ctx,
106
+ ): { state: ConnectionState; effects: EffectType[] } {
107
+ const mech = state.saslMech as string;
108
+ const buffer = state.saslBuffer as string;
109
+
110
+ if (param === '+') {
111
+ return finalize(state, buffer, mech, ctx);
112
+ }
113
+
114
+ const next = buffer + param;
115
+ if (next.length > SASL_MAX_BYTES) {
116
+ clearSasl(state);
117
+ return { state, effects: [Effect.send(ctx.connId, [saslTooLong(ctx)])] };
118
+ }
119
+
120
+ if (param.length === SASL_CHUNK_BYTES) {
121
+ state.saslBuffer = next;
122
+ return { state, effects: [] };
123
+ }
124
+
125
+ return finalize(state, next, mech, ctx);
126
+ }
127
+
128
+ /** Decodes the accumulated payload and runs it through the AccountStore. */
129
+ function finalize(
130
+ state: ConnectionState,
131
+ payloadB64: string,
132
+ mech: string,
133
+ ctx: Ctx,
134
+ ): { state: ConnectionState; effects: EffectType[] } {
135
+ const parsed = parsePlain(payloadB64);
136
+
137
+ if (parsed === null) {
138
+ clearSasl(state);
139
+ return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
140
+ }
141
+
142
+ const store = ctx.accounts;
143
+ if (store === undefined) {
144
+ clearSasl(state);
145
+ return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
146
+ }
147
+
148
+ const result = store.verify(mech, {
149
+ kind: 'PLAIN',
150
+ username: parsed.username,
151
+ password: parsed.password,
152
+ });
153
+
154
+ clearSasl(state);
155
+
156
+ if (result.ok) {
157
+ state.account = result.account;
158
+ return {
159
+ state,
160
+ effects: [Effect.send(ctx.connId, [loggedIn(ctx, result.account), saslSuccess(ctx)])],
161
+ };
162
+ }
163
+ return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
164
+ }
165
+
166
+ /**
167
+ * Decodes a base64 PLAIN payload (`authzid\0authcid\0passwd`) and returns the
168
+ * authcid/password pair. Returns `null` on any decode or shape failure.
169
+ */
170
+ function parsePlain(payloadB64: string): { username: string; password: string } | null {
171
+ let decoded: string;
172
+ try {
173
+ decoded = decodeBase64(payloadB64);
174
+ } catch {
175
+ return null;
176
+ }
177
+ const parts = decoded.split('\0');
178
+ if (parts.length !== 3) return null;
179
+ const [, username, password] = parts as [string, string, string];
180
+ return { username, password };
181
+ }
182
+
183
+ /** Clears every SASL-related scratch field on the connection. */
184
+ function clearSasl(state: ConnectionState): void {
185
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
186
+ delete state.saslMech;
187
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
188
+ delete state.saslBuffer;
189
+ }
190
+
191
+ function loggedIn(ctx: Ctx, account: string): RawLine {
192
+ const nick = nickOf(ctx);
193
+ const hostmask = hostmaskOf(ctx.connection) ?? nick;
194
+ return L(
195
+ `:${ctx.serverName} ${code(Numerics.RPL_LOGGEDIN)} ${nick} ${hostmask} ${account} :You are now logged in as ${account}`,
196
+ );
197
+ }
198
+
199
+ function saslSuccess(ctx: Ctx): RawLine {
200
+ return L(
201
+ `:${ctx.serverName} ${code(Numerics.RPL_SASLSUCCESS)} ${nickOf(ctx)} :SASL authentication successful`,
202
+ );
203
+ }
204
+
205
+ function saslFail(ctx: Ctx): RawLine {
206
+ return L(
207
+ `:${ctx.serverName} ${code(Numerics.ERR_SASLFAIL)} ${nickOf(ctx)} :SASL authentication failed`,
208
+ );
209
+ }
210
+
211
+ function saslTooLong(ctx: Ctx): RawLine {
212
+ return L(
213
+ `:${ctx.serverName} ${code(Numerics.ERR_SASLTOOLONG)} ${nickOf(ctx)} :SASL message too long`,
214
+ );
215
+ }
216
+
217
+ function saslAbort(ctx: Ctx): RawLine {
218
+ return L(
219
+ `:${ctx.serverName} ${code(Numerics.ERR_SASLABORT)} ${nickOf(ctx)} :SASL authentication aborted`,
220
+ );
221
+ }
222
+
223
+ function saslAlready(ctx: Ctx): RawLine {
224
+ return L(
225
+ `:${ctx.serverName} ${code(Numerics.ERR_SASLALREADY)} ${nickOf(ctx)} :You have already authenticated using SASL`,
226
+ );
227
+ }
228
+
229
+ function saslMechs(ctx: Ctx): RawLine {
230
+ return L(
231
+ `:${ctx.serverName} ${code(Numerics.ERR_SASLMECHS)} ${nickOf(ctx)} ${SUPPORTED_MECHS} :are the available SASL mechanisms`,
232
+ );
233
+ }
234
+
235
+ function needMoreParams(ctx: Ctx): RawLine {
236
+ return L(
237
+ `:${ctx.serverName} ${code(Numerics.ERR_NEEDMOREPARAMS)} ${nickOf(ctx)} AUTHENTICATE :Not enough parameters`,
238
+ );
239
+ }
240
+
241
+ function nickOf(ctx: Ctx): string {
242
+ return ctx.connection.nick ?? '*';
243
+ }
244
+
245
+ function code(n: number): string {
246
+ return n.toString().padStart(3, '0');
247
+ }
248
+
249
+ function L(text: string): RawLine {
250
+ return { text };
251
+ }