serverless-ircd 0.2.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 (134) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +28 -19
  3. package/apps/cf-worker/package.json +1 -1
  4. package/apps/cf-worker/scripts/smoke.mjs +0 -0
  5. package/apps/cf-worker/src/worker.ts +8 -2
  6. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  7. package/apps/cf-worker/wrangler.test.toml +5 -1
  8. package/apps/cf-worker/wrangler.toml +41 -17
  9. package/apps/local-cli/package.json +1 -1
  10. package/apps/local-cli/src/config-loader.ts +1 -0
  11. package/apps/local-cli/src/main.ts +1 -1
  12. package/apps/local-cli/src/server.ts +28 -2
  13. package/apps/local-cli/tests/e2e.test.ts +52 -0
  14. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  15. package/docs/ADR-002-location-of-authority.md +82 -0
  16. package/docs/ADR-003-durable-object-sharding.md +93 -0
  17. package/docs/ADR-004-dynamodb-schema.md +96 -0
  18. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  19. package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
  20. package/docs/ADR-007-deterministic-ports.md +82 -0
  21. package/docs/ADR-008-monorepo-tooling.md +60 -0
  22. package/docs/AWS-Adapter-Architecture.md +6 -4
  23. package/docs/AWS-Deployment.md +86 -7
  24. package/docs/Cloudflare-Deployment-Guide.md +5 -5
  25. package/docs/Home.md +11 -1
  26. package/docs/Release-Process.md +9 -6
  27. package/package.json +14 -15
  28. package/packages/aws-adapter/package.json +1 -1
  29. package/packages/aws-adapter/src/account-store.ts +121 -0
  30. package/packages/aws-adapter/src/aws-runtime.ts +118 -30
  31. package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
  32. package/packages/aws-adapter/src/config-loader.ts +30 -0
  33. package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
  34. package/packages/aws-adapter/src/handlers/default.ts +8 -0
  35. package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
  36. package/packages/aws-adapter/src/handlers/index.ts +51 -6
  37. package/packages/aws-adapter/src/index.ts +7 -0
  38. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
  39. package/packages/aws-adapter/tests/account-store.test.ts +280 -0
  40. package/packages/aws-adapter/tests/config-loader.test.ts +55 -0
  41. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
  42. package/packages/aws-adapter/tests/handlers.test.ts +46 -0
  43. package/packages/cf-adapter/package.json +1 -1
  44. package/packages/cf-adapter/src/cf-runtime.ts +42 -22
  45. package/packages/cf-adapter/src/channel-do.ts +40 -1
  46. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  47. package/packages/cf-adapter/src/config-loader.ts +29 -0
  48. package/packages/cf-adapter/src/connection-do.ts +85 -0
  49. package/packages/cf-adapter/src/env.ts +27 -1
  50. package/packages/cf-adapter/src/index.ts +2 -1
  51. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  52. package/packages/cf-adapter/tests/connection-do.test.ts +40 -0
  53. package/packages/cf-adapter/tests/worker/main.ts +2 -1
  54. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  55. package/packages/cf-adapter/wrangler.test.toml +10 -1
  56. package/packages/in-memory-runtime/package.json +1 -1
  57. package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
  58. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
  59. package/packages/irc-core/package.json +3 -2
  60. package/packages/irc-core/src/case-fold.ts +64 -0
  61. package/packages/irc-core/src/commands/index.ts +2 -0
  62. package/packages/irc-core/src/commands/invite.ts +6 -9
  63. package/packages/irc-core/src/commands/isupport.ts +1 -1
  64. package/packages/irc-core/src/commands/join.ts +4 -3
  65. package/packages/irc-core/src/commands/kick.ts +4 -11
  66. package/packages/irc-core/src/commands/list.ts +5 -4
  67. package/packages/irc-core/src/commands/mode.ts +5 -9
  68. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  69. package/packages/irc-core/src/commands/motd.ts +6 -110
  70. package/packages/irc-core/src/commands/oper.ts +151 -0
  71. package/packages/irc-core/src/commands/registration.ts +8 -7
  72. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  73. package/packages/irc-core/src/config.ts +26 -3
  74. package/packages/irc-core/src/index.ts +2 -0
  75. package/packages/irc-core/src/ports.ts +68 -4
  76. package/packages/irc-core/src/types.ts +22 -0
  77. package/packages/irc-core/tests/account-store.test.ts +88 -0
  78. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  79. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  80. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  81. package/packages/irc-core/tests/commands/registration.test.ts +106 -14
  82. package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
  83. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  84. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  85. package/packages/irc-core/tests/config.test.ts +75 -0
  86. package/packages/irc-server/package.json +1 -1
  87. package/packages/irc-server/src/actor.ts +24 -0
  88. package/packages/irc-server/src/routing.ts +9 -0
  89. package/packages/irc-server/tests/actor.test.ts +220 -1
  90. package/packages/irc-server/tests/routing.test.ts +3 -0
  91. package/packages/irc-test-support/package.json +1 -1
  92. package/packages/irc-test-support/src/in-memory-harness.ts +5 -4
  93. package/pnpm-workspace.yaml +1 -0
  94. package/tools/seed-aws-accounts.ts +80 -0
  95. package/AGENTS.md +0 -5
  96. package/MOTD.txt +0 -3
  97. package/PLAN-FIXES.md +0 -358
  98. package/PLAN.md +0 -420
  99. package/dashboards/cloudwatch-irc.json +0 -139
  100. package/progress.md +0 -107
  101. package/tickets.md +0 -2485
  102. package/webircgateway/LICENSE +0 -201
  103. package/webircgateway/Makefile +0 -44
  104. package/webircgateway/README.md +0 -134
  105. package/webircgateway/config.conf.example +0 -135
  106. package/webircgateway/go.mod +0 -16
  107. package/webircgateway/go.sum +0 -89
  108. package/webircgateway/main.go +0 -118
  109. package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
  110. package/webircgateway/pkg/identd/identd.go +0 -86
  111. package/webircgateway/pkg/identd/rpcclient.go +0 -59
  112. package/webircgateway/pkg/irc/isupport.go +0 -56
  113. package/webircgateway/pkg/irc/message.go +0 -217
  114. package/webircgateway/pkg/irc/state.go +0 -79
  115. package/webircgateway/pkg/proxy/proxy.go +0 -129
  116. package/webircgateway/pkg/proxy/server.go +0 -237
  117. package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
  118. package/webircgateway/pkg/webircgateway/client.go +0 -741
  119. package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
  120. package/webircgateway/pkg/webircgateway/config.go +0 -385
  121. package/webircgateway/pkg/webircgateway/gateway.go +0 -278
  122. package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
  123. package/webircgateway/pkg/webircgateway/hooks.go +0 -152
  124. package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
  125. package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
  126. package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
  127. package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
  128. package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
  129. package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
  130. package/webircgateway/pkg/webircgateway/utils.go +0 -147
  131. package/webircgateway/plugins/example/plugin.go +0 -11
  132. package/webircgateway/plugins/stats/plugin.go +0 -52
  133. package/webircgateway/staticcheck.conf +0 -1
  134. package/webircgateway/webircgateway.svg +0 -3
