serverless-ircd 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +11 -9
  4. package/.github/workflows/deploy-cf.yml +14 -14
  5. package/CHANGELOG.md +550 -0
  6. package/README.md +275 -222
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +82 -9
  9. package/apps/aws-stack/cdk.json +0 -3
  10. package/apps/aws-stack/package.json +3 -4
  11. package/apps/aws-stack/src/aws-stack.ts +177 -52
  12. package/apps/aws-stack/src/static-site.ts +323 -0
  13. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  14. package/apps/aws-stack/tests/stack.test.ts +267 -92
  15. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  16. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  17. package/apps/cf-tcp-container/package.json +2 -3
  18. package/apps/cf-tcp-container/src/container-server.ts +33 -10
  19. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  20. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  21. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  22. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  23. package/apps/cf-tcp-container/wrangler.toml +1 -10
  24. package/apps/cf-worker/package.json +3 -4
  25. package/apps/cf-worker/wrangler.toml +12 -71
  26. package/apps/local-cli/package.json +1 -1
  27. package/apps/local-cli/src/server.ts +115 -48
  28. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  29. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  30. package/apps/local-cli/tests/rehash.test.ts +147 -0
  31. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  32. package/apps/local-cli/tests/tcp.test.ts +89 -0
  33. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  34. package/apps/web/landing/favicon.ico +0 -0
  35. package/apps/web/landing/index.html +227 -3
  36. package/apps/web/package.json +3 -2
  37. package/apps/web/scripts/build.mjs +91 -6
  38. package/apps/web/src/build-env.ts +125 -4
  39. package/apps/web/src/config-schema.ts +20 -6
  40. package/apps/web/src/render-docs.ts +292 -0
  41. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  42. package/apps/web/tests/build-env.test.ts +210 -9
  43. package/apps/web/tests/build-smoke.test.ts +33 -4
  44. package/apps/web/tests/config-schema.test.ts +149 -25
  45. package/apps/web/tests/landing-content.test.ts +103 -0
  46. package/apps/web/tests/render-docs.test.ts +198 -0
  47. package/docs/AWS-Adapter-Architecture.md +3 -2
  48. package/docs/AWS-Deployment.md +670 -96
  49. package/docs/AWS-TCP-Deployment.md +20 -45
  50. package/docs/Cloudflare-Deployment-Guide.md +87 -113
  51. package/docs/Cloudflare-TCP-Deployment.md +25 -49
  52. package/docs/Release-Process.md +27 -23
  53. package/docs/Services.md +102 -23
  54. package/docs/WebClientGuide.md +35 -26
  55. package/package.json +7 -10
  56. package/packages/aws-adapter/package.json +1 -1
  57. package/packages/aws-adapter/src/aws-runtime.ts +15 -1
  58. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
  59. package/packages/aws-adapter/src/config-loader.ts +19 -2
  60. package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +26 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +190 -123
  63. package/packages/aws-adapter/src/handlers/index.ts +67 -23
  64. package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
  65. package/packages/aws-adapter/src/index.ts +5 -7
  66. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  67. package/packages/aws-adapter/src/serialize.ts +15 -0
  68. package/packages/aws-adapter/src/tables.ts +2 -12
  69. package/packages/aws-adapter/tests/aws-harness.ts +0 -1
  70. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  71. package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
  72. package/packages/aws-adapter/tests/connect.test.ts +124 -1
  73. package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
  74. package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
  75. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
  76. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  77. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  78. package/packages/aws-adapter/tests/handlers.test.ts +117 -11
  79. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  80. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  81. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  82. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  83. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  84. package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
  85. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  86. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  87. package/packages/cf-adapter/package.json +1 -5
  88. package/packages/cf-adapter/src/cf-runtime.ts +59 -8
  89. package/packages/cf-adapter/src/channel-do.ts +13 -3
  90. package/packages/cf-adapter/src/connection-do.ts +284 -115
  91. package/packages/cf-adapter/src/d1-services-store.ts +63 -26
  92. package/packages/cf-adapter/src/env.ts +11 -10
  93. package/packages/cf-adapter/src/index.ts +0 -6
  94. package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
  95. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  96. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  97. package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
  98. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  99. package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
  100. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  101. package/packages/in-memory-runtime/package.json +1 -1
  102. package/packages/irc-core/package.json +1 -1
  103. package/packages/irc-core/src/account-migration.ts +140 -0
  104. package/packages/irc-core/src/commands/account-auth.ts +60 -35
  105. package/packages/irc-core/src/commands/chanserv.ts +288 -4
  106. package/packages/irc-core/src/commands/hostserv.ts +38 -3
  107. package/packages/irc-core/src/commands/index.ts +1 -0
  108. package/packages/irc-core/src/commands/join.ts +41 -35
  109. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  110. package/packages/irc-core/src/commands/nickserv.ts +138 -15
  111. package/packages/irc-core/src/commands/registration.ts +28 -17
  112. package/packages/irc-core/src/commands/sasl.ts +22 -31
  113. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  114. package/packages/irc-core/src/commands/topic.ts +23 -10
  115. package/packages/irc-core/src/config.ts +35 -9
  116. package/packages/irc-core/src/credential-hashing.ts +11 -54
  117. package/packages/irc-core/src/index.ts +1 -0
  118. package/packages/irc-core/src/ports.ts +159 -179
  119. package/packages/irc-core/src/state/channel.ts +17 -0
  120. package/packages/irc-core/src/types.ts +38 -10
  121. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  122. package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
  123. package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
  124. package/packages/irc-core/tests/commands/join.test.ts +179 -0
  125. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  126. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  127. package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
  128. package/packages/irc-core/tests/commands/oper.test.ts +15 -0
  129. package/packages/irc-core/tests/commands/registration.test.ts +336 -108
  130. package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
  131. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  132. package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
  133. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  134. package/packages/irc-core/tests/message-store.test.ts +5 -0
  135. package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
  136. package/packages/irc-core/tests/ports.test.ts +71 -0
  137. package/packages/irc-core/tests/services-store.test.ts +204 -0
  138. package/packages/irc-core/vitest.config.ts +6 -1
  139. package/packages/irc-server/package.json +1 -1
  140. package/packages/irc-server/src/actor.ts +80 -44
  141. package/packages/irc-server/tests/actor.test.ts +384 -50
  142. package/packages/irc-test-support/package.json +1 -1
  143. package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
  144. package/packages/irc-test-support/src/scenarios.ts +21 -6
  145. package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
  146. package/packages/irc-test-support/vitest.config.ts +6 -1
  147. package/pnpm-workspace.yaml +1 -0
  148. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  149. package/scripts/deploy-web-aws.mjs +290 -0
  150. package/scripts/package.json +23 -0
  151. package/scripts/tsconfig.test.json +12 -0
  152. package/scripts/vitest.config.ts +19 -0
  153. package/tools/ci-hardening/package.json +1 -1
  154. package/tools/ci-hardening/src/index.ts +2 -0
  155. package/tools/ci-hardening/src/validate.ts +57 -0
  156. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  157. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  158. package/tools/load-test/package.json +1 -1
  159. package/tools/load-test/src/client.ts +13 -13
  160. package/tools/load-test/tests/client.test.ts +258 -2
  161. package/tools/load-test/tests/config.test.ts +39 -0
  162. package/tools/load-test/tests/harness.test.ts +21 -0
  163. package/tools/load-test/tests/metrics.test.ts +7 -0
  164. package/tools/migrate-accounts-to-services.ts +270 -0
  165. package/tools/package.json +2 -1
  166. package/tools/seed-aws-accounts.ts +35 -10
  167. package/tools/seed-cf-accounts.ts +42 -9
  168. package/tools/tcp-ws-forwarder/package.json +1 -1
  169. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  170. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  171. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
  172. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  173. package/packages/aws-adapter/src/account-store.ts +0 -121
  174. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  175. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  176. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  177. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  178. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  179. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -0,0 +1,491 @@
