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
@@ -0,0 +1,290 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `scripts/deploy-web-aws.mjs` — the AWS web-client deploy pipeline helper.
4
+ *
5
+ * Two-phase deploy that runs AFTER `cdk deploy` applies the `IrcAwsStack`
6
+ * CloudFormation stack:
7
+ *
8
+ * 1. `aws cloudformation describe-stacks --stack-name IrcAwsStack` reads
9
+ * the stack's outputs (`ConnectUrl`, `WebsiteBucketName`,
10
+ * `WebsiteDistributionId`).
11
+ * 2. `pnpm --filter @serverless-ircd/web run build:prod-aws -- --api-url <url>`
12
+ * bakes the SPA with the real API Gateway WebSocket URL (read from
13
+ * `ConnectUrl` in step 1). The build script overrides the templated
14
+ * `{{API_ID}}`/`{{REGION}}`/`{{STAGE}}` `server` value in
15
+ * `apps/web/static/config.prod-aws.json` and rejects any leftover
16
+ * `{{...}}` placeholders, so no hardcoded URLs ship.
17
+ * 3. `aws s3 sync apps/web/dist s3://<bucket> --delete` uploads the
18
+ * build. The CLI's `sync` compares etags and SKIPS unchanged objects,
19
+ * so a re-deploy with no SPA changes uploads nothing (the
20
+ * invalidation in step 4 still fires — CloudFront has no
21
+ * unchanged-skip equivalent).
22
+ * 4. `aws cloudfront create-invalidation --distribution-id <id> --paths /*`
23
+ * busts the edge cache so the new build is served immediately.
24
+ *
25
+ * Why a script instead of CDK `BucketDeployment`: `BucketDeployment`
26
+ * re-synths on every deploy (slower), cannot bake the post-deploy API URL
27
+ * into the baked config (it runs before the stack outputs exist), and
28
+ * pulls in a custom-resource Lambda for a one-shot copy. A stand-alone
29
+ * script reads the deployed outputs directly.
30
+ *
31
+ * Why the AWS CLI instead of `@aws-sdk/client-*`: `aws s3 sync`'s etag
32
+ * comparison and unchanged-skip are non-trivial to reimplement on top of
33
+ * the SDK; the CLI is already a deploy-time dependency (the workflow uses
34
+ * it for `describe-stacks`). One-off SDK calls would multiply surface
35
+ * area without buying anything.
36
+ *
37
+ * @see docs/AWS-Deployment.md for the full deploy runbook.
38
+ */
39
+
40
+ import { spawnSync } from 'node:child_process';
41
+ import path from 'node:path';
42
+
43
+ /**
44
+ * Fixed CloudFormation stack name (post-staging/prod-collapse: a single
45
+ * target per platform, no env suffix). Mirrors the value the deploy
46
+ * workflow's `describe-stacks` query already uses.
47
+ */
48
+ export const STACK_NAME = 'IrcAwsStack';
49
+
50
+ /**
51
+ * Outputs the script consumes. Each MUST be present on the deployed
52
+ * stack — a missing output aborts the deploy with a clear message so
53
+ * the operator knows to `cdk deploy` (or check the `webSite` prop) before
54
+ * re-running.
55
+ */
56
+ export const REQUIRED_OUTPUTS = ['ConnectUrl', 'WebsiteBucketName', 'WebsiteDistributionId'];
57
+
58
+ /**
59
+ * Pattern matching the `{{...}}` placeholders baked into
60
+ * `apps/web/static/config.prod-aws.json`. The stack output's `ConnectUrl`
61
+ * should be a fully-resolved wss URL; if it isn't, the build script would
62
+ * bake a broken SPA. This guard fails the deploy at the script layer
63
+ * (defence in depth) instead of relying on `build.mjs` alone.
64
+ */
65
+ const PLACEHOLDER_PATTERN = /\{\{[^}]+\}\}/u;
66
+
67
+ /**
68
+ * Builds the default runner that shells out to the `aws` CLI and `pnpm`
69
+ * via `spawnSync`. IO is captured (pipe) on the AWS path so the script can
70
+ * surface stderr in its own error messages; stdio for the `pnpm build`
71
+ * call is inherited so the (verbose) Kiwi build log streams live to the
72
+ * operator.
73
+ *
74
+ * @param {{ cwd?: string }} [opts]
75
+ * @returns {{ aws: (args: string[]) => CliResult; pnpm: (args: string[]) => CliResult }}
76
+ */
77
+ export function makeDefaultRunner({ cwd = process.cwd() } = {}) {
78
+ return {
79
+ /* v8 ignore next -- 1-line wrapper over the tested runCli(); the literal binary name is the only thing that differs and it cannot be exercised without `aws` on the test PATH. */
80
+ aws(args) {
81
+ return runCli('aws', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
82
+ },
83
+ /* v8 ignore next -- 1-line wrapper over the tested runCli(); same reason as aws() above. */
84
+ pnpm(args) {
85
+ // Inherit stdio for pnpm so the build log streams live.
86
+ return runCli('pnpm', args, { cwd, stdio: 'inherit' });
87
+ },
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Spawns a child process and returns the captured IO + exit status. Pure
93
+ * plumbing — no throwing on non-zero exits (the caller decides whether a
94
+ * non-zero status is an error). Exported so the test-suite can exercise
95
+ * the spawnSync shape with `node` as the binary instead of `aws`/`pnpm`
96
+ * (neither of which is guaranteed to be on PATH in the test environment).
97
+ *
98
+ * @param {string} command
99
+ * @param {string[]} args
100
+ * @param {{ cwd: string; stdio: 'inherit' | Array<'ignore' | 'pipe' | 'inherit'> }} opts
101
+ * @returns {CliResult}
102
+ */
103
+ export function runCli(command, args, opts) {
104
+ const result = spawnSync(command, args, {
105
+ cwd: opts.cwd,
106
+ stdio: opts.stdio,
107
+ encoding: 'utf8',
108
+ });
109
+ const status = result.status ?? -1;
110
+ const stdout = typeof result.stdout === 'string' ? result.stdout : '';
111
+ const stderr = typeof result.stderr === 'string' ? result.stderr : '';
112
+ return { status, stdout, stderr };
113
+ }
114
+
115
+ /**
116
+ * Parses the JSON body returned by
117
+ * `aws cloudformation describe-stacks --query 'Stacks[0].Outputs' --output json`
118
+ * into a plain `OutputKey → OutputValue` lookup. Empty / malformed bodies
119
+ * surface as thrown Errors so the caller (the deploy function) can fail
120
+ * loudly with context.
121
+ *
122
+ * @param {string} jsonBody
123
+ * @returns {Record<string, string>}
124
+ */
125
+ export function parseStackOutputs(jsonBody) {
126
+ const parsed = JSON.parse(jsonBody);
127
+ if (!Array.isArray(parsed)) {
128
+ return {};
129
+ }
130
+ const map = {};
131
+ for (const entry of parsed) {
132
+ if (
133
+ typeof entry === 'object' &&
134
+ entry !== null &&
135
+ typeof entry.OutputKey === 'string' &&
136
+ typeof entry.OutputValue === 'string'
137
+ ) {
138
+ map[entry.OutputKey] = entry.OutputValue;
139
+ }
140
+ }
141
+ return map;
142
+ }
143
+
144
+ /**
145
+ * Runs the two-phase AWS web deploy. Throws on any failure (missing
146
+ * output, CLI non-zero exit, leftover placeholder) — the CLI entrypoint
147
+ * catches and exits non-zero.
148
+ *
149
+ * Implemented as an `async` function so the entrypoint and tests can
150
+ * consume it uniformly via `.catch()` / `rejects.toThrowError`; the
151
+ * underlying `spawnSync` calls are themselves synchronous, but wrapping
152
+ * the orchestration in a Promise keeps the contract stable if a future
153
+ * revision swaps in the AWS SDK or a streaming upload.
154
+ *
155
+ * @param {Object} [opts]
156
+ * @param {{ aws: (args: string[]) => CliResult; pnpm: (args: string[]) => CliResult }} [opts.runner]
157
+ * @param {string} [opts.stackName]
158
+ * @param {string} [opts.distDir]
159
+ * @param {(message: string) => void} [opts.log]
160
+ * @returns {Promise<{ connectUrl: string; bucket: string; distributionId: string }>}
161
+ */
162
+ export async function deployWebAws({
163
+ runner = makeDefaultRunner(),
164
+ stackName = STACK_NAME,
165
+ distDir = path.join('apps', 'web', 'dist'),
166
+ log = (m) => console.log(m),
167
+ } = {}) {
168
+ // 1. Read the deployed stack's outputs.
169
+ const describeResult = runner.aws([
170
+ 'cloudformation',
171
+ 'describe-stacks',
172
+ '--stack-name',
173
+ stackName,
174
+ '--query',
175
+ 'Stacks[0].Outputs',
176
+ '--output',
177
+ 'json',
178
+ ]);
179
+ if (describeResult.status !== 0) {
180
+ throw new Error(
181
+ `aws cloudformation describe-stacks --stack-name ${stackName} exited with ${describeResult.status}: ${describeResult.stderr}`,
182
+ );
183
+ }
184
+ const outputs = parseStackOutputs(describeResult.stdout);
185
+
186
+ // 2. Guard: every required output MUST be present. A missing output means
187
+ // the stack hasn't been deployed (or the `webSite` prop was omitted on
188
+ // the last synth), and proceeding would either bake a broken config
189
+ // or sync to the wrong bucket.
190
+ const connectUrl = outputs.ConnectUrl;
191
+ const bucket = outputs.WebsiteBucketName;
192
+ const distributionId = outputs.WebsiteDistributionId;
193
+ for (const key of REQUIRED_OUTPUTS) {
194
+ const value = outputs[key];
195
+ if (value === undefined || value.length === 0) {
196
+ throw new Error(
197
+ `Missing required CloudFormation output '${key}' on stack '${stackName}'. ` +
198
+ `Has the stack been deployed (cdk deploy --all)? The web deploy pipeline requires: ${REQUIRED_OUTPUTS.join(', ')}.`,
199
+ );
200
+ }
201
+ }
202
+
203
+ // Defence in depth: the stack output itself should never carry an
204
+ // unsubstituted placeholder, but if it did the build would bake a
205
+ // broken SPA. Fail loudly here so the operator sees the script's
206
+ // diagnostic before the build's.
207
+ if (PLACEHOLDER_PATTERN.test(connectUrl)) {
208
+ throw new Error(
209
+ `Stack output ConnectUrl carries an unsubstituted placeholder: '${connectUrl}'. The deployed stage URL must be a fully-resolved wss:// URL, not a template.`,
210
+ );
211
+ }
212
+
213
+ // 3. Bake the SPA with the real API URL. The build script overrides the
214
+ // templated `server` field in config.prod-aws.json with this value
215
+ // and rejects any remaining `{{...}}` placeholders.
216
+ log(`Baking SPA with API URL ${connectUrl}`);
217
+ const buildResult = runner.pnpm([
218
+ '--filter',
219
+ '@serverless-ircd/web',
220
+ 'run',
221
+ 'build:prod-aws',
222
+ '--',
223
+ '--env',
224
+ 'prod-aws',
225
+ '--api-url',
226
+ connectUrl,
227
+ ]);
228
+ if (buildResult.status !== 0) {
229
+ throw new Error(
230
+ `pnpm build:prod-aws exited with ${buildResult.status}. The web SPA build failed; see the build log above.`,
231
+ );
232
+ }
233
+
234
+ // 4. s3 sync. The CLI's `sync` skips unchanged objects via etag
235
+ // comparison, so a re-deploy with no SPA changes uploads nothing.
236
+ // `--delete` removes objects that no longer exist in the dist tree
237
+ // (e.g. hashed assets from the previous build) so the bucket does
238
+ // not accumulate stale entries.
239
+ log(`Syncing ${distDir} → s3://${bucket}`);
240
+ const syncResult = runner.aws(['s3', 'sync', distDir, `s3://${bucket}`, '--delete']);
241
+ if (syncResult.status !== 0) {
242
+ throw new Error(`aws s3 sync exited with ${syncResult.status}: ${syncResult.stderr}`);
243
+ }
244
+
245
+ // 5. CloudFront invalidation. Always fires — CloudFront has no
246
+ // unchanged-skip equivalent, so even a no-op sync requires this to
247
+ // guarantee the edge serves the latest origin state.
248
+ log(`Invalidating CloudFront distribution ${distributionId}`);
249
+ const invalidationResult = runner.aws([
250
+ 'cloudfront',
251
+ 'create-invalidation',
252
+ '--distribution-id',
253
+ distributionId,
254
+ '--paths',
255
+ '/*',
256
+ ]);
257
+ if (invalidationResult.status !== 0) {
258
+ throw new Error(
259
+ `aws cloudfront create-invalidation exited with ${invalidationResult.status}: ${invalidationResult.stderr}`,
260
+ );
261
+ }
262
+
263
+ log(`Deployed web client → s3://${bucket} (CloudFront ${distributionId}, invalidated /*)`);
264
+ return { connectUrl, bucket, distributionId };
265
+ }
266
+
267
+ // --- CLI entrypoint --------------------------------------------------------
268
+ //
269
+ // Excluded from coverage: the entrypoint is exercised by the deploy
270
+ // pipeline / a human operator, not by the unit tests (which call
271
+ // `deployWebAws` directly with an injected runner). The branch under test
272
+ // is the `if (invokedDirectly)` body, which only fires when this file is
273
+ // the process entry point — exactly the case vitest's import does NOT
274
+ // produce. v8's ignore markers keep the coverage threshold meaningful
275
+ // for the testable surface.
276
+
277
+ /* v8 ignore start */
278
+ const invokedDirectly =
279
+ process.argv[1] !== undefined &&
280
+ path.resolve(process.argv[1]) ===
281
+ path.resolve(new URL('./deploy-web-aws.mjs', import.meta.url).pathname);
282
+
283
+ if (invokedDirectly) {
284
+ deployWebAws().catch((err) => {
285
+ const message = err instanceof Error ? err.message : String(err);
286
+ console.error(`deploy-web-aws: ${message}`);
287
+ process.exit(1);
288
+ });
289
+ }
290
+ /* v8 ignore stop */
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@serverless-ircd/deploy-scripts",
3
+ "version": "0.11.0",
4
+ "private": true,
5
+ "description": "Repo-level deploy helpers (AWS web client s3 sync + CloudFront invalidation pipeline).",
6
+ "license": "BSD-3-Clause",
7
+ "type": "module",
8
+ "scripts": {
9
+ "deploy:web-aws": "node ./deploy-web-aws.mjs",
10
+ "typecheck": "tsc -p tsconfig.test.json --noEmit",
11
+ "test": "vitest run",
12
+ "test:watch": "vitest",
13
+ "coverage": "vitest run --coverage",
14
+ "clean": "rimraf coverage .turbo"
15
+ },
16
+ "devDependencies": {
17
+ "@types/node": "^26.1.1",
18
+ "@vitest/coverage-v8": "^4.1.0",
19
+ "rimraf": "^6.0.0",
20
+ "typescript": "^5.6.0",
21
+ "vitest": "^4.1.0"
22
+ }
23
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "composite": false,
6
+ "types": ["node"],
7
+ "allowJs": true,
8
+ "checkJs": false
9
+ },
10
+ "include": ["**/*.ts", "**/*.mjs", "**/*.d.mts"],
11
+ "exclude": ["node_modules", "coverage"]
12
+ }
@@ -0,0 +1,19 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'node',
6
+ include: ['__tests__/**/*.test.ts'],
7
+ coverage: {
8
+ provider: 'v8',
9
+ include: ['deploy-web-aws.mjs'],
10
+ reporter: ['text', 'html', 'json-summary'],
11
+ thresholds: {
12
+ lines: 90,
13
+ functions: 90,
14
+ branches: 90,
15
+ statements: 90,
16
+ },
17
+ },
18
+ },
19
+ });
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@serverless-ircd/ci-hardening",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "private": true,
5
- "description": "CI hardening helpers: coverage-gate config validator + mutation-testing driver for irc-core",
5
+ "description": "CI hardening helpers: coverage-gate config validator + mutation-testing driver for irc-core + deploy hostname placeholder guard",
6
6
  "license": "BSD-3-Clause",
