serverless-ircd 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/.github/workflows/deploy-aws.yml +156 -32
  2. package/.github/workflows/deploy-cf-tcp.yml +11 -9
  3. package/.github/workflows/deploy-cf.yml +14 -14
  4. package/CHANGELOG.md +305 -0
  5. package/README.md +134 -41
  6. package/apps/aws-stack/README.md +3 -5
  7. package/apps/aws-stack/bin/aws.ts +82 -9
  8. package/apps/aws-stack/cdk.json +0 -3
  9. package/apps/aws-stack/package.json +3 -4
  10. package/apps/aws-stack/src/aws-stack.ts +177 -52
  11. package/apps/aws-stack/src/static-site.ts +323 -0
  12. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  13. package/apps/aws-stack/tests/stack.test.ts +267 -92
  14. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  15. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  16. package/apps/cf-tcp-container/package.json +2 -3
  17. package/apps/cf-tcp-container/src/container-server.ts +12 -9
  18. package/apps/cf-tcp-container/wrangler.toml +1 -10
  19. package/apps/cf-worker/package.json +3 -4
  20. package/apps/cf-worker/wrangler.toml +12 -71
  21. package/apps/local-cli/package.json +1 -1
  22. package/apps/local-cli/src/server.ts +21 -17
  23. package/apps/web/landing/favicon.ico +0 -0
  24. package/apps/web/landing/index.html +1 -0
  25. package/apps/web/package.json +2 -2
  26. package/apps/web/scripts/build.mjs +66 -4
  27. package/apps/web/src/build-env.ts +125 -4
  28. package/apps/web/src/config-schema.ts +20 -6
  29. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  30. package/apps/web/tests/build-env.test.ts +210 -9
  31. package/apps/web/tests/build-smoke.test.ts +2 -2
  32. package/apps/web/tests/config-schema.test.ts +149 -25
  33. package/docs/AWS-Deployment.md +670 -96
  34. package/docs/AWS-TCP-Deployment.md +20 -45
  35. package/docs/Cloudflare-Deployment-Guide.md +87 -113
  36. package/docs/Cloudflare-TCP-Deployment.md +25 -49
  37. package/docs/Release-Process.md +27 -23
  38. package/docs/Services.md +69 -22
  39. package/docs/WebClientGuide.md +35 -26
  40. package/package.json +7 -10
  41. package/packages/aws-adapter/package.json +1 -1
  42. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
  43. package/packages/aws-adapter/src/config-loader.ts +19 -2
  44. package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
  45. package/packages/aws-adapter/src/handlers/connect.ts +26 -0
  46. package/packages/aws-adapter/src/handlers/default.ts +190 -123
  47. package/packages/aws-adapter/src/handlers/index.ts +67 -23
  48. package/packages/aws-adapter/src/handlers/nlb-stream.ts +3 -6
  49. package/packages/aws-adapter/src/index.ts +5 -7
  50. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  51. package/packages/aws-adapter/src/serialize.ts +15 -0
  52. package/packages/aws-adapter/src/tables.ts +2 -12
  53. package/packages/aws-adapter/tests/aws-harness.ts +0 -1
  54. package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
  55. package/packages/aws-adapter/tests/connect.test.ts +124 -1
  56. package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
  57. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
  58. package/packages/aws-adapter/tests/handlers.test.ts +117 -11
  59. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  60. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  61. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  62. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  63. package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
  64. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  65. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  66. package/packages/cf-adapter/package.json +1 -5
  67. package/packages/cf-adapter/src/cf-runtime.ts +59 -8
  68. package/packages/cf-adapter/src/channel-do.ts +13 -3
  69. package/packages/cf-adapter/src/connection-do.ts +266 -109
  70. package/packages/cf-adapter/src/d1-services-store.ts +63 -26
  71. package/packages/cf-adapter/src/env.ts +11 -10
  72. package/packages/cf-adapter/src/index.ts +0 -6
  73. package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
  74. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  75. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  76. package/packages/cf-adapter/tests/connection-do-pure.test.ts +95 -54
  77. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  78. package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
  79. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  80. package/packages/in-memory-runtime/package.json +1 -1
  81. package/packages/irc-core/package.json +1 -1
  82. package/packages/irc-core/src/account-migration.ts +140 -0
  83. package/packages/irc-core/src/commands/account-auth.ts +16 -19
  84. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  85. package/packages/irc-core/src/commands/nickserv.ts +122 -11
  86. package/packages/irc-core/src/commands/registration.ts +2 -2
  87. package/packages/irc-core/src/commands/sasl.ts +22 -31
  88. package/packages/irc-core/src/config.ts +35 -9
  89. package/packages/irc-core/src/credential-hashing.ts +11 -54
  90. package/packages/irc-core/src/index.ts +1 -0
  91. package/packages/irc-core/src/ports.ts +159 -179
  92. package/packages/irc-core/src/types.ts +38 -10
  93. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  94. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  95. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  96. package/packages/irc-core/tests/commands/nickserv.test.ts +237 -1
  97. package/packages/irc-core/tests/commands/oper.test.ts +15 -0
  98. package/packages/irc-core/tests/commands/registration.test.ts +140 -133
  99. package/packages/irc-core/tests/commands/sasl.test.ts +159 -178
  100. package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
  101. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  102. package/packages/irc-core/tests/message-store.test.ts +5 -0
  103. package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
  104. package/packages/irc-core/tests/ports.test.ts +71 -0
  105. package/packages/irc-core/tests/services-store.test.ts +204 -0
  106. package/packages/irc-core/vitest.config.ts +6 -1
  107. package/packages/irc-server/package.json +1 -1
  108. package/packages/irc-server/src/actor.ts +0 -14
  109. package/packages/irc-server/tests/actor.test.ts +19 -47
  110. package/packages/irc-test-support/package.json +1 -1
  111. package/pnpm-workspace.yaml +1 -0
  112. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  113. package/scripts/deploy-web-aws.mjs +290 -0
  114. package/scripts/package.json +23 -0
  115. package/scripts/tsconfig.test.json +12 -0
  116. package/scripts/vitest.config.ts +19 -0
  117. package/tools/ci-hardening/package.json +1 -1
  118. package/tools/ci-hardening/src/index.ts +2 -0
  119. package/tools/ci-hardening/src/validate.ts +57 -0
  120. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  121. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  122. package/tools/load-test/package.json +1 -1
  123. package/tools/migrate-accounts-to-services.ts +270 -0
  124. package/tools/package.json +2 -1
  125. package/tools/seed-aws-accounts.ts +35 -10
  126. package/tools/seed-cf-accounts.ts +42 -9
  127. package/tools/tcp-ws-forwarder/package.json +1 -1
  128. package/packages/aws-adapter/src/account-store.ts +0 -121
  129. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  130. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  131. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  132. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  133. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  134. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -0,0 +1,290 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `scripts/deploy-web-aws.mjs` — the AWS web-client deploy pipeline helper.
