serverless-ircd 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +96 -2
- package/.github/workflows/deploy-aws.yml +129 -0
- package/.github/workflows/deploy-cf.yml +0 -2
- package/.gitmodules +3 -0
- package/AGENTS.md +5 -0
- package/CHANGELOG.md +352 -0
- package/MOTD.txt +3 -0
- package/PLAN-FIXES.md +358 -0
- package/PLAN.md +420 -0
- package/README.md +115 -81
- package/apps/aws-stack/README.md +73 -0
- package/apps/aws-stack/bin/aws.ts +49 -0
- package/apps/aws-stack/cdk.json +10 -0
- package/apps/aws-stack/package.json +41 -0
- package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
- package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
- package/apps/aws-stack/scripts/smoke.mjs +142 -0
- package/apps/aws-stack/src/aws-stack.ts +263 -0
- package/apps/aws-stack/src/tables.ts +10 -0
- package/apps/aws-stack/tests/localstack.test.ts +46 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
- package/apps/aws-stack/tests/stack.test.ts +464 -0
- package/apps/aws-stack/tsconfig.build.json +11 -0
- package/apps/aws-stack/tsconfig.test.json +8 -0
- package/apps/aws-stack/vitest.config.ts +20 -0
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/wrangler.toml +25 -0
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +56 -0
- package/apps/local-cli/src/server.ts +241 -32
- package/apps/local-cli/tests/config-loader.test.ts +107 -0
- package/apps/local-cli/tests/e2e.test.ts +74 -0
- package/apps/local-cli/tests/security-e2e.test.ts +239 -0
- package/biome.json +3 -1
- package/dashboards/cloudwatch-irc.json +139 -0
- package/docs/AWS-Adapter-Architecture.md +494 -0
- package/docs/AWS-Deployment.md +1107 -0
- package/docs/Cloudflare-Deployment-Guide.md +660 -0
- package/docs/Home.md +1 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanIRCv3Websocket.md +489 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +440 -0
- package/package.json +8 -2
- package/packages/aws-adapter/README.md +35 -0
- package/packages/aws-adapter/package.json +52 -0
- package/packages/aws-adapter/src/aws-runtime.ts +783 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +69 -0
- package/packages/aws-adapter/src/config-loader.ts +96 -0
- package/packages/aws-adapter/src/dynamo.ts +61 -0
- package/packages/aws-adapter/src/handlers/connect.ts +44 -0
- package/packages/aws-adapter/src/handlers/default.ts +322 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +29 -0
- package/packages/aws-adapter/src/handlers/index.ts +248 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
- package/packages/aws-adapter/src/handlers/state.ts +13 -0
- package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
- package/packages/aws-adapter/src/index.ts +67 -0
- package/packages/aws-adapter/src/message-store.ts +34 -0
- package/packages/aws-adapter/src/serialize.ts +283 -0
- package/packages/aws-adapter/src/tables.ts +53 -0
- package/packages/aws-adapter/tests/aws-harness.ts +408 -0
- package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
- package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +158 -0
- package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
- package/packages/aws-adapter/tests/global-setup.ts +301 -0
- package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
- package/packages/aws-adapter/tests/handlers.test.ts +427 -0
- package/packages/aws-adapter/tests/message-store.test.ts +55 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
- package/packages/aws-adapter/tests/serialize.test.ts +249 -0
- package/packages/aws-adapter/tests/smoke.test.ts +48 -0
- package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
- package/packages/aws-adapter/tests/tables.test.ts +74 -0
- package/packages/aws-adapter/tests/transactions.test.ts +446 -0
- package/packages/aws-adapter/tsconfig.build.json +16 -0
- package/packages/aws-adapter/tsconfig.test.json +14 -0
- package/packages/aws-adapter/vitest.config.ts +23 -0
- package/packages/cf-adapter/package.json +2 -1
- package/packages/cf-adapter/src/config-loader.ts +106 -0
- package/packages/cf-adapter/src/connection-do.ts +34 -0
- package/packages/cf-adapter/tests/cf-harness.ts +2 -2
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
- package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +42 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +108 -3
- package/packages/in-memory-runtime/src/index.ts +1 -1
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +115 -0
- package/packages/irc-core/package.json +12 -2
- package/packages/irc-core/src/admission.ts +216 -0
- package/packages/irc-core/src/caps/capabilities.ts +1 -0
- package/packages/irc-core/src/cloak.ts +81 -0
- package/packages/irc-core/src/commands/cap.ts +1 -2
- package/packages/irc-core/src/commands/chathistory.ts +305 -0
- package/packages/irc-core/src/commands/index.ts +7 -0
- package/packages/irc-core/src/commands/join.ts +59 -7
- package/packages/irc-core/src/commands/privmsg.ts +24 -0
- package/packages/irc-core/src/commands/registration.ts +71 -14
- package/packages/irc-core/src/commands/sasl.ts +251 -0
- package/packages/irc-core/src/config.ts +247 -0
- package/packages/irc-core/src/flood-control.ts +175 -0
- package/packages/irc-core/src/index.ts +5 -0
- package/packages/irc-core/src/ports.ts +655 -0
- package/packages/irc-core/src/protocol/base64.ts +16 -0
- package/packages/irc-core/src/protocol/index.ts +2 -1
- package/packages/irc-core/src/protocol/numerics.ts +11 -0
- package/packages/irc-core/src/protocol/parser.ts +27 -2
- package/packages/irc-core/src/state/connection.ts +41 -0
- package/packages/irc-core/src/types.ts +66 -2
- package/packages/irc-core/stryker.commands.conf.json +41 -0
- package/packages/irc-core/stryker.protocol.conf.json +26 -0
- package/packages/irc-core/tests/admission.test.ts +229 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
- package/packages/irc-core/tests/cloak.test.ts +78 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
- package/packages/irc-core/tests/commands/join.test.ts +246 -2
- package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
- package/packages/irc-core/tests/commands/registration.test.ts +277 -9
- package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
- package/packages/irc-core/tests/config.test.ts +646 -0
- package/packages/irc-core/tests/flood-control.test.ts +394 -0
- package/packages/irc-core/tests/message-store.test.ts +530 -0
- package/packages/irc-core/tests/parser.test.ts +44 -1
- package/packages/irc-core/tests/ports.test.ts +263 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +162 -44
- package/packages/irc-server/src/dispatch.ts +89 -5
- package/packages/irc-server/src/index.ts +2 -0
- package/packages/irc-server/src/routing.ts +151 -0
- package/packages/irc-server/tests/actor.test.ts +470 -14
- package/packages/irc-server/tests/dispatch.test.ts +84 -0
- package/packages/irc-server/tests/routing.test.ts +201 -0
- package/packages/irc-test-support/README.md +32 -0
- package/packages/irc-test-support/package.json +37 -0
- package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +1 -1
- package/packages/irc-test-support/src/index.ts +24 -0
- package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
- package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
- package/packages/irc-test-support/tests/smoke.test.ts +11 -0
- package/packages/irc-test-support/tsconfig.build.json +16 -0
- package/packages/irc-test-support/tsconfig.test.json +14 -0
- package/packages/irc-test-support/vitest.config.ts +20 -0
- package/progress.md +107 -0
- package/tickets.md +2485 -0
- package/tools/ci-hardening/package.json +30 -0
- package/tools/ci-hardening/src/index.ts +6 -0
- package/tools/ci-hardening/src/validate.ts +103 -0
- package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
- package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
- package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
- package/tools/ci-hardening/tests/validate.test.ts +177 -0
- package/tools/ci-hardening/tsconfig.build.json +12 -0
- package/tools/ci-hardening/tsconfig.test.json +10 -0
- package/tools/ci-hardening/vitest.config.ts +21 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
- package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
- package/tools/tcp-ws-forwarder/src/main.ts +33 -14
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
- package/webircgateway/LICENSE +201 -0
- package/webircgateway/Makefile +44 -0
- package/webircgateway/README.md +134 -0
- package/webircgateway/config.conf.example +135 -0
- package/webircgateway/go.mod +16 -0
- package/webircgateway/go.sum +89 -0
- package/webircgateway/main.go +118 -0
- package/webircgateway/pkg/dnsbl/dnsbl.go +121 -0
- package/webircgateway/pkg/identd/identd.go +86 -0
- package/webircgateway/pkg/identd/rpcclient.go +59 -0
- package/webircgateway/pkg/irc/isupport.go +56 -0
- package/webircgateway/pkg/irc/message.go +217 -0
- package/webircgateway/pkg/irc/state.go +79 -0
- package/webircgateway/pkg/proxy/proxy.go +129 -0
- package/webircgateway/pkg/proxy/server.go +237 -0
- package/webircgateway/pkg/recaptcha/recaptcha.go +59 -0
- package/webircgateway/pkg/webircgateway/client.go +741 -0
- package/webircgateway/pkg/webircgateway/client_command_handlers.go +495 -0
- package/webircgateway/pkg/webircgateway/config.go +385 -0
- package/webircgateway/pkg/webircgateway/gateway.go +278 -0
- package/webircgateway/pkg/webircgateway/gateway_utils.go +133 -0
- package/webircgateway/pkg/webircgateway/hooks.go +152 -0
- package/webircgateway/pkg/webircgateway/letsencrypt.go +41 -0
- package/webircgateway/pkg/webircgateway/messagetags.go +103 -0
- package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +206 -0
- package/webircgateway/pkg/webircgateway/transport_sockjs.go +107 -0
- package/webircgateway/pkg/webircgateway/transport_tcp.go +113 -0
- package/webircgateway/pkg/webircgateway/transport_websocket.go +126 -0
- package/webircgateway/pkg/webircgateway/utils.go +147 -0
- package/webircgateway/plugins/example/plugin.go +11 -0
- package/webircgateway/plugins/stats/plugin.go +52 -0
- package/webircgateway/staticcheck.conf +1 -0
- package/webircgateway/webircgateway.svg +3 -0
- package/packages/cf-adapter/tests/integration/index.ts +0 -17
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection-admission gate — pure policy + stateful counter.
|
|
3
|
+
*
|
|
4
|
+
* Guards the connection acceptance path against three classes of abuse:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Per-IP connection cap** (`maxConnectionsPerIp`): caps how many
|
|
7
|
+
* simultaneous connections a single source IP may hold. Distinct
|
|
8
|
+
* from per-connection flood control: this is the "how many sockets
|
|
9
|
+
*. at once" gate, not the "how many commands per socket" gate.
|
|
10
|
+
*
|
|
11
|
+
* 2. **Per-user connection cap** (`maxConnectionsPerUser`): same idea,
|
|
12
|
+
* keyed by SASL account name when available, falling back to the
|
|
13
|
+
* source IP when the connection has not authenticated.
|
|
14
|
+
*
|
|
15
|
+
* 3. **Per-IP connection-rate limit** (`perIpConnectionRate`): caps how
|
|
16
|
+
* fast a single source IP may open new connections within a sliding
|
|
17
|
+
* window. Defends against connection-flood attacks that recycle
|
|
18
|
+
* sockets to evade the per-IP cap.
|
|
19
|
+
*
|
|
20
|
+
* The policy itself is a pure function {@link decideAdmission}; the
|
|
21
|
+
* accounting state lives in {@link AdmissionStats}, which is injected so
|
|
22
|
+
* tests can pin the timeline via {@link FakeClock} and inspect state in
|
|
23
|
+
* isolation. Both adapters (in-memory runtime, CF / AWS) construct one
|
|
24
|
+
* `AdmissionStats` per process / per worker invocation and share it
|
|
25
|
+
* across every connection acceptance decision.
|
|
26
|
+
*
|
|
27
|
+
* Determinism: same Clock + same call stream → same decisions. There are
|
|
28
|
+
* no ambient inputs.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { Clock } from './ports.js';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Static policy consulted by {@link decideAdmission}. Adapters source the
|
|
35
|
+
* values from `ServerConfig` but the gate consumes this narrow shape so
|
|
36
|
+
* the unit tests do not need to construct a full config.
|
|
37
|
+
*/
|
|
38
|
+
export interface AdmissionConfig {
|
|
39
|
+
/** Maximum simultaneous connections from a single source IP. */
|
|
40
|
+
readonly maxConnectionsPerIp: number;
|
|
41
|
+
/** Maximum simultaneous connections from a single authenticated user. */
|
|
42
|
+
readonly maxConnectionsPerUser: number;
|
|
43
|
+
/** Per-IP connection-rate limit (sliding window). */
|
|
44
|
+
readonly perIpConnectionRate: { readonly max: number; readonly windowMs: number };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Positive admission outcome — caller should {@link AdmissionStats.recordAdmission}. */
|
|
48
|
+
export interface AdmitDecision {
|
|
49
|
+
readonly ok: true;
|
|
50
|
+
/**
|
|
51
|
+
* Opaque record id handed back to {@link AdmissionStats.release} when the
|
|
52
|
+
* connection terminates. Unique per admission.
|
|
53
|
+
*/
|
|
54
|
+
readonly recordId: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Negative admission outcome — the caller MUST close the connection. */
|
|
58
|
+
export interface RejectDecision {
|
|
59
|
+
readonly ok: false;
|
|
60
|
+
/** Machine-readable reason code for logs / metrics / `Disconnect` effect. */
|
|
61
|
+
readonly reason: AdmissionRejectReason;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type AdmissionRejectReason =
|
|
65
|
+
| 'max_connections_per_ip'
|
|
66
|
+
| 'max_connections_per_user'
|
|
67
|
+
| 'rate_limited_per_ip';
|
|
68
|
+
|
|
69
|
+
export type AdmissionDecision = AdmitDecision | RejectDecision;
|
|
70
|
+
|
|
71
|
+
/** Per-IP / per-user / rate counters. Owned by the runtime / adapter. */
|
|
72
|
+
export class AdmissionStats {
|
|
73
|
+
private readonly clock: Clock;
|
|
74
|
+
private readonly perIpCount = new Map<string, number>();
|
|
75
|
+
private readonly perUserCount = new Map<string, number>();
|
|
76
|
+
/**
|
|
77
|
+
* Per-IP timestamps of recent admissions, used by the rate-limit check.
|
|
78
|
+
* Stored ascending; entries older than the window are pruned lazily.
|
|
79
|
+
*/
|
|
80
|
+
private readonly perIpAdmitTimes = new Map<string, number[]>();
|
|
81
|
+
/** Reverse index: recordId → { ip, user }, used by {@link release}. */
|
|
82
|
+
private readonly records = new Map<string, { ip: string; user: string }>();
|
|
83
|
+
private nextRecordId = 1;
|
|
84
|
+
|
|
85
|
+
constructor(clock: Clock) {
|
|
86
|
+
this.clock = clock;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Returns the current per-IP connection count. Exposed for diagnostics
|
|
91
|
+
* and tests; not used by {@link decideAdmission}.
|
|
92
|
+
*/
|
|
93
|
+
countForIp(ip: string): number {
|
|
94
|
+
return this.perIpCount.get(ip) ?? 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Returns the current per-user connection count. */
|
|
98
|
+
countForUser(user: string): number {
|
|
99
|
+
return this.perUserCount.get(user) ?? 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Records an accepted admission. MUST be called exactly once per
|
|
104
|
+
* successful {@link decideAdmission} so the counters stay accurate.
|
|
105
|
+
*/
|
|
106
|
+
recordAdmission(ip: string, user: string | undefined, recordId: string): void {
|
|
107
|
+
const effectiveUser = user ?? ip;
|
|
108
|
+
this.perIpCount.set(ip, (this.perIpCount.get(ip) ?? 0) + 1);
|
|
109
|
+
this.perUserCount.set(effectiveUser, (this.perUserCount.get(effectiveUser) ?? 0) + 1);
|
|
110
|
+
const now = this.clock.now();
|
|
111
|
+
const times = this.perIpAdmitTimes.get(ip) ?? [];
|
|
112
|
+
times.push(now);
|
|
113
|
+
this.perIpAdmitTimes.set(ip, times);
|
|
114
|
+
this.records.set(recordId, { ip, user: effectiveUser });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Releases a previously-recorded admission, decrementing the relevant
|
|
119
|
+
* counters. Idempotent: releasing an unknown id is a no-op.
|
|
120
|
+
*/
|
|
121
|
+
release(recordId: string): void {
|
|
122
|
+
const rec = this.records.get(recordId);
|
|
123
|
+
if (rec === undefined) return;
|
|
124
|
+
this.records.delete(recordId);
|
|
125
|
+
|
|
126
|
+
const ipCount = this.perIpCount.get(rec.ip);
|
|
127
|
+
if (ipCount !== undefined) {
|
|
128
|
+
const next = ipCount - 1;
|
|
129
|
+
if (next <= 0) this.perIpCount.delete(rec.ip);
|
|
130
|
+
else this.perIpCount.set(rec.ip, next);
|
|
131
|
+
}
|
|
132
|
+
const userCount = this.perUserCount.get(rec.user);
|
|
133
|
+
if (userCount !== undefined) {
|
|
134
|
+
const next = userCount - 1;
|
|
135
|
+
if (next <= 0) this.perUserCount.delete(rec.user);
|
|
136
|
+
else this.perUserCount.set(rec.user, next);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Returns the number of admissions recorded for `ip` within the last
|
|
142
|
+
* `windowMs` milliseconds (inclusive of the moment returned by
|
|
143
|
+
* `clock.now()`). Stale entries are pruned as a side effect.
|
|
144
|
+
*/
|
|
145
|
+
recentAdmissions(ip: string, windowMs: number): number {
|
|
146
|
+
const times = this.perIpAdmitTimes.get(ip);
|
|
147
|
+
if (times === undefined || times.length === 0) return 0;
|
|
148
|
+
const cutoff = this.clock.now() - windowMs;
|
|
149
|
+
let writeIdx = 0;
|
|
150
|
+
for (let i = 0; i < times.length; i++) {
|
|
151
|
+
const t = times[i];
|
|
152
|
+
if (t === undefined) continue;
|
|
153
|
+
if (t >= cutoff) {
|
|
154
|
+
times[writeIdx++] = t;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (writeIdx === 0) {
|
|
158
|
+
this.perIpAdmitTimes.delete(ip);
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
times.length = writeIdx;
|
|
162
|
+
return writeIdx;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Allocates the next unique record id. Public so {@link decideAdmission}
|
|
167
|
+
* can mint ids without breaking encapsulation.
|
|
168
|
+
*/
|
|
169
|
+
nextRecord(): string {
|
|
170
|
+
const id = this.nextRecordId.toString(36);
|
|
171
|
+
this.nextRecordId += 1;
|
|
172
|
+
return id;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Decides whether a new connection should be admitted.
|
|
178
|
+
*
|
|
179
|
+
* The check is performed in a fixed precedence order so the rejection
|
|
180
|
+
* reason is deterministic:
|
|
181
|
+
* 1. Per-IP connection-rate limit (cheapest to test).
|
|
182
|
+
* 2. Per-IP simultaneous connection cap.
|
|
183
|
+
* 3. Per-user simultaneous connection cap.
|
|
184
|
+
*
|
|
185
|
+
* On success the caller MUST call {@link AdmissionStats.recordAdmission}
|
|
186
|
+
* with the returned `recordId` before admitting any further connection;
|
|
187
|
+
* on failure the caller MUST close the connection.
|
|
188
|
+
*
|
|
189
|
+
* The decision is pure with respect to `(ip, user, stats, config)` — same
|
|
190
|
+
* inputs yield the same outcome, including the assigned `recordId`.
|
|
191
|
+
*/
|
|
192
|
+
export function decideAdmission(
|
|
193
|
+
ip: string,
|
|
194
|
+
user: string | undefined,
|
|
195
|
+
stats: AdmissionStats,
|
|
196
|
+
config: AdmissionConfig,
|
|
197
|
+
): AdmissionDecision {
|
|
198
|
+
// 1. Rate limit (per-IP, sliding window).
|
|
199
|
+
const recent = stats.recentAdmissions(ip, config.perIpConnectionRate.windowMs);
|
|
200
|
+
if (recent >= config.perIpConnectionRate.max) {
|
|
201
|
+
return { ok: false, reason: 'rate_limited_per_ip' };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 2. Per-IP cap.
|
|
205
|
+
if (stats.countForIp(ip) >= config.maxConnectionsPerIp) {
|
|
206
|
+
return { ok: false, reason: 'max_connections_per_ip' };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 3. Per-user cap (falls back to IP when no SASL identity is present).
|
|
210
|
+
const effectiveUser = user ?? ip;
|
|
211
|
+
if (stats.countForUser(effectiveUser) >= config.maxConnectionsPerUser) {
|
|
212
|
+
return { ok: false, reason: 'max_connections_per_user' };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return { ok: true, recordId: stats.nextRecord() };
|
|
216
|
+
}
|
|
@@ -26,6 +26,7 @@ export const SUPPORTED_CAPABILITIES: ReadonlyArray<Capability> = Object.freeze([
|
|
|
26
26
|
{ name: 'away-notify' },
|
|
27
27
|
{ name: 'batch' },
|
|
28
28
|
{ name: 'chghost' },
|
|
29
|
+
{ name: 'draft/chathistory' },
|
|
29
30
|
{ name: 'echo-message' },
|
|
30
31
|
{ name: 'extended-join' },
|
|
31
32
|
{ name: 'invite-notify' },
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hostmask cloaking — replaces a client's real source IP with a deterministic
|
|
3
|
+
* opaque identifier derived from the deployment secret + the IP.
|
|
4
|
+
*
|
|
5
|
+
* Goals (matching mainstream IRC daemon behaviour — charybdis / UnrealIRCd):
|
|
6
|
+
* - The real IP is never revealed on the wire (in WHOIS, hostmasks, etc.).
|
|
7
|
+
* - The same IP + secret always yields the same cloak (so per-IP
|
|
8
|
+
* reputation/line enforcement still works).
|
|
9
|
+
* - Two different IPs almost never collide.
|
|
10
|
+
* - The cloak fits in a single DNS-label-safe segment.
|
|
11
|
+
*
|
|
12
|
+
* Implementation note: this uses a self-contained FNV-1a 32-bit hash plus a
|
|
13
|
+
* short base32 encoding so the module stays pure-TS and runs unchanged on
|
|
14
|
+
* Node 20, Cloudflare Workers, and Lambda. It is NOT a cryptographic MAC:
|
|
15
|
+
* an attacker who learns one cloak cannot recover the secret, but a
|
|
16
|
+
* determined attacker with the secret can forge cloaks. Deployments that
|
|
17
|
+
* need stronger unforgeability should override this module.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { CloakingConfig } from './types.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Returns the visible host for `realHost` after applying the deployment's
|
|
24
|
+
* cloaking policy.
|
|
25
|
+
*
|
|
26
|
+
* - When `config.enabled === false`, `realHost` is returned verbatim.
|
|
27
|
+
* - When `config.enabled === true`, a deterministic cloak of the form
|
|
28
|
+
* `<hash>.<suffix>` is returned. The `<hash>` segment is derived from
|
|
29
|
+
* `secret` + `realHost`; the `<suffix>` defaults to `defaultSuffix`
|
|
30
|
+
* (typically the deployment's `networkName` lowercased) and may be
|
|
31
|
+
* overridden via `config.cloakedSuffix`.
|
|
32
|
+
*
|
|
33
|
+
* The function is pure — same inputs always yield the same output. There
|
|
34
|
+
* is no ambient randomness or clock.
|
|
35
|
+
*
|
|
36
|
+
* @param realHost The connection's real source IP (or any host string).
|
|
37
|
+
* @param config Cloaking policy. See {@link CloakingConfig}.
|
|
38
|
+
* @param defaultSuffix Suffix used when `config.cloakedSuffix` is absent.
|
|
39
|
+
*/
|
|
40
|
+
export function cloakHost(realHost: string, config: CloakingConfig, defaultSuffix: string): string {
|
|
41
|
+
if (!config.enabled) return realHost;
|
|
42
|
+
const suffix = config.cloakedSuffix ?? defaultSuffix;
|
|
43
|
+
// 64-bit hash assembled from two FNV-1a passes with different seeds so
|
|
44
|
+
// distinct IPs collide with probability ~ 1 / 2^64.
|
|
45
|
+
const hi = fnv1a32(`a:${config.secret}:${realHost}`);
|
|
46
|
+
const lo = fnv1a32(`b:${config.secret}:${realHost}`);
|
|
47
|
+
return `${base32(hi)}${base32(lo)}.${suffix}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* FNV-1a 32-bit hash. Self-contained: no Node `crypto`, works on every
|
|
52
|
+
* serverless runtime. Output is an unsigned 32-bit integer.
|
|
53
|
+
*/
|
|
54
|
+
function fnv1a32(input: string): number {
|
|
55
|
+
let h = 0x811c9dc5;
|
|
56
|
+
for (let i = 0; i < input.length; i++) {
|
|
57
|
+
h ^= input.charCodeAt(i);
|
|
58
|
+
// 32-bit multiply by FNV prime using Math.imul to stay in u32 space.
|
|
59
|
+
h = Math.imul(h, 0x01000193);
|
|
60
|
+
}
|
|
61
|
+
// Force unsigned.
|
|
62
|
+
return h >>> 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Lowercase base32 (RFC 4648) encoding without padding, suitable for a
|
|
67
|
+
* DNS-safe identifier. Encodes 5 bits per character.
|
|
68
|
+
*/
|
|
69
|
+
const BASE32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567';
|
|
70
|
+
|
|
71
|
+
function base32(value: number): string {
|
|
72
|
+
// 32 bits -> 7 base32 chars (ceil(32/5) = 7 with one bit of padding,
|
|
73
|
+
// but we don't emit padding so 7 chars is the canonical length here).
|
|
74
|
+
let out = '';
|
|
75
|
+
let v = value;
|
|
76
|
+
for (let i = 0; i < 7; i++) {
|
|
77
|
+
out += BASE32_ALPHABET[v & 0x1f];
|
|
78
|
+
v = Math.floor(v / 32);
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
@@ -199,8 +199,7 @@ function handleReq(
|
|
|
199
199
|
*/
|
|
200
200
|
function handleEnd(state: ConnectionState, ctx: Ctx): ReducerResult<ConnectionState> {
|
|
201
201
|
state.capNegotiating = false;
|
|
202
|
-
|
|
203
|
-
return { state, effects: welcome !== null ? [welcome] : [] };
|
|
202
|
+
return { state, effects: emitWelcomeIfReady(state, ctx) };
|
|
204
203
|
}
|
|
205
204
|
|
|
206
205
|
/**
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducer for the IRCv3 `draft/chathistory` command.
|
|
3
|
+
*
|
|
4
|
+
* Spec: https://ircv3.net/specs/extensions/chathistory
|
|
5
|
+
*
|
|
6
|
+
* Surface 1 (explicit query): `CHATHISTORY <sub> <target> [<args>...]`. The
|
|
7
|
+
* reducer reads the backlog from the {@link MessageStore} injected via
|
|
8
|
+
* {@link Ctx} (mirroring `MotdProvider` / `AccountStore`) and emits the
|
|
9
|
+
* matching `PRIVMSG`/`NOTICE`/`TAGMSG` lines wrapped in a single
|
|
10
|
+
* `BATCH chathistory <target>` frame. Each replay line carries the original
|
|
11
|
+
* `@time=…` (server-time) and `msgid=…` tags so the client can de-dup and
|
|
12
|
+
* place it on the timeline.
|
|
13
|
+
*
|
|
14
|
+
* The cap `draft/chathistory` MUST be negotiated before any query is
|
|
15
|
+
* accepted; clients without the cap get `421 ERR_UNKNOWNCOMMAND` (the spec's
|
|
16
|
+
* permitted "unknown command" fallback).
|
|
17
|
+
*
|
|
18
|
+
* Location-of-authority: chathistory is read-only and runs against the
|
|
19
|
+
* requester's connection. The target channel is passed in (resolved by the
|
|
20
|
+
* actor layer); `undefined` means "no such channel" → `403`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { Effect } from '../effects.js';
|
|
24
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
25
|
+
import type { StoredMessage } from '../ports.js';
|
|
26
|
+
import { wrapBatch } from '../protocol/batch.js';
|
|
27
|
+
import type { IrcMessage } from '../protocol/messages.js';
|
|
28
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
29
|
+
import { formatServerTime } from '../protocol/outbound.js';
|
|
30
|
+
import type { ChannelState } from '../state/channel.js';
|
|
31
|
+
import type { Ctx } from '../types.js';
|
|
32
|
+
|
|
33
|
+
/** The cap name advertising chathistory support. */
|
|
34
|
+
export const CHATHISTORY_CAP = 'draft/chathistory';
|
|
35
|
+
|
|
36
|
+
/** Default query limit when the client omits one (spec recommends 50). */
|
|
37
|
+
const DEFAULT_QUERY_LIMIT = 50;
|
|
38
|
+
|
|
39
|
+
/** Sentinel the client uses to mean "most recent" / "now" / "beginning". */
|
|
40
|
+
const WILDCARD = '*';
|
|
41
|
+
|
|
42
|
+
/** Subcommands that operate on a single channel target. */
|
|
43
|
+
type ChannelSubcommand = 'LATEST' | 'BEFORE' | 'AFTER' | 'AROUND' | 'BETWEEN';
|
|
44
|
+
|
|
45
|
+
const CHANNEL_SUBCOMMANDS: ReadonlySet<string> = new Set<string>([
|
|
46
|
+
'LATEST',
|
|
47
|
+
'BEFORE',
|
|
48
|
+
'AFTER',
|
|
49
|
+
'AROUND',
|
|
50
|
+
'BETWEEN',
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Handles `CHATHISTORY <sub> <target> [<args>...]`.
|
|
55
|
+
*
|
|
56
|
+
* @param channel The authoritative ChannelState for `msg.params[1]`, or
|
|
57
|
+
* `undefined` when no such channel exists (→ `403`).
|
|
58
|
+
*/
|
|
59
|
+
export function chathistoryReducer(
|
|
60
|
+
channel: ChannelState | undefined,
|
|
61
|
+
msg: IrcMessage,
|
|
62
|
+
ctx: Ctx,
|
|
63
|
+
): { effects: EffectType[] } {
|
|
64
|
+
// Cap gate: clients that did not negotiate chathistory see RFC 2812 only.
|
|
65
|
+
if (!ctx.connection.caps.has(CHATHISTORY_CAP)) {
|
|
66
|
+
return {
|
|
67
|
+
effects: [
|
|
68
|
+
Effect.send(ctx.connId, [
|
|
69
|
+
numericErr(ctx, Numerics.ERR_UNKNOWNCOMMAND, 'Unknown command', 'CHATHISTORY'),
|
|
70
|
+
]),
|
|
71
|
+
],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const sub = (msg.params[0] ?? '').toUpperCase();
|
|
76
|
+
if (sub === '') {
|
|
77
|
+
return { effects: [Effect.send(ctx.connId, [needMoreParamsLine(ctx)])] };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (sub === 'TARGETS') {
|
|
81
|
+
return handleTargets(msg, ctx);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!CHANNEL_SUBCOMMANDS.has(sub)) {
|
|
85
|
+
return { effects: [Effect.send(ctx.connId, [invalidParamsLine(ctx)])] };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return handleChannelSub(sub as ChannelSubcommand, channel, msg, ctx);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Handles the four channel-bound subcommands + BETWEEN. */
|
|
92
|
+
function handleChannelSub(
|
|
93
|
+
sub: ChannelSubcommand,
|
|
94
|
+
channel: ChannelState | undefined,
|
|
95
|
+
msg: IrcMessage,
|
|
96
|
+
ctx: Ctx,
|
|
97
|
+
): { effects: EffectType[] } {
|
|
98
|
+
const target = msg.params[1];
|
|
99
|
+
if (target === undefined) {
|
|
100
|
+
return { effects: [Effect.send(ctx.connId, [needMoreParamsLine(ctx)])] };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (channel === undefined) {
|
|
104
|
+
return {
|
|
105
|
+
effects: [
|
|
106
|
+
Effect.send(ctx.connId, [
|
|
107
|
+
numericErr(ctx, Numerics.ERR_NOSUCHCHANNEL, 'No such channel', target),
|
|
108
|
+
]),
|
|
109
|
+
],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// +s/+p leak prevention: a non-member must not learn the channel has
|
|
114
|
+
// history. The spec says return an empty batch; we elide the batch entirely
|
|
115
|
+
// (empty batches are forbidden) so the requester observes nothing.
|
|
116
|
+
if ((channel.modes.secret || channel.modes.private) && !channel.members.has(ctx.connId)) {
|
|
117
|
+
return { effects: [] };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const chanLower = channel.nameLower;
|
|
121
|
+
|
|
122
|
+
if (sub === 'BETWEEN') {
|
|
123
|
+
const lo = msg.params[2];
|
|
124
|
+
const hi = msg.params[3];
|
|
125
|
+
if (lo === undefined || hi === undefined) {
|
|
126
|
+
return { effects: [Effect.send(ctx.connId, [needMoreParamsLine(ctx)])] };
|
|
127
|
+
}
|
|
128
|
+
if (!storeHas(ctx, chanLower, lo) || !storeHas(ctx, chanLower, hi)) {
|
|
129
|
+
return { effects: [Effect.send(ctx.connId, [invalidParamsLine(ctx)])] };
|
|
130
|
+
}
|
|
131
|
+
const result = storeQuery(ctx, {
|
|
132
|
+
chan: chanLower,
|
|
133
|
+
direction: 'between',
|
|
134
|
+
limit: DEFAULT_QUERY_LIMIT,
|
|
135
|
+
msgid: lo,
|
|
136
|
+
msgid2: hi,
|
|
137
|
+
});
|
|
138
|
+
return { effects: buildChathistoryBatch(ctx, channel.name, result) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// LATEST / BEFORE / AFTER / AROUND
|
|
142
|
+
const pivot = msg.params[2];
|
|
143
|
+
const limitRaw = msg.params[3];
|
|
144
|
+
if (sub !== 'LATEST' && pivot === undefined) {
|
|
145
|
+
return { effects: [Effect.send(ctx.connId, [needMoreParamsLine(ctx)])] };
|
|
146
|
+
}
|
|
147
|
+
const limit = parseLimit(limitRaw);
|
|
148
|
+
if (limit === undefined) {
|
|
149
|
+
return { effects: [Effect.send(ctx.connId, [invalidParamsLine(ctx)])] };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (sub === 'LATEST') {
|
|
153
|
+
const pivotArg = pivot === undefined || pivot === WILDCARD ? undefined : pivot;
|
|
154
|
+
if (pivotArg !== undefined && !storeHas(ctx, chanLower, pivotArg)) {
|
|
155
|
+
return { effects: [Effect.send(ctx.connId, [invalidParamsLine(ctx)])] };
|
|
156
|
+
}
|
|
157
|
+
const result = storeQuery(ctx, {
|
|
158
|
+
chan: chanLower,
|
|
159
|
+
direction: 'latest',
|
|
160
|
+
limit,
|
|
161
|
+
...(pivotArg !== undefined ? { msgid: pivotArg } : {}),
|
|
162
|
+
});
|
|
163
|
+
return { effects: buildChathistoryBatch(ctx, channel.name, result) };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// BEFORE / AFTER / AROUND — pivot is required. The `sub !== 'LATEST' &&
|
|
167
|
+
// pivot === undefined` guard above already rejected the missing-pivot
|
|
168
|
+
// case and LATEST returned early just above; assert non-null for the
|
|
169
|
+
// type-checker without an unreachable branch.
|
|
170
|
+
const pivotMsgid = pivot as string;
|
|
171
|
+
if (!storeHas(ctx, chanLower, pivotMsgid)) {
|
|
172
|
+
return { effects: [Effect.send(ctx.connId, [invalidParamsLine(ctx)])] };
|
|
173
|
+
}
|
|
174
|
+
const result = storeQuery(ctx, {
|
|
175
|
+
chan: chanLower,
|
|
176
|
+
direction: sub.toLowerCase() as 'before' | 'after' | 'around',
|
|
177
|
+
limit,
|
|
178
|
+
msgid: pivotMsgid,
|
|
179
|
+
});
|
|
180
|
+
return { effects: buildChathistoryBatch(ctx, channel.name, result) };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Handles `CHATHISTORY TARGETS <since> <until>`. */
|
|
184
|
+
function handleTargets(msg: IrcMessage, ctx: Ctx): { effects: EffectType[] } {
|
|
185
|
+
const sinceRaw = msg.params[1];
|
|
186
|
+
const untilRaw = msg.params[2];
|
|
187
|
+
if (sinceRaw === undefined || untilRaw === undefined) {
|
|
188
|
+
return { effects: [Effect.send(ctx.connId, [needMoreParamsLine(ctx)])] };
|
|
189
|
+
}
|
|
190
|
+
const since = parseTimestamp(sinceRaw, 0);
|
|
191
|
+
const until = parseTimestamp(untilRaw, ctx.clock.now());
|
|
192
|
+
if (since === undefined || until === undefined) {
|
|
193
|
+
return { effects: [Effect.send(ctx.connId, [invalidParamsLine(ctx)])] };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const entries = ctx.messages !== undefined ? ctx.messages.targets(since, until) : [];
|
|
197
|
+
if (entries.length === 0) {
|
|
198
|
+
return { effects: [] };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const body: RawLine[] = entries.map((e) => ({
|
|
202
|
+
text: `:${ctx.serverName} CHATHISTORY TARGETS ${formatServerTime(e.time)} ${e.chan}`,
|
|
203
|
+
}));
|
|
204
|
+
const frame = wrapBatch(body, ctx.ids.batchId(), 'chathistory');
|
|
205
|
+
return { effects: [Effect.send(ctx.connId, frame)] };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Wraps `messages` as replay lines in a `BATCH chathistory <chan>` frame
|
|
210
|
+
* addressed to the requester. Empty input elides the batch entirely (the
|
|
211
|
+
* spec forbids empty batches; the client interprets "no batch" as "no
|
|
212
|
+
* history").
|
|
213
|
+
*
|
|
214
|
+
* Shared by the explicit `CHATHISTORY` query and the JOIN auto-playback hook
|
|
215
|
+
* so both surfaces emit identical framing.
|
|
216
|
+
*/
|
|
217
|
+
export function buildChathistoryBatch(
|
|
218
|
+
ctx: Ctx,
|
|
219
|
+
displayChan: string,
|
|
220
|
+
messages: readonly StoredMessage[],
|
|
221
|
+
): EffectType[] {
|
|
222
|
+
if (messages.length === 0) return [];
|
|
223
|
+
const body = messages.map((m) => formatReplayLine(m, displayChan));
|
|
224
|
+
const frame = wrapBatch(body, ctx.ids.batchId(), 'chathistory', displayChan);
|
|
225
|
+
return [Effect.send(ctx.connId, frame)];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Reconstructs the wire line for one stored message, decorated with the
|
|
230
|
+
* original server-time and msgid tags. PRIVMSG/NOTICE carry the trailing
|
|
231
|
+
* text; TAGMSG has no trailing parameter.
|
|
232
|
+
*/
|
|
233
|
+
export function formatReplayLine(m: StoredMessage, displayChan: string): RawLine {
|
|
234
|
+
const stamp = formatServerTime(m.time);
|
|
235
|
+
const prefix = storedHostmask(m);
|
|
236
|
+
const tag = `@time=${stamp};msgid=${m.msgid}`;
|
|
237
|
+
if (m.command === 'TAGMSG') {
|
|
238
|
+
return { text: `${tag} :${prefix} TAGMSG ${displayChan}` };
|
|
239
|
+
}
|
|
240
|
+
return { text: `${tag} :${prefix} ${m.command} ${displayChan} :${m.text}` };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Builds the `nick!user@host` source for a stored message. */
|
|
244
|
+
function storedHostmask(m: StoredMessage): string {
|
|
245
|
+
if (m.user === undefined && m.host === undefined) return m.nick;
|
|
246
|
+
let out = m.nick;
|
|
247
|
+
if (m.user !== undefined) out = `${out}!${m.user}`;
|
|
248
|
+
if (m.host !== undefined) out = `${out}@${m.host}`;
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Formats a numeric error line addressed to the connection's nick (or `*`). */
|
|
253
|
+
function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
|
|
254
|
+
const nick = ctx.connection.nick ?? '*';
|
|
255
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
256
|
+
return { text: [`:${ctx.serverName}`, codeStr, nick, middle, `:${trailing}`].join(' ') };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** `461 CHATHISTORY :Not enough parameters`. */
|
|
260
|
+
function needMoreParamsLine(ctx: Ctx): RawLine {
|
|
261
|
+
return numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Not enough parameters', 'CHATHISTORY');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** `461 CHATHISTORY :Invalid parameters`. */
|
|
265
|
+
function invalidParamsLine(ctx: Ctx): RawLine {
|
|
266
|
+
return numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Invalid parameters', 'CHATHISTORY');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Parses a non-negative integer limit; returns undefined on garbage. */
|
|
270
|
+
function parseLimit(raw: string | undefined): number | undefined {
|
|
271
|
+
if (raw === undefined) return DEFAULT_QUERY_LIMIT;
|
|
272
|
+
const n = Number(raw);
|
|
273
|
+
if (!Number.isInteger(n) || n < 0) return undefined;
|
|
274
|
+
return n;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Parses a CHATHISTORY timestamp bound. `*` resolves to `wildcardValue`
|
|
279
|
+
* (0 for `since`, `ctx.clock.now()` for `until`); an ISO-8601 string parses
|
|
280
|
+
* to epoch ms; anything else returns undefined (→ `461`).
|
|
281
|
+
*/
|
|
282
|
+
function parseTimestamp(raw: string, wildcardValue: number): number | undefined {
|
|
283
|
+
if (raw === WILDCARD) return wildcardValue;
|
|
284
|
+
const ms = Date.parse(raw);
|
|
285
|
+
return Number.isNaN(ms) ? undefined : ms;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** store.hasMsgid that treats an unbound store as "the msgid is not known". */
|
|
289
|
+
function storeHas(ctx: Ctx, chan: string, msgid: string): boolean {
|
|
290
|
+
return ctx.messages !== undefined ? ctx.messages.hasMsgid(chan, msgid) : false;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** store.query that treats an unbound store as empty. */
|
|
294
|
+
function storeQuery(
|
|
295
|
+
ctx: Ctx,
|
|
296
|
+
q: {
|
|
297
|
+
chan: string;
|
|
298
|
+
direction: 'latest' | 'before' | 'after' | 'around' | 'between';
|
|
299
|
+
limit: number;
|
|
300
|
+
msgid?: string;
|
|
301
|
+
msgid2?: string;
|
|
302
|
+
},
|
|
303
|
+
): StoredMessage[] {
|
|
304
|
+
return ctx.messages !== undefined ? ctx.messages.query(q) : [];
|
|
305
|
+
}
|
|
@@ -38,3 +38,10 @@ export { capReducer } from './cap.js';
|
|
|
38
38
|
export { emitChghost } from './chghost.js';
|
|
39
39
|
export type { ChgHostInput } from './chghost.js';
|
|
40
40
|
export { awayReducer } from './away.js';
|
|
41
|
+
export { authenticateReducer } from './sasl.js';
|
|
42
|
+
export {
|
|
43
|
+
chathistoryReducer,
|
|
44
|
+
formatReplayLine,
|
|
45
|
+
buildChathistoryBatch,
|
|
46
|
+
CHATHISTORY_CAP,
|
|
47
|
+
} from './chathistory.js';
|