@biffo/cli 0.38.0 → 0.41.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 (73) hide show
  1. package/_skeletons/plugin-template/.github/workflows/ci.yml +124 -0
  2. package/_skeletons/plugin-template/.github/workflows/release.yml +114 -0
  3. package/_skeletons/plugin-template/README.md +232 -0
  4. package/_skeletons/plugin-template/biffo.plugin.json +89 -0
  5. package/_skeletons/plugin-template/pyproject.toml +100 -0
  6. package/_skeletons/plugin-template/registry-schema.json +294 -0
  7. package/_skeletons/plugin-template/src/__init__.py +0 -0
  8. package/_skeletons/plugin-template/src/example_plugin/__init__.py +10 -0
  9. package/_skeletons/plugin-template/src/example_plugin/main.py +63 -0
  10. package/_skeletons/plugin-template/src/example_plugin/manifest.py +30 -0
  11. package/_skeletons/plugin-template/src/example_plugin/plugin.py +92 -0
  12. package/_skeletons/plugin-template/terraform/README.md +146 -0
  13. package/_skeletons/plugin-template/terraform/main.tf +167 -0
  14. package/_skeletons/plugin-template/terraform/outputs.tf +33 -0
  15. package/_skeletons/plugin-template/terraform/variables.tf +133 -0
  16. package/_skeletons/plugin-template/tests/conftest.py +17 -0
  17. package/_skeletons/plugin-template/tests/fakes.py +74 -0
  18. package/_skeletons/plugin-template/tests/test_example_plugin.py +108 -0
  19. package/_skeletons/registry/README.md +27 -0
  20. package/_skeletons/registry/plugins.json +5 -0
  21. package/_skeletons/registry/registry-schema.json +294 -0
  22. package/_skeletons/sibling-template/.github/renovate.json +38 -0
  23. package/_skeletons/sibling-template/.github/workflows/ci.yml +185 -0
  24. package/_skeletons/sibling-template/.github/workflows/codeql.yml +56 -0
  25. package/_skeletons/sibling-template/.github/workflows/deploy.yml +219 -0
  26. package/_skeletons/sibling-template/.github/workflows/destroy-infra.yml +73 -0
  27. package/_skeletons/sibling-template/README.md +164 -0
  28. package/_skeletons/sibling-template/_gitignore +71 -0
  29. package/_skeletons/sibling-template/apps/frontend/.env.example +14 -0
  30. package/_skeletons/sibling-template/apps/frontend/eslint.config.mjs +10 -0
  31. package/_skeletons/sibling-template/apps/frontend/next.config.ts +20 -0
  32. package/_skeletons/sibling-template/apps/frontend/package.json +42 -0
  33. package/_skeletons/sibling-template/apps/frontend/pnpm-lock.yaml +5268 -0
  34. package/_skeletons/sibling-template/apps/frontend/pnpm-workspace.yaml +19 -0
  35. package/_skeletons/sibling-template/apps/frontend/src/app/globals.css +34 -0
  36. package/_skeletons/sibling-template/apps/frontend/src/app/layout.tsx +15 -0
  37. package/_skeletons/sibling-template/apps/frontend/src/app/page.test.tsx +75 -0
  38. package/_skeletons/sibling-template/apps/frontend/src/app/page.tsx +85 -0
  39. package/_skeletons/sibling-template/apps/frontend/src/lib/api-client.ts +58 -0
  40. package/_skeletons/sibling-template/apps/frontend/src/lib/auth.test.ts +135 -0
  41. package/_skeletons/sibling-template/apps/frontend/src/lib/auth.ts +87 -0
  42. package/_skeletons/sibling-template/apps/frontend/src/test-setup.ts +1 -0
  43. package/_skeletons/sibling-template/apps/frontend/tsconfig.json +29 -0
  44. package/_skeletons/sibling-template/apps/frontend/vitest.config.ts +18 -0
  45. package/_skeletons/sibling-template/biffo.sibling.json +7 -0
  46. package/_skeletons/sibling-template/infra/backend.tf +23 -0
  47. package/_skeletons/sibling-template/infra/main.tf +79 -0
  48. package/_skeletons/sibling-template/infra/outputs.tf +17 -0
  49. package/_skeletons/sibling-template/infra/variables.tf +63 -0
  50. package/_skeletons/sibling-template/modules/cloud/aws/api-gateway/main.tf +114 -0
  51. package/_skeletons/sibling-template/modules/cloud/aws/api-gateway/outputs.tf +12 -0
  52. package/_skeletons/sibling-template/modules/cloud/aws/api-gateway/variables.tf +41 -0
  53. package/_skeletons/sibling-template/modules/cloud/aws/compute/main.tf +157 -0
  54. package/_skeletons/sibling-template/modules/cloud/aws/compute/outputs.tf +4 -0
  55. package/_skeletons/sibling-template/modules/cloud/aws/compute/variables.tf +70 -0
  56. package/_skeletons/sibling-template/modules/cloud/aws/storage/main.tf +78 -0
  57. package/_skeletons/sibling-template/modules/cloud/aws/storage/outputs.tf +4 -0
  58. package/_skeletons/sibling-template/modules/cloud/aws/storage/variables.tf +12 -0
  59. package/_skeletons/sibling-template/services/api/pyproject.toml +69 -0
  60. package/_skeletons/sibling-template/services/api/src/api/__init__.py +0 -0
  61. package/_skeletons/sibling-template/services/api/src/api/config.py +31 -0
  62. package/_skeletons/sibling-template/services/api/src/api/core_client.py +64 -0
  63. package/_skeletons/sibling-template/services/api/src/api/main.py +43 -0
  64. package/_skeletons/sibling-template/services/api/src/api/middleware/__init__.py +0 -0
  65. package/_skeletons/sibling-template/services/api/src/api/middleware/auth.py +111 -0
  66. package/_skeletons/sibling-template/services/api/src/api/routers/__init__.py +0 -0
  67. package/_skeletons/sibling-template/services/api/src/api/routers/whoami.py +21 -0
  68. package/_skeletons/sibling-template/services/api/tests/conftest.py +24 -0
  69. package/_skeletons/sibling-template/services/api/tests/test_whoami.py +21 -0
  70. package/_skeletons/sibling-template/services/api/uv.lock +1160 -0
  71. package/core.version +1 -1
  72. package/dist/index.js +113 -87
  73. package/package.json +5 -4
