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
@@ -0,0 +1,137 @@
1
+ import type { App, Stack } from 'aws-cdk-lib';
2
+ import { Template } from 'aws-cdk-lib/assertions';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { resolveAllowedOrigins } from '../lib/app-stack.js';
5
+
6
+ // `bin/app.ts` is what every `cdk deploy` and every CI run actually builds, so
7
+ // the CORS allow-list has to be correct THERE — a stack that can be handed the
8
+ // right origin, by an entrypoint that hands it none, ships an app whose own
9
+ // browser calls are blocked. The README's `curl` health check sends no `Origin`
10
+ // header, so nothing downstream notices.
11
+ //
12
+ // The composition is exercised through the entrypoint rather than rebuilt here:
13
+ // a test that re-wires the stacks itself passes just as happily while
14
+ // `bin/app.ts` stays broken.
15
+
16
+ const originsOfComposedApp = async (): Promise<unknown[]> => {
17
+ const { createApp } = (await import('../bin/app.js')) as { createApp: () => App };
18
+ const app = createApp();
19
+ const template = Template.fromStack(app.node.findChild('AppStack') as Stack);
20
+ const apis = template.findResources('AWS::ApiGatewayV2::Api');
21
+ return Object.values(apis).flatMap(
22
+ (api) =>
23
+ (api.Properties as { CorsConfiguration?: { AllowOrigins?: unknown[] } }).CorsConfiguration
24
+ ?.AllowOrigins ?? [],
25
+ );
26
+ };
27
+
28
+ /**
29
+ * `createApp()` builds its own `App`, so `new App({ context })` is not a seam
30
+ * here. CDK's own is: the CLI passes `-c key=value` to a synth through
31
+ * `CDK_CONTEXT_JSON`, which the `App` constructor reads — so setting it around
32
+ * one `createApp()` call reproduces `cdk deploy -c allowedOrigins=…` exactly,
33
+ * without the test knowing anything about how the stacks are wired.
34
+ *
35
+ * `process.env` is process-global, so this seam is not concurrency-safe: an
36
+ * `it.concurrent` in this file would have one case's context leak into
37
+ * another's synth. Keep these cases sequential.
38
+ */
39
+ const withCdkContext = async <T>(context: Record<string, unknown>, run: () => Promise<T>) => {
40
+ const previous = process.env.CDK_CONTEXT_JSON;
41
+ process.env.CDK_CONTEXT_JSON = JSON.stringify(context);
42
+ try {
43
+ return await run();
44
+ } finally {
45
+ if (previous === undefined) delete process.env.CDK_CONTEXT_JSON;
46
+ else process.env.CDK_CONTEXT_JSON = previous;
47
+ }
48
+ };
49
+
50
+ /**
51
+ * The default composition is synthesised once and shared — but deliberately
52
+ * NOT from `beforeAll`. This project's `vitest.config.ts` raises `testTimeout`
53
+ * for exactly this cost and leaves `hookTimeout` at vitest's 10s default, so
54
+ * work put in a hook runs on a fuse a quarter the length of the one the config
55
+ * meant to give it. Synthesising here bundles three Lambdas with esbuild while
56
+ * sibling workers do the same; that contention is what made this suite fail
57
+ * intermittently with `Hook timed out in 10000ms`.
58
+ */
59
+ let defaultComposition: Promise<unknown[]> | undefined;
60
+ const defaultComposedOrigins = (): Promise<unknown[]> =>
61
+ (defaultComposition ??= originsOfComposedApp());
62
+
63
+ /** What CDK emits for a cross-stack reference: a Join around an ImportValue. */
64
+ const referencesTheWebDistribution = (origin: unknown): boolean => {
65
+ if (typeof origin === 'string') return false; // a hard-coded origin, whatever it says
66
+ const rendered = JSON.stringify(origin);
67
+ return /Fn::ImportValue/.test(rendered) && /WebStack/.test(rendered);
68
+ };
69
+
70
+ describe('the deployed app allows the origin it is actually served from', () => {
71
+ it('takes its allowed origin from the web distribution, not from a localhost no deploy uses', async () => {
72
+ // `distributionDomainName` is a synth-time token; CDK resolves it across
73
+ // stacks as an `Fn::ImportValue`, and `AllowOrigins` accepts it. So the
74
+ // entrypoint has no excuse to leave the two stacks unwired.
75
+ const appStackOrigins = await defaultComposedOrigins();
76
+ expect(appStackOrigins.length).toBeGreaterThan(0);
77
+ expect(appStackOrigins.some(referencesTheWebDistribution)).toBe(true);
78
+ });
79
+
80
+ it('still names who may call it: no wildcard reaches the composed app', async () => {
81
+ const appStackOrigins = await defaultComposedOrigins();
82
+ expect(appStackOrigins).not.toContain('*');
83
+ expect(JSON.stringify(appStackOrigins)).not.toContain('"*"');
84
+ });
85
+
86
+ it('carries the https scheme, without which no browser Origin header can match', async () => {
87
+ const appStackOrigins = await defaultComposedOrigins();
88
+ const distributionOrigin = appStackOrigins.find(referencesTheWebDistribution);
89
+ expect(JSON.stringify(distributionOrigin)).toContain('https://');
90
+ });
91
+ });
92
+
93
+ // The README and `AppStackProps.allowedOrigins` both advertise
94
+ // `-c allowedOrigins=https://…` as the way to point the API at a custom domain.
95
+ // It once did not work: the entrypoint passed `allowedOrigins` unconditionally
96
+ // and props win, so the flag was discarded on synth with no error and no
97
+ // warning — a green deploy, a CORS-blocked browser, and `allowOrigins: ['*']`
98
+ // as the obvious repair for a hurried reader. That is the regression the rest
99
+ // of this file exists to prevent, arriving through a different door, which is
100
+ // why the door is pinned here.
101
+ describe('the documented -c escape hatch reaches the composed app', () => {
102
+ it('serves the origin the flag names instead of discarding it for the CloudFront import', async () => {
103
+ const origins = await withCdkContext({ allowedOrigins: 'https://custom.example.com' }, () =>
104
+ originsOfComposedApp(),
105
+ );
106
+
107
+ expect(origins).toContain('https://custom.example.com');
108
+ expect(
109
+ origins.some(referencesTheWebDistribution),
110
+ 'a custom domain that still allows only the CloudFront import is a blocked browser',
111
+ ).toBe(false);
112
+ });
113
+ });
114
+
115
+ // Two files read the same context key, and they once disagreed by one value:
116
+ // the entrypoint asked `override === undefined` while `resolveAllowedOrigins`
117
+ // treated `undefined` OR `null` as "nothing given". A `null` — writable in
118
+ // `cdk.json`, `cdk.context.json` or `CDK_CONTEXT_JSON`, though never producible
119
+ // by the `-c` flag — landed in the gap, and the entrypoint dropped the wired
120
+ // origin as if an override existed while the stack fell back to a localhost no
121
+ // deploy is served from. Green synth, blocked browser: this branch's whole
122
+ // failure class. One predicate now, imported; this pins that it stays one.
123
+ describe('a context that names no origin is not an override', () => {
124
+ it('keeps the wired CloudFront origin when allowedOrigins is present but null', async () => {
125
+ const origins = await withCdkContext({ allowedOrigins: null }, () => originsOfComposedApp());
126
+
127
+ expect(
128
+ origins.some(referencesTheWebDistribution),
129
+ 'a null context must compose exactly like no context at all',
130
+ ).toBe(true);
131
+ // Asked of the resolver rather than spelled out: a literal copy of the
132
+ // localhost default passes vacuously the moment the default changes — it
133
+ // then forbids an origin nothing could have produced, and the leak this
134
+ // line exists to catch walks straight through.
135
+ expect(origins).not.toContain(resolveAllowedOrigins(undefined, undefined)[0]);
136
+ });
137
+ });
@@ -1,38 +1,40 @@
1
1
  import { App } from 'aws-cdk-lib';
