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.
Files changed (134) hide show
  1. package/.github/workflows/deploy-aws.yml +156 -32
  2. package/.github/workflows/deploy-cf-tcp.yml +11 -9
  3. package/.github/workflows/deploy-cf.yml +14 -14
  4. package/CHANGELOG.md +305 -0
  5. package/README.md +134 -41
  6. package/apps/aws-stack/README.md +3 -5
  7. package/apps/aws-stack/bin/aws.ts +82 -9
  8. package/apps/aws-stack/cdk.json +0 -3
  9. package/apps/aws-stack/package.json +3 -4
  10. package/apps/aws-stack/src/aws-stack.ts +177 -52
  11. package/apps/aws-stack/src/static-site.ts +323 -0
  12. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  13. package/apps/aws-stack/tests/stack.test.ts +267 -92
  14. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  15. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  16. package/apps/cf-tcp-container/package.json +2 -3
  17. package/apps/cf-tcp-container/src/container-server.ts +12 -9
  18. package/apps/cf-tcp-container/wrangler.toml +1 -10
  19. package/apps/cf-worker/package.json +3 -4
  20. package/apps/cf-worker/wrangler.toml +12 -71
  21. package/apps/local-cli/package.json +1 -1
  22. package/apps/local-cli/src/server.ts +21 -17
  23. package/apps/web/landing/favicon.ico +0 -0
  24. package/apps/web/landing/index.html +1 -0
  25. package/apps/web/package.json +2 -2
  26. package/apps/web/scripts/build.mjs +66 -4
  27. package/apps/web/src/build-env.ts +125 -4
  28. package/apps/web/src/config-schema.ts +20 -6
  29. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  30. package/apps/web/tests/build-env.test.ts +210 -9
  31. package/apps/web/tests/build-smoke.test.ts +2 -2
  32. package/apps/web/tests/config-schema.test.ts +149 -25
  33. package/docs/AWS-Deployment.md +670 -96
  34. package/docs/AWS-TCP-Deployment.md +20 -45
  35. package/docs/Cloudflare-Deployment-Guide.md +87 -113
  36. package/docs/Cloudflare-TCP-Deployment.md +25 -49
  37. package/docs/Release-Process.md +27 -23
  38. package/docs/Services.md +69 -22
  39. package/docs/WebClientGuide.md +35 -26
  40. package/package.json +7 -10
  41. package/packages/aws-adapter/package.json +1 -1
  42. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
  43. package/packages/aws-adapter/src/config-loader.ts +19 -2
  44. package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
  45. package/packages/aws-adapter/src/handlers/connect.ts +26 -0
  46. package/packages/aws-adapter/src/handlers/default.ts +190 -123
  47. package/packages/aws-adapter/src/handlers/index.ts +67 -23
  48. package/packages/aws-adapter/src/handlers/nlb-stream.ts +3 -6
  49. package/packages/aws-adapter/src/index.ts +5 -7
  50. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  51. package/packages/aws-adapter/src/serialize.ts +15 -0
  52. package/packages/aws-adapter/src/tables.ts +2 -12
  53. package/packages/aws-adapter/tests/aws-harness.ts +0 -1
  54. package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
  55. package/packages/aws-adapter/tests/connect.test.ts +124 -1
  56. package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
  57. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
  58. package/packages/aws-adapter/tests/handlers.test.ts +117 -11
  59. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  60. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  61. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  62. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  63. package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
  64. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  65. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  66. package/packages/cf-adapter/package.json +1 -5
  67. package/packages/cf-adapter/src/cf-runtime.ts +59 -8
  68. package/packages/cf-adapter/src/channel-do.ts +13 -3
  69. package/packages/cf-adapter/src/connection-do.ts +266 -109
  70. package/packages/cf-adapter/src/d1-services-store.ts +63 -26
  71. package/packages/cf-adapter/src/env.ts +11 -10
  72. package/packages/cf-adapter/src/index.ts +0 -6
  73. package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
  74. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  75. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  76. package/packages/cf-adapter/tests/connection-do-pure.test.ts +95 -54
  77. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  78. package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
  79. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  80. package/packages/in-memory-runtime/package.json +1 -1
  81. package/packages/irc-core/package.json +1 -1
  82. package/packages/irc-core/src/account-migration.ts +140 -0
  83. package/packages/irc-core/src/commands/account-auth.ts +16 -19
  84. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  85. package/packages/irc-core/src/commands/nickserv.ts +122 -11
  86. package/packages/irc-core/src/commands/registration.ts +2 -2
  87. package/packages/irc-core/src/commands/sasl.ts +22 -31
  88. package/packages/irc-core/src/config.ts +35 -9
  89. package/packages/irc-core/src/credential-hashing.ts +11 -54
  90. package/packages/irc-core/src/index.ts +1 -0
  91. package/packages/irc-core/src/ports.ts +159 -179
  92. package/packages/irc-core/src/types.ts +38 -10
  93. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  94. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  95. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  96. package/packages/irc-core/tests/commands/nickserv.test.ts +237 -1
  97. package/packages/irc-core/tests/commands/oper.test.ts +15 -0
  98. package/packages/irc-core/tests/commands/registration.test.ts +140 -133
  99. package/packages/irc-core/tests/commands/sasl.test.ts +159 -178
  100. package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
  101. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  102. package/packages/irc-core/tests/message-store.test.ts +5 -0
  103. package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
  104. package/packages/irc-core/tests/ports.test.ts +71 -0
  105. package/packages/irc-core/tests/services-store.test.ts +204 -0
  106. package/packages/irc-core/vitest.config.ts +6 -1
  107. package/packages/irc-server/package.json +1 -1
  108. package/packages/irc-server/src/actor.ts +0 -14
  109. package/packages/irc-server/tests/actor.test.ts +19 -47
  110. package/packages/irc-test-support/package.json +1 -1
  111. package/pnpm-workspace.yaml +1 -0
  112. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  113. package/scripts/deploy-web-aws.mjs +290 -0
  114. package/scripts/package.json +23 -0
  115. package/scripts/tsconfig.test.json +12 -0
  116. package/scripts/vitest.config.ts +19 -0
  117. package/tools/ci-hardening/package.json +1 -1
  118. package/tools/ci-hardening/src/index.ts +2 -0
  119. package/tools/ci-hardening/src/validate.ts +57 -0
  120. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  121. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  122. package/tools/load-test/package.json +1 -1
  123. package/tools/migrate-accounts-to-services.ts +270 -0
  124. package/tools/package.json +2 -1
  125. package/tools/seed-aws-accounts.ts +35 -10
  126. package/tools/seed-cf-accounts.ts +42 -9
  127. package/tools/tcp-ws-forwarder/package.json +1 -1
  128. package/packages/aws-adapter/src/account-store.ts +0 -121
  129. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  130. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  131. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  132. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  133. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  134. 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:staging # wrangler deploy --env staging
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>` per
17
- ## environment; they live in the Workers KV secrets store, not the repo.
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
- ## Staging environment.
104
+ ## D1 database — unified services persistence (NickServ / ChanServ / SASL).
108
105
  ## ---------------------------------------------------------------------------
109
- ## `wrangler deploy --env staging` ships to the staging Worker + DO
110
- ## namespace. The CI workflow (`.github/workflows/deploy-cf.yml`) deploys
111
- ## here on every push to `main` and then runs the smoke e2e.
112
- [env.staging]
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
- ## wrangler d1 execute serverless-ircd-accounts --command \
146
- ## "CREATE TABLE IF NOT EXISTS accounts (account TEXT PRIMARY KEY, algorithm TEXT NOT NULL, salt TEXT NOT NULL, hash TEXT NOT NULL)"
147
- ## Seed accounts via `tools/seed-cf-accounts.ts`.
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` and is env-specific.
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"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/local-cli",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "private": true,
5
5
  "description": "Runnable WebSocket IRC server using the in-memory runtime; manual-test harness and e2e fixture target",