@@ -0,0 +1,17 @@
1
+ output "api_url" {
2
+ description = "This sibling's own API Gateway endpoint — set as NEXT_PUBLIC_API_URL in the frontend build."
3
+ value = module.api_gateway.api_endpoint
4
+ }
5
+
6
+ output "site_bucket_name" {
7
+ value = module.storage.site_bucket_name
8
+ }
9
+
10
+ output "site_bucket_regional_domain" {
11
+ description = "Consumed by the core project's registration PR — written into its siblings.auto.tfvars.json as this sibling's bucket_regional_domain (see modules/cloud/aws/cdn's sibling_origins in the core repo)."
12
+ value = module.storage.site_bucket_regional_domain
13
+ }
14
+
15
+ output "lambda_function_name" {
16
+ value = module.api.function_name
17
+ }
@@ -0,0 +1,63 @@
1
+ variable "project_name" {
2
+ description = "This sibling's name — also the path segment it's routed on (baseurl.com/<project_name>/*, ADR-0007). Must match the \"name\" this sibling was registered with in the core project's siblings.auto.tfvars.json."
3
+ type = string
4
+
5
+ validation {
6
+ condition = can(regex("^[a-z][a-z0-9-]*$", var.project_name))
7
+ error_message = "project_name must be lowercase alphanumeric with hyphens, starting with a letter (it becomes a URL path segment and an AWS resource name prefix)."
8
+ }
9
+ }
10
+
11
+ variable "environment" {
12
+ type = string
13
+ default = "dev"
14
+ }
15
+
16
+ variable "aws_region" {
17
+ type = string
18
+ default = "us-east-1"
19
+ }
20
+
21
+ # ── Core project identity (ADR-0007) ───────────────────────────────────────
22
+ # This sibling never provisions its own Cognito pool or CloudFront
23
+ # distribution — it plugs into the core project's. These are plain
24
+ # pass-through input variables, not resources.
25
+
26
+ variable "core_cognito_user_pool_id" {
27
+ type = string
28
+ }
29
+
30
+ variable "core_cognito_client_id" {
31
+ type = string
32
+ }
33
+
34
+ variable "core_api_url" {
35
+ description = "Base URL of the core project's own API — the only place this sibling may read/write core-owned data (ADR-0002)."
36
+ type = string
37
+ }
38
+
39
+ variable "core_portal_url" {
40
+ description = "Origin of the core portal (e.g. https://baseurl.com) — the frontend redirects here to sign in when there is no shared session."
41
+ type = string
42
+ }
43
+
44
+ variable "cors_origins" {
45
+ description = "Origins allowed to call this sibling's API. Since the frontend is served path-routed on the SAME origin as the core portal, this is normally just [core_portal_url], plus http://localhost:3000 for local dev."
46
+ type = list(string)
47
+ default = ["http://localhost:3000"]
48
+ }
49
+
50
+ # ── Two-phase CDN registration (see modules/cloud/aws/cdn's sibling_origins
51
+ # in the core project, and README.md's "Registering with the core project"
52
+ # section) ─────────────────────────────────────────────────────────────────
53
+
54
+ variable "parent_cloudfront_distribution_arn" {
55
+ description = "ARN of the core project's CloudFront distribution. Left empty until the core project's registration PR (opened by `biffo sibling create`) has merged and redeployed — the bucket policy below is skipped while empty, since there is no distribution ARN yet to trust. Re-apply with this set once that PR merges."
56
+ type = string
57
+ default = ""
58
+ }
59
+
60
+ variable "tags" {
61
+ type = map(string)
62
+ default = {}
63
+ }
@@ -0,0 +1,114 @@
1
+ terraform {
2
+ required_providers {
3
+ aws = { source = "hashicorp/aws", version = "~> 5.0" }
4
+ }
5
+ }
6
+
7
+ locals {
8
+ name_prefix = "${var.project_name}-${var.environment}"
9
+ }
10
+
11
+ resource "aws_apigatewayv2_api" "main" {
12
+ name = "${local.name_prefix}-api"
13
+ protocol_type = "HTTP"
14
+ description = "Sibling API - ${local.name_prefix}"
15
+ tags = var.tags
16
+
17
+ # Applied by API Gateway itself, independent of the Lambda integration — this is
18
+ # what puts CORS headers on responses the JWT authorizer rejects before invoking
19
+ # Lambda (see the $default route below). FastAPI's CORSMiddleware only covers
20
+ # requests that actually reach the Lambda.
21
+ cors_configuration {
22
+ allow_origins = var.cors_origins
23
+ allow_methods = ["*"]
24
+ allow_headers = ["*"]
25
+ allow_credentials = true
26
+ }
27
+ }
28
+
29
+ # JWT authorizer backed by the CORE project's Cognito pool (ADR-0007) —
30
+ # validates Bearer tokens on all protected routes. This is one of two layers
31
+ # of defense in depth: API Gateway rejects invalid/missing tokens before
32
+ # Lambda is ever invoked, and the Lambda's own auth middleware
33
+ # (services/api/src/api/middleware/auth.py) independently re-verifies the
34
+ # same token again.
35
+ resource "aws_apigatewayv2_authorizer" "cognito" {
36
+ api_id = aws_apigatewayv2_api.main.id
37
+ authorizer_type = "JWT"
38
+ identity_sources = ["$request.header.Authorization"]
39
+ name = "${local.name_prefix}-cognito"
40
+
41
+ jwt_configuration {
42
+ audience = [var.cognito_client_id]
43
+ issuer = "https://cognito-idp.${var.aws_region}.amazonaws.com/${var.cognito_user_pool_id}"
44
+ }
45
+ }
46
+
47
+ resource "aws_apigatewayv2_integration" "lambda" {
48
+ api_id = aws_apigatewayv2_api.main.id
49
+ integration_type = "AWS_PROXY"
50
+ integration_uri = var.lambda_function_arn
51
+ payload_format_version = "2.0"
52
+ timeout_milliseconds = 29000
53
+ }
54
+
55
+ # Health check — no auth required (public endpoint)
56
+ resource "aws_apigatewayv2_route" "health" {
57
+ api_id = aws_apigatewayv2_api.main.id
58
+ route_key = "GET /api/v1/health"
59
+ target = "integrations/${aws_apigatewayv2_integration.lambda.id}"
60
+ authorization_type = "NONE"
61
+ }
62
+
63
+ # OPTIONS preflight — no auth so CORS preflight is never blocked by the JWT authorizer.
64
+ resource "aws_apigatewayv2_route" "options" {
65
+ api_id = aws_apigatewayv2_api.main.id
66
+ route_key = "OPTIONS /{proxy+}"
67
+ target = "integrations/${aws_apigatewayv2_integration.lambda.id}"
68
+ }
69
+
70
+ # Catch-all — all other routes require a valid Cognito JWT
71
+ resource "aws_apigatewayv2_route" "default" {
72
+ api_id = aws_apigatewayv2_api.main.id
73
+ route_key = "$default"
74
+ target = "integrations/${aws_apigatewayv2_integration.lambda.id}"
75
+ authorization_type = "JWT"
76
+ authorizer_id = aws_apigatewayv2_authorizer.cognito.id
77
+ }
78
+
79
+ resource "aws_apigatewayv2_stage" "main" {
80
+ api_id = aws_apigatewayv2_api.main.id
81
+ name = "$default"
82
+ auto_deploy = true
83
+
84
+ access_log_settings {
85
+ destination_arn = aws_cloudwatch_log_group.access_logs.arn
86
+ format = jsonencode({
87
+ requestId = "$context.requestId"
88
+ ip = "$context.identity.sourceIp"
89
+ requestTime = "$context.requestTime"
90
+ httpMethod = "$context.httpMethod"
91
+ routeKey = "$context.routeKey"
92
+ status = "$context.status"
93
+ protocol = "$context.protocol"
94
+ responseLength = "$context.responseLength"
95
+ integrationError = "$context.integrationErrorMessage"
96
+ })
97
+ }
98
+
99
+ tags = var.tags
100
+ }
101
+
102
+ resource "aws_cloudwatch_log_group" "access_logs" {
103
+ name = "/sibling/${local.name_prefix}/api-gateway"
104
+ retention_in_days = var.environment == "prod" ? 90 : 14
105
+ tags = var.tags
106
+ }
107
+
108
+ resource "aws_lambda_permission" "api_gateway" {
109
+ statement_id = "AllowAPIGatewayInvoke"
110
+ action = "lambda:InvokeFunction"
111
+ function_name = var.lambda_function_name
112
+ principal = "apigateway.amazonaws.com"
113
+ source_arn = "${aws_apigatewayv2_api.main.execution_arn}/*/*"
114
+ }
@@ -0,0 +1,12 @@
1
+ output "api_id" {
2
+ value = aws_apigatewayv2_api.main.id
3
+ }
4
+
5
+ output "api_endpoint" {
6
+ description = "Base URL of the HTTP API — set as NEXT_PUBLIC_API_URL in the frontend build"
7
+ value = trimsuffix(aws_apigatewayv2_stage.main.invoke_url, "/")
8
+ }
9
+
10
+ output "execution_arn" {
11
+ value = aws_apigatewayv2_api.main.execution_arn
12
+ }
@@ -0,0 +1,41 @@
1
+ variable "project_name" {
2
+ type = string
3
+ }
4
+
5
+ variable "environment" {
6
+ type = string
7
+ }
8
+
9
+ variable "lambda_function_arn" {
10
+ description = "ARN of the sibling API Lambda function to integrate"
11
+ type = string
12
+ }
13
+
14
+ variable "lambda_function_name" {
15
+ description = "Name of the sibling API Lambda function (for the invoke permission)"
16
+ type = string
17
+ }
18
+
19
+ variable "cognito_user_pool_id" {
20
+ description = "The CORE project's Cognito User Pool ID (ADR-0007) — this sibling has no pool of its own."
21
+ type = string
22
+ }
23
+
24
+ variable "cognito_client_id" {
25
+ description = "The CORE project's Cognito App Client ID (ADR-0007) — same client as the core portal, so sessions are shared."
26
+ type = string
27
+ }
28
+
29
+ variable "aws_region" {
30
+ type = string
31
+ }
32
+
33
+ variable "cors_origins" {
34
+ description = "Origins allowed to call this API. Must be applied at the API Gateway level (not just FastAPI's CORSMiddleware) because the JWT authorizer rejects unauthenticated requests before they ever reach the Lambda, so no CORS headers would otherwise be present on 401s."
35
+ type = list(string)
36
+ }
37
+
38
+ variable "tags" {
39
+ type = map(string)
40
+ default = {}
41
+ }
@@ -0,0 +1,157 @@
1
+ terraform {
2
+ required_providers {
3
+ aws = { source = "hashicorp/aws", version = "~> 5.0" }
4
+ archive = { source = "hashicorp/archive", version = "~> 2.0" }
5
+ }
6
+ }
7
+
8
+ # Minimal placeholder zip — Terraform creates this automatically during plan.
9
+ # The CI/CD pipeline overwrites the function code on every deploy;
10
+ # this only exists so the Lambda resource can be created on first apply.
11
+ data "archive_file" "placeholder" {
12
+ type = "zip"
13
+ output_path = "${path.module}/placeholder.zip"
14
+ source {
15
+ content = "def handler(event, context):\n pass\n"
16
+ filename = "handler.py"
17
+ }
18
+ }
19
+
20
+ locals {
21
+ name_prefix = "${var.project_name}-${var.environment}"
22
+ function_name = "${local.name_prefix}-${var.function_name}"
23
+ }
24
+
25
+ # Dead letter queue for failed invocations — encrypted at rest with KMS
26
+ resource "aws_sqs_queue" "dlq" {
27
+ name = "${local.function_name}-dlq"
28
+ message_retention_seconds = 1209600 # 14 days
29
+ kms_master_key_id = var.sqs_kms_key_id
30
+ tags = var.tags
31
+ }
32
+
33
+ # Security group — only created when this function is attached to a VPC
34
+ # (var.enable_vpc_access). A sibling with no DB of its own (ADR-0002/
35
+ # ADR-0007) should generally leave this false.
36
+ resource "aws_security_group" "lambda" {
37
+ count = var.enable_vpc_access ? 1 : 0
38
+ name = "${local.function_name}-sg"
39
+ description = "Lambda function security group for ${local.function_name}"
40
+ vpc_id = var.vpc_id
41
+
42
+ egress {
43
+ from_port = 0
44
+ to_port = 0
45
+ protocol = "-1"
46
+ cidr_blocks = ["0.0.0.0/0"]
47
+ }
48
+
49
+ tags = merge(var.tags, { Name = "${local.function_name}-sg" })
50
+ }
51
+
52
+ resource "aws_cloudwatch_log_group" "function" {
53
+ name = "/aws/lambda/${local.function_name}"
54
+ retention_in_days = 365 # 1 year — satisfies CKV_AWS_338
55
+ kms_key_id = var.cloudwatch_kms_key_id
56
+ tags = var.tags
57
+ }
58
+
59
+ # Least-privilege execution role — no AdministratorAccess, no PowerUser
60
+ data "aws_iam_policy_document" "lambda_trust" {
61
+ statement {
62
+ effect = "Allow"
63
+ actions = ["sts:AssumeRole"]
64
+ principals {
65
+ type = "Service"
66
+ identifiers = ["lambda.amazonaws.com"]
67
+ }
68
+ }
69
+ }
70
+
71
+ resource "aws_iam_role" "lambda" {
72
+ name = "${local.function_name}-role"
73
+ assume_role_policy = data.aws_iam_policy_document.lambda_trust.json
74
+ tags = var.tags
75
+ }
76
+
77
+ resource "aws_iam_role_policy_attachment" "vpc_access" {
78
+ count = var.enable_vpc_access ? 1 : 0
79
+ role = aws_iam_role.lambda.name
80
+ policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
81
+ }
82
+
83
+ data "aws_iam_policy_document" "lambda_permissions" {
84
+ statement {
85
+ sid = "CloudWatchLogs"
86
+ effect = "Allow"
87
+ actions = [
88
+ "logs:CreateLogStream",
89
+ "logs:PutLogEvents"
90
+ ]
91
+ resources = ["${aws_cloudwatch_log_group.function.arn}:*"]
92
+ }
93
+
94
+ statement {
95
+ sid = "DLQAccess"
96
+ effect = "Allow"
97
+ actions = ["sqs:SendMessage"]
98
+ resources = [aws_sqs_queue.dlq.arn]
99
+ }
100
+ }
101
+
102
+ resource "aws_iam_role_policy" "lambda" {
103
+ name = "sibling-lambda-policy"
104
+ role = aws_iam_role.lambda.id
105
+ policy = data.aws_iam_policy_document.lambda_permissions.json
106
+ }
107
+
108
+ resource "aws_lambda_function" "main" {
109
+ function_name = local.function_name
110
+ role = aws_iam_role.lambda.arn
111
+ handler = var.handler
112
+ runtime = var.runtime
113
+ memory_size = var.memory_size
114
+ timeout = var.timeout
115
+
116
+ filename = data.archive_file.placeholder.output_path
117
+ source_code_hash = data.archive_file.placeholder.output_base64sha256
118
+
119
+ dead_letter_config {
120
+ target_arn = aws_sqs_queue.dlq.arn
121
+ }
122
+
123
+ dynamic "vpc_config" {
124
+ for_each = var.enable_vpc_access ? [1] : []
125
+ content {
126
+ subnet_ids = var.private_subnet_ids
127
+ security_group_ids = [aws_security_group.lambda[0].id]
128
+ }
129
+ }
130
+
131
+ environment {
132
+ variables = merge(
133
+ var.environment_variables,
134
+ {
135
+ POWERTOOLS_SERVICE_NAME = local.function_name
136
+ POWERTOOLS_LOG_LEVEL = var.environment == "prod" ? "WARNING" : "INFO"
137
+ }
138
+ )
139
+ }
140
+
141
+ tracing_config {
142
+ mode = "Active"
143
+ }
144
+
145
+ depends_on = [
146
+ aws_cloudwatch_log_group.function,
147
+ aws_iam_role_policy_attachment.vpc_access,
148
+ aws_iam_role_policy.lambda,
149
+ ]
150
+
151
+ lifecycle {
152
+ # Code is managed by the CI/CD pipeline — Terraform only manages config
153
+ ignore_changes = [filename, source_code_hash]
154
+ }
155
+
156
+ tags = var.tags
157
+ }
@@ -0,0 +1,4 @@
1
+ output "function_arn" { value = aws_lambda_function.main.arn }
2
+ output "function_name" { value = aws_lambda_function.main.function_name }
3
+ output "role_arn" { value = aws_iam_role.lambda.arn }
4
+ output "dlq_arn" { value = aws_sqs_queue.dlq.arn }
@@ -0,0 +1,70 @@
1
+ variable "project_name" {
2
+ type = string
3
+ }
4
+
5
+ variable "environment" {
6
+ type = string
7
+ }
8
+
9
+ variable "function_name" {
10
+ type = string
11
+ }
12
+
13
+ variable "handler" {
14
+ type = string
15
+ }
16
+
17
+ variable "runtime" {
18
+ type = string
19
+ default = "python3.13"
20
+ }
21
+
22
+ variable "enable_vpc_access" {
23
+ description = "Attach this Lambda's ENIs to a VPC. Defaults to false — a sibling with no database of its own (ADR-0002/ADR-0007) gets nothing from VPC attachment, and in NAT-less networking configs it would cut off outbound internet access including calls to the core project's API."
24
+ type = bool
25
+ default = false
26
+ }
27
+
28
+ variable "vpc_id" {
29
+ description = "VPC to attach this Lambda's ENIs to. Only used when enable_vpc_access is true."
30
+ type = string
31
+ default = ""
32
+ }
33
+
34
+ variable "private_subnet_ids" {
35
+ description = "Private subnets to place ENIs in. Only used when enable_vpc_access is true."
36
+ type = list(string)
37
+ default = []
38
+ }
39
+
40
+ variable "memory_size" {
41
+ type = number
42
+ default = 512
43
+ }
44
+
45
+ variable "timeout" {
46
+ type = number
47
+ default = 30
48
+ }
49
+
50
+ variable "environment_variables" {
51
+ type = map(string)
52
+ default = {}
53
+ }
54
+
55
+ variable "tags" {
56
+ type = map(string)
57
+ default = {}
58
+ }
59
+
60
+ variable "sqs_kms_key_id" {
61
+ description = "KMS key ID for SQS queue encryption (CKV_AWS_27). Leave empty for AWS-owned key."
62
+ type = string
63
+ default = ""
64
+ }
65
+
66
+ variable "cloudwatch_kms_key_id" {
67
+ description = "KMS key ID for CloudWatch log group encryption (CKV_AWS_158). Leave empty for AWS-owned key."
68
+ type = string
69
+ default = ""
70
+ }
@@ -0,0 +1,78 @@
1
+ terraform {
2
+ required_providers {
3
+ aws = { source = "hashicorp/aws", version = "~> 5.0" }
4
+ }
5
+ }
6
+
7
+ data "aws_caller_identity" "current" {}
8
+
9
+ locals {
10
+ name_prefix = "${var.project_name}-${var.environment}"
11
+ site_bucket = "${local.name_prefix}-site-${data.aws_caller_identity.current.account_id}"
12
+ logs_bucket = "${local.name_prefix}-logs-${data.aws_caller_identity.current.account_id}"
13
+ }
14
+
15
+ # Access logs bucket
16
+ resource "aws_s3_bucket" "logs" {
17
+ bucket = local.logs_bucket
18
+ force_destroy = true
19
+ tags = var.tags
20
+ }
21
+
22
+ resource "aws_s3_bucket_public_access_block" "logs" {
23
+ bucket = aws_s3_bucket.logs.id
24
+ block_public_acls = true
25
+ block_public_policy = true
26
+ ignore_public_acls = true
27
+ restrict_public_buckets = true
28
+ }
29
+
30
+ resource "aws_s3_bucket_server_side_encryption_configuration" "logs" {
31
+ bucket = aws_s3_bucket.logs.id
32
+ rule {
33
+ apply_server_side_encryption_by_default {
34
+ sse_algorithm = "AES256"
35
+ }
36
+ }
37
+ }
38
+
39
+ # This sibling's static frontend export — served via the PARENT project's
40
+ # CloudFront distribution as a new origin/ordered_cache_behavior (ADR-0007;
41
+ # see modules/cloud/aws/cdn/main.tf in the parent repo). The bucket policy
42
+ # trusting that distribution's ARN is deliberately NOT in this module — it
43
+ # lives in infra/main.tf, parameterized by var.parent_cloudfront_distribution_arn,
44
+ # because this module has no way to know that ARN (it belongs to a
45
+ # distribution this repo doesn't own or create).
46
+ resource "aws_s3_bucket" "site" {
47
+ bucket = local.site_bucket
48
+ force_destroy = true
49
+ tags = var.tags
50
+ }
51
+
52
+ resource "aws_s3_bucket_public_access_block" "site" {
53
+ bucket = aws_s3_bucket.site.id
54
+ block_public_acls = true
55
+ block_public_policy = true
56
+ ignore_public_acls = true
57
+ restrict_public_buckets = true
58
+ }
59
+
60
+ resource "aws_s3_bucket_versioning" "site" {
61
+ bucket = aws_s3_bucket.site.id
62
+ versioning_configuration { status = "Enabled" }
63
+ }
64
+
65
+ resource "aws_s3_bucket_server_side_encryption_configuration" "site" {
66
+ bucket = aws_s3_bucket.site.id
67
+ rule {
68
+ apply_server_side_encryption_by_default {
69
+ sse_algorithm = "AES256"
70
+ }
71
+ }
72
+ }
73
+
74
+ resource "aws_s3_bucket_logging" "site" {
75
+ bucket = aws_s3_bucket.site.id
76
+ target_bucket = aws_s3_bucket.logs.id
77
+ target_prefix = "site-access-logs/"
78
+ }
@@ -0,0 +1,4 @@
1
+ output "site_bucket_name" { value = aws_s3_bucket.site.bucket }
2
+ output "site_bucket_arn" { value = aws_s3_bucket.site.arn }
3
+ output "site_bucket_regional_domain" { value = aws_s3_bucket.site.bucket_regional_domain_name }
4
+ output "logs_bucket_name" { value = aws_s3_bucket.logs.bucket }
@@ -0,0 +1,12 @@
1
+ variable "project_name" {
2
+ type = string
3
+ }
4
+
5
+ variable "environment" {
6
+ type = string
7
+ }
8
+
9
+ variable "tags" {
10
+ type = map(string)
11
+ default = {}
12
+ }
@@ -0,0 +1,69 @@
1
+ [project]
2
+ name = "sibling-api"
3
+ version = "0.0.0"
4
+ description = "Sibling app backend — verifies the shared core Cognito JWT and calls the core API for all data (ADR-0002/ADR-0007). No database client of any kind belongs in this service."
5
+ requires-python = ">=3.13"
6
+ dependencies = [
7
+ "fastapi>=0.115.6",
8
+ "mangum>=0.19.0",
9
+ "pydantic>=2.10.4",
10
+ "pydantic-settings>=2.7.1",
11
+ "aws-lambda-powertools[tracer]>=3.4.0",
12
+ "pyjwt[crypto]>=2.10.0",
13
+ "httpx>=0.28.1",
14
+ ]
15
+
16
+ [dependency-groups]
17
+ dev = [
18
+ "pytest>=8.3.4",
19
+ "pytest-asyncio>=0.25.2",
20
+ "pytest-cov>=6.0.0",
21
+ "bandit>=1.8.0",
22
+ "pip-audit>=2.8.0",
23
+ "ruff>=0.8.0",
24
+ "pyright>=1.1.0",
25
+ ]
26
+
27
+ [build-system]
28
+ requires = ["hatchling"]
29
+ build-backend = "hatchling.build"
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["src/api"]
33
+
34
+ [tool.ruff]
35
+ target-version = "py313"
36
+ line-length = 100
37
+
38
+ [tool.ruff.lint]
39
+ select = ["E", "F", "I", "N", "W", "B", "S", "UP", "ANN"]
40
+ ignore = [
41
+ "S101", # allow assert in tests
42
+ "B008", # FastAPI's Depends(...)/Security(...) default-argument pattern is
43
+ # the sanctioned idiom (see FastAPI's own docs) — not the mutable-default
44
+ # footgun this rule otherwise guards against.
45
+ ]
46
+
47
+ [tool.ruff.lint.per-file-ignores]
48
+ "tests/**/*.py" = ["S101", "ANN"]
49
+
50
+ [tool.ruff.format]
51
+ quote-style = "double"
52
+ indent-style = "space"
53
+
54
+ [tool.pyright]
55
+ pythonVersion = "3.13"
56
+ typeCheckingMode = "standard"
57
+ venvPath = "."
58
+ venv = ".venv"
59
+
60
+ [tool.pytest.ini_options]
61
+ testpaths = ["tests"]
62
+ python_files = ["test_*.py", "*_test.py"]
63
+ python_classes = ["Test*"]
64
+ python_functions = ["test_*"]
65
+ asyncio_mode = "auto"
66
+
67
+ [tool.coverage.run]
68
+ source = ["src"]
69
+ omit = ["*/tests/*"]
@@ -0,0 +1,31 @@
1
+ from pydantic_settings import BaseSettings, SettingsConfigDict
2
+
3
+
4
+ class Settings(BaseSettings):
5
+ # Change this prefix to your own sibling's name if you want a more
6
+ # specific env var namespace — just keep it consistent with
7
+ # infra/main.tf's environment_variables block.
8
+ model_config = SettingsConfigDict(env_prefix="SIBLING_", case_sensitive=False)
9
+
10
+ # Cognito — deliberately the CORE project's pool/client, not a new one
11
+ # (ADR-0007): this sibling verifies the same JWT the core portal issued,
12
+ # so users get single sign-on across the core and every sibling for free.
13
+ cognito_user_pool_id: str = ""
14
+ cognito_client_id: str = ""
15
+ cognito_region: str = "us-east-1"
16
+ # Pre-loaded JWKS JSON string — set by Terraform at apply time in no-NAT
17
+ # dev environments so the Lambda can verify JWTs without an outbound
18
+ # call. If empty, the JWKS is fetched at runtime (requires NAT or a
19
+ # cognito-idp VPC endpoint).
20
+ cognito_jwks_json: str = ""
21
+
22
+ # The core project's own API — the ONLY place this service is allowed to
23
+ # get or persist data (ADR-0002). Never add a database client here.
24
+ core_api_url: str = ""
25
+
26
+ environment: str = "dev"
27
+ log_level: str = "INFO"
28
+ cors_origins: list[str] = ["http://localhost:3000"]
29
+
30
+
31
+ settings = Settings()