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.
Files changed (105) hide show
  1. package/CHANGELOG.md +207 -8
  2. package/README.md +50 -17
  3. package/package.json +2 -1
  4. package/packages/cli/dist/commands/create.js +8 -3
  5. package/packages/cli/dist/commands/init.js +82 -34
  6. package/packages/cli/dist/commands/upgrade.js +112 -30
  7. package/packages/cli/dist/index.js +38 -14
  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 +21 -9
  12. package/packages/cli/dist/lib/safe-path.js +30 -0
  13. package/templates/agent-os/init/AGENTS.md +191 -0
  14. package/templates/agent-os/init/CLAUDE.md +61 -9
  15. package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +98 -0
  16. package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
  17. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +34 -3
  18. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
  19. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +34 -0
  20. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
  21. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +233 -0
  22. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +676 -0
  23. package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
  24. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
  25. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
  26. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +338 -0
  27. package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
  28. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +33 -1
  29. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +64 -1
  30. package/templates/agent-os/universal/.claude/agents/security-scanner.md +33 -0
  31. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +274 -24
  32. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
  33. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
  34. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
  35. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
  36. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
  37. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +480 -0
  38. package/templates/agent-os/universal/.claude/rules/autonomy.md +61 -1
  39. package/templates/agent-os/universal/.claude/rules/invariants.md +71 -16
  40. package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
  41. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1242 -0
  42. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
  43. package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
  44. package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
  45. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +433 -0
  46. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +6 -16
  47. package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +89 -0
  48. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +334 -23
  49. package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
  50. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +7 -2
  51. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +413 -8
  52. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +75 -13
  53. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +222 -21
  54. package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
  55. package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
  56. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +441 -0
  57. package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -0
  58. package/templates/agent-os/universal/.claude/settings.json +7 -2
  59. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +115 -7
  60. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +378 -31
  61. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +300 -25
  62. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
  63. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
  64. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
  65. package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
  66. package/templates/agent-os/universal/.codex/hooks.json +65 -0
  67. package/templates/agent-os/universal/AGENTS.md +164 -0
  68. package/templates/agent-os/universal/CLAUDE.md +47 -14
  69. package/templates/agent-os/universal/PLAN.md +7 -40
  70. package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
  71. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
  72. package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
  73. package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
  74. package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
  75. package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
  76. package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
  77. package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +62 -0
  78. package/templates/agent-os/universal/journal/README.md +101 -0
  79. package/templates/agent-os/universal/layers.json +36 -2
  80. package/templates/hash-history.json +2 -1
  81. package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
  82. package/templates/skeleton/aws-serverless/README.md +91 -9
  83. package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
  84. package/templates/skeleton/aws-serverless/gitignore +37 -0
  85. package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
  86. package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
  87. package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
  88. package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
  89. package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
  90. package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
  91. package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
  92. package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
  93. package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
  94. package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
  95. package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
  96. package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
  97. package/templates/skeleton/node-service/README.md +11 -1
  98. package/templates/skeleton/node-service/gitignore +34 -0
  99. package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
  100. package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
  101. package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
  102. package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
  103. package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
  104. package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
  105. 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
- 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
  }
@@ -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
+ });