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
@@ -8,6 +8,11 @@
8
8
  * • a `ws.WebSocketServer` on {@link StartServerOptions.port} — the
9
9
  * original WebSocket transport (one IRC message per text frame, or N
10
10
  * `\r\n`-joined messages per frame). Programmatic test clients use this.
11
+ * When a client negotiates an IRCv3 subprotocol
12
+ * (`text.ircv3.net` / `binary.ircv3.net`) the connection switches to
13
+ * spec framing: one IRC message per WebSocket message with no trailing
14
+ * CR-LF, a 510-byte inbound budget, and (for text) lone-surrogate
15
+ * sanitization. Un-negotiated clients keep the legacy framing.
11
16
  * • an optional `node:net` TCP server on {@link StartServerOptions.tcpPort}
12
17
  * — the RFC-style byte stream that real IRC clients (WeeChat, HexChat,
13
18
  * IRCCloud) speak. The TCP path uses {@link LineScanner} to reassemble
@@ -35,13 +40,25 @@ import {
35
40
  ConsoleLogger,
36
41
  InMemoryAccountStore,
37
42
  InMemoryMessageStore,
43
+ InMemoryNickHistoryStore,
38
44
  LogLevel,
39
45
  type Logger,
40
46
  type MessageStore,
47
+ type NickHistoryStore,
41
48
  type RawLine,
49
+ type ServerConfig,
50
+ WS_SUBPROTO_BINARY,
51
+ WS_SUBPROTO_TEXT,
52
+ type WsFrameMode,
53
+ type WsSubprotocol,
42
54
  createConnection,
55
+ frameToLines,
56
+ isWithinWsByteBudget,
57
+ sanitizeForTextMode,
58
+ selectSubprotocol,
59
+ wsFrameModeFor,
43
60
  } from '@serverless-ircd/irc-core';
44
- import { ConnectionActor } from '@serverless-ircd/irc-server';
61
+ import { ConnectionActor, type Transport } from '@serverless-ircd/irc-server';
45
62
  import { WebSocket, WebSocketServer } from 'ws';
46
63
  import { loadServerConfigFromCliArgs } from './config-loader.js';
47
64
  import { LineScanner } from './line-scanner.js';
@@ -71,6 +88,28 @@ type ResolvedServerConfig = Omit<
71
88
  | 'saslAccounts'
72
89
  >;
73
90
 
91
+ /**
92
+ * Builds the reducer-facing {@link ServerConfig} from the resolved local-cli
93
+ * config. Shared by {@link attachConnection} (initial config) and the
94
+ * `InMemoryRuntime` reload source (REHASH) so a reload returns the same
95
+ * shape the actor was bootstrapped with — keeping the two paths consistent.
96
+ */
97
+ function serverConfigFromResolved(cfg: ResolvedServerConfig): ServerConfig {
98
+ return {
99
+ serverName: cfg.serverName,
100
+ networkName: cfg.networkName,
101
+ maxChannelsPerUser: cfg.maxChannelsPerUser,
102
+ maxTargetsPerCommand: cfg.maxTargetsPerCommand,
103
+ maxListEntries: cfg.maxListEntries,
104
+ nickLen: cfg.nickLen,
105
+ channelLen: cfg.channelLen,
106
+ topicLen: cfg.topicLen,
107
+ quitMessage: cfg.quitMessage,
108
+ ...(cfg.serverPassword !== undefined ? { serverPassword: cfg.serverPassword } : {}),
109
+ ...(cfg.cloaking !== undefined ? { cloaking: cfg.cloaking } : {}),
110
+ };
111
+ }
112
+
74
113
  /** Default per-IP / per-user caps used when callers don't override. */
75
114
  const DEFAULT_MAX_CONNECTIONS_PER_IP = 10;
76
115
  const DEFAULT_MAX_CONNECTIONS_PER_USER = 5;
