diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b62400..fb37c76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,3 +60,166 @@ jobs: - name: Build run: npm run build --prefix server + + deploy: + name: Deploy to production + runs-on: ubuntu-latest + needs: [frontend, backend] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + timeout-minutes: 15 + concurrency: + group: deploy-production + cancel-in-progress: false + permissions: + contents: read + id-token: write + + steps: + - name: Configure AWS credentials via OIDC + uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1 + with: + role-to-assume: ${{ vars.AWS_ROLE_ARN }} + aws-region: us-east-1 + + - name: Fetch server secrets from SSM + run: | + aws ssm get-parameters-by-path \ + --path "/havenhold/prod/server" \ + --with-decryption \ + --query "Parameters[*].{Name:Name,Value:Value}" \ + --output json \ + | python3 -c " + import json, sys, pathlib + params = json.load(sys.stdin) + lines = [] + for p in params: + key = p['Name'].rsplit('/', 1)[-1] + value = p['Value'] + if '\n' in value or '\r' in value: + print(f'ERROR: SSM parameter {key} contains a newline — not supported in EnvironmentFile format', file=sys.stderr) + sys.exit(1) + lines.append(key + '=' + repr(value)) + pathlib.Path('server.env').write_text('\n'.join(lines) + '\n') + " + + - name: Fetch frontend secrets from SSM + run: | + aws ssm get-parameters-by-path \ + --path "/havenhold/prod/frontend" \ + --with-decryption \ + --query "Parameters[*].{Name:Name,Value:Value}" \ + --output json \ + | python3 -c " + import json, sys, pathlib + params = json.load(sys.stdin) + lines = [] + for p in params: + key = p['Name'].rsplit('/', 1)[-1] + value = p['Value'] + if '\n' in value or '\r' in value: + print(f'ERROR: SSM parameter {key} contains a newline — not supported in EnvironmentFile format', file=sys.stderr) + sys.exit(1) + lines.append(key + '=' + repr(value)) + pathlib.Path('frontend.env').write_text('\n'.join(lines) + '\n') + " + + - name: Append feature flag defaults to frontend.env + run: | + grep -q '^VITE_PIPELINE_ENABLED=' frontend.env || echo 'VITE_PIPELINE_ENABLED=true' >> frontend.env + grep -q '^VITE_INTEGRATIONS_ENABLED=' frontend.env || echo 'VITE_INTEGRATIONS_ENABLED=false' >> frontend.env + + - name: Validate required secrets are present and non-empty + run: | + python3 - <<'PY' + import ast + import sys + from pathlib import Path + + required_server = [ + "DATABASE_URL", + "ANTHROPIC_API_KEY", + "BETTER_AUTH_SECRET", + "BETTER_AUTH_URL", + "CORS_ORIGIN", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_S3_BUCKET", + "NODE_ENV", + "PORT", + "AWS_REGION", + ] + required_frontend = ["VITE_AUTH_BASE_URL", "VITE_API_BASE_URL"] + + def parse_env(path: str) -> dict[str, str]: + values: dict[str, str] = {} + for raw_line in Path(path).read_text().splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + key, raw_value = line.split("=", 1) + key = key.strip() + value = raw_value.strip() + if not key: + continue + + # SSM-loaded values are written with Python repr(), so decode quoted + # literals before validating non-empty semantics. + if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'): + try: + decoded = ast.literal_eval(value) + except Exception: + decoded = value + else: + decoded = value + + values[key] = decoded + return values + + server = parse_env("server.env") + frontend = parse_env("frontend.env") + + missing: list[str] = [] + for key in required_server: + value = server.get(key) + if value is None or value == "": + missing.append(f"server: {key}") + + for key in required_frontend: + value = frontend.get(key) + if value is None or value == "": + missing.append(f"frontend: {key}") + + if missing: + print("ERROR: Missing or empty required parameters from SSM:") + for item in missing: + print(f" {item}") + sys.exit(1) + + print("All required parameters present and non-empty.") + PY + + - name: Install SSH key and pin host fingerprint + run: | + mkdir -p ~/.ssh + echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + echo "${{ secrets.DEPLOY_KNOWN_HOST }}" >> ~/.ssh/known_hosts + + - name: SCP env files to server + run: | + scp -i ~/.ssh/deploy_key server.env ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/opt/havenhold/server/.env + scp -i ~/.ssh/deploy_key frontend.env ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/opt/havenhold/.env + + - name: Run deploy.sh on server + run: | + ssh -i ~/.ssh/deploy_key ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} \ + "sudo /usr/bin/bash /opt/havenhold/infra/deploy.sh" + + - name: Verify health endpoint + run: | + STATUS=$(ssh -i ~/.ssh/deploy_key ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} \ + "curl -sf -o /dev/null -w '%{http_code}' http://127.0.0.1:3001/api/health") + [[ "$STATUS" == "200" ]] || { echo "ERROR: health check returned HTTP $STATUS"; exit 1; } + echo "Health check passed (HTTP $STATUS)" diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..cf93c0e --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,177 @@ +# Deployment Architecture + +This document covers how Havenhold's deployment pipeline works, the decisions behind it, and how to operate it day-to-day. For the one-time setup steps, see `docs/tickets/feat-H-041-implementation-plan.md`. + +--- + +## Architecture Overview + +``` +Push to main + │ + ▼ +GitHub Actions (ci.yml) + ├─ frontend job: lint / test / build + ├─ backend job: test / build + └─ deploy job (only on push, after CI passes) + │ + ├─ Authenticate to AWS via OIDC + │ └─ Assumes IAM role havenhold-github-actions + │ (no static credentials stored anywhere) + │ + ├─ Fetch secrets from SSM Parameter Store + │ ├─ /havenhold/prod/server/* → server.env + │ └─ /havenhold/prod/frontend/* → frontend.env + │ + ├─ Validate all required keys are present and non-empty + │ + ├─ SCP .env files to Lightsail server + │ + └─ SSH → sudo /usr/bin/bash /opt/havenhold/infra/deploy.sh + ├─ git pull + ├─ npm ci + build (frontend + backend) + ├─ prisma migrate deploy + ├─ prisma db seed + ├─ systemctl restart havenhold-api + └─ health check (30s poll) +``` + +**Key design decisions:** + +- **OIDC, not static keys.** The GitHub Actions runner authenticates to AWS as a federated identity. AWS issues short-lived credentials per workflow run. No long-lived AWS key is stored in GitHub secrets or on the server. The IAM role trust policy is scoped to `repo:dashprotocol/Havenhold:ref:refs/heads/main` — other branches and forks cannot assume it. + +- **SSM as the secrets source of truth.** All production env vars live in SSM Parameter Store under `/havenhold/prod/`. The `.env` files on the server are written at deploy time and treated as ephemeral build artifacts, not manually maintained files. + +- **Why `.env` files, not app-fetches-at-startup.** Lightsail instances don't support EC2 instance profiles, so the running app has no IAM identity. Writing `.env` at deploy time is the correct pattern for this host type; systemd reads it once via `EnvironmentFile`. + +--- + +## SSM Parameter Layout + +All parameters live under `/havenhold/prod/`. SecureString is used for credentials; String for non-sensitive config. + +### Server parameters (`/havenhold/prod/server/`) + +| Parameter | Type | Notes | +|---|---|---| +| `DATABASE_URL` | SecureString | PostgreSQL connection string | +| `ANTHROPIC_API_KEY` | SecureString | Claude API key | +| `BETTER_AUTH_SECRET` | SecureString | Session signing secret (`openssl rand -base64 32`) | +| `BETTER_AUTH_URL` | SecureString | Canonical API origin (`https://`) | +| `CORS_ORIGIN` | SecureString | Allowed CORS origin (`https://`) | +| `AWS_ACCESS_KEY_ID` | SecureString | havenhold-api IAM user key (S3 access) | +| `AWS_SECRET_ACCESS_KEY` | SecureString | havenhold-api IAM user secret | +| `AWS_S3_BUCKET` | String | From `terraform output bucket_name` | +| `AWS_REGION` | String | `us-east-1` | +| `NODE_ENV` | String | `production` | +| `PORT` | String | `3001` | +| `PIPELINE_ENABLED` | String | `true` / `false` | +| `INTEGRATIONS_ENABLED` | String | `true` / `false` | + +### Frontend parameters (`/havenhold/prod/frontend/`) + +| Parameter | Type | Notes | +|---|---|---| +| `VITE_AUTH_BASE_URL` | SecureString | `https://` | +| `VITE_API_BASE_URL` | SecureString | `https:///api` | + +`VITE_PIPELINE_ENABLED` and `VITE_INTEGRATIONS_ENABLED` are not in SSM — the workflow appends safe defaults (`true` / `false`) if absent. + +--- + +## GitHub Secrets and Variables + +Stored in repo Settings → Secrets and variables → Actions. + +| Type | Name | Value | +|---|---|---| +| Variable | `AWS_ROLE_ARN` | IAM role ARN from `terraform output github_actions_role_arn` | +| Secret | `DEPLOY_HOST` | Lightsail static IP | +| Secret | `DEPLOY_USER` | `adminuser` | +| Secret | `DEPLOY_SSH_KEY` | Full PEM content of the deploy private key | +| Secret | `DEPLOY_KNOWN_HOST` | **All lines** from `ssh-keyscan -H ` — store the full output, not just one key type | + +`AWS_ROLE_ARN` is a variable (not a secret) because the role ARN is an identifier, not a credential. + +--- + +## Day-to-Day Operations + +### Deploying + +Push to `main` (or merge a PR). The deploy job runs automatically after `frontend` and `backend` CI jobs pass. Monitor progress in the Actions tab. + +The deploy job is **skipped** on pull requests — this is expected behavior. + +### Updating a secret + +```bash +aws ssm put-parameter --region us-east-1 --overwrite \ + --type SecureString \ + --name /havenhold/prod/server/ \ + --value "" +``` + +The updated value takes effect on the next push to `main` — no server-side action needed. + +### Rotating the deploy SSH key + +```bash +# Generate new key +ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/havenhold-deploy-new -N "" + +# Add new public key to server (before removing old one) +ssh adminuser@ \ + "echo '$(cat ~/.ssh/havenhold-deploy-new.pub)' >> ~/.ssh/authorized_keys" + +# Update DEPLOY_SSH_KEY GitHub secret with new private key content +# Verify a deploy succeeds, then remove the old public key from authorized_keys +``` + +--- + +## Emergency Manual Deploy + +If GitHub Actions is unavailable, deploy manually. Requires the `.env` files to already exist on the server (they persist between automated deploys). If they've been deleted, recreate them from SSM first: + +```bash +# Fetch and write server .env (run locally, requires AWS CLI with SSM access) +aws ssm get-parameters-by-path \ + --region us-east-1 --path /havenhold/prod/server --with-decryption \ + --query "Parameters[*].{Name:Name,Value:Value}" --output json \ +| python3 -c " +import json, sys, pathlib +params = json.load(sys.stdin) +lines = [p['Name'].rsplit('/',1)[-1] + '=' + repr(p['Value']) for p in params] +pathlib.Path('server.env').write_text('\n'.join(lines)+'\n') +" +scp server.env adminuser@:/opt/havenhold/server/.env + +# Then SSH and run deploy.sh +ssh adminuser@ "sudo /usr/bin/bash /opt/havenhold/infra/deploy.sh" +``` + +--- + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| Deploy job skipped on push to main | CI job (`frontend` or `backend`) failed | Check CI job logs; fix the failing test or build | +| `ERROR: Missing or empty required parameters` | SSM parameter missing or blank | Add/fix the parameter with `aws ssm put-parameter --overwrite` | +| `ERROR: SSM parameter X contains a newline` | SSM value has embedded newline | Re-store the value without newlines; EnvironmentFile format doesn't support them | +| `Permission denied` on SCP step | `/opt/havenhold` not owned by `adminuser` | SSH to server: `sudo chown -R adminuser:adminuser /opt/havenhold` | +| `Host key verification failed` | `DEPLOY_KNOWN_HOST` secret has wrong/incomplete fingerprint | Re-run `ssh-keyscan -H ` and update the secret with all output lines | +| `sudo: /usr/bin/bash: command not found` or permission error | Sudoers entry missing or path mismatch | SSH to server and verify `/etc/sudoers.d/havenhold-deploy` | +| Health check times out after deploy | Service failed to start | SSH to server: `journalctl -u havenhold-api -n 50` | + +--- + +## Related + +- `infra/deploy.sh` — the deploy script run on the server +- `infra/terraform/iam.tf` — OIDC provider, IAM role, and SSM policy definitions +- `.github/workflows/ci.yml` — CI and deploy workflow +- `docs/runbook/s3-iam.md` — S3 and havenhold-api IAM user setup +- `docs/architecture.md` — overall system architecture +- `docs/tickets/feat-H-041-implementation-plan.md` — implementation decisions and one-time setup steps diff --git a/docs/server-user-model.md b/docs/server-user-model.md new file mode 100644 index 0000000..da9681a --- /dev/null +++ b/docs/server-user-model.md @@ -0,0 +1,123 @@ +# Server User Model: Why There Are Two Users + +## The Core Idea + +The server runs two distinct Linux users: `havenadmin` and `havenhold`. They are not interchangeable. Each exists for a specific purpose, and keeping them separate is a deliberate security design. + +The principle behind it is called **least privilege**: every process should be able to access only what it strictly needs to do its job, and nothing more. If something goes wrong — a bug, a dependency vulnerability, a compromised session — the damage is limited to what that process was allowed to touch. + +--- + +## The Two Users + +### `havenadmin` — the operator + +`havenadmin` is a real human-accessible account. It has a login shell, an SSH key, and `sudo` access. This is the account you use when you SSH into the server to do anything administrative: inspect logs, run migrations manually, troubleshoot, update configuration. + +In the H-041 deployment pipeline, `havenadmin` is also the identity GitHub Actions uses via SSH to deploy. It SCPs the `.env` files onto the server and invokes `deploy.sh` via sudo. + +**What `havenadmin` can do:** +- SSH into the server +- Run `sudo` (selectively, not full root) +- Write deployment artifacts (pull code, write `.env` files, build output) +- Restart the service via `deploy.sh` +- Read anything on the filesystem via `sudo` + +### `havenhold` — the runtime + +`havenhold` is a system account. It has no login shell, no SSH access, no sudo rights, and no home directory. You can never SSH in as `havenhold`. It exists for exactly one purpose: to be the identity the running Node.js application process runs under. + +When systemd starts the API server (`havenhold-api.service`), it sets `User=havenhold`. Every HTTP request the app handles, every database query it runs, every file it reads or writes — all of that happens as `havenhold`. + +**What `havenhold` can do:** +- Read its own configuration (`server/.env`) +- Write uploaded files to `uploads/` +- Connect to the database +- Handle incoming requests + +**What `havenhold` cannot do:** +- SSH in +- Run sudo +- Read or write anything outside what it explicitly owns or has group access to +- Modify the application code or deployment scripts + +--- + +## Why the Separation Matters + +Imagine the app has a vulnerability — a dependency with a security flaw, or a bug that lets an attacker execute arbitrary code through an HTTP request. If the app runs as `havenadmin`, the attacker lands with an account that has sudo access, an SSH key, and write access to the entire codebase. They can modify `deploy.sh`, install backdoors, and escalate to root. + +If the app runs as `havenhold`, the attacker lands with a locked-down service account that can only touch what the app itself touches — uploaded files and the database connection. The rest of the server is inaccessible to them. + +This is the blast radius reduction the two-user model provides. + +--- + +## File Ownership in Practice + +The ownership pattern on the server follows directly from which user needs to interact with each file. + +| Path | Owner | Permissions | Why | +|---|---|---|---| +| `/opt/havenhold/` (directory) | `havenadmin:havenadmin` | `755` | Deploy process manages the repo | +| `/opt/havenhold/server/node_modules/` | `havenadmin:havenadmin` | `755` | Written by `npm ci` during deploy | +| `/opt/havenhold/dist/` | `havenadmin:havenadmin` | `755` | Written by Vite build during deploy | +| `/opt/havenhold/server/.env` | `havenadmin:havenhold` | `640` | Written by deploy, read by service | +| `/opt/havenhold/server/uploads/` | `havenhold:havenhold` | `755` | Written at runtime by the app | + +The `.env` file is the most interesting case. It sits at the boundary between the two users: + +- The **deploy process** (`havenadmin`) writes it fresh on every deploy via SCP +- The **running service** (`havenhold`) reads it at startup via systemd's `EnvironmentFile=` + +The `640` permission (owner read/write, group read, others nothing) with `havenadmin:havenhold` ownership is the exact solution to that dual-access requirement: `havenadmin` can write it, `havenhold` can read it via group membership, and no other user on the system can touch it. + +--- + +## Understanding Linux Permission Notation + +When you see `havenhold:havenhold 600` or `havenadmin:havenhold 640`, here is how to read it: + +``` +havenadmin : havenhold 640 + ^owner ^group ^permissions +``` + +The three-digit permission number maps to three groups of access: + +``` +6 4 0 +^ ^ ^ +| | └─ others (everyone else): no access +| └──── group (havenhold): read only +└─────── owner (havenadmin): read + write +``` + +Each digit is a sum: read=4, write=2, execute=1. So `6` = read+write, `4` = read only, `0` = no access. + +For `server/.env` with `havenadmin:havenhold 640`: +- `havenadmin` (the owner) can read and write it +- Any user in the `havenhold` group — which the `havenhold` service account is — can read it +- Everyone else gets nothing + +For comparison, `havenhold:havenhold 600` (how it was set up in H-010): +- Only `havenhold` itself could read and write it +- The `havenhold` group had no access +- Everyone else got nothing + +The shift from `600` to `640` was necessary when H-041 made `havenadmin` responsible for writing the file on every automated deploy. The `600` model worked when a human manually handed ownership to `havenhold` after writing it. Automation changed the ownership model. + +--- + +## The Deploy Flow End to End + +To make all of this concrete, here is what happens on every push to `main`: + +1. GitHub Actions SSHes in as `havenadmin` and SCPs the fresh `.env` files onto the server. At this point the files are owned by `havenadmin:havenadmin`. +2. GitHub Actions SSHes in and runs `sudo /usr/bin/bash /opt/havenhold/infra/deploy.sh`. +3. `deploy.sh` runs as root (via sudo). It immediately runs `chown havenadmin:havenhold server/.env` and `chmod 640` — correcting the ownership so the service user can read it. +4. `deploy.sh` pulls the latest code, installs dependencies, builds, migrates, seeds. +5. `deploy.sh` restarts the `havenhold-api` systemd service. +6. systemd starts the Node.js process as `havenhold`. The process reads `server/.env` via group read access. The app is live. + +At runtime, `havenadmin` is not involved at all. The running server is entirely `havenhold`'s domain. diff --git a/infra/deploy.sh b/infra/deploy.sh index 0d60e6f..a8ac1dd 100755 --- a/infra/deploy.sh +++ b/infra/deploy.sh @@ -17,18 +17,24 @@ log "1/12 Checking prerequisites" command -v node >/dev/null 2>&1 || { echo "ERROR: node not found"; exit 1; } command -v npm >/dev/null 2>&1 || { echo "ERROR: npm not found"; exit 1; } command -v git >/dev/null 2>&1 || { echo "ERROR: git not found"; exit 1; } -[[ -f "$SERVER_DIR/.env" ]] || { echo "ERROR: $SERVER_DIR/.env not found — create it first"; exit 1; } +[[ -f "$SERVER_DIR/.env" ]] || { echo "ERROR: $SERVER_DIR/.env not found — CI/CD writes this via SCP; for manual deploys create it from server/.env.example"; exit 1; } # Frontend env vars are baked in at build time — require the .env file and validate # required vars before building. FRONTEND_ENV="$APP_DIR/.env" [[ -f "$FRONTEND_ENV" ]] \ - || { echo "ERROR: $FRONTEND_ENV not found — create it from .env.example before deploying"; exit 1; } + || { echo "ERROR: $FRONTEND_ENV not found — CI/CD writes this via SCP; for manual deploys create it from .env.example"; exit 1; } grep -qE '^VITE_AUTH_BASE_URL=.+' "$FRONTEND_ENV" \ || { echo "ERROR: VITE_AUTH_BASE_URL not set in $FRONTEND_ENV — auth will silently target localhost in production browsers"; exit 1; } grep -qE '^VITE_API_BASE_URL=.+' "$FRONTEND_ENV" \ || { echo "ERROR: VITE_API_BASE_URL not set in $FRONTEND_ENV"; exit 1; } +# Fix ownership — havenhold systemd service user must be able to read EnvironmentFile +chown "${SUDO_USER:-root}:havenhold" "$SERVER_DIR/.env" "$FRONTEND_ENV" \ + || { echo "ERROR: Failed to set .env ownership to ${SUDO_USER:-root}:havenhold"; exit 1; } +chmod 640 "$SERVER_DIR/.env" "$FRONTEND_ENV" \ + || { echo "ERROR: Failed to set .env permissions to 640"; exit 1; } + # ── 2. Pull latest code ─────────────────────────────────────────────────────── log "2/12 Pulling $BRANCH" git -C "$APP_DIR" pull --ff-only origin "$BRANCH" diff --git a/infra/terraform/iam.tf b/infra/terraform/iam.tf index bf9cffc..357227c 100644 --- a/infra/terraform/iam.tf +++ b/infra/terraform/iam.tf @@ -1,3 +1,51 @@ +# ── GitHub Actions OIDC (no static credentials) ─────────────────────────────── +resource "aws_iam_openid_connect_provider" "github_actions" { + url = "https://token.actions.githubusercontent.com" + client_id_list = ["sts.amazonaws.com"] + thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"] +} + +resource "aws_iam_role" "github_actions" { + name = "havenhold-github-actions" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Federated = aws_iam_openid_connect_provider.github_actions.arn } + Action = "sts:AssumeRoleWithWebIdentity" + Condition = { + StringEquals = { + "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" + "token.actions.githubusercontent.com:sub" = "repo:dashprotocol/Havenhold:ref:refs/heads/main" + } + } + }] + }) +} + +resource "aws_iam_role_policy" "github_actions_ssm" { + name = "HavenholdSSMReadOnly" + role = aws_iam_role.github_actions.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "SSMReadProd" + Effect = "Allow" + Action = [ + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath" + ] + # If switching to a customer-managed KMS key for SecureString parameters, + # add kms:Decrypt on the key ARN here. + Resource = "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/havenhold/prod/*" + }] + }) +} + +# ── App server S3 access ─────────────────────────────────────────────────────── resource "aws_iam_user" "api" { name = "havenhold-api" path = "/" diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf index d5d0635..53d9bc1 100644 --- a/infra/terraform/outputs.tf +++ b/infra/terraform/outputs.tf @@ -12,3 +12,8 @@ output "iam_user_arn" { description = "ARN of the havenhold-api IAM user" value = aws_iam_user.api.arn } + +output "github_actions_role_arn" { + description = "ARN of the havenhold-github-actions IAM role (assumed via OIDC by GitHub Actions deploy job)" + value = aws_iam_role.github_actions.arn +} diff --git a/server/.env.example b/server/.env.example index 11635a0..406a21e 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1,27 +1,51 @@ # Backend runtime configuration +# In production, secrets are stored in AWS SSM Parameter Store under +# /havenhold/prod/server/ and written to this file by CI/CD at deploy time. +# For local development, copy this file to server/.env and fill in real values. + DATABASE_URL="" +# SSM path: /havenhold/prod/server/DATABASE_URL + ANTHROPIC_API_KEY="" +# SSM path: /havenhold/prod/server/ANTHROPIC_API_KEY + PORT=3001 +# Not in SSM — hardcoded default is always 3001. # better-auth session secret — generate with: openssl rand -base64 32 BETTER_AUTH_SECRET="" +# SSM path: /havenhold/prod/server/BETTER_AUTH_SECRET + # better-auth canonical API origin (used for callbacks/redirects) BETTER_AUTH_URL=http://localhost:3001 +# SSM path: /havenhold/prod/server/BETTER_AUTH_URL # Feature flags # Defaults are safe — omit these lines to use the defaults shown below. PIPELINE_ENABLED=true # true = AI document pipeline active (default). false = /documents/upload returns 503. INTEGRATIONS_ENABLED=false # false = no outbound integration calls (default). Reserved for future use. +# SSM paths: /havenhold/prod/server/PIPELINE_ENABLED, /havenhold/prod/server/INTEGRATIONS_ENABLED # Deployment environment and CORS (production host only — omit or set NODE_ENV=development locally) NODE_ENV=production +# SSM path: /havenhold/prod/server/NODE_ENV + CORS_ORIGIN=https:// +# SSM path: /havenhold/prod/server/CORS_ORIGIN -# AWS S3 object storage — provisioned at D2 (H-008/H-016) +# AWS S3 object storage — provisioned by Terraform (infra/terraform/) +# These are credentials for the havenhold-api IAM user (S3 access only). # Run `terraform output` in infra/terraform/ after applying to get BUCKET_NAME. -# Generate access key with: aws iam create-access-key --user-name havenhold-api +# Generate access key: aws iam create-access-key --user-name havenhold-api # Never commit real values. See docs/runbook/s3-iam.md. AWS_REGION=us-east-1 +# SSM path: /havenhold/prod/server/AWS_REGION + AWS_ACCESS_KEY_ID= +# SSM path: /havenhold/prod/server/AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY= +# SSM path: /havenhold/prod/server/AWS_SECRET_ACCESS_KEY + AWS_S3_BUCKET= +# SSM path: /havenhold/prod/server/AWS_S3_BUCKET