@@ -11,38 +11,16 @@
11
11
  * D1, or Secrets Manager) so the reducer itself stays pure and
12
12
  * deterministic — no IO in the handler.
13
13
  *
14
- * Wire format (RFC 2812 §5):
15
- * - `375 RPL_MOTDSTART` — `:<server> 375 <nick> :- <server> Message of the day -`
16
- * - `372 RPL_MOTD` — `:<server> 372 <nick> :- <line>` (one per MOTD line)
17
- * - `376 RPL_ENDOFMOTD` — `:<server> 376 <nick> :End of MOTD command`
18
- * - `422 ERR_NOMOTD` — `:<server> 422 <nick> :MOTD File is missing`
19
- *
20
- * Per RFC 1459 §2.3 the maximum wire line length is 512 bytes including the
21
- * trailing CR-LF. Over-long MOTD content lines are hard-wrapped at the
22
- * 372-line content budget so every emitted line fits within the 512-byte
23
- * limit (also leaving headroom for message-tags).
14
+ * The numeric rendering (`375`/`372`×N/`376`, or `422 ERR_NOMOTD`) is shared
15
+ * with the registration welcome block via {@link buildMotdNumerics} so the
16
+ * two paths cannot drift.
24
17
  */
25
18
 
26
19
  import { Effect } from '../effects.js';
27
20
  import type { Effect as EffectType, RawLine } from '../effects.js';