4
+ *
5
+ * Two-phase deploy that runs AFTER `cdk deploy` applies the `IrcAwsStack`
6
+ * CloudFormation stack:
7
+ *
8
+ * 1. `aws cloudformation describe-stacks --stack-name IrcAwsStack` reads
9
+ * the stack's outputs (`ConnectUrl`, `WebsiteBucketName`,
10
+ * `WebsiteDistributionId`).
11
+ * 2. `pnpm --filter @serverless-ircd/web run build:prod-aws -- --api-url <url>`
12
+ * bakes the SPA with the real API Gateway WebSocket URL (read from
13
+ * `ConnectUrl` in step 1). The build script overrides the templated
14
+ * `{{API_ID}}`/`{{REGION}}`/`{{STAGE}}` `server` value in
15
+ * `apps/web/static/config.prod-aws.json` and rejects any leftover
16
+ * `{{...}}` placeholders, so no hardcoded URLs ship.
17
+ * 3. `aws s3 sync apps/web/dist s3://<bucket> --delete` uploads the
18
+ * build. The CLI's `sync` compares etags and SKIPS unchanged objects,
19
+ * so a re-deploy with no SPA changes uploads nothing (the
20
+ * invalidation in step 4 still fires — CloudFront has no
21
+ * unchanged-skip equivalent).
22
+ * 4. `aws cloudfront create-invalidation --distribution-id <id> --paths /*`
23
+ * busts the edge cache so the new build is served immediately.
24
+ *
25
+ * Why a script instead of CDK `BucketDeployment`: `BucketDeployment`
26
+ * re-synths on every deploy (slower), cannot bake the post-deploy API URL
27
+ * into the baked config (it runs before the stack outputs exist), and
28
+ * pulls in a custom-resource Lambda for a one-shot copy. A stand-alone
29
+ * script reads the deployed outputs directly.
30
+ *
31
+ * Why the AWS CLI instead of `@aws-sdk/client-*`: `aws s3 sync`'s etag
32
+ * comparison and unchanged-skip are non-trivial to reimplement on top of
33
+ * the SDK; the CLI is already a deploy-time dependency (the workflow uses
34
+ * it for `describe-stacks`). One-off SDK calls would multiply surface
35
+ * area without buying anything.
36
+ *
37
+ * @see docs/AWS-Deployment.md for the full deploy runbook.
38
+ */
39
+
40
+ import { spawnSync } from 'node:child_process';
41
+ import path from 'node:path';
42
+
43
+ /**
44
+ * Fixed CloudFormation stack name (post-staging/prod-collapse: a single
45
+ * target per platform, no env suffix). Mirrors the value the deploy
46
+ * workflow's `describe-stacks` query already uses.
47
+ */
48
+ export const STACK_NAME = 'IrcAwsStack';
49
+
50
+ /**
51
+ * Outputs the script consumes. Each MUST be present on the deployed
52
+ * stack — a missing output aborts the deploy with a clear message so
53
+ * the operator knows to `cdk deploy` (or check the `webSite` prop) before
54
+ * re-running.
55
+ */
56
+ export const REQUIRED_OUTPUTS = ['ConnectUrl', 'WebsiteBucketName', 'WebsiteDistributionId'];
57
+
58
+ /**
59
+ * Pattern matching the `{{...}}` placeholders baked into
60
+ * `apps/web/static/config.prod-aws.json`. The stack output's `ConnectUrl`
61
+ * should be a fully-resolved wss URL; if it isn't, the build script would
62
+ * bake a broken SPA. This guard fails the deploy at the script layer
63
+ * (defence in depth) instead of relying on `build.mjs` alone.
64
+ */
65
+ const PLACEHOLDER_PATTERN = /\{\{[^}]+\}\}/u;
66
+
67
+ /**
68
+ * Builds the default runner that shells out to the `aws` CLI and `pnpm`
69
+ * via `spawnSync`. IO is captured (pipe) on the AWS path so the script can
70
+ * surface stderr in its own error messages; stdio for the `pnpm build`
71
+ * call is inherited so the (verbose) Kiwi build log streams live to the
72
+ * operator.
73
+ *
74
+ * @param {{ cwd?: string }} [opts]
75
+ * @returns {{ aws: (args: string[]) => CliResult; pnpm: (args: string[]) => CliResult }}
76
+ */
77
+ export function makeDefaultRunner({ cwd = process.cwd() } = {}) {
78
+ return {
79
+ /* v8 ignore next -- 1-line wrapper over the tested runCli(); the literal binary name is the only thing that differs and it cannot be exercised without `aws` on the test PATH. */
80
+ aws(args) {
81
+ return runCli('aws', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
82
+ },
83
+ /* v8 ignore next -- 1-line wrapper over the tested runCli(); same reason as aws() above. */
84
+ pnpm(args) {
85
+ // Inherit stdio for pnpm so the build log streams live.
86
+ return runCli('pnpm', args, { cwd, stdio: 'inherit' });
87
+ },
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Spawns a child process and returns the captured IO + exit status. Pure
93
+ * plumbing — no throwing on non-zero exits (the caller decides whether a
94
+ * non-zero status is an error). Exported so the test-suite can exercise
95
+ * the spawnSync shape with `node` as the binary instead of `aws`/`pnpm`
96
+ * (neither of which is guaranteed to be on PATH in the test environment).
97
+ *
98
+ * @param {string} command
99
+ * @param {string[]} args
100
+ * @param {{ cwd: string; stdio: 'inherit' | Array<'ignore' | 'pipe' | 'inherit'> }} opts
101
+ * @returns {CliResult}
102
+ */
103
+ export function runCli(command, args, opts) {
104
+ const result = spawnSync(command, args, {
105
+ cwd: opts.cwd,
106
+ stdio: opts.stdio,
107
+ encoding: 'utf8',
108
+ });
109
+ const status = result.status ?? -1;
110
+ const stdout = typeof result.stdout === 'string' ? result.stdout : '';
111
+ const stderr = typeof result.stderr === 'string' ? result.stderr : '';
112
+ return { status, stdout, stderr };
113
+ }
114
+
115
+ /**
116
+ * Parses the JSON body returned by
117
+ * `aws cloudformation describe-stacks --query 'Stacks[0].Outputs' --output json`
118
+ * into a plain `OutputKey → OutputValue` lookup. Empty / malformed bodies
119
+ * surface as thrown Errors so the caller (the deploy function) can fail
120
+ * loudly with context.
121
+ *
122
+ * @param {string} jsonBody
123
+ * @returns {Record<string, string>}
124
+ */
125
+ export function parseStackOutputs(jsonBody) {
126
+ const parsed = JSON.parse(jsonBody);
127
+ if (!Array.isArray(parsed)) {
128
+ return {};
129
+ }
130
+ const map = {};
131
+ for (const entry of parsed) {
132
+ if (
133
+ typeof entry === 'object' &&
134
+ entry !== null &&
135
+ typeof entry.OutputKey === 'string' &&
136
+ typeof entry.OutputValue === 'string'
137
+ ) {
138
+ map[entry.OutputKey] = entry.OutputValue;
139
+ }
140
+ }
141
+ return map;
142
+ }
143
+
144
+ /**
145
+ * Runs the two-phase AWS web deploy. Throws on any failure (missing
146
+ * output, CLI non-zero exit, leftover placeholder) — the CLI entrypoint
147
+ * catches and exits non-zero.
148
+ *
149
+ * Implemented as an `async` function so the entrypoint and tests can
150
+ * consume it uniformly via `.catch()` / `rejects.toThrowError`; the
151
+ * underlying `spawnSync` calls are themselves synchronous, but wrapping
152
+ * the orchestration in a Promise keeps the contract stable if a future
153
+ * revision swaps in the AWS SDK or a streaming upload.
154
+ *
155
+ * @param {Object} [opts]
156
+ * @param {{ aws: (args: string[]) => CliResult; pnpm: (args: string[]) => CliResult }} [opts.runner]
157
+ * @param {string} [opts.stackName]
158
+ * @param {string} [opts.distDir]
159
+ * @param {(message: string) => void} [opts.log]
160
+ * @returns {Promise<{ connectUrl: string; bucket: string; distributionId: string }>}
161
+ */
162
+ export async function deployWebAws({
163
+ runner = makeDefaultRunner(),
164
+ stackName = STACK_NAME,
165
+ distDir = path.join('apps', 'web', 'dist'),
166
+ log = (m) => console.log(m),
167
+ } = {}) {
168
+ // 1. Read the deployed stack's outputs.
169
+ const describeResult = runner.aws([
170
+ 'cloudformation',
171
+ 'describe-stacks',
172
+ '--stack-name',
173
+ stackName,
174
+ '--query',
175
+ 'Stacks[0].Outputs',
176
+ '--output',
177
+ 'json',
178
+ ]);
179
+ if (describeResult.status !== 0) {
180
+ throw new Error(
181
+ `aws cloudformation describe-stacks --stack-name ${stackName} exited with ${describeResult.status}: ${describeResult.stderr}`,
182
+ );
183
+ }
184
+ const outputs = parseStackOutputs(describeResult.stdout);
185
+
186
+ // 2. Guard: every required output MUST be present. A missing output means
187
+ // the stack hasn't been deployed (or the `webSite` prop was omitted on
188
+ // the last synth), and proceeding would either bake a broken config
189
+ // or sync to the wrong bucket.
190
+ const connectUrl = outputs.ConnectUrl;
191
+ const bucket = outputs.WebsiteBucketName;
192
+ const distributionId = outputs.WebsiteDistributionId;
193
+ for (const key of REQUIRED_OUTPUTS) {
194
+ const value = outputs[key];
195
+ if (value === undefined || value.length === 0) {
196
+ throw new Error(
197
+ `Missing required CloudFormation output '${key}' on stack '${stackName}'. ` +
198
+ `Has the stack been deployed (cdk deploy --all)? The web deploy pipeline requires: ${REQUIRED_OUTPUTS.join(', ')}.`,
199
+ );
200
+ }
201
+ }
202
+
203
+ // Defence in depth: the stack output itself should never carry an
204
+ // unsubstituted placeholder, but if it did the build would bake a
205
+ // broken SPA. Fail loudly here so the operator sees the script's
206
+ // diagnostic before the build's.
207
+ if (PLACEHOLDER_PATTERN.test(connectUrl)) {
208
+ throw new Error(
209
+ `Stack output ConnectUrl carries an unsubstituted placeholder: '${connectUrl}'. The deployed stage URL must be a fully-resolved wss:// URL, not a template.`,
210
+ );
211
+ }
212
+
213
+ // 3. Bake the SPA with the real API URL. The build script overrides the
214
+ // templated `server` field in config.prod-aws.json with this value
215
+ // and rejects any remaining `{{...}}` placeholders.
216
+ log(`Baking SPA with API URL ${connectUrl}`);
217
+ const buildResult = runner.pnpm([
218
+ '--filter',
219
+ '@serverless-ircd/web',
220
+ 'run',
221
+ 'build:prod-aws',
222
+ '--',
223
+ '--env',
224
+ 'prod-aws',
225
+ '--api-url',
226
+ connectUrl,
227
+ ]);
228
+ if (buildResult.status !== 0) {
229
+ throw new Error(
230
+ `pnpm build:prod-aws exited with ${buildResult.status}. The web SPA build failed; see the build log above.`,
231
+ );
232
+ }
233
+
234
+ // 4. s3 sync. The CLI's `sync` skips unchanged objects via etag
235
+ // comparison, so a re-deploy with no SPA changes uploads nothing.
236
+ // `--delete` removes objects that no longer exist in the dist tree
237
+ // (e.g. hashed assets from the previous build) so the bucket does
238
+ // not accumulate stale entries.
239
+ log(`Syncing ${distDir} → s3://${bucket}`);
240
+ const syncResult = runner.aws(['s3', 'sync', distDir, `s3://${bucket}`, '--delete']);
241
+ if (syncResult.status !== 0) {
242
+ throw new Error(`aws s3 sync exited with ${syncResult.status}: ${syncResult.stderr}`);
243
+ }
244
+
245
+ // 5. CloudFront invalidation. Always fires — CloudFront has no
246
+ // unchanged-skip equivalent, so even a no-op sync requires this to
247
+ // guarantee the edge serves the latest origin state.
248
+ log(`Invalidating CloudFront distribution ${distributionId}`);
249
+ const invalidationResult = runner.aws([
250
+ 'cloudfront',
251
+ 'create-invalidation',
252
+ '--distribution-id',
253
+ distributionId,
254
+ '--paths',
255
+ '/*',
256
+ ]);
257
+ if (invalidationResult.status !== 0) {
258
+ throw new Error(
259
+ `aws cloudfront create-invalidation exited with ${invalidationResult.status}: ${invalidationResult.stderr}`,
260
+ );
261
+ }
262
+
263
+ log(`Deployed web client → s3://${bucket} (CloudFront ${distributionId}, invalidated /*)`);
264
+ return { connectUrl, bucket, distributionId };
265
+ }
266
+
267
+ // --- CLI entrypoint --------------------------------------------------------
268
+ //
269
+ // Excluded from coverage: the entrypoint is exercised by the deploy
270
+ // pipeline / a human operator, not by the unit tests (which call
271
+ // `deployWebAws` directly with an injected runner). The branch under test
272
+ // is the `if (invokedDirectly)` body, which only fires when this file is
273
+ // the process entry point — exactly the case vitest's import does NOT
274
+ // produce. v8's ignore markers keep the coverage threshold meaningful
275
+ // for the testable surface.
276
+
277
+ /* v8 ignore start */
278
+ const invokedDirectly =
279
+ process.argv[1] !== undefined &&
280
+ path.resolve(process.argv[1]) ===
281
+ path.resolve(new URL('./deploy-web-aws.mjs', import.meta.url).pathname);
282
+
283
+ if (invokedDirectly) {
284
+ deployWebAws().catch((err) => {
285
+ const message = err instanceof Error ? err.message : String(err);
286
+ console.error(`deploy-web-aws: ${message}`);
287
+ process.exit(1);
288
+ });
289
+ }
290
+ /* v8 ignore stop */
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@serverless-ircd/deploy-scripts",
3
+ "version": "0.10.0",
4
+ "private": true,
5
+ "description": "Repo-level deploy helpers (AWS web client s3 sync + CloudFront invalidation pipeline).",
6
+ "license": "BSD-3-Clause",
7
+ "type": "module",
8
+ "scripts": {
9
+ "deploy:web-aws": "node ./deploy-web-aws.mjs",
10
+ "typecheck": "tsc -p tsconfig.test.json --noEmit",
11
+ "test": "vitest run",
12
+ "test:watch": "vitest",
13
+ "coverage": "vitest run --coverage",
14
+ "clean": "rimraf coverage .turbo"
15
+ },
16
+ "devDependencies": {
17
+ "@types/node": "^26.1.1",
18
+ "@vitest/coverage-v8": "^4.1.0",
19
+ "rimraf": "^6.0.0",
20
+ "typescript": "^5.6.0",
21
+ "vitest": "^4.1.0"
22
+ }
23
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "composite": false,
6
+ "types": ["node"],
7
+ "allowJs": true,
8
+ "checkJs": false
9
+ },
10
+ "include": ["**/*.ts", "**/*.mjs", "**/*.d.mts"],
11
+ "exclude": ["node_modules", "coverage"]
12
+ }
@@ -0,0 +1,19 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'node',
6
+ include: ['__tests__/**/*.test.ts'],
7
+ coverage: {
8
+ provider: 'v8',
9
+ include: ['deploy-web-aws.mjs'],
10
+ reporter: ['text', 'html', 'json-summary'],
11
+ thresholds: {
12
+ lines: 90,
13
+ functions: 90,
14
+ branches: 90,
15
+ statements: 90,
16
+ },
17
+ },
18
+ },
19
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/ci-hardening",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "private": true,
5
5
  "description": "CI hardening helpers: coverage-gate config validator + mutation-testing driver for irc-core",
