create-agent-rig 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +87 -0
  3. package/package.json +54 -0
  4. package/packages/cli/dist/commands/create.js +109 -0
  5. package/packages/cli/dist/index.js +102 -0
  6. package/packages/cli/dist/lib/colors.js +14 -0
  7. package/packages/cli/dist/lib/composition.js +20 -0
  8. package/packages/cli/dist/lib/copy-tree.js +91 -0
  9. package/packages/cli/dist/lib/prompts.js +24 -0
  10. package/packages/cli/dist/lib/substitute.js +22 -0
  11. package/packages/cli/dist/lib/summary.js +41 -0
  12. package/packages/cli/dist/lib/targets.js +14 -0
  13. package/packages/cli/dist/templates.js +21 -0
  14. package/scripts/prepare.mjs +29 -0
  15. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +49 -0
  16. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +59 -0
  17. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +51 -0
  18. package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +39 -0
  19. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +36 -0
  20. package/templates/agent-os/universal/.claude/agents/security-scanner.md +40 -0
  21. package/templates/agent-os/universal/.claude/agents/test-writer.md +36 -0
  22. package/templates/agent-os/universal/.claude/hooks/block-no-verify.mjs +43 -0
  23. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +79 -0
  24. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +53 -0
  25. package/templates/agent-os/universal/.claude/rules/architecture.md +74 -0
  26. package/templates/agent-os/universal/.claude/rules/autonomy.md +81 -0
  27. package/templates/agent-os/universal/.claude/rules/workflow.md +62 -0
  28. package/templates/agent-os/universal/.claude/settings.json +28 -0
  29. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +53 -0
  30. package/templates/agent-os/universal/CLAUDE.md +62 -0
  31. package/templates/skeleton/aws-serverless/.github/workflows/ci.yml +23 -0
  32. package/templates/skeleton/aws-serverless/README.md +78 -0
  33. package/templates/skeleton/aws-serverless/apps/web/next.config.mjs +17 -0
  34. package/templates/skeleton/aws-serverless/apps/web/package.json +19 -0
  35. package/templates/skeleton/aws-serverless/apps/web/src/app/layout.tsx +17 -0
  36. package/templates/skeleton/aws-serverless/apps/web/src/app/page.tsx +96 -0
  37. package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +29 -0
  38. package/templates/skeleton/aws-serverless/apps/web/src/lib/validate.ts +23 -0
  39. package/templates/skeleton/aws-serverless/apps/web/test/shared-validation.test.ts +38 -0
  40. package/templates/skeleton/aws-serverless/apps/web/tsconfig.json +14 -0
  41. package/templates/skeleton/aws-serverless/eslint.config.mjs +20 -0
  42. package/templates/skeleton/aws-serverless/gitignore +9 -0
  43. package/templates/skeleton/aws-serverless/infra/bin/app.ts +19 -0
  44. package/templates/skeleton/aws-serverless/infra/cdk.json +3 -0
  45. package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +116 -0
  46. package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +32 -0
  47. package/templates/skeleton/aws-serverless/infra/package.json +18 -0
  48. package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +104 -0
  49. package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +41 -0
  50. package/templates/skeleton/aws-serverless/package.json +30 -0
  51. package/templates/skeleton/aws-serverless/packages/core/package.json +11 -0
  52. package/templates/skeleton/aws-serverless/packages/core/src/events.ts +14 -0
  53. package/templates/skeleton/aws-serverless/packages/core/src/index.ts +15 -0
  54. package/templates/skeleton/aws-serverless/packages/core/src/note.ts +69 -0
  55. package/templates/skeleton/aws-serverless/packages/core/test/events.test.ts +23 -0
  56. package/templates/skeleton/aws-serverless/packages/core/test/note.test.ts +101 -0
  57. package/templates/skeleton/aws-serverless/packages/db/package.json +14 -0
  58. package/templates/skeleton/aws-serverless/packages/db/src/client.ts +17 -0
  59. package/templates/skeleton/aws-serverless/packages/db/src/index.ts +2 -0
  60. package/templates/skeleton/aws-serverless/packages/db/src/note-model.ts +52 -0
  61. package/templates/skeleton/aws-serverless/packages/db/test/note-model.test.ts +91 -0
  62. package/templates/skeleton/aws-serverless/packages/shared/package.json +11 -0
  63. package/templates/skeleton/aws-serverless/packages/shared/src/env.ts +17 -0
  64. package/templates/skeleton/aws-serverless/packages/shared/src/errors.ts +33 -0
  65. package/templates/skeleton/aws-serverless/packages/shared/src/index.ts +3 -0
  66. package/templates/skeleton/aws-serverless/packages/shared/src/logger.ts +20 -0
  67. package/templates/skeleton/aws-serverless/packages/shared/test/env.test.ts +26 -0
  68. package/templates/skeleton/aws-serverless/packages/shared/test/errors.test.ts +28 -0
  69. package/templates/skeleton/aws-serverless/packages/shared/test/logger.test.ts +19 -0
  70. package/templates/skeleton/aws-serverless/pnpm-lock.yaml +2855 -0
  71. package/templates/skeleton/aws-serverless/pnpm-workspace.yaml +14 -0
  72. package/templates/skeleton/aws-serverless/services/api/package.json +15 -0
  73. package/templates/skeleton/aws-serverless/services/api/src/adapters/sqs-publisher.ts +26 -0
  74. package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +42 -0
  75. package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +24 -0
  76. package/templates/skeleton/aws-serverless/services/api/src/list-main.ts +12 -0
  77. package/templates/skeleton/aws-serverless/services/api/src/main.ts +21 -0
  78. package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +30 -0
  79. package/templates/skeleton/aws-serverless/services/api/src/usecases/list-notes.ts +14 -0
  80. package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +92 -0
  81. package/templates/skeleton/aws-serverless/services/api/test/create-note.usecase.test.ts +45 -0
  82. package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +51 -0
  83. package/templates/skeleton/aws-serverless/services/api/test/sqs-publisher.test.ts +22 -0
  84. package/templates/skeleton/aws-serverless/services/worker/package.json +12 -0
  85. package/templates/skeleton/aws-serverless/services/worker/src/handlers/note-created.ts +15 -0
  86. package/templates/skeleton/aws-serverless/services/worker/src/main.ts +7 -0
  87. package/templates/skeleton/aws-serverless/services/worker/src/usecases/process-note-created.ts +37 -0
  88. package/templates/skeleton/aws-serverless/services/worker/test/note-created.test.ts +61 -0
  89. package/templates/skeleton/aws-serverless/tsconfig.base.json +15 -0
  90. package/templates/skeleton/aws-serverless/tsconfig.json +16 -0
  91. package/templates/skeleton/aws-serverless/vitest.config.ts +14 -0
  92. package/templates/skeleton/node-service/.github/workflows/ci.yml +22 -0
  93. package/templates/skeleton/node-service/README.md +74 -0
  94. package/templates/skeleton/node-service/apps/web/next.config.mjs +17 -0
  95. package/templates/skeleton/node-service/apps/web/package.json +19 -0
  96. package/templates/skeleton/node-service/apps/web/src/app/layout.tsx +17 -0
  97. package/templates/skeleton/node-service/apps/web/src/app/page.tsx +96 -0
  98. package/templates/skeleton/node-service/apps/web/src/lib/api.ts +29 -0
  99. package/templates/skeleton/node-service/apps/web/src/lib/validate.ts +23 -0
  100. package/templates/skeleton/node-service/apps/web/test/shared-validation.test.ts +38 -0
  101. package/templates/skeleton/node-service/apps/web/tsconfig.json +14 -0
  102. package/templates/skeleton/node-service/eslint.config.mjs +20 -0
  103. package/templates/skeleton/node-service/gitignore +9 -0
  104. package/templates/skeleton/node-service/package.json +28 -0
  105. package/templates/skeleton/node-service/packages/core/package.json +11 -0
  106. package/templates/skeleton/node-service/packages/core/src/events.ts +14 -0
  107. package/templates/skeleton/node-service/packages/core/src/index.ts +15 -0
  108. package/templates/skeleton/node-service/packages/core/src/note.ts +69 -0
  109. package/templates/skeleton/node-service/packages/core/test/events.test.ts +23 -0
  110. package/templates/skeleton/node-service/packages/core/test/note.test.ts +101 -0
  111. package/templates/skeleton/node-service/packages/db/package.json +12 -0
  112. package/templates/skeleton/node-service/packages/db/src/index.ts +1 -0
  113. package/templates/skeleton/node-service/packages/db/src/note-store.ts +63 -0
  114. package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +80 -0
  115. package/templates/skeleton/node-service/packages/shared/package.json +11 -0
  116. package/templates/skeleton/node-service/packages/shared/src/env.ts +17 -0
  117. package/templates/skeleton/node-service/packages/shared/src/errors.ts +33 -0
  118. package/templates/skeleton/node-service/packages/shared/src/index.ts +3 -0
  119. package/templates/skeleton/node-service/packages/shared/src/logger.ts +20 -0
  120. package/templates/skeleton/node-service/packages/shared/test/env.test.ts +26 -0
  121. package/templates/skeleton/node-service/packages/shared/test/errors.test.ts +28 -0
  122. package/templates/skeleton/node-service/packages/shared/test/logger.test.ts +19 -0
  123. package/templates/skeleton/node-service/pnpm-lock.yaml +2399 -0
  124. package/templates/skeleton/node-service/pnpm-workspace.yaml +13 -0
  125. package/templates/skeleton/node-service/services/api/package.json +17 -0
  126. package/templates/skeleton/node-service/services/api/src/adapters/spool-publisher.ts +23 -0
  127. package/templates/skeleton/node-service/services/api/src/handlers/create-note.ts +40 -0
  128. package/templates/skeleton/node-service/services/api/src/handlers/list-notes.ts +23 -0
  129. package/templates/skeleton/node-service/services/api/src/main.ts +47 -0
  130. package/templates/skeleton/node-service/services/api/src/server.ts +89 -0
  131. package/templates/skeleton/node-service/services/api/src/usecases/create-note.ts +30 -0
  132. package/templates/skeleton/node-service/services/api/src/usecases/list-notes.ts +14 -0
  133. package/templates/skeleton/node-service/services/api/test/create-note.handler.test.ts +64 -0
  134. package/templates/skeleton/node-service/services/api/test/create-note.usecase.test.ts +43 -0
  135. package/templates/skeleton/node-service/services/api/test/list-notes.test.ts +48 -0
  136. package/templates/skeleton/node-service/services/api/test/server.test.ts +123 -0
  137. package/templates/skeleton/node-service/services/api/test/spool-publisher.test.ts +32 -0
  138. package/templates/skeleton/node-service/services/worker/package.json +16 -0
  139. package/templates/skeleton/node-service/services/worker/src/main.ts +28 -0
  140. package/templates/skeleton/node-service/services/worker/src/spool.ts +60 -0
  141. package/templates/skeleton/node-service/services/worker/src/usecases/process-note-created.ts +38 -0
  142. package/templates/skeleton/node-service/services/worker/test/process-note-created.test.ts +34 -0
  143. package/templates/skeleton/node-service/services/worker/test/spool.test.ts +76 -0
  144. package/templates/skeleton/node-service/tsconfig.base.json +15 -0
  145. package/templates/skeleton/node-service/tsconfig.json +13 -0
  146. package/templates/skeleton/node-service/vitest.config.ts +12 -0