28
- import { Numerics } from '../protocol/numerics.js';
29
21
  import type { ConnectionState } from '../state/connection.js';
30
22
  import type { Ctx, Reducer } from '../types.js';
31
-
32
- /** RFC 1459 §2.3: maximum IRC line length in bytes, including the trailing CR-LF. */
33
- const MAX_LINE_BYTES = 512;
34
-
35
- /** Length of the trailing CR-LF that the wire layer appends to each line. */
36
- const TRAILING_CRLF_LEN = 2;
37
-
38
- /** Fixed text emitted for `375 RPL_MOTDSTART` (after `:- `). */
39
- const MOTDSTART_TRAILING_TEXT = '- $server Message of the day -';
40
-
41
- /** Fixed text emitted for `376 RPL_ENDOFMOTD`. */
42
- const ENDOFMOTD_TRAILING_TEXT = 'End of MOTD command';
43
-
44
- /** Fixed text emitted for `422 ERR_NOMOTD`. */
45
- const NOMOTD_TRAILING_TEXT = 'MOTD File is missing';
23
+ import { buildMotdNumerics } from './motd-lines.js';
46
24
 
47
25
  /**
48
26
  * Handles `MOTD [server]`.
@@ -56,91 +34,9 @@ const NOMOTD_TRAILING_TEXT = 'MOTD File is missing';
56
34
  export const motdReducer: Reducer<ConnectionState> = (state, _msg, ctx) => {
57
35
  state.lastSeen = ctx.clock.now();
58
36
 
59
- const lines = ctx.motd.lines();
60
-
61
- if (lines.length === 0) {
62
- return {
63
- state,
64
- effects: [
65
- Effect.send(ctx.connId, [numericLine(ctx, Numerics.ERR_NOMOTD, NOMOTD_TRAILING_TEXT)]),
66
- ],
67
- };
68
- }
69
-
70
- const out: RawLine[] = [
71
- numericLine(
72
- ctx,
73
- Numerics.RPL_MOTDSTART,
74
- MOTDSTART_TRAILING_TEXT.replace('$server', ctx.serverName),
75
- ),
76
- ];
77
-
78
- const maxContent = maxMotdContentLen(ctx);
79
- for (const raw of lines) {
80
- for (const chunk of splitForMotd(raw, maxContent)) {
81
- out.push(numericLine(ctx, Numerics.RPL_MOTD, `- ${chunk}`));
82
- }
83
- }
84
-
85
- out.push(numericLine(ctx, Numerics.RPL_ENDOFMOTD, ENDOFMOTD_TRAILING_TEXT));
37
+ const nick = ctx.connection.nick ?? '*';
38
+ const out: RawLine[] = buildMotdNumerics(ctx.serverName, nick, ctx.motd.lines());
86
39
 
87
40
  const effects: EffectType[] = [Effect.send(ctx.connId, out)];
88
41
  return { state, effects };
89
42
  };
90
-
91
- /**
92
- * Builds a `:<server> <code> <nick> :<trailing>` line. The nick falls back
93
- * to `*` when the connection has not yet registered one, matching the rest
94
- * of the codebase's numeric-error formatting.
95
- */
96
- function numericLine(ctx: Ctx, code: number, trailing: string): RawLine {
97
- const nick = ctx.connection.nick ?? '*';
98
- const codeStr = code.toString().padStart(3, '0');
99
- return { text: `:${ctx.serverName} ${codeStr} ${nick} :${trailing}` };
100
- }
101
-
102
- /**
103
- * Returns the maximum content-byte length that fits in a single `372 RPL_MOTD`
104
- * line for this connection, given the server name and the connection's nick.
105
- *
106
- * The 372 wire format is `:<server> 372 <nick> :- <chunk>\r\n`, so the
107
- * per-chunk budget is:
108
- *
109
- * 512
110
- * - 2 (trailing CR-LF)
111
- * - 1 (leading `:`)
112
- * - <server>.length
113
- * - 5 (` 372 `)
114
- * - <nick>.length
115
- * - 4 (` :- `)
116
- *
117
- * The result is clamped at 0 so a pathologically long server/nick combo
118
- * cannot drive the budget negative.
119
- */
120
- function maxMotdContentLen(ctx: Ctx): number {
121
- const nick = ctx.connection.nick ?? '*';
122
- const overhead = TRAILING_CRLF_LEN + 1 + ctx.serverName.length + 5 + nick.length + 4;
123
- return Math.max(0, MAX_LINE_BYTES - overhead);
124
- }
125
-
126
- /**
127
- * Splits a single MOTD content line into chunks that each fit the per-372
128
- * content budget.
129
- *
130
- * Hard-wraps at the byte budget (no word-boundary awareness) — matching the
131
- * behavior of mainstream IRC daemons and keeping the reducer deterministic.
132
- *
133
- * Edge cases:
134
- * - Empty string → `['']` (one empty 372 line, still RFC-conformant).
135
- * - Zero/negative budget (impossible in practice but defensive) → `['']`
136
- * so the reducer always emits at least one 372 per input line.
137
- */
138
- function splitForMotd(text: string, maxLen: number): string[] {
139
- if (maxLen <= 0) return [''];
140
- if (text.length === 0) return [''];
141
- const chunks: string[] = [];
142
- for (let i = 0; i < text.length; i += maxLen) {
143
- chunks.push(text.slice(i, i + maxLen));
144
- }
145
- return chunks;
146
- }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Pure reducer for the IRC `OPER` command.
3
+ *
4
+ * PLAN §2.1 location-of-authority: OPER runs in the Connection entity — it
5
+ * authenticates the caller against the deployment's configured operator
6
+ * credentials and, on success, grants the `o` user mode by setting
7
+ * `state.userModes.oper = true`. The reducer's `state` argument is the
8
+ * caller's authoritative {@link ConnectionState}.
9
+ *
10
+ * Oper is the *only* path that sets `userModes.oper`: a user `MODE +o` is
11
+ * hard-rejected with `481 ERR_NOPRIVILEGES` (see `mode.ts`). Once oper is
12
+ * granted here, the `RPL_WHOISOPERATOR` (313) branch of WHOIS and the `*`
13
+ * oper flag of WHO become reachable.
14
+ *
15
+ * Wire format (RFC 2812 §5.4.1, §4.1):
16
+ * - `OPER <name> <password>` — authenticates and (on success) grants oper.
17
+ * - `381 RPL_YOUREOPER`:
18
+ * `:<server> 381 <nick> :You are now an IRC operator`
19
+ * - `461 ERR_NEEDMOREPARAMS`:
20
+ * `:<server> 461 <nick> OPER :Not enough parameters`
21
+ * - `464 ERR_PASSWDMISMATCH`:
22
+ * `:<server> 464 <nick> :Password Incorrect`
23
+ * - `491 ERR_NOOPERHOST`:
24
+ * `:<server> 491 <nick> :No O-lines for your host`
25
+ *
26
+ * Behaviour:
27
+ * - Missing `<name>` or `<password>` → `461 ERR_NEEDMOREPARAMS`.
28
+ * - Already an operator → graceful idempotent no-op that re-confirms with
29
+ * `381` (no re-authentication required; the connection is already privy).
30
+ * - No operator credentials configured (absent or empty) →
31
+ * `491 ERR_NOOPERHOST`.
32
+ * - Matching credential pair → grant oper, emit `381`.
33
+ * - Wrong password / unknown name → `464 ERR_PASSWDMISMATCH`.
34
+ */
35
+
36
+ import { Effect } from '../effects.js';
37
+ import type { RawLine } from '../effects.js';
38
+ import { Numerics } from '../protocol/numerics.js';
39
+ import type { ConnectionState } from '../state/connection.js';
40
+ import type { OperCred } from '../types.js';
41
+ import type { Ctx, Reducer } from '../types.js';
42
+
43
+ /** Fixed text emitted for `381 RPL_YOUREOPER`. */
44
+ const YOUREOPER_TRAILING_TEXT = 'You are now an IRC operator';
45
+
46
+ /** Fixed text emitted for `464 ERR_PASSWDMISMATCH`. */
47
+ const PASSWDMISMATCH_TRAILING_TEXT = 'Password Incorrect';
48
+
49
+ /** Fixed text emitted for `491 ERR_NOOPERHOST`. */
50
+ const NOOPERHOST_TRAILING_TEXT = 'No O-lines for your host';
51
+
52
+ /** Fixed text emitted for `461 ERR_NEEDMOREPARAMS`. */
53
+ const NEEDMOREPARAMS_TRAILING_TEXT = 'Not enough parameters';
54
+
55
+ /**
56
+ * Returns true iff a credential pair in `creds` matches `name` / `password`
57
+ * exactly. Extracted as a pure helper so the match logic is testable in
58
+ * isolation and independent of connection state. Comparison is case
59
+ * sensitive and byte-exact (case-mapping is owned elsewhere).
60
+ */
61
+ export function matchOperCred(
62
+ creds: ReadonlyArray<OperCred>,
63
+ name: string,
64
+ password: string,
65
+ ): boolean {
66
+ return creds.some((c) => c.user === name && c.password === password);
67
+ }
68
+
69
+ /**
70
+ * Handles `OPER <name> <password>`.
71
+ *
72
+ * Verifies the supplied pair against `serverConfig.operCreds` and, on
73
+ * success, grants the oper user mode. Emits the appropriate numeric reply
74
+ * in every branch. See the file header for the full decision table.
75
+ */
76
+ export const operReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
77
+ state.lastSeen = ctx.clock.now();
78
+
79
+ const name = msg.params[0];
80
+ const password = msg.params[1];
81
+
82
+ if (name === undefined || password === undefined) {
83
+ return {
84
+ state,
85
+ effects: [
86
+ Effect.send(ctx.connId, [
87
+ numericLine(ctx, Numerics.ERR_NEEDMOREPARAMS, NEEDMOREPARAMS_TRAILING_TEXT, 'OPER'),
88
+ ]),
89
+ ],
90
+ };
91
+ }
92
+
93
+ // Already an operator: graceful idempotent re-confirm, no re-auth needed.
94
+ if (state.userModes.oper) {
95
+ return {
96
+ state,
97
+ effects: [
98
+ Effect.send(ctx.connId, [
99
+ numericLine(ctx, Numerics.RPL_YOUREOPER, YOUREOPER_TRAILING_TEXT),
100
+ ]),
101
+ ],
102
+ };
103
+ }
104
+
105
+ const creds = ctx.serverConfig.operCreds;
106
+ if (creds === undefined || creds.length === 0) {
107
+ return {
108
+ state,
109
+ effects: [
110
+ Effect.send(ctx.connId, [
111
+ numericLine(ctx, Numerics.ERR_NOOPERHOST, NOOPERHOST_TRAILING_TEXT),
112
+ ]),
113
+ ],
114
+ };
115
+ }
116
+
117
+ if (matchOperCred(creds, name, password)) {
118
+ state.userModes.oper = true;
119
+ return {
120
+ state,
121
+ effects: [
122
+ Effect.send(ctx.connId, [
123
+ numericLine(ctx, Numerics.RPL_YOUREOPER, YOUREOPER_TRAILING_TEXT),
124
+ ]),
125
+ ],
126
+ };
127
+ }
128
+
129
+ return {
130
+ state,
131
+ effects: [
132
+ Effect.send(ctx.connId, [
133
+ numericLine(ctx, Numerics.ERR_PASSWDMISMATCH, PASSWDMISMATCH_TRAILING_TEXT),
134
+ ]),
135
+ ],
136
+ };
137
+ };
138
+
139
+ /**
140
+ * Builds a `:<server> <code> <nick> [<middle>] :<trailing>` line. The nick
141
+ * falls back to `*` when the connection has not yet registered one, matching
142
+ * the rest of the codebase's numeric formatting.
143
+ */
144
+ function numericLine(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
145
+ const nick = ctx.connection.nick ?? '*';
146
+ const codeStr = code.toString().padStart(3, '0');
147
+ const parts = [`:${ctx.serverName}`, codeStr, nick];
148
+ if (middle !== undefined) parts.push(middle);
149
+ parts.push(`:${trailing}`);
150
+ return { text: parts.join(' ') };
151
+ }
@@ -21,6 +21,7 @@ import { hostmaskOf } from '../state/connection.js';
21
21
  import type { ConnectionState } from '../state/connection.js';
