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
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * These are the primary red→green tests for the infrastructure stack:
5
5
  * they assert the exact CloudFormation shape that `cdk synth` must
6
- * produce — six DynamoDB tables with the PLAN §4 schema, a single
6
+ * produce — five DynamoDB tables with the PLAN §4 schema, a single
7
7
  * Node 24 Lambda wired to all three WebSocket routes, the API Gateway
8
8
  * v2 WebSocket API, the two stack outputs, and a least-privilege guard
9
9
  * that no IAM policy grants `Resource: '*'` or `Action: '*'`.
@@ -18,28 +18,31 @@ import { App, type Stack } from 'aws-cdk-lib';
18
18
  import { Template } from 'aws-cdk-lib/assertions';
19
19
  import { describe, expect, it } from 'vitest';
20
20
  import { IrcAwsStack } from '../src/aws-stack.js';
21
+ import type { StaticSiteProps } from '../src/static-site.js';
21
22
 
22
23
  /** All five table names that must exist in the stack (PLAN §4). */
23
- const TABLE_NAMES = ['Accounts', 'ChannelMembers', 'ChannelMeta', 'Connections', 'Nicks'] as const;
24
-
25
- /** Default environment name assumed when the prop is omitted. */
26
- const DEFAULT_ENV = 'staging';
24
+ const TABLE_NAMES = ['ChannelMembers', 'ChannelMeta', 'Connections', 'Nicks', 'Services'] as const;
27
25
 
28
26
  /**
29
- * Capitalises the first character of an environment name so it composes
30
- * cleanly with the PascalCase logical table ids (`staging` → `Staging`).
27
+ * Raw synthesised CloudFormation node for a DynamoDB table. `DeletionPolicy`
28
+ * and `UpdateReplacePolicy` live at the resource root (siblings of
29
+ * `Properties`), so they are NOT visible via `Template.hasResourceProperties`
30
+ * — tests that assert the removal policy inspect the full node.
31
31
  */
32
- function prefixedTableName(environmentName: string, logicalId: string): string {
33
- return `${environmentName.charAt(0).toUpperCase()}${environmentName.slice(1)}${logicalId}`;
34
- }
32
+ type DynamoTableResource = {
33
+ Type: string;
34
+ DeletionPolicy?: string;
35
+ UpdateReplacePolicy?: string;
36
+ Properties?: {
37
+ TableName?: string;
38
+ PointInTimeRecoverySpecification?: { PointInTimeRecoveryEnabled?: boolean };
39
+ DeletionProtectionEnabled?: boolean;
40
+ };
41
+ };
35
42
 
