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
|
@@ -10,12 +10,14 @@ import type { Effect } from './effects.js';
|
|
|
10
10
|
import type { FloodControlConfig } from './flood-control.js';
|
|
11
11
|
import type {
|
|
12
12
|
AccountStore,
|
|
13
|
+
AwayStore,
|
|
13
14
|
Clock,
|
|
14
15
|
IdFactory,
|
|
15
16
|
MessageStore,
|
|
16
17
|
MotdProvider,
|
|
17
18
|
MtlsIdentityProvider,
|
|
18
19
|
NickHistoryStore,
|
|
20
|
+
ReadMarkerStore,
|
|
19
21
|
} from './ports.js';
|
|
20
22
|
import type { IrcMessage } from './protocol/messages.js';
|
|
21
23
|
import type { ConnectionState } from './state/connection.js';
|
|
@@ -33,6 +35,23 @@ export interface CloakingConfig {
|
|
|
33
35
|
readonly cloakedSuffix?: string | undefined;
|
|
34
36
|
}
|
|
35
37
|
|
|
38
|
+
/**
|
|
39
|
+
* IRCv3 `sts` (Strict Transport Security) policy as seen by reducers.
|
|
40
|
+
* Mirrors the {@link StsConfigSchema} shape; the cap builder reads it to
|
|
41
|
+
* construct the `sts=…` advertisement per connection.
|
|
42
|
+
*
|
|
43
|
+
* Optional fields are widened to `T | undefined` to mirror Zod's
|
|
44
|
+
* `.optional()` output under `exactOptionalPropertyTypes: true`.
|
|
45
|
+
*/
|
|
46
|
+
export interface StsConfig {
|
|
47
|
+
/** Pin duration in seconds. */
|
|
48
|
+
readonly duration: number;
|
|
49
|
+
/** TLS listener port (plaintext advertisements only). */
|
|
50
|
+
readonly port: number;
|
|
51
|
+
/** Optional preload directive (plaintext advertisements only). */
|
|
52
|
+
readonly preload?: boolean | undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
36
55
|
/**
|
|
37
56
|
* A single IRC operator credential. The {@link operReducer} consults the
|
|
38
57
|
* configured list to authenticate `OPER <name> <password>`. Both fields are
|
|
@@ -127,6 +146,55 @@ export interface ServerConfig {
|
|
|
127
146
|
* `exactOptionalPropertyTypes: true`.
|
|
128
147
|
*/
|
|
129
148
|
chatHistoryPlaybackLimit?: number | undefined;
|
|
149
|
+
/**
|
|
150
|
+
* IRCv3 `sts` (Strict Transport Security) policy. `undefined` disables
|
|
151
|
+
* STS advertisement; an object supplies the policy the cap builder
|
|
152
|
+
* serializes per connection (plaintext vs TLS).
|
|
153
|
+
*
|
|
154
|
+
* Typed `StsConfig | undefined` to mirror Zod's optional output shape
|
|
155
|
+
* under `exactOptionalPropertyTypes: true`.
|
|
156
|
+
*/
|
|
157
|
+
readonly sts?: StsConfig | undefined;
|
|
158
|
+
/**
|
|
159
|
+
* IRCv3 `draft/multiline` byte budget. Advertised as
|
|
160
|
+
* `draft/multiline=<n>` in `CAP LS` and enforced on every finalized
|
|
161
|
+
* multi-line batch. Defaults to {@link DEFAULT_MULTILINE_MAX_BYTES}
|
|
162
|
+
* (4096) when omitted; the parsed config always supplies a value.
|
|
163
|
+
*
|
|
164
|
+
* Typed `number | undefined` to mirror Zod's optional output shape
|
|
165
|
+
* under `exactOptionalPropertyTypes: true` so inline `ServerConfig`
|
|
166
|
+
* literals in tests may omit it.
|
|
167
|
+
*/
|
|
168
|
+
readonly multilineMaxBytes?: number | undefined;
|
|
169
|
+
/**
|
|
170
|
+
* Per-connection MONITOR watchlist ceiling, advertised as `MONITOR=<n>`
|
|
171
|
+
* in `005 RPL_ISUPPORT`. Omitted → the reducer falls back to
|
|
172
|
+
* {@link DEFAULT_MONITOR_LIMIT} (the monitor cap's built-in ceiling).
|
|
173
|
+
*
|
|
174
|
+
* Typed `number | undefined` to mirror Zod's optional output shape under
|
|
175
|
+
* `exactOptionalPropertyTypes: true`.
|
|
176
|
+
*/
|
|
177
|
+
readonly monitorLimit?: number | undefined;
|
|
178
|
+
/**
|
|
179
|
+
* Extended-ban prefix sigil advertised as `EXTBAN=<prefix>,…` in
|
|
180
|
+
* `005 RPL_ISUPPORT`. Omitted → defaults to `~` (the IRC-standard
|
|
181
|
+
* extban prefix).
|
|
182
|
+
*
|
|
183
|
+
* Typed `string | undefined` to mirror Zod's optional output shape under
|
|
184
|
+
* `exactOptionalPropertyTypes: true`.
|
|
185
|
+
*/
|
|
186
|
+
readonly extbanPrefix?: string | undefined;
|
|
187
|
+
/**
|
|
188
|
+
* Whether IRC services (NickServ/ChanServ) are wired up for this
|
|
189
|
+
* deployment. When true, `005 RPL_ISUPPORT` advertises `ACCOUNTEXTBAN=a`
|
|
190
|
+
* (account-name extended bans), which are only meaningful once nicks are
|
|
191
|
+
* registered. Omitted/false → the token is suppressed until services
|
|
192
|
+
* ship.
|
|
193
|
+
*
|
|
194
|
+
* Typed `boolean | undefined` to mirror Zod's optional output shape under
|
|
195
|
+
* `exactOptionalPropertyTypes: true`.
|
|
196
|
+
*/
|
|
197
|
+
readonly servicesEnabled?: boolean | undefined;
|
|
130
198
|
}
|
|
131
199
|
|
|
132
200
|
/** Per-invocation context handed to every reducer. */
|
|
@@ -162,10 +230,43 @@ export interface Ctx {
|
|
|
162
230
|
* entries and the WHOWAS reducer replays them.
|
|
163
231
|
*/
|
|
164
232
|
readonly history?: NickHistoryStore;
|
|
233
|
+
/**
|
|
234
|
+
* Read-marker persistence source for IRCv3 `draft/read-marker` (absent
|
|
235
|
+
* when the deployment does not persist read markers). When present, a
|
|
236
|
+
* `+draft/read-marker=<msgid>` TAGMSG from an identified, cap-enabled
|
|
237
|
+
* client updates the per-`(account, channel)` marker, and SASL identify
|
|
238
|
+
* seeds the connection's `lastReadMarkers` from the stored values so a
|
|
239
|
+
* reconnect restores the user's read position.
|
|
240
|
+
*/
|
|
241
|
+
readonly readMarkers?: ReadMarkerStore;
|
|
242
|
+
/**
|
|
243
|
+
* Away-status persistence source for IRCv3 `draft/pre-away` (absent
|
|
244
|
+
* when the deployment does not persist away reasons). When present, a
|
|
245
|
+
* set/clear of `AWAY` from an identified connection mirrors the change
|
|
246
|
+
* into the per-`account` record, and SASL identify replays the stored
|
|
247
|
+
* reason onto the connection (emitting `306 RPL_NOWAWAY`) so a
|
|
248
|
+
* reconnect restores the user's away state. Omitted (→ `ctx.away`
|
|
249
|
+
* undefined) preserves the session-only behaviour: the connection
|
|
250
|
+
* still tracks its own `AWAY` but the reason is not shared across
|
|
251
|
+
* connections or restored on reconnect.
|
|
252
|
+
*/
|
|
253
|
+
readonly away?: AwayStore;
|
|
165
254
|
/** The connection invoking the command. Reducers mutate this freely. */
|
|
166
255
|
readonly connection: ConnectionState;
|
|
167
256
|
/** Convenience: `connection.id`. */
|
|
168
257
|
readonly connId: string;
|
|
258
|
+
/**
|
|
259
|
+
* IRCv3 `labeled-response` label carried by the inbound message (the value
|
|
260
|
+
* of the `+label` client tag), or `undefined` when the request was
|
|
261
|
+
* unlabeled. Plumbed through ctx so every reducer's emitted effects are
|
|
262
|
+
* uniformly attributable to the labeled request; the actor layer wraps the
|
|
263
|
+
* requester's replies in one `BATCH +label labeled-response … -label` frame
|
|
264
|
+
* (or emits an empty batch when there is no reply).
|
|
265
|
+
*
|
|
266
|
+
* Typed `string | undefined` to mirror the optional-output shape under
|
|
267
|
+
* `exactOptionalPropertyTypes: true`.
|
|
268
|
+
*/
|
|
269
|
+
readonly label?: string | undefined;
|
|
169
270
|
}
|
|
170
271
|
|
|
171
272
|
/** Result of a single reducer invocation. */
|
|
@@ -190,7 +291,15 @@ export interface BuildCtxOptions {
|
|
|
190
291
|
mtlsIdentity?: MtlsIdentityProvider;
|
|
191
292
|
messages?: MessageStore;
|
|
192
293
|
history?: NickHistoryStore;
|
|
294
|
+
readMarkers?: ReadMarkerStore;
|
|
295
|
+
away?: AwayStore;
|
|
193
296
|
connection: ConnectionState;
|
|
297
|
+
/**
|
|
298
|
+
* IRCv3 `labeled-response` label extracted from the inbound `+label` tag.
|
|
299
|
+
* Omitted (→ `ctx.label` undefined) when the request was unlabeled. See
|
|
300
|
+
* {@link Ctx.label}.
|
|
301
|
+
*/
|
|
302
|
+
readonly label?: string | undefined;
|
|
194
303
|
}
|
|
195
304
|
|
|
196
305
|
/** Constructs a {@link Ctx} from the required parts. */
|
|
@@ -208,6 +317,9 @@ export function buildCtx(opts: BuildCtxOptions): Ctx {
|
|
|
208
317
|
...(opts.mtlsIdentity !== undefined ? { mtlsIdentity: opts.mtlsIdentity } : {}),
|
|
209
318
|
...(opts.messages !== undefined ? { messages: opts.messages } : {}),
|
|
210
319
|
...(opts.history !== undefined ? { history: opts.history } : {}),
|
|
320
|
+
...(opts.readMarkers !== undefined ? { readMarkers: opts.readMarkers } : {}),
|
|
321
|
+
...(opts.away !== undefined ? { away: opts.away } : {}),
|
|
322
|
+
...(opts.label !== undefined ? { label: opts.label } : {}),
|
|
211
323
|
};
|
|
212
324
|
return ctx;
|
|
213
325
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { type AwayStore, InMemoryAwayStore } from '../src/ports';
|
|
3
|
+
|
|
4
|
+
describe('InMemoryAwayStore — port conformance', () => {
|
|
5
|
+
it('implements the AwayStore port', () => {
|
|
6
|
+
const store: AwayStore = new InMemoryAwayStore();
|
|
7
|
+
expect(store).toBeDefined();
|
|
8
|
+
});
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe('InMemoryAwayStore — get / set / clear', () => {
|
|
12
|
+
it('returns undefined for an unknown account', () => {
|
|
13
|
+
const store = new InMemoryAwayStore();
|
|
14
|
+
expect(store.get('alice')).toBeUndefined();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('stores and returns the away reason for a known account', () => {
|
|
18
|
+
const store = new InMemoryAwayStore();
|
|
19
|
+
store.set('alice', 'brb');
|
|
20
|
+
expect(store.get('alice')).toBe('brb');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('overwrites the reason on a subsequent set for the same account', () => {
|
|
24
|
+
const store = new InMemoryAwayStore();
|
|
25
|
+
store.set('alice', 'brb');
|
|
26
|
+
store.set('alice', 'lunch');
|
|
27
|
+
expect(store.get('alice')).toBe('lunch');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('keys accounts case-insensitively (rfc1459 fold)', () => {
|
|
31
|
+
const store = new InMemoryAwayStore();
|
|
32
|
+
store.set('Alice', 'brb');
|
|
33
|
+
expect(store.get('alice')).toBe('brb');
|
|
34
|
+
expect(store.get('ALICE')).toBe('brb');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('keeps reasons for distinct accounts separate', () => {
|
|
38
|
+
const store = new InMemoryAwayStore();
|
|
39
|
+
store.set('alice', 'brb');
|
|
40
|
+
store.set('bob', 'afk');
|
|
41
|
+
expect(store.get('alice')).toBe('brb');
|
|
42
|
+
expect(store.get('bob')).toBe('afk');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('clears the reason for an account on clear', () => {
|
|
46
|
+
const store = new InMemoryAwayStore();
|
|
47
|
+
store.set('alice', 'brb');
|
|
48
|
+
store.clear('alice');
|
|
49
|
+
expect(store.get('alice')).toBeUndefined();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('is a no-op to clear an account that has no stored reason', () => {
|
|
53
|
+
const store = new InMemoryAwayStore();
|
|
54
|
+
store.clear('alice');
|
|
55
|
+
expect(store.get('alice')).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('clears only the named account, leaving others intact', () => {
|
|
59
|
+
const store = new InMemoryAwayStore();
|
|
60
|
+
store.set('alice', 'brb');
|
|
61
|
+
store.set('bob', 'afk');
|
|
62
|
+
store.clear('alice');
|
|
63
|
+
expect(store.get('alice')).toBeUndefined();
|
|
64
|
+
expect(store.get('bob')).toBe('afk');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('folds the account name case-insensitively on clear', () => {
|
|
68
|
+
const store = new InMemoryAwayStore();
|
|
69
|
+
store.set('Alice', 'brb');
|
|
70
|
+
store.clear('ALICE');
|
|
71
|
+
expect(store.get('alice')).toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -14,23 +14,44 @@ describe('SUPPORTED_CAPABILITIES', () => {
|
|
|
14
14
|
const names = SUPPORTED_CAPABILITIES.map((c) => c.name).sort();
|
|
15
15
|
expect(names).toEqual(
|
|
16
16
|
[
|
|
17
|
+
'account-notify',
|
|
17
18
|
'account-tag',
|
|
18
19
|
'away-notify',
|
|
19
20
|
'batch',
|
|
20
21
|
'chghost',
|
|
21
22
|
'draft/chathistory',
|
|
23
|
+
'draft/multiline',
|
|
24
|
+
'draft/pre-away',
|
|
25
|
+
'draft/read-marker',
|
|
26
|
+
'draft/typing',
|
|
22
27
|
'echo-message',
|
|
23
28
|
'extended-join',
|
|
24
29
|
'invite-notify',
|
|
30
|
+
'labeled-response',
|
|
25
31
|
'message-tags',
|
|
32
|
+
'msgid',
|
|
33
|
+
'monitor',
|
|
26
34
|
'multi-prefix',
|
|
27
35
|
'safelist',
|
|
28
36
|
'sasl',
|
|
29
37
|
'server-time',
|
|
38
|
+
'standard-replies',
|
|
30
39
|
].sort(),
|
|
31
40
|
);
|
|
32
41
|
});
|
|
33
42
|
|
|
43
|
+
it('advertises draft/typing as a bare name (no value parameter)', () => {
|
|
44
|
+
const cap = SUPPORTED_CAPABILITIES.find((c) => c.name === 'draft/typing');
|
|
45
|
+
expect(cap).toBeDefined();
|
|
46
|
+
expect(cap?.value).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('advertises the standard-replies cap as a bare name (no value parameter)', () => {
|
|
50
|
+
const cap = SUPPORTED_CAPABILITIES.find((c) => c.name === 'standard-replies');
|
|
51
|
+
expect(cap).toBeDefined();
|
|
52
|
+
expect(cap?.value).toBeUndefined();
|
|
53
|
+
});
|
|
54
|
+
|
|
34
55
|
it('declares the sasl value as the base mechanism list (PLAIN only by default)', () => {
|
|
35
56
|
const sasl = SUPPORTED_CAPABILITIES.find((c) => c.name === 'sasl');
|
|
36
57
|
expect(sasl).toBeDefined();
|
|
@@ -38,9 +59,11 @@ describe('SUPPORTED_CAPABILITIES', () => {
|
|
|
38
59
|
});
|
|
39
60
|
|
|
40
61
|
it('does not attach a value to caps that have no parameter', () => {
|
|
41
|
-
// sasl
|
|
62
|
+
// sasl and draft/multiline are the caps with a value parameter.
|
|
63
|
+
const withValue = SUPPORTED_CAPABILITIES.filter((c) => c.value !== undefined);
|
|
64
|
+
expect(withValue.map((c) => c.name).sort()).toEqual(['draft/multiline', 'sasl']);
|
|
42
65
|
const noValue = SUPPORTED_CAPABILITIES.filter((c) => c.value === undefined);
|
|
43
|
-
expect(noValue.length).toBe(SUPPORTED_CAPABILITIES.length -
|
|
66
|
+
expect(noValue.length).toBe(SUPPORTED_CAPABILITIES.length - withValue.length);
|
|
44
67
|
});
|
|
45
68
|
|
|
46
69
|
it('advertises safelist as a bare name (no value parameter)', () => {
|
|
@@ -134,6 +157,100 @@ describe('draft/chathistory advertisement', () => {
|
|
|
134
157
|
});
|
|
135
158
|
});
|
|
136
159
|
|
|
160
|
+
describe('msgid advertisement', () => {
|
|
161
|
+
it('is advertised as a bare name (no value parameter)', () => {
|
|
162
|
+
const cap = SUPPORTED_CAPABILITIES.find((c) => c.name === 'msgid');
|
|
163
|
+
expect(cap).toBeDefined();
|
|
164
|
+
expect(cap?.value).toBeUndefined();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('appears in CAP LS output as the bare token', () => {
|
|
168
|
+
expect(getLsString()).toContain('msgid');
|
|
169
|
+
expect(getLsString()).not.toContain('msgid=');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('is ACKable via SUPPORTED_CAP_NAMES membership (CAP REQ round-trip)', () => {
|
|
173
|
+
expect(SUPPORTED_CAP_NAMES.has('msgid')).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('draft/typing advertisement', () => {
|
|
178
|
+
it('appears in CAP LS output as the bare token', () => {
|
|
179
|
+
expect(getLsString()).toContain('draft/typing');
|
|
180
|
+
expect(getLsString()).not.toContain('draft/typing=');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('is ACKable via SUPPORTED_CAP_NAMES membership (CAP REQ round-trip)', () => {
|
|
184
|
+
expect(SUPPORTED_CAP_NAMES.has('draft/typing')).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('is recognized by isSupportedCap', () => {
|
|
188
|
+
expect(isSupportedCap('draft/typing')).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('draft/read-marker advertisement', () => {
|
|
193
|
+
it('is advertised as a bare name (no value parameter)', () => {
|
|
194
|
+
const cap = SUPPORTED_CAPABILITIES.find((c) => c.name === 'draft/read-marker');
|
|
195
|
+
expect(cap).toBeDefined();
|
|
196
|
+
expect(cap?.value).toBeUndefined();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('appears in CAP LS output as the bare token', () => {
|
|
200
|
+
expect(getLsString()).toContain('draft/read-marker');
|
|
201
|
+
expect(getLsString()).not.toContain('draft/read-marker=');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('is ACKable via SUPPORTED_CAP_NAMES membership (CAP REQ round-trip)', () => {
|
|
205
|
+
expect(SUPPORTED_CAP_NAMES.has('draft/read-marker')).toBe(true);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('is recognized by isSupportedCap', () => {
|
|
209
|
+
expect(isSupportedCap('draft/read-marker')).toBe(true);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe('draft/pre-away advertisement', () => {
|
|
214
|
+
it('is advertised as a bare name (no value parameter)', () => {
|
|
215
|
+
const cap = SUPPORTED_CAPABILITIES.find((c) => c.name === 'draft/pre-away');
|
|
216
|
+
expect(cap).toBeDefined();
|
|
217
|
+
expect(cap?.value).toBeUndefined();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('appears in CAP LS output as the bare token', () => {
|
|
221
|
+
expect(getLsString()).toContain('draft/pre-away');
|
|
222
|
+
expect(getLsString()).not.toContain('draft/pre-away=');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('is ACKable via SUPPORTED_CAP_NAMES membership (CAP REQ round-trip)', () => {
|
|
226
|
+
expect(SUPPORTED_CAP_NAMES.has('draft/pre-away')).toBe(true);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('is recognized by isSupportedCap', () => {
|
|
230
|
+
expect(isSupportedCap('draft/pre-away')).toBe(true);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe('draft/multiline advertisement', () => {
|
|
235
|
+
it('is advertised with the default byte budget value', () => {
|
|
236
|
+
const cap = SUPPORTED_CAPABILITIES.find((c) => c.name === 'draft/multiline');
|
|
237
|
+
expect(cap).toBeDefined();
|
|
238
|
+
expect(cap?.value).toBe('4096');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('appears in CAP LS output as the name=4096 token', () => {
|
|
242
|
+
expect(getLsString()).toContain('draft/multiline=4096');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('is ACKable via SUPPORTED_CAP_NAMES membership (CAP REQ round-trip)', () => {
|
|
246
|
+
expect(SUPPORTED_CAP_NAMES.has('draft/multiline')).toBe(true);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('is recognized by isSupportedCap', () => {
|
|
250
|
+
expect(isSupportedCap('draft/multiline')).toBe(true);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
137
254
|
describe('splitCapsForWire', () => {
|
|
138
255
|
it('returns a single chunk when everything fits', () => {
|
|
139
256
|
const tokens = ['a', 'b', 'c'];
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_STS_DURATION_SECONDS,
|
|
4
|
+
DEFAULT_STS_PORT,
|
|
5
|
+
STS_CAP_NAME,
|
|
6
|
+
stsCapValue,
|
|
7
|
+
} from '../../src/caps/sts';
|
|
8
|
+
import type { StsPolicy } from '../../src/caps/sts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* IRCv3 `sts` (Strict Transport Security) policy builder.
|
|
12
|
+
*
|
|
13
|
+
* Spec: https://ircv3.net/specs/extensions/sts
|
|
14
|
+
*
|
|
15
|
+
* On a plaintext connection the server advertises the full policy
|
|
16
|
+
* (`duration=…,port=…` plus optional `preload`) so compliant clients
|
|
17
|
+
* reconnect over TLS. On a TLS connection only `duration` is sent —
|
|
18
|
+
* the port is meaningless once the client is already on the secure
|
|
19
|
+
* listener, and `preload` is only meaningful as an upgrade directive.
|
|
20
|
+
*/
|
|
21
|
+
describe('stsCapValue', () => {
|
|
22
|
+
describe('STS_CAP_NAME', () => {
|
|
23
|
+
it('exposes the IRCv3 wire name', () => {
|
|
24
|
+
expect(STS_CAP_NAME).toBe('sts');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('plaintext connection (secure=false)', () => {
|
|
29
|
+
it('builds duration+port from the supplied config', () => {
|
|
30
|
+
const cfg: StsPolicy = { duration: 600, port: 6697 };
|
|
31
|
+
expect(stsCapValue(cfg, false)).toBe('duration=600,port=6697');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('appends `preload` when preload=true', () => {
|
|
35
|
+
const cfg: StsPolicy = { duration: 600, port: 6697, preload: true };
|
|
36
|
+
expect(stsCapValue(cfg, false)).toBe('duration=600,port=6697,preload');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('omits `preload` when preload=false', () => {
|
|
40
|
+
const cfg: StsPolicy = { duration: 600, port: 6697, preload: false };
|
|
41
|
+
expect(stsCapValue(cfg, false)).toBe('duration=600,port=6697');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('omits `preload` when preload is undefined', () => {
|
|
45
|
+
const cfg: StsPolicy = { duration: 600, port: 6697 };
|
|
46
|
+
expect(stsCapValue(cfg, false)).toBe('duration=600,port=6697');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('honours an overridden port', () => {
|
|
50
|
+
const cfg: StsPolicy = { duration: 600, port: 7000 };
|
|
51
|
+
expect(stsCapValue(cfg, false)).toBe('duration=600,port=7000');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('honours an overridden duration', () => {
|
|
55
|
+
const cfg: StsPolicy = { duration: 86_400, port: 6697 };
|
|
56
|
+
expect(stsCapValue(cfg, false)).toBe('duration=86400,port=6697');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('TLS connection (secure=true)', () => {
|
|
61
|
+
it('advertises only the duration (port is meaningless on TLS)', () => {
|
|
62
|
+
const cfg: StsPolicy = { duration: 600, port: 6697 };
|
|
63
|
+
expect(stsCapValue(cfg, true)).toBe('duration=600');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('omits preload on TLS (upgrade-only directive)', () => {
|
|
67
|
+
const cfg: StsPolicy = { duration: 600, port: 6697, preload: true };
|
|
68
|
+
expect(stsCapValue(cfg, true)).toBe('duration=600');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('honours an overridden duration on TLS', () => {
|
|
72
|
+
const cfg: StsPolicy = { duration: 86_400, port: 6697 };
|
|
73
|
+
expect(stsCapValue(cfg, true)).toBe('duration=86400');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('disabled (no policy configured)', () => {
|
|
78
|
+
it('returns undefined when config is undefined', () => {
|
|
79
|
+
expect(stsCapValue(undefined, false)).toBeUndefined();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('returns undefined when config is undefined on a TLS connection', () => {
|
|
83
|
+
expect(stsCapValue(undefined, true)).toBeUndefined();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('field defaults', () => {
|
|
88
|
+
it('falls back to DEFAULT_STS_DURATION_SECONDS when duration is omitted (plaintext)', () => {
|
|
89
|
+
const value = stsCapValue({ port: 6697 }, false);
|
|
90
|
+
expect(value).toBe(`duration=${DEFAULT_STS_DURATION_SECONDS},port=6697`);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('falls back to DEFAULT_STS_DURATION_SECONDS when duration is omitted (TLS)', () => {
|
|
94
|
+
const value = stsCapValue({ port: 6697 }, true);
|
|
95
|
+
expect(value).toBe(`duration=${DEFAULT_STS_DURATION_SECONDS}`);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('falls back to DEFAULT_STS_PORT when port is omitted (plaintext)', () => {
|
|
99
|
+
const value = stsCapValue({ duration: 600 }, false);
|
|
100
|
+
expect(value).toBe(`duration=600,port=${DEFAULT_STS_PORT}`);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('uses both defaults when only an empty object is supplied (plaintext)', () => {
|
|
104
|
+
const value = stsCapValue({}, false);
|
|
105
|
+
expect(value).toBe(`duration=${DEFAULT_STS_DURATION_SECONDS},port=${DEFAULT_STS_PORT}`);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('uses both defaults when only an empty object is supplied (TLS)', () => {
|
|
109
|
+
const value = stsCapValue({}, true);
|
|
110
|
+
expect(value).toBe(`duration=${DEFAULT_STS_DURATION_SECONDS}`);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('defaults', () => {
|
|
115
|
+
it('exposes 6697 as the canonical IRC-over-TLS port', () => {
|
|
116
|
+
expect(DEFAULT_STS_PORT).toBe(6697);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('exposes a positive duration default (seconds)', () => {
|
|
120
|
+
expect(DEFAULT_STS_DURATION_SECONDS).toBeGreaterThan(0);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
});
|