Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a5afc85
ci: narrow CI scope to tests, migration check, SAST, secrets, Docker …
Badri-Rajendran Sep 6, 2026
0347606
docs(frontend): shorten lots/statements/fees/chat/admin-customers/adm…
Badri-Rajendran Sep 6, 2026
822abed
docs(frontend): shorten auth/onboarding/funding/portfolio/orders/valu…
Badri-Rajendran Sep 6, 2026
bd1507d
docs(frontend): shorten components/pages/services/hooks/routes/utils …
Badri-Rajendran Sep 6, 2026
8533ab9
docs(backend): shorten models/base.py and models/__init__.py docstrings
Badri-Rajendran Sep 6, 2026
25bd8a8
fix(cd): pass the mcp-server entrypoint as a plain script path
Badri-Rajendran Sep 6, 2026
b216d5b
docs(backend): shorten controllers/jobs/mcp comments and docstrings t…
Badri-Rajendran Sep 6, 2026
b4512e1
docs(backend): shorten core and integrations comments to 1-2 lines
Badri-Rajendran Sep 6, 2026
5419b0f
docs(backend): shorten reconciliation/rebalance/restatement comments
Badri-Rajendran Sep 6, 2026
8f7a0cb
docs(backend): shorten api/contract test comments to 1-2 lines
Badri-Rajendran Sep 6, 2026
db74fa8
feat(backend): add S13 agent-surface data layer (ADR 24)
Badri-Rajendran Sep 6, 2026
1bfb3c8
docs: add project scoring rubric and frontend completion design spec
Badri-Rajendran Sep 6, 2026
869f112
fix(frontend): revert dev-proxy target to port 5000
Badri-Rajendran Sep 6, 2026
bcf3bb4
fix(backend): suppress bandit B104 on the MCP bind host
Badri-Rajendran Sep 6, 2026
565663a
test(backend): drop test tables with raw CASCADE, not Table.drop()
Badri-Rajendran Sep 6, 2026
a829061
feat(frontend): live per-field validation on the register form
Badri-Rajendran Sep 6, 2026
8f2700f
feat(frontend): real email and MFA-code validation on the login form
Badri-Rajendran Sep 6, 2026
6ea8279
feat(frontend): ship the Vault color palette
Badri-Rajendran Sep 6, 2026
cc82a7f
docs: design spec for NN3 correction path and money-row immutability
Badri-Rajendran Sep 6, 2026
e45cc0d
fix(backend): self-heal identity verification status via live provide…
Badri-Rajendran Sep 6, 2026
1dbb076
feat(frontend): add shared Icon component and motion tokens
Badri-Rajendran Sep 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
34 changes: 0 additions & 34 deletions .github/dependabot.yml

This file was deleted.

171 changes: 170 additions & 1 deletion .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ env:
RESOURCE_GROUP: trueup-mvp-rg
REGISTRY: trueupmvpacr.azurecr.io
IMAGE_TAG: ${{ github.event.workflow_run.head_sha }}
CONTAINER_ENV: trueup-mvp-env
KEY_VAULT_URI: https://trueup-mvp-kv.vault.azure.net/
KEY_VAULT_ID: /subscriptions/5c16376b-0431-4786-895e-93e01db5f553/resourceGroups/trueup-mvp-rg/providers/Microsoft.KeyVault/vaults/trueup-mvp-kv
KEYVAULT_WRAP_KEY_NAME: trueup-wrap-key

jobs:
build-and-push:
Expand Down Expand Up @@ -120,7 +124,7 @@ jobs:
--name frontend \
--resource-group ${{ env.RESOURCE_GROUP }} \
--image ${{ env.REGISTRY }}/trueup-frontend:${{ env.IMAGE_TAG }}
- name: Verify health
- name: Verify backend + frontend health
run: |
FQDN=$(az containerapp show --name frontend --resource-group ${{ env.RESOURCE_GROUP }} --query properties.configuration.ingress.fqdn -o tsv)
for i in $(seq 1 10); do
Expand All @@ -129,3 +133,168 @@ jobs:
sleep 5
done
echo "::error::Deployed frontend never reported healthy" && exit 1