@@ -0,0 +1,116 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { CfnOutput, Duration, RemovalPolicy, Stack, type StackProps } from 'aws-cdk-lib';
4
+ import { CorsHttpMethod, HttpApi, HttpMethod } from 'aws-cdk-lib/aws-apigatewayv2';
5
+ import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
6
+ import { ComparisonOperator, TreatMissingData } from 'aws-cdk-lib/aws-cloudwatch';
7
+ import { AttributeType, BillingMode, Table } from 'aws-cdk-lib/aws-dynamodb';
8
+ import { Runtime } from 'aws-cdk-lib/aws-lambda';
9
+ import { SqsEventSource } from 'aws-cdk-lib/aws-lambda-event-sources';
10
+ import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
11
+ import { Queue } from 'aws-cdk-lib/aws-sqs';
12
+ import type { Construct } from 'constructs';
13
+
14
+ const here = path.dirname(fileURLToPath(import.meta.url));
15
+ const workspaceRoot = path.resolve(here, '..', '..');
16
+
17
+ export class AppStack extends Stack {
18
+ constructor(scope: Construct, id: string, props?: StackProps) {
19
+ super(scope, id, props);
20
+
21
+ // --- storage: one single-table DynamoDB table --------------------------
22
+ const table = new Table(this, 'NotesTable', {
23
+ partitionKey: { name: 'pk', type: AttributeType.STRING },
24
+ sortKey: { name: 'sk', type: AttributeType.STRING },
25
+ billingMode: BillingMode.PAY_PER_REQUEST,
26
+ // Skeleton default: destroyable. Flip to RETAIN before storing real data.
27
+ removalPolicy: RemovalPolicy.DESTROY,
28
+ });
29
+
30
+ // --- queue with DLQ + alarm -------------------------------------------
31
+ const deadLetterQueue = new Queue(this, 'NotesDlq', {
32
+ retentionPeriod: Duration.days(14),
33
+ });
34
+ const queue = new Queue(this, 'NotesQueue', {
35
+ visibilityTimeout: Duration.seconds(30),
36
+ deadLetterQueue: { queue: deadLetterQueue, maxReceiveCount: 3 },
37
+ });
38
+ deadLetterQueue
39
+ .metricApproximateNumberOfMessagesVisible({ period: Duration.minutes(1) })
40
+ .createAlarm(this, 'DlqNotEmptyAlarm', {
41
+ alarmDescription: 'A message reached the DLQ — a consumer is failing.',
42
+ threshold: 1,
43
+ evaluationPeriods: 1,
44
+ comparisonOperator: ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
45
+ treatMissingData: TreatMissingData.NOT_BREACHING,
46
+ });
47
+
48
+ // --- functions ---------------------------------------------------------
49
+ const bundling = { sourceMap: false } as const;
50
+ const apiFunction = new NodejsFunction(this, 'CreateNoteFunction', {
51
+ entry: path.join(workspaceRoot, 'services', 'api', 'src', 'main.ts'),
52
+ handler: 'handler',
53
+ runtime: Runtime.NODEJS_22_X,
54
+ memorySize: 256,
55
+ timeout: Duration.seconds(10),
56
+ depsLockFilePath: path.join(workspaceRoot, 'pnpm-lock.yaml'),
57
+ bundling,
58
+ environment: {
59
+ TABLE_NAME: table.tableName,
60
+ QUEUE_URL: queue.queueUrl,
61
+ },
62
+ });
63
+
64
+ const listFunction = new NodejsFunction(this, 'ListNotesFunction', {
65
+ entry: path.join(workspaceRoot, 'services', 'api', 'src', 'list-main.ts'),
66
+ handler: 'handler',
67
+ runtime: Runtime.NODEJS_22_X,
68
+ memorySize: 256,
69
+ timeout: Duration.seconds(10),
70
+ depsLockFilePath: path.join(workspaceRoot, 'pnpm-lock.yaml'),
71
+ bundling,
72
+ environment: {
73
+ TABLE_NAME: table.tableName,
74
+ },
75
+ });
76
+
77
+ const workerFunction = new NodejsFunction(this, 'NoteCreatedWorker', {
78
+ entry: path.join(workspaceRoot, 'services', 'worker', 'src', 'main.ts'),
79
+ handler: 'handler',
80
+ runtime: Runtime.NODEJS_22_X,
81
+ memorySize: 256,
82
+ timeout: Duration.seconds(10),
83
+ depsLockFilePath: path.join(workspaceRoot, 'pnpm-lock.yaml'),
84
+ bundling,
85
+ });
86
+ workerFunction.addEventSource(new SqsEventSource(queue, { batchSize: 1 }));
87
+
88
+ // --- least-privilege grants: exactly what each function does ----------
89
+ table.grantWriteData(apiFunction); // the creator only puts
90
+ queue.grantSendMessages(apiFunction); // and publishes
91
+ table.grantReadData(listFunction); // the lister only reads
92
+
93
+ // --- the HTTP routes ---------------------------------------------------
94
+ // CORS: the web bundle is served from another origin (CloudFront).
95
+ const httpApi = new HttpApi(this, 'NotesApi', {
96
+ corsPreflight: {
97
+ allowOrigins: ['*'],
98
+ allowMethods: [CorsHttpMethod.GET, CorsHttpMethod.POST],
99
+ allowHeaders: ['content-type'],
100
+ },
101
+ });
102
+ httpApi.addRoutes({
103
+ path: '/notes',
104
+ methods: [HttpMethod.POST],
105
+ integration: new HttpLambdaIntegration('CreateNoteIntegration', apiFunction),
106
+ });
107
+ httpApi.addRoutes({
108
+ path: '/notes',
109
+ methods: [HttpMethod.GET],
110
+ integration: new HttpLambdaIntegration('ListNotesIntegration', listFunction),
111
+ });
112
+
113
+ new CfnOutput(this, 'ApiUrl', { value: httpApi.apiEndpoint });
114
+ new CfnOutput(this, 'DlqUrl', { value: deadLetterQueue.queueUrl });
115
+ }
116
+ }
@@ -0,0 +1,32 @@
1
+ // Stateless serving for the static web export: a private S3 bucket behind
2
+ // CloudFront. Deliberately no BucketDeployment construct — synth must never
3
+ // depend on `next build` having run. The bundle ships via `aws s3 sync`
4
+ // (see README), keeping the stack small and the dependency one-way.
5
+ import { CfnOutput, RemovalPolicy, Stack, type StackProps } from 'aws-cdk-lib';
6
+ import { Distribution, ViewerProtocolPolicy } from 'aws-cdk-lib/aws-cloudfront';
7
+ import { S3BucketOrigin } from 'aws-cdk-lib/aws-cloudfront-origins';
8
+ import { BlockPublicAccess, Bucket } from 'aws-cdk-lib/aws-s3';
9
+ import type { Construct } from 'constructs';
10
+
11
+ export class WebStack extends Stack {
12
+ constructor(scope: Construct, id: string, props?: StackProps) {
13
+ super(scope, id, props);
14
+
15
+ const bucket = new Bucket(this, 'WebBucket', {
16
+ blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
17
+ // Skeleton default: destroyable. Flip to RETAIN before real traffic.
18
+ removalPolicy: RemovalPolicy.DESTROY,
19
+ });
20
+
21
+ const distribution = new Distribution(this, 'WebDistribution', {
22
+ defaultBehavior: {
23
+ origin: S3BucketOrigin.withOriginAccessControl(bucket),
24
+ viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
25
+ },
26
+ defaultRootObject: 'index.html',
27
+ });
28
+
29
+ new CfnOutput(this, 'WebBucketName', { value: bucket.bucketName });
30
+ new CfnOutput(this, 'WebUrl', { value: `https://${distribution.domainName}` });
31
+ }
32
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@app/infra",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "synth": "cdk synth --quiet",
8
+ "deploy": "cdk deploy",
9
+ "destroy": "cdk destroy"
10
+ },
11
+ "dependencies": {
12
+ "aws-cdk-lib": "^2.261.0",
13
+ "constructs": "^10.7.1"
14
+ },
15
+ "devDependencies": {
16
+ "aws-cdk": "^2.1132.0"
17
+ }
18
+ }
@@ -0,0 +1,104 @@
1
+ import { App } from 'aws-cdk-lib';
2
+ import { Match, Template } from 'aws-cdk-lib/assertions';
3
+ import { beforeAll, 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, run once.
7
+ let template: Template;
8
+
9
+ beforeAll(() => {
10
+ const app = new App();
11
+ template = Template.fromStack(new AppStack(app, 'TestStack'));
12
+ });
13
+
14
+ describe('storage', () => {
15
+ it('creates the single table with pk/sk and on-demand billing', () => {
16
+ template.hasResourceProperties('AWS::DynamoDB::Table', {
17
+ KeySchema: [
18
+ { AttributeName: 'pk', KeyType: 'HASH' },
19
+ { AttributeName: 'sk', KeyType: 'RANGE' },
20
+ ],
21
+ BillingMode: 'PAY_PER_REQUEST',
22
+ });
23
+ template.resourceCountIs('AWS::DynamoDB::Table', 1);
24
+ });
25
+ });
26
+
27
+ describe('queue discipline', () => {
28
+ it('wires the DLQ with maxReceiveCount 3', () => {
29
+ template.hasResourceProperties('AWS::SQS::Queue', {
30
+ RedrivePolicy: Match.objectLike({ maxReceiveCount: 3 }),
31
+ });
32
+ });
33
+
34
+ it('alarms as soon as one message reaches the DLQ', () => {
35
+ template.hasResourceProperties('AWS::CloudWatch::Alarm', {
36
+ MetricName: 'ApproximateNumberOfMessagesVisible',
37
+ Threshold: 1,
38
+ EvaluationPeriods: 1,
39
+ ComparisonOperator: 'GreaterThanOrEqualToThreshold',
40
+ });
41
+ });
42
+
43
+ it('feeds the worker one message at a time', () => {
44
+ template.hasResourceProperties('AWS::Lambda::EventSourceMapping', {
45
+ BatchSize: 1,
46
+ });
47
+ });
48
+ });
49
+
50
+ describe('functions and routes', () => {
51
+ it('deploys exactly the create, list and worker functions — one purpose each', () => {
52
+ template.resourceCountIs('AWS::Lambda::Function', 3);
53
+ });
54
+
55
+ it('passes table and queue to the api via environment', () => {
56
+ template.hasResourceProperties('AWS::Lambda::Function', {
57
+ Environment: {
58
+ Variables: Match.objectLike({
59
+ TABLE_NAME: Match.anyValue(),
60
+ QUEUE_URL: Match.anyValue(),
61
+ }),
62
+ },
63
+ });
64
+ });
65
+
66
+ it('exposes exactly two routes: POST /notes and GET /notes', () => {
67
+ template.hasResourceProperties('AWS::ApiGatewayV2::Route', {
68
+ RouteKey: 'POST /notes',
69
+ });
70
+ template.hasResourceProperties('AWS::ApiGatewayV2::Route', {
71
+ RouteKey: 'GET /notes',
72
+ });
73
+ template.resourceCountIs('AWS::ApiGatewayV2::Route', 2);
74
+ });
75
+
76
+ it('allows the browser origin in: CORS is configured', () => {
77
+ template.hasResourceProperties('AWS::ApiGatewayV2::Api', {
78
+ CorsConfiguration: Match.objectLike({
79
+ AllowMethods: Match.arrayWith(['GET', 'POST']),
80
+ }),
81
+ });
82
+ });
83
+ });
84
+
85
+ describe('least-privilege IAM', () => {
86
+ it('grants writes to the creator, reads to the lister, queue send — nothing broad', () => {
87
+ const policies = template.findResources('AWS::IAM::Policy');
88
+ const statements = Object.values(policies).flatMap(
89
+ (policy) =>
90
+ (policy.Properties as { PolicyDocument: { Statement: Array<Record<string, unknown>> } })
91
+ .PolicyDocument.Statement,
92
+ );
93
+ const actions = statements.flatMap((s) =>
94
+ Array.isArray(s.Action) ? (s.Action as string[]) : [s.Action as string],
95
+ );
96
+
97
+ expect(actions).toContain('dynamodb:PutItem');
98
+ expect(actions).toContain('dynamodb:Scan'); // the list function reads
99
+ expect(actions).toContain('sqs:SendMessage');
100
+ // Least privilege: nothing broad.
101
+ expect(actions).not.toContain('dynamodb:*');
102
+ expect(actions.some((a) => a === '*')).toBe(false);
103
+ });
104
+ });
@@ -0,0 +1,41 @@
1
+ import { App } from 'aws-cdk-lib';
2
+ import { Match, Template } from 'aws-cdk-lib/assertions';
3
+ import { beforeAll, 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
+ let template: Template;
10
+
11
+ beforeAll(() => {
12
+ template = Template.fromStack(new WebStack(new App(), 'TestWebStack'));
13
+ });
14
+
15
+ describe('web serving stack', () => {
16
+ it('keeps the bucket fully private', () => {
17
+ template.hasResourceProperties('AWS::S3::Bucket', {
18
+ PublicAccessBlockConfiguration: Match.objectLike({
19
+ BlockPublicAcls: true,
20
+ BlockPublicPolicy: true,
21
+ IgnorePublicAcls: true,
22
+ RestrictPublicBuckets: true,
23
+ }),
24
+ });
25
+ });
26
+
27
+ it('serves through CloudFront with origin access control', () => {
28
+ template.resourceCountIs('AWS::CloudFront::Distribution', 1);
29
+ template.resourceCountIs('AWS::CloudFront::OriginAccessControl', 1);
30
+ template.hasResourceProperties('AWS::CloudFront::Distribution', {
31
+ DistributionConfig: Match.objectLike({
32
+ DefaultRootObject: 'index.html',
33
+ }),
34
+ });
35
+ });
36
+
37
+ it('exports the distribution domain and bucket name for the deploy step', () => {
38
+ template.hasOutput('WebBucketName', {});
39
+ template.hasOutput('WebUrl', {});
40
+ });
41
+ });
@@ -0,0 +1,30 @@
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
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "@app/core",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "dependencies": {
9
+ "zod": "^4.4.3"
10
+ }
11
+ }
@@ -0,0 +1,14 @@
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
+ }
@@ -0,0 +1,15 @@
1
+ export {
2
+ NewNoteSchema,
3
+ NoteSchema,
4
+ InvalidNoteError,
5
+ createNote,
6
+ slugify,
7
+ type NewNote,
8
+ type Note,
9
+ type NoteIdentity,
10
+ } from './note.js';
11
+ export {
12
+ NoteCreatedEventSchema,
13
+ makeNoteCreatedEvent,
14
+ type NoteCreatedEvent,
15
+ } from './events.js';
@@ -0,0 +1,69 @@
1
+ // The domain core is pure: no I/O, no clock, no randomness, no environment.
2
+ // "Now" and "a new id" are decided by the caller (the usecase layer) and enter
3
+ // as arguments. The guard-core-purity hook enforces this at the tool layer.
4
+ import { z } from 'zod';
5
+
6
+ export const NewNoteSchema = z.object({
7
+ title: z.string().trim().min(1, 'title must not be empty').max(200, 'title too long'),
8
+ tags: z.array(z.string().trim().min(1).max(40)).max(10).default([]),
9
+ });
10
+ export type NewNote = z.infer<typeof NewNoteSchema>;
11
+
12
+ export const NoteSchema = z.object({
13
+ id: z.string().min(1),
14
+ title: z.string().min(1),
15
+ slug: z.string().min(1),
16
+ tags: z.array(z.string()),
17
+ createdAt: z.string().min(1),
18
+ });
19
+ export type Note = z.infer<typeof NoteSchema>;
20
+
21
+ /** Thrown when input fails domain validation. The core owns its own error type. */
22
+ export class InvalidNoteError extends Error {
23
+ readonly issues: readonly string[];
24
+
25
+ constructor(issues: readonly string[]) {
26
+ super(`invalid note: ${issues.join('; ')}`);
27
+ this.name = 'InvalidNoteError';
28
+ this.issues = issues;
29
+ }
30
+ }
31
+
32
+ export interface NoteIdentity {
33
+ /** Generated by the caller — the core never invents ids. */
34
+ id: string;
35
+ /** ISO-8601 timestamp decided by the caller — the core never reads the clock. */
36
+ createdAt: string;
37
+ }
38
+
39
+ /**
40
+ * The one domain function: validate raw input and construct a Note.
41
+ * Deterministic — identical arguments always yield an identical note.
42
+ */
43
+ export function createNote(input: unknown, identity: NoteIdentity): Note {
44
+ const parsed = NewNoteSchema.safeParse(input);
45
+ if (!parsed.success) {
46
+ throw new InvalidNoteError(
47
+ parsed.error.issues.map((issue) => `${issue.path.join('.') || 'input'}: ${issue.message}`),
48
+ );
49
+ }
50
+ const { title, tags } = parsed.data;
51
+ return {
52
+ id: identity.id,
53
+ title,
54
+ slug: slugify(title),
55
+ tags: [...new Set(tags)],
56
+ createdAt: identity.createdAt,
57
+ };
58
+ }
59
+
60
+ /** Lowercased, hyphen-separated, ascii-ish slug. Pure string work. */
61
+ export function slugify(title: string): string {
62
+ const slug = title
63
+ .toLowerCase()
64
+ .normalize('NFKD')
65
+ .replace(/[\u0300-\u036f]/g, '') // strip combining marks left by NFKD
66
+ .replace(/[^a-z0-9]+/g, '-')
67
+ .replace(/^-+|-+$/g, '');
68
+ return slug.length > 0 ? slug : 'note';
69
+ }
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { NoteCreatedEventSchema, makeNoteCreatedEvent } from '../src/events.js';
3
+ import { createNote } from '../src/note.js';
4
+
5
+ const note = createNote({ title: 'Hello' }, { id: 'n1', createdAt: '2024-01-01T00:00:00.000Z' });
6
+
7
+ describe('note.created event', () => {
8
+ it('wraps a note and round-trips through its own schema', () => {
9
+ const event = makeNoteCreatedEvent(note);
10
+ expect(event.type).toBe('note.created');
11
+ expect(NoteCreatedEventSchema.parse(JSON.parse(JSON.stringify(event)))).toEqual(event);
12
+ });
13
+
14
+ it('rejects a payload with the wrong type tag', () => {
15
+ expect(NoteCreatedEventSchema.safeParse({ type: 'other', note }).success).toBe(false);
16
+ });
17
+
18
+ it('rejects a payload with a malformed note', () => {
19
+ expect(
20
+ NoteCreatedEventSchema.safeParse({ type: 'note.created', note: { id: '' } }).success,
21
+ ).toBe(false);
22
+ });
23
+ });
@@ -0,0 +1,101 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { InvalidNoteError, createNote, slugify } from '../src/note.js';
3
+
4
+ const identity = { id: 'note-1', createdAt: '2024-01-01T00:00:00.000Z' };
5
+
6
+ describe('createNote', () => {
7
+ it('builds a note from valid input', () => {
8
+ const note = createNote({ title: 'Hello World', tags: ['a', 'b'] }, identity);
9
+ expect(note).toEqual({
10
+ id: 'note-1',
11
+ title: 'Hello World',
12
+ slug: 'hello-world',
13
+ tags: ['a', 'b'],
14
+ createdAt: '2024-01-01T00:00:00.000Z',
15
+ });
16
+ });
17
+
18
+ it('is deterministic: identical arguments yield an identical note', () => {
19
+ const a = createNote({ title: 'Same' }, identity);
20
+ const b = createNote({ title: 'Same' }, identity);
21
+ expect(a).toEqual(b);
22
+ });
23
+
24
+ it('defaults tags to an empty array', () => {
25
+ expect(createNote({ title: 'No tags' }, identity).tags).toEqual([]);
26
+ });
27
+
28
+ it('trims the title', () => {
29
+ expect(createNote({ title: ' padded ' }, identity).title).toBe('padded');
30
+ });
31
+
32
+ it('deduplicates tags', () => {
33
+ expect(createNote({ title: 'T', tags: ['x', 'x', 'y'] }, identity).tags).toEqual(['x', 'y']);
34
+ });
35
+
36
+ it('refuses an empty or whitespace-only title', () => {
37
+ expect(() => createNote({ title: '' }, identity)).toThrow(InvalidNoteError);
38
+ expect(() => createNote({ title: ' ' }, identity)).toThrow(InvalidNoteError);
39
+ });
40
+
41
+ it('refuses a title over 200 characters', () => {
42
+ expect(() => createNote({ title: 'x'.repeat(201) }, identity)).toThrow(InvalidNoteError);
43
+ expect(() => createNote({ title: 'x'.repeat(200) }, identity)).not.toThrow();
44
+ });
45
+
46
+ it('refuses more than 10 tags', () => {
47
+ const tags = Array.from({ length: 11 }, (_, i) => `t${i}`);
48
+ expect(() => createNote({ title: 'T', tags }, identity)).toThrow(InvalidNoteError);
49
+ });
50
+
51
+ it('refuses empty or oversized tags', () => {
52
+ expect(() => createNote({ title: 'T', tags: [''] }, identity)).toThrow(InvalidNoteError);
53
+ expect(() => createNote({ title: 'T', tags: ['x'.repeat(41)] }, identity)).toThrow(
54
+ InvalidNoteError,
55
+ );
56
+ });
57
+
58
+ it('refuses non-object input', () => {
59
+ for (const bad of [null, undefined, 42, 'title', []]) {
60
+ expect(() => createNote(bad, identity)).toThrow(InvalidNoteError);
61
+ }
62
+ });
63
+
64
+ it('refuses wrongly-typed fields', () => {
65
+ expect(() => createNote({ title: 42 }, identity)).toThrow(InvalidNoteError);
66
+ expect(() => createNote({ title: 'T', tags: 'not-an-array' }, identity)).toThrow(
67
+ InvalidNoteError,
68
+ );
69
+ });
70
+
71
+ it('reports every issue with its path', () => {
72
+ try {
73
+ createNote({ title: '', tags: [''] }, identity);
74
+ expect.unreachable('should have thrown');
75
+ } catch (error) {
76
+ const invalid = error as InvalidNoteError;
77
+ expect(invalid.issues.length).toBeGreaterThanOrEqual(2);
78
+ expect(invalid.issues.join('\n')).toMatch(/title/);
79
+ expect(invalid.issues.join('\n')).toMatch(/tags/);
80
+ }
81
+ });
82
+ });
83
+
84
+ describe('slugify', () => {
85
+ it('lowercases and hyphenates', () => {
86
+ expect(slugify('Hello World')).toBe('hello-world');
87
+ });
88
+
89
+ it('collapses runs of non-alphanumerics and trims hyphens', () => {
90
+ expect(slugify(' a -- b!! c ')).toBe('a-b-c');
91
+ });
92
+
93
+ it('strips diacritics', () => {
94
+ expect(slugify('Crème Brûlée')).toBe('creme-brulee');
95
+ });
96
+
97
+ it('falls back to "note" when nothing survives', () => {
98
+ expect(slugify('!!!')).toBe('note');
99
+ expect(slugify('日本語')).toBe('note');
100
+ });
101
+ });
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "@app/db",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "dependencies": {
9
+ "@app/core": "workspace:*",
10
+ "@app/shared": "workspace:*",
11
+ "@aws-sdk/client-dynamodb": "^3.1092.0",
12
+ "@aws-sdk/lib-dynamodb": "^3.1092.0"
13
+ }
14
+ }
@@ -0,0 +1,17 @@
1
+ // The ONLY file in the project that constructs the storage SDK client.
2
+ import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
3
+ import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
4
+
5
+ /**
6
+ * Structural view of the document client — models depend on this, tests stub it.
7
+ * (Method parameters are bivariant, so the real client satisfies it.)
8
+ */
9
+ export interface DocumentClient {
10
+ send(command: unknown): Promise<unknown>;
11
+ }
12
+
13
+ export function createDocumentClient(): DocumentClient {
14
+ return DynamoDBDocumentClient.from(new DynamoDBClient({}), {
15
+ marshallOptions: { removeUndefinedValues: true },
16
+ }) as DocumentClient;
17
+ }
@@ -0,0 +1,2 @@
1
+ export { createDocumentClient, type DocumentClient } from './client.js';
2
+ export { NoteModel } from './note-model.js';
@@ -0,0 +1,52 @@
1
+ // Single-table layout: pk = NOTE#<id>, sk = META. One model, one table, one
2
+ // place that knows the key schema.
3
+ import { GetCommand, PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';
4
+ import { NoteSchema, type Note } from '@app/core';
5
+ import { NotFoundError } from '@app/shared';
6
+ import type { DocumentClient } from './client.js';
7
+
8
+ const noteKey = (id: string) => ({ pk: `NOTE#${id}`, sk: 'META' });
9
+
10
+ export class NoteModel {
11
+ constructor(
12
+ private readonly client: DocumentClient,
13
+ private readonly tableName: string,
14
+ ) {}
15
+
16
+ async put(note: Note): Promise<void> {
17
+ await this.client.send(
18
+ new PutCommand({
19
+ TableName: this.tableName,
20
+ Item: { ...noteKey(note.id), ...note },
21
+ ConditionExpression: 'attribute_not_exists(pk)',
22
+ }),
23
+ );
24
+ }
25
+
26
+ async get(id: string): Promise<Note> {
27
+ const result = (await this.client.send(
28
+ new GetCommand({ TableName: this.tableName, Key: noteKey(id) }),
29
+ )) as { Item?: Record<string, unknown> };
30
+ if (!result.Item) {
31
+ throw new NotFoundError(`note ${id} not found`);
32
+ }
33
+ // Validate on the way out too: the database is an external system.
34
+ return NoteSchema.parse(result.Item);
35
+ }
36
+
37
+ async list(): Promise<Note[]> {
38
+ // A filtered Scan is fine at skeleton scale; swap for a GSI Query when
39
+ // real data volume arrives (a Tier-2 change — it touches the schema).
40
+ const result = (await this.client.send(
41
+ new ScanCommand({
42
+ TableName: this.tableName,
43
+ FilterExpression: 'sk = :meta',
44
+ ExpressionAttributeValues: { ':meta': 'META' },
45
+ }),
46
+ )) as { Items?: Array<Record<string, unknown>> };
47
+ // Validate every entry — silently skipping corruption would hide data loss.
48
+ return (result.Items ?? [])
49
+ .map((item) => NoteSchema.parse(item))
50
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
51
+ }
52
+ }