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
|
@@ -33,20 +33,22 @@ import { randomUUID } from 'node:crypto';
|
|
|
33
33
|
import { type Server, type Socket, createServer } from 'node:net';
|
|
34
34
|
import { InMemoryRuntime } from '@serverless-ircd/in-memory-runtime';
|
|
35
35
|
import {
|
|
36
|
-
type AccountStore,
|
|
37
36
|
type AdmissionConfig,
|
|
38
37
|
type ChanName,
|
|
39
38
|
type ConnectionState,
|
|
40
39
|
ConsoleLogger,
|
|
41
|
-
InMemoryAccountStore,
|
|
42
40
|
InMemoryMessageStore,
|
|
43
41
|
InMemoryNickHistoryStore,
|
|
42
|
+
InMemoryServicesStore,
|
|
44
43
|
LogLevel,
|
|
45
44
|
type Logger,
|
|
46
45
|
type MessageStore,
|
|
47
46
|
type NickHistoryStore,
|
|
47
|
+
type OperCred,
|
|
48
48
|
type RawLine,
|
|
49
49
|
type ServerConfig,
|
|
50
|
+
type ServicesStore,
|
|
51
|
+
SystemClock,
|
|
50
52
|
WS_SUBPROTO_BINARY,
|
|
51
53
|
WS_SUBPROTO_TEXT,
|
|
52
54
|
type WsFrameMode,
|
|
@@ -168,10 +170,11 @@ export interface LocalServerConfig {
|
|
|
168
170
|
*/
|
|
169
171
|
historyMaxPerChannel?: number;
|
|
170
172
|
/**
|
|
171
|
-
* SASL PLAIN account credentials.
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
173
|
+
* SASL PLAIN account credentials. Seeded into the in-memory
|
|
174
|
+
* {@link ServicesStore} at boot so `AUTHENTICATE PLAIN`, `PASS
|
|
175
|
+
* <nick>:<password>`, and NickServ `IDENTIFY` all verify against the
|
|
176
|
+
* listed accounts. Empty array (the default) still binds a services
|
|
177
|
+
* store — NickServ REGISTER creates accounts at runtime.
|
|
175
178
|
*/
|
|
176
179
|
saslAccounts?: Array<{ username: string; password: string }>;
|
|
177
180
|
/**
|
|
@@ -180,8 +183,15 @@ export interface LocalServerConfig {
|
|
|
180
183
|
* like `REHASH` are unreachable; supply a non-empty list to let a client
|
|
181
184
|
* oper up. Threaded straight through to the reducer-facing
|
|
182
185
|
* {@link ServerConfig} via the shared schema.
|
|
186
|
+
*
|
|
187
|
+
* Each entry is EITHER the at-rest hashed form
|
|
188
|
+
* `{user, salt, hash}` (recommended — generate via
|
|
189
|
+
* `tools/hash-oper-cred.ts`) OR the legacy plaintext form
|
|
190
|
+
* `{user, password}` (deprecated — retained for a single
|
|
191
|
+
* deprecation-cycle window). The two shapes can be mixed in the same
|
|
192
|
+
* array; the shared `OperCredSchema` validates either.
|
|
183
193
|
*/
|
|
184
|
-
readonly operCreds?:
|
|
194
|
+
readonly operCreds?: ReadonlyArray<OperCred>;
|
|
185
195
|
}
|
|
186
196
|
|
|
187
197
|
export interface StartServerOptions extends LocalServerConfig {
|
|
@@ -385,13 +395,15 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
385
395
|
// matches wall time.
|
|
386
396
|
const history = new InMemoryNickHistoryStore({ now: () => Date.now() });
|
|
387
397
|
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
398
|
+
// Services store: the single credential home for NickServ, SASL PLAIN,
|
|
399
|
+
// SASL EXTERNAL, and PASS-auth. Seeded from `saslAccounts` config so the
|
|
400
|
+
// listed accounts verify end-to-end via `services.verifyNick`. Always
|
|
401
|
+
// bound (never `undefined`) so NickServ REGISTER / IDENTIFY work
|
|
402
|
+
// out-of-the-box for local development.
|
|
403
|
+
const services = new InMemoryServicesStore({ clock: SystemClock });
|
|
404
|
+
for (const acct of cfg.saslAccounts ?? []) {
|
|
405
|
+
services.registerNick(acct.username, acct.password, '');
|
|
406
|
+
}
|
|
395
407
|
|
|
396
408
|
const wss = new WebSocketServer({
|
|
397
409
|
port: opts.port,
|
|
@@ -434,7 +446,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
434
446
|
wsModes.set(ws, subproto);
|
|
435
447
|
const frameMode = wsFrameModeFor(subproto ?? undefined);
|
|
436
448
|
|
|
437
|
-
const { state, actor } = attachConnection(runtime, cfg, messages,
|
|
449
|
+
const { state, actor } = attachConnection(runtime, cfg, messages, services, history, {
|
|
438
450
|
sendText: (text) => {
|
|
439
451
|
if (ws.readyState !== WebSocket.OPEN) return;
|
|
440
452
|
// binary.ircv3.net connections exchange binary WebSocket frames;
|
|
@@ -521,7 +533,16 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
521
533
|
const tcpSockets = new Set<Socket>();
|
|
522
534
|
const tcpServer = createServer((socket) => {
|
|
523
535
|
tcpSockets.add(socket);
|
|
524
|
-
const scanner = new LineScanner(
|
|
536
|
+
const scanner = new LineScanner({
|
|
537
|
+
// A > 8 KiB unterminated tail is pure attacker-controlled memory
|
|
538
|
+
// (no legal IRC line can exceed 512 bytes): answer with the
|
|
539
|
+
// RFC-style ERROR notice and destroy the socket. Cleanup runs in
|
|
540
|
+
// the shared 'close' handler below.
|
|
541
|
+
onOverflow: () => {
|
|
542
|
+
safeSocketWrite(socket, 'ERROR :Closing link: input buffer overflow\r\n');
|
|
543
|
+
socket.destroy();
|
|
544
|
+
},
|
|
545
|
+
});
|
|
525
546
|
|
|
526
547
|
// Admission gate — same policy as the WS path: refuse before the
|
|
527
548
|
// actor is attached so per-IP / per-user counters stay authoritative.
|
|
@@ -539,7 +560,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
539
560
|
const admissionRecordId = decision.recordId;
|
|
540
561
|
runtime.commitAdmission(ip, undefined, admissionRecordId);
|
|
541
562
|
|
|
542
|
-
const { state, actor } = attachConnection(runtime, cfg, messages,
|
|
563
|
+
const { state, actor } = attachConnection(runtime, cfg, messages, services, history, {
|
|
543
564
|
sendText: (text) => {
|
|
544
565
|
safeSocketWrite(socket, text);
|
|
545
566
|
},
|
|
@@ -630,7 +651,7 @@ function attachConnection(
|
|
|
630
651
|
runtime: InMemoryRuntime,
|
|
631
652
|
cfg: ResolvedServerConfig,
|
|
632
653
|
messages: MessageStore,
|
|
633
|
-
|
|
654
|
+
services: ServicesStore,
|
|
634
655
|
history: NickHistoryStore,
|
|
635
656
|
transport: {
|
|
636
657
|
sendText: (text: string) => void;
|
|
@@ -680,8 +701,12 @@ function attachConnection(
|
|
|
680
701
|
ids: DEFAULT_ID_FACTORY,
|
|
681
702
|
motd: { lines: () => cfg.motdLines },
|
|
682
703
|
messages,
|
|
683
|
-
...(
|
|
704
|
+
...(services !== undefined ? { services } : {}),
|
|
684
705
|
history,
|
|
706
|
+
// Shared per-IP failed-OPER counter: one budget per source host across
|
|
707
|
+
// every connection the CLI hosts (runtime-owned, bound to the runtime
|
|
708
|
+
// clock). Drives the OPER brute-force lockout.
|
|
709
|
+
operFailures: runtime.operFailures,
|
|
685
710
|
logger,
|
|
686
711
|
...(transport.actorTransport !== undefined ? { transport: transport.actorTransport } : {}),
|
|
687
712
|
});
|
|
@@ -79,3 +79,67 @@ describe('LineScanner', () => {
|
|
|
79
79
|
expect(sc.flush()).toEqual([]);
|
|
80
80
|
});
|
|
81
81
|
});
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Input buffer cap: a peer streaming megabytes with no line terminator must
|
|
85
|
+
// not grow the carry unboundedly. The cap is 8 KiB of raw bytes on the
|
|
86
|
+
// retained (unterminated) tail, checked once per push — complete lines
|
|
87
|
+
// flushed by the same chunk never count against the budget.
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
describe('LineScanner — input buffer cap', () => {
|
|
91
|
+
it('signals onOverflow and drops the carry when an appended tail exceeds the 8 KiB cap', () => {
|
|
92
|
+
const overflows: number[] = [];
|
|
93
|
+
const sc = new LineScanner({ onOverflow: () => overflows.push(1) });
|
|
94
|
+
expect(sc.push(enc('x'.repeat(9 * 1024)))).toEqual([]);
|
|
95
|
+
expect(overflows.length).toBe(1);
|
|
96
|
+
// The oversized tail is dropped, not retained.
|
|
97
|
+
expect(sc.flush()).toEqual([]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('checks the cap once per push (chunk), not per byte', () => {
|
|
101
|
+
// A single 9 KiB chunk that overflows fires the callback exactly once —
|
|
102
|
+
// the guard runs on the appended carry, not per input byte.
|
|
103
|
+
const overflows: number[] = [];
|
|
104
|
+
const sc = new LineScanner({ onOverflow: () => overflows.push(1) });
|
|
105
|
+
sc.push(enc('y'.repeat(9 * 1024)));
|
|
106
|
+
expect(overflows.length).toBe(1);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('signals overflow only on the push that crosses the cap (accumulated chunks)', () => {
|
|
110
|
+
const overflows: number[] = [];
|
|
111
|
+
const sc = new LineScanner({ onOverflow: () => overflows.push(1) });
|
|
112
|
+
expect(sc.push(enc('a'.repeat(4 * 1024)))).toEqual([]);
|
|
113
|
+
expect(overflows.length).toBe(0);
|
|
114
|
+
expect(sc.push(enc('b'.repeat(5 * 1024)))).toEqual([]);
|
|
115
|
+
expect(overflows.length).toBe(1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('does NOT overflow a buffered partial line at (just under) the cap', () => {
|
|
119
|
+
const overflows: number[] = [];
|
|
120
|
+
const sc = new LineScanner({ onOverflow: () => overflows.push(1) });
|
|
121
|
+
// Exactly 8192 bytes: at the cap, still allowed.
|
|
122
|
+
sc.push(enc('z'.repeat(8192)));
|
|
123
|
+
expect(overflows.length).toBe(0);
|
|
124
|
+
expect(sc.flush()).toEqual(['z'.repeat(8192)]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('does not overflow a chunk of complete lines totalling over the cap (batching)', () => {
|
|
128
|
+
const overflows: number[] = [];
|
|
129
|
+
const sc = new LineScanner({ onOverflow: () => overflows.push(1) });
|
|
130
|
+
// 20 terminated ~500-byte lines ≈ 10 KiB in one chunk: every line is
|
|
131
|
+
// complete, so the carry is empty and nothing overflows.
|
|
132
|
+
const chunk = `${Array.from({ length: 20 }, (_, i) => `PING :${`${i}`.padStart(495, '-')}`).join('\r\n')}\r\n`;
|
|
133
|
+
expect(chunk.length).toBeGreaterThan(8192);
|
|
134
|
+
expect(sc.push(enc(chunk)).length).toBe(20);
|
|
135
|
+
expect(overflows.length).toBe(0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('measures the carry in bytes (multi-byte UTF-8 counts at wire size)', () => {
|
|
139
|
+
// 'é' encodes to 2 UTF-8 bytes: 5000 × é = 10000 bytes > 8192.
|
|
140
|
+
const overflows: number[] = [];
|
|
141
|
+
const sc = new LineScanner({ onOverflow: () => overflows.push(1) });
|
|
142
|
+
sc.push(enc('é'.repeat(5000)));
|
|
143
|
+
expect(overflows.length).toBe(1);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -275,6 +275,35 @@ describe('local-cli TCP transport — IRC flows', () => {
|
|
|
275
275
|
await client.close();
|
|
276
276
|
});
|
|
277
277
|
|
|
278
|
+
it('closes the TCP connection with ERROR when the input buffer cap is exceeded', async () => {
|
|
279
|
+
// A peer streaming > 8 KiB with no line terminator would grow the
|
|
280
|
+
// LineScanner carry unboundedly; the server must answer with the
|
|
281
|
+
// RFC-style ERROR notice, destroy the socket, and unregister the
|
|
282
|
+
// connection from the runtime.
|
|
283
|
+
const client = new TcpTestClient('127.0.0.1', server.tcpPort as number);
|
|
284
|
+
await client.opened();
|
|
285
|
+
await client.send('NICK overflower');
|
|
286
|
+
await client.send('USER overflower 0 * :Over');
|
|
287
|
+
await client.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
|
|
288
|
+
|
|
289
|
+
const connId = await server.runtime.lookupNick('overflower');
|
|
290
|
+
expect(connId).not.toBeNull();
|
|
291
|
+
expect(server.runtime.hasConnection(connId as string)).toBe(true);
|
|
292
|
+
|
|
293
|
+
const closed = new Promise<void>((resolve) => client.socket.once('close', () => resolve()));
|
|
294
|
+
await client.writeRaw('x'.repeat(9 * 1024)); // no terminator anywhere
|
|
295
|
+
const errorLine = await client.waitFor(
|
|
296
|
+
(l) => l === 'ERROR :Closing link: input buffer overflow',
|
|
297
|
+
2_000,
|
|
298
|
+
);
|
|
299
|
+
expect(errorLine).toBe('ERROR :Closing link: input buffer overflow');
|
|
300
|
+
await closed;
|
|
301
|
+
expect(client.socket.destroyed).toBe(true);
|
|
302
|
+
// Cleanup ran: the runtime no longer tracks the connection.
|
|
303
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
304
|
+
expect(server.runtime.hasConnection(connId as string)).toBe(false);
|
|
305
|
+
});
|
|
306
|
+
|
|
278
307
|
it('logs an actor failure on the TCP path without crashing the server', async () => {
|
|
279
308
|
const original = server.runtime.send.bind(server.runtime);
|
|
280
309
|
server.runtime.send = async () => {
|
|
Binary file
|
package/apps/web/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverless-ircd/web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Web client workspace: Kiwi IRC SPA served at /webclient/ and project landing page at /",
|
|
6
6
|
"license": "BSD-3-Clause",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"scripts": {
|
|
9
9
|
"build": "node scripts/build.mjs",
|
|
10
|
-
"build:staging": "node scripts/build.mjs --env staging",
|
|
11
10
|
"build:prod": "node scripts/build.mjs --env prod",
|
|
11
|
+
"build:prod-aws": "node scripts/build.mjs --env prod-aws",
|
|
12
12
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
13
13
|
"test": "vitest run",
|
|
14
14
|
"test:watch": "vitest",
|
|
@@ -3,7 +3,12 @@ import { existsSync } from 'node:fs';
|
|
|
3
3
|
import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
hasUnsubstitutedPlaceholder,
|
|
8
|
+
parseApiUrlForKiwi,
|
|
9
|
+
resolveApiUrl,
|
|
10
|
+
resolveConfigFile,
|
|
11
|
+
} from '../src/build-env.ts';
|
|
7
12
|
import { parseKiwiConfig } from '../src/config-schema.ts';
|
|
8
13
|
import { buildDocs } from '../src/render-docs.ts';
|
|
9
14
|
|
|
@@ -107,11 +112,12 @@ await writeFile(indexDest, indexHtml);
|
|
|
107
112
|
// so missing fields, out-of-range ports, and non-`#` channels abort the
|
|
108
113
|
// build here with a readable error. The validated JSON is re-serialised
|
|
109
114
|
// (stable key order) so the baked config is identical to the schema output.
|
|
110
|
-
const
|
|
115
|
+
const argv = process.argv.slice(2);
|
|
116
|
+
const { file: configFileName, env: configEnv } = resolveConfigFile(argv);
|
|
111
117
|
const configSrcPath = path.join(staticDir, configFileName);
|
|
112
118
|
if (!existsSync(configSrcPath)) {
|
|
113
119
|
throw new Error(
|
|
114
|
-
`Config file for env "${configEnv}" not found at ${configSrcPath}. Expected one of: config.json, config.
|
|
120
|
+
`Config file for env "${configEnv}" not found at ${configSrcPath}. Expected one of: config.json, config.prod.json, config.prod-aws.json.`,
|
|
115
121
|
);
|
|
116
122
|
}
|
|
117
123
|
const configRaw = await readFile(configSrcPath, 'utf8');
|
|
@@ -123,6 +129,50 @@ try {
|
|
|
123
129
|
// offending env config, not just the schema path.
|
|
124
130
|
throw new Error(`${configFileName}: ${err.message}`, { cause: err });
|
|
125
131
|
}
|
|
132
|
+
|
|
133
|
+
// 4b. AWS deploy pipeline override: `--api-url <wss-url>` (read from the
|
|
134
|
+
// deployed stack's `ConnectUrl` CloudFormation output by
|
|
135
|
+
// `scripts/deploy-web-aws.mjs`) replaces the templated `server` value
|
|
136
|
+
// baked into `config.prod-aws.json` with the real API Gateway
|
|
137
|
+
// WebSocket host. No concrete URLs in the repo; substitution happens
|
|
138
|
+
// at deploy time.
|
|
139
|
+
//
|
|
140
|
+
// irc-framework builds the WS URL as `wss://<server>:<port><direct_path>`,
|
|
141
|
+
// prepending `wss://` itself whenever `tls: true`. The override MUST
|
|
142
|
+
// split the URL into a hostname-only `server` and a stage-name
|
|
143
|
+
// `direct_path` (e.g. `/prod`) — assigning the full `wss://...` URL to
|
|
144
|
+
// `server` would make irc-framework prepend a second `wss://`,
|
|
145
|
+
// producing the malformed `wss://wss//<host>...` URL that Firefox
|
|
146
|
+
// rejects. When the flag is absent (Cloudflare deploys, local dev)
|
|
147
|
+
// the config's `server`/`direct_path` fields are left untouched.
|
|
148
|
+
const apiUrlOverride = resolveApiUrl(argv);
|
|
149
|
+
if (apiUrlOverride !== undefined) {
|
|
150
|
+
const { server, directPath } = parseApiUrlForKiwi(apiUrlOverride);
|
|
151
|
+
configParsed.startupOptions.server = server;
|
|
152
|
+
if (directPath !== undefined) {
|
|
153
|
+
configParsed.startupOptions.direct_path = directPath;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 4c. Reject unsubstituted AWS deploy-time placeholders — but ONLY when
|
|
158
|
+
// the selected config is the prod-aws env. The prod-aws config carries
|
|
159
|
+
// `{{API_ID}}`/`{{REGION}}`/`{{STAGE}}` that MUST be substituted by the
|
|
160
|
+
// deploy pipeline via `--api-url`; a baked prod-aws build that left
|
|
161
|
+
// them in place would ship a broken SPA dialling a literal `{{API_ID}}...`
|
|
162
|
+
// URL.
|
|
163
|
+
//
|
|
164
|
+
// The CF envs (`default`, `prod`) use Kiwi's runtime templates
|
|
165
|
+
// (`{{hostname}}`, `{{port}}`) which are resolved in the browser at
|
|
166
|
+
// load time and must survive the bake. Applying the guard to them
|
|
167
|
+
// would break every default `pnpm build` and every `build:prod`, so
|
|
168
|
+
// the helper scopes the check to non-CF envs. See
|
|
169
|
+
// `hasUnsubstitutedPlaceholder` in `src/build-env.ts`.
|
|
170
|
+
if (hasUnsubstitutedPlaceholder(JSON.stringify(configParsed), configEnv)) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`Refusing to bake ${configFileName} with unsubstituted {{...}} placeholders. Pass --api-url <wss-url> (the deployed stack's ConnectUrl output) to substitute the API Gateway WebSocket URL.`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
126
176
|
await writeFile(
|
|
127
177
|
path.join(appDist, 'static', 'config.json'),
|
|
128
178
|
`${JSON.stringify(configParsed, null, 2)}\n`,
|
|
@@ -133,6 +183,11 @@ await writeFile(
|
|
|
133
183
|
// dist/index.html so the Worker `[assets]` binding serves it at `/`. Pure
|
|
134
184
|
// static HTML + inline CSS (no JS); links to /webclient/ (the SPA),
|
|
135
185
|
// /docs/ (the rendered docs built in step 6), and the source repo.
|
|
186
|
+
// Also copies landing/favicon.ico → dist/favicon.ico so both platforms
|
|
187
|
+
// (CF [assets] binding and AWS `s3 sync apps/web/dist`) serve the
|
|
188
|
+
// project-front-door icon at /favicon.ico. The Kiwi SPA has its own
|
|
189
|
+
// icon at /webclient/static/favicon.png (a Kiwi-upstream asset, copied
|
|
190
|
+
// in step 3); this one is for the landing page only.
|
|
136
191
|
await mkdir(dist, { recursive: true });
|
|
137
192
|
const landingSrc = path.join(pkgRoot, 'landing', 'index.html');
|
|
138
193
|
if (!existsSync(landingSrc)) {
|
|
@@ -141,6 +196,13 @@ if (!existsSync(landingSrc)) {
|
|
|
141
196
|
);
|
|
142
197
|
}
|
|
143
198
|
await cp(landingSrc, path.join(dist, 'index.html'));
|
|
199
|
+
const faviconSrc = path.join(pkgRoot, 'landing', 'favicon.ico');
|
|
200
|
+
if (!existsSync(faviconSrc)) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
`Landing page favicon missing at ${faviconSrc}. Expected apps/web/landing/favicon.ico.`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
await cp(faviconSrc, path.join(dist, 'favicon.ico'));
|
|
144
206
|
|
|
145
207
|
// 6. Docs: render every docs/*.md to standalone HTML under dist/docs/ so the
|
|
146
208
|
// Worker `[assets]` binding serves them at /docs/<name>.html (and /docs/
|
|
@@ -150,6 +212,6 @@ await cp(landingSrc, path.join(dist, 'index.html'));
|
|
|
150
212
|
const docsResult = await buildDocs({ srcDir: docsSrc, outDir: docsDist });
|
|
151
213
|
|
|
152
214
|
console.log(
|
|
153
|
-
`Kiwi SPA built -> dist/webclient/ (publicPath=${PUBLIC_PATH}, config=${configFileName}, env=${configEnv})`,
|
|
215
|
+
`Kiwi SPA built -> dist/webclient/ (publicPath=${PUBLIC_PATH}, config=${configFileName}, env=${configEnv}, apiUrl=${apiUrlOverride === undefined ? 'from-config' : 'overridden'})`,
|
|
154
216
|
);
|
|
155
217
|
console.log(`Docs rendered -> dist/docs/ (${docsResult.emitted.length} pages, index=index.html)`);
|
|
@@ -5,22 +5,36 @@
|
|
|
5
5
|
* unit-testable without running the (slow) Kiwi upstream build. The build
|
|
6
6
|
* script delegates here and then validates the selected file against
|
|
7
7
|
* {@link parseKiwiConfig} before baking it into `dist/webclient/static/config.json`.
|
|
8
|
+
*
|
|
9
|
+
* There is a single Cloudflare deploy target (`prod`); the staging/prod
|
|
10
|
+
* split was collapsed. `--env prod` selects `config.prod.json`; omitting
|
|
11
|
+
* `--env` bakes the dev default (`config.json`). `--env staging` is rejected.
|
|
12
|
+
*
|
|
13
|
+
* AWS has its own per-env config because the WebSocket endpoint is a separate
|
|
14
|
+
* API Gateway URL (`wss://{api-id}.execute-api.{region}.amazonaws.com/{stage}`),
|
|
15
|
+
* not the CloudFront origin serving the SPA. `--env prod-aws` selects
|
|
16
|
+
* `config.prod-aws.json`, whose `server` carries `{{API_ID}}` / `{{REGION}}`
|
|
17
|
+
* / `{{STAGE}}` placeholders the deploy pipeline substitutes at bake time
|
|
18
|
+
* (no concrete URLs in the repo). The `prod-aws` env name is retained for
|
|
19
|
+
* fidelity with the historical staging/prod nomenclature even though the
|
|
20
|
+
* staging/prod split is gone — `aws` alone would read as a cleaner label but
|
|
21
|
+
* is left for a separate rename pass.
|
|
8
22
|
*/
|
|
9
23
|
|
|
10
24
|
/** argv values accepted by `--env`; mapped to a `static/config.<env>.json` file. */
|
|
11
25
|
export const ENV_TO_CONFIG_FILE = {
|
|
12
|
-
staging: 'config.staging.json',
|
|
13
26
|
prod: 'config.prod.json',
|
|
27
|
+
'prod-aws': 'config.prod-aws.json',
|
|
14
28
|
} as const;
|
|
15
29
|
|
|
16
30
|
export type BuildEnv = keyof typeof ENV_TO_CONFIG_FILE;
|
|
17
31
|
|
|
18
|
-
/** Default config file baked when `--env` is not supplied (
|
|
32
|
+
/** Default config file baked when `--env` is not supplied (dev default). */
|
|
19
33
|
export const DEFAULT_CONFIG_FILE = 'config.json';
|
|
20
34
|
|
|
21
35
|
/**
|
|
22
36
|
* Resolves which `static/config.*.json` the build should bake from the
|
|
23
|
-
* `--env
|
|
37
|
+
* `--env prod` CLI flag.
|
|
24
38
|
*
|
|
25
39
|
* @param argv the CLI args after the script name (typically `process.argv.slice(2)`)
|
|
26
40
|
* @returns `{ file, env }` where `file` is the basename under `static/` and
|
|
@@ -31,7 +45,7 @@ export function resolveConfigFile(argv: string[]): { file: string; env: string }
|
|
|
31
45
|
const envIndex = argv.indexOf('--env');
|
|
32
46
|
const env = envIndex !== -1 ? argv[envIndex + 1] : undefined;
|
|
33
47
|
if (env === undefined) {
|
|
34
|
-
return { file: DEFAULT_CONFIG_FILE, env: 'default
|
|
48
|
+
return { file: DEFAULT_CONFIG_FILE, env: 'default' };
|
|
35
49
|
}
|
|
36
50
|
const file = ENV_TO_CONFIG_FILE[env as BuildEnv];
|
|
37
51
|
if (file === undefined) {
|
|
@@ -41,3 +55,110 @@ export function resolveConfigFile(argv: string[]): { file: string; env: string }
|
|
|
41
55
|
}
|
|
42
56
|
return { file, env };
|
|
43
57
|
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolves the optional `--api-url <value>` flag carried on the build argv.
|
|
61
|
+
*
|
|
62
|
+
* The AWS web deploy pipeline reads the deployed stack's `ConnectUrl`
|
|
63
|
+
* CloudFormation output (the API Gateway WebSocket URL) and forwards it
|
|
64
|
+
* to the SPA build via `--api-url`. The build script then overrides
|
|
65
|
+
* `startupOptions.server` in the parsed Kiwi config with this value before
|
|
66
|
+
* baking `dist/webclient/static/config.json`, so the baked SPA carries the
|
|
67
|
+
* real wss URL instead of the `{{API_ID}}`/`{{REGION}}`/`{{STAGE}}`
|
|
68
|
+
* template that lives in `static/config.prod-aws.json`. No concrete URLs
|
|
69
|
+
* are committed; the URL is read from stack outputs at deploy time.
|
|
70
|
+
*
|
|
71
|
+
* Returns `undefined` when the flag is absent OR when it is the final
|
|
72
|
+
* token on the argv (no following value). The caller treats both cases
|
|
73
|
+
* identically: leave the config's `server` field untouched. The Cloudflare
|
|
74
|
+
* deploys never pass this flag; only the AWS deploy pipeline does.
|
|
75
|
+
*
|
|
76
|
+
* @param argv the CLI args after the script name (typically `process.argv.slice(2)`)
|
|
77
|
+
* @returns the URL string following `--api-url`, or `undefined` when unset.
|
|
78
|
+
*/
|
|
79
|
+
export function resolveApiUrl(argv: string[]): string | undefined {
|
|
80
|
+
const i = argv.indexOf('--api-url');
|
|
81
|
+
if (i === -1) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
return argv[i + 1];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Splits a `wss://` API Gateway WebSocket URL into the two Kiwi fields it
|
|
89
|
+
* becomes at bake time: `server` (hostname only) and `direct_path` (the
|
|
90
|
+
* stage-name path segment).
|
|
91
|
+
*
|
|
92
|
+
* irc-framework builds the WebSocket URL as `wss://<server>:<port><direct_path>`,
|
|
93
|
+
* prepending the `wss://` scheme itself whenever `tls: true`. Setting
|
|
94
|
+
* `server` to a full `wss://...` URL would make irc-framework prepend a
|
|
95
|
+
* second `wss://`, producing a malformed URL that Firefox reports as
|
|
96
|
+
* `wss://wss//<host>...` (the inner `:` becomes a userinfo separator).
|
|
97
|
+
*
|
|
98
|
+
* AWS API Gateway WebSocket URLs include the stage name as a path
|
|
99
|
+
* segment (`wss://<id>.execute-api.<region>.amazonaws.com/<stage>`) —
|
|
100
|
+
* that segment must reach the browser as `direct_path`, not as part of
|
|
101
|
+
* `server`. The CF Worker's WS upgrade is at root, so CF configs leave
|
|
102
|
+
* `direct_path` unset (or `/`) and `server` is just the hostname.
|
|
103
|
+
*
|
|
104
|
+
* @param url a `wss://` or `ws://` URL (the stack's `ConnectUrl` output)
|
|
105
|
+
* @returns `{ server, directPath }` where `server` is the bare hostname
|
|
106
|
+
* and `directPath` is the leading-slash path (e.g. `/prod`) or
|
|
107
|
+
* `undefined` when the URL's path is `/` or empty.
|
|
108
|
+
* @throws {Error} when the URL is missing the `ws://`/`wss://` scheme or
|
|
109
|
+
* fails `new URL()` parsing.
|
|
110
|
+
*/
|
|
111
|
+
export function parseApiUrlForKiwi(url: string): {
|
|
112
|
+
server: string;
|
|
113
|
+
directPath: string | undefined;
|
|
114
|
+
} {
|
|
115
|
+
if (!/^wss?:\/\//iu.test(url)) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`--api-url must be a wss:// (or ws://) URL; got: ${url}. irc-framework prepends the scheme itself from tls:true, so the bake must split the URL into a hostname-only server + a direct_path.`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
const parsed = new URL(url);
|
|
121
|
+
const directPath =
|
|
122
|
+
parsed.pathname === '' || parsed.pathname === '/' ? undefined : parsed.pathname;
|
|
123
|
+
return { server: parsed.hostname, directPath };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Pattern matching any `{{key}}` placeholder, used by both the Kiwi
|
|
128
|
+
* runtime templates (`{{hostname}}`, `{{host}}`, `{{port}}`) and the
|
|
129
|
+
* AWS deploy-time placeholders (`{{API_ID}}`, `{{REGION}}`,
|
|
130
|
+
* `{{STAGE}}`).
|
|
131
|
+
*/
|
|
132
|
+
export const PLACEHOLDER_PATTERN = /\{\{[^}]+\}\}/u;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The Cloudflare deploy envs whose `server` field legitimately bakes
|
|
136
|
+
* Kiwi runtime templates. The placeholder guard must NOT fire on these
|
|
137
|
+
* — `{{hostname}}` is resolved by Kiwi in the browser at load time.
|
|
138
|
+
*/
|
|
139
|
+
const RUNTIME_TEMPLATE_ENVS = new Set<string>(['default', 'prod']);
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Returns `true` when a baked Kiwi config carries an unsubstituted
|
|
143
|
+
* placeholder that would produce a broken SPA. The guard is scoped to
|
|
144
|
+
* the `prod-aws` env: that is the only config whose `server` carries
|
|
145
|
+
* AWS deploy-time placeholders (`{{API_ID}}`/`{{REGION}}`/`{{STAGE}}`)
|
|
146
|
+
* that the deploy pipeline (`scripts/deploy-web-aws.mjs`) must
|
|
147
|
+
* substitute via `--api-url` before baking.
|
|
148
|
+
*
|
|
149
|
+
* The Cloudflare envs (`default`, `prod`) use Kiwi's `{{hostname}}`
|
|
150
|
+
* runtime template, which is resolved by Kiwi in the browser at load
|
|
151
|
+
* time and MUST survive the bake. Applying the guard to them would
|
|
152
|
+
* break every default `pnpm build` and every `build:prod`.
|
|
153
|
+
*
|
|
154
|
+
* @param bakedConfigJson the serialised config payload about to be
|
|
155
|
+
* written to `dist/webclient/static/config.json`.
|
|
156
|
+
* @param env the selected build env (the `env` field from
|
|
157
|
+
* {@link resolveConfigFile}).
|
|
158
|
+
*/
|
|
159
|
+
export function hasUnsubstitutedPlaceholder(bakedConfigJson: string, env: string): boolean {
|
|
160
|
+
if (RUNTIME_TEMPLATE_ENVS.has(env)) {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
return PLACEHOLDER_PATTERN.test(bakedConfigJson);
|
|
164
|
+
}
|
|
@@ -83,13 +83,27 @@ export const KiwiStartupOptionsSchema = z
|
|
|
83
83
|
/**
|
|
84
84
|
* WebSocket path on the server. Kiwi reads `direct_path` (NOT `path`) —
|
|
85
85
|
* upstream `helpers/Misc.js:139` copies `config.direct_path` into the
|
|
86
|
-
* connection info; a `path` field is silently ignored.
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
86
|
+
* connection info; a `path` field is silently ignored.
|
|
87
|
+
*
|
|
88
|
+
* Two valid shapes:
|
|
89
|
+
* - CF Worker: WS upgrade is at root, so `direct_path` is `/`.
|
|
90
|
+
* - AWS API Gateway: the WebSocket endpoint URL includes the stage
|
|
91
|
+
* name as a path segment
|
|
92
|
+
* (`wss://<id>.execute-api.<region>.amazonaws.com/<stage>`), so
|
|
93
|
+
* `direct_path` MUST be `/<stage>` (e.g. `/prod`). irc-framework
|
|
94
|
+
* builds the WS URL as `wss://<server>:<port><direct_path>`.
|
|
95
|
+
*
|
|
96
|
+
* The constraint accepts a leading slash plus zero or one path segment
|
|
97
|
+
* (no nested paths), allowing alphanumeric stage names and the
|
|
98
|
+
* `{{STAGE}}` placeholder baked into `config.prod-aws.json` for the
|
|
99
|
+
* deploy pipeline to substitute. Optional — when omitted irc-framework
|
|
100
|
+
* builds `wss://host:port` with no path, which the browser normalises
|
|
101
|
+
* to `wss://host:port/` (CF root case).
|
|
91
102
|
*/
|
|
92
|
-
direct_path: z
|
|
103
|
+
direct_path: z
|
|
104
|
+
.string()
|
|
105
|
+
.regex(/^\/(?:[a-zA-Z0-9._-]+|\{\{[^}]+\}\})?$/u, 'direct_path must be / or /<stage>')
|
|
106
|
+
.optional(),
|
|
93
107
|
})
|
|
94
108
|
.passthrough();
|
|
95
109
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"windowTitle": "ServerlessIRCd
|
|
2
|
+
"windowTitle": "ServerlessIRCd",
|
|
3
3
|
"startupScreen": "welcome",
|
|
4
4
|
"restricted": false,
|
|
5
5
|
"theme": "Dark",
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
{ "name": "Elite", "url": "static/themes/elite" }
|
|
16
16
|
],
|
|
17
17
|
"startupOptions": {
|
|
18
|
-
"server": "{{
|
|
18
|
+
"server": "{{API_ID}}.execute-api.{{REGION}}.amazonaws.com",
|
|
19
|
+
"direct_path": "/{{STAGE}}",
|
|
19
20
|
"direct": true,
|
|
20
21
|
"tls": true,
|
|
21
22
|
"port": 443,
|