serverless-ircd 0.1.0 → 0.2.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 +96 -2
- package/.github/workflows/deploy-aws.yml +129 -0
- package/.github/workflows/deploy-cf.yml +0 -2
- package/.gitmodules +3 -0
- package/AGENTS.md +5 -0
- package/CHANGELOG.md +352 -0
- package/MOTD.txt +3 -0
- package/PLAN-FIXES.md +358 -0
- package/PLAN.md +420 -0
- package/README.md +115 -81
- package/apps/aws-stack/README.md +73 -0
- package/apps/aws-stack/bin/aws.ts +49 -0
- package/apps/aws-stack/cdk.json +10 -0
- package/apps/aws-stack/package.json +41 -0
- package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
- package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
- package/apps/aws-stack/scripts/smoke.mjs +142 -0
- package/apps/aws-stack/src/aws-stack.ts +263 -0
- package/apps/aws-stack/src/tables.ts +10 -0
- package/apps/aws-stack/tests/localstack.test.ts +46 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
- package/apps/aws-stack/tests/stack.test.ts +464 -0
- package/apps/aws-stack/tsconfig.build.json +11 -0
- package/apps/aws-stack/tsconfig.test.json +8 -0
- package/apps/aws-stack/vitest.config.ts +20 -0
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/wrangler.toml +25 -0
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +56 -0
- package/apps/local-cli/src/server.ts +241 -32
- package/apps/local-cli/tests/config-loader.test.ts +107 -0
- package/apps/local-cli/tests/e2e.test.ts +74 -0
- package/apps/local-cli/tests/security-e2e.test.ts +239 -0
- package/biome.json +3 -1
- package/dashboards/cloudwatch-irc.json +139 -0
- package/docs/AWS-Adapter-Architecture.md +494 -0
- package/docs/AWS-Deployment.md +1107 -0
- package/docs/Cloudflare-Deployment-Guide.md +660 -0
- package/docs/Home.md +1 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanIRCv3Websocket.md +489 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +440 -0
- package/package.json +8 -2
- package/packages/aws-adapter/README.md +35 -0
- package/packages/aws-adapter/package.json +52 -0
- package/packages/aws-adapter/src/aws-runtime.ts +783 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +69 -0
- package/packages/aws-adapter/src/config-loader.ts +96 -0
- package/packages/aws-adapter/src/dynamo.ts +61 -0
- package/packages/aws-adapter/src/handlers/connect.ts +44 -0
- package/packages/aws-adapter/src/handlers/default.ts +322 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +29 -0
- package/packages/aws-adapter/src/handlers/index.ts +248 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
- package/packages/aws-adapter/src/handlers/state.ts +13 -0
- package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
- package/packages/aws-adapter/src/index.ts +67 -0
- package/packages/aws-adapter/src/message-store.ts +34 -0
- package/packages/aws-adapter/src/serialize.ts +283 -0
- package/packages/aws-adapter/src/tables.ts +53 -0
- package/packages/aws-adapter/tests/aws-harness.ts +408 -0
- package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
- package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +158 -0
- package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
- package/packages/aws-adapter/tests/global-setup.ts +301 -0
- package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
- package/packages/aws-adapter/tests/handlers.test.ts +427 -0
- package/packages/aws-adapter/tests/message-store.test.ts +55 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
- package/packages/aws-adapter/tests/serialize.test.ts +249 -0
- package/packages/aws-adapter/tests/smoke.test.ts +48 -0
- package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
- package/packages/aws-adapter/tests/tables.test.ts +74 -0
- package/packages/aws-adapter/tests/transactions.test.ts +446 -0
- package/packages/aws-adapter/tsconfig.build.json +16 -0
- package/packages/aws-adapter/tsconfig.test.json +14 -0
- package/packages/aws-adapter/vitest.config.ts +23 -0
- package/packages/cf-adapter/package.json +2 -1
- package/packages/cf-adapter/src/config-loader.ts +106 -0
- package/packages/cf-adapter/src/connection-do.ts +34 -0
- package/packages/cf-adapter/tests/cf-harness.ts +2 -2
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
- package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +42 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +108 -3
- package/packages/in-memory-runtime/src/index.ts +1 -1
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +115 -0
- package/packages/irc-core/package.json +12 -2
- package/packages/irc-core/src/admission.ts +216 -0
- package/packages/irc-core/src/caps/capabilities.ts +1 -0
- package/packages/irc-core/src/cloak.ts +81 -0
- package/packages/irc-core/src/commands/cap.ts +1 -2
- package/packages/irc-core/src/commands/chathistory.ts +305 -0
- package/packages/irc-core/src/commands/index.ts +7 -0
- package/packages/irc-core/src/commands/join.ts +59 -7
- package/packages/irc-core/src/commands/privmsg.ts +24 -0
- package/packages/irc-core/src/commands/registration.ts +71 -14
- package/packages/irc-core/src/commands/sasl.ts +251 -0
- package/packages/irc-core/src/config.ts +247 -0
- package/packages/irc-core/src/flood-control.ts +175 -0
- package/packages/irc-core/src/index.ts +5 -0
- package/packages/irc-core/src/ports.ts +655 -0
- package/packages/irc-core/src/protocol/base64.ts +16 -0
- package/packages/irc-core/src/protocol/index.ts +2 -1
- package/packages/irc-core/src/protocol/numerics.ts +11 -0
- package/packages/irc-core/src/protocol/parser.ts +27 -2
- package/packages/irc-core/src/state/connection.ts +41 -0
- package/packages/irc-core/src/types.ts +66 -2
- package/packages/irc-core/stryker.commands.conf.json +41 -0
- package/packages/irc-core/stryker.protocol.conf.json +26 -0
- package/packages/irc-core/tests/admission.test.ts +229 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
- package/packages/irc-core/tests/cloak.test.ts +78 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
- package/packages/irc-core/tests/commands/join.test.ts +246 -2
- package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
- package/packages/irc-core/tests/commands/registration.test.ts +277 -9
- package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
- package/packages/irc-core/tests/config.test.ts +646 -0
- package/packages/irc-core/tests/flood-control.test.ts +394 -0
- package/packages/irc-core/tests/message-store.test.ts +530 -0
- package/packages/irc-core/tests/parser.test.ts +44 -1
- package/packages/irc-core/tests/ports.test.ts +263 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +162 -44
- package/packages/irc-server/src/dispatch.ts +89 -5
- package/packages/irc-server/src/index.ts +2 -0
- package/packages/irc-server/src/routing.ts +151 -0
- package/packages/irc-server/tests/actor.test.ts +470 -14
- package/packages/irc-server/tests/dispatch.test.ts +84 -0
- package/packages/irc-server/tests/routing.test.ts +201 -0
- package/packages/irc-test-support/README.md +32 -0
- package/packages/irc-test-support/package.json +37 -0
- package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +1 -1
- package/packages/irc-test-support/src/index.ts +24 -0
- package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
- package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
- package/packages/irc-test-support/tests/smoke.test.ts +11 -0
- package/packages/irc-test-support/tsconfig.build.json +16 -0
- package/packages/irc-test-support/tsconfig.test.json +14 -0
- package/packages/irc-test-support/vitest.config.ts +20 -0
- package/progress.md +107 -0
- package/tickets.md +2485 -0
- package/tools/ci-hardening/package.json +30 -0
- package/tools/ci-hardening/src/index.ts +6 -0
- package/tools/ci-hardening/src/validate.ts +103 -0
- package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
- package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
- package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
- package/tools/ci-hardening/tests/validate.test.ts +177 -0
- package/tools/ci-hardening/tsconfig.build.json +12 -0
- package/tools/ci-hardening/tsconfig.test.json +10 -0
- package/tools/ci-hardening/vitest.config.ts +21 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
- package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
- package/tools/tcp-ws-forwarder/src/main.ts +33 -14
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
- package/webircgateway/LICENSE +201 -0
- package/webircgateway/Makefile +44 -0
- package/webircgateway/README.md +134 -0
- package/webircgateway/config.conf.example +135 -0
- package/webircgateway/go.mod +16 -0
- package/webircgateway/go.sum +89 -0
- package/webircgateway/main.go +118 -0
- package/webircgateway/pkg/dnsbl/dnsbl.go +121 -0
- package/webircgateway/pkg/identd/identd.go +86 -0
- package/webircgateway/pkg/identd/rpcclient.go +59 -0
- package/webircgateway/pkg/irc/isupport.go +56 -0
- package/webircgateway/pkg/irc/message.go +217 -0
- package/webircgateway/pkg/irc/state.go +79 -0
- package/webircgateway/pkg/proxy/proxy.go +129 -0
- package/webircgateway/pkg/proxy/server.go +237 -0
- package/webircgateway/pkg/recaptcha/recaptcha.go +59 -0
- package/webircgateway/pkg/webircgateway/client.go +741 -0
- package/webircgateway/pkg/webircgateway/client_command_handlers.go +495 -0
- package/webircgateway/pkg/webircgateway/config.go +385 -0
- package/webircgateway/pkg/webircgateway/gateway.go +278 -0
- package/webircgateway/pkg/webircgateway/gateway_utils.go +133 -0
- package/webircgateway/pkg/webircgateway/hooks.go +152 -0
- package/webircgateway/pkg/webircgateway/letsencrypt.go +41 -0
- package/webircgateway/pkg/webircgateway/messagetags.go +103 -0
- package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +206 -0
- package/webircgateway/pkg/webircgateway/transport_sockjs.go +107 -0
- package/webircgateway/pkg/webircgateway/transport_tcp.go +113 -0
- package/webircgateway/pkg/webircgateway/transport_websocket.go +126 -0
- package/webircgateway/pkg/webircgateway/utils.go +147 -0
- package/webircgateway/plugins/example/plugin.go +11 -0
- package/webircgateway/plugins/stats/plugin.go +52 -0
- package/webircgateway/staticcheck.conf +1 -0
- package/webircgateway/webircgateway.svg +3 -0
- package/packages/cf-adapter/tests/integration/index.ts +0 -17
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synth-time assertions for the `apps/aws-stack` CDK stack.
|
|
3
|
+
*
|
|
4
|
+
* These are the primary red→green tests for the infrastructure ticket:
|
|
5
|
+
* they assert the exact CloudFormation shape that `cdk synth` must
|
|
6
|
+
* produce — five DynamoDB tables with the PLAN §4 schema, a single
|
|
7
|
+
* Node 20 Lambda wired to all three WebSocket routes, the API Gateway
|
|
8
|
+
* v2 WebSocket API, the two stack outputs, and a least-privilege guard
|
|
9
|
+
* that no IAM policy grants `Resource: '*'` or `Action: '*'`.
|
|
10
|
+
*
|
|
11
|
+
* The assertions run against `Template.fromStack`, which executes the
|
|
12
|
+
* CDK synthesis in-process — no AWS credentials and no Docker required.
|
|
13
|
+
* The separately-gated `localstack.test.ts` covers the deploy-time
|
|
14
|
+
* contract against a real localstack container.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { App, type Stack } from 'aws-cdk-lib';
|
|
18
|
+
import { Template } from 'aws-cdk-lib/assertions';
|
|
19
|
+
import { describe, expect, it } from 'vitest';
|
|
20
|
+
import { IrcAwsStack } from '../src/aws-stack.js';
|
|
21
|
+
|
|
22
|
+
/** All five table names that must exist in the stack (PLAN §4). */
|
|
23
|
+
const TABLE_NAMES = ['Accounts', 'ChannelMembers', 'ChannelMeta', 'Connections', 'Nicks'] as const;
|
|
24
|
+
|
|
25
|
+
/** Default environment name assumed when the prop is omitted. */
|
|
26
|
+
const DEFAULT_ENV = 'staging';
|
|
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 } {
|
|
37
|
+
const app = new App();
|
|
38
|
+
const stack = new IrcAwsStack(
|
|
39
|
+
app,
|
|
40
|
+
'TestStack',
|
|
41
|
+
environmentName !== undefined ? { environmentName } : undefined,
|
|
42
|
+
);
|
|
43
|
+
const template = Template.fromStack(stack);
|
|
44
|
+
return { app, stack, template };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('IrcAwsStack — DynamoDB tables', () => {
|
|
48
|
+
it('creates exactly five DynamoDB tables', () => {
|
|
49
|
+
const { template } = makeTemplate();
|
|
50
|
+
template.resourceCountIs('AWS::DynamoDB::Table', 5);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it.each(TABLE_NAMES)('creates the %s table', (tableName) => {
|
|
54
|
+
const { template } = makeTemplate();
|
|
55
|
+
template.hasResourceProperties('AWS::DynamoDB::Table', {
|
|
56
|
+
TableName: prefixedTableName(DEFAULT_ENV, tableName),
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('configures every table for on-demand (PAY_PER_REQUEST) billing', () => {
|
|
61
|
+
const { template } = makeTemplate();
|
|
62
|
+
template.allResourcesProperties('AWS::DynamoDB::Table', {
|
|
63
|
+
BillingMode: 'PAY_PER_REQUEST',
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('enables TTL on the Connections table via the idleSince attribute', () => {
|
|
68
|
+
const { template } = makeTemplate();
|
|
69
|
+
template.hasResourceProperties('AWS::DynamoDB::Table', {
|
|
70
|
+
TableName: prefixedTableName(DEFAULT_ENV, 'Connections'),
|
|
71
|
+
TimeToLiveSpecification: {
|
|
72
|
+
AttributeName: 'idleSince',
|
|
73
|
+
Enabled: true,
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('keys the Connections table by connectionId (String)', () => {
|
|
79
|
+
const { template } = makeTemplate();
|
|
80
|
+
template.hasResourceProperties('AWS::DynamoDB::Table', {
|
|
81
|
+
TableName: prefixedTableName(DEFAULT_ENV, 'Connections'),
|
|
82
|
+
AttributeDefinitions: [{ AttributeName: 'connectionId', AttributeType: 'S' }],
|
|
83
|
+
KeySchema: [{ AttributeName: 'connectionId', KeyType: 'HASH' }],
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('keys the ChannelMembers table by channelName (PK) + connectionId (SK)', () => {
|
|
88
|
+
const { template } = makeTemplate();
|
|
89
|
+
template.hasResourceProperties('AWS::DynamoDB::Table', {
|
|
90
|
+
TableName: prefixedTableName(DEFAULT_ENV, 'ChannelMembers'),
|
|
91
|
+
AttributeDefinitions: [
|
|
92
|
+
{ AttributeName: 'channelName', AttributeType: 'S' },
|
|
93
|
+
{ AttributeName: 'connectionId', AttributeType: 'S' },
|
|
94
|
+
],
|
|
95
|
+
KeySchema: [
|
|
96
|
+
{ AttributeName: 'channelName', KeyType: 'HASH' },
|
|
97
|
+
{ AttributeName: 'connectionId', KeyType: 'RANGE' },
|
|
98
|
+
],
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('keys the ChannelMeta table by channelName (String)', () => {
|
|
103
|
+
const { template } = makeTemplate();
|
|
104
|
+
template.hasResourceProperties('AWS::DynamoDB::Table', {
|
|
105
|
+
TableName: prefixedTableName(DEFAULT_ENV, 'ChannelMeta'),
|
|
106
|
+
AttributeDefinitions: [{ AttributeName: 'channelName', AttributeType: 'S' }],
|
|
107
|
+
KeySchema: [{ AttributeName: 'channelName', KeyType: 'HASH' }],
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('keys the Nicks table by nickLower (String)', () => {
|
|
112
|
+
const { template } = makeTemplate();
|
|
113
|
+
template.hasResourceProperties('AWS::DynamoDB::Table', {
|
|
114
|
+
TableName: prefixedTableName(DEFAULT_ENV, 'Nicks'),
|
|
115
|
+
AttributeDefinitions: [{ AttributeName: 'nickLower', AttributeType: 'S' }],
|
|
116
|
+
KeySchema: [{ AttributeName: 'nickLower', KeyType: 'HASH' }],
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('keys the Accounts table by account (String)', () => {
|
|
121
|
+
const { template } = makeTemplate();
|
|
122
|
+
template.hasResourceProperties('AWS::DynamoDB::Table', {
|
|
123
|
+
TableName: prefixedTableName(DEFAULT_ENV, 'Accounts'),
|
|
124
|
+
AttributeDefinitions: [{ AttributeName: 'account', AttributeType: 'S' }],
|
|
125
|
+
KeySchema: [{ AttributeName: 'account', KeyType: 'HASH' }],
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('IrcAwsStack — API Gateway v2 WebSocket API', () => {
|
|
131
|
+
it('creates a WebSocket protocol API Gateway v2 API', () => {
|
|
132
|
+
const { template } = makeTemplate();
|
|
133
|
+
template.hasResourceProperties('AWS::ApiGatewayV2::Api', {
|
|
134
|
+
ProtocolType: 'WEBSOCKET',
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it.each(['$connect', '$disconnect', '$default'] as const)(
|
|
139
|
+
'wires the %s route to the Lambda integration',
|
|
140
|
+
(routeKey) => {
|
|
141
|
+
const { template } = makeTemplate();
|
|
142
|
+
template.hasResourceProperties('AWS::ApiGatewayV2::Route', {
|
|
143
|
+
RouteKey: routeKey,
|
|
144
|
+
});
|
|
145
|
+
},
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
it('creates exactly three routes', () => {
|
|
149
|
+
const { template } = makeTemplate();
|
|
150
|
+
template.resourceCountIs('AWS::ApiGatewayV2::Route', 3);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('IrcAwsStack — Lambda', () => {
|
|
155
|
+
it('creates exactly three Lambda functions (handler + sweeper + pingChecker)', () => {
|
|
156
|
+
const { template } = makeTemplate();
|
|
157
|
+
template.resourceCountIs('AWS::Lambda::Function', 3);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('targets the Node 20 runtime for every function', () => {
|
|
161
|
+
const { template } = makeTemplate();
|
|
162
|
+
template.allResourcesProperties('AWS::Lambda::Function', {
|
|
163
|
+
Runtime: 'nodejs20.x',
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('IrcAwsStack — gone-connection sweeper', () => {
|
|
169
|
+
it('creates an EventBridge rule on a fixed schedule targeting the sweeper', () => {
|
|
170
|
+
const { template } = makeTemplate();
|
|
171
|
+
template.hasResourceProperties('AWS::Events::Rule', {
|
|
172
|
+
ScheduleExpression: 'rate(5 minutes)',
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('wires the sweeper Lambda as an EventBridge target', () => {
|
|
177
|
+
const { template } = makeTemplate();
|
|
178
|
+
template.hasResourceProperties('AWS::Events::Rule', {
|
|
179
|
+
ScheduleExpression: 'rate(5 minutes)',
|
|
180
|
+
});
|
|
181
|
+
// EventBridge needs permission to invoke each scheduled target. APIGW
|
|
182
|
+
// also creates one permission per route ($connect/$disconnect/$default),
|
|
183
|
+
// so the total is 5: 3 APIGW + 2 EventBridge (sweeper + pingChecker).
|
|
184
|
+
template.hasResourceProperties('AWS::Lambda::Permission', {
|
|
185
|
+
Principal: 'events.amazonaws.com',
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('grants the sweeper read/delete on every DynamoDB table (no wildcards)', () => {
|
|
190
|
+
const { template } = makeTemplate();
|
|
191
|
+
// Both Lambda roles must scope their DynamoDB actions to the concrete
|
|
192
|
+
// table ARNs — the least-privilege suite below also asserts no '*'.
|
|
193
|
+
const tables = template.findResources('AWS::DynamoDB::Table');
|
|
194
|
+
expect(Object.keys(tables)).toHaveLength(5);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('IrcAwsStack — idle / PING checker', () => {
|
|
199
|
+
it('creates an EventBridge rule firing every 1 minute', () => {
|
|
200
|
+
const { template } = makeTemplate();
|
|
201
|
+
template.hasResourceProperties('AWS::Events::Rule', {
|
|
202
|
+
ScheduleExpression: 'rate(1 minute)',
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('creates exactly two EventBridge rules (sweeper + pingChecker)', () => {
|
|
207
|
+
const { template } = makeTemplate();
|
|
208
|
+
template.resourceCountIs('AWS::Events::Rule', 2);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('grants the ping checker Lambda management-api access on the stage', () => {
|
|
212
|
+
// The CDK wires execute-api:ManageConnections via
|
|
213
|
+
// `stage.grantManagementApiAccess(pingChecker)`. We assert the
|
|
214
|
+
// resource pattern scoping the policy to this API's stage.
|
|
215
|
+
const { template } = makeTemplate();
|
|
216
|
+
const policies = template.findResources('AWS::IAM::Policy');
|
|
217
|
+
let found = false;
|
|
218
|
+
for (const [, policy] of Object.entries(policies)) {
|
|
219
|
+
const stmts = (policy as { Properties: { PolicyDocument: { Statement: unknown[] } } })
|
|
220
|
+
.Properties.PolicyDocument.Statement;
|
|
221
|
+
for (const stmt of stmts) {
|
|
222
|
+
const action = (stmt as { Action?: unknown }).Action;
|
|
223
|
+
const matchesAction =
|
|
224
|
+
action === 'execute-api:ManageConnections' ||
|
|
225
|
+
(Array.isArray(action) && action.includes('execute-api:ManageConnections'));
|
|
226
|
+
if (matchesAction) {
|
|
227
|
+
found = true;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
expect(found, 'ping checker must be granted execute-api:ManageConnections').toBe(true);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('forwards the MANAGEMENT_URL env var to the ping checker so it can PING clients', () => {
|
|
235
|
+
// The $default handler AND the ping checker both need MANAGEMENT_URL
|
|
236
|
+
// (the sweeper does not). Walk every Lambda function and confirm
|
|
237
|
+
// at least two carry the env var — combined with the 3-function
|
|
238
|
+
// count above this locks the wiring in place.
|
|
239
|
+
const { template } = makeTemplate();
|
|
240
|
+
const fns = template.findResources('AWS::Lambda::Function') as Record<
|
|
241
|
+
string,
|
|
242
|
+
{ Properties?: { Environment?: { Variables?: Record<string, string> } } }
|
|
243
|
+
>;
|
|
244
|
+
const withMgmt = Object.values(fns).filter(
|
|
245
|
+
(fn) => fn.Properties?.Environment?.Variables?.MANAGEMENT_URL !== undefined,
|
|
246
|
+
);
|
|
247
|
+
expect(withMgmt.length).toBeGreaterThanOrEqual(2);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe('IrcAwsStack — stack outputs', () => {
|
|
252
|
+
it('emits a ConnectUrl output', () => {
|
|
253
|
+
const { template } = makeTemplate();
|
|
254
|
+
const outputs = template.toJSON().Outputs ?? {};
|
|
255
|
+
expect(outputs).toHaveProperty('ConnectUrl');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('emits a ManagementUrl output', () => {
|
|
259
|
+
const { template } = makeTemplate();
|
|
260
|
+
const outputs = template.toJSON().Outputs ?? {};
|
|
261
|
+
expect(outputs).toHaveProperty('ManagementUrl');
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe('IrcAwsStack — least-privilege IAM', () => {
|
|
266
|
+
it('never grants Action: "*" in any IAM policy', () => {
|
|
267
|
+
const { template } = makeTemplate();
|
|
268
|
+
const policies = template.findResources('AWS::IAM::Policy');
|
|
269
|
+
for (const [, policy] of Object.entries(policies)) {
|
|
270
|
+
const statements = (policy as { Properties: { PolicyDocument: { Statement: unknown[] } } })
|
|
271
|
+
.Properties.PolicyDocument.Statement;
|
|
272
|
+
for (const stmt of statements) {
|
|
273
|
+
const action = (stmt as { Action?: unknown }).Action;
|
|
274
|
+
if (Array.isArray(action)) {
|
|
275
|
+
expect(
|
|
276
|
+
action,
|
|
277
|
+
`policy ${JSON.stringify(policy)} must not grant Action '*'`,
|
|
278
|
+
).not.toContain('*');
|
|
279
|
+
} else {
|
|
280
|
+
expect(action, `policy ${JSON.stringify(policy)} must not grant Action '*'`).not.toBe(
|
|
281
|
+
'*',
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('never grants Resource: "*" in any IAM policy', () => {
|
|
289
|
+
const { template } = makeTemplate();
|
|
290
|
+
const policies = template.findResources('AWS::IAM::Policy');
|
|
291
|
+
for (const [, policy] of Object.entries(policies)) {
|
|
292
|
+
const statements = (policy as { Properties: { PolicyDocument: { Statement: unknown[] } } })
|
|
293
|
+
.Properties.PolicyDocument.Statement;
|
|
294
|
+
for (const stmt of statements) {
|
|
295
|
+
const resource = (stmt as { Resource?: unknown }).Resource;
|
|
296
|
+
if (Array.isArray(resource)) {
|
|
297
|
+
expect(
|
|
298
|
+
resource,
|
|
299
|
+
`policy ${JSON.stringify(policy)} must not grant Resource '*'`,
|
|
300
|
+
).not.toContain('*');
|
|
301
|
+
} else {
|
|
302
|
+
expect(resource, `policy ${JSON.stringify(policy)} must not grant Resource '*'`).not.toBe(
|
|
303
|
+
'*',
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
describe('IrcAwsStack — environmentName prop (env isolation)', () => {
|
|
312
|
+
it('prefixes the Connections table name with the production environment', () => {
|
|
313
|
+
const { template } = makeTemplate('production');
|
|
314
|
+
template.hasResourceProperties('AWS::DynamoDB::Table', {
|
|
315
|
+
TableName: 'ProductionConnections',
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('prefixes the Connections table name with the staging environment', () => {
|
|
320
|
+
const { template } = makeTemplate('staging');
|
|
321
|
+
template.hasResourceProperties('AWS::DynamoDB::Table', {
|
|
322
|
+
TableName: 'StagingConnections',
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('defaults to the staging prefix when the prop is omitted', () => {
|
|
327
|
+
const { template } = makeTemplate();
|
|
328
|
+
template.hasResourceProperties('AWS::DynamoDB::Table', {
|
|
329
|
+
TableName: prefixedTableName(DEFAULT_ENV, 'Connections'),
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('injects the prefixed physical table name into every Lambda env var', () => {
|
|
334
|
+
const { template } = makeTemplate('production');
|
|
335
|
+
const fns = template.findResources('AWS::Lambda::Function') as Record<
|
|
336
|
+
string,
|
|
337
|
+
{ Properties?: { Environment?: { Variables?: Record<string, string> } } }
|
|
338
|
+
>;
|
|
339
|
+
// Every Lambda must carry CONNECTIONS_TABLE pointing at the production-prefixed name.
|
|
340
|
+
const withPrefixed = Object.values(fns).filter(
|
|
341
|
+
(fn) => fn.Properties?.Environment?.Variables?.CONNECTIONS_TABLE === 'ProductionConnections',
|
|
342
|
+
);
|
|
343
|
+
expect(withPrefixed).toHaveLength(3);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('produces non-colliding table names for staging vs production', () => {
|
|
347
|
+
const stagingTables = Object.keys(
|
|
348
|
+
makeTemplate('staging').template.findResources('AWS::DynamoDB::Table'),
|
|
349
|
+
);
|
|
350
|
+
const prodTables = Object.keys(
|
|
351
|
+
makeTemplate('production').template.findResources('AWS::DynamoDB::Table'),
|
|
352
|
+
);
|
|
353
|
+
const stagingNames = new Set(
|
|
354
|
+
Object.values(
|
|
355
|
+
makeTemplate('staging').template.findResources('AWS::DynamoDB::Table') as Record<
|
|
356
|
+
string,
|
|
357
|
+
{ Properties: { TableName: string } }
|
|
358
|
+
>,
|
|
359
|
+
).map((t) => t.Properties.TableName),
|
|
360
|
+
);
|
|
361
|
+
const prodNames = Object.values(
|
|
362
|
+
makeTemplate('production').template.findResources('AWS::DynamoDB::Table') as Record<
|
|
363
|
+
string,
|
|
364
|
+
{ Properties: { TableName: string } }
|
|
365
|
+
>,
|
|
366
|
+
).map((t) => t.Properties.TableName);
|
|
367
|
+
expect(stagingTables).toHaveLength(5);
|
|
368
|
+
expect(prodTables).toHaveLength(5);
|
|
369
|
+
expect(prodNames.every((n) => !stagingNames.has(n))).toBe(true);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('includes the environment suffix in the CloudFormation stack name', () => {
|
|
373
|
+
const app = new App();
|
|
374
|
+
const stack = new IrcAwsStack(app, 'IrcAwsStack-production', {
|
|
375
|
+
environmentName: 'production',
|
|
376
|
+
});
|
|
377
|
+
expect(stack.stackName).toBe('IrcAwsStack-production');
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
describe('IrcAwsStack — server identity props (configurable deploy identity)', () => {
|
|
382
|
+
/** Finds a Lambda function resource by its logical-id prefix (e.g. `IrcHandler`). */
|
|
383
|
+
function findFunction(
|
|
384
|
+
template: Template,
|
|
385
|
+
idPrefix: string,
|
|
386
|
+
): {
|
|
387
|
+
Properties?: { Environment?: { Variables?: Record<string, string> } };
|
|
388
|
+
} {
|
|
389
|
+
const fns = template.findResources('AWS::Lambda::Function') as Record<
|
|
390
|
+
string,
|
|
391
|
+
{ Properties?: { Environment?: { Variables?: Record<string, string> } } }
|
|
392
|
+
>;
|
|
393
|
+
const found = Object.entries(fns).find(([id]) => id.startsWith(idPrefix));
|
|
394
|
+
if (!found) {
|
|
395
|
+
throw new Error(`No Lambda with logical id starting with "${idPrefix}"`);
|
|
396
|
+
}
|
|
397
|
+
return found[1];
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
it('injects an overridden serverName into the handler Lambda env', () => {
|
|
401
|
+
const app = new App();
|
|
402
|
+
const stack = new IrcAwsStack(app, 'TestStack', { serverName: 'irc.my.net' });
|
|
403
|
+
const template = Template.fromStack(stack);
|
|
404
|
+
expect(
|
|
405
|
+
findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.SERVER_NAME,
|
|
406
|
+
).toBe('irc.my.net');
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('flows the overridden serverName into the pingChecker Lambda env', () => {
|
|
410
|
+
const app = new App();
|
|
411
|
+
const stack = new IrcAwsStack(app, 'TestStack', { serverName: 'irc.my.net' });
|
|
412
|
+
const template = Template.fromStack(stack);
|
|
413
|
+
// Catches the previously duplicated hard-coded copy on IrcPingChecker.
|
|
414
|
+
expect(
|
|
415
|
+
findFunction(template, 'IrcPingChecker').Properties?.Environment?.Variables?.SERVER_NAME,
|
|
416
|
+
).toBe('irc.my.net');
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('injects an overridden networkName into both handler and pingChecker', () => {
|
|
420
|
+
const app = new App();
|
|
421
|
+
const stack = new IrcAwsStack(app, 'TestStack', { networkName: 'MyNet' });
|
|
422
|
+
const template = Template.fromStack(stack);
|
|
423
|
+
expect(
|
|
424
|
+
findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.NETWORK_NAME,
|
|
425
|
+
).toBe('MyNet');
|
|
426
|
+
expect(
|
|
427
|
+
findFunction(template, 'IrcPingChecker').Properties?.Environment?.Variables?.NETWORK_NAME,
|
|
428
|
+
).toBe('MyNet');
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('joins motdLines with newline so the config loader splits them into separate lines', () => {
|
|
432
|
+
const app = new App();
|
|
433
|
+
const stack = new IrcAwsStack(app, 'TestStack', { motdLines: ['line one', 'line two'] });
|
|
434
|
+
const template = Template.fromStack(stack);
|
|
435
|
+
expect(findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.MOTD).toBe(
|
|
436
|
+
'line one\nline two',
|
|
437
|
+
);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('applies the default server identity when the props are omitted', () => {
|
|
441
|
+
const { template } = makeTemplate();
|
|
442
|
+
expect(
|
|
443
|
+
findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.SERVER_NAME,
|
|
444
|
+
).toBe('irc.example.com');
|
|
445
|
+
expect(
|
|
446
|
+
findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.NETWORK_NAME,
|
|
447
|
+
).toBe('ExampleNet');
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('default MOTD is newline-joined so the loader yields multiple lines (no comma)', () => {
|
|
451
|
+
const { template } = makeTemplate();
|
|
452
|
+
const motd =
|
|
453
|
+
findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.MOTD ?? '';
|
|
454
|
+
expect(motd.split('\n').length).toBeGreaterThanOrEqual(2);
|
|
455
|
+
expect(motd).not.toContain(',');
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('does not inject server identity into the sweeper Lambda', () => {
|
|
459
|
+
const { template } = makeTemplate();
|
|
460
|
+
expect(
|
|
461
|
+
findFunction(template, 'IrcSweeper').Properties?.Environment?.Variables?.SERVER_NAME,
|
|
462
|
+
).toBeUndefined();
|
|
463
|
+
});
|
|
464
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
include: ['tests/**/*.test.ts'],
|
|
6
|
+
exclude: ['cdk.out/**', 'dist/**', 'node_modules/**'],
|
|
7
|
+
coverage: {
|
|
8
|
+
provider: 'v8',
|
|
9
|
+
all: false,
|
|
10
|
+
include: ['src/**/*.ts'],
|
|
11
|
+
reporter: ['text', 'html', 'json-summary'],
|
|
12
|
+
thresholds: {
|
|
13
|
+
lines: 90,
|
|
14
|
+
functions: 90,
|
|
15
|
+
branches: 90,
|
|
16
|
+
statements: 90,
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
});
|
|
@@ -23,6 +23,27 @@ main = "src/worker.ts"
|
|
|
23
23
|
compatibility_date = "2024-11-01"
|
|
24
24
|
compatibility_flags = ["nodejs_compat"]
|
|
25
25
|
|
|
26
|
+
## ---------------------------------------------------------------------------
|
|
27
|
+
## Observability.
|
|
28
|
+
## ---------------------------------------------------------------------------
|
|
29
|
+
## `observability` enables Workers Analytics: every `console.*` call the
|
|
30
|
+
## `ConnectionActor` emits via `ConsoleLogger` (one JSON line per record,
|
|
31
|
+
## stamped with `traceId` + `connectionId`) is streamed into the Workers
|
|
32
|
+
## Observability pipeline. Tail the live feed with `wrangler tail`, build
|
|
33
|
+
## dashboards on the Workers Analytics tab, or export via Logpush.
|
|
34
|
+
## The structured `msg` field names below (`frame.receive`, `dispatch`,
|
|
35
|
+
## `dispatch.error`, `frame.parse-error`) are the canonical query keys.
|
|
36
|
+
[observability]
|
|
37
|
+
enabled = true
|
|
38
|
+
## `head_sampling_rate` keeps 100% of console output — valuable during v1
|
|
39
|
+
## and consistent with the per-frame traceId scheme (every request is
|
|
40
|
+
## uniquely identifiable). Lower this (e.g. 0.1) once traffic grows.
|
|
41
|
+
head_sampling_rate = 1
|
|
42
|
+
|
|
43
|
+
[observability.logs]
|
|
44
|
+
enabled = true
|
|
45
|
+
invocation_logs = true
|
|
46
|
+
|
|
26
47
|
## Observable per-deployment knobs. Override per environment below.
|
|
27
48
|
## Production deployments SHOULD override `SERVER_NAME` to the public
|
|
28
49
|
## hostname clients will see in numerics (001, 005, etc.).
|
|
@@ -40,6 +61,10 @@ MOTD_LINES = "Welcome to ServerlessIRCd.\nThis is a deployed Cloudflare Worker."
|
|
|
40
61
|
[env.staging]
|
|
41
62
|
name = "serverless-ircd-staging"
|
|
42
63
|
|
|
64
|
+
[env.staging.observability]
|
|
65
|
+
enabled = true
|
|
66
|
+
head_sampling_rate = 1
|
|
67
|
+
|
|
43
68
|
[env.staging.vars]
|
|
44
69
|
SERVER_NAME = "irc-staging.example.com"
|
|
45
70
|
NETWORK_NAME = "ServerlessIRCd (staging)"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local-cli config loader.
|
|
3
|
+
*
|
|
4
|
+
* Both adapters (Cloudflare Workers via `loadServerConfigFromCfEnv`, and
|
|
5
|
+
* this local harness via `loadServerConfigFromCliArgs`) consume the
|
|
6
|
+
* single `ServerConfigSchema` shipped from `irc-core`. The local-cli
|
|
7
|
+
* accepts already-typed values (no env-string coercion needed) and
|
|
8
|
+
* validates them through the shared schema so misconfigurations are
|
|
9
|
+
* surfaced at boot rather than mid-session.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { type ParsedServerConfig, parseServerConfig } from '@serverless-ircd/irc-core';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* CLI / programmatic input for `loadServerConfigFromCliArgs`. Every
|
|
16
|
+
* field is optional except the two required schema fields
|
|
17
|
+
* (`serverName`, `networkName`); the schema applies defaults to the
|
|
18
|
+
* rest. Field types match the schema's input shape (numbers stay
|
|
19
|
+
* numbers, etc.) so callers don't need to coerce strings.
|
|
20
|
+
*/
|
|
21
|
+
export interface CliConfigInput {
|
|
22
|
+
serverName: string;
|
|
23
|
+
networkName: string;
|
|
24
|
+
motdLines?: string[];
|
|
25
|
+
channelPrefixes?: string;
|
|
26
|
+
maxClients?: number;
|
|
27
|
+
operCreds?: Array<{ user: string; password: string }>;
|
|
28
|
+
maxChannelsPerUser?: number;
|
|
29
|
+
maxTargetsPerCommand?: number;
|
|
30
|
+
maxListEntries?: number;
|
|
31
|
+
nickLen?: number;
|
|
32
|
+
channelLen?: number;
|
|
33
|
+
topicLen?: number;
|
|
34
|
+
quitMessage?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Validates CLI-supplied config against the shared
|
|
39
|
+
* {@link ServerConfigSchema} from `irc-core`.
|
|
40
|
+
*
|
|
41
|
+
* Throws a single readable `Error` listing every invalid field if the
|
|
42
|
+
* input fails validation. Adapters MUST call this once at boot —
|
|
43
|
+
* `startLocalServer` already calls it via its options normalisation.
|
|
44
|
+
*/
|
|
45
|
+
export function loadServerConfigFromCliArgs(input: CliConfigInput): ParsedServerConfig {
|
|
46
|
+
return parseServerConfig(stripUndefined(input));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Returns a shallow copy of `obj` with `undefined` values dropped. */
|
|
50
|
+
function stripUndefined(obj: CliConfigInput): Record<string, unknown> {
|
|
51
|
+
const out: Record<string, unknown> = {};
|
|
52
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
53
|
+
if (v !== undefined) out[k] = v;
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|