serverless-ircd 0.5.0 → 0.6.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 (167) hide show
  1. package/.github/workflows/ci.yml +34 -2
  2. package/.github/workflows/deploy-aws.yml +1 -2
  3. package/.github/workflows/deploy-cf-tcp.yml +1 -5
  4. package/.github/workflows/deploy-cf.yml +1 -2
  5. package/CHANGELOG.md +260 -0
  6. package/README.md +132 -22
  7. package/apps/aws-stack/README.md +33 -0
  8. package/apps/aws-stack/package.json +1 -1
  9. package/apps/aws-stack/src/aws-stack.ts +74 -9
  10. package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
  11. package/apps/aws-stack/tests/stack.test.ts +2 -2
  12. package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
  13. package/apps/aws-stack/tests/vitest.setup.ts +18 -0
  14. package/apps/aws-stack/vitest.config.ts +3 -0
  15. package/apps/cf-tcp-container/package.json +1 -1
  16. package/apps/cf-tcp-container/src/config-loader.ts +6 -7
  17. package/apps/cf-tcp-container/src/container-server.ts +3 -0
  18. package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
  19. package/apps/cf-tcp-container/wrangler.toml +1 -1
  20. package/apps/cf-worker/package.json +2 -1
  21. package/apps/cf-worker/vitest.config.ts +1 -1
  22. package/apps/cf-worker/wrangler.toml +5 -3
  23. package/apps/local-cli/package.json +1 -1
  24. package/apps/local-cli/src/config-loader.ts +6 -6
  25. package/apps/local-cli/src/main.ts +20 -0
  26. package/apps/local-cli/src/server.ts +9 -7
  27. package/apps/local-cli/tests/e2e.test.ts +112 -28
  28. package/apps/local-cli/tests/tcp.test.ts +71 -19
  29. package/apps/local-cli/tests/ws-subprotocol.test.ts +12 -7
  30. package/biome.json +1 -0
  31. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  32. package/docs/ADR-002-location-of-authority.md +82 -0
  33. package/docs/ADR-003-durable-object-sharding.md +110 -0
  34. package/docs/ADR-004-dynamodb-schema.md +96 -0
  35. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  36. package/docs/ADR-006-sasl-mechanism-scope.md +96 -0
  37. package/docs/ADR-007-deterministic-ports.md +82 -0
  38. package/docs/ADR-008-monorepo-tooling.md +60 -0
  39. package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
  40. package/docs/ADR-Index.md +15 -0
  41. package/docs/AWS-Adapter-Architecture.md +496 -0
  42. package/docs/AWS-Deployment.md +1275 -0
  43. package/docs/AWS-TCP-Deployment.md +410 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +719 -0
  45. package/docs/Cloudflare-TCP-Deployment.md +510 -0
  46. package/docs/Home.md +15 -0
  47. package/docs/Observability.md +87 -0
  48. package/docs/PlanExtensions.md +812 -0
  49. package/docs/PlanIRCv3Websocket.md +503 -0
  50. package/docs/PlanWebClient.md +451 -0
  51. package/docs/Release-Process.md +443 -0
  52. package/package.json +2 -2
  53. package/packages/aws-adapter/package.json +1 -1
  54. package/packages/aws-adapter/src/handlers/default.ts +3 -0
  55. package/packages/aws-adapter/src/handlers/nlb-stream.ts +9 -0
  56. package/packages/aws-adapter/src/serialize.ts +29 -1
  57. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
  58. package/packages/aws-adapter/tests/account-store.test.ts +4 -7
  59. package/packages/aws-adapter/tests/aws-harness.ts +5 -2
  60. package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
  61. package/packages/aws-adapter/tests/aws-runtime.test.ts +79 -0
  62. package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
  63. package/packages/aws-adapter/tests/connect.test.ts +9 -9
  64. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
  65. package/packages/aws-adapter/tests/global-setup.ts +55 -30
  66. package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
  67. package/packages/aws-adapter/tests/handlers.test.ts +90 -4
  68. package/packages/aws-adapter/tests/message-store.test.ts +6 -5
  69. package/packages/aws-adapter/tests/nlb-stream.test.ts +60 -8
  70. package/packages/cf-adapter/package.json +2 -1
  71. package/packages/cf-adapter/src/connection-do.ts +3 -0
  72. package/packages/cf-adapter/src/serialize.ts +25 -4
  73. package/packages/cf-adapter/tests/cf-runtime.test.ts +269 -0
  74. package/packages/cf-adapter/tests/channel-do.test.ts +117 -0
  75. package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
  76. package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
  77. package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
  78. package/packages/cf-adapter/tests/registry-do.test.ts +104 -0
  79. package/packages/cf-adapter/tests/serialize.test.ts +308 -0
  80. package/packages/cf-adapter/vitest.config.ts +1 -1
  81. package/packages/in-memory-runtime/package.json +1 -1
  82. package/packages/irc-core/package.json +1 -1
  83. package/packages/irc-core/src/caps/capabilities.ts +37 -3
  84. package/packages/irc-core/src/caps/index.ts +1 -0
  85. package/packages/irc-core/src/caps/sts.ts +84 -0
  86. package/packages/irc-core/src/commands/account-notify.ts +53 -0
  87. package/packages/irc-core/src/commands/away.ts +9 -3
  88. package/packages/irc-core/src/commands/cap.ts +23 -5
  89. package/packages/irc-core/src/commands/chathistory.ts +40 -11
  90. package/packages/irc-core/src/commands/index.ts +34 -0
  91. package/packages/irc-core/src/commands/isupport.ts +53 -0
  92. package/packages/irc-core/src/commands/mode.ts +8 -1
  93. package/packages/irc-core/src/commands/monitor.ts +327 -0
  94. package/packages/irc-core/src/commands/multiline.ts +256 -0
  95. package/packages/irc-core/src/commands/pre-away.ts +112 -0
  96. package/packages/irc-core/src/commands/privmsg.ts +19 -12
  97. package/packages/irc-core/src/commands/read-marker.ts +108 -0
  98. package/packages/irc-core/src/commands/registration.ts +8 -0
  99. package/packages/irc-core/src/commands/sasl.ts +24 -1
  100. package/packages/irc-core/src/commands/tagmsg.ts +71 -1
  101. package/packages/irc-core/src/commands/whois.ts +5 -0
  102. package/packages/irc-core/src/config.ts +65 -10
  103. package/packages/irc-core/src/effects.ts +14 -0
  104. package/packages/irc-core/src/ports.ts +389 -0
  105. package/packages/irc-core/src/protocol/index.ts +14 -0
  106. package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
  107. package/packages/irc-core/src/protocol/numerics.ts +15 -0
  108. package/packages/irc-core/src/protocol/outbound.ts +16 -1
  109. package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
  110. package/packages/irc-core/src/state/connection.ts +32 -1
  111. package/packages/irc-core/src/types.ts +112 -0
  112. package/packages/irc-core/stryker.commands.conf.json +1 -2
  113. package/packages/irc-core/tests/away-store.test.ts +73 -0
  114. package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
  115. package/packages/irc-core/tests/caps/sts.test.ts +123 -0
  116. package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
  117. package/packages/irc-core/tests/commands/away.test.ts +85 -2
  118. package/packages/irc-core/tests/commands/cap.test.ts +165 -0
  119. package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
  120. package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
  121. package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
  122. package/packages/irc-core/tests/commands/mode.test.ts +72 -0
  123. package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
  124. package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
  125. package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
  126. package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
  127. package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
  128. package/packages/irc-core/tests/commands/registration.test.ts +64 -0
  129. package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
  130. package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
  131. package/packages/irc-core/tests/commands/whois.test.ts +34 -0
  132. package/packages/irc-core/tests/config.test.ts +103 -13
  133. package/packages/irc-core/tests/labeled-response.test.ts +181 -0
  134. package/packages/irc-core/tests/message-tags.test.ts +114 -0
  135. package/packages/irc-core/tests/monitor-store.test.ts +200 -0
  136. package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
  137. package/packages/irc-core/tests/standard-replies.test.ts +412 -0
  138. package/packages/irc-core/tests/types.test.ts +27 -0
  139. package/packages/irc-server/package.json +1 -1
  140. package/packages/irc-server/src/actor.ts +450 -3
  141. package/packages/irc-server/src/dispatch.ts +108 -16
  142. package/packages/irc-server/tests/actor.test.ts +1432 -39
  143. package/packages/irc-server/tests/dispatch.test.ts +263 -2
  144. package/packages/irc-test-support/package.json +1 -1
  145. package/packages/irc-test-support/src/index.ts +6 -0
  146. package/packages/irc-test-support/src/test-config.ts +54 -0
  147. package/packages/irc-test-support/tests/test-config.test.ts +51 -0
  148. package/tools/ci-hardening/package.json +1 -1
  149. package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
  150. package/tools/load-test/package.json +33 -0
  151. package/tools/load-test/src/client.ts +351 -0
  152. package/tools/load-test/src/config.ts +313 -0
  153. package/tools/load-test/src/harness.ts +116 -0
  154. package/tools/load-test/src/main.ts +120 -0
  155. package/tools/load-test/src/metrics.ts +168 -0
  156. package/tools/load-test/src/report.ts +106 -0
  157. package/tools/load-test/tests/client.test.ts +212 -0
  158. package/tools/load-test/tests/config.test.ts +152 -0
  159. package/tools/load-test/tests/framing.test.ts +37 -0
  160. package/tools/load-test/tests/harness.test.ts +165 -0
  161. package/tools/load-test/tests/metrics.test.ts +174 -0
  162. package/tools/load-test/tests/report.test.ts +161 -0
  163. package/tools/load-test/tests/smoke.test.ts +67 -0
  164. package/tools/load-test/tsconfig.build.json +12 -0
  165. package/tools/load-test/tsconfig.test.json +10 -0
  166. package/tools/load-test/vitest.config.ts +29 -0
  167. package/tools/tcp-ws-forwarder/package.json +1 -1
