serverless-ircd 0.1.0 → 0.3.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 (204) 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/CHANGELOG.md +436 -0
  6. package/README.md +127 -84
  7. package/apps/aws-stack/README.md +73 -0
  8. package/apps/aws-stack/bin/aws.ts +49 -0
  9. package/apps/aws-stack/cdk.json +10 -0
  10. package/apps/aws-stack/package.json +41 -0
  11. package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
  12. package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
  13. package/apps/aws-stack/scripts/smoke.mjs +142 -0
  14. package/apps/aws-stack/src/aws-stack.ts +263 -0
  15. package/apps/aws-stack/src/tables.ts +10 -0
  16. package/apps/aws-stack/tests/localstack.test.ts +46 -0
  17. package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
  18. package/apps/aws-stack/tests/stack.test.ts +464 -0
  19. package/apps/aws-stack/tsconfig.build.json +11 -0
  20. package/apps/aws-stack/tsconfig.test.json +8 -0
  21. package/apps/aws-stack/vitest.config.ts +20 -0
  22. package/apps/cf-worker/package.json +1 -1
  23. package/apps/cf-worker/scripts/smoke.mjs +0 -0
  24. package/apps/cf-worker/src/worker.ts +8 -2
  25. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  26. package/apps/cf-worker/wrangler.test.toml +5 -1
  27. package/apps/cf-worker/wrangler.toml +66 -17
  28. package/apps/local-cli/package.json +1 -1
  29. package/apps/local-cli/src/config-loader.ts +57 -0
  30. package/apps/local-cli/src/main.ts +1 -1
  31. package/apps/local-cli/src/server.ts +267 -32
  32. package/apps/local-cli/tests/config-loader.test.ts +107 -0
  33. package/apps/local-cli/tests/e2e.test.ts +126 -0
  34. package/apps/local-cli/tests/security-e2e.test.ts +239 -0
  35. package/biome.json +3 -1
  36. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  37. package/docs/ADR-002-location-of-authority.md +82 -0
  38. package/docs/ADR-003-durable-object-sharding.md +93 -0
  39. package/docs/ADR-004-dynamodb-schema.md +96 -0
  40. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  41. package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
  42. package/docs/ADR-007-deterministic-ports.md +82 -0
  43. package/docs/ADR-008-monorepo-tooling.md +60 -0
  44. package/docs/AWS-Adapter-Architecture.md +496 -0
  45. package/docs/AWS-Deployment.md +1186 -0
  46. package/docs/Cloudflare-Deployment-Guide.md +660 -0
  47. package/docs/Home.md +11 -0
  48. package/docs/Observability.md +87 -0
  49. package/docs/PlanIRCv3Websocket.md +489 -0
  50. package/docs/PlanWebClient.md +451 -0
  51. package/docs/Release-Process.md +443 -0
  52. package/package.json +19 -14
  53. package/packages/aws-adapter/README.md +35 -0
  54. package/packages/aws-adapter/package.json +52 -0
  55. package/packages/aws-adapter/src/account-store.ts +121 -0
  56. package/packages/aws-adapter/src/aws-runtime.ts +871 -0
  57. package/packages/aws-adapter/src/cdk-table-defs.ts +73 -0
  58. package/packages/aws-adapter/src/config-loader.ts +126 -0
  59. package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
  60. package/packages/aws-adapter/src/dynamo.ts +61 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +44 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +330 -0
  63. package/packages/aws-adapter/src/handlers/disconnect.ts +48 -0
  64. package/packages/aws-adapter/src/handlers/index.ts +293 -0
  65. package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
  66. package/packages/aws-adapter/src/handlers/state.ts +13 -0
  67. package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
  68. package/packages/aws-adapter/src/index.ts +74 -0
  69. package/packages/aws-adapter/src/message-store.ts +34 -0
  70. package/packages/aws-adapter/src/serialize.ts +283 -0
  71. package/packages/aws-adapter/src/tables.ts +53 -0
  72. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
  73. package/packages/aws-adapter/tests/account-store.test.ts +280 -0
  74. package/packages/aws-adapter/tests/aws-harness.ts +408 -0
  75. package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
  76. package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
  77. package/packages/aws-adapter/tests/config-loader.test.ts +213 -0
  78. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
  79. package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
  80. package/packages/aws-adapter/tests/global-setup.ts +301 -0
  81. package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
  82. package/packages/aws-adapter/tests/handlers.test.ts +473 -0
  83. package/packages/aws-adapter/tests/message-store.test.ts +55 -0
  84. package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
  85. package/packages/aws-adapter/tests/serialize.test.ts +249 -0
  86. package/packages/aws-adapter/tests/smoke.test.ts +48 -0
  87. package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
  88. package/packages/aws-adapter/tests/tables.test.ts +74 -0
  89. package/packages/aws-adapter/tests/transactions.test.ts +446 -0
  90. package/packages/aws-adapter/tsconfig.build.json +16 -0
  91. package/packages/aws-adapter/tsconfig.test.json +14 -0
  92. package/packages/aws-adapter/vitest.config.ts +23 -0
  93. package/packages/cf-adapter/package.json +2 -1
  94. package/packages/cf-adapter/src/cf-runtime.ts +42 -22
  95. package/packages/cf-adapter/src/channel-do.ts +40 -1
  96. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  97. package/packages/cf-adapter/src/config-loader.ts +135 -0
  98. package/packages/cf-adapter/src/connection-do.ts +119 -0
  99. package/packages/cf-adapter/src/env.ts +27 -1
  100. package/packages/cf-adapter/src/index.ts +2 -1
  101. package/packages/cf-adapter/tests/cf-harness.ts +2 -2
  102. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
  103. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  104. package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
  105. package/packages/cf-adapter/tests/connection-do.test.ts +82 -0
  106. package/packages/cf-adapter/tests/worker/main.ts +2 -1
  107. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  108. package/packages/cf-adapter/wrangler.test.toml +10 -1
  109. package/packages/in-memory-runtime/package.json +1 -1
  110. package/packages/in-memory-runtime/src/in-memory-runtime.ts +107 -16
  111. package/packages/in-memory-runtime/src/index.ts +1 -1
  112. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +134 -0
  113. package/packages/irc-core/package.json +13 -2
  114. package/packages/irc-core/src/admission.ts +216 -0
  115. package/packages/irc-core/src/caps/capabilities.ts +1 -0
  116. package/packages/irc-core/src/case-fold.ts +64 -0
  117. package/packages/irc-core/src/cloak.ts +81 -0
  118. package/packages/irc-core/src/commands/cap.ts +1 -2
  119. package/packages/irc-core/src/commands/chathistory.ts +305 -0
  120. package/packages/irc-core/src/commands/index.ts +9 -0
  121. package/packages/irc-core/src/commands/invite.ts +6 -9
  122. package/packages/irc-core/src/commands/isupport.ts +1 -1
  123. package/packages/irc-core/src/commands/join.ts +63 -10
  124. package/packages/irc-core/src/commands/kick.ts +4 -11
  125. package/packages/irc-core/src/commands/list.ts +5 -4
  126. package/packages/irc-core/src/commands/mode.ts +5 -9
  127. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  128. package/packages/irc-core/src/commands/motd.ts +6 -110
  129. package/packages/irc-core/src/commands/oper.ts +151 -0
  130. package/packages/irc-core/src/commands/privmsg.ts +24 -0
  131. package/packages/irc-core/src/commands/registration.ts +79 -21
  132. package/packages/irc-core/src/commands/sasl.ts +251 -0
  133. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  134. package/packages/irc-core/src/config.ts +270 -0
  135. package/packages/irc-core/src/flood-control.ts +175 -0
  136. package/packages/irc-core/src/index.ts +7 -0
  137. package/packages/irc-core/src/ports.ts +719 -0
  138. package/packages/irc-core/src/protocol/base64.ts +16 -0
  139. package/packages/irc-core/src/protocol/index.ts +2 -1
  140. package/packages/irc-core/src/protocol/numerics.ts +11 -0
  141. package/packages/irc-core/src/protocol/parser.ts +27 -2
  142. package/packages/irc-core/src/state/connection.ts +41 -0
  143. package/packages/irc-core/src/types.ts +88 -2
  144. package/packages/irc-core/stryker.commands.conf.json +41 -0
  145. package/packages/irc-core/stryker.protocol.conf.json +26 -0
  146. package/packages/irc-core/tests/account-store.test.ts +88 -0
  147. package/packages/irc-core/tests/admission.test.ts +229 -0
  148. package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
  149. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  150. package/packages/irc-core/tests/cloak.test.ts +78 -0
  151. package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
  152. package/packages/irc-core/tests/commands/join.test.ts +246 -2
  153. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  154. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  155. package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
  156. package/packages/irc-core/tests/commands/registration.test.ts +380 -20
  157. package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
  158. package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
  159. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  160. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  161. package/packages/irc-core/tests/config.test.ts +721 -0
  162. package/packages/irc-core/tests/flood-control.test.ts +394 -0
  163. package/packages/irc-core/tests/message-store.test.ts +530 -0
  164. package/packages/irc-core/tests/parser.test.ts +44 -1
  165. package/packages/irc-core/tests/ports.test.ts +263 -0
  166. package/packages/irc-server/package.json +1 -1
  167. package/packages/irc-server/src/actor.ts +186 -44
  168. package/packages/irc-server/src/dispatch.ts +89 -5
  169. package/packages/irc-server/src/index.ts +2 -0
  170. package/packages/irc-server/src/routing.ts +160 -0
  171. package/packages/irc-server/tests/actor.test.ts +690 -15
  172. package/packages/irc-server/tests/dispatch.test.ts +84 -0
  173. package/packages/irc-server/tests/routing.test.ts +204 -0
  174. package/packages/irc-test-support/README.md +32 -0
  175. package/packages/irc-test-support/package.json +37 -0
  176. package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +6 -5
  177. package/packages/irc-test-support/src/index.ts +24 -0
  178. package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
  179. package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
  180. package/packages/irc-test-support/tests/smoke.test.ts +11 -0
  181. package/packages/irc-test-support/tsconfig.build.json +16 -0
  182. package/packages/irc-test-support/tsconfig.test.json +14 -0
  183. package/packages/irc-test-support/vitest.config.ts +20 -0
  184. package/pnpm-workspace.yaml +1 -0
  185. package/tools/ci-hardening/package.json +30 -0
  186. package/tools/ci-hardening/src/index.ts +6 -0
  187. package/tools/ci-hardening/src/validate.ts +103 -0
  188. package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
  189. package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
  190. package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
  191. package/tools/ci-hardening/tests/validate.test.ts +177 -0
  192. package/tools/ci-hardening/tsconfig.build.json +12 -0
  193. package/tools/ci-hardening/tsconfig.test.json +10 -0
  194. package/tools/ci-hardening/vitest.config.ts +21 -0
  195. package/tools/seed-aws-accounts.ts +80 -0
  196. package/tools/tcp-ws-forwarder/package.json +1 -1
  197. package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
  198. package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
  199. package/tools/tcp-ws-forwarder/src/main.ts +33 -14
  200. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
  201. package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
  202. package/packages/cf-adapter/tests/integration/index.ts +0 -17
  203. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
  204. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Shared MOTD-lines → IRC numerics builder.
