create-agent-rig 0.4.0 → 0.6.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 (118) hide show
  1. package/CHANGELOG.md +316 -11
  2. package/README.md +58 -19
  3. package/package.json +2 -1
  4. package/packages/cli/dist/commands/create.js +8 -3
  5. package/packages/cli/dist/commands/init.js +85 -34
  6. package/packages/cli/dist/commands/upgrade.js +112 -30
  7. package/packages/cli/dist/index.js +81 -19
  8. package/packages/cli/dist/lib/copy-tree.js +35 -6
  9. package/packages/cli/dist/lib/init-settings.js +12 -0
  10. package/packages/cli/dist/lib/install-set.js +6 -8
  11. package/packages/cli/dist/lib/manifest.js +31 -9
  12. package/packages/cli/dist/lib/safe-path.js +30 -0
  13. package/scripts/prepare.mjs +1 -1
  14. package/templates/agent-os/init/AGENTS.md +199 -0
  15. package/templates/agent-os/init/CLAUDE.md +69 -9
  16. package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +105 -0
  17. package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
  18. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +41 -3
  19. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
  20. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +41 -0
  21. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
  22. package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +29 -0
  23. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +236 -0
  24. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +958 -0
  25. package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
  26. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
  27. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
  28. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +396 -0
  29. package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
  30. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +40 -1
  31. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +71 -1
  32. package/templates/agent-os/universal/.claude/agents/security-scanner.md +40 -0
  33. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +301 -26
  34. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
  35. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
  36. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +127 -0
  37. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
  38. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
  39. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
  40. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +503 -0
  41. package/templates/agent-os/universal/.claude/rules/autonomy.md +69 -1
  42. package/templates/agent-os/universal/.claude/rules/invariants.md +104 -19
  43. package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
  44. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1260 -0
  45. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
  46. package/templates/agent-os/universal/.claude/scripts/doctor.mjs +351 -0
  47. package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
  48. package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
  49. package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +28 -0
  50. package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
  51. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +462 -0
  52. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +33 -17
  53. package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
  54. package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +149 -0
  55. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +809 -28
  56. package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
  57. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +95 -16
  58. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +543 -15
  59. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +457 -47
  60. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +289 -25
  61. package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
  62. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +316 -0
  63. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +180 -0
  64. package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
  65. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +539 -0
  66. package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
  67. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +239 -0
  68. package/templates/agent-os/universal/.claude/scripts/verdict.mjs +198 -0
  69. package/templates/agent-os/universal/.claude/settings.json +11 -2
  70. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +118 -7
  71. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +667 -38
  72. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +359 -26
  73. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
  74. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
  75. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
  76. package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
  77. package/templates/agent-os/universal/.codex/hooks.json +70 -0
  78. package/templates/agent-os/universal/AGENTS.md +166 -0
  79. package/templates/agent-os/universal/CLAUDE.md +49 -14
  80. package/templates/agent-os/universal/PLAN.md +7 -40
  81. package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
  82. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
  83. package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
  84. package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
  85. package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
  86. package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
  87. package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
  88. package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
  89. package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +74 -0
  90. package/templates/agent-os/universal/journal/README.md +101 -0
  91. package/templates/agent-os/universal/layers.json +45 -2
  92. package/templates/hash-history.json +310 -49
  93. package/templates/release-ledger.json +9 -0
  94. package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
  95. package/templates/skeleton/aws-serverless/README.md +91 -9
  96. package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
  97. package/templates/skeleton/aws-serverless/gitignore +37 -0
  98. package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
  99. package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
  100. package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
  101. package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
  102. package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
  103. package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
  104. package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
  105. package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
  106. package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
  107. package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
  108. package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
  109. package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
  110. package/templates/skeleton/node-service/README.md +11 -1
  111. package/templates/skeleton/node-service/gitignore +34 -0
  112. package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
  113. package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
  114. package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
  115. package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
  116. package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
  117. package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
  118. package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +28 -0
@@ -45,13 +45,27 @@ do not edit the workflow:
45
45
 
46
46
  1. In AWS, create an IAM role your repo can assume via GitHub's OIDC provider
47
47
  (`token.actions.githubusercontent.com`) — a short-lived federated role, no
