Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/terraform.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ on:
- state-mgmt
- docs-site
- web-domains
- profile-assets
- ses
- posthog
- vercel
Expand Down Expand Up @@ -52,6 +53,7 @@ env:
TERRAFORM_SES_DOMAIN_NAME: ${{ vars.TERRAFORM_SES_DOMAIN_NAME }}
TERRAFORM_SES_FROM_EMAIL: ${{ vars.TERRAFORM_SES_FROM_EMAIL }}
TERRAFORM_ROUTE53_ZONE_ID: ${{ vars.TERRAFORM_ROUTE53_ZONE_ID }}
TERRAFORM_PROFILE_ASSETS_ENABLED: ${{ vars.TERRAFORM_PROFILE_ASSETS_ENABLED }}

jobs:
terraform-fmt:
Expand Down Expand Up @@ -95,6 +97,7 @@ jobs:
requires_posthog: "false"
requires_posthog_public_key: "false"
requires_ses_domain: "false"
requires_profile_assets_enabled: "false"
- name: docs-site
path: infra/terraform/docs-site
backend: "true"
Expand All @@ -106,6 +109,7 @@ jobs:
requires_posthog: "false"
requires_posthog_public_key: "false"
requires_ses_domain: "false"
requires_profile_assets_enabled: "false"
- name: web-domains
path: infra/terraform/web-domains
backend: "true"
Expand All @@ -117,6 +121,19 @@ jobs:
requires_posthog: "false"
requires_posthog_public_key: "false"
requires_ses_domain: "false"
requires_profile_assets_enabled: "false"
- name: profile-assets
path: infra/terraform/profile-assets
backend: "true"
plan: "true"
auto_apply: "true"
manual_apply: "true"
requires_aws: "true"
requires_vercel: "true"
requires_posthog: "false"
requires_posthog_public_key: "false"
requires_ses_domain: "false"
requires_profile_assets_enabled: "true"
- name: ses
path: infra/terraform/ses
backend: "true"
Expand All @@ -128,6 +145,7 @@ jobs:
requires_posthog: "false"
requires_posthog_public_key: "false"
requires_ses_domain: "true"
requires_profile_assets_enabled: "false"
- name: posthog
path: infra/terraform/posthog
backend: "true"
Expand All @@ -139,6 +157,7 @@ jobs:
requires_posthog: "true"
requires_posthog_public_key: "false"
requires_ses_domain: "false"
requires_profile_assets_enabled: "false"
- name: vercel
path: infra/terraform/vercel
backend: "true"
Expand All @@ -150,6 +169,7 @@ jobs:
requires_posthog: "false"
requires_posthog_public_key: "true"
requires_ses_domain: "false"
requires_profile_assets_enabled: "false"
- name: restream-worker
path: infra/terraform/restream-worker
backend: "true"
Expand All @@ -161,6 +181,7 @@ jobs:
requires_posthog: "false"
requires_posthog_public_key: "false"
requires_ses_domain: "false"
requires_profile_assets_enabled: "false"

steps:
- name: Checkout
Expand Down Expand Up @@ -229,6 +250,7 @@ jobs:
if [ "${{ matrix.stack.requires_posthog }}" = "true" ] && [ -z "$POSTHOG_API_KEY" ]; then missing+=("POSTHOG_API_KEY"); fi
if [ "${{ matrix.stack.requires_posthog_public_key }}" = "true" ] && [ -z "$TERRAFORM_POSTHOG_PUBLIC_KEY" ]; then missing+=("TERRAFORM_POSTHOG_PUBLIC_KEY"); fi
if [ "${{ matrix.stack.requires_ses_domain }}" = "true" ] && [ -z "$TERRAFORM_SES_DOMAIN_NAME" ]; then missing+=("TERRAFORM_SES_DOMAIN_NAME"); fi
if [ "${{ matrix.stack.requires_profile_assets_enabled }}" = "true" ] && [ "$TERRAFORM_PROFILE_ASSETS_ENABLED" != "true" ]; then missing+=("TERRAFORM_PROFILE_ASSETS_ENABLED=true"); fi

