create-agent-rig 0.9.1 → 0.10.1
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 +462 -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 +508 -101
- package/packages/cli/dist/index.js +658 -47
- 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 +45 -12
- 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/gate-stop-dod.mjs +4 -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 +59 -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 +45 -12
- 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 +112 -40
- package/templates/release-ledger.json +3 -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,14 +0,0 @@
|
|
|
1
|
-
packages:
|
|
2
|
-
- packages/*
|
|
3
|
-
- services/*
|
|
4
|
-
- apps/*
|
|
5
|
-
- infra
|
|
6
|
-
|
|
7
|
-
# esbuild's postinstall is intentionally NOT approved: skipping it keeps the JS
|
|
8
|
-
# launcher in place (which resolves the platform binary itself); approving it
|
|
9
|
-
# swaps in a raw binary that pnpm's node-based bin shim cannot execute.
|
|
10
|
-
# sharp (via next) only matters for the image optimizer, which a static
|
|
11
|
-
# export never runs — its native build stays off.
|
|
12
|
-
allowBuilds:
|
|
13
|
-
esbuild: false
|
|
14
|
-
sharp: false
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@app/api",
|
|
3
|
-
"version": "0.1.0",
|
|
4
|
-
"private": true,
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "src/main.ts",
|
|
7
|
-
"types": "src/main.ts",
|
|
8
|
-
"dependencies": {
|
|
9
|
-
"@app/core": "workspace:*",
|
|
10
|
-
"@app/db": "workspace:*",
|
|
11
|
-
"@app/shared": "workspace:*",
|
|
12
|
-
"@aws-sdk/client-sqs": "^3.1092.0",
|
|
13
|
-
"zod": "^4.4.3"
|
|
14
|
-
}
|
|
15
|
-
}
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
// The one place that touches the queue SDK on the sending side.
|
|
2
|
-
import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs';
|
|
3
|
-
import type { NoteCreatedEvent } from '@app/core';
|
|
4
|
-
import type { EventPublisher } from '../usecases/create-note.js';
|
|
5
|
-
|
|
6
|
-
/** Structural view of the SQS client — tests stub it. */
|
|
7
|
-
export interface QueueClient {
|
|
8
|
-
send(command: unknown): Promise<unknown>;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function createQueueClient(): QueueClient {
|
|
12
|
-
return new SQSClient({}) as QueueClient;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export class SqsEventPublisher implements EventPublisher {
|
|
16
|
-
constructor(
|
|
17
|
-
private readonly client: QueueClient,
|
|
18
|
-
private readonly queueUrl: string,
|
|
19
|
-
) {}
|
|
20
|
-
|
|
21
|
-
async publish(event: NoteCreatedEvent): Promise<void> {
|
|
22
|
-
await this.client.send(
|
|
23
|
-
new SendMessageCommand({ QueueUrl: this.queueUrl, MessageBody: JSON.stringify(event) }),
|
|
24
|
-
);
|
|
25
|
-
}
|
|
26
|
-
}
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
// The handler translates transport ⇄ domain and nothing else: decode the
|
|
2
|
-
// payload, call the usecase, map the result (or the typed error) to HTTP.
|
|
3
|
-
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2, Context } from 'aws-lambda';
|
|
4
|
-
import { InvalidNoteError } from '@app/core';
|
|
5
|
-
import { AppError } from '@app/shared';
|
|
6
|
-
import { createNoteUsecase, type CreateNoteDeps } from '../usecases/create-note.js';
|
|
7
|
-
|
|
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>;
|
|
17
|
-
|
|
18
|
-
const json = (statusCode: number, body: unknown): APIGatewayProxyResultV2 => ({
|
|
19
|
-
statusCode,
|
|
20
|
-
headers: { 'content-type': 'application/json' },
|
|
21
|
-
body: JSON.stringify(body),
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
export function makeCreateNoteHandler(deps: CreateNoteDeps): Handler {
|
|
25
|
-
return async (event, context) => {
|
|
26
|
-
let payload: unknown;
|
|
27
|
-
try {
|
|
28
|
-
const raw = event.isBase64Encoded
|
|
29
|
-
? Buffer.from(event.body ?? '', 'base64').toString('utf8')
|
|
30
|
-
: (event.body ?? '');
|
|
31
|
-
payload = JSON.parse(raw);
|
|
32
|
-
} catch {
|
|
33
|
-
return json(400, { error: 'body must be valid JSON' });
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
try {
|
|
37
|
-
const note = await createNoteUsecase(payload, deps);
|
|
38
|
-
return json(201, { note });
|
|
39
|
-
} catch (error) {
|
|
40
|
-
if (error instanceof InvalidNoteError) {
|
|
41
|
-
return json(400, { error: 'invalid note', issues: error.issues });
|
|
42
|
-
}
|
|
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) {
|
|
52
|
-
return json(error.statusCode, { error: error.message, code: error.code });
|
|
53
|
-
}
|
|
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' });
|
|
67
|
-
}
|
|
68
|
-
};
|
|
69
|
-
}
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2, Context } from 'aws-lambda';
|
|
2
|
-
import { AppError } from '@app/shared';
|
|
3
|
-
import type { Logger } from '@app/shared';
|
|
4
|
-
import { listNotesUsecase, type ListNotesDeps } from '../usecases/list-notes.js';
|
|
5
|
-
|
|
6
|
-
const json = (statusCode: number, body: unknown): APIGatewayProxyResultV2 => ({
|
|
7
|
-
statusCode,
|
|
8
|
-
headers: { 'content-type': 'application/json' },
|
|
9
|
-
body: JSON.stringify(body),
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
export function makeListNotesHandler(deps: ListNotesDeps & { log: Logger }) {
|
|
13
|
-
return async (
|
|
14
|
-
_event?: APIGatewayProxyEventV2,
|
|
15
|
-
context?: Pick<Context, 'awsRequestId'>,
|
|
16
|
-
): Promise<APIGatewayProxyResultV2> => {
|
|
17
|
-
try {
|
|
18
|
-
return json(200, { notes: await listNotesUsecase(deps) });
|
|
19
|
-
} catch (error) {
|
|
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) {
|
|
24
|
-
return json(error.statusCode, { error: error.message, code: error.code });
|
|
25
|
-
}
|
|
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' });
|
|
35
|
-
}
|
|
36
|
-
};
|
|
37
|
-
}
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
// Composition root for the list function — one function, one entry, one job.
|
|
2
|
-
import { z } from 'zod';
|
|
3
|
-
import { NoteModel, createDocumentClient } from '@app/db';
|
|
4
|
-
import { createLogger, loadEnv } from '@app/shared';
|
|
5
|
-
import { makeListNotesHandler } from './handlers/list-notes.js';
|
|
6
|
-
|
|
7
|
-
const env = loadEnv(z.object({ TABLE_NAME: z.string().min(1) }), process.env);
|
|
8
|
-
|
|
9
|
-
export const handler = makeListNotesHandler({
|
|
10
|
-
notes: new NoteModel(createDocumentClient(), env.TABLE_NAME),
|
|
11
|
-
log: createLogger({ service: 'api', handler: 'list-notes' }),
|
|
12
|
-
});
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
// Composition root — the ONLY file with real wiring. Tests target the factory
|
|
2
|
-
// (makeCreateNoteHandler) with stub deps and never import this module.
|
|
3
|
-
import { randomUUID } from 'node:crypto';
|
|
4
|
-
import { z } from 'zod';
|
|
5
|
-
import { NoteModel, createDocumentClient } from '@app/db';
|
|
6
|
-
import { createLogger, loadEnv } from '@app/shared';
|
|
7
|
-
import { SqsEventPublisher, createQueueClient } from './adapters/sqs-publisher.js';
|
|
8
|
-
import { makeCreateNoteHandler } from './handlers/create-note.js';
|
|
9
|
-
|
|
10
|
-
const env = loadEnv(
|
|
11
|
-
z.object({ TABLE_NAME: z.string().min(1), QUEUE_URL: z.string().min(1) }),
|
|
12
|
-
process.env,
|
|
13
|
-
);
|
|
14
|
-
|
|
15
|
-
export const handler = makeCreateNoteHandler({
|
|
16
|
-
notes: new NoteModel(createDocumentClient(), env.TABLE_NAME),
|
|
17
|
-
events: new SqsEventPublisher(createQueueClient(), env.QUEUE_URL),
|
|
18
|
-
newId: () => randomUUID(),
|
|
19
|
-
now: () => new Date().toISOString(),
|
|
20
|
-
log: createLogger({ service: 'api' }),
|
|
21
|
-
});
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
// The usecase layer is mandatory: every business operation lives here, and
|
|
2
|
-
// handlers call nothing below it. Dependencies are consumer-owned interfaces —
|
|
3
|
-
// the usecase says what it needs, adapters satisfy it.
|
|
4
|
-
import { createNote, makeNoteCreatedEvent, type Note, type NoteCreatedEvent } from '@app/core';
|
|
5
|
-
import type { Logger } from '@app/shared';
|
|
6
|
-
|
|
7
|
-
export interface NoteStore {
|
|
8
|
-
put(note: Note): Promise<void>;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export interface EventPublisher {
|
|
12
|
-
publish(event: NoteCreatedEvent): Promise<void>;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export interface CreateNoteDeps {
|
|
16
|
-
notes: NoteStore;
|
|
17
|
-
events: EventPublisher;
|
|
18
|
-
/** Id generation and the clock are decided here, never inside the core. */
|
|
19
|
-
newId(): string;
|
|
20
|
-
now(): string;
|
|
21
|
-
log: Logger;
|
|
22
|
-
}
|
|
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
|
-
*/
|
|
35
|
-
export async function createNoteUsecase(input: unknown, deps: CreateNoteDeps): Promise<Note> {
|
|
36
|
-
const note = createNote(input, { id: deps.newId(), createdAt: deps.now() });
|
|
37
|
-
await deps.notes.put(note);
|
|
38
|
-
await deps.events.publish(makeNoteCreatedEvent(note));
|
|
39
|
-
deps.log.info('note created', { noteId: note.id, slug: note.slug });
|
|
40
|
-
return note;
|
|
41
|
-
}
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
// Even a trivial read goes through a usecase — the uniformity is the rule.
|
|
2
|
-
import type { Note } from '@app/core';
|
|
3
|
-
|
|
4
|
-
export interface NoteLister {
|
|
5
|
-
list(): Promise<Note[]>;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export interface ListNotesDeps {
|
|
9
|
-
notes: NoteLister;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export async function listNotesUsecase(deps: ListNotesDeps): Promise<Note[]> {
|
|
13
|
-
return deps.notes.list();
|
|
14
|
-
}
|
|
@@ -1,211 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
APIGatewayProxyEventV2,
|
|
3
|
-
APIGatewayProxyStructuredResultV2,
|
|
4
|
-
Context,
|
|
5
|
-
} from 'aws-lambda';
|
|
6
|
-
import { AppError, createLogger } from '@app/shared';
|
|
7
|
-
import { describe, expect, it } from 'vitest';
|
|
8
|
-
import { makeCreateNoteHandler } from '../src/handlers/create-note.js';
|
|
9
|
-
import type { CreateNoteDeps } from '../src/usecases/create-note.js';
|
|
10
|
-
|
|
11
|
-
const event = (body: string): APIGatewayProxyEventV2 =>
|
|
12
|
-
({ body, isBase64Encoded: false }) as APIGatewayProxyEventV2;
|
|
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
|
-
|
|
17
|
-
function stubDeps(overrides: Partial<CreateNoteDeps> = {}) {
|
|
18
|
-
const deps: CreateNoteDeps = {
|
|
19
|
-
notes: { put: async () => {} },
|
|
20
|
-
events: { publish: async () => {} },
|
|
21
|
-
newId: () => 'fixed-id',
|
|
22
|
-
now: () => '2024-01-01T00:00:00.000Z',
|
|
23
|
-
log: createLogger({}, () => {}),
|
|
24
|
-
...overrides,
|
|
25
|
-
};
|
|
26
|
-
return deps;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const asResult = (r: unknown) => r as APIGatewayProxyStructuredResultV2;
|
|
30
|
-
|
|
31
|
-
describe('POST /notes handler', () => {
|
|
32
|
-
it('returns 201 with the created note', async () => {
|
|
33
|
-
const handler = makeCreateNoteHandler(stubDeps());
|
|
34
|
-
const result = asResult(await handler(event(JSON.stringify({ title: 'Hello' }))));
|
|
35
|
-
expect(result.statusCode).toBe(201);
|
|
36
|
-
expect(JSON.parse(result.body!)).toMatchObject({
|
|
37
|
-
note: { id: 'fixed-id', slug: 'hello', createdAt: '2024-01-01T00:00:00.000Z' },
|
|
38
|
-
});
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it('decodes base64-encoded bodies', async () => {
|
|
42
|
-
const handler = makeCreateNoteHandler(stubDeps());
|
|
43
|
-
const raw = Buffer.from(JSON.stringify({ title: 'Encoded' })).toString('base64');
|
|
44
|
-
const result = asResult(
|
|
45
|
-
await handler({ body: raw, isBase64Encoded: true } as APIGatewayProxyEventV2),
|
|
46
|
-
);
|
|
47
|
-
expect(result.statusCode).toBe(201);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it('returns 400 for malformed JSON without touching the store', async () => {
|
|
51
|
-
let touched = false;
|
|
52
|
-
const handler = makeCreateNoteHandler(
|
|
53
|
-
stubDeps({
|
|
54
|
-
notes: {
|
|
55
|
-
put: async () => {
|
|
56
|
-
touched = true;
|
|
57
|
-
},
|
|
58
|
-
},
|
|
59
|
-
}),
|
|
60
|
-
);
|
|
61
|
-
const result = asResult(await handler(event('{not json')));
|
|
62
|
-
expect(result.statusCode).toBe(400);
|
|
63
|
-
expect(touched).toBe(false);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('returns 400 with issues for invalid input', async () => {
|
|
67
|
-
const handler = makeCreateNoteHandler(stubDeps());
|
|
68
|
-
const result = asResult(await handler(event(JSON.stringify({ title: '' }))));
|
|
69
|
-
expect(result.statusCode).toBe(400);
|
|
70
|
-
expect(JSON.parse(result.body!).issues.join(' ')).toMatch(/title/);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it('maps typed errors to their status code', async () => {
|
|
74
|
-
const handler = makeCreateNoteHandler(
|
|
75
|
-
stubDeps({
|
|
76
|
-
notes: {
|
|
77
|
-
put: () => Promise.reject(new AppError('conflict', { statusCode: 409, code: 'DUP' })),
|
|
78
|
-
},
|
|
79
|
-
}),
|
|
80
|
-
);
|
|
81
|
-
const result = asResult(await handler(event(JSON.stringify({ title: 'T' }))));
|
|
82
|
-
expect(result.statusCode).toBe(409);
|
|
83
|
-
expect(JSON.parse(result.body!)).toEqual({ error: 'conflict', code: 'DUP' });
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('hides unknown errors behind a 500 and logs them', async () => {
|
|
87
|
-
const logs: string[] = [];
|
|
88
|
-
const handler = makeCreateNoteHandler(
|
|
89
|
-
stubDeps({
|
|
90
|
-
notes: { put: () => Promise.reject(new Error('secret detail')) },
|
|
91
|
-
log: createLogger({}, (line) => logs.push(line)),
|
|
92
|
-
}),
|
|
93
|
-
);
|
|
94
|
-
const result = asResult(await handler(event(JSON.stringify({ title: 'T' }))));
|
|
95
|
-
expect(result.statusCode).toBe(500);
|
|
96
|
-
expect(result.body).not.toContain('secret detail');
|
|
97
|
-
expect(logs.join('\n')).toContain('secret detail');
|
|
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
|
-
});
|
|
211
|
-
});
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import { InvalidNoteError, type Note, type NoteCreatedEvent } from '@app/core';
|
|
2
|
-
import { createLogger } from '@app/shared';
|
|
3
|
-
import { describe, expect, it } from 'vitest';
|
|
4
|
-
import { createNoteUsecase, type CreateNoteDeps } from '../src/usecases/create-note.js';
|
|
5
|
-
|
|
6
|
-
function stubDeps() {
|
|
7
|
-
const stored: Note[] = [];
|
|
8
|
-
const published: NoteCreatedEvent[] = [];
|
|
9
|
-
const logs: string[] = [];
|
|
10
|
-
const deps: CreateNoteDeps = {
|
|
11
|
-
notes: { put: async (note) => void stored.push(note) },
|
|
12
|
-
events: { publish: async (event) => void published.push(event) },
|
|
13
|
-
newId: () => 'fixed-id',
|
|
14
|
-
now: () => '2024-01-01T00:00:00.000Z',
|
|
15
|
-
log: createLogger({}, (line) => logs.push(line)),
|
|
16
|
-
};
|
|
17
|
-
return { deps, stored, published, logs };
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
describe('createNoteUsecase', () => {
|
|
21
|
-
it('creates, stores, publishes, and logs — in that order of effects', async () => {
|
|
22
|
-
const { deps, stored, published, logs } = stubDeps();
|
|
23
|
-
const note = await createNoteUsecase({ title: 'Hello World' }, deps);
|
|
24
|
-
expect(note).toMatchObject({ id: 'fixed-id', slug: 'hello-world' });
|
|
25
|
-
expect(stored).toEqual([note]);
|
|
26
|
-
expect(published).toEqual([{ type: 'note.created', note }]);
|
|
27
|
-
expect(logs.join('\n')).toContain('fixed-id');
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it('rejects invalid input before any side effect', async () => {
|
|
31
|
-
const { deps, stored, published } = stubDeps();
|
|
32
|
-
await expect(createNoteUsecase({ title: '' }, deps)).rejects.toThrow(InvalidNoteError);
|
|
33
|
-
expect(stored).toEqual([]);
|
|
34
|
-
expect(published).toEqual([]);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('propagates store failures and does not publish', async () => {
|
|
38
|
-
const { deps, published } = stubDeps();
|
|
39
|
-
deps.notes = {
|
|
40
|
-
put: () => Promise.reject(new Error('dynamo down')),
|
|
41
|
-
};
|
|
42
|
-
await expect(createNoteUsecase({ title: 'T' }, deps)).rejects.toThrow('dynamo down');
|
|
43
|
-
expect(published).toEqual([]);
|
|
44
|
-
});
|
|
45
|
-
});
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
APIGatewayProxyEventV2,
|
|
3
|
-
APIGatewayProxyStructuredResultV2,
|
|
4
|
-
Context,
|
|
5
|
-
} from 'aws-lambda';
|
|
6
|
-
import { AppError, createLogger } from '@app/shared';
|
|
7
|
-
import { describe, expect, it } from 'vitest';
|
|
8
|
-
import { makeListNotesHandler } from '../src/handlers/list-notes.js';
|
|
9
|
-
import { listNotesUsecase } from '../src/usecases/list-notes.js';
|
|
10
|
-
|
|
11
|
-
const note = {
|
|
12
|
-
id: 'n1',
|
|
13
|
-
title: 'Hello',
|
|
14
|
-
slug: 'hello',
|
|
15
|
-
tags: [],
|
|
16
|
-
createdAt: '2024-01-01T00:00:00.000Z',
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
const asResult = (r: unknown) => r as APIGatewayProxyStructuredResultV2;
|
|
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
|
-
|
|
25
|
-
describe('listNotesUsecase', () => {
|
|
26
|
-
it('returns what the model lists', async () => {
|
|
27
|
-
expect(await listNotesUsecase({ notes: { list: async () => [note] } })).toEqual([note]);
|
|
28
|
-
});
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
describe('GET /notes handler', () => {
|
|
32
|
-
it('returns 200 with the notes', async () => {
|
|
33
|
-
const handler = makeListNotesHandler({
|
|
34
|
-
notes: { list: async () => [note] },
|
|
35
|
-
log: createLogger({}, () => {}),
|
|
36
|
-
});
|
|
37
|
-
const result = asResult(await handler());
|
|
38
|
-
expect(result.statusCode).toBe(200);
|
|
39
|
-
expect(JSON.parse(result.body!)).toEqual({ notes: [note] });
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('maps typed errors and hides unknown ones', async () => {
|
|
43
|
-
const typed = makeListNotesHandler({
|
|
44
|
-
notes: { list: () => Promise.reject(new AppError('nope', { statusCode: 503, code: 'X' })) },
|
|
45
|
-
log: createLogger({}, () => {}),
|
|
46
|
-
});
|
|
47
|
-
expect(asResult(await typed()).statusCode).toBe(503);
|
|
48
|
-
|
|
49
|
-
const logs: string[] = [];
|
|
50
|
-
const unknown = makeListNotesHandler({
|
|
51
|
-
notes: { list: () => Promise.reject(new Error('secret')) },
|
|
52
|
-
log: createLogger({}, (line) => logs.push(line)),
|
|
53
|
-
});
|
|
54
|
-
const result = asResult(await unknown());
|
|
55
|
-
expect(result.statusCode).toBe(500);
|
|
56
|
-
expect(result.body).not.toContain('secret');
|
|
57
|
-
expect(logs.join('')).toContain('secret');
|
|
58
|
-
});
|
|
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
|
-
});
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import { SendMessageCommand } from '@aws-sdk/client-sqs';
|
|
2
|
-
import { makeNoteCreatedEvent, createNote } from '@app/core';
|
|
3
|
-
import { describe, expect, it } from 'vitest';
|
|
4
|
-
import { SqsEventPublisher } from '../src/adapters/sqs-publisher.js';
|
|
5
|
-
|
|
6
|
-
describe('SqsEventPublisher', () => {
|
|
7
|
-
it('sends the JSON-encoded event to the configured queue', async () => {
|
|
8
|
-
const sent: unknown[] = [];
|
|
9
|
-
const publisher = new SqsEventPublisher(
|
|
10
|
-
{ send: (c) => (sent.push(c), Promise.resolve({})) },
|
|
11
|
-
'https://queue.example/q',
|
|
12
|
-
);
|
|
13
|
-
const note = createNote({ title: 'Hi' }, { id: 'n1', createdAt: '2024-01-01T00:00:00.000Z' });
|
|
14
|
-
const event = makeNoteCreatedEvent(note);
|
|
15
|
-
await publisher.publish(event);
|
|
16
|
-
|
|
17
|
-
const command = sent[0] as SendMessageCommand;
|
|
18
|
-
expect(command).toBeInstanceOf(SendMessageCommand);
|
|
19
|
-
expect(command.input.QueueUrl).toBe('https://queue.example/q');
|
|
20
|
-
expect(JSON.parse(command.input.MessageBody!)).toEqual(event);
|
|
21
|
-
});
|
|
22
|
-
});
|