serverless-ircd 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/deploy-aws.yml +1 -3
  3. package/.github/workflows/deploy-cf-tcp.yml +87 -0
  4. package/.github/workflows/deploy-cf.yml +1 -1
  5. package/.node-version +1 -0
  6. package/.nvmrc +1 -0
  7. package/CHANGELOG.md +174 -18
  8. package/README.md +63 -30
  9. package/apps/aws-stack/README.md +2 -2
  10. package/apps/aws-stack/bin/aws.ts +7 -0
  11. package/apps/aws-stack/package.json +4 -4
  12. package/apps/aws-stack/src/aws-stack.ts +118 -6
  13. package/apps/aws-stack/tests/stack.test.ts +98 -3
  14. package/apps/cf-tcp-container/Dockerfile +69 -0
  15. package/apps/cf-tcp-container/package.json +34 -0
  16. package/apps/cf-tcp-container/src/config-loader.ts +145 -0
  17. package/apps/cf-tcp-container/src/container-do.ts +38 -0
  18. package/apps/cf-tcp-container/src/container-server.ts +363 -0
  19. package/apps/cf-tcp-container/src/main.ts +77 -0
  20. package/apps/cf-tcp-container/src/persistence.ts +144 -0
  21. package/apps/cf-tcp-container/src/worker.ts +41 -0
  22. package/apps/cf-tcp-container/terraform/provider.tf +24 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
  25. package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
  26. package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
  27. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
  28. package/apps/cf-tcp-container/tsconfig.build.json +17 -0
  29. package/apps/cf-tcp-container/tsconfig.test.json +15 -0
  30. package/apps/cf-tcp-container/vitest.config.ts +26 -0
  31. package/apps/cf-tcp-container/wrangler.toml +63 -0
  32. package/apps/cf-worker/package.json +1 -1
  33. package/apps/cf-worker/wrangler.test.toml +6 -0
  34. package/apps/cf-worker/wrangler.toml +28 -2
  35. package/apps/local-cli/package.json +1 -1
  36. package/apps/local-cli/src/config-loader.ts +10 -0
  37. package/apps/local-cli/src/server.ts +20 -3
  38. package/package.json +14 -10
  39. package/packages/aws-adapter/package.json +3 -3
  40. package/packages/aws-adapter/src/account-store.ts +1 -1
  41. package/packages/aws-adapter/src/admission.ts +74 -0
  42. package/packages/aws-adapter/src/aws-runtime.ts +6 -4
  43. package/packages/aws-adapter/src/config-loader.ts +32 -0
  44. package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
  45. package/packages/aws-adapter/src/handlers/connect.ts +64 -8
  46. package/packages/aws-adapter/src/handlers/default.ts +35 -2
  47. package/packages/aws-adapter/src/handlers/index.ts +69 -3
  48. package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
  49. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  50. package/packages/aws-adapter/src/index.ts +6 -0
  51. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
  52. package/packages/aws-adapter/tests/account-store.test.ts +19 -20
  53. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  54. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  55. package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
  56. package/packages/aws-adapter/tests/connect.test.ts +78 -0
  57. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
  58. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  59. package/packages/aws-adapter/tests/handlers.test.ts +154 -53
  60. package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
  61. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  62. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  63. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  64. package/packages/cf-adapter/package.json +1 -1
  65. package/packages/cf-adapter/src/cf-runtime.ts +2 -4
  66. package/packages/cf-adapter/src/config-loader.ts +33 -0
  67. package/packages/cf-adapter/src/connection-do.ts +111 -46
  68. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  69. package/packages/cf-adapter/src/env.ts +33 -9
  70. package/packages/cf-adapter/src/index.ts +9 -8
  71. package/packages/cf-adapter/tests/cf-harness.ts +11 -1
  72. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
  73. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  74. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  75. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  76. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  77. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  78. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  79. package/packages/cf-adapter/tests/worker/main.ts +8 -1
  80. package/packages/cf-adapter/wrangler.test.toml +8 -0
  81. package/packages/in-memory-runtime/package.json +1 -1
  82. package/packages/irc-core/package.json +6 -1
  83. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  84. package/packages/irc-core/src/caps/capabilities.ts +23 -2
  85. package/packages/irc-core/src/cloak.ts +1 -1
  86. package/packages/irc-core/src/commands/cap.ts +8 -1
  87. package/packages/irc-core/src/commands/index.ts +9 -0
  88. package/packages/irc-core/src/commands/ison.ts +61 -0
  89. package/packages/irc-core/src/commands/quit.ts +12 -0
  90. package/packages/irc-core/src/commands/registration.ts +18 -12
  91. package/packages/irc-core/src/commands/sasl.ts +72 -9
  92. package/packages/irc-core/src/commands/server-info.ts +129 -0
  93. package/packages/irc-core/src/commands/userhost.ts +84 -0
  94. package/packages/irc-core/src/commands/whowas.ts +113 -0
  95. package/packages/irc-core/src/config.ts +58 -0
  96. package/packages/irc-core/src/credential-hashing.ts +124 -0
  97. package/packages/irc-core/src/effects.ts +6 -29
  98. package/packages/irc-core/src/index.ts +1 -0
  99. package/packages/irc-core/src/ports.ts +181 -12
  100. package/packages/irc-core/src/protocol/numerics.ts +14 -8
  101. package/packages/irc-core/src/types.ts +38 -1
  102. package/packages/irc-core/tests/account-store.test.ts +45 -2
  103. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  104. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  105. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  106. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  107. package/packages/irc-core/tests/commands/registration.test.ts +151 -6
  108. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  109. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  110. package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
  111. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  112. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  113. package/packages/irc-core/tests/config.test.ts +95 -1
  114. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  115. package/packages/irc-core/tests/effects.test.ts +0 -27
  116. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  117. package/packages/irc-core/tests/numerics.test.ts +12 -0
  118. package/packages/irc-core/tests/types.test.ts +35 -1
  119. package/packages/irc-core/tsconfig.build.json +1 -1
  120. package/packages/irc-core/tsconfig.test.json +1 -1
  121. package/packages/irc-server/package.json +1 -1
  122. package/packages/irc-server/src/actor.ts +158 -14
  123. package/packages/irc-server/src/dispatch.ts +0 -3
  124. package/packages/irc-server/src/index.ts +10 -2
  125. package/packages/irc-server/src/routing.ts +12 -0
  126. package/packages/irc-server/src/transport.ts +101 -0
  127. package/packages/irc-server/tests/actor.test.ts +400 -3
  128. package/packages/irc-server/tests/dispatch.test.ts +0 -17
  129. package/packages/irc-server/tests/routing.test.ts +4 -0
  130. package/packages/irc-server/tests/transport.test.ts +230 -0
  131. package/packages/irc-test-support/package.json +1 -1
  132. package/packages/irc-test-support/src/harness.ts +44 -9
  133. package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
  134. package/packages/irc-test-support/src/index.ts +3 -0
  135. package/packages/irc-test-support/src/scenarios.ts +132 -2
  136. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  137. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  138. package/pnpm-workspace.yaml +9 -1
  139. package/tools/ci-hardening/package.json +1 -1
  140. package/tools/package.json +4 -0
  141. package/tools/seed-aws-accounts.ts +5 -6
  142. package/tools/seed-cf-accounts.ts +104 -0
  143. package/tools/tcp-ws-forwarder/package.json +1 -1
  144. package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
  145. package/docs/ADR-002-location-of-authority.md +0 -82
  146. package/docs/ADR-003-durable-object-sharding.md +0 -93
  147. package/docs/ADR-004-dynamodb-schema.md +0 -96
  148. package/docs/ADR-005-wss-only-transport-v1.md +0 -83
  149. package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
  150. package/docs/ADR-007-deterministic-ports.md +0 -82
  151. package/docs/ADR-008-monorepo-tooling.md +0 -60
  152. package/docs/AWS-Adapter-Architecture.md +0 -496
  153. package/docs/AWS-Deployment.md +0 -1186
  154. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  155. package/docs/Home.md +0 -11
  156. package/docs/Observability.md +0 -87
  157. package/docs/PlanIRCv3Websocket.md +0 -489
  158. package/docs/PlanWebClient.md +0 -451
  159. package/docs/Release-Process.md +0 -443
