From 8a14295fbf5d262fa42c030693790bf628f65b11 Mon Sep 17 00:00:00 2001 From: ranade-oss Date: Thu, 16 Jul 2026 23:16:21 -0400 Subject: [PATCH] Add private online ROSS deployment --- .dockerignore | 14 ++ .github/workflows/deploy-private-ross.yml | 176 ++++++++++++++++++++ backend/.env.example | 3 +- backend/schema.sql | 72 ++++++++ backend/src/config/runtime.test.ts | 3 + backend/src/config/runtime.ts | 1 + backend/src/lib/storage.ts | 7 +- deploy/fly/api.toml | 35 ++++ deploy/fly/backend.Dockerfile | 30 ++++ deploy/fly/frontend.Dockerfile | 51 ++++++ deploy/fly/frontend.toml | 36 ++++ docs/private-online-deployment.md | 72 ++++++++ frontend/.env.local.example | 1 + frontend/next.config.ts | 2 + frontend/src/app/login/page.tsx | 16 +- frontend/src/app/signup/page.tsx | 29 ++++ reports/release-manifest-v1.json | 8 +- tests/baseline/private-deployment.test.mjs | 39 +++++ tests/baseline/repository-contract.test.mjs | 28 ++++ 19 files changed, 609 insertions(+), 14 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/deploy-private-ross.yml create mode 100644 deploy/fly/api.toml create mode 100644 deploy/fly/backend.Dockerfile create mode 100644 deploy/fly/frontend.Dockerfile create mode 100644 deploy/fly/frontend.toml create mode 100644 docs/private-online-deployment.md create mode 100644 tests/baseline/private-deployment.test.mjs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..7111e7b01d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.github +**/.env +**/.env.* +!**/.env.example +!**/.env.local.example +**/node_modules +**/.next +**/.open-next +**/dist +**/out +**/coverage +*.log + diff --git a/.github/workflows/deploy-private-ross.yml b/.github/workflows/deploy-private-ross.yml new file mode 100644 index 0000000000..5790f2843a --- /dev/null +++ b/.github/workflows/deploy-private-ross.yml @@ -0,0 +1,176 @@ +name: Deploy private ROSS + +on: + workflow_dispatch: + inputs: + fly_organization: + description: Fly.io organization slug shown in the Fly dashboard + required: true + default: personal + type: string + api_app_name: + description: Globally unique Fly.io name for the ROSS API + required: true + default: ross-ranadeoss-api + type: string + web_app_name: + description: Globally unique Fly.io name for the private ROSS website + required: true + default: ross-ranadeoss-private + type: string + +permissions: + contents: read + +concurrency: + group: deploy-private-ross + cancel-in-progress: false + +jobs: + deploy: + name: Deploy owner-only ROSS to Toronto + runs-on: ubuntu-latest + environment: private-online + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + ROSS_SUPABASE_URL: ${{ secrets.ROSS_SUPABASE_URL }} + ROSS_SUPABASE_PUBLISHABLE_KEY: ${{ secrets.ROSS_SUPABASE_PUBLISHABLE_KEY }} + ROSS_SUPABASE_SECRET_KEY: ${{ secrets.ROSS_SUPABASE_SECRET_KEY }} + ROSS_S3_ENDPOINT_URL: ${{ secrets.ROSS_S3_ENDPOINT_URL }} + ROSS_S3_REGION: ${{ secrets.ROSS_S3_REGION }} + ROSS_S3_ACCESS_KEY_ID: ${{ secrets.ROSS_S3_ACCESS_KEY_ID }} + ROSS_S3_SECRET_ACCESS_KEY: ${{ secrets.ROSS_S3_SECRET_ACCESS_KEY }} + API_APP: ${{ inputs.api_app_name }} + WEB_APP: ${{ inputs.web_app_name }} + FLY_ORG: ${{ inputs.fly_organization }} + PUBLIC_WEBSITE_URL: https://ross-ontario.augustmaat.chatgpt.site + steps: + - uses: actions/checkout@v4 + + - name: Validate deployment inputs and secrets + shell: bash + run: | + set -euo pipefail + for name in \ + FLY_API_TOKEN \ + ROSS_SUPABASE_URL \ + ROSS_SUPABASE_PUBLISHABLE_KEY \ + ROSS_SUPABASE_SECRET_KEY \ + ROSS_S3_ENDPOINT_URL \ + ROSS_S3_REGION \ + ROSS_S3_ACCESS_KEY_ID \ + ROSS_S3_SECRET_ACCESS_KEY; do + if [ -z "${!name:-}" ]; then + echo "Required GitHub Actions secret is missing: ${name}" >&2 + exit 1 + fi + done + for value in "$API_APP" "$WEB_APP"; do + if ! [[ "$value" =~ ^[a-z0-9][a-z0-9-]{2,61}[a-z0-9]$ ]]; then + echo "Fly app names must use 4-63 lowercase letters, numbers, or hyphens." >&2 + exit 1 + fi + done + if [ "$API_APP" = "$WEB_APP" ]; then + echo "The API and website app names must be different." >&2 + exit 1 + fi + + - uses: superfly/flyctl-actions/setup-flyctl@master + with: + version: 0.4.49 + + - name: Create Fly applications when absent + shell: bash + run: | + set -euo pipefail + if ! flyctl status --app "$API_APP" >/dev/null 2>&1; then + flyctl apps create "$API_APP" --org "$FLY_ORG" + fi + if ! flyctl status --app "$WEB_APP" >/dev/null 2>&1; then + flyctl apps create "$WEB_APP" --org "$FLY_ORG" + fi + + - name: Configure private API secrets + shell: bash + run: | + set -euo pipefail + WEB_URL="https://${WEB_APP}.fly.dev" + API_URL="https://${API_APP}.fly.dev" + + flyctl secrets set --stage --app "$API_APP" \ + "SUPABASE_URL=${ROSS_SUPABASE_URL}" \ + "SUPABASE_SECRET_KEY=${ROSS_SUPABASE_SECRET_KEY}" \ + "R2_ENDPOINT_URL=${ROSS_S3_ENDPOINT_URL}" \ + "R2_REGION=${ROSS_S3_REGION}" \ + "R2_ACCESS_KEY_ID=${ROSS_S3_ACCESS_KEY_ID}" \ + "R2_SECRET_ACCESS_KEY=${ROSS_S3_SECRET_ACCESS_KEY}" \ + "R2_BUCKET_NAME=ross-private-files" \ + "ROSS_ENV=staging" \ + "ROSS_HOSTED_MODE=controlled-beta" \ + "HOSTED_MODEL_PROVIDERS=openai" \ + "ROSS_DATA_BOUNDARY_VERSION=2026-07-16" \ + "CORS_ALLOWED_ORIGINS=${WEB_URL}" \ + "FRONTEND_URL=${WEB_URL}" \ + "API_PUBLIC_URL=${API_URL}" + + ensure_random_secret() { + local key="$1" + if ! flyctl secrets list --app "$API_APP" --json \ + | jq -e --arg key "$key" '.[] | select((.Name // .name) == $key)' \ + >/dev/null; then + flyctl secrets set --stage --app "$API_APP" \ + "${key}=$(openssl rand -hex 32)" + fi + } + + ensure_random_secret DOWNLOAD_SIGNING_SECRET + ensure_random_secret USER_API_KEYS_ENCRYPTION_SECRET + ensure_random_secret MCP_CONNECTORS_ENCRYPTION_SECRET + + - name: Deploy ROSS API + run: >- + flyctl deploy . + --config deploy/fly/api.toml + --app "$API_APP" + --remote-only + --ha=false + + - name: Deploy private ROSS website + shell: bash + run: | + set -euo pipefail + WEB_URL="https://${WEB_APP}.fly.dev" + API_URL="https://${API_APP}.fly.dev" + flyctl deploy . \ + --config deploy/fly/frontend.toml \ + --app "$WEB_APP" \ + --remote-only \ + --ha=false \ + --build-arg "NEXT_PUBLIC_SUPABASE_URL=${ROSS_SUPABASE_URL}" \ + --build-arg "NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${ROSS_SUPABASE_PUBLISHABLE_KEY}" \ + --build-arg "NEXT_PUBLIC_API_BASE_URL=${API_URL}" \ + --build-arg "NEXT_PUBLIC_ROSS_APP_URL=${WEB_URL}" \ + --build-arg "NEXT_PUBLIC_ROSS_WEBSITE_URL=${PUBLIC_WEBSITE_URL}" \ + --build-arg "NEXT_PUBLIC_ROSS_HOSTED_MODE=controlled-beta" \ + --build-arg "NEXT_PUBLIC_ROSS_DATA_BOUNDARY_VERSION=2026-07-16" \ + --build-arg "NEXT_PUBLIC_ROSS_SIGNUPS_ENABLED=false" + + - name: Verify owner-only services + shell: bash + run: | + set -euo pipefail + API_URL="https://${API_APP}.fly.dev" + WEB_URL="https://${WEB_APP}.fly.dev" + curl --fail --retry 8 --retry-delay 5 --retry-all-errors \ + "${API_URL}/health" + curl --fail --retry 8 --retry-delay 5 --retry-all-errors \ + "${WEB_URL}/login" >/dev/null + { + echo "## Private ROSS deployed" + echo + echo "- Login: ${WEB_URL}/login" + echo "- API health: ${API_URL}/health" + echo "- Region: Toronto (yyz)" + echo "- New sign-ups: disabled" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/backend/.env.example b/backend/.env.example index 723161251c..50e721fcc9 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -24,9 +24,10 @@ SUPABASE_URL=https://your-project.supabase.co SUPABASE_SECRET_KEY=your-supabase-service-role-key R2_ENDPOINT_URL=https://your-account-id.r2.cloudflarestorage.com +R2_REGION=auto R2_ACCESS_KEY_ID=your-r2-access-key R2_SECRET_ACCESS_KEY=your-r2-secret-key -R2_BUCKET_NAME=mike +R2_BUCKET_NAME=ross-private-files GEMINI_API_KEY=your-gemini-key ANTHROPIC_API_KEY=your-anthropic-key diff --git a/backend/schema.sql b/backend/schema.sql index da5f340006..b5ddc868b5 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -28,6 +28,8 @@ create table if not exists public.user_profiles ( default_province text default 'ON' check (default_province is null or default_province = 'ON'), enabled_jurisdictions text[] not null default array['CA-ON', 'CA', 'US']::text[], enabled_source_providers text[] not null default array['a2aj-canada', 'ontario-elaws', 'justice-laws-canada', 'courtlistener-us']::text[], + beta_data_boundary_version text, + beta_data_boundary_acknowledged_at timestamptz, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); @@ -794,6 +796,45 @@ create table if not exists public.tabular_review_chat_messages ( create index if not exists tabular_review_chat_messages_chat_idx on public.tabular_review_chat_messages(chat_id, created_at); +-- --------------------------------------------------------------------------- +-- Ontario legal-source operations and metadata-only security audit +-- --------------------------------------------------------------------------- + +create table if not exists public.legal_source_version_checks ( + id bigint generated by default as identity primary key, + source_id text not null, + source_url text not null, + checked_at timestamptz not null, + reachable boolean not null, + etag text, + last_modified text, + metadata_hash text not null, + created_at timestamptz not null default now(), + constraint legal_source_version_checks_metadata_hash_format + check (metadata_hash ~ '^[a-f0-9]{64}$'), + constraint legal_source_version_checks_source_metadata_unique + unique (source_id, metadata_hash) +); + +create table if not exists public.security_audit_events ( + id uuid primary key default gen_random_uuid(), + occurred_at timestamptz not null default now(), + actor_user_id uuid references auth.users(id) on delete set null, + event_type text not null, + resource_type text, + resource_id text, + metadata jsonb not null default '{}'::jsonb, + constraint security_audit_event_type_format + check (event_type ~ '^[a-z][a-z0-9_.-]{2,79}$'), + constraint security_audit_metadata_object + check (jsonb_typeof(metadata) = 'object') +); + +create index if not exists security_audit_events_actor_time_idx + on public.security_audit_events (actor_user_id, occurred_at desc); +create index if not exists security_audit_events_type_time_idx + on public.security_audit_events (event_type, occurred_at desc); + -- --------------------------------------------------------------------------- -- CourtListener bulk-data indexes -- --------------------------------------------------------------------------- @@ -842,6 +883,35 @@ alter table public.courtlistener_opinion_cluster_index enable row level security -- backend verifies the user's JWT. Do not grant the browser anon/authenticated -- roles direct table privileges for backend-owned data. +-- Defence in depth: every table in the exposed public schema has RLS enabled. +-- No permissive browser policy is created because application data must pass +-- through the authenticated backend. The Supabase service role bypasses RLS. +alter table public.user_profiles enable row level security; +alter table public.user_api_keys enable row level security; +alter table public.user_mcp_connectors enable row level security; +alter table public.user_mcp_oauth_tokens enable row level security; +alter table public.user_mcp_oauth_states enable row level security; +alter table public.user_mcp_connector_tools enable row level security; +alter table public.user_mcp_tool_audit_logs enable row level security; +alter table public.projects enable row level security; +alter table public.project_subfolders enable row level security; +alter table public.documents enable row level security; +alter table public.document_versions enable row level security; +alter table public.document_edits enable row level security; +alter table public.workflows enable row level security; +alter table public.hidden_workflows enable row level security; +alter table public.workflow_shares enable row level security; +alter table public.chats enable row level security; +alter table public.chat_messages enable row level security; +alter table public.tabular_reviews enable row level security; +alter table public.tabular_cells enable row level security; +alter table public.tabular_review_chats enable row level security; +alter table public.tabular_review_chat_messages enable row level security; +alter table public.legal_source_version_checks enable row level security; +alter table public.security_audit_events enable row level security; +alter table public.courtlistener_citation_index enable row level security; +alter table public.courtlistener_opinion_cluster_index enable row level security; + revoke all on public.user_profiles from anon, authenticated; revoke all on public.projects from anon, authenticated; revoke all on public.project_subfolders from anon, authenticated; @@ -863,5 +933,7 @@ revoke all on public.user_mcp_oauth_tokens from anon, authenticated; revoke all on public.user_mcp_oauth_states from anon, authenticated; revoke all on public.user_mcp_connector_tools from anon, authenticated; revoke all on public.user_mcp_tool_audit_logs from anon, authenticated; +revoke all on public.legal_source_version_checks from anon, authenticated; +revoke all on public.security_audit_events from anon, authenticated; revoke all on public.courtlistener_citation_index from anon, authenticated; revoke all on public.courtlistener_opinion_cluster_index from anon, authenticated; diff --git a/backend/src/config/runtime.test.ts b/backend/src/config/runtime.test.ts index b879def3e0..30be11018c 100644 --- a/backend/src/config/runtime.test.ts +++ b/backend/src/config/runtime.test.ts @@ -17,6 +17,7 @@ const KEYS = [ "SUPABASE_SECRET_KEY", "DOWNLOAD_SIGNING_SECRET", "R2_ENDPOINT_URL", + "R2_REGION", "R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY", "R2_BUCKET_NAME", @@ -100,6 +101,7 @@ test("non-local raw model logging and unapproved production fail closed", () => SUPABASE_SECRET_KEY: "production-secret-value", DOWNLOAD_SIGNING_SECRET: "production-signing-value", R2_ENDPOINT_URL: "https://objects.ross.test", + R2_REGION: "ca-central-1", R2_ACCESS_KEY_ID: "production-access-value", R2_SECRET_ACCESS_KEY: "production-storage-secret", R2_BUCKET_NAME: "ross-production", @@ -126,6 +128,7 @@ test("production requires a valid immutable release manifest identity", () => { SUPABASE_SECRET_KEY: "production-secret-value", DOWNLOAD_SIGNING_SECRET: "production-signing-value", R2_ENDPOINT_URL: "https://objects.ross.test", + R2_REGION: "ca-central-1", R2_ACCESS_KEY_ID: "production-access-value", R2_SECRET_ACCESS_KEY: "production-storage-secret", R2_BUCKET_NAME: "ross-production", diff --git a/backend/src/config/runtime.ts b/backend/src/config/runtime.ts index 4d25bf1409..ca14489291 100644 --- a/backend/src/config/runtime.ts +++ b/backend/src/config/runtime.ts @@ -128,6 +128,7 @@ export function loadRuntimeConfig(): RuntimeConfig { "SUPABASE_SECRET_KEY", "DOWNLOAD_SIGNING_SECRET", "R2_ENDPOINT_URL", + "R2_REGION", "R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY", "R2_BUCKET_NAME", diff --git a/backend/src/lib/storage.ts b/backend/src/lib/storage.ts index dccf9e43ea..532165f0c6 100644 --- a/backend/src/lib/storage.ts +++ b/backend/src/lib/storage.ts @@ -1,9 +1,10 @@ /** - * Cloudflare R2 storage utilities for Mike document management. - * R2 is S3-compatible — uses @aws-sdk/client-s3. + * S3-compatible storage utilities for ROSS document management. + * Supports Cloudflare R2, Supabase Storage S3, and compatible providers. * * Required env vars: * R2_ENDPOINT_URL — https://.r2.cloudflarestorage.com + * R2_REGION — signing region ("auto" for R2; project region for Supabase) * R2_ACCESS_KEY_ID — R2 API token (Access Key ID) * R2_SECRET_ACCESS_KEY — R2 API token (Secret Access Key) * R2_BUCKET_NAME — bucket name (default: "mike") @@ -25,7 +26,7 @@ let cachedClient: S3Client | undefined; function getClient(): S3Client { if (!cachedClient) { cachedClient = new S3Client({ - region: "auto", + region: process.env.R2_REGION?.trim() || "auto", endpoint: process.env.R2_ENDPOINT_URL!, forcePathStyle: true, credentials: { diff --git a/deploy/fly/api.toml b/deploy/fly/api.toml new file mode 100644 index 0000000000..6f6e98f432 --- /dev/null +++ b/deploy/fly/api.toml @@ -0,0 +1,35 @@ +primary_region = "yyz" +kill_signal = "SIGTERM" +kill_timeout = "30s" + +[build] + dockerfile = "deploy/fly/backend.Dockerfile" + +[env] + NODE_ENV = "production" + PORT = "3001" + +[http_service] + internal_port = 3001 + force_https = true + auto_stop_machines = "stop" + auto_start_machines = true + min_machines_running = 0 + processes = ["app"] + + [http_service.concurrency] + type = "requests" + soft_limit = 20 + hard_limit = 30 + + [[http_service.checks]] + grace_period = "30s" + interval = "30s" + method = "GET" + path = "/health" + timeout = "10s" + +[[vm]] + size = "shared-cpu-1x" + memory = "1gb" + diff --git a/deploy/fly/backend.Dockerfile b/deploy/fly/backend.Dockerfile new file mode 100644 index 0000000000..ab666c173a --- /dev/null +++ b/deploy/fly/backend.Dockerfile @@ -0,0 +1,30 @@ +FROM node:22-bookworm-slim AS build + +WORKDIR /app/backend +COPY backend/package.json backend/package-lock.json ./ +RUN npm ci +COPY backend/tsconfig.json ./ +COPY backend/src ./src +RUN npm run build && npm prune --omit=dev + +FROM node:22-bookworm-slim AS runtime + +ENV NODE_ENV=production +WORKDIR /app/backend + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + fonts-liberation \ + libreoffice-calc \ + libreoffice-impress \ + libreoffice-writer \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=build /app/backend/package.json ./ +COPY --from=build /app/backend/node_modules ./node_modules +COPY --from=build /app/backend/dist ./dist + +EXPOSE 3001 +USER node +CMD ["node", "dist/index.js"] + diff --git a/deploy/fly/frontend.Dockerfile b/deploy/fly/frontend.Dockerfile new file mode 100644 index 0000000000..edb50a484a --- /dev/null +++ b/deploy/fly/frontend.Dockerfile @@ -0,0 +1,51 @@ +FROM node:22-bookworm-slim AS dependencies + +WORKDIR /app/frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci + +FROM node:22-bookworm-slim AS build + +ARG NEXT_PUBLIC_SUPABASE_URL +ARG NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY +ARG NEXT_PUBLIC_API_BASE_URL +ARG NEXT_PUBLIC_ROSS_APP_URL +ARG NEXT_PUBLIC_ROSS_WEBSITE_URL +ARG NEXT_PUBLIC_ROSS_HOSTED_MODE=controlled-beta +ARG NEXT_PUBLIC_ROSS_DATA_BOUNDARY_VERSION=2026-07-16 +ARG NEXT_PUBLIC_ROSS_SIGNUPS_ENABLED=false + +ENV NEXT_TELEMETRY_DISABLED=1 \ + NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL} \ + NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY} \ + NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL} \ + NEXT_PUBLIC_ROSS_APP_URL=${NEXT_PUBLIC_ROSS_APP_URL} \ + NEXT_PUBLIC_ROSS_WEBSITE_URL=${NEXT_PUBLIC_ROSS_WEBSITE_URL} \ + NEXT_PUBLIC_ROSS_HOSTED_MODE=${NEXT_PUBLIC_ROSS_HOSTED_MODE} \ + NEXT_PUBLIC_ROSS_DATA_BOUNDARY_VERSION=${NEXT_PUBLIC_ROSS_DATA_BOUNDARY_VERSION} \ + NEXT_PUBLIC_ROSS_SIGNUPS_ENABLED=${NEXT_PUBLIC_ROSS_SIGNUPS_ENABLED} + +WORKDIR /app +COPY --from=dependencies /app/frontend/node_modules ./frontend/node_modules +COPY config ./config +COPY frontend ./frontend +WORKDIR /app/frontend +RUN npm run build + +FROM node:22-bookworm-slim AS runtime + +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + PORT=3000 \ + HOSTNAME=0.0.0.0 + +WORKDIR /app/frontend +COPY --from=build --chown=node:node /app/frontend/public ./public +COPY --from=build --chown=node:node /app/frontend/.next/standalone ./standalone +COPY --from=build --chown=node:node /app/frontend/.next/static ./standalone/frontend/.next/static + +WORKDIR /app/frontend/standalone/frontend +EXPOSE 3000 +USER node +CMD ["node", "server.js"] + diff --git a/deploy/fly/frontend.toml b/deploy/fly/frontend.toml new file mode 100644 index 0000000000..0c3221c264 --- /dev/null +++ b/deploy/fly/frontend.toml @@ -0,0 +1,36 @@ +primary_region = "yyz" +kill_signal = "SIGTERM" +kill_timeout = "30s" + +[build] + dockerfile = "deploy/fly/frontend.Dockerfile" + +[env] + NODE_ENV = "production" + PORT = "3000" + HOSTNAME = "0.0.0.0" + +[http_service] + internal_port = 3000 + force_https = true + auto_stop_machines = "stop" + auto_start_machines = true + min_machines_running = 0 + processes = ["app"] + + [http_service.concurrency] + type = "requests" + soft_limit = 20 + hard_limit = 30 + + [[http_service.checks]] + grace_period = "30s" + interval = "30s" + method = "GET" + path = "/login" + timeout = "10s" + +[[vm]] + size = "shared-cpu-1x" + memory = "1gb" + diff --git a/docs/private-online-deployment.md b/docs/private-online-deployment.md new file mode 100644 index 0000000000..534994faec --- /dev/null +++ b/docs/private-online-deployment.md @@ -0,0 +1,72 @@ +# Private online ROSS deployment + +This runbook deploys the authenticated ROSS application and API to Fly.io in +Toronto (`yyz`). Supabase provides authentication, PostgreSQL, and private +S3-compatible document storage in Canada Central. It is a controlled beta for +the owner account only; new registrations remain disabled. + +## Safety boundary + +- The Fly workflow is manual and never runs merely because code is merged. +- No credential is committed to the repository. +- Supabase's secret/service-role key and S3 credentials are server-side only. +- The S3 bucket is private and named `ross-private-files`. +- Every application route requires Supabase authentication; `/health` exposes + only non-sensitive service status. +- The controlled beta accepts synthetic and non-confidential test material + only. Do not upload client, privileged, or otherwise confidential records. + +## One-time GitHub configuration + +In GitHub, open **Settings → Secrets and variables → Actions** and create the +following repository secrets: + +| Secret | Source | +| ------------------------------- | ----------------------------------------- | +| `FLY_API_TOKEN` | Fly.io user access token | +| `ROSS_SUPABASE_URL` | Supabase project URL | +| `ROSS_SUPABASE_PUBLISHABLE_KEY` | Supabase publishable key | +| `ROSS_SUPABASE_SECRET_KEY` | Supabase secret/service-role key | +| `ROSS_S3_ENDPOINT_URL` | Supabase Storage S3 endpoint | +| `ROSS_S3_REGION` | Region shown by Supabase S3 configuration | +| `ROSS_S3_ACCESS_KEY_ID` | Generated S3 access key ID | +| `ROSS_S3_SECRET_ACCESS_KEY` | Generated S3 secret access key | + +Never paste these values into an issue, pull request, workflow input, source +file, or chat. GitHub masks repository secrets in action logs. + +Create a GitHub Actions environment named `private-online`. It may optionally +require the repository owner to approve each deployment. + +## Deploy + +1. Open **Actions → Deploy private ROSS → Run workflow**. +2. Enter the organization slug shown in the Fly.io dashboard. +3. Keep the suggested application names unless Fly reports that a name is + already in use. Names must be globally unique. +4. Run the workflow and wait for both health checks to pass. + +The workflow creates one auto-stopping 1 GB shared-CPU Machine for the frontend +and one for the API. It generates stable download, API-key encryption, and MCP +connector encryption secrets directly in Fly.io on the first run and preserves +them on later deployments. + +## Complete Supabase URL configuration + +After the first successful deploy, copy the website URL from the GitHub Actions +summary. In Supabase **Authentication → URL Configuration**: + +1. Set **Site URL** to the Fly website URL. +2. Add `/**` to **Redirect URLs**. +3. Confirm new user sign-ups remain disabled. + +## First login verification + +1. Open the `/login` URL from the workflow summary. +2. Sign in with the manually created owner account. +3. Confirm `/signup` reports that registrations are unavailable. +4. Accept the controlled-beta boundary using synthetic/non-confidential data. +5. Add an OpenAI API key under **Account → API Keys** if model features are to + be exercised. The backend encrypts this key before storing it. +6. Upload a synthetic document, download it, then delete it to verify private + storage end to end. diff --git a/frontend/.env.local.example b/frontend/.env.local.example index aac1d0db24..1d636f819a 100644 --- a/frontend/.env.local.example +++ b/frontend/.env.local.example @@ -5,3 +5,4 @@ NEXT_PUBLIC_ROSS_APP_URL=http://localhost:3000 NEXT_PUBLIC_ROSS_WEBSITE_URL=http://localhost:4173 NEXT_PUBLIC_ROSS_HOSTED_MODE=self-hosted NEXT_PUBLIC_ROSS_DATA_BOUNDARY_VERSION=2026-07-16 +NEXT_PUBLIC_ROSS_SIGNUPS_ENABLED=true diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 32f7700d8d..e34e7de9cc 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -8,6 +8,8 @@ const repositoryRoot = path.resolve( ); const nextConfig: NextConfig = { + output: "standalone", + outputFileTracingRoot: repositoryRoot, reactCompiler: true, turbopack: { root: repositoryRoot, diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 821400f5d8..47abfbc20e 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -21,6 +21,8 @@ const authToggleInactiveClassName = "inline-flex h-6 items-center rounded-full border border-transparent px-3 text-gray-500 transition-colors hover:bg-white/38 hover:text-gray-900"; export default function LoginPage() { + const signupsEnabled = + process.env.NEXT_PUBLIC_ROSS_SIGNUPS_ENABLED !== "false"; const router = useRouter(); const { isAuthenticated, authLoading } = useAuth(); const [email, setEmail] = useState(""); @@ -75,12 +77,14 @@ export default function LoginPage() { Log in - - Sign up - + {signupsEnabled && ( + + Sign up + + )}
diff --git a/frontend/src/app/signup/page.tsx b/frontend/src/app/signup/page.tsx index 2fe5e7eb14..11d8ebe327 100644 --- a/frontend/src/app/signup/page.tsx +++ b/frontend/src/app/signup/page.tsx @@ -24,6 +24,8 @@ const authToggleInactiveClassName = "inline-flex h-6 items-center rounded-full border border-transparent px-3 text-gray-500 transition-colors hover:bg-white/38 hover:text-gray-900"; export default function SignupPage() { + const signupsEnabled = + process.env.NEXT_PUBLIC_ROSS_SIGNUPS_ENABLED !== "false"; const router = useRouter(); const { isAuthenticated, authLoading } = useAuth(); const [email, setEmail] = useState(""); @@ -100,6 +102,33 @@ export default function SignupPage() { } }; + if (!signupsEnabled) { + return ( +
+
+ +
+
+
+

+ Private ROSS +

+

+ This deployment does not accept new account + registrations. Sign in with the owner account. +

+ +
+
+
+ ); + } + // Success View if (success) { return ( diff --git a/reports/release-manifest-v1.json b/reports/release-manifest-v1.json index 479946eeca..f1c4056506 100644 --- a/reports/release-manifest-v1.json +++ b/reports/release-manifest-v1.json @@ -7,13 +7,13 @@ "artifacts": [ { "path": "backend/schema.sql", - "sha256": "a802879090542e04152ef55a0faf47b78d2726d15859eb438b5537c1132d8026", - "sizeBytes": 27712 + "sha256": "75dc087ae4081b23e6e6f75778678fbc6d41d69a02bac8cfb569fd9d7c43690c", + "sizeBytes": 31393 }, { "path": "backend/src/config/runtime.ts", - "sha256": "b24c1d7c6848e6c7237c71487b061d3ca4aec843db3a14f65676f2d5fa5208f6", - "sizeBytes": 5963 + "sha256": "827e3b4b3fce29c7fb8061cd262cf185a6b461271ed22932a5200f9cc9c997a6", + "sizeBytes": 5988 }, { "path": "config/legal-source-operations.v1.json", diff --git a/tests/baseline/private-deployment.test.mjs b/tests/baseline/private-deployment.test.mjs new file mode 100644 index 0000000000..6920c41c79 --- /dev/null +++ b/tests/baseline/private-deployment.test.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const read = (path) => readFileSync(resolve(root, path), "utf8"); + +test("private deployment is manual, Toronto-hosted, and owner-only", () => { + const workflow = read(".github/workflows/deploy-private-ross.yml"); + const api = read("deploy/fly/api.toml"); + const frontend = read("deploy/fly/frontend.toml"); + + assert.match(workflow, /workflow_dispatch:/); + assert.doesNotMatch(workflow, /^\s*push:/m); + assert.match(workflow, /NEXT_PUBLIC_ROSS_SIGNUPS_ENABLED=false/); + assert.match(workflow, /ROSS_HOSTED_MODE=controlled-beta/); + assert.match(workflow, /R2_BUCKET_NAME=ross-private-files/); + assert.match(api, /primary_region = "yyz"/); + assert.match(frontend, /primary_region = "yyz"/); +}); + +test("private deployment credentials are supplied only through GitHub secrets", () => { + const workflow = read(".github/workflows/deploy-private-ross.yml"); + for (const name of [ + "FLY_API_TOKEN", + "ROSS_SUPABASE_URL", + "ROSS_SUPABASE_PUBLISHABLE_KEY", + "ROSS_SUPABASE_SECRET_KEY", + "ROSS_S3_ENDPOINT_URL", + "ROSS_S3_REGION", + "ROSS_S3_ACCESS_KEY_ID", + "ROSS_S3_SECRET_ACCESS_KEY", + ]) { + assert.match(workflow, new RegExp(`secrets\\.${name}`), name); + } +}); + diff --git a/tests/baseline/repository-contract.test.mjs b/tests/baseline/repository-contract.test.mjs index 4a21a64f56..7c0989ce26 100644 --- a/tests/baseline/repository-contract.test.mjs +++ b/tests/baseline/repository-contract.test.mjs @@ -95,6 +95,34 @@ test("the inherited data model keeps its core tables", () => { } }); +test("the fresh database schema includes current ROSS controls and enables RLS everywhere", () => { + const schema = read("backend/schema.sql"); + for (const required of [ + "beta_data_boundary_version", + "beta_data_boundary_acknowledged_at", + "legal_source_version_checks", + "security_audit_events", + ]) + assert.match(schema, new RegExp(required), required); + + const tables = [ + ...schema.matchAll(/create table if not exists public\.([a-z0-9_]+)/g), + ].map((match) => match[1]); + assert.ok(tables.length >= 25); + for (const table of tables) { + assert.match( + schema, + new RegExp(`alter table public\\.${table} enable row level security`, "i"), + `${table} must enable RLS in the fresh schema`, + ); + assert.match( + schema, + new RegExp(`revoke all on(?: table)? public\\.${table} from anon, authenticated`, "i"), + `${table} must revoke browser grants in the fresh schema`, + ); + } +}); + test("baseline fixtures are explicitly synthetic", () => { const manifest = json("tests/fixtures/manifest.json"); assert.equal(manifest.synthetic_only, true);