serverless-ircd 0.7.0 → 0.9.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 (168) hide show
  1. package/.github/workflows/ci.yml +7 -3
  2. package/.gitmodules +1 -1
  3. package/CHANGELOG.md +518 -29
  4. package/README.md +221 -161
  5. package/apps/aws-stack/package.json +1 -1
  6. package/apps/aws-stack/src/aws-stack.ts +186 -18
  7. package/apps/aws-stack/tests/stack.test.ts +400 -56
  8. package/apps/cf-tcp-container/package.json +1 -1
  9. package/apps/cf-tcp-container/src/container-server.ts +21 -1
  10. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  11. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  12. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  13. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  14. package/apps/cf-worker/package.json +1 -1
  15. package/apps/cf-worker/src/worker.ts +4 -4
  16. package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
  17. package/apps/cf-worker/tests/smoke.test.ts +5 -5
  18. package/apps/cf-worker/wrangler.test.toml +6 -6
  19. package/apps/cf-worker/wrangler.toml +7 -5
  20. package/apps/local-cli/package.json +1 -1
  21. package/apps/local-cli/src/config-loader.ts +8 -0
  22. package/apps/local-cli/src/main.ts +16 -0
  23. package/apps/local-cli/src/server.ts +95 -31
  24. package/apps/local-cli/tests/config-loader.test.ts +14 -0
  25. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  26. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  27. package/apps/local-cli/tests/rehash.test.ts +147 -0
  28. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  29. package/apps/local-cli/tests/tcp.test.ts +89 -0
  30. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  31. package/apps/web/landing/index.html +237 -16
  32. package/apps/web/package.json +3 -2
  33. package/apps/web/scripts/build.mjs +46 -16
  34. package/apps/web/src/build-env.ts +1 -1
  35. package/apps/web/src/config-schema.ts +6 -6
  36. package/apps/web/src/render-docs.ts +292 -0
  37. package/apps/web/tests/build-smoke.test.ts +43 -14
  38. package/apps/web/tests/config-schema.test.ts +1 -1
  39. package/apps/web/tests/landing-content.test.ts +103 -0
  40. package/apps/web/tests/render-docs.test.ts +198 -0
  41. package/docs/AWS-Adapter-Architecture.md +3 -2
  42. package/docs/AWS-Deployment.md +21 -8
  43. package/docs/Cloudflare-Deployment-Guide.md +22 -6
  44. package/docs/PlanExtensions.md +113 -3
  45. package/docs/Release-Process.md +23 -13
  46. package/docs/Services.md +578 -0
  47. package/docs/WebClientGuide.md +32 -31
  48. package/package.json +2 -2
  49. package/packages/aws-adapter/package.json +1 -1
  50. package/packages/aws-adapter/src/aws-runtime.ts +20 -1
  51. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
  52. package/packages/aws-adapter/src/config-loader.ts +11 -0
  53. package/packages/aws-adapter/src/connection-counter.ts +89 -0
  54. package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
  55. package/packages/aws-adapter/src/handlers/connect.ts +55 -51
  56. package/packages/aws-adapter/src/handlers/default.ts +36 -4
  57. package/packages/aws-adapter/src/handlers/index.ts +15 -0
  58. package/packages/aws-adapter/src/handlers/nlb-stream.ts +25 -2
  59. package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
  60. package/packages/aws-adapter/src/index.ts +4 -0
  61. package/packages/aws-adapter/src/stats.ts +6 -1
  62. package/packages/aws-adapter/src/tables.ts +34 -4
  63. package/packages/aws-adapter/tests/aws-harness.ts +3 -0
  64. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  65. package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
  66. package/packages/aws-adapter/tests/connect.test.ts +158 -32
  67. package/packages/aws-adapter/tests/connection-counter.test.ts +144 -0
  68. package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
  69. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
  70. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  71. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  72. package/packages/aws-adapter/tests/handlers.test.ts +105 -3
  73. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  74. package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
  75. package/packages/aws-adapter/tests/tables.test.ts +6 -1
  76. package/packages/cf-adapter/package.json +1 -1
  77. package/packages/cf-adapter/src/config-loader.ts +11 -0
  78. package/packages/cf-adapter/src/connection-do.ts +130 -8
  79. package/packages/cf-adapter/src/d1-services-store.ts +703 -0
  80. package/packages/cf-adapter/src/env.ts +8 -0
  81. package/packages/cf-adapter/src/index.ts +5 -0
  82. package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
  83. package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
  84. package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
  85. package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
  86. package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
  87. package/packages/cf-adapter/tests/serialize.test.ts +1 -0
  88. package/packages/in-memory-runtime/package.json +1 -1
  89. package/packages/irc-core/package.json +1 -1
  90. package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
  91. package/packages/irc-core/src/commands/account-auth.ts +200 -0
  92. package/packages/irc-core/src/commands/chanserv.ts +1166 -0
  93. package/packages/irc-core/src/commands/hostserv.ts +522 -0
  94. package/packages/irc-core/src/commands/index.ts +13 -0
  95. package/packages/irc-core/src/commands/join.ts +171 -9
  96. package/packages/irc-core/src/commands/markread.ts +202 -0
  97. package/packages/irc-core/src/commands/memoserv.ts +319 -0
  98. package/packages/irc-core/src/commands/mode.ts +96 -4
  99. package/packages/irc-core/src/commands/nickserv.ts +402 -0
  100. package/packages/irc-core/src/commands/oper.ts +18 -1
  101. package/packages/irc-core/src/commands/operserv.ts +346 -0
  102. package/packages/irc-core/src/commands/pre-away.ts +3 -1
  103. package/packages/irc-core/src/commands/privmsg.ts +42 -0
  104. package/packages/irc-core/src/commands/read-marker.ts +8 -8
  105. package/packages/irc-core/src/commands/registration.ts +78 -12
  106. package/packages/irc-core/src/commands/sasl.ts +18 -49
  107. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  108. package/packages/irc-core/src/commands/tagmsg.ts +41 -6
  109. package/packages/irc-core/src/commands/topic.ts +60 -10
  110. package/packages/irc-core/src/config.ts +36 -5
  111. package/packages/irc-core/src/effects.ts +56 -1
  112. package/packages/irc-core/src/ports.ts +1653 -84
  113. package/packages/irc-core/src/protocol/numerics.ts +8 -0
  114. package/packages/irc-core/src/state/channel.ts +38 -1
  115. package/packages/irc-core/src/state/connection.ts +25 -1
  116. package/packages/irc-core/src/types.ts +48 -12
  117. package/packages/irc-core/tests/commands/chanserv.test.ts +2335 -0
  118. package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
  119. package/packages/irc-core/tests/commands/hostserv.test.ts +1006 -0
  120. package/packages/irc-core/tests/commands/join.test.ts +572 -1
  121. package/packages/irc-core/tests/commands/markread.test.ts +361 -0
  122. package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
  123. package/packages/irc-core/tests/commands/mode.test.ts +381 -2
  124. package/packages/irc-core/tests/commands/nickserv.test.ts +990 -0
  125. package/packages/irc-core/tests/commands/oper.test.ts +13 -0
  126. package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
  127. package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
  128. package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
  129. package/packages/irc-core/tests/commands/registration.test.ts +1015 -20
  130. package/packages/irc-core/tests/commands/sasl.test.ts +229 -12
  131. package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
  132. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  133. package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
  134. package/packages/irc-core/tests/commands/topic.test.ts +94 -2
  135. package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
  136. package/packages/irc-core/tests/config.test.ts +49 -5
  137. package/packages/irc-core/tests/effects.test.ts +19 -0
  138. package/packages/irc-core/tests/message-store.test.ts +63 -0
  139. package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
  140. package/packages/irc-core/tests/services-store.test.ts +1289 -0
  141. package/packages/irc-core/tests/state/channel.test.ts +3 -0
  142. package/packages/irc-server/package.json +1 -1
  143. package/packages/irc-server/src/actor.ts +121 -16
  144. package/packages/irc-server/src/dispatch.ts +94 -7
  145. package/packages/irc-server/src/routing.ts +19 -0
  146. package/packages/irc-server/tests/actor.test.ts +988 -15
  147. package/packages/irc-server/tests/dispatch.test.ts +270 -2
  148. package/packages/irc-server/tests/routing.test.ts +6 -0
  149. package/packages/irc-test-support/package.json +1 -1
  150. package/packages/irc-test-support/src/in-memory-harness.ts +37 -8
  151. package/packages/irc-test-support/src/index.ts +1 -0
  152. package/packages/irc-test-support/src/scenarios.ts +21 -6
  153. package/packages/irc-test-support/tests/in-memory-harness.test.ts +51 -0
  154. package/packages/irc-test-support/vitest.config.ts +6 -1
  155. package/tools/ci-hardening/package.json +1 -1
  156. package/tools/load-test/package.json +1 -1
  157. package/tools/load-test/src/client.ts +13 -13
  158. package/tools/load-test/tests/client.test.ts +258 -2
  159. package/tools/load-test/tests/config.test.ts +39 -0
  160. package/tools/load-test/tests/harness.test.ts +21 -0
  161. package/tools/load-test/tests/metrics.test.ts +7 -0
  162. package/tools/tcp-ws-forwarder/package.json +1 -1
  163. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  164. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  165. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +53 -2
  166. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  167. package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
  168. package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