@@ -0,0 +1,275 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { EventEmitter } from 'node:events';
3
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
4
+ import { type Socket, createConnection } from 'node:net';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import {
8
+ type Server,
9
+ type TLSSocket,
10
+ connect as tlsConnect,
11
+ createServer as tlsCreateServer,
12
+ } from 'node:tls';
13
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
14
+ import { type ContainerServer, startContainerServer } from '../src/container-server.js';
15
+
16
+ /**
17
+ * Generates a self-signed TLS certificate for the test proxy using the
18
+ * system `openssl` binary. This mirrors what Cloudflare Spectrum does in
19
+ * production: terminate TLS at the edge with a certificate, then forward
20
+ * plaintext TCP to the origin.
21
+ *
22
+ * If `openssl` is not available, the calling test is skipped — the TLS
23
+ * e2e requires it to synthesise a trust anchor.
24
+ */
25
+ async function generateSelfSignedCert(dir: string): Promise<{ key: string; cert: string }> {
26
+ const keyPath = join(dir, 'key.pem');
27
+ const certPath = join(dir, 'cert.pem');
28
+ try {
29
+ execFileSync(
30
+ 'openssl',
31
+ [
32
+ 'req',
33
+ '-x509',
34
+ '-newkey',
35
+ 'rsa:2048',
36
+ '-keyout',
37
+ keyPath,
38
+ '-out',
39
+ certPath,
40
+ '-days',
41
+ '1',
42
+ '-nodes',
43
+ '-subj',
44
+ '/CN=localhost',
45
+ ],
46
+ { stdio: 'pipe' },
47
+ );
48
+ } catch {
49
+ throw new Error('openssl not available — required to generate test TLS cert');
50
+ }
51
+ const key = await readFile(keyPath, 'utf8');
52
+ const cert = await readFile(certPath, 'utf8');
53
+ return { key, cert };
54
+ }
55
+
56
+ /**
57
+ * IRC test client that speaks TLS (mirrors the Spectrum client path).
58
+ * Connects via `tls.connect`, sends lines with CRLF, parses inbound bytes.
59
+ */
60
+ class TlsIrcClient extends EventEmitter {
61
+ readonly socket: TLSSocket;
62
+ readonly received: string[] = [];
63
+
64
+ constructor(host: string, port: number, opts: { key: string; cert: string }) {
65
+ super();
66
+ this.socket = tlsConnect({
67
+ host,
68
+ port,
69
+ rejectUnauthorized: false,
70
+ ca: opts.cert,
71
+ });
72
+ this.socket.setEncoding('utf8');
73
+ this.socket.on('data', (text: string) => {
74
+ for (const line of text.split(/\r?\n/u)) {
75
+ if (line.length > 0) {
76
+ this.received.push(line);
77
+ this.emit('line', line);
78
+ }
79
+ }
80
+ });
81
+ }
82
+
83
+ secured(): Promise<void> {
84
+ return new Promise((resolve, reject) => {
85
+ if (this.socket.authorized !== undefined) resolve();
86
+ else {
87
+ this.socket.once('secure', () => resolve());
88
+ this.socket.once('error', reject);
89
+ }
90
+ });
91
+ }
92
+
93
+ send(line: string): Promise<void> {
94
+ return new Promise((resolve, reject) => {
95
+ this.socket.write(`${line}\r\n`, (err) => (err ? reject(err) : resolve()));
96
+ });
97
+ }
98
+
99
+ waitFor(predicate: (line: string) => boolean, timeoutMs = 3000): Promise<string> {
100
+ return new Promise((resolve, reject) => {
101
+ const timeout = setTimeout(() => {
102
+ reject(new Error(`timeout; received: ${JSON.stringify(this.received)}`));
103
+ }, timeoutMs);
104
+ const check = (line: string): void => {
105
+ if (predicate(line)) {
106
+ clearTimeout(timeout);
107
+ this.off('line', check);
108
+ resolve(line);
109
+ }
110
+ };
111
+ for (const line of this.received) {
112
+ if (predicate(line)) {
113
+ clearTimeout(timeout);
114
+ resolve(line);
115
+ return;
116
+ }
117
+ }
118
+ this.on('line', check);
119
+ });
120
+ }
121
+
122
+ close(): Promise<void> {
123
+ return new Promise((resolve) => {
124
+ this.socket.once('close', () => resolve());
125
+ this.socket.end();
126
+ });
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Starts an in-process TLS-terminating proxy (the "stunnel" replacement).
132
+ * Each TLS connection from a client gets a plaintext TCP connection piped
133
+ * to the container origin. This is the local analogue of Cloudflare
134
+ * Spectrum: TLS at the edge, plaintext to the origin.
135
+ */
136
+ function startTlsProxy(opts: {
137
+ key: string;
138
+ cert: string;
139
+ originHost: string;
140
+ originPort: number;
141
+ }): Promise<{ server: Server; port: number; close: () => Promise<void> }> {
142
+ return new Promise((resolve, reject) => {
143
+ const originSockets = new Set<Socket>();
144
+ const server = tlsCreateServer({ key: opts.key, cert: opts.cert }, (tlsSocket) => {
145
+ const origin = createConnection({ host: opts.originHost, port: opts.originPort });
146
+ originSockets.add(origin);
147
+ tlsSocket.pipe(origin);
148
+ origin.pipe(tlsSocket);
149
+ const cleanup = (): void => {
150
+ origin.destroy();
151
+ tlsSocket.destroy();
152
+ };
153
+ tlsSocket.on('close', cleanup);
154
+ origin.on('close', () => {
155
+ originSockets.delete(origin);
156
+ tlsSocket.destroy();
157
+ });
158
+ tlsSocket.on('error', cleanup);
159
+ origin.on('error', cleanup);
160
+ });
161
+ server.once('error', reject);
162
+ server.listen(0, '127.0.0.1', () => {
163
+ server.removeListener('error', reject);
164
+ const addr = server.address();
165
+ // c8 ignore next 1
166
+ const port = typeof addr === 'object' && addr !== null ? addr.port : 0;
167
+ resolve({
168
+ server,
169
+ port,
170
+ close: () =>
171
+ new Promise<void>((res) => {
172
+ for (const s of originSockets) s.destroy();
173
+ originSockets.clear();
174
+ server.close(() => res());
175
+ }),
176
+ });
177
+ });
178
+ });
179
+ }
180
+
181
+ describe('container-server — TLS e2e via in-process proxy (Spectrum analogue)', () => {
182
+ let origin: ContainerServer;
183
+ let proxy: { server: Server; port: number; close: () => Promise<void> };
184
+ let dir: string;
185
+ let cert: { key: string; cert: string };
186
+
187
+ beforeAll(async () => {
188
+ dir = await mkdtemp(join(tmpdir(), 'sirc-tls-'));
189
+ try {
190
+ cert = await generateSelfSignedCert(dir);
191
+ } catch (err) {
192
+ // Skip the entire suite if openssl isn't available.
193
+ console.warn(String((err as Error).message));
194
+ return;
195
+ }
196
+ origin = await startContainerServer({
197
+ port: 0,
198
+ host: '127.0.0.1',
199
+ serverName: 'irc.tls.test',
200
+ networkName: 'TlsTestNet',
201
+ });
202
+ proxy = await startTlsProxy({
203
+ key: cert.key,
204
+ cert: cert.cert,
205
+ originHost: '127.0.0.1',
206
+ originPort: origin.port,
207
+ });
208
+ });
209
+
210
+ afterAll(async () => {
211
+ if (proxy !== undefined) await proxy.close();
212
+ if (origin !== undefined) await origin.close();
213
+ await rm(dir, { recursive: true, force: true });
214
+ });
215
+
216
+ it('a TLS client completes register → join → privmsg → quit through the proxy', async () => {
217
+ if (cert === undefined) return; // openssl not available
218
+
219
+ const alice = new TlsIrcClient('127.0.0.1', proxy.port, cert);
220
+ await alice.secured();
221
+ const bob = new TlsIrcClient('127.0.0.1', proxy.port, cert);
222
+ await bob.secured();
223
+
224
+ await alice.send('NICK alice');
225
+ await alice.send('USER alice 0 * :Alice');
226
+ await alice.waitFor((l) => l.startsWith(':irc.tls.test 001 '));
227
+
228
+ await bob.send('NICK bob');
229
+ await bob.send('USER bob 0 * :Bob');
230
+ await bob.waitFor((l) => l.startsWith(':irc.tls.test 001 '));
231
+
232
+ await alice.send('JOIN #tls-e2e');
233
+ await alice.waitFor((l) => l.includes('JOIN #tls-e2e'));
234
+ await bob.send('JOIN #tls-e2e');
235
+ await bob.waitFor((l) => l.includes('JOIN #tls-e2e'));
236
+
237
+ await alice.send('PRIVMSG #tls-e2e :over tls');
238
+ await bob.waitFor((l) => l.includes('PRIVMSG #tls-e2e :over tls'));
239
+
240
+ await alice.close();
241
+ await bob.close();
242
+ });
243
+
244
+ it('routes PING → PONG over TLS', async () => {
245
+ if (cert === undefined) return;
246
+
247
+ const client = new TlsIrcClient('127.0.0.1', proxy.port, cert);
248
+ await client.secured();
249
+ await client.send('NICK tlsPing');
250
+ await client.send('USER tlsPing 0 * :TlsPing');
251
+ await client.waitFor((l) => l.startsWith(':irc.tls.test 001 '));
252
+
253
+ await client.send('PING :tls-tok');
254
+ await client.waitFor((l) => l === 'PONG :tls-tok');
255
+ await client.close();
256
+ });
257
+
258
+ it('the origin only sees plaintext (no TLS handshake bytes reach the actor)', async () => {
259
+ if (cert === undefined) return;
260
+
261
+ // If the origin received TLS handshake bytes, the TcpByteStreamTransport
262
+ // would try to parse them as IRC lines and produce garbage 421 replies.
263
+ // A clean registration proves only plaintext IRC lines arrived.
264
+ const client = new TlsIrcClient('127.0.0.1', proxy.port, cert);
265
+ await client.secured();
266
+ await client.send('NICK plaintext');
267
+ await client.send('USER plaintext 0 * :Plaintext');
268
+ const welcome = await client.waitFor((l) => l.startsWith(':irc.tls.test 001 plaintext'));
269
+ expect(welcome).toBeDefined();
270
+ // No 421 (ERR_UNKNOWNCOMMAND) should appear — that would indicate
271
+ // non-IRC bytes (TLS handshake) leaking through to the parser.
272
+ expect(client.received.some((l) => l.includes(' 421 '))).toBe(false);
273
+ await client.close();
274
+ });
275
+ });
@@ -0,0 +1,17 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "composite": true,
5
+ "rootDir": "./src",
6
+ "outDir": "./dist",
7
+ "tsBuildInfoFile": "./.tsbuildinfo",
8
+ "types": ["node"]
9
+ },
10
+ "include": ["src/**/*.ts"],
11
+ "exclude": ["dist", "tests", "**/*.test.ts", "src/worker.ts", "src/container-do.ts"],
12
+ "references": [
13
+ { "path": "../../packages/irc-core/tsconfig.build.json" },
14
+ { "path": "../../packages/irc-server/tsconfig.build.json" },
15
+ { "path": "../../packages/in-memory-runtime/tsconfig.build.json" }
16
+ ]
17
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "types": ["node"],
6
+ "composite": false
7
+ },
8
+ "include": ["src/**/*.ts", "tests/**/*.ts"],
9
+ "exclude": ["dist", "src/worker.ts", "src/container-do.ts"],
10
+ "references": [
11
+ { "path": "../../packages/irc-core/tsconfig.build.json" },
12
+ { "path": "../../packages/irc-server/tsconfig.build.json" },
13
+ { "path": "../../packages/in-memory-runtime/tsconfig.build.json" }
14
+ ]
15
+ }
@@ -0,0 +1,26 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'node',
6
+ testTimeout: 10_000,
7
+ include: ['tests/**/*.test.ts'],
8
+ coverage: {
9
+ provider: 'v8',
10
+ all: false,
11
+ include: ['src/**/*.ts'],
12
+ // `main.ts` is CLI glue (argv parsing, signal handlers); exercised
13
+ // manually by `pnpm start`. `worker.ts` + `container-do.ts` are
14
+ // wrangler-only deploy artifacts (depend on @cloudflare/containers,
15
+ // compiled by wrangler at deploy time). The rest targets ≥90%.
16
+ exclude: ['src/main.ts', 'src/worker.ts', 'src/container-do.ts'],
17
+ reporter: ['text', 'html', 'json-summary'],
18
+ thresholds: {
19
+ lines: 90,
20
+ functions: 90,
21
+ branches: 80,
22
+ statements: 90,
23
+ },
24
+ },
25
+ },
26
+ });
@@ -0,0 +1,63 @@
1
+ #:vim:set ft=toml:
2
+ ##
3
+ ## Cloudflare Containers config for the ServerlessIRCd TCP origin.
4
+ ##
5
+ ## Deploys the IRC TCP container (apps/cf-tcp-container) to the Cloudflare
6
+ ## Containers platform. The container listens on port 6667 (plaintext TCP);
7
+ ## Cloudflare Spectrum (configured separately in terraform/spectrum.tf)
8
+ ## terminates TLS on :6697 and forwards plaintext to this container.
9
+ ##
10
+ ## Prerequisites:
11
+ ## - Cloudflare account with Containers beta access.
12
+ ## - wrangler v4.112+ (`pnpm add -g wrangler`).
13
+ ## - The container image is built from apps/cf-tcp-container/Dockerfile.
14
+ ##
15
+ ## Deploy:
16
+ ## wrangler deploy --env staging
17
+ ##
18
+
19
+ name = "sirc-tcp-origin"
20
+ main = "src/worker.ts"
21
+ compatibility_date = "2024-11-01"
22
+ compatibility_flags = ["nodejs_compat"]
23
+
24
+ [observability]
25
+ enabled = true
26
+
27
+ # --- Container definition ---
28
+ # The IRC TCP origin container. Listens on port 6667; the Worker below is a
29
+ # thin management/health-check wrapper (Spectrum routes TCP directly to the
30
+ # container origin, not through the Worker).
31
+ [[containers]]
32
+ class_name = "IrcTcpOrigin"
33
+ image = "./Dockerfile"
34
+ instance_type = "standard-1"
35
+ max_instances = 3
36
+ sleep_after = "2h"
37
+
38
+ # --- Durable Object binding for the container ---
39
+ [[durable_objects.bindings]]
40
+ name = "IRC_TCP_ORIGIN"
41
+ class_name = "IrcTcpOrigin"
42
+
43
+ # --- Migration ---
44
+ [[migrations]]
45
+ tag = "v1"
46
+ new_sqlite_classes = ["IrcTcpOrigin"]
47
+
48
+ # --- Environment ---
49
+ [vars]
50
+ SERVER_NAME = "irc.example.com"
51
+ NETWORK_NAME = "ExampleNet"
52
+ MOTD_LINES = "Welcome to the ServerlessIRCd TCP origin."
53
+ TCP_PORT = "6667"
54
+ PERSISTENCE_PATH = "/data/state.json"
55
+
56
+ # --- Staging ---
57
+ [env.staging]
58
+ name = "sirc-tcp-origin-staging"
59
+
60
+ [env.staging.vars]
61
+ SERVER_NAME = "irc-staging.example.com"
62
+ NETWORK_NAME = "StagingNet"
63
+ MOTD_LINES = "Welcome to the staging ServerlessIRCd TCP origin."
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/cf-worker",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "private": true,
5
5
  "description": "Cloudflare Worker deploy glue: WebSocket edge entry point + DO bindings + wrangler pipeline",