deploy-worker-and-mcp-server:
name: Deploy worker + mcp-server
needs: migrate
runs-on: ubuntu-latest
environment: production
steps:
- name: Azure login (OIDC)
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# Both worker (ADR 13's always-on job_outbox drain process) and mcp-server (S13/ADR 24's
# MCP agent surface) reuse the backend image with a different command -- the same "different
# process, same database" shape docker-compose.yml already establishes locally. Neither
# existed as a Container App before this workflow; both are created idempotently here (a
# first run creates, every run after just updates the image) rather than provisioned by
# hand outside the pipeline, so this workflow is the one place their config is defined.
- name: Create or update the worker Container App
run: |
if az containerapp show --name worker --resource-group ${{ env.RESOURCE_GROUP }} &>/dev/null; then
echo "worker exists -- updating image only"
az containerapp update \
--name worker \
--resource-group ${{ env.RESOURCE_GROUP }} \
--image ${{ env.REGISTRY }}/trueup-backend:${{ env.IMAGE_TAG }}
exit 0
fi

echo "worker does not exist -- bootstrapping"
az containerapp create \
--name worker \
--resource-group ${{ env.RESOURCE_GROUP }} \
--environment ${{ env.CONTAINER_ENV }} \
--image ${{ env.REGISTRY }}/trueup-backend:${{ env.IMAGE_TAG }} \
--command flask \
--args "jobs" "outbox-worker" \
--min-replicas 1 --max-replicas 1 \
--system-assigned \
--registry-server ${{ env.REGISTRY }}

PRINCIPAL_ID=$(az containerapp show --name worker --resource-group ${{ env.RESOURCE_GROUP }} --query identity.principalId -o tsv)
az role assignment create --assignee "$PRINCIPAL_ID" --role "Key Vault Secrets User" --scope ${{ env.KEY_VAULT_ID }}
az role assignment create --assignee "$PRINCIPAL_ID" --role "Key Vault Crypto User" --scope ${{ env.KEY_VAULT_ID }}

# RBAC assignments are not instantaneous -- the secret-set step below resolves each
# keyvaultref immediately, so a fixed wait here is the simplest correct fix (confirmed
# during the manual OIDC setup earlier this session: ~15s was sufficient in practice).
sleep 30

az containerapp secret set \
--name worker \
--resource-group ${{ env.RESOURCE_GROUP }} \
--secrets \
database-url=keyvaultref:${{ env.KEY_VAULT_URI }}secrets/database-url,identityref:system \
database-url-worker=keyvaultref:${{ env.KEY_VAULT_URI }}secrets/database-url-worker,identityref:system \
database-url-owner=keyvaultref:${{ env.KEY_VAULT_URI }}secrets/database-url-owner,identityref:system \
database-url-chat=keyvaultref:${{ env.KEY_VAULT_URI }}secrets/database-url-chat,identityref:system \
redis-url=keyvaultref:${{ env.KEY_VAULT_URI }}secrets/redis-url,identityref:system \
secret-key=keyvaultref:${{ env.KEY_VAULT_URI }}secrets/secret-key,identityref:system \
stripe-secret-key=keyvaultref:${{ env.KEY_VAULT_URI }}secrets/stripe-secret-key,identityref:system \
alpaca-api-key-id=keyvaultref:${{ env.KEY_VAULT_URI }}secrets/alpaca-api-key-id,identityref:system \
alpaca-api-secret-key=keyvaultref:${{ env.KEY_VAULT_URI }}secrets/alpaca-api-secret-key,identityref:system