22
22
  import type { Ctx, Reducer } from '../types.js';
23
23
  import { generateIsupport } from './isupport.js';
24
+ import { buildMotdNumerics } from './motd-lines.js';
24
25
 
25
26
  /**
26
27
  * Server version reported in `002` / `004`. Placeholder until a config-driven
@@ -55,9 +56,9 @@ export function isValidNick(nick: string, maxLen: number): boolean {
55
56
 
56
57
  /**
57
58
  * Builds the welcome numeric block emitted once registration completes:
58
- * `001`–`005` (with `005` as a placeholder) followed by
59
- * the `375`/`372`/`376` MOTD placeholders (real MOTD content via the
60
- * `MotdProvider` port).
59
+ * `001`–`005` (with `005` generated from the server config) followed by
60
+ * the MOTD numerics rendered from {@link Ctx.motd} — `375`/`372`×N/`376`
61
+ * for a configured MOTD, or `422 ERR_NOMOTD` when none is configured.
61
62
  *
62
63
  * Caller guarantees `state.nick` and `state.user` are set.
63
64
  */
@@ -81,10 +82,10 @@ export function buildWelcomeLines(state: ConnectionState, ctx: Ctx): RawLine[] {
81
82
  },
82
83
  // 005 RPL_ISUPPORT — real tokens generated from serverConfig.
