serverless-ircd 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) 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 +258 -18
  8. package/README.md +72 -30
  9. package/apps/aws-stack/README.md +2 -2
  10. package/apps/aws-stack/bin/aws.ts +7 -0
  11. package/apps/aws-stack/package.json +4 -4
  12. package/apps/aws-stack/src/aws-stack.ts +118 -6
  13. package/apps/aws-stack/tests/stack.test.ts +98 -3
  14. package/apps/cf-tcp-container/Dockerfile +69 -0
  15. package/apps/cf-tcp-container/package.json +34 -0
  16. package/apps/cf-tcp-container/src/config-loader.ts +145 -0
  17. package/apps/cf-tcp-container/src/container-do.ts +38 -0
  18. package/apps/cf-tcp-container/src/container-server.ts +363 -0
  19. package/apps/cf-tcp-container/src/main.ts +77 -0
  20. package/apps/cf-tcp-container/src/persistence.ts +144 -0
  21. package/apps/cf-tcp-container/src/worker.ts +41 -0
  22. package/apps/cf-tcp-container/terraform/provider.tf +24 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
  25. package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
  26. package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
  27. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
  28. package/apps/cf-tcp-container/tsconfig.build.json +17 -0
  29. package/apps/cf-tcp-container/tsconfig.test.json +15 -0
  30. package/apps/cf-tcp-container/vitest.config.ts +26 -0
  31. package/apps/cf-tcp-container/wrangler.toml +63 -0
  32. package/apps/cf-worker/package.json +1 -1
  33. package/apps/cf-worker/scripts/smoke.mjs +0 -0
  34. package/apps/cf-worker/src/worker.ts +8 -2
  35. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  36. package/apps/cf-worker/wrangler.test.toml +11 -1
  37. package/apps/cf-worker/wrangler.toml +69 -19
  38. package/apps/local-cli/package.json +1 -1
  39. package/apps/local-cli/src/config-loader.ts +11 -0
  40. package/apps/local-cli/src/main.ts +1 -1
  41. package/apps/local-cli/src/server.ts +46 -3
  42. package/apps/local-cli/tests/e2e.test.ts +52 -0
  43. package/package.json +19 -16
  44. package/packages/aws-adapter/package.json +3 -3
  45. package/packages/aws-adapter/src/account-store.ts +121 -0
  46. package/packages/aws-adapter/src/admission.ts +74 -0
  47. package/packages/aws-adapter/src/aws-runtime.ts +124 -34
  48. package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
  49. package/packages/aws-adapter/src/config-loader.ts +62 -0
  50. package/packages/aws-adapter/src/dynamo-account-store.ts +95 -0
  51. package/packages/aws-adapter/src/handlers/connect.ts +64 -8
  52. package/packages/aws-adapter/src/handlers/default.ts +43 -2
  53. package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
  54. package/packages/aws-adapter/src/handlers/index.ts +120 -9
  55. package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
  56. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  57. package/packages/aws-adapter/src/index.ts +13 -0
  58. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +182 -0
  59. package/packages/aws-adapter/tests/account-store.test.ts +279 -0
  60. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  61. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  62. package/packages/aws-adapter/tests/config-loader.test.ts +75 -0
  63. package/packages/aws-adapter/tests/connect.test.ts +78 -0
  64. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +343 -0
  65. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  66. package/packages/aws-adapter/tests/handlers.test.ts +194 -47
  67. package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
  68. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  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 +1 -1
  72. package/packages/cf-adapter/src/cf-runtime.ts +40 -22
  73. package/packages/cf-adapter/src/channel-do.ts +40 -1
  74. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  75. package/packages/cf-adapter/src/config-loader.ts +62 -0
  76. package/packages/cf-adapter/src/connection-do.ts +181 -31
  77. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  78. package/packages/cf-adapter/src/env.ts +58 -8
  79. package/packages/cf-adapter/src/index.ts +11 -9
  80. package/packages/cf-adapter/tests/cf-harness.ts +11 -1
  81. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
  82. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  83. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  84. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  85. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  86. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  87. package/packages/cf-adapter/tests/connection-do.test.ts +40 -0
  88. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  89. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  90. package/packages/cf-adapter/tests/worker/main.ts +9 -1
  91. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  92. package/packages/cf-adapter/wrangler.test.toml +18 -1
  93. package/packages/in-memory-runtime/package.json +1 -1
  94. package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
  95. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
  96. package/packages/irc-core/package.json +8 -2
  97. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  98. package/packages/irc-core/src/caps/capabilities.ts +23 -2
  99. package/packages/irc-core/src/case-fold.ts +64 -0
  100. package/packages/irc-core/src/cloak.ts +1 -1
  101. package/packages/irc-core/src/commands/cap.ts +8 -1
  102. package/packages/irc-core/src/commands/index.ts +11 -0
  103. package/packages/irc-core/src/commands/invite.ts +6 -9
  104. package/packages/irc-core/src/commands/ison.ts +61 -0
  105. package/packages/irc-core/src/commands/isupport.ts +1 -1
  106. package/packages/irc-core/src/commands/join.ts +4 -3
  107. package/packages/irc-core/src/commands/kick.ts +4 -11
  108. package/packages/irc-core/src/commands/list.ts +5 -4
  109. package/packages/irc-core/src/commands/mode.ts +5 -9
  110. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  111. package/packages/irc-core/src/commands/motd.ts +6 -110
  112. package/packages/irc-core/src/commands/oper.ts +151 -0
  113. package/packages/irc-core/src/commands/quit.ts +12 -0
  114. package/packages/irc-core/src/commands/registration.ts +26 -19
  115. package/packages/irc-core/src/commands/sasl.ts +72 -9
  116. package/packages/irc-core/src/commands/server-info.ts +129 -0
  117. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  118. package/packages/irc-core/src/commands/userhost.ts +84 -0
  119. package/packages/irc-core/src/commands/whowas.ts +113 -0
  120. package/packages/irc-core/src/config.ts +84 -3
  121. package/packages/irc-core/src/credential-hashing.ts +124 -0
  122. package/packages/irc-core/src/effects.ts +6 -29
  123. package/packages/irc-core/src/index.ts +3 -0
  124. package/packages/irc-core/src/ports.ts +240 -7
  125. package/packages/irc-core/src/protocol/numerics.ts +14 -8
  126. package/packages/irc-core/src/types.ts +60 -1
  127. package/packages/irc-core/tests/account-store.test.ts +131 -0
  128. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  129. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  130. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  131. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  132. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  133. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  134. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  135. package/packages/irc-core/tests/commands/registration.test.ts +256 -19
  136. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  137. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  138. package/packages/irc-core/tests/commands/tagmsg.test.ts +662 -0
  139. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  140. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  141. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  142. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  143. package/packages/irc-core/tests/config.test.ts +170 -1
  144. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  145. package/packages/irc-core/tests/effects.test.ts +0 -27
  146. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  147. package/packages/irc-core/tests/numerics.test.ts +12 -0
  148. package/packages/irc-core/tests/types.test.ts +35 -1
  149. package/packages/irc-core/tsconfig.build.json +1 -1
  150. package/packages/irc-core/tsconfig.test.json +1 -1
  151. package/packages/irc-server/package.json +1 -1
  152. package/packages/irc-server/src/actor.ts +182 -14
  153. package/packages/irc-server/src/dispatch.ts +0 -3
  154. package/packages/irc-server/src/index.ts +10 -2
  155. package/packages/irc-server/src/routing.ts +21 -0
  156. package/packages/irc-server/src/transport.ts +101 -0
  157. package/packages/irc-server/tests/actor.test.ts +617 -1
  158. package/packages/irc-server/tests/dispatch.test.ts +0 -17
  159. package/packages/irc-server/tests/routing.test.ts +7 -0
  160. package/packages/irc-server/tests/transport.test.ts +230 -0
  161. package/packages/irc-test-support/package.json +1 -1
  162. package/packages/irc-test-support/src/harness.ts +44 -9
  163. package/packages/irc-test-support/src/in-memory-harness.ts +78 -13
  164. package/packages/irc-test-support/src/index.ts +3 -0
  165. package/packages/irc-test-support/src/scenarios.ts +132 -2
  166. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  167. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  168. package/pnpm-workspace.yaml +9 -0
  169. package/tools/ci-hardening/package.json +1 -1
  170. package/tools/package.json +4 -0
  171. package/tools/seed-aws-accounts.ts +79 -0
  172. package/tools/seed-cf-accounts.ts +104 -0
  173. package/tools/tcp-ws-forwarder/package.json +1 -1
  174. package/AGENTS.md +0 -5
  175. package/MOTD.txt +0 -3
  176. package/PLAN-FIXES.md +0 -358
  177. package/PLAN.md +0 -420
  178. package/dashboards/cloudwatch-irc.json +0 -139
  179. package/docs/AWS-Adapter-Architecture.md +0 -494
  180. package/docs/AWS-Deployment.md +0 -1107
  181. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  182. package/docs/Home.md +0 -1
  183. package/docs/Observability.md +0 -87
  184. package/docs/PlanIRCv3Websocket.md +0 -489
  185. package/docs/PlanWebClient.md +0 -451
  186. package/docs/Release-Process.md +0 -440
  187. package/progress.md +0 -107
  188. package/tickets.md +0 -2485
  189. package/webircgateway/LICENSE +0 -201
  190. package/webircgateway/Makefile +0 -44
  191. package/webircgateway/README.md +0 -134
  192. package/webircgateway/config.conf.example +0 -135
  193. package/webircgateway/go.mod +0 -16
  194. package/webircgateway/go.sum +0 -89
  195. package/webircgateway/main.go +0 -118
  196. package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
  197. package/webircgateway/pkg/identd/identd.go +0 -86
  198. package/webircgateway/pkg/identd/rpcclient.go +0 -59
  199. package/webircgateway/pkg/irc/isupport.go +0 -56
  200. package/webircgateway/pkg/irc/message.go +0 -217
  201. package/webircgateway/pkg/irc/state.go +0 -79
  202. package/webircgateway/pkg/proxy/proxy.go +0 -129
  203. package/webircgateway/pkg/proxy/server.go +0 -237
  204. package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
  205. package/webircgateway/pkg/webircgateway/client.go +0 -741
  206. package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
  207. package/webircgateway/pkg/webircgateway/config.go +0 -385
  208. package/webircgateway/pkg/webircgateway/gateway.go +0 -278
  209. package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
  210. package/webircgateway/pkg/webircgateway/hooks.go +0 -152
  211. package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
  212. package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
  213. package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
  214. package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
  215. package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
  216. package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
  217. package/webircgateway/pkg/webircgateway/utils.go +0 -147
  218. package/webircgateway/plugins/example/plugin.go +0 -11
  219. package/webircgateway/plugins/stats/plugin.go +0 -52
  220. package/webircgateway/staticcheck.conf +0 -1
  221. package/webircgateway/webircgateway.svg +0 -3
