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
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Pure reducer for the IRCv3 `draft/chathistory` command.
3
+ *
4
+ * Spec: https://ircv3.net/specs/extensions/chathistory
5
+ *
6
+ * Surface 1 (explicit query): `CHATHISTORY <sub> <target> [<args>...]`. The
7
+ * reducer reads the backlog from the {@link MessageStore} injected via
8
+ * {@link Ctx} (mirroring `MotdProvider` / `AccountStore`) and emits the
9
+ * matching `PRIVMSG`/`NOTICE`/`TAGMSG` lines wrapped in a single
10
+ * `BATCH chathistory <target>` frame. Each replay line carries the original
11
+ * `@time=…` (server-time) and `msgid=…` tags so the client can de-dup and
12
+ * place it on the timeline.
13
+ *
14
+ * The cap `draft/chathistory` MUST be negotiated before any query is
15
+ * accepted; clients without the cap get `421 ERR_UNKNOWNCOMMAND` (the spec's
16
+ * permitted "unknown command" fallback).
17
+ *
18
+ * Location-of-authority: chathistory is read-only and runs against the
19
+ * requester's connection. The target channel is passed in (resolved by the
20
+ * actor layer); `undefined` means "no such channel" → `403`.
21
+ */
22
+
23
+ import { Effect } from '../effects.js';
24
+ import type { Effect as EffectType, RawLine } from '../effects.js';
25
+ import type { StoredMessage } from '../ports.js';
26
+ import { wrapBatch } from '../protocol/batch.js';
27
+ import type { IrcMessage } from '../protocol/messages.js';
28
+ import { Numerics } from '../protocol/numerics.js';
29
+ import { formatServerTime } from '../protocol/outbound.js';
30
+ import type { ChannelState } from '../state/channel.js';
31
+ import type { Ctx } from '../types.js';
32
+
33
+ /** The cap name advertising chathistory support. */
34
+ export const CHATHISTORY_CAP = 'draft/chathistory';
35
+
36
+ /** Default query limit when the client omits one (spec recommends 50). */
37
+ const DEFAULT_QUERY_LIMIT = 50;
38
+
39
+ /** Sentinel the client uses to mean "most recent" / "now" / "beginning". */
40
+ const WILDCARD = '*';
41
+
42
+ /** Subcommands that operate on a single channel target. */
43
+ type ChannelSubcommand = 'LATEST' | 'BEFORE' | 'AFTER' | 'AROUND' | 'BETWEEN';
44
+
45
+ const CHANNEL_SUBCOMMANDS: ReadonlySet<string> = new Set<string>([
46
+ 'LATEST',
47
+ 'BEFORE',
48
+ 'AFTER',
49
+ 'AROUND',
50
+ 'BETWEEN',
51
+ ]);
52
+
53
+ /**
54
+ * Handles `CHATHISTORY <sub> <target> [<args>...]`.
55
+ *
56
+ * @param channel The authoritative ChannelState for `msg.params[1]`, or
57
+ * `undefined` when no such channel exists (→ `403`).
58
+ */
59
+ export function chathistoryReducer(
60
+ channel: ChannelState | undefined,
61
+ msg: IrcMessage,
62
+ ctx: Ctx,
63
+ ): { effects: EffectType[] } {
64
+ // Cap gate: clients that did not negotiate chathistory see RFC 2812 only.
65
+ if (!ctx.connection.caps.has(CHATHISTORY_CAP)) {
66
+ return {
67
+ effects: [
68
+ Effect.send(ctx.connId, [
69
+ numericErr(ctx, Numerics.ERR_UNKNOWNCOMMAND, 'Unknown command', 'CHATHISTORY'),
70
+ ]),
71
+ ],
72
+ };
73
+ }
74
+
75
+ const sub = (msg.params[0] ?? '').toUpperCase();
76
+ if (sub === '') {
77
+ return { effects: [Effect.send(ctx.connId, [needMoreParamsLine(ctx)])] };
78
+ }
79
+
80
+ if (sub === 'TARGETS') {
81
+ return handleTargets(msg, ctx);
82
+ }
83
+
84
+ if (!CHANNEL_SUBCOMMANDS.has(sub)) {
85
+ return { effects: [Effect.send(ctx.connId, [invalidParamsLine(ctx)])] };
86
+ }
87
+
88
+ return handleChannelSub(sub as ChannelSubcommand, channel, msg, ctx);
89
+ }
90
+
91
+ /** Handles the four channel-bound subcommands + BETWEEN. */
92
+ function handleChannelSub(
93
+ sub: ChannelSubcommand,
94
+ channel: ChannelState | undefined,
95
+ msg: IrcMessage,
96
+ ctx: Ctx,
97
+ ): { effects: EffectType[] } {
98
+ const target = msg.params[1];
99
+ if (target === undefined) {
100
+ return { effects: [Effect.send(ctx.connId, [needMoreParamsLine(ctx)])] };
101
+ }
102
+
103
+ if (channel === undefined) {
104
+ return {
105
+ effects: [
106
+ Effect.send(ctx.connId, [
107
+ numericErr(ctx, Numerics.ERR_NOSUCHCHANNEL, 'No such channel', target),
108
+ ]),
109
+ ],
110
+ };
111
+ }
112
+
113
+ // +s/+p leak prevention: a non-member must not learn the channel has
114
+ // history. The spec says return an empty batch; we elide the batch entirely
115
+ // (empty batches are forbidden) so the requester observes nothing.
116
+ if ((channel.modes.secret || channel.modes.private) && !channel.members.has(ctx.connId)) {
117
+ return { effects: [] };
118
+ }
119
+
120
+ const chanLower = channel.nameLower;
121
+
122
+ if (sub === 'BETWEEN') {
123
+ const lo = msg.params[2];
124
+ const hi = msg.params[3];
125
+ if (lo === undefined || hi === undefined) {
126
+ return { effects: [Effect.send(ctx.connId, [needMoreParamsLine(ctx)])] };
127
+ }
128
+ if (!storeHas(ctx, chanLower, lo) || !storeHas(ctx, chanLower, hi)) {
129
+ return { effects: [Effect.send(ctx.connId, [invalidParamsLine(ctx)])] };
130
+ }
131
+ const result = storeQuery(ctx, {
132
+ chan: chanLower,
133
+ direction: 'between',
134
+ limit: DEFAULT_QUERY_LIMIT,
135
+ msgid: lo,
136
+ msgid2: hi,
137
+ });
138
+ return { effects: buildChathistoryBatch(ctx, channel.name, result) };
139
+ }
140
+
141
+ // LATEST / BEFORE / AFTER / AROUND
142
+ const pivot = msg.params[2];
143
+ const limitRaw = msg.params[3];
144
+ if (sub !== 'LATEST' && pivot === undefined) {
145
+ return { effects: [Effect.send(ctx.connId, [needMoreParamsLine(ctx)])] };
146
+ }
147
+ const limit = parseLimit(limitRaw);
148
+ if (limit === undefined) {
149
+ return { effects: [Effect.send(ctx.connId, [invalidParamsLine(ctx)])] };
150
+ }
151
+
152
+ if (sub === 'LATEST') {
153
+ const pivotArg = pivot === undefined || pivot === WILDCARD ? undefined : pivot;
154
+ if (pivotArg !== undefined && !storeHas(ctx, chanLower, pivotArg)) {
155
+ return { effects: [Effect.send(ctx.connId, [invalidParamsLine(ctx)])] };
156
+ }
157
+ const result = storeQuery(ctx, {
158
+ chan: chanLower,
159
+ direction: 'latest',
160
+ limit,
161
+ ...(pivotArg !== undefined ? { msgid: pivotArg } : {}),
162
+ });
163
+ return { effects: buildChathistoryBatch(ctx, channel.name, result) };
164
+ }
165
+
166
+ // BEFORE / AFTER / AROUND — pivot is required. The `sub !== 'LATEST' &&
167
+ // pivot === undefined` guard above already rejected the missing-pivot
168
+ // case and LATEST returned early just above; assert non-null for the
169
+ // type-checker without an unreachable branch.
170
+ const pivotMsgid = pivot as string;
171
+ if (!storeHas(ctx, chanLower, pivotMsgid)) {
172
+ return { effects: [Effect.send(ctx.connId, [invalidParamsLine(ctx)])] };
173
+ }
174
+ const result = storeQuery(ctx, {
175
+ chan: chanLower,
176
+ direction: sub.toLowerCase() as 'before' | 'after' | 'around',
177
+ limit,
178
+ msgid: pivotMsgid,
179
+ });
180
+ return { effects: buildChathistoryBatch(ctx, channel.name, result) };
181
+ }
182
+
183
+ /** Handles `CHATHISTORY TARGETS <since> <until>`. */
184
+ function handleTargets(msg: IrcMessage, ctx: Ctx): { effects: EffectType[] } {
185
+ const sinceRaw = msg.params[1];
186
+ const untilRaw = msg.params[2];
187
+ if (sinceRaw === undefined || untilRaw === undefined) {
188
+ return { effects: [Effect.send(ctx.connId, [needMoreParamsLine(ctx)])] };
189
+ }
190
+ const since = parseTimestamp(sinceRaw, 0);
191
+ const until = parseTimestamp(untilRaw, ctx.clock.now());
192
+ if (since === undefined || until === undefined) {
193
+ return { effects: [Effect.send(ctx.connId, [invalidParamsLine(ctx)])] };
194
+ }
195
+
196
+ const entries = ctx.messages !== undefined ? ctx.messages.targets(since, until) : [];
197
+ if (entries.length === 0) {
198
+ return { effects: [] };
199
+ }
200
+
201
+ const body: RawLine[] = entries.map((e) => ({
202
+ text: `:${ctx.serverName} CHATHISTORY TARGETS ${formatServerTime(e.time)} ${e.chan}`,
203
+ }));
204
+ const frame = wrapBatch(body, ctx.ids.batchId(), 'chathistory');
205
+ return { effects: [Effect.send(ctx.connId, frame)] };
206
+ }
207
+
208
+ /**
209
+ * Wraps `messages` as replay lines in a `BATCH chathistory <chan>` frame
210
+ * addressed to the requester. Empty input elides the batch entirely (the
211
+ * spec forbids empty batches; the client interprets "no batch" as "no
212
+ * history").
213
+ *
214
+ * Shared by the explicit `CHATHISTORY` query and the JOIN auto-playback hook
215
+ * so both surfaces emit identical framing.
216
+ */
217
+ export function buildChathistoryBatch(
218
+ ctx: Ctx,
219
+ displayChan: string,
220
+ messages: readonly StoredMessage[],
221
+ ): EffectType[] {
222
+ if (messages.length === 0) return [];
223
+ const body = messages.map((m) => formatReplayLine(m, displayChan));
224
+ const frame = wrapBatch(body, ctx.ids.batchId(), 'chathistory', displayChan);
225
+ return [Effect.send(ctx.connId, frame)];
226
+ }
227
+
228
+ /**
229
+ * Reconstructs the wire line for one stored message, decorated with the
230
+ * original server-time and msgid tags. PRIVMSG/NOTICE carry the trailing
231
+ * text; TAGMSG has no trailing parameter.
232
+ */
233
+ export function formatReplayLine(m: StoredMessage, displayChan: string): RawLine {
234
+ const stamp = formatServerTime(m.time);
235
+ const prefix = storedHostmask(m);
236
+ const tag = `@time=${stamp};msgid=${m.msgid}`;
237
+ if (m.command === 'TAGMSG') {
238
+ return { text: `${tag} :${prefix} TAGMSG ${displayChan}` };
239
+ }
240
+ return { text: `${tag} :${prefix} ${m.command} ${displayChan} :${m.text}` };
241
+ }
242
+
243
+ /** Builds the `nick!user@host` source for a stored message. */
244
+ function storedHostmask(m: StoredMessage): string {
245
+ if (m.user === undefined && m.host === undefined) return m.nick;
246
+ let out = m.nick;
247
+ if (m.user !== undefined) out = `${out}!${m.user}`;
248
+ if (m.host !== undefined) out = `${out}@${m.host}`;
249
+ return out;
250
+ }
251
+
252
+ /** Formats a numeric error line addressed to the connection's nick (or `*`). */
253
+ function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
254
+ const nick = ctx.connection.nick ?? '*';
255
+ const codeStr = code.toString().padStart(3, '0');
256
+ return { text: [`:${ctx.serverName}`, codeStr, nick, middle, `:${trailing}`].join(' ') };
257
+ }
258
+
259
+ /** `461 CHATHISTORY :Not enough parameters`. */
260
+ function needMoreParamsLine(ctx: Ctx): RawLine {
261
+ return numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Not enough parameters', 'CHATHISTORY');
262
+ }
263
+
264
+ /** `461 CHATHISTORY :Invalid parameters`. */
265
+ function invalidParamsLine(ctx: Ctx): RawLine {
266
+ return numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Invalid parameters', 'CHATHISTORY');
267
+ }
268
+
269
+ /** Parses a non-negative integer limit; returns undefined on garbage. */
270
+ function parseLimit(raw: string | undefined): number | undefined {
271
+ if (raw === undefined) return DEFAULT_QUERY_LIMIT;
272
+ const n = Number(raw);
273
+ if (!Number.isInteger(n) || n < 0) return undefined;
274
+ return n;
275
+ }
276
+
277
+ /**
278
+ * Parses a CHATHISTORY timestamp bound. `*` resolves to `wildcardValue`
279
+ * (0 for `since`, `ctx.clock.now()` for `until`); an ISO-8601 string parses
280
+ * to epoch ms; anything else returns undefined (→ `461`).
281
+ */
282
+ function parseTimestamp(raw: string, wildcardValue: number): number | undefined {
283
+ if (raw === WILDCARD) return wildcardValue;
284
+ const ms = Date.parse(raw);
285
+ return Number.isNaN(ms) ? undefined : ms;
286
+ }
287
+
288
+ /** store.hasMsgid that treats an unbound store as "the msgid is not known". */
289
+ function storeHas(ctx: Ctx, chan: string, msgid: string): boolean {
290
+ return ctx.messages !== undefined ? ctx.messages.hasMsgid(chan, msgid) : false;
291
+ }
292
+
293
+ /** store.query that treats an unbound store as empty. */
294
+ function storeQuery(
295
+ ctx: Ctx,
296
+ q: {
297
+ chan: string;
298
+ direction: 'latest' | 'before' | 'after' | 'around' | 'between';
299
+ limit: number;
300
+ msgid?: string;
301
+ msgid2?: string;
302
+ },
303
+ ): StoredMessage[] {
304
+ return ctx.messages !== undefined ? ctx.messages.query(q) : [];
305
+ }
@@ -18,6 +18,7 @@ export { quitReducer } from './quit.js';
18
18
  export { handleJoinZero, isValidChannelName, joinReducer } from './join.js';
