serverless-ircd 0.3.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 (223) 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 +349 -18
  8. package/README.md +132 -30
  9. package/apps/aws-stack/README.md +6 -5
  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 +150 -10
  13. package/apps/aws-stack/tests/stack.test.ts +145 -4
  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 +149 -24
  38. package/apps/local-cli/tests/e2e.test.ts +1 -1
  39. package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
  40. package/package.json +14 -10
  41. package/packages/aws-adapter/package.json +3 -3
  42. package/packages/aws-adapter/src/account-store.ts +1 -1
  43. package/packages/aws-adapter/src/admission.ts +74 -0
  44. package/packages/aws-adapter/src/aws-runtime.ts +75 -4
  45. package/packages/aws-adapter/src/config-loader.ts +32 -0
  46. package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
  47. package/packages/aws-adapter/src/handlers/connect.ts +96 -9
  48. package/packages/aws-adapter/src/handlers/default.ts +98 -7
  49. package/packages/aws-adapter/src/handlers/index.ts +109 -4
  50. package/packages/aws-adapter/src/handlers/nlb-stream.ts +490 -0
  51. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  52. package/packages/aws-adapter/src/index.ts +8 -0
  53. package/packages/aws-adapter/src/serialize.ts +11 -1
  54. package/packages/aws-adapter/src/stats.ts +80 -0
  55. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
  56. package/packages/aws-adapter/tests/account-store.test.ts +19 -20
  57. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  58. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  59. package/packages/aws-adapter/tests/aws-integration.test.ts +1 -1
  60. package/packages/aws-adapter/tests/aws-runtime.test.ts +61 -0
  61. package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
  62. package/packages/aws-adapter/tests/connect.test.ts +174 -0
  63. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
  64. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  65. package/packages/aws-adapter/tests/handlers.test.ts +302 -53
  66. package/packages/aws-adapter/tests/nlb-stream.test.ts +480 -0
  67. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  68. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  69. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  70. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  71. package/packages/cf-adapter/package.json +5 -1
  72. package/packages/cf-adapter/src/cf-runtime.ts +68 -5
  73. package/packages/cf-adapter/src/channel-do.ts +2 -2
  74. package/packages/cf-adapter/src/config-loader.ts +33 -0
  75. package/packages/cf-adapter/src/connection-do.ts +278 -85
  76. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  77. package/packages/cf-adapter/src/env.ts +54 -11
  78. package/packages/cf-adapter/src/index.ts +11 -8
  79. package/packages/cf-adapter/src/registry-do.ts +22 -3
  80. package/packages/cf-adapter/src/sharding.ts +1 -2
  81. package/packages/cf-adapter/src/stats.ts +65 -0
  82. package/packages/cf-adapter/tests/cf-harness.ts +12 -2
  83. package/packages/cf-adapter/tests/cf-integration.test.ts +6 -5
  84. package/packages/cf-adapter/tests/cf-runtime.test.ts +38 -2
  85. package/packages/cf-adapter/tests/channel-do.test.ts +2 -2
  86. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  87. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  88. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  89. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  90. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  91. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  92. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  93. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  94. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  95. package/packages/cf-adapter/tests/registry-do.test.ts +4 -4
  96. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  97. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  98. package/packages/cf-adapter/tests/worker/main.ts +15 -8
  99. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  100. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  101. package/packages/cf-adapter/wrangler.test.toml +15 -0
  102. package/packages/in-memory-runtime/package.json +1 -1
  103. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  104. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  105. package/packages/irc-core/package.json +6 -1
  106. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  107. package/packages/irc-core/src/admission.ts +16 -15
  108. package/packages/irc-core/src/caps/capabilities.ts +24 -3
  109. package/packages/irc-core/src/cloak.ts +1 -1
  110. package/packages/irc-core/src/commands/cap.ts +8 -1
  111. package/packages/irc-core/src/commands/index.ts +17 -0
  112. package/packages/irc-core/src/commands/invite.ts +2 -4
  113. package/packages/irc-core/src/commands/ison.ts +61 -0
  114. package/packages/irc-core/src/commands/isupport.ts +6 -2
  115. package/packages/irc-core/src/commands/kick.ts +2 -4
  116. package/packages/irc-core/src/commands/kill.ts +127 -0
  117. package/packages/irc-core/src/commands/list.ts +1 -1
  118. package/packages/irc-core/src/commands/lusers.ts +204 -0
  119. package/packages/irc-core/src/commands/mode.ts +4 -8
  120. package/packages/irc-core/src/commands/names.ts +3 -5
  121. package/packages/irc-core/src/commands/part.ts +2 -4
  122. package/packages/irc-core/src/commands/quit.ts +12 -0
  123. package/packages/irc-core/src/commands/registration.ts +18 -12
  124. package/packages/irc-core/src/commands/rehash.ts +119 -0
  125. package/packages/irc-core/src/commands/sasl.ts +72 -9
  126. package/packages/irc-core/src/commands/server-info.ts +129 -0
  127. package/packages/irc-core/src/commands/setname.ts +109 -0
  128. package/packages/irc-core/src/commands/stats.ts +152 -0
  129. package/packages/irc-core/src/commands/topic.ts +2 -4
  130. package/packages/irc-core/src/commands/trace.ts +137 -0
  131. package/packages/irc-core/src/commands/userhost.ts +84 -0
  132. package/packages/irc-core/src/commands/wallops.ts +118 -0
  133. package/packages/irc-core/src/commands/whowas.ts +113 -0
  134. package/packages/irc-core/src/config.ts +65 -0
  135. package/packages/irc-core/src/credential-hashing.ts +124 -0
  136. package/packages/irc-core/src/effects.ts +33 -30
  137. package/packages/irc-core/src/index.ts +3 -0
  138. package/packages/irc-core/src/ports.ts +360 -12
  139. package/packages/irc-core/src/protocol/numerics.ts +48 -11
  140. package/packages/irc-core/src/protocol/outbound.ts +20 -3
  141. package/packages/irc-core/src/types.ts +46 -2
  142. package/packages/irc-core/src/ws-framing.ts +132 -0
  143. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  144. package/packages/irc-core/tests/account-store.test.ts +45 -2
  145. package/packages/irc-core/tests/admission.test.ts +18 -0
  146. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  147. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  148. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  149. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  150. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  151. package/packages/irc-core/tests/commands/mode.test.ts +57 -0
  152. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  153. package/packages/irc-core/tests/commands/registration.test.ts +151 -6
  154. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  155. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  156. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  157. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  158. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  159. package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
  160. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  161. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  162. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  163. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  164. package/packages/irc-core/tests/config.test.ts +95 -1
  165. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  166. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  167. package/packages/irc-core/tests/effects.test.ts +14 -27
  168. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  169. package/packages/irc-core/tests/numerics.test.ts +102 -0
  170. package/packages/irc-core/tests/outbound.test.ts +51 -0
  171. package/packages/irc-core/tests/ports.test.ts +22 -0
  172. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  173. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  174. package/packages/irc-core/tests/types.test.ts +35 -1
  175. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  176. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  177. package/packages/irc-core/tsconfig.build.json +1 -1
  178. package/packages/irc-core/tsconfig.test.json +1 -1
  179. package/packages/irc-server/package.json +1 -1
  180. package/packages/irc-server/src/actor.ts +393 -16
  181. package/packages/irc-server/src/dispatch.ts +1 -3
  182. package/packages/irc-server/src/index.ts +10 -2
  183. package/packages/irc-server/src/routing.ts +15 -0
  184. package/packages/irc-server/src/runtime.ts +31 -0
  185. package/packages/irc-server/src/transport.ts +104 -0
  186. package/packages/irc-server/tests/actor.test.ts +1489 -4
  187. package/packages/irc-server/tests/dispatch.test.ts +37 -17
  188. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  189. package/packages/irc-server/tests/routing.test.ts +5 -0
  190. package/packages/irc-server/tests/runtime.test.ts +7 -0
  191. package/packages/irc-server/tests/transport.test.ts +230 -0
  192. package/packages/irc-test-support/package.json +1 -1
  193. package/packages/irc-test-support/src/harness.ts +44 -9
  194. package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
  195. package/packages/irc-test-support/src/index.ts +3 -0
  196. package/packages/irc-test-support/src/scenarios.ts +141 -3
  197. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  198. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  199. package/pnpm-workspace.yaml +10 -1
  200. package/tools/ci-hardening/package.json +1 -1
  201. package/tools/package.json +9 -0
  202. package/tools/seed-aws-accounts.ts +5 -6
  203. package/tools/seed-cf-accounts.ts +107 -0
  204. package/tools/tcp-ws-forwarder/package.json +1 -1
  205. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  206. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  207. package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
  208. package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
  209. package/docs/ADR-002-location-of-authority.md +0 -82
  210. package/docs/ADR-003-durable-object-sharding.md +0 -93
  211. package/docs/ADR-004-dynamodb-schema.md +0 -96
  212. package/docs/ADR-005-wss-only-transport-v1.md +0 -83
  213. package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
  214. package/docs/ADR-007-deterministic-ports.md +0 -82
  215. package/docs/ADR-008-monorepo-tooling.md +0 -60
  216. package/docs/AWS-Adapter-Architecture.md +0 -496
  217. package/docs/AWS-Deployment.md +0 -1186
  218. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  219. package/docs/Home.md +0 -11
  220. package/docs/Observability.md +0 -87
  221. package/docs/PlanIRCv3Websocket.md +0 -489
  222. package/docs/PlanWebClient.md +0 -451
  223. package/docs/Release-Process.md +0 -443
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import type { RawLine } from '../effects.js';
19
+ import { MAX_WS_MESSAGE_BYTES, type WsFrameMode } from '../ws-framing.js';
19
20
 
