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.
- package/.github/workflows/ci.yml +4 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +11 -9
- package/.github/workflows/deploy-cf.yml +14 -14
- package/CHANGELOG.md +550 -0
- package/README.md +275 -222
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +82 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +177 -52
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +267 -92
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/package.json +2 -3
- package/apps/cf-tcp-container/src/container-server.ts +33 -10
- package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
- package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
- package/apps/cf-tcp-container/wrangler.toml +1 -10
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/wrangler.toml +12 -71
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +115 -48
- package/apps/local-cli/tests/config-resolution.test.ts +65 -0
- package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
- package/apps/local-cli/tests/rehash.test.ts +147 -0
- package/apps/local-cli/tests/server-helpers.test.ts +63 -0
- package/apps/local-cli/tests/tcp.test.ts +89 -0
- package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +227 -3
- package/apps/web/package.json +3 -2
- package/apps/web/scripts/build.mjs +91 -6
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/src/render-docs.ts +292 -0
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +33 -4
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/apps/web/tests/landing-content.test.ts +103 -0
- package/apps/web/tests/render-docs.test.ts +198 -0
- package/docs/AWS-Adapter-Architecture.md +3 -2
- package/docs/AWS-Deployment.md +670 -96
- package/docs/AWS-TCP-Deployment.md +20 -45
- package/docs/Cloudflare-Deployment-Guide.md +87 -113
- package/docs/Cloudflare-TCP-Deployment.md +25 -49
- package/docs/Release-Process.md +27 -23
- package/docs/Services.md +102 -23
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +15 -1
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
- package/packages/aws-adapter/src/config-loader.ts +19 -2
- package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
- package/packages/aws-adapter/src/handlers/connect.ts +26 -0
- package/packages/aws-adapter/src/handlers/default.ts +190 -123
- package/packages/aws-adapter/src/handlers/index.ts +67 -23
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +15 -0
- package/packages/aws-adapter/src/tables.ts +2 -12
- package/packages/aws-adapter/tests/aws-harness.ts +0 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
- package/packages/aws-adapter/tests/connect.test.ts +124 -1
- package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
- package/packages/aws-adapter/tests/global-setup.ts +28 -1
- package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
- package/packages/aws-adapter/tests/handlers.test.ts +117 -11
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +59 -8
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/connection-do.ts +284 -115
- package/packages/cf-adapter/src/d1-services-store.ts +63 -26
- package/packages/cf-adapter/src/env.ts +11 -10
- package/packages/cf-adapter/src/index.ts +0 -6
- package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/commands/account-auth.ts +60 -35
- package/packages/irc-core/src/commands/chanserv.ts +288 -4
- package/packages/irc-core/src/commands/hostserv.ts +38 -3
- package/packages/irc-core/src/commands/index.ts +1 -0
- package/packages/irc-core/src/commands/join.ts +41 -35
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/nickserv.ts +138 -15
- package/packages/irc-core/src/commands/registration.ts +28 -17
- package/packages/irc-core/src/commands/sasl.ts +22 -31
- package/packages/irc-core/src/commands/service-aliases.ts +52 -0
- package/packages/irc-core/src/commands/topic.ts +23 -10
- package/packages/irc-core/src/config.ts +35 -9
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/index.ts +1 -0
- package/packages/irc-core/src/ports.ts +159 -179
- package/packages/irc-core/src/state/channel.ts +17 -0
- package/packages/irc-core/src/types.ts +38 -10
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
- package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
- package/packages/irc-core/tests/commands/join.test.ts +179 -0
- package/packages/irc-core/tests/commands/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
- package/packages/irc-core/tests/commands/oper.test.ts +15 -0
- package/packages/irc-core/tests/commands/registration.test.ts +336 -108
- package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
- package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
- package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
- package/packages/irc-core/tests/ports.test.ts +71 -0
- package/packages/irc-core/tests/services-store.test.ts +204 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +80 -44
- package/packages/irc-server/tests/actor.test.ts +384 -50
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
- package/packages/irc-test-support/src/scenarios.ts +21 -6
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
- package/packages/irc-test-support/vitest.config.ts +6 -1
- package/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/ci-hardening/src/index.ts +2 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/load-test/package.json +1 -1
- package/tools/load-test/src/client.ts +13 -13
- package/tools/load-test/tests/client.test.ts +258 -2
- package/tools/load-test/tests/config.test.ts +39 -0
- package/tools/load-test/tests/harness.test.ts +21 -0
- package/tools/load-test/tests/metrics.test.ts +7 -0
- package/tools/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
- package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- 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 —
|
|
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 = ['
|
|
24
|
+
const TABLE_NAMES = ['ChannelMembers', 'ChannelMeta', 'Connections', 'Nicks', 'Services'] as const;
|
|
24
25
|
|
|
25
|
-
|
|
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
|
|
68
|
+
it('creates exactly five DynamoDB tables', () => {
|
|
83
69
|
const { template } = makeTemplate();
|
|
84
|
-
template.resourceCountIs('AWS::DynamoDB::Table',
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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(
|
|
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 —
|
|
641
|
-
it('
|
|
642
|
-
const { template } = makeTemplate(
|
|
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: '
|
|
677
|
+
TableName: 'Connections',
|
|
652
678
|
});
|
|
653
679
|
});
|
|
654
680
|
|
|
655
|
-
it('
|
|
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:
|
|
684
|
+
TableName: 'Services',
|
|
659
685
|
});
|
|
660
686
|
});
|
|
661
687
|
|
|
662
|
-
it('injects the
|
|
663
|
-
const { template } = makeTemplate(
|
|
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
|
|
669
|
-
const
|
|
670
|
-
(fn) => fn.Properties?.Environment?.Variables?.CONNECTIONS_TABLE === '
|
|
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(
|
|
698
|
+
expect(withBare).toHaveLength(3);
|
|
673
699
|
});
|
|
674
700
|
|
|
675
|
-
it('produces
|
|
676
|
-
const
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
);
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
+
});
|