48
- long-lived access keys anywhere.
49
- 2. Add its ARN as the repository secret **`AWS_DEPLOY_ROLE_ARN`** (and,
50
- optionally, repo variables `AWS_REGION` and `API_URL`).
51
-
52
- The workflow then assumes the role, builds the web bundle, and runs
53
- `cdk deploy AppStack WebStack`. The web bundle is served from S3 + CloudFront
54
- (the `WebUrl` output).
48
+ long-lived access keys anywhere. Beyond what `cdk deploy` needs, the
49
+ workflow uploads the bundle itself, so the role also needs
50
+ **`s3:ListBucket`, `s3:PutObject`, `s3:DeleteObject`** on the web bucket and
51
+ **`cloudfront:CreateInvalidation`** on the distribution. A role scoped only
52
+ to CDK's bootstrap roles gets through the deploy and fails on the upload —
53
+ after both stacks are already up.
54
+ 2. Add its ARN as the repository secret **`AWS_DEPLOY_ROLE_ARN`**, and set the
55
+ repo variable **`API_URL`** to the deployed API's URL (the `ApiUrl` output
56
+ of a first deploy). It becomes `NEXT_PUBLIC_API_URL`, which Next **inlines
57
+ into the bundle at build time** — unset, the site calls `/notes` on its own
58
+ CloudFront domain, where nothing answers. `AWS_REGION` is genuinely
59
+ optional. The first deploy is therefore two passes: deploy, read `ApiUrl`
60
+ from the job's `cdk deploy` Outputs, set the variable, then re-run the
61
+ workflow (push, or **Run workflow** — it is `workflow_dispatch`-enabled).
62
+
63
+ The workflow then assumes the role, builds the web bundle, runs
64
+ `cdk deploy AppStack WebStack --outputs-file`, then **uploads `apps/web/out` to
65
+ the web bucket (`aws s3 sync --delete`) and invalidates the CloudFront cache**,
66
+ reading both destinations from the stack outputs. You do not sync anything by
67
+ hand on this path. The site is served from S3 + CloudFront (the `WebUrl`
68
+ output).
55
69
 
56
70
  ### Local / manual
57
71
 
@@ -59,8 +73,26 @@ The workflow then assumes the role, builds the web bundle, and runs
59
73
  # needs AWS credentials; region comes from your profile (generator default: __REGION__)
60
74
  cd infra
61
75
  npx cdk bootstrap # first time per account/region
62
- npx cdk deploy AppStack WebStack
63
- aws s3 sync ../apps/web/out "s3://<WebBucketName output>"
76
+ npx cdk deploy AppStack WebStack --outputs-file cdk-outputs.json
77
+ # --delete makes the bucket the bundle's territory alone, so a stale or missing
78
+ # `out/` would empty the live site — or restore last month's. The workflow
79
+ # builds two steps before its sync; by hand, build here.
80
+ # Read the outputs into variables FIRST. `jq -er` exits non-zero on a missing
81
+ # key, but a command substitution inside an assignment or an argument throws
82
+ # that status away — which is how a stale outputs file becomes a bundle built
83
+ # against `null` and an `aws s3 sync … s3://null --delete`.
84
+ API=$(jq -er '.AppStack.ApiUrl' cdk-outputs.json) || { echo "no ApiUrl"; exit 1; }
85
+ BUCKET=$(jq -er '.WebStack.WebBucketName' cdk-outputs.json) || { echo "no WebBucketName"; exit 1; }
86
+ DIST=$(jq -er '.WebStack.WebDistributionId' cdk-outputs.json) || { echo "no WebDistributionId"; exit 1; }
87
+
88
+ # NEXT_PUBLIC_API_URL is inlined at build time: without it the bundle calls its
89
+ # own CloudFront domain instead of the API, and the sync below makes that live.
90
+ (cd .. && NEXT_PUBLIC_API_URL="$API" pnpm build:web)
91
+ [ -f ../apps/web/out/index.html ] || { echo "no web bundle — build failed"; exit 1; }
92
+ aws s3 sync ../apps/web/out "s3://$BUCKET" --delete
93
+ # a synced bucket whose distribution still serves the old objects has not
94
+ # deployed — invalidate, or you are looking at the previous build
95
+ aws cloudfront create-invalidation --paths '/*' --distribution-id "$DIST"
64
96
  ```
65
97
 
66
98
  ### Production — a human step, on purpose
@@ -76,12 +108,41 @@ your own approval — reusing the dev workflow's OIDC pattern.
76
108
  After every deploy:
77
109
 
78
110
  ```sh
111
+ # From the deploy's outputs file — run this from `infra/`, where the manual
112
+ # section leaves you. Deployed through CI instead? That file is written on the
113
+ # runner and never lands here: take both values from the job's `cdk deploy`
114
+ # Outputs, or re-run `cdk deploy … --outputs-file cdk-outputs.json` locally.
115
+ API_URL=$(jq -er '.AppStack.ApiUrl' cdk-outputs.json)
116
+ WEB_URL=$(jq -er '.WebStack.WebUrl' cdk-outputs.json)
117
+
79
118
  curl -s -X POST "$API_URL/notes" \
80
119
  -H 'content-type: application/json' \
81
120
  -d '{"title":"smoke test","tags":["deploy"]}'
82
121
  # expect: HTTP 201 with { "note": { … } }
