serverless-ircd 0.9.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 (134) hide show
  1. package/.github/workflows/deploy-aws.yml +156 -32
  2. package/.github/workflows/deploy-cf-tcp.yml +11 -9
  3. package/.github/workflows/deploy-cf.yml +14 -14
  4. package/CHANGELOG.md +305 -0
  5. package/README.md +134 -41
  6. package/apps/aws-stack/README.md +3 -5
  7. package/apps/aws-stack/bin/aws.ts +82 -9
  8. package/apps/aws-stack/cdk.json +0 -3
  9. package/apps/aws-stack/package.json +3 -4
  10. package/apps/aws-stack/src/aws-stack.ts +177 -52
  11. package/apps/aws-stack/src/static-site.ts +323 -0
  12. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  13. package/apps/aws-stack/tests/stack.test.ts +267 -92
  14. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  15. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  16. package/apps/cf-tcp-container/package.json +2 -3
  17. package/apps/cf-tcp-container/src/container-server.ts +12 -9
  18. package/apps/cf-tcp-container/wrangler.toml +1 -10
  19. package/apps/cf-worker/package.json +3 -4
  20. package/apps/cf-worker/wrangler.toml +12 -71
  21. package/apps/local-cli/package.json +1 -1
  22. package/apps/local-cli/src/server.ts +21 -17
  23. package/apps/web/landing/favicon.ico +0 -0
  24. package/apps/web/landing/index.html +1 -0
  25. package/apps/web/package.json +2 -2
  26. package/apps/web/scripts/build.mjs +66 -4
  27. package/apps/web/src/build-env.ts +125 -4
  28. package/apps/web/src/config-schema.ts +20 -6
  29. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  30. package/apps/web/tests/build-env.test.ts +210 -9
  31. package/apps/web/tests/build-smoke.test.ts +2 -2
  32. package/apps/web/tests/config-schema.test.ts +149 -25
  33. package/docs/AWS-Deployment.md +670 -96
  34. package/docs/AWS-TCP-Deployment.md +20 -45
  35. package/docs/Cloudflare-Deployment-Guide.md +87 -113
  36. package/docs/Cloudflare-TCP-Deployment.md +25 -49
  37. package/docs/Release-Process.md +27 -23
  38. package/docs/Services.md +69 -22
  39. package/docs/WebClientGuide.md +35 -26
  40. package/package.json +7 -10
  41. package/packages/aws-adapter/package.json +1 -1
  42. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
  43. package/packages/aws-adapter/src/config-loader.ts +19 -2
  44. package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
  45. package/packages/aws-adapter/src/handlers/connect.ts +26 -0
  46. package/packages/aws-adapter/src/handlers/default.ts +190 -123
  47. package/packages/aws-adapter/src/handlers/index.ts +67 -23
  48. package/packages/aws-adapter/src/handlers/nlb-stream.ts +3 -6
  49. package/packages/aws-adapter/src/index.ts +5 -7
  50. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  51. package/packages/aws-adapter/src/serialize.ts +15 -0
  52. package/packages/aws-adapter/src/tables.ts +2 -12
  53. package/packages/aws-adapter/tests/aws-harness.ts +0 -1
  54. package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
  55. package/packages/aws-adapter/tests/connect.test.ts +124 -1
  56. package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
  57. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
  58. package/packages/aws-adapter/tests/handlers.test.ts +117 -11
  59. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  60. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  61. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  62. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  63. package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
  64. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  65. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  66. package/packages/cf-adapter/package.json +1 -5
  67. package/packages/cf-adapter/src/cf-runtime.ts +59 -8
  68. package/packages/cf-adapter/src/channel-do.ts +13 -3
  69. package/packages/cf-adapter/src/connection-do.ts +266 -109
  70. package/packages/cf-adapter/src/d1-services-store.ts +63 -26
  71. package/packages/cf-adapter/src/env.ts +11 -10
  72. package/packages/cf-adapter/src/index.ts +0 -6
  73. package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
  74. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  75. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  76. package/packages/cf-adapter/tests/connection-do-pure.test.ts +95 -54
  77. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  78. package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
  79. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  80. package/packages/in-memory-runtime/package.json +1 -1
  81. package/packages/irc-core/package.json +1 -1
  82. package/packages/irc-core/src/account-migration.ts +140 -0
  83. package/packages/irc-core/src/commands/account-auth.ts +16 -19
  84. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  85. package/packages/irc-core/src/commands/nickserv.ts +122 -11
  86. package/packages/irc-core/src/commands/registration.ts +2 -2
  87. package/packages/irc-core/src/commands/sasl.ts +22 -31
  88. package/packages/irc-core/src/config.ts +35 -9
  89. package/packages/irc-core/src/credential-hashing.ts +11 -54
  90. package/packages/irc-core/src/index.ts +1 -0
  91. package/packages/irc-core/src/ports.ts +159 -179
  92. package/packages/irc-core/src/types.ts +38 -10
  93. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  94. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  95. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  96. package/packages/irc-core/tests/commands/nickserv.test.ts +237 -1
  97. package/packages/irc-core/tests/commands/oper.test.ts +15 -0
  98. package/packages/irc-core/tests/commands/registration.test.ts +140 -133
  99. package/packages/irc-core/tests/commands/sasl.test.ts +159 -178
  100. package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
  101. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  102. package/packages/irc-core/tests/message-store.test.ts +5 -0
  103. package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
  104. package/packages/irc-core/tests/ports.test.ts +71 -0
  105. package/packages/irc-core/tests/services-store.test.ts +204 -0
  106. package/packages/irc-core/vitest.config.ts +6 -1
  107. package/packages/irc-server/package.json +1 -1
  108. package/packages/irc-server/src/actor.ts +0 -14
  109. package/packages/irc-server/tests/actor.test.ts +19 -47
  110. package/packages/irc-test-support/package.json +1 -1
  111. package/pnpm-workspace.yaml +1 -0
  112. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  113. package/scripts/deploy-web-aws.mjs +290 -0
  114. package/scripts/package.json +23 -0
  115. package/scripts/tsconfig.test.json +12 -0
  116. package/scripts/vitest.config.ts +19 -0
  117. package/tools/ci-hardening/package.json +1 -1
  118. package/tools/ci-hardening/src/index.ts +2 -0
  119. package/tools/ci-hardening/src/validate.ts +57 -0
  120. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  121. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  122. package/tools/load-test/package.json +1 -1
  123. package/tools/migrate-accounts-to-services.ts +270 -0
  124. package/tools/package.json +2 -1
  125. package/tools/seed-aws-accounts.ts +35 -10
  126. package/tools/seed-cf-accounts.ts +42 -9
  127. package/tools/tcp-ws-forwarder/package.json +1 -1
  128. package/packages/aws-adapter/src/account-store.ts +0 -121
  129. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  130. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  131. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  132. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  133. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  134. 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.9.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';