20
21
  /**
21
22
  * RFC 1459 §2.3: maximum IRC line length in bytes, including the trailing
@@ -65,7 +66,7 @@ export function applyServerTime(line: RawLine, caps: ReadonlySet<string>, now: n
65
66
  }
66
67
 
67
68
  /**
68
- * Enforces the IRC 512-byte line limit on `line.text`.
69
+ * Enforces the IRC line-length budget on `line.text`.
69
70
  *
70
71
  * For recipients that negotiated `message-tags`, the tag section (the
71
72
  * leading `@... ` prefix) is part of the wire budget and is preserved
@@ -73,11 +74,22 @@ export function applyServerTime(line: RawLine, caps: ReadonlySet<string>, now: n
73
74
  * For legacy recipients the entire `line.text` is the body and may be
74
75
  * truncated from the right.
75
76
  *
77
+ * The optional `mode` selects which byte budget applies: an IRCv3
78
+ * WebSocket spec mode (`spec-text` / `spec-binary`) caps the message at
79
+ * {@link MAX_WS_MESSAGE_BYTES} (510) directly, because WebSocket messages
80
+ * carry no trailing CR-LF; legacy mode (the default) caps at
81
+ * `MAX_LINE_BYTES - 2` (512 − 2 = 510). Both resolve to 510 today, but the
82
+ * mode-aware call sites need no edits if either constant ever changes.
83
+ *
76
84
  * The returned {@link RawLine} is the original object when no truncation
77
85
  * was needed, otherwise a fresh object with the truncated text.
78
86
  */
