serverless-ircd 0.9.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 (134) hide show
  1. package/.github/workflows/deploy-aws.yml +156 -32
  2. package/.github/workflows/deploy-cf-tcp.yml +11 -9
  3. package/.github/workflows/deploy-cf.yml +14 -14
  4. package/CHANGELOG.md +305 -0
  5. package/README.md +134 -41
  6. package/apps/aws-stack/README.md +3 -5
  7. package/apps/aws-stack/bin/aws.ts +82 -9
  8. package/apps/aws-stack/cdk.json +0 -3
  9. package/apps/aws-stack/package.json +3 -4
  10. package/apps/aws-stack/src/aws-stack.ts +177 -52
  11. package/apps/aws-stack/src/static-site.ts +323 -0
  12. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  13. package/apps/aws-stack/tests/stack.test.ts +267 -92
  14. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  15. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  16. package/apps/cf-tcp-container/package.json +2 -3
  17. package/apps/cf-tcp-container/src/container-server.ts +12 -9
  18. package/apps/cf-tcp-container/wrangler.toml +1 -10
  19. package/apps/cf-worker/package.json +3 -4
  20. package/apps/cf-worker/wrangler.toml +12 -71
  21. package/apps/local-cli/package.json +1 -1
  22. package/apps/local-cli/src/server.ts +21 -17
  23. package/apps/web/landing/favicon.ico +0 -0
  24. package/apps/web/landing/index.html +1 -0
  25. package/apps/web/package.json +2 -2
  26. package/apps/web/scripts/build.mjs +66 -4
  27. package/apps/web/src/build-env.ts +125 -4
  28. package/apps/web/src/config-schema.ts +20 -6
  29. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  30. package/apps/web/tests/build-env.test.ts +210 -9
  31. package/apps/web/tests/build-smoke.test.ts +2 -2
  32. package/apps/web/tests/config-schema.test.ts +149 -25
  33. package/docs/AWS-Deployment.md +670 -96
  34. package/docs/AWS-TCP-Deployment.md +20 -45
  35. package/docs/Cloudflare-Deployment-Guide.md +87 -113
  36. package/docs/Cloudflare-TCP-Deployment.md +25 -49
  37. package/docs/Release-Process.md +27 -23
  38. package/docs/Services.md +69 -22
  39. package/docs/WebClientGuide.md +35 -26
  40. package/package.json +7 -10
  41. package/packages/aws-adapter/package.json +1 -1
  42. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
  43. package/packages/aws-adapter/src/config-loader.ts +19 -2
  44. package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
  45. package/packages/aws-adapter/src/handlers/connect.ts +26 -0
  46. package/packages/aws-adapter/src/handlers/default.ts +190 -123
  47. package/packages/aws-adapter/src/handlers/index.ts +67 -23
  48. package/packages/aws-adapter/src/handlers/nlb-stream.ts +3 -6
  49. package/packages/aws-adapter/src/index.ts +5 -7
  50. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  51. package/packages/aws-adapter/src/serialize.ts +15 -0
  52. package/packages/aws-adapter/src/tables.ts +2 -12
  53. package/packages/aws-adapter/tests/aws-harness.ts +0 -1
  54. package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
  55. package/packages/aws-adapter/tests/connect.test.ts +124 -1
  56. package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
  57. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
  58. package/packages/aws-adapter/tests/handlers.test.ts +117 -11
  59. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  60. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  61. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  62. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  63. package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
  64. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  65. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  66. package/packages/cf-adapter/package.json +1 -5
  67. package/packages/cf-adapter/src/cf-runtime.ts +59 -8
  68. package/packages/cf-adapter/src/channel-do.ts +13 -3
  69. package/packages/cf-adapter/src/connection-do.ts +266 -109
  70. package/packages/cf-adapter/src/d1-services-store.ts +63 -26
  71. package/packages/cf-adapter/src/env.ts +11 -10
  72. package/packages/cf-adapter/src/index.ts +0 -6
  73. package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
  74. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  75. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  76. package/packages/cf-adapter/tests/connection-do-pure.test.ts +95 -54
  77. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  78. package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
  79. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  80. package/packages/in-memory-runtime/package.json +1 -1
  81. package/packages/irc-core/package.json +1 -1
  82. package/packages/irc-core/src/account-migration.ts +140 -0
  83. package/packages/irc-core/src/commands/account-auth.ts +16 -19
  84. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  85. package/packages/irc-core/src/commands/nickserv.ts +122 -11
  86. package/packages/irc-core/src/commands/registration.ts +2 -2
  87. package/packages/irc-core/src/commands/sasl.ts +22 -31
  88. package/packages/irc-core/src/config.ts +35 -9
  89. package/packages/irc-core/src/credential-hashing.ts +11 -54
  90. package/packages/irc-core/src/index.ts +1 -0
  91. package/packages/irc-core/src/ports.ts +159 -179
  92. package/packages/irc-core/src/types.ts +38 -10
  93. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  94. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  95. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  96. package/packages/irc-core/tests/commands/nickserv.test.ts +237 -1
  97. package/packages/irc-core/tests/commands/oper.test.ts +15 -0
  98. package/packages/irc-core/tests/commands/registration.test.ts +140 -133
  99. package/packages/irc-core/tests/commands/sasl.test.ts +159 -178
  100. package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
  101. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  102. package/packages/irc-core/tests/message-store.test.ts +5 -0
  103. package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
  104. package/packages/irc-core/tests/ports.test.ts +71 -0
  105. package/packages/irc-core/tests/services-store.test.ts +204 -0
  106. package/packages/irc-core/vitest.config.ts +6 -1
  107. package/packages/irc-server/package.json +1 -1
  108. package/packages/irc-server/src/actor.ts +0 -14
  109. package/packages/irc-server/tests/actor.test.ts +19 -47
  110. package/packages/irc-test-support/package.json +1 -1
  111. package/pnpm-workspace.yaml +1 -0
  112. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  113. package/scripts/deploy-web-aws.mjs +290 -0
  114. package/scripts/package.json +23 -0
  115. package/scripts/tsconfig.test.json +12 -0
  116. package/scripts/vitest.config.ts +19 -0
  117. package/tools/ci-hardening/package.json +1 -1
  118. package/tools/ci-hardening/src/index.ts +2 -0
  119. package/tools/ci-hardening/src/validate.ts +57 -0
  120. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  121. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  122. package/tools/load-test/package.json +1 -1
  123. package/tools/migrate-accounts-to-services.ts +270 -0
  124. package/tools/package.json +2 -1
  125. package/tools/seed-aws-accounts.ts +35 -10
  126. package/tools/seed-cf-accounts.ts +42 -9
  127. package/tools/tcp-ws-forwarder/package.json +1 -1
  128. package/packages/aws-adapter/src/account-store.ts +0 -121
  129. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  130. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  131. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  132. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  133. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  134. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -5,11 +5,13 @@