@@ -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 — five DynamoDB tables with the PLAN §4 schema, a single
6
+ * produce — six 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: '*'`.
@@ -44,10 +44,44 @@ function makeTemplate(environmentName?: string): { app: App; stack: Stack; templ
44
44
  return { app, stack, template };
45
45
  }
46
46
 
47
+ /** Returns the synthesised CloudFormation `Parameters` section as a record. */
48
+ function parameters(
49
+ template: Template,
50
+ ): Record<string, { Type: string; Default?: string; Description?: string }> {
51
+ const tmpl = template.toJSON() as {
52
+ Parameters?: Record<string, { Type: string; Default?: string; Description?: string }>;
53
+ };
54
+ return tmpl.Parameters ?? {};
55
+ }
56
+
57
+ /**
58
+ * Finds the `AWS::Events::Rule` whose target `Arn` references a Lambda
59
+ * with the given logical-id prefix (CDK appends a hash suffix, e.g.
60
+ * `IrcSweeper7D484D9E`). Match is via `Fn::GetAtt: [<id>, Arn]`.
61
+ */
62
+ function findRuleTargetingLambda(
63
+ template: Template,
64
+ fnIdPrefix: string,
65
+ ):
66
+ | { Properties?: { ScheduleExpression?: unknown; Targets?: Array<{ Arn?: unknown }> } }
67
+ | undefined {
68
+ const rules = template.findResources('AWS::Events::Rule') as Record<
69
+ string,
70
+ { Properties?: { Targets?: Array<{ Arn?: unknown }> } }
71
+ >;
72
+ return Object.values(rules).find((rule) =>
73
+ (rule.Properties?.Targets ?? []).some((t) => {
74
+ const getAtt = (t.Arn as { 'Fn::GetAtt'?: string[] } | undefined)?.['Fn::GetAtt'];
75
+ const id = Array.isArray(getAtt) ? getAtt[0] : undefined;
76
+ return typeof id === 'string' && id.startsWith(fnIdPrefix);
77
+ }),
78
+ );
79
+ }
80
+
47
81
  describe('IrcAwsStack — DynamoDB tables', () => {
48
- it('creates exactly five DynamoDB tables', () => {
82
+ it('creates exactly six DynamoDB tables', () => {
49
83
  const { template } = makeTemplate();
50
- template.resourceCountIs('AWS::DynamoDB::Table', 5);
84
+ template.resourceCountIs('AWS::DynamoDB::Table', 6);
51
85
  });
52
86
 
53
87
  it.each(TABLE_NAMES)('creates the %s table', (tableName) => {
@@ -125,6 +159,21 @@ describe('IrcAwsStack — DynamoDB tables', () => {
125
159
  KeySchema: [{ AttributeName: 'account', KeyType: 'HASH' }],
126
160
  });
127
161
  });
162
+
163
+ it('keys the Services table by composite pk/sk (String/String)', () => {
164
+ const { template } = makeTemplate();
165
+ template.hasResourceProperties('AWS::DynamoDB::Table', {
166
+ TableName: prefixedTableName(DEFAULT_ENV, 'Services'),
167
+ AttributeDefinitions: [
168
+ { AttributeName: 'pk', AttributeType: 'S' },
169
+ { AttributeName: 'sk', AttributeType: 'S' },
170
+ ],
171
+ KeySchema: [
172
+ { AttributeName: 'pk', KeyType: 'HASH' },
173
+ { AttributeName: 'sk', KeyType: 'RANGE' },
174
+ ],
175
+ });
176
+ });
128
177
  });
129
178
 
130
179
  describe('IrcAwsStack — API Gateway v2 WebSocket API', () => {
@@ -171,7 +220,7 @@ describe('IrcAwsStack — IRCv3 WebSocket subprotocol echo', () => {
171
220
  const { template } = makeTemplate();
172
221
  template.hasResourceProperties('AWS::ApiGatewayV2::IntegrationResponse', {
173
222
  ResponseParameters: {
174
- 'method.response.header.Sec-WebSocket-Protocol':
223
+ 'route.response.header.Sec-WebSocket-Protocol':
175
224
  'integration.response.header.Sec-WebSocket-Protocol',
176
225
  },
177
226
  });
@@ -197,6 +246,163 @@ describe('IrcAwsStack — IRCv3 WebSocket subprotocol echo', () => {
197
246
  });
198
247
  });
199
248
 
249
+ describe('IrcAwsStack — API Gateway logging', () => {
250
+ /** Returns the synthesised CloudFormation `Parameters` section. */
251
+ function parameters(
252
+ template: Template,
253
+ ): Record<
254
+ string,
255
+ { Type: string; Default?: string; AllowedValues?: string[]; Description?: string }
256
+ > {
257
+ const tmpl = template.toJSON() as {
258
+ Parameters?: Record<
259
+ string,
260
+ {
261
+ Type: string;
262
+ Default?: string;
263
+ AllowedValues?: string[];
264
+ Description?: string;
265
+ }
266
+ >;
267
+ };
268
+ return tmpl.Parameters ?? {};
269
+ }
270
+
271
+ it('exposes ApiGatewayLoggingLevel as a configurable String parameter (default INFO)', () => {
272
+ const { template } = makeTemplate();
273
+ const p = parameters(template).ApiGatewayLoggingLevel;
274
+ expect(p).toBeDefined();
275
+ expect(p?.Type).toBe('String');
276
+ expect(p?.Default).toBe('INFO');
277
+ expect(typeof p?.Description).toBe('string');
278
+ expect(p?.Description?.length).toBeGreaterThan(0);
279
+ });
280
+
281
+ it('restricts ApiGatewayLoggingLevel to OFF/INFO/ERROR', () => {
282
+ const { template } = makeTemplate();
283
+ expect(parameters(template).ApiGatewayLoggingLevel?.AllowedValues).toEqual([
284
+ 'OFF',
285
+ 'INFO',
286
+ 'ERROR',
287
+ ]);
288
+ });
289
+
290
+ it('wires the stage execution logging level to the parameter (Ref) with data tracing on', () => {
291
+ const { template } = makeTemplate();
292
+ template.hasResourceProperties('AWS::ApiGatewayV2::Stage', {
293
+ DefaultRouteSettings: {
294
+ LoggingLevel: { Ref: 'ApiGatewayLoggingLevel' },
295
+ DataTraceEnabled: true,
296
+ },
297
+ });
298
+ });
299
+
300
+ it('creates a CloudWatch LogGroup for API Gateway access logs', () => {
301
+ const { template } = makeTemplate();
302
+ template.resourceCountIs('AWS::Logs::LogGroup', 1);
303
+ });
304
+
305
+ it('configures the API Gateway account CloudWatch role so execution logs can be written', () => {
306
+ const { template } = makeTemplate();
307
+ template.hasResourceProperties('AWS::ApiGateway::Account', {});
308
+ });
309
+
310
+ it('wires the stage access log destination to the LogGroup', () => {
311
+ const { template } = makeTemplate();
312
+ const stages = template.findResources('AWS::ApiGatewayV2::Stage') as Record<
313
+ string,
314
+ { Properties?: { AccessLogSettings?: { DestinationArn?: unknown } } }
315
+ >;
316
+ const stage = Object.values(stages)[0];
317
+ expect(stage?.Properties?.AccessLogSettings?.DestinationArn).toBeDefined();
318
+ });
319
+
320
+ it('makes the stage depend on the account CloudWatch role so logging can enable', () => {
321
+ // Without an explicit DependsOn, CloudFormation may apply the Stage's
322
+ // logging config before the AWS::ApiGateway::Account resource sets the
323
+ // account-wide CloudWatch role, and APIGW rejects the update:
324
+ // "CloudWatch Logs role ARN must be set in account settings to enable logging"
325
+ const { template } = makeTemplate();
326
+ const accountIds = Object.keys(template.findResources('AWS::ApiGateway::Account'));
327
+ expect(accountIds).toHaveLength(1);
328
+ const stages = template.findResources('AWS::ApiGatewayV2::Stage') as Record<
329
+ string,
330
+ { DependsOn?: string[] }
331
+ >;
332
+ const stage = Object.values(stages)[0];
333
+ expect(stage?.DependsOn).toBeDefined();
334
+ expect(stage?.DependsOn?.some((id) => accountIds.includes(id))).toBe(true);
335
+ });
336
+
337
+ it('makes the API Gateway Account depend on the logging role permissions policy', () => {
338
+ // APIGW validates the account CloudWatch role's CONTENT (trust AND the
339
+ // attached permissions) when it processes the AWS::ApiGateway::Account
340
+ // resource. CDK renders `addToPolicy` as a SEPARATE AWS::IAM::Policy
341
+ // resource, and the Account only references the role Arn, so without an
342
+ // explicit DependsOn CloudFormation can apply the Account after the Role
343
+ // is created but BEFORE the policy attaches. APIGW then assumes a
344
+ // permission-less role and rejects the update:
345
+ // "The role ARN does not have required permissions configured."
346
+ const { template } = makeTemplate();
347
+ const accounts = template.findResources('AWS::ApiGateway::Account') as Record<
348
+ string,
349
+ { DependsOn?: string[] }
350
+ >;
351
+ expect(Object.keys(accounts)).toHaveLength(1);
352
+ const account = Object.values(accounts)[0];
353
+ expect(
354
+ account?.DependsOn,
355
+ 'Account must depend on the logging role permissions policy',
356
+ ).toBeDefined();
357
+ const policyIds = new Set(Object.keys(template.findResources('AWS::IAM::Policy')));
358
+ expect(
359
+ account?.DependsOn?.some((id) => policyIds.has(id)),
360
+ `Account.DependsOn ${JSON.stringify(account?.DependsOn)} must include the logging role policy`,
361
+ ).toBe(true);
362
+ });
363
+
364
+ it('grants the logging role CloudWatch Logs write access across ALL log groups', () => {
365
+ // APIGW writes execution logs to its OWN AWS/ApiGateway... log groups
366
+ // (separate from this stack's access-log group), so the role must be
367
+ // able to write broadly — a single-log-group ARN scope is rejected by
368
+ // APIGW account-role validation. Defined inline (the
369
+ // AmazonAPIGatewayPushToCloudWatchLogs managed policy is not available
370
+ // in every partition); CDK renders the statement as a separate
371
+ // AWS::IAM::Policy attached to the role.
372
+ const { template } = makeTemplate();
373
+ const roles = template.findResources('AWS::IAM::Role') as Record<string, unknown>;
374
+ const loggingRoleEntry = Object.entries(roles).find(([id]) =>
375
+ id.startsWith('ApiGatewayLoggingRole'),
376
+ );
377
+ expect(loggingRoleEntry, 'expected an ApiGatewayLoggingRole').toBeDefined();
378
+ const loggingRoleId = loggingRoleEntry?.[0];
379
+ const policies = template.findResources('AWS::IAM::Policy') as Record<
380
+ string,
381
+ {
382
+ Properties?: {
383
+ Roles?: Array<{ Ref?: string }>;
384
+ PolicyDocument?: {
385
+ Statement?: Array<{ Action?: string | string[]; Resource?: unknown }>;
386
+ };
387
+ };
388
+ }
389
+ >;
390
+ const attached = Object.values(policies).find((p) =>
391
+ (p.Properties?.Roles ?? []).some((r) => r.Ref === loggingRoleId),
392
+ );
393
+ expect(attached, 'expected an inline policy attached to the logging role').toBeDefined();
394
+ const stmts = attached?.Properties?.PolicyDocument?.Statement ?? [];
395
+ const flat = (a: string | string[] | undefined): string[] =>
396
+ Array.isArray(a) ? a : a !== undefined ? [a] : [];
397
+ const actions = JSON.stringify(stmts.flatMap((s) => flat(s.Action)));
398
+ const resources = JSON.stringify(stmts.flatMap((s) => flat(s.Resource as string[])));
399
+ expect(actions).toContain('logs:PutLogEvents');
400
+ expect(actions).toContain('logs:CreateLogGroup');
401
+ // Broad scope — NOT narrowed to a single log-group ARN.
402
+ expect(resources).toContain('*');
403
+ });
404
+ });
405
+
200
406
  describe('IrcAwsStack — Lambda', () => {
201
407
  it('creates exactly three Lambda functions (handler + sweeper + pingChecker)', () => {
202
408
  const { template } = makeTemplate();
@@ -212,18 +418,13 @@ describe('IrcAwsStack — Lambda', () => {
212
418
  });
213
419
 
214
420
  describe('IrcAwsStack — gone-connection sweeper', () => {
215
- it('creates an EventBridge rule on a fixed schedule targeting the sweeper', () => {
421
+ it('creates an EventBridge rule targeting the sweeper Lambda', () => {
216
422
  const { template } = makeTemplate();
217
- template.hasResourceProperties('AWS::Events::Rule', {
218
- ScheduleExpression: 'rate(5 minutes)',
219
- });
423
+ expect(findRuleTargetingLambda(template, 'IrcSweeper')).toBeDefined();
220
424
  });
221
425
 
222
- it('wires the sweeper Lambda as an EventBridge target', () => {
426
+ it('grants EventBridge permission to invoke the sweeper', () => {
223
427
  const { template } = makeTemplate();
224
- template.hasResourceProperties('AWS::Events::Rule', {
225
- ScheduleExpression: 'rate(5 minutes)',
226
- });
227
428
  // EventBridge needs permission to invoke each scheduled target. APIGW
228
429
  // also creates one permission per route ($connect/$disconnect/$default),
229
430
  // so the total is 5: 3 APIGW + 2 EventBridge (sweeper + pingChecker).
@@ -237,16 +438,14 @@ describe('IrcAwsStack — gone-connection sweeper', () => {
237
438
  // Both Lambda roles must scope their DynamoDB actions to the concrete
238
439
  // table ARNs — the least-privilege suite below also asserts no '*'.
239
440
  const tables = template.findResources('AWS::DynamoDB::Table');
240
- expect(Object.keys(tables)).toHaveLength(5);
441
+ expect(Object.keys(tables)).toHaveLength(6);
241
442
  });
242
443
  });
243
444
 
244
445
  describe('IrcAwsStack — idle / PING checker', () => {
245
- it('creates an EventBridge rule firing every 1 minute', () => {
446
+ it('creates an EventBridge rule targeting the pingChecker Lambda', () => {
246
447
  const { template } = makeTemplate();
247
- template.hasResourceProperties('AWS::Events::Rule', {
248
- ScheduleExpression: 'rate(1 minute)',
249
- });
448
+ expect(findRuleTargetingLambda(template, 'IrcPingChecker')).toBeDefined();
250
449
  });
251
450
 
252
451
  it('creates exactly two EventBridge rules (sweeper + pingChecker)', () => {
@@ -294,6 +493,55 @@ describe('IrcAwsStack — idle / PING checker', () => {
294
493
  });
295
494
  });
296
495
 
496
+ describe('IrcAwsStack — schedule CloudFormation parameters', () => {
497
+ it('exposes SweeperScheduleExpression and PingCheckerScheduleExpression as parameters', () => {
498
+ const { template } = makeTemplate();
499
+ const keys = Object.keys(parameters(template));
500
+ expect(keys).toContain('SweeperScheduleExpression');
501
+ expect(keys).toContain('PingCheckerScheduleExpression');
502
+ });
503
+
504
+ it('declares SweeperScheduleExpression as a String parameter with a non-empty description', () => {
505
+ const { template } = makeTemplate();
506
+ const p = parameters(template).SweeperScheduleExpression;
507
+ expect(p).toBeDefined();
508
+ expect(p?.Type).toBe('String');
509
+ expect(typeof p?.Description).toBe('string');
510
+ expect(p?.Description?.length ?? 0).toBeGreaterThan(0);
511
+ });
512
+
513
+ it('declares PingCheckerScheduleExpression as a String parameter with a non-empty description', () => {
514
+ const { template } = makeTemplate();
515
+ const p = parameters(template).PingCheckerScheduleExpression;
516
+ expect(p).toBeDefined();
517
+ expect(p?.Type).toBe('String');
518
+ expect(typeof p?.Description).toBe('string');
519
+ expect(p?.Description?.length ?? 0).toBeGreaterThan(0);
520
+ });
521
+
522
+ it('defaults SweeperScheduleExpression to rate(5 minutes) when the prop is omitted', () => {
523
+ const { template } = makeTemplate();
524
+ expect(parameters(template).SweeperScheduleExpression?.Default).toBe('rate(5 minutes)');
525
+ });
526
+
527
+ it('defaults PingCheckerScheduleExpression to rate(1 minute) when the prop is omitted', () => {
528
+ const { template } = makeTemplate();
529
+ expect(parameters(template).PingCheckerScheduleExpression?.Default).toBe('rate(1 minute)');
530
+ });
531
+
532
+ it('wires the sweeper rule ScheduleExpression via Ref to the parameter', () => {
533
+ const { template } = makeTemplate();
534
+ const rule = findRuleTargetingLambda(template, 'IrcSweeper');
535
+ expect(rule?.Properties?.ScheduleExpression).toEqual({ Ref: 'SweeperScheduleExpression' });
536
+ });
537
+
538
+ it('wires the pingChecker rule ScheduleExpression via Ref to the parameter', () => {
539
+ const { template } = makeTemplate();
540
+ const rule = findRuleTargetingLambda(template, 'IrcPingChecker');
541
+ expect(rule?.Properties?.ScheduleExpression).toEqual({ Ref: 'PingCheckerScheduleExpression' });
542
+ });
543
+ });
544
+
297
545
  describe('IrcAwsStack — stack outputs', () => {
298
546
  it('emits a ConnectUrl output', () => {
299
547
  const { template } = makeTemplate();
@@ -333,10 +581,45 @@ describe('IrcAwsStack — least-privilege IAM', () => {
333
581
 
334
582
  it('never grants Resource: "*" in any IAM policy', () => {
335
583
  const { template } = makeTemplate();
584
+ // Documented exception: the API Gateway account CloudWatch role MUST
585
+ // use Resource: '*'. APIGW writes execution logs to its OWN
586
+ // AWS/ApiGateway... log groups (not this stack's access-log group), and
587
+ // rejects a role scoped to a single log-group ARN. Identify the role's
588
+ // policy by the apigateway service principal so the exception is narrow.
589
+ const apigwRoleIds = new Set(
590
+ Object.entries(template.findResources('AWS::IAM::Role'))
591
+ .filter(([, r]) => {
592
+ const stmts =
593
+ (
594
+ r as {
595
+ Properties?: {
596
+ AssumeRolePolicyDocument?: {
597
+ Statement?: Array<{ Principal?: { Service?: string } }>;
598
+ };
599
+ };
600
+ }
601
+ ).Properties?.AssumeRolePolicyDocument?.Statement ?? [];
602
+ return stmts.some((s) => s.Principal?.Service === 'apigateway.amazonaws.com');
603
+ })
604
+ .map(([id]) => id),
605
+ );
336
606
  const policies = template.findResources('AWS::IAM::Policy');
337
607
  for (const [, policy] of Object.entries(policies)) {
338
- const statements = (policy as { Properties: { PolicyDocument: { Statement: unknown[] } } })
339
- .Properties.PolicyDocument.Statement;
608
+ const props = (
609
+ policy as {
610
+ Properties: {
611
+ Roles?: Array<{ Ref?: string }>;
612
+ PolicyDocument: { Statement: unknown[] };
613
+ };
614
+ }
615
+ ).Properties;
616
+ const isApigwLoggingPolicy = (props.Roles ?? []).some(
617
+ (r) => r.Ref !== undefined && apigwRoleIds.has(r.Ref),
618
+ );
619
+ if (isApigwLoggingPolicy) {
620
+ continue;
621
+ }
622
+ const statements = props.PolicyDocument.Statement;
340
623
  for (const stmt of statements) {
341
624
  const resource = (stmt as { Resource?: unknown }).Resource;
342
625
  if (Array.isArray(resource)) {
@@ -410,8 +693,8 @@ describe('IrcAwsStack — environmentName prop (env isolation)', () => {
410
693
  { Properties: { TableName: string } }
411
694
  >,
412
695
  ).map((t) => t.Properties.TableName);
413
- expect(stagingTables).toHaveLength(5);
414
- expect(prodTables).toHaveLength(5);
696
+ expect(stagingTables).toHaveLength(6);
697
+ expect(prodTables).toHaveLength(6);
415
698
  expect(prodNames.every((n) => !stagingNames.has(n))).toBe(true);
416
699
  });
417
700
 
@@ -424,17 +707,17 @@ describe('IrcAwsStack — environmentName prop (env isolation)', () => {
424
707
  });
425
708
  });
426
709
 
427
- describe('IrcAwsStack — server identity props (configurable deploy identity)', () => {
710
+ describe('IrcAwsStack — server identity CloudFormation parameters', () => {
428
711
  /** Finds a Lambda function resource by its logical-id prefix (e.g. `IrcHandler`). */
429
712
  function findFunction(
430
713
  template: Template,
431
714
  idPrefix: string,
432
715
  ): {
433
- Properties?: { Environment?: { Variables?: Record<string, string> } };
716
+ Properties?: { Environment?: { Variables?: Record<string, unknown> } };
434
717
  } {
435
718
  const fns = template.findResources('AWS::Lambda::Function') as Record<
436
719
  string,
437
- { Properties?: { Environment?: { Variables?: Record<string, string> } } }
720
+ { Properties?: { Environment?: { Variables?: Record<string, unknown> } } }
438
721
  >;
439
722
  const found = Object.entries(fns).find(([id]) => id.startsWith(idPrefix));
440
723
  if (!found) {
@@ -443,70 +726,131 @@ describe('IrcAwsStack — server identity props (configurable deploy identity)',
443
726
  return found[1];
444
727
  }
445
728
 
446
- it('injects an overridden serverName into the handler Lambda env', () => {
729
+ it('exposes ServerName and NetworkName as CloudFormation parameters', () => {
730
+ const { template } = makeTemplate();
731
+ const keys = Object.keys(parameters(template));
732
+ expect(keys).toContain('ServerName');
733
+ expect(keys).toContain('NetworkName');
734
+ });
735
+
736
+ it('declares ServerName as a String parameter with a non-empty description', () => {
737
+ const { template } = makeTemplate();
738
+ const p = parameters(template).ServerName;
739
+ expect(p).toBeDefined();
740
+ expect(p?.Type).toBe('String');
741
+ expect(typeof p?.Description).toBe('string');
742
+ expect(p?.Description?.length).toBeGreaterThan(0);
743
+ });
744
+
745
+ it('declares NetworkName as a String parameter with a non-empty description', () => {
746
+ const { template } = makeTemplate();
747
+ const p = parameters(template).NetworkName;
748
+ expect(p).toBeDefined();
749
+ expect(p?.Type).toBe('String');
750
+ expect(typeof p?.Description).toBe('string');
751
+ expect(p?.Description?.length).toBeGreaterThan(0);
752
+ });
753
+
754
+ it('defaults ServerName to the non-placeholder fallback when the prop is omitted', () => {
755
+ const { template } = makeTemplate();
756
+ expect(parameters(template).ServerName?.Default).toBe('irc.localhost');
757
+ });
758
+
759
+ it('defaults NetworkName to the non-placeholder fallback when the prop is omitted', () => {
760
+ const { template } = makeTemplate();
761
+ expect(parameters(template).NetworkName?.Default).toBe('ExampleNet');
762
+ });
763
+
764
+ it('flows an overridden serverName prop into the parameter Default', () => {
447
765
  const app = new App();
448
766
  const stack = new IrcAwsStack(app, 'TestStack', { serverName: 'irc.my.net' });
449
767
  const template = Template.fromStack(stack);
450
- expect(
451
- findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.SERVER_NAME,
452
- ).toBe('irc.my.net');
768
+ expect(parameters(template).ServerName?.Default).toBe('irc.my.net');
453
769
  });
454
770
 
455
- it('flows the overridden serverName into the pingChecker Lambda env', () => {
771
+ it('flows an overridden networkName prop into the parameter Default', () => {
456
772
  const app = new App();
457
- const stack = new IrcAwsStack(app, 'TestStack', { serverName: 'irc.my.net' });
773
+ const stack = new IrcAwsStack(app, 'TestStack', { networkName: 'MyNet' });
458
774
  const template = Template.fromStack(stack);
459
- // Catches the previously duplicated hard-coded copy on IrcPingChecker.
775
+ expect(parameters(template).NetworkName?.Default).toBe('MyNet');
776
+ });
777
+
778
+ it('wires SERVER_NAME on the handler Lambda via Ref to the parameter', () => {
779
+ const { template } = makeTemplate();
780
+ expect(
781
+ findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.SERVER_NAME,
782
+ ).toEqual({ Ref: 'ServerName' });
783
+ });
784
+
785
+ it('wires SERVER_NAME on the pingChecker Lambda via Ref to the parameter', () => {
786
+ const { template } = makeTemplate();
460
787
  expect(
461
788
  findFunction(template, 'IrcPingChecker').Properties?.Environment?.Variables?.SERVER_NAME,
462
- ).toBe('irc.my.net');
789
+ ).toEqual({ Ref: 'ServerName' });
463
790
  });
464
791
 
465
- it('injects an overridden networkName into both handler and pingChecker', () => {
466
- const app = new App();
467
- const stack = new IrcAwsStack(app, 'TestStack', { networkName: 'MyNet' });
468
- const template = Template.fromStack(stack);
792
+ it('wires NETWORK_NAME on the handler Lambda via Ref to the parameter', () => {
793
+ const { template } = makeTemplate();
469
794
  expect(
470
795
  findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.NETWORK_NAME,
471
- ).toBe('MyNet');
796
+ ).toEqual({ Ref: 'NetworkName' });
797
+ });
798
+
799
+ it('wires NETWORK_NAME on the pingChecker Lambda via Ref to the parameter', () => {
800
+ const { template } = makeTemplate();
472
801
  expect(
473
802
  findFunction(template, 'IrcPingChecker').Properties?.Environment?.Variables?.NETWORK_NAME,
474
- ).toBe('MyNet');
803
+ ).toEqual({ Ref: 'NetworkName' });
475
804
  });
476
805
 
477
- it('joins motdLines with newline so the config loader splits them into separate lines', () => {
806
+ it('wires SERVER_NAME and NETWORK_NAME on the NLB handler via Ref when tcpTlsDomainName is set', () => {
478
807
  const app = new App();
479
- const stack = new IrcAwsStack(app, 'TestStack', { motdLines: ['line one', 'line two'] });
808
+ const stack = new IrcAwsStack(app, 'TestStack', { tcpTlsDomainName: 'irc.example.com' });
480
809
  const template = Template.fromStack(stack);
481
- expect(findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.MOTD).toBe(
482
- 'line one\nline two',
483
- );
810
+ expect(
811
+ findFunction(template, 'IrcNlbHandler').Properties?.Environment?.Variables?.SERVER_NAME,
812
+ ).toEqual({ Ref: 'ServerName' });
813
+ expect(
814
+ findFunction(template, 'IrcNlbHandler').Properties?.Environment?.Variables?.NETWORK_NAME,
815
+ ).toEqual({ Ref: 'NetworkName' });
484
816
  });
485
817
 
486
- it('applies a non-placeholder fallback server identity when the props are omitted', () => {
818
+ it('injects server identity into the sweeper Lambda (shared config loader requires it)', () => {
819
+ // sweeperHandler calls buildDepsFromEnv() -> loadServerConfigFromLambdaEnv(),
820
+ // which hard-requires SERVER_NAME / NETWORK_NAME (and reads MOTD). Omitting
821
+ // them makes every scheduled sweeper invocation throw "serverName: Required"
822
+ // — so the sweeper MUST receive the same server-identity env vars as the
823
+ // handler, wired through the same CloudFormation parameters.
487
824
  const { template } = makeTemplate();
488
825
  expect(
489
- findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.SERVER_NAME,
490
- ).toBe('irc.localhost');
826
+ findFunction(template, 'IrcSweeper').Properties?.Environment?.Variables?.SERVER_NAME,
827
+ ).toEqual({ Ref: 'ServerName' });
491
828
  expect(
492
- findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.NETWORK_NAME,
493
- ).toBe('ExampleNet');
829
+ findFunction(template, 'IrcSweeper').Properties?.Environment?.Variables?.NETWORK_NAME,
830
+ ).toEqual({ Ref: 'NetworkName' });
831
+ expect(
832
+ findFunction(template, 'IrcSweeper').Properties?.Environment?.Variables?.MOTD,
833
+ ).toBeDefined();
834
+ });
835
+
836
+ it('joins motdLines with newline so the config loader splits them into separate lines', () => {
837
+ const app = new App();
838
+ const stack = new IrcAwsStack(app, 'TestStack', { motdLines: ['line one', 'line two'] });
839
+ const template = Template.fromStack(stack);
840
+ expect(findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.MOTD).toBe(
841
+ 'line one\nline two',
842
+ );
494
843
  });
495
844
 
496
845
  it('default MOTD is newline-joined so the loader yields multiple lines (no comma)', () => {
497
846
  const { template } = makeTemplate();
498
847
  const motd =
499
- findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.MOTD ?? '';
848
+ (findFunction(template, 'IrcHandler').Properties?.Environment?.Variables?.MOTD as
849
+ | string
850
+ | undefined) ?? '';
500
851
  expect(motd.split('\n').length).toBeGreaterThanOrEqual(2);
501
852
  expect(motd).not.toContain(',');
502
853
  });
503
-
504
- it('does not inject server identity into the sweeper Lambda', () => {
505
- const { template } = makeTemplate();
506
- expect(
507
- findFunction(template, 'IrcSweeper').Properties?.Environment?.Variables?.SERVER_NAME,
508
- ).toBeUndefined();
509
- });
510
854
  });
511
855
 
512
856
  describe('IrcAwsStack — TCP+TLS NLB adapter', () => {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/cf-tcp-container",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "description": "Cloudflare Container TCP origin for the irc+tls :6697 transport — Spectrum terminates TLS at the edge, this container runs the IRC core over plaintext TCP",
6
6
  "license": "BSD-3-Clause",
@@ -95,6 +95,9 @@ interface ConnectionBindings {
95
95
  const DEFAULT_ID_FACTORY = {
96
96
  batchId: () => randomUUID(),
97
97
  nonce: () => randomUUID(),
98
+ // `sessionId` is required for `IdFactory` interface completeness; no
99
+ // current code path invokes it (only batchId/nonce/traceId are called).
100
+ // c8 ignore next 1
98
101
  sessionId: () => randomUUID(),
99
102
  traceId: () => randomUUID(),
100
103
  };
@@ -154,12 +157,21 @@ export async function startContainerServer(opts: StartContainerOptions): Promise
154
157
  const tcpServer = createServer((socket) => {
155
158
  sockets.add(socket);
156
159
 
160
+ // `remoteAddress` is always set on a TCP server's connection socket;
161
+ // the 'unknown' fallback is defensive and unreachable in practice.
162
+ /* c8 ignore start */
157
163
  const ip = socket.remoteAddress ?? 'unknown';
164
+ /* c8 ignore stop */
158
165
  const decision = runtime.admitConnection(ip, undefined);
159
166
  if (!decision.ok) {
167
+ // A freshly-accepted TCP socket is always writable; the else branch
168
+ // (skip the ERROR write) is a defensive guard against an impossible
169
+ // race and cannot be exercised over a real socket.
170
+ /* c8 ignore start */
160
171
  if (socket.writable) {
161
172
  socket.write(`ERROR :Closing link: (connection limit reached: ${decision.reason})\r\n`);
162
173
  }
174
+ /* c8 ignore stop */
163
175
  socket.destroy();
164
176
  sockets.delete(socket);
165
177
  return;
@@ -213,8 +225,12 @@ export async function startContainerServer(opts: StartContainerOptions): Promise
213
225
  const onListening = (): void => {
214
226
  tcpServer.removeListener('error', onError);
215
227
  const addr = tcpServer.address();
216
- // c8 ignore next 1
228
+ // Inside the 'listening' handler `address()` is always an AddressInfo
229
+ // (TCP server, just bound); the null/string returns only occur for IPC
230
+ // sockets or before listening. The `opts.port` fallback is unreachable.
231
+ /* c8 ignore start */
217
232
  const port = typeof addr === 'object' && addr !== null ? addr.port : opts.port;
233
+ /* c8 ignore stop */
218
234
  resolve({
219
235
  port,
220
236
  host,
@@ -255,9 +271,13 @@ function attachConnection(
255
271
  ): ConnectionBindings {
256
272
  const id = randomUUID();
257
273
  const state = createConnection({ id, connectedSince: clock.now() });
274
+ // `transport.sourceHost` is always supplied (set to the peer IP) by the
275
+ // only call site above; the else branch is defensive and unreachable.
276
+ /* c8 ignore start */
258
277
  if (transport.sourceHost !== undefined) {
259
278
  state.host = transport.sourceHost;
260
279
  }
280
+ /* c8 ignore stop */
261
281
 
262
282
  const logger: Logger = new ConsoleLogger({ connectionId: id }, undefined, LogLevel.Info);
263
283