serverless-ircd 0.1.0 → 0.3.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 (204) hide show
  1. package/.github/workflows/ci.yml +96 -2
  2. package/.github/workflows/deploy-aws.yml +129 -0
  3. package/.github/workflows/deploy-cf.yml +0 -2
  4. package/.gitmodules +3 -0
  5. package/CHANGELOG.md +436 -0
  6. package/README.md +127 -84
  7. package/apps/aws-stack/README.md +73 -0
  8. package/apps/aws-stack/bin/aws.ts +49 -0
  9. package/apps/aws-stack/cdk.json +10 -0
  10. package/apps/aws-stack/package.json +41 -0
  11. package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
  12. package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
  13. package/apps/aws-stack/scripts/smoke.mjs +142 -0
  14. package/apps/aws-stack/src/aws-stack.ts +263 -0
  15. package/apps/aws-stack/src/tables.ts +10 -0
  16. package/apps/aws-stack/tests/localstack.test.ts +46 -0
  17. package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
  18. package/apps/aws-stack/tests/stack.test.ts +464 -0
  19. package/apps/aws-stack/tsconfig.build.json +11 -0
  20. package/apps/aws-stack/tsconfig.test.json +8 -0
  21. package/apps/aws-stack/vitest.config.ts +20 -0
  22. package/apps/cf-worker/package.json +1 -1
  23. package/apps/cf-worker/scripts/smoke.mjs +0 -0
  24. package/apps/cf-worker/src/worker.ts +8 -2
  25. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  26. package/apps/cf-worker/wrangler.test.toml +5 -1
  27. package/apps/cf-worker/wrangler.toml +66 -17
  28. package/apps/local-cli/package.json +1 -1
  29. package/apps/local-cli/src/config-loader.ts +57 -0
  30. package/apps/local-cli/src/main.ts +1 -1
  31. package/apps/local-cli/src/server.ts +267 -32
  32. package/apps/local-cli/tests/config-loader.test.ts +107 -0
  33. package/apps/local-cli/tests/e2e.test.ts +126 -0
  34. package/apps/local-cli/tests/security-e2e.test.ts +239 -0
  35. package/biome.json +3 -1
  36. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  37. package/docs/ADR-002-location-of-authority.md +82 -0
  38. package/docs/ADR-003-durable-object-sharding.md +93 -0
  39. package/docs/ADR-004-dynamodb-schema.md +96 -0
  40. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  41. package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
  42. package/docs/ADR-007-deterministic-ports.md +82 -0
  43. package/docs/ADR-008-monorepo-tooling.md +60 -0
  44. package/docs/AWS-Adapter-Architecture.md +496 -0
  45. package/docs/AWS-Deployment.md +1186 -0
  46. package/docs/Cloudflare-Deployment-Guide.md +660 -0
  47. package/docs/Home.md +11 -0
  48. package/docs/Observability.md +87 -0
  49. package/docs/PlanIRCv3Websocket.md +489 -0
  50. package/docs/PlanWebClient.md +451 -0
  51. package/docs/Release-Process.md +443 -0
  52. package/package.json +19 -14
  53. package/packages/aws-adapter/README.md +35 -0
  54. package/packages/aws-adapter/package.json +52 -0
  55. package/packages/aws-adapter/src/account-store.ts +121 -0
  56. package/packages/aws-adapter/src/aws-runtime.ts +871 -0
  57. package/packages/aws-adapter/src/cdk-table-defs.ts +73 -0
  58. package/packages/aws-adapter/src/config-loader.ts +126 -0
  59. package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
  60. package/packages/aws-adapter/src/dynamo.ts +61 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +44 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +330 -0
  63. package/packages/aws-adapter/src/handlers/disconnect.ts +48 -0
  64. package/packages/aws-adapter/src/handlers/index.ts +293 -0
  65. package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
  66. package/packages/aws-adapter/src/handlers/state.ts +13 -0
  67. package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
  68. package/packages/aws-adapter/src/index.ts +74 -0
  69. package/packages/aws-adapter/src/message-store.ts +34 -0
  70. package/packages/aws-adapter/src/serialize.ts +283 -0
  71. package/packages/aws-adapter/src/tables.ts +53 -0
  72. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
  73. package/packages/aws-adapter/tests/account-store.test.ts +280 -0
  74. package/packages/aws-adapter/tests/aws-harness.ts +408 -0
  75. package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
  76. package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
  77. package/packages/aws-adapter/tests/config-loader.test.ts +213 -0
  78. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
  79. package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
  80. package/packages/aws-adapter/tests/global-setup.ts +301 -0
  81. package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
  82. package/packages/aws-adapter/tests/handlers.test.ts +473 -0
  83. package/packages/aws-adapter/tests/message-store.test.ts +55 -0
  84. package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
  85. package/packages/aws-adapter/tests/serialize.test.ts +249 -0
  86. package/packages/aws-adapter/tests/smoke.test.ts +48 -0
  87. package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
  88. package/packages/aws-adapter/tests/tables.test.ts +74 -0
  89. package/packages/aws-adapter/tests/transactions.test.ts +446 -0
  90. package/packages/aws-adapter/tsconfig.build.json +16 -0
  91. package/packages/aws-adapter/tsconfig.test.json +14 -0
  92. package/packages/aws-adapter/vitest.config.ts +23 -0
  93. package/packages/cf-adapter/package.json +2 -1
  94. package/packages/cf-adapter/src/cf-runtime.ts +42 -22
  95. package/packages/cf-adapter/src/channel-do.ts +40 -1
  96. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  97. package/packages/cf-adapter/src/config-loader.ts +135 -0
  98. package/packages/cf-adapter/src/connection-do.ts +119 -0
  99. package/packages/cf-adapter/src/env.ts +27 -1
  100. package/packages/cf-adapter/src/index.ts +2 -1
  101. package/packages/cf-adapter/tests/cf-harness.ts +2 -2
  102. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
  103. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  104. package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
  105. package/packages/cf-adapter/tests/connection-do.test.ts +82 -0
  106. package/packages/cf-adapter/tests/worker/main.ts +2 -1
  107. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  108. package/packages/cf-adapter/wrangler.test.toml +10 -1
  109. package/packages/in-memory-runtime/package.json +1 -1
  110. package/packages/in-memory-runtime/src/in-memory-runtime.ts +107 -16
  111. package/packages/in-memory-runtime/src/index.ts +1 -1
  112. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +134 -0
  113. package/packages/irc-core/package.json +13 -2
  114. package/packages/irc-core/src/admission.ts +216 -0
  115. package/packages/irc-core/src/caps/capabilities.ts +1 -0
  116. package/packages/irc-core/src/case-fold.ts +64 -0
  117. package/packages/irc-core/src/cloak.ts +81 -0
  118. package/packages/irc-core/src/commands/cap.ts +1 -2
  119. package/packages/irc-core/src/commands/chathistory.ts +305 -0
  120. package/packages/irc-core/src/commands/index.ts +9 -0
  121. package/packages/irc-core/src/commands/invite.ts +6 -9
  122. package/packages/irc-core/src/commands/isupport.ts +1 -1
  123. package/packages/irc-core/src/commands/join.ts +63 -10
  124. package/packages/irc-core/src/commands/kick.ts +4 -11
  125. package/packages/irc-core/src/commands/list.ts +5 -4
  126. package/packages/irc-core/src/commands/mode.ts +5 -9
  127. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  128. package/packages/irc-core/src/commands/motd.ts +6 -110
  129. package/packages/irc-core/src/commands/oper.ts +151 -0
  130. package/packages/irc-core/src/commands/privmsg.ts +24 -0
  131. package/packages/irc-core/src/commands/registration.ts +79 -21
  132. package/packages/irc-core/src/commands/sasl.ts +251 -0
  133. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  134. package/packages/irc-core/src/config.ts +270 -0
  135. package/packages/irc-core/src/flood-control.ts +175 -0
  136. package/packages/irc-core/src/index.ts +7 -0
  137. package/packages/irc-core/src/ports.ts +719 -0
  138. package/packages/irc-core/src/protocol/base64.ts +16 -0
  139. package/packages/irc-core/src/protocol/index.ts +2 -1
  140. package/packages/irc-core/src/protocol/numerics.ts +11 -0
  141. package/packages/irc-core/src/protocol/parser.ts +27 -2
  142. package/packages/irc-core/src/state/connection.ts +41 -0
  143. package/packages/irc-core/src/types.ts +88 -2
  144. package/packages/irc-core/stryker.commands.conf.json +41 -0
  145. package/packages/irc-core/stryker.protocol.conf.json +26 -0
  146. package/packages/irc-core/tests/account-store.test.ts +88 -0
  147. package/packages/irc-core/tests/admission.test.ts +229 -0
  148. package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
  149. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  150. package/packages/irc-core/tests/cloak.test.ts +78 -0
  151. package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
  152. package/packages/irc-core/tests/commands/join.test.ts +246 -2
  153. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  154. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  155. package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
  156. package/packages/irc-core/tests/commands/registration.test.ts +380 -20
  157. package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
  158. package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
  159. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  160. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  161. package/packages/irc-core/tests/config.test.ts +721 -0
  162. package/packages/irc-core/tests/flood-control.test.ts +394 -0
  163. package/packages/irc-core/tests/message-store.test.ts +530 -0
  164. package/packages/irc-core/tests/parser.test.ts +44 -1
  165. package/packages/irc-core/tests/ports.test.ts +263 -0
  166. package/packages/irc-server/package.json +1 -1
  167. package/packages/irc-server/src/actor.ts +186 -44
  168. package/packages/irc-server/src/dispatch.ts +89 -5
  169. package/packages/irc-server/src/index.ts +2 -0
  170. package/packages/irc-server/src/routing.ts +160 -0
  171. package/packages/irc-server/tests/actor.test.ts +690 -15
  172. package/packages/irc-server/tests/dispatch.test.ts +84 -0
  173. package/packages/irc-server/tests/routing.test.ts +204 -0
  174. package/packages/irc-test-support/README.md +32 -0
  175. package/packages/irc-test-support/package.json +37 -0
  176. package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +6 -5
  177. package/packages/irc-test-support/src/index.ts +24 -0
  178. package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
  179. package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
  180. package/packages/irc-test-support/tests/smoke.test.ts +11 -0
  181. package/packages/irc-test-support/tsconfig.build.json +16 -0
  182. package/packages/irc-test-support/tsconfig.test.json +14 -0
  183. package/packages/irc-test-support/vitest.config.ts +20 -0
  184. package/pnpm-workspace.yaml +1 -0
  185. package/tools/ci-hardening/package.json +30 -0
  186. package/tools/ci-hardening/src/index.ts +6 -0
  187. package/tools/ci-hardening/src/validate.ts +103 -0
  188. package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
  189. package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
  190. package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
  191. package/tools/ci-hardening/tests/validate.test.ts +177 -0
  192. package/tools/ci-hardening/tsconfig.build.json +12 -0
  193. package/tools/ci-hardening/tsconfig.test.json +10 -0
  194. package/tools/ci-hardening/vitest.config.ts +21 -0
  195. package/tools/seed-aws-accounts.ts +80 -0
  196. package/tools/tcp-ws-forwarder/package.json +1 -1
  197. package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
  198. package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
  199. package/tools/tcp-ws-forwarder/src/main.ts +33 -14
  200. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
  201. package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
  202. package/packages/cf-adapter/tests/integration/index.ts +0 -17
  203. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
  204. /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,11 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "composite": true,