3
+ *
4
+ * Both the registration welcome block ({@link buildWelcomeLines}) and the
5
+ * standalone `MOTD` command ({@link motdReducer}) render the same
6
+ * `375`/`372`×N/`376` sequence (or `422 ERR_NOMOTD` when no MOTD is
7
+ * configured) from the lines supplied by a {@link MotdProvider}. This module
8
+ * owns that rendering so the two call sites cannot drift.
9
+ *
10
+ * Wire format (RFC 2812 §5):
11
+ * - `375 RPL_MOTDSTART` — `:<server> 375 <nick> :- <server> Message of the day -`
12
+ * - `372 RPL_MOTD` — `:<server> 372 <nick> :- <line>` (one per MOTD line)
13
+ * - `376 RPL_ENDOFMOTD` — `:<server> 376 <nick> :End of MOTD command`
14
+ * - `422 ERR_NOMOTD` — `:<server> 422 <nick> :MOTD File is missing`
15
+ *
16
+ * Per RFC 1459 §2.3 the maximum wire line length is 512 bytes including the
17
+ * trailing CR-LF. Over-long MOTD content lines are hard-wrapped at the
18
+ * 372-line content budget so every emitted line fits within the 512-byte
19
+ * limit (also leaving headroom for message-tags).
20
+ */
21
+
22
+ import type { RawLine } from '../effects.js';
23
+ import { Numerics } from '../protocol/numerics.js';
24
+
25
+ /** RFC 1459 §2.3: maximum IRC line length in bytes, including the trailing CR-LF. */
26
+ const MAX_LINE_BYTES = 512;
27
+
28
+ /** Length of the trailing CR-LF that the wire layer appends to each line. */
29
+ const TRAILING_CRLF_LEN = 2;
30
+
31
+ /** Fixed text emitted for `375 RPL_MOTDSTART` (after `:- `). */
32
+ const MOTDSTART_TRAILING_TEXT = '- $server Message of the day -';
33
+
34
+ /** Fixed text emitted for `376 RPL_ENDOFMOTD`. */
35
+ const ENDOFMOTD_TRAILING_TEXT = 'End of MOTD command';
36
+
37
+ /** Fixed text emitted for `422 ERR_NOMOTD`. */
38
+ const NOMOTD_TRAILING_TEXT = 'MOTD File is missing';
39
+
40
+ /**
41
+ * Renders the MOTD numerics addressed to `nick` from the supplied `lines`.
42
+ *
43
+ * Returns `[422 ERR_NOMOTD]` when `lines` is empty (mirroring the standalone
44
+ * `MOTD` command), otherwise the `375`/`372`×N/`376` block with over-long
45
+ * lines hard-wrapped to fit the 512-byte wire limit.
46
+ *
47
+ * `nick` is taken verbatim from the caller — the welcome path passes the
48
+ * (guaranteed-defined) registering nick, the standalone command passes
49
+ * `connection.nick ?? '*'`.
50
+ */
51
+ export function buildMotdNumerics(serverName: string, nick: string, lines: string[]): RawLine[] {
52
+ if (lines.length === 0) {
53
+ return [numericLine(serverName, nick, Numerics.ERR_NOMOTD, NOMOTD_TRAILING_TEXT)];
54
+ }
55
+
56
+ const out: RawLine[] = [
57
+ numericLine(
58
+ serverName,
59
+ nick,
60
+ Numerics.RPL_MOTDSTART,
61
+ MOTDSTART_TRAILING_TEXT.replace('$server', serverName),
62
+ ),
63
+ ];
64
+
65
+ const maxContent = maxMotdContentLen(serverName, nick);
66
+ for (const raw of lines) {
67
+ for (const chunk of splitForMotd(raw, maxContent)) {
68
+ out.push(numericLine(serverName, nick, Numerics.RPL_MOTD, `- ${chunk}`));
69
+ }
70
+ }
71
+
72
+ out.push(numericLine(serverName, nick, Numerics.RPL_ENDOFMOTD, ENDOFMOTD_TRAILING_TEXT));
73
+ return out;
74
+ }
75
+
76
+ /**
77
+ * Builds a `:<server> <code> <nick> :<trailing>` line.
78
+ */
79
+ function numericLine(serverName: string, nick: string, code: number, trailing: string): RawLine {
80
+ const codeStr = code.toString().padStart(3, '0');
81
+ return { text: `:${serverName} ${codeStr} ${nick} :${trailing}` };
82
+ }
83
+
84
+ /**
85
+ * Returns the maximum content-byte length that fits in a single `372 RPL_MOTD`
86
+ * line for this connection, given the server name and nick.
87
+ *
88
+ * The 372 wire format is `:<server> 372 <nick> :- <chunk>\r\n`, so the
89
+ * per-chunk budget is:
90
+ *
91
+ * 512
92
+ * - 2 (trailing CR-LF)
93
+ * - 1 (leading `:`)
94
+ * - <server>.length
95
+ * - 5 (` 372 `)
96
+ * - <nick>.length
97
+ * - 4 (` :- `)
98
+ *
99
+ * The result is clamped at 0 so a pathologically long server/nick combo
100
+ * cannot drive the budget negative.
101
+ */
102
+ function maxMotdContentLen(serverName: string, nick: string): number {
103
+ const overhead = TRAILING_CRLF_LEN + 1 + serverName.length + 5 + nick.length + 4;
104
+ return Math.max(0, MAX_LINE_BYTES - overhead);
105
+ }
106
+
107
+ /**
108
+ * Splits a single MOTD content line into chunks that each fit the per-372
109
+ * content budget.
110
+ *
111
+ * Hard-wraps at the byte budget (no word-boundary awareness) — matching the
112
+ * behavior of mainstream IRC daemons and keeping the rendering deterministic.
113
+ *
114
+ * Edge cases:
115
+ * - Empty string → `['']` (one empty 372 line, still RFC-conformant).
116
+ * - Zero/negative budget (impossible in practice but defensive) → `['']`
117
+ * so the builder always emits at least one 372 per input line.
118
+ */
119
+ function splitForMotd(text: string, maxLen: number): string[] {
120
+ if (maxLen <= 0) return [''];
121
+ if (text.length === 0) return [''];
122
+ const chunks: string[] = [];
123
+ for (let i = 0; i < text.length; i += maxLen) {
124
+ chunks.push(text.slice(i, i + maxLen));
125
+ }
126
+ return chunks;
127
+ }
@@ -11,38 +11,16 @@
11
11
  * D1, or Secrets Manager) so the reducer itself stays pure and