6
6
  "license": "BSD-3-Clause",
@@ -32,6 +32,12 @@ class_name = "ChannelDO"
32
32
  name = "CHANNEL_REGISTRY_DO"
33
33
  class_name = "ChannelRegistryDO"
34
34
 
35
+ # D1 backing for persistent SASL accounts
36
+ [[d1_databases]]
37
+ binding = "ACCOUNTS_DB"
38
+ database_name = "cf-worker-test-accounts"
39
+ database_id = "test-d1-worker-accounts"
40
+
35
41
  [[migrations]]
36
42
  tag = "v1"
37
43
  new_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO"]
@@ -50,7 +50,10 @@ invocation_logs = true
50
50
  [vars]
51
51
  SERVER_NAME = "irc.example.com"
52
52
  NETWORK_NAME = "ServerlessIRCd"
53
- MOTD_LINES = "Welcome to ServerlessIRCd.\nThis is a deployed Cloudflare Worker."
53
+ MOTD_LINES = """
54
+ Welcome to ServerlessIRCd.
55
+ This is a deployed Cloudflare Worker.
56
+ """
54
57
 
55
58
  ## ---------------------------------------------------------------------------
56
59
  ## Staging environment.
@@ -68,7 +71,30 @@ head_sampling_rate = 1
68
71
  [env.staging.vars]