5
+ "rootDir": "./src",
6
+ "outDir": "./dist",
7
+ "tsBuildInfoFile": "./.tsbuildinfo"
8
+ },
9
+ "include": ["src/**/*.ts"],
10
+ "exclude": ["dist", "tests", "**/*.test.ts"]
11
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "noEmit": true
5
+ },
6
+ "include": ["src/**/*.ts", "tests/**/*.ts", "bin/**/*.ts"],
7
+ "exclude": ["dist"]
8
+ }
@@ -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
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/cf-worker",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "private": true,
5
5
  "description": "Cloudflare Worker deploy glue: WebSocket edge entry point + DO bindings + wrangler pipeline",
6
6
  "license": "BSD-3-Clause",
File without changes
@@ -22,11 +22,17 @@
22
22
  * See `wrangler.toml` and `docs/deployment-cf.md`.
23
23
  */
24
24
 
25
- import { ChannelDO, ConnectionDO, type Env, RegistryDO } from '@serverless-ircd/cf-adapter';
25
+ import {
26
+ ChannelDO,
27
+ ChannelRegistryDO,
28
+ ConnectionDO,
29
+ type Env,
30
+ RegistryDO,
31
+ } from '@serverless-ircd/cf-adapter';
26
32
 
27
33
  // Re-exported for wrangler DO binding resolution. The class names below