83
84
  ...generateIsupport(ctx.serverConfig, state.caps, nick),
84
- // MOTD placeholders filled via MotdProvider elsewhere.
85
- { text: `${prefix} 375 ${nick} :- ${ctx.serverName} Message of the day -` },
86
- { text: `${prefix} 372 ${nick} :- MOTD placeholder` },
87
- { text: `${prefix} 376 ${nick} :End of MOTD command` },
85
+ // MOTD — rendered from the configured MotdProvider (375/372/376, or
86
+ // 422 ERR_NOMOTD when no MOTD is configured). Shares the exact rendering
87
+ // path with the standalone MOTD command.
88
+ ...buildMotdNumerics(ctx.serverName, nick, ctx.motd.lines()),
88
89
  ];
89
90
  }
90
91
 
@@ -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
+ };
@@ -10,9 +10,10 @@
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';
@@ -28,6 +29,17 @@ export const OperCredSchema = z.object({
28
29
  password: z.string().min(1),
29
30
  });
30
31
 
32
+ /**
33
+ * Schema for a single SASL PLAIN account credential. The `username` is also
34
+ * the canonical account name recorded on the connection (for `extended-join`
35
+ * / `account-tag`). Both fields are non-empty strings; adapters seed an
36
+ * {@link InMemoryAccountStore} from this list at boot.
37
+ */
38
+ export const SaslAccountSchema = z.object({
39
+ username: z.string().min(1),
40
+ password: z.string().min(1),
41
+ });
42
+
31
43
  /**
32
44
  * Cloaking configuration. When enabled, the visible hostmask of every
33
45
  * connecting client is replaced by `<cloak>.<suffix>` where `<cloak>` is a
@@ -182,6 +194,17 @@ export const ServerConfigSchema = z.object({
182
194
  */