12
12
  * deterministic — no IO in the handler.
13
13
  *
14
- * Wire format (RFC 2812 §5):
15
- * - `375 RPL_MOTDSTART` — `:<server> 375 <nick> :- <server> Message of the day -`
16
- * - `372 RPL_MOTD` — `:<server> 372 <nick> :- <line>` (one per MOTD line)
17
- * - `376 RPL_ENDOFMOTD` — `:<server> 376 <nick> :End of MOTD command`
18
- * - `422 ERR_NOMOTD` — `:<server> 422 <nick> :MOTD File is missing`
19
- *
20
- * Per RFC 1459 §2.3 the maximum wire line length is 512 bytes including the
21
- * trailing CR-LF. Over-long MOTD content lines are hard-wrapped at the
22
- * 372-line content budget so every emitted line fits within the 512-byte
23
- * limit (also leaving headroom for message-tags).
14
+ * The numeric rendering (`375`/`372`×N/`376`, or `422 ERR_NOMOTD`) is shared
15
+ * with the registration welcome block via {@link buildMotdNumerics} so the
16
+ * two paths cannot drift.
24
17
  */
25
18
 
26
19
  import { Effect } from '../effects.js';
27
20
  import type { Effect as EffectType, RawLine } from '../effects.js';
28
- import { Numerics } from '../protocol/numerics.js';
29
21
  import type { ConnectionState } from '../state/connection.js';
