serverless-ircd 0.8.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 (179) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +11 -9
  4. package/.github/workflows/deploy-cf.yml +14 -14
  5. package/CHANGELOG.md +550 -0
  6. package/README.md +275 -222
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +82 -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 +177 -52
  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 +267 -92
  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/package.json +2 -3
  18. package/apps/cf-tcp-container/src/container-server.ts +33 -10
  19. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  20. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  21. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  22. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  23. package/apps/cf-tcp-container/wrangler.toml +1 -10
  24. package/apps/cf-worker/package.json +3 -4
  25. package/apps/cf-worker/wrangler.toml +12 -71
  26. package/apps/local-cli/package.json +1 -1
  27. package/apps/local-cli/src/server.ts +115 -48
  28. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  29. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  30. package/apps/local-cli/tests/rehash.test.ts +147 -0
  31. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  32. package/apps/local-cli/tests/tcp.test.ts +89 -0
  33. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  34. package/apps/web/landing/favicon.ico +0 -0
  35. package/apps/web/landing/index.html +227 -3
  36. package/apps/web/package.json +3 -2
  37. package/apps/web/scripts/build.mjs +91 -6
  38. package/apps/web/src/build-env.ts +125 -4
  39. package/apps/web/src/config-schema.ts +20 -6
  40. package/apps/web/src/render-docs.ts +292 -0
  41. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  42. package/apps/web/tests/build-env.test.ts +210 -9
  43. package/apps/web/tests/build-smoke.test.ts +33 -4
  44. package/apps/web/tests/config-schema.test.ts +149 -25
  45. package/apps/web/tests/landing-content.test.ts +103 -0
  46. package/apps/web/tests/render-docs.test.ts +198 -0
  47. package/docs/AWS-Adapter-Architecture.md +3 -2
  48. package/docs/AWS-Deployment.md +670 -96
  49. package/docs/AWS-TCP-Deployment.md +20 -45
  50. package/docs/Cloudflare-Deployment-Guide.md +87 -113
  51. package/docs/Cloudflare-TCP-Deployment.md +25 -49
  52. package/docs/Release-Process.md +27 -23
  53. package/docs/Services.md +102 -23
  54. package/docs/WebClientGuide.md +35 -26
  55. package/package.json +7 -10
  56. package/packages/aws-adapter/package.json +1 -1
  57. package/packages/aws-adapter/src/aws-runtime.ts +15 -1
  58. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
  59. package/packages/aws-adapter/src/config-loader.ts +19 -2
  60. package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +26 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +190 -123
  63. package/packages/aws-adapter/src/handlers/index.ts +67 -23
  64. package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
  65. package/packages/aws-adapter/src/index.ts +5 -7
  66. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  67. package/packages/aws-adapter/src/serialize.ts +15 -0
  68. package/packages/aws-adapter/src/tables.ts +2 -12
  69. package/packages/aws-adapter/tests/aws-harness.ts +0 -1
  70. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  71. package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
  72. package/packages/aws-adapter/tests/connect.test.ts +124 -1
  73. package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
  74. package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
  75. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
  76. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  77. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  78. package/packages/aws-adapter/tests/handlers.test.ts +117 -11
  79. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  80. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  81. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  82. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  83. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  84. package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
  85. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  86. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  87. package/packages/cf-adapter/package.json +1 -5
  88. package/packages/cf-adapter/src/cf-runtime.ts +59 -8
  89. package/packages/cf-adapter/src/channel-do.ts +13 -3
  90. package/packages/cf-adapter/src/connection-do.ts +284 -115
  91. package/packages/cf-adapter/src/d1-services-store.ts +63 -26
  92. package/packages/cf-adapter/src/env.ts +11 -10
  93. package/packages/cf-adapter/src/index.ts +0 -6
  94. package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
  95. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  96. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  97. package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
  98. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  99. package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
  100. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  101. package/packages/in-memory-runtime/package.json +1 -1
  102. package/packages/irc-core/package.json +1 -1
  103. package/packages/irc-core/src/account-migration.ts +140 -0
  104. package/packages/irc-core/src/commands/account-auth.ts +60 -35
  105. package/packages/irc-core/src/commands/chanserv.ts +288 -4
  106. package/packages/irc-core/src/commands/hostserv.ts +38 -3
  107. package/packages/irc-core/src/commands/index.ts +1 -0
  108. package/packages/irc-core/src/commands/join.ts +41 -35
  109. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  110. package/packages/irc-core/src/commands/nickserv.ts +138 -15
  111. package/packages/irc-core/src/commands/registration.ts +28 -17
  112. package/packages/irc-core/src/commands/sasl.ts +22 -31
  113. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  114. package/packages/irc-core/src/commands/topic.ts +23 -10
  115. package/packages/irc-core/src/config.ts +35 -9
  116. package/packages/irc-core/src/credential-hashing.ts +11 -54
  117. package/packages/irc-core/src/index.ts +1 -0
  118. package/packages/irc-core/src/ports.ts +159 -179
  119. package/packages/irc-core/src/state/channel.ts +17 -0
  120. package/packages/irc-core/src/types.ts +38 -10
  121. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  122. package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
  123. package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
  124. package/packages/irc-core/tests/commands/join.test.ts +179 -0
  125. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  126. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  127. package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
  128. package/packages/irc-core/tests/commands/oper.test.ts +15 -0
  129. package/packages/irc-core/tests/commands/registration.test.ts +336 -108
  130. package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
  131. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  132. package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
  133. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  134. package/packages/irc-core/tests/message-store.test.ts +5 -0
  135. package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
  136. package/packages/irc-core/tests/ports.test.ts +71 -0
  137. package/packages/irc-core/tests/services-store.test.ts +204 -0
  138. package/packages/irc-core/vitest.config.ts +6 -1
  139. package/packages/irc-server/package.json +1 -1
  140. package/packages/irc-server/src/actor.ts +80 -44
  141. package/packages/irc-server/tests/actor.test.ts +384 -50
  142. package/packages/irc-test-support/package.json +1 -1
  143. package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
  144. package/packages/irc-test-support/src/scenarios.ts +21 -6
  145. package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
  146. package/packages/irc-test-support/vitest.config.ts +6 -1
  147. package/pnpm-workspace.yaml +1 -0
  148. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  149. package/scripts/deploy-web-aws.mjs +290 -0
  150. package/scripts/package.json +23 -0
  151. package/scripts/tsconfig.test.json +12 -0
  152. package/scripts/vitest.config.ts +19 -0
  153. package/tools/ci-hardening/package.json +1 -1
  154. package/tools/ci-hardening/src/index.ts +2 -0
  155. package/tools/ci-hardening/src/validate.ts +57 -0
  156. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  157. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  158. package/tools/load-test/package.json +1 -1
  159. package/tools/load-test/src/client.ts +13 -13
  160. package/tools/load-test/tests/client.test.ts +258 -2
  161. package/tools/load-test/tests/config.test.ts +39 -0
  162. package/tools/load-test/tests/harness.test.ts +21 -0
  163. package/tools/load-test/tests/metrics.test.ts +7 -0
  164. package/tools/migrate-accounts-to-services.ts +270 -0
  165. package/tools/package.json +2 -1
  166. package/tools/seed-aws-accounts.ts +35 -10
  167. package/tools/seed-cf-accounts.ts +42 -9
  168. package/tools/tcp-ws-forwarder/package.json +1 -1
  169. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  170. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  171. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
  172. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  173. package/packages/aws-adapter/src/account-store.ts +0 -121
  174. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  175. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  176. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  177. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  178. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  179. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -1,28 +1,40 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { DEFAULT_CONFIG_FILE, ENV_TO_CONFIG_FILE, resolveConfigFile } from '../src/build-env';
