serverless-ircd 0.9.0 → 0.10.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/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +11 -9
- package/.github/workflows/deploy-cf.yml +14 -14
- package/CHANGELOG.md +305 -0
- package/README.md +134 -41
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +82 -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 +177 -52
- 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 +267 -92
- 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/package.json +2 -3
- package/apps/cf-tcp-container/src/container-server.ts +12 -9
- package/apps/cf-tcp-container/wrangler.toml +1 -10
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/wrangler.toml +12 -71
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +21 -17
- 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 +670 -96
- package/docs/AWS-TCP-Deployment.md +20 -45
- package/docs/Cloudflare-Deployment-Guide.md +87 -113
- package/docs/Cloudflare-TCP-Deployment.md +25 -49
- package/docs/Release-Process.md +27 -23
- 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/cdk-table-defs.ts +6 -11
- package/packages/aws-adapter/src/config-loader.ts +19 -2
- package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
- package/packages/aws-adapter/src/handlers/connect.ts +26 -0
- package/packages/aws-adapter/src/handlers/default.ts +190 -123
- package/packages/aws-adapter/src/handlers/index.ts +67 -23
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +3 -6
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +15 -0
- package/packages/aws-adapter/src/tables.ts +2 -12
- package/packages/aws-adapter/tests/aws-harness.ts +0 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
- package/packages/aws-adapter/tests/connect.test.ts +124 -1
- package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
- package/packages/aws-adapter/tests/handlers.test.ts +117 -11
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- 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 +59 -8
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/connection-do.ts +266 -109
- package/packages/cf-adapter/src/d1-services-store.ts +63 -26
- package/packages/cf-adapter/src/env.ts +11 -10
- package/packages/cf-adapter/src/index.ts +0 -6
- package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +95 -54
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/commands/account-auth.ts +16 -19
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/nickserv.ts +122 -11
- package/packages/irc-core/src/commands/registration.ts +2 -2
- package/packages/irc-core/src/commands/sasl.ts +22 -31
- package/packages/irc-core/src/config.ts +35 -9
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/index.ts +1 -0
- package/packages/irc-core/src/ports.ts +159 -179
- package/packages/irc-core/src/types.ts +38 -10
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- 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/nickserv.test.ts +237 -1
- package/packages/irc-core/tests/commands/oper.test.ts +15 -0
- package/packages/irc-core/tests/commands/registration.test.ts +140 -133
- package/packages/irc-core/tests/commands/sasl.test.ts +159 -178
- package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
- package/packages/irc-core/tests/ports.test.ts +71 -0
- package/packages/irc-core/tests/services-store.test.ts +204 -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 +0 -14
- package/packages/irc-server/tests/actor.test.ts +19 -47
- package/packages/irc-test-support/package.json +1 -1
- 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 +1 -1
- package/tools/ci-hardening/src/index.ts +2 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -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
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
## Cloudflare Worker production config for ServerlessIRCd.
|
|
4
4
|
##
|
|
5
5
|
## Deploy:
|
|
6
|
-
## pnpm deploy:cf
|
|
7
|
-
## pnpm deploy:cf:prod # wrangler deploy
|
|
6
|
+
## pnpm deploy:cf # wrangler deploy
|
|
8
7
|
##
|
|
9
8
|
## The `apps/cf-worker` worker is the public edge for the IRC service.
|
|
10
9
|
## It owns the three Durable Object namespaces from the cf-adapter
|
|
@@ -13,10 +12,8 @@
|
|
|
13
12
|
## `src/worker.ts` and PLAN §6.1.
|
|
14
13
|
##
|
|
15
14
|
## Secrets: NEVER put secrets (API tokens, server passwords, account
|
|
16
|
-
## credentials) in this file. Use `wrangler secret put <NAME
|
|
17
|
-
##
|
|
18
|
-
## As of v1 the worker does not consume any secret bindings — server
|
|
19
|
-
## password, SASL `AccountStore`, and oper creds are future work.
|
|
15
|
+
## credentials) in this file. Use `wrangler secret put <NAME>`;
|
|
16
|
+
## they live in the Workers KV secrets store, not the repo.
|
|
20
17
|
|
|
21
18
|
name = "serverless-ircd"
|
|
22
19
|
main = "src/worker.ts"
|
|
@@ -104,55 +101,20 @@ not_found_handling = "none"
|
|
|
104
101
|
run_worker_first = true
|
|
105
102
|
|
|
106
103
|
## ---------------------------------------------------------------------------
|
|
107
|
-
##
|
|
104
|
+
## D1 database — unified services persistence (NickServ / ChanServ / SASL).
|
|
108
105
|
## ---------------------------------------------------------------------------
|
|
109
|
-
##
|
|
110
|
-
##
|
|
111
|
-
##
|
|
112
|
-
|
|
113
|
-
name = "serverless-ircd-staging"
|
|
114
|
-
|
|
115
|
-
[env.staging.observability]
|
|
116
|
-
enabled = true
|
|
117
|
-
head_sampling_rate = 1
|
|
118
|
-
|
|
119
|
-
[env.staging.vars]
|
|
120
|
-
SERVER_NAME = "irc-staging.example.com"
|
|
121
|
-
NETWORK_NAME = "ServerlessIRCd (staging)"
|
|
122
|
-
MOTD_LINES = """
|
|
123
|
-
Welcome to ServerlessIRCd staging.
|
|
124
|
-
This is a deployed Cloudflare Worker.
|
|
125
|
-
This Worker was deployed by CI from the main branch.
|
|
126
|
-
"""
|
|
127
|
-
|
|
128
|
-
# Staging serves the same `apps/web/dist` asset layer. Wrangler requires the
|
|
129
|
-
# per-env assets block so `--env staging` deploys both the Worker and its
|
|
130
|
-
# assets in a single `wrangler deploy --env staging` (the same `pnpm build`
|
|
131
|
-
# populates `dist/` ahead of the deploy step).
|
|
132
|
-
[env.staging.assets]
|
|
133
|
-
directory = "../../apps/web/dist"
|
|
134
|
-
binding = "ASSETS"
|
|
135
|
-
not_found_handling = "none"
|
|
136
|
-
run_worker_first = true
|
|
137
|
-
|
|
138
|
-
## ---------------------------------------------------------------------------
|
|
139
|
-
## D1 database — SASL account persistence.
|
|
140
|
-
## ---------------------------------------------------------------------------
|
|
141
|
-
## The `accounts` table stores scrypt-hashed SASL PLAIN credentials (never
|
|
142
|
-
## plaintext). Loaded at ConnectionDO construction; when empty or unreachable
|
|
143
|
-
## the resolver falls back to the `SASL_ACCOUNTS` env-var seed. Provision with:
|
|
106
|
+
## The `nickserv_accounts` table is the single credential home for SASL
|
|
107
|
+
## PLAIN, SASL EXTERNAL, PASS-auth, and NickServ IDENTIFY. All scrypt-hashed
|
|
108
|
+
## credentials live here; the standalone `accounts` table was dropped.
|
|
109
|
+
## Provision with:
|
|
144
110
|
## wrangler d1 create serverless-ircd-accounts
|
|
145
|
-
##
|
|
146
|
-
##
|
|
147
|
-
##
|
|
111
|
+
## The services tables self-provision on first load (ConnectionDO runs
|
|
112
|
+
## `CREATE TABLE IF NOT EXISTS` for each table). Seed accounts via
|
|
113
|
+
## `tools/seed-cf-accounts.ts`.
|
|
148
114
|
[[d1_databases]]
|
|
149
115
|
binding = "ACCOUNTS_DB"
|
|
150
116
|
database_name = "serverless-ircd-accounts"
|
|
151
|
-
# database_id is filled in by `wrangler d1 create
|
|
152
|
-
|
|
153
|
-
[[env.staging.d1_databases]]
|
|
154
|
-
binding = "ACCOUNTS_DB"
|
|
155
|
-
database_name = "serverless-ircd-accounts-staging"
|
|
117
|
+
# database_id is filled in by `wrangler d1 create`.
|
|
156
118
|
|
|
157
119
|
## ---------------------------------------------------------------------------
|
|
158
120
|
## Durable Object bindings.
|
|
@@ -179,23 +141,6 @@ class_name = "ChannelDO"
|
|
|
179
141
|
name = "CHANNEL_REGISTRY_DO"
|
|
180
142
|
class_name = "ChannelRegistryDO"
|
|
181
143
|
|
|
182
|
-
# Staging environment bindings (wrangler requires per-env blocks).
|
|
183
|
-
[[env.staging.durable_objects.bindings]]
|
|
184
|
-
name = "CONNECTION_DO"
|
|
185
|
-
class_name = "ConnectionDO"
|
|
186
|
-
|
|
187
|
-
[[env.staging.durable_objects.bindings]]
|
|
188
|
-
name = "REGISTRY_DO"
|
|
189
|
-
class_name = "RegistryDO"
|
|
190
|
-
|
|
191
|
-
[[env.staging.durable_objects.bindings]]
|
|
192
|
-
name = "CHANNEL_DO"
|
|
193
|
-
class_name = "ChannelDO"
|
|
194
|
-
|
|
195
|
-
[[env.staging.durable_objects.bindings]]
|
|
196
|
-
name = "CHANNEL_REGISTRY_DO"
|
|
197
|
-
class_name = "ChannelRegistryDO"
|
|
198
|
-
|
|
199
144
|
# ## ---------------------------------------------------------------------------
|
|
200
145
|
# ## Durable Object migrations.
|
|
201
146
|
# ## ---------------------------------------------------------------------------
|
|
@@ -210,10 +155,6 @@ class_name = "ChannelRegistryDO"
|
|
|
210
155
|
# tag = "v1"
|
|
211
156
|
# new_sqlite_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO"]
|
|
212
157
|
|
|
213
|
-
# [[env.staging.migrations]]
|
|
214
|
-
# tag = "v1"
|
|
215
|
-
# new_sqlite_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO"]
|
|
216
|
-
|
|
217
158
|
[exports.ConnectionDO]
|
|
218
159
|
type = "durable-object"
|
|
219
160
|
storage = "sqlite"
|
|
@@ -33,20 +33,21 @@ 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,
|
|
48
47
|
type RawLine,
|
|
49
48
|
type ServerConfig,
|
|
49
|
+
type ServicesStore,
|
|
50
|
+
SystemClock,
|
|
50
51
|
WS_SUBPROTO_BINARY,
|
|
51
52
|
WS_SUBPROTO_TEXT,
|
|
52
53
|
type WsFrameMode,
|
|
@@ -168,10 +169,11 @@ export interface LocalServerConfig {
|
|
|
168
169
|
*/
|
|
169
170
|
historyMaxPerChannel?: number;
|
|
170
171
|
/**
|
|
171
|
-
* SASL PLAIN account credentials.
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
172
|
+
* SASL PLAIN account credentials. Seeded into the in-memory
|
|
173
|
+
* {@link ServicesStore} at boot so `AUTHENTICATE PLAIN`, `PASS
|
|
174
|
+
* <nick>:<password>`, and NickServ `IDENTIFY` all verify against the
|
|
175
|
+
* listed accounts. Empty array (the default) still binds a services
|
|
176
|
+
* store — NickServ REGISTER creates accounts at runtime.
|
|
175
177
|
*/
|
|
176
178
|
saslAccounts?: Array<{ username: string; password: string }>;
|
|
177
179
|
/**
|
|
@@ -385,13 +387,15 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
385
387
|
// matches wall time.
|
|
386
388
|
const history = new InMemoryNickHistoryStore({ now: () => Date.now() });
|
|
387
389
|
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
390
|
+
// Services store: the single credential home for NickServ, SASL PLAIN,
|
|
391
|
+
// SASL EXTERNAL, and PASS-auth. Seeded from `saslAccounts` config so the
|
|
392
|
+
// listed accounts verify end-to-end via `services.verifyNick`. Always
|
|
393
|
+
// bound (never `undefined`) so NickServ REGISTER / IDENTIFY work
|
|
394
|
+
// out-of-the-box for local development.
|
|
395
|
+
const services = new InMemoryServicesStore({ clock: SystemClock });
|
|
396
|
+
for (const acct of cfg.saslAccounts ?? []) {
|
|
397
|
+
services.registerNick(acct.username, acct.password, '');
|
|
398
|
+
}
|
|
395
399
|
|
|
396
400
|
const wss = new WebSocketServer({
|
|
397
401
|
port: opts.port,
|
|
@@ -434,7 +438,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
434
438
|
wsModes.set(ws, subproto);
|
|
435
439
|
const frameMode = wsFrameModeFor(subproto ?? undefined);
|
|
436
440
|
|
|
437
|
-
const { state, actor } = attachConnection(runtime, cfg, messages,
|
|
441
|
+
const { state, actor } = attachConnection(runtime, cfg, messages, services, history, {
|
|
438
442
|
sendText: (text) => {
|
|
439
443
|
if (ws.readyState !== WebSocket.OPEN) return;
|
|
440
444
|
// binary.ircv3.net connections exchange binary WebSocket frames;
|
|
@@ -539,7 +543,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
539
543
|
const admissionRecordId = decision.recordId;
|
|
540
544
|
runtime.commitAdmission(ip, undefined, admissionRecordId);
|
|
541
545
|
|
|
542
|
-
const { state, actor } = attachConnection(runtime, cfg, messages,
|
|
546
|
+
const { state, actor } = attachConnection(runtime, cfg, messages, services, history, {
|
|
543
547
|
sendText: (text) => {
|
|
544
548
|
safeSocketWrite(socket, text);
|
|
545
549
|
},
|
|
@@ -630,7 +634,7 @@ function attachConnection(
|
|
|
630
634
|
runtime: InMemoryRuntime,
|
|
631
635
|
cfg: ResolvedServerConfig,
|
|
632
636
|
messages: MessageStore,
|
|
633
|
-
|
|
637
|
+
services: ServicesStore,
|
|
634
638
|
history: NickHistoryStore,
|
|
635
639
|
transport: {
|
|
636
640
|
sendText: (text: string) => void;
|
|
@@ -680,7 +684,7 @@ function attachConnection(
|
|
|
680
684
|
ids: DEFAULT_ID_FACTORY,
|
|
681
685
|
motd: { lines: () => cfg.motdLines },
|
|
682
686
|
messages,
|
|
683
|
-
...(
|
|
687
|
+
...(services !== undefined ? { services } : {}),
|
|
684
688
|
history,
|
|
685
689
|
logger,
|
|
686
690
|
...(transport.actorTransport !== undefined ? { transport: transport.actorTransport } : {}),
|
|
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.10.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,
|