serverless-ircd 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +11 -9
  4. package/.github/workflows/deploy-cf.yml +14 -14
  5. package/CHANGELOG.md +550 -0
  6. package/README.md +275 -222
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +82 -9
  9. package/apps/aws-stack/cdk.json +0 -3
  10. package/apps/aws-stack/package.json +3 -4
  11. package/apps/aws-stack/src/aws-stack.ts +177 -52
  12. package/apps/aws-stack/src/static-site.ts +323 -0
  13. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  14. package/apps/aws-stack/tests/stack.test.ts +267 -92
  15. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  16. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  17. package/apps/cf-tcp-container/package.json +2 -3
  18. package/apps/cf-tcp-container/src/container-server.ts +33 -10
  19. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  20. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  21. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  22. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  23. package/apps/cf-tcp-container/wrangler.toml +1 -10
  24. package/apps/cf-worker/package.json +3 -4
  25. package/apps/cf-worker/wrangler.toml +12 -71
  26. package/apps/local-cli/package.json +1 -1
  27. package/apps/local-cli/src/server.ts +115 -48
  28. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  29. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  30. package/apps/local-cli/tests/rehash.test.ts +147 -0
  31. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  32. package/apps/local-cli/tests/tcp.test.ts +89 -0
  33. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  34. package/apps/web/landing/favicon.ico +0 -0
  35. package/apps/web/landing/index.html +227 -3
  36. package/apps/web/package.json +3 -2
  37. package/apps/web/scripts/build.mjs +91 -6
  38. package/apps/web/src/build-env.ts +125 -4
  39. package/apps/web/src/config-schema.ts +20 -6
  40. package/apps/web/src/render-docs.ts +292 -0
  41. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  42. package/apps/web/tests/build-env.test.ts +210 -9
  43. package/apps/web/tests/build-smoke.test.ts +33 -4
  44. package/apps/web/tests/config-schema.test.ts +149 -25
  45. package/apps/web/tests/landing-content.test.ts +103 -0
  46. package/apps/web/tests/render-docs.test.ts +198 -0
  47. package/docs/AWS-Adapter-Architecture.md +3 -2
  48. package/docs/AWS-Deployment.md +670 -96
  49. package/docs/AWS-TCP-Deployment.md +20 -45
  50. package/docs/Cloudflare-Deployment-Guide.md +87 -113
  51. package/docs/Cloudflare-TCP-Deployment.md +25 -49
  52. package/docs/Release-Process.md +27 -23
  53. package/docs/Services.md +102 -23
  54. package/docs/WebClientGuide.md +35 -26
  55. package/package.json +7 -10
  56. package/packages/aws-adapter/package.json +1 -1
  57. package/packages/aws-adapter/src/aws-runtime.ts +15 -1
  58. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
  59. package/packages/aws-adapter/src/config-loader.ts +19 -2
  60. package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +26 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +190 -123
  63. package/packages/aws-adapter/src/handlers/index.ts +67 -23
  64. package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
  65. package/packages/aws-adapter/src/index.ts +5 -7
  66. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  67. package/packages/aws-adapter/src/serialize.ts +15 -0
  68. package/packages/aws-adapter/src/tables.ts +2 -12
  69. package/packages/aws-adapter/tests/aws-harness.ts +0 -1
  70. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  71. package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
  72. package/packages/aws-adapter/tests/connect.test.ts +124 -1
  73. package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
  74. package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
  75. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
  76. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  77. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  78. package/packages/aws-adapter/tests/handlers.test.ts +117 -11
  79. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  80. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  81. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  82. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  83. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  84. package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
  85. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  86. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  87. package/packages/cf-adapter/package.json +1 -5
  88. package/packages/cf-adapter/src/cf-runtime.ts +59 -8
  89. package/packages/cf-adapter/src/channel-do.ts +13 -3
  90. package/packages/cf-adapter/src/connection-do.ts +284 -115
  91. package/packages/cf-adapter/src/d1-services-store.ts +63 -26
  92. package/packages/cf-adapter/src/env.ts +11 -10
  93. package/packages/cf-adapter/src/index.ts +0 -6
  94. package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
  95. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  96. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  97. package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
  98. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  99. package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
  100. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  101. package/packages/in-memory-runtime/package.json +1 -1
  102. package/packages/irc-core/package.json +1 -1
  103. package/packages/irc-core/src/account-migration.ts +140 -0
  104. package/packages/irc-core/src/commands/account-auth.ts +60 -35
  105. package/packages/irc-core/src/commands/chanserv.ts +288 -4
  106. package/packages/irc-core/src/commands/hostserv.ts +38 -3
  107. package/packages/irc-core/src/commands/index.ts +1 -0
  108. package/packages/irc-core/src/commands/join.ts +41 -35
  109. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  110. package/packages/irc-core/src/commands/nickserv.ts +138 -15
  111. package/packages/irc-core/src/commands/registration.ts +28 -17
  112. package/packages/irc-core/src/commands/sasl.ts +22 -31
  113. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  114. package/packages/irc-core/src/commands/topic.ts +23 -10
  115. package/packages/irc-core/src/config.ts +35 -9
  116. package/packages/irc-core/src/credential-hashing.ts +11 -54
  117. package/packages/irc-core/src/index.ts +1 -0
  118. package/packages/irc-core/src/ports.ts +159 -179
  119. package/packages/irc-core/src/state/channel.ts +17 -0
  120. package/packages/irc-core/src/types.ts +38 -10
  121. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  122. package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
  123. package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
  124. package/packages/irc-core/tests/commands/join.test.ts +179 -0
  125. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  126. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  127. package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
  128. package/packages/irc-core/tests/commands/oper.test.ts +15 -0
  129. package/packages/irc-core/tests/commands/registration.test.ts +336 -108
  130. package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
  131. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  132. package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
  133. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  134. package/packages/irc-core/tests/message-store.test.ts +5 -0
  135. package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
  136. package/packages/irc-core/tests/ports.test.ts +71 -0
  137. package/packages/irc-core/tests/services-store.test.ts +204 -0
  138. package/packages/irc-core/vitest.config.ts +6 -1
  139. package/packages/irc-server/package.json +1 -1
  140. package/packages/irc-server/src/actor.ts +80 -44
  141. package/packages/irc-server/tests/actor.test.ts +384 -50
  142. package/packages/irc-test-support/package.json +1 -1
  143. package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
  144. package/packages/irc-test-support/src/scenarios.ts +21 -6
  145. package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
  146. package/packages/irc-test-support/vitest.config.ts +6 -1
  147. package/pnpm-workspace.yaml +1 -0
  148. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  149. package/scripts/deploy-web-aws.mjs +290 -0
  150. package/scripts/package.json +23 -0
  151. package/scripts/tsconfig.test.json +12 -0
  152. package/scripts/vitest.config.ts +19 -0
  153. package/tools/ci-hardening/package.json +1 -1
  154. package/tools/ci-hardening/src/index.ts +2 -0
  155. package/tools/ci-hardening/src/validate.ts +57 -0
  156. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  157. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  158. package/tools/load-test/package.json +1 -1
  159. package/tools/load-test/src/client.ts +13 -13
  160. package/tools/load-test/tests/client.test.ts +258 -2
  161. package/tools/load-test/tests/config.test.ts +39 -0
  162. package/tools/load-test/tests/harness.test.ts +21 -0
  163. package/tools/load-test/tests/metrics.test.ts +7 -0
  164. package/tools/migrate-accounts-to-services.ts +270 -0
  165. package/tools/package.json +2 -1
  166. package/tools/seed-aws-accounts.ts +35 -10
  167. package/tools/seed-cf-accounts.ts +42 -9
  168. package/tools/tcp-ws-forwarder/package.json +1 -1
  169. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  170. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  171. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
  172. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  173. package/packages/aws-adapter/src/account-store.ts +0 -121
  174. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  175. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  176. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  177. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  178. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  179. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -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,14 @@ 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