2
+ import {
3
+ DEFAULT_CONFIG_FILE,
4
+ ENV_TO_CONFIG_FILE,
5
+ hasUnsubstitutedPlaceholder,
6
+ parseApiUrlForKiwi,
7
+ resolveApiUrl,
8
+ resolveConfigFile,
9
+ } from '../src/build-env';
3
10
 
4
11
  // ============================================================================
5
12
  // resolveConfigFile — per-env config selection
6
13
  // ============================================================================
7
14
 
8
15
  describe('resolveConfigFile', () => {
9
- it('selects config.staging.json for --env staging', () => {
10
- const result = resolveConfigFile(['--env', 'staging']);
11
- expect(result.file).toBe('config.staging.json');
12
- expect(result.env).toBe('staging');
13
- });
14
-
15
16
  it('selects config.prod.json for --env prod', () => {
16
17
  const result = resolveConfigFile(['--env', 'prod']);
17
18
  expect(result.file).toBe('config.prod.json');
18
19
  expect(result.env).toBe('prod');
19
20
  });
20
21
 
21
- it('falls back to the default config (staging target) when --env is absent', () => {
22
+ // AWS deploys the WebSocket endpoint as a separate API Gateway URL, so the
23
+ // AWS SPA build needs its own per-env config (`config.prod-aws.json`) baked
24
+ // into dist/webclient/static/config.json. The `prod-aws` env name matches
25
+ // the historical staging/prod split nomenclature (now collapsed); see the
26
+ // build-env.ts docstring.
27
+ it('selects config.prod-aws.json for --env prod-aws', () => {
28
+ const result = resolveConfigFile(['--env', 'prod-aws']);
29
+ expect(result.file).toBe('config.prod-aws.json');
30
+ expect(result.env).toBe('prod-aws');
31
+ });
32
+
33
+ it('falls back to the default config when --env is absent', () => {
22
34
  const result = resolveConfigFile([]);
23
35
  expect(result.file).toBe(DEFAULT_CONFIG_FILE);
24
36
  expect(result.file).toBe('config.json');
25
- expect(result.env).toBe('default (staging)');
37
+ expect(result.env).toBe('default');
26
38
  });
27
39
 
28
40
  it('throws on an unknown --env value', () => {
@@ -36,6 +48,15 @@ describe('resolveConfigFile', () => {
36
48
  expect(() => resolveConfigFile(['--env', 'stg'])).toThrowError(/Unknown --env value: stg/u);
37
49
  });
38
50
 
51
+ it('rejects staging (no longer a recognised env)', () => {
52
+ // staging was collapsed into the single prod target; the config file
53
+ // and the ENV_TO_CONFIG_FILE entry are gone, so --env staging must
54
+ // throw like any other unknown value.
55
+ expect(() => resolveConfigFile(['--env', 'staging'])).toThrowError(
56
+ /Unknown --env value: staging/u,
57
+ );
58
+ });
59
+
39
60
  it('names the accepted env values in the error message', () => {
40
61
  try {
41
62
  resolveConfigFile(['--env', 'nope']);
@@ -61,3 +82,183 @@ describe('resolveConfigFile', () => {
61
82
  expect(result.file).toBe(DEFAULT_CONFIG_FILE);
62
83
  });
63
84
  });
85
+
86
+ // ============================================================================
87
+ // resolveApiUrl — stack-output URL override (`--api-url` flag)
88
+ // ============================================================================
89
+ //
90
+ // The AWS web deploy pipeline reads the deployed stack's `ConnectUrl` output
91
+ // (the API Gateway WebSocket URL) and forwards it to the SPA build via
92
+ // `--api-url`, so the baked `config.json` `startupOptions.server` carries
93
+ // the real wss URL instead of the `{{API_ID}}`/`{{REGION}}`/`{{STAGE}}`
94
+ // template baked into `static/config.prod-aws.json`. No hardcoded URLs
95
+ // live in the repo: the URL is read from CloudFormation outputs at deploy
96
+ // time and threaded through this flag.
97
+
98
+ describe('resolveApiUrl', () => {
99
+ it('returns the value following --api-url', () => {
100
+ expect(resolveApiUrl(['--api-url', 'wss://abc.execute-api.us-east-1.amazonaws.com/prod'])).toBe(
101
+ 'wss://abc.execute-api.us-east-1.amazonaws.com/prod',
102
+ );
103
+ });
104
+
105
+ it('returns undefined when --api-url is absent', () => {
106
+ expect(resolveApiUrl([])).toBeUndefined();
107
+ expect(resolveApiUrl(['--env', 'prod-aws'])).toBeUndefined();
108
+ });
109
+
110
+ it('returns undefined when --api-url has no following value', () => {
111
+ // Trailing `--api-url` with no value: indexOf + 1 yields `undefined`,
112
+ // which the caller treats as "not supplied".
113
+ expect(resolveApiUrl(['--api-url'])).toBeUndefined();
114
+ });
115
+
116
+ it('coexists with --env on the same argv', () => {
117
+ // The deploy pipeline invokes the build with both flags at once:
118
+ // pnpm --filter web run build:prod-aws -- --api-url <url>
119
+ // resolves to `node scripts/build.mjs --env prod-aws --api-url <url>`.
120
+ const argv = ['--env', 'prod-aws', '--api-url', 'wss://example'];
121
+ expect(resolveApiUrl(argv)).toBe('wss://example');
122
+ expect(resolveConfigFile(argv).file).toBe('config.prod-aws.json');
123
+ });
124
+ });
125
+
126
+ // ============================================================================
127
+ // hasUnsubstitutedPlaceholder — guard against shipping a broken baked config
128
+ // ============================================================================
129
+ //
130
+ // The placeholder guard prevents the AWS deploy pipeline from shipping a
131
+ // prod-aws config that still carries `{{API_ID}}`/`{{REGION}}`/`{{STAGE}}`
132
+ // (which would produce a broken SPA dialling a literal `{{API_ID}}...` URL).
133
+ // It is scoped to the prod-aws env only — the CF envs (`default`, `prod`)
134
+ // intentionally use Kiwi's `{{hostname}}` runtime template, which is
135
+ // resolved by Kiwi in the browser at load time and must survive the bake.
136
+ // A regression that applied the guard to the CF envs would break every
137
+ // default `pnpm build` and every `build:prod`.
138
+
139
+ describe('hasUnsubstitutedPlaceholder', () => {
140
+ it('returns false for the default env with a {{hostname}} runtime template (CF dev config)', () => {
141
+ // This is the regression that broke `pnpm build` after the initial
142
+ // T167 placeholder guard: the check fired on {{hostname}}, treating
143
+ // it as an unsubstituted AWS placeholder. {{hostname}} is a Kiwi
144
+ // runtime template resolved at browser load time and must survive.
145
+ const baked = JSON.stringify({
146
+ startupOptions: { server: '{{hostname}}', tls: true, port: 443 },
147
+ });
148
+ expect(hasUnsubstitutedPlaceholder(baked, 'default')).toBe(false);
149
+ });
150
+
151
+ it('returns false for the prod env with a {{hostname}} runtime template (CF prod config)', () => {
152
+ const baked = JSON.stringify({
153
+ startupOptions: { server: '{{hostname}}', tls: true, port: 443 },
154
+ });
155
+ expect(hasUnsubstitutedPlaceholder(baked, 'prod')).toBe(false);
156
+ });
157
+
158
+ it('returns true for the prod-aws env with unsubstituted {{API_ID}}/{{REGION}}/{{STAGE}}', () => {
159
+ const baked = JSON.stringify({
160
+ startupOptions: {
161
+ server: 'wss://{{API_ID}}.execute-api.{{REGION}}.amazonaws.com/{{STAGE}}',
162
+ tls: true,
163
+ port: 443,
164
+ },
165
+ });
166
+ expect(hasUnsubstitutedPlaceholder(baked, 'prod-aws')).toBe(true);
167
+ });
168
+
169
+ it('returns false for the prod-aws env after --api-url substitution', () => {
170
+ const baked = JSON.stringify({
171
+ startupOptions: {
172
+ server: 'wss://abc123.execute-api.us-east-1.amazonaws.com/prod',
173
+ tls: true,
174
+ port: 443,
175
+ },
176
+ });
177
+ expect(hasUnsubstitutedPlaceholder(baked, 'prod-aws')).toBe(false);
178
+ });
179
+
180
+ it('still rejects a {{hostname}}-only payload in the prod-aws env (defensive — AWS does not use the runtime template)', () => {
181
+ // prod-aws config carries literal wss:// + AWS placeholders, never
182
+ // {{hostname}}. If somehow {{hostname}} ended up in a prod-aws bake,
183
+ // the guard would catch it — better to fail loudly than ship a
184
+ // browser-resolved hostname on the AWS path where the wss endpoint
185
+ // is on a different origin.
186
+ const baked = JSON.stringify({ startupOptions: { server: '{{hostname}}' } });
187
+ expect(hasUnsubstitutedPlaceholder(baked, 'prod-aws')).toBe(true);
188
+ });
189
+
190
+ it('returns false for any env when the baked payload has no placeholder at all', () => {
191
+ const baked = JSON.stringify({ startupOptions: { server: 'wss://concrete.example.com' } });
192
+ expect(hasUnsubstitutedPlaceholder(baked, 'default')).toBe(false);
193
+ expect(hasUnsubstitutedPlaceholder(baked, 'prod')).toBe(false);
194
+ expect(hasUnsubstitutedPlaceholder(baked, 'prod-aws')).toBe(false);
195
+ });
196
+ });
197
+
198
+ // ============================================================================
199
+ // parseApiUrlForKiwi — split a wss:// URL into Kiwi's server + direct_path
200
+ // ============================================================================
201
+ //
202
+ // irc-framework builds the WS URL as `wss://<server>:<port><direct_path>`,
203
+ // prepending the `wss://` scheme itself whenever `tls: true`. The AWS
204
+ // deploy pipeline's `--api-url` therefore cannot pass the full URL through
205
+ // to `server` — that would produce a doubled-scheme URL like
206
+ // `wss://wss//<host>...`. The helper splits the stack-output URL into the
207
+ // two fields Kiwi actually wants: a hostname-only `server` and a
208
+ // stage-name `direct_path`.
209
+
210
+ describe('parseApiUrlForKiwi', () => {
211
+ it('splits a wss:// URL with a stage path into server + direct_path', () => {
212
+ const r = parseApiUrlForKiwi('wss://abc.execute-api.us-east-1.amazonaws.com/prod');
213
+ expect(r.server).toBe('abc.execute-api.us-east-1.amazonaws.com');
214
+ expect(r.directPath).toBe('/prod');
215
+ });
216
+
217
+ it('returns direct_path undefined when the URL path is / (CF Worker root case)', () => {
218
+ const r = parseApiUrlForKiwi('wss://worker.example.com/');
219
+ expect(r.server).toBe('worker.example.com');
220
+ expect(r.directPath).toBeUndefined();
221
+ });
222
+
223
+ it('returns direct_path undefined when the URL has no path', () => {
224
+ const r = parseApiUrlForKiwi('wss://worker.example.com');
225
+ expect(r.server).toBe('worker.example.com');
226
+ expect(r.directPath).toBeUndefined();
227
+ });
228
+
229
+ it('accepts a ws:// (insecure) URL', () => {
230
+ // Local dev / testing only; the prod build always carries wss://. The
231
+ // helper still splits it so the build script does not need to know the
232
+ // scheme in advance.
233
+ const r = parseApiUrlForKiwi('ws://localhost:8787/dev');
234
+ expect(r.server).toBe('localhost');
235
+ expect(r.directPath).toBe('/dev');
236
+ });
237
+
238
+ it('strips the port into neither server nor direct_path (Kiwi reads `port` separately)', () => {
239
+ // The deploy pipeline sets `port` to 443 via the template; the URL's
240
+ // explicit `:443` is dropped during the split. irc-framework re-adds
241
+ // it from the `port` field at WS construction time.
242
+ const r = parseApiUrlForKiwi('wss://host.example.com:8443/stage');
243
+ expect(r.server).toBe('host.example.com');
244
+ expect(r.directPath).toBe('/stage');
245
+ });
246
+
247
+ it('throws when the URL has no wss:// or ws:// scheme', () => {
248
+ expect(() => parseApiUrlForKiwi('abc.execute-api.us-east-1.amazonaws.com/prod')).toThrowError(
249
+ /must be a wss:\/\/ \(or ws:\/\/\) URL/u,
250
+ );
251
+ });
252
+
253
+ it('throws on a https:// URL (wrong scheme)', () => {
254
+ expect(() => parseApiUrlForKiwi('https://example.com/prod')).toThrowError(
255
+ /must be a wss:\/\/ \(or ws:\/\/\) URL/u,
256
+ );
257
+ });
258
+
259
+ it('throws on an unparseable URL', () => {
260
+ // `new URL('wss://')` does not throw, but a hostname with invalid
261
+ // chars does. Pick a value the URL parser rejects outright.
262
+ expect(() => parseApiUrlForKiwi('wss://<<>>/prod')).toThrowError();
263
+ });
264
+ });
@@ -10,8 +10,8 @@ const indexPath = path.join(appDist, 'index.html');
10
10
  const landingSrc = path.join(pkgRoot, 'landing', 'index.html');
11
11
  const landingDist = path.join(pkgRoot, 'dist', 'index.html');
12
12
 
13
- // Validates the output of `pnpm --filter web build` (or `build:staging` /
14
- // `build:prod`). Skipped until the SPA has been built (the Kiwi upstream
13
+ // Validates the output of `pnpm --filter web build` (or `build:prod`).
14
+ // Skipped until the SPA has been built (the Kiwi upstream
15
15
  // build is too heavy to run inside the test suite); run the build first to
16
16
  // exercise these assertions.
17
17
  const built = existsSync(indexPath);
@@ -70,9 +70,9 @@ describe('landing page source (landing/index.html)', () => {
70
70
  expect(html).toMatch(/href=["']\/webclient\/?["']/);
71
71
  });
72
72
 
73
- it('does not link to the project docs (docs button temporarily removed)', () => {
73
+ it('links to the rendered project docs at /docs/', () => {
74
74
  const html = readFileSync(landingSrc, 'utf8');
75
- expect(html).not.toMatch(/href=["'][^"']*docs\/?["']/);
75
+ expect(html).toMatch(/href=["']\/docs\/?["']/);
76
76
  });
77
77
 
78
78
  it('links to the source repository on Gitea', () => {
@@ -101,3 +101,32 @@ describe.skipIf(!landingBuilt)('landing page build output (dist/index.html)', ()
101
101
  expect(dist).toBe(src);
102
102
  });
103
103
  });
104
+
105
+ // Validates the docs/ build pass: `pnpm --filter web build` renders every
106
+ // `docs/*.md` to standalone HTML under `dist/docs/` so the Worker `[assets]`
107
+ // binding serves them at `/docs/<name>.html` (and `/docs/` for the index).
108
+ // Gated on the directory's presence — like the Kiwi SPA gate above — so CI
109
+ // can run this test file without paying the heavy SPA build cost; the
110
+ // pipeline runs in full via `apps/web/tests/render-docs.test.ts`.
111
+ const docsDist = path.join(pkgRoot, 'dist', 'docs');
112
+ const docsBuilt = existsSync(docsDist) && statSync(docsDist).size > 0;
113
+
114
+ describe.skipIf(!docsBuilt)('docs build output (dist/docs/)', () => {
115
+ it('emits dist/docs/index.html', () => {
116
+ expect(existsSync(path.join(docsDist, 'index.html'))).toBe(true);
117
+ });
118
+
119
+ it('emits Services.html with rendered HTML (an <h1>, not raw markdown)', () => {
120
+ const target = path.join(docsDist, 'Services.html');
121
+ expect(existsSync(target)).toBe(true);
122
+ const html = readFileSync(target, 'utf8');
123
+ expect(html).toMatch(/<h1[^>]*>/);
124
+ // The markdown H1 marker must not survive rendering.
125
+ expect(html).not.toMatch(/^# IRC Services Reference/m);
126
+ });
127
+
128
+ it('shares a stylesheet (a .css file is emitted alongside the HTML)', () => {
129
+ const css = readdirSync(docsDist).filter((f) => f.endsWith('.css'));
130
+ expect(css.length).toBeGreaterThanOrEqual(1);
131
+ });
132
+ });
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { describe, expect, it } from 'vitest';
5
+ import { resolveConfigFile } from '../src/build-env';
5
6
  import { KiwiConfigSchema, parseKiwiConfig } from '../src/config-schema';
6
7
 
7
8
  const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -240,23 +241,21 @@ describe('KiwiConfigSchema — direct WebSocket mode', () => {
240
241
  //
241
242
  // NOTE: Kiwi reads `startupOptions.direct_path`, NOT `startupOptions.path`
242
243
  // (upstream `src/helpers/Misc.js:139`: `connection.direct_path = config.direct_path || ''`).
243
- // The Worker accepts WS upgrades at `/` (root), so `direct_path` — when
244
- // supplied MUST be `/`. It is optional: an empty/absent value lets
245
- // irc-framework build `wss://host:port` with no path, which the browser
246
- // normalises to `wss://host:port/` and the Worker accepts.
244
+ //
245
+ // Two valid shapes:
246
+ // - CF Worker: WS upgrade is at root, so `direct_path` is `/` (or absent).
247
+ // - AWS API Gateway: the WebSocket endpoint URL includes the stage name as
248
+ // a path segment (`wss://<id>.execute-api.<region>.amazonaws.com/prod`),
249
+ // so `direct_path` MUST be `/<stage>` (e.g. `/prod`). irc-framework
250
+ // builds the WS URL as `wss://<server>:<port><direct_path>`.
251
+ //
252
+ // The constraint accepts a leading-slash plus zero or one path segment
253
+ // (no nested paths), allowing alphanumeric stage names and the
254
+ // `{{STAGE}}` placeholder baked into `config.prod-aws.json` for the
255
+ // deploy pipeline to substitute.
247
256
 
248
257
  describe('KiwiConfigSchema — WS path (direct_path)', () => {
249
- it('rejects a direct_path other than / (WS endpoint is at root)', () => {
250
- expect(() =>
251
- parseKiwiConfig(
252
- validConfig({
253
- startupOptions: validStartupOptions({ direct_path: '/webclient/' }),
254
- }),
255
- ),
256
- ).toThrowError(/direct_path/u);
257
- });
258
-
259
- it('accepts the / direct_path', () => {
258
+ it('accepts the / direct_path (CF Worker WS upgrade at root)', () => {
260
259
  const cfg = parseKiwiConfig(
261
260
  validConfig({
262
261
  startupOptions: validStartupOptions({ direct_path: '/' }),
@@ -269,6 +268,44 @@ describe('KiwiConfigSchema — WS path (direct_path)', () => {
269
268
  const cfg = parseKiwiConfig(validConfig());
270
269
  expect(cfg.startupOptions.direct_path).toBeUndefined();
271
270
  });
271
+
272
+ it('accepts a single-segment stage path (AWS API Gateway — /prod)', () => {
273
+ const cfg = parseKiwiConfig(
274
+ validConfig({
275
+ startupOptions: validStartupOptions({ direct_path: '/prod' }),
276
+ }),
277
+ );
278
+ expect(cfg.startupOptions.direct_path).toBe('/prod');
279
+ });
280
+
281
+ it('accepts the /{{STAGE}} template placeholder (config.prod-aws.json before substitution)', () => {
282
+ const cfg = parseKiwiConfig(
283
+ validConfig({
284
+ startupOptions: validStartupOptions({ direct_path: '/{{STAGE}}' }),
285
+ }),
286
+ );
287
+ expect(cfg.startupOptions.direct_path).toBe('/{{STAGE}}');
288
+ });
289
+
290
+ it('rejects a multi-segment direct_path (path nesting is not a stage shape)', () => {
291
+ expect(() =>
292
+ parseKiwiConfig(
293
+ validConfig({
294
+ startupOptions: validStartupOptions({ direct_path: '/webclient/' }),
295
+ }),
296
+ ),
297
+ ).toThrowError(/direct_path/u);
298
+ });
299
+
300
+ it('rejects a direct_path with no leading slash', () => {
301
+ expect(() =>
302
+ parseKiwiConfig(
303
+ validConfig({
304
+ startupOptions: validStartupOptions({ direct_path: 'prod' }),
305
+ }),
306
+ ),
307
+ ).toThrowError(/direct_path/u);
308
+ });
272
309
  });
273
310
 
274
311
  // ============================================================================
@@ -368,30 +405,35 @@ describe('KiwiConfigSchema — server auto-detect ({{hostname}} template)', () =
368
405
  });
369
406
 
370
407
  // ============================================================================
371
- // Static config files — per-env build matrix
408
+ // Static config files — build matrix (single prod target + dev default)
372
409
  // ============================================================================
373
410
 
374
411
  describe('static config files', () => {
375
- it('config.json parses (default = staging target)', () => {
412
+ it('config.json parses (dev default)', () => {
376
413
  const cfg = parseKiwiConfig(readStaticConfig('config.json'));
377
414
  expect(cfg.startupOptions.server.length).toBeGreaterThan(0);
378
415
  expect(cfg.startupOptions.channel.startsWith('#')).toBe(true);
379
416
  });
380
417
 
381
- it('config.staging.json parses', () => {
382
- const cfg = parseKiwiConfig(readStaticConfig('config.staging.json'));
418
+ it('config.prod.json parses', () => {
419
+ const cfg = parseKiwiConfig(readStaticConfig('config.prod.json'));
383
420
  expect(cfg.startupOptions.channel.startsWith('#')).toBe(true);
384
421
  });
385
422
 
386
- it('config.prod.json parses', () => {
387
- const cfg = parseKiwiConfig(readStaticConfig('config.prod.json'));
423
+ // AWS deploys the WebSocket endpoint as a separate API Gateway URL
424
+ // (wss://{api-id}.execute-api.{region}.amazonaws.com/{stage}), not the
425
+ // CloudFront origin the SPA is served from. The prod-aws config carries
426
+ // a templated API-GW URL whose {{...}} placeholders the deploy pipeline
427
+ // substitutes at bake time, so no concrete URL is hardcoded in the repo.
428
+ it('config.prod-aws.json parses', () => {
429
+ const cfg = parseKiwiConfig(readStaticConfig('config.prod-aws.json'));
388
430
  expect(cfg.startupOptions.channel.startsWith('#')).toBe(true);
389
431
  });
390
432
 
391
433
  it('the raw schema also accepts every static config', () => {
392
434
  expect(KiwiConfigSchema.safeParse(readStaticConfig('config.json')).success).toBe(true);
393
- expect(KiwiConfigSchema.safeParse(readStaticConfig('config.staging.json')).success).toBe(true);
394
435
  expect(KiwiConfigSchema.safeParse(readStaticConfig('config.prod.json')).success).toBe(true);
436
+ expect(KiwiConfigSchema.safeParse(readStaticConfig('config.prod-aws.json')).success).toBe(true);
395
437
  });
396
438
 
397
439
  // Deploy-time resolution: every shipped config uses the {{hostname}} template
@@ -399,7 +441,7 @@ describe('static config files', () => {
399
441
  // time. This is the whole point of NOT baking a per-env hostname; if any
400
442
  // config regresses to a literal host, this assertion fires.
401
443
  it('every static config bakes {{hostname}} for server (deploy-time auto-detect)', () => {
402
- for (const file of ['config.json', 'config.staging.json', 'config.prod.json']) {
444
+ for (const file of ['config.json', 'config.prod.json']) {
403
445
  const cfg = parseKiwiConfig(readStaticConfig(file));
404
446
  expect(cfg.startupOptions.server, `${file} server`).toBe('{{hostname}}');
405
447
  }
@@ -410,7 +452,7 @@ describe('static config files', () => {
410
452
  // opens a wss:// WebSocket straight to the Worker instead of probing the
411
453
  // (non-existent) /webirc/kiwiirc/info gateway endpoint and 404-ing.
412
454
  it('every static config sets direct:true + tls:true (no kiwiServer gateway)', () => {
413
- for (const file of ['config.json', 'config.staging.json', 'config.prod.json']) {
455
+ for (const file of ['config.json', 'config.prod.json', 'config.prod-aws.json']) {
414
456
  const cfg = parseKiwiConfig(readStaticConfig(file));
415
457
  expect(cfg.startupOptions.direct, `${file} direct`).toBe(true);
416
458
  expect(cfg.startupOptions.tls, `${file} tls`).toBe(true);
@@ -424,9 +466,91 @@ describe('static config files', () => {
424
466
  // `plugins` block. If plugin support is ever needed, the build must copy
425
467
  // `.example` → `.html` (or ship a real plugin file) first.
426
468
  it('no static config ships a plugins block (avoids the customise.html 404)', () => {
427
- for (const file of ['config.json', 'config.staging.json', 'config.prod.json']) {
469
+ for (const file of ['config.json', 'config.prod.json', 'config.prod-aws.json']) {
428
470
  const raw = readStaticConfig(file) as Record<string, unknown>;
429
471
  expect(raw.plugins, `${file} plugins`).toBeUndefined();
430
472
  }
431
473
  });
432
474
  });
475
+
476
+ // ============================================================================
477
+ // AWS prod-aws config — API Gateway WebSocket URL + bake pipeline shape
478
+ // ============================================================================
479
+ //
480
+ // On AWS the WebSocket endpoint is a separate API Gateway URL, not the
481
+ // CloudFront origin that serves the SPA. The prod-aws config therefore
482
+ // bakes a templated host (`{{API_ID}}.execute-api.{{REGION}}.amazonaws.com`)
483
+ // for `server` plus a templated `direct_path` of `/{{STAGE}}` for the API
484
+ // Gateway stage path. irc-framework builds the WS URL as
485
+ // `wss://<server>:<port><direct_path>` (it prepends `wss://` itself when
486
+ // `tls: true`), so a full `wss://...` URL in `server` would produce a
487
+ // doubled-scheme URL that Firefox rejects. No concrete URL is committed;
488
+ // the `{{...}}` placeholders are substituted by the deploy pipeline (see
489
+ // `parseApiUrlForKiwi` and the AWS web deploy ticket) after `cdk deploy`
490
+ // emits the real API id, region, and stage. This block covers the
491
+ // accept-criteria: schema-conformant file, API-GW-shaped server + path,
492
+ // invariants shared with the CF configs, and a malformed prod-aws payload
493
+ // failing the build with a readable aggregated Zod error.
494
+
495
+ describe('static config files — prod-aws (AWS API Gateway)', () => {
496
+ it('config.prod-aws.json server is the templated API Gateway host (no wss:// scheme)', () => {
497
+ const cfg = parseKiwiConfig(readStaticConfig('config.prod-aws.json'));
498
+ expect(cfg.startupOptions.server).toBe('{{API_ID}}.execute-api.{{REGION}}.amazonaws.com');
499
+ });
500
+
501
+ it('config.prod-aws.json direct_path is the templated stage path', () => {
502
+ const cfg = parseKiwiConfig(readStaticConfig('config.prod-aws.json'));
503
+ expect(cfg.startupOptions.direct_path).toBe('/{{STAGE}}');
504
+ });
505
+
506
+ it('config.prod-aws.json keeps direct:true + tls:true (CloudFront serves HTTPS; WS must be wss://)', () => {
507
+ const cfg = parseKiwiConfig(readStaticConfig('config.prod-aws.json'));
508
+ expect(cfg.startupOptions.direct).toBe(true);
509
+ expect(cfg.startupOptions.tls).toBe(true);
510
+ });
511
+
512
+ // Mirrors the bake step in scripts/build.mjs (resolveConfigFile → read →
513
+ // parseKiwiConfig → re-serialise) without invoking the heavy Kiwi upstream
514
+ // build. The point of the assertion is that the prod-aws server + path
515
+ // survive the parse + re-stringify round-trip verbatim, i.e. the baked
516
+ // dist/webclient/static/config.json would carry the API-GW-shaped fields.
517
+ it('the baked prod-aws config carries the API-GW-shaped server + direct_path (bake round-trip)', () => {
518
+ const resolved = resolveConfigFile(['--env', 'prod-aws']);
519
+ expect(resolved.file).toBe('config.prod-aws.json');
520
+ const raw = readStaticConfig(resolved.file);
521
+ const parsed = parseKiwiConfig(raw);
522
+ const baked = `${JSON.stringify(parsed, null, 2)}\n`;
523
+ expect(baked).toContain('{{API_ID}}.execute-api.{{REGION}}.amazonaws.com');
524
+ expect(baked).toContain('/{{STAGE}}');
525
+ });
526
+
527
+ it('a malformed prod-aws server (non-empty string required) fails the build with a readable Zod error', () => {
528
+ const valid = readStaticConfig('config.prod-aws.json') as Record<string, unknown>;
529
+ const startupOptions = (valid.startupOptions as Record<string, unknown>) ?? {};
530
+ const malformed = {
531
+ ...valid,
532
+ startupOptions: { ...startupOptions, server: '' },
533
+ };
534
+ expect(() => parseKiwiConfig(malformed)).toThrowError(/server/u);
535
+ });
536
+
537
+ it('an out-of-range port in a prod-aws payload fails the build', () => {
538
+ const valid = readStaticConfig('config.prod-aws.json') as Record<string, unknown>;
539
+ const startupOptions = (valid.startupOptions as Record<string, unknown>) ?? {};
540
+ const malformed = {
541
+ ...valid,
542
+ startupOptions: { ...startupOptions, port: 99999 },
543
+ };
544
+ expect(() => parseKiwiConfig(malformed)).toThrowError(/port/u);
545
+ });
546
+
547
+ it('a non-# channel in a prod-aws payload fails the build', () => {
548
+ const valid = readStaticConfig('config.prod-aws.json') as Record<string, unknown>;
549
+ const startupOptions = (valid.startupOptions as Record<string, unknown>) ?? {};
550
+ const malformed = {
551
+ ...valid,
552
+ startupOptions: { ...startupOptions, channel: 'welcome' },
553
+ };
554
+ expect(() => parseKiwiConfig(malformed)).toThrowError(/channel/u);
555
+ });
556
+ });
@@ -0,0 +1,103 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
7
+ const landingSrc = path.join(pkgRoot, 'landing', 'index.html');
8
+
9
+ // Pure-string assertions over landing/index.html. The file is copied verbatim
10
+ // to dist/index.html by scripts/build.mjs (verified separately by
11
+ // build-smoke.test.ts), so these checks run against the source in CI without
12
+ // paying the Kiwi SPA build cost.
13
+ const html = readFileSync(landingSrc, 'utf8');
14
+
15
+ describe('landing page "Connect" section', () => {
16
+ it('contains a "Connect" heading', () => {
17
+ // The project front door must tell visitors how to reach the IRC server.
18
+ expect(html).toMatch(/<h2[^>]*>\s*Connect\s*<\/h2>/i);
19
+ });
20
+
21
+ it('documents the WeeChat client', () => {
22
+ expect(html).toMatch(/WeeChat/i);
23
+ });
24
+
25
+ it('documents the HexChat client', () => {
26
+ expect(html).toMatch(/HexChat/i);
27
+ });
28
+
29
+ it('documents the irssi client', () => {
30
+ expect(html).toMatch(/irssi/i);
31
+ });
32
+
33
+ it('documents the IRCCloud client', () => {
34
+ expect(html).toMatch(/IRCCloud/i);
35
+ });
36
+
37
+ it('documents TheLounge client', () => {
38
+ expect(html).toMatch(/TheLounge/i);
39
+ });
40
+
41
+ it('mentions the wss (secure WebSocket) transport for the web client', () => {
42
+ expect(html).toMatch(/\bwss\b/i);
43
+ });
44
+
45
+ it('mentions port 443 for the wss transport', () => {
46
+ expect(html).toMatch(/443\b/);
47
+ });
48
+
49
+ it('mentions the irc+tls :6697 direct-connect transport', () => {
50
+ expect(html).toMatch(/6697\b/);
51
+ });
52
+
53
+ it('hides the low-level WebSocket subprotocol framing detail', () => {
54
+ // The subprotocol negotiation + 510-byte frame budget is IRC-wire-level
55
+ // detail that doesn't belong on the project front door. It lives in the
56
+ // docs; the page should not surface `text.ircv3.net` or the 510 figure.
57
+ expect(html).not.toMatch(/text\.ircv3\.net/i);
58
+ expect(html).not.toMatch(/\b510\b/);
59
+ });
60
+
61
+ it('links to the Web Client Guide doc', () => {
62
+ // Cross-references to the docs/ subtree keep the page terse while still
63
+ // pointing at full instructions. The docs are out-of-tree (git submodule)
64
+ // so the link is a relative repo path, not a served route.
65
+ expect(html).toMatch(/WebClientGuide/i);
66
+ });
67
+
68
+ it('references the tcp-ws-forwarder bridge for stock TCP clients', () => {
69
+ expect(html).toMatch(/tcp-ws-forwarder/i);
70
+ });
71
+
72
+ it('uses a generic hostname placeholder (no per-env host baked in)', () => {
73
+ // The landing page is built once and served across staging + prod, so it
74
+ // must NOT pin a concrete worker hostname.
75
+ expect(html).toMatch(/irc\.example\.com|{{\s*hostname\s*}}|your deployed Worker host/i);
76
+ });
77
+
78
+ it('includes a WeeChat /server add snippet', () => {
79
+ expect(html).toMatch(/\/server add/i);
80
+ });
81
+
82
+ it('renders the connect instructions as a radio-backed client tab picker', () => {
83
+ // A horizontal list of client "buttons" selects which instructions show.
84
+ // Radio inputs share one name so the picker works without JavaScript
85
+ // (the build-smoke suite asserts the page ships no <script> tags).
86
+ const tabRadios = html.match(/type=["']radio["'][^>]*name=["']client-tab["']/gi) ?? [];
87
+ expect(tabRadios.length).toBeGreaterThanOrEqual(4);
88
+ });
89
+
90
+ it('renders a tab label for each documented client', () => {
91
+ expect(html).toMatch(/<label[^>]*for=["']tab-web["']/i);
92
+ expect(html).toMatch(/<label[^>]*for=["']tab-weechat["']/i);
93
+ expect(html).toMatch(/<label[^>]*for=["']tab-hexchat["']/i);
94
+ expect(html).toMatch(/<label[^>]*for=["']tab-irccloud["']/i);
95
+ });
96
+
97
+ it('hides client panels by default and reveals the selected one via :checked', () => {
98
+ // Pure-CSS tabs: panels are display:none unless their radio is checked,
99
+ // then `:checked ~ #panel-x` flips them to display:block.
100
+ expect(html).toMatch(/\.panel\s*\{[^}]*display:\s*none/i);
101
+ expect(html).toMatch(/:checked\s*~\s*#panel-/i);
102
+ });
103
+ });