6
6
  "license": "BSD-3-Clause",
@@ -1,6 +1,8 @@
1
1
  export {
2
2
  MUTATION_SCORE_THRESHOLD,
3
+ findAwsCredentialSteps,
3
4
  readCoverageThresholds,
4
5
  readStrykerConfig,
5
6
  readWorkflowJob,
7
+ readWorkflowPermissions,
6
8
  } from './validate.js';
@@ -37,9 +37,66 @@ interface WorkflowJob {
37
37
  }
38
38
 
39
39
  interface ParsedWorkflow {
40
+ permissions?: Record<string, string> | string;
40
41
  jobs?: Record<string, WorkflowJob>;
41
42
  }
42
43
 
44
+ /**
45
+ * Parse a GitHub Actions YAML file and return the top-level
46
+ * `permissions:` block as a `Record<string, string>` (e.g.
47
+ * `{ 'id-token': 'write', contents: 'read' }`).
48
+ *
49
+ * Throws if the workflow has no top-level `permissions:` block.
50
+ * The literal `'read-all'` / `'write-all'` shapes are returned
51
+ * verbatim as a single key so callers can flag them.
52
+ *
53
+ * @param yamlText raw YAML source of a workflow file.
54
+ */
55
+ export function readWorkflowPermissions(yamlText: string): Record<string, string> {
56
+ const parsed = load(yamlText) as ParsedWorkflow;
57
+ if (parsed.permissions === undefined || parsed.permissions === null) {
58
+ throw new Error('Workflow has no top-level permissions: block');
59
+ }
60
+ if (typeof parsed.permissions === 'string') {
61
+ return { [parsed.permissions]: parsed.permissions };
62
+ }
63
+ return parsed.permissions;
64
+ }
65
+
66
+ /**
67
+ * Return every step in the workflow that uses `configure-aws-credentials`,
68
+ * keyed by `"<job>.<index>"`. Each value is the step's `with:` mapping
69
+ * (an empty object if the step has no `with:`).
70
+ *
71
+ * Used by the OIDC-only lint to assert no static-key inputs survive.
72
+ *
73
+ * @param yamlText raw YAML source of a workflow file.
74
+ */
75
+ export function findAwsCredentialSteps(
76
+ yamlText: string,
77
+ ): Array<{ job: string; index: number; with: Record<string, unknown> }> {
78
+ const parsed = load(yamlText) as ParsedWorkflow;
79
+ const out: Array<{ job: string; index: number; with: Record<string, unknown> }> = [];
80
+ for (const [jobName, job] of Object.entries(parsed.jobs ?? {})) {
81
+ const steps = job?.steps ?? [];
82
+ steps.forEach((step, index) => {
83
+ const uses = step.uses;
84
+ if (typeof uses === 'string' && uses.includes('configure-aws-credentials')) {
85
+ const withBlock = step.with;
86
+ out.push({
87
+ job: jobName,
88
+ index,
89
+ with:
90
+ withBlock && typeof withBlock === 'object'
91
+ ? (withBlock as Record<string, unknown>)
92
+ : {},
93
+ });
94
+ }
95
+ });
96
+ }
97
+ return out;
98
+ }
99
+
43
100
  /**
44
101
  * Extract the `coverage.thresholds` block from a vitest config file.
45
102
  *
@@ -0,0 +1,96 @@
1
+ /**
2
+ * OIDC-only AWS deploy enforcement.
3
+ *
4
+ * The deploy workflow (`.github/workflows/deploy-aws.yml`) must NOT
5
+ * accept long-lived access keys. It must require an OIDC web-identity
6
+ * role (`AWS_DEPLOY_ROLE_ARN`), grant `id-token: write`, fail fast when
7
+ * the role ARN is missing, and assert that `aws sts get-caller-identity`
8
+ * returns the configured role before any `cdk deploy` / `cdk synth` —
9
+ * so a silent fallback to ambient static credentials cannot ship a
10
+ * deploy.
11
+ *
12
+ * These assertions run as part of the CI-hardening suite on every PR,
13
+ * the same way the coverage-gate and mutation-testing contracts do.
14
+ */
15
+
16
+ import { readFileSync } from 'node:fs';
17
+ import { resolve } from 'node:path';
18
+ import { describe, expect, it } from 'vitest';
19
+ import {
20
+ findAwsCredentialSteps,
21
+ readWorkflowJob,
22
+ readWorkflowPermissions,
23
+ } from '../src/validate.js';
24
+
25
+ const REPO_ROOT = resolve(import.meta.dirname, '..', '..', '..');
26
+ const WORKFLOW_PATH = resolve(REPO_ROOT, '.github/workflows/deploy-aws.yml');
27
+ const WORKFLOW = readFileSync(WORKFLOW_PATH, 'utf8');
28
+
29
+ describe('deploy-aws.yml: OIDC is the only accepted AWS credential source', () => {
30
+ it('does NOT pass aws-access-key-id / aws-secret-access-key to configure-aws-credentials', () => {
31
+ const steps = findAwsCredentialSteps(WORKFLOW);
32
+ expect(steps.length).toBeGreaterThan(0);
33
+ for (const step of steps) {
34
+ expect(step.with).not.toHaveProperty('aws-access-key-id');
35
+ expect(step.with).not.toHaveProperty('aws-secret-access-key');
36
+ }
37
+ });
38
+
39
+ it('configures a role-to-assume on every configure-aws-credentials step', () => {
40
+ const steps = findAwsCredentialSteps(WORKFLOW);
41
+ expect(steps.length).toBeGreaterThan(0);
42
+ for (const step of steps) {
43
+ expect(step.with['role-to-assume']).toBeDefined();
44
+ }
45
+ });
46
+
47
+ it('grants id-token: write at the top level (OIDC minting requires it)', () => {
48
+ const permissions = readWorkflowPermissions(WORKFLOW);
49
+ expect(permissions['id-token']).toBe('write');
50
+ });
51
+
52
+ it('keeps the permissions block least-privilege (only id-token + contents)', () => {
53
+ const permissions = readWorkflowPermissions(WORKFLOW);
54
+ expect(permissions.contents).toBe('read');
55
+ const keys = Object.keys(permissions).sort();
56
+ expect(keys).toEqual(['contents', 'id-token']);
57
+ });
58
+
59
+ it('fails fast when AWS_DEPLOY_ROLE_ARN is empty (guard step exists in every AWS-touching job)', () => {
60
+ // The "deploy" job runs `pnpm deploy:aws` (cdk deploy) and the
61
+ // "deploy-web" job runs `node scripts/deploy-web-aws.mjs`. Both
62
+ // must abort before any AWS call if the role ARN secret is unset.
63
+ for (const jobName of ['deploy', 'deploy-web']) {
64
+ const job = readWorkflowJob(WORKFLOW, jobName);
65
+ const hasGuard = job.steps.some((step) => {
66
+ const ifExpr = typeof step.if === 'string' ? step.if : '';
67
+ const env = step.env as Record<string, string> | undefined;
68
+ return (
69
+ ifExpr.includes('AWS_DEPLOY_ROLE_ARN') &&
70
+ ifExpr.includes("== ''") &&
71
+ env !== undefined &&
72
+ typeof env.AWS_DEPLOY_ROLE_ARN === 'string' &&
73
+ typeof step.run === 'string' &&
74
+ step.run.includes('exit 1')
75
+ );
76
+ });
77
+ expect(hasGuard).toBe(true);
78
+ }
79
+ });
80
+
81
+ it('asserts the sts get-caller-identity ARN matches the configured role before any CDK call', () => {
82
+ // The "deploy" job gates `cdk deploy`. It must, after
83
+ // configure-aws-credentials and before `pnpm deploy:aws`, run
84
+ // `aws sts get-caller-identity` and reject anything that is not
85
+ // the configured role ARN. This catches silent fallbacks to
86
+ // ambient static credentials.
87
+ const job = readWorkflowJob(WORKFLOW, 'deploy');
88
+ const runSteps = job.steps
89
+ .filter((s) => typeof s.run === 'string')
90
+ .map((s) => (s.run as string).trim());
91
+ const hasStsGuard = runSteps.some(
92
+ (r) => r.includes('aws sts get-caller-identity') && r.includes('AWS_DEPLOY_ROLE_ARN'),
93
+ );
94
+ expect(hasStsGuard).toBe(true);
95
+ });
96
+ });
@@ -3,9 +3,11 @@ import { resolve } from 'node:path';
3
3
  import { describe, expect, it } from 'vitest';
