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
package/docs/AWS-Deployment.md
CHANGED
|
@@ -26,6 +26,8 @@ Key in-repo paths cited below:
|
|
|
26
26
|
| `packages/aws-adapter/src/cdk-table-defs.ts` | Five DynamoDB table shapes. |
|
|
27
27
|
| `packages/aws-adapter/src/config-loader.ts` | Lambda env → `ServerConfig` mapping. |
|
|
28
28
|
| `packages/aws-adapter/src/handlers/index.ts` | Lambda entry (`handler`, `sweeperHandler`, `pingCheckerHandler`). |
|
|
29
|
+
| `apps/aws-stack/src/static-site.ts` | CDK construct for the optional web client (S3 + CloudFront + OAC). See §16. |
|
|
30
|
+
| `scripts/deploy-web-aws.mjs` | Two-phase web client deploy (describe → bake → s3 sync → invalidate). See §16. |
|
|
29
31
|
| `.github/workflows/deploy-aws.yml` | Staging deploy + smoke e2e CI. |
|
|
30
32
|
|
|
31
33
|
**Acceptance criterion:** a new contributor can deploy their own
|
|
@@ -36,8 +38,11 @@ staging instance following only this doc.
|
|
|
36
38
|
## 1. What gets deployed
|
|
37
39
|
|
|
38
40
|
A single CDK v2 stack (`IrcAwsStack`) provisions the entire AWS
|
|
39
|
-
substrate.
|
|
40
|
-
|
|
41
|
+
substrate. Every resource is regional. The IRC runtime itself is
|
|
42
|
+
stateful compute (Lambda + DynamoDB + API Gateway WebSocket); the
|
|
43
|
+
optional **web client** (S3 + CloudFront + OAC) is provisioned only
|
|
44
|
+
when the `webSite`/`webSiteCustomDomain` CDK context is supplied. See
|
|
45
|
+
§16 for the web-serving path.
|
|
41
46
|
|
|
42
47
|
```
|
|
43
48
|
┌──────────────────────────────────────────────────────────────────┐
|
|
@@ -191,19 +196,30 @@ If you skip this, the first `cdk deploy` fails with a bucket-not-found
|
|
|
191
196
|
error (see §13.1). Bootstrap is idempotent — re-running on an
|
|
192
197
|
already-bootstrapped account is a no-op.
|
|
193
198
|
|
|
194
|
-
### 3.4
|
|
199
|
+
### 3.4 Set up OIDC for CI (required)
|
|
195
200
|
|
|
196
|
-
|
|
197
|
-
|
|
201
|
+
> **Mandatory.** Long-lived access keys (`AWS_ACCESS_KEY_ID` /
|
|
202
|
+
> `AWS_SECRET_ACCESS_KEY`) are NOT accepted by the deploy workflow.
|
|
203
|
+
> The workflow assumes a role via GitHub OIDC web identity and asserts
|
|
204
|
+
> the assumed role matches `AWS_DEPLOY_ROLE_ARN` before any `cdk
|
|
205
|
+
> deploy`. The full trust-policy snippet, condition keys, and
|
|
206
|
+
> verification steps are in **§17**.
|
|
198
207
|
|
|
199
|
-
|
|
208
|
+
To deploy via the GitHub Actions workflow (§12) you MUST configure
|
|
209
|
+
GitHub OIDC — there is no static-key fallback. Summary of the one-time
|
|
210
|
+
setup (full snippet and verification in §17):
|
|
211
|
+
|
|
212
|
+
1. Create an IAM identity provider for
|
|
213
|
+
`token.actions.githubusercontent.com` (one per AWS account).
|
|
200
214
|
2. Create a role with a trust policy granting
|
|
201
|
-
`sts:AssumeRoleWithWebIdentity` to your repo's
|
|
215
|
+
`sts:AssumeRoleWithWebIdentity` to your repo's
|
|
216
|
+
`ref:refs/heads/main` claim (the workflow is
|
|
217
|
+
`workflow_dispatch`-only on `main`).
|
|
202
218
|
3. Attach a permission policy broad enough to manage the stack (IAM,
|
|
203
|
-
API Gateway v2, Lambda, DynamoDB, EventBridge, CloudFormation)
|
|
219
|
+
API Gateway v2, Lambda, DynamoDB, EventBridge, CloudFormation) —
|
|
220
|
+
ideally the same surface `cdk deploy` needs.
|
|
204
221
|
4. Note the role ARN — it becomes the `AWS_DEPLOY_ROLE_ARN` secret in
|
|
205
|
-
CI.
|
|
206
|
-
the fallback; the workflow supports both.
|
|
222
|
+
CI.
|
|
207
223
|
|
|
208
224
|
---
|
|
209
225
|
|
|
@@ -283,18 +299,18 @@ shared protocol layer.
|
|
|
283
299
|
|
|
284
300
|
---
|
|
285
301
|
|
|
286
|
-
## 5. Deploy
|
|
302
|
+
## 5. Deploy
|
|
287
303
|
|
|
288
|
-
|
|
289
|
-
also deploy
|
|
304
|
+
The deploy is `workflow_dispatch`-only (a maintainer runs it by hand);
|
|
305
|
+
you can also deploy manually from a clean checkout.
|
|
290
306
|
|
|
291
307
|
### 5.1 Deploy from your machine
|
|
292
308
|
|
|
293
309
|
```bash
|
|
294
310
|
# From the repo root:
|
|
295
|
-
pnpm deploy:aws
|
|
311
|
+
pnpm deploy:aws
|
|
296
312
|
# Equivalent to:
|
|
297
|
-
# pnpm --filter @serverless-ircd/aws-stack deploy
|
|
313
|
+
# pnpm --filter @serverless-ircd/aws-stack run deploy
|
|
298
314
|
# → cdk deploy --all --require-approval never
|
|
299
315
|
```
|
|
300
316
|
|
|
@@ -349,38 +365,39 @@ TCP IRC client to a WebSocket endpoint.
|
|
|
349
365
|
|
|
350
366
|
---
|
|
351
367
|
|
|
352
|
-
## 6.
|
|
353
|
-
|
|
354
|
-
The repo-level shortcut is:
|
|
355
|
-
|
|
356
|
-
```bash
|
|
357
|
-
pnpm deploy:aws:prod
|
|
358
|
-
# Equivalent to:
|
|
359
|
-
# pnpm --filter @serverless-ircd/aws-stack deploy:prod
|
|
360
|
-
# → cdk deploy --all --require-approval never
|
|
361
|
-
```
|
|
368
|
+
## 6. Production hardening
|
|
362
369
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
stack
|
|
368
|
-
`
|
|
370
|
+
There is a single deploy target per platform (`pnpm deploy:aws`). The
|
|
371
|
+
staging/prod split was collapsed — there is no `-staging` / `-prod`
|
|
372
|
+
stack-name suffix, no `environmentName` CDK context, and no per-env
|
|
373
|
+
table-name prefixing. The stack is always named `IrcAwsStack` (hardcoded
|
|
374
|
+
in `apps/aws-stack/bin/aws.ts`), and table names are the bare logical
|
|
375
|
+
ids (`Connections`, `Nicks`, …). The staging/production distinction is
|
|
376
|
+
**which AWS account and region the stack is deployed into**, controlled
|
|
377
|
+
by the credentials and `AWS_REGION` in the environment.
|
|
369
378
|
|
|
370
|
-
For a real production deployment, before your first `pnpm deploy:aws
|
|
379
|
+
For a real production deployment, before your first `pnpm deploy:aws`:
|
|
371
380
|
|
|
372
381
|
1. **Use a dedicated AWS account** (or at least a dedicated region) for
|
|
373
382
|
production. Do not point production credentials at the same stack
|
|
374
383
|
CI is deploying to on every push.
|
|
375
|
-
2. **
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
384
|
+
2. **Keep the default table protection on.** State-bearing tables
|
|
385
|
+
(`Nicks`, `Services`, `ChannelMeta`, `ChannelMembers`) default to
|
|
386
|
+
`RemovalPolicy.RETAIN` + `deletionProtection: true` + PITR-on, so a
|
|
387
|
+
mistaken `cdk destroy` or a stray `aws dynamodb delete-table` cannot
|
|
388
|
+
wipe them (see §9.4). Only set `-c allowTableDeletion=true` for a
|
|
389
|
+
deliberate teardown.
|
|
379
390
|
3. **Set the server identity** (`SERVER_NAME`, `NETWORK_NAME`,
|
|
380
391
|
`MOTD`) — see §7.1. `SERVER_NAME` is required at boot; the stack
|
|
381
392
|
falls back to `irc.localhost` when the prop is omitted, which is
|
|
382
393
|
intentionally not production-ready.
|
|
383
394
|
4. **Pick a region close to your users** (see §10).
|
|
395
|
+
5. **Do NOT re-enable API Gateway `DataTraceEnabled`.** It is OFF by default
|
|
396
|
+
(safe). Turning it on logs the full body of every IRC frame (`PASS`,
|
|
397
|
+
`AUTHENTICATE <SASL-PLAIN>`, channel keys, all `PRIVMSG`/`NOTICE`) to
|
|
398
|
+
CloudWatch, leaking credentials to anyone with `logs:GetLogEvents`. The
|
|
399
|
+
two-step sandbox-only escape hatch is documented in §7.6; never use it
|
|
400
|
+
against a production account.
|
|
384
401
|
|
|
385
402
|
There is **no CI auto-deploy to production**. The deploy workflow
|
|
386
403
|
(`deploy-aws.yml`) only triggers on push to `main` and targets
|
|
@@ -418,8 +435,7 @@ These three values are injected into the `IrcHandler` and
|
|
|
418
435
|
pass the construct props when creating the stack:
|
|
419
436
|
|
|
420
437
|
```ts
|
|
421
|
-
new IrcAwsStack(app, 'IrcAwsStack
|
|
422
|
-
environmentName: 'production',
|
|
438
|
+
new IrcAwsStack(app, 'IrcAwsStack', {
|
|
423
439
|
serverName: 'irc.my.net',
|
|
424
440
|
networkName: 'MyNet',
|
|
425
441
|
motdLines: ['Welcome to my network.', 'Be excellent to each other.'],
|
|
@@ -484,6 +500,13 @@ any of them** — to enable one, add an `addEnvironment(...)` line in
|
|
|
484
500
|
| `MAX_LIST_ENTRIES` | int | `maxListEntries` |
|
|
485
501
|
| `QUIT_MESSAGE` | string | `quitMessage` |
|
|
486
502
|
| `SASL_ACCOUNTS` | string | `saslAccounts` |
|
|
503
|
+
| `MAX_CONNECTIONS_PER_IP` | int | `maxConnectionsPerIp` |
|
|
504
|
+
| `PER_IP_CONNECTION_RATE_MAX` | int | `perIpConnectionRate.max` |
|
|
505
|
+
| `PER_IP_CONNECTION_RATE_WINDOW_MS` | int | `perIpConnectionRate.windowMs` |
|
|
506
|
+
|
|
507
|
+
The last three are the per-IP admission knobs (§7.8): setting only one
|
|
508
|
+
half of the rate pair falls back to the schema default (5 per 60 000 ms)
|
|
509
|
+
for the other half.
|
|
487
510
|
|
|
488
511
|
`SASL_ACCOUNTS` is the legacy/config fallback (newline-delimited
|
|
489
512
|
`username:password` pairs); it is used only when the `Accounts` table is
|
|
@@ -493,18 +516,25 @@ Invalid values cause a readable boot-time error — the Lambda will
|
|
|
493
516
|
throw on the first invocation (cold start). Test changes locally with
|
|
494
517
|
`pnpm --filter @serverless-ircd/aws-adapter test`.
|
|
495
518
|
|
|
519
|
+
> **Note:** `WEB_ORIGINS` is intentionally NOT in this table. It is an
|
|
520
|
+
> adapter-level operational concern (the `$connect` CSWSH defence), not
|
|
521
|
+
> a `ServerConfig` knob — the env var is parsed directly by
|
|
522
|
+
> `buildDepsFromEnv` via `parseWebOrigins` rather than threaded through
|
|
523
|
+
> the shared Zod schema. See §8.2.
|
|
524
|
+
|
|
496
525
|
### 7.4 No per-environment stack parameter
|
|
497
526
|
|
|
498
|
-
There is no `stageName` or `
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
527
|
+
There is no `stageName`, `environmentType`, or `environmentName`
|
|
528
|
+
parameter on the CDK stack (the former staging/prod split was
|
|
529
|
+
collapsed into a single target). The stack name (`IrcAwsStack`), table
|
|
530
|
+
names (`Connections`, `ChannelMeta`, `ChannelMembers`, `Nicks`,
|
|
531
|
+
`Accounts`, `Services`), API Gateway stage name (`prod`), and Lambda
|
|
532
|
+
construct ids are all hardcoded. To run staging and production in
|
|
533
|
+
**the same AWS account**, you would need to fork the stack to
|
|
534
|
+
parameterize these names; the recommended path is to use separate
|
|
535
|
+
accounts (or separate regions) for staging vs production. This is the
|
|
536
|
+
same architectural choice the Cloudflare adapter makes (one Worker
|
|
537
|
+
namespace; staging vs production isolation is by account).
|
|
508
538
|
|
|
509
539
|
### 7.5 Lambda runtime and bundling
|
|
510
540
|
|
|
@@ -525,6 +555,91 @@ namespaces).
|
|
|
525
555
|
| Auto-deploy | `true` (changes propagate without a manual deployment). |
|
|
526
556
|
| Auth on `$connect`| None today (no authorizer). See §13.5. |
|
|
527
557
|
| Route responses | Default (`$connect` returns 200 → upgrade; `$default` and `$disconnect` are fire-and-forget). |
|
|
558
|
+
| Logging level | `INFO` by default; override at deploy time via `--parameters ApiGatewayLoggingLevel=OFF\|INFO\|ERROR`. Emits request/response metadata (route, status, source IP, …) **without** frame bodies. |
|
|
559
|
+
| Full-frame body tracing (`DataTraceEnabled`) | **OFF by default and hard-locked.** See the warning below. |
|
|
560
|
+
|
|
561
|
+
#### ⚠️ Re-enabling `DataTraceEnabled` leaks IRC credentials
|
|
562
|
+
|
|
563
|
+
API Gateway execution-log body tracing (`DataTraceEnabled = true`) writes
|
|
564
|
+
the FULL payload of every WebSocket frame to the APIGW CloudWatch log group
|
|
565
|
+
(`AWS/ApiGateway…`, separate from the stack's access-log group). For an IRC
|
|
566
|
+
endpoint that includes, verbatim:
|
|
567
|
+
|
|
568
|
+
- `PASS <server password>`
|
|
569
|
+
- `AUTHENTICATE <base64-SASL-PLAIN>` (decodes to `account\0account\0password`)
|
|
570
|
+
- `JOIN #channel <channel-key>`
|
|
571
|
+
- every `PRIVMSG` / `NOTICE` (private messages)
|
|
572
|
+
|
|
573
|
+
Anyone with `logs:GetLogEvents` — operators, SREs, a Logpush destination, or
|
|
574
|
+
compromised CI credentials — recovers server passwords, SASL accounts,
|
|
575
|
+
channel keys, and private messages. Retention is account-default (unbounded).
|
|
576
|
+
|
|
577
|
+
For this reason `DataTraceEnabled` defaults to `false` and is the single
|
|
578
|
+
highest-impact logging control on the stack. INFO-level execution logging
|
|
579
|
+
alone is enough to diagnose whether API Gateway received and routed a frame
|
|
580
|
+
or dropped it pre-Lambda.
|
|
581
|
+
|
|
582
|
+
Because the staging/prod split was collapsed (every synth is effectively a
|
|
583
|
+
prod synth), there is no quiet "non-prod" path to re-enable it. The only way
|
|
584
|
+
back on is an explicit, **two-step opt-in** that exists solely for live
|
|
585
|
+
debugging in a throwaway sandbox:
|
|
586
|
+
|
|
587
|
+
```sh
|
|
588
|
+
pnpm --filter @serverless-ircd/aws-stack run cdk:synth -- \
|
|
589
|
+
-c allowDataTrace=true \
|
|
590
|
+
-c iUnderstandThisLeaksCredentials=true
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
Setting only `-c allowDataTrace=true` **refuses synthesis** with a loud error
|
|
594
|
+
that names the credentials it would leak. The awkward second flag is
|
|
595
|
+
intentional — it cannot be set by accident. Tear the sandbox stack down
|
|
596
|
+
immediately after debugging; never deploy this combination to a shared
|
|
597
|
+
account.
|
|
598
|
+
|
|
599
|
+
### 7.7 DynamoDB table-protection context flags
|
|
600
|
+
|
|
601
|
+
Two CDK context flags control the state-bearing tables' data-loss
|
|
602
|
+
protection (`Nicks`, `Services`, `ChannelMeta`, `ChannelMembers`).
|
|
603
|
+
`Connections` is always `DESTROY` with no PITR and is unaffected by
|
|
604
|
+
either flag. See §9.4 for the full table and the rationale (and the
|
|
605
|
+
"no snapshot-on-delete" note).
|
|
606
|
+
|
|
607
|
+
| Context flag | Default | Effect when flipped |
|
|
608
|
+
|------------------------|---------|-------------------------------------------------------------------------------------|
|
|
609
|
+
| `allowTableDeletion` | `false` | `true` → state tables become `RemovalPolicy.DESTROY` + `deletionProtection: false`, so a deliberate `cdk destroy` teardown can delete them. Data is irreversibly lost. |
|
|
610
|
+
| `enablePitr` | `true` | `false` → point-in-time recovery is disabled on every state table. Independent of `allowTableDeletion` (a teardown keeps PITR on). PITR costs ~$0.20/GB-month per table (§11.1). |
|
|
611
|
+
|
|
612
|
+
Both arrive as strings on the CLI and are normalised with the shared
|
|
613
|
+
`parseContextBool` helper (same path as `allowDataTrace`), so
|
|
614
|
+
`-c allowTableDeletion=false` stays false-y. They map onto the
|
|
615
|
+
`IrcStackProps.allowTableDeletion` / `IrcStackProps.enablePitr` construct
|
|
616
|
+
props (`apps/aws-stack/bin/aws.ts`).
|
|
617
|
+
|
|
618
|
+
### 7.8 Connect throttling (per-IP admission, stage limits, WAF)
|
|
619
|
+
|
|
620
|
+
Connection setup is where credential brute-force spends its budget
|
|
621
|
+
(`PASS` / `AUTHENTICATE` per connection), so the stack throttles it in
|
|
622
|
+
three layers, outermost first:
|
|
623
|
+
|
|
624
|
+
| Layer | Where | Knob | Default |
|
|
625
|
+
|-------|-------|------|---------|
|
|
626
|
+
| **1. WAF edge rate limit** | WAFv2 rate-based rule, associated with the stage. WAF only inspects the initial HTTP upgrade on a WebSocket API, so the rule is effectively scoped to `$connect` and never re-fires on established frames. | `-c wafConnectRateLimit=<req/5min/IP>` (CDK context; blocks at the edge before a Lambda invocation is billed) | off (no WAF resources) |
|
|
627
|
+
| **2. APIGW stage throttling** | `defaultRouteSettings` on the stage — global backstop across ALL routes and IPs. | `--parameters ApiThrottlingRateLimit=…` / `--parameters ApiThrottlingBurstLimit=…` (CloudFormation parameters) | 100 rps / burst 200 |
|
|
628
|
+
| **3. Per-IP admission gates** | Inside the `$connect` Lambda: a paginated Query against the `Connections` table's `sourceIp-connectedSince` GSI counts the caller IP's live connections (`maxConnectionsPerIp`) and in-window establishments (`perIpConnectionRate`); over-budget connects return `429` and no row is written. The same rate budget gates NLB new flows (§AWS-TCP-Deployment). | `MAX_CONNECTIONS_PER_IP`, `PER_IP_CONNECTION_RATE_MAX`, `PER_IP_CONNECTION_RATE_WINDOW_MS` env vars (§7.3) | 10 / 5 per 60 s |
|
|
629
|
+
|
|
630
|
+
Notes:
|
|
631
|
+
|
|
632
|
+
- Layer 1 is opt-in because WAFv2 web ACLs cost ~$5/mo + $1/rule + $0.60/M
|
|
633
|
+
requests; for a small deployment, layers 2+3 are usually enough. Enable
|
|
634
|
+
with `pnpm --filter @serverless-ircd/aws-stack run cdk:synth -- -c wafConnectRateLimit=2000`
|
|
635
|
+
(2000 upgrade requests per 5-minute window per client IP before the IP
|
|
636
|
+
is blocked at the edge).
|
|
637
|
+
- Layer 3's per-IP counts are eventually consistent (GSI reads), so a
|
|
638
|
+
coordinated burst from one IP can slightly overshoot the cap — the same
|
|
639
|
+
best-effort TOCTOU contract as the global `maxClients` counter.
|
|
640
|
+
- The 429 response carries `ERROR :Closing link` semantics at the HTTP
|
|
641
|
+
layer where possible; clients should treat it as back-pressure (retry
|
|
642
|
+
after the window decays), not a permanent ban.
|
|
528
643
|
|
|
529
644
|
---
|
|
530
645
|
|
|
@@ -599,7 +714,7 @@ root):
|
|
|
599
714
|
node --input-type=module -e '
|
|
600
715
|
import { createDynamoDocumentClient, putAccountCredential } from "@serverless-ircd/aws-adapter";
|
|
601
716
|
const dynamo = createDynamoDocumentClient({ region: "us-east-1" });
|
|
602
|
-
await putAccountCredential(dynamo, "
|
|
717
|
+
await putAccountCredential(dynamo, "Accounts", "alice", "change-me");
|
|
603
718
|
console.log("seeded alice");
|
|
604
719
|
'
|
|
605
720
|
```
|
|
@@ -610,13 +725,13 @@ calls for batch provisioning:
|
|
|
610
725
|
```bash
|
|
611
726
|
# Seed against DynamoDB Local:
|
|
612
727
|
npx tsx tools/seed-aws-accounts.ts \
|
|
613
|
-
--table
|
|
728
|
+
--table Accounts \
|
|
614
729
|
--endpoint http://localhost:8000 \
|
|
615
730
|
--accounts alice:s3cret bob:password2
|
|
616
731
|
|
|
617
732
|
# Or from a file (newline-delimited username:password):
|
|
618
733
|
npx tsx tools/seed-aws-accounts.ts \
|
|
619
|
-
--table
|
|
734
|
+
--table Accounts \
|
|
620
735
|
--region us-east-1 \
|
|
621
736
|
--file accounts.txt
|
|
622
737
|
```
|
|
@@ -627,7 +742,7 @@ row directly:
|
|
|
627
742
|
```bash
|
|
628
743
|
# Hash with the adapter, then write the row yourself:
|
|
629
744
|
aws dynamodb put-item \
|
|
630
|
-
--table-name
|
|
745
|
+
--table-name Accounts \
|
|
631
746
|
--item '{"account":{"S":"alice"},"algorithm":{"S":"scrypt"},"salt":{"S":"<base64-salt>"},"hash":{"S":"<base64-hash>"}}'
|
|
632
747
|
```
|
|
633
748
|
|
|
@@ -641,14 +756,80 @@ aws dynamodb put-item \
|
|
|
641
756
|
vars are visible in the console and in CloudFormation describe output;
|
|
642
757
|
they are not secret.
|
|
643
758
|
|
|
759
|
+
### 8.2 `$connect` Origin allowlist (`WEB_ORIGINS`, CSWSH defence)
|
|
760
|
+
|
|
761
|
+
WebSocket upgrades do not follow the same-origin policy, so a malicious
|
|
762
|
+
web page can open a WebSocket to the IRC server from the victim's
|
|
763
|
+
browser and drive the session with the victim's credentials (Cross-Site
|
|
764
|
+
WebSocket Hijacking, CSWSH). API Gateway WebSocket has **no built-in
|
|
765
|
+
`Origin` validation** — the `$connect` Lambda receives
|
|
766
|
+
`event.headers.Origin` and must enforce the allowlist itself.
|
|
767
|
+
|
|
768
|
+
**Knob:** `WEB_ORIGINS` — a comma-separated list of allowed web origins
|
|
769
|
+
(e.g. `https://app.example.com, https://staging.app.example.com`).
|
|
770
|
+
Mirror of the CF Worker's `WEB_ORIGINS` (TICKET-137).
|
|
771
|
+
|
|
772
|
+
**Behaviour** (`packages/aws-adapter/src/origin-allowlist.ts` and
|
|
773
|
+
`packages/aws-adapter/src/handlers/connect.ts`):
|
|
774
|
+
|
|
775
|
+
| `WEB_ORIGINS` | `Origin` header on the upgrade | Outcome |
|
|
776
|
+
|---------------|--------------------------------|----------------------------------------|
|
|
777
|
+
| unset / empty | (any) | **Allow** — defence disabled (default) |
|
|
778
|
+
| set | absent (non-browser client) | **Allow** — curl / WeeChat / `tcp-ws-forwarder` / NLB TCP+TLS pass through |
|
|
779
|
+
| set | present, in allowlist | **Allow** |
|
|
780
|
+
| set | present, NOT in allowlist | **Deny** — `$connect` returns `403`; API Gateway closes the upgrade |
|
|
781
|
+
|
|
782
|
+
**The defence is opt-in.** Unset `WEB_ORIGINS` skips the check entirely
|
|
783
|
+
— existing AWS deployments without a web frontend are unchanged on
|
|
784
|
+
upgrade. The check only matters once you ship a web client (TICKET-164)
|
|
785
|
+
and want to admit the SPA's origin while rejecting hostile ones.
|
|
786
|
+
|
|
787
|
+
**Matching semantics** (mirror the CF Worker):
|
|
788
|
+
- entries are trimmed of surrounding whitespace and lowercased
|
|
789
|
+
(`https://APP.Example.COM` → `https://app.example.com`);
|
|
790
|
+
- the incoming `Origin` header is normalised the same way before the
|
|
791
|
+
membership test, so the match is case-insensitive on scheme+host
|
|
792
|
+
(per RFC 3986);
|
|
793
|
+
- origins are otherwise case-sensitive — `http://app.example.com` is
|
|
794
|
+
a different origin from `https://app.example.com` and is denied if
|
|
795
|
+
only the `https://` form is listed (defends against a stripped-TLS
|
|
796
|
+
attacker on the same hostname).
|
|
797
|
+
|
|
798
|
+
**Deliberate divergence from the CF policy:** the CF Worker has a
|
|
799
|
+
same-origin auto-derive mode (when `WEB_ORIGINS` is unset, it compares
|
|
800
|
+
`Origin` against the request's own `Host`). On AWS the SPA is served
|
|
801
|
+
from a different CloudFront origin than the API Gateway WebSocket
|
|
802
|
+
endpoint (`wss://{api-id}.execute-api.…`), so the request's own host is
|
|
803
|
+
never the SPA's origin — auto-derive does not apply. The AWS defence is
|
|
804
|
+
therefore explicit-allowlist only.
|
|
805
|
+
|
|
806
|
+
**Wiring.** Inject `WEB_ORIGINS` on the `$connect` Lambda via the CDK
|
|
807
|
+
stack prop:
|
|
808
|
+
|
|
809
|
+
```typescript
|
|
810
|
+
new IrcAwsStack(app, 'IrcAwsStack', {
|
|
811
|
+
webOrigins: 'https://app.example.com, https://staging.app.example.com',
|
|
812
|
+
// ...other props
|
|
813
|
+
});
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
…or at deploy time via CDK context (`-c webOrigins=…`). The stack
|
|
817
|
+
injects the env var only on the wss `$connect` handler (the sweeper,
|
|
818
|
+
ping-checker, and NLB handler never run `$connect`). The handler parses
|
|
819
|
+
the value once per cold start via `parseWebOrigins` and stores the
|
|
820
|
+
resulting `Set<string>` on `HandlerDeps.webOrigins`.
|
|
821
|
+
|
|
822
|
+
`WEB_ORIGINS` is **not** a secret — it is a public allowlist of origin
|
|
823
|
+
strings. Do not put it in Secrets Manager / SSM; set it as a plain
|
|
824
|
+
Lambda env var (the CDK stack does this directly via
|
|
825
|
+
`addEnvironment`).
|
|
826
|
+
|
|
644
827
|
The CI deploy uses **GitHub Actions secrets** plus an optional repo
|
|
645
828
|
variable (see §12):
|
|
646
829
|
|
|
647
830
|
| Secret | Used by | Purpose |
|
|
648
831
|
|-------------------------|-------------------|---------------------------------------------------------------|
|
|
649
|
-
| `
|
|
650
|
-
| `AWS_SECRET_ACCESS_KEY` | `deploy-aws.yml` | Companion secret key. Required unless OIDC is used. |
|
|
651
|
-
| `AWS_DEPLOY_ROLE_ARN` | `deploy-aws.yml` | Optional. When set, the job assumes this role via OIDC web identity instead of using static keys. |
|
|
832
|
+
| `AWS_DEPLOY_ROLE_ARN` | `deploy-aws.yml` | **Required.** ARN of the IAM role the workflow assumes via GitHub OIDC web identity. No static-key fallback. |
|
|
652
833
|
| `AWS_REGION` | `deploy-aws.yml` | Region to deploy into (e.g. `us-east-1`). |
|
|
653
834
|
|
|
654
835
|
| Variable | Purpose |
|
|
@@ -728,28 +909,62 @@ postToConnection, per-channel send-list cache, a dedicated fanout
|
|
|
728
909
|
Lambda) are deferred to post-v1; the load-test ticket will surface
|
|
729
910
|
the ceiling.
|
|
730
911
|
|
|
731
|
-
### 9.4 Removal policy (IMPORTANT)
|
|
912
|
+
### 9.4 Removal policy, deletion protection, and PITR (IMPORTANT)
|
|
913
|
+
|
|
914
|
+
Every table is protected from accidental data loss by default. The
|
|
915
|
+
state-bearing tables (`Nicks`, `Services`, `ChannelMeta`, `ChannelMembers`)
|
|
916
|
+
get **three independent safeguards**, each toggleable so a deliberate
|
|
917
|
+
teardown can still proceed; the one ephemeral table (`Connections`) is
|
|
918
|
+
always DESTROY:
|
|
919
|
+
|
|
920
|
+
| Table | RemovalPolicy | `deletionProtection` | PITR |
|
|
921
|
+
|-----------------------------------------------|---------------|----------------------|------|
|
|
922
|
+
| `Nicks`, `Services`, `ChannelMeta`, `ChannelMembers` | `RETAIN` (default) | `true` (default) | `true` (default) |
|
|
923
|
+
| `Connections` | `DESTROY` | (unset → off) | (unset → off) |
|
|
924
|
+
|
|
925
|
+
- **`RemovalPolicy.RETAIN`** — when the stack is deleted, CloudFormation
|
|
926
|
+
*orphans* the table instead of deleting it; the data survives.
|
|
927
|
+
- **`deletionProtection: true`** — DynamoDB refuses a `DeleteTable` API
|
|
928
|
+
call while this is on, blocking a stray `aws dynamodb delete-table` even
|
|
929
|
+
from an admin or compromised credential.
|
|
930
|
+
- **`pointInTimeRecovery` (PITR)** — continuous, per-table restore (within
|
|
931
|
+
the last ~35 days) against accidental writes/deletes that RETAIN +
|
|
932
|
+
deletion protection cannot stop (e.g. a buggy `UpdateItem` that
|
|
933
|
+
overwrites rows).
|
|
934
|
+
|
|
935
|
+
> **No snapshot-on-delete.** DynamoDB tables do NOT snapshot when a stack
|
|
936
|
+
> is deleted (`RemovalPolicy.SNAPSHOT` is a no-op here). `RETAIN` (orphan +
|
|
937
|
+
> keep) + PITR (continuous, in-place restore) is the correct combination;
|
|
938
|
+
> do not rely on a snapshot that does not exist.
|
|
939
|
+
|
|
940
|
+
Two CDK context flags control these (threaded through `bin/aws.ts`, see
|
|
941
|
+
§7.7):
|
|
942
|
+
|
|
943
|
+
- **`allowTableDeletion`** (default `false`). Set `-c allowTableDeletion=true`
|
|
944
|
+
for a deliberate `cdk destroy` teardown: every state table flips to
|
|
945
|
+
`RemovalPolicy.DESTROY` with `deletionProtection: false` so CloudFormation
|
|
946
|
+
can delete them. The data is **irreversibly lost** — flip this off only
|
|
947
|
+
when you genuinely want the tables gone. `Connections` is always DESTROY
|
|
948
|
+
regardless.
|
|
949
|
+
- **`enablePitr`** (default `true`). Set `-c enablePitr=false` to disable
|
|
950
|
+
PITR on every state table. Independent of `allowTableDeletion` — a
|
|
951
|
+
teardown keeps PITR on (harmless, and the data is still recoverable up
|
|
952
|
+
until the table is actually deleted). `Connections` never carries PITR.
|
|
732
953
|
|
|
733
|
-
|
|
954
|
+
```bash
|
|
955
|
+
# Default deploy: state tables RETAIN + deletion-protected + PITR-on.
|
|
956
|
+
pnpm --filter @serverless-ircd/aws-stack run cdk:synth
|
|
734
957
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
const tables = Object.entries(TABLE_DEFS).map(
|
|
738
|
-
([logicalId, tableProps]) =>
|
|
739
|
-
new Table(this, logicalId, { ...tableProps, removalPolicy: RemovalPolicy.DESTROY }),
|
|
740
|
-
);
|
|
741
|
-
```
|
|
958
|
+
# Deliberate teardown (state tables become DELETABLE):
|
|
959
|
+
pnpm --filter @serverless-ircd/aws-stack run cdk:synth -- -c allowTableDeletion=true
|
|
742
960
|
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
961
|
+
# Disable PITR (e.g. cost-sensitive dev deploy):
|
|
962
|
+
pnpm --filter @serverless-ircd/aws-stack run cdk:synth -- -c enablePitr=false
|
|
963
|
+
```
|
|
746
964
|
|
|
747
|
-
|
|
748
|
-
`
|
|
749
|
-
|
|
750
|
-
editing `aws-stack.ts`. Recommendation: ship a `production: boolean`
|
|
751
|
-
stack prop in a future change that flips the removal policy. Flagged
|
|
752
|
-
in §15.
|
|
965
|
+
The protection knobs are asserted per parameter combination in
|
|
966
|
+
`apps/aws-stack/tests/stack.test.ts` (the "DynamoDB table protection"
|
|
967
|
+
suite).
|
|
753
968
|
|
|
754
969
|
### 9.5 TTL on `Connections.idleSince`
|
|
755
970
|
|
|
@@ -819,9 +1034,10 @@ see ~10 ms. **Pick the region closest to your user base.**
|
|
|
819
1034
|
|
|
820
1035
|
### 10.2 Recommendation: single-region for v1
|
|
821
1036
|
|
|
822
|
-
For v1, deploy in one region.
|
|
823
|
-
|
|
824
|
-
|
|
1037
|
+
For v1, deploy in one region. The IRC runtime is regional stateful
|
|
1038
|
+
compute (Lambda + DynamoDB + APIGW); CloudFront only enters the picture
|
|
1039
|
+
for the optional web client (§16) and is global-edge, not a region
|
|
1040
|
+
picker. Single-region keeps the data model simple: the cross-table
|
|
825
1041
|
`TransactWriteItems` flows (§9.2) only work within one region.
|
|
826
1042
|
|
|
827
1043
|
### 10.3 Multi-region is out of scope for v1
|
|
@@ -877,12 +1093,25 @@ verify against the current AWS pricing page** before budgeting:
|
|
|
877
1093
|
| Lambda | GB-second | $0.0000166667 (≈ $0.0167 / GB-hour) |
|
|
878
1094
|
| DynamoDB on-demand | write request unit (WRU) | $1.25 / million |
|
|
879
1095
|
| DynamoDB on-demand | read request unit (RRU) | $0.25 / million |
|
|
1096
|
+
| DynamoDB PITR (continuous)| GB-month of table data + indexes, per table | ~$0.20 / GB-month |
|
|
880
1097
|
| EventBridge Scheduler | event published | $1.00 / million |
|
|
881
1098
|
| CloudWatch Logs | GB ingested | $0.50 |
|
|
882
1099
|
|
|
883
1100
|
The Lambda free tier (1M requests + 400k GB-seconds per month) covers
|
|
884
1101
|
a meaningful slice of a small staging deployment.
|
|
885
1102
|
|
|
1103
|
+
> **Table-protection cost.** `RemovalPolicy.RETAIN` and
|
|
1104
|
+
> `deletionProtection` are **free** — they are CloudFormation/DynamoDB
|
|
1105
|
+
> flags, not billable features. Point-in-time recovery (PITR) **is**
|
|
1106
|
+
> billed: continuously at ~$0.20/GB-month **per table**, charged on the
|
|
1107
|
+
> full table size including indexes, for as long as PITR is on. With the
|
|
1108
|
+
> default (`enablePitr=true`) this applies to the four state tables
|
|
1109
|
+
> (`Nicks`, `Services`, `ChannelMeta`, `ChannelMembers`); `Connections`
|
|
1110
|
+
> never carries PITR. For a small deployment these tables are tiny
|
|
1111
|
+
> (kilobytes to low megabytes), so the PITR line item is fractions of a
|
|
1112
|
+
> cent; it only becomes material if a table grows to many GB. Disable
|
|
1113
|
+
> with `-c enablePitr=false` on a cost-sensitive dev deploy (see §9.4).
|
|
1114
|
+
|
|
886
1115
|
### 11.2 Back-of-envelope: 100 concurrent users
|
|
887
1116
|
|
|
888
1117
|
Assume a small staging deployment with 100 concurrent users, average
|
|
@@ -922,10 +1151,10 @@ single 1k-member announcement channel can multiply the fanout cost by
|
|
|
922
1151
|
|
|
923
1152
|
## 12. CI/CD
|
|
924
1153
|
|
|
925
|
-
The GitHub Actions workflow at `.github/workflows/deploy-aws.yml`
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
1154
|
+
The GitHub Actions workflow at `.github/workflows/deploy-aws.yml` is
|
|
1155
|
+
`workflow_dispatch`-only (a maintainer triggers it by hand); there is
|
|
1156
|
+
no automatic push-to-main deploy. It deploys the single AWS target and
|
|
1157
|
+
replays the smoke e2e.
|
|
929
1158
|
|
|
930
1159
|
### 12.1 What the workflow does
|
|
931
1160
|
|
|
@@ -936,60 +1165,91 @@ Steps performed (in order):
|
|
|
936
1165
|
3. `pnpm install --frozen-lockfile`.
|
|
937
1166
|
4. `pnpm build` — builds all workspace packages.
|
|
938
1167
|
5. `pnpm typecheck` and `pnpm test` — gate the deploy.
|
|
939
|
-
6.
|
|
940
|
-
|
|
941
|
-
7.
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
`
|
|
1168
|
+
6. **Assert `AWS_DEPLOY_ROLE_ARN` is non-empty** (fail-fast before any
|
|
1169
|
+
AWS call).
|
|
1170
|
+
7. Configure AWS credentials via OIDC (`aws-actions/configure-aws-credentials@v4`
|
|
1171
|
+
with `role-to-assume` only — no static keys).
|
|
1172
|
+
8. **Assert `aws sts get-caller-identity` returns the configured role**
|
|
1173
|
+
(defence-in-depth against silent fallbacks).
|
|
1174
|
+
9. `pnpm deploy:aws` → `cdk deploy --all --require-approval never`.
|
|
1175
|
+
10. Resolve the smoke URL: repo variable `AWS_SMOKE_URL` first, else
|
|
1176
|
+
the stack's `ConnectUrl` CloudFormation output via
|
|
1177
|
+
`aws cloudformation describe-stacks --stack-name IrcAwsStack`, else
|
|
1178
|
+
skip the smoke step with a warning.
|
|
1179
|
+
11. `pnpm smoke:aws -- --url "$SMOKE_URL"` — failure fails the
|
|
1180
|
+
build.
|
|
1181
|
+
|
|
1182
|
+
Concurrency is serialized via `concurrency.group: aws-deploy`,
|
|
1183
|
+
`cancel-in-progress: false`, so two runs cannot race the same
|
|
951
1184
|
CloudFormation stack.
|
|
952
1185
|
|
|
953
1186
|
### 12.2 Required repo configuration
|
|
954
1187
|
|
|
955
1188
|
Under **Settings → Secrets and variables → Actions**:
|
|
956
1189
|
|
|
957
|
-
- **Secret `
|
|
958
|
-
|
|
959
|
-
|
|
1190
|
+
- **Secret `AWS_DEPLOY_ROLE_ARN`** — **required**. ARN of the IAM
|
|
1191
|
+
role the workflow assumes via GitHub OIDC. There is no static-key
|
|
1192
|
+
fallback. Set this up once per AWS account (see §17 for the trust
|
|
1193
|
+
policy).
|
|
960
1194
|
- **Secret `AWS_REGION`** — required (e.g. `us-east-1`).
|
|
961
1195
|
- **Variable `AWS_SMOKE_URL`** — optional but recommended; the
|
|
962
1196
|
workflow can fall back to the CFN output, but setting this variable
|
|
963
1197
|
explicitly avoids the `describe-stacks` round-trip and the
|
|
964
1198
|
possibility of a stale cached value after a redeploy.
|
|
965
1199
|
|
|
966
|
-
|
|
1200
|
+
> **Removed:** `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` are no
|
|
1201
|
+
> longer read by the workflow. Long-lived access keys powerful enough
|
|
1202
|
+
> to deploy CloudFormation + IAM + Lambda + DynamoDB are effectively
|
|
1203
|
+
> account-admin; a single exfil vector yields full AWS account
|
|
1204
|
+
> takeover. The H1 security-hardening ticket dropped them. If you have
|
|
1205
|
+
> stale `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` secrets in the
|
|
1206
|
+
> repo, delete them and invalidate the underlying IAM access key in
|
|
1207
|
+
> IAM → Users → Security credentials.
|
|
967
1208
|
|
|
968
|
-
|
|
1209
|
+
### 12.3 OIDC is mandatory
|
|
1210
|
+
|
|
1211
|
+
The workflow accepts **only** GitHub OIDC web-identity credentials:
|
|
969
1212
|
|
|
970
1213
|
```yaml
|
|
1214
|
+
permissions:
|
|
1215
|
+
id-token: write
|
|
1216
|
+
contents: read
|
|
1217
|
+
|
|
1218
|
+
# …
|
|
1219
|
+
|
|
1220
|
+
- name: Assert OIDC deploy role ARN is set
|
|
1221
|
+
if: env.AWS_DEPLOY_ROLE_ARN == ''
|
|
1222
|
+
env:
|
|
1223
|
+
AWS_DEPLOY_ROLE_ARN: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
|
|
1224
|
+
run: |
|
|
1225
|
+
echo "::error::AWS_DEPLOY_ROLE_ARN secret is empty …"
|
|
1226
|
+
exit 1
|
|
1227
|
+
|
|
971
1228
|
- name: Configure AWS credentials
|
|
972
1229
|
uses: aws-actions/configure-aws-credentials@v4
|
|
973
1230
|
with:
|
|
974
1231
|
aws-region: ${{ secrets.AWS_REGION }}
|
|
975
1232
|
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
|
|
976
|
-
|
|
977
|
-
|
|
1233
|
+
|
|
1234
|
+
- name: Assert assumed role matches AWS_DEPLOY_ROLE_ARN
|
|
1235
|
+
# aws sts get-caller-identity + ARN-shape check; aborts before cdk deploy.
|
|
1236
|
+
run: …
|
|
978
1237
|
```
|
|
979
1238
|
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
workflow
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
1239
|
+
The `id-token: write` permission lets Actions mint the OIDC JWT that
|
|
1240
|
+
`sts:AssumeRoleWithWebIdentity` consumes. After the assume-role step
|
|
1241
|
+
the workflow calls `aws sts get-caller-identity` and asserts the
|
|
1242
|
+
returned ARN's role-name + account-id match `AWS_DEPLOY_ROLE_ARN`, so
|
|
1243
|
+
a silent fallback to ambient static credentials cannot ship a deploy.
|
|
1244
|
+
See §17 for the matching IAM trust policy and verification.
|
|
986
1245
|
|
|
987
|
-
### 12.4
|
|
1246
|
+
### 12.4 Manual-only deploy trigger
|
|
988
1247
|
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1248
|
+
The workflow's `on:` block is `workflow_dispatch`-only — there is no
|
|
1249
|
+
automatic push-to-main trigger, and no separate staging/prod target.
|
|
1250
|
+
A maintainer runs the deploy by hand against whichever AWS account
|
|
1251
|
+
and region the secrets point at; staging vs production isolation is
|
|
1252
|
+
driven by account/region, not by a workflow-per-env split.
|
|
993
1253
|
|
|
994
1254
|
---
|
|
995
1255
|
|
|
@@ -1005,7 +1265,7 @@ The CDK bootstrap stack hasn't been created in this account/region.
|
|
|
1005
1265
|
```bash
|
|
1006
1266
|
cd apps/aws-stack
|
|
1007
1267
|
pnpm cdk bootstrap
|
|
1008
|
-
pnpm deploy:aws
|
|
1268
|
+
pnpm deploy:aws
|
|
1009
1269
|
```
|
|
1010
1270
|
|
|
1011
1271
|
Bootstrap is idempotent and per-region — re-run it if you change
|
|
@@ -1024,7 +1284,7 @@ injects these — if one is missing in the console, somebody hand-edited
|
|
|
1024
1284
|
the Lambda config. Fix by re-deploying:
|
|
1025
1285
|
|
|
1026
1286
|
```bash
|
|
1027
|
-
pnpm deploy:aws
|
|
1287
|
+
pnpm deploy:aws
|
|
1028
1288
|
```
|
|
1029
1289
|
|
|
1030
1290
|
If the error mentions `MANAGEMENT_URL` being absent: that env var is
|
|
@@ -1073,7 +1333,7 @@ to resolve the smoke URL. If this returns nothing, either:
|
|
|
1073
1333
|
name, the workflow's `--stack-name IrcAwsStack` won't find it.
|
|
1074
1334
|
Update the workflow or rename back.
|
|
1075
1335
|
- **The deploy failed silently.** Check the prior `pnpm
|
|
1076
|
-
deploy:aws
|
|
1336
|
+
deploy:aws` step's exit code. `cdk deploy` with
|
|
1077
1337
|
`--require-approval never` can still fail on IAM or
|
|
1078
1338
|
CloudFormation errors.
|
|
1079
1339
|
- **Wrong region.** The `describe-stacks` call uses `AWS_REGION` from
|
|
@@ -1234,14 +1494,18 @@ npm install -g aws-cdk-local
|
|
|
1234
1494
|
export AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test AWS_DEFAULT_REGION=us-east-1
|
|
1235
1495
|
LOCALSTACK=1 pnpm --filter @serverless-ircd/aws-stack test
|
|
1236
1496
|
|
|
1237
|
-
#
|
|
1238
|
-
pnpm deploy:aws
|
|
1497
|
+
# Deploy (single target — staging vs prod is which account/region you point at)
|
|
1498
|
+
pnpm deploy:aws
|
|
1239
1499
|
node apps/aws-stack/scripts/smoke.mjs \
|
|
1240
1500
|
--url wss://<id>.execute-api.<region>.amazonaws.com/prod
|
|
1241
1501
|
aws logs tail /aws/lambda/IrcHandler --follow # live logs
|
|
1242
1502
|
|
|
1243
|
-
#
|
|
1244
|
-
pnpm deploy:aws
|
|
1503
|
+
# Web client (opt-in; provision via webSite/webSiteCustomDomain context)
|
|
1504
|
+
pnpm deploy:aws -- -c webSiteCustomDomain=app.example.com \
|
|
1505
|
+
-c webSiteCertificateArn=arn:aws:acm:us-east-1:... \
|
|
1506
|
+
-c webSiteHostedZoneName=example.com. -c webSiteHostedZoneId=... \
|
|
1507
|
+
-c webOrigins=https://app.example.com # CSWSH (§8.2, §16.4)
|
|
1508
|
+
node scripts/deploy-web-aws.mjs # bake + s3 sync + invalidate (§16.3)
|
|
1245
1509
|
|
|
1246
1510
|
# Tests / lint
|
|
1247
1511
|
pnpm test # full workspace
|
|
@@ -1263,9 +1527,420 @@ Key files:
|
|
|
1263
1527
|
| `packages/aws-adapter/src/tables.ts` | Runtime table-name + key-column constants. |
|
|
1264
1528
|
| `packages/aws-adapter/src/config-loader.ts` | Lambda env → `ServerConfig` schema mapping. |
|
|
1265
1529
|
| `packages/aws-adapter/src/handlers/index.ts` | Lambda entry points (`handler`, `sweeperHandler`, `pingCheckerHandler`). |
|
|
1530
|
+
| `apps/aws-stack/src/static-site.ts` | CDK construct for the optional web client (S3 + CloudFront + OAC). See §16. |
|
|
1531
|
+
| `scripts/deploy-web-aws.mjs` | Two-phase web client deploy helper. See §16. |
|
|
1266
1532
|
| `.github/workflows/deploy-aws.yml` | Staging deploy + smoke e2e CI. |
|
|
1267
1533
|
|
|
1268
|
-
|
|
1534
|
+
---
|
|
1535
|
+
|
|
1536
|
+
## 16. Web client (S3 + CloudFront + OAC)
|
|
1537
|
+
|
|
1538
|
+
The CF Worker serves the SPA from the same origin as the WebSocket
|
|
1539
|
+
(one Worker, one domain). AWS **cannot** do this — API Gateway
|
|
1540
|
+
WebSocket and a static SPA host are different AWS services on
|
|
1541
|
+
different endpoints — so the AWS web client splits in two: the wss
|
|
1542
|
+
IRC endpoint stays on API Gateway, and the SPA is served from an S3
|
|
1543
|
+
bucket fronted by CloudFront. This § documents the architecture, the
|
|
1544
|
+
build, the deploy pipeline, the CSWSH defence (mandatory once the
|
|
1545
|
+
SPA ships), and the optional custom-domain setup.
|
|
1546
|
+
|
|
1547
|
+
> **Cross-reference:** `docs/WebClientGuide.md` covers the
|
|
1548
|
+
> Cloudflare path (one origin for HTTP + WebSocket). The CF-vs-AWS
|
|
1549
|
+
> architectural difference is called out in §16.5 below.
|
|
1550
|
+
|
|
1551
|
+
### 16.1 What gets provisioned
|
|
1552
|
+
|
|
1553
|
+
The `StaticSite` construct (`apps/aws-stack/src/static-site.ts`,
|
|
1554
|
+
provisioned only when `IrcStackProps.webSite` is supplied) creates:
|
|
1555
|
+
|
|
1556
|
+
- a **private S3 bucket** holding the `apps/web/dist` build artifact
|
|
1557
|
+
(`index.html` for the landing page, `webclient/index.html` for the
|
|
1558
|
+
Kiwi SPA, `webclient/static/*` for hashed JS/CSS) — the same layout
|
|
1559
|
+
the CF Worker's `[assets]` binding serves, so one artifact ships on
|
|
1560
|
+
both platforms unchanged;
|
|
1561
|
+
- a **CloudFront distribution** with an S3 origin pointing at the
|
|
1562
|
+
bucket via **Origin Access Control (OAC)** — the modern replacement
|
|
1563
|
+
for the legacy OAI. Direct S3 access (`https://<bucket>.s3.…/…`)
|
|
1564
|
+
is denied by the bucket policy; only CloudFront URLs resolve;
|
|
1565
|
+
- `defaultRootObject = index.html` so requests to `/` serve the
|
|
1566
|
+
project landing page (the static HTML front door with links to the
|
|
1567
|
+
SPA, the rendered docs, and the source repo);
|
|
1568
|
+
- a **custom error response** mapping S3's `403`/`404` → `200` with
|
|
1569
|
+
response page `/webclient/index.html`, so client-side routes under
|
|
1570
|
+
`/webclient/*` resolve to the SPA shell (Kiwi is hash/history-routed);
|
|
1571
|
+
- three CFN outputs at the parent-stack level (stable logical IDs):
|
|
1572
|
+
`WebsiteURL` (the `*.cloudfront.net` default OR the custom domain),
|
|
1573
|
+
`WebsiteBucketName`, `WebsiteDistributionId`. The deploy pipeline
|
|
1574
|
+
(§16.3) consumes the latter two.
|
|
1575
|
+
|
|
1576
|
+
**Why not Amplify / why not S3-only:** Amplify Hosting adds a managed
|
|
1577
|
+
build step the project doesn't need (the SPA build is already
|
|
1578
|
+
`pnpm --filter web build:prod-aws`); S3-only (no CloudFront) forces
|
|
1579
|
+
either a public bucket (anti-pattern) or a signed-URL-per-asset
|
|
1580
|
+
scheme (heavy). CloudFront + OAC is the AWS-recommended static-site
|
|
1581
|
+
shape and gives HTTPS on `*.cloudfront.net` for free.
|
|
1582
|
+
|
|
1583
|
+
### 16.2 Opt-in: provision the web client
|
|
1584
|
+
|
|
1585
|
+
The web client is **opt-in**. To provision it, pass `webSite` props
|
|
1586
|
+
on the stack. From CDK context (the easiest path), set
|
|
1587
|
+
`webSiteCustomDomain`:
|
|
1588
|
+
|
|
1589
|
+
```bash
|
|
1590
|
+
pnpm deploy:aws -- \
|
|
1591
|
+
-c webSiteCustomDomain=app.example.com \
|
|
1592
|
+
-c webSiteCertificateArn=arn:aws:acm:us-east-1:111122223333:certificate/abc-def-... \
|
|
1593
|
+
-c webSiteHostedZoneName=example.com. \
|
|
1594
|
+
-c webSiteHostedZoneId=Z2KVMGOMGMOU2
|
|
1595
|
+
```
|
|
1596
|
+
|
|
1597
|
+
The ACM certificate **must** be in `us-east-1` (CloudFront requirement).
|
|
1598
|
+
Hosted-zone name + ID are both required because CDK's
|
|
1599
|
+
`fromHostedZoneAttributes` needs both; retrieve them once via
|
|
1600
|
+
`aws route53 list-hosted-zones-by-name --dns-name <zone>`.
|
|
1601
|
+
|
|
1602
|
+
> **Default-`*.cloudfront.net` only (no custom domain) gap:**
|
|
1603
|
+
> `bin/aws.ts` currently keys the `webSite` prop's existence on
|
|
1604
|
+
> `webSiteCustomDomain` being set, so provisioning the construct
|
|
1605
|
+
> without a custom domain requires editing `bin/aws.ts` to pass
|
|
1606
|
+
> `webSite: {}` directly. Tracked as a follow-up. For v1 deploys,
|
|
1607
|
+
> supply a custom domain (prod-style) or patch `bin/aws.ts`.
|
|
1608
|
+
|
|
1609
|
+
### 16.3 Two-phase deploy (stack → bake → s3 sync → invalidate)
|
|
1610
|
+
|
|
1611
|
+
Unlike the CF Worker (where `wrangler deploy` ships the SPA in the
|
|
1612
|
+
same command as the runtime), the AWS SPA deploy is **two-phase** and
|
|
1613
|
+
**stack-output-driven**: the CloudFormation stack must exist before
|
|
1614
|
+
the SPA can be baked, because the baked `config.json` carries the
|
|
1615
|
+
API Gateway WebSocket URL as a literal. The deploy pipeline
|
|
1616
|
+
(`scripts/deploy-web-aws.mjs`) runs:
|
|
1617
|
+
|
|
1618
|
+
1. `aws cloudformation describe-stacks --stack-name IrcAwsStack`
|
|
1619
|
+
reads the `ConnectUrl`, `WebsiteBucketName`, and
|
|
1620
|
+
`WebsiteDistributionId` outputs. If any are missing the script
|
|
1621
|
+
exits non-zero ("stack not deployed" or "web client construct not
|
|
1622
|
+
provisioned").
|
|
1623
|
+
2. `pnpm --filter @serverless-ircd/web run build:prod-aws -- --api-url <ConnectUrl>`
|
|
1624
|
+
bakes the SPA with the real API Gateway URL. The `--api-url` flag is
|
|
1625
|
+
overridden **in memory** after Zod parse — the source
|
|
1626
|
+
`apps/web/static/config.prod-aws.json` is never mutated, so the
|
|
1627
|
+
templated `{{API_ID}}.execute-api.{{REGION}}.amazonaws.com` host and
|
|
1628
|
+
`/{{STAGE}}` path placeholders stay in the repo and no concrete URL
|
|
1629
|
+
is committed. The build rejects any leftover `{{...}}` placeholders.
|
|
1630
|
+
|
|
1631
|
+
The flag value (`wss://<id>.execute-api.<region>.amazonaws.com/<stage>`)
|
|
1632
|
+
is **split** by `parseApiUrlForKiwi` into Kiwi's two URL fields:
|
|
1633
|
+
`server` ← `<id>.execute-api.<region>.amazonaws.com` (hostname only,
|
|
1634
|
+
no scheme) and `direct_path` ← `/<stage>`. irc-framework builds the
|
|
1635
|
+
WS URL itself as `wss://<server>:<port><direct_path>` — it prepends
|
|
1636
|
+
`wss://` whenever `tls: true`, so passing the full URL through to
|
|
1637
|
+
`server` would double the scheme (`wss://wss//<host>...`) and Firefox
|
|
1638
|
+
would reject the connection. The split keeps `server` host-only and
|
|
1639
|
+
routes the API Gateway stage name through `direct_path`.
|
|
1640
|
+
3. `aws s3 sync apps/web/dist s3://<WebsiteBucketName> --delete`
|
|
1641
|
+
uploads the build. The CLI skips unchanged objects via etag
|
|
1642
|
+
comparison, so a re-deploy with no SPA changes uploads nothing.
|
|
1643
|
+
(This is why the deploy uses the AWS CLI rather than the SDK —
|
|
1644
|
+
`s3 sync`'s etag-based unchanged-skip is non-trivial to reproduce
|
|
1645
|
+
on `@aws-sdk/client-s3`.)
|
|
1646
|
+
4. `aws cloudfront create-invalidation --distribution-id <WebsiteDistributionId> --paths /*`
|
|
1647
|
+
busts the edge cache so the next visitor gets the new build.
|
|
1648
|
+
|
|
1649
|
+
**CI wiring:** the `deploy-web` job in `.github/workflows/deploy-aws.yml`
|
|
1650
|
+
runs `node scripts/deploy-web-aws.mjs` with `needs: deploy` (after the
|
|
1651
|
+
CDK stack applies). Both jobs are `workflow_dispatch`-only — a
|
|
1652
|
+
maintainer runs the workflow by hand (T170 collapsed the staging/prod
|
|
1653
|
+
split; there is no push trigger and no `staging` branch).
|
|
1654
|
+
|
|
1655
|
+
**Why a script instead of CDK `BucketDeployment`:** `BucketDeployment`
|
|
1656
|
+
re-synths on every deploy, is slower, and — critically — cannot bake
|
|
1657
|
+
a post-deploy stack output (the API Gateway URL) into the SPA config,
|
|
1658
|
+
because at synth time the URL does not exist yet. The two-phase
|
|
1659
|
+
script reads the output, then bakes.
|
|
1660
|
+
|
|
1661
|
+
### 16.4 CSWSH defence (`WEB_ORIGINS`) — mandatory once the SPA ships
|
|
1662
|
+
|
|
1663
|
+
**Once you deploy the web client, set `WEB_ORIGINS`.** WebSocket
|
|
1664
|
+
upgrades do not follow the same-origin policy, so a malicious web
|
|
1665
|
+
page can otherwise open a wss to the IRC endpoint from a victim's
|
|
1666
|
+
browser and drive the session with the victim's credentials
|
|
1667
|
+
(Cross-Site WebSocket Hijacking, CSWSH). API Gateway WebSocket has
|
|
1668
|
+
no built-in Origin validation — the `$connect` Lambda enforces it.
|
|
1669
|
+
|
|
1670
|
+
Pass the SPA's origin alongside the `webSite` props:
|
|
1671
|
+
|
|
1672
|
+
```bash
|
|
1673
|
+
pnpm deploy:aws -- \
|
|
1674
|
+
-c webSiteCustomDomain=app.example.com \
|
|
1675
|
+
-c webSiteCertificateArn=... \
|
|
1676
|
+
-c webSiteHostedZoneName=example.com. \
|
|
1677
|
+
-c webSiteHostedZoneId=... \
|
|
1678
|
+
-c webOrigins=https://app.example.com
|
|
1679
|
+
```
|
|
1680
|
+
|
|
1681
|
+
The full behaviour matrix, matching semantics, and the deliberate
|
|
1682
|
+
divergence from the CF Worker policy (no same-origin auto-derive on
|
|
1683
|
+
AWS — the SPA and the wss endpoint are on different origins) are in
|
|
1684
|
+
**§8.2**. Unset `WEB_ORIGINS` disables the check; this is why the
|
|
1685
|
+
defence is documented as "mandatory-once-the-SPA-ships" rather than
|
|
1686
|
+
"mandatory always" — bare-IRC deployments without a web frontend
|
|
1687
|
+
(`curl`, WeeChat, `tcp-ws-forwarder`) never send `Origin` and are
|
|
1688
|
+
correctly served without the knob.
|
|
1689
|
+
|
|
1690
|
+
### 16.5 CF-vs-AWS: one origin vs two origins
|
|
1691
|
+
|
|
1692
|
+
The architectural difference is the reason this § exists:
|
|
1693
|
+
|
|
1694
|
+
| Platform | HTTP (SPA) origin | WebSocket origin | Same-origin? |
|
|
1695
|
+
|----------|---------------------------------|-----------------------------------|--------------|
|
|
1696
|
+
| CF | the Worker (`*.workers.dev` / custom domain) | the same Worker | **Yes** — one domain serves both |
|
|
1697
|
+
| AWS | CloudFront (`*.cloudfront.net` / custom domain) | API Gateway (`*.execute-api.*.amazonaws.com`) | **No** — two different endpoints |
|
|
1698
|
+
|
|
1699
|
+
Two practical consequences:
|
|
1700
|
+
|
|
1701
|
+
1. **The baked SPA config (`config.prod-aws.json`) carries a templated
|
|
1702
|
+
host + stage path** because the browser cannot infer either from
|
|
1703
|
+
`window.location`. The CF config (`config.prod.json`) uses Kiwi's
|
|
1704
|
+
`{{hostname}}` template resolved at load time and no `direct_path`
|
|
1705
|
+
(the Worker's WS upgrade is at root). The AWS config templates
|
|
1706
|
+
`server` = `{{API_ID}}.execute-api.{{REGION}}.amazonaws.com` (host
|
|
1707
|
+
only — irc-framework prepends `wss://` from `tls: true`) and
|
|
1708
|
+
`direct_path` = `/{{STAGE}}` (the API Gateway stage segment). The
|
|
1709
|
+
AWS deploy pipeline splits the stack-output URL into these two
|
|
1710
|
+
fields at bake time (§16.3 step 2).
|
|
1711
|
+
2. **The CF Worker's CSWSH defence has a same-origin auto-derive
|
|
1712
|
+
mode** (when `WEB_ORIGINS` is unset, compare `Origin` against the
|
|
1713
|
+
request's own `Host`). AWS cannot use auto-derive — the request's
|
|
1714
|
+
own host is the API Gateway domain, never the SPA's — so the AWS
|
|
1715
|
+
defence is explicit-allowlist only (§8.2).
|
|
1716
|
+
|
|
1717
|
+
### 16.6 Custom domain (ACM + CloudFront alias + Route53)
|
|
1718
|
+
|
|
1719
|
+
`StaticSite` accepts four optional props for the custom domain (see
|
|
1720
|
+
`apps/aws-stack/src/static-site.ts`):
|
|
1721
|
+
|
|
1722
|
+
| Prop | Purpose |
|
|
1723
|
+
|-------------------|----------------------------------------------------------------------|
|
|
1724
|
+
| `customDomain` | The hostname to serve the SPA on (e.g. `app.example.com`). |
|
|
1725
|
+
| `certificateArn` | ARN of an ACM certificate covering `customDomain`. Must be in `us-east-1`. |
|
|
1726
|
+
| `hostedZoneName` | Public Route53 hosted zone name (e.g. `example.com.`). |
|
|
1727
|
+
| `hostedZoneId` | Public Route53 hosted zone ID for the above. |
|
|
1728
|
+
|
|
1729
|
+
When all four are supplied (the CDK context wiring in `bin/aws.ts`
|
|
1730
|
+
handles the optional spread), the construct adds the alias to the
|
|
1731
|
+
CloudFront distribution and creates a Route53 `ARecord` aliasing the
|
|
1732
|
+
distribution. When `customDomain` is omitted, the distribution serves
|
|
1733
|
+
on its default `*.cloudfront.net` URL — but see the gap note in
|
|
1734
|
+
§16.2 (the current `bin/aws.ts` wiring only provisions the construct
|
|
1735
|
+
at all when `webSiteCustomDomain` is set).
|
|
1736
|
+
|
|
1737
|
+
ACM certificate validation: the certificate must be DNS-validated
|
|
1738
|
+
and in `ISSUED` status before `cdk deploy` runs — CloudFront will
|
|
1739
|
+
not deploy an alias to a pending certificate. Create it ahead of time
|
|
1740
|
+
in `us-east-1` via the console or:
|
|
1741
|
+
|
|
1742
|
+
```bash
|
|
1743
|
+
aws acm request-certificate \
|
|
1744
|
+
--domain-name app.example.com \
|
|
1745
|
+
--validation-method DNS \
|
|
1746
|
+
--region us-east-1
|
|
1747
|
+
# add the returned CNAME to Route53, wait for ISSUED
|
|
1748
|
+
```
|
|
1749
|
+
|
|
1750
|
+
### 16.7 Verification (manual smoke check)
|
|
1751
|
+
|
|
1752
|
+
After a `deploy-web` job completes:
|
|
1753
|
+
|
|
1754
|
+
```bash
|
|
1755
|
+
# Stack outputs
|
|
1756
|
+
aws cloudformation describe-stacks --stack-name IrcAwsStack \
|
|
1757
|
+
--query 'Stacks[0].Outputs[?OutputKey==`WebsiteURL`].OutputValue' --output text
|
|
1758
|
+
|
|
1759
|
+
# Landing page
|
|
1760
|
+
curl -sI https://<distribution-or-custom-domain>/ | head -1
|
|
1761
|
+
# Landing page (the project front door — links to SPA + docs + source repo)
|
|
1762
|
+
curl -sI https://<distribution-or-custom-domain>/ | head -1
|
|
1763
|
+
# SPA shell (200, served via the 404→/webclient/index.html error mapping for /webclient/* routes)
|
|
1764
|
+
curl -sI https://<distribution-or-custom-domain>/webclient/ | head -1
|
|
1765
|
+
# Baked config — server MUST be the real wss URL, not a {{...}} template
|
|
1766
|
+
curl -s https://<distribution-or-custom-domain>/webclient/static/config.json | jq .startupOptions.server
|
|
1767
|
+
```
|
|
1768
|
+
|
|
1769
|
+
Open `https://<distribution-or-custom-domain>/webclient/` in a browser,
|
|
1770
|
+
register, join `#welcome`, send a `PRIVMSG`, see the echo. (Visiting the
|
|
1771
|
+
root `https://<distribution-or-custom-domain>/` serves the landing page,
|
|
1772
|
+
which links to the SPA at `/webclient/`.) If the SPA loads but never
|
|
1773
|
+
connects, check (1) the baked `config.json` `server` matches the stack's
|
|
1774
|
+
`ConnectUrl` output, (2) `WEB_ORIGINS` (§16.4) includes the CloudFront
|
|
1775
|
+
origin you're loading the SPA from, and (3) the `$connect` route is
|
|
1776
|
+
reachable.
|
|
1777
|
+
|
|
1778
|
+
---
|
|
1779
|
+
|
|
1780
|
+
## 17. OIDC deploy role (CI)
|
|
1781
|
+
|
|
1782
|
+
The GitHub Actions deploy workflow (§12) accepts **only** GitHub OIDC
|
|
1783
|
+
web-identity credentials. Long-lived access keys
|
|
1784
|
+
(`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`) are NOT supported — a
|
|
1785
|
+
single exfiltration vector against them (rogue PR `printenv`,
|
|
1786
|
+
compromised third-party Action, poisoned runner) yields full AWS
|
|
1787
|
+
account takeover because the deploy role can manage CloudFormation,
|
|
1788
|
+
IAM, Lambda, and DynamoDB. This section documents the operator-side
|
|
1789
|
+
setup that the `AWS_DEPLOY_ROLE_ARN` repo secret points at.
|
|
1790
|
+
|
|
1791
|
+
### 17.1 One-time AWS account setup
|
|
1792
|
+
|
|
1793
|
+
1. **Create the GitHub OIDC identity provider** (one per AWS account,
|
|
1794
|
+
idempotent):
|
|
1795
|
+
|
|
1796
|
+
```bash
|
|
1797
|
+
aws iam create-open-id-connect-provider \
|
|
1798
|
+
--url https://token.actions.githubusercontent.com \
|
|
1799
|
+
--client-id-list sts.amazonaws.com \
|
|
1800
|
+
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1 \
|
|
1801
|
+
--tags Key=managed-by,Value=github-actions
|
|
1802
|
+
```
|
|
1803
|
+
|
|
1804
|
+
> The `--thumbprint-list` above is the well-known root CA
|
|
1805
|
+
> thumbprint GitHub's OIDC tokens chain to. AWS now also accepts
|
|
1806
|
+
> the trust-root automatically, but specifying it keeps the
|
|
1807
|
+
> provider deterministic across accounts.
|
|
1808
|
+
|
|
1809
|
+
2. **Create the deploy role** with the trust policy below (save as
|
|
1810
|
+
`trust-policy.json` then `create-role`). The single condition key
|
|
1811
|
+
binds the role to this repository's `main` branch — the only ref
|
|
1812
|
+
the `workflow_dispatch` deploy workflow can run from:
|
|
1813
|
+
|
|
1814
|
+
```json
|
|
1815
|
+
{
|
|
1816
|
+
"Version": "2012-10-17",
|
|
1817
|
+
"Statement": [
|
|
1818
|
+
{
|
|
1819
|
+
"Effect": "Allow",
|
|
1820
|
+
"Principal": {
|
|
1821
|
+
"Federated": "arn:aws:iam::<ACCOUNT_ID>:oidc-provider/token.actions.githubusercontent.com"
|
|
1822
|
+
},
|
|
1823
|
+
"Action": "sts:AssumeRoleWithWebIdentity",
|
|
1824
|
+
"Condition": {
|
|
1825
|
+
"StringEquals": {
|
|
1826
|
+
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
|
|
1827
|
+
"token.actions.githubusercontent.com:sub": "repo:<owner>/<repo>:ref:refs/heads/main"
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
]
|
|
1832
|
+
}
|
|
1833
|
+
```
|
|
1834
|
+
|
|
1835
|
+
Replace `<ACCOUNT_ID>` with the AWS account id and `<owner>/<repo>`
|
|
1836
|
+
with the GitHub owner/repo (e.g. `ServerlessIRCd/ServerlessIRCd`).
|
|
1837
|
+
The `sub` condition is the security boundary: only workflows
|
|
1838
|
+
running on a ref matching `refs/heads/main` in this exact repo can
|
|
1839
|
+
assume the role. Do NOT widen it to `StringLike` with a wildcard —
|
|
1840
|
+
that lets forked PRs mint credentials.
|
|
1841
|
+
|
|
1842
|
+
```bash
|
|
1843
|
+
aws iam create-role \
|
|
1844
|
+
--role-name IrcDeployRole \
|
|
1845
|
+
--assume-role-policy-document file://trust-policy.json \
|
|
1846
|
+
--description "GitHub Actions OIDC role for IrcAwsStack deploys"
|
|
1847
|
+
|
|
1848
|
+
# Note the returned ARN — this becomes AWS_DEPLOY_ROLE_ARN.
|
|
1849
|
+
aws iam get-role --role-name IrcDeployRole --query 'Role.Arn' --output text
|
|
1850
|
+
```
|
|
1851
|
+
|
|
1852
|
+
3. **Attach a permission policy.** The role needs the same surface
|
|
1853
|
+
`cdk deploy` uses: IAM (pass-role + role creation), API Gateway v2,
|
|
1854
|
+
Lambda, DynamoDB, EventBridge, CloudFormation, S3 (the CDK
|
|
1855
|
+
bootstrap bucket), and logs. The CDK bootstrap already creates a
|
|
1856
|
+
`cdk-hnb659fds-deploy-role-<account>-<region>` role with the right
|
|
1857
|
+
shape — for a v1 deployment, the simplest pattern is to let the
|
|
1858
|
+
GitHub-OIDC role `sts:AssumeRole` the CDK deploy role:
|
|
1859
|
+
|
|
1860
|
+
```json
|
|
1861
|
+
{
|
|
1862
|
+
"Version": "2012-10-17",
|
|
1863
|
+
"Statement": [
|
|
1864
|
+
{
|
|
1865
|
+
"Effect": "Allow",
|
|
1866
|
+
"Action": "sts:AssumeRole",
|
|
1867
|
+
"Resource": "arn:aws:iam::<ACCOUNT_ID>:role/cdk-hnb659fds-deploy-role-<ACCOUNT_ID>-<REGION>"
|
|
1868
|
+
}
|
|
1869
|
+
]
|
|
1870
|
+
}
|
|
1871
|
+
```
|
|
1872
|
+
|
|
1873
|
+
Attach this as an inline policy on `IrcDeployRole`. (If you would
|
|
1874
|
+
rather grant the deploy permissions directly on `IrcDeployRole`,
|
|
1875
|
+
mirror the statement list from the CDK deploy role's
|
|
1876
|
+
`DefaultDeploymentRole`.)
|
|
1877
|
+
|
|
1878
|
+
4. **Store the role ARN as the `AWS_DEPLOY_ROLE_ARN` repo secret**
|
|
1879
|
+
(Settings → Secrets and variables → Actions → New repository
|
|
1880
|
+
secret). This is the **only** AWS-shaped secret the workflow reads.
|
|
1881
|
+
|
|
1882
|
+
### 17.2 Condition key reference
|
|
1883
|
+
|
|
1884
|
+
| JWT claim (`token.actions.githubusercontent.com:<key>`) | Value to bind | Meaning |
|
|
1885
|
+
|---------------------------------------------------------|------------------------------------------------------------|----------------------------------------------------------------------|
|
|
1886
|
+
| `aud` | `sts.amazonaws.com` | Required. AWS is the intended audience of the OIDC token. |
|
|
1887
|
+
| `sub` | `repo:<owner>/<repo>:ref:refs/heads/main` | Required. Pinning to `main` matches the `workflow_dispatch` trigger. |
|
|
1888
|
+
|
|
1889
|
+
The workflow's `on:` block is `workflow_dispatch` only, and a
|
|
1890
|
+
maintainer runs it from the `main` branch in the GitHub UI, so the
|
|
1891
|
+
`sub` claim always carries `:ref:refs/heads/main`. If you later add a
|
|
1892
|
+
push trigger on another branch, add a second condition entry rather
|
|
1893
|
+
than loosening this one.
|
|
1894
|
+
|
|
1895
|
+
### 17.3 CI verification (operator-run, out-of-band)
|
|
1896
|
+
|
|
1897
|
+
Once the role and secret are configured, verify end-to-end by running
|
|
1898
|
+
the workflow from the Actions UI against a sandbox account. The
|
|
1899
|
+
workflow's `Assert assumed role matches AWS_DEPLOY_ROLE_ARN` step
|
|
1900
|
+
prints the assumed role-name + account on success:
|
|
1901
|
+
|
|
1902
|
+
```
|
|
1903
|
+
OIDC role assertion passed: IrcDeployRole in account 111122223333
|
|
1904
|
+
```
|
|
1905
|
+
|
|
1906
|
+
If that step fails, the trust policy condition keys do not match the
|
|
1907
|
+
workflow's `sub` claim — re-check `repo:<owner>/<repo>` and the branch
|
|
1908
|
+
ref. The workflow aborts BEFORE `cdk deploy`, so a misconfigured trust
|
|
1909
|
+
policy cannot mutate the stack.
|
|
1910
|
+
|
|
1911
|
+
Authoritative reference: GitHub's "Configuring OpenID Connect in
|
|
1912
|
+
Amazon Web Services" guide
|
|
1913
|
+
(<https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services>)
|
|
1914
|
+
and the AWS "Creating OpenID Connect (OIDC) identity providers" guide
|
|
1915
|
+
walk through the IAM-side setup in more detail.
|
|
1916
|
+
|
|
1917
|
+
### 17.4 What the workflow enforces in-repo
|
|
1918
|
+
|
|
1919
|
+
The CI-hardening test suite (`tools/ci-hardening/tests/deploy-aws-oidc.test.ts`)
|
|
1920
|
+
asserts the workflow shape so the security property cannot regress
|
|
1921
|
+
silently:
|
|
1922
|
+
|
|
1923
|
+
- No `aws-access-key-id` / `aws-secret-access-key` inputs to
|
|
1924
|
+
`configure-aws-credentials@v4` anywhere in the file.
|
|
1925
|
+
- Top-level `permissions:` block grants exactly
|
|
1926
|
+
`{ id-token: write, contents: read }` (least-privilege).
|
|
1927
|
+
- Every `configure-aws-credentials` step has a `role-to-assume` input.
|
|
1928
|
+
- The `deploy` and `deploy-web` jobs both have an
|
|
1929
|
+
`if: env.AWS_DEPLOY_ROLE_ARN == ''` guard that runs `exit 1` before
|
|
1930
|
+
any AWS call.
|
|
1931
|
+
- The `deploy` job runs `aws sts get-caller-identity` and references
|
|
1932
|
+
`AWS_DEPLOY_ROLE_ARN` before any `cdk deploy` (defence-in-depth
|
|
1933
|
+
against silent OIDC fallback).
|
|
1934
|
+
|
|
1935
|
+
The `deploy-web` job (TICKET-167, §16) inherits the same OIDC model:
|
|
1936
|
+
it has its own empty-ARN guard before `configure-aws-credentials` and
|
|
1937
|
+
assumes the same role via web identity — no static keys, no ambient
|
|
1938
|
+
credential inheritance from the parent `deploy` job. The two jobs are
|
|
1939
|
+
independent `workflow_dispatch` runs against the same role ARN.
|
|
1940
|
+
|
|
1941
|
+
---
|
|
1942
|
+
|
|
1943
|
+
## 18. Open questions / future work
|
|
1269
1944
|
|
|
1270
1945
|
These are facts the **code does not yet have an answer for**; the
|
|
1271
1946
|
recommendations above are deployer guidance, not built-in knobs:
|