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,137 @@
1
+ /**
2
+ * Pure reducer for the IRC `TRACE` command (RFC 2812 §4.6.3).
3
+ *
4
+ * PLAN §2.1 location-of-authority: TRACE is a query-only diagnostic. The
5
+ * actor layer resolves the optional `<target>` and — when it is a nick —
6
+ * hands the resolved {@link ConnectionState} here. The reducer never
7
+ * mutates anything but the requester's `lastSeen`.
8
+ *
9
+ * In a single-server serverless IRCd, multi-hop routing collapses to the
10
+ * local server. The classic `200 RPL_TRACELINK` / `206 RPL_TRACESERVER`
11
+ * server-link dump is therefore trivial (one server, zero hops); modern
12
+ * daemons emit just the per-connection `205`/`204` lines and the
13
+ * terminating `262 RPL_ENDOFTRACE`. This reducer follows that convention.
14
+ *
15
+ * Wire format (RFC 2812 §4.6.3 + modern practice):
16
+ * - `TRACE [<target>]`
17
+ * - `205 RPL_TRACEUSER`:
18
+ * `:<server> 205 <nick> User <hops> <user> <host>` (per matched conn)
19
+ * - `204 RPL_TRACEOPERATOR`:
20
+ * `:<server> 204 <nick> Oper <hops> <user> <host>` (oper conns only)
21
+ * - `262 RPL_ENDOFTRACE`:
22
+ * `:<server> 262 <nick> <target> :End of TRACE`
23
+ * - `402 ERR_NOSUCHSERVER`:
24
+ * `:<server> 402 <nick> <server> :No such server`
25
+ *
26
+ * Detail visibility is oper-gated:
27
+ * - Non-opers see only the `262` terminator (no per-connection detail),
28
+ * so a non-oper cannot use `TRACE` to enumerate online users. This
29
+ * also implicitly enforces `+i` invisibility for non-opers.
30
+ * - Opers see one `205`/`204` line per matched connection and bypass
31
+ * the `+i` filter (RFC 2812 §4.6.3).
32
+ */
33
+
34
+ import { Effect } from '../effects.js';
35
+ import type { Effect as EffectType, RawLine } from '../effects.js';
36
+ import { Numerics } from '../protocol/numerics.js';
37
+ import type { ConnectionState } from '../state/connection.js';
38
+ import type { Ctx } from '../types.js';
39
+
40
+ /** Fixed text emitted for `402 ERR_NOSUCHSERVER`. */
41
+ const NOSUCHSERVER_TRAILING_TEXT = 'No such server';
42
+
43
+ /** Fixed text emitted for `262 RPL_ENDOFTRACE`. */
44
+ const ENDOFTRACE_TRAILING_TEXT = 'End of TRACE';
45
+
46
+ /**
47
+ * Builds a `:<server> <code> <nick> <Class> <hops> <nick> <user> <host>`
48
+ * line for one matched connection. `<Class>` is `Oper` for an oper,
49
+ * otherwise `User` (matching RFC 2812 §4.6.3 + common client rendering).
50
+ * `<hops>` is always 0 in a single-server deployment.
51
+ */
52
+ function traceDetailLine(ctx: Ctx, code: number, cls: string, target: ConnectionState): RawLine {
53
+ const requesterNick = ctx.connection.nick ?? '*';
54
+ const codeStr = code.toString().padStart(3, '0');
55
+ const tNick = target.nick ?? '*';
56
+ const tUser = target.user ?? '?';
57
+ const tHost = target.host ?? '?';
58
+ return {
59
+ text: `:${ctx.serverName} ${codeStr} ${requesterNick} ${cls} 0 ${tNick} ${tUser} ${tHost}`,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Builds a `:<server> <code> <nick> [<middle>] :<trailing>` numeric line
65
+ * addressed to the requester. When `middle` is empty the middle field is
66
+ * omitted entirely.
67
+ */
68
+ function numericLine(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
69
+ const nick = ctx.connection.nick ?? '*';
70
+ const codeStr = code.toString().padStart(3, '0');
71
+ const parts = [`:${ctx.serverName}`, codeStr, nick];
72
+ if (middle !== '') parts.push(middle);
73
+ parts.push(`:${trailing}`);
74
+ return { text: parts.join(' ') };
75
+ }
76
+
77
+ /**
78
+ * Handles `TRACE [<target>]`.
79
+ *
80
+ * The actor layer resolves `<target>`:
81
+ * - Empty / absent / equal to the local server name → `target` is
82
+ * `undefined` and `serverTarget` is unset; the reducer emits only the
83
+ * terminating `262`.
84
+ * - A nick that resolves → `target` is the live
85
+ * {@link ConnectionState}; the reducer emits `205`/`204` (oper
86
+ * requester only) then `262`.
87
+ * - A nick that does not resolve → `target` is `undefined`; the reducer
88
+ * emits only the `262` (carrying the requested nick).
89
+ * - A name that is not the local server (treated as a remote server) →
90
+ * the actor passes `serverTarget = '<name>'`; the reducer emits
91
+ * `402 ERR_NOSUCHSERVER`.
92
+ *
93
+ * @param target The resolved target connection, or `undefined`.
94
+ * @param msg The parsed `TRACE` message.
95
+ * @param ctx The per-invocation context.
96
+ * @param serverTarget When set, the actor determined `<target>` names a
97
+ * non-local server; the reducer emits `402` against this name.
98
+ */
99
+ export function traceReducer(
100
+ target: ConnectionState | undefined,
101
+ msg: { command: string; params: readonly string[] },
102
+ ctx: Ctx,
103
+ serverTarget?: string,
104
+ ): { effects: EffectType[] } {
105
+ ctx.connection.lastSeen = ctx.clock.now();
106
+
107
+ const effects: EffectType[] = [];
108
+ const rawTarget = msg.params[0] ?? '';
109
+
110
+ if (serverTarget !== undefined) {
111
+ effects.push(
112
+ Effect.send(ctx.connId, [
113
+ numericLine(ctx, Numerics.ERR_NOSUCHSERVER, NOSUCHSERVER_TRAILING_TEXT, serverTarget),
114
+ ]),
115
+ );
116
+ return { effects };
117
+ }
118
+
119
+ const isOper = ctx.connection.userModes.oper;
120
+ if (target !== undefined && isOper) {
121
+ // Per-connection detail is oper-gated, which also implicitly enforces
122
+ // `+i` invisibility for non-opers (they never receive detail). Opers
123
+ // bypass `+i` per RFC 2812 §4.6.3 and always see the matched conn.
124
+ const code = target.userModes.oper ? Numerics.RPL_TRACEOPERATOR : Numerics.RPL_TRACEUSER;
125
+ const cls = target.userModes.oper ? 'Oper' : 'User';
126
+ effects.push(Effect.send(ctx.connId, [traceDetailLine(ctx, code, cls, target)]));
127
+ }
128
+
129
+ const endMiddle = rawTarget.length > 0 ? rawTarget : '';
130
+ effects.push(
131
+ Effect.send(ctx.connId, [
132
+ numericLine(ctx, Numerics.RPL_ENDOFTRACE, ENDOFTRACE_TRAILING_TEXT, endMiddle),
133
+ ]),
134
+ );
135
+
136
+ return { effects };
137
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Pure reducer for the IRC `USERHOST` command.
3
+ *
4
+ * PLAN §2.1 location-of-authority: USERHOST is read-only — it queries nick
5
+ * → connection state and emits a single `302 RPL_USERHOST` line. The reducer
6
+ * itself does not perform lookups (those are async and belong to the actor /
7
+ * runtime layer); it receives the pre-resolved snapshots and formats the
8
+ * reply.
9
+ *
10
+ * Wire format (RFC 2812 §5.5):
11
+ * - `USERHOST <nick>{ <nick>}` (up to {@link ServerConfig.maxTargetsPerCommand})
12
+ * - `302 RPL_USERHOST`:
13
+ * `:<server> 302 <nick> :<reply1> <reply2> …`
14
+ * - Each reply: `<nick>[*]=<+|-><user>@<host>`
15
+ * - `*` between nick and `=` marks an IRC operator.
16
+ * - `+` means available; `-` means away.
17
+ * - Offline nicks are omitted from the reply entirely.
18
+ *
19
+ * The actor resolves each requested nick via `runtime.lookupNick` +
20
+ * `runtime.getConnection` before calling this reducer, passing `null` for
21
+ * nicks that are offline.
22
+ */
23
+
24
+ import { Effect } from '../effects.js';
25
+ import type { Effect as EffectType, RawLine } from '../effects.js';
26
+ import { Numerics } from '../protocol/numerics.js';
27
+ import type { ConnSnapshot, ConnectionState } from '../state/connection.js';
28
+ import type { Ctx, ReducerResult } from '../types.js';
29
+
30
+ /**
31
+ * Formats a single USERHOST reply token for a resolved connection snapshot.
32
+ *
33
+ * `nick[*]=[+|-]user@host` — `*` for operators, `+` for available, `-` for
34
+ * away. Returns `null` for snapshots missing the required `nick` / `user` /
35
+ * `host` fields (should not happen for registered connections, but guarded).
36
+ */
37
+ export function formatUserhostReply(snap: ConnSnapshot): string | null {
38
+ if (snap.nick === undefined || snap.user === undefined || snap.host === undefined) {
39
+ return null;
40
+ }
41
+ const operMark = snap.userModes.oper ? '*' : '';
42
+ const awayMark = snap.away !== undefined ? '-' : '+';
43
+ return `${snap.nick}${operMark}=${awayMark}${snap.user}@${snap.host}`;
44
+ }
45
+
46
+ /**
47
+ * Handles `USERHOST <nick>{ <nick>}`.
48
+ *
49
+ * `resolved` maps each requested nick (original case) to its snapshot, or
50
+ * `null` when the nick is offline. The reducer formats each online entry into
51
+ * a USERHOST reply token and emits a single `302` line. Offline nicks are
52
+ * omitted. The number of queried nicks is capped at
53
+ * `ctx.serverConfig.maxTargetsPerCommand`.
54
+ *
55
+ * Signature is bespoke (not {@link Reducer}) because the actor must resolve
56
+ * the nicks asynchronously before formatting — mirrors `whoReducer` /
57
+ * `whoisReducer`.
58
+ */
59
+ export function userhostReducer(
60
+ resolved: ReadonlyMap<string, ConnSnapshot | null>,
61
+ msg: { params: readonly string[] },
62
+ ctx: Ctx,
63
+ ): ReducerResult<ConnectionState> {
64
+ ctx.connection.lastSeen = ctx.clock.now();
65
+
66
+ const cap = ctx.serverConfig.maxTargetsPerCommand;
67
+ const requested = msg.params.slice(0, cap);
68
+ const tokens: string[] = [];
69
+ for (const nick of requested) {
70
+ if (nick.length === 0) continue;
71
+ const snap = resolved.get(nick);
72
+ if (snap === null || snap === undefined) continue;
73
+ const token = formatUserhostReply(snap);
74
+ if (token !== null) tokens.push(token);
75
+ }
76
+
77
+ const ircNick = ctx.connection.nick ?? '*';
78
+ const codeStr = Numerics.RPL_USERHOST.toString().padStart(3, '0');
79
+ const trailing = tokens.join(' ');
80
+ const line: RawLine = { text: `:${ctx.serverName} ${codeStr} ${ircNick} :${trailing}` };
81
+
82
+ const effects: EffectType[] = [Effect.send(ctx.connId, [line])];
83
+ return { state: ctx.connection, effects };
84
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Pure reducer for the IRC `WALLOPS` command (oper-only global broadcast).
3
+ *
4
+ * PLAN §2.1 location-of-authority: WALLOPS runs in the Connection entity.
5
+ * The reducer is pure — it enforces the oper gate, validates the message,
6
+ * and emits a single {@link BroadcastWallopsEffect}. The bound runtime's
7
+ * `broadcastWallops` method owns the cross-connection enumeration that
8
+ * the gate cannot observe purely (it does not see which other
9
+ * connections carry the `+w` user mode).
10
+ *
11
+ * Wire format (RFC 2812 §4.7):
12
+ * - `WALLOPS :<message>`
13
+ * - `481 ERR_NOPRIVILEGES`:
14
+ * `:<server> 481 <nick> :Permission Denied - You're not an IRC operator`
15
+ * - `412 ERR_NOTEXTTOSEND`:
16
+ * `:<server> 412 <nick> :No text to send`
17
+ *
18
+ * Behaviour:
19
+ * - Non-oper sender → `481` (checked before the message is inspected, so
20
+ * a non-oper cannot probe the command shape).
21
+ * - Empty / missing message → `412 ERR_NOTEXTTOSEND`. We pick `412` over
22
+ * `461 ERR_NEEDMOREPARAMS` to match the message-bearing commands
23
+ * PRIVMSG/NOTICE, which use `412` for "no text to send". WALLOPS is a
24
+ * single-param message command, so the semantically closer numeric wins.
25
+ * - Otherwise emit `BroadcastWallops([:<oper> WALLOPS :<message>], sender)`
26
+ * so the runtime fans the line out to every `+w` connection.
27
+ *
28
+ * Delivery / skip-self: the originator is ALWAYS passed as `except`. This
29
+ * matches NOTICE/PRIVMSG channel-broadcast semantics (the sender never
30
+ * receives their own message back) and avoids a redundant echo to the
31
+ * oper who just typed it. The `+w` user mode is the *recipient* gate,
32
+ * owned by the runtime — the sender's own `+w` is therefore irrelevant
33
+ * under this default. An oper who wants a delivery receipt can query
34
+ * logs. (RFC leaves delivery to `+w` regardless of oper; we deliberately
35
+ * diverge to match the rest of the codebase's broadcast primitives.)
36
+ *
37
+ * The `+w` user mode is accepted by the MODE reducer (`w → 'wallops'`)
38
+ * and advertised in `004 RPL_MYINFO`, but no command consumed it before
39
+ * this one — setting `+w` used to be a silent no-op. This reducer lights
40
+ * up the consumer.
41
+ */
42
+
43
+ import { Effect } from '../effects.js';
44
+ import type { RawLine } from '../effects.js';
45
+ import { Numerics } from '../protocol/numerics.js';
46
+ import { hostmaskOf } from '../state/connection.js';
47
+ import type { ConnectionState } from '../state/connection.js';
48
+ import type { Ctx, Reducer } from '../types.js';
49
+
50
+ /** Fixed text emitted for `481 ERR_NOPRIVILEGES` (matches `kill.ts` / `mode.ts`). */
51
+ const NOPRIVILEGES_TRAILING_TEXT = "Permission Denied - You're not an IRC operator";
52
+
53
+ /** Fixed text emitted for `412 ERR_NOTEXTTOSEND` (matches `privmsg.ts`). */
54
+ const NOTEXTTOSEND_TRAILING_TEXT = 'No text to send';
55
+
56
+ /**
57
+ * Renders the `:<source> WALLOPS :<message>` wire line that every `+w`
58
+ * connection receives. `<source>` is the oper's full hostmask
59
+ * (`nick!user@host`), consistent with how PRIVMSG/NOTICE/QUIT attribute
60
+ * user-originated messages. When the hostmask is incomplete (e.g. no
61
+ * `user`/`host` yet, which should not happen for a registered oper but is
62
+ * defended against) the bare nick is used, falling back to `*` only when
63
+ * no nick is set at all.
64
+ */
65
+ export function wallopsLine(state: ConnectionState, message: string): RawLine {
66
+ const source = hostmaskOf(state) ?? state.nick ?? '*';
67
+ return { text: `:${source} WALLOPS :${message}` };
68
+ }
69
+
70
+ /**
71
+ * Handles `WALLOPS :<message>`.
72
+ *
73
+ * Enforces the oper gate, stamps `lastSeen`, and on success emits a single
74
+ * {@link BroadcastWallopsEffect} addressed to every `+w` connection. The
75
+ * sender is always excluded (skip-self). The reducer never performs IO.
76
+ */
77
+ export const wallopsReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
78
+ state.lastSeen = ctx.clock.now();
79
+
80
+ if (!state.userModes.oper) {
81
+ return {
82
+ state,
83
+ effects: [
84
+ Effect.send(ctx.connId, [
85
+ numericLine(ctx, Numerics.ERR_NOPRIVILEGES, NOPRIVILEGES_TRAILING_TEXT),
86
+ ]),
87
+ ],
88
+ };
89
+ }
90
+
91
+ const message = msg.params[0];
92
+ if (message === undefined || message.length === 0) {
93
+ return {
94
+ state,
95
+ effects: [
96
+ Effect.send(ctx.connId, [
97
+ numericLine(ctx, Numerics.ERR_NOTEXTTOSEND, NOTEXTTOSEND_TRAILING_TEXT),
98
+ ]),
99
+ ],
100
+ };
101
+ }
102
+
103
+ return {
104
+ state,
105
+ effects: [Effect.broadcastWallops([wallopsLine(state, message)], ctx.connId)],
106
+ };
107
+ };
108
+
109
+ /**
110
+ * Builds a `:<server> <code> <nick> :<trailing>` line. The nick falls back
111
+ * to `*` when the connection has not yet registered one, matching the rest
112
+ * of the codebase's numeric formatting.
113
+ */
114
+ function numericLine(ctx: Ctx, code: number, trailing: string): RawLine {
115
+ const nick = ctx.connection.nick ?? '*';
116
+ const codeStr = code.toString().padStart(3, '0');
117
+ return { text: [`:${ctx.serverName}`, codeStr, nick, `:${trailing}`].join(' ') };
118
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Pure reducer for the IRC `WHOWAS` command.
3
+ *
4
+ * PLAN §2.1 location-of-authority: WHOWAS reads the nick-history store but
5
+ * does not mutate connection or channel state. The actor layer resolves
6
+ * `ctx.history` (a synchronously-readable {@link NickHistoryStore}) and
7
+ * passes it here. The requester is `ctx.connection`.
8
+ *
9
+ * Wire format (RFC 1459/2812 §3.6.3):
10
+ * - `314 RPL_WHOWASUSER`:
11
+ * `:<server> 314 <nick> <target> <user> <host> * :<real>`
12
+ * - `369 RPL_ENDOFWHOWAS`:
13
+ * `:<server> 369 <nick> <target> :End of WHOWAS`
14
+ * - `406 ERR_WASNOSUCHNICK`:
15
+ * `:<server> 406 <nick> <target> :There was no such nickname`
16
+ * - `431 ERR_NONICKNAMEGIVEN`:
17
+ * `:<server> 431 <nick> :No nickname given`
18
+ *
19
+ * The reducer takes a bespoke signature (mirroring {@link whoisReducer})
20
+ * because it queries runtime-provided state (the history store) rather than
21
+ * the `Reducer<S>` state argument.
22
+ */
23
+
24
+ import { Effect } from '../effects.js';
25
+ import type { Effect as EffectType, RawLine } from '../effects.js';
26
+ import type { NickHistoryEntry, NickHistoryStore } from '../ports.js';
27
+ import { Numerics } from '../protocol/numerics.js';
28
+ import type { Ctx } from '../types.js';
29
+
30
+ /** Formats a numeric line addressed to the requester. */
31
+ function numericLine(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
32
+ const nick = ctx.connection.nick ?? '*';
33
+ const codeStr = code.toString().padStart(3, '0');
34
+ const parts = [`:${ctx.serverName}`, codeStr, nick];
35
+ if (middle !== undefined) parts.push(middle);
36
+ parts.push(`:${trailing}`);
37
+ return { text: parts.join(' ') };
38
+ }
39
+
40
+ /** Builds the `314 RPL_WHOWASUSER` line for one sign-off entry. */
41
+ function whowasUserLine(ctx: Ctx, entry: NickHistoryEntry): RawLine {
42
+ const requesterNick = ctx.connection.nick ?? '*';
43
+ const nick = entry.nick;
44
+ const user = entry.username ?? '?';
45
+ const host = entry.hostname ?? '?';
46
+ const real = entry.realname ?? nick;
47
+ const code = Numerics.RPL_WHOWASUSER.toString().padStart(3, '0');
48
+ return { text: `:${ctx.serverName} ${code} ${requesterNick} ${nick} ${user} ${host} * :${real}` };
49
+ }
50
+
51
+ /**
52
+ * Handles `WHOWAS <nick>{,<nick>} [<count>]`.
53
+ *
54
+ * The actor layer resolves `ctx.history` (absent when no nick-history store
55
+ * is bound) and passes the store (or `undefined`) here. For each queried
56
+ * nick the reducer emits one `314 RPL_WHOWASUSER` per matching history
57
+ * entry (capped at `count`, most-recent-first), then exactly one
58
+ * `369 RPL_ENDOFWHOWAS`. When a nick has no matches it emits
59
+ * `406 ERR_WASNOSUCHNICK` before the `369`. When no nick argument is
60
+ * supplied it emits `431 ERR_NONICKNAMEGIVEN` and returns.
61
+ */
62
+ export function whowasReducer(
63
+ history: NickHistoryStore | undefined,
64
+ msg: { command: string; params: string[] },
65
+ ctx: Ctx,
66
+ ): { effects: EffectType[] } {
67
+ ctx.connection.lastSeen = ctx.clock.now();
68
+
69
+ const effects: EffectType[] = [];
70
+ const nickParam = msg.params[0];
71
+ if (nickParam === undefined || nickParam.length === 0) {
72
+ effects.push(
73
+ Effect.send(ctx.connId, [
74
+ numericLine(ctx, Numerics.ERR_NONICKNAMEGIVEN, 'No nickname given'),
75
+ ]),
76
+ );
77
+ return { effects };
78
+ }
79
+
80
+ const countParam = msg.params[1];
81
+ const nicks = nickParam.split(',').filter((n) => n.length > 0);
82
+ const count = countParam !== undefined ? parseCount(countParam) : Number.POSITIVE_INFINITY;
83
+
84
+ for (const nick of nicks) {
85
+ const matches = history === undefined ? [] : history.query(nick, count);
86
+ for (const entry of matches) {
87
+ effects.push(Effect.send(ctx.connId, [whowasUserLine(ctx, entry)]));
88
+ }
89
+ if (matches.length === 0) {
90
+ effects.push(
91
+ Effect.send(ctx.connId, [
92
+ numericLine(ctx, Numerics.ERR_WASNOSUCHNICK, 'There was no such nickname', nick),
93
+ ]),
94
+ );
95
+ }
96
+ effects.push(
97
+ Effect.send(ctx.connId, [numericLine(ctx, Numerics.RPL_ENDOFWHOWAS, 'End of WHOWAS', nick)]),
98
+ );
99
+ }
100
+
101
+ return { effects };
102
+ }
103
+
104
+ /**
105
+ * Parses the `WHOWAS` count argument. RFC 1459 allows a non-positive or
106
+ * missing count to mean "unlimited"; a malformed (non-numeric) count is
107
+ * treated as unlimited as well so the command still answers.
108
+ */
109
+ function parseCount(raw: string): number {
110
+ const n = Number.parseInt(raw, 10);
111
+ if (!Number.isFinite(n) || n <= 0) return Number.POSITIVE_INFINITY;
112
+ return n;
113
+ }
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import { z } from 'zod';
20
+ import { BUILD_DATE } from './build-info.js';
20
21
 
21
22
  /**
22
23
  * Schema for a single IRC operator credential.
@@ -113,6 +114,26 @@ export const DEFAULT_FLOOD_CONTROL_CONFIG: {
113
114
  disconnectThreshold: 0,
114
115
  };
115
116
 
117
+ /**
118
+ * Default server version surfaced in `002`/`004`/`351`/`371` when a deployment
119
+ * does not supply one. Kept in sync with `irc-core/package.json` `version`;
120
+ * adapters inject the deploy-time version via the `serverVersion` config
121
+ * knob (see the per-adapter config loaders).
122
+ */
123
+ export const DEFAULT_SERVER_VERSION = '0.3.0';
124
+
125
+ /**
126
+ * Default "created" text for `003 RPL_CREATED` when a deployment does not
127
+ * supply one. Baked into `dist/index.js` at build time by
128
+ * `scripts/generate-build-info.mjs` (run via `prebuild`); the value is the
129
+ * ISO timestamp of the build that produced the artifact, so a deployed
130
+ * Worker/Lambda reports the date it was built rather than a static
131
+ * placeholder. Production deployments may still override via the `createdAt`
132
+ * config knob (a human-readable string or an epoch-ms number that the
133
+ * reducer formats).
134
+ */
135
+ export const DEFAULT_CREATED_TEXT: string = BUILD_DATE;
136
+
116
137
  /**
117
138
  * The full server-config schema. Optional fields default to deployment-
118
139
  * sensible values so a minimal `{ serverName, networkName }` config is
@@ -124,6 +145,22 @@ export const ServerConfigSchema = z.object({
124
145
  /** Network name advertised in `005 NETWORK=...` and WHOIS replies. */
125
146
  networkName: z.string().min(1),
126
147
 
148
+ /**
149
+ * Server version surfaced in the `002`/`004` welcome numerics, `351
150
+ * RPL_VERSION`, and `371 RPL_INFO`. Defaults to the irc-core package
151
+ * version ({@link DEFAULT_SERVER_VERSION}); deployments inject the
152
+ * deploy-time version (e.g. via a build-time constant or env var).
153
+ */
154
+ serverVersion: z.string().min(1).default(DEFAULT_SERVER_VERSION),
155
+
156
+ /**
157
+ * "Created" text for `003 RPL_CREATED` (`This server was created in
158
+ * <text>`). Either a human-readable string used verbatim, or an epoch-ms
159
+ * number that the reducer formats as a UTC timestamp. Defaults to
160
+ * {@link DEFAULT_CREATED_TEXT}; deployments inject the deploy/build time.
161
+ */
162
+ createdAt: z.union([z.string().min(1), z.number()]).default(DEFAULT_CREATED_TEXT),
163
+
127
164
  /**
128
165
  * Server password. Empty string or undefined disables the password
129
166
  * gate. When set, every connection must supply the same value via
@@ -219,6 +256,13 @@ export const ServerConfigSchema = z.object({
219
256
  channelLen: z.number().int().positive().default(50),
220
257
  /** Maximum topic length (advertised as `TOPICLEN=`). */
221
258
  topicLen: z.number().int().nonnegative().default(390),
259
+ /**
260
+ * Maximum realname (GECOS) length enforced by `SETNAME`. There is no
261
+ * standard ISUPPORT token for this; registration-time `USER` does not
262
+ * enforce a cap, so the knob is optional on {@link ServerConfig} and the
263
+ * reducer falls back to a built-in default when unset.
264
+ */
265
+ realnameLen: z.number().int().nonnegative().default(50),
222
266
  /** Default QUIT reason when the client disconnects without one. */
223
267
  quitMessage: z.string().default('Client Quit'),
224
268
 
@@ -268,3 +312,24 @@ export const DEFAULT_SERVER_CONFIG: ParsedServerConfig = ServerConfigSchema.pars
268
312
  serverName: 'irc.example.com',
269
313
  networkName: 'ServerlessIRCd',
270
314
  });
315
+
316
+ /**
317
+ * Resolves the effective server version for wire output: the configured
318
+ * value when present, otherwise {@link DEFAULT_SERVER_VERSION}. Used by the
319
+ * registration and server-info reducers so they share one source of truth.
320
+ */
321
+ export function resolveServerVersion(cfg: { readonly serverVersion?: string | undefined }): string {
322
+ return cfg.serverVersion ?? DEFAULT_SERVER_VERSION;
323
+ }
324
+
325
+ /**
326
+ * Formats the `003 RPL_CREATED` text from the configured `createdAt`:
327
+ * a string is used verbatim, a number is treated as epoch-ms and rendered
328
+ * as a UTC timestamp, and `undefined` falls back to
329
+ * {@link DEFAULT_CREATED_TEXT}.
330
+ */
331
+ export function formatCreatedAt(createdAt: string | number | undefined): string {
332
+ if (createdAt === undefined) return DEFAULT_CREATED_TEXT;
333
+ if (typeof createdAt === 'number') return new Date(createdAt).toUTCString();
334
+ return createdAt;
335
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Shared SASL credential hashing primitives.
3
+ *
4
+ * Both the AWS adapter (`DynamoAccountStore`) and the CF adapter
5
+ * (`D1AccountStore`) store SASL PLAIN credentials as **scrypt hashes**.
6
+ * These helpers live in `irc-core` so both adapters share one hashing
7
+ * implementation — the AWS path previously owned the only copy.
8
+ *
9
+ * Uses `node:crypto` (`scryptSync`), which is available on Node (AWS Lambda)
10
+ * natively and on Cloudflare Workers via the `nodejs_compat` flag. The public
11
+ * API surface is platform-agnostic: salts are passed as `Uint8Array` and the
12
+ * stored `HashedAccountCredential` carries only base64 `string` fields, so
13
+ * consuming packages never need `@types/node` to use these helpers.
14
+ */
15
+
16
+ import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
17
+ import type { AccountStore, SaslPayload, SaslResult } from './ports.js';
18
+
19
+ const SCRYPT_KEY_LEN = 64;
20
+ const SALT_LEN = 16;
21
+
22
+ /**
23
+ * A single SASL PLAIN credential stored as a scrypt hash.
24
+ *
25
+ * Every field is a persistence-backend attribute — this object IS the table
26
+ * row (DynamoDB `Accounts` item / D1 `accounts` row, keyed by `account`).
27
+ * `algorithm` is retained so future migrations to Argon2/bcrypt can be
28
+ * detected and handled gracefully by {@link verifyHashedPassword}.
29
+ */
30
+ export interface HashedAccountCredential {
31
+ readonly account: string;
32
+ readonly algorithm: 'scrypt';
33
+ readonly salt: string;
34
+ readonly hash: string;
35
+ }
36
+
37
+ /**
38
+ * Hashes a plaintext password into a {@link HashedAccountCredential}
39
+ * suitable for writing to the accounts backend (`putAccountCredential` /
40
+ * `seed-cf-accounts`).
41
+ *
42
+ * Uses `scryptSync` (memory-hard, GPU-resistant) with a random salt.
43
+ * Pass `{ salt }` for deterministic test scenarios; pass `{ keyLen }` to
44
+ * override the default 64-byte derived-key length.
45
+ *
46
+ * @param opts.salt Optional salt (deterministic for tests). When omitted a
47
+ * fresh cryptographically-random salt is generated. Typed as `Uint8Array`
48
+ * to keep the public API free of node-specific `Buffer` types.
49
+ */
50
+ export function hashAccountCredential(
51
+ username: string,
52
+ password: string,
53
+ opts?: { salt?: Uint8Array; keyLen?: number },
54
+ ): HashedAccountCredential {
55
+ const salt = opts?.salt ?? randomBytes(SALT_LEN);
56
+ const keyLen = opts?.keyLen ?? SCRYPT_KEY_LEN;
57
+ const hash = scryptSync(password, salt, keyLen);
58
+ return {
59
+ account: username,
60
+ algorithm: 'scrypt',
61
+ salt: Buffer.from(salt).toString('base64'),
62
+ hash: hash.toString('base64'),
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Verifies a plaintext password against a {@link HashedAccountCredential}
68
+ * using `timingSafeEqual` (no short-circuit on first mismatched byte).
69
+ *
70
+ * Returns `false` for malformed entries rather than throwing — a corrupt
71
+ * row should not crash the SASL exchange.
72
+ */
73
+ export function verifyHashedPassword(password: string, entry: HashedAccountCredential): boolean {
74
+ if (entry.algorithm !== 'scrypt') return false;
75
+ const salt = Buffer.from(entry.salt, 'base64');
76
+ const expected = Buffer.from(entry.hash, 'base64');
77
+ if (expected.length === 0) return false;
78
+ const computed = scryptSync(password, salt, expected.length);
79
+ return computed.length === expected.length && timingSafeEqual(computed, expected);
80
+ }
81
+
82
+ /**
83
+ * Synchronous {@link AccountStore} backed by pre-loaded hashed credentials.
84
+ *
85
+ * Construct with entries produced by {@link hashAccountCredential} (for unit
86
+ * tests / seeding) or pre-loaded from a persistence backend at boot. Both
87
+ * adapters use this class:
88
+ *
89
+ * - AWS: `loadDynamoAccountStore` scans the `Accounts` table at Lambda
90
+ * cold start and constructs this store; `DynamoAccountStore` is a
91
+ * re-export alias for back-compat.
92
+ * - CF: `loadD1AccountStore` queries the D1 `accounts` table at
93
+ * `ConnectionDO` construction (mirroring the two-phase load pattern).
94
+ *
95
+ * The class has zero backend coupling — it works equally well with entries
96
+ * from any source. The `AccountStore.verify` port stays synchronous, so
97
+ * the async pre-load MUST complete before the first frame dispatches.
98
+ */
99
+ export class HashedAccountStore implements AccountStore {
100
+ private readonly entries: ReadonlyMap<string, HashedAccountCredential>;
101
+
102
+ constructor(entries: ReadonlyArray<HashedAccountCredential>) {
103
+ this.entries = new Map(entries.map((e) => [e.account, e]));
104
+ }
105
+
106
+ verify(mech: string, payload: SaslPayload): SaslResult {
107
+ if (mech.toUpperCase() !== 'PLAIN' || payload.kind !== 'PLAIN') {
108
+ return { ok: false, reason: `unsupported mechanism: ${mech}` };
109
+ }
110
+ const entry = this.entries.get(payload.username);
111
+ if (entry === undefined) {
112
+ return { ok: false, reason: 'invalid credentials' };
113
+ }
114
+ if (!verifyHashedPassword(payload.password, entry)) {
115
+ return { ok: false, reason: 'invalid credentials' };
116
+ }
117
+ return { ok: true, account: payload.username };
118
+ }
119
+
120
+ /** Number of loaded accounts (diagnostics / logging). */
121
+ get size(): number {
122
+ return this.entries.size;
123
+ }
124
+ }