serverless-ircd 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +28 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +35 -9
- package/.github/workflows/deploy-cf.yml +40 -14
- package/CHANGELOG.md +594 -0
- package/README.md +286 -60
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +118 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +398 -67
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +714 -105
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -3
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +267 -87
- package/apps/cf-tcp-container/src/main.ts +22 -7
- package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
- package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
- package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
- package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
- package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
- package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
- package/apps/cf-tcp-container/wrangler.toml +18 -14
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/src/worker.ts +77 -5
- package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
- package/apps/cf-worker/tests/smoke.test.ts +4 -0
- package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
- package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
- package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
- package/apps/cf-worker/wrangler.test.toml +15 -1
- package/apps/cf-worker/wrangler.toml +95 -77
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +14 -2
- package/apps/local-cli/src/line-scanner.ts +26 -0
- package/apps/local-cli/src/server.ts +44 -19
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +1 -0
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +66 -4
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +2 -2
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/docs/AWS-Deployment.md +793 -118
- package/docs/AWS-TCP-Deployment.md +57 -47
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +95 -114
- package/docs/Cloudflare-TCP-Deployment.md +160 -101
- package/docs/Release-Process.md +27 -23
- package/docs/SASL-EXTERNAL.md +175 -0
- package/docs/Services.md +69 -22
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/admission.ts +28 -13
- package/packages/aws-adapter/src/aws-runtime.ts +30 -3
- package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
- package/packages/aws-adapter/src/config-loader.ts +153 -8
- package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
- package/packages/aws-adapter/src/handlers/connect.ts +73 -1
- package/packages/aws-adapter/src/handlers/default.ts +279 -123
- package/packages/aws-adapter/src/handlers/index.ts +98 -25
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +23 -0
- package/packages/aws-adapter/src/tables.ts +11 -12
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -2
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
- package/packages/aws-adapter/tests/connect.test.ts +323 -3
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
- package/packages/aws-adapter/tests/handlers.test.ts +174 -12
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +100 -10
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +406 -116
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +105 -26
- package/packages/cf-adapter/src/env.ts +99 -10
- package/packages/cf-adapter/src/index.ts +17 -7
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
- package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
- package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
- package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/cf-adapter/tests/worker/main.ts +4 -0
- package/packages/cf-adapter/wrangler.test.toml +18 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/caps/capabilities.ts +20 -10
- package/packages/irc-core/src/certfp.ts +178 -0
- package/packages/irc-core/src/commands/account-auth.ts +16 -19
- package/packages/irc-core/src/commands/cap.ts +10 -2
- package/packages/irc-core/src/commands/chanserv.ts +117 -14
- package/packages/irc-core/src/commands/chathistory.ts +13 -5
- package/packages/irc-core/src/commands/hostserv.ts +84 -8
- package/packages/irc-core/src/commands/index.ts +2 -1
- package/packages/irc-core/src/commands/invite.ts +1 -7
- package/packages/irc-core/src/commands/join.ts +1 -16
- package/packages/irc-core/src/commands/kick.ts +1 -8
- package/packages/irc-core/src/commands/list.ts +1 -8
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/mode.ts +1 -8
- package/packages/irc-core/src/commands/multiline.ts +4 -10
- package/packages/irc-core/src/commands/names.ts +53 -13
- package/packages/irc-core/src/commands/nickserv.ts +161 -11
- package/packages/irc-core/src/commands/oper.ts +361 -8
- package/packages/irc-core/src/commands/part.ts +4 -10
- package/packages/irc-core/src/commands/privmsg.ts +8 -4
- package/packages/irc-core/src/commands/registration.ts +148 -4
- package/packages/irc-core/src/commands/sasl.ts +154 -46
- package/packages/irc-core/src/commands/topic.ts +10 -12
- package/packages/irc-core/src/commands/who.ts +1 -8
- package/packages/irc-core/src/config.ts +424 -25
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/effects.ts +24 -0
- package/packages/irc-core/src/flood-control.ts +10 -10
- package/packages/irc-core/src/frame-rate-limit.ts +82 -0
- package/packages/irc-core/src/index.ts +9 -0
- package/packages/irc-core/src/oper-hashing.ts +43 -0
- package/packages/irc-core/src/oper-lockout.ts +87 -0
- package/packages/irc-core/src/ports.ts +529 -190
- package/packages/irc-core/src/protocol/bytes.ts +65 -0
- package/packages/irc-core/src/protocol/channel-name.ts +37 -0
- package/packages/irc-core/src/protocol/index.ts +12 -1
- package/packages/irc-core/src/protocol/outbound.ts +43 -10
- package/packages/irc-core/src/protocol/parser.ts +79 -10
- package/packages/irc-core/src/state/connection.ts +13 -0
- package/packages/irc-core/src/types.ts +266 -23
- package/packages/irc-core/src/ws-framing.ts +5 -4
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/bytes.test.ts +89 -0
- package/packages/irc-core/tests/certfp.test.ts +117 -0
- package/packages/irc-core/tests/commands/cap.test.ts +76 -2
- package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
- package/packages/irc-core/tests/commands/join.test.ts +78 -1
- package/packages/irc-core/tests/commands/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
- package/packages/irc-core/tests/commands/oper.test.ts +574 -1
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +602 -133
- package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
- package/packages/irc-core/tests/config.test.ts +534 -2
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/flood-control.test.ts +29 -1
- package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
- package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
- package/packages/irc-core/tests/outbound.test.ts +148 -0
- package/packages/irc-core/tests/parser.test.ts +287 -5
- package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
- package/packages/irc-core/tests/ports.test.ts +170 -7
- package/packages/irc-core/tests/services-store.test.ts +567 -1
- package/packages/irc-core/tests/ws-framing.test.ts +45 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +123 -22
- package/packages/irc-server/src/dispatch.ts +1 -0
- package/packages/irc-server/src/index.ts +7 -0
- package/packages/irc-server/src/redact.ts +159 -0
- package/packages/irc-server/src/runtime.ts +14 -0
- package/packages/irc-server/src/transport.ts +28 -1
- package/packages/irc-server/tests/actor.test.ts +563 -54
- package/packages/irc-server/tests/dispatch.test.ts +31 -0
- package/packages/irc-server/tests/redact.test.ts +198 -0
- package/packages/irc-server/tests/runtime.test.ts +2 -0
- package/packages/irc-server/tests/transport.test.ts +66 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
- package/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- package/tools/ci-hardening/package.json +2 -2
- package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
- package/tools/ci-hardening/src/cf-deploy.ts +118 -0
- package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
- package/tools/ci-hardening/src/env-var-drift.ts +192 -0
- package/tools/ci-hardening/src/hostname-guard.ts +11 -0
- package/tools/ci-hardening/src/index.ts +19 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
- package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
- package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
- package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
- package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/ci-hardening/vitest.config.ts +5 -1
- package/tools/hash-oper-cred.ts +85 -0
- package/tools/load-test/package.json +1 -1
- package/tools/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- 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,14 +1,24 @@
|
|
|
1
1
|
# Cloudflare Container / Docker image for the ServerlessIRCd TCP origin.
|
|
2
2
|
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
3
|
+
# TLS terminates INSIDE this container on :6697 (Spectrum runs in
|
|
4
|
+
# `tls_mode = "full"`, re-encrypting edge→origin) — no plaintext IRC
|
|
5
|
+
# ever crosses the origin network. The container runs the IRC core
|
|
6
|
+
# (ConnectionActor + InMemoryRuntime) over the TcpByteStreamTransport
|
|
7
|
+
# seam behind its own TLS listener.
|
|
6
8
|
#
|
|
7
9
|
# Build context: the repository root (monorepo). The container needs the
|
|
8
10
|
# irc-core, irc-server, and in-memory-runtime packages.
|
|
9
11
|
#
|
|
10
12
|
# docker build -f apps/cf-tcp-container/Dockerfile -t sirc-tcp-origin .
|
|
11
|
-
# docker run -p
|
|
13
|
+
# docker run -p 6697:6697 \
|
|
14
|
+
# -v $(pwd)/certs:/data/certs:ro \
|
|
15
|
+
# -e SERVER_NAME=irc.example.com sirc-tcp-origin
|
|
16
|
+
#
|
|
17
|
+
# Certificates: mount a PEM cert/key pair at /data/certs/ (the image
|
|
18
|
+
# defaults TLS_CERT_PATH=/data/certs/tls.pem, TLS_KEY_PATH=/data/certs/
|
|
19
|
+
# tls.key). Let's Encrypt, Bring-Your-Own, and Cloudflare Origin Certs
|
|
20
|
+
# are all supported — see docs/Cloudflare-TCP-Deployment.md. Cert files
|
|
21
|
+
# must be readable by UID 1000 (node).
|
|
12
22
|
#
|
|
13
23
|
# For Cloudflare Containers deployment, the same image is referenced from
|
|
14
24
|
# wrangler.toml.
|
|
@@ -57,13 +67,35 @@ COPY --from=builder /app/apps/cf-tcp-container/dist/ apps/cf-tcp-container/dist/
|
|
|
57
67
|
RUN corepack enable && corepack prepare pnpm@11.17.0 --activate && \
|
|
58
68
|
pnpm install --frozen-lockfile --prod
|
|
59
69
|
|
|
70
|
+
# Runtime-writable state: the persistence snapshot lives in /data, and the
|
|
71
|
+
# runtime process runs as the unprivileged `node` user — so the directory
|
|
72
|
+
# must exist and be node-owned before privileges drop (chown needs root).
|
|
73
|
+
RUN mkdir -p /data && chown node:node /data
|
|
74
|
+
|
|
60
75
|
# Persistence volume: Spectrum-origin channel state survives restarts.
|
|
61
76
|
VOLUME ["/data"]
|
|
62
77
|
|
|
63
|
-
|
|
78
|
+
# Cert files for TLS termination at the origin: mount the PEM pair at
|
|
79
|
+
# /data/certs/ (see ENV defaults below) — Let's Encrypt, BYO, or a
|
|
80
|
+
# Cloudflare Origin Cert all work. Any cert mount must be readable by
|
|
81
|
+
# UID 1000 (node) — mount them owned by 1000:1000.
|
|
82
|
+
|
|
83
|
+
# Drop root: the IRC stack (parser + actor + transport) handles untrusted
|
|
84
|
+
# input, so it must not run as UID 0. node:24-slim ships the non-root
|
|
85
|
+
# `node` user (UID 1000) out of the box.
|
|
86
|
+
USER node
|
|
87
|
+
|
|
88
|
+
EXPOSE 6697
|
|
64
89
|
|
|
90
|
+
# The image is a production origin by default: the config loader then
|
|
91
|
+
# refuses to boot without TLS material (no silent plaintext fallback).
|
|
92
|
+
ENV NODE_ENV=production
|
|
65
93
|
ENV TCP_PORT=6667
|
|
66
94
|
ENV TCP_HOST=0.0.0.0
|
|
67
95
|
ENV PERSISTENCE_PATH=/data/state.json
|
|
96
|
+
# Documented cert mount path (override per-deploy via TLS_CERT_PATH /
|
|
97
|
+
# TLS_KEY_PATH if the pair lives elsewhere).
|
|
98
|
+
ENV TLS_CERT_PATH=/data/certs/tls.pem
|
|
99
|
+
ENV TLS_KEY_PATH=/data/certs/tls.key
|
|
68
100
|
|
|
69
101
|
CMD ["node", "--enable-source-maps", "apps/cf-tcp-container/dist/main.js"]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverless-ircd/cf-tcp-container",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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
|
|
14
|
-
"deploy:prod": "wrangler deploy",
|
|
13
|
+
"deploy": "node ../../tools/ci-hardening/dist/cf-deploy-cli.js wrangler.toml",
|
|
15
14
|
"test": "vitest run",
|
|
16
15
|
"test:watch": "vitest",
|
|
17
16
|
"coverage": "vitest run --coverage",
|
|
@@ -22,6 +21,11 @@
|
|
|
22
21
|
"@serverless-ircd/irc-core": "workspace:*",
|
|
23
22
|
"@serverless-ircd/irc-server": "workspace:*"
|
|
24
23
|
},
|
|
24
|
+
"proxyProtocol": {
|
|
25
|
+
"version": 1,
|
|
26
|
+
"parser": "hand-rolled (src/proxy-protocol.ts)",
|
|
27
|
+
"rationale": "PROXY protocol v1 is a single fixed-format ASCII line, fully validated in ~40 lines; no npm proxy-protocol dependency is pulled onto the connection hot path (supply-chain surface > value)."
|
|
28
|
+
},
|
|
25
29
|
"devDependencies": {
|
|
26
30
|
"@cloudflare/containers": "^0.1.0",
|
|
27
31
|
"@types/node": "^26.1.1",
|