79
- export function enforceLineLimit(line: RawLine, caps: ReadonlySet<string>): RawLine {
80
- const budget = MAX_LINE_BYTES - TRAILING_CRLF_LEN;
87
+ export function enforceLineLimit(
88
+ line: RawLine,
89
+ caps: ReadonlySet<string>,
90
+ mode?: WsFrameMode,
91
+ ): RawLine {
92
+ const budget = isSpecMode(mode) ? MAX_WS_MESSAGE_BYTES : MAX_LINE_BYTES - TRAILING_CRLF_LEN;
81
93
  if (line.text.length <= budget) return line;
82
94
 
83
95
  const hasTags = caps.has('message-tags') && line.text.startsWith('@');
@@ -143,3 +155,8 @@ function clientTagKey(entry: string): string {
143
155
  const eq = entry.indexOf('=');
144
156
  return eq === -1 ? entry : entry.slice(0, eq);
145
157
  }
158
+
159
+ /** True when `mode` is an IRCv3 WebSocket spec framing mode (not legacy/absent). */
160
+ function isSpecMode(mode: WsFrameMode | undefined): boolean {
161
+ return mode === 'spec-text' || mode === 'spec-binary';
162
+ }
@@ -8,7 +8,15 @@
8
8
 
9
9
  import type { Effect } from './effects.js';
10
10
  import type { FloodControlConfig } from './flood-control.js';
11
- import type { AccountStore, Clock, IdFactory, MessageStore, MotdProvider } from './ports.js';
11
+ import type {
12
+ AccountStore,
13
+ Clock,
14
+ IdFactory,
15
+ MessageStore,
16
+ MotdProvider,
17
+ MtlsIdentityProvider,
18
+ NickHistoryStore,
19
+ } from './ports.js';
12
20
  import type { IrcMessage } from './protocol/messages.js';
13
21
  import type { ConnectionState } from './state/connection.js';
14
22
 
@@ -40,12 +48,24 @@ export interface OperCred {
40
48
  * Per-deployment server configuration. Adapters load this from a KV store
41
49
  * (CF) or Secrets Manager / SSM (AWS); the core only reads it.
42
50
  *
43
- * This is the minimum the Phase 1 reducers need; later tickets (SASL,
51
+ * This is the minimum the Phase 1 reducers need; later phases (SASL,
44
52
  * MOTD provider, channel-prefix policy) extend it.
45
53
  */
46
54
  export interface ServerConfig {
47
55
  serverName: string;
48
56
  networkName: string;
57
+ /**
58
+ * Server version surfaced in `002`/`004`/`351`/`371`. When undefined the
59
+ * reducer falls back to {@link DEFAULT_SERVER_VERSION} (the irc-core
60
+ * package version); adapters inject the deploy-time version via config.
61
+ */
62
+ readonly serverVersion?: string | undefined;
63
+ /**
64
+ * "Created" text for `003 RPL_CREATED`. A string is used verbatim; a
65
+ * number is treated as epoch-ms and formatted as a UTC timestamp. When
66
+ * undefined the reducer falls back to {@link DEFAULT_CREATED_TEXT}.
67
+ */
68
+ readonly createdAt?: string | number | undefined;
49
69
  /**
50
70
  * Server password gate. Empty string or undefined disables enforcement.
51
71
  * When set, registration refuses to complete until the connection
@@ -81,6 +101,13 @@ export interface ServerConfig {
81
101
  nickLen: number;
82
102
  channelLen: number;
83
103
  topicLen: number;
104
+ /**
105
+ * Maximum realname (GECOS) length enforced by `SETNAME`. Optional because
106
+ * registration-time `USER` does not enforce a cap; the parsed config always
107
+ * supplies a value (schema default), but inline `ServerConfig` literals in
108
+ * tests may omit it — `SETNAME` then falls back to its built-in default.
109
+ */
110
+ readonly realnameLen?: number | undefined;
84
111
  quitMessage: string;
85
112
  /**
86
113
  * Per-connection message-rate flood control. `undefined` → the actor
@@ -115,6 +142,12 @@ export interface Ctx {
115
142
  readonly motd: MotdProvider;
116
143
  /** SASL account verification source (absent when no accounts are configured). */
117
144
  readonly accounts?: AccountStore;
145
+ /**
146
+ * mTLS client-cert identity source for SASL EXTERNAL (absent when mTLS
147
+ * is not configured). When present, `AUTHENTICATE EXTERNAL` resolves the
148
+ * connection's verified certificate subject through this port.
149
+ */
150
+ readonly mtlsIdentity?: MtlsIdentityProvider;
118
151
  /**
119
152
  * Chat-history persistence source (absent when chathistory playback is
120
153
  * disabled for this deployment). When present, the PRIVMSG/NOTICE channel
@@ -122,6 +155,13 @@ export interface Ctx {
122
155
  * cap-enabled joiners.
123
156
  */
124
157
  readonly messages?: MessageStore;
158
+ /**
159
+ * Nick-history source for the `WHOWAS` command (absent when WHOWAS
160
+ * history retention is disabled for this deployment). When present, the
161
+ * QUIT and post-registration NICK-change reducers record sign-off
162
+ * entries and the WHOWAS reducer replays them.
163
+ */
164
+ readonly history?: NickHistoryStore;
125
165
  /** The connection invoking the command. Reducers mutate this freely. */
126
166
  readonly connection: ConnectionState;
127
167
  /** Convenience: `connection.id`. */
@@ -147,7 +187,9 @@ export interface BuildCtxOptions {
147
187
  ids: IdFactory;
148
188
  motd: MotdProvider;
149
189
  accounts?: AccountStore;
190
+ mtlsIdentity?: MtlsIdentityProvider;
150
191
  messages?: MessageStore;
192
+ history?: NickHistoryStore;
151
193
  connection: ConnectionState;
152
194
  }
153
195
 
@@ -163,7 +205,9 @@ export function buildCtx(opts: BuildCtxOptions): Ctx {
163
205
  connection: opts.connection,
164
206
  connId: opts.connection.id,
165
207
  ...(opts.accounts !== undefined ? { accounts: opts.accounts } : {}),
208
+ ...(opts.mtlsIdentity !== undefined ? { mtlsIdentity: opts.mtlsIdentity } : {}),
166
209
  ...(opts.messages !== undefined ? { messages: opts.messages } : {}),
210
+ ...(opts.history !== undefined ? { history: opts.history } : {}),
167
211
  };
168
212
  return ctx;
169
213
  }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * WebSocket IRC-message framing and the IRCv3 510-byte message budget.
3
+ *
4
+ * The IRCv3 WebSocket spec
5
+ * (https://ircv3.net/specs/websockets.html) changes two things about how a
6
+ * WebSocket text/binary message maps to an IRC message, relative to the
7
+ * legacy framing a raw-TCP-tolerant adapter uses:
8
+ *
9
+ * 1. **One IRC message per WebSocket message.** A frame never carries
10
+ * several `\r\n`-joined messages, so framing MUST NOT split on an
11
+ * embedded line break. A single optional trailing CR-LF (or bare LF)
12
+ * is stripped and the remainder is treated as exactly one IRC line.
13
+ *
14
+ * 2. **No trailing CR-LF on the wire.** The 512-byte IRC line limit
15
+ * therefore applies to the message content only, giving a hard cap of
16
+ * {@link MAX_WS_MESSAGE_BYTES} (510) bytes per WebSocket message.
17
+ *
18
+ * The legacy framing (`WsFrameMode === 'legacy'`) preserves the original
19
+ * adapter behaviour: split the whole frame on `/\r?\n/` so older clients
20
+ * that concatenate messages into one frame keep working. That is the same
21
+ * `splitFrameLines` logic the transport layer shipped before subprotocol
22
+ * negotiation existed.
23
+ *
24
+ * Everything here is pure and platform-agnostic so it runs unchanged on
25
+ * every WebSocket runtime (Node `ws`, Cloudflare Durable Objects, Lambda).
26
+ */
27
+
28
+ import type { WsSubprotocol } from './ws-subprotocol.js';
29
+
30
+ /**
31
+ * How a WebSocket message is framed into IRC lines.
32
+ *
33
+ * - `spec-text` — `text.ircv3.net`: one IRC message per frame, trailing
34
+ * CR-LF stripped, never split.
35
+ * - `spec-binary` — `binary.ircv3.net`: same framing as `spec-text`.
36
+ * - `legacy` — no IRCv3 subprotocol negotiated: split the frame on
37
+ * `/\r?\n/` (the original adapter behaviour).
38
+ */
39
+ export type WsFrameMode = 'spec-text' | 'spec-binary' | 'legacy';
40
+
41
+ /**
42
+ * Maximum number of bytes allowed in a single IRCv3 WebSocket message.
43
+ *
44
+ * The IRC line limit is 512 bytes *including* the trailing CR-LF. WebSocket
45
+ * messages omit the trailing CR-LF, so the content budget is `512 - 2 = 510`.
46
+ * Inbound messages exceeding this MUST be rejected.
47
+ */
48
+ export const MAX_WS_MESSAGE_BYTES = 510;
49
+
50
+ /**
51
+ * Maps a negotiated IRCv3 subprotocol onto the matching {@link WsFrameMode}.
52
+ * `undefined` (no subprotocol) maps to the legacy-tolerant framing.
53
+ */
54
+ export function wsFrameModeFor(subproto: WsSubprotocol | undefined): WsFrameMode {
55
+ if (subproto === 'text.ircv3.net') return 'spec-text';
56
+ if (subproto === 'binary.ircv3.net') return 'spec-binary';
57
+ return 'legacy';
58
+ }
59
+
60
+ /**
61
+ * Strips a single trailing `\r\n` (or bare `\n`) from `s`. Leaves every
62
+ * other character — including leading or embedded terminators — untouched.
63
+ * Used by the spec-mode framing to yield one bare IRC line per frame.
64
+ */
65
+ export function stripTrailingCrLf(s: string): string {
66
+ if (s.endsWith('\r\n')) return s.slice(0, -2);
67
+ if (s.endsWith('\n')) return s.slice(0, -1);
68
+ return s;
69
+ }
70
+
71
+ /**
72
+ * Splits a WebSocket text frame into IRC lines using the legacy-tolerant
73
+ * contract: split the whole frame on `/\r?\n/`, returning every part
74
+ * (including a possible trailing empty string). The empty frame yields no
75
+ * lines.
76
+ *
77
+ * This is the exact behaviour the transport layer shipped before IRCv3
78
+ * subprotocol negotiation; spec-mode framing ({@link frameToLines}) does
79
+ * NOT use it.
80
+ */
81
+ export function splitFrameLines(text: string): string[] {
82
+ if (text.length === 0) return [];
83
+ return text.split(/\r?\n/u);
84
+ }
85
+
86
+ /**
87
+ * Frames `frame` into IRC lines according to `mode`.
88
+ *
89
+ * - `spec-text` / `spec-binary`: one IRC message per frame. A single
90
+ * trailing CR-LF (or bare LF) is stripped and the remainder is returned
91
+ * as the sole element. The frame is never split on an embedded line
92
+ * break. An empty frame (or a frame that is only a terminator) yields
93
+ * no lines.
94
+ * - `legacy`: delegates to {@link splitFrameLines} (split on `/\r?\n/`).
95
+ */
96
+ export function frameToLines(frame: string, mode: WsFrameMode): string[] {
97
+ if (mode === 'legacy') return splitFrameLines(frame);
98
+ const line = stripTrailingCrLf(frame);
99
+ if (line.length === 0) return [];
100
+ return [line];
101
+ }
102
+
103
+ /**
104
+ * Returns `true` when `frame` fits within the IRCv3 WebSocket 510-byte
105
+ * message budget, `false` when it exceeds it and MUST be rejected by the
106
+ * inbound path. Measured in UTF-16 code units, consistent with how
107
+ * {@link enforceLineLimit} and the input parser cap line length across this
108
+ * package (every supported runtime exposes `.length`).
109
+ */
110
+ export function isWithinWsByteBudget(frame: string): boolean {
111
+ return frame.length <= MAX_WS_MESSAGE_BYTES;
112
+ }
113
+
114
+ /**
115
+ * Replaces every unpaired UTF-16 surrogate in `str` with U+FFFD (the
116
+ * Unicode replacement character).
117
+ *
118
+ * `text.ircv3.net` frames are UTF-8 text. A lone surrogate (half of a
119
+ * surrogate pair) has no valid UTF-8 encoding, so per the Unicode
120
+ * recommendation it is substituted with U+FFFD before the frame is handed
121
+ * to the parser. Valid surrogate pairs (supplementary-plane code points)
122
+ * are preserved unchanged, as is all BMP text.
123
+ */
124
+ export function sanitizeForTextMode(str: string): string {
125
+ // A lone high surrogate (0xD800–0xDBFF) NOT followed by a low surrogate,
126
+ // and a lone low surrogate (0xDC00–0xDFFF) NOT preceded by a high
127
+ // surrogate. Both are replaced with U+FFFD.
128
+ return str.replace(
129
+ /(?:[\uD800-\uDBFF](?![\uDC00-\uDFFF]))|(?:(?<![\uD800-\uDBFF])[\uDC00-\uDFFF])/gu,
130
+ '\uFFFD',
131
+ );
132
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * WebSocket subprotocol negotiation for IRC connections (IRCv3).
3
+ *
4
+ * The IRCv3 WebSocket specification defines two subprotocols a client may
5
+ * offer in its opening handshake via the `Sec-WebSocket-Protocol` header:
6
+ *
7
+ * - `text.ircv3.net` — UTF-8 text frames, one IRC message per WebSocket
8
+ * message, no trailing CR-LF.
9
+ * - `binary.ircv3.net` — binary frames, one IRC message per WebSocket
10
+ * message, no trailing CR-LF.
11
+ *
12
+ * The header is a comma-separated list offered in client-preference order.
13
+ * These pure helpers parse the header and select the single subprotocol the
14
+ * server should agree to (the first supported entry, honouring the client's
15
+ * preference). They are dependency-free so they run unchanged on every
16
+ * runtime (Node, Workers, Lambda).
17
+ */
18
+
19
+ /** The IRCv3 binary WebSocket subprotocol. */
20
+ export const WS_SUBPROTO_BINARY = 'binary.ircv3.net';
21
+
22
+ /** The IRCv3 text WebSocket subprotocol. */
23
+ export const WS_SUBPROTO_TEXT = 'text.ircv3.net';
24
+
25
+ /** A supported IRCv3 WebSocket subprotocol name. */
26
+ export type WsSubprotocol = typeof WS_SUBPROTO_TEXT | typeof WS_SUBPROTO_BINARY;
27
+
28
+ /**
29
+ * Parses the `Sec-WebSocket-Protocol` header value into an ordered list of
30
+ * offered protocols (client-preference order). Whitespace around each item is
31
+ * trimmed and empty entries (stray/leading/trailing commas) are dropped.
32
+ * Duplicate entries are preserved in their original order.
33
+ *
34
+ * `null`, `undefined`, or an empty string yield an empty array.
35
+ *
36
+ * @param header The raw `Sec-WebSocket-Protocol` header value, if present.
37
+ */
38
+ export function parseSecWsProtocolOffers(header: string | null | undefined): string[] {
39
+ if (header === null || header === undefined || header === '') {
40
+ return [];
41
+ }
42
+ const offers: string[] = [];
43
+ for (const raw of header.split(',')) {
44
+ const item = raw.trim();
45
+ if (item !== '') {
46
+ offers.push(item);
47
+ }
48
+ }
49
+ return offers;
50
+ }
51
+
52
+ /**
53
+ * Selects the supported subprotocol to agree to from a list of client offers
54
+ * (in client-preference order). Returns the first offer that matches a
55
+ * supported subprotocol, or `null` when none match.
56
+ *
57
+ * @param offers The ordered list of offered protocols.
58
+ */
59
+ export function selectSubprotocol(offers: readonly string[]): WsSubprotocol | null {
60
+ for (const offer of offers) {
61
+ if (offer === WS_SUBPROTO_TEXT || offer === WS_SUBPROTO_BINARY) {
62
+ return offer as WsSubprotocol;
63
+ }
64
+ }
65
+ return null;
66
+ }
@@ -65,9 +65,9 @@ describe('InMemoryAccountStore — PLAIN', () => {
65
65
  });
66
66
 
67
67
  describe('InMemoryAccountStore — mechanism handling', () => {
68
- it('rejects any non-PLAIN mechanism with a failure result', () => {
68
+ it('rejects an unsupported mechanism with a failure result', () => {
69
69
  const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
70
- const out = store.verify('EXTERNAL', { kind: 'RAW', data: 'cert-subject' });
70
+ const out = store.verify('CRAM-MD5', { kind: 'RAW', data: 'whatever' });
71
71
  expect(out.ok).toBe(false);
72
72
  });
73
73
 
@@ -78,6 +78,49 @@ describe('InMemoryAccountStore — mechanism handling', () => {
78
78
  });
79
79
  });
80
80
 
81
+ describe('InMemoryAccountStore — EXTERNAL (mTLS)', () => {
82
+ it('maps a known cert subject to its account', () => {
83
+ const store = new InMemoryAccountStore([
84
+ { username: 'alice', password: 'secret', certSubject: 'CN=alice' },
85
+ ]);
86
+ const out = store.verify('EXTERNAL', { kind: 'EXTERNAL', identity: 'CN=alice' });
87
+ expect(out).toEqual<SaslResult>({ ok: true, account: 'alice' });
88
+ });
89
+
90
+ it('rejects an unknown cert subject', () => {
91
+ const store = new InMemoryAccountStore([
92
+ { username: 'alice', password: 'secret', certSubject: 'CN=alice' },
93
+ ]);
94
+ const out = store.verify('EXTERNAL', { kind: 'EXTERNAL', identity: 'CN=evil' });
95
+ expect(out.ok).toBe(false);
96
+ });
97
+
98
+ it('rejects EXTERNAL when no cert subjects are configured', () => {
99
+ const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
100
+ const out = store.verify('EXTERNAL', { kind: 'EXTERNAL', identity: 'CN=alice' });
101
+ expect(out.ok).toBe(false);
102
+ });
103
+
104
+ it('treats the mechanism name case-insensitively for EXTERNAL', () => {
105
+ const store = new InMemoryAccountStore([
106
+ { username: 'alice', password: 'secret', certSubject: 'CN=alice' },
107
+ ]);
108
+ const out = store.verify('external', { kind: 'EXTERNAL', identity: 'CN=alice' });
109
+ expect(out).toEqual<SaslResult>({ ok: true, account: 'alice' });
110
+ });
111
+
112
+ it('maps multiple accounts with distinct cert subjects', () => {
113
+ const store = new InMemoryAccountStore([
114
+ { username: 'alice', password: 'a', certSubject: 'CN=alice' },
115
+ { username: 'bob', password: 'b', certSubject: 'CN=bob' },
116
+ ]);
117
+ expect(store.verify('EXTERNAL', { kind: 'EXTERNAL', identity: 'CN=bob' })).toEqual<SaslResult>({
118
+ ok: true,
119
+ account: 'bob',
120
+ });
121
+ });
122
+ });
123
+
81
124
  describe('InMemoryAccountStore — failure reason', () => {
82
125
  it('returns a human-readable reason on failure', () => {
83
126
  const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
@@ -211,6 +211,24 @@ describe('AdmissionStats — release', () => {
211
211
  const stats = makeStats(clock);
212
212
  expect(() => stats.release('unknown')).not.toThrow();
213
213
  });
214
+
215
+ it('deletes the per-IP and per-user keys when the last admission is released', () => {
216
+ // Covers the `next <= 0` delete branch in `release`: a single recorded
217
+ // admission, when released, must clear the keys entirely rather than
218
+ // leaving a zero entry in the maps.
219
+ const clock = new FakeClock(1_000);
220
+ const stats = makeStats(clock);
221
+ const d = decideAdmission('5.6.7.8', 'carol', stats, BASE_CONFIG);
222
+ if (!d.ok) throw new Error('precondition: d should admit');
223
+ stats.recordAdmission('5.6.7.8', 'carol', d.recordId);
224
+
225
+ stats.release(d.recordId);
226
+
227
+ // A fresh admission from the same IP/user must be allowed (counts are
228
+ // back to zero because the keys were deleted, not just decremented).
229
+ const after = decideAdmission('5.6.7.8', 'carol', stats, BASE_CONFIG);
230
+ expect(after.ok).toBe(true);
231
+ });
214
232
  });
215
233
 
216
234
  describe('AdmissionStats — determinism', () => {
@@ -31,10 +31,10 @@ describe('SUPPORTED_CAPABILITIES', () => {
31
31
  );
32
32
  });
33
33
 
34
- it('declares the sasl value as the advertised mechanism list', () => {
34
+ it('declares the sasl value as the base mechanism list (PLAIN only by default)', () => {
35
35
  const sasl = SUPPORTED_CAPABILITIES.find((c) => c.name === 'sasl');
36
36
  expect(sasl).toBeDefined();
37
- expect(sasl?.value).toBe('PLAIN,EXTERNAL');
37
+ expect(sasl?.value).toBe('PLAIN');
38
38
  });
39
39
 
40
40
  it('does not attach a value to caps that have no parameter', () => {
@@ -107,7 +107,8 @@ describe('getLsString', () => {
107
107
  });
108
108
 
109
109
  it('contains the sasl token with its value', () => {
110
- expect(getLsString()).toContain('sasl=PLAIN,EXTERNAL');
110
+ expect(getLsString()).toContain('sasl=PLAIN');
111
+ expect(getLsString()).not.toContain('sasl=PLAIN,EXTERNAL');
111
112
  });
112
113
 
113
114
  it('contains server-time as a bare name', () => {
@@ -4,6 +4,7 @@ import { capReducer } from '../../src/commands/cap';
4
4
  import { userReducer } from '../../src/commands/registration';
5
5
  import { Effect } from '../../src/effects';
6
6
  import type { Effect as EffectType, RawLine } from '../../src/effects';
7
+ import type { MtlsIdentityProvider } from '../../src/ports';
7
8
  import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
8
9
  import { type ConnectionState, createConnection } from '../../src/state/connection';
9
10
  import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
@@ -20,13 +21,18 @@ const serverConfig: ServerConfig = {
20
21
  quitMessage: 'Client Quit',
21
22
  };
22
23
 
23
- function makeCtx(state: ConnectionState, cfg: ServerConfig = serverConfig): Ctx {
24
+ function makeCtx(
25
+ state: ConnectionState,
26
+ cfg: ServerConfig = serverConfig,
27
+ mtlsIdentity?: MtlsIdentityProvider,
28
+ ): Ctx {
24
29
  return buildCtx({
25
30
  serverConfig: cfg,
26
31
  clock: new FakeClock(1_000),
27
32
  ids: new SequentialIdFactory(),
28
33
  motd: EmptyMotdProvider,
29
34
  connection: state,
35
+ ...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
30
36
  });
31
37
  }
32
38
 
@@ -110,6 +116,32 @@ describe('capReducer — LS', () => {
110
116
  }
111
117
  });
112
118
 
119
+ it('advertises sasl=PLAIN when no mTLS provider is configured', () => {
120
+ const state = makeState();
121
+ const ctx = makeCtx(state);
122
+ const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
123
+ const send = out.effects[0];
124
+ expect(send).toBeDefined();
125
+ if (send?.tag === 'Send') {
126
+ const body = send.lines[0]?.text ?? '';
127
+ expect(body).toContain('sasl=PLAIN');
128
+ expect(body).not.toContain('sasl=PLAIN,EXTERNAL');
129
+ }
130
+ });
131
+
132
+ it('advertises sasl=PLAIN,EXTERNAL when an mTLS provider is configured', () => {
133
+ const state = makeState();
134
+ const provider: MtlsIdentityProvider = { getIdentity: () => 'CN=alice' };
135
+ const ctx = makeCtx(state, serverConfig, provider);
136
+ const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
137
+ const send = out.effects[0];
138
+ expect(send).toBeDefined();
139
+ if (send?.tag === 'Send') {
140
+ const body = send.lines[0]?.text ?? '';
141
+ expect(body).toContain('sasl=PLAIN,EXTERNAL');
142
+ }
143
+ });
144
+
113
145
  it('marks the connection as in CAP negotiation so the welcome is deferred', () => {
114
146
  const state = makeState();
115
147
  const ctx = makeCtx(state);