30
22
  import type { Ctx, Reducer } from '../types.js';
31
-
32
- /** RFC 1459 §2.3: maximum IRC line length in bytes, including the trailing CR-LF. */
33
- const MAX_LINE_BYTES = 512;
34
-
35
- /** Length of the trailing CR-LF that the wire layer appends to each line. */
36
- const TRAILING_CRLF_LEN = 2;
37
-
38
- /** Fixed text emitted for `375 RPL_MOTDSTART` (after `:- `). */
39
- const MOTDSTART_TRAILING_TEXT = '- $server Message of the day -';
40
-
41
- /** Fixed text emitted for `376 RPL_ENDOFMOTD`. */
42
- const ENDOFMOTD_TRAILING_TEXT = 'End of MOTD command';
43
-
44
- /** Fixed text emitted for `422 ERR_NOMOTD`. */
45
- const NOMOTD_TRAILING_TEXT = 'MOTD File is missing';
23
+ import { buildMotdNumerics } from './motd-lines.js';
46
24
 
47
25
  /**
48
26
  * Handles `MOTD [server]`.
@@ -56,91 +34,9 @@ const NOMOTD_TRAILING_TEXT = 'MOTD File is missing';
56
34
  export const motdReducer: Reducer<ConnectionState> = (state, _msg, ctx) => {
57
35
  state.lastSeen = ctx.clock.now();
58
36
 
59
- const lines = ctx.motd.lines();
60
-
61
- if (lines.length === 0) {
62
- return {
63
- state,
64
- effects: [
65
- Effect.send(ctx.connId, [numericLine(ctx, Numerics.ERR_NOMOTD, NOMOTD_TRAILING_TEXT)]),
66
- ],
67
- };
68
- }
69
-
70
- const out: RawLine[] = [
71
- numericLine(
72
- ctx,
73
- Numerics.RPL_MOTDSTART,
74
- MOTDSTART_TRAILING_TEXT.replace('$server', ctx.serverName),
75
- ),
76
- ];
77
-
78
- const maxContent = maxMotdContentLen(ctx);
79
- for (const raw of lines) {
80
- for (const chunk of splitForMotd(raw, maxContent)) {
81
- out.push(numericLine(ctx, Numerics.RPL_MOTD, `- ${chunk}`));
82
- }
83
- }
84
-
85
- out.push(numericLine(ctx, Numerics.RPL_ENDOFMOTD, ENDOFMOTD_TRAILING_TEXT));
37
+ const nick = ctx.connection.nick ?? '*';
38
+ const out: RawLine[] = buildMotdNumerics(ctx.serverName, nick, ctx.motd.lines());
86
39
 
87
40
  const effects: EffectType[] = [Effect.send(ctx.connId, out)];
88
41
  return { state, effects };
89
42
  };
90
-
91
- /**
92
- * Builds a `:<server> <code> <nick> :<trailing>` line. The nick falls back
93
- * to `*` when the connection has not yet registered one, matching the rest
94
- * of the codebase's numeric-error formatting.
95
- */
96
- function numericLine(ctx: Ctx, code: number, trailing: string): RawLine {
97
- const nick = ctx.connection.nick ?? '*';
98
- const codeStr = code.toString().padStart(3, '0');
99
- return { text: `:${ctx.serverName} ${codeStr} ${nick} :${trailing}` };
100
- }
101
-
102
- /**
103
- * Returns the maximum content-byte length that fits in a single `372 RPL_MOTD`
104
- * line for this connection, given the server name and the connection's nick.
105
- *
106
- * The 372 wire format is `:<server> 372 <nick> :- <chunk>\r\n`, so the
107
- * per-chunk budget is:
108
- *
109
- * 512
110
- * - 2 (trailing CR-LF)
111
- * - 1 (leading `:`)
112
- * - <server>.length
113
- * - 5 (` 372 `)
114
- * - <nick>.length
115
- * - 4 (` :- `)
116
- *
117
- * The result is clamped at 0 so a pathologically long server/nick combo
118
- * cannot drive the budget negative.
119
- */
120
- function maxMotdContentLen(ctx: Ctx): number {
121
- const nick = ctx.connection.nick ?? '*';
122
- const overhead = TRAILING_CRLF_LEN + 1 + ctx.serverName.length + 5 + nick.length + 4;
123
- return Math.max(0, MAX_LINE_BYTES - overhead);
124
- }
125
-
126
- /**
127
- * Splits a single MOTD content line into chunks that each fit the per-372
128
- * content budget.
129
- *
130
- * Hard-wraps at the byte budget (no word-boundary awareness) — matching the
131
- * behavior of mainstream IRC daemons and keeping the reducer deterministic.
132
- *
133
- * Edge cases:
134
- * - Empty string → `['']` (one empty 372 line, still RFC-conformant).
135
- * - Zero/negative budget (impossible in practice but defensive) → `['']`
136
- * so the reducer always emits at least one 372 per input line.
137
- */
138
- function splitForMotd(text: string, maxLen: number): string[] {
139
- if (maxLen <= 0) return [''];
140
- if (text.length === 0) return [''];
141
- const chunks: string[] = [];
142
- for (let i = 0; i < text.length; i += maxLen) {
143
- chunks.push(text.slice(i, i + maxLen));
144
- }
145
- return chunks;
146
- }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Pure reducer for the IRC `OPER` command.
3
+ *
4
+ * PLAN §2.1 location-of-authority: OPER runs in the Connection entity — it
5
+ * authenticates the caller against the deployment's configured operator
6
+ * credentials and, on success, grants the `o` user mode by setting
7
+ * `state.userModes.oper = true`. The reducer's `state` argument is the
8
+ * caller's authoritative {@link ConnectionState}.
9
+ *
10
+ * Oper is the *only* path that sets `userModes.oper`: a user `MODE +o` is
11
+ * hard-rejected with `481 ERR_NOPRIVILEGES` (see `mode.ts`). Once oper is
12
+ * granted here, the `RPL_WHOISOPERATOR` (313) branch of WHOIS and the `*`
13
+ * oper flag of WHO become reachable.
14
+ *
15
+ * Wire format (RFC 2812 §5.4.1, §4.1):
16
+ * - `OPER <name> <password>` — authenticates and (on success) grants oper.
17
+ * - `381 RPL_YOUREOPER`:
18
+ * `:<server> 381 <nick> :You are now an IRC operator`
19
+ * - `461 ERR_NEEDMOREPARAMS`:
20
+ * `:<server> 461 <nick> OPER :Not enough parameters`
21
+ * - `464 ERR_PASSWDMISMATCH`:
22
+ * `:<server> 464 <nick> :Password Incorrect`
23
+ * - `491 ERR_NOOPERHOST`:
24
+ * `:<server> 491 <nick> :No O-lines for your host`
25
+ *
26
+ * Behaviour:
27
+ * - Missing `<name>` or `<password>` → `461 ERR_NEEDMOREPARAMS`.
28
+ * - Already an operator → graceful idempotent no-op that re-confirms with
29
+ * `381` (no re-authentication required; the connection is already privy).
30
+ * - No operator credentials configured (absent or empty) →
31
+ * `491 ERR_NOOPERHOST`.
32
+ * - Matching credential pair → grant oper, emit `381`.
33
+ * - Wrong password / unknown name → `464 ERR_PASSWDMISMATCH`.
34
+ */
35
+
36
+ import { Effect } from '../effects.js';
37
+ import type { RawLine } from '../effects.js';
38
+ import { Numerics } from '../protocol/numerics.js';
39
+ import type { ConnectionState } from '../state/connection.js';
40
+ import type { OperCred } from '../types.js';
41
+ import type { Ctx, Reducer } from '../types.js';
42
+
43
+ /** Fixed text emitted for `381 RPL_YOUREOPER`. */
44
+ const YOUREOPER_TRAILING_TEXT = 'You are now an IRC operator';
45
+
46
+ /** Fixed text emitted for `464 ERR_PASSWDMISMATCH`. */
47
+ const PASSWDMISMATCH_TRAILING_TEXT = 'Password Incorrect';
48
+
49
+ /** Fixed text emitted for `491 ERR_NOOPERHOST`. */
50
+ const NOOPERHOST_TRAILING_TEXT = 'No O-lines for your host';
51
+
52
+ /** Fixed text emitted for `461 ERR_NEEDMOREPARAMS`. */
53
+ const NEEDMOREPARAMS_TRAILING_TEXT = 'Not enough parameters';
54
+
55
+ /**
56
+ * Returns true iff a credential pair in `creds` matches `name` / `password`
57
+ * exactly. Extracted as a pure helper so the match logic is testable in
58
+ * isolation and independent of connection state. Comparison is case
59
+ * sensitive and byte-exact (case-mapping is owned elsewhere).
60
+ */
61
+ export function matchOperCred(
62
+ creds: ReadonlyArray<OperCred>,
63
+ name: string,
64
+ password: string,
65
+ ): boolean {
66
+ return creds.some((c) => c.user === name && c.password === password);
67
+ }
68
+
69
+ /**
70
+ * Handles `OPER <name> <password>`.
71
+ *
72
+ * Verifies the supplied pair against `serverConfig.operCreds` and, on
73
+ * success, grants the oper user mode. Emits the appropriate numeric reply
74
+ * in every branch. See the file header for the full decision table.
75
+ */
76
+ export const operReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
77
+ state.lastSeen = ctx.clock.now();
78
+
79
+ const name = msg.params[0];
80
+ const password = msg.params[1];
81
+
82
+ if (name === undefined || password === undefined) {
83
+ return {
84
+ state,
85
+ effects: [
86
+ Effect.send(ctx.connId, [
87
+ numericLine(ctx, Numerics.ERR_NEEDMOREPARAMS, NEEDMOREPARAMS_TRAILING_TEXT, 'OPER'),
88
+ ]),
89
+ ],
90
+ };
91
+ }
92
+
93
+ // Already an operator: graceful idempotent re-confirm, no re-auth needed.
94
+ if (state.userModes.oper) {
95
+ return {
96
+ state,
97
+ effects: [
98
+ Effect.send(ctx.connId, [
99
+ numericLine(ctx, Numerics.RPL_YOUREOPER, YOUREOPER_TRAILING_TEXT),
100
+ ]),
101
+ ],
102
+ };
103
+ }
104
+
105
+ const creds = ctx.serverConfig.operCreds;
106
+ if (creds === undefined || creds.length === 0) {
107
+ return {
108
+ state,
109
+ effects: [
110
+ Effect.send(ctx.connId, [
111
+ numericLine(ctx, Numerics.ERR_NOOPERHOST, NOOPERHOST_TRAILING_TEXT),
112
+ ]),
113
+ ],
114
+ };
115
+ }
116
+
117
+ if (matchOperCred(creds, name, password)) {
118
+ state.userModes.oper = true;
119
+ return {
120
+ state,
121
+ effects: [
122
+ Effect.send(ctx.connId, [
123
+ numericLine(ctx, Numerics.RPL_YOUREOPER, YOUREOPER_TRAILING_TEXT),
124
+ ]),
125
+ ],
126
+ };
127
+ }
128
+
129
+ return {
130
+ state,
131
+ effects: [
132
+ Effect.send(ctx.connId, [
133
+ numericLine(ctx, Numerics.ERR_PASSWDMISMATCH, PASSWDMISMATCH_TRAILING_TEXT),
134
+ ]),
135
+ ],
136
+ };
137
+ };
138
+
139
+ /**
140
+ * Builds a `:<server> <code> <nick> [<middle>] :<trailing>` line. The nick
141
+ * falls back to `*` when the connection has not yet registered one, matching
142
+ * the rest of the codebase's numeric formatting.
143
+ */
144
+ function numericLine(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
145
+ const nick = ctx.connection.nick ?? '*';
146
+ const codeStr = code.toString().padStart(3, '0');
147
+ const parts = [`:${ctx.serverName}`, codeStr, nick];
148
+ if (middle !== undefined) parts.push(middle);
149
+ parts.push(`:${trailing}`);
150
+ return { text: parts.join(' ') };
151
+ }
@@ -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';
@@ -20,12 +21,13 @@ import { hostmaskOf } from '../state/connection.js';
20
21
  import type { ConnectionState } from '../state/connection.js';
