serverless-ircd 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +96 -2
- package/.github/workflows/deploy-aws.yml +129 -0
- package/.github/workflows/deploy-cf.yml +0 -2
- package/.gitmodules +3 -0
- package/CHANGELOG.md +436 -0
- package/README.md +127 -84
- package/apps/aws-stack/README.md +73 -0
- package/apps/aws-stack/bin/aws.ts +49 -0
- package/apps/aws-stack/cdk.json +10 -0
- package/apps/aws-stack/package.json +41 -0
- package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
- package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
- package/apps/aws-stack/scripts/smoke.mjs +142 -0
- package/apps/aws-stack/src/aws-stack.ts +263 -0
- package/apps/aws-stack/src/tables.ts +10 -0
- package/apps/aws-stack/tests/localstack.test.ts +46 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
- package/apps/aws-stack/tests/stack.test.ts +464 -0
- package/apps/aws-stack/tsconfig.build.json +11 -0
- package/apps/aws-stack/tsconfig.test.json +8 -0
- package/apps/aws-stack/vitest.config.ts +20 -0
- 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 +66 -17
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +57 -0
- package/apps/local-cli/src/main.ts +1 -1
- package/apps/local-cli/src/server.ts +267 -32
- package/apps/local-cli/tests/config-loader.test.ts +107 -0
- package/apps/local-cli/tests/e2e.test.ts +126 -0
- package/apps/local-cli/tests/security-e2e.test.ts +239 -0
- package/biome.json +3 -1
- 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 +496 -0
- package/docs/AWS-Deployment.md +1186 -0
- package/docs/Cloudflare-Deployment-Guide.md +660 -0
- package/docs/Home.md +11 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanIRCv3Websocket.md +489 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +443 -0
- package/package.json +19 -14
- package/packages/aws-adapter/README.md +35 -0
- package/packages/aws-adapter/package.json +52 -0
- package/packages/aws-adapter/src/account-store.ts +121 -0
- package/packages/aws-adapter/src/aws-runtime.ts +871 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +73 -0
- package/packages/aws-adapter/src/config-loader.ts +126 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
- package/packages/aws-adapter/src/dynamo.ts +61 -0
- package/packages/aws-adapter/src/handlers/connect.ts +44 -0
- package/packages/aws-adapter/src/handlers/default.ts +330 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +48 -0
- package/packages/aws-adapter/src/handlers/index.ts +293 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
- package/packages/aws-adapter/src/handlers/state.ts +13 -0
- package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
- package/packages/aws-adapter/src/index.ts +74 -0
- package/packages/aws-adapter/src/message-store.ts +34 -0
- package/packages/aws-adapter/src/serialize.ts +283 -0
- package/packages/aws-adapter/src/tables.ts +53 -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/aws-harness.ts +408 -0
- package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
- package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +213 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
- package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
- package/packages/aws-adapter/tests/global-setup.ts +301 -0
- package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
- package/packages/aws-adapter/tests/handlers.test.ts +473 -0
- package/packages/aws-adapter/tests/message-store.test.ts +55 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
- package/packages/aws-adapter/tests/serialize.test.ts +249 -0
- package/packages/aws-adapter/tests/smoke.test.ts +48 -0
- package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
- package/packages/aws-adapter/tests/tables.test.ts +74 -0
- package/packages/aws-adapter/tests/transactions.test.ts +446 -0
- package/packages/aws-adapter/tsconfig.build.json +16 -0
- package/packages/aws-adapter/tsconfig.test.json +14 -0
- package/packages/aws-adapter/vitest.config.ts +23 -0
- package/packages/cf-adapter/package.json +2 -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 +135 -0
- package/packages/cf-adapter/src/connection-do.ts +119 -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-harness.ts +2 -2
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
- package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +82 -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 +107 -16
- package/packages/in-memory-runtime/src/index.ts +1 -1
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +134 -0
- package/packages/irc-core/package.json +13 -2
- package/packages/irc-core/src/admission.ts +216 -0
- package/packages/irc-core/src/caps/capabilities.ts +1 -0
- package/packages/irc-core/src/case-fold.ts +64 -0
- package/packages/irc-core/src/cloak.ts +81 -0
- package/packages/irc-core/src/commands/cap.ts +1 -2
- package/packages/irc-core/src/commands/chathistory.ts +305 -0
- package/packages/irc-core/src/commands/index.ts +9 -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 +63 -10
- 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/privmsg.ts +24 -0
- package/packages/irc-core/src/commands/registration.ts +79 -21
- package/packages/irc-core/src/commands/sasl.ts +251 -0
- package/packages/irc-core/src/commands/tagmsg.ts +205 -0
- package/packages/irc-core/src/config.ts +270 -0
- package/packages/irc-core/src/flood-control.ts +175 -0
- package/packages/irc-core/src/index.ts +7 -0
- package/packages/irc-core/src/ports.ts +719 -0
- package/packages/irc-core/src/protocol/base64.ts +16 -0
- package/packages/irc-core/src/protocol/index.ts +2 -1
- package/packages/irc-core/src/protocol/numerics.ts +11 -0
- package/packages/irc-core/src/protocol/parser.ts +27 -2
- package/packages/irc-core/src/state/connection.ts +41 -0
- package/packages/irc-core/src/types.ts +88 -2
- package/packages/irc-core/stryker.commands.conf.json +41 -0
- package/packages/irc-core/stryker.protocol.conf.json +26 -0
- package/packages/irc-core/tests/account-store.test.ts +88 -0
- package/packages/irc-core/tests/admission.test.ts +229 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
- package/packages/irc-core/tests/case-fold.test.ts +80 -0
- package/packages/irc-core/tests/cloak.test.ts +78 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
- package/packages/irc-core/tests/commands/join.test.ts +246 -2
- 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/privmsg.test.ts +146 -2
- package/packages/irc-core/tests/commands/registration.test.ts +380 -20
- package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
- 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 +721 -0
- package/packages/irc-core/tests/flood-control.test.ts +394 -0
- package/packages/irc-core/tests/message-store.test.ts +530 -0
- package/packages/irc-core/tests/parser.test.ts +44 -1
- package/packages/irc-core/tests/ports.test.ts +263 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +186 -44
- package/packages/irc-server/src/dispatch.ts +89 -5
- package/packages/irc-server/src/index.ts +2 -0
- package/packages/irc-server/src/routing.ts +160 -0
- package/packages/irc-server/tests/actor.test.ts +690 -15
- package/packages/irc-server/tests/dispatch.test.ts +84 -0
- package/packages/irc-server/tests/routing.test.ts +204 -0
- package/packages/irc-test-support/README.md +32 -0
- package/packages/irc-test-support/package.json +37 -0
- package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +6 -5
- package/packages/irc-test-support/src/index.ts +24 -0
- package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
- package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
- package/packages/irc-test-support/tests/smoke.test.ts +11 -0
- package/packages/irc-test-support/tsconfig.build.json +16 -0
- package/packages/irc-test-support/tsconfig.test.json +14 -0
- package/packages/irc-test-support/vitest.config.ts +20 -0
- package/pnpm-workspace.yaml +1 -0
- package/tools/ci-hardening/package.json +30 -0
- package/tools/ci-hardening/src/index.ts +6 -0
- package/tools/ci-hardening/src/validate.ts +103 -0
- package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
- package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
- package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
- package/tools/ci-hardening/tests/validate.test.ts +177 -0
- package/tools/ci-hardening/tsconfig.build.json +12 -0
- package/tools/ci-hardening/tsconfig.test.json +10 -0
- package/tools/ci-hardening/vitest.config.ts +21 -0
- package/tools/seed-aws-accounts.ts +80 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
- package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
- package/tools/tcp-ws-forwarder/src/main.ts +33 -14
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
- package/packages/cf-adapter/tests/integration/index.ts +0 -17
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducer for the IRCv3 `AUTHENTICATE` command (SASL).
|
|
3
|
+
*
|
|
4
|
+
* PLAN §2.1 location-of-authority: SASL runs in the Connection entity.
|
|
5
|
+
* The reducer drives the multi-frame SASL exchange purely:
|
|
6
|
+
*
|
|
7
|
+
* 1. `AUTHENTICATE <MECH>` — selects a mechanism. `PLAIN` is supported and
|
|
8
|
+
* answers with `AUTHENTICATE +` (requesting the payload). `EXTERNAL` is
|
|
9
|
+
* recognized but stubbed to `904` until mTLS lands. Anything else gets
|
|
10
|
+
* `908 ERR_SASLMECHS` listing what is available.
|
|
11
|
+
* 2. `AUTHENTICATE <b64>` / `AUTHENTICATE +` — delivers the payload (whole
|
|
12
|
+
* or in `<=400`-byte chunks; `+` marks an empty/final chunk). The
|
|
13
|
+
* decoded bytes are handed to the injected {@link AccountStore}; on
|
|
14
|
+
* success the connection records the account name and the reducer emits
|
|
15
|
+
* `900 RPL_LOGGEDIN` + `903 RPL_SASLSUCCESS`, on failure `904 ERR_SASLFAIL`.
|
|
16
|
+
* 3. `AUTHENTICATE *` — client abort → `906 ERR_SASLABORT`.
|
|
17
|
+
*
|
|
18
|
+
* The `AccountStore` port is **synchronous** (see {@link AccountStore} docs):
|
|
19
|
+
* adapters pre-load credentials so the reducer stays pure. Async backends
|
|
20
|
+
* resolve their lookups before the actor invokes this reducer.
|
|
21
|
+
*
|
|
22
|
+
* State: {@link ConnectionState.saslMech} tracks the mechanism in flight;
|
|
23
|
+
* {@link ConnectionState.saslBuffer} accumulates chunked payloads. Both are
|
|
24
|
+
* cleared on every terminal outcome.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { Effect } from '../effects.js';
|
|
28
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
29
|
+
import { decodeBase64 } from '../protocol/base64.js';
|
|
30
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
31
|
+
import { hostmaskOf } from '../state/connection.js';
|
|
32
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
33
|
+
import type { Ctx, Reducer } from '../types.js';
|
|
34
|
+
|
|
35
|
+
/** Per-ircdocs.horse: a single `AUTHENTICATE` payload chunk is at most 400 bytes. */
|
|
36
|
+
const SASL_CHUNK_BYTES = 400;
|
|
37
|
+
|
|
38
|
+
/** Total decoded-payload cap before `905 ERR_SASLTOOLONG`. */
|
|
39
|
+
const SASL_MAX_BYTES = 8192;
|
|
40
|
+
|
|
41
|
+
/** Mechanisms this server will accept today (advertised verbatim by the cap). */
|
|
42
|
+
const SUPPORTED_MECHS = 'PLAIN,EXTERNAL';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Reducer for `AUTHENTICATE [<param>]`. See module docs for the state
|
|
46
|
+
* machine. Authority: the invoking connection's {@link ConnectionState}.
|
|
47
|
+
*/
|
|
48
|
+
export const authenticateReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
49
|
+
state.lastSeen = ctx.clock.now();
|
|
50
|
+
|
|
51
|
+
const param = msg.params[0];
|
|
52
|
+
if (param === undefined) {
|
|
53
|
+
return { state, effects: [Effect.send(ctx.connId, [needMoreParams(ctx)])] };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (state.registration === 'registered' || !state.capNegotiating || state.account !== undefined) {
|
|
57
|
+
return { state, effects: [Effect.send(ctx.connId, [saslAlready(ctx)])] };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (param === '*') {
|
|
61
|
+
return handleAbort(state, ctx);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (state.saslMech === undefined) {
|
|
65
|
+
return handleMechanism(state, param, ctx);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return handlePayload(state, param, ctx);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** `AUTHENTICATE *` — client-initiated abort. */
|
|
72
|
+
function handleAbort(
|
|
73
|
+
state: ConnectionState,
|
|
74
|
+
ctx: Ctx,
|
|
75
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
76
|
+
if (state.saslMech === undefined) {
|
|
77
|
+
return { state, effects: [] };
|
|
78
|
+
}
|
|
79
|
+
clearSasl(state);
|
|
80
|
+
return { state, effects: [Effect.send(ctx.connId, [saslAbort(ctx)])] };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** `AUTHENTICATE <MECH>` — first frame of an exchange. */
|
|
84
|
+
function handleMechanism(
|
|
85
|
+
state: ConnectionState,
|
|
86
|
+
param: string,
|
|
87
|
+
ctx: Ctx,
|
|
88
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
89
|
+
const mech = param.toUpperCase();
|
|
90
|
+
if (mech === 'PLAIN') {
|
|
91
|
+
state.saslMech = 'PLAIN';
|
|
92
|
+
state.saslBuffer = '';
|
|
93
|
+
return { state, effects: [Effect.send(ctx.connId, [L('AUTHENTICATE +')])] };
|
|
94
|
+
}
|
|
95
|
+
if (mech === 'EXTERNAL') {
|
|
96
|
+
return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
|
|
97
|
+
}
|
|
98
|
+
return { state, effects: [Effect.send(ctx.connId, [saslMechs(ctx)])] };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** `AUTHENTICATE <chunk>` / `AUTHENTICATE +` — payload delivery. */
|
|
102
|
+
function handlePayload(
|
|
103
|
+
state: ConnectionState,
|
|
104
|
+
param: string,
|
|
105
|
+
ctx: Ctx,
|
|
106
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
107
|
+
const mech = state.saslMech as string;
|
|
108
|
+
const buffer = state.saslBuffer as string;
|
|
109
|
+
|
|
110
|
+
if (param === '+') {
|
|
111
|
+
return finalize(state, buffer, mech, ctx);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const next = buffer + param;
|
|
115
|
+
if (next.length > SASL_MAX_BYTES) {
|
|
116
|
+
clearSasl(state);
|
|
117
|
+
return { state, effects: [Effect.send(ctx.connId, [saslTooLong(ctx)])] };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (param.length === SASL_CHUNK_BYTES) {
|
|
121
|
+
state.saslBuffer = next;
|
|
122
|
+
return { state, effects: [] };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return finalize(state, next, mech, ctx);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Decodes the accumulated payload and runs it through the AccountStore. */
|
|
129
|
+
function finalize(
|
|
130
|
+
state: ConnectionState,
|
|
131
|
+
payloadB64: string,
|
|
132
|
+
mech: string,
|
|
133
|
+
ctx: Ctx,
|
|
134
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
135
|
+
const parsed = parsePlain(payloadB64);
|
|
136
|
+
|
|
137
|
+
if (parsed === null) {
|
|
138
|
+
clearSasl(state);
|
|
139
|
+
return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const store = ctx.accounts;
|
|
143
|
+
if (store === undefined) {
|
|
144
|
+
clearSasl(state);
|
|
145
|
+
return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const result = store.verify(mech, {
|
|
149
|
+
kind: 'PLAIN',
|
|
150
|
+
username: parsed.username,
|
|
151
|
+
password: parsed.password,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
clearSasl(state);
|
|
155
|
+
|
|
156
|
+
if (result.ok) {
|
|
157
|
+
state.account = result.account;
|
|
158
|
+
return {
|
|
159
|
+
state,
|
|
160
|
+
effects: [Effect.send(ctx.connId, [loggedIn(ctx, result.account), saslSuccess(ctx)])],
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Decodes a base64 PLAIN payload (`authzid\0authcid\0passwd`) and returns the
|
|
168
|
+
* authcid/password pair. Returns `null` on any decode or shape failure.
|
|
169
|
+
*/
|
|
170
|
+
function parsePlain(payloadB64: string): { username: string; password: string } | null {
|
|
171
|
+
let decoded: string;
|
|
172
|
+
try {
|
|
173
|
+
decoded = decodeBase64(payloadB64);
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
const parts = decoded.split('\0');
|
|
178
|
+
if (parts.length !== 3) return null;
|
|
179
|
+
const [, username, password] = parts as [string, string, string];
|
|
180
|
+
return { username, password };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Clears every SASL-related scratch field on the connection. */
|
|
184
|
+
function clearSasl(state: ConnectionState): void {
|
|
185
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
|
|
186
|
+
delete state.saslMech;
|
|
187
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
|
|
188
|
+
delete state.saslBuffer;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function loggedIn(ctx: Ctx, account: string): RawLine {
|
|
192
|
+
const nick = nickOf(ctx);
|
|
193
|
+
const hostmask = hostmaskOf(ctx.connection) ?? nick;
|
|
194
|
+
return L(
|
|
195
|
+
`:${ctx.serverName} ${code(Numerics.RPL_LOGGEDIN)} ${nick} ${hostmask} ${account} :You are now logged in as ${account}`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function saslSuccess(ctx: Ctx): RawLine {
|
|
200
|
+
return L(
|
|
201
|
+
`:${ctx.serverName} ${code(Numerics.RPL_SASLSUCCESS)} ${nickOf(ctx)} :SASL authentication successful`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function saslFail(ctx: Ctx): RawLine {
|
|
206
|
+
return L(
|
|
207
|
+
`:${ctx.serverName} ${code(Numerics.ERR_SASLFAIL)} ${nickOf(ctx)} :SASL authentication failed`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function saslTooLong(ctx: Ctx): RawLine {
|
|
212
|
+
return L(
|
|
213
|
+
`:${ctx.serverName} ${code(Numerics.ERR_SASLTOOLONG)} ${nickOf(ctx)} :SASL message too long`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function saslAbort(ctx: Ctx): RawLine {
|
|
218
|
+
return L(
|
|
219
|
+
`:${ctx.serverName} ${code(Numerics.ERR_SASLABORT)} ${nickOf(ctx)} :SASL authentication aborted`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function saslAlready(ctx: Ctx): RawLine {
|
|
224
|
+
return L(
|
|
225
|
+
`:${ctx.serverName} ${code(Numerics.ERR_SASLALREADY)} ${nickOf(ctx)} :You have already authenticated using SASL`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function saslMechs(ctx: Ctx): RawLine {
|
|
230
|
+
return L(
|
|
231
|
+
`:${ctx.serverName} ${code(Numerics.ERR_SASLMECHS)} ${nickOf(ctx)} ${SUPPORTED_MECHS} :are the available SASL mechanisms`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function needMoreParams(ctx: Ctx): RawLine {
|
|
236
|
+
return L(
|
|
237
|
+
`:${ctx.serverName} ${code(Numerics.ERR_NEEDMOREPARAMS)} ${nickOf(ctx)} AUTHENTICATE :Not enough parameters`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function nickOf(ctx: Ctx): string {
|
|
242
|
+
return ctx.connection.nick ?? '*';
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function code(n: number): string {
|
|
246
|
+
return n.toString().padStart(3, '0');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function L(text: string): RawLine {
|
|
250
|
+
return { text };
|
|
251
|
+
}
|
|
@@ -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
|
+
};
|