@@ -26,9 +26,11 @@
26
26
  */
27
27
 
28
28
  import {
29
+ type AccountStore,
29
30
  type ChanName,
30
31
  type ChannelState,
31
32
  type Clock,
33
+ type ConnSnapshot,
32
34
  type ConnectionState,
33
35
  Effect,
34
36
  type Effect as EffectType,
@@ -38,6 +40,8 @@ import {
38
40
  type Logger,
39
41
  type MessageStore,
40
42
  type MotdProvider,
43
+ type MtlsIdentityProvider,
44
+ type NickHistoryStore,
41
45
  NoopLoggerInstance,
42
46
  Numerics,
43
47
  type RawLine,
@@ -46,15 +50,20 @@ import {
46
50
  chathistoryReducer,
47
51
  handleJoinZero,
48
52
  isChannelTarget,
53
+ isonReducer,
49
54
  listReducer,
50
55
  parse,
56
+ toSnapshot,
57
+ userhostReducer,
51
58
  whoReducer,
52
59
  whoisReducer,
60
+ whowasReducer,
53
61
  } from '@serverless-ircd/irc-core';
54
62
  import { dispatch } from './dispatch.js';
55
63
  import type { DispatchOptions } from './dispatch.js';
56
64
  import { type RoutedReducers, buildRoutedReducers } from './routing.js';
57
65
  import type { IrcRuntime } from './runtime.js';
66
+ import { type Transport, WsTextFrameTransport } from './transport.js';
58
67
 
59
68
  /**
60
69
  * Channel-state access for the actor.
@@ -103,6 +112,32 @@ export interface ConnectionActorOptions {
103
112
  * opt in.
104
113
  */
105
114
  readonly messages?: MessageStore;
115
+ /**
116
+ * SASL account verification source (absent when no accounts are
117
+ * configured). When bound, `ctx.accounts` is defined so the
118
+ * `AUTHENTICATE` reducer can verify PLAIN credentials end-to-end.
119
+ * Omitted (→ `ctx.accounts` undefined) preserves the existing
120
+ * behaviour for callers/tests that do not opt in: a client completing
121
+ * `AUTHENTICATE PLAIN` receives `904 ERR_SASLFAIL` because no store
122
+ * can vouch for the credentials.
123
+ */
124
+ readonly accounts?: AccountStore;
125
+ /**
126
+ * mTLS client-cert identity source for SASL EXTERNAL (absent when mTLS
127
+ * is not configured). When bound, `ctx.mtlsIdentity` is defined so the
128
+ * `AUTHENTICATE EXTERNAL` reducer can resolve the connection's verified
129
+ * certificate subject. Omitted (→ `ctx.mtlsIdentity` undefined) means
130
+ * EXTERNAL is rejected with `904 ERR_SASLFAIL`.
131
+ */
132
+ readonly mtlsIdentity?: MtlsIdentityProvider;
133
+ /**
134
+ * Nick-history source for the `WHOWAS` command (absent when WHOWAS
135
+ * history retention is disabled). When bound, `ctx.history` is defined
136
+ * so the QUIT and NICK-change reducers record sign-off entries and the
137
+ * WHOWAS reducer replays them. Omitted (→ `ctx.history` undefined)
138
+ * preserves the no-store behaviour: `WHOWAS <nick>` emits `406`+`369`.
139
+ */
140
+ readonly history?: NickHistoryStore;
106
141
  /**
107
142
  * Structured-logger port. When omitted the actor runs
108
143
  * silently — observability is purely opt-in. When supplied, every
@@ -116,6 +151,15 @@ export interface ConnectionActorOptions {
116
151
  * still uniquely identifiable in logs.
117
152
  */
118
153
  readonly traceId?: string;
154
+ /**
155
+ * Line-framing seam (PLAN §9, ADR-009). Turns inbound transport input
156
+ * into complete IRC lines. Defaults to a {@link WsTextFrameTransport}
157
+ * (one message per WebSocket text frame, the serverless default). A raw
158
+ * TCP+TLS adapter injects a {@link TcpByteStreamTransport} for stateful
159
+ * `\r\n` byte-stream framing across chunks. The parser/reducer/dispatch
160
+ * pipeline is shared unchanged across both transports.
161
+ */
162
+ readonly transport?: Transport;
119
163
  }
120
164
 
121
165
  export class ConnectionActor {
@@ -127,7 +171,17 @@ export class ConnectionActor {
127
171
  private readonly ids: IdFactory;
128
172
  private readonly motd: MotdProvider;
129
173
  private readonly messages: MessageStore | undefined;
174
+ private readonly accounts: AccountStore | undefined;
175
+ private readonly mtlsIdentity: MtlsIdentityProvider | undefined;
176
+ private readonly history: NickHistoryStore | undefined;
130
177
  private readonly reducers: RoutedReducers;
178
+ /**
179
+ * Line-framing seam. Turns inbound input (a WS text frame or a raw TCP
180
+ * chunk) into complete IRC lines. Defaulted to a WS-frame transport for
181
+ * the serverless default; a TCP+TLS adapter injects the stateful
182
+ * byte-stream transport at construction.
183
+ */
184
+ private readonly transport: Transport;
131
185
  /**
132
186
  * Bound logger. Defaults to a no-op so the actor pipeline has no
133
187
  * observable side effects when callers do not opt in.
@@ -150,6 +204,9 @@ export class ConnectionActor {
150
204
  this.ids = opts.ids;
151
205
  this.motd = opts.motd ?? EmptyMotdProvider;
152
206
  this.messages = opts.messages;
207
+ this.accounts = opts.accounts;
208
+ this.mtlsIdentity = opts.mtlsIdentity;
209
+ this.history = opts.history;
153
210
  this.logger = opts.logger ?? NoopLoggerInstance;
154
211
  this.pinnedTraceId = opts.traceId;
155
212
  // Bind the connection id once so every record the actor emits carries
@@ -163,15 +220,19 @@ export class ConnectionActor {
163
220
  // reducer when flood control is disabled) so a flooding client is bounded
164
221
  // regardless of which authority owns the command's state.
165
222
  this.reducers = buildRoutedReducers(opts.serverConfig);
223
+ this.transport = opts.transport ?? new WsTextFrameTransport();
166
224
  }
167
225
 
168
226
  /**
169
- * Processes one WebSocket text frame. Splits on `\r\n` (tolerating bare
170
- * `\n`) and dispatches each non-empty line in order. A parse failure on
171
- * one line emits a `421` and processing continues with the next line.
227
+ * Transport-agnostic entry point: feeds `chunk` through the bound
228
+ * {@link Transport} and dispatches each complete, non-empty line in order.
229
+ * A WS adapter passes one text frame per call; a raw TCP+TLS adapter
230
+ * passes one arbitrary byte chunk per call and the transport buffers any
231
+ * partial trailing line across calls. A parse failure on one line emits a
232
+ * `421` and processing continues with the next line.
172
233
  */
173
- async receiveTextFrame(text: string): Promise<void> {
174
- const lines = splitFrameLines(text);
234
+ async receive(chunk: string): Promise<void> {
235
+ const lines = this.transport.feed(chunk);
175
236
  if (lines.length === 0) return;
176
237
  const traceId = this.pinnedTraceId ?? this.ids.traceId();
177
238
  const log = this.logger.child({ traceId });
@@ -181,6 +242,17 @@ export class ConnectionActor {
181
242
  }
182
243
  }
183
244
 
245
+ /**
246
+ * Processes one WebSocket text frame. Equivalent to {@link receive} — kept
247
+ * as the named entry point every WS adapter already calls. Splits on
248
+ * `\r\n` (tolerating bare `\n`) via the default WS-frame transport and
249
+ * dispatches each non-empty line in order. A parse failure on one line
250
+ * emits a `421` and processing continues with the next line.
251
+ */
252
+ async receiveTextFrame(text: string): Promise<void> {
253
+ await this.receive(text);
254
+ }
255
+
184
256
  private async processLine(line: string, log: Logger, traceId: string): Promise<void> {
185
257
  let msg: IrcMessage;
186
258
  let parseFailed = false;
@@ -242,6 +314,9 @@ export class ConnectionActor {
242
314
  motd: this.motd,
243
315
  connection: this.state,
244
316
  ...(this.messages !== undefined ? { messages: this.messages } : {}),
317
+ ...(this.accounts !== undefined ? { accounts: this.accounts } : {}),
318
+ ...(this.mtlsIdentity !== undefined ? { mtlsIdentity: this.mtlsIdentity } : {}),
319
+ ...(this.history !== undefined ? { history: this.history } : {}),
245
320
  });
246
321
 
247
322
  switch (msg.command) {
@@ -260,6 +335,8 @@ export class ConnectionActor {
260
335
  return this.reducers.quit(this.state, msg, ctx).effects;
261
336
  case 'MOTD':
262
337
  return this.reducers.motd(this.state, msg, ctx).effects;
338
+ case 'OPER':
339
+ return this.reducers.oper(this.state, msg, ctx).effects;
263
340
  case 'CAP':
264
341
  return this.reducers.cap(this.state, msg, ctx).effects;
265
342
  case 'AUTHENTICATE':
@@ -267,6 +344,20 @@ export class ConnectionActor {
267
344
  case 'AWAY':
268
345
  return this.reducers.away(this.state, msg, ctx).effects;
269
346
 
347
+ // -------- Server-info query commands --------
348
+ case 'VERSION':
349
+ return this.reducers.version(this.state, msg, ctx).effects;
350
+ case 'TIME':
351
+ return this.reducers.time(this.state, msg, ctx).effects;
352
+ case 'ADMIN':
353
+ return this.reducers.admin(this.state, msg, ctx).effects;
354
+ case 'INFO':
355
+ return this.reducers.info(this.state, msg, ctx).effects;
356
+ case 'USERHOST':
357
+ return this.routeUserhost(msg, ctx);
358
+ case 'ISON':
359
+ return this.routeIson(msg, ctx);
360
+
270
361
  // -------- Channel-authority commands --------
271
362
  case 'JOIN':
272
363
  return this.routeJoin(msg, ctx);
@@ -289,6 +380,8 @@ export class ConnectionActor {
289
380
  return this.routeWho(msg, ctx);
290
381
  case 'WHOIS':
291
382
  return this.routeWhois(msg, ctx);
383
+ case 'WHOWAS':
384
+ return this.routeWhowas(msg, ctx);
292
385
  case 'CHATHISTORY':
293
386
  return this.routeChathistory(msg, ctx);
294
387
 
@@ -307,6 +400,13 @@ export class ConnectionActor {
307
400
  this.reducers.noticeUser,
308
401
  ctx,
309
402
  );
403
+ case 'TAGMSG':
404
+ return this.routeMessageTarget(
405
+ msg,
406
+ this.reducers.tagmsgChannel,
407
+ this.reducers.tagmsgUser,
408
+ ctx,
409
+ );
310
410
  case 'MODE':
311
411
  return isChannelTarget(msg.params[0] ?? '')
312
412
  ? this.routeSingleChannel(
@@ -316,6 +416,16 @@ export class ConnectionActor {
316
416
  : this.reducers.userMode(this.state, msg, ctx).effects;
317
417
 
318
418
  default:
419
+ // Remaining standard IRC verbs not yet implemented. They fall through
420
+ // to a graceful `421 ERR_UNKNOWNCOMMAND`. Triage of remaining verbs:
421
+ // - Deferred (serverless-incompatible / S2S / needs new infra):
422
+ // LUSERS (needs cross-adapter getStats), KILL (oper disconnect),
423
+ // REHASH (no runtime config reload), CONNECT/SQUIT (S2S, PLAN
424
+ // non-goal), TRACE (complex routing info), STATS (stats backend),
425
+ // WALLOPS (oper broadcast mechanism), SETNAME (CHGHOST plumbing),
426
+ // LINKS (S2S server list).
427
+ // - Obsolete (RFC 2812, never widely implemented): SERVICE, SUMMON,
428
+ // USERS.
319
429
  return [this.unknownCommandEffect(msg.command)];
320
430
  }
321
431
  }
@@ -364,6 +474,72 @@ export class ConnectionActor {
364
474
  return whoisReducer(target, channels, msg, ctx).effects;
365
475
  }
366
476
 
477
+ /**
478
+ * Routes `WHOWAS <nick>{,<nick>} [<count>]` to the whowas reducer. The
479
+ * command is query-only: it reads the bound nick-history store
480
+ * (`this.history`, absent when no store is wired) and emits
481
+ * `314`/`369`/`406` numerics. No runtime lookups are needed — the store
482
+ * is synchronous and self-contained.
483
+ */
484
+ private async routeWhowas(
485
+ msg: IrcMessage,
486
+ ctx: ReturnType<typeof buildCtx>,
487
+ ): Promise<EffectType[]> {
488
+ return whowasReducer(this.history, msg, ctx).effects;
489
+ }
490
+
491
+ /**
492
+ * Routes `USERHOST <nick>{ <nick>}` to the userhost reducer. Resolves each
493
+ * requested nick via the nick registry + connection lookup, passing the
494
+ * resulting snapshots (or `null` for offline nicks) to the pure formatter.
495
+ * The number of queried nicks is capped at
496
+ * `serverConfig.maxTargetsPerCommand` by the reducer.
497
+ */
498
+ private async routeUserhost(
499
+ msg: IrcMessage,
500
+ ctx: ReturnType<typeof buildCtx>,
501
+ ): Promise<EffectType[]> {
502
+ const cap = ctx.serverConfig.maxTargetsPerCommand;
503
+ const nicks = msg.params.slice(0, cap);
504
+ const resolved = new Map<string, ConnSnapshot | null>();
505
+ for (const nick of nicks) {
506
+ if (nick.length === 0) continue;
507
+ const connId = await this.runtime.lookupNick(nick);
508
+ if (connId === null) {
509
+ resolved.set(nick, null);
510
+ continue;
511
+ }
512
+ const conn = await this.runtime.getConnection(connId);
513
+ resolved.set(nick, conn === null ? null : toSnapshot(conn));
514
+ }
515
+ return userhostReducer(resolved, msg, ctx).effects;
516
+ }
517
+
518
+ /**
519
+ * Routes `ISON <nick>{ <nick>}` to the ison reducer. Resolves each requested
520
+ * nick via the nick registry; for online nicks, fetches the connection to
521
+ * recover the registered (display) spelling. Passes a folded→display map to
522
+ * the pure formatter.
523
+ */
524
+ private async routeIson(
525
+ msg: IrcMessage,
526
+ ctx: ReturnType<typeof buildCtx>,
527
+ ): Promise<EffectType[]> {
528
+ const cap = ctx.serverConfig.maxTargetsPerCommand;
529
+ const nicks = msg.params.slice(0, cap);
530
+ const online = new Map<string, string>();
531
+ for (const nick of nicks) {
532
+ if (nick.length === 0) continue;
533
+ const connId = await this.runtime.lookupNick(nick);
534
+ if (connId === null) continue;
535
+ const conn = await this.runtime.getConnection(connId);
536
+ if (conn?.nick !== undefined) {
537
+ online.set(conn.nick.toLowerCase(), conn.nick);
538
+ }
539
+ }
540
+ return isonReducer(online, msg, ctx).effects;
541
+ }
542
+
367
543
  /**
368
544
  * Routes `CHATHISTORY <sub> <target> [<args>...]` to the chathistory
369
545
  * reducer. The command is query-only and must NOT create a channel as a
@@ -508,15 +684,6 @@ export class ConnectionActor {
508
684
  }
509
685
  }
510
686
 
511
- /**
512
- * Splits a WebSocket text frame into IRC lines. Tolerates `\r\n`, bare `\n`,
513
- * and missing trailing terminators. Returns each line bare (no CRLF).
514
- */
515
- export function splitFrameLines(text: string): string[] {
516
- if (text.length === 0) return [];
517
- return text.split(/\r?\n/u);
518
- }
519
-
520
687
  /** Returns the first whitespace-delimited token of `line`, or `''`. */
521
688
  function firstToken(line: string): string {
522
689
  const m = line.match(/^\S+/u);
@@ -557,6 +724,7 @@ function channelTargetsForMessage(msg: IrcMessage): ChanName[] {
557
724
  case 'MODE':
558
725
  case 'PRIVMSG':
559
726
  case 'NOTICE':
727
+ case 'TAGMSG':
560
728
  return splitCsv(msg.params[0] ?? '').filter(isChannelTarget) as ChanName[];
561
729
  case 'INVITE':
562
730
  // INVITE <nick> <channel> — the channel is the second param.
@@ -33,9 +33,6 @@ const handlers: { [K in Effect['tag']]: Handler<Extract<Effect, { tag: K }>> } =
33
33
  ChangeNick: (e, r) => r.changeNick(e.conn, e.oldNick, e.newNick).then(noop),
34
34
  ReleaseNick: (e, r) => r.releaseNick(e.nick),
35
35
  ApplyChannelDelta: (e, r) => r.applyChannelDelta(e.chan, e.delta),
36
- LookupNick: (e, r) => r.lookupNick(e.nick).then(noop),
37
- GetConnectionInfo: (e, r) => r.getConnectionInfo(e.conn).then(noop),
38
- GetChannelSnapshot: (e, r) => r.getChannelSnapshot(e.chan).then(noop),
39
36
  };
40
37
 
41
38
  function noop(): void {}
@@ -3,11 +3,19 @@
3
3
  *
4
4
  * Orchestration layer: defines the {@link IrcRuntime} port, the {@link dispatch}
5
5
  * interpreter that runs an `Effect[]` against it, and the {@link ConnectionActor}
6
- * that turns a WebSocket text frame into reducer invocations + dispatch.
6
+ * that turns inbound transport input (a WebSocket text frame or a raw TCP byte
7
+ * chunk) into reducer invocations + dispatch via the transport-agnostic
8
+ * {@link Transport} framing seam.
7
9
  */
8
10
  export type { IrcRuntime } from './runtime.js';
9
11
  export { dispatch } from './dispatch.js';
10
- export { ConnectionActor, splitFrameLines } from './actor.js';
12
+ export { ConnectionActor } from './actor.js';
11
13
  export type { ActorChannelAccess, ConnectionActorOptions } from './actor.js';
14
+ export {
15
+ splitFrameLines,
16
+ WsTextFrameTransport,
17
+ TcpByteStreamTransport,
18
+ } from './transport.js';
19
+ export type { Transport } from './transport.js';
12
20
  export { buildRoutedReducers, resolveFloodControl } from './routing.js';
13
21
  export type { RoutedReducers } from './routing.js';
@@ -29,11 +29,13 @@ import {
29
29
  type FloodControlConfig,
30
30
  type Reducer,
31
31
  type ServerConfig,
32
+ adminReducer,
32
33
  authenticateReducer,
33
34
  awayReducer,
34
35
  capReducer,
35
36
  channelModeReducer,
36
37
  defaultCommandCost,
38
+ infoReducer,
37
39
  inviteReducer,
38
40
  joinReducer,
39
41
  kickReducer,
@@ -42,6 +44,7 @@ import {
42
44
  nickReducer,
43
45
  noticeChannelReducer,
44
46
  noticeUserReducer,
47
+ operReducer,
45
48
  partReducer,
46
49
  passReducer,
47
50
  pingReducer,
@@ -49,9 +52,13 @@ import {
49
52
  privmsgChannelReducer,
50
53
  privmsgUserReducer,
51
54
  quitReducer,
55
+ tagmsgChannelReducer,
56
+ tagmsgUserReducer,
57
+ timeReducer,
52
58
  topicReducer,
53
59
  userModeReducer,
54
60
  userReducer,
61
+ versionReducer,
55
62
  wrapWithFloodControl,
56
63
  } from '@serverless-ircd/irc-core';
57
64
 
@@ -69,9 +76,14 @@ export interface RoutedReducers {
69
76
  readonly pass: Reducer<ConnectionState>;
70
77
  readonly quit: Reducer<ConnectionState>;
71
78
  readonly motd: Reducer<ConnectionState>;
79
+ readonly oper: Reducer<ConnectionState>;
72
80
  readonly cap: Reducer<ConnectionState>;
73
81
  readonly authenticate: Reducer<ConnectionState>;
74
82
  readonly away: Reducer<ConnectionState>;
83
+ readonly version: Reducer<ConnectionState>;
84
+ readonly time: Reducer<ConnectionState>;
85
+ readonly admin: Reducer<ConnectionState>;
86
+ readonly info: Reducer<ConnectionState>;
75
87
  // Channel-authority reducers.
76
88
  readonly join: Reducer<ChannelState>;
77
89
  readonly part: Reducer<ChannelState>;
@@ -84,6 +96,8 @@ export interface RoutedReducers {
84
96
  readonly privmsgUser: Reducer<ConnectionState>;
85
97
  readonly noticeChannel: Reducer<ChannelState>;
86
98
  readonly noticeUser: Reducer<ConnectionState>;
99
+ readonly tagmsgChannel: Reducer<ChannelState>;
100
+ readonly tagmsgUser: Reducer<ConnectionState>;
87
101
  readonly channelMode: Reducer<ChannelState>;
88
102
  readonly userMode: Reducer<ConnectionState>;
89
103
  }
@@ -132,9 +146,14 @@ export function buildRoutedReducers(serverConfig: ServerConfig): RoutedReducers
132
146
  pass: wrap(passReducer),
133
147
  quit: wrap(quitReducer),
134
148
  motd: wrap(motdReducer),
149
+ oper: wrap(operReducer),
135
150
  cap: wrap(capReducer),
136
151
  authenticate: wrap(authenticateReducer),
137
152
  away: wrap(awayReducer),
153
+ version: wrap(versionReducer),
154
+ time: wrap(timeReducer),
155
+ admin: wrap(adminReducer),
156
+ info: wrap(infoReducer),
138
157
  join: wrap(joinReducer),
139
158
  part: wrap(partReducer),
140
159
  topic: wrap(topicReducer),
@@ -145,6 +164,8 @@ export function buildRoutedReducers(serverConfig: ServerConfig): RoutedReducers
145
164
  privmsgUser: wrap(privmsgUserReducer),
146
165
  noticeChannel: wrap(noticeChannelReducer),
147
166
  noticeUser: wrap(noticeUserReducer),
167
+ tagmsgChannel: wrap(tagmsgChannelReducer),
168
+ tagmsgUser: wrap(tagmsgUserReducer),
148
169
  channelMode: wrap(channelModeReducer),
149
170
  userMode: wrap(userModeReducer),
150
171
  };
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Transport seam: turns raw transport input into complete IRC lines.
3
+ *
4
+ * PLAN §9 (ADR-009) — the pure reducer core + `ConnectionActor` are shared
5
+ * across both the WebSocket (`wss`) and the raw TCP+TLS (`irc+tls :6697`)
6
+ * transports. The only transport-specific concern is line framing:
7
+ * - A WebSocket text frame is a complete unit (one or N `\r\n`-joined
8
+ * messages), so a trailing unterminated line is still emitted.
9
+ * - A raw TCP byte stream arrives in arbitrary chunks, so a partial line
10
+ * at a chunk boundary must be buffered until its `\r\n` terminator
11
+ * arrives.
12
+ *
13
+ * Both transports emit complete lines (no terminators); the actor skips
14
+ * empty lines and parses each non-empty one through the shared
15
+ * parser/reducer/dispatch pipeline.
16
+ */
17
+
18
+ /**
19
+ * The line-framing contract a {@link ConnectionActor} consumes. `feed`
20
+ * returns the complete IRC lines terminated within `chunk`; a stateful
21
+ * transport buffers any incomplete trailing line for the next call.
22
+ */
23
+ export interface Transport {
24
+ feed(chunk: string): string[];
25
+ }
26
+
27
+ /**
28
+ * Splits a WebSocket text frame into IRC lines. Tolerates `\r\n`, bare `\n`,
29
+ * and a missing trailing terminator (a WS frame is a complete unit). Returns
30
+ * each line bare (no CRLF). This is the legacy-tolerant WS contract.
31
+ */
32
+ export function splitFrameLines(text: string): string[] {
33
+ if (text.length === 0) return [];
34
+ return text.split(/\r?\n/u);
35
+ }
36
+
37
+ /**
38
+ * Stateless WS-frame transport: one {@link feed} call per inbound text frame.
39
+ * A frame may carry one message or N `\r\n`-joined messages; a trailing
40
+ * unterminated line is emitted because the frame itself is the unit of
41
+ * completeness. This is the default transport for the WebSocket adapters.
42
+ */
43
+ export class WsTextFrameTransport implements Transport {
44
+ feed(chunk: string): string[] {
45
+ return splitFrameLines(chunk);
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Stateful TCP byte-stream transport: reassembles `\r\n`- (or bare `\n`-)
51
+ * terminated lines across arbitrary chunks, buffering any partial trailing
52
+ * line until its terminator arrives. Used by the raw TCP+TLS adapter path
53
+ * (PLAN §9). One instance owns one connection's buffer for its lifetime.
54
+ *
55
+ * For long-lived TCP connections fronted by request/response compute (AWS
56
+ * NLB + Lambda) each client chunk arrives in a fresh Lambda
57
+ * invocation. The partial-line buffer must persist across those
58
+ * invocations. {@link getBuffer} / {@link restore} snapshot the in-flight
59
+ * bytes so the adapter can round-trip them through its data store
60
+ * (DynamoDB) between chunks.
61
+ */
62
+ export class TcpByteStreamTransport implements Transport {
63
+ private buffer = '';
64
+
65
+ feed(chunk: string): string[] {
66
+ if (chunk.length === 0) return [];
67
+ this.buffer += chunk;
68
+ const lines: string[] = [];
69
+ let start = 0;
70
+ for (let i = 0; i < this.buffer.length; i++) {
71
+ if (this.buffer[i] === '\n') {
72
+ // A `\n` terminates a line. Strip a preceding `\r` so both `\r\n`
73
+ // and bare `\n` yield the same bare line.
74
+ const lineEnd = i > 0 && this.buffer[i - 1] === '\r' ? i - 1 : i;
75
+ lines.push(this.buffer.slice(start, lineEnd));
76
+ start = i + 1;
77
+ }
78
+ }
79
+ this.buffer = this.buffer.slice(start);
80
+ return lines;
81
+ }
82
+
83
+ /**
84
+ * Returns the partial-line bytes currently buffered (not yet terminated).
85
+ * Empty when every fed byte has been emitted as a complete line. The
86
+ * adapter persists this between compute invocations so a chunk split
87
+ * across two requests is reassembled correctly.
88
+ */
89
+ getBuffer(): string {
90
+ return this.buffer;
91
+ }
92
+
93
+ /**
94
+ * Replaces the internal buffer with `buffer`. Call before {@link feed}
95
+ * when restoring from a persisted snapshot. An empty (or omitted)
96
+ * `buffer` resets the transport to a fresh state.
97
+ */
98
+ restore(buffer: string): void {
99
+ this.buffer = buffer;
100
+ }
101
+ }