@@ -92,7 +92,12 @@ describe('local-cli IRCv3 WebSocket subprotocol', () => {
92
92
  let server: LocalServer;
93
93
 
94
94
  beforeAll(async () => {
95
- server = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
95
+ server = await startLocalServer({
96
+ port: 0,
97
+ hostname: '127.0.0.1',
98
+ serverName: 'irc.local.example.com',
99
+ networkName: 'LocalNet',
100
+ });
96
101
  });
97
102
 
98
103
  afterAll(async () => {
@@ -121,7 +126,7 @@ describe('local-cli IRCv3 WebSocket subprotocol', () => {
121
126
  await client.opened();
122
127
  await client.send('NICK spec-bin');
123
128
  await client.send('USER spec-bin 0 * :Spec Bin');
124
- const welcome = await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
129
+ const welcome = await client.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
125
130
  expect(welcome).toContain('spec-bin');
126
131
 
127
132
  await client.send('PING :bin-tok');
@@ -172,7 +177,7 @@ describe('local-cli IRCv3 WebSocket subprotocol', () => {
172
177
  // One bare line per WS message (no trailing CRLF).
173
178
  await client.send('NICK spec-alice');
174
179
  await client.send('USER spec-alice 0 * :Spec Alice');
175
- const welcome = await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
180
+ const welcome = await client.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
176
181
  expect(welcome).toContain('spec-alice');
177
182
 
178
183
  // Every received message must be a single IRC line with no trailing CRLF
@@ -189,7 +194,7 @@ describe('local-cli IRCv3 WebSocket subprotocol', () => {
189
194
  await client.opened();
190
195
  await client.send('NICK spec-bob');
191
196
  await client.send('USER spec-bob 0 * :Spec Bob');
192
- await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
197
+ await client.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
193
198
 
194
199
  // The welcome sequence is many numerics (001..005, 375/372/376). In spec
195
200
  // mode each arrives as its own WS message; a legacy batch would have
@@ -204,7 +209,7 @@ describe('local-cli IRCv3 WebSocket subprotocol', () => {
204
209
  await client.opened();
205
210
  await client.send('NICK spec-ping');
206
211
  await client.send('USER spec-ping 0 * :Spec Ping');
207
- await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
212
+ await client.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
208
213
 
209
214
  await client.send('PING :spec-tok');
210
215
  const pong = await client.waitFor((l) => l === 'PONG :spec-tok');
@@ -219,7 +224,7 @@ describe('local-cli IRCv3 WebSocket subprotocol', () => {
219
224
  await client.send('USER spec-budget 0 * :Spec Budget');
220
225
  // Drain the entire registration burst (001..376) so delayed numerics
221
226
  // don't pollute the post-overlong-message assertion below.
222
- await client.waitFor((l) => l.startsWith(':irc.example.com 376 '));
227
+ await client.waitFor((l) => l.startsWith(':irc.local.example.com 376 '));
223
228
 
224
229
  client.received.length = 0;
225
230
  // 511-byte payload: `PRIVMSG #x :` (12 bytes) + 499 chars = 511 total.
@@ -244,7 +249,7 @@ describe('local-cli IRCv3 WebSocket subprotocol', () => {
244
249
  await client.opened();
245
250
  await client.send(`NICK ${nick}`);
246
251
  await client.send(`USER ${nick} 0 * :Spec Disc`);
247
- await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
252
+ await client.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
248
253
 
249
254
  const connId = await server.runtime.lookupNick(nick);
250
255
  expect(connId).not.toBeNull();
package/biome.json CHANGED
@@ -10,6 +10,7 @@
10
10
  "ignore": [
11
11
  "**/dist/**",
12
12
  "**/coverage/**",
13
+ "**/coverage-self-test/**",
13
14
  "**/node_modules/**",
14
15
  "**/.turbo/**",
15
16
  "pnpm-lock.yaml",
@@ -0,0 +1,74 @@
1
+ # ADR-001: Pure reducers and the effect system
2
+
3
+ **Date:** 2025-01-15 (Phase 1)
4
+ **Status:** Accepted
5
+ **Supersedes:** —
6
+ **Superseded by:** —
7
+
8
+ ## Context
9
+
10
+ Every IRC daemon must decide where side effects happen. A traditional ircd
11
+ threads mutable state and IO through command handlers — the handler reads a
12
+ channel roster, sends messages, mutates a user record, and returns. That makes
13
+ handlers hard to unit-test (they require a live socket, a real timer, a
14
+ populated data store) and hard to reason about (the handler's observable
15
+ behaviour is inseparable from its IO).
16
+
17
+ ServerlessIRCd targets two cloud adapters (Cloudflare Workers, AWS Lambda)
18
+ that share one protocol core. If the core performed IO directly, each adapter
19
+ would need its own mock surface for every core test — or worse, the core
20
+ would grow adapter-specific code paths.
21
+
22
+ ## Decision
23
+
24
+ Every command handler is a **pure function** with the signature:
25
+
26
+ ```ts
27
+ type Reducer<S> = (state: S, msg: IrcMessage, ctx: Ctx) => { state: S; effects: Effect[] };
28
+ ```
29
+
30
+ - **No IO in the reducer.** No `await`, no `fetch`, no `setTimeout`, no
31
+ `Date.now()`, no `Math.random()`. The reducer reads `ctx.clock.now()` and
32
+ `ctx.ids.nonce()` instead (see ADR-007).
33
+ - **Side effects are values.** The reducer returns an `Effect[]` — a
34
+ discriminated union (`Send`, `Broadcast`, `Disconnect`, `ReserveNick`,
35
+ `ChangeNick`, `ReleaseNick`, `ApplyChannelDelta`, `SendToNick`, …). Each
36
+ effect tag maps one-to-one to an `IrcRuntime` method.
37
+ - **A single `dispatch(effects, runtime)` function** in `irc-server`
38
+ interprets the effects against the bound runtime. Only `dispatch` awaits.
39
+ - **Reducer purity is enforced by convention and testability, not by a
40
+ sandbox.** The type system carries the contract; the 100% coverage gate on
41
+ `irc-core` verifies it empirically.
42
+
43
+ ## Consequences
44
+
45
+ **Positive:**
46
+ - Every reducer is a trivial unit test: arrange state, apply message, assert
47
+ `state` and `effects`. No mocks, no fakes beyond an injected `Clock` and
48
+ `IdFactory`.
49
+ - The same reducer code runs unchanged on in-memory, CF, and AWS runtimes.
50
+ Adapter work is "wire the runtime", not "rewrite the handler".
51
+ - Reducer behaviour is deterministic and reproducible — critical for the
52
+ parametrized contract suite (TICKET-032) that runs identical scenarios
53
+ across all three runtimes.
54
+ - Mutation testing (Stryker, TICKET-048) is meaningful because reducers have
55
+ no hidden IO to mask a mutation's effect.
56
+
57
+ **Negative:**
58
+ - The `Effect` union grows with every new side-effect type. Adding a novel
59
+ effect (e.g. `QueryHistory` for chathistory) requires touching `effects.ts`,
60
+ `dispatch.ts`, and every runtime implementation. This is by design (the
61
+ type system makes missing-runtime-support a compile error) but adds
62
+ ceremony.
63
+ - Reducers cannot directly observe the result of a side effect they emit
64
+ (e.g. "did the broadcast actually reach anyone?"). Cross-effect
65
+ coordination is deferred to the actor layer, which can pre-fetch runtime
66
+ state before invoking the reducer.
67
+
68
+ ## References
69
+
70
+ - PLAN §2.1 — "The key idea: pure reducers + location-of-authority"
71
+ - PLAN §2.2 — `IrcRuntime` port
72
+ - `packages/irc-core/src/effects.ts` — `Effect` union
73
+ - `packages/irc-core/src/types.ts` — `Reducer<S>` signature
74
+ - `packages/irc-server/src/dispatch.ts` — `dispatch(effects, runtime)`
@@ -0,0 +1,82 @@
1
+ # ADR-002: Location-of-authority state split
2
+
3
+ **Date:** 2025-01-15 (Phase 1)
4
+ **Status:** Accepted
5
+ **Supersedes:** —
6
+ **Superseded by:** —
7
+
8
+ ## Context
9
+
10
+ An IRC server manages several kinds of state: connection records (nick, caps,
11
+ away status), channel rosters (who is in `#foo`, with what prefix), channel
12
+ metadata (topic, modes, ban lists), and the nick registry (nick → connection
13
+ map). In a single-process ircd all of this lives in one address space, so
14
+ read-modify-write races are handled with locks or a single event loop.
15
+
16
+ ServerlessIRCd runs on stateless-per-request compute (Lambda) or
17
+ per-entity-isolated compute (Durable Objects). There is no global lock. If
18
+ two connections race to JOIN the same channel, or two clients race to claim
19
+ the same nick, the outcome must still be correct. Traditional locking is
20
+ impossible across instances.
21
+
22
+ ## Decision
23
+
24
+ Each piece of state has **exactly one authority** — the single entity that
25
+ owns the canonical copy and serializes all mutations to it. Reducers are
26
+ routed to the authority that owns the state they mutate:
27
+
28
+ | Command family | Authority | Primary state |
29
+ |---|---|---|
30
+ | Registration (NICK/USER/PASS) | Connection entity | `ConnectionState` |
31
+ | PING/PONG, QUIT, AWAY, user MODE | Connection entity | `ConnectionState` |
32
+ | JOIN/PART/channel MODE/TOPIC/KICK/INVITE | Channel entity | `ChannelState` + roster |
33
+ | PRIVMSG/NOTICE to channel | Channel entity | Fanout (reads roster) |
34
+ | PRIVMSG/NOTICE to user | Sender Connection | Nick → Connection route |
35
+ | Nick reservation | Registry entity | Nick → Connection map |
36
+
37
+ Cross-authority coordination is done via **effects**, not direct mutation.
38
+ When a connection accepts a JOIN, the connection-authority reducer emits an
39
+ `ApplyChannelDelta` effect that the channel authority consumes. The channel
40
+ authority is the sole writer of its roster; the connection never touches it
41
+ directly.
42
+
43
+ Platform mapping:
44
+ - **Cloudflare:** each authority is a Durable Object (`ConnectionDO`,
45
+ `ChannelDO`, `RegistryDO`). DO single-threaded serial execution gives the
46
+ uniqueness invariant for free — two concurrent requests to the same DO are
47
+ queued, not racing.
48
+ - **AWS:** each authority is a DynamoDB table with conditional writes
49
+ (`ConditionExpression`) or `TransactWriteItems` for multi-row atomicity
50
+ (e.g. JOIN spans `Connections` and `ChannelMembers`).
51
+
52
+ ## Consequences
53
+
54
+ **Positive:**
55
+ - Races are structurally impossible, not defended against. Two clients
56
+ claiming the same nick both route to the same `RegistryDO` shard (or the
57
+ same `Nicks` row with a conditional PutItem) — exactly one wins.
58
+ - The reducer signature stays uniform (`Reducer<S>`) regardless of which
59
+ authority it runs in. The actor layer routes; the reducer does not know or
60
+ care whether it is running in a DO or a Lambda.
61
+ - Each authority can be tested in isolation. The channel reducer tests
62
+ arrange a `ChannelState` and assert effects; they never need a live
63
+ connection registry.
64
+
65
+ **Negative:**
66
+ - Cross-authority coordination requires a round-trip (DO `stub()` call on CF;
67
+ extra DynamoDB write on AWS). A JOIN that updates both the connection's
68
+ joined-list and the channel roster is two writes, not one.
69
+ - The actor layer must pre-fetch authoritative state before invoking a
70
+ reducer (e.g. WHOIS needs the target connection's state from a different
71
+ authority). This adds a `prefetchChannelState` step in the actor pipeline.
72
+ - Listing all channels (LIST) or resolving every member of a channel (WHO)
73
+ requires aggregating across authorities — there is no global view. The CF
74
+ adapter needs a channel registry (TICKET-073); the AWS adapter does a
75
+ DynamoDB scan.
76
+
77
+ ## References
78
+
79
+ - PLAN §2.1 — location-of-authority table
80
+ - PLAN §4 — state model & authoritative ownership
81
+ - ADR-001 — pure reducers (how effects flow between authorities)
82
+ - `packages/irc-server/src/actor.ts` — `route()` switch (authority routing)
@@ -0,0 +1,110 @@
1
+ # ADR-003: Cloudflare Durable Object sharding strategy
2
+
3
+ **Date:** 2025-02-01 (Phase 3)
4
+ **Status:** Accepted
5
+ **Supersedes:** —
6
+ **Superseded by:** —
7
+
8
+ ## Context
9
+
10
+ Cloudflare Durable Objects (DOs) are single-threaded per-instance: all
11
+ requests to a given DO instance are serialized. This is ideal for enforcing
12
+ uniqueness invariants (two racing requests land on the same DO, one after the
13
+ other), but a single DO instance is a throughput ceiling. ServerlessIRCd needs
14
+ three DO classes:
15
+
16
+ 1. **ConnectionDO** — one per WebSocket connection. Keyed by `connectionId`.
17
+ No sharding needed: each connection is its own instance.
18
+ 2. **ChannelDO** — one per channel. Keyed by lowercased channel name.
19
+ No sharding needed: one DO per channel is the natural unit; a hot channel
20
+ (1k+ members) is bounded by DO fanout latency, not by DO count.
21
+ 3. **RegistryDO** — holds the global nick → connectionId map. If this were a
22
+ single DO, every nick registration on the entire server would serialize
23
+ through one instance. That is an unacceptable bottleneck for any non-trivial
24
+ deployment.
25
+
26
+ The open question (PLAN §9): *"Channel DO sharding granularity (one DO per
27
+ channel vs. bucketed)?"* and *"Registry DO: how to shard?"*
28
+
29
+ ## Decision
30
+
31
+ **ConnectionDO:** one instance per connection (`idFromName(connectionId)`).
32
+ No sharding.
33
+
34
+ **ChannelDO:** one instance per channel
35
+ (`idFromName(lowercasedChannelName)`). No bucketing. If a hot channel exceeds
36
+ DO throughput limits, the mitigation is fanout batching (one `stub()` call
37
+ carrying multiple lines) rather than splitting the channel across DOs. A
38
+ single channel's roster and modes live in one DO so that all mutations are
39
+ serialized correctly.
40
+
41
+ **RegistryDO:** sharded by `hash(lowercased(nick)) % N`, where `N` defaults
42
+ to **32** (`DEFAULT_REGISTRY_SHARDS`). The hash function is FNV-1a 32-bit,
43
+ chosen for its uniform distribution, deterministic output (no randomness),
44
+ and trivial implementation.
45
+
46
+ ```
47
+ shard = fnv1a32(nick.toLowerCase()) % N
48
+ key = `s${shard}` // e.g. "s7", "s31"
49
+ stub = env.REGISTRY_DO.get(env.REGISTRY_DO.idFromName(key))
50
+ ```
51
+
52
+ Two clients racing on the same nick always land on the same shard (because
53
+ lowercasing collapses case variants), so the single-shard uniqueness check
54
+ covers all case variants. Different nicks spread across shards, so
55
+ registration throughput scales with the fleet size.
56
+
57
+ Cross-shard nick changes (old nick and new nick hash to different shards)
58
+ use a two-phase reserve-new-then-release-old. The new-nick shard wins the
59
+ race; the old-nick shard is released only after the new reservation succeeds.
60
+
61
+ `N` is configurable via the `REGISTRY_SHARDS` environment variable. Changing
62
+ `N` in production requires a migration (re-sharding every existing nick entry
63
+ to its new shard).
64
+
65
+ ## Consequences
66
+
67
+ **Positive:**
68
+ - Nick uniqueness is race-free within the DO model. No conditional writes,
69
+ no optimistic concurrency — the DO's serial execution is the lock.
70
+ - Registration throughput scales horizontally: 32 shards handle 32× the
71
+ single-DO throughput for distinct nicks.
72
+ - One DO per channel keeps channel semantics simple: JOIN/PART/MODE/KICK all
73
+ serialize through the same instance.
74
+
75
+ **Negative:**
76
+ - Changing `N` (the shard count) requires migrating every existing nick to
77
+ its new shard. This is an offline migration, not a live reshard. The shard
78
+ count should be chosen conservatively at deployment time.
79
+ - The channel registry problem: there is no DO that "knows" all channels
80
+ (LIST, TICKET-073). A dedicated registry DO or KV index is needed — this is
81
+ a known gap tracked in TICKET-073.
82
+ - Hot-nick collisions (many nicks hashing to the same shard) are possible but
83
+ statistically rare with FNV-1a and `N = 32` for typical populations.
84
+
85
+ ### Outbound batching scope (TICKET-087)
86
+
87
+ The fanout batching decided above is implemented **per call**:
88
+ `ConnectionDO.deliver()` joins every line of one ChannelDO broadcast into a
89
+ single `WebSocket.send()`, and the actor's `send` handler does the same for
90
+ all lines of one frame's response. A speculative **cross-call** coalescing
91
+ optimization (queueing lines from independent `deliver`/`send` invocations
92
+ within one microtask and flushing them as one write) was reserved as a
93
+ commented-out field in `ConnectionDO` and subsequently **dropped**: it would
94
+ add a drain queue and a `queueMicrotask` scheduler, introduce ordering
95
+ hazards between independent fan-out calls, and yield no measured benefit —
96
+ nothing in the TICKET-049 load-test report or the ChannelDO fan-out path
97
+ shows WS round-trips as a bottleneck. The AWS adapter batches at a different
98
+ layer (per-invocation `outbound: string[]` accumulated then posted once), so
99
+ no shared abstraction is worth extracting. Revisit only if WS round-trips
100
+ become a demonstrated bottleneck.
101
+
102
+ ## References
103
+
104
+ - PLAN §9 — open question: "Channel DO sharding granularity"
105
+ - PLAN §6.1 — `RegistryDO` sharded by `hash(nick) % N`
106
+ - `packages/cf-adapter/src/sharding.ts` — `shardNick`, `registryKeyForNick`,
107
+ `DEFAULT_REGISTRY_SHARDS = 32`
108
+ - `packages/cf-adapter/src/registry-do.ts` — per-shard registry authority
109
+ - TICKET-034 — RegistryDO implementation
110
+ - TICKET-073 — CF runtime stubbed lookups (channel registry gap)
@@ -0,0 +1,96 @@
1
+ # ADR-004: AWS DynamoDB schema
2
+
3
+ **Date:** 2025-02-15 (Phase 4)
4
+ **Status:** Accepted
5
+ **Supersedes:** —
6
+ **Superseded by:** —
7
+
8
+ ## Context
9
+
10
+ The AWS adapter runs IRC state on DynamoDB (single table family, not
11
+ single-table design). Unlike Cloudflare Durable Objects, DynamoDB does not
12
+ serialize access to a record by default — concurrent writes can interleave.
13
+ Nick uniqueness, channel membership atomicity, and gone-connection cleanup
14
+ all require explicit concurrency control.
15
+
16
+ The schema must support: connection records, channel metadata, channel
17
+ membership (roster), nick registry, and SASL account credentials. It must
18
+ also handle the Lambda invocation model: each `$default` invocation is a
19
+ fresh process that reads state, runs a reducer, and writes back — there is no
20
+ in-memory state between invocations.
21
+
22
+ ## Decision
23
+
24
+ Five tables, each with a clear partition-key boundary:
25
+
26
+ | Table | Partition Key | Sort Key | TTL | Purpose |
27
+ |---|---|---|---|---|
28
+ | `Connections` | `connectionId` | — | `idleSince` | Per-connection state: nick, user, host, caps, joined channels, away |
29
+ | `ChannelMeta` | `channelName` | — | — | Topic, modes, key, limit |
30
+ | `ChannelMembers` | `channelName` | `connectionId` | — | Roster: membership + prefix flags (op/voice) |
31
+ | `Nicks` | `nickLower` | — | — | Nick → connectionId registry |
32
+ | `Accounts` | `account` | — | — | SASL credentials (scrypt-hashed, never plaintext) |
33
+
34
+ **Concurrency invariants:**
35
+ - **Nick uniqueness:** enforced via conditional `PutItem`
36
+ (`ConditionExpression: attribute_not_exists(nickLower)`). Two racing
37
+ Puts for the same nick — exactly one succeeds; the other gets a
38
+ `ConditionalCheckFailedException` → `433 ERR_NICKNAMEINUSE`.
39
+ - **Membership atomicity:** `TransactWriteItems` spans `Connections`
40
+ (update `joinedChannels`) and `ChannelMembers` (add/remove member) in a
41
+ single atomic transaction. Concurrent JOIN/PART by the same connection
42
+ cannot leave inconsistent state.
43
+ - **Gone connections:** `ApiGatewayManagementApi.postToConnection` throws
44
+ `GoneException` for a stale connection. The handler catches this, triggers
45
+ lazy cleanup (removes the `Connections` row + `ChannelMembers` rows), and
46
+ broadcasts QUIT to peers (TICKET-074). A sweeper Lambda reaps rows whose
47
+ `idleSince` TTL has expired.
48
+ - **Environment isolation:** physical table names are environment-prefixed
49
+ (`<Env>Connections`, e.g. `StagingConnections`) so staging and production
50
+ can coexist in the same account + region (TICKET-066).
51
+
52
+ **SASL credentials** (`Accounts`): stored as scrypt `HashedAccountCredential`
53
+ rows (`{ account, algorithm, salt, hash }`, base64-encoded). The Lambda
54
+ pre-loads all rows at cold start into an in-memory `DynamoAccountStore`; the
55
+ reducer's `AccountStore.verify()` stays synchronous (reducers are pure — no
56
+ async). Seed tooling (`tools/seed-aws-accounts.ts`) writes hashed rows; never
57
+ plaintext.
58
+
59
+ ## Consequences
60
+
61
+ **Positive:**
62
+ - Clear separation of concerns: each table is one authority (matches
63
+ ADR-002). A channel's metadata, membership, and each member's connection
64
+ record are independently addressable.
65
+ - Conditional writes + `TransactWriteItems` give race-free semantics without
66
+ locks — the natural fit for DynamoDB.
67
+ - `idleSince` TTL auto-reaps idle connections even if the sweeper misses a
68
+ tick; the sweeper is a belt-and-suspenders cleanup, not the only path.
69
+ - Pay-per-request billing means idle tables cost nothing; the schema does not
70
+ over-provision.
71
+
72
+ **Negative:**
73
+ - Five tables (vs. a single-table design) means more `TransactWriteItems`
74
+ spanning multiple partitions. `TransactWriteItems` consumes more RCUs than
75
+ individual writes and has a 100-item-per-transaction cap. Membership
76
+ transactions are 2 items (well within limits), but a future feature that
77
+ touches many channels in one command could approach the cap.
78
+ - No cross-table GSI for "all channels a connection is in" — the
79
+ `Connections` row stores `joinedChannels` as a set, which must be updated
80
+ on every JOIN/PART. This duplicates membership data between `Connections`
81
+ and `ChannelMembers`, kept consistent by the transaction.
82
+ - `Accounts` pre-loading at cold start means every Lambda cold start scans
83
+ the table. For deployments with thousands of accounts, this adds latency.
84
+ Mitigated by Lambda reuse (warm starts skip the scan); a cache layer or
85
+ on-demand lookup is a documented follow-up.
86
+
87
+ ## References
88
+
89
+ - PLAN §4 — state model & authoritative ownership
90
+ - PLAN §6.2 — DynamoDB tables
91
+ - `packages/aws-adapter/src/cdk-table-defs.ts` — `TABLE_DEFS` (source of truth)
92
+ - `packages/aws-adapter/src/aws-runtime.ts` — `AwsRuntime` implementation
93
+ - TICKET-039 — CDK stack
94
+ - TICKET-041 — DynamoDB transactions for membership
95
+ - TICKET-066 — environment-name prefixing
96
+ - TICKET-072 — `Accounts` table scrypt-hashed `AccountStore`
@@ -0,0 +1,83 @@
1
+ # ADR-005: wss-only transport in v1 (no raw TCP)
2
+
3
+ **Date:** 2025-01-15 (Phase 1)
4
+ **Status:** Superseded by ADR-009 (Phase 7 — TICKET-052)
5
+ **Supersedes:** —
6
+
7
+ ## Context
8
+
9
+ IRC is traditionally spoken over raw TCP (port 6667 plaintext, 6697 TLS).
10
+ WebSocket Secure (wss) is a framing layer over TLS that adds an HTTP upgrade
11
+ handshake. Serverless compute platforms have fundamentally different support
12
+ for these two transports:
13
+
14
+ - **Cloudflare Workers** can accept WebSocket upgrades but **cannot accept raw
15
+ TCP**. Spectrum (Enterprise tier) can terminate TLS and proxy to an origin,
16
+ but Workers itself is request-response.
17
+ - **AWS API Gateway WebSocket API** provides persistent WebSocket connections
18
+ with per-message Lambda invocation. Raw TCP requires a Network Load Balancer
19
+ + Lambda streaming (invoke mode) — a fundamentally different integration.
20
+
21
+ The v1 goal is a clean serverless story: one codebase, two adapters, zero
22
+ stateful origin servers. Raw TCP on either platform requires additional,
23
+ non-serverless infrastructure (a long-running container on CF; NLB + Lambda
24
+ streaming on AWS).
25
+
26
+ Additionally, wss carries one IRC message per WebSocket text frame, which
27
+ simplifies framing: the server does not need to implement a stateful
28
+ byte-stream line buffer. The parser still tolerates `\r\n`-joined frames
29
+ (multiple messages in one text frame) for robustness, but the common case is
30
+ one-message-per-frame.
31
+
32
+ ## Decision
33
+
34
+ v1 ships **wss-only**. No plaintext TCP listener, no `irc://` scheme, no
35
+ port 6667/6697. Clients connect via `wss://server.tld/` and speak IRC inside
36
+ WebSocket text frames.
37
+
38
+ Rationale:
39
+ 1. Both target platforms support wss natively without additional
40
+ infrastructure.
41
+ 2. wss is TLS-terminated at the edge (CF) or at API Gateway (AWS) — the
42
+ Lambda/Worker never sees plaintext credentials.
43
+ 3. One-message-per-frame eliminates the need for a TCP-style line-framing
44
+ buffer in the core (though the parser tolerates joined frames).
45
+ 4. The serverless cost/ops model is clean: no long-running origin, no TCP
46
+ keepalive management.
47
+
48
+ The `ConnectionActor` transport seam accepts WebSocket text frames directly.
49
+ A `Transport` interface is not abstracted in v1 — the actor's
50
+ `receiveTextFrame(text)` is the only entry point.
51
+
52
+ ## Consequences
53
+
54
+ **Positive:**
55
+ - Zero infrastructure beyond the serverless platform. No TCP listener, no
56
+ origin server, no port management.
57
+ - TLS everywhere — no accidental plaintext exposure.
58
+ - Simple framing: WebSocket text frames are message-delimited by definition.
59
+ - Lower operational burden for v1.
60
+
61
+ **Negative:**
62
+ - RFC 1459/2812 clients that only speak raw TCP cannot connect. WeeChat and
63
+ HexChat support WebSocket; some older clients and bots do not.
64
+ - The WebSocket overhead (HTTP upgrade, per-frame masking) adds ~2–10 bytes
65
+ per message vs. raw TCP. Negligible for IRC's small message sizes.
66
+ - Some IRCv3 extensions assume a TCP transport (e.g. SASL EXTERNAL via client
67
+ certs over TLS passthrough). Without raw TCP, mTLS must be done at the edge
68
+ (CF API Shield, API Gateway custom domain), which changes the cert
69
+ verification surface.
70
+
71
+ **Superseded:** Phase 7 (TICKET-052) reverses this decision for v1.x: a
72
+ dual-transport model (wss stays the default; `irc+tls` on :6697 is added via
73
+ Cloudflare Spectrum + Container origin, and AWS NLB + Lambda streaming). This
74
+ ADR documents the original v1 rationale; the superseding ADR covers the
75
+ reversal and per-platform TCP+TLS mapping.
76
+
77
+ ## References
78
+
79
+ - PLAN §3 — "No plaintext TCP in v1 (wss only)"
80
+ - PLAN §9 — "Framing: wss-only"
81
+ - TICKET-052 — superseding ADR: dual wss + irc+tls
82
+ - `packages/irc-server/src/actor.ts` — `receiveTextFrame(text)` (single
83
+ transport entry point in v1)
@@ -0,0 +1,96 @@
1
+ # ADR-006: SASL mechanism scope (PLAIN always; EXTERNAL mTLS-conditional)
2
+
3
+ **Date:** 2025-01-20 (Phase 1) · revised 2025-07-27 (TICKET-079)
4
+ **Status:** Superseded (TICKET-079)
5
+ **Supersedes:** —
6
+ **Superseded by:** —
7
+
8
+ ## Context
9
+
10
+ IRCv3 SASL (`CAP sasl`) lets a client authenticate during registration,
11
+ before the welcome sequence. The server advertises supported mechanisms in
12
+ the `sasl` cap value (e.g. `sasl=PLAIN,EXTERNAL`). Each mechanism has
13
+ different infrastructure requirements:
14
+
15
+ - **PLAIN:** the client sends `AUTHENTICATE PLAIN`, then a base64 payload of
16
+ `\0username\0password`. The server verifies the credentials against an
17
+ `AccountStore`. No client certificates, no TLS-passthrough — works over
18
+ any transport, including wss with edge-terminated TLS.
19
+ - **EXTERNAL:** the server authenticates the client based on an external
20
+ identity, typically a client certificate verified via mutual TLS (mTLS).
21
+ This requires the TLS handshake to present the client cert to the
22
+ application layer. On serverless platforms with edge-terminated TLS
23
+ (Cloudflare, API Gateway), the application does not see the raw client
24
+ cert — it must be configured at the edge (API Shield mTLS, custom-domain
25
+ mTLS trust store). That infrastructure does not exist in v1 (ADR-005:
26
+ wss-only, no raw TCP; mTLS passthrough is Phase 7, TICKET-054).
27
+
28
+ ## Decision
29
+
30
+ The server ships **SASL PLAIN** as the always-available mechanism and
31
+ **SASL EXTERNAL** as an mTLS-conditional mechanism:
32
+
33
+ - When **no** `MtlsIdentityProvider` is bound to the connection (the default
34
+ for non-mTLS deployments), the `sasl` cap advertises `PLAIN` only and
35
+ `AUTHENTICATE EXTERNAL` returns `908 ERR_SASLMECHS` listing `PLAIN` as the
36
+ sole available mechanism.
37
+ - When an `MtlsIdentityProvider` **is** bound (the operator configured mTLS
38
+ at the edge — CF API Shield mTLS or AWS custom-domain mTLS), the `sasl` cap
39
+ advertises `PLAIN,EXTERNAL` and `AUTHENTICATE EXTERNAL` proceeds through
40
+ the full handshake: the verified client-cert subject is resolved via the
41
+ provider and verified against the `AccountStore`.
42
+
43
+ This means EXTERNAL advertisement and functionality are driven entirely by
44
+ whether mTLS is configured — operators enable EXTERNAL by configuring edge
45
+ mTLS, with no code change required. The `saslCapValue(hasMtls)` helper in
46
+ `caps/capabilities.ts` and the `supportedMechs(ctx)` helper in
47
+ `commands/sasl.ts` are the single sources of truth for the advertised value.
48
+
49
+ `AccountStore` is a port in `irc-core` consumed by the SASL reducer. The
50
+ `verify(mechanism, payload)` method is **synchronous** — adapters pre-load
51
+ credentials at boot so the reducer stays pure (ADR-001). PLAIN verification
52
+ decodes the base64 payload, extracts username + password, and matches
53
+ against the store. EXTERNAL verification resolves the cert subject via the
54
+ `MtlsIdentityProvider` and matches it against the store.
55
+
56
+ ## Consequences
57
+
58
+ **Positive:**
59
+ - PLAIN works end-to-end with no special infrastructure. The
60
+ `AccountStore` port abstracts the credential store; adapters seed it from
61
+ config (in-memory), DynamoDB (AWS), or a persistent backend (D1/DO for CF,
62
+ TICKET-093).
63
+ - EXTERNAL is fully functional when mTLS is configured (TICKET-054) and
64
+ correctly unadvertised when it is not — clients never waste a round-trip
65
+ on a mechanism that cannot succeed.
66
+ - The mTLS-conditional advertisement is driven by the `MtlsIdentityProvider`
67
+ binding, so operators enable EXTERNAL by configuring edge mTLS with no
68
+ code change.
69
+ - The synchronous `AccountStore.verify()` keeps the reducer pure and
70
+ deterministic. Async credential lookups (DynamoDB) are resolved at cold
71
+ start or via pre-loading, not inside the reducer.
72
+
73
+ **Negative:**
74
+ - Deployments without mTLS (the common case) cannot offer EXTERNAL. This is
75
+ inherent — EXTERNAL requires client-certificate verification at the TLS
76
+ layer, which is only available via edge mTLS configuration.
77
+ - PLAIN sends the password base64-encoded (not encrypted) inside the wss
78
+ frame. wss provides the TLS layer, so the password is encrypted in transit
79
+ — but PLAIN is still weaker than EXTERNAL (no mutual auth).
80
+ - The synchronous `AccountStore` contract means the adapter must pre-load all
81
+ credentials. For deployments with many accounts, cold-start latency grows
82
+ linearly with account count. A lazy/async lookup variant is a documented
83
+ follow-up but would require an async-reducer or effect-based credential
84
+ query — a significant architecture change.
85
+
86
+ ## References
87
+
88
+ - PLAN §3 — "sasl (mechanisms: PLAIN, EXTERNAL reserved for later with mTLS)"
89
+ - PLAN §9 — SASL mechanism scope
90
+ - TICKET-026 — SASL reducer implementation
91
+ - TICKET-071 — AccountStore wiring into ConnectionActor
92
+ - TICKET-072 — AWS DynamoDB AccountStore (scrypt hashing)
93
+ - TICKET-079 — SASL EXTERNAL: implement or stop advertising (interim decision)
94
+ - TICKET-054 — mTLS support (Phase 7, enables functional EXTERNAL)
95
+ - `packages/irc-core/src/commands/sasl.ts` — `authenticateReducer`
96
+ - `packages/irc-core/src/ports.ts` — `AccountStore` port