183
195
  operCreds: z.array(OperCredSchema).default([]),
184
196
 
197
+ /**
198
+ * SASL PLAIN account credentials. The adapter seeds an
199
+ * `InMemoryAccountStore` from this list at boot so `AUTHENTICATE PLAIN`
200
+ * succeeds end-to-end for the listed accounts. Empty array (the default)
201
+ * disables SASL account verification: `ctx.accounts` is then omitted and
202
+ * a client completing `AUTHENTICATE PLAIN` receives `904 ERR_SASLFAIL`.
203
+ * A persistent variant (DynamoDB `Accounts`, D1) is a documented
204
+ * follow-up; swapping it in means replacing the construction call.
205
+ */
206
+ saslAccounts: z.array(SaslAccountSchema).default([]),
207
+
185
208
  // --- Reducer-facing limit knobs (mirrored on `ServerConfig`) ---
186
209
 
187
210
  /** Maximum channels a single connection may JOIN. */
@@ -12,12 +12,14 @@ export * from './effects.js';
12
12
  export * from './ports.js';
13
13
  export * from './flood-control.js';
14
14
  export * from './cloak.js';
15
+ export * from './case-fold.js';
15
16
  export * from './admission.js';
16
17
  export * from './config.js';
17
18
  export type {
18
19
  BuildCtxOptions,
19
20
  CloakingConfig,
20
21
  Ctx,
22
+ OperCred,
21
23
  Reducer,
22
24
  ReducerResult,
23
25
  ServerConfig,