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
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
import type { Effect } from './effects.js';
|
|
10
10
|
import type { FloodControlConfig } from './flood-control.js';
|
|
11
|
+
import type { OperFailureStats } from './oper-lockout.js';
|
|
11
12
|
import type {
|
|
12
|
-
AccountStore,
|
|
13
13
|
AwayStore,
|
|
14
14
|
Clock,
|
|
15
15
|
IdFactory,
|
|
@@ -53,16 +53,163 @@ export interface StsConfig {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
56
|
+
* Legacy plaintext IRC operator credential (DEPRECATED — retained for a
|
|
57
|
+
* single deprecation-cycle window).
|
|
58
|
+
*
|
|
59
|
+
* The {@link operReducer} still verifies this shape via timing-safe byte
|
|
60
|
+
* comparison, but emits a one-shot `warn` per boot when it is encountered.
|
|
61
|
+
* Migrate to {@link OperCredHashed} via `tools/hash-oper-cred.ts`; this
|
|
62
|
+
* form will be removed in a future release.
|
|
60
63
|
*/
|
|
61
|
-
export interface
|
|
64
|
+
export interface OperCredPlaintext {
|
|
62
65
|
readonly user: string;
|
|
63
66
|
readonly password: string;
|
|
64
67
|
}
|
|
65
68
|
|
|
69
|
+
/**
|
|
70
|
+
* At-rest hashed IRC operator credential (the recommended shape).
|
|
71
|
+
*
|
|
72
|
+
* `salt` and `hash` are base64 strings produced by
|
|
73
|
+
* `tools/hash-oper-cred.ts` (which wraps {@link hashAccountCredential}'s
|
|
74
|
+
* scrypt derivation). The {@link operReducer} verifies via the shared
|
|
75
|
+
* {@link verifyHashedPassword} with `timingSafeEqual`, mirroring the
|
|
76
|
+
* NickServ / SASL PLAIN credential path.
|
|
77
|
+
*/
|
|
78
|
+
export interface OperCredHashed {
|
|
79
|
+
readonly user: string;
|
|
80
|
+
readonly salt: string;
|
|
81
|
+
readonly hash: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A single IRC operator credential. Either the at-rest hashed form
|
|
86
|
+
* ({@link OperCredHashed}, recommended) or the legacy plaintext form
|
|
87
|
+
* ({@link OperCredPlaintext}, deprecated — retained for a single
|
|
88
|
+
* deprecation-cycle window). The {@link operReducer} consults whichever
|
|
89
|
+
* shape is present; adapters are responsible for any hashing at the auth
|
|
90
|
+
* boundary if they layer one on top.
|
|
91
|
+
*/
|
|
92
|
+
export type OperCred = OperCredHashed | OperCredPlaintext;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Per-IP failed-`OPER` lockout tunables as seen by reducers. Mirrors the
|
|
96
|
+
* `oper` sub-schema of {@link ServerConfigSchema}; the OPER reducer reads
|
|
97
|
+
* `maxFailures` / `failureWindowSeconds` (falling back to the documented
|
|
98
|
+
* defaults when unset).
|
|
99
|
+
*
|
|
100
|
+
* Optional fields are widened to `T | undefined` to mirror Zod's
|
|
101
|
+
* `.optional()` output under `exactOptionalPropertyTypes: true`.
|
|
102
|
+
*/
|
|
103
|
+
export interface OperThrottleConfig {
|
|
104
|
+
/**
|
|
105
|
+
* Consecutive failed `OPER` attempts (per host, per window) that trip
|
|
106
|
+
* the lockout. Omitted → the reducer falls back to
|
|
107
|
+
* {@link DEFAULT_OPER_MAX_FAILURES} (3).
|
|
108
|
+
*/
|
|
109
|
+
readonly maxFailures?: number | undefined;
|
|
110
|
+
/**
|
|
111
|
+
* Sliding window length in seconds. Omitted → the reducer falls back to
|
|
112
|
+
* {@link DEFAULT_OPER_FAILURE_WINDOW_SECONDS} (300).
|
|
113
|
+
*/
|
|
114
|
+
readonly failureWindowSeconds?: number | undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* NickServ-specific tunables as seen by reducers. Mirrors the
|
|
119
|
+
* `nickServ` sub-schema of {@link ServerConfigSchema}; the NickServ
|
|
120
|
+
* `SET PASSWORD` reducer reads `minPasswordLength` (falling back to
|
|
121
|
+
* {@link DEFAULT_MIN_PASSWORD_LENGTH} when unset).
|
|
122
|
+
*
|
|
123
|
+
* Optional fields are widened to `T | undefined` to mirror Zod's
|
|
124
|
+
* `.optional()` output under `exactOptionalPropertyTypes: true`.
|
|
125
|
+
*/
|
|
126
|
+
export interface NickServConfig {
|
|
127
|
+
/**
|
|
128
|
+
* Minimum length a new password must meet to be accepted by
|
|
129
|
+
* `SET PASSWORD`. Omitted → the reducer falls back to
|
|
130
|
+
* {@link DEFAULT_MIN_PASSWORD_LENGTH} (8).
|
|
131
|
+
*/
|
|
132
|
+
readonly minPasswordLength?: number | undefined;
|
|
133
|
+
/**
|
|
134
|
+
* Failed-IDENTIFY threshold that freezes the account. Omitted → the
|
|
135
|
+
* NickServ IDENTIFY reducer falls back to
|
|
136
|
+
* {@link DEFAULT_MAX_IDENTIFY_FAILURES} (5).
|
|
137
|
+
*/
|
|
138
|
+
readonly maxIdentifyFailures?: number | undefined;
|
|
139
|
+
/**
|
|
140
|
+
* Sliding window (seconds) the failed-IDENTIFY threshold counts
|
|
141
|
+
* within. Omitted → the reducer falls back to
|
|
142
|
+
* {@link DEFAULT_IDENTIFY_FREEZE_SECONDS} (300).
|
|
143
|
+
*/
|
|
144
|
+
readonly identifyFreezeSeconds?: number | undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* SASL brute-force hardening tunables as seen by reducers. Mirrors the
|
|
149
|
+
* `sasl` sub-schema of {@link ServerConfigSchema}; the SASL reducer reads
|
|
150
|
+
* them to trip a per-connection lockout after too many failed
|
|
151
|
+
* `AUTHENTICATE` attempts (falling back to the documented defaults when
|
|
152
|
+
* unset).
|
|
153
|
+
*
|
|
154
|
+
* Optional fields are widened to `T | undefined` to mirror Zod's
|
|
155
|
+
* `.optional()` output under `exactOptionalPropertyTypes: true`.
|
|
156
|
+
*/
|
|
157
|
+
export interface SaslConfig {
|
|
158
|
+
/**
|
|
159
|
+
* Failed `AUTHENTICATE` attempts (per connection, within the sliding
|
|
160
|
+
* window) before the connection is disconnected. Omitted → the reducer
|
|
161
|
+
* falls back to {@link DEFAULT_SASL_MAX_FAILURES} (3).
|
|
162
|
+
*/
|
|
163
|
+
readonly maxFailures?: number | undefined;
|
|
164
|
+
/**
|
|
165
|
+
* Sliding-window length (seconds) the failure tracker counts within.
|
|
166
|
+
* Failures older than the window no longer count. Omitted → the reducer
|
|
167
|
+
* falls back to {@link DEFAULT_SASL_FAILURE_WINDOW_SECONDS} (60).
|
|
168
|
+
*/
|
|
169
|
+
readonly failureWindowSeconds?: number | undefined;
|
|
170
|
+
/**
|
|
171
|
+
* Operator opt-in for the certificate-backed SASL EXTERNAL mechanism.
|
|
172
|
+
* Omitted → EXTERNAL is neither advertised nor accepted (the reducers
|
|
173
|
+
* treat absence as `false`). Enabling additionally requires a bound
|
|
174
|
+
* mTLS identity source and a secure (TLS) connection — see the
|
|
175
|
+
* AUTHENTICATE reducer. The adapters surface this as the
|
|
176
|
+
* `EXTERNAL_ENABLED` env var.
|
|
177
|
+
*/
|
|
178
|
+
readonly externalEnabled?: boolean | undefined;
|
|
179
|
+
/**
|
|
180
|
+
* Transport-level EXTERNAL non-support override. When set (by an
|
|
181
|
+
* adapter whose transport cannot surface a client-cert identity —
|
|
182
|
+
* e.g. the Spectrum PROXY-v1 TCP container origin), SASL EXTERNAL is
|
|
183
|
+
* hard-disabled for the deployment: the `sasl` cap and `908
|
|
184
|
+
* ERR_SASLMECHS` list `PLAIN` only, and `AUTHENTICATE EXTERNAL` is
|
|
185
|
+
* rejected up front with `904 ERR_SASLFAIL` carrying this exact
|
|
186
|
+
* message (a stable, transport-specific refusal) — even if
|
|
187
|
+
* {@link externalEnabled} and every three-way-gate input is bound.
|
|
188
|
+
* The rejection is a protocol-level refusal, not a credential
|
|
189
|
+
* attempt: it does not count toward the per-connection SASL failure
|
|
190
|
+
* lockout.
|
|
191
|
+
*/
|
|
192
|
+
readonly externalUnsupportedMessage?: string | undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* `draft/chathistory` tunables as seen by reducers. Mirrors the
|
|
197
|
+
* `chathistory` sub-schema of {@link ServerConfigSchema}; the CHATHISTORY
|
|
198
|
+
* reducer caps every client-supplied query limit at `maxLimit` (falling
|
|
199
|
+
* back to {@link DEFAULT_MAX_CHATHISTORY_LIMIT} when unset).
|
|
200
|
+
*
|
|
201
|
+
* Optional fields are widened to `T | undefined` to mirror Zod's
|
|
202
|
+
* `.optional()` output under `exactOptionalPropertyTypes: true`.
|
|
203
|
+
*/
|
|
204
|
+
export interface ChathistoryConfig {
|
|
205
|
+
/**
|
|
206
|
+
* Maximum number of messages one `CHATHISTORY` query may return.
|
|
207
|
+
* Omitted → the reducer falls back to
|
|
208
|
+
* {@link DEFAULT_MAX_CHATHISTORY_LIMIT} (100).
|
|
209
|
+
*/
|
|
210
|
+
readonly maxLimit?: number | undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
66
213
|
/**
|
|
67
214
|
* Per-deployment server configuration. Adapters load this from a KV store
|
|
68
215
|
* (CF) or Secrets Manager / SSM (AWS); the core only reads it.
|
|
@@ -114,6 +261,17 @@ export interface ServerConfig {
|
|
|
114
261
|
* here.
|
|
115
262
|
*/
|
|
116
263
|
readonly operCreds?: ReadonlyArray<OperCred> | undefined;
|
|
264
|
+
/**
|
|
265
|
+
* Per-IP failed-`OPER` lockout tunables consulted by the `OPER`
|
|
266
|
+
* reducer's brute-force gate. Omitted (or field-omitted) values fall
|
|
267
|
+
* back to the documented defaults (3 failures / 300s window); the
|
|
268
|
+
* parsed config always supplies both fields (schema defaults).
|
|
269
|
+
*
|
|
270
|
+
* Typed `OperThrottleConfig | undefined` to mirror Zod's `.optional()`
|
|
271
|
+
* output shape under `exactOptionalPropertyTypes: true` so inline
|
|
272
|
+
* `ServerConfig` literals in tests may omit it.
|
|
273
|
+
*/
|
|
274
|
+
readonly oper?: OperThrottleConfig | undefined;
|
|
117
275
|
maxChannelsPerUser: number;
|
|
118
276
|
maxTargetsPerCommand: number;
|
|
119
277
|
maxListEntries: number;
|
|
@@ -166,6 +324,18 @@ export interface ServerConfig {
|
|
|
166
324
|
* literals in tests may omit it.
|
|
167
325
|
*/
|
|
168
326
|
readonly multilineMaxBytes?: number | undefined;
|
|
327
|
+
/**
|
|
328
|
+
* Per-batch entry ceiling for IRCv3 `draft/multiline`. Enforced
|
|
329
|
+
* incrementally on each accumulated inner line — together with
|
|
330
|
+
* {@link ServerConfig.multilineMaxBytes} — so a client cannot hold an
|
|
331
|
+
* unbounded batch open. Defaults to
|
|
332
|
+
* {@link DEFAULT_MULTILINE_MAX_ENTRIES} (100) when omitted.
|
|
333
|
+
*
|
|
334
|
+
* Typed `number | undefined` to mirror Zod's optional output shape under
|
|
335
|
+
* `exactOptionalPropertyTypes: true` so inline `ServerConfig` literals in
|
|
336
|
+
* tests may omit it.
|
|
337
|
+
*/
|
|
338
|
+
readonly multilineMaxEntries?: number | undefined;
|
|
169
339
|
/**
|
|
170
340
|
* Per-connection MONITOR watchlist ceiling, advertised as `MONITOR=<n>`
|
|
171
341
|
* in `005 RPL_ISUPPORT`. Omitted → the reducer falls back to
|
|
@@ -210,18 +380,71 @@ export interface ServerConfig {
|
|
|
210
380
|
*/
|
|
211
381
|
readonly nickEnforceGraceMs?: number | undefined;
|
|
212
382
|
/**
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
383
|
+
* NickServ-specific tunables consulted by the NickServ reducer. Currently
|
|
384
|
+
* only the minimum length enforced by `SET PASSWORD` is configurable
|
|
385
|
+
* (`minPasswordLength`); the maximum (256) is fixed to bound the scrypt
|
|
386
|
+
* input budget.
|
|
387
|
+
*
|
|
388
|
+
* Typed `NickServConfig | undefined` to mirror Zod's optional output
|
|
389
|
+
* shape under `exactOptionalPropertyTypes: true` so inline
|
|
390
|
+
* `ServerConfig` literals in tests may omit it (the reducer falls back
|
|
391
|
+
* to {@link DEFAULT_MIN_PASSWORD_LENGTH} when `minPasswordLength` is
|
|
392
|
+
* unset).
|
|
393
|
+
*/
|
|
394
|
+
readonly nickServ?: NickServConfig | undefined;
|
|
395
|
+
/**
|
|
396
|
+
* SASL brute-force hardening tunables consulted by the SASL reducer:
|
|
397
|
+
* `maxFailures` failed `AUTHENTICATE` attempts within
|
|
398
|
+
* `failureWindowSeconds` disconnect the connection. Omitted entirely →
|
|
399
|
+
* the reducer falls back to the documented defaults (3 failures / 60 s).
|
|
400
|
+
*
|
|
401
|
+
* Typed `SaslConfig | undefined` to mirror Zod's `.optional()` output
|
|
402
|
+
* under `exactOptionalPropertyTypes: true` so inline `ServerConfig`
|
|
403
|
+
* literals in tests may omit it.
|
|
404
|
+
*/
|
|
405
|
+
readonly sasl?: SaslConfig | undefined;
|
|
406
|
+
/**
|
|
407
|
+
* `draft/chathistory` tunables consulted by the CHATHISTORY reducer.
|
|
408
|
+
* Every client-supplied query limit is silently capped at
|
|
409
|
+
* `maxLimit`; omitted entirely → the documented default ceiling (100).
|
|
410
|
+
*
|
|
411
|
+
* Typed `ChathistoryConfig | undefined` to mirror Zod's `.optional()`
|
|
412
|
+
* output shape under `exactOptionalPropertyTypes: true` so inline
|
|
413
|
+
* `ServerConfig` literals in tests may omit it.
|
|
414
|
+
*/
|
|
415
|
+
readonly chathistory?: ChathistoryConfig | undefined;
|
|
416
|
+
/**
|
|
417
|
+
* HostServ approval mode for `REQUEST <vhost>`. `false` (the parsed
|
|
418
|
+
* default) is oper-queue mode: `REQUEST` queues a pending request that an
|
|
419
|
+
* oper must `APPROVE` (or `REJECT`); `LIST` enumerates the pending
|
|
420
|
+
* queue — the recommended mode. `true` preserves the legacy
|
|
421
|
+
* auto-approve behaviour (the vhost is recorded immediately, no oper
|
|
422
|
+
* review), hard-constrained by {@link hostservAutoApproveAllowlist} and
|
|
423
|
+
* a built-in denylist (reserved labels + this server's own hostname).
|
|
424
|
+
*
|
|
425
|
+
* @deprecated Auto-approve is deprecated; queue mode (the default) is
|
|
426
|
+
* the long-term path and a future release removes the auto-approve
|
|
427
|
+
* arm. See `docs/Services.md`.
|
|
219
428
|
*
|
|
220
429
|
* Typed `boolean | undefined` so inline `ServerConfig` literals in tests
|
|
221
|
-
* may omit it (the reducer treats `undefined` as
|
|
222
|
-
*
|
|
430
|
+
* may omit it (the reducer treats `undefined` as queue mode, matching
|
|
431
|
+
* the parsed default).
|
|
223
432
|
*/
|
|
224
433
|
readonly hostservAutoApproveVhosts?: boolean | undefined;
|
|
434
|
+
/**
|
|
435
|
+
* Explicit allowlist constraining auto-approved vhosts when
|
|
436
|
+
* {@link hostservAutoApproveVhosts} is `true`: an entry is an exact
|
|
437
|
+
* hostname (`users.example.com`) or a wildcard suffix
|
|
438
|
+
* (`*.users.example.com`, any depth beneath it). Case-insensitive.
|
|
439
|
+
* Ignored in queue mode (opers vet requests). The parsed config always
|
|
440
|
+
* supplies an array (default `[]`), and config parsing refuses to boot
|
|
441
|
+
* auto-approve mode with an empty allowlist; the reducer additionally
|
|
442
|
+
* fails closed when the field is omitted from an inline literal.
|
|
443
|
+
*
|
|
444
|
+
* Typed `ReadonlyArray<string> | undefined` to mirror Zod's `.optional()`
|
|
445
|
+
* output under `exactOptionalPropertyTypes: true`.
|
|
446
|
+
*/
|
|
447
|
+
readonly hostservAutoApproveAllowlist?: ReadonlyArray<string> | undefined;
|
|
225
448
|
}
|
|
226
449
|
|
|
227
450
|
/** Per-invocation context handed to every reducer. */
|
|
@@ -235,8 +458,6 @@ export interface Ctx {
|
|
|
235
458
|
readonly ids: IdFactory;
|
|
236
459
|
/** Pre-loaded MOTD source for the `MOTD` reducer. */
|
|
237
460
|
readonly motd: MotdProvider;
|
|
238
|
-
/** SASL account verification source (absent when no accounts are configured). */
|
|
239
|
-
readonly accounts?: AccountStore;
|
|
240
461
|
/**
|
|
241
462
|
* mTLS client-cert identity source for SASL EXTERNAL (absent when mTLS
|
|
242
463
|
* is not configured). When present, `AUTHENTICATE EXTERNAL` resolves the
|
|
@@ -280,13 +501,30 @@ export interface Ctx {
|
|
|
280
501
|
* marker via {@link ServicesStore.setLastReadMarker}, SASL identify seeds
|
|
281
502
|
* the connection's `lastReadMarkers` from
|
|
282
503
|
* {@link ServicesStore.listReadMarkers} so a reconnect restores the user's
|
|
283
|
-
* read position,
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
504
|
+
* read position, SASL PLAIN consults `services.verifyNick`, SASL EXTERNAL
|
|
505
|
+
* consults `services.verifyCertFP`, and PASS-based account auth
|
|
506
|
+
* (`PASS <nick>:<password>`) consults `services.verifyNick`. Omitted (→
|
|
507
|
+
* `ctx.services` undefined) preserves the no-services behaviour:
|
|
508
|
+
* `PRIVMSG NickServ :…` is not intercepted, read-marker persistence stays
|
|
509
|
+
* session-only, and every SASL/PASS credential verify fails with
|
|
510
|
+
* `904` / silent no-op.
|
|
288
511
|
*/
|
|
289
512
|
readonly services?: ServicesStore;
|
|
513
|
+
/**
|
|
514
|
+
* Per-IP failed-`OPER` counter (absent when the deployment's runtime
|
|
515
|
+
* does not maintain one). When present, the OPER reducer counts
|
|
516
|
+
* credential failures per source host: once
|
|
517
|
+
* `serverConfig.oper.maxFailures` failures land within
|
|
518
|
+
* `serverConfig.oper.failureWindowSeconds`, further OPER attempts from
|
|
519
|
+
* that host are rejected with `491 ERR_NOOPERHOST` without consulting
|
|
520
|
+
* the credential store, and a `BroadcastOperNotice` effect notifies
|
|
521
|
+
* every oper. A successful OPER resets the host's counter. Omitted (→
|
|
522
|
+
* `ctx.operFailures` undefined) preserves the unlocked behaviour for
|
|
523
|
+
* runtimes that have not wired the shared tracker (e.g. per-connection
|
|
524
|
+
* Durable Object adapters): every attempt still reaches the credential
|
|
525
|
+
* store.
|
|
526
|
+
*/
|
|
527
|
+
readonly operFailures?: OperFailureStats;
|
|
290
528
|
/** The connection invoking the command. Reducers mutate this freely. */
|
|
291
529
|
readonly connection: ConnectionState;
|
|
292
530
|
/** Convenience: `connection.id`. */
|
|
@@ -323,12 +561,17 @@ export interface BuildCtxOptions {
|
|
|
323
561
|
clock: Clock;
|
|
324
562
|
ids: IdFactory;
|
|
325
563
|
motd: MotdProvider;
|
|
326
|
-
accounts?: AccountStore;
|
|
327
564
|
mtlsIdentity?: MtlsIdentityProvider;
|
|
328
565
|
messages?: MessageStore;
|
|
329
566
|
history?: NickHistoryStore;
|
|
330
567
|
away?: AwayStore;
|
|
331
568
|
services?: ServicesStore;
|
|
569
|
+
/**
|
|
570
|
+
* Per-IP failed-`OPER` counter shared across every connection (see
|
|
571
|
+
* {@link Ctx.operFailures}). Omitted → the OPER brute-force gate is
|
|
572
|
+
* disabled for this actor.
|
|
573
|
+
*/
|
|
574
|
+
operFailures?: OperFailureStats;
|
|
332
575
|
connection: ConnectionState;
|
|
333
576
|
/**
|
|
334
577
|
* IRCv3 `labeled-response` label extracted from the inbound `+label` tag.
|
|
@@ -349,12 +592,12 @@ export function buildCtx(opts: BuildCtxOptions): Ctx {
|
|
|
349
592
|
motd: opts.motd,
|
|
350
593
|
connection: opts.connection,
|
|
351
594
|
connId: opts.connection.id,
|
|
352
|
-
...(opts.accounts !== undefined ? { accounts: opts.accounts } : {}),
|
|
353
595
|
...(opts.mtlsIdentity !== undefined ? { mtlsIdentity: opts.mtlsIdentity } : {}),
|
|
354
596
|
...(opts.messages !== undefined ? { messages: opts.messages } : {}),
|
|
355
597
|
...(opts.history !== undefined ? { history: opts.history } : {}),
|
|
356
598
|
...(opts.away !== undefined ? { away: opts.away } : {}),
|
|
357
599
|
...(opts.services !== undefined ? { services: opts.services } : {}),
|
|
600
|
+
...(opts.operFailures !== undefined ? { operFailures: opts.operFailures } : {}),
|
|
358
601
|
...(opts.label !== undefined ? { label: opts.label } : {}),
|
|
359
602
|
};
|
|
360
603
|
return ctx;
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* every WebSocket runtime (Node `ws`, Cloudflare Durable Objects, Lambda).
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
+
import { byteLength } from './protocol/bytes.js';
|
|
28
29
|
import type { WsSubprotocol } from './ws-subprotocol.js';
|
|
29
30
|
|
|
30
31
|
/**
|
|
@@ -103,12 +104,12 @@ export function frameToLines(frame: string, mode: WsFrameMode): string[] {
|
|
|
103
104
|
/**
|
|
104
105
|
* Returns `true` when `frame` fits within the IRCv3 WebSocket 510-byte
|
|
105
106
|
* message budget, `false` when it exceeds it and MUST be rejected by the
|
|
106
|
-
* inbound path. Measured in UTF-
|
|
107
|
-
* {@link enforceLineLimit} and the input parser cap line length
|
|
108
|
-
* package
|
|
107
|
+
* inbound path. Measured in UTF-8 bytes (the wire encoding), consistent
|
|
108
|
+
* with how {@link enforceLineLimit} and the input parser cap line length
|
|
109
|
+
* across this package, so multi-byte content cannot double the budget.
|
|
109
110
|
*/
|
|
110
111
|
export function isWithinWsByteBudget(frame: string): boolean {
|
|
111
|
-
return frame
|
|
112
|
+
return byteLength(frame) <= MAX_WS_MESSAGE_BYTES;
|
|
112
113
|
}
|
|
113
114
|
|
|
114
115
|
/**
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
type LegacyAccountRow,
|
|
4
|
+
buildMigrationSql,
|
|
5
|
+
buildServicesPutItem,
|
|
6
|
+
parseSaslAccountsForMigration,
|
|
7
|
+
} from '../src/account-migration';
|
|
8
|
+
|
|
9
|
+
describe('parseSaslAccountsForMigration', () => {
|
|
10
|
+
it('returns an empty array for undefined', () => {
|
|
11
|
+
expect(parseSaslAccountsForMigration(undefined)).toEqual([]);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('returns an empty array for an empty string', () => {
|
|
15
|
+
expect(parseSaslAccountsForMigration('')).toEqual([]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('parses a single username:password entry', () => {
|
|
19
|
+
expect(parseSaslAccountsForMigration('alice:s3cret')).toEqual([
|
|
20
|
+
{ username: 'alice', password: 's3cret' },
|
|
21
|
+
]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('skips blank lines and malformed entries', () => {
|
|
25
|
+
expect(
|
|
26
|
+
parseSaslAccountsForMigration('alice:s3cret\n\nnocolon\n:emptyuser\nemptypass:\n \n'),
|
|
27
|
+
).toEqual([{ username: 'alice', password: 's3cret' }]);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('buildMigrationSql', () => {
|
|
32
|
+
const legacyRow = (account: string, salt = 'salt', hash = 'hash'): LegacyAccountRow => ({
|
|
33
|
+
account,
|
|
34
|
+
algorithm: 'scrypt',
|
|
35
|
+
salt,
|
|
36
|
+
hash,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('creates a nickserv_accounts row with cert_subjects "[]" for a legacy account', () => {
|
|
40
|
+
const stmts = buildMigrationSql([legacyRow('alice')], undefined);
|
|
41
|
+
expect(stmts[0]).toContain('CREATE TABLE IF NOT EXISTS nickserv_accounts');
|
|
42
|
+
expect(stmts[1]).toContain('INSERT OR IGNORE');
|
|
43
|
+
expect(stmts[1]).toContain("'alice'");
|
|
44
|
+
expect(stmts[1]).toContain("'[]'");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('uses INSERT OR IGNORE (never REPLACE)', () => {
|
|
48
|
+
const stmts = buildMigrationSql([legacyRow('alice')], undefined);
|
|
49
|
+
expect(stmts[1]).toContain('INSERT OR IGNORE');
|
|
50
|
+
expect(stmts[1]).not.toContain('INSERT OR REPLACE');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('is idempotent (same input → same output)', () => {
|
|
54
|
+
const stmts1 = buildMigrationSql([legacyRow('alice')], undefined);
|
|
55
|
+
const stmts2 = buildMigrationSql([legacyRow('alice')], undefined);
|
|
56
|
+
expect(stmts1).toEqual(stmts2);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('carries SASL_ACCOUNTS env-seed entries not in the legacy table', () => {
|
|
60
|
+
const stmts = buildMigrationSql([legacyRow('alice')], 'bob:bobspw\nalice:alicespw');
|
|
61
|
+
expect(stmts).toHaveLength(3);
|
|
62
|
+
expect(stmts[1]).toContain("'alice'");
|
|
63
|
+
expect(stmts[1]).toContain("'salt'");
|
|
64
|
+
expect(stmts[2]).toContain("'bob'");
|
|
65
|
+
expect(stmts[2]).not.toContain("'bobspw'");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('does not re-hash legacy rows', () => {
|
|
69
|
+
const stmts = buildMigrationSql([legacyRow('alice', 'mysalt', 'myhash')], undefined);
|
|
70
|
+
expect(stmts[1]).toContain("'mysalt'");
|
|
71
|
+
expect(stmts[1]).toContain("'myhash'");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('produces only CREATE TABLE when both inputs are empty', () => {
|
|
75
|
+
const stmts = buildMigrationSql([], undefined);
|
|
76
|
+
expect(stmts).toHaveLength(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('escapes single quotes in usernames', () => {
|
|
80
|
+
const stmts = buildMigrationSql([legacyRow("O'Brien")], undefined);
|
|
81
|
+
expect(stmts[1]).toContain("'O''Brien'");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('buildServicesPutItem', () => {
|
|
86
|
+
it('targets the NICK:<fold> partition', () => {
|
|
87
|
+
const input = buildServicesPutItem(
|
|
88
|
+
{ account: 'alice', algorithm: 'scrypt', salt: 's', hash: 'h' },
|
|
89
|
+
'StagingServices',
|
|
90
|
+
);
|
|
91
|
+
expect(input.TableName).toBe('StagingServices');
|
|
92
|
+
expect(input.Item.pk).toBe('NICK:alice');
|
|
93
|
+
expect(input.Item.sk).toBe('#');
|
|
94
|
+
expect(input.Item.type).toBe('nick');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('preserves the legacy scrypt credential fields', () => {
|
|
98
|
+
const input = buildServicesPutItem(
|
|
99
|
+
{ account: 'alice', algorithm: 'scrypt', salt: 'mysalt', hash: 'myhash' },
|
|
100
|
+
'Services',
|
|
101
|
+
);
|
|
102
|
+
expect(input.Item.algorithm).toBe('scrypt');
|
|
103
|
+
expect(input.Item.salt).toBe('mysalt');
|
|
104
|
+
expect(input.Item.hash).toBe('myhash');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('defaults certSubjects to an empty array', () => {
|
|
108
|
+
const input = buildServicesPutItem(
|
|
109
|
+
{ account: 'alice', algorithm: 'scrypt', salt: 's', hash: 'h' },
|
|
110
|
+
'Services',
|
|
111
|
+
);
|
|
112
|
+
expect(input.Item.certSubjects).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('folds the nick case-insensitively', () => {
|
|
116
|
+
const input = buildServicesPutItem(
|
|
117
|
+
{ account: 'Alice', algorithm: 'scrypt', salt: 's', hash: 'h' },
|
|
118
|
+
'Services',
|
|
119
|
+
);
|
|
120
|
+
expect(input.Item.pk).toBe('NICK:alice');
|
|
121
|
+
expect(input.Item.nick).toBe('Alice');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('stamps default email / enforce / createdAt', () => {
|
|
125
|
+
const input = buildServicesPutItem(
|
|
126
|
+
{ account: 'alice', algorithm: 'scrypt', salt: 's', hash: 'h' },
|
|
127
|
+
'Services',
|
|
128
|
+
);
|
|
129
|
+
expect(input.Item.email).toBe('');
|
|
130
|
+
expect(input.Item.enforce).toBe('none');
|
|
131
|
+
expect(input.Item.createdAt).toBe(0);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { byteLength, truncateToByteBudget } from '../src/protocol/bytes';
|
|
3
|
+
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// byteLength — UTF-8 byte accounting for IRC wire budgets
|
|
6
|
+
//
|
|
7
|
+
// IRC line limits are byte budgets (RFC 1459 §2.3), but JavaScript strings
|
|
8
|
+
// expose UTF-16 code units via `.length`. These tests pin the semantics the
|
|
9
|
+
// line-budget seams rely on: one byte per ASCII char, 2–4 bytes per
|
|
10
|
+
// non-ASCII code point.
|
|
11
|
+
// ============================================================================
|
|
12
|
+
|
|
13
|
+
describe('byteLength', () => {
|
|
14
|
+
it('returns 0 for the empty string', () => {
|
|
15
|
+
expect(byteLength('')).toBe(0);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('counts one byte per ASCII character (matches .length)', () => {
|
|
19
|
+
expect(byteLength('NICK bob')).toBe('NICK bob'.length);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('counts 2 bytes for a 2-byte BMP character (é)', () => {
|
|
23
|
+
expect('é'.length).toBe(1);
|
|
24
|
+
expect(byteLength('é')).toBe(2);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('returns 1022 for 511 é characters — not the UTF-16 count of 511', () => {
|
|
28
|
+
const s = 'é'.repeat(511);
|
|
29
|
+
expect(s.length).toBe(511);
|
|
30
|
+
expect(byteLength(s)).toBe(1022);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('counts 4 bytes for an astral code point (surrogate pair)', () => {
|
|
34
|
+
const emoji = '😀';
|
|
35
|
+
expect(emoji.length).toBe(2);
|
|
36
|
+
expect(byteLength(emoji)).toBe(4);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('accounts for mixed ASCII and multi-byte content', () => {
|
|
40
|
+
expect(byteLength('aé😀')).toBe(1 + 2 + 4);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// ============================================================================
|
|
45
|
+
// truncateToByteBudget — byte-bounded right-truncation
|
|
46
|
+
//
|
|
47
|
+
// Line-budget seams must not only DETECT over-budget multi-byte lines, they
|
|
48
|
+
// must emit output that fits the byte budget. Code-unit slicing cannot do
|
|
49
|
+
// that (510 'é' units = 1020 bytes) and can split surrogate pairs.
|
|
50
|
+
// ============================================================================
|
|
51
|
+
|
|
52
|
+
describe('truncateToByteBudget', () => {
|
|
53
|
+
it('returns the input unchanged when it already fits the budget', () => {
|
|
54
|
+
expect(truncateToByteBudget('NICK bob', 510)).toBe('NICK bob');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('truncates ASCII input exactly like code-unit slicing', () => {
|
|
58
|
+
expect(truncateToByteBudget('x'.repeat(600), 510)).toBe('x'.repeat(510));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('truncates multi-byte input so the result fits the byte budget', () => {
|
|
62
|
+
const out = truncateToByteBudget('é'.repeat(600), 510);
|
|
63
|
+
expect(byteLength(out)).toBeLessThanOrEqual(510);
|
|
64
|
+
// 510 bytes of 'é' = 255 chars; the budget is fully used.
|
|
65
|
+
expect(out).toBe('é'.repeat(255));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('stops before the code point that would cross the budget', () => {
|
|
69
|
+
// Budget 511 with 'é' (2 bytes each): 255 chars = 510 bytes; the 256th
|
|
70
|
+
// 'é' would make 512, so it is dropped and one byte stays unused.
|
|
71
|
+
expect(truncateToByteBudget('é'.repeat(600), 511)).toBe('é'.repeat(255));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('never splits a surrogate pair', () => {
|
|
75
|
+
const out = truncateToByteBudget('😀'.repeat(400), 510);
|
|
76
|
+
// 4-byte emoji: floor(510/4) = 127 complete pairs = 508 bytes.
|
|
77
|
+
expect(out).toBe('😀'.repeat(127));
|
|
78
|
+
expect(byteLength(out)).toBe(508);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('accounts for mixed ASCII and multi-byte content', () => {
|
|
82
|
+
// 'ab' (2 bytes) + 3 'é' (6 bytes) = 8 bytes; budget 5 keeps 'abé'.
|
|
83
|
+
expect(truncateToByteBudget('abééé', 5)).toBe('abé');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('returns the empty string for a zero budget', () => {
|
|
87
|
+
expect(truncateToByteBudget('abc', 0)).toBe('');
|
|
88
|
+
});
|
|
89
|
+
});
|