@@ -162,6 +201,12 @@ export interface LocalServer {
162
201
  * one client is replayable by another. Exposed for test assertions.
163
202
  */
164
203
  readonly messages: MessageStore;
204
+ /**
205
+ * The per-process nick-history store backing `WHOWAS`. Shared across
206
+ * every connection so a nick signed off by one client is queryable by
207
+ * another. Exposed for test assertions.
208
+ */
209
+ readonly history: NickHistoryStore;
165
210
  close(): Promise<void>;
166
211
  /**
167
212
  * Test-only seam: live server-side WebSockets. Exposed so tests can
@@ -208,7 +253,7 @@ const DEFAULT_CONFIG: Omit<
208
253
  * the three generators once at module load, not per-connection. No reducer in
209
254
  * the current codebase actually invokes `batchId` / `nonce` / `sessionId`;
210
255
  * they are forward-looking seams for SASL / BATCH / context-restoration
211
- * tickets and are exercised by irc-core's own unit tests via test fakes.
256
+ * and are exercised by irc-core's own unit tests via test fakes.
212
257
  *
213
258
  * Exported so `apps/local-cli` can ship a focused unit test asserting each
214
259
  * generator returns a fresh UUID-shaped string (defending against future
@@ -304,6 +349,10 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
304
349
  const runtime = new InMemoryRuntime({
305
350
  clock: { now: () => Date.now() },
306
351
  ...(admission !== undefined ? { admission } : {}),
352
+ // REHASH reload source: re-derive the live ServerConfig from the
353
+ // resolved config so an oper's `REHASH` re-fetches the latest values
354
+ // (rotated oper creds, MOTD, limits) without restarting the process.
355
+ configLoader: async () => serverConfigFromResolved(cfg),
307
356
  });
308
357
 
309
358
  // Per-process chat-history store: one ring buffer shared by every
@@ -311,6 +360,12 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
311
360
  // is replayable when bob queries CHATHISTORY). The cap is config-driven.
312
361
  const messages = new InMemoryMessageStore(opts.historyMaxPerChannel);
313
362
 
363
+ // Per-process nick-history store: one TTL-buffer shared by every
364
+ // connection so cross-connection WHOWAS works (a nick alice quits is
365
+ // queryable by bob). Driven by the real-system clock so TTL eviction
366
+ // matches wall time.
367
+ const history = new InMemoryNickHistoryStore({ now: () => Date.now() });
368
+
314
369
  // SASL account store: seeded from config so `AUTHENTICATE PLAIN` works
315
370
  // end-to-end. `undefined` (no accounts configured) leaves the actor's
316
371
  // `ctx.accounts` unset, preserving the no-store behaviour.
@@ -319,8 +374,23 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
319
374
  ? new InMemoryAccountStore(cfg.saslAccounts)
320
375
  : undefined;
321
376
 
322
- const wss = new WebSocketServer({ port: opts.port, host: hostname });
377
+ const wss = new WebSocketServer({
378
+ port: opts.port,
379
+ host: hostname,
380
+ // IRCv3 WebSocket subprotocol negotiation: select the first supported
381
+ // offer in client-preference order (core selector), or fall through to
382
+ // legacy framing when none match. The `ws` library only invokes this
383
+ // when the client offered protocols; returning the selected name echoes
384
+ // it, returning false completes the handshake with no
385
+ // Sec-WebSocket-Protocol header (legacy) — it never rejects.
386
+ handleProtocols: (protocols: Set<string>): string | false =>
387
+ selectSubprotocol([...protocols]) ?? false,
388
+ });
323
389
  const wsConnections = new Map<WebSocket, ConnectionBindings>();
390
+ // Per-connection negotiated IRCv3 subprotocol registry (null = legacy
391
+ // framing). Mirrors the selection made in `handleProtocols`; the resolved
392
+ // frame mode is also captured in the per-connection handlers below.
393
+ const wsModes = new Map<WebSocket, WsSubprotocol | null>();
324
394
 
325
395
  wss.on('connection', (ws, req) => {
326
396
  // Admission gate: refuse the connection BEFORE we attach an actor / emit
@@ -336,18 +406,50 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
336
406
  const admissionRecordId = decision.recordId;
337
407
  runtime.commitAdmission(ip, undefined, admissionRecordId);
338
408
 
339
- const { state, actor } = attachConnection(runtime, cfg, messages, accounts, {
409
+ // Resolve the negotiated IRCv3 subprotocol (echoed by handleProtocols)
410
+ // and derive the frame mode. `ws.protocol` is '' when none negotiated.
411
+ const subproto: WsSubprotocol | null =
412
+ ws.protocol === WS_SUBPROTO_TEXT || ws.protocol === WS_SUBPROTO_BINARY
413
+ ? (ws.protocol as WsSubprotocol)
414
+ : null;
415
+ wsModes.set(ws, subproto);
416
+ const frameMode = wsFrameModeFor(subproto ?? undefined);
417
+
418
+ const { state, actor } = attachConnection(runtime, cfg, messages, accounts, history, {
340
419
  sendText: (text) => {
341
- if (ws.readyState === WebSocket.OPEN) ws.send(text);
420
+ if (ws.readyState !== WebSocket.OPEN) return;
421
+ // binary.ircv3.net connections exchange binary WebSocket frames;
422
+ // text/legacy connections send UTF-8 text frames.
423
+ ws.send(frameMode === 'spec-binary' ? Buffer.from(text, 'utf8') : text);
342
424
  },
343
425
  closeTransport: () => ws.close(),
344
426
  admissionRecordId,
345
427
  sourceHost: ip,
428
+ frameMode,
429
+ // Spec-mode framing: one IRC message per WS message (strip a single
430
+ // trailing CR-LF, never split on an embedded line break). Legacy
431
+ // connections fall through to the actor's default WsTextFrameTransport.
432
+ ...(frameMode === 'legacy'
433
+ ? {}
434
+ : { actorTransport: { feed: (chunk: string) => frameToLines(chunk, frameMode) } }),
346
435
  });
347
436
  wsConnections.set(ws, { state, actor });
348
437
 
349
438
  ws.on('message', (data) => {
350
439
  const text = data.toString('utf8');
440
+ // Spec-mode inbound: enforce the IRCv3 510-byte message budget and
441
+ // sanitize lone surrogates for text frames before framing. Legacy
442
+ // frames pass through unchanged (the 512-byte line cap is enforced in
443
+ // the parser, as before).
444
+ if (frameMode === 'spec-text' || frameMode === 'spec-binary') {
445
+ if (!isWithinWsByteBudget(text)) {
446
+ ws.close(1009, 'Message Too Big');
447
+ return;
448
+ }
449
+ const inbound = frameMode === 'spec-text' ? sanitizeForTextMode(text) : text;
450
+ actor.receiveTextFrame(inbound).catch((err) => logError('actor failure', err));
451
+ return;
452
+ }
351
453
  // Fire-and-forget: the actor's reducer pipeline is synchronous through
352
454
  // to dispatch, and dispatch awaits each runtime call in order. Errors
353
455
  // propagate to the catch here and are logged (never crash the server).
@@ -362,6 +464,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
362
464
  // admission record (if any) so per-IP / per-user counters stay accurate.
363
465
  const cleanup = (): void => {
364
466
  wsConnections.delete(ws);
467
+ wsModes.delete(ws);
365
468
  runtime.unregisterConnection(state.id);
366
469
  };
367
470
  ws.on('close', cleanup);
@@ -413,7 +516,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
413
516
  const admissionRecordId = decision.recordId;
414
517
  runtime.commitAdmission(ip, undefined, admissionRecordId);
415
518
 
416
- const { state, actor } = attachConnection(runtime, cfg, messages, accounts, {
519
+ const { state, actor } = attachConnection(runtime, cfg, messages, accounts, history, {
417
520
  sendText: (text) => {
418
521
  if (socket.writable) socket.write(text);
419
522
  },
@@ -478,6 +581,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
478
581
  hostname,
479
582
  runtime,
480
583
  messages,
584
+ history,
481
585
  testSockets: wss.clients,
482
586
  testTcpSockets: tcpSockets,
483
587
  close: () =>
@@ -506,11 +610,26 @@ function attachConnection(
506
610
  cfg: ResolvedServerConfig,
507
611
  messages: MessageStore,
508
612
  accounts: AccountStore | undefined,
613
+ history: NickHistoryStore,
509
614
  transport: {
510
615
  sendText: (text: string) => void;
511
616
  closeTransport: () => void;
512
617
  admissionRecordId?: string;
513
618
  sourceHost?: string;
619
+ /**
620
+ * Negotiated IRCv3 WebSocket frame mode for this connection. Spec modes
621
+ * (`spec-text` / `spec-binary`) switch outbound delivery to one WS
622
+ * message per IRC line with no trailing CR-LF (and surrogate
623
+ * sanitization for text). `undefined` / `legacy` keeps the original
624
+ * `\r\n`-joined batch. TCP connections never set this.
625
+ */
626
+ frameMode?: WsFrameMode;
627
+ /**
628
+ * Inbound line-framing seam forwarded to the actor's `Transport`. Only
629
+ * set for spec-mode WebSocket connections (one IRC line per frame);
630
+ * when omitted the actor defaults to its WS-text-frame transport.
631
+ */
632
+ actorTransport?: Transport;
514
633
  },