az containerapp update \
--name worker \
--resource-group ${{ env.RESOURCE_GROUP }} \
--set-env-vars \
FLASK_ENV=production \
FLASK_APP=wsgi \
DATABASE_URL=secretref:database-url \
DATABASE_URL_WORKER=secretref:database-url-worker \
DATABASE_URL_OWNER=secretref:database-url-owner \
DATABASE_URL_CHAT=secretref:database-url-chat \
REDIS_URL=secretref:redis-url \
SECRET_KEY=secretref:secret-key \
AZURE_KEY_VAULT_URL=${{ env.KEY_VAULT_URI }} \
AZURE_KEYVAULT_WRAP_KEY_NAME=${{ env.KEYVAULT_WRAP_KEY_NAME }} \
STRIPE_SECRET_KEY=secretref:stripe-secret-key \
ALPACA_API_KEY_ID=secretref:alpaca-api-key-id \
ALPACA_API_SECRET_KEY=secretref:alpaca-api-secret-key \
ALPACA_BASE_URL=https://paper-api.alpaca.markets \
FEE_RATE_PCT=0.0
- name: Create or update the mcp-server Container App
run: |
if az containerapp show --name mcp-server --resource-group ${{ env.RESOURCE_GROUP }} &>/dev/null; then
echo "mcp-server exists -- updating image only"
az containerapp update \
--name mcp-server \
--resource-group ${{ env.RESOURCE_GROUP }} \
--image ${{ env.REGISTRY }}/trueup-backend:${{ env.IMAGE_TAG }}
exit 0
fi

echo "mcp-server does not exist -- bootstrapping"
# External ingress: an MCP client connects from outside Azure (ADR 24 "Transport and
# deployment"). Auth is the app's own staff_api_token bearer check on every tool call
# (app/mcp/auth.py), not network-level restriction -- matching backend's own external
# ingress, which relies on its session/CSRF layer the same way.
az containerapp create \
--name mcp-server \
--resource-group ${{ env.RESOURCE_GROUP }} \
--environment ${{ env.CONTAINER_ENV }} \
--image ${{ env.REGISTRY }}/trueup-backend:${{ env.IMAGE_TAG }} \
--command python \
--args "app/mcp/entrypoint.py" \
--min-replicas 1 --max-replicas 1 \
--system-assigned \
--ingress external \
--target-port 8001 \
--registry-server ${{ env.REGISTRY }}

PRINCIPAL_ID=$(az containerapp show --name mcp-server --resource-group ${{ env.RESOURCE_GROUP }} --query identity.principalId -o tsv)
az role assignment create --assignee "$PRINCIPAL_ID" --role "Key Vault Secrets User" --scope ${{ env.KEY_VAULT_ID }}
az role assignment create --assignee "$PRINCIPAL_ID" --role "Key Vault Crypto User" --scope ${{ env.KEY_VAULT_ID }}

sleep 30

az containerapp secret set \
--name mcp-server \
--resource-group ${{ env.RESOURCE_GROUP }} \
--secrets \
database-url=keyvaultref:${{ env.KEY_VAULT_URI }}secrets/database-url,identityref:system \
database-url-worker=keyvaultref:${{ env.KEY_VAULT_URI }}secrets/database-url-worker,identityref:system \
database-url-owner=keyvaultref:${{ env.KEY_VAULT_URI }}secrets/database-url-owner,identityref:system \
database-url-chat=keyvaultref:${{ env.KEY_VAULT_URI }}secrets/database-url-chat,identityref:system \
redis-url=keyvaultref:${{ env.KEY_VAULT_URI }}secrets/redis-url,identityref:system \
secret-key=keyvaultref:${{ env.KEY_VAULT_URI }}secrets/secret-key,identityref:system

