create-agent-rig 0.9.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +419 -3
- package/README.md +267 -305
- package/package.json +8 -14
- package/packages/cli/dist/commands/create.js +56 -88
- package/packages/cli/dist/commands/doctor.js +213 -0
- package/packages/cli/dist/commands/init.js +111 -50
- package/packages/cli/dist/commands/integrations.js +468 -0
- package/packages/cli/dist/commands/setup-wizard.js +61 -0
- package/packages/cli/dist/commands/uninstall.js +1223 -0
- package/packages/cli/dist/commands/upgrade.js +472 -90
- package/packages/cli/dist/index.js +651 -46
- package/packages/cli/dist/integrations/declaration.js +158 -0
- package/packages/cli/dist/integrations/doctor-guards.js +126 -0
- package/packages/cli/dist/integrations/doctor-workflow.js +25 -0
- package/packages/cli/dist/integrations/mcp-json.js +107 -0
- package/packages/cli/dist/integrations/memory-doctor.js +124 -0
- package/packages/cli/dist/integrations/registry.js +22 -0
- package/packages/cli/dist/integrations/spawn.js +228 -0
- package/packages/cli/dist/integrations/spec-kit.js +280 -0
- package/packages/cli/dist/integrations/verify.js +193 -0
- package/packages/cli/dist/integrations/windows-job.js +275 -0
- package/packages/cli/dist/lib/elevated-paths.js +74 -0
- package/packages/cli/dist/lib/install-set.js +1 -44
- package/packages/cli/dist/lib/manifest.js +64 -5
- package/packages/cli/dist/lib/prompts.js +0 -23
- package/packages/cli/dist/lib/safe-path.js +193 -2
- package/packages/cli/dist/lib/safe-text.js +78 -0
- package/packages/cli/dist/lib/substitute.js +1 -52
- package/packages/cli/dist/lib/summary.js +5 -5
- package/packages/cli/dist/templates.js +4 -14
- package/scripts/prepare.mjs +28 -12
- package/templates/agent-os/subagent-routing.json +4 -4
- package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +13 -9
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +31 -6
- package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +5 -5
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +1 -1
- package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +3 -3
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +8 -6
- package/templates/agent-os/universal/.claude/agents/implementation-agent.md +41 -0
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +8 -4
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +4 -2
- package/templates/agent-os/universal/.claude/agents/test-writer.md +2 -2
- package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +83 -4
- package/templates/agent-os/universal/.claude/rules/autonomy.md +55 -27
- package/templates/agent-os/universal/.claude/rules/invariants.md +9 -8
- package/templates/agent-os/universal/.claude/rules/workflow.md +47 -22
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1 -1
- package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +12 -9
- package/templates/agent-os/universal/.claude/scripts/doctor.mjs +100 -2
- package/templates/agent-os/universal/.claude/scripts/lib/claim-records.mjs +236 -6
- package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +2 -2
- package/templates/agent-os/universal/.claude/scripts/reconcile-external-prs.mjs +4 -3
- package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +16 -0
- package/templates/agent-os/universal/.claude/settings.json +0 -8
- package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +13 -9
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +31 -6
- package/templates/agent-os/universal/.claude/skills/new-invariant/SKILL.md +5 -5
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +1 -1
- package/templates/agent-os/universal/.claude/skills/worktree-task/SKILL.md +3 -3
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +2 -2
- package/templates/agent-os/universal/.codex/agents/implementation-agent.toml +6 -0
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +2 -2
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +1 -1
- package/templates/agent-os/universal/.codex/agents/test-writer.toml +1 -1
- package/templates/agent-os/universal/.codex/hooks.json +0 -10
- package/templates/agent-os/universal/AGENTS.md +208 -107
- package/templates/agent-os/universal/CLAUDE.md +15 -162
- package/templates/agent-os/universal/PLAN.md +17 -10
- package/templates/agent-os/universal/docs/decisions/agents-md-canonical.md +313 -0
- package/templates/agent-os/universal/docs/decisions/codex-adapter.md +15 -10
- package/templates/agent-os/universal/docs/decisions/review-lanes.md +12 -8
- package/templates/agent-os/universal/docs/decisions/session-start-wire-format.md +206 -0
- package/templates/agent-os/universal/docs/decisions/subagent-routing.md +6 -3
- package/templates/agent-os/universal/docs/decisions/workflow-layer-split.md +235 -0
- package/templates/agent-os/universal/layers.json +25 -30
- package/templates/hash-history.json +8 -4
- package/templates/release-ledger.json +2 -1
- package/packages/cli/dist/lib/composition.js +0 -20
- package/packages/cli/dist/lib/targets.js +0 -28
- package/packages/cli/dist/policy/benchmark/corpus.js +0 -165
- package/packages/cli/dist/policy/core/adapter.js +0 -18
- package/packages/cli/dist/policy/core/coverage.js +0 -253
- package/packages/cli/dist/policy/core/decision-record.js +0 -287
- package/packages/cli/dist/policy/core/declaration.js +0 -127
- package/packages/cli/dist/policy/core/evidence-matrix.js +0 -94
- package/packages/cli/dist/policy/core/probe.js +0 -442
- package/packages/cli/dist/policy/core/registry.js +0 -115
- package/packages/cli/dist/policy/core/validation.js +0 -275
- package/packages/cli/dist/policy/core/vocabulary.js +0 -123
- package/packages/cli/dist/policy/harness/claude.js +0 -47
- package/packages/cli/dist/policy/harness/codex.js +0 -87
- package/packages/cli/dist/policy/harness/index.js +0 -15
- package/packages/cli/dist/policy/harness/shared-hooks.js +0 -28
- package/packages/cli/dist/policy/index.js +0 -17
- package/templates/agent-os/init/AGENTS.md +0 -201
- package/templates/agent-os/init/CLAUDE.md +0 -201
- package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +0 -105
- package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +0 -117
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +0 -89
- package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +0 -105
- package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +0 -105
- package/templates/agent-os/stack/aws-cdk/.claude/skills/ro-debug/SKILL.md +0 -117
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +0 -6
- package/templates/agent-os/stack/node-ts/.claude/hooks/dod-checks.json +0 -1
- package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +0 -85
- package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +0 -89
- package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +0 -68
- package/templates/agent-os/universal/.claude/rules/architecture.md +0 -81
- package/templates/skeleton/aws-serverless/.github/workflows/ci.yml +0 -28
- package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +0 -90
- package/templates/skeleton/aws-serverless/README.md +0 -181
- package/templates/skeleton/aws-serverless/apps/web/next.config.mjs +0 -17
- package/templates/skeleton/aws-serverless/apps/web/package.json +0 -19
- package/templates/skeleton/aws-serverless/apps/web/src/app/layout.tsx +0 -17
- package/templates/skeleton/aws-serverless/apps/web/src/app/page.tsx +0 -96
- package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +0 -36
- package/templates/skeleton/aws-serverless/apps/web/src/lib/validate.ts +0 -23
- package/templates/skeleton/aws-serverless/apps/web/test/shared-validation.test.ts +0 -38
- package/templates/skeleton/aws-serverless/apps/web/tsconfig.json +0 -14
- package/templates/skeleton/aws-serverless/eslint.config.mjs +0 -20
- package/templates/skeleton/aws-serverless/gitignore +0 -56
- package/templates/skeleton/aws-serverless/infra/bin/app.ts +0 -79
- package/templates/skeleton/aws-serverless/infra/cdk.json +0 -3
- package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +0 -248
- package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +0 -45
- package/templates/skeleton/aws-serverless/infra/package.json +0 -18
- package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +0 -301
- package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +0 -137
- package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +0 -120
- package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +0 -44
- package/templates/skeleton/aws-serverless/package.json +0 -30
- package/templates/skeleton/aws-serverless/packages/core/package.json +0 -11
- package/templates/skeleton/aws-serverless/packages/core/src/events.ts +0 -14
- package/templates/skeleton/aws-serverless/packages/core/src/index.ts +0 -15
- package/templates/skeleton/aws-serverless/packages/core/src/note.ts +0 -69
- package/templates/skeleton/aws-serverless/packages/core/test/events.test.ts +0 -23
- package/templates/skeleton/aws-serverless/packages/core/test/note.test.ts +0 -101
- package/templates/skeleton/aws-serverless/packages/db/package.json +0 -14
- package/templates/skeleton/aws-serverless/packages/db/src/client.ts +0 -17
- package/templates/skeleton/aws-serverless/packages/db/src/index.ts +0 -2
- package/templates/skeleton/aws-serverless/packages/db/src/note-model.ts +0 -52
- package/templates/skeleton/aws-serverless/packages/db/test/note-model.test.ts +0 -91
- package/templates/skeleton/aws-serverless/packages/shared/package.json +0 -11
- package/templates/skeleton/aws-serverless/packages/shared/src/env.ts +0 -17
- package/templates/skeleton/aws-serverless/packages/shared/src/errors.ts +0 -33
- package/templates/skeleton/aws-serverless/packages/shared/src/index.ts +0 -3
- package/templates/skeleton/aws-serverless/packages/shared/src/logger.ts +0 -20
- package/templates/skeleton/aws-serverless/packages/shared/test/env.test.ts +0 -26
- package/templates/skeleton/aws-serverless/packages/shared/test/errors.test.ts +0 -28
- package/templates/skeleton/aws-serverless/packages/shared/test/logger.test.ts +0 -19
- package/templates/skeleton/aws-serverless/pnpm-lock.yaml +0 -2855
- package/templates/skeleton/aws-serverless/pnpm-workspace.yaml +0 -14
- package/templates/skeleton/aws-serverless/services/api/package.json +0 -15
- package/templates/skeleton/aws-serverless/services/api/src/adapters/sqs-publisher.ts +0 -26
- package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +0 -69
- package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +0 -37
- package/templates/skeleton/aws-serverless/services/api/src/list-main.ts +0 -12
- package/templates/skeleton/aws-serverless/services/api/src/main.ts +0 -21
- package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +0 -41
- package/templates/skeleton/aws-serverless/services/api/src/usecases/list-notes.ts +0 -14
- package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +0 -211
- package/templates/skeleton/aws-serverless/services/api/test/create-note.usecase.test.ts +0 -45
- package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +0 -122
- package/templates/skeleton/aws-serverless/services/api/test/sqs-publisher.test.ts +0 -22
- package/templates/skeleton/aws-serverless/services/worker/package.json +0 -12
- package/templates/skeleton/aws-serverless/services/worker/src/handlers/note-created.ts +0 -15
- package/templates/skeleton/aws-serverless/services/worker/src/main.ts +0 -7
- package/templates/skeleton/aws-serverless/services/worker/src/usecases/process-note-created.ts +0 -37
- package/templates/skeleton/aws-serverless/services/worker/test/note-created.test.ts +0 -61
- package/templates/skeleton/aws-serverless/tsconfig.base.json +0 -15
- package/templates/skeleton/aws-serverless/tsconfig.json +0 -16
- package/templates/skeleton/aws-serverless/vitest.config.ts +0 -14
- package/templates/skeleton/node-service/.github/workflows/ci.yml +0 -27
- package/templates/skeleton/node-service/.github/workflows/deploy.yml +0 -29
- package/templates/skeleton/node-service/README.md +0 -104
- package/templates/skeleton/node-service/apps/web/next.config.mjs +0 -17
- package/templates/skeleton/node-service/apps/web/package.json +0 -19
- package/templates/skeleton/node-service/apps/web/src/app/layout.tsx +0 -17
- package/templates/skeleton/node-service/apps/web/src/app/page.tsx +0 -96
- package/templates/skeleton/node-service/apps/web/src/lib/api.ts +0 -29
- package/templates/skeleton/node-service/apps/web/src/lib/validate.ts +0 -23
- package/templates/skeleton/node-service/apps/web/test/shared-validation.test.ts +0 -38
- package/templates/skeleton/node-service/apps/web/tsconfig.json +0 -14
- package/templates/skeleton/node-service/eslint.config.mjs +0 -20
- package/templates/skeleton/node-service/gitignore +0 -54
- package/templates/skeleton/node-service/package.json +0 -30
- package/templates/skeleton/node-service/packages/core/package.json +0 -11
- package/templates/skeleton/node-service/packages/core/src/events.ts +0 -14
- package/templates/skeleton/node-service/packages/core/src/index.ts +0 -15
- package/templates/skeleton/node-service/packages/core/src/note.ts +0 -69
- package/templates/skeleton/node-service/packages/core/test/events.test.ts +0 -23
- package/templates/skeleton/node-service/packages/core/test/note.test.ts +0 -101
- package/templates/skeleton/node-service/packages/db/package.json +0 -12
- package/templates/skeleton/node-service/packages/db/src/index.ts +0 -1
- package/templates/skeleton/node-service/packages/db/src/note-store.ts +0 -100
- package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +0 -100
- package/templates/skeleton/node-service/packages/shared/package.json +0 -11
- package/templates/skeleton/node-service/packages/shared/src/env.ts +0 -17
- package/templates/skeleton/node-service/packages/shared/src/errors.ts +0 -33
- package/templates/skeleton/node-service/packages/shared/src/index.ts +0 -3
- package/templates/skeleton/node-service/packages/shared/src/logger.ts +0 -20
- package/templates/skeleton/node-service/packages/shared/test/env.test.ts +0 -26
- package/templates/skeleton/node-service/packages/shared/test/errors.test.ts +0 -28
- package/templates/skeleton/node-service/packages/shared/test/logger.test.ts +0 -19
- package/templates/skeleton/node-service/pnpm-lock.yaml +0 -2402
- package/templates/skeleton/node-service/pnpm-workspace.yaml +0 -13
- package/templates/skeleton/node-service/scripts/build-artifact.mjs +0 -34
- package/templates/skeleton/node-service/services/api/package.json +0 -17
- package/templates/skeleton/node-service/services/api/src/adapters/spool-publisher.ts +0 -23
- package/templates/skeleton/node-service/services/api/src/handlers/create-note.ts +0 -40
- package/templates/skeleton/node-service/services/api/src/handlers/list-notes.ts +0 -23
- package/templates/skeleton/node-service/services/api/src/main.ts +0 -40
- package/templates/skeleton/node-service/services/api/src/server.ts +0 -172
- package/templates/skeleton/node-service/services/api/src/static-dir.ts +0 -20
- package/templates/skeleton/node-service/services/api/src/usecases/create-note.ts +0 -30
- package/templates/skeleton/node-service/services/api/src/usecases/list-notes.ts +0 -14
- package/templates/skeleton/node-service/services/api/test/artifact.test.ts +0 -92
- package/templates/skeleton/node-service/services/api/test/create-note.handler.test.ts +0 -64
- package/templates/skeleton/node-service/services/api/test/create-note.usecase.test.ts +0 -43
- package/templates/skeleton/node-service/services/api/test/list-notes.test.ts +0 -48
- package/templates/skeleton/node-service/services/api/test/package-manager.test.ts +0 -40
- package/templates/skeleton/node-service/services/api/test/package-manager.ts +0 -51
- package/templates/skeleton/node-service/services/api/test/server.test.ts +0 -208
- package/templates/skeleton/node-service/services/api/test/spool-publisher.test.ts +0 -32
- package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +0 -29
- package/templates/skeleton/node-service/services/worker/package.json +0 -16
- package/templates/skeleton/node-service/services/worker/src/main.ts +0 -28
- package/templates/skeleton/node-service/services/worker/src/spool.ts +0 -60
- package/templates/skeleton/node-service/services/worker/src/usecases/process-note-created.ts +0 -38
- package/templates/skeleton/node-service/services/worker/test/process-note-created.test.ts +0 -34
- package/templates/skeleton/node-service/services/worker/test/spool.test.ts +0 -76
- package/templates/skeleton/node-service/tsconfig.base.json +0 -15
- package/templates/skeleton/node-service/tsconfig.json +0 -13
- package/templates/skeleton/node-service/vitest.config.ts +0 -12
|
@@ -1,301 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,137 +0,0 @@
|
|
|
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,120 +0,0 @@
|
|
|
1
|
-
import { App } from 'aws-cdk-lib';
|
|
2
|
-
import { Match, Template } from 'aws-cdk-lib/assertions';
|
|
3
|
-
import { describe, expect, it } from 'vitest';
|
|
4
|
-
import { AppStack } from '../lib/app-stack.js';
|
|
5
|
-
|
|
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')));
|
|
15
|
-
|
|
16
|
-
describe('storage', () => {
|
|
17
|
-
it('creates the single table with pk/sk and on-demand billing', () => {
|
|
18
|
-
template().hasResourceProperties('AWS::DynamoDB::Table', {
|
|
19
|
-
KeySchema: [
|
|
20
|
-
{ AttributeName: 'pk', KeyType: 'HASH' },
|
|
21
|
-
{ AttributeName: 'sk', KeyType: 'RANGE' },
|
|
22
|
-
],
|
|
23
|
-
BillingMode: 'PAY_PER_REQUEST',
|
|
24
|
-
});
|
|
25
|
-
template().resourceCountIs('AWS::DynamoDB::Table', 1);
|
|
26
|
-
});
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
describe('queue discipline', () => {
|
|
30
|
-
it('wires the DLQ with maxReceiveCount 3', () => {
|
|
31
|
-
template().hasResourceProperties('AWS::SQS::Queue', {
|
|
32
|
-
RedrivePolicy: Match.objectLike({ maxReceiveCount: 3 }),
|
|
33
|
-
});
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it('alarms as soon as one message reaches the DLQ', () => {
|
|
37
|
-
template().hasResourceProperties('AWS::CloudWatch::Alarm', {
|
|
38
|
-
MetricName: 'ApproximateNumberOfMessagesVisible',
|
|
39
|
-
Threshold: 1,
|
|
40
|
-
EvaluationPeriods: 1,
|
|
41
|
-
ComparisonOperator: 'GreaterThanOrEqualToThreshold',
|
|
42
|
-
});
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it('feeds the worker one message at a time', () => {
|
|
46
|
-
template().hasResourceProperties('AWS::Lambda::EventSourceMapping', {
|
|
47
|
-
BatchSize: 1,
|
|
48
|
-
});
|
|
49
|
-
});
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
describe('functions and routes', () => {
|
|
53
|
-
it('deploys exactly the create, list and worker functions — one purpose each', () => {
|
|
54
|
-
template().resourceCountIs('AWS::Lambda::Function', 3);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('passes table and queue to the api via environment', () => {
|
|
58
|
-
template().hasResourceProperties('AWS::Lambda::Function', {
|
|
59
|
-
Environment: {
|
|
60
|
-
Variables: Match.objectLike({
|
|
61
|
-
TABLE_NAME: Match.anyValue(),
|
|
62
|
-
QUEUE_URL: Match.anyValue(),
|
|
63
|
-
}),
|
|
64
|
-
},
|
|
65
|
-
});
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('exposes exactly two routes: POST /notes and GET /notes', () => {
|
|
69
|
-
template().hasResourceProperties('AWS::ApiGatewayV2::Route', {
|
|
70
|
-
RouteKey: 'POST /notes',
|
|
71
|
-
});
|
|
72
|
-
template().hasResourceProperties('AWS::ApiGatewayV2::Route', {
|
|
73
|
-
RouteKey: 'GET /notes',
|
|
74
|
-
});
|
|
75
|
-
template().resourceCountIs('AWS::ApiGatewayV2::Route', 2);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('allows the browser origin in: CORS is configured', () => {
|
|
79
|
-
template().hasResourceProperties('AWS::ApiGatewayV2::Api', {
|
|
80
|
-
CorsConfiguration: Match.objectLike({
|
|
81
|
-
AllowMethods: Match.arrayWith(['GET', 'POST']),
|
|
82
|
-
}),
|
|
83
|
-
});
|
|
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
|
-
});
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
describe('least-privilege IAM', () => {
|
|
102
|
-
it('grants writes to the creator, reads to the lister, queue send — nothing broad', () => {
|
|
103
|
-
const policies = template().findResources('AWS::IAM::Policy');
|
|
104
|
-
const statements = Object.values(policies).flatMap(
|
|
105
|
-
(policy) =>
|
|
106
|
-
(policy.Properties as { PolicyDocument: { Statement: Array<Record<string, unknown>> } })
|
|
107
|
-
.PolicyDocument.Statement,
|
|
108
|
-
);
|
|
109
|
-
const actions = statements.flatMap((s) =>
|
|
110
|
-
Array.isArray(s.Action) ? (s.Action as string[]) : [s.Action as string],
|
|
111
|
-
);
|
|
112
|
-
|
|
113
|
-
expect(actions).toContain('dynamodb:PutItem');
|
|
114
|
-
expect(actions).toContain('dynamodb:Scan'); // the list function reads
|
|
115
|
-
expect(actions).toContain('sqs:SendMessage');
|
|
116
|
-
// Least privilege: nothing broad.
|
|
117
|
-
expect(actions).not.toContain('dynamodb:*');
|
|
118
|
-
expect(actions.some((a) => a === '*')).toBe(false);
|
|
119
|
-
});
|
|
120
|
-
});
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { App } from 'aws-cdk-lib';
|
|
2
|
-
import { Match, Template } from 'aws-cdk-lib/assertions';
|
|
3
|
-
import { describe, it } from 'vitest';
|
|
4
|
-
import { WebStack } from '../lib/web-stack.js';
|
|
5
|
-
|
|
6
|
-
// The web stack is stateless serving only: a private bucket behind CloudFront.
|
|
7
|
-
// The bundle itself is synced by the deploy step (see README) — synth stays
|
|
8
|
-
// independent of `next build`.
|
|
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')));
|
|
17
|
-
|
|
18
|
-
describe('web serving stack', () => {
|
|
19
|
-
it('keeps the bucket fully private', () => {
|
|
20
|
-
template().hasResourceProperties('AWS::S3::Bucket', {
|
|
21
|
-
PublicAccessBlockConfiguration: Match.objectLike({
|
|
22
|
-
BlockPublicAcls: true,
|
|
23
|
-
BlockPublicPolicy: true,
|
|
24
|
-
IgnorePublicAcls: true,
|
|
25
|
-
RestrictPublicBuckets: true,
|
|
26
|
-
}),
|
|
27
|
-
});
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it('serves through CloudFront with origin access control', () => {
|
|
31
|
-
template().resourceCountIs('AWS::CloudFront::Distribution', 1);
|
|
32
|
-
template().resourceCountIs('AWS::CloudFront::OriginAccessControl', 1);
|
|
33
|
-
template().hasResourceProperties('AWS::CloudFront::Distribution', {
|
|
34
|
-
DistributionConfig: Match.objectLike({
|
|
35
|
-
DefaultRootObject: 'index.html',
|
|
36
|
-
}),
|
|
37
|
-
});
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('exports the distribution domain and bucket name for the deploy step', () => {
|
|
41
|
-
template().hasOutput('WebBucketName', {});
|
|
42
|
-
template().hasOutput('WebUrl', {});
|
|
43
|
-
});
|
|
44
|
-
});
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@app/root",
|
|
3
|
-
"version": "0.1.0",
|
|
4
|
-
"private": true,
|
|
5
|
-
"type": "module",
|
|
6
|
-
"engines": {
|
|
7
|
-
"node": ">=20"
|
|
8
|
-
},
|
|
9
|
-
"scripts": {
|
|
10
|
-
"test": "vitest run",
|
|
11
|
-
"lint": "eslint .",
|
|
12
|
-
"typecheck": "tsc -p tsconfig.json && tsc -p apps/web/tsconfig.json",
|
|
13
|
-
"build:web": "pnpm --filter @app/web build",
|
|
14
|
-
"synth": "pnpm --filter @app/infra synth",
|
|
15
|
-
"deploy": "pnpm --filter @app/infra deploy",
|
|
16
|
-
"check": "pnpm lint && pnpm typecheck && pnpm test && pnpm build:web && pnpm synth"
|
|
17
|
-
},
|
|
18
|
-
"devDependencies": {
|
|
19
|
-
"@eslint/js": "^10.0.1",
|
|
20
|
-
"@types/aws-lambda": "^8.10.162",
|
|
21
|
-
"@types/node": "^26.1.1",
|
|
22
|
-
"esbuild": "^0.28.1",
|
|
23
|
-
"eslint": "^10.7.0",
|
|
24
|
-
"globals": "^17.7.0",
|
|
25
|
-
"tsx": "^4.23.1",
|
|
26
|
-
"typescript": "^6.0.3",
|
|
27
|
-
"typescript-eslint": "^8.65.0",
|
|
28
|
-
"vitest": "^4.1.10"
|
|
29
|
-
}
|
|
30
|
-
}
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
// Domain event vocabulary. The schema is the contract between the API (producer)
|
|
2
|
-
// and the worker (consumer) — both sides validate against it.
|
|
3
|
-
import { z } from 'zod';
|
|
4
|
-
import { NoteSchema, type Note } from './note.js';
|
|
5
|
-
|
|
6
|
-
export const NoteCreatedEventSchema = z.object({
|
|
7
|
-
type: z.literal('note.created'),
|
|
8
|
-
note: NoteSchema,
|
|
9
|
-
});
|
|
10
|
-
export type NoteCreatedEvent = z.infer<typeof NoteCreatedEventSchema>;
|
|
11
|
-
|
|
12
|
-
export function makeNoteCreatedEvent(note: Note): NoteCreatedEvent {
|
|
13
|
-
return { type: 'note.created', note };
|
|
14
|
-
}
|