4
4
  import {
5
5
  MUTATION_SCORE_THRESHOLD,
6
+ findAwsCredentialSteps,
6
7
  readCoverageThresholds,
7
8
  readStrykerConfig,
8
9
  readWorkflowJob,
10
+ readWorkflowPermissions,
9
11
  } from '../src/validate.js';
10
12
 
11
13
  const REPO_ROOT = resolve(import.meta.dirname, '..', '..', '..');
@@ -174,4 +176,44 @@ describe('validate.ts error paths', () => {
174
176
  /no job named "verify"/,
175
177
  );
176
178
  });
179
+
180
+ it('readWorkflowPermissions throws when no top-level permissions block exists', () => {
181
+ expect(() => readWorkflowPermissions('on: push\njobs:\n build: {}\n')).toThrow(
182
+ /no top-level permissions/,
183
+ );
184
+ });
185
+
186
+ it('readWorkflowPermissions returns the literal string form as a single key', () => {
187
+ // `permissions: read-all` is a valid GitHub Actions shorthand.
188
+ const result = readWorkflowPermissions('on: push\npermissions: read-all\njobs:\n b: {}\n');
189
+ expect(result).toEqual({ 'read-all': 'read-all' });
190
+ });
191
+
192
+ it('findAwsCredentialSteps returns [] when the workflow has no jobs', () => {
193
+ expect(findAwsCredentialSteps('on: push\n')).toEqual([]);
194
+ });
195
+
196
+ it('findAwsCredentialSteps returns an empty with-block when the step has no with:', () => {
197
+ // Job has a `configure-aws-credentials` step with no `with:` and a
198
+ // sibling `run:` step (which has a non-string `uses` and must be
199
+ // skipped silently).
200
+ const yaml = [
201
+ 'on: push',
202
+ 'jobs:',
203
+ ' build:',
204
+ ' steps:',
205
+ ' - run: echo hi',
206
+ ' - uses: aws-actions/configure-aws-credentials@v4',
207
+ '',
208
+ ].join('\n');
209
+ const result = findAwsCredentialSteps(yaml);
210
+ expect(result).toEqual([{ job: 'build', index: 1, with: {} }]);
211
+ });
212
+
213
+ it('findAwsCredentialSteps tolerates a job with no steps key', () => {
214
+ // A job literal `{}` (no `steps:`) must not throw — it just yields
215
+ // no credential steps.
216
+ const yaml = 'on: push\njobs:\n empty: {}\n';
217
+ expect(findAwsCredentialSteps(yaml)).toEqual([]);
218
+ });
177
219
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/load-test",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "private": true,
5
5
  "description": "Synthetic WebSocket IRC client farm for load testing ServerlessIRCd (concurrency, latency p50/p95/p99, drop rate)",
6
6
  "license": "BSD-3-Clause",