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
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Pure reducer for the IRC `REHASH` command (oper-gated config reload).
3
+ *
4
+ * PLAN §2.1 location-of-authority: REHASH runs in the Connection entity. The
5
+ * reducer is pure — it only enforces the oper gate and emits the
6
+ * `382 RPL_REHASHING` reply. The actual config re-fetch is a request/response
7
+ * side effect (re-invoke the adapter's bound config loader and swap the live
8
+ * {@link ServerConfig} reference on the actor), so — exactly like
9
+ * `lookupNick` / `getConnection` — it is NOT modelled as a fire-and-forget
10
+ * `Effect`; the actor layer performs it and surfaces success/failure back to
11
+ * the oper via a second `382` line on failure.
12
+ *
13
+ * Wire format:
14
+ * - `REHASH` (no params).
15
+ * - `382 RPL_REHASHING`:
16
+ * `:<server> 382 <nick> :Rehashing from <source>`
17
+ * - `481 ERR_NOPRIVILEGES`:
18
+ * `:<server> 481 <nick> :Permission Denied - You're not an IRC operator`
19
+ *
20
+ * Behaviour:
21
+ * - Non-oper → `481 ERR_NOPRIVILEGES`; no reload is signalled.
22
+ * - Oper → emits `382` noting the bound config source and signals
23
+ * `shouldReload: true`. The actor re-invokes its loader and swaps the
24
+ * live config; on failure the previous config stays in effect and a
25
+ * graceful error-suffixed `382` follows (handled by the actor).
26
+ */
27
+
28
+ import { Effect } from '../effects.js';
29
+ import type { Effect as EffectType, RawLine } from '../effects.js';
30
+ import type { IrcMessage } from '../protocol/messages.js';
31
+ import { Numerics } from '../protocol/numerics.js';
32
+ import type { ConnectionState } from '../state/connection.js';
33
+ import type { Ctx } from '../types.js';
34
+
35
+ /** Fixed text emitted for `481 ERR_NOPRIVILEGES` (matches `mode.ts`). */
36
+ const NOPRIVILEGES_TRAILING_TEXT = "Permission Denied - You're not an IRC operator";
37
+
38
+ /**
39
+ * Result of {@link rehashReducer}. Adds a `shouldReload` intent flag on top of
40
+ * the standard reducer shape so the actor layer knows whether to perform the
41
+ * async config re-fetch (oper gate passed) without re-deriving the gate.
42
+ */
43
+ export interface RehashResult {
44
+ state: ConnectionState;
45
+ effects: EffectType[];
46
+ shouldReload: boolean;
47
+ }
48
+
49
+ /**
50
+ * Composes the trailing text for the `382 RPL_REHASHING` line. The success
51
+ * form notes the bound config source; the failure form (used by the actor on
52
+ * reload rejection) appends the error so the oper sees what went wrong
53
+ * without being disconnected.
54
+ */
55
+ export function buildRehashTrailing(configSource: string, error?: string): string {
56
+ const base = `Rehashing from ${configSource}`;
57
+ return error === undefined ? base : `${base} failed: ${error}`;
58
+ }
59
+
60
+ /**
61
+ * Renders the full `:<server> 382 <nick> :<trailing>` wire line. Shared by
62
+ * the reducer (optimistic success line) and the actor (error-suffixed line
63
+ * on reload failure) so the two paths cannot drift in formatting.
64
+ */
65
+ export function rehashLine(
66
+ serverName: string,
67
+ nick: string,
68
+ configSource: string,
69
+ error?: string,
70
+ ): RawLine {
71
+ const code = Numerics.RPL_REHASHING.toString().padStart(3, '0');
72
+ return { text: `:${serverName} ${code} ${nick} :${buildRehashTrailing(configSource, error)}` };
73
+ }
74
+
75
+ /**
76
+ * Handles `REHASH`.
77
+ *
78
+ * Enforces the oper gate, stamps `lastSeen`, and — for an oper — emits the
79
+ * `382 RPL_REHASHING` reply and signals the actor to perform the reload via
80
+ * `shouldReload`. The reducer never performs IO.
81
+ */
82
+ export function rehashReducer(
83
+ state: ConnectionState,
84
+ _msg: IrcMessage,
85
+ ctx: Ctx,
86
+ configSource: string,
87
+ ): RehashResult {
88
+ state.lastSeen = ctx.clock.now();
89
+
90
+ if (!state.userModes.oper) {
91
+ return {
92
+ state,
93
+ effects: [
94
+ Effect.send(ctx.connId, [
95
+ numericLine(ctx, Numerics.ERR_NOPRIVILEGES, NOPRIVILEGES_TRAILING_TEXT),
96
+ ]),
97
+ ],
98
+ shouldReload: false,
99
+ };
100
+ }
101
+
102
+ const nick = ctx.connection.nick ?? '*';
103
+ return {
104
+ state,
105
+ effects: [Effect.send(ctx.connId, [rehashLine(ctx.serverName, nick, configSource)])],
106
+ shouldReload: true,
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Builds a `:<server> <code> <nick> :<trailing>` line. The nick falls back to
112
+ * `*` for an unregistered connection, matching the rest of the codebase's
113
+ * numeric formatting.
114
+ */
115
+ function numericLine(ctx: Ctx, code: number, trailing: string): RawLine {
116
+ const nick = ctx.connection.nick ?? '*';
117
+ const codeStr = code.toString().padStart(3, '0');
118
+ return { text: [`:${ctx.serverName}`, codeStr, nick, `:${trailing}`].join(' ') };
119
+ }
@@ -4,10 +4,13 @@
4
4
  * PLAN §2.1 location-of-authority: SASL runs in the Connection entity.
5
5
  * The reducer drives the multi-frame SASL exchange purely:
6
6
  *
7
- * 1. `AUTHENTICATE <MECH>` — selects a mechanism. `PLAIN` is supported and
8
- * answers with `AUTHENTICATE +` (requesting the payload). `EXTERNAL` is
9
- * recognized but stubbed to `904` until mTLS lands. Anything else gets
10
- * `908 ERR_SASLMECHS` listing what is available.
7
+ * 1. `AUTHENTICATE <MECH>` — selects a mechanism. `PLAIN` is always
8
+ * supported and answers with `AUTHENTICATE +` (requesting the payload).
9
+ * `EXTERNAL` is supported only when an mTLS identity provider is
10
+ * configured for the connection; without one, EXTERNAL falls through
11
+ * to `908 ERR_SASLMECHS` (mechanism not available) listing what is.
12
+ * The verified client-cert subject is resolved at finalise time.
13
+ * Anything else gets `908 ERR_SASLMECHS` listing what is available.
11
14
  * 2. `AUTHENTICATE <b64>` / `AUTHENTICATE +` — delivers the payload (whole
12
15
  * or in `<=400`-byte chunks; `+` marks an empty/final chunk). The
13
16
  * decoded bytes are handed to the injected {@link AccountStore}; on
@@ -26,6 +29,7 @@
26
29
 
27
30
  import { Effect } from '../effects.js';
28
31
  import type { Effect as EffectType, RawLine } from '../effects.js';
32
+ import type { SaslResult } from '../ports.js';
29
33
  import { decodeBase64 } from '../protocol/base64.js';
30
34
  import { Numerics } from '../protocol/numerics.js';
31
35
  import { hostmaskOf } from '../state/connection.js';
@@ -38,8 +42,17 @@ const SASL_CHUNK_BYTES = 400;
38
42
  /** Total decoded-payload cap before `905 ERR_SASLTOOLONG`. */
39
43
  const SASL_MAX_BYTES = 8192;
40
44
 
41
- /** Mechanisms this server will accept today (advertised verbatim by the cap). */
42
- const SUPPORTED_MECHS = 'PLAIN,EXTERNAL';
45
+ /**
46
+ * Mechanisms advertised in `908 ERR_SASLMECHS` and the `sasl` cap value,
47
+ * derived from whether mTLS is configured for this connection.
48
+ *
49
+ * PLAIN is always available; EXTERNAL only when an `MtlsIdentityProvider`
50
+ * is bound. Operators enable EXTERNAL by configuring mTLS at the edge
51
+ * — no code change required.
52
+ */
53
+ function supportedMechs(ctx: Ctx): string {
54
+ return ctx.mtlsIdentity !== undefined ? 'PLAIN,EXTERNAL' : 'PLAIN';
55
+ }
43
56
 
44
57
  /**
45
58
  * Reducer for `AUTHENTICATE [<param>]`. See module docs for the state
@@ -93,7 +106,12 @@ function handleMechanism(
93
106
  return { state, effects: [Effect.send(ctx.connId, [L('AUTHENTICATE +')])] };
94
107
  }
95
108
  if (mech === 'EXTERNAL') {
96
- return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
109
+ if (ctx.mtlsIdentity === undefined) {
110
+ return { state, effects: [Effect.send(ctx.connId, [saslMechs(ctx)])] };
111
+ }
112
+ state.saslMech = 'EXTERNAL';
113
+ state.saslBuffer = '';
114
+ return { state, effects: [Effect.send(ctx.connId, [L('AUTHENTICATE +')])] };
97
115
  }
98
116
  return { state, effects: [Effect.send(ctx.connId, [saslMechs(ctx)])] };
99
117
  }
@@ -131,6 +149,18 @@ function finalize(
131
149
  payloadB64: string,
132
150
  mech: string,
133
151
  ctx: Ctx,
152
+ ): { state: ConnectionState; effects: EffectType[] } {
153
+ if (mech === 'EXTERNAL') {
154
+ return finalizeExternal(state, ctx);
155
+ }
156
+ return finalizePlain(state, payloadB64, ctx);
157
+ }
158
+
159
+ /** SASL PLAIN: decode base64 payload, verify username/password. */
160
+ function finalizePlain(
161
+ state: ConnectionState,
162
+ payloadB64: string,
163
+ ctx: Ctx,
134
164
  ): { state: ConnectionState; effects: EffectType[] } {
135
165
  const parsed = parsePlain(payloadB64);
136
166
 
@@ -145,12 +175,45 @@ function finalize(
145
175
  return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
146
176
  }
147
177
 
148
- const result = store.verify(mech, {
178
+ const result = store.verify('PLAIN', {
149
179
  kind: 'PLAIN',
150
180
  username: parsed.username,
151
181
  password: parsed.password,
152
182
  });
153
183
 
184
+ return applyResult(state, result, ctx);
185
+ }
186
+
187
+ /** SASL EXTERNAL: resolve the mTLS cert subject and verify it. */
188
+ function finalizeExternal(
189
+ state: ConnectionState,
190
+ ctx: Ctx,
191
+ ): { state: ConnectionState; effects: EffectType[] } {
192
+ const provider = ctx.mtlsIdentity;
193
+ const identity = provider?.getIdentity(ctx.connId);
194
+
195
+ if (identity === undefined) {
196
+ clearSasl(state);
197
+ return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
198
+ }
199
+
200
+ const store = ctx.accounts;
201
+ if (store === undefined) {
202
+ clearSasl(state);
203
+ return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
204
+ }
205
+
206
+ const result = store.verify('EXTERNAL', { kind: 'EXTERNAL', identity });
207
+
208
+ return applyResult(state, result, ctx);
209
+ }
210
+
211
+ /** Shared success/failure handling after a `verify` call. */
212
+ function applyResult(
213
+ state: ConnectionState,
214
+ result: SaslResult,
215
+ ctx: Ctx,
216
+ ): { state: ConnectionState; effects: EffectType[] } {
154
217
  clearSasl(state);
155
218
 
156
219
  if (result.ok) {
@@ -228,7 +291,7 @@ function saslAlready(ctx: Ctx): RawLine {
228
291
 
229
292
  function saslMechs(ctx: Ctx): RawLine {
230
293
  return L(
231
- `:${ctx.serverName} ${code(Numerics.ERR_SASLMECHS)} ${nickOf(ctx)} ${SUPPORTED_MECHS} :are the available SASL mechanisms`,
294
+ `:${ctx.serverName} ${code(Numerics.ERR_SASLMECHS)} ${nickOf(ctx)} ${supportedMechs(ctx)} :are the available SASL mechanisms`,
232
295
  );
233
296
  }
234
297
 
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Pure reducers for the server-information query commands `VERSION`, `TIME`,
3
+ * `ADMIN`, and `INFO`.
4
+ *
5
+ * PLAN §2.1 location-of-authority: these are read-only Connection-entity
6
+ * commands — they do not mutate connection or channel state, only emit
7
+ * `Send` numeric replies addressed to the requester. Each is a thin
8
+ * config/clock-derived reply, so they share a numeric-formatting helper.
9
+ *
10
+ * Wire format (RFC 1459/2812):
11
+ * - `VERSION [server]` → `351 RPL_VERSION`:
12
+ * `:<server> 351 <nick> <version> <server> :<comments>`
13
+ * - `TIME [server]` → `391 RPL_TIME`:
14
+ * `:<server> 391 <nick> <server> :<human-readable time>`
15
+ * - `ADMIN [server]` → `256 RPL_ADMINME`:
16
+ * `:<server> 256 <nick> <server> :Administrative info`
17
+ * - `INFO [server]` → `371 RPL_INFO` ×N + `374 RPL_ENDOFINFO`:
18
+ * `:<server> 371 <nick> :<info line>`
19
+ * `:<server> 374 <nick> :End of INFO list`
20
+ *
21
+ * The optional `<server>` target parameter is ignored on all four: this is a
22
+ * single-server implementation (PLAN §1 non-goal: no S2S linking), so a
23
+ * request addressed to another server still gets the local info.
24
+ */
25
+
26
+ import { resolveServerVersion } from '../config.js';
27
+ import { Effect } from '../effects.js';
28
+ import type { RawLine } from '../effects.js';
29
+ import { Numerics } from '../protocol/numerics.js';
30
+ import type { ConnectionState } from '../state/connection.js';
31
+ import type { Ctx, Reducer, ReducerResult } from '../types.js';
32
+
33
+ /** Fixed trailing text for `256 RPL_ADMINME`. */
34
+ const ADMINME_TRAILING_TEXT = 'Administrative info';
35
+
36
+ /** Fixed trailing text for `374 RPL_ENDOFINFO`. */
37
+ const ENDOFINFO_TRAILING_TEXT = 'End of INFO list';
38
+
39
+ /**
40
+ * Handles `VERSION [server]`. Emits `351 RPL_VERSION` with the server version,
41
+ * server name, and network name as the comment.
42
+ */
43
+ export const versionReducer: Reducer<ConnectionState> = (state, _msg, ctx) => {
44
+ state.lastSeen = ctx.clock.now();
45
+
46
+ const nick = ctx.connection.nick ?? '*';
47
+ const version = resolveServerVersion(ctx.serverConfig);
48
+ const line: RawLine = numericLine(
49
+ ctx,
50
+ Numerics.RPL_VERSION,
51
+ nick,
52
+ `${version} ${ctx.serverName} :${ctx.networkName}`,
53
+ );
54
+ return {
55
+ state,
56
+ effects: [Effect.send(ctx.connId, [line])],
57
+ };
58
+ };
59
+
60
+ /**
61
+ * Handles `TIME [server]`. Emits `391 RPL_TIME` with an ISO-8601 timestamp
62
+ * derived from the injected {@link Clock} (deterministic under the fake clock).
63
+ */
64
+ export const timeReducer: Reducer<ConnectionState> = (state, _msg, ctx) => {
65
+ state.lastSeen = ctx.clock.now();
66
+
67
+ const nick = ctx.connection.nick ?? '*';
68
+ const iso = new Date(ctx.clock.now()).toISOString();
69
+ const line = numericLine(ctx, Numerics.RPL_TIME, nick, `${ctx.serverName} :${iso}`);
70
+ return {
71
+ state,
72
+ effects: [Effect.send(ctx.connId, [line])],
73
+ };
74
+ };
75
+
76
+ /**
77
+ * Handles `ADMIN [server]`. Emits `256 RPL_ADMINME` with the server name.
78
+ * No admin contact info is configured in v1, so the deeper `257`/`258`/`259`
79
+ * numerics are omitted (the server has no admin-email data to report).
80
+ */
81
+ export const adminReducer: Reducer<ConnectionState> = (state, _msg, ctx) => {
82
+ state.lastSeen = ctx.clock.now();
83
+
84
+ const nick = ctx.connection.nick ?? '*';
85
+ const line = numericLine(
86
+ ctx,
87
+ Numerics.RPL_ADMINME,
88
+ nick,
89
+ `${ctx.serverName} :${ADMINME_TRAILING_TEXT}`,
90
+ );
91
+ return {
92
+ state,
93
+ effects: [Effect.send(ctx.connId, [line])],
94
+ };
95
+ };
96
+
97
+ /**
98
+ * Handles `INFO [server]`. Emits one or more `371 RPL_INFO` lines with
99
+ * server metadata followed by the `374 RPL_ENDOFINFO` terminator.
100
+ */
101
+ export const infoReducer: Reducer<ConnectionState> = (
102
+ state,
103
+ _msg,
104
+ ctx,
105
+ ): ReducerResult<ConnectionState> => {
106
+ state.lastSeen = ctx.clock.now();
107
+
108
+ const nick = ctx.connection.nick ?? '*';
109
+ const version = resolveServerVersion(ctx.serverConfig);
110
+ const out: RawLine[] = [
111
+ numericLine(ctx, Numerics.RPL_INFO, nick, `:ServerlessIRCd - version ${version}`),
112
+ numericLine(ctx, Numerics.RPL_INFO, nick, `:Running on network ${ctx.networkName}`),
113
+ numericLine(ctx, Numerics.RPL_ENDOFINFO, nick, `:${ENDOFINFO_TRAILING_TEXT}`),
114
+ ];
115
+ return {
116
+ state,
117
+ effects: [Effect.send(ctx.connId, out)],
118
+ };
119
+ };
120
+
121
+ /**
122
+ * Builds `:<server> <code> <nick> <rest>` where `rest` already carries its
123
+ * own leading colon / spacing for the trailing parameter. This keeps the
124
+ * three reducers DRY without duplicating the prefix logic.
125
+ */
126
+ function numericLine(ctx: Ctx, code: number, nick: string, rest: string): RawLine {
127
+ const codeStr = code.toString().padStart(3, '0');
128
+ return { text: `:${ctx.serverName} ${codeStr} ${nick} ${rest}` };
129
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Pure reducer for the IRC `SETNAME` command (RFC 2812 §4.1.7).
3
+ *
4
+ * PLAN §2.1 location-of-authority: SETNAME runs in the Connection entity.
5
+ * The reducer mutates `state.realname` (the GECOS field originally set by
6
+ * the `USER` reducer) and performs no IO.
7
+ *
8
+ * Wire format:
9
+ * - `SETNAME :<realname>` — replaces `state.realname`.
10
+ * - `461 ERR_NEEDMOREPARAMS` when the realname is missing/empty.
11
+ * - `451 ERR_NOTREGISTERED` when issued before registration completes.
12
+ *
13
+ * Broadcast decision: there is no standard IRCv3 cap for relaying a
14
+ * realname change (the `chghost` cap covers user/host, not realname), so
15
+ * v1 updates `state.realname` silently. A subsequent `WHOIS` reflects the
16
+ * new value in `311 RPL_WHOISUSER`. A `draft/setname` relay cap is a
17
+ * documented follow-up.
18
+ *
19
+ * Length cap: registration-time `USER` does not enforce a realname length
20
+ * cap, so this command applies its own via `serverConfig.realnameLen`
21
+ * (falling back to {@link DEFAULT_REALNAME_LEN}). An over-cap value is
22
+ * rejected with `432 ERR_ERRONEUSNICKNAME` — the same numeric registration
23
+ * uses for an over-length `NICK` (the codebase's only registration-time
24
+ * "value rejected for length" precedent). The offending value is NOT
25
+ * echoed back so a client cannot amplify a multi-kilobyte payload via the
26
+ * error reply. Truncation (as `TOPIC` does) is deliberately not used: the
27
+ * spec frames an over-long realname as a rejection, not a silent trim.
28
+ */
29
+
30
+ import { Effect } from '../effects.js';
31
+ import type { Effect as EffectType, RawLine } from '../effects.js';
32
+ import { Numerics } from '../protocol/numerics.js';
33
+ import type { ConnectionState } from '../state/connection.js';
34
+ import type { Ctx, Reducer } from '../types.js';
35
+
36
+ /**
37
+ * Built-in realname length cap when `serverConfig.realnameLen` is unset.
38
+ * Matches the schema default and the common ircd-seven GECOS ceiling.
39
+ */
40
+ const DEFAULT_REALNAME_LEN = 50;
41
+
42
+ /** Fixed trailing text for `461 ERR_NEEDMOREPARAMS` (matches `USER`/`PASS`). */
43
+ const NEEDMOREPARAMS_TRAILING = 'Not enough parameters';
44
+
45
+ /** Fixed trailing text for `451 ERR_NOTREGISTERED`. */
46
+ const NOTREGISTERED_TRAILING = 'You have not registered';
47
+
48
+ /** Fixed trailing text for the over-cap `432` rejection. */
49
+ const ERRONEOUS_REALNAME_TRAILING = 'Erroneous realname';
50
+
51
+ /**
52
+ * Handles `SETNAME :<realname>`.
53
+ *
54
+ * Stamps `lastSeen`, enforces the registration + length gates, and on
55
+ * success replaces `state.realname` with no broadcast. The reducer never
56
+ * performs IO.
57
+ */
58
+ export const setnameReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
59
+ state.lastSeen = ctx.clock.now();
60
+
61
+ const effects: EffectType[] = [];
62
+
63
+ if (state.registration !== 'registered') {
64
+ effects.push(
65
+ Effect.send(ctx.connId, [
66
+ numericLine(ctx, Numerics.ERR_NOTREGISTERED, NOTREGISTERED_TRAILING),
67
+ ]),
68
+ );
69
+ return { state, effects };
70
+ }
71
+
72
+ const realname = msg.params[0];
73
+ if (realname === undefined || realname.length === 0) {
74
+ effects.push(
75
+ Effect.send(ctx.connId, [
76
+ numericLine(ctx, Numerics.ERR_NEEDMOREPARAMS, NEEDMOREPARAMS_TRAILING, 'SETNAME'),
77
+ ]),
78
+ );
79
+ return { state, effects };
80
+ }
81
+
82
+ const cap = ctx.serverConfig.realnameLen ?? DEFAULT_REALNAME_LEN;
83
+ if (realname.length > cap) {
84
+ effects.push(
85
+ Effect.send(ctx.connId, [
86
+ numericLine(ctx, Numerics.ERR_ERRONEUSNICKNAME, ERRONEOUS_REALNAME_TRAILING),
87
+ ]),
88
+ );
89
+ return { state, effects };
90
+ }
91
+
92
+ state.realname = realname;
93
+ return { state, effects };
94
+ };
95
+
96
+ /**
97
+ * Builds a `:<server> <code> <nick> [:<middle>] :<trailing>` line. The nick
98
+ * falls back to `*` when the connection has not yet registered one, and the
99
+ * optional `middle` is omitted when not supplied — matching the numeric
100
+ * formatting used by the registration reducers.
101
+ */
102
+ function numericLine(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
103
+ const nick = ctx.connection.nick ?? '*';
104
+ const codeStr = code.toString().padStart(3, '0');
105
+ const parts = [`:${ctx.serverName}`, codeStr, nick];
106
+ if (middle !== undefined) parts.push(middle);
107
+ parts.push(`:${trailing}`);
108
+ return { text: parts.join(' ') };
109
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Pure reducer for the IRC `STATS` command (RFC 2812 §4.6.3).
3
+ *
4
+ * PLAN §2.1 location-of-authority: STATS is a read-only diagnostic that
5
+ * reports server statistics keyed by a single-letter query. The reducer
6
+ * takes an already-aggregated {@link ServerStatsSnapshot} (the actor
7
+ * awaits a {@link ServerStats} backend before calling here). The reducer
8
+ * never mutates anything but the requester's `lastSeen`.
9
+ *
10
+ * Wire format (RFC 2812 §5.1 + modern practice):
11
+ * - `STATS <query> [<server>]`
12
+ * - `211 RPL_STATSLINKINFO` (the `l` query): per-link sendq/byte/msg
13
+ * counters. Single-server deployment collapses this to one line for
14
+ * the bound server.
15
+ * - `242 RPL_STATSUPTIME` (the `u` query):
16
+ * `:<server> 242 <nick> :Server Up <d> days, <h>:<mm>:<ss>`
17
+ * - `219 RPL_ENDOFSTATS`:
18
+ * `:<server> 219 <nick> <query> :End of /STATS report`
19
+ * - `402 ERR_NOSUCHSERVER`:
20
+ * `:<server> 402 <nick> <server> :No such server`
21
+ *
22
+ * Implemented query letters: `u` (uptime), `l` (link info). Unknown letters
23
+ * follow charybdis semantics: the reducer emits ONLY the `219` terminator
24
+ * with an empty body (no 461/401/etc). The remaining RFC letters
25
+ * (`k`/`K`/`q`/`g`/`x`/`z`/…) are deferred behind per-letter tests; the
26
+ * actor routes them here regardless so a future implementation is just
27
+ * another case branch.
28
+ *
29
+ * `<server>` resolves at the actor layer: a non-local name yields
30
+ * `402 ERR_NOSUCHSERVER` (single-server network — S2S routing is a PLAN
31
+ * non-goal). When the actor passes `serverTarget`, the reducer emits ONLY
32
+ * the 402 and nothing else.
33
+ */
34
+
35
+ import { Effect } from '../effects.js';
36
+ import type { Effect as EffectType, RawLine } from '../effects.js';
37
+ import type { ServerStatsSnapshot } from '../ports.js';
38
+ import { Numerics } from '../protocol/numerics.js';
39
+ import type { Ctx } from '../types.js';
40
+
41
+ /** Fixed text emitted for `402 ERR_NOSUCHSERVER`. */
42
+ const NOSUCHSERVER_TRAILING_TEXT = 'No such server';
43
+
44
+ /** Fixed text emitted for `219 RPL_ENDOFSTATS`. */
45
+ const ENDOFSTATS_TRAILING_TEXT = 'End of /STATS report';
46
+
47
+ /**
48
+ * Builds a `:<server> <code> <nick> [<middle>] :<trailing>` numeric line
49
+ * addressed to the requester. When `middle` is empty the middle field is
50
+ * still emitted as a bare space-prefixed token (matches charybdis output
51
+ * for STATS with no query letter).
52
+ */
53
+ function numericLine(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
54
+ const nick = ctx.connection.nick ?? '*';
55
+ const codeStr = code.toString().padStart(3, '0');
56
+ const parts = [`:${ctx.serverName}`, codeStr, nick];
57
+ parts.push(middle);
58
+ parts.push(`:${trailing}`);
59
+ return { text: parts.join(' ') };
60
+ }
61
+
62
+ /**
63
+ * Formats an uptime delta (ms) as `Server Up <d> days, <h>:<mm>:<ss>`,
64
+ * matching charybdis's `RPL_STATSUPTIME` text. Days and hours are not
65
+ * zero-padded; minutes and seconds are.
66
+ */
67
+ function formatUptime(deltaMs: number): string {
68
+ let totalSec = Math.floor(deltaMs / 1000);
69
+ const days = Math.floor(totalSec / 86_400);
70
+ totalSec -= days * 86_400;
71
+ const hours = Math.floor(totalSec / 3_600);
72
+ totalSec -= hours * 3_600;
73
+ const minutes = Math.floor(totalSec / 60);
74
+ totalSec -= minutes * 60;
75
+ const seconds = totalSec;
76
+ const mm = minutes.toString().padStart(2, '0');
77
+ const ss = seconds.toString().padStart(2, '0');
78
+ return `Server Up ${days} days, ${hours}:${mm}:${ss}`;
79
+ }
80
+
81
+ /**
82
+ * Handles `STATS <query> [<server>]`.
83
+ *
84
+ * @param snapshot The aggregated network statistics (only `uptimeStartedAt`
85
+ * is consulted here; full counts are surfaced by LUSERS).
86
+ * @param msg The parsed `STATS` message.
87
+ * @param ctx The per-invocation context.
88
+ * @param serverTarget When set, the actor determined `<server>` names a
89
+ * non-local server; the reducer emits `402 ERR_NOSUCHSERVER` against this
90
+ * name.
91
+ */
92
+ export function statsReducer(
93
+ snapshot: ServerStatsSnapshot,
94
+ msg: { command: string; params: readonly string[] },
95
+ ctx: Ctx,
96
+ serverTarget?: string,
97
+ ): { effects: EffectType[] } {
98
+ ctx.connection.lastSeen = ctx.clock.now();
99
+
100
+ const effects: EffectType[] = [];
101
+
102
+ if (serverTarget !== undefined) {
103
+ effects.push(
104
+ Effect.send(ctx.connId, [
105
+ numericLine(ctx, Numerics.ERR_NOSUCHSERVER, NOSUCHSERVER_TRAILING_TEXT, serverTarget),
106
+ ]),
107
+ );
108
+ return { effects };
109
+ }
110
+
111
+ // Normalise: STATS with no query letter is treated as the empty string;
112
+ // case is folded to lower so the terminator matches the canonical charybdis
113
+ // output (`STATS U` and `STATS u` both terminate with `219 <nick> u`).
114
+ const rawQuery = msg.params[0] ?? '';
115
+ const query = rawQuery.toLowerCase();
116
+
117
+ if (query === 'u') {
118
+ const uptimeMs = ctx.clock.now() - snapshot.uptimeStartedAt;
119
+ const code = Numerics.RPL_STATSUPTIME.toString().padStart(3, '0');
120
+ effects.push(
121
+ Effect.send(ctx.connId, [
122
+ {
123
+ text: `:${ctx.serverName} ${code} ${ctx.connection.nick ?? '*'} :${formatUptime(uptimeMs)}`,
124
+ },
125
+ ]),
126
+ );
127
+ } else if (query === 'l') {
128
+ // Single-server: one link-info line for the bound server. All sendq /
129
+ // byte / message counters are zero for a stateless serverless runtime
130
+ // (no persistent S2S link); the trailing text is the configured server
131
+ // version so the client sees a self-describing entry.
132
+ const code = Numerics.RPL_STATSLINKINFO.toString().padStart(3, '0');
133
+ const requester = ctx.connection.nick ?? '*';
134
+ const version = ctx.serverConfig.serverVersion ?? '';
135
+ effects.push(
136
+ Effect.send(ctx.connId, [
137
+ {
138
+ text: `:${ctx.serverName} ${code} ${requester} ${ctx.serverName} 0 0 0 0 0 0 :${version}`,
139
+ },
140
+ ]),
141
+ );
142
+ }
143
+ // Terminator always emitted, even for unknown letters (charybdis
144
+ // semantics: unknown STATS letters produce no body lines, just 219).
145
+ effects.push(
146
+ Effect.send(ctx.connId, [
147
+ numericLine(ctx, Numerics.RPL_ENDOFSTATS, ENDOFSTATS_TRAILING_TEXT, query),
148
+ ]),
149
+ );
150
+
151
+ return { effects };
152
+ }
@@ -35,12 +35,10 @@ function isValidChannelName(name: string, maxLen: number): boolean {
35
35
  }
36
36
 
37
37
  /** Formats a numeric error/reply line addressed to the connection's nick. */
38
- function numericErr(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
38
+ function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
39
39
  const nick = ctx.connection.nick ?? '*';
40
40
  const codeStr = code.toString().padStart(3, '0');
41
- const parts = [`:${ctx.serverName}`, codeStr, nick];
42
- if (middle !== undefined) parts.push(middle);
43
- parts.push(`:${trailing}`);
41
+ const parts = [`:${ctx.serverName}`, codeStr, nick, middle, `:${trailing}`];
44
42
  return { text: parts.join(' ') };
45
43
  }
46
44