1
+ /**
2
+ * Synth-time assertions for the `StaticSite` construct (the AWS web-client
3
+ * hosting substrate: a private S3 bucket fronted by a CloudFront
4
+ * distribution with Origin Access Control).
5
+ *
6
+ * The web client (Kiwi SPA + landing page) is the shared `apps/web/dist`
7
+ * build artifact. On AWS it is served exclusively over CloudFront; the S3
8
+ * origin bucket is private and grants the distribution via OAC (never
9
+ * public-read). The landing page at `/` is served via the distribution's
10
+ * `defaultRootObject` (`/index.html`); SPA client-side routing under
11
+ * `/webclient/*` is handled by a CloudFront custom error response mapping
12
+ * S3's 403/404 → `200 /webclient/index.html`.
13
+ *
14
+ * These tests synthesise the construct in isolation (via `Template` from
15
+ * `aws-cdk-lib/assertions`) and assert the exact CloudFormation shape: the
16
+ * `AWS::CloudFront::Distribution`, `AWS::S3::Bucket`, the OAC resource, the
17
+ * bucket-policy grant (OAC principal only — no `Public`/`*`), the
18
+ * 403/404 → `/webclient/index.html` error response, the `/index.html`
19
+ * default root object, and the `WebsiteURL` output. The integration into
20
+ * `IrcAwsStack` (opt-in prop) is covered in `stack.test.ts`.
21
+ */
22
+
23
+ import { runInNewContext } from 'node:vm';
24
+ import { App, Stack } from 'aws-cdk-lib';
25
+ import { Template } from 'aws-cdk-lib/assertions';
26
+ import { describe, expect, it } from 'vitest';
27
+ import { StaticSite, type StaticSiteProps } from '../src/static-site.js';
28
+
29
+ /** Synthesises a `StaticSite` in isolation and returns the assertion Template. */
30
+ function makeSiteTemplate(props: StaticSiteProps = {}): {
31
+ app: App;
32
+ stack: Stack;
33
+ template: Template;
34
+ } {
35
+ const app = new App();
36
+ const stack = new Stack(app, 'SiteStack');
37
+ new StaticSite(stack, 'Site', props);
38
+ const template = Template.fromStack(stack);
39
+ return { app, stack, template };
40
+ }
41
+
42
+ /** Shape of a synthesised CloudFront Distribution's DistributionConfig. */
43
+ type DistConfig = {
44
+ Origins?: Array<{
45
+ DomainName?: unknown;
46
+ S3OriginConfig?: unknown;
47
+ OriginAccessControlId?: unknown;
48
+ }>;
49
+ DefaultCacheBehavior?: {
50
+ ViewerProtocolPolicy?: string;
51
+ FunctionAssociations?: Array<{ EventType?: string; FunctionARN?: unknown }>;
52
+ };
53
+ Aliases?: string[];
54
+ CustomErrorResponses?: Array<{
55
+ ErrorCode?: number;
56
+ ResponseCode?: number;
57
+ ResponsePagePath?: string;
58
+ }>;
59
+ };
60
+
61
+ /** Extracts the DistributionConfig from the single CloudFront distribution. */
62
+ function distConfig(template: Template): DistConfig {
63
+ const dists = template.findResources('AWS::CloudFront::Distribution') as Record<
64
+ string,
65
+ { Properties?: { DistributionConfig?: DistConfig } }
66
+ >;
67
+ const dist = Object.values(dists)[0];
68
+ return dist?.Properties?.DistributionConfig ?? {};
69
+ }
70
+
71
+ /**
72
+ * Result of invoking the synthesised directory-index function: either the
73
+ * (possibly rewritten) request object (`uri` set), or a 301-redirect
74
+ * response (`statusCode` + `headers.location`). Kept as a single loose
75
+ * type with all-optional fields so the per-case tests can read either
76
+ * shape without per-call casts; the assertions themselves pin the shape.
77
+ */
78
+ type IndexHandlerResult = {
79
+ uri?: string;
80
+ statusCode?: number;
81
+ statusDescription?: string;
82
+ headers?: { location?: { value?: string } };
83
+ };
84
+
85
+ /**
86
+ * Extracts the synthesized directory-index CloudFront Function's `handler`
87
+ * entry point as a callable, so its rewrite/redirect logic can be exercised
88
+ * behaviourally (not just "the source contains 'index.html'"). Uses
89
+ * `node:vm`'s `runInNewContext` (rather than `eval`/`new Function`, which
90
+ * lint forbids) to evaluate the inline `FunctionCode` and return the
91
+ * declared `handler`. Throws if no `AWS::CloudFront::Function` was
92
+ * synthesised — the calling tests use that as their RED signal.
93
+ */
94
+ function extractIndexHandler(template: Template): (event: {
95
+ request: { uri: string; querystring?: Record<string, { value: string }> };
96
+ }) => IndexHandlerResult {
97
+ const fns = template.findResources('AWS::CloudFront::Function') as Record<
98
+ string,
99
+ { Properties?: { FunctionCode?: string } }
100
+ >;
101
+ // CDK's `cloudfront.Function` puts `FunctionCode` directly under
102
+ // `Properties` (NOT nested under `FunctionConfig`, which only holds
103
+ // `Comment` + `Runtime`).
104
+ const code = Object.values(fns)[0]?.Properties?.FunctionCode;
105
+ if (code === undefined) {
106
+ throw new Error('no AWS::CloudFront::Function with FunctionCode was synthesised');
107
+ }
108
+ // The CloudFront function declares `function handler(event) {...}`. Append
109
+ // a trailing `handler` expression so runInNewContext returns it.
110
+ return runInNewContext(`${code}\nhandler`, {}) as (event: {
111
+ request: { uri: string; querystring?: Record<string, { value: string }> };
112
+ }) => IndexHandlerResult;
113
+ }
114
+
115
+ /** Returns all bucket-policy statements across the synthesised template. */
116
+ function bucketPolicyStatements(template: Template): Array<{
117
+ Principal?: unknown;
118
+ Action?: unknown;
119
+ Resource?: unknown;
120
+ Condition?: Record<string, Record<string, unknown>>;
121
+ }> {
122
+ const policies = template.findResources('AWS::S3::BucketPolicy') as Record<
123
+ string,
124
+ { Properties?: { PolicyDocument?: { Statement?: Array<Record<string, unknown>> } } }
125
+ >;
126
+ const all: Array<Record<string, unknown>> = [];
127
+ for (const policy of Object.values(policies)) {
128
+ all.push(...(policy.Properties?.PolicyDocument?.Statement ?? []));
129
+ }
130
+ return all as Array<{
131
+ Principal?: unknown;
132
+ Action?: unknown;
133
+ Resource?: unknown;
134
+ Condition?: Record<string, Record<string, unknown>>;
135
+ }>;
136
+ }
137
+
138
+ describe('StaticSite — CloudFront distribution', () => {
139
+ it('creates exactly one AWS::CloudFront::Distribution', () => {
140
+ const { template } = makeSiteTemplate();
141
+ template.resourceCountIs('AWS::CloudFront::Distribution', 1);
142
+ });
143
+
144
+ it('creates a distribution backed by an S3 origin', () => {
145
+ const { template } = makeSiteTemplate();
146
+ const origins = distConfig(template).Origins ?? [];
147
+ expect(origins.length).toBeGreaterThanOrEqual(1);
148
+ expect(origins[0]?.S3OriginConfig).toBeDefined();
149
+ });
150
+
151
+ it('redirects HTTP viewers to HTTPS', () => {
152
+ const { template } = makeSiteTemplate();
153
+ expect(distConfig(template).DefaultCacheBehavior?.ViewerProtocolPolicy).toBe(
154
+ 'redirect-to-https',
155
+ );
156
+ });
157
+ });
158
+
159
+ describe('StaticSite — private S3 origin bucket', () => {
160
+ it('creates exactly one AWS::S3::Bucket', () => {
161
+ const { template } = makeSiteTemplate();
162
+ template.resourceCountIs('AWS::S3::Bucket', 1);
163
+ });
164
+
165
+ it('never grants public read access (no Public/* principal in any bucket-policy statement)', () => {
166
+ const { template } = makeSiteTemplate();
167
+ const statements = bucketPolicyStatements(template);
168
+ expect(statements.length).toBeGreaterThanOrEqual(1);
169
+ for (const stmt of statements) {
170
+ const serialised = JSON.stringify(stmt.Principal);
171
+ expect(serialised, 'bucket policy must not grant Principal "*"').not.toBe('"*"');
172
+ expect(serialised, 'bucket policy must not grant Principal AWS "*"').not.toContain(
173
+ '"AWS":"*"',
174
+ );
175
+ expect(serialised, 'bucket policy must not be public-read').not.toContain('Public');
176
+ }
177
+ });
178
+
179
+ it('grants the CloudFront distribution read access via the OAC service principal', () => {
180
+ const { template } = makeSiteTemplate();
181
+ const statements = bucketPolicyStatements(template);
182
+ const oacGrant = statements.find((s) =>
183
+ JSON.stringify(s.Principal).includes('cloudfront.amazonaws.com'),
184
+ );
185
+ expect(oacGrant, 'expected a cloudfront OAC bucket-policy statement').toBeDefined();
186
+ });
187
+
188
+ it('scopes the OAC grant to this distribution via a StringEquals AWS:SourceArn condition', () => {
189
+ // OAC uses a condition keyed on `AWS:SourceArn` (the distribution ARN)
190
+ // so only THIS distribution can read the bucket, not every CloudFront
191
+ // distribution in the account.
192
+ const { template } = makeSiteTemplate();
193
+ const statements = bucketPolicyStatements(template);
194
+ const oacGrant = statements.find((s) =>
195
+ JSON.stringify(s.Principal).includes('cloudfront.amazonaws.com'),
196
+ );
197
+ const cond = JSON.stringify(oacGrant?.Condition ?? {});
198
+ expect(cond).toContain('StringEquals');
199
+ expect(cond).toContain('AWS:SourceArn');
200
+ expect(cond).toContain('cloudfront');
201
+ expect(cond).toContain('distribution');
202
+ });
203
+ });
204
+
205
+ describe('StaticSite — Origin Access Control (OAC)', () => {
206
+ it('creates an AWS::CloudFront::OriginAccessControl resource', () => {
207
+ const { template } = makeSiteTemplate();
208
+ template.resourceCountIs('AWS::CloudFront::OriginAccessControl', 1);
209
+ });
210
+
211
+ it('configures the OAC for an S3 origin with SIGV4_ALWAYS signing', () => {
212
+ const { template } = makeSiteTemplate();
213
+ const oacs = template.findResources('AWS::CloudFront::OriginAccessControl') as Record<
214
+ string,
215
+ {
216
+ Properties?: {
217
+ OriginAccessControlConfig?: {
218
+ OriginAccessControlOriginType?: string;
219
+ SigningProtocol?: string;
220
+ SigningBehavior?: string;
221
+ };
222
+ };
223
+ }
224
+ >;
225
+ const oac = Object.values(oacs)[0]?.Properties?.OriginAccessControlConfig;
226
+ expect(oac?.OriginAccessControlOriginType).toBe('s3');
227
+ expect(oac?.SigningProtocol).toBe('sigv4');
228
+ expect(oac?.SigningBehavior).toBe('always');
229
+ });
230
+
231
+ it('references the OAC id from the S3 origin', () => {
232
+ const { template } = makeSiteTemplate();
233
+ const origin = distConfig(template).Origins?.[0];
234
+ expect(origin?.OriginAccessControlId).toBeDefined();
235
+ });
236
+ });
237
+
238
+ describe('StaticSite — default root object + SPA error response', () => {
239
+ it('serves the landing page (/index.html) as the default root object', () => {
240
+ // `https://<dist>/` must serve the project landing page (the 13 KB
241
+ // static HTML at `dist/index.html` produced by step 5 of
242
+ // `apps/web/scripts/build.mjs`). Without this `defaultRootObject`,
243
+ // a request to `/` would 403 against the private bucket and trigger
244
+ // the SPA error response, which is the wrong page (the SPA shell,
245
+ // not the landing page).
246
+ const { template } = makeSiteTemplate();
247
+ const dists = template.findResources('AWS::CloudFront::Distribution') as Record<
248
+ string,
249
+ { Properties?: { DistributionConfig?: { DefaultRootObject?: string } } }
250
+ >;
251
+ const root = Object.values(dists)[0]?.Properties?.DistributionConfig?.DefaultRootObject;
252
+ expect(root).toBe('index.html');
253
+ });
254
+
255
+ it('maps HTTP 403 → 200 with response page /webclient/index.html', () => {
256
+ const { template } = makeSiteTemplate();
257
+ const errors = distConfig(template).CustomErrorResponses ?? [];
258
+ const mapping = errors.find((e) => e.ErrorCode === 403);
259
+ expect(mapping, 'expected a 403 custom error response').toBeDefined();
260
+ expect(mapping?.ResponseCode).toBe(200);
261
+ expect(mapping?.ResponsePagePath).toBe('/webclient/index.html');
262
+ });
263
+
264
+ it('maps HTTP 404 → 200 with response page /webclient/index.html', () => {
265
+ const { template } = makeSiteTemplate();
266
+ const errors = distConfig(template).CustomErrorResponses ?? [];
267
+ const mapping = errors.find((e) => e.ErrorCode === 404);
268
+ expect(mapping, 'expected a 404 custom error response').toBeDefined();
269
+ expect(mapping?.ResponseCode).toBe(200);
270
+ expect(mapping?.ResponsePagePath).toBe('/webclient/index.html');
271
+ });
272
+ });
273
+
274
+ describe('StaticSite — WebsiteURL output', () => {
275
+ /**
276
+ * The `WebsiteURL` CfnOutput lives inside the `StaticSite` construct, but
277
+ * its logical id is pinned to the stable key `WebsiteURL` via
278
+ * `overrideLogicalId` so operators see `Stack.WebsiteURL` rather than the
279
+ * CDK-hashed `StaticSiteWebsiteURL<8-hex>`. This matches the convention
280
+ * used by the parent-stack outputs (`WebsiteBucketName`,
281
+ * `WebsiteDistributionId`) and keeps the deploy pipeline's
282
+ * `describe-stacks --query` lookups predictable.
283
+ */
284
+ it('emits a WebsiteURL output with a stable logical id (no CDK hash) naming the distribution domain', () => {
285
+ const { template } = makeSiteTemplate();
286
+ const outputs = (template.toJSON().Outputs ?? {}) as Record<string, { Value?: unknown }>;
287
+ expect(outputs.WebsiteURL, 'expected a WebsiteURL output with a stable key').toBeDefined();
288
+ const distIds = Object.keys(template.findResources('AWS::CloudFront::Distribution'));
289
+ expect(distIds).toHaveLength(1);
290
+ const getAtt = (outputs.WebsiteURL?.Value as { 'Fn::GetAtt'?: unknown[] })?.['Fn::GetAtt'];
291
+ expect(getAtt, 'expected WebsiteURL.Value to Fn::GetAtt the distribution DomainName').toEqual([
292
+ distIds[0],
293
+ 'DomainName',
294
+ ]);
295
+ });
296
+ });
297
+
298
+ describe('StaticSite — default domain (no custom-domain props)', () => {
299
+ it('does NOT configure an alternate domain name when no custom domain is provided', () => {
300
+ const { template } = makeSiteTemplate();
301
+ expect(distConfig(template).Aliases).toBeUndefined();
302
+ });
303
+
304
+ it('does NOT create a Route53 record when no custom domain is provided', () => {
305
+ const { template } = makeSiteTemplate();
306
+ template.resourceCountIs('AWS::Route53::RecordSet', 0);
307
+ });
308
+ });
309
+
310
+ describe('StaticSite — custom domain (opt-in)', () => {
311
+ const customProps: StaticSiteProps = {
312
+ customDomain: 'app.example.com',
313
+ certificateArn: 'arn:aws:acm:us-east-1:123456789012:certificate/abc',
314
+ hostedZoneName: 'example.com.',
315
+ hostedZoneId: 'Z2KVMGOMG3KSP5',
316
+ };
317
+
318
+ it('configures the alternate domain name on the distribution', () => {
319
+ const { template } = makeSiteTemplate(customProps);
320
+ expect(distConfig(template).Aliases).toEqual(['app.example.com']);
321
+ });
322
+
323
+ it('attaches the provided ACM certificate as the viewer certificate', () => {
324
+ const { template } = makeSiteTemplate(customProps);
325
+ const dists = template.findResources('AWS::CloudFront::Distribution') as Record<
326
+ string,
327
+ {
328
+ Properties?: {
329
+ DistributionConfig?: { ViewerCertificate?: { AcmCertificateArn?: string } };
330
+ };
331
+ }
332
+ >;
333
+ const cert =
334
+ Object.values(dists)[0]?.Properties?.DistributionConfig?.ViewerCertificate?.AcmCertificateArn;
335
+ expect(cert).toBe('arn:aws:acm:us-east-1:123456789012:certificate/abc');
336
+ });
337
+
338
+ it('creates a Route53 ARecord aliasing the distribution', () => {
339
+ const { template } = makeSiteTemplate(customProps);
340
+ template.hasResourceProperties('AWS::Route53::RecordSet', {
341
+ Name: 'app.example.com.',
342
+ Type: 'A',
343
+ });
344
+ });
345
+
346
+ it('configures the alias without a viewer certificate when certificateArn is omitted', () => {
347
+ // CloudFront requires a cert for an alias at deploy time, but the
348
+ // construct does not fail at synth when none is imported — the deploy
349
+ // surfaces the CFN error. This exercises the no-cert branch so the
350
+ // synth path is covered either way.
351
+ const { template } = makeSiteTemplate({
352
+ customDomain: 'app.example.com',
353
+ hostedZoneName: 'example.com.',
354
+ hostedZoneId: 'Z2KVMGOMG3KSP5',
355
+ });
356
+ expect(distConfig(template).Aliases).toEqual(['app.example.com']);
357
+ const dists = template.findResources('AWS::CloudFront::Distribution') as Record<
358
+ string,
359
+ { Properties?: { DistributionConfig?: { ViewerCertificate?: unknown } } }
360
+ >;
361
+ expect(
362
+ Object.values(dists)[0]?.Properties?.DistributionConfig?.ViewerCertificate,
363
+ ).toBeUndefined();
364
+ });
365
+
366
+ it('skips the Route53 record when the hosted zone id is omitted', () => {
367
+ // The ARecord needs both zone name AND id (fromHostedZoneAttributes);
368
+ // without the id the alias is still configured on the distribution but
369
+ // no Route53 record is materialised.
370
+ const { template } = makeSiteTemplate({
371
+ customDomain: 'app.example.com',
372
+ certificateArn: 'arn:aws:acm:us-east-1:123456789012:certificate/abc',
373
+ hostedZoneName: 'example.com.',
374
+ });
375
+ expect(distConfig(template).Aliases).toEqual(['app.example.com']);
376
+ template.resourceCountIs('AWS::Route53::RecordSet', 0);
377
+ });
378
+
379
+ it('skips the Route53 record when hostedZoneName is omitted', () => {
380
+ const { template } = makeSiteTemplate({
381
+ customDomain: 'app.example.com',
382
+ certificateArn: 'arn:aws:acm:us-east-1:123456789012:certificate/abc',
383
+ });
384
+ template.resourceCountIs('AWS::Route53::RecordSet', 0);
385
+ });
386
+ });
387
+
388
+ describe('StaticSite — directory-index viewer-request function', () => {
389
+ // S3 REST origins (used with OAC) do NOT resolve directory indexes: a
390
+ // request for '/docs/' looks up an object literally named 'docs/' and
391
+ // 404s, which then falls through to the SPA error response and serves
392
+ // Kiwi instead of the rendered docs index (reproduced live on
393
+ // irc.aws.fowl.cloud). A CloudFront viewer-request function appends
394
+ // 'index.html' to any URI ending in '/', mirroring the CF Worker
395
+ // `[assets]` binding which resolves directory indexes automatically.
396
+
397
+ it('creates exactly one AWS::CloudFront::Function', () => {
398
+ const { template } = makeSiteTemplate();
399
+ template.resourceCountIs('AWS::CloudFront::Function', 1);
400
+ });
401
+
402
+ it('associates the function as a viewer-request on the default behavior', () => {
403
+ const { template } = makeSiteTemplate();
404
+ const associations = distConfig(template).DefaultCacheBehavior?.FunctionAssociations ?? [];
405
+ const viewerRequest = associations.find((a) => a.EventType === 'viewer-request');
406
+ expect(viewerRequest, 'expected a viewer-request function association').toBeDefined();
407
+ });
408
+
409
+ it('rewrites a trailing-slash directory URI by appending index.html', () => {
410
+ const { template } = makeSiteTemplate();
411
+ const handler = extractIndexHandler(template);
412
+ expect(handler({ request: { uri: '/docs/' } }).uri).toBe('/docs/index.html');
413
+ });
414
+
415
+ it('301-redirects a directory URI without a trailing slash to the trailing-slash form', () => {
416
+ // A bare '/docs' must NOT be internally rewritten to '/docs/index.html':
417
+ // the browser URL would stay at '/docs' and the page's relative asset
418
+ // links (e.g. <link href="docs.css">) would resolve against the parent
419
+ // ('/docs.css') instead of the directory ('/docs/docs.css'). A 301 to
420
+ // '/docs/' makes the browser re-request the directory form, after which
421
+ // the trailing-slash rewrite serves '/docs/index.html' and relative
422
+ // links resolve correctly. Mirrors Apache/nginx DirectorySlash.
423
+ const { template } = makeSiteTemplate();
424
+ const handler = extractIndexHandler(template);
425
+ const result = handler({ request: { uri: '/docs' } });
426
+ expect(result.statusCode).toBe(301);
427
+ expect(result.headers?.location?.value).toBe('/docs/');
428
+ });
429
+
430
+ it('preserves the query string in the no-trailing-slash redirect', () => {
431
+ // CloudFront Functions deliver request.querystring as an OBJECT
432
+ // ({ key: { value: "..." } }), not a raw string — an empty querystring
433
+ // is {} (truthy), so a naive string concat would append "?[object
434
+ // Object]". The function rebuilds the query string from the object.
435
+ const { template } = makeSiteTemplate();
436
+ const handler = extractIndexHandler(template);
437
+ const result = handler({
438
+ request: { uri: '/docs', querystring: { foo: { value: 'bar' } } },
439
+ });
440
+ expect(result.statusCode).toBe(301);
441
+ expect(result.headers?.location?.value).toBe('/docs/?foo=bar');
442
+ });
443
+
444
+ it('omits the query string entirely when there are no params', () => {
445
+ // Regression guard for the live "?[object Object]" bug: an empty
446
+ // querystring object must NOT append a trailing "?".
447
+ const { template } = makeSiteTemplate();
448
+ const handler = extractIndexHandler(template);
449
+ const result = handler({ request: { uri: '/docs', querystring: {} } });
450
+ expect(result.statusCode).toBe(301);
451
+ expect(result.headers?.location?.value).toBe('/docs/');
452
+ });
453
+
454
+ it('rewrites the bare root "/" to "/index.html"', () => {
455
+ const { template } = makeSiteTemplate();
456
+ const handler = extractIndexHandler(template);
457
+ expect(handler({ request: { uri: '/' } }).uri).toBe('/index.html');
458
+ });
459
+
460
+ it('leaves a URI with a file extension unchanged', () => {
461
+ const { template } = makeSiteTemplate();
462
+ const handler = extractIndexHandler(template);
463
+ expect(handler({ request: { uri: '/docs/Services.html' } }).uri).toBe('/docs/Services.html');
464
+ });
465
+
466
+ it('leaves a hashed asset path unchanged', () => {
467
+ // Every real static asset carries a file extension (.js/.css/.png/
468
+ // .json/.woff2/.ico), so the no-extension rewrite never touches them.
469
+ const { template } = makeSiteTemplate();
470
+ const handler = extractIndexHandler(template);
471
+ expect(handler({ request: { uri: '/webclient/static/js/app.d5acc7fe.js' } }).uri).toBe(
472
+ '/webclient/static/js/app.d5acc7fe.js',
473
+ );
474
+ });
475
+
476
+ it('301-redirects a no-extension SPA deep-link to the trailing-slash form', () => {
477
+ // A SPA client-side route like '/webclient/networks/example' has no
478
+ // extension, so the function redirects it to the trailing-slash form
479
+ // '/webclient/networks/example/'. That object does not exist → S3 404
480
+ // → the 403/404 SPA error response still serves '/webclient/index.html'
481
+ // (unchanged), and Kiwi's router resolves the deep link from the URL.
482
+ // The redirect therefore does not break SPA routing; it merely routes
483
+ // the (expected) miss through the existing fallback, with a canonical
484
+ // trailing slash on the browser URL.
485
+ const { template } = makeSiteTemplate();
486
+ const handler = extractIndexHandler(template);
487
+ const result = handler({ request: { uri: '/webclient/networks/example' } });
488
+ expect(result.statusCode).toBe(301);
489
+ expect(result.headers?.location?.value).toBe('/webclient/networks/example/');
490
+ });
491
+ });
@@ -57,7 +57,6 @@ describe('IrcAwsStack — test-mode bundle stub (no esbuild)', () => {
57
57
  it('emits inline (ZipFile) Lambda code for the NLB streaming handler when tcpTlsDomainName is set', () => {
58
58
  const app = new App();
59
59
  const stack = new IrcAwsStack(app, 'TestStack', {
60
- environmentName: 'staging',
61
60
  tcpTlsDomainName: 'irc.example.com',
62
61
  });
63
62
  const template = Template.fromStack(stack);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/cf-tcp-container",
3
- "version": "0.8.0",
3
+ "version": "0.10.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",
@@ -10,8 +10,7 @@
10
10
  "build": "tsc -p tsconfig.build.json",
11
11
  "typecheck": "tsc -p tsconfig.test.json --noEmit",
12
12
  "start": "node --enable-source-maps ./dist/main.js",
13
- "deploy:staging": "wrangler deploy --env staging",
14
- "deploy:prod": "wrangler deploy",
13
+ "deploy": "wrangler deploy",
15
14
  "test": "vitest run",
16
15
  "test:watch": "vitest",
17
16
  "coverage": "vitest run --coverage",
@@ -18,20 +18,21 @@ import { randomUUID } from 'node:crypto';
18
18
  import { type Server, type Socket, createServer } from 'node:net';
19
19
  import { InMemoryRuntime } from '@serverless-ircd/in-memory-runtime';
20
20
  import {
21
- type AccountStore,
22
21
  type AdmissionConfig,
23
22
  type ChanName,
24
23
  type Clock,
25
24
  type ConnectionState,
26
25
  ConsoleLogger,
27
- InMemoryAccountStore,
28
26
  InMemoryMessageStore,
29
27
  InMemoryNickHistoryStore,
28
+ InMemoryServicesStore,
30
29
  LogLevel,
31
30
  type Logger,
32
31
  type MessageStore,
33
32
  type NickHistoryStore,
34
33
  type RawLine,
34
+ type ServicesStore,
35
+ SystemClock,
35
36
  createConnection,
36
37
  } from '@serverless-ircd/irc-core';
37
38
  import { ConnectionActor, TcpByteStreamTransport } from '@serverless-ircd/irc-server';
@@ -95,6 +96,9 @@ interface ConnectionBindings {
95
96
  const DEFAULT_ID_FACTORY = {
96
97
  batchId: () => randomUUID(),
97
98
  nonce: () => randomUUID(),
99
+ // `sessionId` is required for `IdFactory` interface completeness; no
100
+ // current code path invokes it (only batchId/nonce/traceId are called).
101
+ // c8 ignore next 1
98
102
  sessionId: () => randomUUID(),
99
103
  traceId: () => randomUUID(),
100
104
  };
@@ -131,10 +135,12 @@ export async function startContainerServer(opts: StartContainerOptions): Promise
131
135
 
132
136
  const messages = new InMemoryMessageStore(opts.historyMaxPerChannel);
133
137
  const history = new InMemoryNickHistoryStore(clock);
134
- const accounts: AccountStore | undefined =
135
- opts.saslAccounts !== undefined && opts.saslAccounts.length > 0
136
- ? new InMemoryAccountStore(opts.saslAccounts)
137
- : undefined;
138
+ // Services store: the single credential home. Seeded from `saslAccounts`
139
+ // so SASL PLAIN / PASS-auth / NickServ IDENTIFY all verify.
140
+ const services = new InMemoryServicesStore({ clock: SystemClock });
141
+ for (const acct of opts.saslAccounts ?? []) {
142
+ services.registerNick(acct.username, acct.password, '');
143
+ }
138
144
 
139
145
  // Restore persisted channel state before accepting connections.
140
146
  if (opts.persistencePath !== undefined) {
@@ -154,12 +160,21 @@ export async function startContainerServer(opts: StartContainerOptions): Promise
154
160
  const tcpServer = createServer((socket) => {
155
161
  sockets.add(socket);
156
162
 
163
+ // `remoteAddress` is always set on a TCP server's connection socket;
164
+ // the 'unknown' fallback is defensive and unreachable in practice.
165
+ /* c8 ignore start */
157
166
  const ip = socket.remoteAddress ?? 'unknown';
167
+ /* c8 ignore stop */
158
168
  const decision = runtime.admitConnection(ip, undefined);
159
169
  if (!decision.ok) {
170
+ // A freshly-accepted TCP socket is always writable; the else branch
171
+ // (skip the ERROR write) is a defensive guard against an impossible
172
+ // race and cannot be exercised over a real socket.
173
+ /* c8 ignore start */
160
174
  if (socket.writable) {
161
175
  socket.write(`ERROR :Closing link: (connection limit reached: ${decision.reason})\r\n`);
162
176
  }
177
+ /* c8 ignore stop */
163
178
  socket.destroy();
164
179
  sockets.delete(socket);
165
180
  return;
@@ -167,7 +182,7 @@ export async function startContainerServer(opts: StartContainerOptions): Promise
167
182
  const admissionRecordId = decision.recordId;
168
183
  runtime.commitAdmission(ip, undefined, admissionRecordId);
169
184
 
170
- const { state, actor } = attachConnection(runtime, opts, messages, accounts, history, clock, {
185
+ const { state, actor } = attachConnection(runtime, opts, messages, services, history, clock, {
171
186
  sendText: (text) => {
172
187
  if (socket.writable) socket.write(text);
173
188
  },
@@ -213,8 +228,12 @@ export async function startContainerServer(opts: StartContainerOptions): Promise
213
228
  const onListening = (): void => {
214
229
  tcpServer.removeListener('error', onError);
215
230
  const addr = tcpServer.address();
216
- // c8 ignore next 1
231
+ // Inside the 'listening' handler `address()` is always an AddressInfo
232
+ // (TCP server, just bound); the null/string returns only occur for IPC
233
+ // sockets or before listening. The `opts.port` fallback is unreachable.
234
+ /* c8 ignore start */
217
235
  const port = typeof addr === 'object' && addr !== null ? addr.port : opts.port;
236
+ /* c8 ignore stop */
218
237
  resolve({
219
238
  port,
220
239
  host,
@@ -243,7 +262,7 @@ function attachConnection(
243
262
  runtime: InMemoryRuntime,
244
263
  opts: StartContainerOptions,
245
264
  messages: MessageStore,
246
- accounts: AccountStore | undefined,
265
+ services: ServicesStore,
247
266
  history: NickHistoryStore,
248
267
  clock: Clock,
249
268
  transport: {
@@ -255,9 +274,13 @@ function attachConnection(
255
274
  ): ConnectionBindings {
256
275
  const id = randomUUID();
257
276
  const state = createConnection({ id, connectedSince: clock.now() });
277
+ // `transport.sourceHost` is always supplied (set to the peer IP) by the
278
+ // only call site above; the else branch is defensive and unreachable.
279
+ /* c8 ignore start */
258
280
  if (transport.sourceHost !== undefined) {
259
281
  state.host = transport.sourceHost;
260
282
  }
283
+ /* c8 ignore stop */
261
284
 
262
285
  const logger: Logger = new ConsoleLogger({ connectionId: id }, undefined, LogLevel.Info);
263
286
 
@@ -282,7 +305,7 @@ function attachConnection(
282
305
  ids: DEFAULT_ID_FACTORY,
283
306
  motd: { lines: () => opts.motdLines ?? [] },
284
307
  messages,
285
- ...(accounts !== undefined ? { accounts } : {}),
308
+ ...(services !== undefined ? { services } : {}),
286
309
  history,
287
310
  logger,
288
311
  transport: new TcpByteStreamTransport(),