serverless-ircd 0.4.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 +435 -0
- package/README.md +206 -27
- package/apps/aws-stack/README.md +37 -3
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +106 -13
- package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
- package/apps/aws-stack/tests/stack.test.ts +49 -3
- 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 +138 -28
- package/apps/local-cli/tests/e2e.test.ts +113 -29
- package/apps/local-cli/tests/tcp.test.ts +71 -19
- package/apps/local-cli/tests/ws-subprotocol.test.ts +262 -0
- 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/aws-runtime.ts +69 -0
- package/packages/aws-adapter/src/handlers/connect.ts +36 -5
- package/packages/aws-adapter/src/handlers/default.ts +66 -5
- package/packages/aws-adapter/src/handlers/index.ts +41 -2
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +18 -0
- package/packages/aws-adapter/src/index.ts +2 -0
- package/packages/aws-adapter/src/serialize.ts +40 -2
- package/packages/aws-adapter/src/stats.ts +80 -0
- 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 +140 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
- package/packages/aws-adapter/tests/connect.test.ts +100 -4
- 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 +238 -4
- package/packages/aws-adapter/tests/message-store.test.ts +6 -5
- package/packages/aws-adapter/tests/nlb-stream.test.ts +62 -8
- package/packages/aws-adapter/tests/stats.test.ts +317 -0
- package/packages/cf-adapter/package.json +6 -1
- package/packages/cf-adapter/src/cf-runtime.ts +66 -1
- package/packages/cf-adapter/src/channel-do.ts +2 -2
- package/packages/cf-adapter/src/connection-do.ts +185 -54
- package/packages/cf-adapter/src/env.ts +25 -6
- package/packages/cf-adapter/src/index.ts +2 -0
- package/packages/cf-adapter/src/registry-do.ts +22 -3
- package/packages/cf-adapter/src/serialize.ts +25 -4
- package/packages/cf-adapter/src/sharding.ts +1 -2
- package/packages/cf-adapter/src/stats.ts +65 -0
- package/packages/cf-adapter/tests/cf-harness.ts +1 -1
- package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
- package/packages/cf-adapter/tests/cf-runtime.test.ts +307 -2
- package/packages/cf-adapter/tests/channel-do.test.ts +119 -2
- package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
- package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
- package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
- package/packages/cf-adapter/tests/registry-do.test.ts +108 -4
- package/packages/cf-adapter/tests/serialize.test.ts +308 -0
- package/packages/cf-adapter/tests/sharding.test.ts +1 -1
- package/packages/cf-adapter/tests/stats.test.ts +120 -0
- package/packages/cf-adapter/tests/worker/main.ts +7 -7
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
- package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
- package/packages/cf-adapter/vitest.config.ts +1 -1
- package/packages/cf-adapter/wrangler.test.toml +7 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/admission.ts +16 -15
- package/packages/irc-core/src/caps/capabilities.ts +38 -4
- 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 +42 -0
- package/packages/irc-core/src/commands/invite.ts +2 -4
- package/packages/irc-core/src/commands/isupport.ts +59 -2
- package/packages/irc-core/src/commands/kick.ts +2 -4
- package/packages/irc-core/src/commands/kill.ts +127 -0
- package/packages/irc-core/src/commands/list.ts +1 -1
- package/packages/irc-core/src/commands/lusers.ts +204 -0
- package/packages/irc-core/src/commands/mode.ts +12 -9
- 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/names.ts +3 -5
- package/packages/irc-core/src/commands/part.ts +2 -4
- 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/rehash.ts +119 -0
- package/packages/irc-core/src/commands/sasl.ts +24 -1
- package/packages/irc-core/src/commands/setname.ts +109 -0
- package/packages/irc-core/src/commands/stats.ts +152 -0
- package/packages/irc-core/src/commands/tagmsg.ts +71 -1
- package/packages/irc-core/src/commands/topic.ts +2 -4
- package/packages/irc-core/src/commands/trace.ts +137 -0
- package/packages/irc-core/src/commands/wallops.ts +118 -0
- package/packages/irc-core/src/commands/whois.ts +5 -0
- package/packages/irc-core/src/config.ts +72 -10
- package/packages/irc-core/src/effects.ts +41 -1
- package/packages/irc-core/src/index.ts +2 -0
- package/packages/irc-core/src/ports.ts +568 -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 +57 -11
- package/packages/irc-core/src/protocol/outbound.ts +36 -4
- 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 +120 -1
- package/packages/irc-core/src/ws-framing.ts +132 -0
- package/packages/irc-core/src/ws-subprotocol.ts +66 -0
- package/packages/irc-core/stryker.commands.conf.json +1 -2
- package/packages/irc-core/tests/admission.test.ts +18 -0
- 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/kill.test.ts +243 -0
- package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
- package/packages/irc-core/tests/commands/mode.test.ts +129 -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/rehash.test.ts +171 -0
- package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
- package/packages/irc-core/tests/commands/setname.test.ts +225 -0
- package/packages/irc-core/tests/commands/stats.test.ts +294 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
- package/packages/irc-core/tests/commands/trace.test.ts +282 -0
- package/packages/irc-core/tests/commands/wallops.test.ts +231 -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/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
- package/packages/irc-core/tests/effects.test.ts +14 -0
- 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/numerics.test.ts +90 -0
- package/packages/irc-core/tests/outbound.test.ts +51 -0
- package/packages/irc-core/tests/ports.test.ts +22 -0
- package/packages/irc-core/tests/raw-modules.d.ts +11 -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/stats-store.test.ts +222 -0
- package/packages/irc-core/tests/types.test.ts +27 -0
- package/packages/irc-core/tests/ws-framing.test.ts +213 -0
- package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +699 -19
- package/packages/irc-server/src/dispatch.ts +109 -16
- package/packages/irc-server/src/routing.ts +3 -0
- package/packages/irc-server/src/runtime.ts +31 -0
- package/packages/irc-server/src/transport.ts +10 -7
- package/packages/irc-server/tests/actor.test.ts +2523 -42
- package/packages/irc-server/tests/dispatch.test.ts +300 -2
- package/packages/irc-server/tests/raw-modules.d.ts +11 -0
- package/packages/irc-server/tests/routing.test.ts +1 -0
- package/packages/irc-server/tests/runtime.test.ts +7 -0
- 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/scenarios.ts +9 -1
- package/packages/irc-test-support/src/test-config.ts +54 -0
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
- package/packages/irc-test-support/tests/test-config.test.ts +51 -0
- package/pnpm-workspace.yaml +1 -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/package.json +6 -1
- package/tools/seed-cf-accounts.ts +4 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
- package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
type ConnectionState,
|
|
27
27
|
type Nick,
|
|
28
28
|
type RawLine,
|
|
29
|
+
type ServerConfig,
|
|
29
30
|
applyChannelDelta as applyDelta,
|
|
30
31
|
caseFold,
|
|
31
32
|
createChannel,
|
|
@@ -64,6 +65,15 @@ export interface InMemoryRuntimeOptions {
|
|
|
64
65
|
* deployment's `ServerConfig`.
|
|
65
66
|
*/
|
|
66
67
|
readonly admission?: AdmissionConfig;
|
|
68
|
+
/**
|
|
69
|
+
* Config-reload source for `REHASH`. When bound, the runtime re-invokes
|
|
70
|
+
* it on every {@link InMemoryRuntime.reloadConfig} call and returns the
|
|
71
|
+
* fresh {@link ServerConfig}; the local-cli binds the loader that
|
|
72
|
+
* re-derives config from its original options. When omitted
|
|
73
|
+
* `reloadConfig` rejects so the actor's graceful failure path applies a
|
|
74
|
+
* consistent error-suffixed `382`.
|
|
75
|
+
*/
|
|
76
|
+
readonly configLoader?: () => Promise<ServerConfig>;
|
|
67
77
|
}
|
|
68
78
|
|
|
69
79
|
export class InMemoryRuntime implements IrcRuntime {
|
|
@@ -73,9 +83,11 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
73
83
|
private readonly nicks = new Map<string, ConnId>();
|
|
74
84
|
private readonly admissionStats: AdmissionStats | undefined;
|
|
75
85
|
private readonly admissionConfig: AdmissionConfig | undefined;
|
|
86
|
+
private readonly configLoader: (() => Promise<ServerConfig>) | undefined;
|
|
76
87
|
|
|
77
88
|
constructor(opts: InMemoryRuntimeOptions) {
|
|
78
89
|
this.clock = opts.clock;
|
|
90
|
+
this.configLoader = opts.configLoader;
|
|
79
91
|
if (opts.admission !== undefined) {
|
|
80
92
|
this.admissionConfig = opts.admission;
|
|
81
93
|
this.admissionStats = new AdmissionStats(this.clock);
|
|
@@ -242,6 +254,22 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
242
254
|
}
|
|
243
255
|
}
|
|
244
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Global cross-connection fanout for `WALLOPS`. Scans the connection map
|
|
259
|
+
* and delivers `lines` to every live connection whose `+w` user mode is
|
|
260
|
+
* set, skipping `except` (the originator). Unlike {@link broadcast} this
|
|
261
|
+
* is NOT channel-scoped — it reaches connections with no shared channel
|
|
262
|
+
* membership. This is the reference implementation the CF and AWS
|
|
263
|
+
* adapters mirror against their own enumeration primitives.
|
|
264
|
+
*/
|
|
265
|
+
async broadcastWallops(lines: RawLine[], except?: ConnId): Promise<void> {
|
|
266
|
+
for (const [connId, tracked] of this.connections) {
|
|
267
|
+
if (connId === except) continue;
|
|
268
|
+
if (!tracked.state.userModes.wallops) continue;
|
|
269
|
+
tracked.handlers.send(lines);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
245
273
|
// ------------------------------------------------------------------
|
|
246
274
|
// IrcRuntime — nick registry
|
|
247
275
|
// ------------------------------------------------------------------
|
|
@@ -333,4 +361,15 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
333
361
|
async listChannels(): Promise<ChanSnapshot[]> {
|
|
334
362
|
return Array.from(this.channels.values(), (c) => toChannelSnapshot(c));
|
|
335
363
|
}
|
|
364
|
+
|
|
365
|
+
// ------------------------------------------------------------------
|
|
366
|
+
// IrcRuntime — config reload (REHASH)
|
|
367
|
+
// ------------------------------------------------------------------
|
|
368
|
+
|
|
369
|
+
async reloadConfig(): Promise<ServerConfig> {
|
|
370
|
+
if (this.configLoader === undefined) {
|
|
371
|
+
throw new Error('no config loader bound to InMemoryRuntime');
|
|
372
|
+
}
|
|
373
|
+
return this.configLoader();
|
|
374
|
+
}
|
|
336
375
|
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type ConnectionState,
|
|
6
6
|
FakeClock,
|
|
7
7
|
type RawLine,
|
|
8
|
+
type ServerConfig,
|
|
8
9
|
createConnection,
|
|
9
10
|
} from '@serverless-ircd/irc-core';
|
|
10
11
|
import { describe, expect, it } from 'vitest';
|
|
@@ -124,6 +125,26 @@ describe('InMemoryRuntime — changeNick', () => {
|
|
|
124
125
|
expect(await rt.lookupNick('alice')).toBe('c1');
|
|
125
126
|
expect(await rt.lookupNick('bob')).toBe('c2');
|
|
126
127
|
});
|
|
128
|
+
|
|
129
|
+
it('does not clobber a third party that grabbed the old nick via re-reserve (stale-release guard)', async () => {
|
|
130
|
+
// Covers the defensive branch on the old-mapping clear: c1 held
|
|
131
|
+
// `alice`, released it, c2 re-reserved it, then c1 reports a nick
|
|
132
|
+
// change FROM `alice` (stale state). The guard must NOT delete the
|
|
133
|
+
// alice mapping that now belongs to c2.
|
|
134
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
135
|
+
rt.registerConnection(makeConn('c1'), { send: () => {}, disconnect: () => {} });
|
|
136
|
+
rt.registerConnection(makeConn('c2'), { send: () => {}, disconnect: () => {} });
|
|
137
|
+
|
|
138
|
+
await rt.reserveNick('alice', 'c1');
|
|
139
|
+
await rt.releaseNick('alice'); // nicks[alice] deleted (still pointed at c1)
|
|
140
|
+
await rt.reserveNick('alice', 'c2'); // nicks[alice] = c2
|
|
141
|
+
|
|
142
|
+
const ok = await rt.changeNick('c1', 'alice', 'bob');
|
|
143
|
+
expect(ok).toBe(true);
|
|
144
|
+
// c1 got `bob`, c2 still holds `alice` (the stale clear was skipped).
|
|
145
|
+
expect(await rt.lookupNick('alice')).toBe('c2');
|
|
146
|
+
expect(await rt.lookupNick('bob')).toBe('c1');
|
|
147
|
+
});
|
|
127
148
|
});
|
|
128
149
|
|
|
129
150
|
describe('InMemoryRuntime — releaseNick', () => {
|
|
@@ -255,6 +276,100 @@ describe('InMemoryRuntime — applyChannelDelta / getChannelSnapshot', () => {
|
|
|
255
276
|
});
|
|
256
277
|
});
|
|
257
278
|
|
|
279
|
+
describe('InMemoryRuntime — getChannel (peek-only)', () => {
|
|
280
|
+
it('returns the authoritative ChannelState when the channel exists', async () => {
|
|
281
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
282
|
+
await rt.applyChannelDelta('#foo', {
|
|
283
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const chan = rt.getChannel('#foo');
|
|
287
|
+
expect(chan).toBeDefined();
|
|
288
|
+
expect(chan?.name).toBe('#foo');
|
|
289
|
+
expect(chan?.members.has('c1')).toBe(true);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('returns undefined for an unknown channel (does not create it)', async () => {
|
|
293
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
294
|
+
expect(rt.getChannel('#nope')).toBeUndefined();
|
|
295
|
+
// Peek-only: the channel map is still empty.
|
|
296
|
+
expect(await rt.listChannels()).toEqual([]);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('folds the channel name with rfc1459 case-mapping on lookup', async () => {
|
|
300
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
301
|
+
await rt.applyChannelDelta('#Foo', {
|
|
302
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
303
|
+
});
|
|
304
|
+
expect(rt.getChannel('#foo')).toBeDefined();
|
|
305
|
+
expect(rt.getChannel('#FOO')).toBeDefined();
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
describe('InMemoryRuntime — getChannelConnections', () => {
|
|
310
|
+
it('returns the live ConnectionState for each registered member', async () => {
|
|
311
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
312
|
+
rt.registerConnection(makeConn('c1', 'alice'), { send: () => {}, disconnect: () => {} });
|
|
313
|
+
rt.registerConnection(makeConn('c2', 'bob'), { send: () => {}, disconnect: () => {} });
|
|
314
|
+
await rt.applyChannelDelta('#foo', {
|
|
315
|
+
memberships: [
|
|
316
|
+
{ type: 'add', conn: 'c1', nick: 'alice' },
|
|
317
|
+
{ type: 'add', conn: 'c2', nick: 'bob' },
|
|
318
|
+
],
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
const conns = await rt.getChannelConnections('#foo');
|
|
322
|
+
expect(conns.size).toBe(2);
|
|
323
|
+
expect(conns.get('c1')?.nick).toBe('alice');
|
|
324
|
+
expect(conns.get('c2')?.nick).toBe('bob');
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('returns an empty map for an unknown channel', async () => {
|
|
328
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
329
|
+
expect(await rt.getChannelConnections('#nope')).toEqual(new Map());
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('skips members whose connection is no longer tracked', async () => {
|
|
333
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
334
|
+
rt.registerConnection(makeConn('c1', 'alice'), { send: () => {}, disconnect: () => {} });
|
|
335
|
+
// c2 is referenced by a membership but was never registered (and c1's
|
|
336
|
+
// registration is dropped mid-test to exercise the filter branch).
|
|
337
|
+
await rt.applyChannelDelta('#foo', {
|
|
338
|
+
memberships: [
|
|
339
|
+
{ type: 'add', conn: 'c1', nick: 'alice' },
|
|
340
|
+
{ type: 'add', conn: 'c2', nick: 'bob' },
|
|
341
|
+
],
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const conns = await rt.getChannelConnections('#foo');
|
|
345
|
+
expect(conns.size).toBe(1);
|
|
346
|
+
expect(conns.get('c1')?.nick).toBe('alice');
|
|
347
|
+
expect(conns.has('c2')).toBe(false);
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
describe('InMemoryRuntime — listChannels', () => {
|
|
352
|
+
it('returns an empty array when no channels exist', async () => {
|
|
353
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
354
|
+
expect(await rt.listChannels()).toEqual([]);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('returns a snapshot per channel, preserving original casing', async () => {
|
|
358
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
359
|
+
await rt.applyChannelDelta('#Foo', {
|
|
360
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
361
|
+
});
|
|
362
|
+
await rt.applyChannelDelta('#bar', {
|
|
363
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const list = await rt.listChannels();
|
|
367
|
+
expect(list).toHaveLength(2);
|
|
368
|
+
const names = list.map((c) => c.name).sort();
|
|
369
|
+
expect(names).toEqual(['#Foo', '#bar']);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
258
373
|
describe('InMemoryRuntime — send', () => {
|
|
259
374
|
it('delivers lines to the target connection send handler', async () => {
|
|
260
375
|
const sent: RawLine[][] = [];
|
|
@@ -430,6 +545,28 @@ describe('InMemoryRuntime — sendToNick', () => {
|
|
|
430
545
|
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
431
546
|
await expect(rt.sendToNick('ghost', 'c1', [L('hi')], [L('401')])).resolves.toBeUndefined();
|
|
432
547
|
});
|
|
548
|
+
|
|
549
|
+
it('falls through to notFoundLines when the owner nick is registered but its connection is gone', async () => {
|
|
550
|
+
// Covers the stale-nick defensive branch in sendToNick: `this.nicks`
|
|
551
|
+
// still maps the nick to a connId, but that conn is no longer tracked
|
|
552
|
+
// (unregisterConnection deletes the conn without releasing its nick —
|
|
553
|
+
// the comment on unregisterConnection documents this split).
|
|
554
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
555
|
+
const senderOut: RawLine[][] = [];
|
|
556
|
+
rt.registerConnection(makeConn('c1', 'alice'), { send: () => {}, disconnect: () => {} });
|
|
557
|
+
rt.registerConnection(makeConn('c2', 'bob'), {
|
|
558
|
+
send: (lines) => {
|
|
559
|
+
senderOut.push(lines);
|
|
560
|
+
},
|
|
561
|
+
disconnect: () => {},
|
|
562
|
+
});
|
|
563
|
+
await rt.reserveNick('alice', 'c1');
|
|
564
|
+
// Remove c1 from the connection map while leaving the nick mapping intact.
|
|
565
|
+
rt.unregisterConnection('c1');
|
|
566
|
+
|
|
567
|
+
await rt.sendToNick('alice', 'c2', [L('hi')], [L('401 bob alice :No such nick')]);
|
|
568
|
+
expect(senderOut).toEqual([[{ text: '401 bob alice :No such nick' }]]);
|
|
569
|
+
});
|
|
433
570
|
});
|
|
434
571
|
|
|
435
572
|
describe('InMemoryRuntime — disconnect', () => {
|
|
@@ -634,3 +771,125 @@ describe('InMemoryRuntime — admission gate', () => {
|
|
|
634
771
|
}
|
|
635
772
|
});
|
|
636
773
|
});
|
|
774
|
+
|
|
775
|
+
describe('InMemoryRuntime — reloadConfig (REHASH)', () => {
|
|
776
|
+
const baseConfig: ServerConfig = {
|
|
777
|
+
serverName: 'irc.example.com',
|
|
778
|
+
networkName: 'ExampleNet',
|
|
779
|
+
maxChannelsPerUser: 30,
|
|
780
|
+
maxTargetsPerCommand: 10,
|
|
781
|
+
maxListEntries: 50,
|
|
782
|
+
nickLen: 30,
|
|
783
|
+
channelLen: 50,
|
|
784
|
+
topicLen: 390,
|
|
785
|
+
quitMessage: 'Client Quit',
|
|
786
|
+
};
|
|
787
|
+
|
|
788
|
+
it('re-invokes the bound loader and returns the fresh config', async () => {
|
|
789
|
+
let version = 1;
|
|
790
|
+
const rt = new InMemoryRuntime({
|
|
791
|
+
clock: new FakeClock(0),
|
|
792
|
+
configLoader: async () => ({ ...baseConfig, serverName: `v${version}.example.com` }),
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
const first = await rt.reloadConfig();
|
|
796
|
+
expect(first.serverName).toBe('v1.example.com');
|
|
797
|
+
version = 2;
|
|
798
|
+
const second = await rt.reloadConfig();
|
|
799
|
+
expect(second.serverName).toBe('v2.example.com');
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
it('propagates a loader rejection so the actor can apply the graceful failure path', async () => {
|
|
803
|
+
const rt = new InMemoryRuntime({
|
|
804
|
+
clock: new FakeClock(0),
|
|
805
|
+
configLoader: async () => {
|
|
806
|
+
throw new Error('store unreachable');
|
|
807
|
+
},
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
await expect(rt.reloadConfig()).rejects.toThrow('store unreachable');
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
it('rejects when no loader was bound at construction', async () => {
|
|
814
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
815
|
+
|
|
816
|
+
await expect(rt.reloadConfig()).rejects.toThrow('no config loader bound');
|
|
817
|
+
});
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
describe('InMemoryRuntime — broadcastWallops', () => {
|
|
821
|
+
it('delivers lines to every connection with the +w user mode set', async () => {
|
|
822
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
823
|
+
const received: Record<string, RawLine[][]> = { c1: [], c2: [], c3: [] };
|
|
824
|
+
const push = (id: string) => (lines: RawLine[]) => received[id]?.push(lines);
|
|
825
|
+
// c1 has NO +w; c2 and c3 do.
|
|
826
|
+
rt.registerConnection(makeConn('c1', 'alice'), { send: push('c1'), disconnect: () => {} });
|
|
827
|
+
const c2 = makeConn('c2', 'bob');
|
|
828
|
+
c2.userModes.wallops = true;
|
|
829
|
+
rt.registerConnection(c2, { send: push('c2'), disconnect: () => {} });
|
|
830
|
+
const c3 = makeConn('c3', 'carol');
|
|
831
|
+
c3.userModes.wallops = true;
|
|
832
|
+
rt.registerConnection(c3, { send: push('c3'), disconnect: () => {} });
|
|
833
|
+
|
|
834
|
+
await rt.broadcastWallops([L(':alice WALLOPS :reboot')]);
|
|
835
|
+
|
|
836
|
+
// c1 has no +w → no delivery.
|
|
837
|
+
expect(received.c1).toHaveLength(0);
|
|
838
|
+
expect(received.c2).toHaveLength(1);
|
|
839
|
+
expect(received.c3).toHaveLength(1);
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
it('skips the except connection even when it has +w', async () => {
|
|
843
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
844
|
+
const received: Record<string, RawLine[][]> = { c1: [], c2: [] };
|
|
845
|
+
const push = (id: string) => (lines: RawLine[]) => received[id]?.push(lines);
|
|
846
|
+
const oper = makeConn('c1', 'alice');
|
|
847
|
+
oper.userModes.wallops = true;
|
|
848
|
+
oper.userModes.oper = true;
|
|
849
|
+
rt.registerConnection(oper, { send: push('c1'), disconnect: () => {} });
|
|
850
|
+
const listener = makeConn('c2', 'bob');
|
|
851
|
+
listener.userModes.wallops = true;
|
|
852
|
+
rt.registerConnection(listener, { send: push('c2'), disconnect: () => {} });
|
|
853
|
+
|
|
854
|
+
await rt.broadcastWallops([L(':alice WALLOPS :hi')], 'c1');
|
|
855
|
+
|
|
856
|
+
// The oper (except) never receives their own wallops; the +w peer does.
|
|
857
|
+
expect(received.c1).toHaveLength(0);
|
|
858
|
+
expect(received.c2).toHaveLength(1);
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
it('does not deliver to +w connections that have unregistered', async () => {
|
|
862
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
863
|
+
let c2Received = 0;
|
|
864
|
+
rt.registerConnection(makeConn('c1', 'alice'), {
|
|
865
|
+
send: () => {},
|
|
866
|
+
disconnect: () => {},
|
|
867
|
+
});
|
|
868
|
+
const c2 = makeConn('c2', 'bob');
|
|
869
|
+
c2.userModes.wallops = true;
|
|
870
|
+
rt.registerConnection(c2, {
|
|
871
|
+
send: () => {
|
|
872
|
+
c2Received++;
|
|
873
|
+
},
|
|
874
|
+
disconnect: () => {},
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
rt.unregisterConnection('c2');
|
|
878
|
+
await rt.broadcastWallops([L(':alice WALLOPS :hi')]);
|
|
879
|
+
expect(c2Received).toBe(0);
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
it('is a no-op when no connection has +w', async () => {
|
|
883
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
884
|
+
let c1Received = 0;
|
|
885
|
+
rt.registerConnection(makeConn('c1', 'alice'), {
|
|
886
|
+
send: () => {
|
|
887
|
+
c1Received++;
|
|
888
|
+
},
|
|
889
|
+
disconnect: () => {},
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
await rt.broadcastWallops([L(':a WALLOPS :hi')]);
|
|
893
|
+
expect(c1Received).toBe(0);
|
|
894
|
+
});
|
|
895
|
+
});
|
|
@@ -123,18 +123,18 @@ export class AdmissionStats {
|
|
|
123
123
|
if (rec === undefined) return;
|
|
124
124
|
this.records.delete(recordId);
|
|
125
125
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
126
|
+
// recordAdmission always sets both counters, so they are present for
|
|
127
|
+
// any id found in `records`. The decrement-and-delete-if-zero keeps
|
|
128
|
+
// the maps compact (no zero entries hanging around).
|
|
129
|
+
const ipCount = this.perIpCount.get(rec.ip) as number;
|
|
130
|
+
const ipNext = ipCount - 1;
|
|
131
|
+
if (ipNext <= 0) this.perIpCount.delete(rec.ip);
|
|
132
|
+
else this.perIpCount.set(rec.ip, ipNext);
|
|
133
|
+
|
|
134
|
+
const userCount = this.perUserCount.get(rec.user) as number;
|
|
135
|
+
const userNext = userCount - 1;
|
|
136
|
+
if (userNext <= 0) this.perUserCount.delete(rec.user);
|
|
137
|
+
else this.perUserCount.set(rec.user, userNext);
|
|
138
138
|
}
|
|
139
139
|
|
|
140
140
|
/**
|
|
@@ -147,9 +147,10 @@ export class AdmissionStats {
|
|
|
147
147
|
if (times === undefined || times.length === 0) return 0;
|
|
148
148
|
const cutoff = this.clock.now() - windowMs;
|
|
149
149
|
let writeIdx = 0;
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
150
|
+
// `times` is dense (only ever appended to via push, never spliced), so
|
|
151
|
+
// iterating values via for-of avoids the noUncheckedIndexedAccess trap
|
|
152
|
+
// while still allowing in-place compaction of stale entries.
|
|
153
|
+
for (const t of times) {
|
|
153
154
|
if (t >= cutoff) {
|
|
154
155
|
times[writeIdx++] = t;
|
|
155
156
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The CAP *negotiation reducer* ships separately; this module is the
|
|
5
5
|
* pure-data table that advertises what caps this server supports. PLAN §3
|
|
6
|
-
* lists the v1 cap set; later
|
|
6
|
+
* lists the v1 cap set; later phases implement each cap's behavior.
|
|
7
7
|
*
|
|
8
8
|
* The table is exposed as a frozen, sorted list so the `CAP LS` response
|
|
9
9
|
* (and any future `CAP NEW` push) serializes deterministically.
|
|
@@ -21,29 +21,63 @@ export interface Capability {
|
|
|
21
21
|
value?: string;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/** Wire name of the SASL capability. */
|
|
25
|
+
export const SASL_CAP_NAME = 'sasl';
|
|
26
|
+
|
|
27
|
+
/** Wire name of the IRCv3 `draft/multiline` capability. */
|
|
28
|
+
export const MULTILINE_CAP_NAME = 'draft/multiline';
|
|
29
|
+
|
|
30
|
+
/** Wire name of the IRCv3 `draft/read-marker` capability. */
|
|
31
|
+
export const READ_MARKER_CAP_NAME = 'draft/read-marker';
|
|
32
|
+
|
|
33
|
+
/** Wire name of the IRCv3 `draft/pre-away` capability. */
|
|
34
|
+
export const PRE_AWAY_CAP_NAME = 'draft/pre-away';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Default byte budget advertised in `draft/multiline=<n>` and enforced on
|
|
38
|
+
* every finalized multi-line batch. Mirrors the IRCv3 spec's canonical
|
|
39
|
+
* recommendation; deployments override via `ServerConfig.multilineMaxBytes`.
|
|
40
|
+
*/
|
|
41
|
+
export const DEFAULT_MULTILINE_MAX_BYTES = 4096;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Builds the `draft/multiline` cap *value* (the substring after `=`) for the
|
|
45
|
+
* advertised byte budget. The {@link CapReducer} and the multi-line reducer
|
|
46
|
+
* share this helper so the wire token has one source of truth.
|
|
47
|
+
*/
|
|
48
|
+
export function multilineCapValue(maxBytes: number): string {
|
|
49
|
+
return String(maxBytes);
|
|
50
|
+
}
|
|
51
|
+
|
|
24
52
|
/**
|
|
25
53
|
* Every cap advertised by this server in PLAN §3, in canonical (alpha) order
|
|
26
54
|
* so wire output is deterministic across runs.
|
|
27
55
|
*/
|
|
28
56
|
export const SUPPORTED_CAPABILITIES: ReadonlyArray<Capability> = Object.freeze([
|
|
57
|
+
{ name: 'account-notify' },
|
|
29
58
|
{ name: 'account-tag' },
|
|
30
59
|
{ name: 'away-notify' },
|
|
31
60
|
{ name: 'batch' },
|
|
32
61
|
{ name: 'chghost' },
|
|
33
62
|
{ name: 'draft/chathistory' },
|
|
63
|
+
{ name: 'draft/multiline', value: multilineCapValue(DEFAULT_MULTILINE_MAX_BYTES) },
|
|
64
|
+
{ name: 'draft/pre-away' },
|
|
65
|
+
{ name: 'draft/read-marker' },
|
|
66
|
+
{ name: 'draft/typing' },
|
|
34
67
|
{ name: 'echo-message' },
|
|
35
68
|
{ name: 'extended-join' },
|
|
36
69
|
{ name: 'invite-notify' },
|
|
70
|
+
{ name: 'labeled-response' },
|
|
37
71
|
{ name: 'message-tags' },
|
|
72
|
+
{ name: 'msgid' },
|
|
73
|
+
{ name: 'monitor' },
|
|
38
74
|
{ name: 'multi-prefix' },
|
|
39
75
|
{ name: 'safelist' },
|
|
40
76
|
{ name: 'sasl', value: 'PLAIN' },
|
|
41
77
|
{ name: 'server-time' },
|
|
78
|
+
{ name: 'standard-replies' },
|
|
42
79
|
]);
|
|
43
80
|
|
|
44
|
-
/** Wire name of the SASL capability. */
|
|
45
|
-
export const SASL_CAP_NAME = 'sasl';
|
|
46
|
-
|
|
47
81
|
/**
|
|
48
82
|
* Returns the `sasl` cap value to advertise given whether an mTLS identity
|
|
49
83
|
* provider is bound for the connection.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IRCv3 `sts` (Strict Transport Security) policy.
|
|
3
|
+
*
|
|
4
|
+
* Spec: https://ircv3.net/specs/extensions/sts
|
|
5
|
+
*
|
|
6
|
+
* The server advertises an `sts` capability whose value is a
|
|
7
|
+
* comma-separated `key=value` policy. Compliant clients inspect the
|
|
8
|
+
* policy and:
|
|
9
|
+
*
|
|
10
|
+
* - On a plaintext connection: reconnect over TLS to the advertised
|
|
11
|
+
* `port` and pin it for `duration` seconds. `preload` (optional)
|
|
12
|
+
* signals the client may hard-code the policy into a preload list.
|
|
13
|
+
* - On a TLS connection: only the `duration` is locked in — `port`
|
|
14
|
+
* is meaningless once the client is already on the secure listener,
|
|
15
|
+
* and `preload` is an upgrade-time directive.
|
|
16
|
+
*
|
|
17
|
+
* Config-driven only: there is no per-connection protocol state. The
|
|
18
|
+
* deployment supplies the policy through {@link StsConfig} on
|
|
19
|
+
* {@link ServerConfig}; when omitted entirely the cap is not advertised.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* IRC-over-TLS port per RFC 7193 / IRCv3 STS spec examples. Used as the
|
|
24
|
+
* fallback `port` when a deployment turns on STS without pinning a port.
|
|
25
|
+
*/
|
|
26
|
+
export const DEFAULT_STS_PORT = 6697;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Default pin duration (30 days, in seconds). Mirrors the IRCv3 spec's
|
|
30
|
+
* canonical example and what mainstream IRC networks publish. Deployments
|
|
31
|
+
* override via {@link StsConfig.duration}.
|
|
32
|
+
*/
|
|
33
|
+
export const DEFAULT_STS_DURATION_SECONDS = 30 * 24 * 60 * 60;
|
|
34
|
+
|
|
35
|
+
/** Wire name of the STS capability. */
|
|
36
|
+
export const STS_CAP_NAME = 'sts';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Raw STS policy as supplied by {@link ServerConfig}. Every field is
|
|
40
|
+
* optional; {@link stsCapValue} applies the documented defaults when a
|
|
41
|
+
* field is omitted. `undefined` (the whole object) disables STS.
|
|
42
|
+
*
|
|
43
|
+
* Optional fields are widened to `T | undefined` to mirror Zod's
|
|
44
|
+
* `.optional()` output under `exactOptionalPropertyTypes: true` so the
|
|
45
|
+
* parsed config is structurally assignable here.
|
|
46
|
+
*/
|
|
47
|
+
export interface StsPolicy {
|
|
48
|
+
/** Pin duration in seconds. Defaults to 30 days. */
|
|
49
|
+
readonly duration?: number;
|
|
50
|
+
/** TLS listener port advertised on plaintext connections. Defaults to 6697. */
|
|
51
|
+
readonly port?: number;
|
|
52
|
+
/** Optional preload directive (plaintext advertisements only). */
|
|
53
|
+
readonly preload?: boolean | undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Builds the IRCv3 `sts` capability *value* (the substring after `sts=`)
|
|
58
|
+
* for the given policy and transport.
|
|
59
|
+
*
|
|
60
|
+
* Returns `undefined` when STS is disabled (`policy` undefined), so the cap
|
|
61
|
+
* table consumer can omit the token entirely from `CAP LS`.
|
|
62
|
+
*
|
|
63
|
+
* On a plaintext transport the full policy is advertised:
|
|
64
|
+
* `duration=<seconds>,port=<tls-port>[,preload]`
|
|
65
|
+
*
|
|
66
|
+
* On a TLS transport only `duration=<seconds>` is sent — the port is
|
|
67
|
+
* meaningless (the client is already on the secure listener) and
|
|
68
|
+
* `preload` is an upgrade-time directive that does not apply.
|
|
69
|
+
*
|
|
70
|
+
* Missing `duration`/`port` fall back to {@link DEFAULT_STS_DURATION_SECONDS}
|
|
71
|
+
* and {@link DEFAULT_STS_PORT} so a deployment can enable STS with a bare
|
|
72
|
+
* `{}` policy and still publish a sensible value.
|
|
73
|
+
*/
|
|
74
|
+
export function stsCapValue(policy: StsPolicy | undefined, secure: boolean): string | undefined {
|
|
75
|
+
if (policy === undefined) return undefined;
|
|
76
|
+
const duration = policy.duration ?? DEFAULT_STS_DURATION_SECONDS;
|
|
77
|
+
if (secure) {
|
|
78
|
+
return `duration=${duration}`;
|
|
79
|
+
}
|
|
80
|
+
const port = policy.port ?? DEFAULT_STS_PORT;
|
|
81
|
+
const parts = [`duration=${duration}`, `port=${port}`];
|
|
82
|
+
if (policy.preload === true) parts.push('preload');
|
|
83
|
+
return parts.join(',');
|
|
84
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Effect } from '../effects.js';
|
|
2
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
3
|
+
import type { ChanName } from '../state/channel.js';
|
|
4
|
+
import { hostmaskOf } from '../state/connection.js';
|
|
5
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
6
|
+
|
|
7
|
+
export interface AccountNotifyInput {
|
|
8
|
+
conn: ConnectionState;
|
|
9
|
+
/**
|
|
10
|
+
* New account name on login. Pass `undefined` to signal a logout, which
|
|
11
|
+
* serializes the account field as `*` per IRCv3 `account-notify`.
|
|
12
|
+
*/
|
|
13
|
+
account?: string | undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Builds the cap-split Broadcast effects that announce an `ACCOUNT` login
|
|
18
|
+
* (or logout) to peers in every shared channel.
|
|
19
|
+
*
|
|
20
|
+
* Mirrors the `emitChghost` broadcast helper: each joined channel produces
|
|
21
|
+
* one {@link Effect.broadcast} carrying empty `lines` (so non-cap peers
|
|
22
|
+
* receive nothing) and the `ACCOUNT` line in `capLines` for peers that
|
|
23
|
+
* negotiated `account-notify`. The sender is excluded via `except` unless
|
|
24
|
+
* they negotiated `echo-message`, in which case a single echo
|
|
25
|
+
* {@link Effect.send} is appended (one echo regardless of how many shared
|
|
26
|
+
* channels the user occupies).
|
|
27
|
+
*
|
|
28
|
+
* Returns `[]` before registration completes or when the connection holds
|
|
29
|
+
* no joined channels — there is no audience to notify.
|
|
30
|
+
*/
|
|
31
|
+
export function emitAccountNotify(input: AccountNotifyInput): EffectType[] {
|
|
32
|
+
const { conn, account } = input;
|
|
33
|
+
|
|
34
|
+
if (conn.registration !== 'registered') return [];
|
|
35
|
+
if (conn.joinedChannels.size === 0) return [];
|
|
36
|
+
|
|
37
|
+
const source = hostmaskOf(conn) ?? conn.nick ?? '?';
|
|
38
|
+
const accountField = account ?? '*';
|
|
39
|
+
const line: RawLine = { text: `:${source} ACCOUNT ${accountField}` };
|
|
40
|
+
|
|
41
|
+
const effects: EffectType[] = [];
|
|
42
|
+
for (const chan of conn.joinedChannels) {
|
|
43
|
+
effects.push(Effect.broadcast(chan as ChanName, [], conn.id, 'account-notify', [line]));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// IRCv3 echo-message: the sender observes its own ACCOUNT change exactly
|
|
47
|
+
// once, regardless of how many channels it shares with peers.
|
|
48
|
+
if (conn.caps.has('echo-message')) {
|
|
49
|
+
effects.push(Effect.send(conn.id, [line]));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return effects;
|
|
53
|
+
}
|
|
@@ -27,6 +27,7 @@ import type { ChanName } from '../state/channel.js';
|
|
|
27
27
|
import { hostmaskOf } from '../state/connection.js';
|
|
28
28
|
import type { ConnectionState } from '../state/connection.js';
|
|
29
29
|
import type { Reducer } from '../types.js';
|
|
30
|
+
import { clearPersistedAway, persistAway } from './pre-away.js';
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
33
|
* Handles `AWAY [:<reason>]`.
|
|
@@ -45,7 +46,10 @@ export const awayReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
|
45
46
|
const nick = state.nick ?? '*';
|
|
46
47
|
|
|
47
48
|
if (isSettingAway) {
|
|
48
|
-
|
|
49
|
+
// IRCv3 draft/pre-away: persist the reason keyed by account when an
|
|
50
|
+
// AwayStore is bound and the connection is identified. The in-memory
|
|
51
|
+
// away is also set so the current session tracks the new reason.
|
|
52
|
+
persistAway(ctx, reason as string);
|
|
49
53
|
effects.push(
|
|
50
54
|
Effect.send(ctx.connId, [
|
|
51
55
|
{
|
|
@@ -54,8 +58,10 @@ export const awayReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
|
54
58
|
]),
|
|
55
59
|
);
|
|
56
60
|
} else {
|
|
57
|
-
//
|
|
58
|
-
|
|
61
|
+
// IRCv3 draft/pre-away: clear the persisted reason on unset so a
|
|
62
|
+
// reconnect does not re-mark the user away. The in-memory away is
|
|
63
|
+
// also cleared so the session's view stays consistent.
|
|
64
|
+
clearPersistedAway(ctx);
|
|
59
65
|
effects.push(
|
|
60
66
|
Effect.send(ctx.connId, [
|
|
61
67
|
{
|