serverless-ircd 0.2.0 → 0.4.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 (221) 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 +258 -18
  8. package/README.md +72 -30
  9. package/apps/aws-stack/README.md +2 -2
  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 +118 -6
  13. package/apps/aws-stack/tests/stack.test.ts +98 -3
  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/scripts/smoke.mjs +0 -0
  34. package/apps/cf-worker/src/worker.ts +8 -2
  35. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  36. package/apps/cf-worker/wrangler.test.toml +11 -1
  37. package/apps/cf-worker/wrangler.toml +69 -19
  38. package/apps/local-cli/package.json +1 -1
  39. package/apps/local-cli/src/config-loader.ts +11 -0
  40. package/apps/local-cli/src/main.ts +1 -1
  41. package/apps/local-cli/src/server.ts +46 -3
  42. package/apps/local-cli/tests/e2e.test.ts +52 -0
  43. package/package.json +19 -16
  44. package/packages/aws-adapter/package.json +3 -3
  45. package/packages/aws-adapter/src/account-store.ts +121 -0
  46. package/packages/aws-adapter/src/admission.ts +74 -0
  47. package/packages/aws-adapter/src/aws-runtime.ts +124 -34
  48. package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
  49. package/packages/aws-adapter/src/config-loader.ts +62 -0
  50. package/packages/aws-adapter/src/dynamo-account-store.ts +95 -0
  51. package/packages/aws-adapter/src/handlers/connect.ts +64 -8
  52. package/packages/aws-adapter/src/handlers/default.ts +43 -2
  53. package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
  54. package/packages/aws-adapter/src/handlers/index.ts +120 -9
  55. package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
  56. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  57. package/packages/aws-adapter/src/index.ts +13 -0
  58. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +182 -0
  59. package/packages/aws-adapter/tests/account-store.test.ts +279 -0
  60. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  61. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  62. package/packages/aws-adapter/tests/config-loader.test.ts +75 -0
  63. package/packages/aws-adapter/tests/connect.test.ts +78 -0
  64. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +343 -0
  65. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  66. package/packages/aws-adapter/tests/handlers.test.ts +194 -47
  67. package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
  68. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  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 +1 -1
  72. package/packages/cf-adapter/src/cf-runtime.ts +40 -22
  73. package/packages/cf-adapter/src/channel-do.ts +40 -1
  74. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  75. package/packages/cf-adapter/src/config-loader.ts +62 -0
  76. package/packages/cf-adapter/src/connection-do.ts +181 -31
  77. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  78. package/packages/cf-adapter/src/env.ts +58 -8
  79. package/packages/cf-adapter/src/index.ts +11 -9
  80. package/packages/cf-adapter/tests/cf-harness.ts +11 -1
  81. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
  82. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  83. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  84. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  85. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  86. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  87. package/packages/cf-adapter/tests/connection-do.test.ts +40 -0
  88. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  89. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  90. package/packages/cf-adapter/tests/worker/main.ts +9 -1
  91. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  92. package/packages/cf-adapter/wrangler.test.toml +18 -1
  93. package/packages/in-memory-runtime/package.json +1 -1
  94. package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
  95. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
  96. package/packages/irc-core/package.json +8 -2
  97. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  98. package/packages/irc-core/src/caps/capabilities.ts +23 -2
  99. package/packages/irc-core/src/case-fold.ts +64 -0
  100. package/packages/irc-core/src/cloak.ts +1 -1
  101. package/packages/irc-core/src/commands/cap.ts +8 -1
  102. package/packages/irc-core/src/commands/index.ts +11 -0
  103. package/packages/irc-core/src/commands/invite.ts +6 -9
  104. package/packages/irc-core/src/commands/ison.ts +61 -0
  105. package/packages/irc-core/src/commands/isupport.ts +1 -1
  106. package/packages/irc-core/src/commands/join.ts +4 -3
  107. package/packages/irc-core/src/commands/kick.ts +4 -11
  108. package/packages/irc-core/src/commands/list.ts +5 -4
  109. package/packages/irc-core/src/commands/mode.ts +5 -9
  110. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  111. package/packages/irc-core/src/commands/motd.ts +6 -110
  112. package/packages/irc-core/src/commands/oper.ts +151 -0
  113. package/packages/irc-core/src/commands/quit.ts +12 -0
  114. package/packages/irc-core/src/commands/registration.ts +26 -19
  115. package/packages/irc-core/src/commands/sasl.ts +72 -9
  116. package/packages/irc-core/src/commands/server-info.ts +129 -0
  117. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  118. package/packages/irc-core/src/commands/userhost.ts +84 -0
  119. package/packages/irc-core/src/commands/whowas.ts +113 -0
  120. package/packages/irc-core/src/config.ts +84 -3
  121. package/packages/irc-core/src/credential-hashing.ts +124 -0
  122. package/packages/irc-core/src/effects.ts +6 -29
  123. package/packages/irc-core/src/index.ts +3 -0
  124. package/packages/irc-core/src/ports.ts +240 -7
  125. package/packages/irc-core/src/protocol/numerics.ts +14 -8
  126. package/packages/irc-core/src/types.ts +60 -1
  127. package/packages/irc-core/tests/account-store.test.ts +131 -0
  128. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  129. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  130. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  131. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  132. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  133. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  134. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  135. package/packages/irc-core/tests/commands/registration.test.ts +256 -19
  136. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  137. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  138. package/packages/irc-core/tests/commands/tagmsg.test.ts +662 -0
  139. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  140. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  141. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  142. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  143. package/packages/irc-core/tests/config.test.ts +170 -1
  144. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  145. package/packages/irc-core/tests/effects.test.ts +0 -27
  146. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  147. package/packages/irc-core/tests/numerics.test.ts +12 -0
  148. package/packages/irc-core/tests/types.test.ts +35 -1
  149. package/packages/irc-core/tsconfig.build.json +1 -1
  150. package/packages/irc-core/tsconfig.test.json +1 -1
  151. package/packages/irc-server/package.json +1 -1
  152. package/packages/irc-server/src/actor.ts +182 -14
  153. package/packages/irc-server/src/dispatch.ts +0 -3
  154. package/packages/irc-server/src/index.ts +10 -2
  155. package/packages/irc-server/src/routing.ts +21 -0
  156. package/packages/irc-server/src/transport.ts +101 -0
  157. package/packages/irc-server/tests/actor.test.ts +617 -1
  158. package/packages/irc-server/tests/dispatch.test.ts +0 -17
  159. package/packages/irc-server/tests/routing.test.ts +7 -0
  160. package/packages/irc-server/tests/transport.test.ts +230 -0
  161. package/packages/irc-test-support/package.json +1 -1
  162. package/packages/irc-test-support/src/harness.ts +44 -9
  163. package/packages/irc-test-support/src/in-memory-harness.ts +78 -13
  164. package/packages/irc-test-support/src/index.ts +3 -0
  165. package/packages/irc-test-support/src/scenarios.ts +132 -2
  166. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  167. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  168. package/pnpm-workspace.yaml +9 -0
  169. package/tools/ci-hardening/package.json +1 -1
  170. package/tools/package.json +4 -0
  171. package/tools/seed-aws-accounts.ts +79 -0
  172. package/tools/seed-cf-accounts.ts +104 -0
  173. package/tools/tcp-ws-forwarder/package.json +1 -1
  174. package/AGENTS.md +0 -5
  175. package/MOTD.txt +0 -3
  176. package/PLAN-FIXES.md +0 -358
  177. package/PLAN.md +0 -420
  178. package/dashboards/cloudwatch-irc.json +0 -139
  179. package/docs/AWS-Adapter-Architecture.md +0 -494
  180. package/docs/AWS-Deployment.md +0 -1107
  181. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  182. package/docs/Home.md +0 -1
  183. package/docs/Observability.md +0 -87
  184. package/docs/PlanIRCv3Websocket.md +0 -489
  185. package/docs/PlanWebClient.md +0 -451
  186. package/docs/Release-Process.md +0 -440
  187. package/progress.md +0 -107
  188. package/tickets.md +0 -2485
  189. package/webircgateway/LICENSE +0 -201
  190. package/webircgateway/Makefile +0 -44
  191. package/webircgateway/README.md +0 -134
  192. package/webircgateway/config.conf.example +0 -135
  193. package/webircgateway/go.mod +0 -16
  194. package/webircgateway/go.sum +0 -89
  195. package/webircgateway/main.go +0 -118
  196. package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
  197. package/webircgateway/pkg/identd/identd.go +0 -86
  198. package/webircgateway/pkg/identd/rpcclient.go +0 -59
  199. package/webircgateway/pkg/irc/isupport.go +0 -56
  200. package/webircgateway/pkg/irc/message.go +0 -217
  201. package/webircgateway/pkg/irc/state.go +0 -79
  202. package/webircgateway/pkg/proxy/proxy.go +0 -129
  203. package/webircgateway/pkg/proxy/server.go +0 -237
  204. package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
  205. package/webircgateway/pkg/webircgateway/client.go +0 -741
  206. package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
  207. package/webircgateway/pkg/webircgateway/config.go +0 -385
  208. package/webircgateway/pkg/webircgateway/gateway.go +0 -278
  209. package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
  210. package/webircgateway/pkg/webircgateway/hooks.go +0 -152
  211. package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
  212. package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
  213. package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
  214. package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
  215. package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
  216. package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
  217. package/webircgateway/pkg/webircgateway/utils.go +0 -147
  218. package/webircgateway/plugins/example/plugin.go +0 -11
  219. package/webircgateway/plugins/stats/plugin.go +0 -52
  220. package/webircgateway/staticcheck.conf +0 -1
  221. package/webircgateway/webircgateway.svg +0 -3
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Pure reducer for the IRCv3 `message-tags` `TAGMSG` command.
3
+ *
4
+ * Spec: https://ircv3.net/specs/extensions/message-tags#the-tagmsg-command
5
+ *
6
+ * TAGMSG carries ONLY message tags — no text body. Wire format:
7
+ * `@+client-tag=value;vendor/tag=value :nick!user@host TAGMSG #target`
8
+ *
9
+ * The command is gated behind the `message-tags` capability: clients that
10
+ * did not negotiate it see `421 ERR_UNKNOWNCOMMAND` (matching the spec's
11
+ * permitted "unknown command" fallback, same as `draft/chathistory`).
12
+ *
13
+ * After the cap gate passes, TAGMSG follows NOTICE semantics: **no numeric
14
+ * error replies are ever emitted.** All rejection paths (+n, +m, +b,
15
+ * missing target) silently return an empty effect list.
16
+ *
17
+ * Recording mirrors the PRIVMSG/NOTICE channel path so chathistory replay
18
+ * (which already handles `command === 'TAGMSG'` in `formatReplayLine`)
19
+ * picks up TAGMSG lines for free.
20
+ */
21
+
22
+ import { Effect } from '../effects.js';
23
+ import type { Effect as EffectType, RawLine } from '../effects.js';
24
+ import { Numerics } from '../protocol/numerics.js';
25
+ import { escapeTagValue } from '../protocol/serializer.js';
26
+ import type { ChannelState } from '../state/channel.js';
27
+ import { hostmaskOf } from '../state/connection.js';
28
+ import type { ConnectionState } from '../state/connection.js';
29
+ import type { Ctx, Reducer } from '../types.js';
30
+
31
+ /** The cap name advertising message-tags support. */
32
+ const MESSAGE_TAGS_CAP = 'message-tags';
33
+
34
+ /**
35
+ * Returns true iff `hostmask` matches the IRC ban mask `mask`. Identical to
36
+ * the implementation in `privmsg.ts`; duplicated to keep the message path
37
+ * self-contained (ban-matching consolidation is planned).
38
+ */
39
+ function matchesBanMask(mask: string, hostmask: string): boolean {
40
+ let pattern = '^';
41
+ for (const ch of mask) {
42
+ if (ch === '*') {
43
+ pattern += '.*';
44
+ } else if (ch === '?') {
45
+ pattern += '.';
46
+ } else if (/[A-Za-z0-9]/.test(ch)) {
47
+ pattern += ch;
48
+ } else {
49
+ pattern += `\\${ch}`;
50
+ }
51
+ }
52
+ pattern += '$';
53
+ return new RegExp(pattern, 'i').test(hostmask);
54
+ }
55
+
56
+ /** Formats a numeric error line addressed to the connection's nick (or `*`). */
57
+ function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
58
+ const nick = ctx.connection.nick ?? '*';
59
+ const codeStr = code.toString().padStart(3, '0');
60
+ return { text: [`:${ctx.serverName}`, codeStr, nick, middle, `:${trailing}`].join(' ') };
61
+ }
62
+
63
+ /** Resolves the sender's hostmask, falling back to nick then `?`. */
64
+ function senderHostmask(conn: ConnectionState): string {
65
+ return hostmaskOf(conn) ?? conn.nick ?? '?';
66
+ }
67
+
68
+ /**
69
+ * Returns true iff `entry` may speak on a moderated channel: ops and voiced
70
+ * members pass; everyone else is blocked.
71
+ */
72
+ function maySpeakOnModerated(entry: { op: boolean; voice: boolean } | undefined): boolean {
73
+ if (entry === undefined) return false;
74
+ return entry.op || entry.voice;
75
+ }
76
+
77
+ /**
78
+ * Serializes `tags` into the `@key=val;… ` wire prefix used by TAGMSG.
79
+ * Returns an empty string when there are no tags (so the line has no `@…`
80
+ * prefix at all). A valueless tag (empty string) renders as just the key.
81
+ */
82
+ function serializeTags(tags: Readonly<Record<string, string>>): string {
83
+ const entries = Object.entries(tags);
84
+ if (entries.length === 0) return '';
85
+ const section = entries
86
+ .map(([key, value]) => (value === '' ? key : `${key}=${escapeTagValue(value)}`))
87
+ .join(';');
88
+ return `@${section} `;
89
+ }
90
+
91
+ /** Reducer for `TAGMSG <channel>`. Authority: ChannelState. */
92
+ export const tagmsgChannelReducer: Reducer<ChannelState> = (state, msg, ctx) => {
93
+ ctx.connection.lastSeen = ctx.clock.now();
94
+
95
+ // Cap gate: clients that did not negotiate message-tags see RFC 2812 only.
96
+ if (!ctx.connection.caps.has(MESSAGE_TAGS_CAP)) {
97
+ return {
98
+ state,
99
+ effects: [
100
+ Effect.send(ctx.connId, [
101
+ numericErr(ctx, Numerics.ERR_UNKNOWNCOMMAND, 'Unknown command', 'TAGMSG'),
102
+ ]),
103
+ ],
104
+ };
105
+ }
106
+
107
+ const effects: EffectType[] = [];
108
+
109
+ const target = msg.params[0];
110
+
111
+ // NOTICE-style silence: missing target → empty effects.
112
+ if (target === undefined) {
113
+ return { state, effects };
114
+ }
115
+
116
+ const senderEntry = state.members.get(ctx.connId);
117
+ const hostmask = senderHostmask(ctx.connection);
118
+
119
+ // +n (no external messages): sender must be a member. Silent.
120
+ if (state.modes.noExternal && senderEntry === undefined) {
121
+ return { state, effects };
122
+ }
123
+
124
+ // +m (moderated): sender must be op or voiced. Silent.
125
+ if (state.modes.moderated && !maySpeakOnModerated(senderEntry)) {
126
+ return { state, effects };
127
+ }
128
+
129
+ // +b (ban): sender's hostmask must not match any ban mask. Silent.
130
+ if (state.banMasks.size > 0) {
131
+ for (const mask of state.banMasks) {
132
+ if (matchesBanMask(mask, hostmask)) {
133
+ return { state, effects };
134
+ }
135
+ }
136
+ }
137
+
138
+ // Persist for chathistory playback when a MessageStore is bound.
139
+ if (ctx.messages !== undefined) {
140
+ ctx.messages.record({
141
+ msgid: ctx.ids.nonce(),
142
+ time: ctx.clock.now(),
143
+ chan: state.nameLower,
144
+ command: 'TAGMSG',
145
+ nick: ctx.connection.nick as string,
146
+ user: ctx.connection.user as string,
147
+ host: ctx.connection.host as string,
148
+ text: '',
149
+ });
150
+ }
151
+
152
+ // Broadcast the tag-bearing line to message-tags-enabled members only.
153
+ const tagPrefix = serializeTags(msg.tags);
154
+ const line: RawLine = { text: `${tagPrefix}:${hostmask} TAGMSG ${state.name}` };
155
+ effects.push(Effect.broadcast(state.name, [line], ctx.connId, MESSAGE_TAGS_CAP));
156
+
157
+ // IRCv3 echo-message: echo the same line back to the sender, after
158
+ // the broadcast effect so clients observe their own message last.
159
+ if (ctx.connection.caps.has('echo-message')) {
160
+ effects.push(Effect.send(ctx.connId, [line]));
161
+ }
162
+
163
+ return { state, effects };
164
+ };
165
+
166
+ /** Reducer for `TAGMSG <nick>`. Authority: sender's ConnectionState. */
167
+ export const tagmsgUserReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
168
+ state.lastSeen = ctx.clock.now();
169
+
170
+ // Cap gate: clients that did not negotiate message-tags see RFC 2812 only.
171
+ if (!ctx.connection.caps.has(MESSAGE_TAGS_CAP)) {
172
+ return {
173
+ state,
174
+ effects: [
175
+ Effect.send(ctx.connId, [
176
+ numericErr(ctx, Numerics.ERR_UNKNOWNCOMMAND, 'Unknown command', 'TAGMSG'),
177
+ ]),
178
+ ],
179
+ };
180
+ }
181
+
182
+ const effects: EffectType[] = [];
183
+
184
+ const target = msg.params[0];
185
+
186
+ // NOTICE-style silence: missing target → empty effects.
187
+ if (target === undefined) {
188
+ return { state, effects };
189
+ }
190
+
191
+ const hostmask = senderHostmask(state);
192
+ const tagPrefix = serializeTags(msg.tags);
193
+ const line: RawLine = { text: `${tagPrefix}:${hostmask} TAGMSG ${target}` };
194
+
195
+ // No notFoundLines — TAGMSG is silent like NOTICE for offline recipients.
196
+ effects.push(Effect.sendToNick(target, ctx.connId, [line]));
197
+
198
+ // IRCv3 echo-message: echo the same line back to the sender, after
199
+ // the SendToNick effect so clients observe their own message last.
200
+ if (ctx.connection.caps.has('echo-message')) {
201
+ effects.push(Effect.send(ctx.connId, [line]));
202
+ }
203
+
204
+ return { state, effects };
205
+ };
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Pure reducer for the IRC `USERHOST` command.
3
+ *
4
+ * PLAN §2.1 location-of-authority: USERHOST is read-only — it queries nick
5
+ * → connection state and emits a single `302 RPL_USERHOST` line. The reducer
6
+ * itself does not perform lookups (those are async and belong to the actor /
7
+ * runtime layer); it receives the pre-resolved snapshots and formats the
8
+ * reply.
9
+ *
10
+ * Wire format (RFC 2812 §5.5):
11
+ * - `USERHOST <nick>{ <nick>}` (up to {@link ServerConfig.maxTargetsPerCommand})
12
+ * - `302 RPL_USERHOST`:
13
+ * `:<server> 302 <nick> :<reply1> <reply2> …`
14
+ * - Each reply: `<nick>[*]=<+|-><user>@<host>`
15
+ * - `*` between nick and `=` marks an IRC operator.
16
+ * - `+` means available; `-` means away.
17
+ * - Offline nicks are omitted from the reply entirely.
18
+ *
19
+ * The actor resolves each requested nick via `runtime.lookupNick` +
20
+ * `runtime.getConnection` before calling this reducer, passing `null` for
21
+ * nicks that are offline.
22
+ */
23
+
24
+ import { Effect } from '../effects.js';
25
+ import type { Effect as EffectType, RawLine } from '../effects.js';
26
+ import { Numerics } from '../protocol/numerics.js';
27
+ import type { ConnSnapshot, ConnectionState } from '../state/connection.js';
28
+ import type { Ctx, ReducerResult } from '../types.js';
29
+
30
+ /**
31
+ * Formats a single USERHOST reply token for a resolved connection snapshot.
32
+ *
33
+ * `nick[*]=[+|-]user@host` — `*` for operators, `+` for available, `-` for
34
+ * away. Returns `null` for snapshots missing the required `nick` / `user` /
35
+ * `host` fields (should not happen for registered connections, but guarded).
36
+ */
37
+ export function formatUserhostReply(snap: ConnSnapshot): string | null {
38
+ if (snap.nick === undefined || snap.user === undefined || snap.host === undefined) {
39
+ return null;
40
+ }
41
+ const operMark = snap.userModes.oper ? '*' : '';
42
+ const awayMark = snap.away !== undefined ? '-' : '+';
43
+ return `${snap.nick}${operMark}=${awayMark}${snap.user}@${snap.host}`;
44
+ }
45
+
46
+ /**
47
+ * Handles `USERHOST <nick>{ <nick>}`.
48
+ *
49
+ * `resolved` maps each requested nick (original case) to its snapshot, or
50
+ * `null` when the nick is offline. The reducer formats each online entry into
51
+ * a USERHOST reply token and emits a single `302` line. Offline nicks are
52
+ * omitted. The number of queried nicks is capped at
53
+ * `ctx.serverConfig.maxTargetsPerCommand`.
54
+ *
55
+ * Signature is bespoke (not {@link Reducer}) because the actor must resolve
56
+ * the nicks asynchronously before formatting — mirrors `whoReducer` /
57
+ * `whoisReducer`.
58
+ */
59
+ export function userhostReducer(
60
+ resolved: ReadonlyMap<string, ConnSnapshot | null>,
61
+ msg: { params: readonly string[] },
62
+ ctx: Ctx,
63
+ ): ReducerResult<ConnectionState> {
64
+ ctx.connection.lastSeen = ctx.clock.now();
65
+
66
+ const cap = ctx.serverConfig.maxTargetsPerCommand;
67
+ const requested = msg.params.slice(0, cap);
68
+ const tokens: string[] = [];
69
+ for (const nick of requested) {
70
+ if (nick.length === 0) continue;
71
+ const snap = resolved.get(nick);
72
+ if (snap === null || snap === undefined) continue;
73
+ const token = formatUserhostReply(snap);
74
+ if (token !== null) tokens.push(token);
75
+ }
76
+
77
+ const ircNick = ctx.connection.nick ?? '*';
78
+ const codeStr = Numerics.RPL_USERHOST.toString().padStart(3, '0');
79
+ const trailing = tokens.join(' ');
80
+ const line: RawLine = { text: `:${ctx.serverName} ${codeStr} ${ircNick} :${trailing}` };
81
+
82
+ const effects: EffectType[] = [Effect.send(ctx.connId, [line])];
83
+ return { state: ctx.connection, effects };
84
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Pure reducer for the IRC `WHOWAS` command.
3
+ *
4
+ * PLAN §2.1 location-of-authority: WHOWAS reads the nick-history store but
5
+ * does not mutate connection or channel state. The actor layer resolves
6
+ * `ctx.history` (a synchronously-readable {@link NickHistoryStore}) and
7
+ * passes it here. The requester is `ctx.connection`.
8
+ *
9
+ * Wire format (RFC 1459/2812 §3.6.3):
10
+ * - `314 RPL_WHOWASUSER`:
11
+ * `:<server> 314 <nick> <target> <user> <host> * :<real>`
12
+ * - `369 RPL_ENDOFWHOWAS`:
13
+ * `:<server> 369 <nick> <target> :End of WHOWAS`
14
+ * - `406 ERR_WASNOSUCHNICK`:
15
+ * `:<server> 406 <nick> <target> :There was no such nickname`
16
+ * - `431 ERR_NONICKNAMEGIVEN`:
17
+ * `:<server> 431 <nick> :No nickname given`
18
+ *
19
+ * The reducer takes a bespoke signature (mirroring {@link whoisReducer})
20
+ * because it queries runtime-provided state (the history store) rather than
21
+ * the `Reducer<S>` state argument.
22
+ */
23
+
24
+ import { Effect } from '../effects.js';
25
+ import type { Effect as EffectType, RawLine } from '../effects.js';
26
+ import type { NickHistoryEntry, NickHistoryStore } from '../ports.js';
27
+ import { Numerics } from '../protocol/numerics.js';
28
+ import type { Ctx } from '../types.js';
29
+
30
+ /** Formats a numeric line addressed to the requester. */
31
+ function numericLine(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
32
+ const nick = ctx.connection.nick ?? '*';
33
+ const codeStr = code.toString().padStart(3, '0');
34
+ const parts = [`:${ctx.serverName}`, codeStr, nick];
35
+ if (middle !== undefined) parts.push(middle);
36
+ parts.push(`:${trailing}`);
37
+ return { text: parts.join(' ') };
38
+ }
39
+
40
+ /** Builds the `314 RPL_WHOWASUSER` line for one sign-off entry. */
41
+ function whowasUserLine(ctx: Ctx, entry: NickHistoryEntry): RawLine {
42
+ const requesterNick = ctx.connection.nick ?? '*';
43
+ const nick = entry.nick;
44
+ const user = entry.username ?? '?';
45
+ const host = entry.hostname ?? '?';
46
+ const real = entry.realname ?? nick;
47
+ const code = Numerics.RPL_WHOWASUSER.toString().padStart(3, '0');
48
+ return { text: `:${ctx.serverName} ${code} ${requesterNick} ${nick} ${user} ${host} * :${real}` };
49
+ }
50
+
51
+ /**
52
+ * Handles `WHOWAS <nick>{,<nick>} [<count>]`.
53
+ *
54
+ * The actor layer resolves `ctx.history` (absent when no nick-history store
55
+ * is bound) and passes the store (or `undefined`) here. For each queried
56
+ * nick the reducer emits one `314 RPL_WHOWASUSER` per matching history
57
+ * entry (capped at `count`, most-recent-first), then exactly one
58
+ * `369 RPL_ENDOFWHOWAS`. When a nick has no matches it emits
59
+ * `406 ERR_WASNOSUCHNICK` before the `369`. When no nick argument is
60
+ * supplied it emits `431 ERR_NONICKNAMEGIVEN` and returns.
61
+ */
62
+ export function whowasReducer(
63
+ history: NickHistoryStore | undefined,
64
+ msg: { command: string; params: string[] },
65
+ ctx: Ctx,
66
+ ): { effects: EffectType[] } {
67
+ ctx.connection.lastSeen = ctx.clock.now();
68
+
69
+ const effects: EffectType[] = [];
70
+ const nickParam = msg.params[0];
71
+ if (nickParam === undefined || nickParam.length === 0) {
72
+ effects.push(
73
+ Effect.send(ctx.connId, [
74
+ numericLine(ctx, Numerics.ERR_NONICKNAMEGIVEN, 'No nickname given'),
75
+ ]),
76
+ );
77
+ return { effects };
78
+ }
79
+
80
+ const countParam = msg.params[1];
81
+ const nicks = nickParam.split(',').filter((n) => n.length > 0);
82
+ const count = countParam !== undefined ? parseCount(countParam) : Number.POSITIVE_INFINITY;
83
+
84
+ for (const nick of nicks) {
85
+ const matches = history === undefined ? [] : history.query(nick, count);
86
+ for (const entry of matches) {
87
+ effects.push(Effect.send(ctx.connId, [whowasUserLine(ctx, entry)]));
88
+ }
89
+ if (matches.length === 0) {
90
+ effects.push(
91
+ Effect.send(ctx.connId, [
92
+ numericLine(ctx, Numerics.ERR_WASNOSUCHNICK, 'There was no such nickname', nick),
93
+ ]),
94
+ );
95
+ }
96
+ effects.push(
97
+ Effect.send(ctx.connId, [numericLine(ctx, Numerics.RPL_ENDOFWHOWAS, 'End of WHOWAS', nick)]),
98
+ );
99
+ }
100
+
101
+ return { effects };
102
+ }
103
+
104
+ /**
105
+ * Parses the `WHOWAS` count argument. RFC 1459 allows a non-positive or
106
+ * missing count to mean "unlimited"; a malformed (non-numeric) count is
107
+ * treated as unlimited as well so the command still answers.
108
+ */
109
+ function parseCount(raw: string): number {
110
+ const n = Number.parseInt(raw, 10);
111
+ if (!Number.isFinite(n) || n <= 0) return Number.POSITIVE_INFINITY;
112
+ return n;
113
+ }
@@ -10,12 +10,14 @@
10
10
  *
11
11
  * The reducer-facing {@link ServerConfig} (in `types.ts`) is a strict
12
12
  * subset of this schema — the reducers do not care about MOTD text,
13
- * channel-prefix policy, max-clients, or oper creds (those are consumed
14
- * by adapter-side plumbing: the MOTD provider, the JOIN validator's
15
- * prefix table, the connection admission gate, and the OPER auth path).
13
+ * channel-prefix policy, or max-clients (those are consumed by
14
+ * adapter-side plumbing: the MOTD provider, the JOIN validator's prefix
15
+ * table, and the connection admission gate). Oper credentials ARE surfaced
16
+ * on the reducer-facing config so the OPER reducer can authenticate.
16
17
  */
17
18
 
18
19
  import { z } from 'zod';
20
+ import { BUILD_DATE } from './build-info.js';
19
21
 
20
22
  /**
21
23
  * Schema for a single IRC operator credential.
@@ -28,6 +30,17 @@ export const OperCredSchema = z.object({
28
30
  password: z.string().min(1),
29
31
  });
30
32
 
33
+ /**
34
+ * Schema for a single SASL PLAIN account credential. The `username` is also
35
+ * the canonical account name recorded on the connection (for `extended-join`
36
+ * / `account-tag`). Both fields are non-empty strings; adapters seed an
37
+ * {@link InMemoryAccountStore} from this list at boot.
38
+ */
39
+ export const SaslAccountSchema = z.object({
40
+ username: z.string().min(1),
41
+ password: z.string().min(1),
42
+ });
43
+
31
44
  /**
32
45
  * Cloaking configuration. When enabled, the visible hostmask of every
33
46
  * connecting client is replaced by `<cloak>.<suffix>` where `<cloak>` is a
@@ -101,6 +114,26 @@ export const DEFAULT_FLOOD_CONTROL_CONFIG: {
101
114
  disconnectThreshold: 0,
102
115
  };
103
116
 
117
+ /**
118
+ * Default server version surfaced in `002`/`004`/`351`/`371` when a deployment
119
+ * does not supply one. Kept in sync with `irc-core/package.json` `version`;
120
+ * adapters inject the deploy-time version via the `serverVersion` config
121
+ * knob (see the per-adapter config loaders).
122
+ */
123
+ export const DEFAULT_SERVER_VERSION = '0.3.0';
124
+
125
+ /**
126
+ * Default "created" text for `003 RPL_CREATED` when a deployment does not
127
+ * supply one. Baked into `dist/index.js` at build time by
128
+ * `scripts/generate-build-info.mjs` (run via `prebuild`); the value is the
129
+ * ISO timestamp of the build that produced the artifact, so a deployed
130
+ * Worker/Lambda reports the date it was built rather than a static
131
+ * placeholder. Production deployments may still override via the `createdAt`
132
+ * config knob (a human-readable string or an epoch-ms number that the
133
+ * reducer formats).
134
+ */
135
+ export const DEFAULT_CREATED_TEXT: string = BUILD_DATE;
136
+
104
137
  /**
105
138
  * The full server-config schema. Optional fields default to deployment-
106
139
  * sensible values so a minimal `{ serverName, networkName }` config is
@@ -112,6 +145,22 @@ export const ServerConfigSchema = z.object({
112
145
  /** Network name advertised in `005 NETWORK=...` and WHOIS replies. */
113
146
  networkName: z.string().min(1),
114
147
 
148
+ /**
149
+ * Server version surfaced in the `002`/`004` welcome numerics, `351
150
+ * RPL_VERSION`, and `371 RPL_INFO`. Defaults to the irc-core package
151
+ * version ({@link DEFAULT_SERVER_VERSION}); deployments inject the
152
+ * deploy-time version (e.g. via a build-time constant or env var).
153
+ */
154
+ serverVersion: z.string().min(1).default(DEFAULT_SERVER_VERSION),
155
+
156
+ /**
157
+ * "Created" text for `003 RPL_CREATED` (`This server was created in
158
+ * <text>`). Either a human-readable string used verbatim, or an epoch-ms
159
+ * number that the reducer formats as a UTC timestamp. Defaults to
160
+ * {@link DEFAULT_CREATED_TEXT}; deployments inject the deploy/build time.
161
+ */
162
+ createdAt: z.union([z.string().min(1), z.number()]).default(DEFAULT_CREATED_TEXT),
163
+
115
164
  /**
116
165
  * Server password. Empty string or undefined disables the password
117
166
  * gate. When set, every connection must supply the same value via
@@ -182,6 +231,17 @@ export const ServerConfigSchema = z.object({
182
231
  */
183
232
  operCreds: z.array(OperCredSchema).default([]),
184
233
 
234
+ /**
235
+ * SASL PLAIN account credentials. The adapter seeds an
236
+ * `InMemoryAccountStore` from this list at boot so `AUTHENTICATE PLAIN`
237
+ * succeeds end-to-end for the listed accounts. Empty array (the default)
238
+ * disables SASL account verification: `ctx.accounts` is then omitted and
239
+ * a client completing `AUTHENTICATE PLAIN` receives `904 ERR_SASLFAIL`.
240
+ * A persistent variant (DynamoDB `Accounts`, D1) is a documented
241
+ * follow-up; swapping it in means replacing the construction call.
242
+ */
243
+ saslAccounts: z.array(SaslAccountSchema).default([]),
244
+
185
245
  // --- Reducer-facing limit knobs (mirrored on `ServerConfig`) ---
186
246
 
187
247
  /** Maximum channels a single connection may JOIN. */
@@ -245,3 +305,24 @@ export const DEFAULT_SERVER_CONFIG: ParsedServerConfig = ServerConfigSchema.pars
245
305
  serverName: 'irc.example.com',
246
306
  networkName: 'ServerlessIRCd',
247
307
  });
