serverless-ircd 0.4.0 → 0.5.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 (124) hide show
  1. package/CHANGELOG.md +175 -0
  2. package/README.md +88 -19
  3. package/apps/aws-stack/README.md +4 -3
  4. package/apps/aws-stack/package.json +1 -1
  5. package/apps/aws-stack/src/aws-stack.ts +32 -4
  6. package/apps/aws-stack/tests/stack.test.ts +47 -1
  7. package/apps/cf-tcp-container/package.json +1 -1
  8. package/apps/cf-worker/package.json +1 -1
  9. package/apps/local-cli/package.json +1 -1
  10. package/apps/local-cli/src/server.ts +129 -21
  11. package/apps/local-cli/tests/e2e.test.ts +1 -1
  12. package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
  13. package/package.json +2 -2
  14. package/packages/aws-adapter/package.json +1 -1
  15. package/packages/aws-adapter/src/aws-runtime.ts +69 -0
  16. package/packages/aws-adapter/src/handlers/connect.ts +36 -5
  17. package/packages/aws-adapter/src/handlers/default.ts +63 -5
  18. package/packages/aws-adapter/src/handlers/index.ts +41 -2
  19. package/packages/aws-adapter/src/handlers/nlb-stream.ts +9 -0
  20. package/packages/aws-adapter/src/index.ts +2 -0
  21. package/packages/aws-adapter/src/serialize.ts +11 -1
  22. package/packages/aws-adapter/src/stats.ts +80 -0
  23. package/packages/aws-adapter/tests/aws-integration.test.ts +1 -1
  24. package/packages/aws-adapter/tests/aws-runtime.test.ts +61 -0
  25. package/packages/aws-adapter/tests/connect.test.ts +97 -1
  26. package/packages/aws-adapter/tests/handlers.test.ts +148 -0
  27. package/packages/aws-adapter/tests/nlb-stream.test.ts +2 -0
  28. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  29. package/packages/cf-adapter/package.json +5 -1
  30. package/packages/cf-adapter/src/cf-runtime.ts +66 -1
  31. package/packages/cf-adapter/src/channel-do.ts +2 -2
  32. package/packages/cf-adapter/src/connection-do.ts +182 -54
  33. package/packages/cf-adapter/src/env.ts +25 -6
  34. package/packages/cf-adapter/src/index.ts +2 -0
  35. package/packages/cf-adapter/src/registry-do.ts +22 -3
  36. package/packages/cf-adapter/src/sharding.ts +1 -2
  37. package/packages/cf-adapter/src/stats.ts +65 -0
  38. package/packages/cf-adapter/tests/cf-harness.ts +1 -1
  39. package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
  40. package/packages/cf-adapter/tests/cf-runtime.test.ts +38 -2
  41. package/packages/cf-adapter/tests/channel-do.test.ts +2 -2
  42. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
  43. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
  44. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  45. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  46. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  47. package/packages/cf-adapter/tests/registry-do.test.ts +4 -4
  48. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  49. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  50. package/packages/cf-adapter/tests/worker/main.ts +7 -7
  51. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  52. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  53. package/packages/cf-adapter/wrangler.test.toml +7 -0
  54. package/packages/in-memory-runtime/package.json +1 -1
  55. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  56. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  57. package/packages/irc-core/package.json +1 -1
  58. package/packages/irc-core/src/admission.ts +16 -15
  59. package/packages/irc-core/src/caps/capabilities.ts +1 -1
  60. package/packages/irc-core/src/commands/index.ts +8 -0
  61. package/packages/irc-core/src/commands/invite.ts +2 -4
  62. package/packages/irc-core/src/commands/isupport.ts +6 -2
  63. package/packages/irc-core/src/commands/kick.ts +2 -4
  64. package/packages/irc-core/src/commands/kill.ts +127 -0
  65. package/packages/irc-core/src/commands/list.ts +1 -1
  66. package/packages/irc-core/src/commands/lusers.ts +204 -0
  67. package/packages/irc-core/src/commands/mode.ts +4 -8
  68. package/packages/irc-core/src/commands/names.ts +3 -5
  69. package/packages/irc-core/src/commands/part.ts +2 -4
  70. package/packages/irc-core/src/commands/rehash.ts +119 -0
  71. package/packages/irc-core/src/commands/setname.ts +109 -0
  72. package/packages/irc-core/src/commands/stats.ts +152 -0
  73. package/packages/irc-core/src/commands/topic.ts +2 -4
  74. package/packages/irc-core/src/commands/trace.ts +137 -0
  75. package/packages/irc-core/src/commands/wallops.ts +118 -0
  76. package/packages/irc-core/src/config.ts +7 -0
  77. package/packages/irc-core/src/effects.ts +27 -1
  78. package/packages/irc-core/src/index.ts +2 -0
  79. package/packages/irc-core/src/ports.ts +179 -0
  80. package/packages/irc-core/src/protocol/numerics.ts +42 -11
  81. package/packages/irc-core/src/protocol/outbound.ts +20 -3
  82. package/packages/irc-core/src/types.ts +8 -1
  83. package/packages/irc-core/src/ws-framing.ts +132 -0
  84. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  85. package/packages/irc-core/tests/admission.test.ts +18 -0
  86. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  87. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  88. package/packages/irc-core/tests/commands/mode.test.ts +57 -0
  89. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  90. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  91. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  92. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  93. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  94. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  95. package/packages/irc-core/tests/effects.test.ts +14 -0
  96. package/packages/irc-core/tests/numerics.test.ts +90 -0
  97. package/packages/irc-core/tests/outbound.test.ts +51 -0
  98. package/packages/irc-core/tests/ports.test.ts +22 -0
  99. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  100. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  101. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  102. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  103. package/packages/irc-server/package.json +1 -1
  104. package/packages/irc-server/src/actor.ts +249 -16
  105. package/packages/irc-server/src/dispatch.ts +1 -0
  106. package/packages/irc-server/src/routing.ts +3 -0
  107. package/packages/irc-server/src/runtime.ts +31 -0
  108. package/packages/irc-server/src/transport.ts +10 -7
  109. package/packages/irc-server/tests/actor.test.ts +1089 -1
  110. package/packages/irc-server/tests/dispatch.test.ts +37 -0
  111. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  112. package/packages/irc-server/tests/routing.test.ts +1 -0
  113. package/packages/irc-server/tests/runtime.test.ts +7 -0
  114. package/packages/irc-test-support/package.json +1 -1
  115. package/packages/irc-test-support/src/scenarios.ts +9 -1
  116. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
  117. package/pnpm-workspace.yaml +1 -0
  118. package/tools/ci-hardening/package.json +1 -1
  119. package/tools/package.json +6 -1
  120. package/tools/seed-cf-accounts.ts +4 -1
  121. package/tools/tcp-ws-forwarder/package.json +1 -1
  122. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  123. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  124. package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
