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,270 @@
1
+ /**
2
+ * Server configuration schema (Zod) — the single source of truth for what
3
+ * the deployment supplies at boot time.
4
+ *
5
+ * Adapters load this from their platform-native secret/config store
6
+ * (Cloudflare: KV/vars in `wrangler.toml`; AWS: Secrets Manager/SSM) and
7
+ * pass it through {@link parseServerConfig} before the runtime boots.
8
+ * Invalid config fails fast at boot with a readable error instead of
9
+ * corrupting connection state mid-flight.
10
+ *
11
+ * The reducer-facing {@link ServerConfig} (in `types.ts`) is a strict
12
+ * subset of this schema — the reducers do not care about MOTD text,
13
+ * channel-prefix policy, or max-clients (those are consumed by
14
+ * adapter-side plumbing: the MOTD provider, the JOIN validator's prefix
15
+ * table, and the connection admission gate). Oper credentials ARE surfaced
16
+ * on the reducer-facing config so the OPER reducer can authenticate.
17
+ */
18
+
19
+ import { z } from 'zod';
20
+
21
+ /**
22
+ * Schema for a single IRC operator credential.
23
+ *
24
+ * Both fields are non-empty strings; the adapter is responsible for any
25
+ * hashing/constant-time comparison at the OPER auth boundary.
26
+ */
27
+ export const OperCredSchema = z.object({
28
+ user: z.string().min(1),
29
+ password: z.string().min(1),
30
+ });
31
+
32
+ /**
33
+ * Schema for a single SASL PLAIN account credential. The `username` is also
34
+ * the canonical account name recorded on the connection (for `extended-join`
35
+ * / `account-tag`). Both fields are non-empty strings; adapters seed an
36
+ * {@link InMemoryAccountStore} from this list at boot.
37
+ */
38
+ export const SaslAccountSchema = z.object({
39
+ username: z.string().min(1),
40
+ password: z.string().min(1),
41
+ });
42
+
43
+ /**
44
+ * Cloaking configuration. When enabled, the visible hostmask of every
45
+ * connecting client is replaced by `<cloak>.<suffix>` where `<cloak>` is a
46
+ * short deterministic hash of the client's real IP + the deployment
47
+ * secret. The real IP is never revealed on the wire.
48
+ *
49
+ * The `secret` MUST be set when `enabled` is true; the schema rejects a
50
+ * cloaking config that turns on cloaking without one.
51
+ */
52
+ export const CloakingConfigSchema = z.object({
53
+ enabled: z.boolean(),
54
+ /** Strong per-deployment secret. Required when `enabled` is true. */
55
+ secret: z.string().min(1),
56
+ /**
57
+ * Suffix appended to every cloak (e.g. `"irc.example.com"` yields
58
+ * `<cloak>.irc.example.com`). Defaults to the deployment's `networkName`.
59
+ */
60
+ cloakedSuffix: z.string().min(1).optional(),
61
+ });
62
+
63
+ /**
64
+ * Per-IP connection-rate limit. Caps how many new connections a single
65
+ * source IP may open within a sliding `windowMs` window before the
66
+ * admission gate starts refusing them. Distinct from per-connection
67
+ * message-rate flood control: this guards the connection acceptance path
68
+ * rather than individual command flow.
69
+ */
70
+ export const PerIpConnectionRateSchema = z.object({
71
+ /** Maximum connections from one IP within `windowMs`. */
72
+ max: z.number().int().positive(),
73
+ /** Sliding window length in milliseconds. */
74
+ windowMs: z.number().int().positive(),
75
+ });
76
+
77
+ /**
78
+ * Per-connection message-rate flood control (token bucket). Caps how many
79
+ * commands a single established connection may send in a burst before the
80
+ * server disconnects it with the canonical "Excess Flood" reason. Distinct
81
+ * from the per-IP connection admission rate: this bounds individual command
82
+ * flow on an already-admitted connection.
83
+ *
84
+ * Only the numeric knobs live here. The per-command cost is a function and
85
+ * cannot be serialized from a config store, so the actor wires the
86
+ * control-plane exemption (`PING`/`PONG`/`CAP`/`AUTHENTICATE`/`QUIT`) in
87
+ * code at construction time.
88
+ */
89
+ export const FloodControlConfigSchema = z.object({
90
+ /** Bucket ceiling; the largest burst a fresh connection can sustain. */
91
+ capacity: z.number().int().positive(),
92
+ /** Tokens added per wall-clock second. */
93
+ refillRatePerSecond: z.number().int().nonnegative(),
94
+ /**
95
+ * How far the bucket may go negative before the wrapper disconnects.
96
+ * `0` kills on the first over-rate message; larger values grant slack.
97
+ */
98
+ disconnectThreshold: z.number().int().nonnegative(),
99
+ });
100
+
101
+ /**
102
+ * Documented default bucket. Tolerates a short interactive burst while
103
+ * killing an obvious flood. Matches the canonical fixture used throughout
104
+ * the flood-control test suite (capacity 10, 1 token/sec, no slack).
105
+ */
106
+ export const DEFAULT_FLOOD_CONTROL_CONFIG: {
107
+ capacity: number;
108
+ refillRatePerSecond: number;
109
+ disconnectThreshold: number;
110
+ } = {
111
+ capacity: 10,
112
+ refillRatePerSecond: 1,
113
+ disconnectThreshold: 0,
114
+ };
115
+
116
+ /**
117
+ * The full server-config schema. Optional fields default to deployment-
118
+ * sensible values so a minimal `{ serverName, networkName }` config is
119
+ * enough to boot; production deployments override the defaults.
120
+ */
121
+ export const ServerConfigSchema = z.object({
122
+ /** Server hostname shown in numerics (`:serverName 001 nick ...`). */
123
+ serverName: z.string().min(1),
124
+ /** Network name advertised in `005 NETWORK=...` and WHOIS replies. */
125
+ networkName: z.string().min(1),
126
+
127
+ /**
128
+ * Server password. Empty string or undefined disables the password
129
+ * gate. When set, every connection must supply the same value via
130
+ * `PASS <password>` (or SASL PLAIN) before registration completes.
131
+ */
132
+ serverPassword: z.string().optional(),
133
+
134
+ /**
135
+ * Hostmask cloaking. When enabled, real client IPs are replaced by a
136
+ * deterministic cloak derived from `secret` + the source IP. Omit to
137
+ * disable cloaking entirely (the default).
138
+ */
139
+ cloaking: CloakingConfigSchema.optional(),
140
+
141
+ /**
142
+ * Maximum simultaneous connections from a single source IP. Enforced at
143
+ * the admission boundary (the in-memory runtime and CF / AWS adapters).
144
+ */
145
+ maxConnectionsPerIp: z.number().int().positive().default(10),
146
+
147
+ /**
148
+ * Maximum simultaneous connections from a single authenticated user
149
+ * (identified by SASL account, falling back to source IP when no SASL
150
+ * identity is established). Enforced at the admission boundary.
151
+ */
152
+ maxConnectionsPerUser: z.number().int().positive().default(5),
153
+
154
+ /**
155
+ * Per-IP connection-rate limit (sliding window). Caps how fast a single
156
+ * source IP may open new connections. Distinct from per-connection
157
+ * message-rate flood control.
158
+ */
159
+ perIpConnectionRate: PerIpConnectionRateSchema.default({
160
+ max: 5,
161
+ windowMs: 60_000,
162
+ }),
163
+
164
+ /**
165
+ * Per-connection message-rate flood control. Omit to apply the documented
166
+ * default bucket ({@link DEFAULT_FLOOD_CONTROL_CONFIG}); set to `null`
167
+ * explicitly to disable flood control for this deployment (e.g. a trusted
168
+ * loopback relay that enforces its own limits upstream).
169
+ */
170
+ floodControl: FloodControlConfigSchema.nullable().default(DEFAULT_FLOOD_CONTROL_CONFIG),
171
+
172
+ /**
173
+ * Message-of-the-day lines. Empty array => `422 ERR_NOMOTD`.
174
+ * Adapters typically feed these into a `StaticMotdProvider` at boot.
175
+ */
176
+ motdLines: z.array(z.string()).default([]),
177
+
178
+ /**
179
+ * Allowed channel-name prefix sigils (e.g. `#`, `#&+`). The first
180
+ * character is the canonical prefix advertised via `CHANTYPES` in
181
+ * the `005` reply.
182
+ */
183
+ channelPrefixes: z.string().min(1).default('#'),
184
+
185
+ /**
186
+ * Maximum simultaneous client connections this deployment will admit.
187
+ * Adapters enforce the cap at the `$connect` / WS-upgrade boundary.
188
+ */
189
+ maxClients: z.number().int().positive().default(10_000),
190
+
191
+ /**
192
+ * IRC operator credentials. The adapter's OPER auth path consults
193
+ * these; empty array disables OPER entirely.
194
+ */
195
+ operCreds: z.array(OperCredSchema).default([]),
196
+
197
+ /**
198
+ * SASL PLAIN account credentials. The adapter seeds an
199
+ * `InMemoryAccountStore` from this list at boot so `AUTHENTICATE PLAIN`
200
+ * succeeds end-to-end for the listed accounts. Empty array (the default)
201
+ * disables SASL account verification: `ctx.accounts` is then omitted and
202
+ * a client completing `AUTHENTICATE PLAIN` receives `904 ERR_SASLFAIL`.
203
+ * A persistent variant (DynamoDB `Accounts`, D1) is a documented
204
+ * follow-up; swapping it in means replacing the construction call.
205
+ */
206
+ saslAccounts: z.array(SaslAccountSchema).default([]),
207
+
208
+ // --- Reducer-facing limit knobs (mirrored on `ServerConfig`) ---
209
+
210
+ /** Maximum channels a single connection may JOIN. */
211
+ maxChannelsPerUser: z.number().int().positive().default(30),
212
+ /** Maximum targets per PRIVMSG/NOTICE/KICK command. */
213
+ maxTargetsPerCommand: z.number().int().positive().default(10),
214
+ /** Maximum entries returned by LIST/NAMES/ban-list queries. */
215
+ maxListEntries: z.number().int().nonnegative().default(50),
216
+ /** Maximum nickname length (advertised as `NICKLEN=`). */
217
+ nickLen: z.number().int().positive().default(30),
218
+ /** Maximum channel-name length (advertised as `CHANNELLEN=`). */
219
+ channelLen: z.number().int().positive().default(50),
220
+ /** Maximum topic length (advertised as `TOPICLEN=`). */
221
+ topicLen: z.number().int().nonnegative().default(390),
222
+ /** Default QUIT reason when the client disconnects without one. */
223
+ quitMessage: z.string().default('Client Quit'),
224
+
225
+ /**
226
+ * Maximum number of back-log messages replayed on JOIN for clients that
227
+ * negotiated `draft/chathistory`. The IRCv3 spec recommends 50; raise or
228
+ * lower per deployment. Only consulted when a MessageStore is bound.
229
+ */
230
+ chatHistoryPlaybackLimit: z.number().int().positive().default(50),
231
+ });
232
+
233
+ /** Input type: what adapters supply (fields with defaults may be omitted). */
234
+ export type ServerConfigInput = z.input<typeof ServerConfigSchema>;
235
+
236
+ /** Output type: what the schema yields post-parse (all fields populated). */
237
+ export type ParsedServerConfig = z.output<typeof ServerConfigSchema>;
238
+
239
+ /**
240
+ * Parses a raw config value against {@link ServerConfigSchema}, applying
241
+ * defaults and throwing a single aggregated {@link Error} on failure.
242
+ *
243
+ * Adapters call this exactly once at boot. The thrown message names every
244
+ * invalid field so operators can fix the config without round-tripping
245
+ * through Zod's error tree.
246
+ *
247
+ * @throws {Error} when `input` fails schema validation. The message
248
+ * starts with "Invalid server config" and lists every offending field.
249
+ */
250
+ export function parseServerConfig(input: unknown): ParsedServerConfig {
251
+ const result = ServerConfigSchema.safeParse(input);
252
+ if (!result.success) {
253
+ const lines = result.error.issues.map((issue) => {
254
+ const path = issue.path.length > 0 ? issue.path.join('.') : '<root>';
255
+ return ` - ${path}: ${issue.message}`;
256
+ });
257
+ throw new Error(`Invalid server config:\n${lines.join('\n')}`);
258
+ }
259
+ return result.data;
260
+ }
261
+
262
+ /**
263
+ * Default server config: the result of parsing an empty input. Useful as a
264
+ * known-good baseline for tests and for adapters that want to overlay
265
+ * per-deployment overrides via object spread.
266
+ */
267
+ export const DEFAULT_SERVER_CONFIG: ParsedServerConfig = ServerConfigSchema.parse({
268
+ serverName: 'irc.example.com',
269
+ networkName: 'ServerlessIRCd',
270
+ });
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Flood control as a pure higher-order reducer wrapper.
3
+ *
4
+ * Wraps any {@link Reducer} with a per-connection token bucket. The bucket is
5
+ * refilled using the injected {@link Clock} (deterministic — never
6
+ * `Date.now()`), stored on `ctx.connection.floodBucket`, and consulted
7
+ * before the inner reducer is invoked.
8
+ *
9
+ * On a healthy message the inner reducer is called and its
10
+ * `{ state, effects }` is passed through untouched. When the bucket crosses
11
+ * the configured negative threshold the wrapper short-circuits with a
12
+ * `Disconnect("Excess Flood")` effect (RFC 2812 §6 canonical reason) and
13
+ * does NOT call the inner reducer — matching the behaviour of mainstream
14
+ * IRC daemons (hybrid, charybdis, Unreal).
15
+ *
16
+ * The wrapper is generic over the inner reducer's state type: the bucket
17
+ * lives on `ctx.connection`, so this works for connection-authority
18
+ * reducers (`Reducer<ConnectionState>`) and channel-authority reducers
19
+ * (`Reducer<ChannelState>`) alike.
20
+ *
21
+ * Determinism invariant: given the same `Clock` readings and the same
22
+ * message stream the pass-through vs. disconnect decision is identical.
23
+ * There are no ambient inputs.
24
+ */
25
+
26
+ import { Effect } from './effects.js';
27
+ import type { FloodBucketState } from './state/connection.js';
28
+ import type { Reducer } from './types.js';
29
+
30
+ /** Canonical IRC quit reason for flooding (RFC 2812 §6). */
31
+ export const EXCESS_FLOOD_REASON = 'Excess Flood';
32
+
33
+ /**
34
+ * Token-bucket parameters. Tune per deployment via the wrapper factory; the
35
+ * wrapper does not read `ServerConfig` so existing config literals are
36
+ * untouched.
37
+ */
38
+ export interface FloodControlConfig {
39
+ /** Bucket ceiling; the largest burst a fresh connection can sustain. */
40
+ capacity: number;
41
+ /** Tokens added per wall-clock second. */
42
+ refillRatePerSecond: number;
43
+ /**
44
+ * How far the bucket may go negative before the wrapper disconnects.
45
+ * `0` means the first over-rate message kills; larger values grant slack
46
+ * (e.g. `2` tolerates two messages past capacity before disconnecting).
47
+ */
48
+ disconnectThreshold: number;
49
+ /**
50
+ * Optional pure function returning the token cost of a command. The
51
+ * command is the parsed `IrcMessage.command`, already uppercased by the
52
+ * parser, so case-insensitivity is handled once upstream.
53
+ *
54
+ * When omitted, every command is charged the flat {@link MESSAGE_COST}
55
+ * (`1`) — i.e. the original wrapper behaviour is preserved bit-for-bit.
56
+ * Compose with {@link defaultCommandCost} to exempt control-plane
57
+ * traffic (`PING`/`PONG`/`CAP`/`AUTHENTICATE`/`QUIT`).
58
+ *
59
+ * A cost of `0` neither decrements the bucket nor triggers the disconnect
60
+ * path; the inner reducer is called as normal. Negative costs are rejected
61
+ * at wrapper-construction time — they would invert the bucket.
62
+ */
63
+ commandCost?: (command: string) => number;
64
+ }
65
+
66
+ /**
67
+ * Control-plane commands that mainstream ircds (charybdis, hybrid, Unreal) exempt
68
+ * from flood control. A client exchanging `PING`/`PONG` with the server or
69
+ * negotiating `CAP` / `AUTHENTICATE` during SASL must not flood itself out
70
+ * through no fault of its own.
71
+ *
72
+ * Override the set by composing your own cost function on top of this one.
73
+ */
74
+ const EXEMPT_COMMANDS: ReadonlySet<string> = new Set([
75
+ 'PING',
76
+ 'PONG',
77
+ 'CAP',
78
+ 'AUTHENTICATE',
79
+ 'QUIT',
80
+ ]);
81
+
82
+ /**
83
+ * Ready-made per-command cost: `0` for the exempt control-plane set, `1`
84
+ * otherwise. Pass this as `FloodControlConfig.commandCost` to get sensible
85
+ * mainstream-ircd behaviour, or compose your own function to override the
86
+ * exempt set.
87
+ */
88
+ export function defaultCommandCost(command: string): number {
89
+ return EXEMPT_COMMANDS.has(command) ? 0 : 1;
90
+ }
91
+
92
+ /** Cost charged for every message when no `commandCost` override is given. */
93
+ const MESSAGE_COST = 1;
94
+
95
+ /**
96
+ * Command used to probe a caller-supplied `commandCost` at wrapper-construction
97
+ * time for the negative-cost rejection. The parser uppercases commands, so the
98
+ * probe uses a canonical charged command.
99
+ */
100
+ const COST_PROBE_COMMAND = 'PRIVMSG';
101
+
102
+ /**
103
+ * Returns the post-refill token count at a given moment, capped at
104
+ * `capacity`. Pure — does not mutate the bucket.
105
+ */
106
+ function refillTokens(bucket: FloodBucketState, now: number, config: FloodControlConfig): number {
107
+ const elapsedSec = Math.max(0, (now - bucket.lastRefill) / 1_000);
108
+ return Math.min(config.capacity, bucket.tokens + elapsedSec * config.refillRatePerSecond);
109
+ }
110
+
111
+ /**
112
+ * Pure computation of the next bucket balance after refilling and deducting
113
+ * the per-command cost. Shared by the pass-through and disconnect branches so
114
+ * they can never diverge. Lazily initializes the bucket to `capacity` tokens
115
+ * on the first observed message.
116
+ */
117
+ function computeNextBalance(
118
+ previous: FloodBucketState | undefined,
119
+ now: number,
120
+ cost: number,
121
+ config: FloodControlConfig,
122
+ ): FloodBucketState {
123
+ const refilled = previous === undefined ? config.capacity : refillTokens(previous, now, config);
124
+ return { tokens: refilled - cost, lastRefill: now };
125
+ }
126
+
127
+ /**
128
+ * Wraps `inner` with a per-connection token-bucket flood-control check.
129
+ *
130
+ * The returned reducer:
131
+ * 1. Derives the per-command cost via `config.commandCost` (or the flat
132
+ * {@link MESSAGE_COST} when omitted).
133
+ * 2. Lazily initializes `ctx.connection.floodBucket` to `capacity` tokens
134
+ * on the first observed message.
135
+ * 3. Refills the bucket using `ctx.clock.now()` and deducts the cost.
136
+ * 4. Decides: if the new balance is at or above `-disconnectThreshold`,
137
+ * delegates to `inner` (pass-through). Otherwise emits
138
+ * `Disconnect("Excess Flood")` and skips `inner`.
139
+ *
140
+ * A command whose cost is `0` never trips the disconnect branch (the balance
141
+ * is unchanged after refill) and the inner reducer runs as normal.
142
+ *
143
+ * The bucket is updated in place on `ctx.connection` so subsequent invocations
144
+ * observe the new balance — this is the same in-place mutation pattern other
145
+ * connection-authority reducers use (e.g. `lastSeen` updates).
146
+ *
147
+ * Throws synchronously if `config.commandCost` returns a negative value for
148
+ * the probe command, since negative costs would invert the bucket.
149
+ */
150
+ export function wrapWithFloodControl<S>(inner: Reducer<S>, config: FloodControlConfig): Reducer<S> {
151
+ const costOf = config.commandCost ?? (() => MESSAGE_COST);
152
+ if (config.commandCost !== undefined && config.commandCost(COST_PROBE_COMMAND) < 0) {
153
+ throw new Error(
154
+ 'FloodControlConfig.commandCost returned a negative cost; costs must be non-negative',
155
+ );
156
+ }
157
+
158
+ return (state, msg, ctx) => {
159
+ const now = ctx.clock.now();
160
+ const conn = ctx.connection;
161
+
162
+ const cost = costOf(msg.command);
163
+ const next = computeNextBalance(conn.floodBucket, now, cost, config);
164
+ conn.floodBucket = next;
165
+
166
+ if (next.tokens < -config.disconnectThreshold) {
167
+ return {
168
+ state,
169
+ effects: [Effect.disconnect(conn.id, EXCESS_FLOOD_REASON)],
170
+ };
171
+ }
172
+
173
+ return inner(state, msg, ctx);
174
+ };
175
+ }
@@ -10,9 +10,16 @@ export * from './caps/index.js';
10
10
  export * from './commands/index.js';
11
11
  export * from './effects.js';
12
12
  export * from './ports.js';
13
+ export * from './flood-control.js';
14
+ export * from './cloak.js';
15
+ export * from './case-fold.js';
16
+ export * from './admission.js';
17
+ export * from './config.js';
13
18
  export type {
14
19
  BuildCtxOptions,
20
+ CloakingConfig,
15
21
  Ctx,
22
+ OperCred,
16
23
  Reducer,
17
24
  ReducerResult,
18
25
  ServerConfig,