serverless-ircd 0.1.0 → 0.3.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 (204) hide show
  1. package/.github/workflows/ci.yml +96 -2
  2. package/.github/workflows/deploy-aws.yml +129 -0
  3. package/.github/workflows/deploy-cf.yml +0 -2
  4. package/.gitmodules +3 -0
  5. package/CHANGELOG.md +436 -0
  6. package/README.md +127 -84
  7. package/apps/aws-stack/README.md +73 -0
  8. package/apps/aws-stack/bin/aws.ts +49 -0
  9. package/apps/aws-stack/cdk.json +10 -0
  10. package/apps/aws-stack/package.json +41 -0
  11. package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
  12. package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
  13. package/apps/aws-stack/scripts/smoke.mjs +142 -0
  14. package/apps/aws-stack/src/aws-stack.ts +263 -0
  15. package/apps/aws-stack/src/tables.ts +10 -0
  16. package/apps/aws-stack/tests/localstack.test.ts +46 -0
  17. package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
  18. package/apps/aws-stack/tests/stack.test.ts +464 -0
  19. package/apps/aws-stack/tsconfig.build.json +11 -0
  20. package/apps/aws-stack/tsconfig.test.json +8 -0
  21. package/apps/aws-stack/vitest.config.ts +20 -0
  22. package/apps/cf-worker/package.json +1 -1
  23. package/apps/cf-worker/scripts/smoke.mjs +0 -0
  24. package/apps/cf-worker/src/worker.ts +8 -2
  25. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  26. package/apps/cf-worker/wrangler.test.toml +5 -1
  27. package/apps/cf-worker/wrangler.toml +66 -17
  28. package/apps/local-cli/package.json +1 -1
  29. package/apps/local-cli/src/config-loader.ts +57 -0
  30. package/apps/local-cli/src/main.ts +1 -1
  31. package/apps/local-cli/src/server.ts +267 -32
  32. package/apps/local-cli/tests/config-loader.test.ts +107 -0
  33. package/apps/local-cli/tests/e2e.test.ts +126 -0
  34. package/apps/local-cli/tests/security-e2e.test.ts +239 -0
  35. package/biome.json +3 -1
  36. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  37. package/docs/ADR-002-location-of-authority.md +82 -0
  38. package/docs/ADR-003-durable-object-sharding.md +93 -0
  39. package/docs/ADR-004-dynamodb-schema.md +96 -0
  40. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  41. package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
  42. package/docs/ADR-007-deterministic-ports.md +82 -0
  43. package/docs/ADR-008-monorepo-tooling.md +60 -0
  44. package/docs/AWS-Adapter-Architecture.md +496 -0
  45. package/docs/AWS-Deployment.md +1186 -0
  46. package/docs/Cloudflare-Deployment-Guide.md +660 -0
  47. package/docs/Home.md +11 -0
  48. package/docs/Observability.md +87 -0
  49. package/docs/PlanIRCv3Websocket.md +489 -0
  50. package/docs/PlanWebClient.md +451 -0
  51. package/docs/Release-Process.md +443 -0
  52. package/package.json +19 -14
  53. package/packages/aws-adapter/README.md +35 -0
  54. package/packages/aws-adapter/package.json +52 -0
  55. package/packages/aws-adapter/src/account-store.ts +121 -0
  56. package/packages/aws-adapter/src/aws-runtime.ts +871 -0
  57. package/packages/aws-adapter/src/cdk-table-defs.ts +73 -0
  58. package/packages/aws-adapter/src/config-loader.ts +126 -0
  59. package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
  60. package/packages/aws-adapter/src/dynamo.ts +61 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +44 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +330 -0
  63. package/packages/aws-adapter/src/handlers/disconnect.ts +48 -0
  64. package/packages/aws-adapter/src/handlers/index.ts +293 -0
  65. package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
  66. package/packages/aws-adapter/src/handlers/state.ts +13 -0
  67. package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
  68. package/packages/aws-adapter/src/index.ts +74 -0
  69. package/packages/aws-adapter/src/message-store.ts +34 -0
  70. package/packages/aws-adapter/src/serialize.ts +283 -0
  71. package/packages/aws-adapter/src/tables.ts +53 -0
  72. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
  73. package/packages/aws-adapter/tests/account-store.test.ts +280 -0
  74. package/packages/aws-adapter/tests/aws-harness.ts +408 -0
  75. package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
  76. package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
  77. package/packages/aws-adapter/tests/config-loader.test.ts +213 -0
  78. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
  79. package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
  80. package/packages/aws-adapter/tests/global-setup.ts +301 -0
  81. package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
  82. package/packages/aws-adapter/tests/handlers.test.ts +473 -0
  83. package/packages/aws-adapter/tests/message-store.test.ts +55 -0
  84. package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
  85. package/packages/aws-adapter/tests/serialize.test.ts +249 -0
  86. package/packages/aws-adapter/tests/smoke.test.ts +48 -0
  87. package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
  88. package/packages/aws-adapter/tests/tables.test.ts +74 -0
  89. package/packages/aws-adapter/tests/transactions.test.ts +446 -0
  90. package/packages/aws-adapter/tsconfig.build.json +16 -0
  91. package/packages/aws-adapter/tsconfig.test.json +14 -0
  92. package/packages/aws-adapter/vitest.config.ts +23 -0
  93. package/packages/cf-adapter/package.json +2 -1
  94. package/packages/cf-adapter/src/cf-runtime.ts +42 -22
  95. package/packages/cf-adapter/src/channel-do.ts +40 -1
  96. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  97. package/packages/cf-adapter/src/config-loader.ts +135 -0
  98. package/packages/cf-adapter/src/connection-do.ts +119 -0
  99. package/packages/cf-adapter/src/env.ts +27 -1
  100. package/packages/cf-adapter/src/index.ts +2 -1
  101. package/packages/cf-adapter/tests/cf-harness.ts +2 -2
  102. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
  103. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  104. package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
  105. package/packages/cf-adapter/tests/connection-do.test.ts +82 -0
  106. package/packages/cf-adapter/tests/worker/main.ts +2 -1
  107. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  108. package/packages/cf-adapter/wrangler.test.toml +10 -1
  109. package/packages/in-memory-runtime/package.json +1 -1
  110. package/packages/in-memory-runtime/src/in-memory-runtime.ts +107 -16
  111. package/packages/in-memory-runtime/src/index.ts +1 -1
  112. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +134 -0
  113. package/packages/irc-core/package.json +13 -2
  114. package/packages/irc-core/src/admission.ts +216 -0
  115. package/packages/irc-core/src/caps/capabilities.ts +1 -0
  116. package/packages/irc-core/src/case-fold.ts +64 -0
  117. package/packages/irc-core/src/cloak.ts +81 -0
  118. package/packages/irc-core/src/commands/cap.ts +1 -2
  119. package/packages/irc-core/src/commands/chathistory.ts +305 -0
  120. package/packages/irc-core/src/commands/index.ts +9 -0
  121. package/packages/irc-core/src/commands/invite.ts +6 -9
  122. package/packages/irc-core/src/commands/isupport.ts +1 -1
  123. package/packages/irc-core/src/commands/join.ts +63 -10
  124. package/packages/irc-core/src/commands/kick.ts +4 -11
  125. package/packages/irc-core/src/commands/list.ts +5 -4
  126. package/packages/irc-core/src/commands/mode.ts +5 -9
  127. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  128. package/packages/irc-core/src/commands/motd.ts +6 -110
  129. package/packages/irc-core/src/commands/oper.ts +151 -0
  130. package/packages/irc-core/src/commands/privmsg.ts +24 -0
  131. package/packages/irc-core/src/commands/registration.ts +79 -21
  132. package/packages/irc-core/src/commands/sasl.ts +251 -0
  133. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  134. package/packages/irc-core/src/config.ts +270 -0
  135. package/packages/irc-core/src/flood-control.ts +175 -0
  136. package/packages/irc-core/src/index.ts +7 -0
  137. package/packages/irc-core/src/ports.ts +719 -0
  138. package/packages/irc-core/src/protocol/base64.ts +16 -0
  139. package/packages/irc-core/src/protocol/index.ts +2 -1
  140. package/packages/irc-core/src/protocol/numerics.ts +11 -0
  141. package/packages/irc-core/src/protocol/parser.ts +27 -2
  142. package/packages/irc-core/src/state/connection.ts +41 -0
  143. package/packages/irc-core/src/types.ts +88 -2
  144. package/packages/irc-core/stryker.commands.conf.json +41 -0
  145. package/packages/irc-core/stryker.protocol.conf.json +26 -0
  146. package/packages/irc-core/tests/account-store.test.ts +88 -0
  147. package/packages/irc-core/tests/admission.test.ts +229 -0
  148. package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
  149. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  150. package/packages/irc-core/tests/cloak.test.ts +78 -0
  151. package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
  152. package/packages/irc-core/tests/commands/join.test.ts +246 -2
  153. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  154. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  155. package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
  156. package/packages/irc-core/tests/commands/registration.test.ts +380 -20
  157. package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
  158. package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
  159. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  160. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  161. package/packages/irc-core/tests/config.test.ts +721 -0
  162. package/packages/irc-core/tests/flood-control.test.ts +394 -0
  163. package/packages/irc-core/tests/message-store.test.ts +530 -0
  164. package/packages/irc-core/tests/parser.test.ts +44 -1
  165. package/packages/irc-core/tests/ports.test.ts +263 -0
  166. package/packages/irc-server/package.json +1 -1
  167. package/packages/irc-server/src/actor.ts +186 -44
  168. package/packages/irc-server/src/dispatch.ts +89 -5
  169. package/packages/irc-server/src/index.ts +2 -0
  170. package/packages/irc-server/src/routing.ts +160 -0
  171. package/packages/irc-server/tests/actor.test.ts +690 -15
  172. package/packages/irc-server/tests/dispatch.test.ts +84 -0
  173. package/packages/irc-server/tests/routing.test.ts +204 -0
  174. package/packages/irc-test-support/README.md +32 -0
  175. package/packages/irc-test-support/package.json +37 -0
  176. package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +6 -5
  177. package/packages/irc-test-support/src/index.ts +24 -0
  178. package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
  179. package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
  180. package/packages/irc-test-support/tests/smoke.test.ts +11 -0
  181. package/packages/irc-test-support/tsconfig.build.json +16 -0
  182. package/packages/irc-test-support/tsconfig.test.json +14 -0
  183. package/packages/irc-test-support/vitest.config.ts +20 -0
  184. package/pnpm-workspace.yaml +1 -0
  185. package/tools/ci-hardening/package.json +30 -0
  186. package/tools/ci-hardening/src/index.ts +6 -0
  187. package/tools/ci-hardening/src/validate.ts +103 -0
  188. package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
  189. package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
  190. package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
  191. package/tools/ci-hardening/tests/validate.test.ts +177 -0
  192. package/tools/ci-hardening/tsconfig.build.json +12 -0
  193. package/tools/ci-hardening/tsconfig.test.json +10 -0
  194. package/tools/ci-hardening/vitest.config.ts +21 -0
  195. package/tools/seed-aws-accounts.ts +80 -0
  196. package/tools/tcp-ws-forwarder/package.json +1 -1
  197. package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
  198. package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
  199. package/tools/tcp-ws-forwarder/src/main.ts +33 -14
  200. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
  201. package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
  202. package/packages/cf-adapter/tests/integration/index.ts +0 -17
  203. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
  204. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