69
72
  SERVER_NAME = "irc-staging.example.com"
70
73
  NETWORK_NAME = "ServerlessIRCd (staging)"
71
- MOTD_LINES = "Welcome to ServerlessIRCd staging.\nThis Worker was deployed by CI from the main branch."
74
+ MOTD_LINES = """
75
+ Welcome to ServerlessIRCd staging.
76
+ This is a deployed Cloudflare Worker.
77
+ This Worker was deployed by CI from the main branch.
78
+ """
79
+
80
+ ## ---------------------------------------------------------------------------
81
+ ## D1 database — SASL account persistence.
82
+ ## ---------------------------------------------------------------------------
83
+ ## The `accounts` table stores scrypt-hashed SASL PLAIN credentials (never
84
+ ## plaintext). Loaded at ConnectionDO construction; when empty or unreachable
85
+ ## the resolver falls back to the `SASL_ACCOUNTS` env-var seed. Provision with:
86
+ ## wrangler d1 create serverless-ircd-accounts
87
+ ## wrangler d1 execute serverless-ircd-accounts --command \
88
+ ## "CREATE TABLE IF NOT EXISTS accounts (account TEXT PRIMARY KEY, algorithm TEXT NOT NULL, salt TEXT NOT NULL, hash TEXT NOT NULL)"
89
+ ## Seed accounts via `tools/seed-cf-accounts.ts`.
90
+ [[d1_databases]]
91
+ binding = "ACCOUNTS_DB"
92
+ database_name = "serverless-ircd-accounts"
93
+ # database_id is filled in by `wrangler d1 create` and is env-specific.
94
+
95
+ [[env.staging.d1_databases]]
96
+ binding = "ACCOUNTS_DB"
97
+ database_name = "serverless-ircd-accounts-staging"
72
98
 