az containerapp update \
--name mcp-server \
--resource-group ${{ env.RESOURCE_GROUP }} \
--set-env-vars \
FLASK_ENV=production \
DATABASE_URL=secretref:database-url \
DATABASE_URL_WORKER=secretref:database-url-worker \
DATABASE_URL_OWNER=secretref:database-url-owner \
DATABASE_URL_CHAT=secretref:database-url-chat \
REDIS_URL=secretref:redis-url \
SECRET_KEY=secretref:secret-key \
AZURE_KEY_VAULT_URL=${{ env.KEY_VAULT_URI }} \
AZURE_KEYVAULT_WRAP_KEY_NAME=${{ env.KEYVAULT_WRAP_KEY_NAME }} \
FEE_RATE_PCT=0.0 \
MCP_SERVER_HOST=0.0.0.0 \
MCP_SERVER_PORT=8001
- name: Verify worker is running (no HTTP endpoint of its own)
run: |
for i in $(seq 1 10); do
STATE=$(az containerapp revision list --name worker --resource-group ${{ env.RESOURCE_GROUP }} --query "[0].properties.runningState" -o tsv)
[ "$STATE" = "Running" ] && echo "worker running" && exit 0
sleep 5
done
echo "::error::worker never reached Running state ($STATE)" && exit 1
- name: Verify mcp-server health
run: |
FQDN=$(az containerapp show --name mcp-server --resource-group ${{ env.RESOURCE_GROUP }} --query properties.configuration.ingress.fqdn -o tsv)
for i in $(seq 1 10); do
# No /health route on the MCP transport itself -- a bare TCP/HTTP reachability check
# against the streamable-HTTP path is what proves the process is actually listening.
CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://$FQDN/mcp" || echo "000")
[ "$CODE" != "000" ] && echo "mcp-server reachable (HTTP $CODE)" && exit 0
sleep 5
done
echo "::error::mcp-server never became reachable" && exit 1
90 changes: 10 additions & 80 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
name: CI

# Root CLAUDE.md ("CI/CD (GitHub Actions)"): lint, unit + integration tests, API tests, UI
# component tests, coverage thresholds; dependency and secret scanning, SAST, Docker build +
# image vulnerability scan, Alembic migration check. Runs on every PR marked ready for review and
# on main -- draft PRs stay cheap to iterate on locally.
# Scope, deliberately narrowed (2026-09-06): tests + coverage floor, Alembic migration check,
# SAST (Bandit + Semgrep), secret scanning, and Docker build + image vulnerability scan -- each
# job here catches a real, distinct class of defect a merge could otherwise ship. Lint/typecheck/
# import-layering/dependency-audit stay local-only (`make lint`/`typecheck`/`imports`/`audit`,
# `npm run lint`) rather than CI gates. Runs on every PR marked ready for review and on main --
# draft PRs stay cheap to iterate on locally.
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
Expand All @@ -18,29 +20,6 @@ env:
UV_VERSION: "0.5.11"

jobs:
backend-quality:
name: Backend / lint, types, imports
if: github.event.pull_request.draft != true
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
with:
version: ${{ env.UV_VERSION }}
- run: uv sync --project backend --all-extras
# `working-directory: backend` on every step below, not just the last one: mypy and ruff
# both auto-discover `backend/pyproject.toml` (the `[tool.mypy]` overrides -- the pydantic
# plugin, the flask_*/alpaca/plaid ignore_missing_imports list) only when invoked from that
# directory. Running from the repo root silently drops all of it, which is a materially
# different, much noisier check (confirmed the hard way: passed locally under `cwd=backend`,
# failed in CI with 34 spurious errors before this fix).
- run: uv run --project backend ruff check app tests
working-directory: backend
- run: uv run --project backend mypy --strict app
working-directory: backend
- run: uv run --project backend lint-imports
working-directory: backend

backend-tests:
name: Backend / tests + coverage floor
if: github.event.pull_request.draft != true
Expand Down Expand Up @@ -93,18 +72,6 @@ jobs:
name: backend-coverage
path: backend/coverage.xml

backend-dependency-audit:
name: Backend / dependency scan
if: github.event.pull_request.draft != true
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
with:
version: ${{ env.UV_VERSION }}
- run: uv sync --project backend --all-extras
- run: uv run --project backend pip-audit

backend-sast:
name: Backend / SAST (Bandit + Semgrep)
if: github.event.pull_request.draft != true
Expand Down Expand Up @@ -182,47 +149,6 @@ jobs:
rm -f "$GENERATED"
working-directory: backend

