CultureQ 2.0 Infrastructure
ArchitectureSequence
Infrastructure map of the CultureQ 2.0 monorepo: multi-tenant platform for cultural organisations (Vault asset storage). Next.js Workspace on ECS Fargate behind
Open & fork this on Zstem →Architecture
- CI/CD — GitHub Actions → AWS via OIDC (ADR-0013): No long-lived keys. PRs run ci.yml + read-only terraform plan; merge to main deploys the Workspace image; terraform applies are manual workflow_dispatch gated by the infra-production environment.
- Edge — DNS, TLS, public subnets: Public entry into the ap-southeast-6 VPC. ALB terminates TLS; optional Cognito OIDC gate for preview environments (ADR-0014).
- VPC — private subnets (ap-southeast-6): No NAT gateway: all AWS API traffic rides interface endpoints + an S3 gateway endpoint. ssmmessages endpoint + ECS Exec are flipped on only for admin DB tunnels (ADR-0015).
- Regional platform services (platform stack): Shared per-region foundation provisioned by infra/terraform/platform.
- Per-tenant slice (tenant stack — 1 workspace per tenant): A tenant exists iff this Terraform state exists (ADR-0003/0008). Isolation is structural: dedicated DB + role, prefix-scoped IAM, KMS grant, registry entry.
- Workspace container — pnpm monorepo contexts: Dependency direction: platform ← vault ← workspace; tools depend on platform. Every Vault mutation goes through the packages/vault service layer with a required audit event (the one door).
- GitHub monorepo: pnpm + Turborepo. apps/workspace, packages/vault, packages/platform, tools/migrate, tools/seed, infra/terraform.
- ci.yml — verify: Biome lint/format, typecheck, tests, build on every PR and push to main.
- infra.yml — terraform plan: Read-only plan rendered into the job summary for PRs touching infra/terraform/. Applies are manual + confirmed.
- deploy.yml — build & deploy: On merge to main: docker build apps/workspace, push to ECR, force new ECS deployment.
- Terraform state buckets: Created by the bootstrap stack; one backend per stack (github-ci, platform, tenant workspaces).
- OIDC IAM roles: github-ci stack: plan (read-only), apply (infra-production gated), deploy (ECR push + ECS UpdateService, scoped to cultureq-*).
- Tenant users: Workspace admins at <slug>.cultureq.app. Auth.js session cookie; edge middleware pins x-tenant-slug from the subdomain.
- Route 53: Hosted zone; A-alias for the Workspace domain + ACM DNS validation records.
- ACM certificate: DNS-validated TLS cert attached to the HTTPS listener.
- Application Load Balancer: Public subnets across 2 AZs. HTTPS :443 → target group; HTTP redirects to HTTPS.
- Cognito preview pool: Preview-only login fronting the ALB via authenticate-oidc (ap-southeast-6 ALBs lack authenticate-cognito).
- Workspace service (Fargate): Next.js container from ECR behind the ALB (ADR-0011). Execution role injects DATABASE_SECRET; CPU-target autoscaling; CloudWatch logs.
- Aurora PostgreSQL cluster: Shared regional cluster; one database + login role per tenant (ADR-0003). Private subnets only; reachable solely from the service SG.
- VPC endpoints: Interface endpoints (ECR api/dkr, CloudWatch Logs, Secrets Manager, SSM, ssmmessages) + S3 gateway endpoint.
- ECR — workspace image: Single Docker image for the Workspace; scan-on-push; lifecycle keeps last 20 images.
- CloudWatch Logs: ECS task log group + autoscaling alarms.
- Secrets Manager: RDS-managed master secret (injected into the container at start-up) + one credentials secret per tenant.
- SSM Parameter Store — tenant registry: /cultureq/tenants/* — the app's source of truth for tenant resolution (ADR-0008). Pointers only, never credentials.
- S3 assets bucket: One bucket per region, per-tenant prefixes (<slug>/assets/<uuid>/...). Versioned, SSE-KMS, CORS for presigned browser transfers.
- KMS assets key: CMK encrypting the assets bucket; per-tenant grants scope decrypt to each tenant's asset role.
- Tenant database + role: Dedicated Postgres DB on the shared cluster, owned by master; PUBLIC CONNECT revoked, CONNECT granted only to cq_<slug>. Table grants applied by tools/migrate keep audit_events append-only.
- Tenant DB secret: Credentials for the tenant login role; referenced by ARN from the registry entry.
- Tenant asset role: IAM role assumable by the app task role, scoped to the tenant's S3 prefix, with a matching KMS grant.
- /cultureq/tenants/<slug>: Registry entry: hostname, region, s3_prefix, secret ARN, asset role ARN.
- apps/workspace: Next.js App Router admin. Edge middleware resolves subdomain → tenant slug and gates /vault; Auth.js sign-in; tRPC route handlers as thin skins.
- packages/vault — the one door: Domain layer: asset lifecycle (pending → available), Access/AI-access/Tapu, presigned S3 transfer (ADR-0009), audit events in the same transaction (ADR-0010).
- packages/platform — tenancy: Tenant registry client, per-tenant DB pools, tenant context, single shared Drizzle schema, Auth.js config.
- tools/seed: Seeds a tenant database (users, sample data) via the platform layer.
- tools/migrate — fan-out: Lists the registry and runs Drizzle migrations + table grants against every tenant DB before each deploy (expand/contract).
Sequence
Participants: Browser (<slug>.cultureq.app), Edge middleware, Workspace (Next.js + tRPC), packages/vault (one door), Tenant DB (Aurora), S3 assets bucket
- Browser (<slug>.cultureq.app)→Edge middlewarePOST vault.upload.initiateSession cookie checked at the edge; subdomain parsed to a slug and pinned on x-tenant-slug.
- Edge middleware→Workspace (Next.js + tRPC)forward with x-tenant-slugFull tenant resolution happens server-side via the SSM registry; unknown tenants get 404, never 403.
- Workspace (Next.js + tRPC)→packages/vault (one door)initiateUpload(tenantCtx, actor, input)tRPC router is a thin skin — no business logic; audit event is a required argument.
- packages/vault (one door)→Tenant DB (Aurora)INSERT asset (pending) + audit asset.createdSame transaction (ADR-0010). Key: <slug>/assets/<uuid>/original/<filename>.
- Tenant DB (Aurora)→packages/vault (one door)committed
- packages/vault (one door)→S3 assets bucketsign presigned PUT (prefix-scoped role)
- packages/vault (one door)→Workspace (Next.js + tRPC)assetId + presigned URL
- Workspace (Next.js + tRPC)→Browser (<slug>.cultureq.app)200 { assetId, uploadUrl }
- Browser (<slug>.cultureq.app)→S3 assets bucketPUT bytes via presigned URLDirect to S3 — asset bytes never pass through the app (ADR-0009).
- S3 assets bucket→Browser (<slug>.cultureq.app)200
- Browser (<slug>.cultureq.app)→Workspace (Next.js + tRPC)POST vault.upload.complete(assetId)
- Workspace (Next.js + tRPC)→packages/vault (one door)completeUpload(tenantCtx, actor, assetId)
- packages/vault (one door)→S3 assets bucketHEAD object — verify it exists
- packages/vault (one door)→Tenant DB (Aurora)UPDATE status → available + audit event
- Workspace (Next.js + tRPC)→Browser (<slug>.cultureq.app)200 { status: available }
- packages/vault (one door)→Workspace (Next.js + tRPC)object missing → errorOrphaned pending rows are cleaned up lazily.
- Workspace (Next.js + tRPC)→Browser (<slug>.cultureq.app)409 upload incomplete