28
34
  // MUST match `class_name` in `wrangler.toml`.
29
- export { ChannelDO, ConnectionDO, RegistryDO };
35
+ export { ChannelDO, ChannelRegistryDO, ConnectionDO, RegistryDO };
30
36
  export type { Env };
31
37
 
32
38
  /**
@@ -26,6 +26,7 @@ declare global {
26
26
  CONNECTION_DO: DurableObjectNamespace;
27
27
  REGISTRY_DO: DurableObjectNamespace;
28
28
  CHANNEL_DO: DurableObjectNamespace;
29
+ CHANNEL_REGISTRY_DO: DurableObjectNamespace;
29
30
  SERVER_NAME: string;
30
31
  NETWORK_NAME: string;
31
32
  MOTD_LINES: string;
@@ -28,6 +28,10 @@ class_name = "RegistryDO"
28
28
  name = "CHANNEL_DO"
29
29
  class_name = "ChannelDO"
30
30
 
31
+ [[durable_objects.bindings]]
32
+ name = "CHANNEL_REGISTRY_DO"
33
+ class_name = "ChannelRegistryDO"
34
+
31
35
  [[migrations]]
32
36
  tag = "v1"
33
- new_classes = ["ConnectionDO", "RegistryDO", "ChannelDO"]
37
+ new_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO"]
@@ -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)"
@@ -66,6 +91,10 @@ class_name = "RegistryDO"
66
91
  name = "CHANNEL_DO"
67
92
  class_name = "ChannelDO"
68
93
 
94
+ [[durable_objects.bindings]]
95
+ name = "CHANNEL_REGISTRY_DO"
96
+ class_name = "ChannelRegistryDO"
97
+
69
98
  # Staging environment bindings (wrangler requires per-env blocks).
70
99
  [[env.staging.durable_objects.bindings]]
71
100
  name = "CONNECTION_DO"
@@ -79,20 +108,40 @@ class_name = "RegistryDO"
79
108
  name = "CHANNEL_DO"
80
109
  class_name = "ChannelDO"
81
110
 
82
- ## ---------------------------------------------------------------------------
83
- ## Durable Object migrations.
84
- ## ---------------------------------------------------------------------------
85
- ## `tag` is the user-defined migration id; wrangler refuses to deploy if
86
- ## the on-disk migrations don't match the live Workers state. `new_classes`
87
- ## declares the three DO classes on first deploy; subsequent schema
88
- ## changes (renames, deletions, splits) extend this list with their own
89
- ## `tag` so the runtime can migrate persisted state.
90
- ##
91
- ## See https://developers.cloudflare.com/durable-objects/reference/durable-objects-migrations/
92
- [[migrations]]
93
- tag = "v1"
94
- new_sqlite_classes = ["ConnectionDO", "RegistryDO", "ChannelDO"]
95
-
96
- [[env.staging.migrations]]
97
- tag = "v1"
98
- new_sqlite_classes = ["ConnectionDO", "RegistryDO", "ChannelDO"]
111
+ [[env.staging.durable_objects.bindings]]
112
+ name = "CHANNEL_REGISTRY_DO"
113
+ class_name = "ChannelRegistryDO"
114
+
115
+ # ## ---------------------------------------------------------------------------
116
+ # ## Durable Object migrations.
117
+ # ## ---------------------------------------------------------------------------
118
+ # ## `tag` is the user-defined migration id; wrangler refuses to deploy if
119
+ # ## the on-disk migrations don't match the live Workers state. `new_classes`
120
+ # ## declares the three DO classes on first deploy; subsequent schema
121
+ # ## changes (renames, deletions, splits) extend this list with their own
122
+ # ## `tag` so the runtime can migrate persisted state.
123
+ # ##
124
+ # ## See https://developers.cloudflare.com/durable-objects/reference/durable-objects-migrations/
125
+ # [[migrations]]
126
+ # tag = "v1"
127
+ # new_sqlite_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO"]
128
+
129
+ # [[env.staging.migrations]]
130
+ # tag = "v1"
131
+ # new_sqlite_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO"]
132
+
133
+ [exports.ConnectionDO]
134
+ type = "durable-object"
135
+ storage = "sqlite"
136
+
137
+ [exports.RegistryDO]
138
+ type = "durable-object"
139
+ storage = "sqlite"
140
+
141
+ [exports.ChannelDO]
142
+ type = "durable-object"
143
+ storage = "sqlite"
144
+
145
+ [exports.ChannelRegistryDO]
146
+ type = "durable-object"
147
+ storage = "sqlite"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/local-cli",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "private": true,
5
5
  "description": "Runnable WebSocket IRC server using the in-memory runtime; manual-test harness and e2e fixture target",
6
6
  "license": "BSD-3-Clause",