+ const TABLE_NAMES = ['ChannelMembers', 'ChannelMeta', 'Connections', 'Nicks', 'Services'] as const;
24
25
 
25
- /** Default environment name assumed when the prop is omitted. */
26
- const DEFAULT_ENV = 'staging';
27
-
28
- /**
29
- * Capitalises the first character of an environment name so it composes
30
- * cleanly with the PascalCase logical table ids (`staging` → `Staging`).
31
- */
32
- function prefixedTableName(environmentName: string, logicalId: string): string {
33
- return `${environmentName.charAt(0).toUpperCase()}${environmentName.slice(1)}${logicalId}`;
34
- }
35
-
36
- function makeTemplate(environmentName?: string): { app: App; stack: Stack; template: Template } {
26
+ function makeTemplate(): { app: App; stack: Stack; template: Template } {
37
27
  const app = new App();
38
- const stack = new IrcAwsStack(
39
- app,
40
- 'TestStack',
41
- environmentName !== undefined ? { environmentName } : undefined,
42
- );
28
+ const stack = new IrcAwsStack(app, 'TestStack');
43
29
  const template = Template.fromStack(stack);
44
30
  return { app, stack, template };
45
31
  }
@@ -79,15 +65,15 @@ function findRuleTargetingLambda(
79
65
  }
80
66
 
81
67
  describe('IrcAwsStack — DynamoDB tables', () => {
82
- it('creates exactly six DynamoDB tables', () => {
68
+ it('creates exactly five DynamoDB tables', () => {
83
69
  const { template } = makeTemplate();
84
- template.resourceCountIs('AWS::DynamoDB::Table', 6);
70
+ template.resourceCountIs('AWS::DynamoDB::Table', 5);
85
71
  });
86
72
 
87
73
  it.each(TABLE_NAMES)('creates the %s table', (tableName) => {
88
74
  const { template } = makeTemplate();
89
75
  template.hasResourceProperties('AWS::DynamoDB::Table', {
90
- TableName: prefixedTableName(DEFAULT_ENV, tableName),
76
+ TableName: tableName,
91
77
  });
92
78
  });
93
79
 
@@ -101,7 +87,7 @@ describe('IrcAwsStack — DynamoDB tables', () => {
101
87
  it('enables TTL on the Connections table via the idleSince attribute', () => {
102
88
  const { template } = makeTemplate();
103
89
  template.hasResourceProperties('AWS::DynamoDB::Table', {
104
- TableName: prefixedTableName(DEFAULT_ENV, 'Connections'),
90
+ TableName: 'Connections',
105
91
  TimeToLiveSpecification: {
106
92
  AttributeName: 'idleSince',
107
93
  Enabled: true,
@@ -112,7 +98,7 @@ describe('IrcAwsStack — DynamoDB tables', () => {
112
98
  it('keys the Connections table by connectionId (String)', () => {
113
99
  const { template } = makeTemplate();
114
100
  template.hasResourceProperties('AWS::DynamoDB::Table', {
115
- TableName: prefixedTableName(DEFAULT_ENV, 'Connections'),
101
+ TableName: 'Connections',
116
102
  AttributeDefinitions: [{ AttributeName: 'connectionId', AttributeType: 'S' }],
117
103
  KeySchema: [{ AttributeName: 'connectionId', KeyType: 'HASH' }],
118
104
  });
@@ -121,7 +107,7 @@ describe('IrcAwsStack — DynamoDB tables', () => {
121
107
  it('keys the ChannelMembers table by channelName (PK) + connectionId (SK)', () => {
122
108
  const { template } = makeTemplate();
123
109
  template.hasResourceProperties('AWS::DynamoDB::Table', {
124
- TableName: prefixedTableName(DEFAULT_ENV, 'ChannelMembers'),
110
+ TableName: 'ChannelMembers',
125
111
  AttributeDefinitions: [
126
112
  { AttributeName: 'channelName', AttributeType: 'S' },
127
113
  { AttributeName: 'connectionId', AttributeType: 'S' },
@@ -136,7 +122,7 @@ describe('IrcAwsStack — DynamoDB tables', () => {
136
122
  it('keys the ChannelMeta table by channelName (String)', () => {
137
123
  const { template } = makeTemplate();
138
124
  template.hasResourceProperties('AWS::DynamoDB::Table', {
139
- TableName: prefixedTableName(DEFAULT_ENV, 'ChannelMeta'),
125
+ TableName: 'ChannelMeta',
140
126
  AttributeDefinitions: [{ AttributeName: 'channelName', AttributeType: 'S' }],
141
127
  KeySchema: [{ AttributeName: 'channelName', KeyType: 'HASH' }],
142
128
  });
@@ -145,25 +131,16 @@ describe('IrcAwsStack — DynamoDB tables', () => {
145
131
  it('keys the Nicks table by nickLower (String)', () => {
146
132
  const { template } = makeTemplate();
147
133
  template.hasResourceProperties('AWS::DynamoDB::Table', {
148
- TableName: prefixedTableName(DEFAULT_ENV, 'Nicks'),
134
+ TableName: 'Nicks',
149
135
  AttributeDefinitions: [{ AttributeName: 'nickLower', AttributeType: 'S' }],
150
136
  KeySchema: [{ AttributeName: 'nickLower', KeyType: 'HASH' }],
151
137
  });
152
138
  });
153
139
 
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
140
  it('keys the Services table by composite pk/sk (String/String)', () => {
164
141
  const { template } = makeTemplate();
165
142
  template.hasResourceProperties('AWS::DynamoDB::Table', {
166
- TableName: prefixedTableName(DEFAULT_ENV, 'Services'),
143
+ TableName: 'Services',
167
144
  AttributeDefinitions: [
168
145
  { AttributeName: 'pk', AttributeType: 'S' },
169
146
  { AttributeName: 'sk', AttributeType: 'S' },
@@ -287,12 +264,20 @@ describe('IrcAwsStack — API Gateway logging', () => {
287
264
  ]);
288
265
  });
289
266
 
290
- it('wires the stage execution logging level to the parameter (Ref) with data tracing on', () => {
267
+ it('wires the stage execution logging level to the parameter (Ref) with full-frame body tracing OFF by default', () => {
268
+ // Regression guard: `dataTraceEnabled` MUST default to `false`. For a
269
+ // WebSocket IRC endpoint, APIGW execution-log body tracing writes the
270
+ // full payload of every frame — including `PASS <password>`,
271
+ // `AUTHENTICATE <base64-SASL-PLAIN>`, `JOIN #chan <channel-key>`, and
272
+ // every `PRIVMSG`/`NOTICE` — to the APIGW CloudWatch log group. Anyone
273
+ // with `logs:GetLogEvents` recovers IRC credentials verbatim. The
274
+ // default is therefore safe-by-default; re-enabling requires an
275
+ // explicit, double-opt-in escape hatch (see the guard tests below).
291
276
  const { template } = makeTemplate();
292
277
  template.hasResourceProperties('AWS::ApiGatewayV2::Stage', {
293
278
  DefaultRouteSettings: {
294
279
  LoggingLevel: { Ref: 'ApiGatewayLoggingLevel' },
295
- DataTraceEnabled: true,
280
+ DataTraceEnabled: false,
296
281
  },
297
282
  });
298
283
  });
@@ -403,6 +388,54 @@ describe('IrcAwsStack — API Gateway logging', () => {
403
388
  });
404
389
  });
405
390
 
391
+ describe('IrcAwsStack — `dataTraceEnabled` credential-leak guard', () => {
392
+ /**
393
+ * Since the staging/prod split was collapsed (every synth is effectively a
394
+ * prod synth), `dataTraceEnabled` defaults to `false` and the ONLY way to
395
+ * re-enable it is the explicit `allowDataTrace` + `iUnderstandThisLeaksCredentials`
396
+ * double opt-in. Enabling without the confirmation REFUSES synthesis with a
397
+ * loud error naming the IRC credentials it would leak.
398
+ */
399
+ it('refuses synth when allowDataTrace=true is set WITHOUT the leak acknowledgement', () => {
400
+ const app = new App();
401
+ expect(() => new IrcAwsStack(app, 'TestStack', { allowDataTrace: true })).toThrow(
402
+ /DataTraceEnabled|dataTraceEnabled|leak|credential/i,
403
+ );
404
+ });
405
+
406
+ it('still refuses synth when allowDataTrace=true is set but the acknowledgement is explicitly false', () => {
407
+ // Belt-and-braces: an explicit `false` confirmation is identical to
408
+ // omitting it — the guard must not be bypassable by passing the prop.
409
+ const app = new App();
410
+ expect(
411
+ () =>
412
+ new IrcAwsStack(app, 'TestStack', {
413
+ allowDataTrace: true,
414
+ iUnderstandThisLeaksCredentials: false,
415
+ }),
416
+ ).toThrow(/DataTraceEnabled|dataTraceEnabled|leak|credential/i);
417
+ });
418
+
419
+ it('permits dataTraceEnabled ONLY when allowDataTrace AND the leak acknowledgement are BOTH true', () => {
420
+ // The documented dev escape hatch: a sandbox operator who has read the
421
+ // warning can force-enable full-frame tracing by supplying the
422
+ // two-step opt-in. This is the ONLY path that produces
423
+ // DataTraceEnabled=true, and it is deliberately awkward to set.
424
+ const app = new App();
425
+ const stack = new IrcAwsStack(app, 'TestStack', {
426
+ allowDataTrace: true,
427
+ iUnderstandThisLeaksCredentials: true,
428
+ });
429
+ const template = Template.fromStack(stack);
430
+ template.hasResourceProperties('AWS::ApiGatewayV2::Stage', {
431
+ DefaultRouteSettings: {
432
+ LoggingLevel: { Ref: 'ApiGatewayLoggingLevel' },
433
+ DataTraceEnabled: true,
434
+ },
435
+ });
436
+ });
437
+ });
438
+
406
439
  describe('IrcAwsStack — Lambda', () => {
407
440
  it('creates exactly three Lambda functions (handler + sweeper + pingChecker)', () => {
408
441
  const { template } = makeTemplate();
@@ -438,7 +471,7 @@ describe('IrcAwsStack — gone-connection sweeper', () => {
438
471
  // Both Lambda roles must scope their DynamoDB actions to the concrete
439
472
  // table ARNs — the least-privilege suite below also asserts no '*'.
440
473
  const tables = template.findResources('AWS::DynamoDB::Table');
441
- expect(Object.keys(tables)).toHaveLength(6);
474
+ expect(Object.keys(tables)).toHaveLength(5);
442
475
  });
443
476
  });
444
477
 
@@ -637,73 +670,44 @@ describe('IrcAwsStack — least-privilege IAM', () => {
637
670
  });
638
671
  });
639
672
 
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');
673
+ describe('IrcAwsStack — single deploy target (no env prefixing)', () => {
674
+ it('uses the bare logical id as the Connections table name (no env prefix)', () => {
675
+ const { template } = makeTemplate();
650
676
  template.hasResourceProperties('AWS::DynamoDB::Table', {
651
- TableName: 'StagingConnections',
677
+ TableName: 'Connections',
652
678
  });
653
679
  });
654
680
 
655
- it('defaults to the staging prefix when the prop is omitted', () => {
681
+ it('uses the bare logical id as the Services table name (no env prefix)', () => {
656
682
  const { template } = makeTemplate();
657
683
  template.hasResourceProperties('AWS::DynamoDB::Table', {
658
- TableName: prefixedTableName(DEFAULT_ENV, 'Connections'),
684
+ TableName: 'Services',
659
685
  });
660
686
  });
661
687
 
662
- it('injects the prefixed physical table name into every Lambda env var', () => {
663
- const { template } = makeTemplate('production');
688
+ it('injects the bare logical table name into every Lambda env var', () => {
689
+ const { template } = makeTemplate();
664
690
  const fns = template.findResources('AWS::Lambda::Function') as Record<
665
691
  string,
666
692
  { Properties?: { Environment?: { Variables?: Record<string, string> } } }
667
693
  >;
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',
694
+ // Every Lambda must carry CONNECTIONS_TABLE pointing at the bare name.
695
+ const withBare = Object.values(fns).filter(
696
+ (fn) => fn.Properties?.Environment?.Variables?.CONNECTIONS_TABLE === 'Connections',
671
697
  );
672
- expect(withPrefixed).toHaveLength(3);
698
+ expect(withBare).toHaveLength(3);
673
699
  });
674
700
 
675
- it('produces non-colliding table names for staging vs production', () => {
676
- const stagingTables = Object.keys(
677
- makeTemplate('staging').template.findResources('AWS::DynamoDB::Table'),
678
- );
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);
699
- });
700
-
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');
701
+ it('produces a fixed set of bare table names (no Staging* / Production* prefixes)', () => {
702
+ const { template } = makeTemplate();
703
+ const tables = template.findResources('AWS::DynamoDB::Table') as Record<
704
+ string,
705
+ { Properties: { TableName: string } }
706
+ >;
707
+ const names = Object.values(tables).map((t) => t.Properties.TableName);
708
+ expect(names).toHaveLength(5);
709
+ // No name may carry a capitalized environment prefix.
710
+ expect(names.every((n) => !/^(Staging|Production)/.test(n))).toBe(true);
707
711
  });
708
712
  });
709
713
 
@@ -853,6 +857,82 @@ describe('IrcAwsStack — server identity CloudFormation parameters', () => {
853
857
  });
854
858
  });
855
859
 
860
+ describe('IrcAwsStack — `$connect` Origin allowlist (CSWSH defence)', () => {
861
+ /** Finds a Lambda function resource by its logical-id prefix (e.g. `IrcHandler`). */
862
+ function findFunction(
863
+ template: Template,
864
+ idPrefix: string,
865
+ ): {
866
+ Properties?: { Environment?: { Variables?: Record<string, unknown> } };
867
+ } {
868
+ const fns = template.findResources('AWS::Lambda::Function') as Record<
869
+ string,
870
+ { Properties?: { Environment?: { Variables?: Record<string, unknown> } } }
871
+ >;
872
+ const found = Object.entries(fns).find(([id]) => id.startsWith(idPrefix));
873
+ if (!found) {
874
+ throw new Error(`No Lambda with logical id starting with "${idPrefix}"`);
875
+ }
876
+ return found[1];
877
+ }
878
+
879
+ it('does NOT inject WEB_ORIGINS on the handler when the prop is omitted (defence opt-in)', () => {
880
+ // The CSWSH defence is opt-in: existing AWS deployments without a
881
+ // web frontend must be unchanged on upgrade, so the env var is
882
+ // omitted entirely (not set to '') when the prop is absent. The
883
+ // handler's parser then returns an empty set and the `$connect`
884
+ // Origin check is skipped.
885
+ const app = new App();
886
+ const stack = new IrcAwsStack(app, 'TestStack');
887
+ const template = Template.fromStack(stack);
888
+ expect(
889
+ findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.WEB_ORIGINS,
890
+ ).toBeUndefined();
891
+ });
892
+
893
+ it('does NOT inject WEB_ORIGINS when the prop is an empty string', () => {
894
+ const app = new App();
895
+ const stack = new IrcAwsStack(app, 'TestStack', { webOrigins: '' });
896
+ const template = Template.fromStack(stack);
897
+ expect(
898
+ findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.WEB_ORIGINS,
899
+ ).toBeUndefined();
900
+ });
901
+
902
+ it('injects WEB_ORIGINS on the wss handler when the prop is set', () => {
903
+ const app = new App();
904
+ const stack = new IrcAwsStack(app, 'TestStack', {
905
+ webOrigins: 'https://app.example.com, https://staging.app.example.com',
906
+ });
907
+ const template = Template.fromStack(stack);
908
+ expect(
909
+ findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.WEB_ORIGINS,
910
+ ).toBe('https://app.example.com, https://staging.app.example.com');
911
+ });
912
+
913
+ it('does NOT inject WEB_ORIGINS on the sweeper (never runs `$connect`)', () => {
914
+ const app = new App();
915
+ const stack = new IrcAwsStack(app, 'TestStack', {
916
+ webOrigins: 'https://app.example.com',
917
+ });
918
+ const template = Template.fromStack(stack);
919
+ expect(
920
+ findFunction(template, 'IrcSweeper').Properties?.Environment?.Variables?.WEB_ORIGINS,
921
+ ).toBeUndefined();
922
+ });
923
+
924
+ it('does NOT inject WEB_ORIGINS on the ping checker (never runs `$connect`)', () => {
925
+ const app = new App();
926
+ const stack = new IrcAwsStack(app, 'TestStack', {
927
+ webOrigins: 'https://app.example.com',
928
+ });
929
+ const template = Template.fromStack(stack);
930
+ expect(
931
+ findFunction(template, 'IrcPingChecker').Properties?.Environment?.Variables?.WEB_ORIGINS,
932
+ ).toBeUndefined();
933
+ });
934
+ });
935
+
856
936
  describe('IrcAwsStack — TCP+TLS NLB adapter', () => {
857
937
  /** Makes a template WITH the tcpTlsDomainName prop (enables the NLB path). */
858
938
  function makeTcpTemplate(domainName = 'irc.example.com'): {
@@ -862,7 +942,6 @@ describe('IrcAwsStack — TCP+TLS NLB adapter', () => {
862
942
  } {
863
943
  const app = new App();
864
944
  const stack = new IrcAwsStack(app, 'TestStack', {
865
- environmentName: 'staging',
866
945
  tcpTlsDomainName: domainName,
867
946
  });
868
947
  const template = Template.fromStack(stack);
@@ -947,3 +1026,99 @@ describe('IrcAwsStack — TCP+TLS NLB adapter', () => {
947
1026
  });
948
1027
  });
949
1028
  });
1029
+
1030
+ describe('IrcAwsStack — web client hosting (StaticSite)', () => {
1031
+ /** Makes a template WITH the webSite prop (enables the StaticSite path). */
1032
+ function makeWebTemplate(webSite: StaticSiteProps = {}): {
1033
+ app: App;
1034
+ stack: Stack;
1035
+ template: Template;
1036
+ } {
1037
+ const app = new App();
1038
+ const stack = new IrcAwsStack(app, 'TestStack', { webSite });
1039
+ const template = Template.fromStack(stack);
1040
+ return { app, stack, template };
1041
+ }
1042
+
1043
+ describe('when webSite is omitted (default)', () => {
1044
+ it('creates no S3 origin bucket for the web client', () => {
1045
+ const { template } = makeTemplate();
1046
+ template.resourceCountIs('AWS::S3::Bucket', 0);
1047
+ });
1048
+
1049
+ it('creates no CloudFront distribution', () => {
1050
+ const { template } = makeTemplate();
1051
+ template.resourceCountIs('AWS::CloudFront::Distribution', 0);
1052
+ });
1053
+
1054
+ it('creates no Origin Access Control', () => {
1055
+ const { template } = makeTemplate();
1056
+ template.resourceCountIs('AWS::CloudFront::OriginAccessControl', 0);
1057
+ });
1058
+ });
1059
+
1060
+ describe('when webSite is provided', () => {
1061
+ it('creates an S3 origin bucket', () => {
1062
+ const { template } = makeWebTemplate();
1063
+ template.resourceCountIs('AWS::S3::Bucket', 1);
1064
+ });
1065
+
1066
+ it('creates a CloudFront distribution with an S3 origin + OAC', () => {
1067
+ const { template } = makeWebTemplate();
1068
+ template.resourceCountIs('AWS::CloudFront::Distribution', 1);
1069
+ template.resourceCountIs('AWS::CloudFront::OriginAccessControl', 1);
1070
+ });
1071
+
1072
+ it('still creates the wss WebSocket API (both paths coexist)', () => {
1073
+ const { template } = makeWebTemplate();
1074
+ template.hasResourceProperties('AWS::ApiGatewayV2::Api', {
1075
+ ProtocolType: 'WEBSOCKET',
1076
+ });
1077
+ });
1078
+
1079
+ it('emits a WebsiteURL output with a stable logical id naming the distribution domain', () => {
1080
+ const { template } = makeWebTemplate();
1081
+ const outputs = (template.toJSON().Outputs ?? {}) as Record<string, { Value?: unknown }>;
1082
+ expect(outputs.WebsiteURL, 'expected a WebsiteURL output with a stable key').toBeDefined();
1083
+ const distIds = Object.keys(template.findResources('AWS::CloudFront::Distribution'));
1084
+ expect(distIds).toHaveLength(1);
1085
+ const getAtt = (outputs.WebsiteURL?.Value as { 'Fn::GetAtt'?: unknown[] })?.['Fn::GetAtt'];
1086
+ expect(getAtt, 'expected WebsiteURL.Value to Fn::GetAtt the distribution DomainName').toEqual(
1087
+ [distIds[0], 'DomainName'],
1088
+ );
1089
+ });
1090
+
1091
+ // The AWS web deploy pipeline (`scripts/deploy-web-aws.mjs`) reads these
1092
+ // outputs to drive `s3 sync <bucket>` and `cloudfront create-invalidation
1093
+ // --distribution-id <id>`. They MUST be exposed at the parent-stack level
1094
+ // with stable logical IDs (no CDK hashing, since the CfnOutput constructs
1095
+ // live at the stack root alongside `ConnectUrl` / `ManagementUrl`) — the
1096
+ // script queries them by exact key via `describe-stacks --query`.
1097
+ it('emits a WebsiteBucketName output Ref-ing the S3 origin bucket', () => {
1098
+ const { template } = makeWebTemplate();
1099
+ const outputs = (template.toJSON().Outputs ?? {}) as Record<string, { Value?: unknown }>;
1100
+ const bucketIds = Object.keys(template.findResources('AWS::S3::Bucket'));
1101
+ expect(bucketIds).toHaveLength(1);
1102
+ expect(outputs.WebsiteBucketName, 'expected a WebsiteBucketName output').toBeDefined();
1103
+ const ref = (outputs.WebsiteBucketName?.Value as { Ref?: unknown })?.Ref;
1104
+ expect(ref, 'expected WebsiteBucketName.Value to be a { Ref: <bucket-logical-id> }').toBe(
1105
+ bucketIds[0],
1106
+ );
1107
+ });
1108
+
1109
+ it('emits a WebsiteDistributionId output Ref-ing the CloudFront distribution', () => {
1110
+ const { template } = makeWebTemplate();
1111
+ const outputs = (template.toJSON().Outputs ?? {}) as Record<string, { Value?: unknown }>;
1112
+ const distIds = Object.keys(template.findResources('AWS::CloudFront::Distribution'));
1113
+ expect(distIds).toHaveLength(1);
1114
+ expect(
1115
+ outputs.WebsiteDistributionId,
1116
+ 'expected a WebsiteDistributionId output',
1117
+ ).toBeDefined();
1118
+ const ref = (outputs.WebsiteDistributionId?.Value as { Ref?: unknown })?.Ref;
1119
+ expect(ref, 'expected WebsiteDistributionId.Value to be a { Ref: <dist-logical-id> }').toBe(
1120
+ distIds[0],
1121
+ );
1122
+ });
1123
+ });
1124
+ });