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.
Files changed (254) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +35 -9
  4. package/.github/workflows/deploy-cf.yml +40 -14
  5. package/CHANGELOG.md +594 -0
  6. package/README.md +286 -60
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +118 -9
  9. package/apps/aws-stack/cdk.json +0 -3
  10. package/apps/aws-stack/package.json +3 -4
  11. package/apps/aws-stack/src/aws-stack.ts +398 -67
  12. package/apps/aws-stack/src/static-site.ts +323 -0
  13. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  14. package/apps/aws-stack/tests/stack.test.ts +714 -105
  15. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  16. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  17. package/apps/cf-tcp-container/Dockerfile +37 -5
  18. package/apps/cf-tcp-container/package.json +7 -3
  19. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  20. package/apps/cf-tcp-container/src/container-server.ts +267 -87
  21. package/apps/cf-tcp-container/src/main.ts +22 -7
  22. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  25. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  26. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  27. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  28. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  29. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  30. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  31. package/apps/cf-tcp-container/wrangler.toml +18 -14
  32. package/apps/cf-worker/package.json +3 -4
  33. package/apps/cf-worker/src/worker.ts +77 -5
  34. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  35. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  36. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  37. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  38. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  39. package/apps/cf-worker/wrangler.test.toml +15 -1
  40. package/apps/cf-worker/wrangler.toml +95 -77
  41. package/apps/local-cli/package.json +1 -1
  42. package/apps/local-cli/src/config-loader.ts +14 -2
  43. package/apps/local-cli/src/line-scanner.ts +26 -0
  44. package/apps/local-cli/src/server.ts +44 -19
  45. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  46. package/apps/local-cli/tests/tcp.test.ts +29 -0
  47. package/apps/web/landing/favicon.ico +0 -0
  48. package/apps/web/landing/index.html +1 -0
  49. package/apps/web/package.json +2 -2
  50. package/apps/web/scripts/build.mjs +66 -4
  51. package/apps/web/src/build-env.ts +125 -4
  52. package/apps/web/src/config-schema.ts +20 -6
  53. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  54. package/apps/web/tests/build-env.test.ts +210 -9
  55. package/apps/web/tests/build-smoke.test.ts +2 -2
  56. package/apps/web/tests/config-schema.test.ts +149 -25
  57. package/docs/AWS-Deployment.md +793 -118
  58. package/docs/AWS-TCP-Deployment.md +57 -47
  59. package/docs/Chat-History.md +55 -0
  60. package/docs/Cloudflare-Deployment-Guide.md +95 -114
  61. package/docs/Cloudflare-TCP-Deployment.md +160 -101
  62. package/docs/Release-Process.md +27 -23
  63. package/docs/SASL-EXTERNAL.md +175 -0
  64. package/docs/Services.md +69 -22
  65. package/docs/WebClientGuide.md +35 -26
  66. package/package.json +7 -10
  67. package/packages/aws-adapter/package.json +1 -1
  68. package/packages/aws-adapter/src/admission.ts +28 -13
  69. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  70. package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
  71. package/packages/aws-adapter/src/config-loader.ts +153 -8
  72. package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
  73. package/packages/aws-adapter/src/handlers/connect.ts +73 -1
  74. package/packages/aws-adapter/src/handlers/default.ts +279 -123
  75. package/packages/aws-adapter/src/handlers/index.ts +98 -25
  76. package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
  77. package/packages/aws-adapter/src/index.ts +5 -7
  78. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  79. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  80. package/packages/aws-adapter/src/serialize.ts +23 -0
  81. package/packages/aws-adapter/src/tables.ts +11 -12
  82. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  83. package/packages/aws-adapter/tests/aws-harness.ts +23 -2
  84. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  85. package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
  86. package/packages/aws-adapter/tests/connect.test.ts +323 -3
  87. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  88. package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
  89. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
  90. package/packages/aws-adapter/tests/handlers.test.ts +174 -12
  91. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  92. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  93. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  94. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  95. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  96. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  97. package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
  98. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  99. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  100. package/packages/cf-adapter/package.json +1 -5
  101. package/packages/cf-adapter/src/cf-runtime.ts +100 -10
  102. package/packages/cf-adapter/src/channel-do.ts +13 -3
  103. package/packages/cf-adapter/src/config-loader.ts +133 -8
  104. package/packages/cf-adapter/src/connection-do.ts +406 -116
  105. package/packages/cf-adapter/src/counter-do.ts +142 -0
  106. package/packages/cf-adapter/src/d1-services-store.ts +105 -26
  107. package/packages/cf-adapter/src/env.ts +99 -10
  108. package/packages/cf-adapter/src/index.ts +17 -7
  109. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  110. package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
  111. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  112. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  113. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  114. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  115. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  116. package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
  117. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  118. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  119. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  120. package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
  121. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  122. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  123. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  124. package/packages/cf-adapter/wrangler.test.toml +18 -1
  125. package/packages/in-memory-runtime/package.json +1 -1
  126. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  127. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  128. package/packages/irc-core/package.json +1 -1
  129. package/packages/irc-core/src/account-migration.ts +140 -0
  130. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  131. package/packages/irc-core/src/certfp.ts +178 -0
  132. package/packages/irc-core/src/commands/account-auth.ts +16 -19
  133. package/packages/irc-core/src/commands/cap.ts +10 -2
  134. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  135. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  136. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  137. package/packages/irc-core/src/commands/index.ts +2 -1
  138. package/packages/irc-core/src/commands/invite.ts +1 -7
  139. package/packages/irc-core/src/commands/join.ts +1 -16
  140. package/packages/irc-core/src/commands/kick.ts +1 -8
  141. package/packages/irc-core/src/commands/list.ts +1 -8
  142. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  143. package/packages/irc-core/src/commands/mode.ts +1 -8
  144. package/packages/irc-core/src/commands/multiline.ts +4 -10
  145. package/packages/irc-core/src/commands/names.ts +53 -13
  146. package/packages/irc-core/src/commands/nickserv.ts +161 -11
  147. package/packages/irc-core/src/commands/oper.ts +361 -8
  148. package/packages/irc-core/src/commands/part.ts +4 -10
  149. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  150. package/packages/irc-core/src/commands/registration.ts +148 -4
  151. package/packages/irc-core/src/commands/sasl.ts +154 -46
  152. package/packages/irc-core/src/commands/topic.ts +10 -12
  153. package/packages/irc-core/src/commands/who.ts +1 -8
  154. package/packages/irc-core/src/config.ts +424 -25
  155. package/packages/irc-core/src/credential-hashing.ts +11 -54
  156. package/packages/irc-core/src/effects.ts +24 -0
  157. package/packages/irc-core/src/flood-control.ts +10 -10
  158. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  159. package/packages/irc-core/src/index.ts +9 -0
  160. package/packages/irc-core/src/oper-hashing.ts +43 -0
  161. package/packages/irc-core/src/oper-lockout.ts +87 -0
  162. package/packages/irc-core/src/ports.ts +529 -190
  163. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  164. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  165. package/packages/irc-core/src/protocol/index.ts +12 -1
  166. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  167. package/packages/irc-core/src/protocol/parser.ts +79 -10
  168. package/packages/irc-core/src/state/connection.ts +13 -0
  169. package/packages/irc-core/src/types.ts +266 -23
  170. package/packages/irc-core/src/ws-framing.ts +5 -4
  171. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  172. package/packages/irc-core/tests/bytes.test.ts +89 -0
  173. package/packages/irc-core/tests/certfp.test.ts +117 -0
  174. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  175. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  176. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  177. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  178. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  179. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  180. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  181. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  182. package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
  183. package/packages/irc-core/tests/commands/oper.test.ts +574 -1
  184. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  185. package/packages/irc-core/tests/commands/registration.test.ts +602 -133
  186. package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
  187. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  188. package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
  189. package/packages/irc-core/tests/config.test.ts +534 -2
  190. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  191. package/packages/irc-core/tests/effects.test.ts +14 -0
  192. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  193. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  194. package/packages/irc-core/tests/message-store.test.ts +5 -0
  195. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  196. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  197. package/packages/irc-core/tests/outbound.test.ts +148 -0
  198. package/packages/irc-core/tests/parser.test.ts +287 -5
  199. package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
  200. package/packages/irc-core/tests/ports.test.ts +170 -7
  201. package/packages/irc-core/tests/services-store.test.ts +567 -1
  202. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  203. package/packages/irc-core/vitest.config.ts +6 -1
  204. package/packages/irc-server/package.json +1 -1
  205. package/packages/irc-server/src/actor.ts +123 -22
  206. package/packages/irc-server/src/dispatch.ts +1 -0
  207. package/packages/irc-server/src/index.ts +7 -0
  208. package/packages/irc-server/src/redact.ts +159 -0
  209. package/packages/irc-server/src/runtime.ts +14 -0
  210. package/packages/irc-server/src/transport.ts +28 -1
  211. package/packages/irc-server/tests/actor.test.ts +563 -54
  212. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  213. package/packages/irc-server/tests/redact.test.ts +198 -0
  214. package/packages/irc-server/tests/runtime.test.ts +2 -0
  215. package/packages/irc-server/tests/transport.test.ts +66 -0
  216. package/packages/irc-test-support/package.json +1 -1
  217. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  218. package/pnpm-workspace.yaml +1 -0
  219. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  220. package/scripts/deploy-web-aws.mjs +290 -0
  221. package/scripts/package.json +23 -0
  222. package/scripts/tsconfig.test.json +12 -0
  223. package/scripts/vitest.config.ts +19 -0
  224. package/tools/ci-hardening/package.json +2 -2
  225. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  226. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  227. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  228. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  229. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  230. package/tools/ci-hardening/src/index.ts +19 -0
  231. package/tools/ci-hardening/src/validate.ts +57 -0
  232. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  233. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  234. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  235. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  236. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  237. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  238. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  239. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  240. package/tools/ci-hardening/vitest.config.ts +5 -1
  241. package/tools/hash-oper-cred.ts +85 -0
  242. package/tools/load-test/package.json +1 -1
  243. package/tools/migrate-accounts-to-services.ts +270 -0
  244. package/tools/package.json +2 -1
  245. package/tools/seed-aws-accounts.ts +35 -10
  246. package/tools/seed-cf-accounts.ts +42 -9
  247. package/tools/tcp-ws-forwarder/package.json +1 -1
  248. package/packages/aws-adapter/src/account-store.ts +0 -121
  249. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  250. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  251. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  252. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  253. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  254. 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. 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`).
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?: Array<{ user: string; password: string }>;
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
- // 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;
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, accounts, history, {
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, accounts, history, {
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
- accounts: AccountStore | undefined,
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
- ...(accounts !== undefined ? { accounts } : {}),
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
@@ -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.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 { 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,