create-agent-rig 0.4.0 → 0.5.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/CHANGELOG.md +207 -8
- package/README.md +50 -17
- package/package.json +2 -1
- package/packages/cli/dist/commands/create.js +8 -3
- package/packages/cli/dist/commands/init.js +82 -34
- package/packages/cli/dist/commands/upgrade.js +112 -30
- package/packages/cli/dist/index.js +38 -14
- package/packages/cli/dist/lib/copy-tree.js +35 -6
- package/packages/cli/dist/lib/init-settings.js +12 -0
- package/packages/cli/dist/lib/install-set.js +6 -8
- package/packages/cli/dist/lib/manifest.js +21 -9
- package/packages/cli/dist/lib/safe-path.js +30 -0
- package/templates/agent-os/init/AGENTS.md +191 -0
- package/templates/agent-os/init/CLAUDE.md +61 -9
- package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +98 -0
- package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +34 -3
- package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
- package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +34 -0
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
- package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +233 -0
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +676 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +338 -0
- package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +33 -1
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +64 -1
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +33 -0
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +274 -24
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
- package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
- package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
- package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
- package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +480 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +61 -1
- package/templates/agent-os/universal/.claude/rules/invariants.md +71 -16
- package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1242 -0
- package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
- package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
- package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
- package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +433 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +6 -16
- package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +89 -0
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +334 -23
- package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +7 -2
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +413 -8
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +75 -13
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +222 -21
- package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
- package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
- package/templates/agent-os/universal/.claude/scripts/run-state.mjs +441 -0
- package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -0
- package/templates/agent-os/universal/.claude/settings.json +7 -2
- package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +115 -7
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +378 -31
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +300 -25
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
- package/templates/agent-os/universal/.codex/hooks.json +65 -0
- package/templates/agent-os/universal/AGENTS.md +164 -0
- package/templates/agent-os/universal/CLAUDE.md +47 -14
- package/templates/agent-os/universal/PLAN.md +7 -40
- package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
- package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
- package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
- package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
- package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
- package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
- package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
- package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +62 -0
- package/templates/agent-os/universal/journal/README.md +101 -0
- package/templates/agent-os/universal/layers.json +36 -2
- package/templates/hash-history.json +2 -1
- package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
- package/templates/skeleton/aws-serverless/README.md +91 -9
- package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
- package/templates/skeleton/aws-serverless/gitignore +37 -0
- package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
- package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
- package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
- package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
- package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
- package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
- package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
- package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
- package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
- package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
- package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
- package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
- package/templates/skeleton/node-service/README.md +11 -1
- package/templates/skeleton/node-service/gitignore +34 -0
- package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
- package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
- package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
- package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
- package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
- package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
- package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +28 -0
|
@@ -2,16 +2,53 @@ node_modules/
|
|
|
2
2
|
dist/
|
|
3
3
|
coverage/
|
|
4
4
|
cdk.out/
|
|
5
|
+
# what `cdk deploy --outputs-file` writes: bucket names, distribution ids,
|
|
6
|
+
# endpoints. Harmless today, and the file that accumulates the ones that are not.
|
|
7
|
+
cdk-outputs.json
|
|
5
8
|
*.tsbuildinfo
|
|
6
9
|
.DS_Store
|
|
7
10
|
.next/
|
|
8
11
|
out/
|
|
9
12
|
next-env.d.ts
|
|
10
13
|
.claude/worktrees/
|
|
14
|
+
# per-checkout queue state: the tier the last close recorded. The queue CONFIG
|
|
15
|
+
# is composed and tracked; this is runtime state and must never be committed.
|
|
16
|
+
.claude/queue.state.json
|
|
17
|
+
# gate rounds, one count per branch
|
|
18
|
+
.claude/gate-rounds.json
|
|
19
|
+
# per-run machine trace (.claude/scripts/run-journal.mjs): forensics that churn
|
|
20
|
+
# on every gate and collide across concurrent sessions. Unlike a committed
|
|
21
|
+
# evidence directory, this one is per-run and stays out of the repository.
|
|
22
|
+
.claude/runs/
|
|
11
23
|
|
|
12
24
|
# secrets — never commit these
|
|
25
|
+
*.env
|
|
13
26
|
.env
|
|
14
27
|
.env.*
|
|
15
28
|
!.env.example
|
|
29
|
+
.envrc
|
|
30
|
+
.npmrc
|
|
31
|
+
.netrc
|
|
32
|
+
.pgpass
|
|
33
|
+
id_rsa
|
|
34
|
+
id_ed25519
|
|
16
35
|
*.pem
|
|
17
36
|
*.key
|
|
37
|
+
*.p12
|
|
38
|
+
*.pfx
|
|
39
|
+
*.keystore
|
|
40
|
+
*.jks
|
|
41
|
+
*.secret
|
|
42
|
+
*.secrets
|
|
43
|
+
*.token
|
|
44
|
+
*.tokens
|
|
45
|
+
*.password
|
|
46
|
+
*.passwords
|
|
47
|
+
*.credential
|
|
48
|
+
*.credentials
|
|
49
|
+
*.apikey
|
|
50
|
+
*.apikeys
|
|
51
|
+
*.passwd
|
|
52
|
+
*.creds
|
|
53
|
+
*.jwt
|
|
54
|
+
*.bearer
|
|
@@ -1,19 +1,79 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
1
3
|
import { App } from 'aws-cdk-lib';
|
|
2
|
-
import { AppStack } from '../lib/app-stack.js';
|
|
4
|
+
import { AppStack, nothingGiven } from '../lib/app-stack.js';
|
|
3
5
|
import { WebStack } from '../lib/web-stack.js';
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
/**
|
|
8
|
+
* The composition, as a function so a test can synthesise the same wiring the
|
|
9
|
+
* CLI gets. A test that re-wires the stacks itself would pass while this file
|
|
10
|
+
* stayed broken, which is the one failure worth catching here.
|
|
11
|
+
*/
|
|
12
|
+
export function createApp(): App {
|
|
13
|
+
const app = new App();
|
|
6
14
|
|
|
7
|
-
// Region-agnostic by default; `cdk deploy` uses your CLI profile. Pin it here
|
|
8
|
-
// when the project settles (the generator's default region is __REGION__).
|
|
9
|
-
const env = { region: process.env.CDK_DEFAULT_REGION };
|
|
15
|
+
// Region-agnostic by default; `cdk deploy` uses your CLI profile. Pin it here
|
|
16
|
+
// when the project settles (the generator's default region is __REGION__).
|
|
17
|
+
const env = { region: process.env.CDK_DEFAULT_REGION };
|
|
10
18
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
19
|
+
// The web stack first: the API has to allow the origin the browser loads the
|
|
20
|
+
// app from, and that origin is a CloudFront domain this stack owns. Passing
|
|
21
|
+
// it makes CORS correct on the first deploy instead of after someone
|
|
22
|
+
// discovers the app is blocked and reaches for `*`.
|
|
23
|
+
//
|
|
24
|
+
// The price, so it is not discovered later: this is a cross-stack export, so
|
|
25
|
+
// WebStack is deploy-ordered before AppStack and cannot be torn down while
|
|
26
|
+
// AppStack still imports it — CDK warns about the reference strength on
|
|
27
|
+
// synth. Pass `allowedOrigins` explicitly (a custom domain, several origins)
|
|
28
|
+
// and the export disappears with it.
|
|
29
|
+
const web = new WebStack(app, 'WebStack', {
|
|
30
|
+
description: 'Static web serving for __PROJECT_NAME__ (generated by create-agent-rig)',
|
|
31
|
+
env,
|
|
32
|
+
});
|
|
15
33
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
34
|
+
// `-c allowedOrigins=…` wins over the wired default — a custom domain is a
|
|
35
|
+
// deliberate act, and props short-circuit the context inside AppStack, so
|
|
36
|
+
// passing them unconditionally would accept the flag and ignore it. Omitting
|
|
37
|
+
// the prop hands the flag to AppStack's own validation instead of dropping
|
|
38
|
+
// it: an unusable value is refused at synth rather than deployed.
|
|
39
|
+
// `nothingGiven` is imported rather than restated: this file and the stack
|
|
40
|
+
// both decide whether the operator supplied an origin, and two spellings of
|
|
41
|
+
// that question is how `"allowedOrigins": null` in cdk.json comes to mean
|
|
42
|
+
// "they chose one" here and "use the default" there — a green synth whose
|
|
43
|
+
// deployed API allows only localhost.
|
|
44
|
+
const override = app.node.tryGetContext('allowedOrigins');
|
|
45
|
+
|
|
46
|
+
new AppStack(app, 'AppStack', {
|
|
47
|
+
description: 'API + worker for __PROJECT_NAME__ (generated by create-agent-rig)',
|
|
48
|
+
env,
|
|
49
|
+
...(nothingGiven(override) ? { allowedOrigins: [web.origin] } : {}),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return app;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Only when the CDK CLI runs this file. A test importing `createApp` would
|
|
57
|
+
* otherwise build a second composition — and re-bundle three Lambdas — for an
|
|
58
|
+
* App nobody looks at.
|
|
59
|
+
*
|
|
60
|
+
* Both sides go through realpath: ESM resolves `import.meta.url` through
|
|
61
|
+
* symlinks while `process.argv[1]` keeps the path as typed, so a checkout
|
|
62
|
+
* behind a link (a macOS temp dir, a symlinked home) would fail a plain
|
|
63
|
+
* comparison and synthesise an empty app.
|
|
64
|
+
*/
|
|
65
|
+
function invokedDirectly(): boolean {
|
|
66
|
+
if (!process.argv[1]) return false;
|
|
67
|
+
const real = (p: string) => {
|
|
68
|
+
try {
|
|
69
|
+
return realpathSync(p);
|
|
70
|
+
} catch {
|
|
71
|
+
return p;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (invokedDirectly()) {
|
|
78
|
+
createApp();
|
|
79
|
+
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
Annotations,
|
|
5
|
+
CfnOutput,
|
|
6
|
+
Duration,
|
|
7
|
+
RemovalPolicy,
|
|
8
|
+
Stack,
|
|
9
|
+
Token,
|
|
10
|
+
type StackProps,
|
|
11
|
+
} from 'aws-cdk-lib';
|
|
4
12
|
import { CorsHttpMethod, HttpApi, HttpMethod } from 'aws-cdk-lib/aws-apigatewayv2';
|
|
5
13
|
import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
|
|
6
14
|
import { ComparisonOperator, TreatMissingData } from 'aws-cdk-lib/aws-cloudwatch';
|
|
@@ -14,10 +22,131 @@ import type { Construct } from 'constructs';
|
|
|
14
22
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
15
23
|
const workspaceRoot = path.resolve(here, '..', '..');
|
|
16
24
|
|
|
25
|
+
export interface AppStackProps extends StackProps {
|
|
26
|
+
/**
|
|
27
|
+
* Browser origins allowed to call the API.
|
|
28
|
+
*
|
|
29
|
+
* `bin/app.ts` passes the CloudFront origin from `WebStack`, so a deployed
|
|
30
|
+
* app works without configuring anything. Pass this (or
|
|
31
|
+
* `-c allowedOrigins=https://…`, comma-separated, which the entrypoint
|
|
32
|
+
* prefers when given) for a custom domain, a staging origin, or several.
|
|
33
|
+
*/
|
|
34
|
+
allowedOrigins?: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Local dev only: a real origin is a deliberate act, not a default.
|
|
39
|
+
*
|
|
40
|
+
* The port belongs to `apps/web`'s `dev` script — that is the source of truth,
|
|
41
|
+
* and a test reads it from there rather than restating it. A default naming a
|
|
42
|
+
* port nothing serves is worse than no default: it looks configured.
|
|
43
|
+
*/
|
|
44
|
+
const DEFAULT_ALLOWED_ORIGINS = ['http://localhost:3001'];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Props win, then `-c allowedOrigins=a,b`, then the local default.
|
|
48
|
+
*
|
|
49
|
+
* Anything given but unusable — an empty list, a context value that trims away
|
|
50
|
+
* to nothing, a value that is not a string — throws rather than falling back.
|
|
51
|
+
* The failure this prevents is silent: an API synthesised with an empty
|
|
52
|
+
* allow-list blocks every browser call, and the symptom appears far from the
|
|
53
|
+
* flag that caused it.
|
|
54
|
+
*
|
|
55
|
+
* Exported for the tests, and only for them — nothing else imports it. The
|
|
56
|
+
* alternative was a case per branch built on its own `AppStack`, which cost a
|
|
57
|
+
* full synth and three Lambda bundles each and put the generated project's
|
|
58
|
+
* suite over its hook timeout.
|
|
59
|
+
*/
|
|
60
|
+
export function resolveAllowedOrigins(fromProps: string[] | undefined, fromContext: unknown): string[] {
|
|
61
|
+
if (fromProps !== undefined) {
|
|
62
|
+
if (fromProps.length === 0) {
|
|
63
|
+
throw new Error('allowedOrigins was given as an empty list — name an origin, or omit it');
|
|
64
|
+
}
|
|
65
|
+
return fromProps.map(checkedOrigin);
|
|
66
|
+
}
|
|
67
|
+
if (nothingGiven(fromContext)) return DEFAULT_ALLOWED_ORIGINS;
|
|
68
|
+
if (typeof fromContext !== 'string') {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`allowedOrigins must be a comma-separated string, got ${typeof fromContext} — ` +
|
|
71
|
+
'use -c allowedOrigins=https://example.com',
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
const origins = fromContext
|
|
75
|
+
.split(',')
|
|
76
|
+
.map((origin) => origin.trim())
|
|
77
|
+
.filter(Boolean);
|
|
78
|
+
if (origins.length === 0) {
|
|
79
|
+
throw new Error(`allowedOrigins parsed to no origin at all from ${JSON.stringify(fromContext)}`);
|
|
80
|
+
}
|
|
81
|
+
return origins.map(checkedOrigin);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Whether the caller supplied nothing. Exported because `bin/app.ts` decides
|
|
86
|
+
* the same question about the same value, and two spellings of "nothing given"
|
|
87
|
+
* is how a `null` in `cdk.json` ends up meaning "the operator chose an origin"
|
|
88
|
+
* in one file and "use the default" in the other.
|
|
89
|
+
*/
|
|
90
|
+
export function nothingGiven(contextValue: unknown): boolean {
|
|
91
|
+
return contextValue === undefined || contextValue === null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* An origin a browser could actually send: `*`, or a scheme plus a host.
|
|
96
|
+
*
|
|
97
|
+
* The check exists because the near-misses synthesise green and fail in the
|
|
98
|
+
* browser, far from the flag that caused them — `app.example.com` with the
|
|
99
|
+
* scheme left off is the likeliest of them, since an `Origin` header always
|
|
100
|
+
* carries one.
|
|
101
|
+
*/
|
|
102
|
+
function checkedOrigin(origin: string): string {
|
|
103
|
+
if (origin === '*') return origin;
|
|
104
|
+
// A cross-stack origin is a token here and a domain only at deploy time —
|
|
105
|
+
// there is nothing to parse yet, and refusing it would refuse the wiring
|
|
106
|
+
// this stack ships with.
|
|
107
|
+
if (Token.isUnresolved(origin)) return origin;
|
|
108
|
+
let parsed: URL;
|
|
109
|
+
try {
|
|
110
|
+
parsed = new URL(origin);
|
|
111
|
+
} catch {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`allowedOrigins entry ${JSON.stringify(origin)} is not an origin — ` +
|
|
114
|
+
'a browser sends scheme://host[:port], as in https://app.example.com',
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
118
|
+
throw new Error(`allowedOrigins entry ${JSON.stringify(origin)} is not an http(s) origin`);
|
|
119
|
+
}
|
|
120
|
+
// The NORMALISED form, not what was typed: a trailing slash, an uppercase
|
|
121
|
+
// scheme, a path, a unicode host or a redundant :443 all synthesise green and
|
|
122
|
+
// then match no Origin header a browser sends.
|
|
123
|
+
return parsed.origin;
|
|
124
|
+
}
|
|
125
|
+
|
|
17
126
|
export class AppStack extends Stack {
|
|
18
|
-
constructor(scope: Construct, id: string, props?:
|
|
127
|
+
constructor(scope: Construct, id: string, props?: AppStackProps) {
|
|
19
128
|
super(scope, id, props);
|
|
20
129
|
|
|
130
|
+
const allowedOrigins = resolveAllowedOrigins(
|
|
131
|
+
props?.allowedOrigins,
|
|
132
|
+
this.node.tryGetContext('allowedOrigins'),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// `*` is honoured — an operator who typed it into a flag has chosen it, and
|
|
136
|
+
// refusing would push them to edit this stack instead, which is worse. It
|
|
137
|
+
// does not get to be quiet about it: the whole point of not shipping the
|
|
138
|
+
// wildcard is that reaching for it should be a visible act.
|
|
139
|
+
if (allowedOrigins.includes('*')) {
|
|
140
|
+
// V2 because it can be acknowledged: `addWarning` hard-blocks
|
|
141
|
+
// `cdk synth --strict`, which would turn "loud" into "refused" for an
|
|
142
|
+
// operator who chose the wildcard deliberately.
|
|
143
|
+
Annotations.of(this).addWarningV2(
|
|
144
|
+
'@app/allowed-origins:wildcard',
|
|
145
|
+
'allowedOrigins includes "*": every site may call this API from a browser. ' +
|
|
146
|
+
'Name the origins instead unless this is a throwaway environment.',
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
21
150
|
// --- storage: one single-table DynamoDB table --------------------------
|
|
22
151
|
const table = new Table(this, 'NotesTable', {
|
|
23
152
|
partitionKey: { name: 'pk', type: AttributeType.STRING },
|
|
@@ -91,10 +220,13 @@ export class AppStack extends Stack {
|
|
|
91
220
|
table.grantReadData(listFunction); // the lister only reads
|
|
92
221
|
|
|
93
222
|
// --- the HTTP routes ---------------------------------------------------
|
|
94
|
-
// CORS: the web bundle is served from another origin (CloudFront)
|
|
223
|
+
// CORS: the web bundle is served from another origin (CloudFront), so the
|
|
224
|
+
// API has to name who may call it. `*` is not that name — a starter
|
|
225
|
+
// multiplies whatever it ships, and a wildcard here becomes the default of
|
|
226
|
+
// every project generated from it.
|
|
95
227
|
const httpApi = new HttpApi(this, 'NotesApi', {
|
|
96
228
|
corsPreflight: {
|
|
97
|
-
allowOrigins:
|
|
229
|
+
allowOrigins: allowedOrigins,
|
|
98
230
|
allowMethods: [CorsHttpMethod.GET, CorsHttpMethod.POST],
|
|
99
231
|
allowHeaders: ['content-type'],
|
|
100
232
|
},
|
|
@@ -9,6 +9,14 @@ import { BlockPublicAccess, Bucket } from 'aws-cdk-lib/aws-s3';
|
|
|
9
9
|
import type { Construct } from 'constructs';
|
|
10
10
|
|
|
11
11
|
export class WebStack extends Stack {
|
|
12
|
+
/**
|
|
13
|
+
* Where the browser loads the app from — and therefore the origin the API
|
|
14
|
+
* has to allow. A synth-time token: CDK resolves it across stacks as an
|
|
15
|
+
* `Fn::ImportValue`, so `AppStack` can name it without either stack having
|
|
16
|
+
* been deployed yet.
|
|
17
|
+
*/
|
|
18
|
+
public readonly origin: string;
|
|
19
|
+
|
|
12
20
|
constructor(scope: Construct, id: string, props?: StackProps) {
|
|
13
21
|
super(scope, id, props);
|
|
14
22
|
|
|
@@ -26,7 +34,12 @@ export class WebStack extends Stack {
|
|
|
26
34
|
defaultRootObject: 'index.html',
|
|
27
35
|
});
|
|
28
36
|
|
|
37
|
+
this.origin = `https://${distribution.domainName}`;
|
|
38
|
+
|
|
29
39
|
new CfnOutput(this, 'WebBucketName', { value: bucket.bucketName });
|
|
30
|
-
new CfnOutput(this, 'WebUrl', { value:
|
|
40
|
+
new CfnOutput(this, 'WebUrl', { value: this.origin });
|
|
41
|
+
// The upload step needs this to invalidate the edge cache: a synced bucket
|
|
42
|
+
// whose distribution still serves the old objects has not deployed.
|
|
43
|
+
new CfnOutput(this, 'WebDistributionId', { value: distribution.distributionId });
|
|
31
44
|
}
|
|
32
45
|
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { Annotations as CdkAnnotations, App } from 'aws-cdk-lib';
|
|
5
|
+
import { Annotations, Match } from 'aws-cdk-lib/assertions';
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
import {
|
|
8
|
+
AppStack,
|
|
9
|
+
type AppStackProps,
|
|
10
|
+
nothingGiven,
|
|
11
|
+
resolveAllowedOrigins,
|
|
12
|
+
} from '../lib/app-stack.js';
|
|
13
|
+
|
|
14
|
+
// Who may call the API is a security decision with exactly two safe outcomes:
|
|
15
|
+
// the right origins, or a refusal loud enough that somebody fixes it. An empty
|
|
16
|
+
// allow-list is neither — it synthesises, deploys, and blocks every call.
|
|
17
|
+
//
|
|
18
|
+
// Resolving that list is a pure function of (props, context), so it is tested
|
|
19
|
+
// as one. The previous shape of this file built a whole AppStack per case —
|
|
20
|
+
// twelve synths, each bundling three Lambdas with esbuild — which cost ~9.3s
|
|
21
|
+
// and left the generated project's `beforeAll` hooks racing vitest's 10s
|
|
22
|
+
// default hookTimeout on a saturated CPU. Only the CDK-level behaviour below
|
|
23
|
+
// (the wildcard annotation) needs a stack, and it builds one per case.
|
|
24
|
+
|
|
25
|
+
const resolved = (given: { props?: string[]; context?: unknown } = {}): string[] =>
|
|
26
|
+
resolveAllowedOrigins(given.props, given.context);
|
|
27
|
+
|
|
28
|
+
const thrownBy = (run: () => unknown): unknown => {
|
|
29
|
+
try {
|
|
30
|
+
run();
|
|
31
|
+
} catch (error) {
|
|
32
|
+
return error;
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The port `apps/web` is actually started on, read from the script that starts it.
|
|
41
|
+
*
|
|
42
|
+
* The localhost default in `app-stack.ts` is a restated copy of a number owned
|
|
43
|
+
* by another file, and a restated constant drifts: it shipped `:3000` while the
|
|
44
|
+
* only dev server in this repository has always been launched with
|
|
45
|
+
* `--port 3001`, so the default named an origin nothing here ever sends —
|
|
46
|
+
* configured-looking, and working for nobody. Reading the source of truth is
|
|
47
|
+
* what stops that happening a second time.
|
|
48
|
+
*
|
|
49
|
+
* The read is deliberately loud. A `dev` script this cannot parse fails the
|
|
50
|
+
* suite rather than falling back to a port of its own, because a correspondence
|
|
51
|
+
* test that quietly supplies both sides of the correspondence checks nothing.
|
|
52
|
+
*/
|
|
53
|
+
const webDevServerPort = (): string => {
|
|
54
|
+
const manifest = path.join(workspaceRoot, 'apps', 'web', 'package.json');
|
|
55
|
+
const scripts = (JSON.parse(readFileSync(manifest, 'utf8')) as { scripts?: Record<string, string> })
|
|
56
|
+
.scripts;
|
|
57
|
+
const dev = scripts?.dev;
|
|
58
|
+
expect(dev, 'apps/web/package.json has no dev script to take the origin port from').toBeTypeOf(
|
|
59
|
+
'string',
|
|
60
|
+
);
|
|
61
|
+
const port = /--port[=\s]+(\d+)/.exec(dev as string)?.[1];
|
|
62
|
+
expect(
|
|
63
|
+
port,
|
|
64
|
+
`the dev script ${JSON.stringify(dev)} names no --port, so nothing pins the default to it`,
|
|
65
|
+
).toBeTypeOf('string');
|
|
66
|
+
return port as string;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Every refusal must name the flag that carried the bad value, and be an Error. */
|
|
70
|
+
const refusalFrom = (run: () => unknown): Error => {
|
|
71
|
+
const error = thrownBy(run);
|
|
72
|
+
expect(error, 'the value was accepted instead of refused').toBeInstanceOf(Error);
|
|
73
|
+
expect(
|
|
74
|
+
(error as Error).message,
|
|
75
|
+
'the message must name the flag that was wrong',
|
|
76
|
+
).toMatch(/allowedOrigins/);
|
|
77
|
+
return error as Error;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
describe('choosing the allowed origins', () => {
|
|
81
|
+
it('falls back to localhost only when nothing at all was given', () => {
|
|
82
|
+
expect(resolved()).toEqual(['http://localhost:3001']);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('defaults to the port the web dev server is actually started on', () => {
|
|
86
|
+
// The correspondence, not a second copy of the number. `apps/web` owns the
|
|
87
|
+
// port; a default that restates it is only correct until somebody changes
|
|
88
|
+
// one of the two, which is exactly how it came to name :3000.
|
|
89
|
+
expect(resolved()).toEqual([`http://localhost:${webDevServerPort()}`]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('prefers the origins passed in props over the localhost default', () => {
|
|
93
|
+
expect(resolved({ props: ['https://app.example.com'] })).toEqual(['https://app.example.com']);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('prefers the origins passed in props over a context value', () => {
|
|
97
|
+
// Both are "given"; props are the programmatic wiring and win. `bin/app.ts`
|
|
98
|
+
// depends on this ordering being decided in one place.
|
|
99
|
+
expect(
|
|
100
|
+
resolved({ props: ['https://app.example.com'], context: 'https://ctx.example.com' }),
|
|
101
|
+
).toEqual(['https://app.example.com']);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('reads a comma-separated -c allowedOrigins as one origin each, trimmed', () => {
|
|
105
|
+
expect(resolved({ context: ' https://a.example.com , https://b.example.com ' })).toEqual([
|
|
106
|
+
'https://a.example.com',
|
|
107
|
+
'https://b.example.com',
|
|
108
|
+
]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('treats a null context as nothing given, exactly as bin/app.ts does', () => {
|
|
112
|
+
// A JSON `null` is writable in `cdk.json`, `cdk.context.json` or
|
|
113
|
+
// `CDK_CONTEXT_JSON`. If the two files disagree about it, the entrypoint
|
|
114
|
+
// drops the wired CloudFront origin while the stack falls back to a
|
|
115
|
+
// localhost no deploy is served from — green synth, blocked browser.
|
|
116
|
+
expect(nothingGiven(null)).toBe(true);
|
|
117
|
+
expect(nothingGiven(undefined)).toBe(true);
|
|
118
|
+
expect(resolved({ context: null })).toEqual(['http://localhost:3001']);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('an allow-list that names nobody is a broken deploy, not a default', () => {
|
|
123
|
+
it('refuses a context value that parses to no origin at all', () => {
|
|
124
|
+
// `-c allowedOrigins=" , , "` is a typo, and an empty allow-list means
|
|
125
|
+
// every browser call fails — whose only obvious repair is putting `*` back.
|
|
126
|
+
refusalFrom(() => resolved({ context: ' , , ' }));
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('refuses an explicitly empty allowedOrigins prop for the same reason', () => {
|
|
130
|
+
// `?? DEFAULT` does not catch `[]` — an empty array is not nullish, so it
|
|
131
|
+
// travels all the way to CorsConfiguration.
|
|
132
|
+
refusalFrom(() => resolved({ props: [] }));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('refuses a non-string context value instead of crashing inside the stack', () => {
|
|
136
|
+
// `cdk.json` holds JSON, so `"allowedOrigins": ["https://a"]` is a natural
|
|
137
|
+
// thing to write. Chosen behaviour: REFUSE it and name the flag — one code
|
|
138
|
+
// path for "the flag was given something unusable", rather than quietly
|
|
139
|
+
// widening what the stack accepts.
|
|
140
|
+
const error = refusalFrom(() => resolved({ context: ['https://a.example.com'] }));
|
|
141
|
+
expect(error.message, 'a TypeError from .split is a crash, not a refusal').not.toMatch(
|
|
142
|
+
/is not a function/,
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// A browser's `Origin` header is always `scheme://host[:port]` — so an entry
|
|
148
|
+
// without a scheme cannot match one, ever, and the failure surfaces in a
|
|
149
|
+
// browser console days later, far from the flag that caused it.
|
|
150
|
+
describe('an origin no browser could ever send is refused at synth', () => {
|
|
151
|
+
it.each([
|
|
152
|
+
// The likeliest operator typo: the host as it appears in the address bar.
|
|
153
|
+
['a host with the scheme left off', 'app.example.com'],
|
|
154
|
+
// `-c allowedOrigins=null` is a string; the JSON null this branch also
|
|
155
|
+
// handles goes through a different path entirely.
|
|
156
|
+
['the literal string null', 'null'],
|
|
157
|
+
// A quoting artifact from a shell or a CI variable: non-empty after trim,
|
|
158
|
+
// and meaningless as an origin.
|
|
159
|
+
['a quoting artifact left over from the shell', '" "'],
|
|
160
|
+
])('refuses %s and names the flag that carried it', (_case, value) => {
|
|
161
|
+
refusalFrom(() => resolved({ context: value }));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('refuses the same shapes when they arrive through props', () => {
|
|
165
|
+
// Two entry points, one standard: a prop is not a trusted channel just
|
|
166
|
+
// because it is typed `string[]`.
|
|
167
|
+
refusalFrom(() => resolved({ props: ['app.example.com'] }));
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('accepts the origins a browser can actually send', () => {
|
|
171
|
+
// The refusal above must stay narrow: a real deploy origin and the local
|
|
172
|
+
// dev one are the two shapes this project is built around. The second is
|
|
173
|
+
// spelled as THIS project's dev origin on purpose — an arbitrary port would
|
|
174
|
+
// prove the same thing about the parser while quietly reading as "the dev
|
|
175
|
+
// origin" to the next person, which is the confusion that produced the
|
|
176
|
+
// :3000 default in the first place.
|
|
177
|
+
expect(resolved({ context: 'https://a.example.com' })).toEqual(['https://a.example.com']);
|
|
178
|
+
expect(resolved({ context: 'http://localhost:3001' })).toEqual(['http://localhost:3001']);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// An entry that synthesises but can never equal a browser's `Origin` header is
|
|
183
|
+
// the same failure as a typo, only quieter: CORS compares the header to the
|
|
184
|
+
// allow-list byte for byte. The near-misses below all survive `new URL()`, so
|
|
185
|
+
// nothing downstream objects — the API just refuses every call.
|
|
186
|
+
describe('an origin is stored the way a browser would actually send it', () => {
|
|
187
|
+
it.each([
|
|
188
|
+
// Copying the site's root URL out of the address bar adds the slash. This
|
|
189
|
+
// is the likeliest of the five by a wide margin.
|
|
190
|
+
['a trailing slash', 'https://app.example.com/', 'https://app.example.com'],
|
|
191
|
+
['an uppercase scheme', 'HTTPS://app.example.com', 'https://app.example.com'],
|
|
192
|
+
['a path left on the end', 'https://app.example.com/notes', 'https://app.example.com'],
|
|
193
|
+
// A browser resolves an IDN to punycode before it builds the header, so
|
|
194
|
+
// the unicode spelling never appears on the wire.
|
|
195
|
+
['a unicode host', 'https://héllo.example.com', 'https://xn--hllo-bpa.example.com'],
|
|
196
|
+
// Also strips a credential that has no business in a synthesised template.
|
|
197
|
+
['userinfo', 'https://user:secret@app.example.com', 'https://app.example.com'],
|
|
198
|
+
// Browsers omit the default port; a literal `:443` never matches.
|
|
199
|
+
['a redundant default port', 'https://app.example.com:443/', 'https://app.example.com'],
|
|
200
|
+
])('normalises %s to what the Origin header carries', (_case, given, expected) => {
|
|
201
|
+
expect(resolved({ context: given }), 'through -c allowedOrigins').toEqual([expected]);
|
|
202
|
+
expect(resolved({ props: [given] }), 'and through props, identically').toEqual([expected]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('keeps a non-default port, which a browser does send', () => {
|
|
206
|
+
expect(resolved({ context: 'https://app.example.com:8443/' })).toEqual([
|
|
207
|
+
'https://app.example.com:8443',
|
|
208
|
+
]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('trims a props entry the way it trims a context entry', () => {
|
|
212
|
+
// The context branch trims before checking; the props branch never did, so
|
|
213
|
+
// a stray space from a shell array reached CorsConfiguration intact.
|
|
214
|
+
expect(resolved({ props: [' https://a.example.com ', 'https://b.example.com\t'] })).toEqual([
|
|
215
|
+
'https://a.example.com',
|
|
216
|
+
'https://b.example.com',
|
|
217
|
+
]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('never stores the literal string null, whatever it was handed', () => {
|
|
221
|
+
// `URL.origin` is the string "null" for every opaque scheme. An allow-list
|
|
222
|
+
// entry of `null` matches the `Origin: null` a sandboxed iframe sends —
|
|
223
|
+
// granting exactly the caller nobody meant to grant.
|
|
224
|
+
for (const value of ['null', 'file:///tmp/x', 'data:text/html,x']) {
|
|
225
|
+
expect(thrownBy(() => resolved({ context: value })), value).toBeInstanceOf(Error);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// These are real `Origin` header values — a Chrome extension, and the two
|
|
231
|
+
// WebView schemes Capacitor and Ionic use on device. Refusing them is a
|
|
232
|
+
// DECISION, recorded here rather than left to be rediscovered: the http(s)
|
|
233
|
+
// check is what makes the scheme-less typo above catchable, the refusal is
|
|
234
|
+
// loud and names the flag, and an app that genuinely serves a WebView client
|
|
235
|
+
// widens `checkedOrigin` in one place. Changing this must change this test.
|
|
236
|
+
describe('a non-http(s) origin is refused, and that is a decision on record', () => {
|
|
237
|
+
it.each([
|
|
238
|
+
['a browser extension', 'chrome-extension://abcdefghijklmnopabcdefghijklmnop'],
|
|
239
|
+
['a Capacitor WebView', 'capacitor://localhost'],
|
|
240
|
+
['an Ionic WebView', 'ionic://localhost'],
|
|
241
|
+
['a blob URL whose inner origin looks real', 'blob:https://example.com/uuid'],
|
|
242
|
+
])('refuses %s, saying which schemes it does take', (_case, value) => {
|
|
243
|
+
const error = refusalFrom(() => resolved({ context: value }));
|
|
244
|
+
expect(error.message, 'the refusal must say what it would have accepted').toMatch(/http/);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// `*` is a decision, not a defect: an operator who types it into a flag has
|
|
249
|
+
// chosen it, and refusing outright would push people to edit the stack instead
|
|
250
|
+
// — a worse outcome, invisible to this test file. So it is accepted AND it is
|
|
251
|
+
// loud.
|
|
252
|
+
describe('a wildcard allow-list is honoured, but never silently', () => {
|
|
253
|
+
const namesTheWildcardRisk = Match.stringLikeRegexp('allowedOrigins');
|
|
254
|
+
/** Acknowledgeable warnings need a stable id; changing it is a breaking change. */
|
|
255
|
+
const wildcardWarningId = '@app/allowed-origins:wildcard';
|
|
256
|
+
|
|
257
|
+
it('keeps the wildcard the operator asked for', () => {
|
|
258
|
+
expect(resolved({ context: '*' })).toEqual(['*']);
|
|
259
|
+
expect(resolved({ props: ['*'] })).toEqual(['*']);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('keeps a wildcard hidden among real origins, rather than dropping it', () => {
|
|
263
|
+
// `https://a.example.com,*` reads like an allow-list; the `*` makes every
|
|
264
|
+
// other entry decorative, which is exactly why it must not slip past.
|
|
265
|
+
expect(resolved({ context: 'https://a.example.com,*' })).toEqual([
|
|
266
|
+
'https://a.example.com',
|
|
267
|
+
'*',
|
|
268
|
+
]);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// Everything below builds a real AppStack — three esbuild bundles and a synth
|
|
272
|
+
// apiece. One stack per case, and nothing in a `beforeAll`: the generated
|
|
273
|
+
// project's vitest config raises `testTimeout`, not `hookTimeout`, so a hook
|
|
274
|
+
// that synthesises is a 10s fuse on a loaded machine.
|
|
275
|
+
const stackWith = (options: { context?: Record<string, unknown>; props?: AppStackProps }) =>
|
|
276
|
+
new AppStack(new App({ context: options.context }), 'TestStack', options.props);
|
|
277
|
+
|
|
278
|
+
it('warns about a wildcard from -c allowedOrigins, acknowledgeably', () => {
|
|
279
|
+
// "Honoured, but loud" only holds if the operator can then proceed. A bare
|
|
280
|
+
// `addWarning` cannot be acknowledged, so `cdk synth --strict` — the flag
|
|
281
|
+
// CI reaches for — turns a deliberate `*` into a hard failure with no way
|
|
282
|
+
// past it short of editing the stack: the outcome this design refuses.
|
|
283
|
+
// Emission and acknowledgeability are one behaviour and share one stack;
|
|
284
|
+
// a second one costs another three esbuild bundles.
|
|
285
|
+
const stack = stackWith({ context: { allowedOrigins: '*' } });
|
|
286
|
+
Annotations.fromStack(stack).hasWarning('*', namesTheWildcardRisk);
|
|
287
|
+
|
|
288
|
+
CdkAnnotations.of(stack).acknowledgeWarning(wildcardWarningId);
|
|
289
|
+
|
|
290
|
+
Annotations.fromStack(stack).hasNoWarning('*', namesTheWildcardRisk);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('warns just the same when the wildcard arrives through props', () => {
|
|
294
|
+
// `bin/app.ts` passes props, so this is the path a composed app takes —
|
|
295
|
+
// and it was the untested half: a wildcard wired in code warned nobody.
|
|
296
|
+
Annotations.fromStack(stackWith({ props: { allowedOrigins: ['*'] } })).hasWarning(
|
|
297
|
+
'*',
|
|
298
|
+
namesTheWildcardRisk,
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
});
|