515
634
  ): ConnectionBindings {
516
635
  const id = randomUUID();
@@ -531,40 +650,46 @@ function attachConnection(
531
650
  state,
532
651
  runtime,
533
652
  channels: runtime,
534
- serverConfig: {
535
- serverName: cfg.serverName,
536
- networkName: cfg.networkName,
537
- maxChannelsPerUser: cfg.maxChannelsPerUser,
538
- maxTargetsPerCommand: cfg.maxTargetsPerCommand,
539
- maxListEntries: cfg.maxListEntries,
540
- nickLen: cfg.nickLen,
541
- channelLen: cfg.channelLen,
542
- topicLen: cfg.topicLen,
543
- quitMessage: cfg.quitMessage,
544
- ...(cfg.serverPassword !== undefined ? { serverPassword: cfg.serverPassword } : {}),
545
- ...(cfg.cloaking !== undefined ? { cloaking: cfg.cloaking } : {}),
546
- },
653
+ serverConfig: serverConfigFromResolved(cfg),
654
+ configSource: 'config file',
547
655
  clock: { now: () => Date.now() },
548
656
  ids: DEFAULT_ID_FACTORY,
549
657
  motd: { lines: () => cfg.motdLines },
550
658
  messages,
551
659
  ...(accounts !== undefined ? { accounts } : {}),
660
+ history,
552
661
  logger,
662
+ ...(transport.actorTransport !== undefined ? { transport: transport.actorTransport } : {}),
553
663
  });