308
+
309
+ /**
310
+ * Resolves the effective server version for wire output: the configured
311
+ * value when present, otherwise {@link DEFAULT_SERVER_VERSION}. Used by the
312
+ * registration and server-info reducers so they share one source of truth.
313
+ */
314
+ export function resolveServerVersion(cfg: { readonly serverVersion?: string | undefined }): string {
315
+ return cfg.serverVersion ?? DEFAULT_SERVER_VERSION;
316
+ }
317
+
318
+ /**
319
+ * Formats the `003 RPL_CREATED` text from the configured `createdAt`:
320
+ * a string is used verbatim, a number is treated as epoch-ms and rendered
321
+ * as a UTC timestamp, and `undefined` falls back to
322
+ * {@link DEFAULT_CREATED_TEXT}.
323
+ */
324
+ export function formatCreatedAt(createdAt: string | number | undefined): string {
325
+ if (createdAt === undefined) return DEFAULT_CREATED_TEXT;
326
+ if (typeof createdAt === 'number') return new Date(createdAt).toUTCString();
327
+ return createdAt;
328
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Shared SASL credential hashing primitives.
3
+ *
4
+ * Both the AWS adapter (`DynamoAccountStore`) and the CF adapter
5
+ * (`D1AccountStore`) store SASL PLAIN credentials as **scrypt hashes**.
6
+ * These helpers live in `irc-core` so both adapters share one hashing
7
+ * implementation — the AWS path previously owned the only copy.
8
+ *
9
+ * Uses `node:crypto` (`scryptSync`), which is available on Node (AWS Lambda)
10
+ * natively and on Cloudflare Workers via the `nodejs_compat` flag. The public
11
+ * API surface is platform-agnostic: salts are passed as `Uint8Array` and the
12
+ * stored `HashedAccountCredential` carries only base64 `string` fields, so
13
+ * consuming packages never need `@types/node` to use these helpers.
14
+ */
15
+
16
+ import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
17
+ import type { AccountStore, SaslPayload, SaslResult } from './ports.js';
18
+
19
+ const SCRYPT_KEY_LEN = 64;
20
+ const SALT_LEN = 16;
21
+
22
+ /**
23
+ * A single SASL PLAIN credential stored as a scrypt hash.
24
+ *
25
+ * Every field is a persistence-backend attribute — this object IS the table
26
+ * row (DynamoDB `Accounts` item / D1 `accounts` row, keyed by `account`).
27
+ * `algorithm` is retained so future migrations to Argon2/bcrypt can be
28
+ * detected and handled gracefully by {@link verifyHashedPassword}.
29
+ */
30
+ export interface HashedAccountCredential {
31
+ readonly account: string;
32
+ readonly algorithm: 'scrypt';
33
+ readonly salt: string;
34
+ readonly hash: string;
35
+ }
36
+
37
+ /**
38
+ * Hashes a plaintext password into a {@link HashedAccountCredential}
39
+ * suitable for writing to the accounts backend (`putAccountCredential` /
40
+ * `seed-cf-accounts`).
41
+ *
42
+ * Uses `scryptSync` (memory-hard, GPU-resistant) with a random salt.
43
+ * Pass `{ salt }` for deterministic test scenarios; pass `{ keyLen }` to
44
+ * override the default 64-byte derived-key length.
45
+ *
46
+ * @param opts.salt Optional salt (deterministic for tests). When omitted a
47
+ * fresh cryptographically-random salt is generated. Typed as `Uint8Array`
48
+ * to keep the public API free of node-specific `Buffer` types.
49
+ */
50
+ export function hashAccountCredential(
51
+ username: string,
52
+ password: string,
53
+ opts?: { salt?: Uint8Array; keyLen?: number },
54
+ ): HashedAccountCredential {
55
+ const salt = opts?.salt ?? randomBytes(SALT_LEN);
56
+ const keyLen = opts?.keyLen ?? SCRYPT_KEY_LEN;
57
+ const hash = scryptSync(password, salt, keyLen);
58
+ return {
59
+ account: username,
60
+ algorithm: 'scrypt',
61
+ salt: Buffer.from(salt).toString('base64'),
62
+ hash: hash.toString('base64'),
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Verifies a plaintext password against a {@link HashedAccountCredential}
68
+ * using `timingSafeEqual` (no short-circuit on first mismatched byte).
69
+ *
70
+ * Returns `false` for malformed entries rather than throwing — a corrupt
71
+ * row should not crash the SASL exchange.
72
+ */
73
+ export function verifyHashedPassword(password: string, entry: HashedAccountCredential): boolean {
74
+ if (entry.algorithm !== 'scrypt') return false;
75
+ const salt = Buffer.from(entry.salt, 'base64');
76
+ const expected = Buffer.from(entry.hash, 'base64');
77
+ if (expected.length === 0) return false;
78
+ const computed = scryptSync(password, salt, expected.length);
79
+ return computed.length === expected.length && timingSafeEqual(computed, expected);
80
+ }
81
+
82
+ /**
83
+ * Synchronous {@link AccountStore} backed by pre-loaded hashed credentials.
84
+ *
85
+ * Construct with entries produced by {@link hashAccountCredential} (for unit
86
+ * tests / seeding) or pre-loaded from a persistence backend at boot. Both
87
+ * adapters use this class:
88
+ *
89
+ * - AWS: `loadDynamoAccountStore` scans the `Accounts` table at Lambda
90
+ * cold start and constructs this store; `DynamoAccountStore` is a
91
+ * re-export alias for back-compat.
92
+ * - CF: `loadD1AccountStore` queries the D1 `accounts` table at
93
+ * `ConnectionDO` construction (mirroring the two-phase load pattern).
94
+ *
95
+ * The class has zero backend coupling — it works equally well with entries
96
+ * from any source. The `AccountStore.verify` port stays synchronous, so
97
+ * the async pre-load MUST complete before the first frame dispatches.
98
+ */
99
+ export class HashedAccountStore implements AccountStore {
100
+ private readonly entries: ReadonlyMap<string, HashedAccountCredential>;
101
+
102
+ constructor(entries: ReadonlyArray<HashedAccountCredential>) {
103
+ this.entries = new Map(entries.map((e) => [e.account, e]));
104
+ }
105
+
106
+ verify(mech: string, payload: SaslPayload): SaslResult {
107
+ if (mech.toUpperCase() !== 'PLAIN' || payload.kind !== 'PLAIN') {
108
+ return { ok: false, reason: `unsupported mechanism: ${mech}` };
109
+ }
110
+ const entry = this.entries.get(payload.username);
111
+ if (entry === undefined) {
112
+ return { ok: false, reason: 'invalid credentials' };
113
+ }
114
+ if (!verifyHashedPassword(payload.password, entry)) {
115
+ return { ok: false, reason: 'invalid credentials' };
116
+ }
117
+ return { ok: true, account: payload.username };
118
+ }
119
+
120
+ /** Number of loaded accounts (diagnostics / logging). */
121
+ get size(): number {
122
+ return this.entries.size;
123
+ }
124
+ }