@@ -6,6 +6,8 @@
6
6
  * can pin the timeline and identity space (PLAN §8 determinism rules).
7
7
  */
8
8
 
9
+ import { caseFold } from './case-fold.js';
10
+
9
11
  /** Read-only wall clock, epoch milliseconds. */
10
12
  export interface Clock {
11
13
  now(): number;
@@ -49,6 +51,8 @@ export interface IdFactory {
49
51
  nonce(): string;
50
52
  /** SASL / session-scoped id. */
51
53
  sessionId(): string;
54
+ /** Per-request observability trace id, propagated via {@link Ctx}. */
55
+ traceId(): string;
52
56
  }
53
57
 
54
58
  /**
@@ -67,6 +71,9 @@ export class UuidIdFactory implements IdFactory {
67
71
  sessionId(): string {
68
72
  return uuidV4();
69
73
  }
74
+ traceId(): string {
75
+ return uuidV4();
76
+ }
70
77
  }
71
78
 
72
79
  // RFC 4122 v4 shape (random). Six fixed bits identify the version/variant;
@@ -105,6 +112,9 @@ export class SequentialIdFactory implements IdFactory {
105
112
  sessionId(): string {
106
113
  return `session-${this.counter++}`;
107
114
  }
115
+ traceId(): string {
116
+ return `trace-${this.counter++}`;
117
+ }
108
118
  }
109
119
 
110
120
  /**
@@ -151,3 +161,712 @@ export class StaticMotdProvider implements MotdProvider {
151
161
 
152
162
  /** Convenience: a {@link MotdProvider} that always reports "no MOTD". */
153
163
  export const EmptyMotdProvider: MotdProvider = new StaticMotdProvider([]);
164
+
165
+ /**
166
+ * Parsed SASL credentials handed to an {@link AccountStore}.
167
+ *
168
+ * `PLAIN` is the structured form produced by base64-decoding and splitting
169
+ * the `\0authzid\0authcid\0passwd` payload. `RAW` carries the decoded bytes
170
+ * for any other mechanism the server wants to forward verbatim.
171
+ */
172
+ export type SaslPayload =
173
+ | { kind: 'PLAIN'; username: string; password: string }
174
+ | { kind: 'RAW'; data: string };
175
+
176
+ /**
177
+ * Outcome of an {@link AccountStore.verify} call: either the canonical
178
+ * account name to record on the connection, or a human-readable reason
179
+ * for the failure that surfaces as `904 ERR_SASLFAIL`.
180
+ */
181
+ export type SaslResult = { ok: true; account: string } | { ok: false; reason: string };
182
+
183
+ /**
184
+ * Port that verifies SASL credentials against the deployment's account
185
+ * backend.
186
+ *
187
+ * The method is **synchronous** on purpose: reducers must stay pure and
188
+ * cannot await. Adapters that need an async lookup (D1, DynamoDB, Secrets
189
+ * Manager) pre-load the credentials they need into a synchronously-readable
190
+ * store (mirroring the {@link MotdProvider} pattern). For Phase 1 the
191
+ * in-memory reference adapter and tests inject a fake directly.
192
+ */
193
+ export interface AccountStore {
194
+ verify(mech: string, payload: SaslPayload): SaslResult;
195
+ }
196
+
197
+ /**
198
+ * One SASL PLAIN credential entry seeded into an {@link InMemoryAccountStore}.
199
+ * The `username` is also the canonical account name recorded on the
200
+ * connection for `extended-join` / `account-tag`.
201
+ */
202
+ export interface SaslAccountCredential {
203
+ username: string;
204
+ password: string;
205
+ }
206
+
207
+ /**
208
+ * Reference in-memory {@link AccountStore} backed by a static list of PLAIN
209
+ * credentials seeded at construction.
210
+ *
211
+ * Mirrors the {@link InMemoryMessageStore} / {@link StaticMotdProvider}
212
+ * pattern: the port is synchronous, so adapters that need an async backend
213
+ * (D1, DynamoDB, Secrets Manager) pre-load their credentials into this store
214
+ * at boot. All three adapters (local-cli, CF, AWS) use this as the minimum
215
+ * viable `AccountStore`; a persistent variant (DynamoDB `Accounts` table, D1)
216
+ * is a documented follow-up that swaps in by replacing the construction call.
217
+ *
218
+ * Password comparison is constant-time to avoid the early-return timing
219
+ * oracle a naive `===` would expose. Only `PLAIN` is verified; any other
220
+ * mechanism returns failure (matching the reducer's mech gating).
221
+ */
222
+ export class InMemoryAccountStore implements AccountStore {
223
+ private readonly creds: ReadonlyMap<string, string>;
224
+
225
+ constructor(credentials: ReadonlyArray<SaslAccountCredential>) {
226
+ this.creds = new Map(credentials.map((c) => [c.username, c.password]));
227
+ }
228
+
229
+ verify(mech: string, payload: SaslPayload): SaslResult {
230
+ if (mech.toUpperCase() !== 'PLAIN' || payload.kind !== 'PLAIN') {
231
+ return { ok: false, reason: `unsupported mechanism: ${mech}` };
232
+ }
233
+ const expected = this.creds.get(payload.username);
234
+ if (expected === undefined) {
235
+ return { ok: false, reason: 'invalid credentials' };
236
+ }
237
+ if (!constantTimeEquals(payload.password, expected)) {
238
+ return { ok: false, reason: 'invalid credentials' };
239
+ }
240
+ return { ok: true, account: payload.username };
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Constant-time string equality. Compares every byte regardless of early
246
+ * mismatches so a remote attacker cannot short-circuit the comparison via a
247
+ * timing side-channel. Returns `false` when the lengths differ (the length
248
+ * is not considered secret for SASL PLAIN passwords).
249
+ */
250
+ function constantTimeEquals(a: string, b: string): boolean {
251
+ if (a.length !== b.length) return false;
252
+ let diff = 0;
253
+ for (let i = 0; i < a.length; i++) {
254
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
255
+ }
256
+ return diff === 0;
257
+ }
258
+
259
+ // ============================================================================
260
+ // Chat-history persistence (IRCv3 draft/chathistory)
261
+ // ============================================================================
262
+
263
+ /** IRC message commands that chathistory can replay. */
264
+ export type StoredCommand = 'PRIVMSG' | 'NOTICE' | 'TAGMSG';
265
+
266
+ /**
267
+ * One persisted channel message, as recorded by the PRIVMSG/NOTICE/TAGMSG
268
+ * reducers and replayed by chathistory.
269
+ *
270
+ * `chan` is the lowercased channel name (the storage key); the display name
271
+ * is reconstructed by the reducer from the authoritative {@link ChannelState}
272
+ * when building replay lines. `time` is epoch milliseconds from the injected
273
+ * {@link Clock}; `msgid` is a fresh nonce from {@link IdFactory}.
274
+ */
275
+ export interface StoredMessage {
276
+ msgid: string;
277
+ time: number;
278
+ chan: string;
279
+ command: StoredCommand;
280
+ nick: string;
281
+ user?: string;
282
+ host?: string;
283
+ text: string;
284
+ }
285
+
286
+ /** Subcommands of `CHATHISTORY` that select a slice of the backlog. */
287
+ export type HistoryDirection = 'latest' | 'before' | 'after' | 'between' | 'around';
288
+
289
+ /**
290
+ * One chathistory query. The reducer builds this from the parsed command and
291
+ * hands it to {@link MessageStore.query}; results come back synchronously.
292
+ *
293
+ * `msgid` is the pivot for `before` / `after` / `around` and the first bound
294
+ * for `between`; `msgid2` is the second bound for `between`. For `latest`,
295
+ * `msgid` may be omitted or the literal `*` to mean "most recent".
296
+ */
297
+ export interface HistoryQuery {
298
+ chan: string;
299
+ direction: HistoryDirection;
300
+ limit: number;
301
+ msgid?: string;
302
+ msgid2?: string;
303
+ }
304
+
305
+ /** One entry in a `CHATHISTORY TARGETS` reply: a channel with recent activity. */
306
+ export interface TargetEntry {
307
+ /** Lowercased channel name. */
308
+ chan: string;
309
+ /** Epoch ms of the most recent in-window message for this channel. */
310
+ time: number;
311
+ }
312
+
313
+ /**
314
+ * Persistence port for IRCv3 `draft/chathistory` playback.
315
+ *
316
+ * Mirrors the {@link MotdProvider} / {@link AccountStore} pattern: the port
317
+ * is **synchronous** (reducers must stay pure and free of IO) and injected
318
+ * via {@link Ctx}. Adapters that need async storage (D1, R2, DynamoDB)
319
+ * pre-load the relevant slice into a synchronously-readable store; Phase 1
320
+ * ships the in-memory reference implementation only.
321
+ *
322
+ * `query` always returns messages oldest-first (chronological order), the
323
+ * shape `BATCH chathistory` expects on the wire.
324
+ */
325
+ export interface MessageStore {
326
+ /** Appends a message to the channel's history (eviction policy is impl-defined). */
327
+ record(message: StoredMessage): void;
328
+ /** Returns the slice described by `query`, oldest-first. */
329
+ query(q: HistoryQuery): StoredMessage[];
330
+ /**
331
+ * Returns the most recent `limit` messages strictly after `sinceMsgid`
332
+ * (or the most recent `limit` overall when `sinceMsgid` is undefined or
333
+ * no longer retained), oldest-first. This is the JOIN auto-playback query
334
+ * and differs from the `after` direction, which returns the *first* `limit`
335
+ * messages after a pivot.
336
+ */
337
+ recent(chan: string, sinceMsgid: string | undefined, limit: number): StoredMessage[];
338
+ /** Returns true iff `msgid` is currently retained for `chan`. */
339
+ hasMsgid(chan: string, msgid: string): boolean;
340
+ /** Enumerates channels with activity inside `[since, until]`, newest-first. */
341
+ targets(since: number, until: number): TargetEntry[];
342
+ }
343
+
344
+ /** Default per-channel retention cap when none is supplied. */
345
+ const DEFAULT_MAX_PER_CHANNEL = 100;
346
+
347
+ /** Sentinel meaning "most recent" for the `LATEST` direction pivot. */
348
+ const LATEST_WILDCARD = '*';
349
+
350
+ /**
351
+ * Reference in-memory {@link MessageStore} backed by a per-channel ring
352
+ * buffer with a configurable cap.
353
+ *
354
+ * Used by the local CLI and by tests. Each channel keeps its messages in a
355
+ * chronological array; once the array exceeds `maxPerChannel` the oldest
356
+ * entries are dropped. A `msgid → message` index accelerates pivot lookups
357
+ * and {@link hasMsgid}.
358
+ */
359
+ export class InMemoryMessageStore implements MessageStore {
360
+ private readonly byChan = new Map<string, StoredMessage[]>();
361
+ private readonly maxPerChannel: number;
362
+
363
+ constructor(maxPerChannel: number = DEFAULT_MAX_PER_CHANNEL) {
364
+ this.maxPerChannel = maxPerChannel;
365
+ }
366
+
367
+ record(message: StoredMessage): void {
368
+ const key = caseFold('rfc1459', message.chan);
369
+ let arr = this.byChan.get(key);
370
+ if (arr === undefined) {
371
+ arr = [];
372
+ this.byChan.set(key, arr);
373
+ }
374
+ arr.push(message);
375
+ if (arr.length > this.maxPerChannel) {
376
+ arr.splice(0, arr.length - this.maxPerChannel);
377
+ }
378
+ }
379
+
380
+ query(q: HistoryQuery): StoredMessage[] {
381
+ if (q.limit <= 0) return [];
382
+ const arr = this.byChan.get(caseFold('rfc1459', q.chan));
383
+ if (arr === undefined || arr.length === 0) return [];
384
+
385
+ switch (q.direction) {
386
+ case 'latest':
387
+ return this.queryLatest(arr, q);
388
+ case 'before':
389
+ return this.queryBefore(arr, q);
390
+ case 'after':
391
+ return this.queryAfter(arr, q);
392
+ case 'around':
393
+ return this.queryAround(arr, q);
394
+ case 'between':
395
+ return this.queryBetween(arr, q);
396
+ default:
397
+ return [];
398
+ }
399
+ }
400
+
401
+ hasMsgid(chan: string, msgid: string): boolean {
402
+ const arr = this.byChan.get(caseFold('rfc1459', chan));
403
+ if (arr === undefined) return false;
404
+ return arr.some((m) => m.msgid === msgid);
405
+ }
406
+
407
+ recent(chan: string, sinceMsgid: string | undefined, limit: number): StoredMessage[] {
408
+ if (limit <= 0) return [];
409
+ const arr = this.byChan.get(caseFold('rfc1459', chan));
410
+ if (arr === undefined || arr.length === 0) return [];
411
+ let start = 0;
412
+ if (sinceMsgid !== undefined) {
413
+ const idx = indexOfMsgid(arr, sinceMsgid);
414
+ // If the marker was evicted, fall back to the most recent `limit`
415
+ // overall rather than skipping playback entirely.
416
+ if (idx !== -1) start = idx + 1;
417
+ }
418
+ return tail(arr.slice(start), limit);
419
+ }
420
+
421
+ targets(since: number, until: number): TargetEntry[] {
422
+ const out: TargetEntry[] = [];
423
+ for (const [chan, arr] of this.byChan) {
424
+ let best = -1;
425
+ for (const m of arr) {
426
+ if (m.time >= since && m.time <= until && m.time > best) best = m.time;
427
+ }
428
+ if (best !== -1) out.push({ chan, time: best });
429
+ }
430
+ out.sort((a, b) => b.time - a.time);
431
+ return out;
432
+ }
433
+
434
+ private queryLatest(arr: StoredMessage[], q: HistoryQuery): StoredMessage[] {
435
+ if (q.msgid !== undefined && q.msgid !== LATEST_WILDCARD) {
436
+ const idx = indexOfMsgid(arr, q.msgid);
437
+ if (idx === -1) return [];
438
+ const slice = arr.slice(0, idx + 1);
439
+ return tail(slice, q.limit);
440
+ }
441
+ return tail(arr, q.limit);
442
+ }
443
+
444
+ private queryBefore(arr: StoredMessage[], q: HistoryQuery): StoredMessage[] {
445
+ const pivot = q.msgid;
446
+ if (pivot === undefined) return [];
447
+ const idx = indexOfMsgid(arr, pivot);
448
+ if (idx === -1) return [];
449
+ return tail(arr.slice(0, idx), q.limit);
450
+ }
451
+
452
+ private queryAfter(arr: StoredMessage[], q: HistoryQuery): StoredMessage[] {
453
+ const pivot = q.msgid;
454
+ if (pivot === undefined) return [];
455
+ const idx = indexOfMsgid(arr, pivot);
456
+ if (idx === -1) return [];
457
+ return arr.slice(idx + 1, idx + 1 + q.limit);
458
+ }
459
+
460
+ private queryAround(arr: StoredMessage[], q: HistoryQuery): StoredMessage[] {
461
+ const pivot = q.msgid;
462
+ if (pivot === undefined) return [];
463
+ const idx = indexOfMsgid(arr, pivot);
464
+ if (idx === -1) return [];
465
+ const half = Math.floor(q.limit / 2);
466
+ // Center a `limit`-sized window on the pivot, then slide it to stay in
467
+ // bounds so boundaries fill from the available side instead of shrinking.
468
+ let start = idx - half;
469
+ if (start < 0) start = 0;
470
+ if (start + q.limit > arr.length) start = arr.length - q.limit;
471
+ if (start < 0) start = 0;
472
+ return arr.slice(start, start + q.limit);
473
+ }
474
+
475
+ private queryBetween(arr: StoredMessage[], q: HistoryQuery): StoredMessage[] {
476
+ const lo = q.msgid;
477
+ const hi = q.msgid2;
478
+ if (lo === undefined || hi === undefined) return [];
479
+ const loIdx = indexOfMsgid(arr, lo);
480
+ const hiIdx = indexOfMsgid(arr, hi);
481
+ if (loIdx === -1 || hiIdx === -1) return [];
482
+ if (loIdx >= hiIdx) return [];
483
+ return arr.slice(loIdx + 1, hiIdx + 1);
484
+ }
485
+ }
486
+
487
+ /** Returns the index of the first message with `msgid`, or -1. */
488
+ function indexOfMsgid(arr: readonly StoredMessage[], msgid: string): number {
489
+ for (let i = 0; i < arr.length; i++) {
490
+ const entry = arr[i];
491
+ if (entry !== undefined && entry.msgid === msgid) return i;
492
+ }
493
+ return -1;
494
+ }
495
+
496
+ /** Returns the last (newest) up-to-`n` entries of `arr`, in original order. */
497
+ function tail<T>(arr: readonly T[], n: number): T[] {
498
+ // Callers (query/recent) pre-validate `n > 0`; the Math.max below also
499
+ // clamps to a valid slice, so an explicit `n <= 0` branch is unreachable.
500
+ const start = Math.max(0, arr.length - n);
501
+ return arr.slice(start);
502
+ }
503
+
504
+ // ============================================================================
505
+ // Observability — structured logger
506
+ // ============================================================================
507
+
508
+ /**
509
+ * Log severity level. Ordering matters: a logger configured with
510
+ * {@link LogLevel.Info} suppresses {@link LogLevel.Debug} records.
511
+ */
512
+ export enum LogLevel {
513
+ Debug = 10,
514
+ Info = 20,
515
+ Warn = 30,
516
+ Error = 40,
517
+ }
518
+
519
+ /**
520
+ * Human-readable label for a {@link LogLevel}, used as `level` in output.
521
+ *
522
+ * Exhaustive over the {@link LogLevel} enum — TypeScript verifies the
523
+ * switch covers every variant. Any unknown value would be a type hole,
524
+ * which is precisely what we want: callers cannot construct a fifth
525
+ * level without updating this mapping.
526
+ */
527
+ function levelLabel(level: LogLevel): string {
528
+ switch (level) {
529
+ case LogLevel.Debug:
530
+ return 'debug';
531
+ case LogLevel.Info:
532
+ return 'info';
533
+ case LogLevel.Warn:
534
+ return 'warn';
535
+ case LogLevel.Error:
536
+ return 'error';
537
+ }
538
+ }
539
+
540
+ /**
541
+ * One structured log record.
542
+ *
543
+ * `traceId` and `connectionId` are the cross-cutting identifiers
544
+ * (observability contract); reducers / dispatch / adapter layers
545
+ * stamp them via the bound {@link Logger} so a single request's path
546
+ * through the actor → dispatch → runtime pipeline can be reconstructed
547
+ * end-to-end.
548
+ *
549
+ * `fields` carries any per-record structured context beyond those two
550
+ * (channel name, command, byte length, error detail, …).
551
+ */
552
+ export interface LogRecord {
553
+ /** Epoch milliseconds from the injected {@link Clock}. */
554
+ ts: number;
555
+ /** Severity. */
556
+ level: LogLevel;
557
+ /** Short human-readable event name (e.g. `"receive"`, `"dispatch"`). */
558
+ msg: string;
559
+ /** Per-request trace id, propagated through {@link Ctx}. */
560
+ traceId?: string;
561
+ /** Connection id the event concerns, when relevant. */
562
+ connectionId?: string;
563
+ /** Per-event structured context. */
564
+ fields?: Record<string, unknown>;
565
+ }
566
+
567
+ /**
568
+ * Sink callback for low-level log routing (used by tests and adapters
569
+ * that want to layer metrics on top of each record).
570
+ */
571
+ export type LogSink = (record: LogRecord) => void;
572
+
573
+ /**
574
+ * Structured logger port.
575
+ *
576
+ * Reducers are pure and therefore must not call this directly; the
577
+ * actor / dispatch / adapter layers do. Each adapter injects a concrete
578
+ * implementation:
579
+ *
580
+ * - tests: {@link CapturingLogger}
581
+ * - local-cli / Lambda: {@link ConsoleLogger}
582
+ * - Cloudflare Workers: a `ConsoleLogger` whose sink writes to `console`
583
+ * (Workers' `console.log` streams into Workers Analytics Observability)
584
+ *
585
+ * `child()` returns a new logger bound to extra context fields — the
586
+ * canonical pattern is to bind `traceId` + `connectionId` once when the
587
+ * actor is constructed, then every record the actor emits carries them
588
+ * automatically.
589
+ */
590
+ export interface Logger {
591
+ debug(msg: string, fields?: Record<string, unknown>): void;
592
+ info(msg: string, fields?: Record<string, unknown>): void;
593
+ warn(msg: string, fields?: Record<string, unknown>): void;
594
+ error(msg: string, fields?: Record<string, unknown>): void;
595
+ /** Logs at the supplied level. Used by plumbing that maps levels dynamically. */
596
+ on(level: LogLevel, msg: string, fields?: Record<string, unknown>): void;
597
+ /** Returns a logger that stamps `fields` onto every record in addition to its parent's. */
598
+ child(fields: LoggerContext): Logger;
599
+ }
600
+
601
+ /** Cross-cutting fields every logger implementation recognises. */
602
+ export interface LoggerContext {
603
+ traceId?: string;
604
+ connectionId?: string;
605
+ // Allow arbitrary structured fields alongside the cross-cutting ones.
606
+ [key: string]: unknown;
607
+ }
608
+
609
+ /** Logger that drops every record. Default for unit tests of pure code. */
610
+ export class NoopLogger implements Logger {
611
+ constructor(private readonly minLevel: LogLevel = LogLevel.Debug) {}
612
+ debug(msg: string, fields?: Record<string, unknown>): void {
613
+ this.on(LogLevel.Debug, msg, fields);
614
+ }
615
+ info(msg: string, fields?: Record<string, unknown>): void {
616
+ this.on(LogLevel.Info, msg, fields);
617
+ }
618
+ warn(msg: string, fields?: Record<string, unknown>): void {
619
+ this.on(LogLevel.Warn, msg, fields);
620
+ }
621
+ error(msg: string, fields?: Record<string, unknown>): void {
622
+ this.on(LogLevel.Error, msg, fields);
623
+ }
624
+ on(level: LogLevel, _msg: string, _fields?: Record<string, unknown>): void {
625
+ if (level < this.minLevel) return;
626
+ // No-op: unit tests of pure code do not want I/O during the run.
627
+ }
628
+ child(_fields: LoggerContext): Logger {
629
+ return this;
630
+ }
631
+ }
632
+
633
+ /**
634
+ * Shared singleton: a {@link NoopLogger} with the lowest possible
635
+ * threshold. The canonical default for reducers / dispatch / actor paths
636
+ * when no concrete logger is supplied — every call becomes a no-op.
637
+ */
638
+ export const NoopLoggerInstance: Logger = new NoopLogger();
639
+
640
+ /**
641
+ * Test logger that retains every record in `records`. The `records` array
642
+ * is intentionally mutable so tests can assert ordering and shape directly.
643
+ *
644
+ * `traceId` / `connectionId` supplied in the constructor are stamped on
645
+ * every record — same pattern as production loggers. `child()` returns a
646
+ * new logger bound to extra context fields that shares the parent's
647
+ * `records` sink, so records emitted on a child are visible from the root.
648
+ */
649
+ export class CapturingLogger implements Logger {
650
+ readonly records: LogRecord[] = [];
651
+ private readonly ctx: LoggerContext;
652
+ private readonly minLevel: LogLevel;
653
+ private readonly clock: Clock;
654
+ /** @internal shared sink so child loggers push into the parent's array. */
655
+ readonly sink: LogRecord[];
656
+
657
+ constructor(ctx: LoggerContext = {}, minLevel: LogLevel = LogLevel.Debug, clock?: Clock) {
658
+ this.ctx = ctx;
659
+ this.minLevel = minLevel;
660
+ this.clock = clock ?? SystemClock;
661
+ this.sink = this.records;
662
+ }
663
+ debug(msg: string, fields?: Record<string, unknown>): void {
664
+ this.capture(LogLevel.Debug, msg, fields);
665
+ }
666
+ info(msg: string, fields?: Record<string, unknown>): void {
667
+ this.capture(LogLevel.Info, msg, fields);
668
+ }
669
+ warn(msg: string, fields?: Record<string, unknown>): void {
670
+ this.capture(LogLevel.Warn, msg, fields);
671
+ }
672
+ error(msg: string, fields?: Record<string, unknown>): void {
673
+ this.capture(LogLevel.Error, msg, fields);
674
+ }
675
+ on(level: LogLevel, msg: string, fields?: Record<string, unknown>): void {
676
+ this.capture(level, msg, fields);
677
+ }
678
+ child(fields: LoggerContext): Logger {
679
+ // Share the parent's records sink so a child's emissions are visible
680
+ // from the root logger the test holds.
681
+ return CapturingLogger.withSink(
682
+ this.sink,
683
+ { ...this.ctx, ...fields },
684
+ this.minLevel,
685
+ this.clock,
686
+ );
687
+ }
688
+ private static withSink(
689
+ sink: LogRecord[],
690
+ ctx: LoggerContext,
691
+ minLevel: LogLevel,
692
+ clock: Clock,
693
+ ): CapturingLogger {
694
+ const inst = new CapturingLogger(ctx, minLevel, clock);
695
+ // Reassign the per-instance `records`/`sink` to the shared parent sink
696
+ // so child records land in the parent's array.
697
+ (inst as { records: LogRecord[] }).records = sink;
698
+ (inst as { sink: LogRecord[] }).sink = sink;
699
+ return inst;
700
+ }
701
+ private capture(level: LogLevel, msg: string, fields?: Record<string, unknown>): void {
702
+ if (level < this.minLevel) return;
703
+ const ids = extractIds(this.ctx, fields);
704
+ const merged = mergeFields(this.ctx, fields);
705
+ const rec: LogRecord = {
706
+ ts: this.clock.now(),
707
+ level,
708
+ msg,
709
+ ...(ids.traceId !== undefined ? { traceId: ids.traceId } : {}),
710
+ ...(ids.connectionId !== undefined ? { connectionId: ids.connectionId } : {}),
711
+ ...(Object.keys(merged).length > 0 ? { fields: merged } : {}),
712
+ };
713
+ this.sink.push(rec);
714
+ }
715
+ }
716
+
717
+ /**
718
+ * Extracts the cross-cutting identifiers from the logger
719
+ * context plus the per-call fields. Per-call values win — a child logger
720
+ * bound to `connectionId: c-1` can still override it on a single record.
721
+ */
722
+ function extractIds(
723
+ ctx: LoggerContext,
724
+ call: Record<string, unknown> | undefined,
725
+ ): { traceId?: string; connectionId?: string } {
726
+ const out: { traceId?: string; connectionId?: string } = {};
727
+ if (typeof ctx.traceId === 'string') out.traceId = ctx.traceId;
728
+ if (typeof ctx.connectionId === 'string') out.connectionId = ctx.connectionId;
729
+ if (call !== undefined) {
730
+ if (typeof call.traceId === 'string') out.traceId = call.traceId;
731
+ if (typeof call.connectionId === 'string') out.connectionId = call.connectionId;
732
+ }
733
+ return out;
734
+ }
735
+
736
+ /**
737
+ * Merges the per-context fields (excluding the cross-cutting ids) with the
738
+ * per-call fields. Call fields win on conflict. Cross-cutting ids are
739
+ * promoted to top-level `LogRecord` fields by the caller, so they are
740
+ * stripped from the merged `fields` bag to avoid duplication.
741
+ */
742
+ function mergeFields(
743
+ ctx: LoggerContext,
744
+ call: Record<string, unknown> | undefined,
745
+ ): Record<string, unknown> {
746
+ const out: Record<string, unknown> = {};
747
+ for (const [k, v] of Object.entries(ctx)) {
748
+ if (k === 'traceId' || k === 'connectionId') continue;
749
+ out[k] = v;
750
+ }
751
+ if (call !== undefined) {
752
+ for (const [k, v] of Object.entries(call)) {
753
+ if (k === 'traceId' || k === 'connectionId') continue;
754
+ out[k] = v;
755
+ }
756
+ }
757
+ return out;
758
+ }
759
+
760
+ /**
761
+ * Sink typealias for the four `console.*` methods. Adapters that want to
762
+ * fan-out to CloudWatch Logs or Workers Analytics can supply a custom
763
+ * sink; the default is the global `console`.
764
+ */
765
+ export interface ConsoleLoggerSinks {
766
+ debug(payload: string): void;
767
+ info(payload: string): void;
768
+ warn(payload: string): void;
769
+ error(payload: string): void;
770
+ }
771
+
772
+ /** Minimal structural shape of the global `console`. */
773
+ type GlobalConsole = Pick<ConsoleLoggerSinks, 'debug' | 'info' | 'warn' | 'error'>;
774
+
775
+ /**
776
+ * Logger that serialises each record as a single-line JSON string and
777
+ * writes it to the matching console sink.
778
+ *
779
+ * Used in production by `apps/local-cli` (Node), AWS Lambda (Node), and
780
+ * Cloudflare Workers (where `console.log` is captured by Workers
781
+ * Analytics Observability and surfaced in theWorkers Logpush / wrangler
782
+ * tail pipeline).
783
+ *
784
+ * Output shape: `{ ts, level, msg, traceId?, connectionId?, ...fields }`
785
+ * — one physical line per record (no embedded newlines) so downstream
786
+ * log shippers can split cleanly.
787
+ */
788
+ export class ConsoleLogger implements Logger {
789
+ private readonly ctx: LoggerContext;
790
+ private readonly minLevel: LogLevel;
791
+ private readonly clock: Clock;
792
+ private readonly sinks: ConsoleLoggerSinks;
793
+
794
+ constructor(
795
+ ctx: LoggerContext = {},
796
+ sinks?: ConsoleLoggerSinks,
797
+ minLevel: LogLevel = LogLevel.Debug,
798
+ clock?: Clock,
799
+ ) {
800
+ this.ctx = ctx;
801
+ this.minLevel = minLevel;
802
+ this.clock = clock ?? SystemClock;
803
+ this.sinks = sinks ?? globalThisConsoleSinks();
804
+ }
805
+ debug(msg: string, fields?: Record<string, unknown>): void {
806
+ this.write(LogLevel.Debug, msg, fields);
807
+ }
808
+ info(msg: string, fields?: Record<string, unknown>): void {
809
+ this.write(LogLevel.Info, msg, fields);
810
+ }
811
+ warn(msg: string, fields?: Record<string, unknown>): void {
812
+ this.write(LogLevel.Warn, msg, fields);
813
+ }
814
+ error(msg: string, fields?: Record<string, unknown>): void {
815
+ this.write(LogLevel.Error, msg, fields);
816
+ }
817
+ on(level: LogLevel, msg: string, fields?: Record<string, unknown>): void {
818
+ this.write(level, msg, fields);
819
+ }
820
+ child(fields: LoggerContext): Logger {
821
+ return new ConsoleLogger({ ...this.ctx, ...fields }, this.sinks, this.minLevel, this.clock);
822
+ }
823
+ private write(level: LogLevel, msg: string, fields?: Record<string, unknown>): void {
824
+ if (level < this.minLevel) return;
825
+ const ids = extractIds(this.ctx, fields);
826
+ const merged = mergeFields(this.ctx, fields);
827
+ const payload: Record<string, unknown> = {
828
+ ts: new Date(this.clock.now()).toISOString(),
829
+ level: levelLabel(level),
830
+ msg,
831
+ ...merged,
832
+ };
833
+ if (ids.traceId !== undefined) payload.traceId = ids.traceId;
834
+ if (ids.connectionId !== undefined) payload.connectionId = ids.connectionId;
835
+ const line = JSON.stringify(payload);
836
+ // Exhaustive over the LogLevel enum; TS verifies every variant is
837
+ // routed to its matching sink.
838
+ switch (level) {
839
+ case LogLevel.Debug:
840
+ this.sinks.debug(line);
841
+ return;
842
+ case LogLevel.Info:
843
+ this.sinks.info(line);
844
+ return;
845
+ case LogLevel.Warn:
846
+ this.sinks.warn(line);
847
+ return;
848
+ case LogLevel.Error:
849
+ this.sinks.error(line);
850
+ return;
851
+ }
852
+ }
853
+ }
854
+
855
+ /** Returns the global `console` as a {@link ConsoleLoggerSinks}. */
856
+ function globalThisConsoleSinks(): ConsoleLoggerSinks {
857
+ // `globalThis.console` is universal across Node 20, Workers, and Lambda.
858
+ // Reference it lazily so tests that stub the logger do not need to
859
+ // replace the global.
860
+ const c = (globalThis as { console?: GlobalConsole }).console;
861
+ if (c === undefined) {
862
+ // Defensive: a runtime without `globalThis.console`. Every level drops.
863
+ const drop = (): void => {};
864
+ return { debug: drop, info: drop, warn: drop, error: drop };
865
+ }
866
+ return {
867
+ debug: (p) => c.debug(p),
868
+ info: (p) => c.info(p),
869
+ warn: (p) => c.warn(p),
870
+ error: (p) => c.error(p),
871
+ };
872
+ }