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
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Connection-admission gate — pure policy + stateful counter.
3
+ *
4
+ * Guards the connection acceptance path against three classes of abuse:
5
+ *
6
+ * 1. **Per-IP connection cap** (`maxConnectionsPerIp`): caps how many
7
+ * simultaneous connections a single source IP may hold. Distinct
8
+ * from per-connection flood control: this is the "how many sockets
9
+ *. at once" gate, not the "how many commands per socket" gate.
10
+ *
11
+ * 2. **Per-user connection cap** (`maxConnectionsPerUser`): same idea,
12
+ * keyed by SASL account name when available, falling back to the
13
+ * source IP when the connection has not authenticated.
14
+ *
15
+ * 3. **Per-IP connection-rate limit** (`perIpConnectionRate`): caps how
16
+ * fast a single source IP may open new connections within a sliding
17
+ * window. Defends against connection-flood attacks that recycle
18
+ * sockets to evade the per-IP cap.
19
+ *
20
+ * The policy itself is a pure function {@link decideAdmission}; the
21
+ * accounting state lives in {@link AdmissionStats}, which is injected so
22
+ * tests can pin the timeline via {@link FakeClock} and inspect state in
23
+ * isolation. Both adapters (in-memory runtime, CF / AWS) construct one
24
+ * `AdmissionStats` per process / per worker invocation and share it
25
+ * across every connection acceptance decision.
26
+ *
27
+ * Determinism: same Clock + same call stream → same decisions. There are
28
+ * no ambient inputs.
29
+ */
30
+
31
+ import type { Clock } from './ports.js';
32
+
33
+ /**
34
+ * Static policy consulted by {@link decideAdmission}. Adapters source the
35
+ * values from `ServerConfig` but the gate consumes this narrow shape so
36
+ * the unit tests do not need to construct a full config.
37
+ */
38
+ export interface AdmissionConfig {
39
+ /** Maximum simultaneous connections from a single source IP. */
40
+ readonly maxConnectionsPerIp: number;
41
+ /** Maximum simultaneous connections from a single authenticated user. */
42
+ readonly maxConnectionsPerUser: number;
43
+ /** Per-IP connection-rate limit (sliding window). */
44
+ readonly perIpConnectionRate: { readonly max: number; readonly windowMs: number };
45
+ }
46
+
47
+ /** Positive admission outcome — caller should {@link AdmissionStats.recordAdmission}. */
48
+ export interface AdmitDecision {
49
+ readonly ok: true;
50
+ /**
51
+ * Opaque record id handed back to {@link AdmissionStats.release} when the
52
+ * connection terminates. Unique per admission.
53
+ */
54
+ readonly recordId: string;
55
+ }
56
+
57
+ /** Negative admission outcome — the caller MUST close the connection. */
58
+ export interface RejectDecision {
59
+ readonly ok: false;
60
+ /** Machine-readable reason code for logs / metrics / `Disconnect` effect. */
61
+ readonly reason: AdmissionRejectReason;
62
+ }
63
+
64
+ export type AdmissionRejectReason =
65
+ | 'max_connections_per_ip'
66
+ | 'max_connections_per_user'
67
+ | 'rate_limited_per_ip';
68
+
69
+ export type AdmissionDecision = AdmitDecision | RejectDecision;
70
+
71
+ /** Per-IP / per-user / rate counters. Owned by the runtime / adapter. */
72
+ export class AdmissionStats {
73
+ private readonly clock: Clock;
74
+ private readonly perIpCount = new Map<string, number>();
75
+ private readonly perUserCount = new Map<string, number>();
76
+ /**
77
+ * Per-IP timestamps of recent admissions, used by the rate-limit check.
78
+ * Stored ascending; entries older than the window are pruned lazily.
79
+ */
80
+ private readonly perIpAdmitTimes = new Map<string, number[]>();
81
+ /** Reverse index: recordId → { ip, user }, used by {@link release}. */
82
+ private readonly records = new Map<string, { ip: string; user: string }>();
83
+ private nextRecordId = 1;
84
+
85
+ constructor(clock: Clock) {
86
+ this.clock = clock;
87
+ }
88
+
89
+ /**
90
+ * Returns the current per-IP connection count. Exposed for diagnostics
91
+ * and tests; not used by {@link decideAdmission}.
92
+ */
93
+ countForIp(ip: string): number {
94
+ return this.perIpCount.get(ip) ?? 0;
95
+ }
96
+
97
+ /** Returns the current per-user connection count. */
98
+ countForUser(user: string): number {
99
+ return this.perUserCount.get(user) ?? 0;
100
+ }
101
+
102
+ /**
103
+ * Records an accepted admission. MUST be called exactly once per
104
+ * successful {@link decideAdmission} so the counters stay accurate.
105
+ */
106
+ recordAdmission(ip: string, user: string | undefined, recordId: string): void {
107
+ const effectiveUser = user ?? ip;
108
+ this.perIpCount.set(ip, (this.perIpCount.get(ip) ?? 0) + 1);
109
+ this.perUserCount.set(effectiveUser, (this.perUserCount.get(effectiveUser) ?? 0) + 1);
110
+ const now = this.clock.now();
111
+ const times = this.perIpAdmitTimes.get(ip) ?? [];
112
+ times.push(now);
113
+ this.perIpAdmitTimes.set(ip, times);
114
+ this.records.set(recordId, { ip, user: effectiveUser });
115
+ }
116
+
117
+ /**
118
+ * Releases a previously-recorded admission, decrementing the relevant
119
+ * counters. Idempotent: releasing an unknown id is a no-op.
120
+ */
121
+ release(recordId: string): void {
122
+ const rec = this.records.get(recordId);
123
+ if (rec === undefined) return;
124
+ this.records.delete(recordId);
125
+
126
+ const ipCount = this.perIpCount.get(rec.ip);
127
+ if (ipCount !== undefined) {
128
+ const next = ipCount - 1;
129
+ if (next <= 0) this.perIpCount.delete(rec.ip);
130
+ else this.perIpCount.set(rec.ip, next);
131
+ }
132
+ const userCount = this.perUserCount.get(rec.user);
133
+ if (userCount !== undefined) {
134
+ const next = userCount - 1;
135
+ if (next <= 0) this.perUserCount.delete(rec.user);
136
+ else this.perUserCount.set(rec.user, next);
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Returns the number of admissions recorded for `ip` within the last
142
+ * `windowMs` milliseconds (inclusive of the moment returned by
143
+ * `clock.now()`). Stale entries are pruned as a side effect.
144
+ */
145
+ recentAdmissions(ip: string, windowMs: number): number {
146
+ const times = this.perIpAdmitTimes.get(ip);
147
+ if (times === undefined || times.length === 0) return 0;
148
+ const cutoff = this.clock.now() - windowMs;
149
+ let writeIdx = 0;
150
+ for (let i = 0; i < times.length; i++) {
151
+ const t = times[i];
152
+ if (t === undefined) continue;
153
+ if (t >= cutoff) {
154
+ times[writeIdx++] = t;
155
+ }
156
+ }
157
+ if (writeIdx === 0) {
158
+ this.perIpAdmitTimes.delete(ip);
159
+ return 0;
160
+ }
161
+ times.length = writeIdx;
162
+ return writeIdx;
163
+ }
164
+
165
+ /**
166
+ * Allocates the next unique record id. Public so {@link decideAdmission}
167
+ * can mint ids without breaking encapsulation.
168
+ */
169
+ nextRecord(): string {
170
+ const id = this.nextRecordId.toString(36);
171
+ this.nextRecordId += 1;
172
+ return id;
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Decides whether a new connection should be admitted.
178
+ *
179
+ * The check is performed in a fixed precedence order so the rejection
180
+ * reason is deterministic:
181
+ * 1. Per-IP connection-rate limit (cheapest to test).
182
+ * 2. Per-IP simultaneous connection cap.
183
+ * 3. Per-user simultaneous connection cap.
184
+ *
185
+ * On success the caller MUST call {@link AdmissionStats.recordAdmission}
186
+ * with the returned `recordId` before admitting any further connection;
187
+ * on failure the caller MUST close the connection.
188
+ *
189
+ * The decision is pure with respect to `(ip, user, stats, config)` — same
190
+ * inputs yield the same outcome, including the assigned `recordId`.
191
+ */
192
+ export function decideAdmission(
193
+ ip: string,
194
+ user: string | undefined,
195
+ stats: AdmissionStats,
196
+ config: AdmissionConfig,
197
+ ): AdmissionDecision {
198
+ // 1. Rate limit (per-IP, sliding window).
199
+ const recent = stats.recentAdmissions(ip, config.perIpConnectionRate.windowMs);
200
+ if (recent >= config.perIpConnectionRate.max) {
201
+ return { ok: false, reason: 'rate_limited_per_ip' };
202
+ }
203
+
204
+ // 2. Per-IP cap.
205
+ if (stats.countForIp(ip) >= config.maxConnectionsPerIp) {
206
+ return { ok: false, reason: 'max_connections_per_ip' };
207
+ }
208
+
209
+ // 3. Per-user cap (falls back to IP when no SASL identity is present).
210
+ const effectiveUser = user ?? ip;
211
+ if (stats.countForUser(effectiveUser) >= config.maxConnectionsPerUser) {
212
+ return { ok: false, reason: 'max_connections_per_user' };
213
+ }
214
+
215
+ return { ok: true, recordId: stats.nextRecord() };
216
+ }
@@ -26,6 +26,7 @@ export const SUPPORTED_CAPABILITIES: ReadonlyArray<Capability> = Object.freeze([
26
26
  { name: 'away-notify' },
27
27
  { name: 'batch' },
28
28
  { name: 'chghost' },
29
+ { name: 'draft/chathistory' },
29
30
  { name: 'echo-message' },
30
31
  { name: 'extended-join' },
31
32
  { name: 'invite-notify' },
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Hostmask cloaking — replaces a client's real source IP with a deterministic
3
+ * opaque identifier derived from the deployment secret + the IP.
4
+ *
5
+ * Goals (matching mainstream IRC daemon behaviour — charybdis / UnrealIRCd):
6
+ * - The real IP is never revealed on the wire (in WHOIS, hostmasks, etc.).
7
+ * - The same IP + secret always yields the same cloak (so per-IP
8
+ * reputation/line enforcement still works).
9
+ * - Two different IPs almost never collide.
10
+ * - The cloak fits in a single DNS-label-safe segment.
11
+ *
12
+ * Implementation note: this uses a self-contained FNV-1a 32-bit hash plus a
13
+ * short base32 encoding so the module stays pure-TS and runs unchanged on
14
+ * Node 20, Cloudflare Workers, and Lambda. It is NOT a cryptographic MAC:
15
+ * an attacker who learns one cloak cannot recover the secret, but a
16
+ * determined attacker with the secret can forge cloaks. Deployments that
17
+ * need stronger unforgeability should override this module.
18
+ */
19
+
20
+ import type { CloakingConfig } from './types.js';
21
+
22
+ /**
23
+ * Returns the visible host for `realHost` after applying the deployment's
24
+ * cloaking policy.
25
+ *
26
+ * - When `config.enabled === false`, `realHost` is returned verbatim.
27
+ * - When `config.enabled === true`, a deterministic cloak of the form
28
+ * `<hash>.<suffix>` is returned. The `<hash>` segment is derived from
29
+ * `secret` + `realHost`; the `<suffix>` defaults to `defaultSuffix`
30
+ * (typically the deployment's `networkName` lowercased) and may be
31
+ * overridden via `config.cloakedSuffix`.
32
+ *
33
+ * The function is pure — same inputs always yield the same output. There
34
+ * is no ambient randomness or clock.
35
+ *
36
+ * @param realHost The connection's real source IP (or any host string).
37
+ * @param config Cloaking policy. See {@link CloakingConfig}.
38
+ * @param defaultSuffix Suffix used when `config.cloakedSuffix` is absent.
39
+ */
40
+ export function cloakHost(realHost: string, config: CloakingConfig, defaultSuffix: string): string {
41
+ if (!config.enabled) return realHost;
42
+ const suffix = config.cloakedSuffix ?? defaultSuffix;
43
+ // 64-bit hash assembled from two FNV-1a passes with different seeds so
44
+ // distinct IPs collide with probability ~ 1 / 2^64.
45
+ const hi = fnv1a32(`a:${config.secret}:${realHost}`);
46
+ const lo = fnv1a32(`b:${config.secret}:${realHost}`);
47
+ return `${base32(hi)}${base32(lo)}.${suffix}`;
48
+ }
49
+
50
+ /**
51
+ * FNV-1a 32-bit hash. Self-contained: no Node `crypto`, works on every
52
+ * serverless runtime. Output is an unsigned 32-bit integer.
53
+ */
54
+ function fnv1a32(input: string): number {
55
+ let h = 0x811c9dc5;
56
+ for (let i = 0; i < input.length; i++) {
57
+ h ^= input.charCodeAt(i);
58
+ // 32-bit multiply by FNV prime using Math.imul to stay in u32 space.
59
+ h = Math.imul(h, 0x01000193);
60
+ }
61
+ // Force unsigned.
62
+ return h >>> 0;
63
+ }
64
+
65
+ /**
66
+ * Lowercase base32 (RFC 4648) encoding without padding, suitable for a
67
+ * DNS-safe identifier. Encodes 5 bits per character.
68
+ */
69
+ const BASE32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567';
70
+
71
+ function base32(value: number): string {
72
+ // 32 bits -> 7 base32 chars (ceil(32/5) = 7 with one bit of padding,
73
+ // but we don't emit padding so 7 chars is the canonical length here).
74
+ let out = '';
75
+ let v = value;
76
+ for (let i = 0; i < 7; i++) {
77
+ out += BASE32_ALPHABET[v & 0x1f];
78
+ v = Math.floor(v / 32);
79
+ }
80
+ return out;
81
+ }
@@ -199,8 +199,7 @@ function handleReq(
199
199
  */
200
200
  function handleEnd(state: ConnectionState, ctx: Ctx): ReducerResult<ConnectionState> {
201
201
  state.capNegotiating = false;
202
- const welcome = emitWelcomeIfReady(state, ctx);
203
- return { state, effects: welcome !== null ? [welcome] : [] };
202
+ return { state, effects: emitWelcomeIfReady(state, ctx) };
204
203
  }
205
204
 
206
205
  /**
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Pure reducer for the IRCv3 `draft/chathistory` command.
3
+ *
4
+ * Spec: https://ircv3.net/specs/extensions/chathistory
5
+ *
6
+ * Surface 1 (explicit query): `CHATHISTORY <sub> <target> [<args>...]`. The
7
+ * reducer reads the backlog from the {@link MessageStore} injected via
8
+ * {@link Ctx} (mirroring `MotdProvider` / `AccountStore`) and emits the
9
+ * matching `PRIVMSG`/`NOTICE`/`TAGMSG` lines wrapped in a single
10
+ * `BATCH chathistory <target>` frame. Each replay line carries the original
11
+ * `@time=…` (server-time) and `msgid=…` tags so the client can de-dup and
12
+ * place it on the timeline.
13
+ *
14
+ * The cap `draft/chathistory` MUST be negotiated before any query is
15
+ * accepted; clients without the cap get `421 ERR_UNKNOWNCOMMAND` (the spec's
16
+ * permitted "unknown command" fallback).
17
+ *
18
+ * Location-of-authority: chathistory is read-only and runs against the
19
+ * requester's connection. The target channel is passed in (resolved by the
20
+ * actor layer); `undefined` means "no such channel" → `403`.
21
+ */
22
+
23
+ import { Effect } from '../effects.js';
24
+ import type { Effect as EffectType, RawLine } from '../effects.js';
25
+ import type { StoredMessage } from '../ports.js';
26
+ import { wrapBatch } from '../protocol/batch.js';
27
+ import type { IrcMessage } from '../protocol/messages.js';
28
+ import { Numerics } from '../protocol/numerics.js';
29
+ import { formatServerTime } from '../protocol/outbound.js';
30
+ import type { ChannelState } from '../state/channel.js';
31
+ import type { Ctx } from '../types.js';
32
+
33
+ /** The cap name advertising chathistory support. */
34
+ export const CHATHISTORY_CAP = 'draft/chathistory';
35
+
36
+ /** Default query limit when the client omits one (spec recommends 50). */
37
+ const DEFAULT_QUERY_LIMIT = 50;
38
+
39
+ /** Sentinel the client uses to mean "most recent" / "now" / "beginning". */
40
+ const WILDCARD = '*';
41
+
42
+ /** Subcommands that operate on a single channel target. */
43
+ type ChannelSubcommand = 'LATEST' | 'BEFORE' | 'AFTER' | 'AROUND' | 'BETWEEN';
44
+
45
+ const CHANNEL_SUBCOMMANDS: ReadonlySet<string> = new Set<string>([
46
+ 'LATEST',
47
+ 'BEFORE',
48
+ 'AFTER',
49
+ 'AROUND',
50
+ 'BETWEEN',
51
+ ]);
52
+
53
+ /**
54
+ * Handles `CHATHISTORY <sub> <target> [<args>...]`.
55
+ *
56
+ * @param channel The authoritative ChannelState for `msg.params[1]`, or
57
+ * `undefined` when no such channel exists (→ `403`).
58
+ */
59
+ export function chathistoryReducer(
60
+ channel: ChannelState | undefined,
61
+ msg: IrcMessage,
62
+ ctx: Ctx,
63
+ ): { effects: EffectType[] } {
64
+ // Cap gate: clients that did not negotiate chathistory see RFC 2812 only.
65
+ if (!ctx.connection.caps.has(CHATHISTORY_CAP)) {
66
+ return {
67
+ effects: [
68
+ Effect.send(ctx.connId, [
69
+ numericErr(ctx, Numerics.ERR_UNKNOWNCOMMAND, 'Unknown command', 'CHATHISTORY'),
70
+ ]),
71
+ ],
72
+ };
73
+ }
74
+
75
+ const sub = (msg.params[0] ?? '').toUpperCase();
76
+ if (sub === '') {
77
+ return { effects: [Effect.send(ctx.connId, [needMoreParamsLine(ctx)])] };
78
+ }
79
+
80
+ if (sub === 'TARGETS') {
81
+ return handleTargets(msg, ctx);
82
+ }
83
+
84
+ if (!CHANNEL_SUBCOMMANDS.has(sub)) {
85
+ return { effects: [Effect.send(ctx.connId, [invalidParamsLine(ctx)])] };
86
+ }
87
+
88
+ return handleChannelSub(sub as ChannelSubcommand, channel, msg, ctx);
89
+ }
90
+
91
+ /** Handles the four channel-bound subcommands + BETWEEN. */
92
+ function handleChannelSub(
93
+ sub: ChannelSubcommand,
94
+ channel: ChannelState | undefined,
95
+ msg: IrcMessage,
96
+ ctx: Ctx,
97
+ ): { effects: EffectType[] } {
98
+ const target = msg.params[1];
99
+ if (target === undefined) {
100
+ return { effects: [Effect.send(ctx.connId, [needMoreParamsLine(ctx)])] };
101
+ }
102
+
103
+ if (channel === undefined) {
104
+ return {
105
+ effects: [
106
+ Effect.send(ctx.connId, [
107
+ numericErr(ctx, Numerics.ERR_NOSUCHCHANNEL, 'No such channel', target),
108
+ ]),
109
+ ],
110
+ };
111
+ }
112
+
113
+ // +s/+p leak prevention: a non-member must not learn the channel has
114
+ // history. The spec says return an empty batch; we elide the batch entirely
115
+ // (empty batches are forbidden) so the requester observes nothing.
116
+ if ((channel.modes.secret || channel.modes.private) && !channel.members.has(ctx.connId)) {
117
+ return { effects: [] };
118
+ }
119
+
120
+ const chanLower = channel.nameLower;
121
+
122
+ if (sub === 'BETWEEN') {
123
+ const lo = msg.params[2];
124
+ const hi = msg.params[3];
125
+ if (lo === undefined || hi === undefined) {
126
+ return { effects: [Effect.send(ctx.connId, [needMoreParamsLine(ctx)])] };
127
+ }
128
+ if (!storeHas(ctx, chanLower, lo) || !storeHas(ctx, chanLower, hi)) {
129
+ return { effects: [Effect.send(ctx.connId, [invalidParamsLine(ctx)])] };
130
+ }
131
+ const result = storeQuery(ctx, {
132
+ chan: chanLower,
133
+ direction: 'between',
134
+ limit: DEFAULT_QUERY_LIMIT,
135
+ msgid: lo,
136
+ msgid2: hi,
137
+ });
138
+ return { effects: buildChathistoryBatch(ctx, channel.name, result) };
139
+ }
140
+
141
+ // LATEST / BEFORE / AFTER / AROUND
142
+ const pivot = msg.params[2];
143
+ const limitRaw = msg.params[3];
144
+ if (sub !== 'LATEST' && pivot === undefined) {
145
+ return { effects: [Effect.send(ctx.connId, [needMoreParamsLine(ctx)])] };
146
+ }
147
+ const limit = parseLimit(limitRaw);
148
+ if (limit === undefined) {
149
+ return { effects: [Effect.send(ctx.connId, [invalidParamsLine(ctx)])] };
150
+ }
151
+
152
+ if (sub === 'LATEST') {
153
+ const pivotArg = pivot === undefined || pivot === WILDCARD ? undefined : pivot;
154
+ if (pivotArg !== undefined && !storeHas(ctx, chanLower, pivotArg)) {
155
+ return { effects: [Effect.send(ctx.connId, [invalidParamsLine(ctx)])] };
156
+ }
157
+ const result = storeQuery(ctx, {
158
+ chan: chanLower,
159
+ direction: 'latest',
160
+ limit,
161
+ ...(pivotArg !== undefined ? { msgid: pivotArg } : {}),
162
+ });
163
+ return { effects: buildChathistoryBatch(ctx, channel.name, result) };
164
+ }
165
+
166
+ // BEFORE / AFTER / AROUND — pivot is required. The `sub !== 'LATEST' &&
167
+ // pivot === undefined` guard above already rejected the missing-pivot
168
+ // case and LATEST returned early just above; assert non-null for the
169
+ // type-checker without an unreachable branch.
170
+ const pivotMsgid = pivot as string;
171
+ if (!storeHas(ctx, chanLower, pivotMsgid)) {
172
+ return { effects: [Effect.send(ctx.connId, [invalidParamsLine(ctx)])] };
173
+ }
174
+ const result = storeQuery(ctx, {
175
+ chan: chanLower,
176
+ direction: sub.toLowerCase() as 'before' | 'after' | 'around',
177
+ limit,
178
+ msgid: pivotMsgid,
179
+ });
180
+ return { effects: buildChathistoryBatch(ctx, channel.name, result) };
181
+ }
182
+
183
+ /** Handles `CHATHISTORY TARGETS <since> <until>`. */
184
+ function handleTargets(msg: IrcMessage, ctx: Ctx): { effects: EffectType[] } {
185
+ const sinceRaw = msg.params[1];
186
+ const untilRaw = msg.params[2];
187
+ if (sinceRaw === undefined || untilRaw === undefined) {
188
+ return { effects: [Effect.send(ctx.connId, [needMoreParamsLine(ctx)])] };
189
+ }
190
+ const since = parseTimestamp(sinceRaw, 0);
191
+ const until = parseTimestamp(untilRaw, ctx.clock.now());
192
+ if (since === undefined || until === undefined) {
193
+ return { effects: [Effect.send(ctx.connId, [invalidParamsLine(ctx)])] };
194
+ }
195
+
196
+ const entries = ctx.messages !== undefined ? ctx.messages.targets(since, until) : [];
197
+ if (entries.length === 0) {
198
+ return { effects: [] };
199
+ }
200
+
201
+ const body: RawLine[] = entries.map((e) => ({
202
+ text: `:${ctx.serverName} CHATHISTORY TARGETS ${formatServerTime(e.time)} ${e.chan}`,
203
+ }));
204
+ const frame = wrapBatch(body, ctx.ids.batchId(), 'chathistory');
205
+ return { effects: [Effect.send(ctx.connId, frame)] };
206
+ }
207
+
208
+ /**
209
+ * Wraps `messages` as replay lines in a `BATCH chathistory <chan>` frame
210
+ * addressed to the requester. Empty input elides the batch entirely (the
211
+ * spec forbids empty batches; the client interprets "no batch" as "no
212
+ * history").
213
+ *
214
+ * Shared by the explicit `CHATHISTORY` query and the JOIN auto-playback hook
215
+ * so both surfaces emit identical framing.
216
+ */
217
+ export function buildChathistoryBatch(
218
+ ctx: Ctx,
219
+ displayChan: string,
220
+ messages: readonly StoredMessage[],
221
+ ): EffectType[] {
222
+ if (messages.length === 0) return [];
223
+ const body = messages.map((m) => formatReplayLine(m, displayChan));
224
+ const frame = wrapBatch(body, ctx.ids.batchId(), 'chathistory', displayChan);
225
+ return [Effect.send(ctx.connId, frame)];
226
+ }
227
+
228
+ /**
229
+ * Reconstructs the wire line for one stored message, decorated with the
230
+ * original server-time and msgid tags. PRIVMSG/NOTICE carry the trailing
231
+ * text; TAGMSG has no trailing parameter.
232
+ */
233
+ export function formatReplayLine(m: StoredMessage, displayChan: string): RawLine {
234
+ const stamp = formatServerTime(m.time);
235
+ const prefix = storedHostmask(m);
236
+ const tag = `@time=${stamp};msgid=${m.msgid}`;
237
+ if (m.command === 'TAGMSG') {
238
+ return { text: `${tag} :${prefix} TAGMSG ${displayChan}` };
239
+ }
240
+ return { text: `${tag} :${prefix} ${m.command} ${displayChan} :${m.text}` };
241
+ }
242
+
243
+ /** Builds the `nick!user@host` source for a stored message. */
244
+ function storedHostmask(m: StoredMessage): string {
245
+ if (m.user === undefined && m.host === undefined) return m.nick;
246
+ let out = m.nick;
247
+ if (m.user !== undefined) out = `${out}!${m.user}`;
248
+ if (m.host !== undefined) out = `${out}@${m.host}`;
249
+ return out;
250
+ }
251
+
252
+ /** Formats a numeric error line addressed to the connection's nick (or `*`). */
253
+ function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
254
+ const nick = ctx.connection.nick ?? '*';
255
+ const codeStr = code.toString().padStart(3, '0');
256
+ return { text: [`:${ctx.serverName}`, codeStr, nick, middle, `:${trailing}`].join(' ') };
257
+ }
258
+
259
+ /** `461 CHATHISTORY :Not enough parameters`. */
260
+ function needMoreParamsLine(ctx: Ctx): RawLine {
261
+ return numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Not enough parameters', 'CHATHISTORY');
262
+ }
263
+
264
+ /** `461 CHATHISTORY :Invalid parameters`. */
265
+ function invalidParamsLine(ctx: Ctx): RawLine {
266
+ return numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Invalid parameters', 'CHATHISTORY');
267
+ }
268
+
269
+ /** Parses a non-negative integer limit; returns undefined on garbage. */
270
+ function parseLimit(raw: string | undefined): number | undefined {
271
+ if (raw === undefined) return DEFAULT_QUERY_LIMIT;
272
+ const n = Number(raw);
273
+ if (!Number.isInteger(n) || n < 0) return undefined;
274
+ return n;
275
+ }
276
+
277
+ /**
278
+ * Parses a CHATHISTORY timestamp bound. `*` resolves to `wildcardValue`
279
+ * (0 for `since`, `ctx.clock.now()` for `until`); an ISO-8601 string parses
280
+ * to epoch ms; anything else returns undefined (→ `461`).
281
+ */
282
+ function parseTimestamp(raw: string, wildcardValue: number): number | undefined {
283
+ if (raw === WILDCARD) return wildcardValue;
284
+ const ms = Date.parse(raw);
285
+ return Number.isNaN(ms) ? undefined : ms;
286
+ }
287
+
288
+ /** store.hasMsgid that treats an unbound store as "the msgid is not known". */
289
+ function storeHas(ctx: Ctx, chan: string, msgid: string): boolean {
290
+ return ctx.messages !== undefined ? ctx.messages.hasMsgid(chan, msgid) : false;
291
+ }
292
+
293
+ /** store.query that treats an unbound store as empty. */
294
+ function storeQuery(
295
+ ctx: Ctx,
296
+ q: {
297
+ chan: string;
298
+ direction: 'latest' | 'before' | 'after' | 'around' | 'between';
299
+ limit: number;
300
+ msgid?: string;
301
+ msgid2?: string;
302
+ },
303
+ ): StoredMessage[] {
304
+ return ctx.messages !== undefined ? ctx.messages.query(q) : [];
305
+ }
@@ -38,3 +38,10 @@ export { capReducer } from './cap.js';
38
38
  export { emitChghost } from './chghost.js';
39
39
  export type { ChgHostInput } from './chghost.js';
40
40
  export { awayReducer } from './away.js';
41
+ export { authenticateReducer } from './sasl.js';
42
+ export {
43
+ chathistoryReducer,
44
+ formatReplayLine,
45
+ buildChathistoryBatch,
46
+ CHATHISTORY_CAP,
47
+ } from './chathistory.js';