5
5
  * CDK CLI's default account/region when those env vars are set; otherwise
6
6
  * the stack is environment-agnostic (suitable for `cdk synth` and localstack).
7
7
  *
8
- * Per-environment isolation is driven by the `environmentName` CDK context
9
- * variable (`-c environmentName=staging`). It defaults to `staging` so the
10
- * existing deploy pipeline stays green without changes, and it is folded
11
- * into the CloudFormation stack id (`IrcAwsStack-<environmentName>`) so
12
- * parallel envs coexist in one account+region.
8
+ * There is a single deploy target per platform (the staging/prod split was
9
+ * collapsed). This app synthesizes exactly one stack named `IrcAwsStack`;
10
+ * staging vs production isolation is driven by which AWS account and region
11
+ * the deploy credentials target, not by stack-name templating. Server
12
+ * identity (`serverName`, `networkName`, `motd`) and the optional TCP+TLS
13
+ * domain are read from CDK context so deploys can override them without
14
+ * editing source.
13
15
  */
14
16
 
15
17
  import { App } from 'aws-cdk-lib';
@@ -20,8 +22,6 @@ const app = new App();
20
22
 
21
23
  const account = process.env.CDK_DEFAULT_ACCOUNT;
22
24
  const region = process.env.CDK_DEFAULT_REGION;
23
- const environmentName =
24
- (app.node.tryGetContext('environmentName') as string | undefined) ?? 'staging';
25
25
 
26
26
  // Server identity is read from CDK context so deploys can override it
27
27
  // without editing source (`-c serverName=…`, `-c networkName=…`,
@@ -44,13 +44,86 @@ const motdLines = Array.isArray(rawMotd)
44
44
  // docs/AWS-TCP-Deployment.md.
45
45
  const tcpTlsDomainName = app.node.tryGetContext('tcpTlsDomainName') as string | undefined;
46
46
 
