serverless-ircd 0.9.0 → 0.11.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 +28 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +35 -9
- package/.github/workflows/deploy-cf.yml +40 -14
- package/CHANGELOG.md +594 -0
- package/README.md +286 -60
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +118 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +398 -67
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +714 -105
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -3
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +267 -87
- package/apps/cf-tcp-container/src/main.ts +22 -7
- package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
- package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
- package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
- package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
- package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
- package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
- package/apps/cf-tcp-container/wrangler.toml +18 -14
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/src/worker.ts +77 -5
- package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
- package/apps/cf-worker/tests/smoke.test.ts +4 -0
- package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
- package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
- package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
- package/apps/cf-worker/wrangler.test.toml +15 -1
- package/apps/cf-worker/wrangler.toml +95 -77
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +14 -2
- package/apps/local-cli/src/line-scanner.ts +26 -0
- package/apps/local-cli/src/server.ts +44 -19
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +1 -0
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +66 -4
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +2 -2
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/docs/AWS-Deployment.md +793 -118
- package/docs/AWS-TCP-Deployment.md +57 -47
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +95 -114
- package/docs/Cloudflare-TCP-Deployment.md +160 -101
- package/docs/Release-Process.md +27 -23
- package/docs/SASL-EXTERNAL.md +175 -0
- package/docs/Services.md +69 -22
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/admission.ts +28 -13
- package/packages/aws-adapter/src/aws-runtime.ts +30 -3
- package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
- package/packages/aws-adapter/src/config-loader.ts +153 -8
- package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
- package/packages/aws-adapter/src/handlers/connect.ts +73 -1
- package/packages/aws-adapter/src/handlers/default.ts +279 -123
- package/packages/aws-adapter/src/handlers/index.ts +98 -25
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +23 -0
- package/packages/aws-adapter/src/tables.ts +11 -12
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -2
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
- package/packages/aws-adapter/tests/connect.test.ts +323 -3
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
- package/packages/aws-adapter/tests/handlers.test.ts +174 -12
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +100 -10
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +406 -116
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +105 -26
- package/packages/cf-adapter/src/env.ts +99 -10
- package/packages/cf-adapter/src/index.ts +17 -7
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
- package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
- package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
- package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/cf-adapter/tests/worker/main.ts +4 -0
- package/packages/cf-adapter/wrangler.test.toml +18 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/caps/capabilities.ts +20 -10
- package/packages/irc-core/src/certfp.ts +178 -0
- package/packages/irc-core/src/commands/account-auth.ts +16 -19
- package/packages/irc-core/src/commands/cap.ts +10 -2
- package/packages/irc-core/src/commands/chanserv.ts +117 -14
- package/packages/irc-core/src/commands/chathistory.ts +13 -5
- package/packages/irc-core/src/commands/hostserv.ts +84 -8
- package/packages/irc-core/src/commands/index.ts +2 -1
- package/packages/irc-core/src/commands/invite.ts +1 -7
- package/packages/irc-core/src/commands/join.ts +1 -16
- package/packages/irc-core/src/commands/kick.ts +1 -8
- package/packages/irc-core/src/commands/list.ts +1 -8
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/mode.ts +1 -8
- package/packages/irc-core/src/commands/multiline.ts +4 -10
- package/packages/irc-core/src/commands/names.ts +53 -13
- package/packages/irc-core/src/commands/nickserv.ts +161 -11
- package/packages/irc-core/src/commands/oper.ts +361 -8
- package/packages/irc-core/src/commands/part.ts +4 -10
- package/packages/irc-core/src/commands/privmsg.ts +8 -4
- package/packages/irc-core/src/commands/registration.ts +148 -4
- package/packages/irc-core/src/commands/sasl.ts +154 -46
- package/packages/irc-core/src/commands/topic.ts +10 -12
- package/packages/irc-core/src/commands/who.ts +1 -8
- package/packages/irc-core/src/config.ts +424 -25
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/effects.ts +24 -0
- package/packages/irc-core/src/flood-control.ts +10 -10
- package/packages/irc-core/src/frame-rate-limit.ts +82 -0
- package/packages/irc-core/src/index.ts +9 -0
- package/packages/irc-core/src/oper-hashing.ts +43 -0
- package/packages/irc-core/src/oper-lockout.ts +87 -0
- package/packages/irc-core/src/ports.ts +529 -190
- package/packages/irc-core/src/protocol/bytes.ts +65 -0
- package/packages/irc-core/src/protocol/channel-name.ts +37 -0
- package/packages/irc-core/src/protocol/index.ts +12 -1
- package/packages/irc-core/src/protocol/outbound.ts +43 -10
- package/packages/irc-core/src/protocol/parser.ts +79 -10
- package/packages/irc-core/src/state/connection.ts +13 -0
- package/packages/irc-core/src/types.ts +266 -23
- package/packages/irc-core/src/ws-framing.ts +5 -4
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/bytes.test.ts +89 -0
- package/packages/irc-core/tests/certfp.test.ts +117 -0
- package/packages/irc-core/tests/commands/cap.test.ts +76 -2
- package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
- package/packages/irc-core/tests/commands/join.test.ts +78 -1
- package/packages/irc-core/tests/commands/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
- package/packages/irc-core/tests/commands/oper.test.ts +574 -1
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +602 -133
- package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
- package/packages/irc-core/tests/config.test.ts +534 -2
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/flood-control.test.ts +29 -1
- package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
- package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
- package/packages/irc-core/tests/outbound.test.ts +148 -0
- package/packages/irc-core/tests/parser.test.ts +287 -5
- package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
- package/packages/irc-core/tests/ports.test.ts +170 -7
- package/packages/irc-core/tests/services-store.test.ts +567 -1
- package/packages/irc-core/tests/ws-framing.test.ts +45 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +123 -22
- package/packages/irc-server/src/dispatch.ts +1 -0
- package/packages/irc-server/src/index.ts +7 -0
- package/packages/irc-server/src/redact.ts +159 -0
- package/packages/irc-server/src/runtime.ts +14 -0
- package/packages/irc-server/src/transport.ts +28 -1
- package/packages/irc-server/tests/actor.test.ts +563 -54
- package/packages/irc-server/tests/dispatch.test.ts +31 -0
- package/packages/irc-server/tests/redact.test.ts +198 -0
- package/packages/irc-server/tests/runtime.test.ts +2 -0
- package/packages/irc-server/tests/transport.test.ts +66 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
- package/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- package/tools/ci-hardening/package.json +2 -2
- package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
- package/tools/ci-hardening/src/cf-deploy.ts +118 -0
- package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
- package/tools/ci-hardening/src/env-var-drift.ts +192 -0
- package/tools/ci-hardening/src/hostname-guard.ts +11 -0
- package/tools/ci-hardening/src/index.ts +19 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
- package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
- package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
- package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
- package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/ci-hardening/vitest.config.ts +5 -1
- package/tools/hash-oper-cred.ts +85 -0
- package/tools/load-test/package.json +1 -1
- package/tools/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- package/packages/irc-core/tests/account-store.test.ts +0 -131
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the `$connect` Origin-allowlist policy
|
|
3
|
+
* (`packages/aws-adapter/src/origin-allowlist.ts`).
|
|
4
|
+
*
|
|
5
|
+
* Mirrors the canonical CF Worker policy
|
|
6
|
+
* (`apps/cf-worker/src/origin-allowlist.ts`) for the parsing semantics
|
|
7
|
+
* (comma-separated, trimmed, lowercased) and the "missing Origin =
|
|
8
|
+
* non-browser → allow" branch. Deliberately diverges from CF by
|
|
9
|
+
* supporting ONLY the explicit-allowlist mode (the AWS SPA is served
|
|
10
|
+
* from a different CloudFront origin than the API Gateway WebSocket
|
|
11
|
+
* endpoint, so same-origin auto-derive does not apply); see the
|
|
12
|
+
* module docstring for the rationale.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, expect, it } from 'vitest';
|
|
15
|
+
import { decideConnectOrigin, parseWebOrigins } from '../src/origin-allowlist.js';
|
|
16
|
+
|
|
17
|
+
describe('parseWebOrigins', () => {
|
|
18
|
+
it('returns an empty set for undefined input', () => {
|
|
19
|
+
expect(parseWebOrigins(undefined).size).toBe(0);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns an empty set for an empty string', () => {
|
|
23
|
+
expect(parseWebOrigins('').size).toBe(0);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('parses a single origin', () => {
|
|
27
|
+
expect(parseWebOrigins('https://app.example.com')).toEqual(
|
|
28
|
+
new Set(['https://app.example.com']),
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('parses multiple comma-separated origins', () => {
|
|
33
|
+
expect(parseWebOrigins('https://a.example.com,https://b.example.com')).toEqual(
|
|
34
|
+
new Set(['https://a.example.com', 'https://b.example.com']),
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('trims whitespace around each entry', () => {
|
|
39
|
+
expect(parseWebOrigins(' https://a.example.com , https://b.example.com ')).toEqual(
|
|
40
|
+
new Set(['https://a.example.com', 'https://b.example.com']),
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('lowercases each entry so the match is case-insensitive on scheme/host', () => {
|
|
45
|
+
// Origins are scheme://host[:port]; scheme and host are case-insensitive
|
|
46
|
+
// per RFC 3986. Lowercasing the whole string is the simplest
|
|
47
|
+
// normalisation that matches the real browser-emitted form.
|
|
48
|
+
expect(parseWebOrigins('https://APP.Example.COM')).toEqual(
|
|
49
|
+
new Set(['https://app.example.com']),
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('drops empty entries (trailing comma tolerance)', () => {
|
|
54
|
+
expect(parseWebOrigins('https://a.example.com,,')).toEqual(new Set(['https://a.example.com']));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('drops whitespace-only entries', () => {
|
|
58
|
+
expect(parseWebOrigins('https://a.example.com, ,https://b.example.com')).toEqual(
|
|
59
|
+
new Set(['https://a.example.com', 'https://b.example.com']),
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('decideConnectOrigin', () => {
|
|
65
|
+
it('allows a missing Origin header (non-browser client)', () => {
|
|
66
|
+
// curl, WeeChat, and the tcp-ws-forwarder never send `Origin`;
|
|
67
|
+
// their upgrades must pass through unchanged so existing
|
|
68
|
+
// non-browser tooling keeps working.
|
|
69
|
+
expect(decideConnectOrigin(undefined, new Set(['https://app.example.com']))).toBe('allow');
|
|
70
|
+
expect(decideConnectOrigin(null, new Set(['https://app.example.com']))).toBe('allow');
|
|
71
|
+
expect(decideConnectOrigin('', new Set(['https://app.example.com']))).toBe('allow');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('allows every Origin when the allowlist is empty (opt-in defense)', () => {
|
|
75
|
+
// Unset `WEB_ORIGINS` skips the check entirely — existing AWS
|
|
76
|
+
// deployments without a web frontend are unchanged on upgrade.
|
|
77
|
+
expect(decideConnectOrigin('https://attacker.example.com', new Set())).toBe('allow');
|
|
78
|
+
expect(decideConnectOrigin('https://app.example.com', new Set())).toBe('allow');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('allows an Origin that is in the allowlist (case-insensitively)', () => {
|
|
82
|
+
const allowlist = parseWebOrigins('https://app.example.com');
|
|
83
|
+
expect(decideConnectOrigin('https://app.example.com', allowlist)).toBe('allow');
|
|
84
|
+
// The header value is normalised (lowercased, trimmed) before the
|
|
85
|
+
// membership test, so a mixed-case header matches a lowercased entry.
|
|
86
|
+
expect(decideConnectOrigin('https://APP.Example.COM', allowlist)).toBe('allow');
|
|
87
|
+
expect(decideConnectOrigin(' https://app.example.com ', allowlist)).toBe('allow');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('denies an Origin that is not in the allowlist', () => {
|
|
91
|
+
const allowlist = parseWebOrigins('https://app.example.com');
|
|
92
|
+
expect(decideConnectOrigin('https://attacker.example.com', allowlist)).toBe('deny');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('denies an Origin that matches a listed origin except for the scheme', () => {
|
|
96
|
+
// Origins are case-sensitive on scheme+host+port; `http://` is a
|
|
97
|
+
// different origin from `https://` and MUST NOT be admitted when
|
|
98
|
+
// only the `https://` form is allowlisted (defends against a
|
|
99
|
+
// stripped-TLS attacker on the same hostname).
|
|
100
|
+
const allowlist = parseWebOrigins('https://app.example.com');
|
|
101
|
+
expect(decideConnectOrigin('http://app.example.com', allowlist)).toBe('deny');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('matches one of several allowlisted origins', () => {
|
|
105
|
+
const allowlist = parseWebOrigins('https://app.example.com, https://staging.app.example.com');
|
|
106
|
+
expect(decideConnectOrigin('https://staging.app.example.com', allowlist)).toBe('allow');
|
|
107
|
+
expect(decideConnectOrigin('https://app.example.com', allowlist)).toBe('allow');
|
|
108
|
+
expect(decideConnectOrigin('https://other.example.com', allowlist)).toBe('deny');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -238,7 +238,6 @@ describe('AwsStats — defensive Scan fallbacks (mocked client)', () => {
|
|
|
238
238
|
ChannelMeta: 'ChannelMeta',
|
|
239
239
|
ChannelMembers: 'ChannelMembers',
|
|
240
240
|
Nicks: 'Nicks',
|
|
241
|
-
Accounts: 'Accounts',
|
|
242
241
|
};
|
|
243
242
|
// Pass `undefined` so the stub returns `{}` — no Items key — to
|
|
244
243
|
// exercise the `?? []` fallback on the Connections scan, and
|
|
@@ -258,7 +257,6 @@ describe('AwsStats — defensive Scan fallbacks (mocked client)', () => {
|
|
|
258
257
|
ChannelMeta: 'ChannelMeta',
|
|
259
258
|
ChannelMembers: 'ChannelMembers',
|
|
260
259
|
Nicks: 'Nicks',
|
|
261
|
-
Accounts: 'Accounts',
|
|
262
260
|
};
|
|
263
261
|
const dynamo = stubDynamo([], 0);
|
|
264
262
|
const stats = new AwsStats({
|
|
@@ -279,7 +277,6 @@ describe('AwsStats — defensive Scan fallbacks (mocked client)', () => {
|
|
|
279
277
|
ChannelMeta: 'ChannelMeta',
|
|
280
278
|
ChannelMembers: 'ChannelMembers',
|
|
281
279
|
Nicks: 'Nicks',
|
|
282
|
-
Accounts: 'Accounts',
|
|
283
280
|
};
|
|
284
281
|
// Two registered connection rows.
|
|
285
282
|
const items = [
|
|
@@ -13,13 +13,12 @@ import {
|
|
|
13
13
|
} from '../src/tables.js';
|
|
14
14
|
|
|
15
15
|
describe('TABLE_NAMES', () => {
|
|
16
|
-
it('contains exactly the
|
|
16
|
+
it('contains exactly the five canonical logical names', () => {
|
|
17
17
|
expect(TABLE_NAMES).toEqual([
|
|
18
18
|
'Connections',
|
|
19
19
|
'ChannelMeta',
|
|
20
20
|
'ChannelMembers',
|
|
21
21
|
'Nicks',
|
|
22
|
-
'Accounts',
|
|
23
22
|
'Services',
|
|
24
23
|
]);
|
|
25
24
|
});
|
|
@@ -46,10 +45,6 @@ describe('TABLE_KEYS', () => {
|
|
|
46
45
|
it('exposes the nickLower PK for Nicks', () => {
|
|
47
46
|
expect(TABLE_KEYS.Nicks.pk).toBe('nickLower');
|
|
48
47
|
});
|
|
49
|
-
|
|
50
|
-
it('exposes the account PK for Accounts', () => {
|
|
51
|
-
expect(TABLE_KEYS.Accounts.pk).toBe('account');
|
|
52
|
-
});
|
|
53
48
|
});
|
|
54
49
|
|
|
55
50
|
describe('tablesConfigFromNames', () => {
|
|
@@ -59,7 +54,6 @@ describe('tablesConfigFromNames', () => {
|
|
|
59
54
|
ChannelMeta: 'p-ChannelMeta',
|
|
60
55
|
ChannelMembers: 'p-ChannelMembers',
|
|
61
56
|
Nicks: 'p-Nicks',
|
|
62
|
-
Accounts: 'p-Accounts',
|
|
63
57
|
};
|
|
64
58
|
expect(tablesConfigFromNames(names)).toEqual(names);
|
|
65
59
|
});
|
|
@@ -70,7 +64,6 @@ describe('tablesConfigFromNames', () => {
|
|
|
70
64
|
ChannelMeta: 'cm',
|
|
71
65
|
ChannelMembers: 'cml',
|
|
72
66
|
Nicks: 'n',
|
|
73
|
-
Accounts: 'a',
|
|
74
67
|
};
|
|
75
68
|
const out = tablesConfigFromNames(names);
|
|
76
69
|
expect(out).not.toBe(names);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverless-ircd/cf-adapter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Cloudflare Workers adapter: Durable Objects (ConnectionDO, RegistryDO, ChannelDO) and CfRuntime implementing IrcRuntime",
|
|
6
6
|
"license": "BSD-3-Clause",
|
|
@@ -11,10 +11,6 @@
|
|
|
11
11
|
".": {
|
|
12
12
|
"types": "./dist/index.d.ts",
|
|
13
13
|
"import": "./dist/index.js"
|
|
14
|
-
},
|
|
15
|
-
"./accounts": {
|
|
16
|
-
"types": "./dist/d1-account-store.d.ts",
|
|
17
|
-
"import": "./dist/d1-account-store.js"
|
|
18
14
|
}
|
|
19
15
|
},
|
|
20
16
|
"scripts": {
|
|
@@ -162,6 +162,30 @@ export class CfRuntime implements IrcRuntime {
|
|
|
162
162
|
* round-trip for the enumeration phase.
|
|
163
163
|
*/
|
|
164
164
|
async broadcastWallops(lines: RawLine[], except?: ConnId): Promise<void> {
|
|
165
|
+
await this.fanOutByUserMode(lines, except, 'wallops');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Oper-only global notice fan-out (e.g. the OPER reducer's per-IP
|
|
170
|
+
* lockout notice). Identical enumeration to {@link broadcastWallops};
|
|
171
|
+
* the recipient gate is user mode `+o` instead of `+w`.
|
|
172
|
+
*/
|
|
173
|
+
async broadcastOperNotice(lines: RawLine[], except?: ConnId): Promise<void> {
|
|
174
|
+
await this.fanOutByUserMode(lines, except, 'oper');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Shared fan-out behind {@link broadcastWallops} and
|
|
179
|
+
* {@link broadcastOperNotice}: enumerate every connId across all
|
|
180
|
+
* registry shards, fetch each candidate's state, gate on the requested
|
|
181
|
+
* user mode, and deliver via the ConnectionDO `deliver` RPC.
|
|
182
|
+
* `except` (the originator) and the bound connection are skipped.
|
|
183
|
+
*/
|
|
184
|
+
private async fanOutByUserMode(
|
|
185
|
+
lines: RawLine[],
|
|
186
|
+
except: ConnId | undefined,
|
|
187
|
+
mode: 'wallops' | 'oper',
|
|
188
|
+
): Promise<void> {
|
|
165
189
|
// Phase 1 — enumerate every connId across all registry shards.
|
|
166
190
|
const shardEntries = await Promise.all(
|
|
167
191
|
Array.from({ length: this.registryShards }, (_, i) => this.registryShardRpc(i).listEntries()),
|
|
@@ -173,18 +197,20 @@ export class CfRuntime implements IrcRuntime {
|
|
|
173
197
|
}
|
|
174
198
|
}
|
|
175
199
|
|
|
176
|
-
// Phase 2 — fetch each candidate's state, filter
|
|
200
|
+
// Phase 2 — fetch each candidate's state, filter by mode, deliver.
|
|
177
201
|
// Bound the per-call fan-out by skipping the excepted/bound connection
|
|
178
|
-
// up front (the bound connection's own
|
|
202
|
+
// up front (the bound connection's own mode never needs a self-RPC).
|
|
179
203
|
await Promise.all(
|
|
180
204
|
Array.from(seen, async (connId) => {
|
|
181
205
|
if (connId === except || connId === this.connId) return;
|
|
182
206
|
const stub = this.env.CONNECTION_DO.get(this.env.CONNECTION_DO.idFromString(connId));
|
|
183
207
|
const dto = await (stub as unknown as ConnectionRpc).getConnState();
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
208
|
+
await this.deliverToCandidateByMode(
|
|
209
|
+
stub as unknown as { deliver(lines: RawLine[]): Promise<unknown> },
|
|
210
|
+
dto,
|
|
211
|
+
lines,
|
|
212
|
+
mode,
|
|
213
|
+
);
|
|
188
214
|
}),
|
|
189
215
|
);
|
|
190
216
|
}
|
|
@@ -284,7 +310,7 @@ export class CfRuntime implements IrcRuntime {
|
|
|
284
310
|
}
|
|
285
311
|
const stub = this.env.CONNECTION_DO.get(this.env.CONNECTION_DO.idFromString(conn));
|
|
286
312
|
const dto = await (stub as unknown as ConnectionRpc).getConnState();
|
|
287
|
-
return
|
|
313
|
+
return this.deserializeConnState(dto);
|
|
288
314
|
}
|
|
289
315
|
|
|
290
316
|
async getChannelConnections(chan: ChanName): Promise<ReadonlyMap<ConnId, ConnectionState>> {
|
|
@@ -294,9 +320,7 @@ export class CfRuntime implements IrcRuntime {
|
|
|
294
320
|
memberIds.map(async (id) => {
|
|
295
321
|
const connStub = this.env.CONNECTION_DO.get(this.env.CONNECTION_DO.idFromString(id));
|
|
296
322
|
const dto = await (connStub as unknown as ConnectionRpc).getConnState();
|
|
297
|
-
|
|
298
|
-
out.set(id, deserialize(dto));
|
|
299
|
-
}
|
|
323
|
+
this.collectChannelMember(out, id, dto);
|
|
300
324
|
}),
|
|
301
325
|
);
|
|
302
326
|
return out;
|
|
@@ -320,6 +344,72 @@ export class CfRuntime implements IrcRuntime {
|
|
|
320
344
|
return loadServerConfigFromCfEnv(this.env as unknown as CfConfigEnv);
|
|
321
345
|
}
|
|
322
346
|
|
|
347
|
+
/**
|
|
348
|
+
* Phase 2 of {@link broadcastWallops}: rehydrates the candidate's
|
|
349
|
+
* persisted state, gates on user mode `+w`, and dispatches `lines`
|
|
350
|
+
* via the target stub's `deliver` RPC. Kept as a public test seam
|
|
351
|
+
* because every real ConnectionDO's `getConnState` always returns a
|
|
352
|
+
* non-null DTO (the storage layer is the source of truth and always
|
|
353
|
+
* has an entry post-`loadState`), so the `dto === null` and `!+w`
|
|
354
|
+
* defensive arms are unreachable through end-to-end RPC.
|
|
355
|
+
*
|
|
356
|
+
* Behavioural contract:
|
|
357
|
+
* - `dto === null` → no-op (candidate reported no state).
|
|
358
|
+
* - `dto` with `+w` off → no-op (recipient gate fails).
|
|
359
|
+
* - `dto` with `+w` on → `stub.deliver(lines)` awaited.
|
|
360
|
+
*/
|
|
361
|
+
public async deliverWallopsToCandidate(
|
|
362
|
+
stub: { deliver(lines: RawLine[]): Promise<unknown> },
|
|
363
|
+
dto: PersistedConnectionState | null,
|
|
364
|
+
lines: RawLine[],
|
|
365
|
+
): Promise<void> {
|
|
366
|
+
await this.deliverToCandidateByMode(stub, dto, lines, 'wallops');
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Mode-parameterised gate behind {@link deliverWallopsToCandidate} and
|
|
371
|
+
* {@link fanOutByUserMode}: rehydrate the DTO, check the requested
|
|
372
|
+
* user mode (`+w` wallops / `+o` oper), deliver when it is set.
|
|
373
|
+
*/
|
|
374
|
+
private async deliverToCandidateByMode(
|
|
375
|
+
stub: { deliver(lines: RawLine[]): Promise<unknown> },
|
|
376
|
+
dto: PersistedConnectionState | null,
|
|
377
|
+
lines: RawLine[],
|
|
378
|
+
mode: 'wallops' | 'oper',
|
|
379
|
+
): Promise<void> {
|
|
380
|
+
if (dto === null) return;
|
|
381
|
+
const state = deserialize(dto);
|
|
382
|
+
if (!state.userModes[mode]) return;
|
|
383
|
+
await stub.deliver(lines);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Rehydrates a cross-connection DTO into a live {@link ConnectionState},
|
|
388
|
+
* returning `null` when the candidate reported no persisted state.
|
|
389
|
+
* Extracted as a public test seam because real ConnectionDOs always
|
|
390
|
+
* return a non-null DTO from `getConnState`, so the `dto === null`
|
|
391
|
+
* arm is unreachable through end-to-end RPC.
|
|
392
|
+
*/
|
|
393
|
+
public deserializeConnState(dto: PersistedConnectionState | null): ConnectionState | null {
|
|
394
|
+
return dto === null ? null : deserialize(dto);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Per-member collector for {@link getChannelConnections}: rehydrates the
|
|
399
|
+
* DTO and adds it to `out` keyed by `id`. Extracted so the null-DTO arm
|
|
400
|
+
* (a member whose ConnectionDO reports no live state) is testable
|
|
401
|
+
* directly without constructing a DO whose `getConnState` always
|
|
402
|
+
* returns non-null.
|
|
403
|
+
*/
|
|
404
|
+
public collectChannelMember(
|
|
405
|
+
out: Map<ConnId, ConnectionState>,
|
|
406
|
+
id: ConnId,
|
|
407
|
+
dto: PersistedConnectionState | null,
|
|
408
|
+
): void {
|
|
409
|
+
if (dto === null) return;
|
|
410
|
+
out.set(id, deserialize(dto));
|
|
411
|
+
}
|
|
412
|
+
|
|
323
413
|
// -------------------------------------------------------------------------
|
|
324
414
|
// Internal
|
|
325
415
|
// -------------------------------------------------------------------------
|
|
@@ -63,7 +63,7 @@ interface PersistedChannelState {
|
|
|
63
63
|
modes: ChannelState['modes'];
|
|
64
64
|
banMasks: string[];
|
|
65
65
|
topic?: ChannelTopic;
|
|
66
|
-
members: Array<Omit<RosterEntry, 'conn'> & { conn: ConnId }>;
|
|
66
|
+
members: Array<Omit<RosterEntry, 'conn'> & { conn: ConnId; op?: boolean; voice?: boolean }>;
|
|
67
67
|
pendingInvites: string[];
|
|
68
68
|
createdAt: number;
|
|
69
69
|
}
|
|
@@ -370,7 +370,13 @@ function serializeChannel(chan: ChannelState): PersistedChannelState {
|
|
|
370
370
|
return out;
|
|
371
371
|
}
|
|
372
372
|
|
|
373
|
-
|
|
373
|
+
/**
|
|
374
|
+
* Rebuilds a live {@link ChannelState} from a JSON-friendly persisted
|
|
375
|
+
* record. Exported so the legacy-shape defensive arms (`op`/`voice`
|
|
376
|
+
* coalescing, missing-topic branch) are unit-testable without driving
|
|
377
|
+
* a full DO storage round-trip.
|
|
378
|
+
*/
|
|
379
|
+
export function deserializeChannel(record: PersistedChannelState): ChannelState {
|
|
374
380
|
const chan: ChannelState = {
|
|
375
381
|
name: record.name,
|
|
376
382
|
nameLower: record.nameLower,
|
|
@@ -393,8 +399,12 @@ function deserializeChannel(record: PersistedChannelState): ChannelState {
|
|
|
393
399
|
* Builds a JSON-friendly {@link ChannelSnapshotDto} from a live
|
|
394
400
|
* {@link ChannelState}. Uses {@link toSnapshot} for the immutable
|
|
395
401
|
* view, then flattens the `Map`/`Set` fields to arrays.
|
|
402
|
+
*
|
|
403
|
+
* Exported so the defensive `?:` arms (which fire when a state was
|
|
404
|
+
* built outside {@link createChannel} and lacks the `Set`/`Map`
|
|
405
|
+
* fields) are unit-testable directly.
|
|
396
406
|
*/
|
|
397
|
-
function toDto(chan: ChannelState): ChannelSnapshotDto {
|
|
407
|
+
export function toDto(chan: ChannelState): ChannelSnapshotDto {
|
|
398
408
|
// Defensively rebuild arrays; in some test-pool code paths the cached
|
|
399
409
|
// state's Set/Map fields may be missing if the channel was created
|
|
400
410
|
// via an unusual path (e.g. direct storage put without going through
|
|
@@ -13,6 +13,15 @@
|
|
|
13
13
|
|
|
14
14
|
import { type ParsedServerConfig, parseServerConfig } from '@serverless-ircd/irc-core';
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Documented schema defaults for `perIpConnectionRate`. Duplicated here
|
|
18
|
+
* (the schema inlines its `.default(...)`) so an operator setting only
|
|
19
|
+
* one half of the knob via env gets the documented default for the other
|
|
20
|
+
* half rather than a parse error.
|
|
21
|
+
*/
|
|
22
|
+
const DEFAULT_PER_IP_RATE_MAX = 5;
|
|
23
|
+
const DEFAULT_PER_IP_RATE_WINDOW_MS = 60_000;
|
|
24
|
+
|
|
16
25
|
/**
|
|
17
26
|
* The subset of the Workers `Env` that this loader reads. Mirrors the
|
|
18
27
|
* production {@link Env} in `env.ts` plus the optional knobs the schema
|
|
@@ -38,6 +47,20 @@ export interface CfConfigEnv {
|
|
|
38
47
|
CHANNEL_PREFIXES?: string;
|
|
39
48
|
OPER_USER?: string;
|
|
40
49
|
OPER_PASSWORD?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Scrypt salt (base64) for the hashed oper credential form. When set
|
|
52
|
+
* alongside `OPER_USER` + `OPER_HASH`, the loader builds a hashed
|
|
53
|
+
* `{user, salt, hash}` cred instead of the legacy plaintext
|
|
54
|
+
* `{user, password}` shape. The hashed form is preferred when both
|
|
55
|
+
* are present so deployments can rotate without removing the legacy
|
|
56
|
+
* vars in the same step. Generate via `tools/hash-oper-cred.ts`.
|
|
57
|
+
*/
|
|
58
|
+
OPER_SALT?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Scrypt hash (base64) for the hashed oper credential form. See
|
|
61
|
+
* `OPER_SALT` for the full set of vars.
|
|
62
|
+
*/
|
|
63
|
+
OPER_HASH?: string;
|
|
41
64
|
/**
|
|
42
65
|
* Server-password gate. Treat as a wrangler secret in production
|
|
43
66
|
* (never a plaintext `[vars]` entry); the loader threads it through
|
|
@@ -59,6 +82,40 @@ export interface CfConfigEnv {
|
|
|
59
82
|
* path (vs the connection-do's direct env read) stays consistent.
|
|
60
83
|
*/
|
|
61
84
|
SASL_ACCOUNTS?: string;
|
|
85
|
+
/**
|
|
86
|
+
* Per-connection inbound frame window ceiling enforced by
|
|
87
|
+
* `ConnectionDO.webSocketMessage` BEFORE the actor / storage write.
|
|
88
|
+
* Parsed into `ServerConfig.adapter.maxFramesPerWindow`.
|
|
89
|
+
*/
|
|
90
|
+
MAX_FRAMES_PER_WINDOW?: string;
|
|
91
|
+
/**
|
|
92
|
+
* Sliding-window length (seconds) for the inbound frame limit; frames
|
|
93
|
+
* older than the window stop counting. Parsed into
|
|
94
|
+
* `ServerConfig.adapter.frameWindowSeconds`.
|
|
95
|
+
*/
|
|
96
|
+
FRAME_WINDOW_SECONDS?: string;
|
|
97
|
+
/**
|
|
98
|
+
* Per-IP simultaneous-connection cap (`maxConnectionsPerIp`). Enforced
|
|
99
|
+
* by the adapters' admission gates; mirrors the Lambda loader's var of
|
|
100
|
+
* the same name.
|
|
101
|
+
*/
|
|
102
|
+
MAX_CONNECTIONS_PER_IP?: string;
|
|
103
|
+
/**
|
|
104
|
+
* Per-IP connection-rate budget (`perIpConnectionRate.max`) — how many
|
|
105
|
+
* new connections one source IP may open per sliding window. The unset
|
|
106
|
+
* half of the knob falls back to the schema default.
|
|
107
|
+
*/
|
|
108
|
+
PER_IP_CONNECTION_RATE_MAX?: string;
|
|
109
|
+
/** Sliding window length in ms (`perIpConnectionRate.windowMs`). */
|
|
110
|
+
PER_IP_CONNECTION_RATE_WINDOW_MS?: string;
|
|
111
|
+
/**
|
|
112
|
+
* Operator opt-in for certificate-backed SASL EXTERNAL. 'true'/'1'
|
|
113
|
+
* (case-insensitive) map to `sasl.externalEnabled: true`; any other
|
|
114
|
+
* value (and unset) leaves the mechanism off. EXTERNAL additionally
|
|
115
|
+
* requires edge mTLS (API Shield client certs) and a secure (TLS)
|
|
116
|
+
* connection — the flag alone advertises nothing.
|
|
117
|
+
*/
|
|
118
|
+
EXTERNAL_ENABLED?: string;
|
|
62
119
|
}
|
|
63
120
|
|
|
64
121
|
/**
|
|
@@ -91,14 +148,36 @@ function buildConfigInput(env: CfConfigEnv): Record<string, unknown> {
|
|
|
91
148
|
input.channelPrefixes = env.CHANNEL_PREFIXES;
|
|
92
149
|
}
|
|
93
150
|
if (env.OPER_USER !== undefined || env.OPER_PASSWORD !== undefined) {
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
151
|
+
// Hashed form (OPER_USER + OPER_SALT + OPER_HASH) is preferred when
|
|
152
|
+
// both salt + hash are supplied — even if OPER_PASSWORD is also set,
|
|
153
|
+
// so a deployment can rotate to the hashed form without dropping the
|
|
154
|
+
// legacy var in the same release. The legacy plaintext
|
|
155
|
+
// `{user, password}` shape is retained for a single deprecation-
|
|
156
|
+
// cycle window.
|
|
157
|
+
const hasHashed = env.OPER_SALT !== undefined && env.OPER_HASH !== undefined;
|
|
158
|
+
if (hasHashed) {
|
|
159
|
+
input.operCreds = [
|
|
160
|
+
{
|
|
161
|
+
user: env.OPER_USER ?? '',
|
|
162
|
+
salt: env.OPER_SALT ?? '',
|
|
163
|
+
hash: env.OPER_HASH ?? '',
|
|
164
|
+
},
|
|
165
|
+
];
|
|
166
|
+
} else {
|
|
167
|
+
// Build the legacy cred object from whatever was supplied; the
|
|
168
|
+
// schema surfaces a `user`/`password` error if only one half was
|
|
169
|
+
// provided. If only OPER_SALT/OPER_HASH (partial) are set, the
|
|
170
|
+
// schema will surface the missing hashed-form field via the
|
|
171
|
+
// superRefine error path.
|
|
172
|
+
input.operCreds = [
|
|
173
|
+
{
|
|
174
|
+
user: env.OPER_USER ?? '',
|
|
175
|
+
password: env.OPER_PASSWORD ?? '',
|
|
176
|
+
...(env.OPER_SALT !== undefined ? { salt: env.OPER_SALT } : {}),
|
|
177
|
+
...(env.OPER_HASH !== undefined ? { hash: env.OPER_HASH } : {}),
|
|
178
|
+
},
|
|
179
|
+
];
|
|
180
|
+
}
|
|
102
181
|
}
|
|
103
182
|
if (env.SERVER_PASSWORD !== undefined) {
|
|
104
183
|
input.serverPassword = env.SERVER_PASSWORD;
|
|
@@ -127,9 +206,55 @@ function buildConfigInput(env: CfConfigEnv): Record<string, unknown> {
|
|
|
127
206
|
if (env.SASL_ACCOUNTS !== undefined && env.SASL_ACCOUNTS.length > 0) {
|
|
128
207
|
input.saslAccounts = parseSaslAccountsLines(env.SASL_ACCOUNTS);
|
|
129
208
|
}
|
|
209
|
+
if (env.MAX_FRAMES_PER_WINDOW !== undefined || env.FRAME_WINDOW_SECONDS !== undefined) {
|
|
210
|
+
// Partial sections are fine: the per-field schema defaults fill the
|
|
211
|
+
// knob(s) the env did not supply.
|
|
212
|
+
input.adapter = {
|
|
213
|
+
...(env.MAX_FRAMES_PER_WINDOW !== undefined
|
|
214
|
+
? { maxFramesPerWindow: Number.parseInt(env.MAX_FRAMES_PER_WINDOW, 10) }
|
|
215
|
+
: {}),
|
|
216
|
+
...(env.FRAME_WINDOW_SECONDS !== undefined
|
|
217
|
+
? { frameWindowSeconds: Number.parseInt(env.FRAME_WINDOW_SECONDS, 10) }
|
|
218
|
+
: {}),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
if (env.MAX_CONNECTIONS_PER_IP !== undefined) {
|
|
222
|
+
input.maxConnectionsPerIp = Number.parseInt(env.MAX_CONNECTIONS_PER_IP, 10);
|
|
223
|
+
}
|
|
224
|
+
if (
|
|
225
|
+
env.PER_IP_CONNECTION_RATE_MAX !== undefined ||
|
|
226
|
+
env.PER_IP_CONNECTION_RATE_WINDOW_MS !== undefined
|
|
227
|
+
) {
|
|
228
|
+
// The schema requires both halves; the unset one falls back to the
|
|
229
|
+
// documented schema defaults so an operator can tune just the budget
|
|
230
|
+
// (or just the window) without restating the other.
|
|
231
|
+
input.perIpConnectionRate = {
|
|
232
|
+
max:
|
|
233
|
+
env.PER_IP_CONNECTION_RATE_MAX !== undefined
|
|
234
|
+
? Number.parseInt(env.PER_IP_CONNECTION_RATE_MAX, 10)
|
|
235
|
+
: DEFAULT_PER_IP_RATE_MAX,
|
|
236
|
+
windowMs:
|
|
237
|
+
env.PER_IP_CONNECTION_RATE_WINDOW_MS !== undefined
|
|
238
|
+
? Number.parseInt(env.PER_IP_CONNECTION_RATE_WINDOW_MS, 10)
|
|
239
|
+
: DEFAULT_PER_IP_RATE_WINDOW_MS,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
if (env.EXTERNAL_ENABLED !== undefined) {
|
|
243
|
+
input.sasl = { externalEnabled: parseEnabledFlag(env.EXTERNAL_ENABLED) };
|
|
244
|
+
}
|
|
130
245
|
return input;
|
|
131
246
|
}
|
|
132
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Parses a boolean feature-flag env var: 'true' / '1' (case-insensitive,
|
|
250
|
+
* whitespace-trimmed) are truthy; every other value is false. Flags
|
|
251
|
+
* default off, so a typo must never silently enable the feature.
|
|
252
|
+
*/
|
|
253
|
+
function parseEnabledFlag(raw: string): boolean {
|
|
254
|
+
const normalized = raw.trim().toLowerCase();
|
|
255
|
+
return normalized === 'true' || normalized === '1';
|
|
256
|
+
}
|
|
257
|
+
|
|
133
258
|
/**
|
|
134
259
|
* Parses the `CREATED_AT` env var into the schema's `createdAt` field.
|
|
135
260
|
*
|