6
6
  "license": "BSD-3-Clause",
@@ -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. When non-empty the server seeds an
172
- * in-memory {@link AccountStore} so `AUTHENTICATE PLAIN` succeeds for the
173
- * listed accounts. When omitted/empty, SASL account verification is
174
- * disabled (`ctx.accounts` undefined `904 ERR_SASLFAIL`).
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
- // SASL account store: seeded from config so `AUTHENTICATE PLAIN` works
389
- // end-to-end. `undefined` (no accounts configured) leaves the actor's
390
- // `ctx.accounts` unset, preserving the no-store behaviour.
391
- const accounts =
392
- cfg.saslAccounts !== undefined && cfg.saslAccounts.length > 0
393
- ? new InMemoryAccountStore(cfg.saslAccounts)
394
- : undefined;
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, accounts, history, {
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, accounts, history, {
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
- accounts: AccountStore | undefined,
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
- ...(accounts !== undefined ? { accounts } : {}),
687
+ ...(services !== undefined ? { services } : {}),
684
688
  history,
685
689
  logger,
686
690
  ...(transport.actorTransport !== undefined ? { transport: transport.actorTransport } : {}),
Binary file
@@ -11,6 +11,7 @@
11
11
  <html lang="en">
12
12
  <head>
13
13
  <meta charset="utf-8" />
14
+ <link rel="icon" href="/favicon.ico" sizes="any" />
14
15
  <meta name="viewport" content="width=device-width, initial-scale=1" />
15
16
  <title>ServerlessIRCd — Serverless IRC Daemon</title>
16
17
  <meta
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@serverless-ircd/web",
3
- "version": "0.9.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 { resolveConfigFile } from '../src/build-env.ts';
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 { file: configFileName, env: configEnv } = resolveConfigFile(process.argv.slice(2));
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.staging.json, config.prod.json.`,
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 (staging target). */
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 <staging|prod>` CLI flag.
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 (staging)' };
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. The Worker exposes
87
- * its WS upgrade at root (`worker.ts` routes the upgrade before any path
88
- * dispatch), so any path other than `/` would miss the handler. Optional
89
- * when omitted irc-framework builds `wss://host:port` with no path,
90
- * which the browser normalises to `wss://host:port/`.
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.literal('/').optional(),
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 (staging)",
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": "{{hostname}}",
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,