47
+ // `$connect` Origin allowlist (CSWSH defence). Opt-in: only provisioned
48
+ // when this context var is supplied as a non-empty string. Comma-separated
49
+ // origin list (e.g. `'https://app.example.com, https://staging.app.example.com'`).
50
+ // When omitted, the `$connect` handler skips the Origin check entirely
51
+ // (existing AWS deployments without a web frontend are unchanged).
52
+ const webOrigins = app.node.tryGetContext('webOrigins') as string | undefined;
53
+
54
+ // API Gateway full-frame body tracing (`DataTraceEnabled`). Safe-by-default:
55
+ // omitted → `false` (no IRC frame bodies logged). Re-enabling requires BOTH
56
+ // `-c allowDataTrace=true` AND `-c iUnderstandThisLeaksCredentials=true`; the
57
+ // stack REFUSES synth with a credential-leak error if only the first is set.
58
+ // Since the staging/prod split was collapsed every synth is effectively a
59
+ // prod synth, so there is no quiet "non-prod" path — use ONLY in a throwaway
60
+ // sandbox for live debugging. See docs/AWS-Deployment.md (§7.6).
61
+ const allowDataTrace = app.node.tryGetContext('allowDataTrace') as boolean | string | undefined;
62
+ const iUnderstandThisLeaksCredentials = app.node.tryGetContext('iUnderstandThisLeaksCredentials') as
63
+ | boolean
64
+ | string
65
+ | undefined;
66
+
67
+ // CDK context vars arrive as strings; normalise the booleans so the stack's
68
+ // guard sees real booleans (and `--c allowDataTrace=false` stays false-y).
69
+ const allowDataTraceBool = parseContextBool(allowDataTrace);
70
+ const acknowledgeLeakBool = parseContextBool(iUnderstandThisLeaksCredentials);
71
+
72
+ // Web client hosting (S3 + CloudFront + OAC) is opt-in. Supplying
73
+ // `webSiteCustomDomain` provisions the static-site construct; prod passes
74
+ // the custom domain + cert + hosted zone, staging leaves them unset to use
75
+ // the default `*.cloudfront.net` URL. See docs/AWS-Deployment.md (web section).
76
+ const webSiteCustomDomain = app.node.tryGetContext('webSiteCustomDomain') as string | undefined;
77
+ const webSiteCertificateArn = app.node.tryGetContext('webSiteCertificateArn') as string | undefined;
78
+ const webSiteHostedZoneName = app.node.tryGetContext('webSiteHostedZoneName') as string | undefined;
79
+ const webSiteHostedZoneId = app.node.tryGetContext('webSiteHostedZoneId') as string | undefined;
80
+ const webSite =
81
+ webSiteCustomDomain !== undefined
82
+ ? {
83
+ customDomain: webSiteCustomDomain,
84
+ ...(webSiteCertificateArn !== undefined ? { certificateArn: webSiteCertificateArn } : {}),
85
+ ...(webSiteHostedZoneName !== undefined ? { hostedZoneName: webSiteHostedZoneName } : {}),
86
+ ...(webSiteHostedZoneId !== undefined ? { hostedZoneId: webSiteHostedZoneId } : {}),
87
+ }
88
+ : undefined;
89
+
47
90
  const stackProps: IrcStackProps = {
48
- environmentName,
49
91
  ...(serverName !== undefined ? { serverName } : {}),
50
92
  ...(networkName !== undefined ? { networkName } : {}),
51
93
  ...(motdLines !== undefined ? { motdLines } : {}),
52
94
  ...(tcpTlsDomainName !== undefined ? { tcpTlsDomainName } : {}),
95
+ ...(webOrigins !== undefined && webOrigins.length > 0 ? { webOrigins } : {}),
96
+ ...(webSite !== undefined ? { webSite } : {}),
97
+ ...(allowDataTraceBool !== undefined ? { allowDataTrace: allowDataTraceBool } : {}),
98
+ ...(acknowledgeLeakBool !== undefined
99
+ ? { iUnderstandThisLeaksCredentials: acknowledgeLeakBool }
100
+ : {}),
53
101
  ...(account && region ? { env: { account, region } } : {}),
54
102
  };
55
103
 