frontend-quality:
name: Frontend / lint, test, build
if: github.event.pull_request.draft != true
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- run: npm ci
working-directory: frontend
- run: npm run lint
working-directory: frontend
# `npm test` only exists once Phase 7 installs Vitest -- until then this step is a no-op
# guarded by the script's own presence so earlier phases' CI runs stay green honestly
# rather than via a fabricated always-pass step.
- name: Run component tests (if configured)
run: |
if npm run | grep -q " test$"; then npm run test -- --run; else echo "no test script yet"; fi
working-directory: frontend
- run: npm run build
working-directory: frontend

frontend-dependency-audit:
name: Frontend / dependency scan
if: github.event.pull_request.draft != true
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- run: npm ci
working-directory: frontend
- run: npm audit --audit-level=high
working-directory: frontend

secret-scan:
name: Secret scanning (Gitleaks)
if: github.event.pull_request.draft != true
Expand Down Expand Up @@ -257,6 +183,10 @@ jobs:
push: false
load: true
tags: trueup-${{ matrix.image }}:ci
# Genuinely finds real, fixable vulnerabilities, not a rubber-stamp step -- confirmed the
# hard way: this exact scan caught CVE-2026-31789 (CRITICAL) in nginx:1.27-alpine's OpenSSL
# packages, fixed by bumping the base image and adding `apk upgrade` at build time
# (frontend/Dockerfile). `severity: CRITICAL,HIGH` + `exit-code: 1` actually fails the job.
- name: Scan image for vulnerabilities
uses: aquasecurity/trivy-action@v0.36.0
with:
Expand Down
4 changes: 2 additions & 2 deletions backend/.importlinter
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ root_package = app
include_external_packages = False

[importlinter:contract:layers]
name = Layered architecture: controllers/jobs -> services -> models -> core
name = Layered architecture: controllers/jobs/mcp -> services -> models -> core
type = layers
layers =
app.controllers : app.jobs
app.controllers : app.jobs : app.mcp
app.services
app.models
app.core
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,7 @@ def upgrade() -> None:
sa.PrimaryKeyConstraint('id', name=op.f('pk_job_run'))
)
op.create_index('uq_job_run_daily_name_market_date', 'job_run', ['job_name', 'market_date'], unique=True, postgresql_where=sa.text("cadence = 'daily'"))
# Postgres's built-in date_trunc() is STABLE, not IMMUTABLE, so it cannot appear directly in an
# index expression ("functions in index expression must be marked IMMUTABLE"). market_date is a
# plain DATE (no timezone dependency), so truncating it to a month boundary is a pure function of
# its input -- genuinely immutable in fact, just not declared that way by Postgres's own catalog.
# A tiny SQL wrapper marked IMMUTABLE is the standard fix (S0 SS9's own index expression hits this
# unstated Postgres constraint verbatim).
# date_trunc() is STABLE not IMMUTABLE in Postgres; wrap it so it can be used in an index expression.
op.execute("""
CREATE OR REPLACE FUNCTION job_run_month_start(d date) RETURNS date AS $$
SELECT date_trunc('month', d)::date
Expand All @@ -109,8 +104,7 @@ def downgrade() -> None:
op.drop_table('idempotency_key')
op.drop_index('ix_admin_audit_log_customer_recorded', table_name='admin_audit_log')
op.drop_table('admin_audit_log')
# drop_table() does not drop the Postgres ENUM types sa.Enum() created alongside these tables --
# same pattern the identity migration already applies to its own three enums.
# drop_table() does not drop the Postgres ENUM types created alongside these tables.
sa.Enum(name='job_run_status').drop(op.get_bind(), checkfirst=True)
sa.Enum(name='job_cadence').drop(op.get_bind(), checkfirst=True)
sa.Enum(name='job_outbox_status').drop(op.get_bind(), checkfirst=True)
Expand Down
Loading
Loading