7
7
  "type": "module",
8
8
  "main": "./dist/index.js",
@@ -0,0 +1,3 @@
1
+ import { runDeploy } from './cf-deploy.js';
2
+
3
+ process.exit(runDeploy(process.argv.slice(2), process.env));
@@ -0,0 +1,118 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { checkDeployHostname, readServerNameFromWranglerToml } from './deploy-hostname.js';
4
+
5
+ /**
6
+ * Guarded `wrangler deploy` wrapper for the Cloudflare apps.
7
+ *
8
+ * Runs the deploy-time hostname guard and injects the resolved
9
+ * SERVER_NAME into the wrangler invocation itself, so an operator
10
+ * setting ONE override (`SERVER_NAME=irc.example.org pnpm deploy:cf`)
11
+ * satisfies both the guard and the deployed var. Without the wrapper
12
+ * the guard reads the env var but wrangler ignores it (vars come from
13
+ * wrangler.toml / --var flags), forcing the value to be specified
14
+ * twice.
15
+ *
16
+ * Usage (from an app's npm script, cwd = the app dir):
17
+ *
18
+ * node ../../tools/ci-hardening/dist/cf-deploy-cli.js wrangler.toml -- <wrangler args...>
19
+ *
20
+ * Resolution order: a non-blank `SERVER_NAME` env override wins; else
21
+ * the `[vars]` SERVER_NAME in the config. The resolved value is always
22
+ * passed to wrangler as `--var SERVER_NAME:<hostname>` (identical to
23
+ * the config value in the fallback case, an override otherwise) and
24
+ * validated by the same placeholder guard as the deploy workflows.
25
+ */
26
+
27
+ export interface WrapperArgv {
28
+ readonly configPath: string;
29
+ readonly passthrough: readonly string[];
30
+ }
31
+
32
+ /**
33
+ * Parses the wrapper argv: first positional is the wrangler.toml path;
34
+ * everything after it (with leading `--` separators stripped, which
35
+ * pnpm appends for `pnpm deploy:cf -- <args>`) is passed through to
36
+ * `wrangler deploy`.
37
+ */
38
+ export function parseWrapperArgv(argv: readonly string[]): WrapperArgv {
39
+ const configPath = argv[0] ?? '';
40
+ const rest = argv.slice(1);
41
+ // Strip every leading `--` separator — pnpm forwarding can produce
42
+ // more than one (`pnpm deploy:cf -- --flag` on a script that itself
43
+ // ends in `--`); wrangler would treat them as positionals.
44
+ let i = 0;
45
+ while (i < rest.length && rest[i] === '--') i++;
46
+ return { configPath, passthrough: rest.slice(i) };
47
+ }
48
+
49
+ export type GuardedHostname = { ok: true; hostname: string } | { ok: false; message: string };
50
+
51
+ /**
52
+ * Resolves the deploy-time hostname (env override → config `[vars]`
53
+ * SERVER_NAME) and runs it through the placeholder guard.
54
+ */
55
+ export function resolveGuardedHostname(
56
+ configPath: string,
57
+ env: Record<string, string | undefined>,
58
+ ): GuardedHostname {
59
+ const override = env.SERVER_NAME;
60
+ const hostname =
61
+ typeof override === 'string' && override.trim() !== ''
62
+ ? override
63
+ : readServerNameFromWranglerToml(readFileSync(configPath, 'utf8'));
64
+ if (hostname === undefined) {
65
+ return {
66
+ ok: false,
67
+ message: `::error::SERVER_NAME is not set: no SERVER_NAME var found in ${configPath} and no SERVER_NAME environment override is set`,
68
+ };
69
+ }
70
+ const check = checkDeployHostname(hostname);
71
+ return check.ok
72
+ ? { ok: true, hostname: check.hostname }
73
+ : { ok: false, message: `::error::${check.error}` };
74
+ }
75
+
76
+ /**
77
+ * Builds the wrangler argument vector: `deploy` with the resolved
78
+ * hostname injected as a `--var` override, followed by any passthrough
79
+ * flags (`--dry-run`, `--keep-vars`, …). Uses the documented
80
+ * `KEY:value` pair syntax.
81
+ */
82
+ export function buildWranglerDeployArgs(
83
+ hostname: string,
84
+ passthrough: readonly string[],
85
+ ): string[] {
86
+ return ['deploy', '--var', `SERVER_NAME:${hostname}`, ...passthrough];
87
+ }
88
+
89
+ const USAGE =
90
+ 'usage: cf-deploy <wrangler.toml> [-- <wrangler deploy args...>] (SERVER_NAME env var optionally overrides the config value)';
91
+
92
+ /**
93
+ * Resolves + guards, then execs `wrangler` (resolved from PATH — the
94
+ * npm script context puts node_modules/.bin there) with the resolved
95
+ * hostname injected. Returns the process exit code to propagate.
96
+ */
97
+ export function runDeploy(
98
+ argv: readonly string[],
99
+ env: Record<string, string | undefined>,
100
+ ): number {
101
+ const { configPath, passthrough } = parseWrapperArgv(argv);
102
+ if (configPath === '') {
103
+ console.error(USAGE);
104
+ return 2;
105
+ }
106
+ const resolved = resolveGuardedHostname(configPath, env);
107
+ if (!resolved.ok) {
108
+ console.error(resolved.message);
109
+ return 1;
110
+ }
111
+ const args = buildWranglerDeployArgs(resolved.hostname, passthrough);
112
+ const result = spawnSync('wrangler', args, { stdio: 'inherit' });
113
+ if (result.error !== undefined) {
114
+ console.error(`::error::failed to run wrangler: ${result.error.message}`);
115
+ return 1;
116
+ }
117
+ return result.status ?? 1;
118
+ }
@@ -0,0 +1,118 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ /**
4
+ * Hostnames the repo ships as defaults in its deploy configs
5
+ * (`SERVER_NAME = "irc.your-domain.invalid"` in both wrangler.toml
6
+ * files, `irc.example.com` in the Spectrum apply docs/defaults).
7
+ *
8
+ * A deploy whose public hostname is still one of these values is a
9
+ * misconfiguration, not a deploy: the wss Worker would advertise the
10
+ * placeholder in its numerics and the Spectrum cert/handshake would
11
+ * target a domain the operator does not own. Deploy paths refuse to
12
+ * ship while the deploy-time hostname is on this list.
13
+ */
14
+ export const PLACEHOLDER_HOSTNAMES: ReadonlySet<string> = new Set([
15
+ 'irc.example.com',
16
+ 'irc.your-domain.invalid',
17
+ ]);
18
+
19
+ /**
20
+ * Whether a hostname is one of the repo's shipping placeholders.
21
+ * Hostnames are case-insensitive and tolerant of surrounding
22
+ * whitespace (workflow inputs / env vars).
23
+ */
24
+ export function isPlaceholderHostname(hostname: string): boolean {
25
+ return PLACEHOLDER_HOSTNAMES.has(hostname.trim().toLowerCase());
26
+ }
27
+
28
+ export type HostnameCheck = { ok: true; hostname: string } | { ok: false; error: string };
29
+
30
+ /**
31
+ * Validate a deploy-time hostname. Returns the trimmed, lowercased
32
+ * hostname on success; on failure returns an `error` explaining how
33
+ * to set a real one (workflow `hostname` input, or `SERVER_NAME` in
34
+ * the app's wrangler.toml `[vars]` block).
35
+ */
36
+ export function checkDeployHostname(raw: string | undefined | null): HostnameCheck {
37
+ const hostname = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
38
+ if (hostname === '') {
39
+ return {
40
+ ok: false,
41
+ error:
42
+ 'deploy hostname is required: pass the public IRC hostname via the workflow `hostname` input (deploy-cf / deploy-cf-tcp) or set SERVER_NAME in the app wrangler.toml [vars] block',
43
+ };
44
+ }
45
+ if (isPlaceholderHostname(hostname)) {
46
+ return {
47
+ ok: false,
48
+ error: `${hostname} is a placeholder hostname shipped with the repo defaults, not a real deploy target: set the public IRC hostname (workflow \`hostname\` input or SERVER_NAME in wrangler.toml [vars]) and redeploy`,
49
+ };
50
+ }
51
+ return { ok: true, hostname };
52
+ }
53
+
54
+ const SERVER_NAME_RE = /^\s*SERVER_NAME\s*=\s*["']([^"'\r\n]+)["']\s*$/m;
55
+
56
+ /**
57
+ * Extract the `SERVER_NAME` var from wrangler.toml text. Anchored at
58
+ * line starts so commented-out (`# SERVER_NAME = ...`) assignments
59
+ * do not match; accepts both TOML quote styles.
60
+ */
61
+ export function readServerNameFromWranglerToml(tomlText: string): string | undefined {
62
+ return tomlText.match(SERVER_NAME_RE)?.[1];
63
+ }
64
+
65
+ export interface HostnameGuardResult {
66
+ /** 0 = deployable, 1 = rejected (unset or placeholder), 2 = usage error. */
67
+ exitCode: number;
68
+ message: string;
69
+ }
70
+
71
+ const USAGE = 'usage: hostname-guard <hostname> | hostname-guard --from-config <wrangler.toml>';
72
+
73
+ function report(check: HostnameCheck): HostnameGuardResult {
74
+ return check.ok
75
+ ? { exitCode: 0, message: `deploy hostname OK: ${check.hostname}` }
76
+ : { exitCode: 1, message: `::error::${check.error}` };
77
+ }
78
+
79
+ /**
80
+ * The deploy-time hostname guard. Two modes:
81
+ *
82
+ * - `hostname-guard <hostname>` — validate an explicit value (the
83
+ * deploy workflows pass their `hostname` input through this).
84
+ * - `hostname-guard --from-config <wrangler.toml>` — validate the
85
+ * deploy-time SERVER_NAME: the `SERVER_NAME` env override if set
86
+ * and non-blank, else the `[vars]` SERVER_NAME in the config (the
87
+ * guarded `wrangler deploy` npm scripts use this).
88
+ *
89
+ * Pure with respect to the process: `main` wires `process.argv` /
90
+ * `process.env` / stdio / `process.exit`.
91
+ */
92
+ export function runHostnameGuard(
93
+ argv: readonly string[],
94
+ env: Record<string, string | undefined>,
95
+ ): HostnameGuardResult {
96
+ if (argv[0] === '--from-config') {
97
+ const configPath = argv[1];
98
+ if (configPath === undefined || configPath === '') {
99
+ return { exitCode: 2, message: USAGE };
100
+ }
101
+ const override = env.SERVER_NAME;
102
+ const hostname =
103
+ typeof override === 'string' && override.trim() !== ''
104
+ ? override
105
+ : readServerNameFromWranglerToml(readFileSync(configPath, 'utf8'));
106
+ if (hostname === undefined) {
107
+ return {
108
+ exitCode: 1,
109
+ message: `::error::SERVER_NAME is not set: no SERVER_NAME var found in ${configPath} and no SERVER_NAME environment override is set`,
110
+ };
111
+ }
112
+ return report(checkDeployHostname(hostname));
113
+ }
114
+ if (argv.length === 1) {
115
+ return report(checkDeployHostname(argv[0]));
116
+ }
117
+ return { exitCode: 2, message: USAGE };
118
+ }