36
- function makeTemplate(environmentName?: string): { app: App; stack: Stack; template: Template } {
43
+ function makeTemplate(): { app: App; stack: Stack; template: Template } {
37
44
  const app = new App();
38
- const stack = new IrcAwsStack(
39
- app,
40
- 'TestStack',
41
- environmentName !== undefined ? { environmentName } : undefined,
42
- );
45
+ const stack = new IrcAwsStack(app, 'TestStack');
43
46
  const template = Template.fromStack(stack);
44
47
  return { app, stack, template };
45
48
  }
@@ -79,15 +82,15 @@ function findRuleTargetingLambda(
79
82
  }
80
83
 
81
84
  describe('IrcAwsStack — DynamoDB tables', () => {
82
- it('creates exactly six DynamoDB tables', () => {
85
+ it('creates exactly five DynamoDB tables', () => {
83
86
  const { template } = makeTemplate();
84
- template.resourceCountIs('AWS::DynamoDB::Table', 6);
87
+ template.resourceCountIs('AWS::DynamoDB::Table', 5);
85
88
  });
86
89
 
87
90
  it.each(TABLE_NAMES)('creates the %s table', (tableName) => {
88
91
  const { template } = makeTemplate();
89
92
  template.hasResourceProperties('AWS::DynamoDB::Table', {
90
- TableName: prefixedTableName(DEFAULT_ENV, tableName),
93
+ TableName: tableName,
91
94
  });
92
95
  });
93
96
 
@@ -101,7 +104,7 @@ describe('IrcAwsStack — DynamoDB tables', () => {
101
104
  it('enables TTL on the Connections table via the idleSince attribute', () => {
102
105
  const { template } = makeTemplate();
103
106
  template.hasResourceProperties('AWS::DynamoDB::Table', {
104
- TableName: prefixedTableName(DEFAULT_ENV, 'Connections'),
107
+ TableName: 'Connections',
105
108
  TimeToLiveSpecification: {
106
109
  AttributeName: 'idleSince',
107
110
  Enabled: true,
@@ -112,8 +115,13 @@ describe('IrcAwsStack — DynamoDB tables', () => {
112
115
  it('keys the Connections table by connectionId (String)', () => {
113
116
  const { template } = makeTemplate();
114
117
  template.hasResourceProperties('AWS::DynamoDB::Table', {
115
- TableName: prefixedTableName(DEFAULT_ENV, 'Connections'),
116
- AttributeDefinitions: [{ AttributeName: 'connectionId', AttributeType: 'S' }],
118
+ TableName: 'Connections',
119
+ // connectionId (base key) plus the per-IP admission GSI keys.
120
+ AttributeDefinitions: [
121
+ { AttributeName: 'connectionId', AttributeType: 'S' },
122
+ { AttributeName: 'sourceIp', AttributeType: 'S' },
123
+ { AttributeName: 'connectedSince', AttributeType: 'N' },
124
+ ],
117
125
  KeySchema: [{ AttributeName: 'connectionId', KeyType: 'HASH' }],
118
126
  });
119
127
  });
@@ -121,7 +129,7 @@ describe('IrcAwsStack — DynamoDB tables', () => {
121
129
  it('keys the ChannelMembers table by channelName (PK) + connectionId (SK)', () => {
122
130
  const { template } = makeTemplate();
123
131
  template.hasResourceProperties('AWS::DynamoDB::Table', {
124
- TableName: prefixedTableName(DEFAULT_ENV, 'ChannelMembers'),
132
+ TableName: 'ChannelMembers',
125
133
  AttributeDefinitions: [
126
134
  { AttributeName: 'channelName', AttributeType: 'S' },
127
135
  { AttributeName: 'connectionId', AttributeType: 'S' },
@@ -136,7 +144,7 @@ describe('IrcAwsStack — DynamoDB tables', () => {
136
144
  it('keys the ChannelMeta table by channelName (String)', () => {
137
145
  const { template } = makeTemplate();
138
146
  template.hasResourceProperties('AWS::DynamoDB::Table', {
139
- TableName: prefixedTableName(DEFAULT_ENV, 'ChannelMeta'),
147
+ TableName: 'ChannelMeta',
140
148
  AttributeDefinitions: [{ AttributeName: 'channelName', AttributeType: 'S' }],
141
149
  KeySchema: [{ AttributeName: 'channelName', KeyType: 'HASH' }],
142
150
  });
@@ -145,25 +153,16 @@ describe('IrcAwsStack — DynamoDB tables', () => {
145
153
  it('keys the Nicks table by nickLower (String)', () => {
146
154
  const { template } = makeTemplate();
147
155
  template.hasResourceProperties('AWS::DynamoDB::Table', {
148
- TableName: prefixedTableName(DEFAULT_ENV, 'Nicks'),
156
+ TableName: 'Nicks',
149
157
  AttributeDefinitions: [{ AttributeName: 'nickLower', AttributeType: 'S' }],
150
158
  KeySchema: [{ AttributeName: 'nickLower', KeyType: 'HASH' }],
151
159
  });
152
160
  });
153
161
 
154
- it('keys the Accounts table by account (String)', () => {
155
- const { template } = makeTemplate();
156
- template.hasResourceProperties('AWS::DynamoDB::Table', {
157
- TableName: prefixedTableName(DEFAULT_ENV, 'Accounts'),
158
- AttributeDefinitions: [{ AttributeName: 'account', AttributeType: 'S' }],
159
- KeySchema: [{ AttributeName: 'account', KeyType: 'HASH' }],
160
- });
161
- });
162
-
163
162
  it('keys the Services table by composite pk/sk (String/String)', () => {
164
163
  const { template } = makeTemplate();
165
164
  template.hasResourceProperties('AWS::DynamoDB::Table', {
166
- TableName: prefixedTableName(DEFAULT_ENV, 'Services'),
165
+ TableName: 'Services',
167
166
  AttributeDefinitions: [
168
167
  { AttributeName: 'pk', AttributeType: 'S' },
169
168
  { AttributeName: 'sk', AttributeType: 'S' },
@@ -176,6 +175,173 @@ describe('IrcAwsStack — DynamoDB tables', () => {
176
175
  });
177
176
  });
178
177
 
178
+ describe('IrcAwsStack — DynamoDB table protection (RETAIN + PITR + deletion protection)', () => {
179
+ /**
180
+ * State-bearing tables hold user data (nicks, services credentials,
181
+ * channel state, membership) and MUST survive a mistaken `cdk destroy`,
182
+ * a stack-name collision, or a stray `aws dynamodb delete-table` from a
183
+ * compromised credential. `Connections` is the one exception: its rows
184
+ * are ephemeral per-connection records (TTL-reaped, sweeper-cleaned), so
185
+ * it stays DESTROY with no PITR and no deletion protection.
186
+ */
187
+ const STATE_TABLES = ['ChannelMembers', 'ChannelMeta', 'Nicks', 'Services'] as const;
188
+ const EPHEMERAL_TABLE = 'Connections';
189
+
190
+ /**
191
+ * Returns the synthesised `AWS::DynamoDB::Table` resources keyed by their
192
+ * physical `TableName`. Each value is the raw CloudFormation resource
193
+ * node, so `DeletionPolicy`, `UpdateReplacePolicy`, and the protection
194
+ * properties under `Properties` are all inspectable. `DeletionPolicy` lives
195
+ * at the resource root (sibling of `Properties`), so it is NOT visible to
196
+ * `hasResourceProperties` — the raw node is required.
197
+ */
198
+ function dynamoTablesByName(template: Template): Record<
199
+ string,
200
+ {
201
+ Type: string;
202
+ DeletionPolicy?: string;
203
+ UpdateReplacePolicy?: string;
204
+ Properties?: {
205
+ TableName?: string;
206
+ PointInTimeRecoverySpecification?: { PointInTimeRecoveryEnabled?: boolean };
207
+ DeletionProtectionEnabled?: boolean;
208
+ };
209
+ }
210
+ > {
211
+ const tmpl = template.toJSON() as { Resources?: Record<string, unknown> };
212
+ const out: Record<string, DynamoTableResource> = {};
213
+ for (const raw of Object.values(tmpl.Resources ?? {})) {
214
+ const res = raw as { Type?: string; Properties?: { TableName?: string } };
215
+ if (res.Type !== 'AWS::DynamoDB::Table') {
216
+ continue;
217
+ }
218
+ const name = res.Properties?.TableName;
219
+ if (typeof name === 'string') {
220
+ out[name] = raw as DynamoTableResource;
221
+ }
222
+ }
223
+ return out;
224
+ }
225
+
226
+ describe('default synth (no flags)', () => {
227
+ it.each(STATE_TABLES)('RETAINs the %s table (DeletionPolicy=Retain)', (name) => {
228
+ const { template } = makeTemplate();
229
+ expect(dynamoTablesByName(template)[name]?.DeletionPolicy).toBe('Retain');
230
+ });
231
+
232
+ it.each(STATE_TABLES)('sets UpdateReplacePolicy=Retain on the %s table', (name) => {
233
+ const { template } = makeTemplate();
234
+ expect(dynamoTablesByName(template)[name]?.UpdateReplacePolicy).toBe('Retain');
235
+ });
236
+
237
+ it('keeps the Connections table DESTROY (DeletionPolicy=Delete)', () => {
238
+ const { template } = makeTemplate();
239
+ expect(dynamoTablesByName(template)[EPHEMERAL_TABLE]?.DeletionPolicy).toBe('Delete');
240
+ });
241
+
242
+ it.each(STATE_TABLES)('enables point-in-time recovery on the %s table', (name) => {
243
+ const { template } = makeTemplate();
244
+ expect(
245
+ dynamoTablesByName(template)[name]?.Properties?.PointInTimeRecoverySpecification
246
+ ?.PointInTimeRecoveryEnabled,
247
+ ).toBe(true);
248
+ });
249
+
250
+ it('does NOT enable point-in-time recovery on the Connections table', () => {
251
+ const { template } = makeTemplate();
252
+ expect(
253
+ dynamoTablesByName(template)[EPHEMERAL_TABLE]?.Properties?.PointInTimeRecoverySpecification,
254
+ ).toBeUndefined();
255
+ });
256
+
257
+ it.each(STATE_TABLES)('enables deletion protection on the %s table', (name) => {
258
+ const { template } = makeTemplate();
259
+ expect(dynamoTablesByName(template)[name]?.Properties?.DeletionProtectionEnabled).toBe(true);
260
+ });
261
+
262
+ it('does NOT enable deletion protection on the Connections table', () => {
263
+ const { template } = makeTemplate();
264
+ expect(
265
+ dynamoTablesByName(template)[EPHEMERAL_TABLE]?.Properties?.DeletionProtectionEnabled,
266
+ ).toBeUndefined();
267
+ });
268
+ });
269
+
270
+ describe('allowTableDeletion=true synth (teardown mode)', () => {
271
+ function makeTeardownTemplate(): Template {
272
+ const app = new App();
273
+ const stack = new IrcAwsStack(app, 'TestStack', { allowTableDeletion: true });
274
+ return Template.fromStack(stack);
275
+ }
276
+
277
+ it.each(STATE_TABLES)('flips the %s table to DESTROY (DeletionPolicy=Delete)', (name) => {
278
+ const template = makeTeardownTemplate();
279
+ expect(dynamoTablesByName(template)[name]?.DeletionPolicy).toBe('Delete');
280
+ });
281
+
282
+ it.each(STATE_TABLES)('disables deletion protection on the %s table', (name) => {
283
+ const template = makeTeardownTemplate();
284
+ expect(dynamoTablesByName(template)[name]?.Properties?.DeletionProtectionEnabled).toBe(false);
285
+ });
286
+
287
+ it('keeps the Connections table DESTROY', () => {
288
+ const template = makeTeardownTemplate();
289
+ expect(dynamoTablesByName(template)[EPHEMERAL_TABLE]?.DeletionPolicy).toBe('Delete');
290
+ });
291
+
292
+ it('still keeps point-in-time recovery enabled on state tables in teardown mode', () => {
293
+ // allowTableDeletion and enablePitr are independent flags: a legitimate
294
+ // teardown still keeps PITR on (it is harmless and the data is still
295
+ // recoverable up until the table is actually deleted). Disabling PITR
296
+ // is a separate, deliberate opt-in via enablePitr=false.
297
+ const template = makeTeardownTemplate();
298
+ for (const name of STATE_TABLES) {
299
+ expect(
300
+ dynamoTablesByName(template)[name]?.Properties?.PointInTimeRecoverySpecification
301
+ ?.PointInTimeRecoveryEnabled,
302
+ ).toBe(true);
303
+ }
304
+ });
305
+ });
306
+
307
+ describe('enablePitr=false synth (PITR disabled)', () => {
308
+ function makeNoPitrTemplate(): Template {
309
+ const app = new App();
310
+ const stack = new IrcAwsStack(app, 'TestStack', { enablePitr: false });
311
+ return Template.fromStack(stack);
312
+ }
313
+
314
+ it.each(STATE_TABLES)('disables point-in-time recovery on the %s table', (name) => {
315
+ const template = makeNoPitrTemplate();
316
+ expect(
317
+ dynamoTablesByName(template)[name]?.Properties?.PointInTimeRecoverySpecification
318
+ ?.PointInTimeRecoveryEnabled,
319
+ ).toBe(false);
320
+ });
321
+
322
+ it('still does NOT enable point-in-time recovery on the Connections table', () => {
323
+ const template = makeNoPitrTemplate();
324
+ expect(
325
+ dynamoTablesByName(template)[EPHEMERAL_TABLE]?.Properties?.PointInTimeRecoverySpecification,
326
+ ).toBeUndefined();
327
+ });
328
+
329
+ it('keeps state tables RETAIN with deletion protection when PITR is disabled', () => {
330
+ // enablePitr=false narrows ONLY the PITR knob; the RETAIN +
331
+ // deletion-protection guarantees are independent of PITR and must
332
+ // remain on so a disabled-PITR deploy is still protected from
333
+ // accidental stack deletion.
334
+ const template = makeNoPitrTemplate();
335
+ for (const name of STATE_TABLES) {
336
+ expect(dynamoTablesByName(template)[name]?.DeletionPolicy).toBe('Retain');
337
+ expect(dynamoTablesByName(template)[name]?.Properties?.DeletionProtectionEnabled).toBe(
338
+ true,
339
+ );
340
+ }
341
+ });
342
+ });
343
+ });
344
+
179
345
  describe('IrcAwsStack — API Gateway v2 WebSocket API', () => {
180
346
  it('creates a WebSocket protocol API Gateway v2 API', () => {
181
347
  const { template } = makeTemplate();
@@ -287,12 +453,20 @@ describe('IrcAwsStack — API Gateway logging', () => {
287
453
  ]);
288
454
  });
289
455
 
290
- it('wires the stage execution logging level to the parameter (Ref) with data tracing on', () => {
456
+ it('wires the stage execution logging level to the parameter (Ref) with full-frame body tracing OFF by default', () => {
457
+ // Regression guard: `dataTraceEnabled` MUST default to `false`. For a
458
+ // WebSocket IRC endpoint, APIGW execution-log body tracing writes the
459
+ // full payload of every frame — including `PASS <password>`,
460
+ // `AUTHENTICATE <base64-SASL-PLAIN>`, `JOIN #chan <channel-key>`, and
461
+ // every `PRIVMSG`/`NOTICE` — to the APIGW CloudWatch log group. Anyone
462
+ // with `logs:GetLogEvents` recovers IRC credentials verbatim. The
463
+ // default is therefore safe-by-default; re-enabling requires an
464
+ // explicit, double-opt-in escape hatch (see the guard tests below).
291
465
  const { template } = makeTemplate();
292
466
  template.hasResourceProperties('AWS::ApiGatewayV2::Stage', {
293
467
  DefaultRouteSettings: {
294
468
  LoggingLevel: { Ref: 'ApiGatewayLoggingLevel' },
295
- DataTraceEnabled: true,
469
+ DataTraceEnabled: false,
296
470
  },
297
471
  });
298
472
  });
@@ -361,15 +535,12 @@ describe('IrcAwsStack — API Gateway logging', () => {
361
535
  ).toBe(true);
362
536
  });
363
537
 
364
- it('grants the logging role CloudWatch Logs write access across ALL log groups', () => {
365
- // APIGW writes execution logs to its OWN AWS/ApiGateway... log groups
366
- // (separate from this stack's access-log group), so the role must be
367
- // able to write broadly — a single-log-group ARN scope is rejected by
368
- // APIGW account-role validation. Defined inline (the
369
- // AmazonAPIGatewayPushToCloudWatchLogs managed policy is not available
370
- // in every partition); CDK renders the statement as a separate
371
- // AWS::IAM::Policy attached to the role.
372
- const { template } = makeTemplate();
538
+ /**
539
+ * Flattens the IAM actions granted by the inline policy CDK attaches to
540
+ * the `ApiGatewayLoggingRole` (`addToPolicy` renders as a separate
541
+ * `AWS::IAM::Policy` resource referencing the role).
542
+ */
543
+ function loggingRoleActions(template: Template): string[] {
373
544
  const roles = template.findResources('AWS::IAM::Role') as Record<string, unknown>;
374
545
  const loggingRoleEntry = Object.entries(roles).find(([id]) =>
375
546
  id.startsWith('ApiGatewayLoggingRole'),
@@ -394,12 +565,102 @@ describe('IrcAwsStack — API Gateway logging', () => {
394
565
  const stmts = attached?.Properties?.PolicyDocument?.Statement ?? [];
395
566
  const flat = (a: string | string[] | undefined): string[] =>
396
567
  Array.isArray(a) ? a : a !== undefined ? [a] : [];
397
- const actions = JSON.stringify(stmts.flatMap((s) => flat(s.Action)));
398
- const resources = JSON.stringify(stmts.flatMap((s) => flat(s.Resource as string[])));
399
- expect(actions).toContain('logs:PutLogEvents');
400
- expect(actions).toContain('logs:CreateLogGroup');
401
- // Broad scope NOT narrowed to a single log-group ARN.
402
- expect(resources).toContain('*');
568
+ return stmts.flatMap((s) => flat(s.Action));
569
+ }
570
+
571
+ it('grants the logging role exactly the three CloudWatch Logs WRITE actions across ALL log groups', () => {
572
+ // APIGW writes execution logs to its OWN AWS/ApiGateway... log groups
573
+ // (separate from this stack's access-log group), so the role must be
574
+ // able to write broadly — a single-log-group ARN scope is rejected by
575
+ // APIGW account-role validation. The grant set is EXACTLY the three
576
+ // write actions APIGW needs; the legacy inline policy additionally
577
+ // carried Describe* actions, which let a compromised role enumerate
578
+ // (and, via the managed policy AWS offers for this purpose, read)
579
+ // arbitrary log groups in the account. Exact-set assertion so any
580
+ // future widening fails this test.
581
+ const { template } = makeTemplate();
582
+ expect([...loggingRoleActions(template)].sort()).toEqual([
583
+ 'logs:CreateLogGroup',
584
+ 'logs:CreateLogStream',
585
+ 'logs:PutLogEvents',
586
+ ]);
587
+ });
588
+
589
+ it('does not grant the logging role any log-enumeration or log-read actions', () => {
590
+ const { template } = makeTemplate();
591
+ const actions = loggingRoleActions(template);
592
+ expect(
593
+ actions.filter((a) => /^logs:(Describe|Get|Filter)/.test(a)),
594
+ 'logging role must not enumerate or read log groups',
595
+ ).toEqual([]);
596
+ });
597
+
598
+ it('attaches no AWS-managed policies to the logging role (documented inline decision)', () => {
599
+ // Decision record: the AmazonAPIGatewayPushToCloudWatchLogs managed
600
+ // policy was considered and REJECTED. Its fixed grant set is broader
601
+ // than the tightened inline policy — it still grants DescribeLogGroups
602
+ // and DescribeLogStreams (the actions this stack deliberately drops)
603
+ // plus GetLogEvents and FilterLogEvents, all on Resource '*' — and AWS
604
+ // can widen its contents without this stack being re-reviewed. The
605
+ // inline policy keeps the grant set exact and auditable at synth time.
606
+ const { template } = makeTemplate();
607
+ const roles = template.findResources('AWS::IAM::Role') as Record<
608
+ string,
609
+ { Properties?: { ManagedPolicyArns?: unknown } }
610
+ >;
611
+ const loggingRole = Object.entries(roles).find(([id]) =>
612
+ id.startsWith('ApiGatewayLoggingRole'),
613
+ );
614
+ expect(loggingRole, 'expected an ApiGatewayLoggingRole').toBeDefined();
615
+ expect(loggingRole?.[1]?.Properties?.ManagedPolicyArns).toBeUndefined();
616
+ });
617
+ });
618
+
619
+ describe('IrcAwsStack — `dataTraceEnabled` credential-leak guard', () => {
620
+ /**
621
+ * Since the staging/prod split was collapsed (every synth is effectively a
622
+ * prod synth), `dataTraceEnabled` defaults to `false` and the ONLY way to
623
+ * re-enable it is the explicit `allowDataTrace` + `iUnderstandThisLeaksCredentials`
624
+ * double opt-in. Enabling without the confirmation REFUSES synthesis with a
625
+ * loud error naming the IRC credentials it would leak.
626
+ */
627
+ it('refuses synth when allowDataTrace=true is set WITHOUT the leak acknowledgement', () => {
628
+ const app = new App();
629
+ expect(() => new IrcAwsStack(app, 'TestStack', { allowDataTrace: true })).toThrow(
630
+ /DataTraceEnabled|dataTraceEnabled|leak|credential/i,
631
+ );
632
+ });
633
+
634
+ it('still refuses synth when allowDataTrace=true is set but the acknowledgement is explicitly false', () => {
635
+ // Belt-and-braces: an explicit `false` confirmation is identical to
636
+ // omitting it — the guard must not be bypassable by passing the prop.
637
+ const app = new App();
638
+ expect(
639
+ () =>
640
+ new IrcAwsStack(app, 'TestStack', {
641
+ allowDataTrace: true,
642
+ iUnderstandThisLeaksCredentials: false,
643
+ }),
644
+ ).toThrow(/DataTraceEnabled|dataTraceEnabled|leak|credential/i);
645
+ });
646
+
647
+ it('permits dataTraceEnabled ONLY when allowDataTrace AND the leak acknowledgement are BOTH true', () => {
648
+ // The documented dev escape hatch: a sandbox operator who has read the
649
+ // warning can force-enable full-frame tracing by supplying the
650
+ // two-step opt-in. This is the ONLY path that produces
651
+ // DataTraceEnabled=true, and it is deliberately awkward to set.
652
+ const app = new App();
653
+ const stack = new IrcAwsStack(app, 'TestStack', {
654
+ allowDataTrace: true,
655
+ iUnderstandThisLeaksCredentials: true,
656
+ });
657
+ const template = Template.fromStack(stack);
658
+ template.hasResourceProperties('AWS::ApiGatewayV2::Stage', {
659
+ DefaultRouteSettings: {
660
+ LoggingLevel: { Ref: 'ApiGatewayLoggingLevel' },
661
+ DataTraceEnabled: true,
662
+ },
663
+ });
403
664
  });
404
665
  });
405
666
 
@@ -438,7 +699,7 @@ describe('IrcAwsStack — gone-connection sweeper', () => {
438
699
  // Both Lambda roles must scope their DynamoDB actions to the concrete
439
700
  // table ARNs — the least-privilege suite below also asserts no '*'.
440
701
  const tables = template.findResources('AWS::DynamoDB::Table');
441
- expect(Object.keys(tables)).toHaveLength(6);
702
+ expect(Object.keys(tables)).toHaveLength(5);
442
703
  });
443
704
  });
444
705
 
@@ -637,73 +898,44 @@ describe('IrcAwsStack — least-privilege IAM', () => {
637
898
  });
638
899
  });
639
900
 
640
- describe('IrcAwsStack — environmentName prop (env isolation)', () => {
641
- it('prefixes the Connections table name with the production environment', () => {
642
- const { template } = makeTemplate('production');
643
- template.hasResourceProperties('AWS::DynamoDB::Table', {
644
- TableName: 'ProductionConnections',
645
- });
646
- });
647
-
648
- it('prefixes the Connections table name with the staging environment', () => {
649
- const { template } = makeTemplate('staging');
901
+ describe('IrcAwsStack — single deploy target (no env prefixing)', () => {
902
+ it('uses the bare logical id as the Connections table name (no env prefix)', () => {
903
+ const { template } = makeTemplate();
650
904
  template.hasResourceProperties('AWS::DynamoDB::Table', {
651
- TableName: 'StagingConnections',
905
+ TableName: 'Connections',
652
906
  });
653
907
  });
654
908
 
655
- it('defaults to the staging prefix when the prop is omitted', () => {
909
+ it('uses the bare logical id as the Services table name (no env prefix)', () => {
656
910
  const { template } = makeTemplate();
657
911
  template.hasResourceProperties('AWS::DynamoDB::Table', {
658
- TableName: prefixedTableName(DEFAULT_ENV, 'Connections'),
912
+ TableName: 'Services',
659
913
  });
660
914
  });
661
915
 
662
- it('injects the prefixed physical table name into every Lambda env var', () => {
663
- const { template } = makeTemplate('production');
916
+ it('injects the bare logical table name into every Lambda env var', () => {
917
+ const { template } = makeTemplate();
664
918
  const fns = template.findResources('AWS::Lambda::Function') as Record<
665
919
  string,
666
920
  { Properties?: { Environment?: { Variables?: Record<string, string> } } }
667
921
  >;
668
- // Every Lambda must carry CONNECTIONS_TABLE pointing at the production-prefixed name.
669
- const withPrefixed = Object.values(fns).filter(
670
- (fn) => fn.Properties?.Environment?.Variables?.CONNECTIONS_TABLE === 'ProductionConnections',
671
- );
672
- expect(withPrefixed).toHaveLength(3);
673
- });
674
-
675
- it('produces non-colliding table names for staging vs production', () => {
676
- const stagingTables = Object.keys(
677
- makeTemplate('staging').template.findResources('AWS::DynamoDB::Table'),
922
+ // Every Lambda must carry CONNECTIONS_TABLE pointing at the bare name.
923
+ const withBare = Object.values(fns).filter(
924
+ (fn) => fn.Properties?.Environment?.Variables?.CONNECTIONS_TABLE === 'Connections',
678
925
  );
679
- const prodTables = Object.keys(
680
- makeTemplate('production').template.findResources('AWS::DynamoDB::Table'),
681
- );
682
- const stagingNames = new Set(
683
- Object.values(
684
- makeTemplate('staging').template.findResources('AWS::DynamoDB::Table') as Record<
685
- string,
686
- { Properties: { TableName: string } }
687
- >,
688
- ).map((t) => t.Properties.TableName),
689
- );
690
- const prodNames = Object.values(
691
- makeTemplate('production').template.findResources('AWS::DynamoDB::Table') as Record<
692
- string,
693
- { Properties: { TableName: string } }
694
- >,
695
- ).map((t) => t.Properties.TableName);
696
- expect(stagingTables).toHaveLength(6);
697
- expect(prodTables).toHaveLength(6);
698
- expect(prodNames.every((n) => !stagingNames.has(n))).toBe(true);
926
+ expect(withBare).toHaveLength(3);
699
927
  });
700
928
 
701
- it('includes the environment suffix in the CloudFormation stack name', () => {
702
- const app = new App();
703
- const stack = new IrcAwsStack(app, 'IrcAwsStack-production', {
704
- environmentName: 'production',
705
- });
706
- expect(stack.stackName).toBe('IrcAwsStack-production');
929
+ it('produces a fixed set of bare table names (no Staging* / Production* prefixes)', () => {
930
+ const { template } = makeTemplate();
931
+ const tables = template.findResources('AWS::DynamoDB::Table') as Record<
932
+ string,
933
+ { Properties: { TableName: string } }
934
+ >;
935
+ const names = Object.values(tables).map((t) => t.Properties.TableName);
936
+ expect(names).toHaveLength(5);
937
+ // No name may carry a capitalized environment prefix.
938
+ expect(names.every((n) => !/^(Staging|Production)/.test(n))).toBe(true);
707
939
  });
708
940
  });
709
941
 
@@ -853,6 +1085,82 @@ describe('IrcAwsStack — server identity CloudFormation parameters', () => {
853
1085
  });
854
1086
  });
855
1087
 
1088
+ describe('IrcAwsStack — `$connect` Origin allowlist (CSWSH defence)', () => {
1089
+ /** Finds a Lambda function resource by its logical-id prefix (e.g. `IrcHandler`). */
1090
+ function findFunction(
1091
+ template: Template,
1092
+ idPrefix: string,
1093
+ ): {
1094
+ Properties?: { Environment?: { Variables?: Record<string, unknown> } };
1095
+ } {
1096
+ const fns = template.findResources('AWS::Lambda::Function') as Record<
1097
+ string,
1098
+ { Properties?: { Environment?: { Variables?: Record<string, unknown> } } }
1099
+ >;
1100
+ const found = Object.entries(fns).find(([id]) => id.startsWith(idPrefix));
1101
+ if (!found) {
1102
+ throw new Error(`No Lambda with logical id starting with "${idPrefix}"`);
1103
+ }
1104
+ return found[1];
1105
+ }
1106
+
1107
+ it('does NOT inject WEB_ORIGINS on the handler when the prop is omitted (defence opt-in)', () => {
1108
+ // The CSWSH defence is opt-in: existing AWS deployments without a
1109
+ // web frontend must be unchanged on upgrade, so the env var is
1110
+ // omitted entirely (not set to '') when the prop is absent. The
1111
+ // handler's parser then returns an empty set and the `$connect`
1112
+ // Origin check is skipped.
1113
+ const app = new App();
1114
+ const stack = new IrcAwsStack(app, 'TestStack');
1115
+ const template = Template.fromStack(stack);
1116
+ expect(
1117
+ findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.WEB_ORIGINS,
1118
+ ).toBeUndefined();
1119
+ });
1120
+
1121
+ it('does NOT inject WEB_ORIGINS when the prop is an empty string', () => {
1122
+ const app = new App();
1123
+ const stack = new IrcAwsStack(app, 'TestStack', { webOrigins: '' });
1124
+ const template = Template.fromStack(stack);
1125
+ expect(
1126
+ findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.WEB_ORIGINS,
1127
+ ).toBeUndefined();
1128
+ });
1129
+
1130
+ it('injects WEB_ORIGINS on the wss handler when the prop is set', () => {
1131
+ const app = new App();
1132
+ const stack = new IrcAwsStack(app, 'TestStack', {
1133
+ webOrigins: 'https://app.example.com, https://staging.app.example.com',
1134
+ });
1135
+ const template = Template.fromStack(stack);
1136
+ expect(
1137
+ findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.WEB_ORIGINS,
1138
+ ).toBe('https://app.example.com, https://staging.app.example.com');
1139
+ });
1140
+
1141
+ it('does NOT inject WEB_ORIGINS on the sweeper (never runs `$connect`)', () => {
1142
+ const app = new App();
1143
+ const stack = new IrcAwsStack(app, 'TestStack', {
1144
+ webOrigins: 'https://app.example.com',
1145
+ });
1146
+ const template = Template.fromStack(stack);
1147
+ expect(
1148
+ findFunction(template, 'IrcSweeper').Properties?.Environment?.Variables?.WEB_ORIGINS,
1149
+ ).toBeUndefined();
1150
+ });
1151
+
1152
+ it('does NOT inject WEB_ORIGINS on the ping checker (never runs `$connect`)', () => {
1153
+ const app = new App();
1154
+ const stack = new IrcAwsStack(app, 'TestStack', {
1155
+ webOrigins: 'https://app.example.com',
1156
+ });
1157
+ const template = Template.fromStack(stack);
1158
+ expect(
1159
+ findFunction(template, 'IrcPingChecker').Properties?.Environment?.Variables?.WEB_ORIGINS,
1160
+ ).toBeUndefined();
1161
+ });
1162
+ });
1163
+
856
1164
  describe('IrcAwsStack — TCP+TLS NLB adapter', () => {
857
1165
  /** Makes a template WITH the tcpTlsDomainName prop (enables the NLB path). */
858
1166
  function makeTcpTemplate(domainName = 'irc.example.com'): {
@@ -862,7 +1170,6 @@ describe('IrcAwsStack — TCP+TLS NLB adapter', () => {
862
1170
  } {
863
1171
  const app = new App();
864
1172
  const stack = new IrcAwsStack(app, 'TestStack', {
865
- environmentName: 'staging',
866
1173
  tcpTlsDomainName: domainName,
867
1174
  });
868
1175
  const template = Template.fromStack(stack);
@@ -933,6 +1240,101 @@ describe('IrcAwsStack — TCP+TLS NLB adapter', () => {
933
1240
  });
934
1241
  });
935
1242
 
1243
+ /**
1244
+ * Collects every IAM policy statement attached to the NLB handler
1245
+ * Lambda's execution role — the role's inline `Policies` plus every
1246
+ * separate `AWS::IAM::Policy` resource referencing it (CDK renders
1247
+ * `grant*` calls as the role's `DefaultPolicy` resource).
1248
+ */
1249
+ function nlbRoleStatements(template: Template): Array<Record<string, unknown>> {
1250
+ const fns = template.findResources('AWS::Lambda::Function') as Record<
1251
+ string,
1252
+ { Properties?: { Role?: { 'Fn::GetAtt'?: string[] } | { Ref?: string } } }
1253
+ >;
1254
+ const nlbFn = Object.entries(fns).find(([id]) => id.startsWith('IrcNlbHandler'));
1255
+ if (nlbFn === undefined) {
1256
+ throw new Error('No Lambda with logical id starting with "IrcNlbHandler"');
1257
+ }
1258
+ const role = nlbFn[1]?.Properties?.Role as
1259
+ | { 'Fn::GetAtt'?: string[]; Ref?: string }
1260
+ | undefined;
1261
+ const getAtt = role?.['Fn::GetAtt'];
1262
+ const roleLogicalId = Array.isArray(getAtt) ? getAtt[0] : role?.Ref;
1263
+ if (typeof roleLogicalId !== 'string') {
1264
+ throw new Error('IrcNlbHandler Lambda has no resolvable execution role');
1265
+ }
1266
+ const statements: Array<Record<string, unknown>> = [];
1267
+ const roles = template.findResources('AWS::IAM::Role') as Record<
1268
+ string,
1269
+ { Properties?: { Policies?: Array<{ PolicyDocument?: { Statement?: unknown[] } }> } }
1270
+ >;
1271
+ for (const inline of roles[roleLogicalId]?.Properties?.Policies ?? []) {
1272
+ statements.push(
1273
+ ...((inline.PolicyDocument?.Statement ?? []) as Array<Record<string, unknown>>),
1274
+ );
1275
+ }
1276
+ const policies = template.findResources('AWS::IAM::Policy') as Record<
1277
+ string,
1278
+ {
1279
+ Properties?: {
1280
+ Roles?: Array<{ Ref?: string }>;
1281
+ PolicyDocument?: { Statement?: unknown[] };
1282
+ };
1283
+ }
1284
+ >;
1285
+ for (const policy of Object.values(policies)) {
1286
+ const attached = (policy.Properties?.Roles ?? []).some((r) => r.Ref === roleLogicalId);
1287
+ if (attached) {
1288
+ statements.push(
1289
+ ...((policy.Properties?.PolicyDocument?.Statement ?? []) as Array<
1290
+ Record<string, unknown>
1291
+ >),
1292
+ );
1293
+ }
1294
+ }
1295
+ return statements;
1296
+ }
1297
+
1298
+ /** True when a statement's Action includes `execute-api:ManageConnections`. */
1299
+ function managesConnections(stmt: Record<string, unknown>): boolean {
1300
+ const action = stmt.Action;
1301
+ return (
1302
+ action === 'execute-api:ManageConnections' ||
1303
+ (Array.isArray(action) && action.includes('execute-api:ManageConnections'))
1304
+ );
1305
+ }
1306
+
1307
+ it('grants the NLB handler Lambda execute-api:ManageConnections on the stage', () => {
1308
+ // The NLB stream handler passes MANAGEMENT_URL into AwsRuntime and
1309
+ // fans channel messages out to *other* connections via
1310
+ // ApiGatewayManagementApi.postToConnection — including wss clients
1311
+ // on the API Gateway transport. Without the stage grant on the NLB
1312
+ // handler's own execution role, every such cross-transport delivery
1313
+ // fails with 403.
1314
+ const { template } = makeTcpTemplate();
1315
+ const manages = nlbRoleStatements(template).filter(managesConnections);
1316
+ expect(
1317
+ manages.length,
1318
+ 'NLB handler role must be granted execute-api:ManageConnections',
1319
+ ).toBeGreaterThan(0);
1320
+ });
1321
+
1322
+ it('scopes the NLB handler ManageConnections grant to this stack API stage connections', () => {
1323
+ // Least privilege: the grant must target THIS stack's WebSocket API
1324
+ // stage connections resource (…/apis/<api>/prod/*/@connections/*),
1325
+ // never a wildcard API or account-wide resource.
1326
+ const { template } = makeTcpTemplate();
1327
+ const apiIds = Object.keys(template.findResources('AWS::ApiGatewayV2::Api')).filter((id) =>
1328
+ id.startsWith('IrcWsApi'),
1329
+ );
1330
+ expect(apiIds).toHaveLength(1);
1331
+ const manages = nlbRoleStatements(template).filter(managesConnections);
1332
+ const resourceJson = JSON.stringify(manages.map((s) => s.Resource));
1333
+ expect(resourceJson).toContain('@connections');
1334
+ expect(resourceJson).toContain('/prod/');
1335
+ expect(resourceJson).toContain(apiIds[0] ?? '');
1336
+ });
1337
+
936
1338
  it('still creates the wss WebSocket API (both transports coexist)', () => {
937
1339
  const { template } = makeTcpTemplate();
938
1340
  template.hasResourceProperties('AWS::ApiGatewayV2::Api', {
@@ -947,3 +1349,210 @@ describe('IrcAwsStack — TCP+TLS NLB adapter', () => {
947
1349
  });
948
1350
  });
949
1351
  });
1352
+
1353
+ describe('IrcAwsStack — web client hosting (StaticSite)', () => {
1354
+ /** Makes a template WITH the webSite prop (enables the StaticSite path). */
1355
+ function makeWebTemplate(webSite: StaticSiteProps = {}): {
1356
+ app: App;
1357
+ stack: Stack;
1358
+ template: Template;
1359
+ } {
1360
+ const app = new App();
1361
+ const stack = new IrcAwsStack(app, 'TestStack', { webSite });
1362
+ const template = Template.fromStack(stack);
1363
+ return { app, stack, template };
1364
+ }
1365
+
1366
+ describe('when webSite is omitted (default)', () => {
1367
+ it('creates no S3 origin bucket for the web client', () => {
1368
+ const { template } = makeTemplate();
1369
+ template.resourceCountIs('AWS::S3::Bucket', 0);
1370
+ });
1371
+
1372
+ it('creates no CloudFront distribution', () => {
1373
+ const { template } = makeTemplate();
1374
+ template.resourceCountIs('AWS::CloudFront::Distribution', 0);
1375
+ });
1376
+
1377
+ it('creates no Origin Access Control', () => {
1378
+ const { template } = makeTemplate();
1379
+ template.resourceCountIs('AWS::CloudFront::OriginAccessControl', 0);
1380
+ });
1381
+ });
1382
+
1383
+ describe('when webSite is provided', () => {
1384
+ it('creates an S3 origin bucket', () => {
1385
+ const { template } = makeWebTemplate();
1386
+ template.resourceCountIs('AWS::S3::Bucket', 1);
1387
+ });
1388
+
1389
+ it('creates a CloudFront distribution with an S3 origin + OAC', () => {
1390
+ const { template } = makeWebTemplate();
1391
+ template.resourceCountIs('AWS::CloudFront::Distribution', 1);
1392
+ template.resourceCountIs('AWS::CloudFront::OriginAccessControl', 1);
1393
+ });
1394
+
1395
+ it('still creates the wss WebSocket API (both paths coexist)', () => {
1396
+ const { template } = makeWebTemplate();
1397
+ template.hasResourceProperties('AWS::ApiGatewayV2::Api', {
1398
+ ProtocolType: 'WEBSOCKET',
1399
+ });
1400
+ });
1401
+
1402
+ it('emits a WebsiteURL output with a stable logical id naming the distribution domain', () => {
1403
+ const { template } = makeWebTemplate();
1404
+ const outputs = (template.toJSON().Outputs ?? {}) as Record<string, { Value?: unknown }>;
1405
+ expect(outputs.WebsiteURL, 'expected a WebsiteURL output with a stable key').toBeDefined();
1406
+ const distIds = Object.keys(template.findResources('AWS::CloudFront::Distribution'));
1407
+ expect(distIds).toHaveLength(1);
1408
+ const getAtt = (outputs.WebsiteURL?.Value as { 'Fn::GetAtt'?: unknown[] })?.['Fn::GetAtt'];
1409
+ expect(getAtt, 'expected WebsiteURL.Value to Fn::GetAtt the distribution DomainName').toEqual(
1410
+ [distIds[0], 'DomainName'],
1411
+ );
1412
+ });
1413
+
1414
+ // The AWS web deploy pipeline (`scripts/deploy-web-aws.mjs`) reads these
1415
+ // outputs to drive `s3 sync <bucket>` and `cloudfront create-invalidation
1416
+ // --distribution-id <id>`. They MUST be exposed at the parent-stack level
1417
+ // with stable logical IDs (no CDK hashing, since the CfnOutput constructs
1418
+ // live at the stack root alongside `ConnectUrl` / `ManagementUrl`) — the
1419
+ // script queries them by exact key via `describe-stacks --query`.
1420
+ it('emits a WebsiteBucketName output Ref-ing the S3 origin bucket', () => {
1421
+ const { template } = makeWebTemplate();
1422
+ const outputs = (template.toJSON().Outputs ?? {}) as Record<string, { Value?: unknown }>;
1423
+ const bucketIds = Object.keys(template.findResources('AWS::S3::Bucket'));
1424
+ expect(bucketIds).toHaveLength(1);
1425
+ expect(outputs.WebsiteBucketName, 'expected a WebsiteBucketName output').toBeDefined();
1426
+ const ref = (outputs.WebsiteBucketName?.Value as { Ref?: unknown })?.Ref;
1427
+ expect(ref, 'expected WebsiteBucketName.Value to be a { Ref: <bucket-logical-id> }').toBe(
1428
+ bucketIds[0],
1429
+ );
1430
+ });
1431
+
1432
+ it('emits a WebsiteDistributionId output Ref-ing the CloudFront distribution', () => {
1433
+ const { template } = makeWebTemplate();
1434
+ const outputs = (template.toJSON().Outputs ?? {}) as Record<string, { Value?: unknown }>;
1435
+ const distIds = Object.keys(template.findResources('AWS::CloudFront::Distribution'));
1436
+ expect(distIds).toHaveLength(1);
1437
+ expect(
1438
+ outputs.WebsiteDistributionId,
1439
+ 'expected a WebsiteDistributionId output',
1440
+ ).toBeDefined();
1441
+ const ref = (outputs.WebsiteDistributionId?.Value as { Ref?: unknown })?.Ref;
1442
+ expect(ref, 'expected WebsiteDistributionId.Value to be a { Ref: <dist-logical-id> }').toBe(
1443
+ distIds[0],
1444
+ );
1445
+ });
1446
+ });
1447
+ });
1448
+
1449
+ describe('IrcAwsStack — API Gateway stage throttling', () => {
1450
+ it('exposes ApiThrottlingRateLimit as a configurable Number parameter (default 100)', () => {
1451
+ const { template } = makeTemplate();
1452
+ const p = parameters(template).ApiThrottlingRateLimit;
1453
+ expect(p).toBeDefined();
1454
+ expect(p?.Type).toBe('Number');
1455
+ // CfnParameter renders a numeric default as a JSON number.
1456
+ expect(Number(p?.Default)).toBe(100);
1457
+ expect(typeof p?.Description).toBe('string');
1458
+ expect(p?.Description?.length).toBeGreaterThan(0);
1459
+ });
1460
+
1461
+ it('exposes ApiThrottlingBurstLimit as a configurable Number parameter (default 200)', () => {
1462
+ const { template } = makeTemplate();
1463
+ const p = parameters(template).ApiThrottlingBurstLimit;
1464
+ expect(p).toBeDefined();
1465
+ expect(p?.Type).toBe('Number');
1466
+ expect(Number(p?.Default)).toBe(200);
1467
+ expect(typeof p?.Description).toBe('string');
1468
+ });
1469
+
1470
+ it('wires the stage default-route throttling to the parameters (Ref)', () => {
1471
+ // Stage-level throttling is the global backstop behind the per-IP
1472
+ // admission gates: it bounds aggregate request rate into the API
1473
+ // (connect floods included) before a Lambda invocation is billed.
1474
+ const { template } = makeTemplate();
1475
+ template.hasResourceProperties('AWS::ApiGatewayV2::Stage', {
1476
+ DefaultRouteSettings: {
1477
+ ThrottlingRateLimit: { Ref: 'ApiThrottlingRateLimit' },
1478
+ ThrottlingBurstLimit: { Ref: 'ApiThrottlingBurstLimit' },
1479
+ },
1480
+ });
1481
+ });
1482
+ });
1483
+
1484
+ describe('IrcAwsStack — Connections sourceIp GSI', () => {
1485
+ it('adds a sourceIp+connectedSince GSI to the Connections table', () => {
1486
+ // The per-IP admission gates ($connect simultaneous cap + rate
1487
+ // window, NLB new-flow rate) consult this index; without it the
1488
+ // handler would need a full-table Scan per connect.
1489
+ const { template } = makeTemplate();
1490
+ template.hasResourceProperties('AWS::DynamoDB::Table', {
1491
+ TableName: 'Connections',
1492
+ GlobalSecondaryIndexes: [
1493
+ {
1494
+ IndexName: 'sourceIp-connectedSince',
1495
+ KeySchema: [
1496
+ { AttributeName: 'sourceIp', KeyType: 'HASH' },
1497
+ { AttributeName: 'connectedSince', KeyType: 'RANGE' },
1498
+ ],
1499
+ },
1500
+ ],
1501
+ });
1502
+ });
1503
+ });
1504
+
1505
+ describe('IrcAwsStack — WAF rate-based rule on $connect', () => {
1506
+ /** Synths a stack with the optional edge rate limit configured. */
1507
+ function makeWafTemplate(limit: number): Template {
1508
+ const app = new App();
1509
+ const stack = new IrcAwsStack(app, 'TestStack', { wafConnectRateLimit: limit });
1510
+ return Template.fromStack(stack);
1511
+ }
1512
+
1513
+ it('emits no WebACL by default (the knob is opt-in)', () => {
1514
+ const { template } = makeTemplate();
1515
+ template.resourceCountIs('AWS::WAFv2::WebACL', 0);
1516
+ template.resourceCountIs('AWS::WAFv2::WebACLAssociation', 0);
1517
+ });
1518
+
1519
+ it('emits a REGIONAL WebACL with a per-IP rate-based rule when the knob is set', () => {
1520
+ // WAF on a WebSocket API only inspects the initial HTTP upgrade —
1521
+ // the `$connect` route — so a plain rate-based statement is
1522
+ // effectively scoped to connection establishment.
1523
+ const template = makeWafTemplate(2000);
1524
+ template.resourceCountIs('AWS::WAFv2::WebACL', 1);
1525
+ template.hasResourceProperties('AWS::WAFv2::WebACL', {
1526
+ Scope: 'REGIONAL',
1527
+ DefaultAction: { Allow: {} },
1528
+ Rules: [
1529
+ {
1530
+ Name: 'ConnectRateLimit',
1531
+ Priority: 0,
1532
+ Action: { Block: {} },
1533
+ Statement: {
1534
+ RateBasedStatement: { Limit: 2000, AggregateKeyType: 'IP' },
1535
+ },
1536
+ },
1537
+ ],
1538
+ });
1539
+ });
1540
+
1541
+ it('associates the WebACL with the WebSocket stage', () => {
1542
+ const template = makeWafTemplate(2000);
1543
+ template.resourceCountIs('AWS::WAFv2::WebACLAssociation', 1);
1544
+ const assoc = template.findResources('AWS::WAFv2::WebACLAssociation') as Record<
1545
+ string,
1546
+ { Properties?: { ResourceArn?: unknown; WebACLArn?: unknown } }
1547
+ >;
1548
+ const entry = Object.values(assoc)[0];
1549
+ expect(entry?.Properties?.WebACLArn).toBeDefined();
1550
+ // ResourceArn must be the API Gateway stage ARN (Fn::Join over the
1551
+ // partition/region refs + the WebSocket Api Ref + the stage name),
1552
+ // not a dangling literal.
1553
+ const arn = JSON.stringify(entry?.Properties?.ResourceArn);
1554
+ expect(arn).toContain('apigateway');
1555
+ expect(arn).toContain('/stages/prod');
1556
+ expect(arn).toContain('IrcWsApi');
1557
+ });
1558
+ });