serverless-ircd 0.5.0 → 0.6.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 +34 -2
- package/.github/workflows/deploy-aws.yml +1 -2
- package/.github/workflows/deploy-cf-tcp.yml +1 -5
- package/.github/workflows/deploy-cf.yml +1 -2
- package/CHANGELOG.md +260 -0
- package/README.md +132 -22
- package/apps/aws-stack/README.md +33 -0
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +74 -9
- package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
- package/apps/aws-stack/tests/stack.test.ts +2 -2
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
- package/apps/aws-stack/tests/vitest.setup.ts +18 -0
- package/apps/aws-stack/vitest.config.ts +3 -0
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-tcp-container/src/config-loader.ts +6 -7
- package/apps/cf-tcp-container/src/container-server.ts +3 -0
- package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
- package/apps/cf-tcp-container/wrangler.toml +1 -1
- package/apps/cf-worker/package.json +2 -1
- package/apps/cf-worker/vitest.config.ts +1 -1
- package/apps/cf-worker/wrangler.toml +5 -3
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +6 -6
- package/apps/local-cli/src/main.ts +20 -0
- package/apps/local-cli/src/server.ts +9 -7
- package/apps/local-cli/tests/e2e.test.ts +112 -28
- package/apps/local-cli/tests/tcp.test.ts +71 -19
- package/apps/local-cli/tests/ws-subprotocol.test.ts +12 -7
- package/biome.json +1 -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 +110 -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 +96 -0
- package/docs/ADR-007-deterministic-ports.md +82 -0
- package/docs/ADR-008-monorepo-tooling.md +60 -0
- package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
- package/docs/ADR-Index.md +15 -0
- package/docs/AWS-Adapter-Architecture.md +496 -0
- package/docs/AWS-Deployment.md +1275 -0
- package/docs/AWS-TCP-Deployment.md +410 -0
- package/docs/Cloudflare-Deployment-Guide.md +719 -0
- package/docs/Cloudflare-TCP-Deployment.md +510 -0
- package/docs/Home.md +15 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanExtensions.md +812 -0
- package/docs/PlanIRCv3Websocket.md +503 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +443 -0
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/handlers/default.ts +3 -0
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +9 -0
- package/packages/aws-adapter/src/serialize.ts +29 -1
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
- package/packages/aws-adapter/tests/account-store.test.ts +4 -7
- package/packages/aws-adapter/tests/aws-harness.ts +5 -2
- package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
- package/packages/aws-adapter/tests/aws-runtime.test.ts +79 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
- package/packages/aws-adapter/tests/connect.test.ts +9 -9
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
- package/packages/aws-adapter/tests/global-setup.ts +55 -30
- package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
- package/packages/aws-adapter/tests/handlers.test.ts +90 -4
- package/packages/aws-adapter/tests/message-store.test.ts +6 -5
- package/packages/aws-adapter/tests/nlb-stream.test.ts +60 -8
- package/packages/cf-adapter/package.json +2 -1
- package/packages/cf-adapter/src/connection-do.ts +3 -0
- package/packages/cf-adapter/src/serialize.ts +25 -4
- package/packages/cf-adapter/tests/cf-runtime.test.ts +269 -0
- package/packages/cf-adapter/tests/channel-do.test.ts +117 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
- package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
- package/packages/cf-adapter/tests/registry-do.test.ts +104 -0
- package/packages/cf-adapter/tests/serialize.test.ts +308 -0
- package/packages/cf-adapter/vitest.config.ts +1 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/caps/capabilities.ts +37 -3
- package/packages/irc-core/src/caps/index.ts +1 -0
- package/packages/irc-core/src/caps/sts.ts +84 -0
- package/packages/irc-core/src/commands/account-notify.ts +53 -0
- package/packages/irc-core/src/commands/away.ts +9 -3
- package/packages/irc-core/src/commands/cap.ts +23 -5
- package/packages/irc-core/src/commands/chathistory.ts +40 -11
- package/packages/irc-core/src/commands/index.ts +34 -0
- package/packages/irc-core/src/commands/isupport.ts +53 -0
- package/packages/irc-core/src/commands/mode.ts +8 -1
- package/packages/irc-core/src/commands/monitor.ts +327 -0
- package/packages/irc-core/src/commands/multiline.ts +256 -0
- package/packages/irc-core/src/commands/pre-away.ts +112 -0
- package/packages/irc-core/src/commands/privmsg.ts +19 -12
- package/packages/irc-core/src/commands/read-marker.ts +108 -0
- package/packages/irc-core/src/commands/registration.ts +8 -0
- package/packages/irc-core/src/commands/sasl.ts +24 -1
- package/packages/irc-core/src/commands/tagmsg.ts +71 -1
- package/packages/irc-core/src/commands/whois.ts +5 -0
- package/packages/irc-core/src/config.ts +65 -10
- package/packages/irc-core/src/effects.ts +14 -0
- package/packages/irc-core/src/ports.ts +389 -0
- package/packages/irc-core/src/protocol/index.ts +14 -0
- package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
- package/packages/irc-core/src/protocol/numerics.ts +15 -0
- package/packages/irc-core/src/protocol/outbound.ts +16 -1
- package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
- package/packages/irc-core/src/state/connection.ts +32 -1
- package/packages/irc-core/src/types.ts +112 -0
- package/packages/irc-core/stryker.commands.conf.json +1 -2
- package/packages/irc-core/tests/away-store.test.ts +73 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
- package/packages/irc-core/tests/caps/sts.test.ts +123 -0
- package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
- package/packages/irc-core/tests/commands/away.test.ts +85 -2
- package/packages/irc-core/tests/commands/cap.test.ts +165 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
- package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
- package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
- package/packages/irc-core/tests/commands/mode.test.ts +72 -0
- package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
- package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
- package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
- package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
- package/packages/irc-core/tests/commands/registration.test.ts +64 -0
- package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
- package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
- package/packages/irc-core/tests/commands/whois.test.ts +34 -0
- package/packages/irc-core/tests/config.test.ts +103 -13
- package/packages/irc-core/tests/labeled-response.test.ts +181 -0
- package/packages/irc-core/tests/message-tags.test.ts +114 -0
- package/packages/irc-core/tests/monitor-store.test.ts +200 -0
- package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
- package/packages/irc-core/tests/standard-replies.test.ts +412 -0
- package/packages/irc-core/tests/types.test.ts +27 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +450 -3
- package/packages/irc-server/src/dispatch.ts +108 -16
- package/packages/irc-server/tests/actor.test.ts +1432 -39
- package/packages/irc-server/tests/dispatch.test.ts +263 -2
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/index.ts +6 -0
- package/packages/irc-test-support/src/test-config.ts +54 -0
- package/packages/irc-test-support/tests/test-config.test.ts +51 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
- package/tools/load-test/package.json +33 -0
- package/tools/load-test/src/client.ts +351 -0
- package/tools/load-test/src/config.ts +313 -0
- package/tools/load-test/src/harness.ts +116 -0
- package/tools/load-test/src/main.ts +120 -0
- package/tools/load-test/src/metrics.ts +168 -0
- package/tools/load-test/src/report.ts +106 -0
- package/tools/load-test/tests/client.test.ts +212 -0
- package/tools/load-test/tests/config.test.ts +152 -0
- package/tools/load-test/tests/framing.test.ts +37 -0
- package/tools/load-test/tests/harness.test.ts +165 -0
- package/tools/load-test/tests/metrics.test.ts +174 -0
- package/tools/load-test/tests/report.test.ts +161 -0
- package/tools/load-test/tests/smoke.test.ts +67 -0
- package/tools/load-test/tsconfig.build.json +12 -0
- package/tools/load-test/tsconfig.test.json +10 -0
- package/tools/load-test/vitest.config.ts +29 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { emitAccountNotify } from '../../src/commands/account-notify';
|
|
3
|
+
import { Effect } from '../../src/effects';
|
|
4
|
+
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
5
|
+
import { type ConnectionState, createConnection } from '../../src/state/connection';
|
|
6
|
+
|
|
7
|
+
const L = (text: string): RawLine => ({ text });
|
|
8
|
+
|
|
9
|
+
function makeConn(id = 'c1', nick = 'alice'): ConnectionState {
|
|
10
|
+
const s = createConnection({ id, connectedSince: 0 });
|
|
11
|
+
s.nick = nick;
|
|
12
|
+
s.user = 'alice';
|
|
13
|
+
s.host = 'example.com';
|
|
14
|
+
s.realname = 'Alice';
|
|
15
|
+
s.registration = 'registered';
|
|
16
|
+
return s;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('emitAccountNotify — login', () => {
|
|
20
|
+
it('emits a cap-split Broadcast per joined channel with the account name', () => {
|
|
21
|
+
const conn = makeConn();
|
|
22
|
+
conn.joinedChannels.add('#foo');
|
|
23
|
+
conn.joinedChannels.add('#bar');
|
|
24
|
+
|
|
25
|
+
const effects = emitAccountNotify({ conn, account: 'alice' });
|
|
26
|
+
|
|
27
|
+
expect(effects).toEqual<EffectType[]>([
|
|
28
|
+
Effect.broadcast('#foo', [], 'c1', 'account-notify', [
|
|
29
|
+
L(':alice!alice@example.com ACCOUNT alice'),
|
|
30
|
+
]),
|
|
31
|
+
Effect.broadcast('#bar', [], 'c1', 'account-notify', [
|
|
32
|
+
L(':alice!alice@example.com ACCOUNT alice'),
|
|
33
|
+
]),
|
|
34
|
+
]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('excludes the sender (except) so non-echo peers do not see their own ACCOUNT', () => {
|
|
38
|
+
const conn = makeConn();
|
|
39
|
+
conn.joinedChannels.add('#foo');
|
|
40
|
+
|
|
41
|
+
const effects = emitAccountNotify({ conn, account: 'alice' });
|
|
42
|
+
|
|
43
|
+
const bcast = effects[0];
|
|
44
|
+
if (bcast?.tag === 'Broadcast') {
|
|
45
|
+
expect(bcast.except).toBe('c1');
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('uses empty legacy lines so non-cap peers receive nothing', () => {
|
|
50
|
+
const conn = makeConn();
|
|
51
|
+
conn.joinedChannels.add('#foo');
|
|
52
|
+
|
|
53
|
+
const effects = emitAccountNotify({ conn, account: 'alice' });
|
|
54
|
+
|
|
55
|
+
const bcast = effects[0];
|
|
56
|
+
if (bcast?.tag === 'Broadcast') {
|
|
57
|
+
expect(bcast.lines).toEqual([]);
|
|
58
|
+
expect(bcast.cap).toBe('account-notify');
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('emitAccountNotify — logout', () => {
|
|
64
|
+
it('emits ACCOUNT * for a logout', () => {
|
|
65
|
+
const conn = makeConn();
|
|
66
|
+
conn.joinedChannels.add('#foo');
|
|
67
|
+
|
|
68
|
+
const effects = emitAccountNotify({ conn, account: undefined });
|
|
69
|
+
|
|
70
|
+
expect(effects).toEqual<EffectType[]>([
|
|
71
|
+
Effect.broadcast('#foo', [], 'c1', 'account-notify', [
|
|
72
|
+
L(':alice!alice@example.com ACCOUNT *'),
|
|
73
|
+
]),
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('emitAccountNotify — gating', () => {
|
|
79
|
+
it('emits nothing before registration completes', () => {
|
|
80
|
+
const conn = makeConn();
|
|
81
|
+
conn.registration = 'pre-registration';
|
|
82
|
+
conn.joinedChannels.add('#foo');
|
|
83
|
+
|
|
84
|
+
const effects = emitAccountNotify({ conn, account: 'alice' });
|
|
85
|
+
|
|
86
|
+
expect(effects).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('emits nothing during registration', () => {
|
|
90
|
+
const conn = makeConn();
|
|
91
|
+
conn.registration = 'registering';
|
|
92
|
+
conn.joinedChannels.add('#foo');
|
|
93
|
+
|
|
94
|
+
const effects = emitAccountNotify({ conn, account: 'alice' });
|
|
95
|
+
|
|
96
|
+
expect(effects).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('emits nothing when the connection has no joined channels', () => {
|
|
100
|
+
const conn = makeConn();
|
|
101
|
+
|
|
102
|
+
const effects = emitAccountNotify({ conn, account: 'alice' });
|
|
103
|
+
|
|
104
|
+
expect(effects).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('falls back to ? for the source when nick/user/host are absent', () => {
|
|
108
|
+
const conn = createConnection({ id: 'c1', connectedSince: 0 });
|
|
109
|
+
conn.registration = 'registered';
|
|
110
|
+
conn.joinedChannels.add('#foo');
|
|
111
|
+
|
|
112
|
+
const effects = emitAccountNotify({ conn, account: 'alice' });
|
|
113
|
+
|
|
114
|
+
const bcast = effects[0];
|
|
115
|
+
if (bcast?.tag === 'Broadcast') {
|
|
116
|
+
expect(bcast.capLines?.[0]?.text).toBe(':? ACCOUNT alice');
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('emitAccountNotify — echo-message', () => {
|
|
122
|
+
it('echoes ACCOUNT back to a sender with echo-message negotiated', () => {
|
|
123
|
+
const conn = makeConn();
|
|
124
|
+
conn.caps.add('echo-message');
|
|
125
|
+
conn.joinedChannels.add('#foo');
|
|
126
|
+
|
|
127
|
+
const effects = emitAccountNotify({ conn, account: 'alice' });
|
|
128
|
+
|
|
129
|
+
// First: the broadcast (sender excluded). Then the echo Send to sender.
|
|
130
|
+
expect(effects).toEqual<EffectType[]>([
|
|
131
|
+
Effect.broadcast('#foo', [], 'c1', 'account-notify', [
|
|
132
|
+
L(':alice!alice@example.com ACCOUNT alice'),
|
|
133
|
+
]),
|
|
134
|
+
Effect.send('c1', [L(':alice!alice@example.com ACCOUNT alice')]),
|
|
135
|
+
]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('does not echo when the sender lacks echo-message', () => {
|
|
139
|
+
const conn = makeConn();
|
|
140
|
+
conn.joinedChannels.add('#foo');
|
|
141
|
+
|
|
142
|
+
const effects = emitAccountNotify({ conn, account: 'alice' });
|
|
143
|
+
|
|
144
|
+
expect(effects).toHaveLength(1);
|
|
145
|
+
expect(effects[0]?.tag).toBe('Broadcast');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('does not echo when the sender has echo-message but no joined channels', () => {
|
|
149
|
+
const conn = makeConn();
|
|
150
|
+
conn.caps.add('echo-message');
|
|
151
|
+
|
|
152
|
+
const effects = emitAccountNotify({ conn, account: 'alice' });
|
|
153
|
+
|
|
154
|
+
expect(effects).toEqual([]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('echoes a single ACCOUNT even across multiple shared channels', () => {
|
|
158
|
+
const conn = makeConn();
|
|
159
|
+
conn.caps.add('echo-message');
|
|
160
|
+
conn.joinedChannels.add('#foo');
|
|
161
|
+
conn.joinedChannels.add('#bar');
|
|
162
|
+
|
|
163
|
+
const effects = emitAccountNotify({ conn, account: 'alice' });
|
|
164
|
+
|
|
165
|
+
// Two broadcasts (one per channel) + exactly one echo Send.
|
|
166
|
+
const broadcasts = effects.filter((e) => e.tag === 'Broadcast');
|
|
167
|
+
const sends = effects.filter((e) => e.tag === 'Send');
|
|
168
|
+
expect(broadcasts).toHaveLength(2);
|
|
169
|
+
expect(sends).toHaveLength(1);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -2,7 +2,13 @@ import { describe, expect, it } from 'vitest';
|
|
|
2
2
|
import { awayReducer } from '../../src/commands/away';
|
|
3
3
|
import { Effect } from '../../src/effects';
|
|
4
4
|
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
type AwayStore,
|
|
7
|
+
EmptyMotdProvider,
|
|
8
|
+
FakeClock,
|
|
9
|
+
InMemoryAwayStore,
|
|
10
|
+
SequentialIdFactory,
|
|
11
|
+
} from '../../src/ports';
|
|
6
12
|
import { type ConnectionState, createConnection } from '../../src/state/connection';
|
|
7
13
|
import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
|
|
8
14
|
|
|
@@ -18,13 +24,14 @@ const serverConfig: ServerConfig = {
|
|
|
18
24
|
quitMessage: 'Client Quit',
|
|
19
25
|
};
|
|
20
26
|
|
|
21
|
-
function makeCtx(conn: ConnectionState, clock = new FakeClock(1_000)): Ctx {
|
|
27
|
+
function makeCtx(conn: ConnectionState, clock = new FakeClock(1_000), away?: AwayStore): Ctx {
|
|
22
28
|
return buildCtx({
|
|
23
29
|
serverConfig,
|
|
24
30
|
clock,
|
|
25
31
|
ids: new SequentialIdFactory(),
|
|
26
32
|
motd: EmptyMotdProvider,
|
|
27
33
|
connection: conn,
|
|
34
|
+
...(away !== undefined ? { away } : {}),
|
|
28
35
|
});
|
|
29
36
|
}
|
|
30
37
|
|
|
@@ -231,3 +238,79 @@ describe('awayReducer — defensive', () => {
|
|
|
231
238
|
expect(broadcast?.lines[0]?.text).toBe(':? AWAY :gone');
|
|
232
239
|
});
|
|
233
240
|
});
|
|
241
|
+
|
|
242
|
+
// ============================================================================
|
|
243
|
+
// awayReducer — draft/pre-away persistence
|
|
244
|
+
// ============================================================================
|
|
245
|
+
|
|
246
|
+
describe('awayReducer — draft/pre-away persistence', () => {
|
|
247
|
+
it('persists the reason to the AwayStore keyed by account when setting away', () => {
|
|
248
|
+
const store = new InMemoryAwayStore();
|
|
249
|
+
const conn = makeConn();
|
|
250
|
+
conn.account = 'alice';
|
|
251
|
+
const ctx = makeCtx(conn, new FakeClock(1_000), store);
|
|
252
|
+
|
|
253
|
+
awayReducer(conn, { command: 'AWAY', params: ['brb'], tags: {} }, ctx);
|
|
254
|
+
|
|
255
|
+
expect(store.get('alice')).toBe('brb');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('overwrites the persisted reason when the away reason changes', () => {
|
|
259
|
+
const store = new InMemoryAwayStore();
|
|
260
|
+
const conn = makeConn();
|
|
261
|
+
conn.account = 'alice';
|
|
262
|
+
const ctx = makeCtx(conn, new FakeClock(1_000), store);
|
|
263
|
+
|
|
264
|
+
awayReducer(conn, { command: 'AWAY', params: ['brb'], tags: {} }, ctx);
|
|
265
|
+
awayReducer(conn, { command: 'AWAY', params: ['lunch'], tags: {} }, ctx);
|
|
266
|
+
|
|
267
|
+
expect(store.get('alice')).toBe('lunch');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('clears the persisted reason when AWAY is unset (no param)', () => {
|
|
271
|
+
const store = new InMemoryAwayStore();
|
|
272
|
+
store.set('alice', 'brb');
|
|
273
|
+
const conn = makeConn();
|
|
274
|
+
conn.account = 'alice';
|
|
275
|
+
conn.away = 'brb';
|
|
276
|
+
const ctx = makeCtx(conn, new FakeClock(1_000), store);
|
|
277
|
+
|
|
278
|
+
awayReducer(conn, { command: 'AWAY', params: [], tags: {} }, ctx);
|
|
279
|
+
|
|
280
|
+
expect(store.get('alice')).toBeUndefined();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('clears the persisted reason when AWAY is unset (empty param)', () => {
|
|
284
|
+
const store = new InMemoryAwayStore();
|
|
285
|
+
store.set('alice', 'brb');
|
|
286
|
+
const conn = makeConn();
|
|
287
|
+
conn.account = 'alice';
|
|
288
|
+
conn.away = 'brb';
|
|
289
|
+
const ctx = makeCtx(conn, new FakeClock(1_000), store);
|
|
290
|
+
|
|
291
|
+
awayReducer(conn, { command: 'AWAY', params: [''], tags: {} }, ctx);
|
|
292
|
+
|
|
293
|
+
expect(store.get('alice')).toBeUndefined();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('does not persist when no AwayStore is bound (in-memory only)', () => {
|
|
297
|
+
const conn = makeConn();
|
|
298
|
+
conn.account = 'alice';
|
|
299
|
+
const ctx = makeCtx(conn); // no store
|
|
300
|
+
|
|
301
|
+
awayReducer(conn, { command: 'AWAY', params: ['brb'], tags: {} }, ctx);
|
|
302
|
+
|
|
303
|
+
// In-memory reason still set so the session's own view is consistent.
|
|
304
|
+
expect(conn.away).toBe('brb');
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('does not persist when the connection is unidentified (no account)', () => {
|
|
308
|
+
const store = new InMemoryAwayStore();
|
|
309
|
+
const conn = makeConn(); // no account
|
|
310
|
+
const ctx = makeCtx(conn, new FakeClock(1_000), store);
|
|
311
|
+
|
|
312
|
+
awayReducer(conn, { command: 'AWAY', params: ['brb'], tags: {} }, ctx);
|
|
313
|
+
|
|
314
|
+
expect(store.get('alice')).toBeUndefined();
|
|
315
|
+
});
|
|
316
|
+
});
|
|
@@ -142,6 +142,32 @@ describe('capReducer — LS', () => {
|
|
|
142
142
|
}
|
|
143
143
|
});
|
|
144
144
|
|
|
145
|
+
it('advertises draft/multiline=4096 with the default byte budget', () => {
|
|
146
|
+
const state = makeState();
|
|
147
|
+
const ctx = makeCtx(state);
|
|
148
|
+
const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
|
|
149
|
+
const send = out.effects[0];
|
|
150
|
+
expect(send).toBeDefined();
|
|
151
|
+
if (send?.tag === 'Send') {
|
|
152
|
+
const body = send.lines[0]?.text ?? '';
|
|
153
|
+
expect(body).toContain('draft/multiline=4096');
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('advertises the configured multiline byte budget when overridden', () => {
|
|
158
|
+
const state = makeState();
|
|
159
|
+
const cfg: ServerConfig = { ...serverConfig, multilineMaxBytes: 8192 };
|
|
160
|
+
const ctx = makeCtx(state, cfg);
|
|
161
|
+
const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
|
|
162
|
+
const send = out.effects[0];
|
|
163
|
+
expect(send).toBeDefined();
|
|
164
|
+
if (send?.tag === 'Send') {
|
|
165
|
+
const body = send.lines[0]?.text ?? '';
|
|
166
|
+
expect(body).toContain('draft/multiline=8192');
|
|
167
|
+
expect(body).not.toContain('draft/multiline=4096');
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
145
171
|
it('marks the connection as in CAP negotiation so the welcome is deferred', () => {
|
|
146
172
|
const state = makeState();
|
|
147
173
|
const ctx = makeCtx(state);
|
|
@@ -529,3 +555,142 @@ describe('capReducer — unknown subcommands', () => {
|
|
|
529
555
|
]);
|
|
530
556
|
});
|
|
531
557
|
});
|
|
558
|
+
|
|
559
|
+
// ============================================================================
|
|
560
|
+
// capReducer — IRCv3 `sts` (Strict Transport Security) advertisement
|
|
561
|
+
// ============================================================================
|
|
562
|
+
|
|
563
|
+
describe('capReducer — STS advertisement', () => {
|
|
564
|
+
it('omits the sts cap when no STS policy is configured', () => {
|
|
565
|
+
const state = makeState();
|
|
566
|
+
const ctx = makeCtx(state);
|
|
567
|
+
const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
|
|
568
|
+
const send = out.effects[0];
|
|
569
|
+
expect(send).toBeDefined();
|
|
570
|
+
if (send?.tag === 'Send') {
|
|
571
|
+
const body = send.lines[0]?.text ?? '';
|
|
572
|
+
expect(body).not.toContain('sts=');
|
|
573
|
+
expect(body).not.toContain(' sts ');
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it('advertises sts=duration=…,port=… on a plaintext connection', () => {
|
|
578
|
+
const state = makeState();
|
|
579
|
+
state.secure = false;
|
|
580
|
+
const cfg: ServerConfig = {
|
|
581
|
+
...serverConfig,
|
|
582
|
+
sts: { duration: 600, port: 6697 },
|
|
583
|
+
};
|
|
584
|
+
const ctx = makeCtx(state, cfg);
|
|
585
|
+
const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
|
|
586
|
+
const send = out.effects[0];
|
|
587
|
+
expect(send).toBeDefined();
|
|
588
|
+
if (send?.tag === 'Send') {
|
|
589
|
+
const body = send.lines[0]?.text ?? '';
|
|
590
|
+
expect(body).toContain('sts=duration=600,port=6697');
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it('reads duration and port from ServerConfig (not literals)', () => {
|
|
595
|
+
const state = makeState();
|
|
596
|
+
state.secure = false;
|
|
597
|
+
const cfg: ServerConfig = {
|
|
598
|
+
...serverConfig,
|
|
599
|
+
sts: { duration: 12345, port: 7000 },
|
|
600
|
+
};
|
|
601
|
+
const ctx = makeCtx(state, cfg);
|
|
602
|
+
const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
|
|
603
|
+
const send = out.effects[0];
|
|
604
|
+
expect(send).toBeDefined();
|
|
605
|
+
if (send?.tag === 'Send') {
|
|
606
|
+
const body = send.lines[0]?.text ?? '';
|
|
607
|
+
expect(body).toContain('sts=duration=12345,port=7000');
|
|
608
|
+
expect(body).not.toContain('sts=duration=600');
|
|
609
|
+
expect(body).not.toContain('port=6697');
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it('appends preload when configured on a plaintext connection', () => {
|
|
614
|
+
const state = makeState();
|
|
615
|
+
state.secure = false;
|
|
616
|
+
const cfg: ServerConfig = {
|
|
617
|
+
...serverConfig,
|
|
618
|
+
sts: { duration: 600, port: 6697, preload: true },
|
|
619
|
+
};
|
|
620
|
+
const ctx = makeCtx(state, cfg);
|
|
621
|
+
const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
|
|
622
|
+
const send = out.effects[0];
|
|
623
|
+
expect(send).toBeDefined();
|
|
624
|
+
if (send?.tag === 'Send') {
|
|
625
|
+
const body = send.lines[0]?.text ?? '';
|
|
626
|
+
expect(body).toContain('sts=duration=600,port=6697,preload');
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it('advertises only duration on a TLS connection (wss / irc+tls)', () => {
|
|
631
|
+
const state = makeState();
|
|
632
|
+
state.secure = true;
|
|
633
|
+
const cfg: ServerConfig = {
|
|
634
|
+
...serverConfig,
|
|
635
|
+
sts: { duration: 600, port: 6697 },
|
|
636
|
+
};
|
|
637
|
+
const ctx = makeCtx(state, cfg);
|
|
638
|
+
const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
|
|
639
|
+
const send = out.effects[0];
|
|
640
|
+
expect(send).toBeDefined();
|
|
641
|
+
if (send?.tag === 'Send') {
|
|
642
|
+
const body = send.lines[0]?.text ?? '';
|
|
643
|
+
expect(body).toContain('sts=duration=600');
|
|
644
|
+
expect(body).not.toContain('port=');
|
|
645
|
+
expect(body).not.toContain('preload');
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it('omits preload on a TLS connection even when configured', () => {
|
|
650
|
+
const state = makeState();
|
|
651
|
+
state.secure = true;
|
|
652
|
+
const cfg: ServerConfig = {
|
|
653
|
+
...serverConfig,
|
|
654
|
+
sts: { duration: 600, port: 6697, preload: true },
|
|
655
|
+
};
|
|
656
|
+
const ctx = makeCtx(state, cfg);
|
|
657
|
+
const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
|
|
658
|
+
const send = out.effects[0];
|
|
659
|
+
expect(send).toBeDefined();
|
|
660
|
+
if (send?.tag === 'Send') {
|
|
661
|
+
const body = send.lines[0]?.text ?? '';
|
|
662
|
+
expect(body).toContain('sts=duration=600');
|
|
663
|
+
expect(body).not.toContain('preload');
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it('ACKs CAP REQ sts when configured', () => {
|
|
668
|
+
const state = readyState();
|
|
669
|
+
const cfg: ServerConfig = {
|
|
670
|
+
...serverConfig,
|
|
671
|
+
sts: { duration: 600, port: 6697 },
|
|
672
|
+
};
|
|
673
|
+
const ctx = makeCtx(state, cfg);
|
|
674
|
+
const out = capReducer(state, { command: 'CAP', params: ['REQ', 'sts'], tags: {} }, ctx);
|
|
675
|
+
const send = out.effects[0];
|
|
676
|
+
expect(send).toBeDefined();
|
|
677
|
+
if (send?.tag === 'Send') {
|
|
678
|
+
const body = send.lines[0]?.text ?? '';
|
|
679
|
+
expect(body).toContain('ACK :sts');
|
|
680
|
+
}
|
|
681
|
+
expect(out.state.caps.has('sts')).toBe(true);
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
it('NAKs CAP REQ sts when no STS policy is configured', () => {
|
|
685
|
+
const state = readyState();
|
|
686
|
+
const ctx = makeCtx(state);
|
|
687
|
+
const out = capReducer(state, { command: 'CAP', params: ['REQ', 'sts'], tags: {} }, ctx);
|
|
688
|
+
const send = out.effects[0];
|
|
689
|
+
expect(send).toBeDefined();
|
|
690
|
+
if (send?.tag === 'Send') {
|
|
691
|
+
const body = send.lines[0]?.text ?? '';
|
|
692
|
+
expect(body).toContain('NAK :sts');
|
|
693
|
+
}
|
|
694
|
+
expect(out.state.caps.has('sts')).toBe(false);
|
|
695
|
+
});
|
|
696
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { chathistoryReducer } from '../../src/commands/chathistory';
|
|
2
|
+
import { chathistoryReducer, formatReplayLine } from '../../src/commands/chathistory';
|
|
3
3
|
import { Effect } from '../../src/effects';
|
|
4
4
|
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
5
5
|
import {
|
|
@@ -396,6 +396,113 @@ describe('chathistoryReducer — BEFORE / AFTER / AROUND / BETWEEN', () => {
|
|
|
396
396
|
});
|
|
397
397
|
});
|
|
398
398
|
|
|
399
|
+
// ============================================================================
|
|
400
|
+
// BEFORE — draft/read-marker fallback (no explicit pivot)
|
|
401
|
+
// ============================================================================
|
|
402
|
+
//
|
|
403
|
+
// IRCv3 draft/read-marker integration: when a cap-enabled client omits the
|
|
404
|
+
// pivot msgid on CHATHISTORY BEFORE, the reducer falls back to the
|
|
405
|
+
// connection's per-channel last-read marker. This lets a client resume
|
|
406
|
+
// reading "everything before my last read position" without re-supplying the
|
|
407
|
+
// msgid. When no marker is recorded either, the reducer keeps the legacy
|
|
408
|
+
// 461 ERR_NEEDMOREPARAMS behaviour.
|
|
409
|
+
// ============================================================================
|
|
410
|
+
|
|
411
|
+
describe('chathistoryReducer — BEFORE read-marker fallback', () => {
|
|
412
|
+
it('falls back to the last-read marker when BEFORE omits the pivot', () => {
|
|
413
|
+
const store = new InMemoryMessageStore();
|
|
414
|
+
const seeded = seed(store, '#foo', 5);
|
|
415
|
+
const conn = makeConn();
|
|
416
|
+
conn.lastReadMarkers = new Map<string, string>([['#foo', 'm4']]);
|
|
417
|
+
const chan = makeChan();
|
|
418
|
+
chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
|
|
419
|
+
const ctx = makeCtx(conn, store);
|
|
420
|
+
|
|
421
|
+
const out = chathistoryReducer(
|
|
422
|
+
chan,
|
|
423
|
+
{ command: 'CHATHISTORY', params: ['BEFORE', '#foo'], tags: {} },
|
|
424
|
+
ctx,
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
expect(out.effects).toEqual<EffectType[]>(
|
|
428
|
+
expectedBatch(
|
|
429
|
+
[
|
|
430
|
+
replayLine(seeded[0] as StoredMessage, '#foo'),
|
|
431
|
+
replayLine(seeded[1] as StoredMessage, '#foo'),
|
|
432
|
+
replayLine(seeded[2] as StoredMessage, '#foo'),
|
|
433
|
+
],
|
|
434
|
+
'batch-0',
|
|
435
|
+
'#foo',
|
|
436
|
+
),
|
|
437
|
+
);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('honours an explicit pivot over the marker when both are supplied', () => {
|
|
441
|
+
const store = new InMemoryMessageStore();
|
|
442
|
+
const seeded = seed(store, '#foo', 5);
|
|
443
|
+
const conn = makeConn();
|
|
444
|
+
// Marker points at m4, but the client explicitly asks for messages before m3.
|
|
445
|
+
conn.lastReadMarkers = new Map<string, string>([['#foo', 'm4']]);
|
|
446
|
+
const chan = makeChan();
|
|
447
|
+
chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
|
|
448
|
+
const ctx = makeCtx(conn, store);
|
|
449
|
+
|
|
450
|
+
const out = chathistoryReducer(
|
|
451
|
+
chan,
|
|
452
|
+
{ command: 'CHATHISTORY', params: ['BEFORE', '#foo', 'm3', '10'], tags: {} },
|
|
453
|
+
ctx,
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
expect(out.effects).toEqual<EffectType[]>(
|
|
457
|
+
expectedBatch(
|
|
458
|
+
[
|
|
459
|
+
replayLine(seeded[0] as StoredMessage, '#foo'),
|
|
460
|
+
replayLine(seeded[1] as StoredMessage, '#foo'),
|
|
461
|
+
],
|
|
462
|
+
'batch-0',
|
|
463
|
+
'#foo',
|
|
464
|
+
),
|
|
465
|
+
);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('returns 461 when BEFORE omits the pivot and no marker is recorded', () => {
|
|
469
|
+
const store = new InMemoryMessageStore();
|
|
470
|
+
seed(store, '#foo', 3);
|
|
471
|
+
const conn = makeConn(); // no marker
|
|
472
|
+
const chan = makeChan();
|
|
473
|
+
chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
|
|
474
|
+
const ctx = makeCtx(conn, store);
|
|
475
|
+
|
|
476
|
+
const out = chathistoryReducer(
|
|
477
|
+
chan,
|
|
478
|
+
{ command: 'CHATHISTORY', params: ['BEFORE', '#foo'], tags: {} },
|
|
479
|
+
ctx,
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
expect((out.effects[0] as { lines: RawLine[] }).lines[0]?.text).toContain('461');
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('returns an empty batch when the marker points at an evicted msgid', () => {
|
|
486
|
+
const store = new InMemoryMessageStore();
|
|
487
|
+
seed(store, '#foo', 3);
|
|
488
|
+
const conn = makeConn();
|
|
489
|
+
// Marker references a message the store no longer retains; BEFORE must
|
|
490
|
+
// not surface an INVALID_PARAMS error, just an empty result.
|
|
491
|
+
conn.lastReadMarkers = new Map<string, string>([['#foo', 'evicted']]);
|
|
492
|
+
const chan = makeChan();
|
|
493
|
+
chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
|
|
494
|
+
const ctx = makeCtx(conn, store);
|
|
495
|
+
|
|
496
|
+
const out = chathistoryReducer(
|
|
497
|
+
chan,
|
|
498
|
+
{ command: 'CHATHISTORY', params: ['BEFORE', '#foo'], tags: {} },
|
|
499
|
+
ctx,
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
expect(out.effects).toEqual([]);
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
|
|
399
506
|
// ============================================================================
|
|
400
507
|
// TARGETS
|
|
401
508
|
// ============================================================================
|
|
@@ -994,3 +1101,43 @@ describe('chathistoryReducer — unregistered connection', () => {
|
|
|
994
1101
|
);
|
|
995
1102
|
});
|
|
996
1103
|
});
|
|
1104
|
+
|
|
1105
|
+
// ===========================================================================
|
|
1106
|
+
// formatReplayLine — clientTags on chathistory replay
|
|
1107
|
+
//
|
|
1108
|
+
// A stored message carrying `clientTags` (e.g. `+draft/multiline` from a
|
|
1109
|
+
// multi-line batch) must surface those tags in the chathistory replay so
|
|
1110
|
+
// message-tags-aware clients can recognise the message kind.
|
|
1111
|
+
// ===========================================================================
|
|
1112
|
+
|
|
1113
|
+
describe('formatReplayLine — clientTags', () => {
|
|
1114
|
+
const baseMsg: StoredMessage = {
|
|
1115
|
+
msgid: 'abc123',
|
|
1116
|
+
time: 1_700_000_000_000,
|
|
1117
|
+
chan: '#foo',
|
|
1118
|
+
command: 'PRIVMSG',
|
|
1119
|
+
nick: 'alice',
|
|
1120
|
+
user: 'alice',
|
|
1121
|
+
host: 'example.com',
|
|
1122
|
+
text: 'line one\nline two',
|
|
1123
|
+
};
|
|
1124
|
+
|
|
1125
|
+
it('appends +draft/multiline to the tag section when clientTags are present', () => {
|
|
1126
|
+
const out = formatReplayLine({ ...baseMsg, clientTags: ['+draft/multiline'] }, '#foo');
|
|
1127
|
+
expect(out.text).toBe(
|
|
1128
|
+
'@time=2023-11-14T22:13:20.000Z;msgid=abc123;+draft/multiline :alice!alice@example.com PRIVMSG #foo :line one\nline two',
|
|
1129
|
+
);
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
it('omits the client-tag section entirely when clientTags is undefined', () => {
|
|
1133
|
+
const out = formatReplayLine(baseMsg, '#foo');
|
|
1134
|
+
expect(out.text).toBe(
|
|
1135
|
+
'@time=2023-11-14T22:13:20.000Z;msgid=abc123 :alice!alice@example.com PRIVMSG #foo :line one\nline two',
|
|
1136
|
+
);
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
it('omits the client-tag section when clientTags is empty', () => {
|
|
1140
|
+
const out = formatReplayLine({ ...baseMsg, clientTags: [] }, '#foo');
|
|
1141
|
+
expect(out.text).not.toContain('+draft/multiline');
|
|
1142
|
+
});
|
|
1143
|
+
});
|
|
@@ -73,7 +73,9 @@ describe('echo-message — channel PRIVMSG', () => {
|
|
|
73
73
|
);
|
|
74
74
|
|
|
75
75
|
expect(out.effects).toEqual<EffectType[]>([
|
|
76
|
-
Effect.broadcast('#foo', [L(':alice!alice@example.com PRIVMSG #foo :hi')], 'c1'
|
|
76
|
+
Effect.broadcast('#foo', [L(':alice!alice@example.com PRIVMSG #foo :hi')], 'c1', 'msgid', [
|
|
77
|
+
L('@msgid=nonce-0 :alice!alice@example.com PRIVMSG #foo :hi'),
|
|
78
|
+
]),
|
|
77
79
|
]);
|
|
78
80
|
});
|
|
79
81
|
|
|
@@ -90,7 +92,9 @@ describe('echo-message — channel PRIVMSG', () => {
|
|
|
90
92
|
);
|
|
91
93
|
|
|
92
94
|
expect(out.effects).toEqual<EffectType[]>([
|
|
93
|
-
Effect.broadcast('#foo', [L(':alice!alice@example.com PRIVMSG #foo :hi')], 'c1'
|
|
95
|
+
Effect.broadcast('#foo', [L(':alice!alice@example.com PRIVMSG #foo :hi')], 'c1', 'msgid', [
|
|
96
|
+
L('@msgid=nonce-0 :alice!alice@example.com PRIVMSG #foo :hi'),
|
|
97
|
+
]),
|
|
94
98
|
Effect.send('c1', [L(':alice!alice@example.com PRIVMSG #foo :hi')]),
|
|
95
99
|
]);
|
|
96
100
|
});
|
|
@@ -108,7 +112,9 @@ describe('echo-message — channel PRIVMSG', () => {
|
|
|
108
112
|
);
|
|
109
113
|
|
|
110
114
|
expect(out.effects).toEqual<EffectType[]>([
|
|
111
|
-
Effect.broadcast('#foo', [L(':alice!alice@example.com NOTICE #foo :hi')], 'c1'
|
|
115
|
+
Effect.broadcast('#foo', [L(':alice!alice@example.com NOTICE #foo :hi')], 'c1', 'msgid', [
|
|
116
|
+
L('@msgid=nonce-0 :alice!alice@example.com NOTICE #foo :hi'),
|
|
117
|
+
]),
|
|
112
118
|
Effect.send('c1', [L(':alice!alice@example.com NOTICE #foo :hi')]),
|
|
113
119
|
]);
|
|
114
120
|
});
|