@@ -134,10 +135,12 @@ export async function startContainerServer(opts: StartContainerOptions): Promise
134
135
 
135
136
  const messages = new InMemoryMessageStore(opts.historyMaxPerChannel);
136
137
  const history = new InMemoryNickHistoryStore(clock);
137
- const accounts: AccountStore | undefined =
138
- opts.saslAccounts !== undefined && opts.saslAccounts.length > 0
139
- ? new InMemoryAccountStore(opts.saslAccounts)
140
- : 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
+ }
141
144
 
142
145
  // Restore persisted channel state before accepting connections.
143
146
  if (opts.persistencePath !== undefined) {
@@ -179,7 +182,7 @@ export async function startContainerServer(opts: StartContainerOptions): Promise
179
182
  const admissionRecordId = decision.recordId;
180
183
  runtime.commitAdmission(ip, undefined, admissionRecordId);
181
184
 
182
- const { state, actor } = attachConnection(runtime, opts, messages, accounts, history, clock, {
185
+ const { state, actor } = attachConnection(runtime, opts, messages, services, history, clock, {
183
186
  sendText: (text) => {
184
187
  if (socket.writable) socket.write(text);
185
188
  },
@@ -259,7 +262,7 @@ function attachConnection(
259
262
  runtime: InMemoryRuntime,
260
263
  opts: StartContainerOptions,
261
264
  messages: MessageStore,
262
- accounts: AccountStore | undefined,
265
+ services: ServicesStore,
263
266
  history: NickHistoryStore,
264
267
  clock: Clock,
265
268
  transport: {
@@ -302,7 +305,7 @@ function attachConnection(
302
305
  ids: DEFAULT_ID_FACTORY,
303
306
  motd: { lines: () => opts.motdLines ?? [] },
304
307
  messages,
305
- ...(accounts !== undefined ? { accounts } : {}),
308
+ ...(services !== undefined ? { services } : {}),
306
309
  history,
307
310
  logger,
308
311
  transport: new TcpByteStreamTransport(),
@@ -13,7 +13,7 @@
13
13
  ## - The container image is built from apps/cf-tcp-container/Dockerfile.
14
14
  ##
15
15
  ## Deploy:
16
- ## wrangler deploy --env staging
16
+ ## wrangler deploy
17
17
  ##
18
18
 
19
19
  name = "sirc-tcp-origin"
@@ -52,12 +52,3 @@ NETWORK_NAME = "ExampleNet"
52
52
  MOTD_LINES = "Welcome to the ServerlessIRCd TCP origin."
53
53
  TCP_PORT = "6667"
54
54
  PERSISTENCE_PATH = "/data/state.json"
55
-
56
- # --- Staging ---
57
- [env.staging]
58
- name = "sirc-tcp-origin-staging"
59
-
60
- [env.staging.vars]
61
- SERVER_NAME = "irc-staging.example.com"
62
- NETWORK_NAME = "StagingNet"
63
- MOTD_LINES = "Welcome to the staging ServerlessIRCd TCP origin."
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/cf-worker",
3
- "version": "0.9.0",
3
+ "version": "0.10.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",
@@ -13,9 +13,8 @@
13
13
  "test:watch": "vitest",
14
14
  "coverage": "vitest run --coverage",
15
15
  "dev": "wrangler dev",
16
- "deploy:staging": "wrangler deploy --env staging",
17
- "deploy:prod": "wrangler deploy",
18
- "smoke:staging": "node scripts/smoke.mjs",
16
+ "deploy": "wrangler deploy",
17
+ "smoke": "node scripts/smoke.mjs",
19
18
  "clean": "rimraf dist coverage .tsbuildinfo .turbo"
20
19
  },
21
20
  "dependencies": {