83
122
  ```
84
123
 
124
+ **That checks the API, not the site.** The two fail independently: a bundle
125
+ built without `NEXT_PUBLIC_API_URL` calls its own CloudFront domain, and a
126
+ misnamed origin is refused by the browser — neither is visible to a `curl` that
127
+ sends no `Origin` header. So also open the `WebUrl` output, create a note in
128
+ the form, and reload:
129
+
130
+ ```sh
131
+ curl -s -I "$WEB_URL" | head -1 # the bundle is served
132
+
133
+ # API Gateway answers a preflight 204 whether or not the origin matched, so the
134
+ # status code proves nothing here. The browser gates on the echoed header —
135
+ # that is what to look for. `content-type` is sent because the real call uses
136
+ # it, and a non-safelisted header is what makes `allowHeaders` matter.
137
+ curl -s -X OPTIONS "$API_URL/notes" -D - -o /dev/null \
138
+ -H "Origin: $WEB_URL" \
139
+ -H 'access-control-request-method: POST' \
140
+ -H 'access-control-request-headers: content-type' \
141
+ | grep -i '^access-control-allow-origin:'
142
+ # expect: a line echoing $WEB_URL. No line = the browser will refuse the call,
143
+ # whatever the status code said.
144
+ ```
145
+
85
146
  Then confirm the pipeline: the worker logs `note.created processed`, and the
86
147
  **DLQ alarm stays quiet**. If the smoke test regresses: `npx cdk deploy` the
87
148
  previous revision (or `git revert` and redeploy) **first**, diagnose second.
@@ -95,5 +156,26 @@ previous revision (or `git revert` and redeploy) **first**, diagnose second.
95
156
  - `packages/db` is the only module that touches the storage SDK.
96
157
  - A failing queue message is poison: it throws, SQS retries ×3, the DLQ alarm
97
158
  fires. Never wrap the worker in a broad catch.
159
+ - **Creating a note is a dual write, and it is not atomic.** `create-note`
160
+ puts to DynamoDB and then publishes to SQS. If the publish fails, the note
161
+ is stored and its event never happened — the worker never runs, and nothing
162
+ compensates; the caller gets a 500 for a note that exists. That is a
163
+ deliberate simplification for a starter. When it starts to matter, the two
164
+ ways out are an **outbox** (write the event alongside the note, relay it
165
+ afterwards) or DynamoDB Streams feeding the worker, which deletes the second
166
+ write instead of coordinating it.
167
+ - **CORS names who may call the API, and that half is wired for you.**
168
+ `bin/app.ts` builds `WebStack` first and passes its CloudFront origin to
169
+ `AppStack`, so the API allows the deployed site without you configuring it.
170
+ The other half is not automatic: the bundle has to know where the API *is*,
171
+ and that is `NEXT_PUBLIC_API_URL` above. For a custom domain or a second
172
+ origin, deploy with `-c allowedOrigins=https://app.example.com` (comma-
173
+ separated for several) — the entrypoint prefers the flag over the wired
174
+ default, and the cross-stack export disappears with it. An `allowedOrigins`
175
+ that parses to nothing, or an entry no browser could send, is refused at
176
+ synth rather than deployed as an API nobody can call. `*` is not the default
177
+ and never will be, but the flag does take it: you get a warning naming the
178
+ consequence, not a refusal, because the alternative is people editing the
179
+ stack.
98
180
 
99
181
  See `.claude/rules/architecture.md` for the full rules.
@@ -2,8 +2,15 @@
2
2
  // (the guard-web-boundary hook refuses such imports at the tool layer).
3
3
  import type { Note } from '@app/core';
4
4
 
5
- // Same-origin by default (the API server serves this bundle); set
6
- // NEXT_PUBLIC_API_URL at build time when the API lives elsewhere.
5
+ // In THIS target the API is never same-origin: the bundle is served from
6
+ // CloudFront and the API is API Gateway, so `NEXT_PUBLIC_API_URL` has to be set
7
+ // at build time — Next inlines it, and an unset one leaves the empty string
8
+ // here, which sends every call to whatever is serving this page.
9
+ //
10
+ // The `?? ''` is a fallback to nothing, not to a working default: there is no
11
+ // dev proxy in this project, so `pnpm --filter @app/web dev` needs the variable
12
+ // too. It exists only so a missing value is a relative URL rather than the
13
+ // string "undefined" in a request path.
7
14
  const base = process.env.NEXT_PUBLIC_API_URL ?? '';
8
15
 
9
16
  export async function createNote(input: { title: string; tags: string[] }): Promise<Note> {
@@ -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
- const app = new App();
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
- new AppStack(app, 'AppStack', {
12
- description: 'API + worker for __PROJECT_NAME__ (generated by create-agent-rig)',
13
- env,
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
- new WebStack(app, 'WebStack', {
17
- description: 'Static web serving for __PROJECT_NAME__ (generated by create-agent-rig)',
18
- env,
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 { CfnOutput, Duration, RemovalPolicy, Stack, type StackProps } from 'aws-cdk-lib';
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?: StackProps) {
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: `https://${distribution.domainName}` });
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
  }