21
22
  import type { Ctx, Reducer } from '../types.js';
22
23
  import { generateIsupport } from './isupport.js';
24
+ import { buildMotdNumerics } from './motd-lines.js';
23
25
 
24
26
  /**
25
27
  * Server version reported in `002` / `004`. Placeholder until a config-driven
26
28
  * version lands.
27
29
  */
28
- const SERVER_VERSION = '0.1.0';
30
+ const SERVER_VERSION = '0.2.0';
29
31
 
30
32
  /** "Created" text for `003 RPL_CREATED`. Placeholder. */
31
33
  const CREATED_TEXT = 'Phase 1';
@@ -54,9 +56,9 @@ export function isValidNick(nick: string, maxLen: number): boolean {
54
56
 
55
57
  /**
56
58
  * Builds the welcome numeric block emitted once registration completes:
57
- * `001`–`005` (with `005` as a placeholder) followed by
58
- * the `375`/`372`/`376` MOTD placeholders (real MOTD content via the
59
- * `MotdProvider` port).
59
+ * `001`–`005` (with `005` generated from the server config) followed by
60
+ * the MOTD numerics rendered from {@link Ctx.motd} — `375`/`372`×N/`376`
61
+ * for a configured MOTD, or `422 ERR_NOMOTD` when none is configured.
60
62
  *
61
63
  * Caller guarantees `state.nick` and `state.user` are set.
62
64
  */
@@ -80,29 +82,87 @@ export function buildWelcomeLines(state: ConnectionState, ctx: Ctx): RawLine[] {
80
82
  },
81
83
  // 005 RPL_ISUPPORT — real tokens generated from serverConfig.
82
84
  ...generateIsupport(ctx.serverConfig, state.caps, nick),
83
- // MOTD placeholders filled via MotdProvider elsewhere.
84
- { text: `${prefix} 375 ${nick} :- ${ctx.serverName} Message of the day -` },
85
- { text: `${prefix} 372 ${nick} :- MOTD placeholder` },
86
- { text: `${prefix} 376 ${nick} :End of MOTD command` },
85
+ // MOTD — rendered from the configured MotdProvider (375/372/376, or
86
+ // 422 ERR_NOMOTD when no MOTD is configured). Shares the exact rendering
87
+ // path with the standalone MOTD command.
88
+ ...buildMotdNumerics(ctx.serverName, nick, ctx.motd.lines()),
87
89
  ];