19
19
  export { partReducer } from './part.js';
20
20
  export { motdReducer } from './motd.js';
21
+ export { operReducer, matchOperCred } from './oper.js';
21
22
  export {
22
23
  isChannelTarget,
23
24
  noticeChannelReducer,
@@ -38,3 +39,11 @@ export { capReducer } from './cap.js';
38
39
  export { emitChghost } from './chghost.js';
39
40
  export type { ChgHostInput } from './chghost.js';
40
41
  export { awayReducer } from './away.js';
42
+ export { authenticateReducer } from './sasl.js';
43
+ export {
44
+ chathistoryReducer,
45
+ formatReplayLine,
46
+ buildChathistoryBatch,
47
+ CHATHISTORY_CAP,
48
+ } from './chathistory.js';
49
+ export { tagmsgChannelReducer, tagmsgUserReducer } from './tagmsg.js';
@@ -20,6 +20,7 @@
20
20
  * case-insensitively against the roster (443 if already a member).
21
21
  */
22
22
 
23
+ import { caseFold } from '../case-fold.js';
23
24
  import { Effect } from '../effects.js';
24
25
  import type { Effect as EffectType, RawLine } from '../effects.js';
25
26
  import { Numerics } from '../protocol/numerics.js';
@@ -50,15 +51,11 @@ function numericErr(ctx: Ctx, code: number, trailing: string, middle?: string):
50
51
  return { text: parts.join(' ') };
51
52
  }