554
664
 
555
665
  runtime.registerConnection(
556
666
  state,
557
667
  {
558
668
  send: (lines: RawLine[]) => {
559
- const text = `${lines.map((l) => l.text).join('\r\n')}\r\n`;
560
- transport.sendText(text);
669
+ const fm = transport.frameMode;
670
+ if (fm === 'spec-text' || fm === 'spec-binary') {
671
+ // Spec mode: one WebSocket message per IRC line, no trailing
672
+ // CR-LF. Sanitize lone surrogates for text frames (they have no
673
+ // valid UTF-8 encoding); binary frames pass through verbatim.
674
+ for (const l of lines) {
675
+ transport.sendText(fm === 'spec-text' ? sanitizeForTextMode(l.text) : l.text);
676
+ }
677
+ } else {
678
+ const text = `${lines.map((l) => l.text).join('\r\n')}\r\n`;
679
+ transport.sendText(text);
680
+ }
561
681
  },
562
682
  disconnect: (reason?: string) => {
563
683
  // If a reason was supplied, send the RFC-style ERROR notice before
564
684
  // closing. (QUIT does not supply one; future paths like flood
565
- // control will.)
685
+ // control will.) In spec mode the notice is its own message with no
686
+ // trailing CR-LF.
566
687
  if (reason !== undefined) {
567
- transport.sendText(`ERROR :Closing link: (${reason})\r\n`);
688
+ const line = `ERROR :Closing link: (${reason})`;
689
+ const fm = transport.frameMode;
690
+ if (fm === 'spec-text') transport.sendText(sanitizeForTextMode(line));
691
+ else if (fm === 'spec-binary') transport.sendText(line);
692
+ else transport.sendText(`${line}\r\n`);
568
693
  }
569
694
  transport.closeTransport();
570
695
  },
@@ -641,4 +766,4 @@ function stringifyErr(err: unknown): unknown {
641
766
  }
642
767
 
643
768
  // Suppress "ChanName imported but unused" — kept for type re-export ergonomics.
644
- export type { ChanName, ConnectionState };
769
+ export type { ChanName, ConnectionState, NickHistoryStore };
@@ -330,7 +330,7 @@ describe('local-cli error paths', () => {
330
330
  // 'error' followed by 'close', and the cleanup handler unregisters the
331
331
  // connection from the runtime. (The nick itself is released by the
332
332
  // QUIT reducer on graceful shutdown; abrupt termination leaks the nick
333
- // pending a future gone-connection sweep — 041.)
333
+ // pending a future gone-connection sweep.)
334
334
  client.ws.terminate();
335
335
  await new Promise((r) => setTimeout(r, 100));
336
336
  expect(srv.runtime.hasConnection(connId as string)).toBe(false);
@@ -0,0 +1,257 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
3
+ import { WebSocket } from 'ws';
4
+ import { type LocalServer, startLocalServer } from '../src/server';
5
+
6
+ /**
7
+ * IRCv3 WebSocket subprotocol negotiation + per-message framing e2e.
8
+ *
9
+ * A spec-mode (`text.ircv3.net` / `binary.ircv3.net`) connection MUST:
10
+ * - have its offered subprotocol echoed back in the handshake;
11
+ * - exchange exactly one IRC message per WebSocket message, with NO
12
+ * trailing CR-LF on either direction.
13
+ *
14
+ * A legacy (un-negotiated) connection keeps the original `\r\n`-batched
15
+ * framing, so the existing integration suite stays green.
16
+ */
17
+
18
+ /** A spec-mode client: one IRC line per WS message, no trailing CRLF. */
19
+ class SpecClient extends EventEmitter {
20
+ readonly ws: WebSocket;
21
+ readonly received: string[] = [];
22
+
23
+ constructor(url: string, protocols: string | string[]) {
24
+ super();
25
+ this.ws = new WebSocket(url, protocols);
26
+ // Each inbound WS message is exactly one IRC line in spec mode (no CRLF).
27
+ this.ws.on('message', (data) => {
28
+ const line = data.toString('utf8');
29
+ if (line.length > 0) {
30
+ this.received.push(line);
31
+ this.emit('line', line);
32
+ }
33
+ });
34
+ }
35
+
36
+ /** Resolves once the WS handshake completes. */
37
+ opened(): Promise<void> {
38
+ return new Promise((resolve, reject) => {
39
+ if (this.ws.readyState === WebSocket.OPEN) resolve();
40
+ else {
41
+ this.ws.once('open', () => resolve());
42
+ this.ws.once('error', reject);
43
+ }
44
+ });
45
+ }
46
+
47
+ /** Sends one bare IRC line as its own WS message (no trailing CRLF). */
48
+ send(line: string): Promise<void> {
49
+ return new Promise((resolve, reject) => {
50
+ this.ws.send(line, (err) => (err ? reject(err) : resolve()));
51
+ });
52
+ }
53
+
54
+ /** Waits until a received line satisfies `predicate` (substring match). */
55
+ waitFor(predicate: (line: string) => boolean, timeoutMs = 1000): Promise<string> {
56
+ return new Promise((resolve, reject) => {
57
+ const timeout = setTimeout(
58
+ () => reject(new Error(`timeout; received: ${JSON.stringify(this.received)}`)),
59
+ timeoutMs,
60
+ );
61
+ const check = (line: string) => {
62
+ if (predicate(line)) {
63
+ clearTimeout(timeout);
64
+ this.off('line', check);
65
+ resolve(line);
66
+ }
67
+ };
68
+ for (const line of this.received) {
69
+ if (predicate(line)) {
70
+ clearTimeout(timeout);
71
+ resolve(line);
72
+ return;
73
+ }
74
+ }
75
+ this.on('line', check);
76
+ });
77
+ }
78
+
79
+ close(): Promise<void> {
80
+ return new Promise((resolve) => {
81
+ if (this.ws.readyState === WebSocket.CLOSED) {
82
+ resolve();
83
+ return;
84
+ }
85
+ this.ws.once('close', () => resolve());
86
+ this.ws.close();
87
+ });
88
+ }
89
+ }
90
+
91
+ describe('local-cli IRCv3 WebSocket subprotocol', () => {
92
+ let server: LocalServer;
93
+
94
+ beforeAll(async () => {
95
+ server = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
96
+ });
97
+
98
+ afterAll(async () => {
99
+ await server.close();
100
+ });
101
+
102
+ it('echoes text.ircv3.net when offered', async () => {
103
+ const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'text.ircv3.net');
104
+ await client.opened();
105
+ expect(client.ws.protocol).toBe('text.ircv3.net');
106
+ await client.close();
107
+ });
108
+
109
+ it('echoes binary.ircv3.net when offered', async () => {
110
+ const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'binary.ircv3.net');
111
+ await client.opened();
112
+ expect(client.ws.protocol).toBe('binary.ircv3.net');
113
+ await client.close();
114
+ });
115
+
116
+ it('completes a full session over binary.ircv3.net (binary frames)', async () => {
117
+ // binary.ircv3.net exchanges binary WebSocket frames. The server emits
118
+ // binary outbound frames (Buffer) and decodes inbound as UTF-8; the
119
+ // round-trip must decode to valid IRC lines with no trailing CRLF.
120
+ const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'binary.ircv3.net');
121
+ await client.opened();
122
+ await client.send('NICK spec-bin');
123
+ await client.send('USER spec-bin 0 * :Spec Bin');
124
+ const welcome = await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
125
+ expect(welcome).toContain('spec-bin');
126
+
127
+ await client.send('PING :bin-tok');
128
+ const pong = await client.waitFor((l) => l === 'PONG :bin-tok');
129
+ expect(pong).toBe('PONG :bin-tok');
130
+
131
+ // Every decoded outbound message is a single bare IRC line.
132
+ for (const line of client.received) {
133
+ expect(line.includes('\r')).toBe(false);
134
+ expect(line.includes('\n')).toBe(false);
135
+ }
136
+ await client.close();
137
+ });
138
+
139
+ it('selects the first-listed supported subprotocol (client preference)', async () => {
140
+ // Client prefers binary over text; server honours the order.
141
+ const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, [
142
+ 'binary.ircv3.net',
143
+ 'text.ircv3.net',
144
+ ]);
145
+ await client.opened();
146
+ expect(client.ws.protocol).toBe('binary.ircv3.net');
147
+ await client.close();
148
+ });
149
+
150
+ it('ignores unsupported offers and still selects a supported one', async () => {
151
+ // Client lists an unsupported protocol first, then a supported one. The
152
+ // server MUST select the supported entry, not blindly echo the first.
153
+ const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, ['chat', 'text.ircv3.net']);
154
+ await client.opened();
155
+ expect(client.ws.protocol).toBe('text.ircv3.net');
156
+ await client.close();
157
+ });
158
+
159
+ it('connects in legacy mode when no subprotocol is offered', async () => {
160
+ // A plain client (no Sec-WebSocket-Protocol) is the legacy path: the
161
+ // server does not negotiate and the existing integration suite keeps
162
+ // working unchanged.
163
+ const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, []);
164
+ await client.opened();
165
+ expect(client.ws.protocol).toBe('');
166
+ await client.close();
167
+ });
168
+
169
+ it('completes registration with one IRC message per WebSocket message (text mode)', async () => {
170
+ const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'text.ircv3.net');
171
+ await client.opened();
172
+ // One bare line per WS message (no trailing CRLF).
173
+ await client.send('NICK spec-alice');
174
+ await client.send('USER spec-alice 0 * :Spec Alice');
175
+ const welcome = await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
176
+ expect(welcome).toContain('spec-alice');
177
+
178
+ // Every received message must be a single IRC line with no trailing CRLF
179
+ // and no embedded line break (one IRC message per WS message).
180
+ for (const line of client.received) {
181
+ expect(line.includes('\r')).toBe(false);
182
+ expect(line.includes('\n')).toBe(false);
183
+ }
184
+ await client.close();
185
+ });
186
+
187
+ it('delivers each outbound line as a distinct WebSocket message in spec mode', async () => {
188
+ const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'text.ircv3.net');
189
+ await client.opened();
190
+ await client.send('NICK spec-bob');
191
+ await client.send('USER spec-bob 0 * :Spec Bob');
192
+ await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
193
+
194
+ // The welcome sequence is many numerics (001..005, 375/372/376). In spec
195
+ // mode each arrives as its own WS message; a legacy batch would have
196
+ // concatenated them with CRLF inside one message. Assert we observed
197
+ // several distinct messages.
198
+ expect(client.received.length).toBeGreaterThan(1);
199
+ await client.close();
200
+ });
201
+
202
+ it('routes PING → PONG as a single-message frame in spec mode', async () => {
203
+ const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'text.ircv3.net');
204
+ await client.opened();
205
+ await client.send('NICK spec-ping');
206
+ await client.send('USER spec-ping 0 * :Spec Ping');
207
+ await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
208
+
209
+ await client.send('PING :spec-tok');
210
+ const pong = await client.waitFor((l) => l === 'PONG :spec-tok');
211
+ expect(pong).toBe('PONG :spec-tok');
212
+ await client.close();
213
+ });
214
+
215
+ it('rejects an inbound message exceeding the 510-byte budget in spec mode', async () => {
216
+ const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, 'text.ircv3.net');
217
+ await client.opened();
218
+ await client.send('NICK spec-budget');
219
+ await client.send('USER spec-budget 0 * :Spec Budget');
220
+ // Drain the entire registration burst (001..376) so delayed numerics
221
+ // don't pollute the post-overlong-message assertion below.
222
+ await client.waitFor((l) => l.startsWith(':irc.example.com 376 '));
223
+
224
+ client.received.length = 0;
225
+ // 511-byte payload: `PRIVMSG #x :` (12 bytes) + 499 chars = 511 total.
226
+ const overlong = `PRIVMSG #x :${'a'.repeat(499)}`;
227
+ expect(overlong.length).toBe(511);
228
+ await client.send(overlong);
229
+ // Give the server a moment to process + close. An over-budget message
230
+ // MUST NOT produce a PRIVMSG broadcast or numeric reply.
231
+ await new Promise((r) => setTimeout(r, 80));
232
+ expect(client.received.length).toBe(0);
233
+ await client.close();
234
+ });
235
+
236
+ // A reducer-driven disconnect with a reason (e.g. flood control / oper
237
+ // kill) emits the RFC-style ERROR notice. In spec mode it MUST arrive as
238
+ // its own WebSocket message with no trailing CR-LF, for both text and
239
+ // binary negotiated connections.
240
+ for (const subproto of ['text.ircv3.net', 'binary.ircv3.net'] as const) {
241
+ it(`delivers the ERROR notice as a single message on a ${subproto} connection`, async () => {
242
+ const nick = subproto === 'text.ircv3.net' ? 'spec-disc-text' : 'spec-disc-bin';
243
+ const client = new SpecClient(`ws://127.0.0.1:${server.port}/`, subproto);
244
+ await client.opened();
245
+ await client.send(`NICK ${nick}`);
246
+ await client.send(`USER ${nick} 0 * :Spec Disc`);
247
+ await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
248
+
249
+ const connId = await server.runtime.lookupNick(nick);
250
+ expect(connId).not.toBeNull();
251
+ await server.runtime.disconnect(connId as string, 'Excess Flood');
252
+ const notice = await client.waitFor((l) => l.startsWith('ERROR :Closing link:'));
253
+ expect(notice).toBe('ERROR :Closing link: (Excess Flood)');
254
+ await client.close();
255
+ });
256
+ }
257
+ });
package/package.json CHANGED
@@ -1,21 +1,23 @@
1
1
  {
2
2
  "name": "serverless-ircd",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "description": "Serverless IRC daemon with a platform-agnostic core and Cloudflare Workers + AWS adapters",
6
6
  "license": "BSD-3-Clause",
7
7
  "engines": {
8
- "node": ">=20"
8
+ "node": ">=24"
9
9
  },
10
10
  "devDependencies": {
11
- "@biomejs/biome": "^1.9.0",
12
- "@vitest/coverage-v8": "^4.1.0",
13
- "fast-check": "^3.19.0",
14
- "rimraf": "^6.0.0",
15
- "turbo": "^2.0.0",
16
- "typescript": "^5.6.0",
17
- "vite": "^7.3.0",
18
- "vitest": "^4.1.0"
11
+ "@biomejs/biome": "^1.9.4",
12
+ "@vitest/coverage-v8": "^4.1.10",
13
+ "fast-check": "^3.23.2",
14
+ "rimraf": "^6.1.3",
15
+ "tsx": "^4.19.0",
16
+ "turbo": "^2.10.7",
17
+ "typescript": "^5.9.3",
18
+ "vite": "^7.3.6",
19
+ "vitest": "^4.1.10",
20
+ "@serverless-ircd/aws-adapter": "0.5.0"
19
21
  },
20
22
  "scripts": {
21
23
  "build": "turbo run build",
@@ -33,6 +35,8 @@
33
35
  "deploy:cf:staging": "pnpm --filter @serverless-ircd/cf-worker deploy:staging",
34
36
  "deploy:cf:prod": "pnpm --filter @serverless-ircd/cf-worker deploy:prod",
35
37
  "smoke:cf:staging": "pnpm --filter @serverless-ircd/cf-worker smoke:staging",
38
+ "deploy:cf-tcp:staging": "pnpm build && pnpm --filter @serverless-ircd/cf-tcp-container deploy:staging",
39
+ "deploy:cf-tcp:prod": "pnpm build && pnpm --filter @serverless-ircd/cf-tcp-container deploy:prod",
36
40
  "deploy:aws:staging": "pnpm build && pnpm --filter @serverless-ircd/aws-stack deploy:staging",
37
41
  "deploy:aws:prod": "pnpm build && pnpm --filter @serverless-ircd/aws-stack deploy:prod",
38
42
  "smoke:aws:staging": "pnpm --filter @serverless-ircd/aws-stack smoke:staging"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/aws-adapter",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "private": true,
5
5
  "description": "AWS Lambda + DynamoDB adapter: AwsRuntime implementing IrcRuntime + $connect/$disconnect/$default handlers",
6
6
  "license": "BSD-3-Clause",
@@ -38,7 +38,7 @@
38
38
  "@serverless-ircd/irc-test-support": "workspace:*",
39
39
  "@types/node": "^26.1.1",
40
40
  "@vitest/coverage-v8": "^4.1.0",
41
- "aws-cdk-lib": "^2.160.0",
41
+ "aws-cdk-lib": "^2.262.0",
42
42
  "aws-sdk-client-mock": "^4.1.0",
43
43
  "constructs": "^10.4.0",
44
44
  "rimraf": "^6.0.0",
@@ -47,6 +47,6 @@
47
47
  "vitest": "^4.1.0"
48
48
  },
49
49
  "engines": {
50
- "node": ">=20"
50
+ "node": ">=24"
51
51
  }
52
52
  }
@@ -28,8 +28,8 @@ import {
28
28
  type ParsedServerConfig,
29
29
  } from '@serverless-ircd/irc-core';
30
30
  import {
31
- type HashedAccountCredential,
32
31
  DynamoAccountStore,
32
+ type HashedAccountCredential,
33
33
  hashAccountCredential,
34
34
  loadDynamoAccountStore,
35
35
  } from './dynamo-account-store.js';