88
90
  }
89
91
 
90
92
  /**
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.
93
+ * Attempts to finalize registration: if both `nick` and `user` are present,
94
+ * the connection is not yet registered, and CAP negotiation is not in
95
+ * flight, transitions `state.registration` to `'registered'` and returns
96
+ * a `Send` effect carrying the welcome block.
97
+ *
98
+ * Side effects are returned as an array so the caller can `push(...)` them
99
+ * onto its own effect list. An empty array means "not ready / no work".
100
+ *
101
+ * Enforces the deployment's server-password gate (configured via
102
+ * `serverConfig.serverPassword`) before completing registration:
103
+ * - No password configured (undefined or empty) → gate skipped.
104
+ * - SASL already authenticated (`state.account` set) → gate satisfied.
105
+ * - Otherwise `state.passAttempt` must equal the configured password.
106
+ *
107
+ * On gate failure the connection is NOT registered; the returned effects
108
+ * are `[Send(464 ERR_PASSWDMISMATCH), Disconnect("Bad Password")]` per
109
+ * RFC 2812 §5.1 (numerics 464 + the canonical close reason used by
110
+ * mainstream IRC daemons).
95
111
  *
96
112
  * Reused by the CAP `END` handler.
97
113
  */
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;
114
+ export function emitWelcomeIfReady(state: ConnectionState, ctx: Ctx): EffectType[] {
115
+ if (state.registration === 'registered') return [];
116
+ if (state.nick === undefined || state.user === undefined) return [];
117
+ if (state.capNegotiating) return [];
118
+
119
+ const gateFailure = passwordGateFailure(state, ctx);
120
+ if (gateFailure !== null) return gateFailure;
121
+
122
+ // Apply hostmask cloaking (if enabled) before emitting the welcome block
123
+ // so the `001` hostmask and every downstream message uses the cloak.
124
+ applyCloakIfEnabled(state, ctx);
125
+
102
126
  state.registration = 'registered';
103
- return Effect.send(ctx.connId, buildWelcomeLines(state, ctx));
127
+ return [Effect.send(ctx.connId, buildWelcomeLines(state, ctx))];
128
+ }
129
+
130
+ /**
131
+ * Replaces `state.host` with a deterministic cloak derived from the
132
+ * deployment's cloaking secret + the current (real) host. No-op when
133
+ * cloaking is disabled or no host is set yet (e.g. transport didn't supply
134
+ * one, or the connection sets it later via CHGHOST).
135
+ */
136
+ function applyCloakIfEnabled(state: ConnectionState, ctx: Ctx): void {
137
+ const cloaking = ctx.serverConfig.cloaking;
138
+ if (cloaking === undefined || !cloaking.enabled) return;
139
+ if (state.host === undefined) return;
140
+ state.host = cloakHost(state.host, cloaking, ctx.networkName);
104
141
  }
