serverless-ircd 0.10.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-cf-tcp.yml +26 -2
- package/.github/workflows/deploy-cf.yml +26 -0
- package/CHANGELOG.md +289 -0
- package/README.md +153 -20
- package/apps/aws-stack/bin/aws.ts +36 -0
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +221 -15
- package/apps/aws-stack/tests/stack.test.ts +450 -16
- package/apps/cf-tcp-container/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -2
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +256 -79
- 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 +17 -4
- package/apps/cf-worker/package.json +2 -2
- 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 +86 -9
- 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 +23 -2
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/package.json +1 -1
- package/docs/AWS-Deployment.md +123 -22
- package/docs/AWS-TCP-Deployment.md +37 -2
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +9 -2
- package/docs/Cloudflare-TCP-Deployment.md +135 -52
- package/docs/SASL-EXTERNAL.md +175 -0
- package/package.json +3 -3
- 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 +34 -6
- package/packages/aws-adapter/src/config-loader.ts +134 -6
- package/packages/aws-adapter/src/dynamo-services-store.ts +12 -0
- package/packages/aws-adapter/src/handlers/connect.ts +47 -1
- package/packages/aws-adapter/src/handlers/default.ts +95 -6
- package/packages/aws-adapter/src/handlers/index.ts +31 -2
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +132 -8
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/serialize.ts +8 -0
- package/packages/aws-adapter/src/tables.ts +9 -0
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +151 -0
- package/packages/aws-adapter/tests/connect.test.ts +199 -2
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +10 -3
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +123 -1
- package/packages/aws-adapter/tests/handlers.test.ts +57 -1
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/cf-runtime.ts +48 -9
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +154 -21
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +47 -5
- package/packages/cf-adapter/src/env.ts +88 -0
- package/packages/cf-adapter/src/index.ts +17 -1
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +104 -15
- 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-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +74 -5
- 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 +192 -1
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -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/caps/capabilities.ts +20 -10
- package/packages/irc-core/src/certfp.ts +178 -0
- 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/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 +40 -1
- 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 +146 -2
- package/packages/irc-core/src/commands/sasl.ts +136 -19
- 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 +393 -20
- 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 +8 -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 +395 -36
- 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 +228 -13
- package/packages/irc-core/src/ws-framing.ts +5 -4
- 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/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +182 -2
- package/packages/irc-core/tests/commands/oper.test.ts +560 -2
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +463 -1
- package/packages/irc-core/tests/commands/sasl.test.ts +596 -7
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +2 -0
- package/packages/irc-core/tests/config.test.ts +534 -2
- 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/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 +141 -0
- package/packages/irc-core/tests/ports.test.ts +99 -7
- package/packages/irc-core/tests/services-store.test.ts +376 -14
- package/packages/irc-core/tests/ws-framing.test.ts +45 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +123 -8
- 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 +544 -7
- 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/scripts/package.json +1 -1
- 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 +17 -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-hostname.test.ts +348 -0
- package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -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/tcp-ws-forwarder/package.json +1 -1
|
@@ -893,3 +893,77 @@ describe('InMemoryRuntime — broadcastWallops', () => {
|
|
|
893
893
|
expect(c1Received).toBe(0);
|
|
894
894
|
});
|
|
895
895
|
});
|
|
896
|
+
|
|
897
|
+
describe('InMemoryRuntime — broadcastOperNotice', () => {
|
|
898
|
+
it('delivers lines to every connection with the +o user mode set', async () => {
|
|
899
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
900
|
+
const received: Record<string, RawLine[][]> = { c1: [], c2: [], c3: [] };
|
|
901
|
+
const push = (id: string) => (lines: RawLine[]) => received[id]?.push(lines);
|
|
902
|
+
// c1 has +w but NOT +o; c2 and c3 are opers.
|
|
903
|
+
const c1 = makeConn('c1', 'alice');
|
|
904
|
+
c1.userModes.wallops = true;
|
|
905
|
+
rt.registerConnection(c1, { send: push('c1'), disconnect: () => {} });
|
|
906
|
+
const c2 = makeConn('c2', 'bob');
|
|
907
|
+
c2.userModes.oper = true;
|
|
908
|
+
rt.registerConnection(c2, { send: push('c2'), disconnect: () => {} });
|
|
909
|
+
const c3 = makeConn('c3', 'carol');
|
|
910
|
+
c3.userModes.oper = true;
|
|
911
|
+
rt.registerConnection(c3, { send: push('c3'), disconnect: () => {} });
|
|
912
|
+
|
|
913
|
+
await rt.broadcastOperNotice([L(':srv NOTICE * :OPER lockout triggered for 10.0.0.1')]);
|
|
914
|
+
|
|
915
|
+
// +w without +o is NOT enough — the oper notice is oper-only.
|
|
916
|
+
expect(received.c1).toHaveLength(0);
|
|
917
|
+
expect(received.c2).toHaveLength(1);
|
|
918
|
+
expect(received.c3).toHaveLength(1);
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
it('skips the except connection even when it is an oper', async () => {
|
|
922
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
923
|
+
const received: Record<string, RawLine[][]> = { c1: [], c2: [] };
|
|
924
|
+
const push = (id: string) => (lines: RawLine[]) => received[id]?.push(lines);
|
|
925
|
+
const oper = makeConn('c1', 'alice');
|
|
926
|
+
oper.userModes.oper = true;
|
|
927
|
+
rt.registerConnection(oper, { send: push('c1'), disconnect: () => {} });
|
|
928
|
+
const peer = makeConn('c2', 'bob');
|
|
929
|
+
peer.userModes.oper = true;
|
|
930
|
+
rt.registerConnection(peer, { send: push('c2'), disconnect: () => {} });
|
|
931
|
+
|
|
932
|
+
await rt.broadcastOperNotice([L(':srv NOTICE * :hi')], 'c1');
|
|
933
|
+
|
|
934
|
+
expect(received.c1).toHaveLength(0);
|
|
935
|
+
expect(received.c2).toHaveLength(1);
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
it('is a no-op when no connection has +o', async () => {
|
|
939
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
940
|
+
let c1Received = 0;
|
|
941
|
+
rt.registerConnection(makeConn('c1', 'alice'), {
|
|
942
|
+
send: () => {
|
|
943
|
+
c1Received++;
|
|
944
|
+
},
|
|
945
|
+
disconnect: () => {},
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
await rt.broadcastOperNotice([L(':srv NOTICE * :hi')]);
|
|
949
|
+
expect(c1Received).toBe(0);
|
|
950
|
+
});
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
describe('InMemoryRuntime — operFailures tracker', () => {
|
|
954
|
+
it('exposes one shared OperFailureStats instance bound to the runtime clock', async () => {
|
|
955
|
+
const clock = new FakeClock(1_000);
|
|
956
|
+
const rt = new InMemoryRuntime({ clock });
|
|
957
|
+
|
|
958
|
+
const tracker = rt.operFailures;
|
|
959
|
+
expect(tracker).toBeDefined();
|
|
960
|
+
// Same instance every read: adapters hand it to every connection's
|
|
961
|
+
// actor so the per-IP budget is shared fleet-wide.
|
|
962
|
+
expect(rt.operFailures).toBe(tracker);
|
|
963
|
+
|
|
964
|
+
tracker.recordFailure('10.0.0.1');
|
|
965
|
+
clock.advance(1_000);
|
|
966
|
+
tracker.recordFailure('10.0.0.1');
|
|
967
|
+
expect(tracker.recentFailures('10.0.0.1', 300_000)).toBe(2);
|
|
968
|
+
});
|
|
969
|
+
});
|
|
@@ -16,7 +16,8 @@ export interface Capability {
|
|
|
16
16
|
/**
|
|
17
17
|
* Optional value parameter. For `sasl` this is the base mechanism list
|
|
18
18
|
* (`PLAIN`); the {@link saslCapValue} helper upgrades it to include
|
|
19
|
-
* `EXTERNAL` when
|
|
19
|
+
* `EXTERNAL` when the mechanism is available for the connection (mTLS
|
|
20
|
+
* source + operator opt-in + secure transport).
|
|
20
21
|
*/
|
|
21
22
|
value?: string;
|
|
22
23
|
}
|
|
@@ -40,6 +41,14 @@ export const PRE_AWAY_CAP_NAME = 'draft/pre-away';
|
|
|
40
41
|
*/
|
|
41
42
|
export const DEFAULT_MULTILINE_MAX_BYTES = 4096;
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Default per-batch entry ceiling enforced incrementally while a
|
|
46
|
+
* `draft/multiline` batch is open. Defense-in-depth against a client that
|
|
47
|
+
* streams many tiny lines (each under the byte budget) and never closes the
|
|
48
|
+
* batch; deployments override via `ServerConfig.multilineMaxEntries`.
|
|
49
|
+
*/
|
|
50
|
+
export const DEFAULT_MULTILINE_MAX_ENTRIES = 100;
|
|
51
|
+
|
|
43
52
|
/**
|
|
44
53
|
* Builds the `draft/multiline` cap *value* (the substring after `=`) for the
|
|
45
54
|
* advertised byte budget. The {@link CapReducer} and the multi-line reducer
|
|
@@ -79,17 +88,18 @@ export const SUPPORTED_CAPABILITIES: ReadonlyArray<Capability> = Object.freeze([
|
|
|
79
88
|
]);
|
|
80
89
|
|
|
81
90
|
/**
|
|
82
|
-
* Returns the `sasl` cap value to advertise given whether
|
|
83
|
-
*
|
|
91
|
+
* Returns the `sasl` cap value to advertise given whether the SASL
|
|
92
|
+
* EXTERNAL mechanism is available for the connection.
|
|
84
93
|
*
|
|
85
|
-
* PLAIN is always available. EXTERNAL is advertised only when
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
94
|
+
* PLAIN is always available. EXTERNAL is advertised only when the
|
|
95
|
+
* caller's three-way gate holds (mTLS identity source bound +
|
|
96
|
+
* `sasl.externalEnabled` operator opt-in + secure transport — see
|
|
97
|
+
* `commands/sasl.ts`), so clients on connections where the mechanism
|
|
98
|
+
* cannot succeed do not attempt an exchange the server would refuse
|
|
99
|
+
* with `908 ERR_SASLMECHS`.
|
|
90
100
|
*/
|
|
91
|
-
export function saslCapValue(
|
|
92
|
-
return
|
|
101
|
+
export function saslCapValue(externalAvailable: boolean): string {
|
|
102
|
+
return externalAvailable ? 'PLAIN,EXTERNAL' : 'PLAIN';
|
|
93
103
|
}
|
|
94
104
|
|
|
95
105
|
/** Set form for fast membership checks during `CAP REQ`. */
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CertFP — canonical client-certificate identity for SASL EXTERNAL.
|
|
3
|
+
*
|
|
4
|
+
* The edge platforms terminate TLS and surface the verified client
|
|
5
|
+
* certificate to the adapter, but neither Cloudflare Workers nor AWS API
|
|
6
|
+
* Gateway hands over the certificate DER itself:
|
|
7
|
+
*
|
|
8
|
+
* - Cloudflare surfaces the SHA-256 fingerprint of the DER on
|
|
9
|
+
* `request.cf.tlsClientAuth.certFingerprintSHA256` (lowercase hex) — the
|
|
10
|
+
* strongest identifier available and the preferred account binding.
|
|
11
|
+
* - AWS API Gateway surfaces only `requestContext.identity.clientCertSubjectDN`
|
|
12
|
+
* (no fingerprint, no DER) — the subject DN is the only identifier.
|
|
13
|
+
*
|
|
14
|
+
* To make subject-DN bindings robust across those platforms (and across
|
|
15
|
+
* cert re-issuances that reorder or re-space the same DN), the DN is
|
|
16
|
+
* canonicalised before comparison:
|
|
17
|
+
*
|
|
18
|
+
* 1. Attribute **types** are lowercased (`CN=` → `cn=`); attribute
|
|
19
|
+
* **values** keep their case (values are compared case-sensitively).
|
|
20
|
+
* 2. Whitespace around types/values is trimmed and runs of whitespace
|
|
21
|
+
* inside a value collapse to a single space (escaped whitespace, e.g.
|
|
22
|
+
* a trailing `\ `, is preserved verbatim).
|
|
23
|
+
* 3. RDNs (`a,b`) and the AVAs of a multi-valued RDN (`a+b`) are sorted,
|
|
24
|
+
* so two semantically-equal DNs written in different orders collapse
|
|
25
|
+
* to one canonical spelling.
|
|
26
|
+
* 4. RFC 4514 escapes (`\,`, `\+`, `\=` …) are kept intact — the
|
|
27
|
+
* canonicaliser never splits on an escaped separator.
|
|
28
|
+
*
|
|
29
|
+
* Accounts bind to either a DER fingerprint (`fp:<hex>` entries in
|
|
30
|
+
* `RegisteredNick.certSubjects`, preferred) or a canonical DN. Legacy
|
|
31
|
+
* verbatim subject bindings still verify — `ServicesStore.verifyCertFP`
|
|
32
|
+
* canonicalises stored entries at compare time and rewrites them to the
|
|
33
|
+
* canonical spelling on first use.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Marker prefix for fingerprint bindings stored in
|
|
38
|
+
* `RegisteredNick.certSubjects` (vs canonical-DN bindings).
|
|
39
|
+
*/
|
|
40
|
+
export const CERT_FP_PREFIX = 'fp:';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The verified client-certificate identity of one connection, as resolved
|
|
44
|
+
* by an {@link MtlsIdentityProvider}.
|
|
45
|
+
*
|
|
46
|
+
* - `subject` — the raw platform-surfaced subject string, verbatim.
|
|
47
|
+
* - `canonicalDn` — {@link canonicalizeCertSubject} of `subject`.
|
|
48
|
+
* - `fingerprint` — the SHA-256 fingerprint of the certificate DER
|
|
49
|
+
* (lowercase hex) when the platform surfaces one; absent otherwise
|
|
50
|
+
* (API Gateway surfaces no fingerprint today).
|
|
51
|
+
*/
|
|
52
|
+
export interface CertIdentity {
|
|
53
|
+
readonly subject: string;
|
|
54
|
+
readonly canonicalDn: string;
|
|
55
|
+
readonly fingerprint?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Splits `s` on unescaped `sep` characters, keeping RFC 4514 `\x` escape
|
|
60
|
+
* pairs intact within each part.
|
|
61
|
+
*/
|
|
62
|
+
function splitUnescaped(s: string, sep: string): string[] {
|
|
63
|
+
const parts: string[] = [];
|
|
64
|
+
let current = '';
|
|
65
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
66
|
+
const ch = s.charAt(i);
|
|
67
|
+
const next = s.charAt(i + 1);
|
|
68
|
+
if (ch === '\\' && next !== '') {
|
|
69
|
+
current += ch + next;
|
|
70
|
+
i += 1;
|
|
71
|
+
} else if (ch === sep) {
|
|
72
|
+
parts.push(current);
|
|
73
|
+
current = '';
|
|
74
|
+
} else {
|
|
75
|
+
current += ch;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
parts.push(current);
|
|
79
|
+
return parts;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Index of the first unescaped occurrence of `ch`, or `-1`. Used to split
|
|
84
|
+
* an AVA into type and value without splitting on `\=`.
|
|
85
|
+
*/
|
|
86
|
+
function indexUnescaped(s: string, ch: string): number {
|
|
87
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
88
|
+
if (s.charAt(i) === '\\') {
|
|
89
|
+
i += 1;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (s.charAt(i) === ch) return i;
|
|
93
|
+
}
|
|
94
|
+
return -1;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Collapses runs of unescaped whitespace to single spaces and trims
|
|
99
|
+
* leading/trailing unescaped whitespace. Escape pairs (`\x`) pass through
|
|
100
|
+
* verbatim and terminate any pending whitespace run.
|
|
101
|
+
*/
|
|
102
|
+
function collapseWhitespace(s: string): string {
|
|
103
|
+
let out = '';
|
|
104
|
+
let pendingSpace = false;
|
|
105
|
+
let started = false;
|
|
106
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
107
|
+
const ch = s.charAt(i);
|
|
108
|
+
const next = s.charAt(i + 1);
|
|
109
|
+
if (ch === '\\' && next !== '') {
|
|
110
|
+
if (pendingSpace) {
|
|
111
|
+
out += ' ';
|
|
112
|
+
pendingSpace = false;
|
|
113
|
+
}
|
|
114
|
+
out += ch + next;
|
|
115
|
+
i += 1;
|
|
116
|
+
started = true;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (/\s/.test(ch)) {
|
|
120
|
+
if (started) pendingSpace = true;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (pendingSpace) {
|
|
124
|
+
out += ' ';
|
|
125
|
+
pendingSpace = false;
|
|
126
|
+
}
|
|
127
|
+
out += ch;
|
|
128
|
+
started = true;
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Canonicalises one attribute-value assertion (`type=value`). */
|
|
134
|
+
function canonicalAva(ava: string): string {
|
|
135
|
+
const eq = indexUnescaped(ava, '=');
|
|
136
|
+
if (eq === -1) return collapseWhitespace(ava);
|
|
137
|
+
const type = collapseWhitespace(ava.slice(0, eq)).toLowerCase();
|
|
138
|
+
const value = collapseWhitespace(ava.slice(eq + 1));
|
|
139
|
+
return `${type}=${value}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Canonicalises an RFC 4514-style DN for byte-stable comparison:
|
|
144
|
+
* lowercase attribute types (values keep their case), collapsed/trimmed
|
|
145
|
+
* whitespace, sorted RDNs, and sorted AVAs inside multi-valued RDNs.
|
|
146
|
+
* Escape sequences pass through unchanged.
|
|
147
|
+
*/
|
|
148
|
+
export function canonicalizeCertSubject(dn: string): string {
|
|
149
|
+
return splitUnescaped(dn, ',')
|
|
150
|
+
.map((rdn) =>
|
|
151
|
+
splitUnescaped(rdn, '+')
|
|
152
|
+
.map((ava) => canonicalAva(ava))
|
|
153
|
+
.sort()
|
|
154
|
+
.join('+'),
|
|
155
|
+
)
|
|
156
|
+
.sort()
|
|
157
|
+
.join(',');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Normalises a DER SHA-256 fingerprint into the stored binding form:
|
|
162
|
+
* `fp:` + trimmed, lowercased, colon-free hex.
|
|
163
|
+
*/
|
|
164
|
+
export function certFingerprintId(fingerprint: string): string {
|
|
165
|
+
return `${CERT_FP_PREFIX}${fingerprint.trim().toLowerCase().replace(/:/g, '')}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Builds the {@link CertIdentity} for a platform-surfaced subject (and the
|
|
170
|
+
* platform fingerprint, when one was provided). Pure; adapters call this
|
|
171
|
+
* at admission time.
|
|
172
|
+
*/
|
|
173
|
+
export function certIdentityFromSubject(subject: string, fingerprint?: string): CertIdentity {
|
|
174
|
+
const canonicalDn = canonicalizeCertSubject(subject);
|
|
175
|
+
return fingerprint === undefined
|
|
176
|
+
? { subject, canonicalDn }
|
|
177
|
+
: { subject, canonicalDn, fingerprint };
|
|
178
|
+
}
|
|
@@ -94,11 +94,19 @@ function handleLs(state: ConnectionState, ctx: Ctx): ReducerResult<ConnectionSta
|
|
|
94
94
|
state.capNegotiating = true;
|
|
95
95
|
|
|
96
96
|
const target = state.nick ?? '*';
|
|
97
|
-
|
|
97
|
+
// Same gate as the AUTHENTICATE reducer (no transport-level override +
|
|
98
|
+
// mTLS source + operator opt-in + secure transport), so the `sasl` cap
|
|
99
|
+
// never advertises a mechanism the reducer would refuse — with 908, or
|
|
100
|
+
// with the transport-specific 904 under the override.
|
|
101
|
+
const external =
|
|
102
|
+
ctx.serverConfig.sasl?.externalUnsupportedMessage === undefined &&
|
|
103
|
+
ctx.mtlsIdentity !== undefined &&
|
|
104
|
+
(ctx.serverConfig.sasl?.externalEnabled ?? false) &&
|
|
105
|
+
ctx.connection.secure;
|
|
98
106
|
const multilineMax = ctx.serverConfig.multilineMaxBytes ?? DEFAULT_MULTILINE_MAX_BYTES;
|
|
99
107
|
const tokens = SUPPORTED_CAPABILITIES.map((cap) => {
|
|
100
108
|
if (cap.name === SASL_CAP_NAME) {
|
|
101
|
-
return capToLsString({ ...cap, value: saslCapValue(
|
|
109
|
+
return capToLsString({ ...cap, value: saslCapValue(external) });
|
|
102
110
|
}
|
|
103
111
|
if (cap.name === MULTILINE_CAP_NAME) {
|
|
104
112
|
return capToLsString({ ...cap, value: multilineCapValue(multilineMax) });
|
|
@@ -36,6 +36,14 @@
|
|
|
36
36
|
* threshold the JOIN hook compares against for the `AUTO*` ops.
|
|
37
37
|
* `SET <op> <level>` overrides; `LIST` enumerates; `RESET` clears
|
|
38
38
|
* every override so the {@link DEFAULT_CHANNEL_LEVELS} take effect.
|
|
39
|
+
* - `OP|DEOP|VOICE|DEVOICE <#channel> <nick>` — grant or strip a channel
|
|
40
|
+
* prefix via a ChanServ-sourced MODE broadcast (founder or
|
|
41
|
+
* AUTOOP-threshold access entry). DEOP additionally enforces a
|
|
42
|
+
* caller-vs-target rank guard: the target must not outrank the caller
|
|
43
|
+
* (see {@link rankOf} / {@link FOUNDER_RANK}), so an access-list entry
|
|
44
|
+
* cannot strip the founder — or any higher-ranked entry — mid-session.
|
|
45
|
+
* - `KICK <#channel> <nick> [:<reason>]` — removes a member (founder
|
|
46
|
+
* only) under the same target-rank guard.
|
|
39
47
|
*
|
|
40
48
|
* The reducer is a pure `Reducer<ConnectionState>`: all side effects are
|
|
41
49
|
* emitted as {@link Effect}s. Channel-mode mutations are emitted as
|
|
@@ -47,6 +55,7 @@ import { caseFold } from '../case-fold.js';
|
|
|
47
55
|
import { Effect } from '../effects.js';
|
|
48
56
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
49
57
|
import type { ChannelLevelOp, ServicesStore } from '../ports.js';
|
|
58
|
+
import { isValidChannelName } from '../protocol/channel-name.js';
|
|
50
59
|
import type { ChannelDelta, Roster } from '../state/channel.js';
|
|
51
60
|
import type { ConnId, ConnectionState } from '../state/connection.js';
|
|
52
61
|
import type { Ctx, Reducer } from '../types.js';
|
|
@@ -60,15 +69,6 @@ const SERVICES_HOST = 'services';
|
|
|
60
69
|
/** Hostmask used as the source of ChanServ-emitted MODE prefix grants. */
|
|
61
70
|
export const CHANSERV_HOSTMASK = `${CHANSERV_NICK}!${CHANSERV_NICK}@${SERVICES_HOST}`;
|
|
62
71
|
|
|
63
|
-
/** Channel-name grammar; mirrors {@link isValidChannelName} in `join.ts`. */
|
|
64
|
-
const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
|
|
65
|
-
|
|
66
|
-
/** Returns true iff `name` looks like a channel target. */
|
|
67
|
-
function isValidChannelName(name: string, maxLen: number): boolean {
|
|
68
|
-
if (name.length === 0 || name.length > maxLen) return false;
|
|
69
|
-
return CHANNEL_NAME_RE.test(name);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
72
|
/** ON / OFF values accepted by SET RESTRICTED / SET KEEPTOPIC. */
|
|
73
73
|
const ON_OFF = new Set<string>(['ON', 'OFF']);
|
|
74
74
|
|
|
@@ -111,6 +111,26 @@ export const SHORTHAND_LEVELS: Readonly<Record<'SOP' | 'AOP' | 'HOP' | 'VOP', nu
|
|
|
111
111
|
/** Set of recognised shorthand verbs (uppercased). */
|
|
112
112
|
const SHORTHAND_VERBS = new Set<string>(['SOP', 'AOP', 'HOP', 'VOP']);
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Rank of the channel founder in the DEOP/KICK caller-vs-target hierarchy.
|
|
116
|
+
* `Infinity` outranks every numeric access level, so no access-list entry
|
|
117
|
+
* can ever reach it — the only caller who can affect a founder-ranked
|
|
118
|
+
* target is the founder (an equal-rank, self-targeting action).
|
|
119
|
+
*/
|
|
120
|
+
export const FOUNDER_RANK = Number.POSITIVE_INFINITY;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Maps a numeric ChanServ access level to the rank ordering used by the
|
|
124
|
+
* DEOP/KICK target guard: founder ({@link FOUNDER_RANK}) > SOP (10) > AOP
|
|
125
|
+
* (5) > HOP (4) > VOP (3) > no entry (0). The mapping is the identity for
|
|
126
|
+
* positive levels today; it exists as the single seam where a future
|
|
127
|
+
* privilege tier (e.g. a distinct SOP rank scale) can be introduced
|
|
128
|
+
* without touching the comparison call sites.
|
|
129
|
+
*/
|
|
130
|
+
export function rankOf(level: number): number {
|
|
131
|
+
return level > 0 ? level : 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
114
134
|
/** Ordered list of level op names, for LEVELS LIST and validation. */
|
|
115
135
|
const LEVEL_OPS: readonly ChannelLevelOp[] = ['AUTOOP', 'AUTOHALFOP', 'AUTOVOICE'];
|
|
116
136
|
|
|
@@ -794,16 +814,24 @@ interface PrefixCommandOpts {
|
|
|
794
814
|
* - `founderOnly: true` → "Permission denied."
|
|
795
815
|
* - `founderOnly: false` → allowed when caller's access level meets the
|
|
796
816
|
* founder-configured `AUTOOP` threshold, otherwise "Permission denied."
|
|
817
|
+
* - Target-rank guard (`guardTargetRankOf`): the nick named by the command
|
|
818
|
+
* must not outrank the caller, otherwise "You do not have sufficient
|
|
819
|
+
* privileges on <chan> to affect <nick>." Used by DEOP and KICK so an
|
|
820
|
+
* access-list entry cannot strip the founder (or any higher-ranked
|
|
821
|
+
* entry) mid-session. Equal ranks — self-targeting and peers — are
|
|
822
|
+
* permitted; only a strictly higher-ranked target is protected.
|
|
797
823
|
*
|
|
798
824
|
* Privilege model: OP/DEOP/VOICE/DEVOICE accept the founder OR any
|
|
799
|
-
* AUTOOP-level access entry; KICK/BAN/UNBAN are founder-only.
|
|
825
|
+
* AUTOOP-level access entry; KICK/BAN/UNBAN are founder-only. The
|
|
826
|
+
* target-rank guard applies only where the caller names a nick whose
|
|
827
|
+
* powers would be *removed* (DEOP/KICK), not where powers are granted.
|
|
800
828
|
*/
|
|
801
829
|
function requirePrivilegedChanServ(
|
|
802
830
|
state: ConnectionState,
|
|
803
831
|
channel: string,
|
|
804
832
|
ctx: Ctx,
|
|
805
833
|
effects: EffectType[],
|
|
806
|
-
opts: { founderOnly: boolean },
|
|
834
|
+
opts: { founderOnly: boolean; guardTargetRankOf?: string },
|
|
807
835
|
): ServicesStore | null {
|
|
808
836
|
const services = ctx.services as ServicesStore;
|
|
809
837
|
if (state.account === undefined) {
|
|
@@ -815,18 +843,89 @@ function requirePrivilegedChanServ(
|
|
|
815
843
|
effects.push(notice(state, `Channel ${channel} is not registered.`));
|
|
816
844
|
return null;
|
|
817
845
|
}
|
|
818
|
-
|
|
819
|
-
|
|
846
|
+
const callerIsFounder = caseFold('rfc1459', founder) === caseFold('rfc1459', state.account);
|
|
847
|
+
if (callerIsFounder) {
|
|
848
|
+
return admitTargetRankGuard(state, services, FOUNDER_RANK, channel, founder, opts, effects);
|
|
820
849
|
}
|
|
821
850
|
if (!opts.founderOnly) {
|
|
822
851
|
const level = services.getChannelAccess(channel, state.account);
|
|
823
852
|
const autoOp = services.getChannelLevel(channel, 'AUTOOP') ?? DEFAULT_CHANNEL_LEVELS.AUTOOP;
|
|
824
|
-
if (level >= autoOp)
|
|
853
|
+
if (level >= autoOp) {
|
|
854
|
+
return admitTargetRankGuard(state, services, rankOf(level), channel, founder, opts, effects);
|
|
855
|
+
}
|
|
825
856
|
}
|
|
826
857
|
effects.push(notice(state, 'Permission denied.'));
|
|
827
858
|
return null;
|
|
828
859
|
}
|
|
829
860
|
|
|
861
|
+
/**
|
|
862
|
+
* Applies the DEOP/KICK target-rank guard at the tail of
|
|
863
|
+
* {@link requirePrivilegedChanServ}: the caller may affect the target nick
|
|
864
|
+
* only when the target's rank does not exceed theirs. Returns the store on
|
|
865
|
+
* success, `null` (with an insufficient-privileges NOTICE pushed) on
|
|
866
|
+
* failure.
|
|
867
|
+
*
|
|
868
|
+
* Rank rule: permitted iff `callerRank >= targetRank`. Equal ranks cover
|
|
869
|
+
* both self-targeting (the founder — or any access entry — releasing
|
|
870
|
+
* their own status) and peer-level actions (an AOP deopping a fellow
|
|
871
|
+
* AOP, who can simply re-op); any strictly higher-ranked target — the
|
|
872
|
+
* founder above every access entry, an SOP above an AOP — is protected.
|
|
873
|
+
*/
|
|
874
|
+
function admitTargetRankGuard(
|
|
875
|
+
state: ConnectionState,
|
|
876
|
+
services: ServicesStore,
|
|
877
|
+
callerRank: number,
|
|
878
|
+
channel: string,
|
|
879
|
+
founder: string,
|
|
880
|
+
opts: { founderOnly: boolean; guardTargetRankOf?: string },
|
|
881
|
+
effects: EffectType[],
|
|
882
|
+
): ServicesStore | null {
|
|
883
|
+
if (
|
|
884
|
+
opts.guardTargetRankOf !== undefined &&
|
|
885
|
+
callerRank < targetRankOf(services, channel, founder, opts.guardTargetRankOf)
|
|
886
|
+
) {
|
|
887
|
+
effects.push(
|
|
888
|
+
notice(
|
|
889
|
+
state,
|
|
890
|
+
`You do not have sufficient privileges on ${channel} to affect ${opts.guardTargetRankOf}.`,
|
|
891
|
+
),
|
|
892
|
+
);
|
|
893
|
+
return null;
|
|
894
|
+
}
|
|
895
|
+
return services;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Resolves the rank of a target NICK for the DEOP/KICK guard.
|
|
900
|
+
*
|
|
901
|
+
* The ChanServ reducer operates on the caller's ConnectionState and cannot
|
|
902
|
+
* see the connection behind the target nick, so the target's account is
|
|
903
|
+
* resolved via the nick registry (`getNick`):
|
|
904
|
+
* - a nick whose registered account matches the channel founder ranks as
|
|
905
|
+
* the founder ({@link FOUNDER_RANK}) and is protected from every
|
|
906
|
+
* non-founder caller;
|
|
907
|
+
* - any other registered nick ranks at its access-list level;
|
|
908
|
+
* - an unregistered (therefore unidentified) nick carries no account and
|
|
909
|
+
* ranks at the minimum.
|
|
910
|
+
*
|
|
911
|
+
* Documented simplification: protection is keyed on the nick's registered
|
|
912
|
+
* account, not on whether the connection currently using the nick is
|
|
913
|
+
* identified — the reducer has no way to observe the latter, and erring
|
|
914
|
+
* toward protection cannot be abused to escalate privileges.
|
|
915
|
+
*/
|
|
916
|
+
function targetRankOf(
|
|
917
|
+
services: ServicesStore,
|
|
918
|
+
channel: string,
|
|
919
|
+
founder: string,
|
|
920
|
+
targetNick: string,
|
|
921
|
+
): number {
|
|
922
|
+
const account = services.getNick(targetNick)?.account;
|
|
923
|
+
if (account !== undefined && caseFold('rfc1459', founder) === caseFold('rfc1459', account)) {
|
|
924
|
+
return FOUNDER_RANK;
|
|
925
|
+
}
|
|
926
|
+
return account === undefined ? rankOf(0) : rankOf(services.getChannelAccess(channel, account));
|
|
927
|
+
}
|
|
928
|
+
|
|
830
929
|
/**
|
|
831
930
|
* Handles `OP|DEOP|VOICE|DEVOICE <#channel> <nick>`.
|
|
832
931
|
*
|
|
@@ -862,6 +961,9 @@ function handlePrefixCommand(
|
|
|
862
961
|
|
|
863
962
|
const services = requirePrivilegedChanServ(state, channel, ctx, effects, {
|
|
864
963
|
founderOnly: false,
|
|
964
|
+
// Only DEOP removes powers, so only it carries the target-rank guard;
|
|
965
|
+
// OP/VOICE/DEVOICE never demote a higher-ranked user.
|
|
966
|
+
...(opts.verb === 'DEOP' ? { guardTargetRankOf: targetNick } : {}),
|
|
865
967
|
});
|
|
866
968
|
if (services === null) return { state, effects };
|
|
867
969
|
|
|
@@ -915,6 +1017,7 @@ function handleKick(
|
|
|
915
1017
|
|
|
916
1018
|
const services = requirePrivilegedChanServ(state, channel, ctx, effects, {
|
|
917
1019
|
founderOnly: true,
|
|
1020
|
+
guardTargetRankOf: targetNick,
|
|
918
1021
|
});
|
|
919
1022
|
if (services === null) return { state, effects };
|
|
920
1023
|
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* actor layer); `undefined` means "no such channel" → `403`.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
import { DEFAULT_MAX_CHATHISTORY_LIMIT } from '../config.js';
|
|
23
24
|
import { Effect } from '../effects.js';
|
|
24
25
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
25
26
|
import type { StoredMessage } from '../ports.js';
|
|
@@ -154,7 +155,8 @@ function handleChannelSub(
|
|
|
154
155
|
if (sub !== 'LATEST' && pivot === undefined && markerPivot === undefined) {
|
|
155
156
|
return { effects: [Effect.send(ctx.connId, [needMoreParamsLine(ctx)])] };
|
|
156
157
|
}
|
|
157
|
-
const
|
|
158
|
+
const maxLimit = ctx.serverConfig.chathistory?.maxLimit ?? DEFAULT_MAX_CHATHISTORY_LIMIT;
|
|
159
|
+
const limit = parseLimit(limitRaw, maxLimit);
|
|
158
160
|
if (limit === undefined) {
|
|
159
161
|
return { effects: [Effect.send(ctx.connId, [invalidParamsLine(ctx)])] };
|
|
160
162
|
}
|
|
@@ -295,12 +297,18 @@ function invalidParamsLine(ctx: Ctx): RawLine {
|
|
|
295
297
|
return numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Invalid parameters', 'CHATHISTORY');
|
|
296
298
|
}
|
|
297
299
|
|
|
298
|
-
/**
|
|
299
|
-
|
|
300
|
-
|
|
300
|
+
/**
|
|
301
|
+
* Parses a non-negative integer limit, silently capped at `maxLimit` (the
|
|
302
|
+
* deployment's chathistory ceiling); returns undefined on garbage. The cap
|
|
303
|
+
* keeps an oversized request (`CHATHISTORY LATEST #chan 999999999`) from
|
|
304
|
+
* asking the store to materialize a huge result set, regardless of the
|
|
305
|
+
* store's backing capacity.
|
|
306
|
+
*/
|
|
307
|
+
function parseLimit(raw: string | undefined, maxLimit: number): number | undefined {
|
|
308
|
+
if (raw === undefined) return Math.min(DEFAULT_QUERY_LIMIT, maxLimit);
|
|
301
309
|
const n = Number(raw);
|
|
302
310
|
if (!Number.isInteger(n) || n < 0) return undefined;
|
|
303
|
-
return n;
|
|
311
|
+
return Math.min(n, maxLimit);
|
|
304
312
|
}
|
|
305
313
|
|
|
306
314
|
/**
|