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.
- package/CHANGELOG.md +84 -0
- package/README.md +28 -19
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/scripts/smoke.mjs +0 -0
- package/apps/cf-worker/src/worker.ts +8 -2
- package/apps/cf-worker/tests/smoke.test.ts +1 -0
- package/apps/cf-worker/wrangler.test.toml +5 -1
- package/apps/cf-worker/wrangler.toml +41 -17
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +1 -0
- package/apps/local-cli/src/main.ts +1 -1
- package/apps/local-cli/src/server.ts +28 -2
- package/apps/local-cli/tests/e2e.test.ts +52 -0
- package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
- package/docs/ADR-002-location-of-authority.md +82 -0
- package/docs/ADR-003-durable-object-sharding.md +93 -0
- package/docs/ADR-004-dynamodb-schema.md +96 -0
- package/docs/ADR-005-wss-only-transport-v1.md +83 -0
- package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
- package/docs/ADR-007-deterministic-ports.md +82 -0
- package/docs/ADR-008-monorepo-tooling.md +60 -0
- package/docs/AWS-Adapter-Architecture.md +6 -4
- package/docs/AWS-Deployment.md +86 -7
- package/docs/Cloudflare-Deployment-Guide.md +5 -5
- package/docs/Home.md +11 -1
- package/docs/Release-Process.md +9 -6
- package/package.json +14 -15
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/account-store.ts +121 -0
- package/packages/aws-adapter/src/aws-runtime.ts +118 -30
- package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
- package/packages/aws-adapter/src/config-loader.ts +30 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
- package/packages/aws-adapter/src/handlers/default.ts +8 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
- package/packages/aws-adapter/src/handlers/index.ts +51 -6
- package/packages/aws-adapter/src/index.ts +7 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
- package/packages/aws-adapter/tests/account-store.test.ts +280 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +55 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
- package/packages/aws-adapter/tests/handlers.test.ts +46 -0
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/cf-runtime.ts +42 -22
- package/packages/cf-adapter/src/channel-do.ts +40 -1
- package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
- package/packages/cf-adapter/src/config-loader.ts +29 -0
- package/packages/cf-adapter/src/connection-do.ts +85 -0
- package/packages/cf-adapter/src/env.ts +27 -1
- package/packages/cf-adapter/src/index.ts +2 -1
- package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +40 -0
- package/packages/cf-adapter/tests/worker/main.ts +2 -1
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
- package/packages/cf-adapter/wrangler.test.toml +10 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
- package/packages/irc-core/package.json +3 -2
- package/packages/irc-core/src/case-fold.ts +64 -0
- package/packages/irc-core/src/commands/index.ts +2 -0
- package/packages/irc-core/src/commands/invite.ts +6 -9
- package/packages/irc-core/src/commands/isupport.ts +1 -1
- package/packages/irc-core/src/commands/join.ts +4 -3
- package/packages/irc-core/src/commands/kick.ts +4 -11
- package/packages/irc-core/src/commands/list.ts +5 -4
- package/packages/irc-core/src/commands/mode.ts +5 -9
- package/packages/irc-core/src/commands/motd-lines.ts +127 -0
- package/packages/irc-core/src/commands/motd.ts +6 -110
- package/packages/irc-core/src/commands/oper.ts +151 -0
- package/packages/irc-core/src/commands/registration.ts +8 -7
- package/packages/irc-core/src/commands/tagmsg.ts +205 -0
- package/packages/irc-core/src/config.ts +26 -3
- package/packages/irc-core/src/index.ts +2 -0
- package/packages/irc-core/src/ports.ts +68 -4
- package/packages/irc-core/src/types.ts +22 -0
- package/packages/irc-core/tests/account-store.test.ts +88 -0
- package/packages/irc-core/tests/case-fold.test.ts +80 -0
- package/packages/irc-core/tests/commands/kick.test.ts +15 -0
- package/packages/irc-core/tests/commands/oper.test.ts +257 -0
- package/packages/irc-core/tests/commands/registration.test.ts +106 -14
- package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
- package/packages/irc-core/tests/commands/who.test.ts +22 -4
- package/packages/irc-core/tests/commands/whois.test.ts +8 -1
- package/packages/irc-core/tests/config.test.ts +75 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +24 -0
- package/packages/irc-server/src/routing.ts +9 -0
- package/packages/irc-server/tests/actor.test.ts +220 -1
- package/packages/irc-server/tests/routing.test.ts +3 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +5 -4
- package/pnpm-workspace.yaml +1 -0
- package/tools/seed-aws-accounts.ts +80 -0
- package/AGENTS.md +0 -5
- package/MOTD.txt +0 -3
- package/PLAN-FIXES.md +0 -358
- package/PLAN.md +0 -420
- package/dashboards/cloudwatch-irc.json +0 -139
- package/progress.md +0 -107
- package/tickets.md +0 -2485
- package/webircgateway/LICENSE +0 -201
- package/webircgateway/Makefile +0 -44
- package/webircgateway/README.md +0 -134
- package/webircgateway/config.conf.example +0 -135
- package/webircgateway/go.mod +0 -16
- package/webircgateway/go.sum +0 -89
- package/webircgateway/main.go +0 -118
- package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
- package/webircgateway/pkg/identd/identd.go +0 -86
- package/webircgateway/pkg/identd/rpcclient.go +0 -59
- package/webircgateway/pkg/irc/isupport.go +0 -56
- package/webircgateway/pkg/irc/message.go +0 -217
- package/webircgateway/pkg/irc/state.go +0 -79
- package/webircgateway/pkg/proxy/proxy.go +0 -129
- package/webircgateway/pkg/proxy/server.go +0 -237
- package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
- package/webircgateway/pkg/webircgateway/client.go +0 -741
- package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
- package/webircgateway/pkg/webircgateway/config.go +0 -385
- package/webircgateway/pkg/webircgateway/gateway.go +0 -278
- package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
- package/webircgateway/pkg/webircgateway/hooks.go +0 -152
- package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
- package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
- package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
- package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
- package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
- package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
- package/webircgateway/pkg/webircgateway/utils.go +0 -147
- package/webircgateway/plugins/example/plugin.go +0 -11
- package/webircgateway/plugins/stats/plugin.go +0 -52
- package/webircgateway/staticcheck.conf +0 -1
- 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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
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`
|
|
59
|
-
* the
|
|
60
|
-
* `
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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,
|