105
142
 
143
+ /**
144
+ * Returns the password-gate failure effects (`[Send 464, Disconnect]`) when
145
+ * the connection has not satisfied the deployment's server-password, or
146
+ * `null` when the gate is satisfied / disabled.
147
+ *
148
+ * Pulling this out as a named helper keeps {@link emitWelcomeIfReady}
149
+ * readable and gives tests a direct handle on the gate logic.
150
+ */
151
+ function passwordGateFailure(state: ConnectionState, ctx: Ctx): EffectType[] | null {
152
+ const configured = ctx.serverConfig.serverPassword;
153
+ if (configured === undefined || configured === '') return null;
154
+ // SASL success satisfies the password gate.
155
+ if (state.account !== undefined) return null;
156
+ if (state.passAttempt === configured) return null;
157
+ return [
158
+ Effect.send(ctx.connId, [numericErr(ctx, Numerics.ERR_PASSWDMISMATCH, 'Password mismatch')]),
159
+ Effect.disconnect(ctx.connId, PASSWORD_MISMATCH_REASON),
160
+ ];
161
+ }
162
+
163
+ /** Canonical disconnect reason paired with the 464 numeric. */
164
+ const PASSWORD_MISMATCH_REASON = 'Bad Password';
165
+
106
166
  /**
107
167
  * Formats a single numeric error line addressed to the connection's current
108
168
  * nick (or `*` when unregistered). Optional `middle` param is emitted before
@@ -166,8 +226,7 @@ export const nickReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
166
226
  }
167
227
  effects.push(Effect.reserveNick(nick, ctx.connId));
168
228
 
169
- const welcome = emitWelcomeIfReady(state, ctx);
170
- if (welcome !== null) effects.push(welcome);
229
+ effects.push(...emitWelcomeIfReady(state, ctx));
171
230
 
172
231
  return { state, effects };
173
232
  };
@@ -209,8 +268,7 @@ export const userReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
209
268
  state.registration = 'registering';
210
269
  }
211
270
 
212
- const welcome = emitWelcomeIfReady(state, ctx);
213
- if (welcome !== null) effects.push(welcome);
271
+ effects.push(...emitWelcomeIfReady(state, ctx));
214
272
 
215
273
  return { state, effects };
216
274
  };