56
- new IrcAwsStack(app, `IrcAwsStack-${environmentName}`, stackProps);
104
+ new IrcAwsStack(app, 'IrcAwsStack', stackProps);
105
+
106
+ /**
107
+ * Normalises a CDK context value (`-c key=value`) into a boolean.
108
+ * CDK passes context values as strings, so `'true'` / `'false'` are
109
+ * parsed; anything else (including `undefined`) returns `undefined` so
110
+ * the downstream optional prop is simply omitted. The data-trace guard
111
+ * in `IrcAwsStack` is strict-equality-checked against `true`, so a stray
112
+ * string here would otherwise defeat the opt-in.
113
+ */
114
+ function parseContextBool(value: boolean | string | undefined): boolean | undefined {
115
+ if (value === undefined) {
116
+ return undefined;
117
+ }
118
+ if (typeof value === 'boolean') {
119
+ return value;
120
+ }
121
+ const normalised = value.trim().toLowerCase();
122
+ if (normalised === 'true') {
123
+ return true;
124
+ }
125
+ if (normalised === 'false') {
126
+ return false;
127
+ }
128
+ return undefined;
129
+ }
@@ -1,8 +1,5 @@
1
1
  {
2
2
  "app": "tsx bin/aws.ts",
3
- "context": {
4
- "environmentName": "staging"
5
- },
6
3
  "watch": {
7
4
  "include": ["src/**", "bin/**"],
8
5
  "exclude": ["tests/**", "dist/**", "node_modules/**"]
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/aws-stack",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "private": true,
5
5
  "description": "AWS CDK v2 stack: API Gateway v2 WebSocket API, Lambda, DynamoDB tables, least-privilege IAM",
6
6
  "license": "BSD-3-Clause",
@@ -14,9 +14,8 @@
14
14
  "clean": "rimraf dist coverage .tsbuildinfo .turbo cdk.out",
15
15
  "cdk": "cdk",
16
16
  "cdk:synth": "cdk synth",
17
- "deploy:staging": "cdk deploy --all --require-approval never",
18
- "deploy:prod": "cdk deploy --all --require-approval never",
19
- "smoke:staging": "node scripts/smoke.mjs"
17
+ "deploy": "cdk deploy --all --require-approval never",
18
+ "smoke": "node scripts/smoke.mjs"
20
19
  },
21
20
  "devDependencies": {
22
21
  "aws-cdk": "^2.262.0",
@@ -2,7 +2,7 @@
2
2
  * `apps/aws-stack` CDK stack — the AWS substrate for ServerlessIRCd (PLAN §6.2).
3
3
  *
4
4
  * • 5 DynamoDB tables sourced from `@serverless-ircd/aws-adapter/cdk-table-defs`
5
- * (Connections, ChannelMeta, ChannelMembers, Nicks, Accounts).
5
+ * (Connections, ChannelMeta, ChannelMembers, Nicks, Services).
6
6
  * • ONE Node 24 `NodejsFunction` bundling the AWS Lambda handler
7
7
  * (`$connect` / `$disconnect` / `$default` dispatched on
8
8
  * `event.requestContext.routeKey`).
@@ -51,6 +51,7 @@ import { Code, Function as Lambda, Runtime } from 'aws-cdk-lib/aws-lambda';
51
51
  import { NodejsFunction, type NodejsFunctionProps } from 'aws-cdk-lib/aws-lambda-nodejs';
52
52
  import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs';
53
53
  import type { Construct } from 'constructs';
54
+ import { StaticSite, type StaticSiteProps } from './static-site.js';
54
55
 
55
56
  /** Stage name served by the auto-deploying WebSocket stage. */
56
57
  const STAGE_NAME = 'prod';
@@ -68,13 +69,6 @@ const SWEEPER_SCHEDULE_RATE = Duration.minutes(5);
68
69
  */
69
70
  const PING_CHECKER_SCHEDULE_RATE = Duration.minutes(1);
70
71
 
71
- /**
72
- * Environment assumed when {@link IrcStackProps.environmentName} is
73
- * omitted. Keeps the existing deploy pipeline green without requiring
74
- * callers to pass the flag.
75
- */
76
- const DEFAULT_ENVIRONMENT_NAME = 'staging';
77
-
78
72
  /**
79
73
  * Fallback server identity, used when the corresponding prop is omitted.
80
74
  * Deliberately not `irc.example.com` (the retired placeholder) so a
@@ -109,21 +103,10 @@ const TEST_NO_BUNDLE_FLAG = 'IRC_AWS_STACK_TEST_NO_BUNDLE';
109
103
 
110
104
  /**
111
105
  * Construct props for {@link IrcAwsStack}. Acts as the single
112
- * configuration bag extended by deploy-time knobs (environment name,
113
- * server identity, …) so the prop surface grows in one place.
106
+ * configuration bag extended by deploy-time knobs (server identity, …)
107
+ * so the prop surface grows in one place.
114
108
  */
115
109
  export interface IrcStackProps extends StackProps {
116
- /**
117
- * Logical deployment environment (e.g. `staging`, `production`).
118
- *
119
- * Prefixes every physical DynamoDB table name
120
- * (`<Env><LogicalId>` — first letter capitalised, e.g.
121
- * `StagingConnections`) and is intended to be folded into the
122
- * CloudFormation stack id by the caller
123
- * (`IrcAwsStack-<environmentName>`). Defaults to `staging`.
124
- */
125
- readonly environmentName?: string;
126
-
127
110
  /**
128
111
  * Server name advertised to clients (`SERVER_NAME` / the `001`
129
112
  * welcome source). Falls back to `irc.localhost` when omitted; production
@@ -158,13 +141,89 @@ export interface IrcStackProps extends StackProps {
158
141
  * (WebSocket) transport is deployed.
159
142
  */
160
143
  readonly tcpTlsDomainName?: string;
144
+
145
+ /**
146
+ * Web client hosting substrate (S3 + CloudFront + OAC).
147
+ *
148
+ * When provided, the stack provisions a {@link StaticSite} that serves
149
+ * the shared `apps/web/dist` build artifact over CloudFront. The S3
150
+ * origin bucket is private and grants the distribution via Origin
151
+ * Access Control (never public-read); SPA client-side routing under
152
+ * `/app/*` is handled by a CloudFront custom error response mapping
153
+ * 403/404 → `200 /app/index.html`. Pure infrastructure — the per-env
154
+ * config bake and the s3-sync deploy pipeline are separate. Pass the
155
+ * custom-domain fields for prod; omit them for the default
156
+ * `*.cloudfront.net` URL (staging). When omitted entirely, no web
157
+ * hosting resources are provisioned.
158
+ */
159
+ readonly webSite?: StaticSiteProps;
160
+
161
+ /**
162
+ * Comma-separated allowlist of web origins permitted to open
163
+ * WebSocket upgrades (CSWSH defence — API Gateway WebSocket has no
164
+ * built-in Origin check). Mirrors the CF Worker's `WEB_ORIGINS` env.
165
+ *
166
+ * When provided (non-empty), the value is injected as the
167
+ * `WEB_ORIGINS` Lambda env var on the wss `$connect` handler. The
168
+ * `$connect` Lambda then denies any browser-sent `Origin` not in
169
+ * the list with `403`, so API Gateway closes the upgrade. Unset /
170
+ * empty (the default) disables the check — existing deployments
171
+ * without a web frontend are unchanged on upgrade. Non-browser
172
+ * clients (curl, WeeChat, the `tcp-ws-forwarder`, NLB TCP+TLS)
173
+ * never send `Origin` and pass through regardless.
174
+ *
175
+ * Example: `'https://app.example.com, https://staging.app.example.com'`
176
+ */
177
+ readonly webOrigins?: string;
178
+
179
+ /**
180
+ * Escape hatch that re-enables API Gateway full-frame body tracing
181
+ * (`DataTraceEnabled = true`) on the WebSocket stage. Defaults to
182
+ * `false` (safe: no frame bodies are logged).
183
+ *
184
+ * ⚠️ Setting this to `true` alone REFUSES synthesis — it must be
185
+ * accompanied by {@link iUnderstandThisLeaksCredentials} = `true`.
186
+ * This double opt-in is deliberate: since the staging/prod split was
187
+ * collapsed (every synth is effectively a prod synth; there is no
188
+ * `environmentName` context), there is no longer a "non-prod" branch
189
+ * to quietly enable tracing on. The only safe default is OFF, and the
190
+ * only way back on is an explicit, two-step acknowledgement.
191
+ *
192
+ * For a WebSocket IRC endpoint, body tracing writes the FULL payload
193
+ * of every frame — `PASS <password>`, `AUTHENTICATE <base64-SASL-PLAIN>`,
194
+ * `JOIN #chan <channel-key>`, and every `PRIVMSG`/`NOTICE` — to the
195
+ * APIGW execution-log CloudWatch group. Anyone with `logs:GetLogEvents`
196
+ * (operators, SREs, Logpush destinations, compromised CI creds)
197
+ * recovers IRC credentials verbatim. Use ONLY in a throwaway sandbox
198
+ * account for live debugging, and tear it down immediately after.
199
+ *
200
+ * Set via CDK context: `-c allowDataTrace=true`.
201
+ */
202
+ readonly allowDataTrace?: boolean;
203
+
204
+ /**
205
+ * Mandatory confirmation that accompanies {@link allowDataTrace} = `true`.
206
+ * Has NO effect on its own; it only unlocks the `allowDataTrace` escape
207
+ * hatch. Defaults to `false`. The awkward name is intentional: it cannot
208
+ * be set by accident, and a synth error names this prop when it is
209
+ * missing so the operator is forced to read the credential-leak warning.
210
+ *
211
+ * Set via CDK context: `-c iUnderstandThisLeaksCredentials=true`.
212
+ */
213
+ readonly iUnderstandThisLeaksCredentials?: boolean;
161
214
  }
162
215
 
163
216
  export class IrcAwsStack extends Stack {
164
217
  constructor(scope: Construct, id: string, props: IrcStackProps = {}) {
165
218
  super(scope, id, props);
166
219
 
167
- const environmentName = props.environmentName ?? DEFAULT_ENVIRONMENT_NAME;
220
+ // Fail-fast on the credential-leak escape hatch BEFORE any resource is
221
+ // constructed: if `allowDataTrace` was requested without the explicit
222
+ // `iUnderstandThisLeaksCredentials` confirmation, refuse synthesis here so
223
+ // the operator sees the credential-leak error immediately (and so the
224
+ // throw is not masked by downstream asset bundling). The resolved value
225
+ // is threaded into `defaultRouteSettings.dataTraceEnabled` below.
226
+ const dataTraceEnabled = resolveDataTraceEnabled(props);
168
227
 
169
228
  // Server identity is exposed as CloudFormation Parameters so it can be
170
229
  // overridden at deploy time (`cdk deploy --parameters ServerName=...`,
@@ -238,15 +297,16 @@ export class IrcAwsStack extends Stack {
238
297
  default: Schedule.rate(PING_CHECKER_SCHEDULE_RATE).expressionString,
239
298
  });
240
299
 
241
- // Prefix every physical table name with the environment so parallel
242
- // envs (staging + production) cannot collide in one account+region.
243
- // The first letter is capitalised to compose cleanly with the
244
- // PascalCase logical ids (`staging` `StagingConnections`).
300
+ // DynamoDB table names are the bare logical ids (`Connections`,
301
+ // `Nicks`, …). There is a single deploy target per platform (the
302
+ // staging/prod split was collapsed); staging vs production isolation
303
+ // is driven by which AWS account + region the deploy credentials
304
+ // target, not by table-name prefixing.
245
305
  const tables = Object.fromEntries(
246
306
  Object.entries(TABLE_DEFS).map(([logicalId, tableProps]) => {
247
307
  const table = new Table(this, logicalId, {
248
308
  ...tableProps,
249
- tableName: prefixedTableName(environmentName, logicalId),
309
+ tableName: logicalId,
250
310
  removalPolicy: RemovalPolicy.DESTROY,
251
311
  });
252
312
  return [logicalId, table];
@@ -414,13 +474,19 @@ export class IrcAwsStack extends Stack {
414
474
  sourceIp: '$context.sourceIp',
415
475
  }),
416
476
  };
417
- // Execution logging: the parameter drives LoggingLevel (OFF/INFO/ERROR);
418
- // DataTraceEnabled logs full request/response bodies when a level is
419
- // active. This is the layer that shows whether APIGW received and routed
420
- // a client frame or dropped it pre-Lambda.
477
+ // Execution logging: the parameter drives LoggingLevel (OFF/INFO/ERROR).
478
+ // `DataTraceEnabled` is hard-locked to `false` here. For a WebSocket IRC
479
+ // endpoint, body tracing writes the FULL payload of every frame
480
+ // `PASS <password>`, `AUTHENTICATE <base64-SASL-PLAIN>`,
481
+ // `JOIN #chan <channel-key>`, and every `PRIVMSG`/`NOTICE` — to the
482
+ // APIGW execution-log CloudWatch group (AWS/ApiGateway…). Anyone with
483
+ // `logs:GetLogEvents` recovers IRC credentials verbatim. The logging
484
+ // LEVEL (INFO request/response metadata without bodies) is enough to
485
+ // diagnose frame-routing drops; the only way to re-enable full-body
486
+ // tracing is the explicit, double-opt-in escape hatch asserted below.
421
487
  cfnStage.defaultRouteSettings = {
422
488
  loggingLevel: apiLoggingLevelParam.valueAsString,
423
- dataTraceEnabled: true,
489
+ dataTraceEnabled,
424
490
  };
425
491
 
426
492
  stage.grantManagementApiAccess(handler);
@@ -430,16 +496,15 @@ export class IrcAwsStack extends Stack {
430
496
 
431
497
  // Inject each table's physical name as an env var so every Lambda can
432
498
  // construct a `TablesConfig` at cold start via `buildDepsFromEnv`. The
433
- // env-var value is the environment-prefixed physical name (computed
434
- // through the same helper as the Table construct so they match
435
- // exactly), NOT a CloudFormation Ref — the runtime needs the literal
436
- // per-env table name to target the correct table.
499
+ // env-var value is the literal physical table name (the bare logical
500
+ // id, computed through the same mapping as the Table construct so they
501
+ // match exactly), NOT a CloudFormation Ref — the runtime needs the
502
+ // literal name to target the correct table.
437
503
  for (const logicalId of Object.keys(TABLE_DEFS)) {
438
504
  const envName = `${logicalId.toUpperCase()}_TABLE`;
439
- const physicalName = prefixedTableName(environmentName, logicalId);
440
- handler.addEnvironment(envName, physicalName);
441
- sweeper.addEnvironment(envName, physicalName);
442
- pingChecker.addEnvironment(envName, physicalName);
505
+ handler.addEnvironment(envName, logicalId);
506
+ sweeper.addEnvironment(envName, logicalId);
507
+ pingChecker.addEnvironment(envName, logicalId);
443
508
  }
444
509
 
445
510
  // Server identity is injected into the handler, ping checker, AND the
@@ -461,6 +526,16 @@ export class IrcAwsStack extends Stack {
461
526
  sweeper.addEnvironment('NETWORK_NAME', networkName);
462
527
  sweeper.addEnvironment('MOTD', motd);
463
528
 
529
+ // CSWSH defence — opt-in `WEB_ORIGINS` allowlist on the `$connect`
530
+ // handler. Only the wss handler runs `$connect`, so only it needs
531
+ // the env var (the sweeper, ping-checker, and NLB handler never see
532
+ // an `event.headers.Origin`). When the prop is unset/empty the env
533
+ // var is omitted entirely and the handler's defence is disabled —
534
+ // existing deployments without a web frontend are unchanged.
535
+ if (props.webOrigins !== undefined && props.webOrigins.length > 0) {
536
+ handler.addEnvironment('WEB_ORIGINS', props.webOrigins);
537
+ }
538
+
464
539
  // EventBridge schedule → sweeper Lambda. The schedule expression comes
465
540
  // from a CloudFormation Parameter (see above), so it can be overridden
466
541
  // at deploy time; the sweeper is self-throttling
@@ -526,7 +601,7 @@ export class IrcAwsStack extends Stack {
526
601
  // Inject the same table names, server identity, and management URL.
527
602
  for (const logicalId of Object.keys(TABLE_DEFS)) {
528
603
  const envName = `${logicalId.toUpperCase()}_TABLE`;
529
- nlbHandler.addEnvironment(envName, prefixedTableName(environmentName, logicalId));
604
+ nlbHandler.addEnvironment(envName, logicalId);
530
605
  }
531
606
  nlbHandler.addEnvironment('SERVER_NAME', serverName);
532
607
  nlbHandler.addEnvironment('NETWORK_NAME', networkName);
@@ -571,6 +646,39 @@ export class IrcAwsStack extends Stack {
571
646
  description: 'HTTPS management endpoint for ApiGatewayManagementApi.postToConnection.',
572
647
  value: stage.callbackUrl,
573
648
  });
649
+
650
+ // -------------------------------------------------------------------------
651
+ // Web client hosting substrate (S3 + CloudFront + OAC).
652
+ //
653
+ // Serves the shared `apps/web/dist` artifact (Kiwi SPA + landing page)
654
+ // exclusively over CloudFront. The S3 origin bucket is private; CloudFront
655
+ // reads it via Origin Access Control (the modern replacement for OAI).
656
+ // Only provisioned when `webSite` is supplied — staging defaults to the
657
+ // `*.cloudfront.net` URL, prod passes the custom-domain fields. Pure
658
+ // infrastructure; the per-env config bake and the s3-sync deploy pipeline
659
+ // are separate.
660
+ // -------------------------------------------------------------------------
661
+ if (props.webSite !== undefined) {
662
+ const site = new StaticSite(this, 'StaticSite', props.webSite);
663
+ // Expose the deploy-pipeline-relevant resource IDs at the PARENT-stack
664
+ // level (stable logical IDs — no CDK hashing, which only kicks in for
665
+ // outputs nested under child constructs). `scripts/deploy-web-aws.mjs`
666
+ // reads these via `aws cloudformation describe-stacks --query` to drive
667
+ // `s3 sync apps/web/dist s3://<bucket> --delete` and
668
+ // `cloudfront create-invalidation --distribution-id <id> --paths /*`.
669
+ // `ConnectUrl` (above) supplies the wss URL that the same script bakes
670
+ // into the SPA's config.json via `build.mjs --api-url`.
671
+ new CfnOutput(this, 'WebsiteBucketName', {
672
+ description:
673
+ 'S3 origin bucket holding the web client build artifact (apps/web/dist). The deploy pipeline runs `aws s3 sync apps/web/dist s3://<this-bucket> --delete`.',
674
+ value: site.bucket.bucketName,
675
+ });
676
+ new CfnOutput(this, 'WebsiteDistributionId', {
677
+ description:
678
+ 'CloudFront distribution ID serving the web client. The deploy pipeline runs `aws cloudfront create-invalidation --distribution-id <this-id> --paths /*` after each s3 sync.',
679
+ value: site.distribution.distributionId,
680
+ });
681
+ }
574
682
  }
575
683
  }
576
684
 
@@ -582,6 +690,34 @@ function handlerEntry(): string {
582
690
  return resolve(HERE, '..', '..', '..', 'packages', 'aws-adapter', 'src', 'handlers', 'index.ts');
583
691
  }
584
692
 
693
+ /**
694
+ * Resolves the API Gateway `DataTraceEnabled` flag for the stage from the
695
+ * construct props. Safe-by-default: returns `false` unless the explicit,
696
+ * documented escape hatch is invoked. See {@link IrcStackProps.allowDataTrace}.
697
+ *
698
+ * Post-T170 (staging/prod collapse) every synth is effectively a prod synth,
699
+ * so there is no "non-prod" branch to quietly enable tracing on. The guard
700
+ * therefore refuses synthesis the moment `allowDataTrace` is requested
701
+ * without the explicit `iUnderstandThisLeaksCredentials` acknowledgement —
702
+ * forcing the operator to read the credential-leak warning.
703
+ */
704
+ function resolveDataTraceEnabled(props: IrcStackProps): boolean {
705
+ if (props.allowDataTrace === true) {
706
+ if (props.iUnderstandThisLeaksCredentials === true) {
707
+ return true;
708
+ }
709
+ throw new Error(
710
+ 'Refusing to enable API Gateway DataTraceEnabled: for a WebSocket IRC ' +
711
+ 'endpoint this logs the FULL body of every frame (PASS <password>, ' +
712
+ 'AUTHENTICATE <base64-SASL-PLAIN>, JOIN #chan <channel-key>, every ' +
713
+ 'PRIVMSG/NOTICE) to the APIGW CloudWatch log group, leaking IRC ' +
714
+ 'credentials to anyone with logs:GetLogEvents. To override in a ' +
715
+ 'throwaway sandbox ONLY, also pass iUnderstandThisLeaksCredentials=true.',
716
+ );
717
+ }
718
+ return false;
719
+ }
720
+
585
721
  /**
586
722
  * Internal Lambda-construct props: `NodejsFunctionProps` narrowed so the
587
723
  * runtime and handler are required. Every call site in the stack already
@@ -623,14 +759,3 @@ function makeLambda(scope: Construct, id: string, props: StrictLambdaProps): Lam
623
759
  }
624
760
  return new NodejsFunction(scope, id, props);
625
761
  }
626
-
627
- /**
628
- * Builds the physical DynamoDB table name for a logical id under the
629
- * given environment. Capitalises the environment's first letter so it
630
- * composes cleanly with PascalCase logical ids
631
- * (`staging` + `Connections` → `StagingConnections`).
632
- */
633
- function prefixedTableName(environmentName: string, logicalId: string): string {
634
- const cap = environmentName.charAt(0).toUpperCase() + environmentName.slice(1);
635
- return `${cap}${logicalId}`;
636
- }