73
99
  ## ---------------------------------------------------------------------------
74
100
  ## Durable Object bindings.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/local-cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "private": true,
5
5
  "description": "Runnable WebSocket IRC server using the in-memory runtime; manual-test harness and e2e fixture target",
6
6
  "license": "BSD-3-Clause",
@@ -21,6 +21,16 @@ import { type ParsedServerConfig, parseServerConfig } from '@serverless-ircd/irc
21
21
  export interface CliConfigInput {
22
22
  serverName: string;
23
23
  networkName: string;
24
+ /**
25
+ * Server version surfaced in `002`/`004`/`351`/`371`. When omitted the
26
+ * schema default ({@link DEFAULT_SERVER_VERSION}) applies.
27
+ */
28
+ serverVersion?: string;
29
+ /**
30
+ * "Created" text for `003 RPL_CREATED`. A string is used verbatim; a
31
+ * number is treated as epoch-ms and formatted as a UTC timestamp.
32
+ */
33
+ createdAt?: string | number;
24
34
  motdLines?: string[];
25
35
  channelPrefixes?: string;
26
36
  maxClients?: number;
@@ -35,9 +35,11 @@ import {
35
35
  ConsoleLogger,
36
36
  InMemoryAccountStore,
37
37
  InMemoryMessageStore,
38
+ InMemoryNickHistoryStore,
38
39
  LogLevel,
39
40
  type Logger,
40
41
  type MessageStore,
42
+ type NickHistoryStore,
41
43
  type RawLine,
42
44
  createConnection,
43
45
  } from '@serverless-ircd/irc-core';
@@ -162,6 +164,12 @@ export interface LocalServer {
162
164
  * one client is replayable by another. Exposed for test assertions.
163
165
  */
164
166
  readonly messages: MessageStore;
167
+ /**
168
+ * The per-process nick-history store backing `WHOWAS`. Shared across
169
+ * every connection so a nick signed off by one client is queryable by
170
+ * another. Exposed for test assertions.
171
+ */
172
+ readonly history: NickHistoryStore;
165
173
  close(): Promise<void>;
166
174
  /**
167
175
  * Test-only seam: live server-side WebSockets. Exposed so tests can
@@ -311,6 +319,12 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
311
319
  // is replayable when bob queries CHATHISTORY). The cap is config-driven.
312
320
  const messages = new InMemoryMessageStore(opts.historyMaxPerChannel);
313
321
 
322
+ // Per-process nick-history store: one TTL-buffer shared by every
323
+ // connection so cross-connection WHOWAS works (a nick alice quits is
324
+ // queryable by bob). Driven by the real-system clock so TTL eviction
325
+ // matches wall time.
326
+ const history = new InMemoryNickHistoryStore({ now: () => Date.now() });
327
+
314
328
  // SASL account store: seeded from config so `AUTHENTICATE PLAIN` works
315
329
  // end-to-end. `undefined` (no accounts configured) leaves the actor's
316
330
  // `ctx.accounts` unset, preserving the no-store behaviour.
@@ -336,7 +350,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
336
350
  const admissionRecordId = decision.recordId;
337
351
  runtime.commitAdmission(ip, undefined, admissionRecordId);
338
352
 
339
- const { state, actor } = attachConnection(runtime, cfg, messages, accounts, {
353
+ const { state, actor } = attachConnection(runtime, cfg, messages, accounts, history, {
340
354
  sendText: (text) => {
341
355
  if (ws.readyState === WebSocket.OPEN) ws.send(text);
342
356
  },
@@ -413,7 +427,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
413
427
  const admissionRecordId = decision.recordId;
414
428
  runtime.commitAdmission(ip, undefined, admissionRecordId);
415
429
 
416
- const { state, actor } = attachConnection(runtime, cfg, messages, accounts, {
430
+ const { state, actor } = attachConnection(runtime, cfg, messages, accounts, history, {
417
431
  sendText: (text) => {
418
432
  if (socket.writable) socket.write(text);
419
433
  },
@@ -478,6 +492,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
478
492
  hostname,
479
493
  runtime,
480
494
  messages,
495
+ history,
481
496
  testSockets: wss.clients,
482
497
  testTcpSockets: tcpSockets,
483
498
  close: () =>
@@ -506,6 +521,7 @@ function attachConnection(
506
521
  cfg: ResolvedServerConfig,
507
522
  messages: MessageStore,
508
523
  accounts: AccountStore | undefined,
524
+ history: NickHistoryStore,
509
525
  transport: {
510
526
  sendText: (text: string) => void;
511
527
  closeTransport: () => void;
@@ -549,6 +565,7 @@ function attachConnection(
549
565
  motd: { lines: () => cfg.motdLines },
550
566
  messages,
551
567
  ...(accounts !== undefined ? { accounts } : {}),
568
+ history,
552
569
  logger,
553
570
  });
554
571
 
@@ -641,4 +658,4 @@ function stringifyErr(err: unknown): unknown {
641
658
  }
642
659
 
643
660
  // Suppress "ChanName imported but unused" — kept for type re-export ergonomics.
644
- export type { ChanName, ConnectionState };
661
+ export type { ChanName, ConnectionState, NickHistoryStore };
package/package.json CHANGED
@@ -1,21 +1,23 @@
1
1
  {
2
2
  "name": "serverless-ircd",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "description": "Serverless IRC daemon with a platform-agnostic core and Cloudflare Workers + AWS adapters",
6
6
  "license": "BSD-3-Clause",
7
7
  "engines": {
8
- "node": ">=20"
8
+ "node": ">=24"
9
9
  },
10
10
  "devDependencies": {
11
- "@biomejs/biome": "^1.9.0",
12
- "@vitest/coverage-v8": "^4.1.0",
13
- "fast-check": "^3.19.0",
14
- "rimraf": "^6.0.0",
15
- "turbo": "^2.0.0",
16
- "typescript": "^5.6.0",
17
- "vite": "^7.3.0",
18
- "vitest": "^4.1.0"
11
+ "@biomejs/biome": "^1.9.4",
12
+ "@vitest/coverage-v8": "^4.1.10",
13
+ "fast-check": "^3.23.2",
14
+ "rimraf": "^6.1.3",
15
+ "tsx": "^4.19.0",
16
+ "turbo": "^2.10.7",
17
+ "typescript": "^5.9.3",
18
+ "vite": "^7.3.6",
19
+ "vitest": "^4.1.10",
20
+ "@serverless-ircd/aws-adapter": "0.4.0"
19
21
  },
20
22
  "scripts": {
21
23
  "build": "turbo run build",
@@ -33,6 +35,8 @@
33
35
  "deploy:cf:staging": "pnpm --filter @serverless-ircd/cf-worker deploy:staging",
34
36
  "deploy:cf:prod": "pnpm --filter @serverless-ircd/cf-worker deploy:prod",
35
37
  "smoke:cf:staging": "pnpm --filter @serverless-ircd/cf-worker smoke:staging",
38
+ "deploy:cf-tcp:staging": "pnpm build && pnpm --filter @serverless-ircd/cf-tcp-container deploy:staging",
39
+ "deploy:cf-tcp:prod": "pnpm build && pnpm --filter @serverless-ircd/cf-tcp-container deploy:prod",
36
40
  "deploy:aws:staging": "pnpm build && pnpm --filter @serverless-ircd/aws-stack deploy:staging",
37
41
  "deploy:aws:prod": "pnpm build && pnpm --filter @serverless-ircd/aws-stack deploy:prod",
38
42
  "smoke:aws:staging": "pnpm --filter @serverless-ircd/aws-stack smoke:staging"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/aws-adapter",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "private": true,
5
5
  "description": "AWS Lambda + DynamoDB adapter: AwsRuntime implementing IrcRuntime + $connect/$disconnect/$default handlers",
6
6
  "license": "BSD-3-Clause",
@@ -38,7 +38,7 @@
38
38
  "@serverless-ircd/irc-test-support": "workspace:*",
39
39
  "@types/node": "^26.1.1",
40
40
  "@vitest/coverage-v8": "^4.1.0",
41
- "aws-cdk-lib": "^2.160.0",
41
+ "aws-cdk-lib": "^2.262.0",
42
42
  "aws-sdk-client-mock": "^4.1.0",
43
43
  "constructs": "^10.4.0",
44
44
  "rimraf": "^6.0.0",
@@ -47,6 +47,6 @@
47
47
  "vitest": "^4.1.0"
48
48
  },
49
49
  "engines": {
50
- "node": ">=20"
50
+ "node": ">=24"
51
51
  }
52
52
  }
@@ -28,8 +28,8 @@ import {
28
28
  type ParsedServerConfig,
29
29
  } from '@serverless-ircd/irc-core';
30
30
  import {
31
- type HashedAccountCredential,
32
31
  DynamoAccountStore,
32
+ type HashedAccountCredential,
33
33
  hashAccountCredential,
34
34
  loadDynamoAccountStore,
35
35
  } from './dynamo-account-store.js';