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,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket `Origin` policy for the AWS adapter's `$connect` Lambda —
|
|
3
|
+
* Cross-Site WebSocket Hijacking (CSWSH) defence.
|
|
4
|
+
*
|
|
5
|
+
* Browsers send an `Origin` header on every cross-origin WebSocket
|
|
6
|
+
* upgrade (RFC 6454); WebSocket upgrades do not otherwise enforce
|
|
7
|
+
* same-origin. Without a check, a malicious page can open a WebSocket
|
|
8
|
+
* to the IRC server from the victim's browser and drive the session
|
|
9
|
+
* with the victim's credentials. API Gateway WebSocket has no
|
|
10
|
+
* built-in `Origin` validation — the `$connect` Lambda receives
|
|
11
|
+
* `event.headers.Origin` and must enforce the allowlist itself.
|
|
12
|
+
*
|
|
13
|
+
* This module mirrors the canonical Cloudflare Worker policy
|
|
14
|
+
* (`apps/cf-worker/src/origin-allowlist.ts`) for the parsing
|
|
15
|
+
* semantics (comma-separated, trimmed, lowercased) and the
|
|
16
|
+
* "missing `Origin` = non-browser → allow" branch.
|
|
17
|
+
*
|
|
18
|
+
* **Deliberate divergence from the CF policy:** the AWS adapter
|
|
19
|
+
* supports ONLY the explicit-allowlist mode. The CF Worker's Mode 2
|
|
20
|
+
* (auto-derive the expected origin from the request's own `Host`
|
|
21
|
+
* header — same-origin enforcement) does not apply on AWS, because
|
|
22
|
+
* the SPA is served from a different CloudFront origin than the API
|
|
23
|
+
* Gateway WebSocket endpoint (`wss://{api-id}.execute-api.…`), so
|
|
24
|
+
* the request's own host is never the SPA's origin. The CSWSH
|
|
25
|
+
* defence is therefore opt-in: it only fires when `WEB_ORIGINS` is
|
|
26
|
+
* set. Unset → no Origin policy (existing AWS deployments without a
|
|
27
|
+
* web frontend are unchanged on upgrade).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Outcome of evaluating a `$connect` upgrade's `Origin` header.
|
|
32
|
+
*
|
|
33
|
+
* • `'allow'` — proceed: the origin is allowlisted, or the
|
|
34
|
+
* allowlist is empty (defence disabled), or the
|
|
35
|
+
* header is absent (non-browser client).
|
|
36
|
+
* • `'deny'` — reject (`403`): a browser sent an `Origin` that
|
|
37
|
+
* is not in the configured allowlist.
|
|
38
|
+
*/
|
|
39
|
+
export type ConnectOriginDecision = 'allow' | 'deny';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parse the comma-separated `WEB_ORIGINS` env var into a normalised
|
|
43
|
+
* set of lowercased origins. Whitespace-only and empty entries are
|
|
44
|
+
* dropped so trailing commas / stray spaces do not pollute the set.
|
|
45
|
+
* An empty/undefined var yields an empty set, which
|
|
46
|
+
* {@link decideConnectOrigin} treats as "defence disabled" (NOT
|
|
47
|
+
* deny-all).
|
|
48
|
+
*
|
|
49
|
+
* Origins are lowercased wholesale for case-insensitive matching;
|
|
50
|
+
* the scheme and host are case-insensitive per RFC 3986, and
|
|
51
|
+
* lowercasing the whole string is the simplest normalisation that
|
|
52
|
+
* handles the real-world browser-emitted form
|
|
53
|
+
* (`https://Example.com` → `https://example.com`). Port numbers —
|
|
54
|
+
* the only case-sensitive trailing component per the URI spec — are
|
|
55
|
+
* always digits in practice, so lowercasing is a no-op on them.
|
|
56
|
+
*/
|
|
57
|
+
export function parseWebOrigins(raw: string | undefined): Set<string> {
|
|
58
|
+
return new Set(
|
|
59
|
+
(raw ?? '')
|
|
60
|
+
.split(',')
|
|
61
|
+
.map((entry) => entry.trim().toLowerCase())
|
|
62
|
+
.filter((entry) => entry.length > 0),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Decide whether a `$connect` upgrade's `Origin` header is permitted.
|
|
68
|
+
*
|
|
69
|
+
* @param originHeader the raw `Origin` header value from
|
|
70
|
+
* `event.headers.Origin` (`null` / `undefined` /
|
|
71
|
+
* `''` when absent — non-browser clients).
|
|
72
|
+
* @param allowlist the parsed {@link parseWebOrigins} set. When
|
|
73
|
+
* empty, the defence is disabled and every
|
|
74
|
+
* upgrade is allowed regardless of its `Origin`.
|
|
75
|
+
* @returns `'allow'` to proceed with the upgrade, or `'deny'` to
|
|
76
|
+
* return `403` so API Gateway closes the connection.
|
|
77
|
+
*/
|
|
78
|
+
export function decideConnectOrigin(
|
|
79
|
+
originHeader: string | null | undefined,
|
|
80
|
+
allowlist: Set<string>,
|
|
81
|
+
): ConnectOriginDecision {
|
|
82
|
+
// Missing Origin = non-browser client (curl, WeeChat, tcp-ws-forwarder).
|
|
83
|
+
if (originHeader === null || originHeader === undefined || originHeader === '') {
|
|
84
|
+
return 'allow';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Defence disabled — WEB_ORIGINS unset/empty. Every Origin proceeds.
|
|
88
|
+
if (allowlist.size === 0) {
|
|
89
|
+
return 'allow';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const origin = originHeader.trim().toLowerCase();
|
|
93
|
+
return allowlist.has(origin) ? 'allow' : 'deny';
|
|
94
|
+
}
|
|
@@ -84,6 +84,20 @@ export interface MarshalledConnection {
|
|
|
84
84
|
away?: string;
|
|
85
85
|
saslMech?: string;
|
|
86
86
|
saslBuffer?: string;
|
|
87
|
+
/**
|
|
88
|
+
* Optimistic-concurrency revision counter. Incremented on every
|
|
89
|
+
* `$default`/NLB state write (conditional `UpdateItem` with
|
|
90
|
+
* `ConditionExpression: revision = :expected`) so two concurrent
|
|
91
|
+
* Lambda invocations for the same connection cannot silently
|
|
92
|
+
* clobber each other's state (the registration deadlock where
|
|
93
|
+
* `CAP END`'s `capNegotiating=false` got overwritten by a later
|
|
94
|
+
* frame's stale read). Absent on rows written before this field
|
|
95
|
+
* shipped; the persist path treats a missing attribute as revision
|
|
96
|
+
* `0` via an `attribute_not_exists(#rev) OR #rev = :expected`
|
|
97
|
+
* condition, so the migration is seamless. Seeded to `0` by
|
|
98
|
+
* {@link marshalConnection} at `$connect`.
|
|
99
|
+
*/
|
|
100
|
+
revision?: number;
|
|
87
101
|
/**
|
|
88
102
|
* Negotiated IRCv3 WebSocket frame mode persisted at `$connect` time.
|
|
89
103
|
* Absent (`undefined`) on legacy connections (no subprotocol agreed) so
|
|
@@ -93,6 +107,14 @@ export interface MarshalledConnection {
|
|
|
93
107
|
* subsequent frame.
|
|
94
108
|
*/
|
|
95
109
|
wsMode?: WsFrameMode;
|
|
110
|
+
/**
|
|
111
|
+
* Source IP of the connecting client, persisted at `$connect` (and on
|
|
112
|
+
* NLB flow creation) so the `sourceIp` GSI (`sourceIp-connectedSince`)
|
|
113
|
+
* carries the row for the per-IP admission walks. Absent on rows
|
|
114
|
+
* written before this field shipped — those are invisible to the
|
|
115
|
+
* index (a GSI item requires both key attributes).
|
|
116
|
+
*/
|
|
117
|
+
sourceIp?: string;
|
|
96
118
|
}
|
|
97
119
|
|
|
98
120
|
/**
|
|
@@ -116,6 +138,7 @@ export function marshalConnection(state: ConnectionState, idleSince: number): Ma
|
|
|
116
138
|
connectedSince: state.connectedSince,
|
|
117
139
|
idleSince,
|
|
118
140
|
version: CONNECTION_VERSION,
|
|
141
|
+
revision: 0,
|
|
119
142
|
};
|
|
120
143
|
if (state.joinedChannels.size > 0) {
|
|
121
144
|
// Copy so later mutations to `state` don't alias the stored value.
|
|
@@ -14,13 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
/** Logical table names — also used as CDK construct ids. */
|
|
17
|
-
export type TableName =
|
|
18
|
-
| 'Connections'
|
|
19
|
-
| 'ChannelMeta'
|
|
20
|
-
| 'ChannelMembers'
|
|
21
|
-
| 'Nicks'
|
|
22
|
-
| 'Accounts'
|
|
23
|
-
| 'Services';
|
|
17
|
+
export type TableName = 'Connections' | 'ChannelMeta' | 'ChannelMembers' | 'Nicks' | 'Services';
|
|
24
18
|
|
|
25
19
|
/** Logical table names in stable order. */
|
|
26
20
|
export const TABLE_NAMES: readonly TableName[] = [
|
|
@@ -28,7 +22,6 @@ export const TABLE_NAMES: readonly TableName[] = [
|
|
|
28
22
|
'ChannelMeta',
|
|
29
23
|
'ChannelMembers',
|
|
30
24
|
'Nicks',
|
|
31
|
-
'Accounts',
|
|
32
25
|
'Services',
|
|
33
26
|
];
|
|
34
27
|
|
|
@@ -47,7 +40,6 @@ export const TABLE_KEYS = {
|
|
|
47
40
|
ChannelMeta: { pk: 'channelName' },
|
|
48
41
|
ChannelMembers: { pk: 'channelName', sk: 'connectionId' },
|
|
49
42
|
Nicks: { pk: 'nickLower' },
|
|
50
|
-
Accounts: { pk: 'account' },
|
|
51
43
|
} as const;
|
|
52
44
|
|
|
53
45
|
/**
|
|
@@ -58,14 +50,13 @@ export const TABLE_KEYS = {
|
|
|
58
50
|
* `Services` is optional: the services-persistence table is provisioned by
|
|
59
51
|
* {@link TABLE_DEFS} but the runtime reads `SERVICES_TABLE` directly and
|
|
60
52
|
* tolerates an unset var (services unbound). A `TablesConfig` built from
|
|
61
|
-
* the required-
|
|
53
|
+
* the required-four env vars still type-checks.
|
|
62
54
|
*/
|
|
63
55
|
export type TablesConfig = {
|
|
64
56
|
Connections: string;
|
|
65
57
|
ChannelMeta: string;
|
|
66
58
|
ChannelMembers: string;
|
|
67
59
|
Nicks: string;
|
|
68
|
-
Accounts: string;
|
|
69
60
|
Services?: string;
|
|
70
61
|
};
|
|
71
62
|
|
|
@@ -75,9 +66,17 @@ export function tablesConfigFromNames(names: {
|
|
|
75
66
|
ChannelMeta: string;
|
|
76
67
|
ChannelMembers: string;
|
|
77
68
|
Nicks: string;
|
|
78
|
-
Accounts: string;
|
|
79
69
|
Services?: string;
|
|
80
70
|
}): TablesConfig {
|
|
81
71
|
const out: TablesConfig = { ...names };
|
|
82
72
|
return out;
|
|
83
73
|
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Name of the per-IP admission GSI on the `Connections` table
|
|
77
|
+
* (partition key `sourceIp`, sort key `connectedSince`). Consulted by the
|
|
78
|
+
* `$connect` and NLB handlers' per-IP admission gates; declared on the
|
|
79
|
+
* deployed table by `TABLE_DEFS` in `cdk-table-defs.ts`. Shared here so
|
|
80
|
+
* the runtime (this module) and the CDK definition reference one name.
|
|
81
|
+
*/
|
|
82
|
+
export const SOURCE_IP_INDEX_NAME = 'sourceIp-connectedSince';
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pure unit tests for the AWS `$connect` admission policy
|
|
3
3
|
* (`src/admission.ts`). No DynamoDB — these exercise every branch of
|
|
4
|
-
* {@link decideConnectAdmission} directly so the per-IP
|
|
5
|
-
*
|
|
4
|
+
* {@link decideConnectAdmission} directly so the per-IP branches are
|
|
5
|
+
* covered without standing up DynamoDB Local.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { describe, expect, it } from 'vitest';
|
|
@@ -67,4 +67,62 @@ describe('decideConnectAdmission', () => {
|
|
|
67
67
|
reason: 'server full',
|
|
68
68
|
});
|
|
69
69
|
});
|
|
70
|
+
|
|
71
|
+
it('admits when a rate budget is set but no recent count is supplied', () => {
|
|
72
|
+
expect(
|
|
73
|
+
decideConnectAdmission(
|
|
74
|
+
{ total: 0 },
|
|
75
|
+
{ maxClients: 10, perIpRate: { max: 5, windowMs: 60_000 } },
|
|
76
|
+
),
|
|
77
|
+
).toEqual({ admitted: true });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('admits when the recent per-IP count is below the rate budget', () => {
|
|
81
|
+
expect(
|
|
82
|
+
decideConnectAdmission(
|
|
83
|
+
{ total: 0, recentPerIp: 4 },
|
|
84
|
+
{ maxClients: 10, perIpRate: { max: 5, windowMs: 60_000 } },
|
|
85
|
+
),
|
|
86
|
+
).toEqual({ admitted: true });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('rejects with 429 once the recent per-IP count reaches the rate budget', () => {
|
|
90
|
+
expect(
|
|
91
|
+
decideConnectAdmission(
|
|
92
|
+
{ total: 0, recentPerIp: 5 },
|
|
93
|
+
{ maxClients: 10, perIpRate: { max: 5, windowMs: 60_000 } },
|
|
94
|
+
),
|
|
95
|
+
).toEqual({
|
|
96
|
+
admitted: false,
|
|
97
|
+
statusCode: 429,
|
|
98
|
+
reason: 'connection rate exceeded for this IP',
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('checks the per-IP simultaneous cap before the rate budget', () => {
|
|
103
|
+
// A caller supplying both over-budget counts gets the simultaneous-
|
|
104
|
+
// cap reason: the more specific signal for an already-connected IP.
|
|
105
|
+
expect(
|
|
106
|
+
decideConnectAdmission(
|
|
107
|
+
{ total: 0, perIp: 3, recentPerIp: 5 },
|
|
108
|
+
{ maxClients: 10, maxConnectionsPerIp: 3, perIpRate: { max: 5, windowMs: 60_000 } },
|
|
109
|
+
),
|
|
110
|
+
).toEqual({
|
|
111
|
+
admitted: false,
|
|
112
|
+
statusCode: 429,
|
|
113
|
+
reason: 'too many connections from this IP',
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('ignores a stale recent count (window decayed) and admits', () => {
|
|
118
|
+
// Window-reset behaviour at the policy seam: a recent count of 0
|
|
119
|
+
// (every prior admission aged out of the window) never trips the
|
|
120
|
+
// rate branch even at a budget of 1.
|
|
121
|
+
expect(
|
|
122
|
+
decideConnectAdmission(
|
|
123
|
+
{ total: 0, recentPerIp: 0 },
|
|
124
|
+
{ maxClients: 10, perIpRate: { max: 1, windowMs: 60_000 } },
|
|
125
|
+
),
|
|
126
|
+
).toEqual({ admitted: true });
|
|
127
|
+
});
|
|
70
128
|
});
|
|
@@ -97,7 +97,6 @@ export class AwsHarness implements IrcHarness {
|
|
|
97
97
|
ChannelMeta: `${this.prefix}ChannelMeta`,
|
|
98
98
|
ChannelMembers: `${this.prefix}ChannelMembers`,
|
|
99
99
|
Nicks: `${this.prefix}Nicks`,
|
|
100
|
-
Accounts: `${this.prefix}Accounts`,
|
|
101
100
|
};
|
|
102
101
|
this.client = createDynamoDocumentClient({ endpoint });
|
|
103
102
|
}
|
|
@@ -212,7 +211,7 @@ export class AwsHarness implements IrcHarness {
|
|
|
212
211
|
const pkName = props.partitionKey?.name;
|
|
213
212
|
const skName = props.sortKey?.name;
|
|
214
213
|
if (pkName === undefined) throw new Error(`table ${logical} missing partitionKey`);
|
|
215
|
-
const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' }> = [
|
|
214
|
+
const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' | 'N' }> = [
|
|
216
215
|
{ AttributeName: pkName, AttributeType: 'S' },
|
|
217
216
|
];
|
|
218
217
|
const keySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
|
|
@@ -222,11 +221,33 @@ export class AwsHarness implements IrcHarness {
|
|
|
222
221
|
attributeDefinitions.push({ AttributeName: skName, AttributeType: 'S' });
|
|
223
222
|
keySchema.push({ AttributeName: skName, KeyType: 'RANGE' });
|
|
224
223
|
}
|
|
224
|
+
// Mirror the CDK-declared secondary indexes (the Connections
|
|
225
|
+
// per-IP admission GSI) so the fixture matches the deployed schema.
|
|
226
|
+
const globalSecondaryIndexes = (props.globalSecondaryIndexes ?? []).map((g) => {
|
|
227
|
+
const gpk = g.partitionKey;
|
|
228
|
+
if (gpk === undefined) throw new Error(`index ${g.indexName} missing partitionKey`);
|
|
229
|
+
const gsiKeySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
|
|
230
|
+
{ AttributeName: gpk.name, KeyType: 'HASH' },
|
|
231
|
+
];
|
|
232
|
+
attributeDefinitions.push({ AttributeName: gpk.name, AttributeType: 'S' });
|
|
233
|
+
if (g.sortKey !== undefined) {
|
|
234
|
+
gsiKeySchema.push({ AttributeName: g.sortKey.name, KeyType: 'RANGE' });
|
|
235
|
+
attributeDefinitions.push({ AttributeName: g.sortKey.name, AttributeType: 'N' });
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
IndexName: g.indexName,
|
|
239
|
+
KeySchema: gsiKeySchema,
|
|
240
|
+
Projection: { ProjectionType: 'ALL' as const },
|
|
241
|
+
};
|
|
242
|
+
});
|
|
225
243
|
await this.client.send(
|
|
226
244
|
new CreateTableCommand({
|
|
227
245
|
TableName: `${this.prefix}${logical}`,
|
|
228
246
|
AttributeDefinitions: attributeDefinitions,
|
|
229
247
|
KeySchema: keySchema,
|
|
248
|
+
...(globalSecondaryIndexes.length > 0
|
|
249
|
+
? { GlobalSecondaryIndexes: globalSecondaryIndexes }
|
|
250
|
+
: {}),
|
|
230
251
|
BillingMode: 'PAY_PER_REQUEST',
|
|
231
252
|
}),
|
|
232
253
|
);
|
|
@@ -471,6 +471,70 @@ describe.skipIf(!available)('AwsRuntime', () => {
|
|
|
471
471
|
});
|
|
472
472
|
});
|
|
473
473
|
|
|
474
|
+
// -------------------------------------------------------------------------
|
|
475
|
+
// broadcastOperNotice — Scan-based fan-out to every +o connection (the
|
|
476
|
+
// OPER lockout gate's oper notice)
|
|
477
|
+
// -------------------------------------------------------------------------
|
|
478
|
+
|
|
479
|
+
describe('broadcastOperNotice', () => {
|
|
480
|
+
it('delivers to every +o connection and skips the except id', async () => {
|
|
481
|
+
const receivedA: string[] = [];
|
|
482
|
+
const receivedB: string[] = [];
|
|
483
|
+
const stateA = createConnection({ id: 'o-a', connectedSince: 0 });
|
|
484
|
+
stateA.userModes.oper = true;
|
|
485
|
+
const stateB = createConnection({ id: 'o-b', connectedSince: 0 });
|
|
486
|
+
stateB.userModes.oper = true;
|
|
487
|
+
|
|
488
|
+
const mgmt = new LocalPostToConnection();
|
|
489
|
+
mgmt.register('o-a', (data) => {
|
|
490
|
+
for (const line of data.split('\r\n')) {
|
|
491
|
+
if (line.length > 0) receivedA.push(line);
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
mgmt.register('o-b', (data) => {
|
|
495
|
+
for (const line of data.split('\r\n')) {
|
|
496
|
+
if (line.length > 0) receivedB.push(line);
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
const rtA = makeRuntime('o-a', handlersSink(receivedA, stateA), undefined, mgmt);
|
|
501
|
+
const rtB = makeRuntime('o-b', handlersSink(receivedB, stateB), undefined, mgmt);
|
|
502
|
+
|
|
503
|
+
await rtA.persistConnectionState(stateA);
|
|
504
|
+
await rtB.persistConnectionState(stateB);
|
|
505
|
+
|
|
506
|
+
// Caller 'o-a' is excepted; only 'o-b' should receive the notice.
|
|
507
|
+
await rtA.broadcastOperNotice(
|
|
508
|
+
[{ text: ':srv NOTICE * :OPER lockout triggered for h' }],
|
|
509
|
+
'o-a',
|
|
510
|
+
);
|
|
511
|
+
expect(receivedA).toEqual([]);
|
|
512
|
+
expect(receivedB).toEqual([':srv NOTICE * :OPER lockout triggered for h']);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it('does not deliver to a +w-only connection (the gate is +o)', async () => {
|
|
516
|
+
const received: string[] = [];
|
|
517
|
+
const state = createConnection({ id: 'o-wonly', connectedSince: 0 });
|
|
518
|
+
state.userModes.wallops = true;
|
|
519
|
+
const rt = makeRuntime('o-caller', handlersSink(received, state), undefined, null);
|
|
520
|
+
await rt.persistConnectionState(state);
|
|
521
|
+
await rt.broadcastOperNotice([{ text: ':srv NOTICE * :hi' }]);
|
|
522
|
+
expect(received).toEqual([]);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('delivers to the bound connection via in-process handlers when not excepted', async () => {
|
|
526
|
+
// The bound connection's own +o delivery goes through handlers.send,
|
|
527
|
+
// not APIGW. With no `except`, the bound oper conn is a recipient.
|
|
528
|
+
const received: string[] = [];
|
|
529
|
+
const state = createConnection({ id: 'self', connectedSince: 0 });
|
|
530
|
+
state.userModes.oper = true;
|
|
531
|
+
const rt = makeRuntime('self', handlersSink(received, state), undefined, null);
|
|
532
|
+
await rt.persistConnectionState(state);
|
|
533
|
+
await rt.broadcastOperNotice([{ text: ':srv NOTICE * :hi' }]);
|
|
534
|
+
expect(received).toEqual([':srv NOTICE * :hi']);
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
|
|
474
538
|
// -------------------------------------------------------------------------
|
|
475
539
|
// Nick registry — error propagation
|
|
476
540
|
// -------------------------------------------------------------------------
|
|
@@ -51,6 +51,35 @@ describe('buildLambdaConfigInput', () => {
|
|
|
51
51
|
expect(out.operCreds).toEqual([{ user: '', password: 'pw' }]);
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
it('builds a hashed operCred when OPER_USER + OPER_SALT + OPER_HASH are set', () => {
|
|
55
|
+
const out = buildLambdaConfigInput({
|
|
56
|
+
OPER_USER: 'admin',
|
|
57
|
+
OPER_SALT: 'c2FsdA==',
|
|
58
|
+
OPER_HASH: 'aGFzaA==',
|
|
59
|
+
});
|
|
60
|
+
expect(out.operCreds).toEqual([{ user: 'admin', salt: 'c2FsdA==', hash: 'aGFzaA==' }]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('prefers the hashed form over plaintext when both are set', () => {
|
|
64
|
+
const out = buildLambdaConfigInput({
|
|
65
|
+
OPER_USER: 'admin',
|
|
66
|
+
OPER_PASSWORD: 'legacy',
|
|
67
|
+
OPER_SALT: 'c2FsdA==',
|
|
68
|
+
OPER_HASH: 'aGFzaA==',
|
|
69
|
+
});
|
|
70
|
+
expect(out.operCreds).toEqual([{ user: 'admin', salt: 'c2FsdA==', hash: 'aGFzaA==' }]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('does NOT build operCreds when only OPER_SALT + OPER_HASH are set (no OPER_USER)', () => {
|
|
74
|
+
// Without OPER_USER the loader has no `user` field to thread; the
|
|
75
|
+
// hashed form requires a user name. The schema's superRefine would
|
|
76
|
+
// catch a missing user too, but the loader opts to omit operCreds
|
|
77
|
+
// entirely so the no-op (vs. `491 ERR_NOOPERHOST`) behaviour stays
|
|
78
|
+
// consistent with the legacy "no OPER_USER" path.
|
|
79
|
+
const out = buildLambdaConfigInput({ OPER_SALT: 'c2FsdA==', OPER_HASH: 'aGFzaA==' });
|
|
80
|
+
expect('operCreds' in out).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
54
83
|
it('threads SERVER_PASSWORD through to serverPassword', () => {
|
|
55
84
|
expect(buildLambdaConfigInput({ SERVER_PASSWORD: 'hunter2' }).serverPassword).toBe('hunter2');
|
|
56
85
|
});
|
|
@@ -155,6 +184,72 @@ describe('buildLambdaConfigInput', () => {
|
|
|
155
184
|
const out = buildLambdaConfigInput({ SASL_ACCOUNTS: ' alice:secret ' });
|
|
156
185
|
expect(out.saslAccounts).toEqual([{ username: 'alice', password: 'secret' }]);
|
|
157
186
|
});
|
|
187
|
+
|
|
188
|
+
it('does NOT thread WEB_ORIGINS into the ServerConfig input', () => {
|
|
189
|
+
// WEB_ORIGINS is an adapter-level operational concern (consumed by
|
|
190
|
+
// `$connect`'s CSWSH defence), NOT a server-config knob. Routing
|
|
191
|
+
// it through the shared Zod schema would either be silently
|
|
192
|
+
// stripped (the schema is non-strict) or pollute the canonical
|
|
193
|
+
// `ServerConfig` type with an AWS-only field. The buildLambdaConfigInput
|
|
194
|
+
// function therefore accepts the typed env field but never emits
|
|
195
|
+
// it — `buildDepsFromEnv` parses it directly via `parseWebOrigins`.
|
|
196
|
+
const out = buildLambdaConfigInput({ WEB_ORIGINS: 'https://app.example.com' });
|
|
197
|
+
expect('webOrigins' in out).toBe(false);
|
|
198
|
+
expect('WEB_ORIGINS' in out).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('types WEB_ORIGINS as an optional string field on LambdaConfigEnv', () => {
|
|
202
|
+
// Compile-time assertion: the env interface accepts the field.
|
|
203
|
+
const env: LambdaConfigEnv = { WEB_ORIGINS: 'https://a.example.com,https://b.example.com' };
|
|
204
|
+
expect(env.WEB_ORIGINS).toBe('https://a.example.com,https://b.example.com');
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe('buildDepsFromEnv — WEB_ORIGINS wiring', () => {
|
|
209
|
+
// Build a minimal env shape that satisfies `tablesConfigFromEnv` and the
|
|
210
|
+
// shared config schema, pointing DynamoDB at an unreachable endpoint so
|
|
211
|
+
// `resolveAccountStore` / `loadDynamoServicesStore` fall through to
|
|
212
|
+
// defaults rather than scanning a live table. We are only asserting the
|
|
213
|
+
// env→deps wiring for `WEB_ORIGINS`.
|
|
214
|
+
function makeBaseEnv(): NodeJS.ProcessEnv {
|
|
215
|
+
return {
|
|
216
|
+
SERVER_NAME: 'irc.test',
|
|
217
|
+
NETWORK_NAME: 'TestNet',
|
|
218
|
+
CONNECTIONS_TABLE: 'Connections',
|
|
219
|
+
CHANNELMETA_TABLE: 'ChannelMeta',
|
|
220
|
+
CHANNELMEMBERS_TABLE: 'ChannelMembers',
|
|
221
|
+
NICKS_TABLE: 'Nicks',
|
|
222
|
+
ACCOUNTS_TABLE: 'Accounts',
|
|
223
|
+
DYNAMO_ENDPOINT: 'http://127.0.0.1:1',
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
it('parses WEB_ORIGINS into a Set on the returned deps', async () => {
|
|
228
|
+
// The env→deps wiring is the production path: `WEB_ORIGINS` is read
|
|
229
|
+
// once at cold start, parsed via `parseWebOrigins`, and stored on
|
|
230
|
+
// `HandlerDeps.webOrigins` so every `$connect` invocation reuses the
|
|
231
|
+
// same set.
|
|
232
|
+
const { buildDepsFromEnv } = await import('../src/handlers/index.js');
|
|
233
|
+
const deps = await buildDepsFromEnv({
|
|
234
|
+
...makeBaseEnv(),
|
|
235
|
+
WEB_ORIGINS: 'https://app.example.com, https://staging.app.example.com',
|
|
236
|
+
});
|
|
237
|
+
expect(deps.webOrigins).toEqual(
|
|
238
|
+
new Set(['https://app.example.com', 'https://staging.app.example.com']),
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('omits webOrigins from deps when WEB_ORIGINS is unset', async () => {
|
|
243
|
+
const { buildDepsFromEnv } = await import('../src/handlers/index.js');
|
|
244
|
+
const deps = await buildDepsFromEnv(makeBaseEnv());
|
|
245
|
+
expect(deps.webOrigins).toBeUndefined();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('omits webOrigins from deps when WEB_ORIGINS is an empty string', async () => {
|
|
249
|
+
const { buildDepsFromEnv } = await import('../src/handlers/index.js');
|
|
250
|
+
const deps = await buildDepsFromEnv({ ...makeBaseEnv(), WEB_ORIGINS: '' });
|
|
251
|
+
expect(deps.webOrigins).toBeUndefined();
|
|
252
|
+
});
|
|
158
253
|
});
|
|
159
254
|
|
|
160
255
|
describe('loadServerConfigFromLambdaEnv', () => {
|
|
@@ -248,3 +343,125 @@ describe('loadServerConfigFromLambdaEnv', () => {
|
|
|
248
343
|
]);
|
|
249
344
|
});
|
|
250
345
|
});
|
|
346
|
+
|
|
347
|
+
describe('loadServerConfigFromLambdaEnv — EXTERNAL_ENABLED (SASL EXTERNAL opt-in)', () => {
|
|
348
|
+
it('leaves SASL EXTERNAL off when EXTERNAL_ENABLED is unset', () => {
|
|
349
|
+
const cfg = loadServerConfigFromLambdaEnv({ SERVER_NAME: 's', NETWORK_NAME: 'n' });
|
|
350
|
+
expect(cfg.sasl?.externalEnabled ?? false).toBe(false);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('maps EXTERNAL_ENABLED=true to sasl.externalEnabled', () => {
|
|
354
|
+
const cfg = loadServerConfigFromLambdaEnv({
|
|
355
|
+
SERVER_NAME: 's',
|
|
356
|
+
NETWORK_NAME: 'n',
|
|
357
|
+
EXTERNAL_ENABLED: 'true',
|
|
358
|
+
});
|
|
359
|
+
expect(cfg.sasl?.externalEnabled).toBe(true);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('maps EXTERNAL_ENABLED=1 to sasl.externalEnabled', () => {
|
|
363
|
+
const cfg = loadServerConfigFromLambdaEnv({
|
|
364
|
+
SERVER_NAME: 's',
|
|
365
|
+
NETWORK_NAME: 'n',
|
|
366
|
+
EXTERNAL_ENABLED: '1',
|
|
367
|
+
});
|
|
368
|
+
expect(cfg.sasl?.externalEnabled).toBe(true);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('maps EXTERNAL_ENABLED=false (and any other value) to false', () => {
|
|
372
|
+
const cfg = loadServerConfigFromLambdaEnv({
|
|
373
|
+
SERVER_NAME: 's',
|
|
374
|
+
NETWORK_NAME: 'n',
|
|
375
|
+
EXTERNAL_ENABLED: 'false',
|
|
376
|
+
});
|
|
377
|
+
expect(cfg.sasl?.externalEnabled).toBe(false);
|
|
378
|
+
const junk = loadServerConfigFromLambdaEnv({
|
|
379
|
+
SERVER_NAME: 's',
|
|
380
|
+
NETWORK_NAME: 'n',
|
|
381
|
+
EXTERNAL_ENABLED: 'yes please',
|
|
382
|
+
});
|
|
383
|
+
expect(junk.sasl?.externalEnabled ?? false).toBe(false);
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
describe('loadServerConfigFromLambdaEnv — adapter frame-rate limit', () => {
|
|
388
|
+
it('defaults the adapter frame window when the vars are unset', () => {
|
|
389
|
+
const cfg = loadServerConfigFromLambdaEnv({ SERVER_NAME: 's', NETWORK_NAME: 'n' });
|
|
390
|
+
expect(cfg.adapter).toEqual({ maxFramesPerWindow: 50, frameWindowSeconds: 5 });
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('maps MAX_FRAMES_PER_WINDOW + FRAME_WINDOW_SECONDS to the adapter section', () => {
|
|
394
|
+
const cfg = loadServerConfigFromLambdaEnv({
|
|
395
|
+
SERVER_NAME: 's',
|
|
396
|
+
NETWORK_NAME: 'n',
|
|
397
|
+
MAX_FRAMES_PER_WINDOW: '20',
|
|
398
|
+
FRAME_WINDOW_SECONDS: '10',
|
|
399
|
+
});
|
|
400
|
+
expect(cfg.adapter).toEqual({ maxFramesPerWindow: 20, frameWindowSeconds: 10 });
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('fills the unset knob with its schema default when only one var is set', () => {
|
|
404
|
+
const cfg = loadServerConfigFromLambdaEnv({
|
|
405
|
+
SERVER_NAME: 's',
|
|
406
|
+
NETWORK_NAME: 'n',
|
|
407
|
+
FRAME_WINDOW_SECONDS: '30',
|
|
408
|
+
});
|
|
409
|
+
expect(cfg.adapter).toEqual({ maxFramesPerWindow: 50, frameWindowSeconds: 30 });
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('throws when MAX_FRAMES_PER_WINDOW is non-numeric', () => {
|
|
413
|
+
expect(() =>
|
|
414
|
+
loadServerConfigFromLambdaEnv({
|
|
415
|
+
SERVER_NAME: 's',
|
|
416
|
+
NETWORK_NAME: 'n',
|
|
417
|
+
MAX_FRAMES_PER_WINDOW: 'many',
|
|
418
|
+
}),
|
|
419
|
+
).toThrowError(/maxFramesPerWindow/u);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('throws when FRAME_WINDOW_SECONDS is non-positive', () => {
|
|
423
|
+
expect(() =>
|
|
424
|
+
loadServerConfigFromLambdaEnv({
|
|
425
|
+
SERVER_NAME: 's',
|
|
426
|
+
NETWORK_NAME: 'n',
|
|
427
|
+
FRAME_WINDOW_SECONDS: '-1',
|
|
428
|
+
}),
|
|
429
|
+
).toThrowError(/frameWindowSeconds/u);
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
describe('loadServerConfigFromLambdaEnv — per-IP admission knobs', () => {
|
|
434
|
+
it('defaults maxConnectionsPerIp / perIpConnectionRate from the schema', () => {
|
|
435
|
+
const cfg = loadServerConfigFromLambdaEnv({ SERVER_NAME: 's', NETWORK_NAME: 'n' });
|
|
436
|
+
expect(cfg.maxConnectionsPerIp).toBe(10);
|
|
437
|
+
expect(cfg.perIpConnectionRate).toEqual({ max: 5, windowMs: 60_000 });
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('parses MAX_CONNECTIONS_PER_IP as an integer', () => {
|
|
441
|
+
const cfg = loadServerConfigFromLambdaEnv({
|
|
442
|
+
SERVER_NAME: 's',
|
|
443
|
+
NETWORK_NAME: 'n',
|
|
444
|
+
MAX_CONNECTIONS_PER_IP: '4',
|
|
445
|
+
});
|
|
446
|
+
expect(cfg.maxConnectionsPerIp).toBe(4);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('parses PER_IP_CONNECTION_RATE_MAX / _WINDOW_MS into the rate knob', () => {
|
|
450
|
+
const cfg = loadServerConfigFromLambdaEnv({
|
|
451
|
+
SERVER_NAME: 's',
|
|
452
|
+
NETWORK_NAME: 'n',
|
|
453
|
+
PER_IP_CONNECTION_RATE_MAX: '10',
|
|
454
|
+
PER_IP_CONNECTION_RATE_WINDOW_MS: '30000',
|
|
455
|
+
});
|
|
456
|
+
expect(cfg.perIpConnectionRate).toEqual({ max: 10, windowMs: 30_000 });
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('fills the unset half of the rate knob with the schema default', () => {
|
|
460
|
+
const cfg = loadServerConfigFromLambdaEnv({
|
|
461
|
+
SERVER_NAME: 's',
|
|
462
|
+
NETWORK_NAME: 'n',
|
|
463
|
+
PER_IP_CONNECTION_RATE_WINDOW_MS: '10000',
|
|
464
|
+
});
|
|
465
|
+
expect(cfg.perIpConnectionRate).toEqual({ max: 5, windowMs: 10_000 });
|
|
466
|
+
});
|
|
467
|
+
});
|