52
53
 
53
- function lowerNick(nick: string): string {
54
- return nick.toLowerCase();
55
- }
56
-
57
- /** Finds a roster entry's connId by nick (case-insensitive). */
54
+ /** Finds a roster entry's connId by nick (case-insensitively, per `CASEMAPPING=rfc1459`). */
58
55
  function findConnIdByNick(state: ChannelState, nick: string): string | null {
59
- const target = lowerNick(nick);
56
+ const target = caseFold('rfc1459', nick);
60
57
  for (const entry of state.members.values()) {
61
- if (lowerNick(entry.nick) === target) return entry.conn;
58
+ if (caseFold('rfc1459', entry.nick) === target) return entry.conn;
62
59
  }
63
60
  return null;
64
61
  }
@@ -132,9 +129,9 @@ export const inviteReducer: Reducer<ChannelState> = (state, msg, ctx) => {
132
129
  return { state, effects };
133
130
  }
134
131
 
135
- // Success: record the pending invite (by lowercased nick) so a later
132
+ // Success: record the pending invite (by case-folded nick) so a later
136
133
  // JOIN bypasses +i, emit 341, and route the INVITE notice to the invitee.
137
- state.pendingInvites.add(targetNick.toLowerCase());
134
+ state.pendingInvites.add(caseFold('rfc1459', targetNick));
138
135
 