if [ "${#missing[@]}" -gt 0 ]; then
echo "plan_enabled=false" >> "$GITHUB_OUTPUT"
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.1068.0",
"@convex-dev/auth": "^0.0.92",
"@vercel/oidc-aws-credentials-provider": "^3.1.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"convex": "^1.32.0",
Expand Down
25 changes: 19 additions & 6 deletions apps/web/src/lib/server/profile-asset-storage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { awsCredentialsProvider } from "@vercel/oidc-aws-credentials-provider";

type StorageConfig = {
bucket: string;
Expand All @@ -13,6 +14,13 @@ type StoredObject = {

const cachedClients = new Map<string, S3Client>();

function vercelOidcRoleArn(): string | undefined {
const roleArn = process.env.VRDEX_PROFILE_ASSET_ROLE_ARN;
const normalized = roleArn?.trim();

return normalized ? normalized : undefined;
}

function storageConfig(): StorageConfig | null {
const bucket = process.env.VRDEX_PROFILE_ASSET_BUCKET ?? process.env.VRDEX_ASSET_BUCKET;
const region =
Expand All @@ -25,15 +33,20 @@ function storageConfig(): StorageConfig | null {
return { bucket, region };
}

function s3Client(region: string): S3Client {
const cachedClient = cachedClients.get(region);
function s3Client(config: StorageConfig): S3Client {
const roleArn = vercelOidcRoleArn();
const cacheKey = `${config.region}:${roleArn ?? "default"}`;
const cachedClient = cachedClients.get(cacheKey);

if (cachedClient !== undefined) {
return cachedClient;
}

const client = new S3Client({ region });
cachedClients.set(region, client);
const client = new S3Client({
region: config.region,
...(roleArn !== undefined ? { credentials: awsCredentialsProvider({ roleArn }) } : {}),
});
cachedClients.set(cacheKey, client);

return client;
}
Expand All @@ -53,7 +66,7 @@ export async function putProfileAssetObject(input: {
throw new Error("Profile asset storage is not configured.");
}

await s3Client(config.region).send(
await s3Client(config).send(
new PutObjectCommand({
Bucket: config.bucket,
Key: input.storageKey,
Expand All @@ -72,7 +85,7 @@ export async function getProfileAssetObject(storageKey: string): Promise<StoredO
}

try {
const object = await s3Client(config.region).send(
const object = await s3Client(config).send(
new GetObjectCommand({
Bucket: config.bucket,
Key: storageKey,
Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ This repo keeps durable markdown under `docs/` so product, developer, engineerin
- `docs/developers/public-api.md` - public API posture, versioning, client classes, and rate-limiting direction
- `docs/developers/vrdex-mcp-read-tools.md` - documentation-only first pass for standalone read-only VRDex MCP tools
- `docs/engineering/service-map.md` - cross-link map for services, docs, and implementation surfaces
- `docs/deployment/aws-baseline.md` - first-pass AWS service baseline for SES and future S3 assets
- `docs/deployment/aws-baseline.md` - first-pass AWS service baseline for SES and private S3 profile assets
- `docs/deployment/docs-site.md` - Docusaurus docs deployment runbook for `docs.vrdex.net`
- `docs/developers/self-hosting-and-iac.md` - self-hosting, hosted deployment, and IaC ownership direction
- `docs/deployment/vercel-preview.md` - initial Vercel hosted-preview setup and validation path
Expand Down
14 changes: 11 additions & 3 deletions docs/deployment/aws-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ The first AWS baseline covers:
- Amazon SES for Convex Auth password and email verification messages
- Route 53 DNS records for the SES sender domain
- IAM credentials scoped to SES sending for Convex
- a planned S3 private asset bucket for owner-authored profile assets, tracked by [#115](https://github.com/BASIC-BIT/VRDex/issues/115)
- a private S3 asset bucket for owner-authored profile assets, tracked by [#115](https://github.com/BASIC-BIT/VRDex/issues/115)
- Terraform state in S3 for checked-in infrastructure stacks
- a validation-only hosted restream worker benchmark foundation for ECR, ECS/Fargate, task roles, logs, metrics, secret references, limits, and kill-switch concepts

Expand Down Expand Up @@ -74,9 +74,15 @@ The first asset-storage implementation uses:

Do not make profile asset buckets public. Public profile pages should render through a controlled URL path that can enforce profile visibility, moderation suppression, replacement, and deletion behavior.

Terraform/runtime baseline:

- Terraform stack: `infra/terraform/profile-assets`
- Terraform state key: `profile-assets/terraform.tfstate`
- Hosted bucket name default: `vrdex-profile-assets-${account_id}`
- Hosted runtime auth: Vercel OIDC, scoped to the `vr-dex-web` project and the allowed production/staging environments

Deferred follow-on work:

- [#115](https://github.com/BASIC-BIT/VRDex/issues/115) S3 bucket Terraform/runtime baseline
- moderation or malware scanning
- CloudFront or image optimization
- lifecycle rules and deletion/retention policy
Expand All @@ -85,6 +91,7 @@ Runtime environment/config names:

- `VRDEX_PROFILE_ASSET_BUCKET` or fallback `VRDEX_ASSET_BUCKET`
- `VRDEX_PROFILE_ASSET_REGION`, fallback `AWS_REGION`, or fallback `AWS_DEFAULT_REGION`
- `VRDEX_PROFILE_ASSET_ROLE_ARN` for hosted Vercel OIDC role-based auth
- AWS runtime credentials through the hosting provider, role-based auth, or a narrow access key only if the runtime cannot use role-based auth
- `VRDEX_ASSET_PUBLIC_BASE_URL` only after a controlled public delivery layer exists

Expand All @@ -98,6 +105,7 @@ Current stacks:
- `infra/terraform/ses`: SES domain identity, DKIM, MAIL FROM, Route 53 records, and optional IAM sender key
- `infra/terraform/posthog`: hosted PostHog project metadata
- `infra/terraform/vercel`: hosted Vercel PostHog client environment variables
- `infra/terraform/profile-assets`: private S3 asset bucket, Vercel OIDC IAM role, and hosted profile asset env vars
- `infra/terraform/docs-site`: hosted docs Vercel project/domain and Route 53 DNS
- `infra/terraform/restream-worker`: validation-only hosted worker benchmark foundation; CI validates it but does not plan or apply it

Expand All @@ -119,4 +127,4 @@ Fargate remains the first benchmark path. ECS on EC2 with GPU/NVENC is a measure

Self-hosted AWS usage is limited to the values this repo documents by name: SES sender identity, DNS zone, Terraform state location, and the S3 asset bucket tracked by [#115](https://github.com/BASIC-BIT/VRDex/issues/115).

This baseline does not claim complete self-hosting support. Alternative S3-compatible stores are out of scope until [#115](https://github.com/BASIC-BIT/VRDex/issues/115) creates the first asset abstraction and a follow-up issue or ADR covers provider portability.
This baseline does not claim complete self-hosting support. Alternative S3-compatible stores are out of scope until a follow-up issue or ADR covers provider portability.
4 changes: 2 additions & 2 deletions docs/developers/self-hosting-and-iac.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ The hosted BASIC BIT deployment uses:
| Docs Vercel project and `docs.vrdex.net` domain | `infra/terraform/docs-site` plus workflow | Owns the docs Vercel project, Vercel domain binding, and Route 53 DNS record; runbook lives in `docs/deployment/docs-site.md`. |
| Convex deployment keys and env vars | provider secret store plus docs | Documented in `docs/deployment/convex-environments.md` and `docs/deployment/ses-auth-email.md`. |
| Convex custom domains | deferred manual provider setup | Runbook lives in `docs/deployment/convex-environments.md`; requires Convex Pro and dashboard-provided DNS records before Route 53 records. |
| Profile asset storage | app runtime plus planned Terraform baseline | Runtime variable names and private S3 behavior are documented in `docs/deployment/aws-baseline.md`; [#115](https://github.com/BASIC-BIT/VRDex/issues/115) still owns fuller Terraform/lifecycle hardening. |
| Profile asset storage | `infra/terraform/profile-assets` plus app runtime | Private S3 behavior, hosted Vercel OIDC auth, and runtime variable names are documented in `docs/deployment/aws-baseline.md`; lifecycle, deletion, CDN, and scanning remain follow-up work. |

## Self-Hosted Minimum Components

Expand All @@ -52,7 +52,7 @@ A self-hosted operator should expect to provide:
- a Convex deployment or compatible backend path supported by the repo at that time
- a domain and DNS host
- an SES sender identity or documented transactional email substitute once supported
- an asset object store once profile uploads are implemented by [#115](https://github.com/BASIC-BIT/VRDex/issues/115)
- an asset object store compatible with the profile asset runtime configuration
- OAuth provider applications for enabled login providers
- a product analytics choice, with BASIC BIT hosted PostHog keys intentionally omitted from committed defaults
- secret storage for provider tokens, deploy keys, OAuth secrets, and email credentials
Expand Down
2 changes: 1 addition & 1 deletion docs/engineering/service-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Use this page as the high-level map between VRDex services, docs, and implementa
| Vercel | Hosted web deployments, preview deployments, staging helpers. | [Vercel preview deployment](../deployment/vercel-preview.md), [Self-hosting and IaC](../developers/self-hosting-and-iac.md) |
| Convex | Application data, functions, auth integration, local backend verification. | [Convex bootstrap](../backend/convex-bootstrap.md), [Convex environments](../deployment/convex-environments.md) |
| AWS SES | Auth email sender and domain email verification. | [SES auth email](../deployment/ses-auth-email.md), [AWS service baseline](../deployment/aws-baseline.md) |
| AWS S3 | Planned private profile asset storage baseline. | [AWS service baseline](../deployment/aws-baseline.md) |
| AWS S3 | Private profile asset storage baseline. | [AWS service baseline](../deployment/aws-baseline.md) |
| Route 53 | DNS records for hosted domains, SES, and future provider-owned records. | [AWS service baseline](../deployment/aws-baseline.md), [Self-hosting and IaC](../developers/self-hosting-and-iac.md) |
| PostHog | Hosted product analytics and feature-flag direction. | [Product analytics and feature flags](../agentic/product-analytics-and-feature-flags.md), [Self-hosting and IaC](../developers/self-hosting-and-iac.md) |
| GitHub Actions | Baseline checks, CodeQL, hosted health checks, and deployment automation. | [Contributor workflow](../agentic/contributor-workflow.md), [Definition of done](../agentic/definition-of-done.md) |
Expand Down
2 changes: 1 addition & 1 deletion docs/planning/docs-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ Any docs statement that says work is deferred, planned, future, or blocked on so

Examples:

- `planned S3 private asset bucket ([#115](https://github.com/BASIC-BIT/VRDex/issues/115))`
- `private S3 profile asset bucket ([#115](https://github.com/BASIC-BIT/VRDex/issues/115))`
- `public API route once the v0 API issue lands ([#39](https://github.com/BASIC-BIT/VRDex/issues/39))`

If there is no owning artifact, either create one or rewrite the sentence so it is clearly an uncommitted candidate direction rather than a silent obligation.
Expand Down
4 changes: 2 additions & 2 deletions docs/planning/engineering-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Important planning note:
Likely adjacent service use:

- AWS email capabilities for verification and transactional mail
- AWS S3 for private profile media-kit assets, with [#115](https://github.com/BASIC-BIT/VRDex/issues/115) still owning fuller Terraform/lifecycle hardening
- AWS S3 for private profile media-kit assets, with [#115](https://github.com/BASIC-BIT/VRDex/issues/115) owning the first Terraform/runtime baseline and later issues owning lifecycle hardening

Status: locked stack direction.

Expand Down Expand Up @@ -130,7 +130,7 @@ Infra direction:
- for secrets that must remain in provider secret stores, commit the expected variable name, environment scope, owning service, and rotation/recreation path instead of relying on dashboard-only tribal knowledge
- treat manual dashboard changes as bootstrap or emergency operations that need a follow-up reproducibility artifact
- use `docs/developers/self-hosting-and-iac.md` as the current hosted vs self-hosted deployment reference
- use `docs/deployment/aws-baseline.md` as the current SES and future S3 asset-storage baseline
- use `docs/deployment/aws-baseline.md` as the current SES and S3 asset-storage baseline
- keep profile asset storage narrow: private S3, Block Public Access, server-side encryption, and app-generated presigned URLs before any CDN or image-processing layer

## Follow-on integration ideas
Expand Down
7 changes: 5 additions & 2 deletions infra/terraform/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ VRDex keeps small infrastructure stacks separate so credentials, blast radius, a
- `ses/`: AWS SES sender identity and least-privilege Convex email credentials.
- `posthog/`: hosted PostHog project metadata for product analytics.
- `vercel/`: Vercel project environment variables for the hosted web app.
- `profile-assets/`: private S3 profile media-kit asset bucket, Vercel OIDC runtime role, and hosted web env vars for profile asset storage.
- `docs-site/`: Vercel docs project/domain and Route 53 DNS for `docs.vrdex.net`.
- `web-domains/`: Vercel web project-domain bindings and Route 53 DNS for `vrdex.net` and `www.vrdex.net`.
- `restream-worker/`: validation-only hosted restream worker benchmark foundation for ECR, ECS/Fargate, logs, roles, secret references, and the disabled kill switch.
Expand All @@ -25,13 +26,14 @@ Required CI settings by provider:

| Setting | Type | Used by |
| --- | --- | --- |
| `AWS_TERRAFORM_ROLE_ARN` | repository variable or secret | all S3-backed stacks: `docs-site`, `ses`, `posthog`, `vercel` |
| `VERCEL_TOKEN` or `VERCEL_API_TOKEN` | repository secret | `docs-site`, `vercel` |
| `AWS_TERRAFORM_ROLE_ARN` | repository variable or secret | all S3-backed stacks: `docs-site`, `ses`, `posthog`, `vercel`, `profile-assets` |
| `VERCEL_TOKEN` or `VERCEL_API_TOKEN` | repository secret | `docs-site`, `vercel`, `profile-assets` |
| `POSTHOG_API_KEY` | repository secret | `posthog` |
| `TERRAFORM_POSTHOG_PUBLIC_KEY` | repository secret | `vercel` |
| `TERRAFORM_SES_DOMAIN_NAME` | repository variable | `ses` |
| `TERRAFORM_SES_FROM_EMAIL` | optional repository variable | `ses` |
| `TERRAFORM_ROUTE53_ZONE_ID` | optional repository variable | `docs-site`, `ses` |
| `TERRAFORM_PROFILE_ASSETS_ENABLED=true` | repository variable | `profile-assets` after `state-mgmt` has been applied with profile asset permissions |

`state-mgmt/` is validation-only in CI because it intentionally uses local bootstrap state and owns the GitHub Actions AWS role used by the provider-backed stacks. Apply it manually when changing the shared state bucket or Terraform CI role, then store `terraform output -raw github_actions_terraform_role_arn` in GitHub variable `AWS_TERRAFORM_ROLE_ARN`.

Expand All @@ -42,6 +44,7 @@ The stack count is intentional, but should stay small:
- keep `state-mgmt/` separate because a stack cannot safely use the backend it creates
- keep `vercel/` separate from `docs-site/` because `vercel/` requires the hosted PostHog client key while docs DNS should not depend on analytics secrets
- keep `ses/` separate because it can create IAM access-key material and has a different blast radius from Vercel/PostHog metadata
- keep `profile-assets/` separate because it owns an AWS S3 bucket, AWS IAM OIDC role, and the Vercel env vars that expose that role to the web runtime
- keep `restream-worker/` validation-only until the local `1080p60` media proof and a human-approved AWS benchmark window exist
- combine future stacks only when they share provider credentials, state ownership, and apply cadence without widening secret exposure

Expand Down
Loading