2
2
  import { Match, Template } from 'aws-cdk-lib/assertions';
3
- import { beforeAll, describe, expect, it } from 'vitest';
3
+ import { describe, expect, it } from 'vitest';
4
4
  import { AppStack } from '../lib/app-stack.js';
5
5
 
6
- // Template.fromStack bundles the Lambda entries with esbuild — slow-ish, run once.
7
- let template: Template;
8
-
9
- beforeAll(() => {
10
- const app = new App();
11
- template = Template.fromStack(new AppStack(app, 'TestStack'));
12
- });
6
+ // `Template.fromStack` bundles the Lambda entries with esbuild — slow-ish, so
7
+ // it runs once and is shared. Deliberately not in `beforeAll`: this project's
8
+ // `vitest.config.ts` raises `testTimeout` for exactly this cost and leaves
9
+ // `hookTimeout` at vitest's 10s default, so the same work in a hook runs on a
10
+ // much shorter fuse — and it is contended, because sibling workers are
11
+ // bundling too.
12
+ let synthesised: Template | undefined;
13
+ const template = (): Template =>
14
+ (synthesised ??= Template.fromStack(new AppStack(new App(), 'TestStack')));
13
15
 
14
16
  describe('storage', () => {
15
17
  it('creates the single table with pk/sk and on-demand billing', () => {
16
- template.hasResourceProperties('AWS::DynamoDB::Table', {
18
+ template().hasResourceProperties('AWS::DynamoDB::Table', {
17
19
  KeySchema: [
18
20
  { AttributeName: 'pk', KeyType: 'HASH' },
19
21
  { AttributeName: 'sk', KeyType: 'RANGE' },
20
22
  ],
21
23
  BillingMode: 'PAY_PER_REQUEST',
22
24
  });
23
- template.resourceCountIs('AWS::DynamoDB::Table', 1);
25
+ template().resourceCountIs('AWS::DynamoDB::Table', 1);
24
26
  });
25
27
  });
26
28
 
27
29
  describe('queue discipline', () => {
28
30
  it('wires the DLQ with maxReceiveCount 3', () => {
29
- template.hasResourceProperties('AWS::SQS::Queue', {
31
+ template().hasResourceProperties('AWS::SQS::Queue', {
30
32
  RedrivePolicy: Match.objectLike({ maxReceiveCount: 3 }),
31
33
  });
32
34
  });
33
35
 
34
36
  it('alarms as soon as one message reaches the DLQ', () => {
35
- template.hasResourceProperties('AWS::CloudWatch::Alarm', {
37
+ template().hasResourceProperties('AWS::CloudWatch::Alarm', {
36
38
  MetricName: 'ApproximateNumberOfMessagesVisible',
37
39
  Threshold: 1,
38
40
  EvaluationPeriods: 1,
@@ -41,7 +43,7 @@ describe('queue discipline', () => {
41
43
  });
42
44
 
43
45
  it('feeds the worker one message at a time', () => {
44
- template.hasResourceProperties('AWS::Lambda::EventSourceMapping', {
46
+ template().hasResourceProperties('AWS::Lambda::EventSourceMapping', {
45
47
  BatchSize: 1,
46
48
  });
47
49
  });
@@ -49,11 +51,11 @@ describe('queue discipline', () => {
49
51
 
50
52
  describe('functions and routes', () => {
51
53
  it('deploys exactly the create, list and worker functions — one purpose each', () => {
52
- template.resourceCountIs('AWS::Lambda::Function', 3);
54
+ template().resourceCountIs('AWS::Lambda::Function', 3);
53
55
  });
54
56
 
55
57
  it('passes table and queue to the api via environment', () => {
56
- template.hasResourceProperties('AWS::Lambda::Function', {
58
+ template().hasResourceProperties('AWS::Lambda::Function', {
57
59
  Environment: {
58
60
  Variables: Match.objectLike({
59
61
  TABLE_NAME: Match.anyValue(),
@@ -64,27 +66,41 @@ describe('functions and routes', () => {
64
66
  });
65
67
 
66
68
  it('exposes exactly two routes: POST /notes and GET /notes', () => {
67
- template.hasResourceProperties('AWS::ApiGatewayV2::Route', {
69
+ template().hasResourceProperties('AWS::ApiGatewayV2::Route', {
68
70
  RouteKey: 'POST /notes',
69
71
  });
70
- template.hasResourceProperties('AWS::ApiGatewayV2::Route', {
72
+ template().hasResourceProperties('AWS::ApiGatewayV2::Route', {
71
73
  RouteKey: 'GET /notes',
72
74
  });
73
- template.resourceCountIs('AWS::ApiGatewayV2::Route', 2);
75
+ template().resourceCountIs('AWS::ApiGatewayV2::Route', 2);
74
76
  });
75
77
 
76
78
  it('allows the browser origin in: CORS is configured', () => {
77
- template.hasResourceProperties('AWS::ApiGatewayV2::Api', {
79
+ template().hasResourceProperties('AWS::ApiGatewayV2::Api', {
78
80
  CorsConfiguration: Match.objectLike({
79
81
  AllowMethods: Match.arrayWith(['GET', 'POST']),
80
82
  }),
81
83
  });
82
84
  });
85
+
86
+ it('never allows every origin: the api names who may call it', () => {
87
+ const apis = template().findResources('AWS::ApiGatewayV2::Api');
88
+ const origins = Object.values(apis).flatMap(
89
+ (api) =>
90
+ (api.Properties as { CorsConfiguration?: { AllowOrigins?: unknown[] } }).CorsConfiguration
91
+ ?.AllowOrigins ?? [],
92
+ );
93
+
94
+ // A starter multiplies whatever it ships: `*` here becomes the default of
95
+ // every project generated from it. The web bundle has a known origin.
96
+ expect(origins.length).toBeGreaterThan(0);
97
+ expect(origins).not.toContain('*');
98
+ });
83
99
  });
84
100
 
85
101
  describe('least-privilege IAM', () => {
86
102
  it('grants writes to the creator, reads to the lister, queue send — nothing broad', () => {
87
- const policies = template.findResources('AWS::IAM::Policy');
103
+ const policies = template().findResources('AWS::IAM::Policy');
88
104
  const statements = Object.values(policies).flatMap(
89
105
  (policy) =>
90
106
  (policy.Properties as { PolicyDocument: { Statement: Array<Record<string, unknown>> } })
@@ -1,20 +1,23 @@
1
1
  import { App } from 'aws-cdk-lib';
2
2
  import { Match, Template } from 'aws-cdk-lib/assertions';
3
- import { beforeAll, describe, it } from 'vitest';
3
+ import { describe, it } from 'vitest';
4
4
  import { WebStack } from '../lib/web-stack.js';
5
5
 
6
6
  // The web stack is stateless serving only: a private bucket behind CloudFront.
7
7
  // The bundle itself is synced by the deploy step (see README) — synth stays
8
8
  // independent of `next build`.
9
- let template: Template;
10
-
11
- beforeAll(() => {
12
- template = Template.fromStack(new WebStack(new App(), 'TestWebStack'));
13
- });
9
+ //
10
+ // Synthesised lazily rather than in a `beforeAll`, like its neighbours in this
11
+ // directory: `vitest.config.ts` raises `testTimeout` and leaves `hookTimeout`
12
+ // at 10 s, so a hook is the one place in the suite running on a short fuse.
13
+ // This stack bundles no Lambda and takes ~370 ms — the reason to do it anyway
14
+ // is that the next person copies whatever shape is here.
15
+ let cached: Template | undefined;
16
+ const template = () => (cached ??= Template.fromStack(new WebStack(new App(), 'TestWebStack')));
14
17
 
15
18
  describe('web serving stack', () => {
16
19
  it('keeps the bucket fully private', () => {
17
- template.hasResourceProperties('AWS::S3::Bucket', {
20
+ template().hasResourceProperties('AWS::S3::Bucket', {
18
21
  PublicAccessBlockConfiguration: Match.objectLike({
19
22
  BlockPublicAcls: true,
20
23
  BlockPublicPolicy: true,
@@ -25,9 +28,9 @@ describe('web serving stack', () => {
25
28
  });
26
29
 
27
30
  it('serves through CloudFront with origin access control', () => {
28
- template.resourceCountIs('AWS::CloudFront::Distribution', 1);
29
- template.resourceCountIs('AWS::CloudFront::OriginAccessControl', 1);
30
- template.hasResourceProperties('AWS::CloudFront::Distribution', {
31
+ template().resourceCountIs('AWS::CloudFront::Distribution', 1);
32
+ template().resourceCountIs('AWS::CloudFront::OriginAccessControl', 1);
33
+ template().hasResourceProperties('AWS::CloudFront::Distribution', {
31
34
  DistributionConfig: Match.objectLike({
32
35
  DefaultRootObject: 'index.html',
33
36
  }),
@@ -35,7 +38,7 @@ describe('web serving stack', () => {
35
38
  });
36
39
 
37
40
  it('exports the distribution domain and bucket name for the deploy step', () => {
38
- template.hasOutput('WebBucketName', {});
39
- template.hasOutput('WebUrl', {});
41
+ template().hasOutput('WebBucketName', {});
42
+ template().hasOutput('WebUrl', {});
40
43
  });
41
44
  });
@@ -1,11 +1,19 @@
1
1
  // The handler translates transport ⇄ domain and nothing else: decode the
2
2
  // payload, call the usecase, map the result (or the typed error) to HTTP.
3
- import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';
3
+ import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2, Context } from 'aws-lambda';
4
4
  import { InvalidNoteError } from '@app/core';
5
5
  import { AppError } from '@app/shared';
6
6
  import { createNoteUsecase, type CreateNoteDeps } from '../usecases/create-note.js';
7
7
 
8
- type Handler = (event: APIGatewayProxyEventV2) => Promise<APIGatewayProxyResultV2>;
8
+ /**
9
+ * Lambda passes the context as the second argument; the handler takes the one
10
+ * field it needs, so a log line can be tied back to the invocation that wrote
11
+ * it. Optional because the context is Lambda's to supply, not the caller's.
12
+ */
13
+ type Handler = (
14
+ event: APIGatewayProxyEventV2,
15
+ context?: Pick<Context, 'awsRequestId'>,
16
+ ) => Promise<APIGatewayProxyResultV2>;
9
17
 
10
18
  const json = (statusCode: number, body: unknown): APIGatewayProxyResultV2 => ({
11
19
  statusCode,
@@ -14,7 +22,7 @@ const json = (statusCode: number, body: unknown): APIGatewayProxyResultV2 => ({
14
22
  });
15
23
 
16
24
  export function makeCreateNoteHandler(deps: CreateNoteDeps): Handler {
17
- return async (event) => {
25
+ return async (event, context) => {
18
26
  let payload: unknown;
19
27
  try {
20
28
  const raw = event.isBase64Encoded
@@ -32,11 +40,30 @@ export function makeCreateNoteHandler(deps: CreateNoteDeps): Handler {
32
40
  if (error instanceof InvalidNoteError) {
33
41
  return json(400, { error: 'invalid note', issues: error.issues });
34
42
  }
35
- if (error instanceof AppError) {
43
+ // A 4xx message was written for the caller to read. A 5xx one was not:
44
+ // `AppError` defaults to 500/INTERNAL, so a table name, a host or an SDK
45
+ // message arrives here wearing the same type as "title is required".
46
+ // Typed does not mean safe to show — the status decides.
47
+ //
48
+ // The status itself is kept: 503 tells a caller to retry and 500 does
49
+ // not, and that distinction is transport, not disclosure. Only the
50
+ // message is withheld.
51
+ if (error instanceof AppError && error.statusCode < 500) {
36
52
  return json(error.statusCode, { error: error.message, code: error.code });
37
53
  }
38
- deps.log.error('unhandled error in create-note', { error: String(error) });
39
- return json(500, { error: 'internal error' });
54
+ // Everything past here is withheld, so it has to be kept somewhere: the
55
+ // message AND its frames, because a message with no stack is not
56
+ // something anyone can debug from a log aggregator, and the request id,
57
+ // because a line nobody can tie to an invocation is barely a line.
58
+ deps.log.error('unhandled error in create-note', {
59
+ error: error instanceof Error ? error.message : String(error),
60
+ stack: error instanceof Error ? error.stack : undefined,
61
+ // The one field of a withheld AppError worth keeping: it says WHICH
62
+ // internal failure this was, and it never reaches the caller.
63
+ code: error instanceof AppError ? error.code : undefined,
64
+ awsRequestId: context?.awsRequestId,
65
+ });
66
+ return json(error instanceof AppError ? error.statusCode : 500, { error: 'internal error' });
40
67
  }
41
68
  };
42
69
  }
@@ -1,4 +1,4 @@
1
- import type { APIGatewayProxyResultV2 } from 'aws-lambda';
1
+ import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2, Context } from 'aws-lambda';
2
2
  import { AppError } from '@app/shared';
3
3
  import type { Logger } from '@app/shared';
4
4
  import { listNotesUsecase, type ListNotesDeps } from '../usecases/list-notes.js';
@@ -10,15 +10,28 @@ const json = (statusCode: number, body: unknown): APIGatewayProxyResultV2 => ({
10
10
  });
11
11
 
12
12
  export function makeListNotesHandler(deps: ListNotesDeps & { log: Logger }) {
13
- return async (): Promise<APIGatewayProxyResultV2> => {
13
+ return async (
14
+ _event?: APIGatewayProxyEventV2,
15
+ context?: Pick<Context, 'awsRequestId'>,
16
+ ): Promise<APIGatewayProxyResultV2> => {
14
17
  try {
15
18
  return json(200, { notes: await listNotesUsecase(deps) });
16
19
  } catch (error) {
17
- if (error instanceof AppError) {
20
+ // Same contract as create-note: a 4xx message was written for the caller,
21
+ // a 5xx one was not. The status survives — 503 tells a caller to retry
22
+ // and 500 does not — and only the message is withheld.
23
+ if (error instanceof AppError && error.statusCode < 500) {
18
24
  return json(error.statusCode, { error: error.message, code: error.code });
19
25
  }
20
- deps.log.error('unhandled error in list-notes', { error: String(error) });
21
- return json(500, { error: 'internal error' });
26
+ deps.log.error('unhandled error in list-notes', {
27
+ error: error instanceof Error ? error.message : String(error),
28
+ stack: error instanceof Error ? error.stack : undefined,
29
+ // The one field of a withheld AppError worth keeping: it says WHICH
30
+ // internal failure this was, and it never reaches the caller.
31
+ code: error instanceof AppError ? error.code : undefined,
32
+ awsRequestId: context?.awsRequestId,
33
+ });
34
+ return json(error instanceof AppError ? error.statusCode : 500, { error: 'internal error' });
22
35
  }
23
36
  };
24
37
  }
@@ -21,6 +21,17 @@ export interface CreateNoteDeps {
21
21
  log: Logger;
22
22
  }
23
23
 
24
+ /**
25
+ * ⚠ These are **two writes and they are not atomic** — a dual write. The put
26
+ * can succeed and the publish then fail, which leaves a stored note whose event
27
+ * never happened: the worker does not run, nothing downstream hears about it,
28
+ * and nothing here compensates. The caller sees a 500 for a note that exists.
29
+ *
30
+ * That is a deliberate simplification for a skeleton, not a pattern to copy at
31
+ * scale. The two ways out, when it starts to matter: an **outbox** (write the
32
+ * event into the same transaction as the note, relay it after), or DynamoDB
33
+ * Streams feeding the worker, which removes the second write entirely.
34
+ */
24
35
  export async function createNoteUsecase(input: unknown, deps: CreateNoteDeps): Promise<Note> {
25
36
  const note = createNote(input, { id: deps.newId(), createdAt: deps.now() });
26
37
  await deps.notes.put(note);
@@ -1,4 +1,8 @@
1
- import type { APIGatewayProxyEventV2, APIGatewayProxyStructuredResultV2 } from 'aws-lambda';
1
+ import type {
2
+ APIGatewayProxyEventV2,
3
+ APIGatewayProxyStructuredResultV2,
4
+ Context,
5
+ } from 'aws-lambda';
2
6
  import { AppError, createLogger } from '@app/shared';
3
7
  import { describe, expect, it } from 'vitest';
4
8
  import { makeCreateNoteHandler } from '../src/handlers/create-note.js';
@@ -7,6 +11,9 @@ import type { CreateNoteDeps } from '../src/usecases/create-note.js';
7
11
  const event = (body: string): APIGatewayProxyEventV2 =>
8
12
  ({ body, isBase64Encoded: false }) as APIGatewayProxyEventV2;
9
13
 
14
+ /** Lambda always passes a context; the handler only ever needs the request id. */
15
+ const lambdaContext = (awsRequestId: string): Context => ({ awsRequestId }) as Context;
16
+
10
17
  function stubDeps(overrides: Partial<CreateNoteDeps> = {}) {
11
18
  const deps: CreateNoteDeps = {
12
19
  notes: { put: async () => {} },
@@ -89,4 +96,116 @@ describe('POST /notes handler', () => {
89
96
  expect(result.body).not.toContain('secret detail');
90
97
  expect(logs.join('\n')).toContain('secret detail');
91
98
  });
99
+
100
+ // AppError defaults to statusCode 500 / code INTERNAL, so "typed" does not
101
+ // mean "safe to show": a table name, a host, an SDK message all arrive this
102
+ // way. Only a 4xx AppError is a message the caller was meant to read.
103
+ it('hides a typed error that carries the default status behind the same constant 500', async () => {
104
+ const handler = makeCreateNoteHandler(
105
+ stubDeps({
106
+ notes: {
107
+ put: () => Promise.reject(new AppError('table NotesTable-prod is not authorised')),
108
+ },
109
+ }),
110
+ );
111
+ const result = asResult(await handler(event(JSON.stringify({ title: 'T' }))));
112
+ expect(result.statusCode).toBe(500);
113
+ expect(result.body).not.toContain('NotesTable-prod');
114
+ expect(JSON.parse(result.body!)).toEqual({ error: 'internal error' });
115
+ });
116
+
117
+ // Withholding the message is not the same decision as flattening the status.
118
+ // 503 tells a caller to retry and 500 tells it not to bother, and that
119
+ // distinction is transport, not disclosure — the sibling handler pins it
120
+ // (`list-notes.test.ts`), and this one did not, so collapsing the status here
121
+ // passed the whole suite.
122
+ it('keeps a 5xx status the caller can act on, while still hiding the message', async () => {
123
+ const handler = makeCreateNoteHandler(
124
+ stubDeps({
125
+ notes: {
126
+ put: () =>
127
+ Promise.reject(
128
+ new AppError('table NotesTable-prod is throttling', {
129
+ statusCode: 503,
130
+ code: 'X',
131
+ }),
132
+ ),
133
+ },
134
+ }),
135
+ );
136
+ const result = asResult(await handler(event(JSON.stringify({ title: 'T' }))));
137
+ expect(result.statusCode).toBe(503);
138
+ expect(result.body).not.toContain('NotesTable-prod');
139
+ expect(JSON.parse(result.body!)).toEqual({ error: 'internal error' });
140
+ });
141
+
142
+ it('logs the typed internal error it hid, so the detail is kept and not lost', async () => {
143
+ const logs: string[] = [];
144
+ const handler = makeCreateNoteHandler(
145
+ stubDeps({
146
+ notes: {
147
+ put: () => Promise.reject(new AppError('table NotesTable-prod is not authorised')),
148
+ },
149
+ log: createLogger({}, (line) => logs.push(line)),
150
+ }),
151
+ );
152
+ await handler(event(JSON.stringify({ title: 'T' })));
153
+ expect(logs.join('\n')).toContain('NotesTable-prod');
154
+ });
155
+
156
+ it('logs the failure with its stack, not only its message', async () => {
157
+ const logs: string[] = [];
158
+ const boom = new Error('secret detail');
159
+ const handler = makeCreateNoteHandler(
160
+ stubDeps({
161
+ notes: { put: () => Promise.reject(boom) },
162
+ log: createLogger({}, (line) => logs.push(line)),
163
+ }),
164
+ );
165
+ await handler(event(JSON.stringify({ title: 'T' })));
166
+ // `String(error)` is "Error: secret detail" — a message with no frames is
167
+ // not something anyone can debug from CloudWatch.
168
+ expect(JSON.parse(logs[0]!)).toMatchObject({ stack: boom.stack });
169
+ });
170
+
171
+ // The response withholds everything about a 5xx AppError, so the log line is
172
+ // the only place left that can say WHICH internal failure it was — and `code`
173
+ // is the one field of that error which is safe to keep verbatim. Losing it
174
+ // flattens DATA_CORRUPT, INTERNAL and every future code into one shape nobody
175
+ // can triage. The body assertion lives here on purpose: the two halves of the
176
+ // contract ("kept in the log", "still absent from the response") must not
177
+ // drift apart.
178
+ it('logs the code of the internal error it withheld, and still keeps it out of the response', async () => {
179
+ const logs: string[] = [];
180
+ const handler = makeCreateNoteHandler(
181
+ stubDeps({
182
+ notes: {
183
+ put: () =>
184
+ Promise.reject(
185
+ new AppError('table NotesTable-prod returned a torn item', {
186
+ code: 'DATA_CORRUPT',
187
+ }),
188
+ ),
189
+ },
190
+ log: createLogger({}, (line) => logs.push(line)),
191
+ }),
192
+ );
193
+ const result = asResult(await handler(event(JSON.stringify({ title: 'T' }))));
194
+ expect(JSON.parse(logs[0]!)).toMatchObject({ code: 'DATA_CORRUPT' });
195
+ expect(result.body).not.toContain('DATA_CORRUPT');
196
+ expect(JSON.parse(result.body!)).toEqual({ error: 'internal error' });
197
+ });
198
+
199
+ it('carries the Lambda request id into the line it logs', async () => {
200
+ const logs: string[] = [];
201
+ const handler = makeCreateNoteHandler(
202
+ stubDeps({
203
+ notes: { put: () => Promise.reject(new Error('secret detail')) },
204
+ log: createLogger({}, (line) => logs.push(line)),
205
+ }),
206
+ );
207
+ // Without it a log line cannot be tied to the invocation that produced it.
208
+ await handler(event(JSON.stringify({ title: 'T' })), lambdaContext('req-42'));
209
+ expect(JSON.parse(logs[0]!)).toMatchObject({ awsRequestId: 'req-42' });
210
+ });
92
211
  });
@@ -1,4 +1,8 @@
1
- import type { APIGatewayProxyStructuredResultV2 } from 'aws-lambda';
1
+ import type {
2
+ APIGatewayProxyEventV2,
3
+ APIGatewayProxyStructuredResultV2,
4
+ Context,
5
+ } from 'aws-lambda';
2
6
  import { AppError, createLogger } from '@app/shared';
3
7
  import { describe, expect, it } from 'vitest';
4
8
  import { makeListNotesHandler } from '../src/handlers/list-notes.js';
@@ -14,6 +18,10 @@ const note = {
14
18
 
15
19
  const asResult = (r: unknown) => r as APIGatewayProxyStructuredResultV2;
16
20
 
21
+ /** Lambda always passes the event and the context; this route needs no body. */
22
+ const getEvent = () => ({ isBase64Encoded: false }) as APIGatewayProxyEventV2;
23
+ const lambdaContext = (awsRequestId: string): Context => ({ awsRequestId }) as Context;
24
+
17
25
  describe('listNotesUsecase', () => {
18
26
  it('returns what the model lists', async () => {
19
27
  expect(await listNotesUsecase({ notes: { list: async () => [note] } })).toEqual([note]);
@@ -49,3 +57,66 @@ describe('GET /notes handler', () => {
49
57
  expect(logs.join('')).toContain('secret');
50
58
  });
51
59
  });
60
+
61
+ // The same leak create-note was fixed for, and this handler had it too:
62
+ // `AppError` defaults to 500/INTERNAL, so a table name, a host or an SDK
63
+ // message arrives wearing the same type as "title is required", and every one
64
+ // of them used to reach the caller. Typed does not mean safe to show — the
65
+ // status decides, and these pin that both handlers decide it the same way.
66
+ describe('GET /notes keeps its internals to itself', () => {
67
+ const failWith = (error: unknown, sink: (line: string) => void = () => {}) =>
68
+ makeListNotesHandler({
69
+ notes: { list: () => Promise.reject(error) },
70
+ log: createLogger({}, sink),
71
+ });
72
+
73
+ it('hides a typed error that carries the default status behind a constant 500', async () => {
74
+ const handler = failWith(new AppError('table NotesTable-prod is not authorised'));
75
+ const result = asResult(await handler(getEvent(), lambdaContext('req-1')));
76
+ expect(result.statusCode).toBe(500);
77
+ expect(result.body).not.toContain('NotesTable-prod');
78
+ expect(JSON.parse(result.body!)).toEqual({ error: 'internal error' });
79
+ });
80
+
81
+ it('still forwards a 4xx message, which was written for the caller to read', async () => {
82
+ const handler = failWith(new AppError('unknown tag filter', { statusCode: 400, code: 'BAD' }));
83
+ const result = asResult(await handler(getEvent(), lambdaContext('req-2')));
84
+ expect(result.statusCode).toBe(400);
85
+ expect(JSON.parse(result.body!)).toEqual({ error: 'unknown tag filter', code: 'BAD' });
86
+ });
87
+
88
+ it('logs the internal error it hid with its stack, so the detail is kept and not lost', async () => {
89
+ const logs: string[] = [];
90
+ const boom = new AppError('table NotesTable-prod is not authorised');
91
+ await failWith(boom, (line) => logs.push(line))(getEvent(), lambdaContext('req-3'));
92
+ // `String(error)` is a message with no frames — not something anyone can
93
+ // debug from a log aggregator.
94
+ expect(JSON.parse(logs[0]!)).toMatchObject({ stack: boom.stack });
95
+ });
96
+
97
+ // Same contract as create-note: the response withholds everything about a 5xx
98
+ // AppError, so the log line is the only place that can still say WHICH
99
+ // internal failure it was, and `code` is the one field safe to keep verbatim.
100
+ // The body assertion stays beside it so the two halves cannot drift apart.
101
+ it('logs the code of the internal error it withheld, and still keeps it out of the response', async () => {
102
+ const logs: string[] = [];
103
+ const handler = failWith(
104
+ new AppError('table NotesTable-prod returned a torn item', { code: 'DATA_CORRUPT' }),
105
+ (line) => logs.push(line),
106
+ );
107
+ const result = asResult(await handler(getEvent(), lambdaContext('req-4')));
108
+ expect(JSON.parse(logs[0]!)).toMatchObject({ code: 'DATA_CORRUPT' });
109
+ expect(result.body).not.toContain('DATA_CORRUPT');
110
+ expect(JSON.parse(result.body!)).toEqual({ error: 'internal error' });
111
+ });
112
+
113
+ it('carries the Lambda request id into the line it logs', async () => {
114
+ const logs: string[] = [];
115
+ const handler = failWith(new AppError('table NotesTable-prod is not authorised'), (line) =>
116
+ logs.push(line),
117
+ );
118
+ // Without it a log line cannot be tied to the invocation that produced it.
119
+ await handler(getEvent(), lambdaContext('req-42'));
120
+ expect(JSON.parse(logs[0]!)).toMatchObject({ awsRequestId: 'req-42' });
121
+ });
122
+ });