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,491 @@
1
+ /**
2
+ * Synth-time assertions for the `StaticSite` construct (the AWS web-client
3
+ * hosting substrate: a private S3 bucket fronted by a CloudFront
4
+ * distribution with Origin Access Control).
5
+ *
6
+ * The web client (Kiwi SPA + landing page) is the shared `apps/web/dist`
7
+ * build artifact. On AWS it is served exclusively over CloudFront; the S3
8
+ * origin bucket is private and grants the distribution via OAC (never
9
+ * public-read). The landing page at `/` is served via the distribution's
10
+ * `defaultRootObject` (`/index.html`); SPA client-side routing under
11
+ * `/webclient/*` is handled by a CloudFront custom error response mapping
12
+ * S3's 403/404 → `200 /webclient/index.html`.
13
+ *
14
+ * These tests synthesise the construct in isolation (via `Template` from
15
+ * `aws-cdk-lib/assertions`) and assert the exact CloudFormation shape: the
16
+ * `AWS::CloudFront::Distribution`, `AWS::S3::Bucket`, the OAC resource, the
17
+ * bucket-policy grant (OAC principal only — no `Public`/`*`), the
18
+ * 403/404 → `/webclient/index.html` error response, the `/index.html`
19
+ * default root object, and the `WebsiteURL` output. The integration into
20
+ * `IrcAwsStack` (opt-in prop) is covered in `stack.test.ts`.
21
+ */
22
+
23
+ import { runInNewContext } from 'node:vm';
24
+ import { App, Stack } from 'aws-cdk-lib';
25
+ import { Template } from 'aws-cdk-lib/assertions';
26
+ import { describe, expect, it } from 'vitest';
27
+ import { StaticSite, type StaticSiteProps } from '../src/static-site.js';
28
+
29
+ /** Synthesises a `StaticSite` in isolation and returns the assertion Template. */
30
+ function makeSiteTemplate(props: StaticSiteProps = {}): {
31
+ app: App;
32
+ stack: Stack;
33
+ template: Template;
34
+ } {
35
+ const app = new App();
36
+ const stack = new Stack(app, 'SiteStack');
37
+ new StaticSite(stack, 'Site', props);
38
+ const template = Template.fromStack(stack);
39
+ return { app, stack, template };
40
+ }
41
+
42
+ /** Shape of a synthesised CloudFront Distribution's DistributionConfig. */
43
+ type DistConfig = {
44
+ Origins?: Array<{
45
+ DomainName?: unknown;
46
+ S3OriginConfig?: unknown;
47
+ OriginAccessControlId?: unknown;
48
+ }>;
49
+ DefaultCacheBehavior?: {
50
+ ViewerProtocolPolicy?: string;
51
+ FunctionAssociations?: Array<{ EventType?: string; FunctionARN?: unknown }>;
52
+ };
53
+ Aliases?: string[];
54
+ CustomErrorResponses?: Array<{
55
+ ErrorCode?: number;
56
+ ResponseCode?: number;
57
+ ResponsePagePath?: string;
58
+ }>;
59
+ };
60
+
61
+ /** Extracts the DistributionConfig from the single CloudFront distribution. */
62
+ function distConfig(template: Template): DistConfig {
63
+ const dists = template.findResources('AWS::CloudFront::Distribution') as Record<
64
+ string,
65
+ { Properties?: { DistributionConfig?: DistConfig } }
66
+ >;
67
+ const dist = Object.values(dists)[0];
68
+ return dist?.Properties?.DistributionConfig ?? {};
69
+ }
70
+
71
+ /**
72
+ * Result of invoking the synthesised directory-index function: either the
73
+ * (possibly rewritten) request object (`uri` set), or a 301-redirect
74
+ * response (`statusCode` + `headers.location`). Kept as a single loose
75
+ * type with all-optional fields so the per-case tests can read either
76
+ * shape without per-call casts; the assertions themselves pin the shape.
77
+ */
78
+ type IndexHandlerResult = {
79
+ uri?: string;
80
+ statusCode?: number;
81
+ statusDescription?: string;
82
+ headers?: { location?: { value?: string } };
83
+ };
84
+
85
+ /**
86
+ * Extracts the synthesized directory-index CloudFront Function's `handler`
87
+ * entry point as a callable, so its rewrite/redirect logic can be exercised
88
+ * behaviourally (not just "the source contains 'index.html'"). Uses
89
+ * `node:vm`'s `runInNewContext` (rather than `eval`/`new Function`, which
90
+ * lint forbids) to evaluate the inline `FunctionCode` and return the
91
+ * declared `handler`. Throws if no `AWS::CloudFront::Function` was
92
+ * synthesised — the calling tests use that as their RED signal.
93
+ */
94
+ function extractIndexHandler(template: Template): (event: {
95
+ request: { uri: string; querystring?: Record<string, { value: string }> };
96
+ }) => IndexHandlerResult {
97
+ const fns = template.findResources('AWS::CloudFront::Function') as Record<
98
+ string,
99
+ { Properties?: { FunctionCode?: string } }
100
+ >;
101
+ // CDK's `cloudfront.Function` puts `FunctionCode` directly under
102
+ // `Properties` (NOT nested under `FunctionConfig`, which only holds
103
+ // `Comment` + `Runtime`).
104
+ const code = Object.values(fns)[0]?.Properties?.FunctionCode;
105
+ if (code === undefined) {
106
+ throw new Error('no AWS::CloudFront::Function with FunctionCode was synthesised');
107
+ }
108
+ // The CloudFront function declares `function handler(event) {...}`. Append
109
+ // a trailing `handler` expression so runInNewContext returns it.
110
+ return runInNewContext(`${code}\nhandler`, {}) as (event: {
111
+ request: { uri: string; querystring?: Record<string, { value: string }> };
112
+ }) => IndexHandlerResult;
113
+ }
114
+
115
+ /** Returns all bucket-policy statements across the synthesised template. */
116
+ function bucketPolicyStatements(template: Template): Array<{
117
+ Principal?: unknown;
118
+ Action?: unknown;
119
+ Resource?: unknown;
120
+ Condition?: Record<string, Record<string, unknown>>;
121
+ }> {
122
+ const policies = template.findResources('AWS::S3::BucketPolicy') as Record<
123
+ string,
124
+ { Properties?: { PolicyDocument?: { Statement?: Array<Record<string, unknown>> } } }
125
+ >;
126
+ const all: Array<Record<string, unknown>> = [];
127
+ for (const policy of Object.values(policies)) {
128
+ all.push(...(policy.Properties?.PolicyDocument?.Statement ?? []));
129
+ }
130
+ return all as Array<{
131
+ Principal?: unknown;
132
+ Action?: unknown;
133
+ Resource?: unknown;
134
+ Condition?: Record<string, Record<string, unknown>>;
135
+ }>;
136
+ }
137
+
138
+ describe('StaticSite — CloudFront distribution', () => {
139
+ it('creates exactly one AWS::CloudFront::Distribution', () => {
140
+ const { template } = makeSiteTemplate();
141
+ template.resourceCountIs('AWS::CloudFront::Distribution', 1);
142
+ });
143
+
144
+ it('creates a distribution backed by an S3 origin', () => {
145
+ const { template } = makeSiteTemplate();
146
+ const origins = distConfig(template).Origins ?? [];
147
+ expect(origins.length).toBeGreaterThanOrEqual(1);
148
+ expect(origins[0]?.S3OriginConfig).toBeDefined();
149
+ });
150
+
151
+ it('redirects HTTP viewers to HTTPS', () => {
152
+ const { template } = makeSiteTemplate();
153
+ expect(distConfig(template).DefaultCacheBehavior?.ViewerProtocolPolicy).toBe(
154
+ 'redirect-to-https',
155
+ );
156
+ });
157
+ });
158
+
159
+ describe('StaticSite — private S3 origin bucket', () => {
160
+ it('creates exactly one AWS::S3::Bucket', () => {
161
+ const { template } = makeSiteTemplate();
162
+ template.resourceCountIs('AWS::S3::Bucket', 1);
163
+ });
164
+
165
+ it('never grants public read access (no Public/* principal in any bucket-policy statement)', () => {
166
+ const { template } = makeSiteTemplate();
167
+ const statements = bucketPolicyStatements(template);
168
+ expect(statements.length).toBeGreaterThanOrEqual(1);
169
+ for (const stmt of statements) {
170
+ const serialised = JSON.stringify(stmt.Principal);
171
+ expect(serialised, 'bucket policy must not grant Principal "*"').not.toBe('"*"');
172
+ expect(serialised, 'bucket policy must not grant Principal AWS "*"').not.toContain(
173
+ '"AWS":"*"',
174
+ );
175
+ expect(serialised, 'bucket policy must not be public-read').not.toContain('Public');
176
+ }
177
+ });
178
+
179
+ it('grants the CloudFront distribution read access via the OAC service principal', () => {
180
+ const { template } = makeSiteTemplate();
181
+ const statements = bucketPolicyStatements(template);
182
+ const oacGrant = statements.find((s) =>
183
+ JSON.stringify(s.Principal).includes('cloudfront.amazonaws.com'),
184
+ );
185
+ expect(oacGrant, 'expected a cloudfront OAC bucket-policy statement').toBeDefined();
186
+ });
187
+
188
+ it('scopes the OAC grant to this distribution via a StringEquals AWS:SourceArn condition', () => {
189
+ // OAC uses a condition keyed on `AWS:SourceArn` (the distribution ARN)
190
+ // so only THIS distribution can read the bucket, not every CloudFront
191
+ // distribution in the account.
192
+ const { template } = makeSiteTemplate();
193
+ const statements = bucketPolicyStatements(template);
194
+ const oacGrant = statements.find((s) =>
195
+ JSON.stringify(s.Principal).includes('cloudfront.amazonaws.com'),
196
+ );
197
+ const cond = JSON.stringify(oacGrant?.Condition ?? {});
198
+ expect(cond).toContain('StringEquals');
199
+ expect(cond).toContain('AWS:SourceArn');
200
+ expect(cond).toContain('cloudfront');
201
+ expect(cond).toContain('distribution');
202
+ });
203
+ });
204
+
205
+ describe('StaticSite — Origin Access Control (OAC)', () => {
206
+ it('creates an AWS::CloudFront::OriginAccessControl resource', () => {
207
+ const { template } = makeSiteTemplate();
208
+ template.resourceCountIs('AWS::CloudFront::OriginAccessControl', 1);
209
+ });
210
+
211
+ it('configures the OAC for an S3 origin with SIGV4_ALWAYS signing', () => {
212
+ const { template } = makeSiteTemplate();
213
+ const oacs = template.findResources('AWS::CloudFront::OriginAccessControl') as Record<
214
+ string,
215
+ {
216
+ Properties?: {
217
+ OriginAccessControlConfig?: {
218
+ OriginAccessControlOriginType?: string;
219
+ SigningProtocol?: string;
220
+ SigningBehavior?: string;
221
+ };
222
+ };
223
+ }
224
+ >;
225
+ const oac = Object.values(oacs)[0]?.Properties?.OriginAccessControlConfig;
226
+ expect(oac?.OriginAccessControlOriginType).toBe('s3');
227
+ expect(oac?.SigningProtocol).toBe('sigv4');
228
+ expect(oac?.SigningBehavior).toBe('always');
229
+ });
230
+
231
+ it('references the OAC id from the S3 origin', () => {
232
+ const { template } = makeSiteTemplate();
233
+ const origin = distConfig(template).Origins?.[0];
234
+ expect(origin?.OriginAccessControlId).toBeDefined();
235
+ });
236
+ });
237
+
238
+ describe('StaticSite — default root object + SPA error response', () => {
239
+ it('serves the landing page (/index.html) as the default root object', () => {
240
+ // `https://<dist>/` must serve the project landing page (the 13 KB
241
+ // static HTML at `dist/index.html` produced by step 5 of
242
+ // `apps/web/scripts/build.mjs`). Without this `defaultRootObject`,
243
+ // a request to `/` would 403 against the private bucket and trigger
244
+ // the SPA error response, which is the wrong page (the SPA shell,
245
+ // not the landing page).
246
+ const { template } = makeSiteTemplate();
247
+ const dists = template.findResources('AWS::CloudFront::Distribution') as Record<
248
+ string,
249
+ { Properties?: { DistributionConfig?: { DefaultRootObject?: string } } }
250
+ >;
251
+ const root = Object.values(dists)[0]?.Properties?.DistributionConfig?.DefaultRootObject;
252
+ expect(root).toBe('index.html');
253
+ });
254
+
255
+ it('maps HTTP 403 → 200 with response page /webclient/index.html', () => {
256
+ const { template } = makeSiteTemplate();
257
+ const errors = distConfig(template).CustomErrorResponses ?? [];
258
+ const mapping = errors.find((e) => e.ErrorCode === 403);
259
+ expect(mapping, 'expected a 403 custom error response').toBeDefined();
260
+ expect(mapping?.ResponseCode).toBe(200);
261
+ expect(mapping?.ResponsePagePath).toBe('/webclient/index.html');
262
+ });
263
+
264
+ it('maps HTTP 404 → 200 with response page /webclient/index.html', () => {
265
+ const { template } = makeSiteTemplate();
266
+ const errors = distConfig(template).CustomErrorResponses ?? [];
267
+ const mapping = errors.find((e) => e.ErrorCode === 404);
268
+ expect(mapping, 'expected a 404 custom error response').toBeDefined();
269
+ expect(mapping?.ResponseCode).toBe(200);
270
+ expect(mapping?.ResponsePagePath).toBe('/webclient/index.html');
271
+ });
272
+ });
273
+
274
+ describe('StaticSite — WebsiteURL output', () => {
275
+ /**
276
+ * The `WebsiteURL` CfnOutput lives inside the `StaticSite` construct, but
277
+ * its logical id is pinned to the stable key `WebsiteURL` via
278
+ * `overrideLogicalId` so operators see `Stack.WebsiteURL` rather than the
279
+ * CDK-hashed `StaticSiteWebsiteURL<8-hex>`. This matches the convention
280
+ * used by the parent-stack outputs (`WebsiteBucketName`,
281
+ * `WebsiteDistributionId`) and keeps the deploy pipeline's
282
+ * `describe-stacks --query` lookups predictable.
283
+ */
284
+ it('emits a WebsiteURL output with a stable logical id (no CDK hash) naming the distribution domain', () => {
285
+ const { template } = makeSiteTemplate();
286
+ const outputs = (template.toJSON().Outputs ?? {}) as Record<string, { Value?: unknown }>;
287
+ expect(outputs.WebsiteURL, 'expected a WebsiteURL output with a stable key').toBeDefined();
288
+ const distIds = Object.keys(template.findResources('AWS::CloudFront::Distribution'));
289
+ expect(distIds).toHaveLength(1);
290
+ const getAtt = (outputs.WebsiteURL?.Value as { 'Fn::GetAtt'?: unknown[] })?.['Fn::GetAtt'];
291
+ expect(getAtt, 'expected WebsiteURL.Value to Fn::GetAtt the distribution DomainName').toEqual([
292
+ distIds[0],
293
+ 'DomainName',
294
+ ]);
295
+ });
296
+ });
297
+
298
+ describe('StaticSite — default domain (no custom-domain props)', () => {
299
+ it('does NOT configure an alternate domain name when no custom domain is provided', () => {
300
+ const { template } = makeSiteTemplate();
301
+ expect(distConfig(template).Aliases).toBeUndefined();
302
+ });
303
+
304
+ it('does NOT create a Route53 record when no custom domain is provided', () => {
305
+ const { template } = makeSiteTemplate();
306
+ template.resourceCountIs('AWS::Route53::RecordSet', 0);
307
+ });
308
+ });
309
+
310
+ describe('StaticSite — custom domain (opt-in)', () => {
311
+ const customProps: StaticSiteProps = {
312
+ customDomain: 'app.example.com',
313
+ certificateArn: 'arn:aws:acm:us-east-1:123456789012:certificate/abc',
314
+ hostedZoneName: 'example.com.',
315
+ hostedZoneId: 'Z2KVMGOMG3KSP5',
316
+ };
317
+
318
+ it('configures the alternate domain name on the distribution', () => {
319
+ const { template } = makeSiteTemplate(customProps);
320
+ expect(distConfig(template).Aliases).toEqual(['app.example.com']);
321
+ });
322
+
323
+ it('attaches the provided ACM certificate as the viewer certificate', () => {
324
+ const { template } = makeSiteTemplate(customProps);
325
+ const dists = template.findResources('AWS::CloudFront::Distribution') as Record<
326
+ string,
327
+ {
328
+ Properties?: {
329
+ DistributionConfig?: { ViewerCertificate?: { AcmCertificateArn?: string } };
330
+ };
331
+ }
332
+ >;
333
+ const cert =
334
+ Object.values(dists)[0]?.Properties?.DistributionConfig?.ViewerCertificate?.AcmCertificateArn;
335
+ expect(cert).toBe('arn:aws:acm:us-east-1:123456789012:certificate/abc');
336
+ });
337
+
338
+ it('creates a Route53 ARecord aliasing the distribution', () => {
339
+ const { template } = makeSiteTemplate(customProps);
340
+ template.hasResourceProperties('AWS::Route53::RecordSet', {
341
+ Name: 'app.example.com.',
342
+ Type: 'A',
343
+ });
344
+ });
345
+
346
+ it('configures the alias without a viewer certificate when certificateArn is omitted', () => {
347
+ // CloudFront requires a cert for an alias at deploy time, but the
348
+ // construct does not fail at synth when none is imported — the deploy
349
+ // surfaces the CFN error. This exercises the no-cert branch so the
350
+ // synth path is covered either way.
351
+ const { template } = makeSiteTemplate({
352
+ customDomain: 'app.example.com',
353
+ hostedZoneName: 'example.com.',
354
+ hostedZoneId: 'Z2KVMGOMG3KSP5',
355
+ });
356
+ expect(distConfig(template).Aliases).toEqual(['app.example.com']);
357
+ const dists = template.findResources('AWS::CloudFront::Distribution') as Record<
358
+ string,
359
+ { Properties?: { DistributionConfig?: { ViewerCertificate?: unknown } } }
360
+ >;
361
+ expect(
362
+ Object.values(dists)[0]?.Properties?.DistributionConfig?.ViewerCertificate,
363
+ ).toBeUndefined();
364
+ });
365
+
366
+ it('skips the Route53 record when the hosted zone id is omitted', () => {
367
+ // The ARecord needs both zone name AND id (fromHostedZoneAttributes);
368
+ // without the id the alias is still configured on the distribution but
369
+ // no Route53 record is materialised.
370
+ const { template } = makeSiteTemplate({
371
+ customDomain: 'app.example.com',
372
+ certificateArn: 'arn:aws:acm:us-east-1:123456789012:certificate/abc',
373
+ hostedZoneName: 'example.com.',
374
+ });
375
+ expect(distConfig(template).Aliases).toEqual(['app.example.com']);
376
+ template.resourceCountIs('AWS::Route53::RecordSet', 0);
377
+ });
378
+
379
+ it('skips the Route53 record when hostedZoneName is omitted', () => {
380
+ const { template } = makeSiteTemplate({
381
+ customDomain: 'app.example.com',
382
+ certificateArn: 'arn:aws:acm:us-east-1:123456789012:certificate/abc',
383
+ });
384
+ template.resourceCountIs('AWS::Route53::RecordSet', 0);
385
+ });
386
+ });
387
+
388
+ describe('StaticSite — directory-index viewer-request function', () => {
389
+ // S3 REST origins (used with OAC) do NOT resolve directory indexes: a
390
+ // request for '/docs/' looks up an object literally named 'docs/' and
391
+ // 404s, which then falls through to the SPA error response and serves
392
+ // Kiwi instead of the rendered docs index (reproduced live on
393
+ // irc.aws.fowl.cloud). A CloudFront viewer-request function appends
394
+ // 'index.html' to any URI ending in '/', mirroring the CF Worker
395
+ // `[assets]` binding which resolves directory indexes automatically.
396
+
397
+ it('creates exactly one AWS::CloudFront::Function', () => {
398
+ const { template } = makeSiteTemplate();
399
+ template.resourceCountIs('AWS::CloudFront::Function', 1);
400
+ });
401
+
402
+ it('associates the function as a viewer-request on the default behavior', () => {
403
+ const { template } = makeSiteTemplate();
404
+ const associations = distConfig(template).DefaultCacheBehavior?.FunctionAssociations ?? [];
405
+ const viewerRequest = associations.find((a) => a.EventType === 'viewer-request');
406
+ expect(viewerRequest, 'expected a viewer-request function association').toBeDefined();
407
+ });
408
+
409
+ it('rewrites a trailing-slash directory URI by appending index.html', () => {
410
+ const { template } = makeSiteTemplate();
411
+ const handler = extractIndexHandler(template);
412
+ expect(handler({ request: { uri: '/docs/' } }).uri).toBe('/docs/index.html');
413
+ });
414
+
415
+ it('301-redirects a directory URI without a trailing slash to the trailing-slash form', () => {
416
+ // A bare '/docs' must NOT be internally rewritten to '/docs/index.html':
417
+ // the browser URL would stay at '/docs' and the page's relative asset
418
+ // links (e.g. <link href="docs.css">) would resolve against the parent
419
+ // ('/docs.css') instead of the directory ('/docs/docs.css'). A 301 to
420
+ // '/docs/' makes the browser re-request the directory form, after which
421
+ // the trailing-slash rewrite serves '/docs/index.html' and relative
422
+ // links resolve correctly. Mirrors Apache/nginx DirectorySlash.
423
+ const { template } = makeSiteTemplate();
424
+ const handler = extractIndexHandler(template);
425
+ const result = handler({ request: { uri: '/docs' } });
426
+ expect(result.statusCode).toBe(301);
427
+ expect(result.headers?.location?.value).toBe('/docs/');
428
+ });
429
+
430
+ it('preserves the query string in the no-trailing-slash redirect', () => {
431
+ // CloudFront Functions deliver request.querystring as an OBJECT
432
+ // ({ key: { value: "..." } }), not a raw string — an empty querystring
433
+ // is {} (truthy), so a naive string concat would append "?[object
434
+ // Object]". The function rebuilds the query string from the object.
435
+ const { template } = makeSiteTemplate();
436
+ const handler = extractIndexHandler(template);
437
+ const result = handler({
438
+ request: { uri: '/docs', querystring: { foo: { value: 'bar' } } },
439
+ });
440
+ expect(result.statusCode).toBe(301);
441
+ expect(result.headers?.location?.value).toBe('/docs/?foo=bar');
442
+ });
443
+
444
+ it('omits the query string entirely when there are no params', () => {
445
+ // Regression guard for the live "?[object Object]" bug: an empty
446
+ // querystring object must NOT append a trailing "?".
447
+ const { template } = makeSiteTemplate();
448
+ const handler = extractIndexHandler(template);
449
+ const result = handler({ request: { uri: '/docs', querystring: {} } });
450
+ expect(result.statusCode).toBe(301);
451
+ expect(result.headers?.location?.value).toBe('/docs/');
452
+ });
453
+
454
+ it('rewrites the bare root "/" to "/index.html"', () => {
455
+ const { template } = makeSiteTemplate();
456
+ const handler = extractIndexHandler(template);
457
+ expect(handler({ request: { uri: '/' } }).uri).toBe('/index.html');
458
+ });
459
+
460
+ it('leaves a URI with a file extension unchanged', () => {
461
+ const { template } = makeSiteTemplate();
462
+ const handler = extractIndexHandler(template);
463
+ expect(handler({ request: { uri: '/docs/Services.html' } }).uri).toBe('/docs/Services.html');
464
+ });
465
+
466
+ it('leaves a hashed asset path unchanged', () => {
467
+ // Every real static asset carries a file extension (.js/.css/.png/
468
+ // .json/.woff2/.ico), so the no-extension rewrite never touches them.
469
+ const { template } = makeSiteTemplate();
470
+ const handler = extractIndexHandler(template);
471
+ expect(handler({ request: { uri: '/webclient/static/js/app.d5acc7fe.js' } }).uri).toBe(
472
+ '/webclient/static/js/app.d5acc7fe.js',
473
+ );
474
+ });
475
+
476
+ it('301-redirects a no-extension SPA deep-link to the trailing-slash form', () => {
477
+ // A SPA client-side route like '/webclient/networks/example' has no
478
+ // extension, so the function redirects it to the trailing-slash form
479
+ // '/webclient/networks/example/'. That object does not exist → S3 404
480
+ // → the 403/404 SPA error response still serves '/webclient/index.html'
481
+ // (unchanged), and Kiwi's router resolves the deep link from the URL.
482
+ // The redirect therefore does not break SPA routing; it merely routes
483
+ // the (expected) miss through the existing fallback, with a canonical
484
+ // trailing slash on the browser URL.
485
+ const { template } = makeSiteTemplate();
486
+ const handler = extractIndexHandler(template);
487
+ const result = handler({ request: { uri: '/webclient/networks/example' } });
488
+ expect(result.statusCode).toBe(301);
489
+ expect(result.headers?.location?.value).toBe('/webclient/networks/example/');
490
+ });
491
+ });
@@ -57,7 +57,6 @@ describe('IrcAwsStack — test-mode bundle stub (no esbuild)', () => {
57
57
  it('emits inline (ZipFile) Lambda code for the NLB streaming handler when tcpTlsDomainName is set', () => {
58
58
  const app = new App();
59
59
  const stack = new IrcAwsStack(app, 'TestStack', {
60
- environmentName: 'staging',
61
60
  tcpTlsDomainName: 'irc.example.com',
62
61
  });
63
62
  const template = Template.fromStack(stack);
@@ -1,14 +1,24 @@
1
1
  # Cloudflare Container / Docker image for the ServerlessIRCd TCP origin.
2
2
  #
3
- # Spectrum terminates TLS at the edge and forwards plaintext TCP to this
4
- # container. The container runs the IRC core (ConnectionActor +
5
- # InMemoryRuntime) over the TcpByteStreamTransport seam on port 6667.
3
+ # TLS terminates INSIDE this container on :6697 (Spectrum runs in
4
+ # `tls_mode = "full"`, re-encrypting edge→origin) no plaintext IRC
5
+ # ever crosses the origin network. The container runs the IRC core
6
+ # (ConnectionActor + InMemoryRuntime) over the TcpByteStreamTransport
7
+ # seam behind its own TLS listener.
6
8
  #
7
9
  # Build context: the repository root (monorepo). The container needs the
8
10
  # irc-core, irc-server, and in-memory-runtime packages.
9
11
  #
10
12
  # docker build -f apps/cf-tcp-container/Dockerfile -t sirc-tcp-origin .
11
- # docker run -p 6667:6667 -e SERVER_NAME=irc.example.com sirc-tcp-origin
13
+ # docker run -p 6697:6697 \
14
+ # -v $(pwd)/certs:/data/certs:ro \
15
+ # -e SERVER_NAME=irc.example.com sirc-tcp-origin
16
+ #
17
+ # Certificates: mount a PEM cert/key pair at /data/certs/ (the image
18
+ # defaults TLS_CERT_PATH=/data/certs/tls.pem, TLS_KEY_PATH=/data/certs/
19
+ # tls.key). Let's Encrypt, Bring-Your-Own, and Cloudflare Origin Certs
20
+ # are all supported — see docs/Cloudflare-TCP-Deployment.md. Cert files
21
+ # must be readable by UID 1000 (node).
12
22
  #
13
23
  # For Cloudflare Containers deployment, the same image is referenced from
14
24
  # wrangler.toml.
@@ -57,13 +67,35 @@ COPY --from=builder /app/apps/cf-tcp-container/dist/ apps/cf-tcp-container/dist/
57
67
  RUN corepack enable && corepack prepare pnpm@11.17.0 --activate && \
58
68
  pnpm install --frozen-lockfile --prod
59
69
 
70
+ # Runtime-writable state: the persistence snapshot lives in /data, and the
71
+ # runtime process runs as the unprivileged `node` user — so the directory
72
+ # must exist and be node-owned before privileges drop (chown needs root).
73
+ RUN mkdir -p /data && chown node:node /data
74
+
60
75
  # Persistence volume: Spectrum-origin channel state survives restarts.
61
76
  VOLUME ["/data"]
62
77
 
63
- EXPOSE 6667
78
+ # Cert files for TLS termination at the origin: mount the PEM pair at
79
+ # /data/certs/ (see ENV defaults below) — Let's Encrypt, BYO, or a
80
+ # Cloudflare Origin Cert all work. Any cert mount must be readable by
81
+ # UID 1000 (node) — mount them owned by 1000:1000.
82
+
83
+ # Drop root: the IRC stack (parser + actor + transport) handles untrusted
84
+ # input, so it must not run as UID 0. node:24-slim ships the non-root
85
+ # `node` user (UID 1000) out of the box.
86
+ USER node
87
+
88
+ EXPOSE 6697
64
89
 
90
+ # The image is a production origin by default: the config loader then
91
+ # refuses to boot without TLS material (no silent plaintext fallback).
92
+ ENV NODE_ENV=production
65
93
  ENV TCP_PORT=6667
66
94
  ENV TCP_HOST=0.0.0.0
67
95
  ENV PERSISTENCE_PATH=/data/state.json
96
+ # Documented cert mount path (override per-deploy via TLS_CERT_PATH /
97
+ # TLS_KEY_PATH if the pair lives elsewhere).
98
+ ENV TLS_CERT_PATH=/data/certs/tls.pem
99
+ ENV TLS_KEY_PATH=/data/certs/tls.key
68
100
 
69
101
  CMD ["node", "--enable-source-maps", "apps/cf-tcp-container/dist/main.js"]
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/cf-tcp-container",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "private": true,
5
5
  "description": "Cloudflare Container TCP origin for the irc+tls :6697 transport — Spectrum terminates TLS at the edge, this container runs the IRC core over plaintext TCP",
6
6
  "license": "BSD-3-Clause",
@@ -10,8 +10,7 @@
10
10
  "build": "tsc -p tsconfig.build.json",
11
11
  "typecheck": "tsc -p tsconfig.test.json --noEmit",
12
12
  "start": "node --enable-source-maps ./dist/main.js",
13
- "deploy:staging": "wrangler deploy --env staging",
14
- "deploy:prod": "wrangler deploy",
13
+ "deploy": "node ../../tools/ci-hardening/dist/cf-deploy-cli.js wrangler.toml",
15
14
  "test": "vitest run",
16
15
  "test:watch": "vitest",
17
16
  "coverage": "vitest run --coverage",
@@ -22,6 +21,11 @@
22
21
  "@serverless-ircd/irc-core": "workspace:*",
23
22
  "@serverless-ircd/irc-server": "workspace:*"
24
23
  },
24
+ "proxyProtocol": {
25
+ "version": 1,
26
+ "parser": "hand-rolled (src/proxy-protocol.ts)",
27
+ "rationale": "PROXY protocol v1 is a single fixed-format ASCII line, fully validated in ~40 lines; no npm proxy-protocol dependency is pulled onto the connection hot path (supply-chain surface > value)."
28
+ },
25
29
  "devDependencies": {
26
30
  "@cloudflare/containers": "^0.1.0",
27
31
  "@types/node": "^26.1.1",