package/CHANGELOG.md CHANGED
@@ -10,6 +10,181 @@ For the release process itself — versioning policy, pre-release checklist,
10
10
  cutting a tag, rolling back — see [`docs/release.md`](docs/release.md).
11
11
  Cross-reference `progress.md` / `tickets.md` for per-ticket detail.
12
12
 
13
+ ## [0.5.0] - 2026-08-02
14
+
15
+ The headline is **protocol completion**: the deferred IRC verbs land
16
+ (Phase 11), and the S2S / obsolete RFC 2812 verbs are **formally
17
+ dropped** rather than left "deferred". With this release every standard
18
+ IRC verb a modern client expects is implemented — the only remaining
19
+ `421 ERR_UNKNOWNCOMMAND` paths are the deliberately-unimplemented S2S
20
+ and obsolete verbs. The roadmap's 0.x protocol surface is complete;
21
+ what remains is the load/compat sweep (10k-connection load test +
22
+ client compatibility matrix) and the required-`serverName` config gate.
23
+
24
+ ### Added — Deferred IRC verbs (`@serverless-ircd/irc-core`, `@serverless-ircd/irc-server`)
25
+
26
+ Six Phase 11 tickets light up the remaining command surface. Each ships
27
+ a pure reducer, an actor route case (no more `421`), new numerics in
28
+ `protocol/numerics.ts`, and unit + actor coverage. (The remaining two
29
+ Phase 11 tickets are drops — see below.)
30
+
31
+ - **`KILL`** (oper-gated force-disconnect): `KILL <nick>
32
+ <comment>` resolves the target via the nick registry + a live
33
+ connection fetch (mirroring WHOIS), checks the oper gate (`481
34
+ ERR_NOPRIVILEGES`), and emits a `Disconnect` effect carrying the
35
+ classic `Killed (<oper> (<comment>))` reason. Peer `QUIT` fanout in
36
+ the target's shared channels happens via the runtime's existing
37
+ `disconnect` path. Missing params → `461`; unknown nick → `401`; an
38
+ oper may `KILL` themselves (RFC-permitted).
39
+ **Platform asymmetry:** on Cloudflare the `disconnect` RPC closes the
40
+ target DO's socket immediately; on AWS a `$default` Lambda invocation
41
+ cannot close a *remote* APIGW WebSocket, so the target's
42
+ live socket only closes on its next `$disconnect` / sweeper cycle —
43
+ the QUIT fanout to peers still fires. The reducer is identical; the
44
+ difference is the bound runtime's `disconnect` semantics.
45
+ - **`REHASH`** (oper-gated config reload): emits `382
46
+ RPL_REHASHING :<source>` and re-fetches `ServerConfig` from the
47
+ adapter's bound source via a new `IrcRuntime.reloadConfig()` port
48
+ method. The refreshed value swaps into the actor's live config
49
+ reference so subsequent commands observe it (rotated oper passwords,
50
+ new MOTD source, etc.). On reload failure the previous config stays
51
+ in effect and a graceful error-suffixed `382` follows — the oper is
52
+ **not** disconnected (a bad store read must never sever a live link).
53
+ Already-open connections keep their negotiated caps and registration;
54
+ transport/TLS reload requires a redeploy.
55
+ - **`LUSERS` + `STATS`** (network statistics): backed by a
56
+ new async `ServerStats` port (`getStats(): Promise<ServerStatsSnapshot>`)
57
+ and an `InMemoryStats` reference impl. `LUSERS` emits the full
58
+ `251`–`255` sequence (only the non-zero counts, per RFC) plus the
59
+ optional `265` / `266` local/global counts. `STATS <query>` supports
60
+ the `u` (uptime, `242 RPL_STATSUPTIME`) and `l` (link info,
61
+ `211 RPL_STATSLINKINFO`) letters; unknown letters get the
62
+ charybdis-style empty body terminated by `219 RPL_ENDOFSTATS`. A
63
+ `<server>` argument that does not match the local server (case-
64
+ insensitive) yields `402 ERR_NOSUCHSERVER` (single-server network —
65
+ S2S aggregation is a PLAN non-goal). The `computeStatsSnapshot`
66
+ helper centralizes the oper/invisible/unknown classification so each
67
+ adapter's backend only has to gather raw counts.
68
+ - **`TRACE`** (routing diagnostics): in a single-server
69
+ serverless IRCd `TRACE` collapses to a local-server line + the
70
+ `262 RPL_ENDOFTRACE` terminator. Per-connection `204
71
+ RPL_TRACEOPERATOR` / `205 RPL_TRACEUSER` detail is **oper-gated** —
72
+ non-opers see only the server line + `262` (no enumeration of `+i`
73
+ users they don't share a channel with). `TRACE <server>` where
74
+ `<server>` is not the local server → `402`.
75
+ - **`WALLOPS`** (oper broadcast): `WALLOPS :<message>` is
76
+ oper-only to send (modern ircd convention — non-oper → `481`);
77
+ delivery fans out to every connection whose `+w` user mode is set via
78
+ a new `BroadcastWallops` effect interpreted by the runtime's
79
+ `broadcastWallops` method (global cross-connection fanout, not
80
+ channel-scoped). The originator is always excluded (skip-self,
81
+ matching NOTICE/PRIVMSG semantics). Empty message → `461`. The `+w`
82
+ user mode was already accepted by `MODE` and advertised in `004
83
+ RPL_MYINFO`; this lights up the consumer.
84
+ - **`SETNAME`** (realname change): `SETNAME :<realname>`
85
+ updates `state.realname` so a subsequent `WHOIS` reflects the new
86
+ value in `311 RPL_WHOISUSER`. No broadcast is emitted in v1 (there
87
+ is no standard IRCv3 cap for relaying a realname change — a
88
+ `draft/setname` relay is a documented follow-up). Empty/missing
89
+ realname → `461`; the registration-time realname length cap applies.
90
+
91
+ ### Changed — S2S & obsolete verbs formally dropped (`@serverless-ircd/irc-core`)
92
+
93
+ The remaining two Phase 11 tickets convert the "deferred" framing into
94
+ an explicit **drop**. There is no behaviour change on the wire — the verbs
95
+ already returned `421 ERR_UNKNOWNCOMMAND` — but the framing, comments,
96
+ and reserved numerics are cleaned up so the codebase no longer carries
97
+ dead scaffolding.
98
+
99
+ - **S2S verbs** (`CONNECT`, `SQUIT`, `LINKS`): dropped as a PLAN
100
+ non-goal (single-server serverless IRCd; no mesh to connect, split,
101
+ or list). The now-unused numerics `RPL_LINKS (364)`,
102
+ `RPL_ENDOFLINKS (365)`, and `ERR_CANTKILLSERVER (483)` are removed
103
+ from `numerics.ts` and its auto-derived `numericToName` reverse map.
104
+ `ERR_NOSUCHSERVER (402)` is retained — `LUSERS` / `STATS` / `TRACE`
105
+ use it for remote-server gating.
106
+ - **Obsolete RFC 2812 verbs** (`SERVICE`, `SUMMON`, `USERS`): dropped
107
+ (never widely implemented; no modern client uses them). The now-unused
108
+ numerics `RPL_YOURESERVICE (383)`, `ERR_NOSUCHSERVICE (408)`,
109
+ `ERR_SUMMONDISABLED (445)`, and `ERR_USERSDISABLED (446)` are removed.
110
+ - The `actor.ts` default-branch triage comment was rewritten: the
111
+ six verbs are documented as **deliberately unimplemented** rather
112
+ than "deferred".
113
+ - Source-level regression guards
114
+ (`packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts`)
115
+ and a behaviour guard in `actor.test.ts` (the verbs still emit
116
+ `421`) pin the drop so the numerics or the "deferred" framing cannot
117
+ creep back in.
118
+
119
+ ### Added — Runtime ports for the new verbs
120
+
121
+ - **`ServerStats` port** (`packages/irc-core/src/ports.ts`): async
122
+ `getStats()` returning `ServerStatsSnapshot` (users / invisible /
123
+ opers / unknown / channels / servers / local+global conn counts /
124
+ uptime anchor). Asynchronous because the aggregation crosses every
125
+ connection and channel — it cannot be pre-loaded into a synchronous
126
+ view the way MOTD or a credential table can. Each adapter implements
127
+ it against its authoritative store; `InMemoryStats` is the reference
128
+ impl. The `computeStatsSnapshot` helper holds the shared
129
+ classification logic (oper / invisible / unknown).
130
+ - **`reloadConfig()` on `IrcRuntime`** (`packages/irc-server/src/runtime.ts`):
131
+ request/response port method the `REHASH` route calls to re-fetch
132
+ `ServerConfig` from the adapter's bound source (CF: KV/secret env;
133
+ AWS: Secrets Manager/SSM env; local-cli: the config file/options).
134
+ Modelled as a runtime method rather than a fire-and-forget effect
135
+ because the refreshed value must come back to the actor.
136
+
137
+ ### ⚠️ Migration required
138
+
139
+ - **For adapter authors wiring the new ports.** `ServerStats` and
140
+ `reloadConfig` are optional at the type level (the actor falls back
141
+ to a zero-count snapshot when no `ServerStats` is bound, and
142
+ `REHASH` still emits `382` even if `reloadConfig` rejects — the
143
+ previous config is retained). To surface real counts and live
144
+ reloads, each adapter wires its own backend. No Durable-Object
145
+ persisted-state schema change (`PERSISTED_STATE_VERSION` stays `1`);
146
+ code rollback across this release is safe.
147
+ - No toolchain change. Node ≥ 24 / pnpm 11 (from v0.4.0) still apply.
148
+
149
+ ### Known limitations
150
+
151
+ - Same as v0.4.0's transport limits: the **CF TCP path requires
152
+ Cloudflare Spectrum (Enterprise tier)** plus a stateful Container
153
+ origin; the **AWS TCP path** uses NLB + Lambda streaming and is
154
+ subject to Lambda idle-timeout / stream-duration limits. The
155
+ WebSocket path remains the zero-extra-deps default.
156
+ - **mTLS (for SASL `EXTERNAL`) requires a custom domain + uploaded
157
+ CA/trust-store** on both platforms and is not enabled by default.
158
+ - `serverName` still defaults to the `irc.example.com` placeholder when
159
+ an adapter config omits it (pending).
160
+ - `STATS` ships the `u` + `l` query letters; the full RFC matrix
161
+ (`k`, `K`, `q`, `g`, `x`, `z`, …) is intentionally out of scope for
162
+ v1 — unknown letters get the charybdis-style empty body + `219`.
163
+ - Load testing (10k concurrent connections) and the client
164
+ compatibility sweep (WeeChat / HexChat / IRCCloud / TheLounge)
165
+ remain pending.
166
+ - **Local coverage instrumentation on the Cloudflare-backed packages
167
+ (`@serverless-ircd/cf-adapter`, `@serverless-ircd/cf-worker`) is
168
+ incompatible with the current vitest toolchain.** `@vitest/coverage-v8`
169
+ v4.1.x imports `node:inspector/promises`, which the
170
+ `@cloudflare/vitest-pool-workers` (workerd) test pool cannot resolve.
171
+ The packages' test suites themselves pass (cf-adapter 185/185,
172
+ cf-worker 3/3); only `pnpm coverage` fails to load the coverage
173
+ module under the workerd pool. This is a documented [vitest-pool-workers
174
+ known issue](https://developers.cloudflare.com/workers/testing/vitest-integration/known-issues/#module-resolution)
175
+ and is expected to resolve with an upstream `@vitest/coverage-v8` or
176
+ pool-workers release. No production code is affected; CI behavior
177
+ depends on its installed versions.
178
+
179
+ ### Security
180
+
181
+ - None. No vulnerabilities have been reported or fixed in this release.
182
+ The dropped S2S / obsolete verbs and their reserved numerics carried
183
+ no reachable code path; their removal reduces surface area but fixes
184
+ no defect.
185
+
186
+ ---
187
+
13
188
  ## [0.4.0] - 2026-07-31
14
189
 
15
190
  The headline is **dual transport**: both adapters now speak a real
package/README.md CHANGED
@@ -6,21 +6,24 @@ platform-agnostic core, and two thin adapters run it on **Cloudflare Workers**
6
6
 
7
7
  One TypeScript codebase. Two serverless substrates.
8
8
 
9
- > **Status:** **v0.4.0 (preview).** The pure protocol core, the
9
+ > **Status:** **v0.5.0 (preview).** The pure protocol core, the
10
10
  > `IrcRuntime` port + in-memory runtime, a runnable local CLI server,
11
11
  > the **Cloudflare Workers** adapter, and the **AWS** (API Gateway
12
12
  > WebSocket + Lambda + DynamoDB + CDK) adapter are all functional and
13
- > deployed to staging. v0.4.0 ships **dual transport**: both adapters
14
- > now speak a real `irc+tls :6697` (TLS-over-TCP) surface for stock IRC
13
+ > deployed to staging. v0.4.0 shipped **dual transport**: both adapters
14
+ > speak a real `irc+tls :6697` (TLS-over-TCP) surface for stock IRC
15
15
  > clients — Cloudflare via Spectrum + a Container origin, AWS via a
16
16
  > Network Load Balancer + Lambda streaming — alongside the WebSocket
17
- > path. It also lands **SASL `EXTERNAL` via mTLS**, the remaining
18
- > standard IRC verbs (`WHOWAS`, `VERSION`, `TIME`, `ADMIN`, `INFO`,
19
- > `USERHOST`, `ISON`), a **Cloudflare D1-backed SASL account store**,
20
- > config-driven server identity, and a **Node 24 / pnpm 11** toolchain.
21
- > Remaining 0.x work: oper-gated verbs (`KILL`/`REHASH`/…), a
22
- > required-`serverName` config gate, and load/compat testing. See
23
- > `CHANGELOG.md` for the per-release manifests.
17
+ > path, plus **SASL `EXTERNAL` via mTLS** and a **Cloudflare D1-backed
18
+ > SASL account store**. v0.5.0 completes the **protocol surface**: the
19
+ > deferred IRC verbs (`KILL`, `REHASH`, `LUSERS` + `STATS`, `TRACE`,
20
+ > `WALLOPS`, `SETNAME`) all land, and the S2S (`CONNECT` / `SQUIT` /
21
+ > `LINKS`) and obsolete RFC 2812 (`SERVICE` / `SUMMON` / `USERS`) verbs
22
+ > are formally dropped every standard verb a modern client expects is
23
+ > now implemented. Remaining 0.x work: the load/compat sweep
24
+ > (10k-connection load test + client compatibility matrix) and a
25
+ > required-`serverName` config gate. See `CHANGELOG.md` for the
26
+ > per-release manifests.
24
27
 
25
28
  ---
26
29
 
@@ -299,6 +302,42 @@ against any line-oriented WebSocket endpoint.
299
302
 
300
303
  ---
301
304
 
305
+ ## WebSocket transport (IRCv3 subprotocols)
306
+
307
+ The WebSocket entry points (the local CLI, the Cloudflare Worker, and the
308
+ AWS API Gateway `$connect` route) implement the
309
+ [IRCv3 WebSocket support](https://ircv3.net/specs/websocket.html) subprotocol
310
+ negotiation. A client advertises support by offering one of two registered
311
+ subprotocols in its opening handshake's `Sec-WebSocket-Protocol` header:
312
+
313
+ | Subprotocol | Frames | Notes |
314
+ |--------------------|----------|--------------------------------------------------|
315
+ | `text.ircv3.net` | UTF-8 text | Lone surrogates → U+FFFD; binary frames rejected (close `1003`). |
316
+ | `binary.ircv3.net` | binary | UTF-8 encoded bytes; same framing as `text`. |
317
+
318
+ The server echoes back the first supported entry in client-preference order
319
+ (in the `101` response's `Sec-WebSocket-Protocol` header). Once negotiated,
320
+ the connection uses **spec framing**:
321
+
322
+ - **One IRC message per WebSocket message** — a frame is never split on an
323
+ embedded line break. A single optional trailing `CR-LF` (or bare `LF`) is
324
+ stripped; the remainder is exactly one IRC line.
325
+ - **No trailing `CR-LF` on the wire**, outbound or inbound.
326
+ - **510-byte message budget** — the 512-byte IRC line limit minus the omitted
327
+ `CR-LF`. A message exceeding 510 bytes is a protocol violation and the
328
+ connection is closed with RFC 6455 code `1009` (Message Too Big).
329
+
330
+ **Legacy fallback.** A client that offers no recognized subprotocol (or none
331
+ at all) is still accepted, in **legacy mode**: frames are split on `\r\n` so
332
+ older clients that concatenate several messages into one frame keep working,
333
+ and the parser's existing 512-byte line cap applies. Nothing breaks until a
334
+ client opts into a subprotocol. The in-tree
335
+ [`tcp-ws-forwarder`](#connecting-a-tcp-irc-client-to-a-deployed-stack-tcpws-forwarder)
336
+ offers `binary.ircv3.net` upstream, so a stock TCP IRC client bridged to a
337
+ deployed stack exercises the spec path end-to-end.
338
+
339
+ ---
340
+
302
341
  ## Testing strategy
303
342
 
304
343
  This project follows strict TDD (Red → Green → Refactor) — every reducer is
@@ -345,9 +384,28 @@ cleanly. Per-release manifests live in `CHANGELOG.md`.
345
384
  ✅ landed in **v0.4.0**: generalized `ConnectionActor` transport seam,
346
385
  Cloudflare Spectrum + Container origin, AWS NLB + Lambda streaming,
347
386
  mTLS → SASL `EXTERNAL`, transport-parametrized contract suite, ADR-009.
348
- - **Phase 7** — Load testing (10k concurrent connections per platform),
349
- client compatibility sweep (WeeChat, HexChat, IRCCloud, TheLounge),
350
- remaining ADRs.
387
+ - **Phase 7** — Load testing (10k concurrent connections per platform)
388
+ and client compatibility sweep (WeeChat, HexChat, IRCCloud, TheLounge).
389
+ pending; the dual-transport surface it needs
390
+ landed in v0.4.0.
391
+ - **Phase 8 — PLAN-FIXES remediation.** ✅ landed across v0.3.0–v0.4.0
392
+ (MessageStore wired into all three adapters, AccountStore end-to-end,
393
+ the DynamoDB `Accounts` table decided, CF enumeration RPCs, AWS QUIT
394
+ fanout, `OPER` / `TAGMSG` / `WHOWAS`, RFC 1459 case-mapping, real
395
+ MOTD, config-driven server identity, AWS max-clients admission,
396
+ CF D1 SASL store, and a sweep of dead-code / stale-comment cleanup).
397
+ - **Phase 9 — Runtime & tooling baseline.** ✅ landed in **v0.4.0**
398
+ (Node ≥ 24 / pnpm 11).
399
+ - **Phase 10 — IRCv3 WebSocket extension.** ✅ landed in **v0.4.0**:
400
+ `text.ircv3.net` / `binary.ircv3.net` subprotocol negotiation +
401
+ per-message framing across the CF Worker, AWS APIGW, local CLI, and
402
+ `tcp-ws-forwarder` (510-byte budget, `1009`/`1003` close codes,
403
+ legacy fallback).
404
+ - **Phase 11 — Deferred IRC verbs.** ✅ landed in **v0.5.0**: `KILL`,
405
+ `REHASH`, `LUSERS` + `STATS`, `TRACE`, `WALLOPS`, `SETNAME`, plus
406
+ the formal drop of the S2S (`CONNECT`/`SQUIT`/`LINKS`) and obsolete
407
+ RFC 2812 (`SERVICE`/`SUMMON`/`USERS`) verbs. The protocol surface is
408
+ feature-complete for modern clients.
351
409
 
352
410
  ---
353
411
 
@@ -356,10 +414,19 @@ cleanly. Per-release manifests live in `CHANGELOG.md`.
356
414
  **Core (RFC 1459/2812 subset):** registration (`NICK`/`USER`/`CAP`/`PASS`),
357
415
  `PING`/`PONG`, `QUIT`, `JOIN`, `PART`, `PRIVMSG`, `NOTICE`, `MODE` (user +
358
416
  channel), `TOPIC`, `KICK`, `INVITE`, `NAMES`, `LIST`, `WHO`, `WHOIS`,
359
- `WHOWAS`, `MOTD`, `AWAY`, `OPER` (credential auth → `o` user mode), plus
360
- the query verbs `VERSION`, `TIME`, `ADMIN`, `INFO`, `USERHOST`, `ISON`.
361
- (Oper-gated verbs `KILL`/`REHASH`/`CONNECT`/`SQUIT`/`TRACE` remain
362
- deferred.)
417
+ `WHOWAS`, `MOTD`, `AWAY`, `OPER` (credential auth → `o` user mode), the
418
+ query verbs `VERSION`, `TIME`, `ADMIN`, `INFO`, `USERHOST`, `ISON`, the
419
+ network-statistics verbs `LUSERS` and `STATS` (`u` uptime + `l` link-info
420
+ letters; unknown letters get the charybdis-style empty body), the
421
+ routing-diagnostic verb `TRACE` (oper-gated per-connection detail), the
422
+ realname-change verb `SETNAME`, and the oper verbs `KILL` (force-
423
+ disconnect), `REHASH` (config reload via `IrcRuntime.reloadConfig()`),
424
+ `WALLOPS` (global broadcast to every `+w` user).
425
+
426
+ (Out of scope: the S2S verbs `CONNECT`/`SQUIT`/`LINKS` and the obsolete
427
+ RFC 2812 verbs `SERVICE`/`SUMMON`/`USERS` are **formally dropped** as of
428
+ v0.5.0 — they return `421 ERR_UNKNOWNCOMMAND`, and their reserved
429
+ numerics have been removed. S2S linking is a PLAN non-goal.)
363
430
 
364
431
  **Channel modes:** `o v b i k l t n m s p`.
365
432
  **User modes:** `i`, `o` (local only), `w`, `s`.
@@ -388,9 +455,11 @@ bridges a stock TCP client to a deployed WebSocket endpoint.
388
455
  - `CHANGELOG.md` — per-release manifests (Keep a Changelog format), including
389
456
  the v0.2.0 work (AWS adapter, SASL, flood control, security hardening, CI
390
457
  gates), v0.3.0 (OPER, TAGMSG, RFC 1459 case-mapping, real MOTD,
391
- ChannelRegistryDO, SASL account persistence), and v0.4.0 (dual transport
458
+ ChannelRegistryDO, SASL account persistence), v0.4.0 (dual transport
392
459
  `irc+tls :6697`, SASL `EXTERNAL`/mTLS, WHOWAS + remaining verbs, D1 account
393
- store, Node 24/pnpm 11).
460
+ store, Node 24/pnpm 11), and v0.5.0 (deferred IRC verbs `KILL` / `REHASH`
461
+ / `LUSERS` + `STATS` / `TRACE` / `WALLOPS` / `SETNAME`, plus the formal
462
+ drop of the S2S and obsolete RFC 2812 verbs).
394
463
  - `docs/Cloudflare-TCP-Deployment.md` and `docs/AWS-TCP-Deployment.md` —
395
464
  end-to-end guides for the `:6697` TCP+TLS variants (Spectrum/Container on
396
465
  CF, NLB + Lambda streaming on AWS), including mTLS trust-store setup.
@@ -8,7 +8,7 @@ least-privilege IAM glue to let the Lambda read/write the tables and post back
8
8
  to connected clients via the management API.
9
9
 
10
10
  The Lambda handler is currently a stub (`{ statusCode: 200 }`); the real
11
- `AwsRuntime` dispatch lands in TICKET-040.
11
+ `AwsRuntime` dispatch lands with the AWS runtime work.
12
12
 
13
13
  ## Prerequisites
14
14
 
@@ -32,7 +32,7 @@ Repo-level shortcuts (from the monorepo root):
32
32
  ```sh
33
33
  pnpm deploy:aws:staging
34
34
  pnpm deploy:aws:prod
35
- pnpm smoke:aws:staging # deferred to TICKET-043
35
+ pnpm smoke:aws:staging # deferred (smoke harness not yet wired)
36
36
  ```
37
37
 
38
38
  ## Optional localstack validation
@@ -70,4 +70,5 @@ LOCALSTACK=1 pnpm test
70
70
  ## Handoff
71
71
 
72
72
  Lambda runtime logic, DynamoDB access patterns, and the `AwsRuntime`
73
- implementation are TICKET-040. The scheduler/sweeper is TICKET-041/042.
73
+ implementation land with the AWS runtime work. The scheduler/sweeper is
74
+ the EventBridge sweeper work.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/aws-stack",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "private": true,
5
5
  "description": "AWS CDK v2 stack: API Gateway v2 WebSocket API, Lambda, DynamoDB tables, least-privilege IAM",
6
6
  "license": "BSD-3-Clause",
@@ -17,7 +17,12 @@ import { resolve } from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
18
18
  import { TABLE_DEFS } from '@serverless-ircd/aws-adapter/cdk-table-defs';
19
19
  import { CfnOutput, Duration, RemovalPolicy, Stack, type StackProps } from 'aws-cdk-lib';
20
- import { WebSocketApi, WebSocketStage } from 'aws-cdk-lib/aws-apigatewayv2';
20
+ import {
21
+ CfnIntegrationResponse,
22
+ WebSocketApi,
23
+ type WebSocketIntegration,
24
+ WebSocketStage,
25
+ } from 'aws-cdk-lib/aws-apigatewayv2';
21
26
  import { WebSocketLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
22
27
  import { Certificate } from 'aws-cdk-lib/aws-certificatemanager';
23
28
  import { Table } from 'aws-cdk-lib/aws-dynamodb';
@@ -191,9 +196,6 @@ export class IrcAwsStack extends Stack {
191
196
  }
192
197
 
193
198
  const webSocketApi = new WebSocketApi(this, 'IrcWsApi', {
194
- connectRouteOptions: {
195
- integration: new WebSocketLambdaIntegration('ConnectIntegration', handler),
196
- },
197
199
  disconnectRouteOptions: {
198
200
  integration: new WebSocketLambdaIntegration('DisconnectIntegration', handler),
199
201
  },
@@ -202,6 +204,32 @@ export class IrcAwsStack extends Stack {
202
204
  },
203
205
  });
204
206
 
207
+ // The `$connect` route is added explicitly (rather than via
208
+ // `connectRouteOptions`) so the negotiated IRCv3 WebSocket subprotocol can
209
+ // be echoed back to the client. API Gateway selects the WebSocket
210
+ // subprotocol from the `$connect` integration response, so the route is
211
+ // configured to return a response and an `IntegrationResponse` maps the
212
+ // `Sec-WebSocket-Protocol` header from the Lambda's reply onto the
213
+ // handshake. The Lambda reads the client's offer, selects the first
214
+ // supported entry, and returns it in its response header (see
215
+ // `handleConnect` / `dispatch`).
216
+ const connectRoute = webSocketApi.addRoute('$connect', {
217
+ integration: new WebSocketLambdaIntegration('ConnectIntegration', handler),
218
+ returnResponse: true,
219
+ });
220
+ const connectIntegration = connectRoute.node.findChild(
221
+ 'ConnectIntegration',
222
+ ) as WebSocketIntegration;
223
+ new CfnIntegrationResponse(this, 'ConnectSubprotocolResponse', {
224
+ apiId: webSocketApi.apiId,
225
+ integrationId: connectIntegration.integrationId,
226
+ integrationResponseKey: '$default',
227
+ responseParameters: {
228
+ 'method.response.header.Sec-WebSocket-Protocol':
229
+ 'integration.response.header.Sec-WebSocket-Protocol',
230
+ },
231
+ });
232
+
205
233
  // The stage name is intentionally NOT suffixed with the environment:
206
234
  // each environment is its own CloudFormation stack (and therefore its
207
235
  // own WebSocket API), so `prod` in every env resolves to a distinct
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Synth-time assertions for the `apps/aws-stack` CDK stack.
3
3
  *
4
- * These are the primary red→green tests for the infrastructure ticket:
4
+ * These are the primary red→green tests for the infrastructure stack:
5
5
  * they assert the exact CloudFormation shape that `cdk synth` must
6
6
  * produce — five DynamoDB tables with the PLAN §4 schema, a single
7
7
  * Node 24 Lambda wired to all three WebSocket routes, the API Gateway
@@ -149,6 +149,52 @@ describe('IrcAwsStack — API Gateway v2 WebSocket API', () => {
149
149
  const { template } = makeTemplate();
150
150
  template.resourceCountIs('AWS::ApiGatewayV2::Route', 3);
151
151
  });
152
+
153
+ it('enables the $connect route to return a response (route response selection)', () => {
154
+ const { template } = makeTemplate();
155
+ template.hasResourceProperties('AWS::ApiGatewayV2::Route', {
156
+ RouteKey: '$connect',
157
+ RouteResponseSelectionExpression: '$default',
158
+ });
159
+ });
160
+
161
+ it('creates a $connect route response so the upgrade can carry a response', () => {
162
+ const { template } = makeTemplate();
163
+ template.hasResourceProperties('AWS::ApiGatewayV2::RouteResponse', {
164
+ RouteResponseKey: '$default',
165
+ });
166
+ });
167
+ });
168
+
169
+ describe('IrcAwsStack — IRCv3 WebSocket subprotocol echo', () => {
170
+ it('attaches an integration response on the $connect route mapping Sec-WebSocket-Protocol', () => {
171
+ const { template } = makeTemplate();
172
+ template.hasResourceProperties('AWS::ApiGatewayV2::IntegrationResponse', {
173
+ ResponseParameters: {
174
+ 'method.response.header.Sec-WebSocket-Protocol':
175
+ 'integration.response.header.Sec-WebSocket-Protocol',
176
+ },
177
+ });
178
+ });
179
+
180
+ it('binds the subprotocol integration response to the $connect integration', () => {
181
+ const { template, stack } = makeTemplate();
182
+ const responses = template.findResources('AWS::ApiGatewayV2::IntegrationResponse');
183
+ // Exactly one integration response is configured (the $connect subprotocol echo).
184
+ expect(Object.keys(responses)).toHaveLength(1);
185
+ // The IntegrationId Ref must resolve to a CfnIntegration under the $connect route.
186
+ const stackName = stack.stackName;
187
+ void stackName;
188
+ const cfnIntegrations = template.findResources('AWS::ApiGatewayV2::Integration');
189
+ expect(Object.keys(cfnIntegrations).length).toBeGreaterThanOrEqual(3);
190
+ });
191
+
192
+ it('uses the $default integration response key', () => {
193
+ const { template } = makeTemplate();
194
+ template.hasResourceProperties('AWS::ApiGatewayV2::IntegrationResponse', {
195
+ IntegrationResponseKey: '$default',
196
+ });
197
+ });
152
198
  });
153
199
 
154
200
  describe('IrcAwsStack — Lambda', () => {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/cf-tcp-container",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "private": true,
5
5
  "description": "Cloudflare Container TCP origin for the irc+tls :6697 transport — Spectrum terminates TLS at the edge, this container runs the IRC core over plaintext TCP",
6
6
  "license": "BSD-3-Clause",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/cf-worker",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/local-cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",