139
136
  const inviterNick = ctx.connection.nick ?? '*';
140
137
  effects.push(
@@ -39,7 +39,7 @@ const PREFIX = '(ov)@+';
39
39
  /** Channel name sigils this server recognises. */
40
40
  const CHANTYPES = '#';
41
41
 
42
- /** Case-mapping algorithm — v1 uses RFC 1459 (matches `lower.ts`). */
42
+ /** Case-mapping algorithm — v1 uses RFC 1459 (matches `case-fold.ts`). */
43
43
  const CASEMAPPING = 'rfc1459';
44
44
 
45
45
  /** Default max mode changes per single MODE command (PLAN §3). */
@@ -15,14 +15,17 @@
15
15
  * channel.
16
16
  */
17
17
 
18
+ import { caseFold } from '../case-fold.js';
18
19
  import { Effect } from '../effects.js';
19
20
  import type { Effect as EffectType, RawLine } from '../effects.js';
21
+ import type { StoredMessage } from '../ports.js';
20
22
  import { wrapBatch } from '../protocol/batch.js';
21
23
  import { Numerics } from '../protocol/numerics.js';
22
24
  import { type ChanName, type ChannelState, prefixOf } from '../state/channel.js';
23
25
  import { hostmaskOf } from '../state/connection.js';
24
26
  import type { ConnectionState } from '../state/connection.js';
25
27
  import type { Ctx, Reducer } from '../types.js';
28
+ import { CHATHISTORY_CAP, buildChathistoryBatch } from './chathistory.js';
26
29
 
27
30
  /**
28
31
  * RFC 1459/2812 channel-name grammar. The first character must be a channel
@@ -64,16 +67,13 @@ function buildNamesList(state: ChannelState): string {
64
67
 
65
68
  /**
66
69
  * Formats a single numeric error line addressed to the connection's current
67
- * nick (or `*` when unregistered). Optional `middle` is emitted before the
68
- * trailing `:`-prefixed text.
70
+ * nick (or `*` when unregistered). `middle` is emitted before the trailing
71
+ * `:`-prefixed text.
69
72
  */
70
- function numericErr(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
73
+ function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
71
74
  const nick = ctx.connection.nick ?? '*';
72
75
  const codeStr = code.toString().padStart(3, '0');
73
- const parts = [`:${ctx.serverName}`, codeStr, nick];
74
- if (middle !== undefined) parts.push(middle);
75
- parts.push(`:${trailing}`);
76
- return { text: parts.join(' ') };
76
+ return { text: [`:${ctx.serverName}`, codeStr, nick, middle, `:${trailing}`].join(' ') };
77
77
  }
78
78
 
79
79
  /**
@@ -107,6 +107,44 @@ function joinerHostmask(conn: ConnectionState): string {
107
107
  return hostmaskOf(conn) ?? conn.nick ?? '?';
108
108
  }
109
109
 
110
+ /** Default backlog depth replayed on JOIN when ServerConfig omits a value. */
111
+ const DEFAULT_CHATHISTORY_PLAYBACK_LIMIT = 50;
112
+
113
+ /**
114
+ * Builds the IRCv3 `draft/chathistory` auto-playback BATCH for a cap-enabled
115
+ * joiner and computes the next "last read" marker.
116
+ *
117
+ * Returns `{ effects: [], marker: undefined }` when playback is disabled (cap
118
+ * not negotiated, no MessageStore bound, or no messages newer than the
119
+ * marker); otherwise a single `Send` carrying the BATCH frame and the msgid
120
+ * of the newest replayed message. The caller is responsible for prepending
121
+ * the effects before the JOIN broadcast and for advancing the per-connection
122
+ * marker so a PART/re-JOIN cycle with no new activity replays nothing.
123
+ */
124
+ function buildChathistoryPlayback(
125
+ state: ChannelState,
126
+ ctx: Ctx,
127
+ ): { effects: EffectType[]; marker: string | undefined } {
128
+ if (!ctx.connection.caps.has(CHATHISTORY_CAP)) {
129
+ return { effects: [], marker: undefined };
130
+ }
131
+ if (ctx.messages === undefined) {
132
+ return { effects: [], marker: undefined };
133
+ }
134
+ const limit = ctx.serverConfig.chatHistoryPlaybackLimit ?? DEFAULT_CHATHISTORY_PLAYBACK_LIMIT;
135
+ const previousMarker = ctx.connection.lastReadMarkers?.get(state.nameLower);
136
+ const recent = ctx.messages.recent(state.nameLower, previousMarker, limit);
137
+ if (recent.length === 0) {
138
+ return { effects: [], marker: undefined };
139
+ }
140
+ // `recent` is non-empty here, so the last entry is defined; the assertion
141
+ // only satisfies the type-checker under `noUncheckedIndexedAccess` (no
142
+ // runtime branch, no `!` token).
143
+ const newest = recent[recent.length - 1] as StoredMessage;
144
+ const effects = buildChathistoryBatch(ctx, state.name, recent);
145
+ return { effects, marker: newest.msgid };
146
+ }
147
+
110
148
  /**
111
149
  * Handles `JOIN <chan> [<key>]` for a single channel.
112
150
  *
@@ -176,9 +214,9 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
176
214
  }
177
215
  }
178
216
 
179
- // Invite-only check. pendingInvites stores lowercased nicks.
217
+ // Invite-only check. pendingInvites stores case-folded nicks.
180
218
  const myNick = ctx.connection.nick;
181
- const invited = myNick !== undefined && state.pendingInvites.has(myNick.toLowerCase());
219
+ const invited = myNick !== undefined && state.pendingInvites.has(caseFold('rfc1459', myNick));
182
220
  if (state.modes.inviteOnly && !invited) {
183
221
  effects.push(
184
222
  Effect.send(ctx.connId, [
@@ -221,7 +259,7 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
221
259
  });
222
260
 
223
261
  // Pending invite is consumed on a successful join.
224
- if (myNick !== undefined) state.pendingInvites.delete(myNick.toLowerCase());
262
+ if (myNick !== undefined) state.pendingInvites.delete(caseFold('rfc1459', myNick));
225
263
 
226
264
  // Track the channel on the connection's joined set (cross-authority).
227
265
  ctx.connection.joinedChannels.add(state.nameLower);
@@ -244,6 +282,21 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
244
282
  }),
245
283
  );
246
284
 
285
+ // IRCv3 draft/chathistory auto-playback: a cap-enabled joiner with a
286
+ // bound MessageStore receives the recent backlog as a BATCH *before*
287
+ // the JOIN broadcast, then the per-(connection, channel) "last read"
288
+ // marker advances to the newest replayed msgid. PART/re-JOIN cycles
289
+ // only replay messages newer than the marker, so a re-join with no
290
+ // new activity emits nothing.
291
+ const playback = buildChathistoryPlayback(state, ctx);
292
+ if (playback.marker !== undefined) {
293
+ if (ctx.connection.lastReadMarkers === undefined) {
294
+ ctx.connection.lastReadMarkers = new Map<string, string>();
295
+ }
296
+ ctx.connection.lastReadMarkers.set(state.nameLower, playback.marker);
297
+ }
298
+ effects.push(...playback.effects);
299
+
247
300
  const legacyJoin: RawLine = { text: `:${hostmask} JOIN ${state.name}` };
248
301
  const realname = ctx.connection.realname;
249
302
  if (realname !== undefined) {
@@ -13,6 +13,7 @@
13
13
  * one channel per invocation.
14
14
  */
15
15
 
16
+ import { caseFold } from '../case-fold.js';
16
17
  import { Effect } from '../effects.js';
17
18
  import type { Effect as EffectType, RawLine } from '../effects.js';
18
19
  import { Numerics } from '../protocol/numerics.js';
@@ -52,14 +53,6 @@ function kickerHostmask(conn: ConnectionState): string {
52
53
  return hostmaskOf(conn) ?? conn.nick ?? '?';
53
54
  }
54
55
 
55
- /**
56
- * Lowercases a nick using ASCII case folding (good enough for v1; rfc1459
57
- * case mapping lands with the isupport `CASEMAPPING` token).
58
- */
59
- function lowerNick(nick: string): string {
60
- return nick.toLowerCase();
61
- }
62
-
63
56
  /**
64
57
  * Handles `KICK <chan> <user> [:reason]`.
65
58
  *
@@ -117,11 +110,11 @@ export const kickReducer: Reducer<ChannelState> = (state, msg, ctx) => {
117
110
  return { state, effects };
118
111
  }
119
112
 
120
- // Find the target by nick (case-insensitive). IRC nicks fold by ASCII.
121
- const targetLower = lowerNick(targetNick);
113
+ // Find the target by nick (case-insensitively, per `CASEMAPPING=rfc1459`).
114
+ const targetLower = caseFold('rfc1459', targetNick);
122
115
  let targetConnId: string | undefined;
123
116
  for (const entry of state.members.values()) {
124
- if (lowerNick(entry.nick) === targetLower) {
117
+ if (caseFold('rfc1459', entry.nick) === targetLower) {
125
118
  targetConnId = entry.conn;
126
119
  break;
127
120
  }
@@ -26,6 +26,7 @@
26
26
  * the cap).
27
27
  */
28
28
 
29
+ import { caseFold } from '../case-fold.js';
29
30
  import { Effect } from '../effects.js';
30
31
  import type { Effect as EffectType, RawLine } from '../effects.js';
31
32
  import { Numerics } from '../protocol/numerics.js';
@@ -104,10 +105,10 @@ export const listReducer: Reducer<ChanSnapshot[]> = (state, msg, ctx) => {
104
105
  for (const rawName of requested) {
105
106
  if (lines.length >= max) break;
106
107
  if (!isValidChannelName(rawName, ctx.serverConfig.channelLen)) continue;
107
- const lower = rawName.toLowerCase();
108
- if (seen.has(lower)) continue;
109
- seen.add(lower);
110
- const chan = state.find((c) => c.nameLower === lower);
108
+ const key = caseFold('rfc1459', rawName);
109
+ if (seen.has(key)) continue;
110
+ seen.add(key);
111
+ const chan = state.find((c) => c.nameLower === key);
111
112
  if (chan === undefined) continue;
112
113
  if (!isVisible(chan, ctx)) continue;
113
114
  lines.push(listLine(chan, ctx, nick));
@@ -12,7 +12,7 @@
12
12
  * for its own user modes, so the reducer's `state` argument is the
13
13
  * caller's {@link ConnectionState}. A user may only change their own
14
14
  * modes (502 otherwise). The oper flag (`+o`) is granted via the
15
- * future OPER command, not via MODE — `+o` here is rejected with 481.
15
+ * OPER command, not via MODE — `+o` here is rejected with 481.
16
16
  *
17
17
  * Mode classes (per modern IRC de-facto convention):
18
18
  * - Type A (list mode, always takes arg): `b` (ban mask).
@@ -28,6 +28,7 @@
28
28
  * channel.
29
29
  */
30
30
 
31
+ import { caseFold } from '../case-fold.js';
31
32
  import { Effect } from '../effects.js';
32
33
  import type { Effect as EffectType, RawLine } from '../effects.js';
33
34
  import { Numerics } from '../protocol/numerics.js';
@@ -66,11 +67,6 @@ function numericErr(ctx: Ctx, code: number, trailing: string, middle?: string):
66
67
  return { text: parts.join(' ') };
67
68
  }
68
69
 
69
- /** Lowercases a nick using ASCII case folding (matches kick.ts). */
70
- function lowerNick(nick: string): string {
71
- return nick.toLowerCase();
72
- }
73
-
74
70
  /** Boolean channel mode letters → mode-field name. */
75
71
  const BOOLEAN_LETTERS: Readonly<Record<string, BooleanChannelMode>> = {
76
72
  i: 'inviteOnly',
@@ -417,9 +413,9 @@ function findMemberByNick(
417
413
  state: ChannelState,
418
414
  nick: string,
419
415
  ): { conn: string; nick: string; op: boolean; voice: boolean } | null {
420
- const targetLower = lowerNick(nick);
416
+ const targetLower = caseFold('rfc1459', nick);
421
417
  for (const entry of state.members.values()) {
422
- if (lowerNick(entry.nick) === targetLower) {
418
+ if (caseFold('rfc1459', entry.nick) === targetLower) {
423
419
  return entry;
424
420
  }
425
421
  }
@@ -566,7 +562,7 @@ export const userModeReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
566
562
 
567
563
  // Target must be the caller's own nick (case-insensitive).
568
564
  const myNick = state.nick;
569
- if (myNick === undefined || lowerNick(target) !== lowerNick(myNick)) {
565
+ if (myNick === undefined || caseFold('rfc1459', target) !== caseFold('rfc1459', myNick)) {
570
566
  effects.push(
571
567
  Effect.send(ctx.connId, [
572
568
  numericErr(ctx, Numerics.ERR_USERSDONTMATCH, 'Cannot change mode for other users'),