[P1] Set up a staging environment to stop testing on production #100

Description

@Jose-Gael-Cruz-Lopez

Problem

Right now everything we develop and test gets pushed to main, which is the production branch. Production points at the only Supabase project we have and the only Google OAuth client we have, so any QA we do — uploading a document, signing in with Google, exercising the chat tutor — runs against real prod data and the same ENCRYPTION_KEY. Once we have real users this becomes risky: a bad migration, a half-shipped feature, or a buggy ingestion path could corrupt user data or leak it across rows.

We need a staging tier that mirrors production closely enough to catch issues, but is fully isolated so we can sign into Google with a real account, hit a real Postgres, and watch the full deployed behavior without touching user data.

Goal

Stand up a devstagingproduction promotion flow with environment-separated config, so:

  • Local dev and PRs can be QA'd against staging credentials, never prod.
  • Schema changes are version-controlled and applied to staging first.
  • Prod-encrypted rows can't be decrypted by staging (separate ENCRYPTION_KEY) and vice versa.
  • Staging is reachable only by the dev team, not the public.

Proposed plan

1. Capture the production schema as migrations

  • Install Supabase CLI.
  • supabase db pull against the prod project to dump the current schema.
  • Commit the output under supabase/migrations/ so the schema is finally in version control.
  • Worth doing even before the rest of this issue lands — it's the prerequisite for everything else (Branching needs migrations to apply) and removes a single point of failure (the schema only existing in the prod dashboard).

2. Use Supabase Branching for the staging database (primary approach)

  • Enable Branching on the existing Supabase project.
  • Create a long-lived staging branch. Migrations from supabase/migrations/ get auto-applied on branch creation.
  • Optionally enable the GitHub integration so PRs spawn ephemeral preview branches per PR.
  • Staging gets its own connection string + service key — same project, different DB.
  • Add a scripts/seed_staging.py (uses db/connection.py::table()) to insert fake users / documents / sessions. Idempotent.

Caveats to verify before committing:

  • Branching is a paid-plan feature (Pro+) with limited free preview-branch quota — confirm this fits the current plan.
  • Auth provider config, storage buckets, and edge function secrets don't always copy cleanly across branches; may need to be re-set per-branch.

Fallback: if Branching turns out not to fit (plan cost, missing parity), fall back to a fully separate Supabase project (sapling-staging). Same migrations, same seed script, just a separate project in the dashboard.

3. Duplicate (or extend) the Google OAuth client

Two options:

  • Cheap: add staging redirect URIs (https://staging.<domain>/api/auth/google/callback, …/api/calendar/callback, plus localhost) to the existing client. Mixes scopes but is fastest.
  • Clean: create a second OAuth 2.0 client for staging and put its client_id/client_secret in the staging env file.

4. Split env files

Currently there is one backend/.env (see backend/.env.example) and one frontend/.env.example. Add:

  • backend/.env.staging — staging Supabase URL + service key (from the staging branch), staging Google client, differentENCRYPTION_KEY, differentSESSION_SECRET.
  • frontend/.env.staging — staging NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, BACKEND_URL.
  • Either parameterize docker-compose.yml with an ENV_FILE arg, or add a docker-compose.staging.yml override that swaps env_file.

5. Staging needs its own origin

Staging must be served from a different origin than production. Three reasons:

  • Google OAuth redirect URIs are exact-match: separate URIs prevent a misconfigured prod build from completing a staging OAuth flow (and vice versa).
  • Cookies are scoped by domain: sharing a domain risks the staging SESSION_SECRET-signed cookie being readable in prod.
  • The frontend bakes BACKEND_URL at build time: staging needs its own frontend deploy pointing at the staging backend.

Use a subdomain (staging.<domain>) once DNS is set up; until then, the Cloudflare Pages preview URL (staging.<project>.pages.dev) works fine. Path-prefix staging (/staging/...) is not a substitute because of cookie scoping.

6. Lock staging behind Cloudflare Access (Zero Trust)

The staging hostname must not be publicly reachable. Approach:

  • Add the staging hostname (frontend and backend, both) as an Access application in Cloudflare Zero Trust.
  • Policy: require Google SSO + an allowlist of dev emails (or a Google Workspace group).
  • Free tier covers up to 50 users — sufficient for the dev team.
  • The check happens at Cloudflare's edge, so unauthorized requests never reach the app.

Gotcha: Cloudflare Access intercepts every request to the protected hostname, including the Google OAuth callback (/api/auth/google/callback). Either configure Access to bypass that path, or use Access's Google identity provider so the user is already signed in to Google by the time the OAuth callback fires (usually no added friction).

What we are explicitly not doing for access control: relying on an unguessable URL, robots.txt, an app-level email allowlist as the only gate, or "we just don't share the link." Those are obscurity, not access control.

7. Branch + deploy flow

  • Create a long-lived staging git branch.
  • Wire Cloudflare Pages (frontend) and the backend host to also deploy staging to the staging origin using the staging env files.
  • New flow: feature branch → PR into staging → manual QA on staging (signed in via Cloudflare Access) with a real Google login against the staging Supabase branch → PR staging into main.

8. Local "real" mode

For local dev that hits real services with zero blast radius on prod, point backend/.env at the staging Supabase branch + staging Google client. (NEXT_PUBLIC_LOCAL_MODE=true in the frontend is mock-only and not a substitute for this.)

Suggested first PR

A small, mergeable starting point:

  • supabase/migrations/0001_initial.sql from the prod pull.
  • backend/.env.staging.example and frontend/.env.staging.example (committed, no secrets).
  • docker-compose.staging.yml override.
  • A short docs/decisions/0017-staging-environment.md ADR recording the split (Supabase Branching as primary, Cloudflare Access as the gate).

Subsequent PRs handle: enabling Branching + creating the staging branch, OAuth wiring, Cloudflare Access policy, and deploy config.

Out of scope

  • Full IaC (Terraform/Pulumi) for the Supabase project — useful eventually, overkill for now.
  • Secrets manager (Doppler / 1Password / sops) — nice to have, not blocking.
  • Automated end-to-end tests against staging — separate issue.

Acceptance criteria

  • Production schema is captured under supabase/migrations/ and applied to a Supabase staging branch (or fallback staging project).
  • Staging is reachable at its own origin (subdomain or Pages preview URL), separate from production.
  • Staging origin is gated by Cloudflare Access — only allowlisted dev emails / Workspace group can reach it.
  • Staging deploys use a separate ENCRYPTION_KEY, SESSION_SECRET, Supabase connection, and Google OAuth redirect URIs.
  • A developer can sign in with a real Google account against staging and exercise upload → chat without touching prod data.
  • main no longer receives un-QA'd changes; merges go via staging.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions

    , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
     blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
    }
    } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
    })();
    (function(){
    try {
    var __m = "github.com";
    var __re = new RegExp('^' + "github\\.com" + '
    
    Skip to content

    [P1] Set up a staging environment to stop testing on production #100

    Description

    @Jose-Gael-Cruz-Lopez

    Problem

    Right now everything we develop and test gets pushed to main, which is the production branch. Production points at the only Supabase project we have and the only Google OAuth client we have, so any QA we do — uploading a document, signing in with Google, exercising the chat tutor — runs against real prod data and the same ENCRYPTION_KEY. Once we have real users this becomes risky: a bad migration, a half-shipped feature, or a buggy ingestion path could corrupt user data or leak it across rows.

    We need a staging tier that mirrors production closely enough to catch issues, but is fully isolated so we can sign into Google with a real account, hit a real Postgres, and watch the full deployed behavior without touching user data.

    Goal

    Stand up a devstagingproduction promotion flow with environment-separated config, so:

    • Local dev and PRs can be QA'd against staging credentials, never prod.
    • Schema changes are version-controlled and applied to staging first.
    • Prod-encrypted rows can't be decrypted by staging (separate ENCRYPTION_KEY) and vice versa.
    • Staging is reachable only by the dev team, not the public.

    Proposed plan

    1. Capture the production schema as migrations

    • Install Supabase CLI.
    • supabase db pull against the prod project to dump the current schema.
    • Commit the output under supabase/migrations/ so the schema is finally in version control.
    • Worth doing even before the rest of this issue lands — it's the prerequisite for everything else (Branching needs migrations to apply) and removes a single point of failure (the schema only existing in the prod dashboard).

    2. Use Supabase Branching for the staging database (primary approach)

    • Enable Branching on the existing Supabase project.
    • Create a long-lived staging branch. Migrations from supabase/migrations/ get auto-applied on branch creation.
    • Optionally enable the GitHub integration so PRs spawn ephemeral preview branches per PR.
    • Staging gets its own connection string + service key — same project, different DB.
    • Add a scripts/seed_staging.py (uses db/connection.py::table()) to insert fake users / documents / sessions. Idempotent.

    Caveats to verify before committing:

    • Branching is a paid-plan feature (Pro+) with limited free preview-branch quota — confirm this fits the current plan.
    • Auth provider config, storage buckets, and edge function secrets don't always copy cleanly across branches; may need to be re-set per-branch.

    Fallback: if Branching turns out not to fit (plan cost, missing parity), fall back to a fully separate Supabase project (sapling-staging). Same migrations, same seed script, just a separate project in the dashboard.

    3. Duplicate (or extend) the Google OAuth client

    Two options:

    • Cheap: add staging redirect URIs (https://staging.<domain>/api/auth/google/callback, …/api/calendar/callback, plus localhost) to the existing client. Mixes scopes but is fastest.
    • Clean: create a second OAuth 2.0 client for staging and put its client_id/client_secret in the staging env file.

    4. Split env files

    Currently there is one backend/.env (see backend/.env.example) and one frontend/.env.example. Add:

    • backend/.env.staging — staging Supabase URL + service key (from the staging branch), staging Google client, differentENCRYPTION_KEY, differentSESSION_SECRET.
    • frontend/.env.staging — staging NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, BACKEND_URL.
    • Either parameterize docker-compose.yml with an ENV_FILE arg, or add a docker-compose.staging.yml override that swaps env_file.

    5. Staging needs its own origin

    Staging must be served from a different origin than production. Three reasons:

    • Google OAuth redirect URIs are exact-match: separate URIs prevent a misconfigured prod build from completing a staging OAuth flow (and vice versa).
    • Cookies are scoped by domain: sharing a domain risks the staging SESSION_SECRET-signed cookie being readable in prod.
    • The frontend bakes BACKEND_URL at build time: staging needs its own frontend deploy pointing at the staging backend.

    Use a subdomain (staging.<domain>) once DNS is set up; until then, the Cloudflare Pages preview URL (staging.<project>.pages.dev) works fine. Path-prefix staging (/staging/...) is not a substitute because of cookie scoping.

    6. Lock staging behind Cloudflare Access (Zero Trust)

    The staging hostname must not be publicly reachable. Approach:

    • Add the staging hostname (frontend and backend, both) as an Access application in Cloudflare Zero Trust.
    • Policy: require Google SSO + an allowlist of dev emails (or a Google Workspace group).
    • Free tier covers up to 50 users — sufficient for the dev team.
    • The check happens at Cloudflare's edge, so unauthorized requests never reach the app.

    Gotcha: Cloudflare Access intercepts every request to the protected hostname, including the Google OAuth callback (/api/auth/google/callback). Either configure Access to bypass that path, or use Access's Google identity provider so the user is already signed in to Google by the time the OAuth callback fires (usually no added friction).

    What we are explicitly not doing for access control: relying on an unguessable URL, robots.txt, an app-level email allowlist as the only gate, or "we just don't share the link." Those are obscurity, not access control.

    7. Branch + deploy flow

    • Create a long-lived staging git branch.
    • Wire Cloudflare Pages (frontend) and the backend host to also deploy staging to the staging origin using the staging env files.
    • New flow: feature branch → PR into staging → manual QA on staging (signed in via Cloudflare Access) with a real Google login against the staging Supabase branch → PR staging into main.

    8. Local "real" mode

    For local dev that hits real services with zero blast radius on prod, point backend/.env at the staging Supabase branch + staging Google client. (NEXT_PUBLIC_LOCAL_MODE=true in the frontend is mock-only and not a substitute for this.)

    Suggested first PR

    A small, mergeable starting point:

    • supabase/migrations/0001_initial.sql from the prod pull.
    • backend/.env.staging.example and frontend/.env.staging.example (committed, no secrets).
    • docker-compose.staging.yml override.
    • A short docs/decisions/0017-staging-environment.md ADR recording the split (Supabase Branching as primary, Cloudflare Access as the gate).

    Subsequent PRs handle: enabling Branching + creating the staging branch, OAuth wiring, Cloudflare Access policy, and deploy config.

    Out of scope

    • Full IaC (Terraform/Pulumi) for the Supabase project — useful eventually, overkill for now.
    • Secrets manager (Doppler / 1Password / sops) — nice to have, not blocking.
    • Automated end-to-end tests against staging — separate issue.

    Acceptance criteria

    • Production schema is captured under supabase/migrations/ and applied to a Supabase staging branch (or fallback staging project).
    • Staging is reachable at its own origin (subdomain or Pages preview URL), separate from production.
    • Staging origin is gated by Cloudflare Access — only allowlisted dev emails / Workspace group can reach it.
    • Staging deploys use a separate ENCRYPTION_KEY, SESSION_SECRET, Supabase connection, and Google OAuth redirect URIs.
    • A developer can sign in with a real Google account against staging and exercise upload → chat without touching prod data.
    • main no longer receives un-QA'd changes; merges go via staging.

    Metadata

    Metadata

    Assignees

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
      Skip to content

      [P1] Set up a staging environment to stop testing on production #100

      Description

      @Jose-Gael-Cruz-Lopez

      Problem

      Right now everything we develop and test gets pushed to main, which is the production branch. Production points at the only Supabase project we have and the only Google OAuth client we have, so any QA we do — uploading a document, signing in with Google, exercising the chat tutor — runs against real prod data and the same ENCRYPTION_KEY. Once we have real users this becomes risky: a bad migration, a half-shipped feature, or a buggy ingestion path could corrupt user data or leak it across rows.

      We need a staging tier that mirrors production closely enough to catch issues, but is fully isolated so we can sign into Google with a real account, hit a real Postgres, and watch the full deployed behavior without touching user data.

      Goal

      Stand up a devstagingproduction promotion flow with environment-separated config, so:

      • Local dev and PRs can be QA'd against staging credentials, never prod.
      • Schema changes are version-controlled and applied to staging first.
      • Prod-encrypted rows can't be decrypted by staging (separate ENCRYPTION_KEY) and vice versa.
      • Staging is reachable only by the dev team, not the public.

      Proposed plan

      1. Capture the production schema as migrations

      • Install Supabase CLI.
      • supabase db pull against the prod project to dump the current schema.
      • Commit the output under supabase/migrations/ so the schema is finally in version control.
      • Worth doing even before the rest of this issue lands — it's the prerequisite for everything else (Branching needs migrations to apply) and removes a single point of failure (the schema only existing in the prod dashboard).

      2. Use Supabase Branching for the staging database (primary approach)

      • Enable Branching on the existing Supabase project.
      • Create a long-lived staging branch. Migrations from supabase/migrations/ get auto-applied on branch creation.
      • Optionally enable the GitHub integration so PRs spawn ephemeral preview branches per PR.
      • Staging gets its own connection string + service key — same project, different DB.
      • Add a scripts/seed_staging.py (uses db/connection.py::table()) to insert fake users / documents / sessions. Idempotent.

      Caveats to verify before committing:

      • Branching is a paid-plan feature (Pro+) with limited free preview-branch quota — confirm this fits the current plan.
      • Auth provider config, storage buckets, and edge function secrets don't always copy cleanly across branches; may need to be re-set per-branch.

      Fallback: if Branching turns out not to fit (plan cost, missing parity), fall back to a fully separate Supabase project (sapling-staging). Same migrations, same seed script, just a separate project in the dashboard.

      3. Duplicate (or extend) the Google OAuth client

      Two options:

      • Cheap: add staging redirect URIs (https://staging.<domain>/api/auth/google/callback, …/api/calendar/callback, plus localhost) to the existing client. Mixes scopes but is fastest.
      • Clean: create a second OAuth 2.0 client for staging and put its client_id/client_secret in the staging env file.

      4. Split env files

      Currently there is one backend/.env (see backend/.env.example) and one frontend/.env.example. Add:

      • backend/.env.staging — staging Supabase URL + service key (from the staging branch), staging Google client, differentENCRYPTION_KEY, differentSESSION_SECRET.
      • frontend/.env.staging — staging NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, BACKEND_URL.
      • Either parameterize docker-compose.yml with an ENV_FILE arg, or add a docker-compose.staging.yml override that swaps env_file.

      5. Staging needs its own origin

      Staging must be served from a different origin than production. Three reasons:

      • Google OAuth redirect URIs are exact-match: separate URIs prevent a misconfigured prod build from completing a staging OAuth flow (and vice versa).
      • Cookies are scoped by domain: sharing a domain risks the staging SESSION_SECRET-signed cookie being readable in prod.
      • The frontend bakes BACKEND_URL at build time: staging needs its own frontend deploy pointing at the staging backend.

      Use a subdomain (staging.<domain>) once DNS is set up; until then, the Cloudflare Pages preview URL (staging.<project>.pages.dev) works fine. Path-prefix staging (/staging/...) is not a substitute because of cookie scoping.

      6. Lock staging behind Cloudflare Access (Zero Trust)

      The staging hostname must not be publicly reachable. Approach:

      • Add the staging hostname (frontend and backend, both) as an Access application in Cloudflare Zero Trust.
      • Policy: require Google SSO + an allowlist of dev emails (or a Google Workspace group).
      • Free tier covers up to 50 users — sufficient for the dev team.
      • The check happens at Cloudflare's edge, so unauthorized requests never reach the app.

      Gotcha: Cloudflare Access intercepts every request to the protected hostname, including the Google OAuth callback (/api/auth/google/callback). Either configure Access to bypass that path, or use Access's Google identity provider so the user is already signed in to Google by the time the OAuth callback fires (usually no added friction).

      What we are explicitly not doing for access control: relying on an unguessable URL, robots.txt, an app-level email allowlist as the only gate, or "we just don't share the link." Those are obscurity, not access control.

      7. Branch + deploy flow

      • Create a long-lived staging git branch.
      • Wire Cloudflare Pages (frontend) and the backend host to also deploy staging to the staging origin using the staging env files.
      • New flow: feature branch → PR into staging → manual QA on staging (signed in via Cloudflare Access) with a real Google login against the staging Supabase branch → PR staging into main.

      8. Local "real" mode

      For local dev that hits real services with zero blast radius on prod, point backend/.env at the staging Supabase branch + staging Google client. (NEXT_PUBLIC_LOCAL_MODE=true in the frontend is mock-only and not a substitute for this.)

      Suggested first PR

      A small, mergeable starting point:

      • supabase/migrations/0001_initial.sql from the prod pull.
      • backend/.env.staging.example and frontend/.env.staging.example (committed, no secrets).
      • docker-compose.staging.yml override.
      • A short docs/decisions/0017-staging-environment.md ADR recording the split (Supabase Branching as primary, Cloudflare Access as the gate).

      Subsequent PRs handle: enabling Branching + creating the staging branch, OAuth wiring, Cloudflare Access policy, and deploy config.

      Out of scope

      • Full IaC (Terraform/Pulumi) for the Supabase project — useful eventually, overkill for now.
      • Secrets manager (Doppler / 1Password / sops) — nice to have, not blocking.
      • Automated end-to-end tests against staging — separate issue.

      Acceptance criteria

      • Production schema is captured under supabase/migrations/ and applied to a Supabase staging branch (or fallback staging project).
      • Staging is reachable at its own origin (subdomain or Pages preview URL), separate from production.
      • Staging origin is gated by Cloudflare Access — only allowlisted dev emails / Workspace group can reach it.
      • Staging deploys use a separate ENCRYPTION_KEY, SESSION_SECRET, Supabase connection, and Google OAuth redirect URIs.
      • A developer can sign in with a real Google account against staging and exercise upload → chat without touching prod data.
      • main no longer receives un-QA'd changes; merges go via staging.

      Metadata

      Metadata

      Assignees

      Type

      No type

      Projects

      No projects

        Milestone

        No milestone

        Relationships

        None yet

        Development

        No branches or pull requests

        Issue actions

        , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
        Skip to content

        [P1] Set up a staging environment to stop testing on production #100

        Description

        @Jose-Gael-Cruz-Lopez

        Problem

        Right now everything we develop and test gets pushed to main, which is the production branch. Production points at the only Supabase project we have and the only Google OAuth client we have, so any QA we do — uploading a document, signing in with Google, exercising the chat tutor — runs against real prod data and the same ENCRYPTION_KEY. Once we have real users this becomes risky: a bad migration, a half-shipped feature, or a buggy ingestion path could corrupt user data or leak it across rows.

        We need a staging tier that mirrors production closely enough to catch issues, but is fully isolated so we can sign into Google with a real account, hit a real Postgres, and watch the full deployed behavior without touching user data.

        Goal

        Stand up a devstagingproduction promotion flow with environment-separated config, so:

        • Local dev and PRs can be QA'd against staging credentials, never prod.
        • Schema changes are version-controlled and applied to staging first.
        • Prod-encrypted rows can't be decrypted by staging (separate ENCRYPTION_KEY) and vice versa.
        • Staging is reachable only by the dev team, not the public.

        Proposed plan

        1. Capture the production schema as migrations

        • Install Supabase CLI.
        • supabase db pull against the prod project to dump the current schema.
        • Commit the output under supabase/migrations/ so the schema is finally in version control.
        • Worth doing even before the rest of this issue lands — it's the prerequisite for everything else (Branching needs migrations to apply) and removes a single point of failure (the schema only existing in the prod dashboard).

        2. Use Supabase Branching for the staging database (primary approach)

        • Enable Branching on the existing Supabase project.
        • Create a long-lived staging branch. Migrations from supabase/migrations/ get auto-applied on branch creation.
        • Optionally enable the GitHub integration so PRs spawn ephemeral preview branches per PR.
        • Staging gets its own connection string + service key — same project, different DB.
        • Add a scripts/seed_staging.py (uses db/connection.py::table()) to insert fake users / documents / sessions. Idempotent.

        Caveats to verify before committing:

        • Branching is a paid-plan feature (Pro+) with limited free preview-branch quota — confirm this fits the current plan.
        • Auth provider config, storage buckets, and edge function secrets don't always copy cleanly across branches; may need to be re-set per-branch.

        Fallback: if Branching turns out not to fit (plan cost, missing parity), fall back to a fully separate Supabase project (sapling-staging). Same migrations, same seed script, just a separate project in the dashboard.

        3. Duplicate (or extend) the Google OAuth client

        Two options:

        • Cheap: add staging redirect URIs (https://staging.<domain>/api/auth/google/callback, …/api/calendar/callback, plus localhost) to the existing client. Mixes scopes but is fastest.
        • Clean: create a second OAuth 2.0 client for staging and put its client_id/client_secret in the staging env file.

        4. Split env files

        Currently there is one backend/.env (see backend/.env.example) and one frontend/.env.example. Add:

        • backend/.env.staging — staging Supabase URL + service key (from the staging branch), staging Google client, differentENCRYPTION_KEY, differentSESSION_SECRET.
        • frontend/.env.staging — staging NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, BACKEND_URL.
        • Either parameterize docker-compose.yml with an ENV_FILE arg, or add a docker-compose.staging.yml override that swaps env_file.

        5. Staging needs its own origin

        Staging must be served from a different origin than production. Three reasons:

        • Google OAuth redirect URIs are exact-match: separate URIs prevent a misconfigured prod build from completing a staging OAuth flow (and vice versa).
        • Cookies are scoped by domain: sharing a domain risks the staging SESSION_SECRET-signed cookie being readable in prod.
        • The frontend bakes BACKEND_URL at build time: staging needs its own frontend deploy pointing at the staging backend.

        Use a subdomain (staging.<domain>) once DNS is set up; until then, the Cloudflare Pages preview URL (staging.<project>.pages.dev) works fine. Path-prefix staging (/staging/...) is not a substitute because of cookie scoping.

        6. Lock staging behind Cloudflare Access (Zero Trust)

        The staging hostname must not be publicly reachable. Approach:

        • Add the staging hostname (frontend and backend, both) as an Access application in Cloudflare Zero Trust.
        • Policy: require Google SSO + an allowlist of dev emails (or a Google Workspace group).
        • Free tier covers up to 50 users — sufficient for the dev team.
        • The check happens at Cloudflare's edge, so unauthorized requests never reach the app.

        Gotcha: Cloudflare Access intercepts every request to the protected hostname, including the Google OAuth callback (/api/auth/google/callback). Either configure Access to bypass that path, or use Access's Google identity provider so the user is already signed in to Google by the time the OAuth callback fires (usually no added friction).

        What we are explicitly not doing for access control: relying on an unguessable URL, robots.txt, an app-level email allowlist as the only gate, or "we just don't share the link." Those are obscurity, not access control.

        7. Branch + deploy flow

        • Create a long-lived staging git branch.
        • Wire Cloudflare Pages (frontend) and the backend host to also deploy staging to the staging origin using the staging env files.
        • New flow: feature branch → PR into staging → manual QA on staging (signed in via Cloudflare Access) with a real Google login against the staging Supabase branch → PR staging into main.

        8. Local "real" mode

        For local dev that hits real services with zero blast radius on prod, point backend/.env at the staging Supabase branch + staging Google client. (NEXT_PUBLIC_LOCAL_MODE=true in the frontend is mock-only and not a substitute for this.)

        Suggested first PR

        A small, mergeable starting point:

        • supabase/migrations/0001_initial.sql from the prod pull.
        • backend/.env.staging.example and frontend/.env.staging.example (committed, no secrets).
        • docker-compose.staging.yml override.
        • A short docs/decisions/0017-staging-environment.md ADR recording the split (Supabase Branching as primary, Cloudflare Access as the gate).

        Subsequent PRs handle: enabling Branching + creating the staging branch, OAuth wiring, Cloudflare Access policy, and deploy config.

        Out of scope

        • Full IaC (Terraform/Pulumi) for the Supabase project — useful eventually, overkill for now.
        • Secrets manager (Doppler / 1Password / sops) — nice to have, not blocking.
        • Automated end-to-end tests against staging — separate issue.

        Acceptance criteria

        • Production schema is captured under supabase/migrations/ and applied to a Supabase staging branch (or fallback staging project).
        • Staging is reachable at its own origin (subdomain or Pages preview URL), separate from production.
        • Staging origin is gated by Cloudflare Access — only allowlisted dev emails / Workspace group can reach it.
        • Staging deploys use a separate ENCRYPTION_KEY, SESSION_SECRET, Supabase connection, and Google OAuth redirect URIs.
        • A developer can sign in with a real Google account against staging and exercise upload → chat without touching prod data.
        • main no longer receives un-QA'd changes; merges go via staging.

        Metadata

        Metadata

        Assignees

        Type

        No type

        Projects

        No projects

          Milestone

          No milestone

          Relationships

          None yet

          Development

          No branches or pull requests

          Issue actions

          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
          Skip to content

          [P1] Set up a staging environment to stop testing on production #100

          Description

          @Jose-Gael-Cruz-Lopez

          Problem

          Right now everything we develop and test gets pushed to main, which is the production branch. Production points at the only Supabase project we have and the only Google OAuth client we have, so any QA we do — uploading a document, signing in with Google, exercising the chat tutor — runs against real prod data and the same ENCRYPTION_KEY. Once we have real users this becomes risky: a bad migration, a half-shipped feature, or a buggy ingestion path could corrupt user data or leak it across rows.

          We need a staging tier that mirrors production closely enough to catch issues, but is fully isolated so we can sign into Google with a real account, hit a real Postgres, and watch the full deployed behavior without touching user data.

          Goal

          Stand up a devstagingproduction promotion flow with environment-separated config, so:

          • Local dev and PRs can be QA'd against staging credentials, never prod.
          • Schema changes are version-controlled and applied to staging first.
          • Prod-encrypted rows can't be decrypted by staging (separate ENCRYPTION_KEY) and vice versa.
          • Staging is reachable only by the dev team, not the public.

          Proposed plan

          1. Capture the production schema as migrations

          • Install Supabase CLI.
          • supabase db pull against the prod project to dump the current schema.
          • Commit the output under supabase/migrations/ so the schema is finally in version control.
          • Worth doing even before the rest of this issue lands — it's the prerequisite for everything else (Branching needs migrations to apply) and removes a single point of failure (the schema only existing in the prod dashboard).

          2. Use Supabase Branching for the staging database (primary approach)

          • Enable Branching on the existing Supabase project.
          • Create a long-lived staging branch. Migrations from supabase/migrations/ get auto-applied on branch creation.
          • Optionally enable the GitHub integration so PRs spawn ephemeral preview branches per PR.
          • Staging gets its own connection string + service key — same project, different DB.
          • Add a scripts/seed_staging.py (uses db/connection.py::table()) to insert fake users / documents / sessions. Idempotent.

          Caveats to verify before committing:

          • Branching is a paid-plan feature (Pro+) with limited free preview-branch quota — confirm this fits the current plan.
          • Auth provider config, storage buckets, and edge function secrets don't always copy cleanly across branches; may need to be re-set per-branch.

          Fallback: if Branching turns out not to fit (plan cost, missing parity), fall back to a fully separate Supabase project (sapling-staging). Same migrations, same seed script, just a separate project in the dashboard.

          3. Duplicate (or extend) the Google OAuth client

          Two options:

          • Cheap: add staging redirect URIs (https://staging.<domain>/api/auth/google/callback, …/api/calendar/callback, plus localhost) to the existing client. Mixes scopes but is fastest.
          • Clean: create a second OAuth 2.0 client for staging and put its client_id/client_secret in the staging env file.

          4. Split env files

          Currently there is one backend/.env (see backend/.env.example) and one frontend/.env.example. Add:

          • backend/.env.staging — staging Supabase URL + service key (from the staging branch), staging Google client, differentENCRYPTION_KEY, differentSESSION_SECRET.
          • frontend/.env.staging — staging NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, BACKEND_URL.
          • Either parameterize docker-compose.yml with an ENV_FILE arg, or add a docker-compose.staging.yml override that swaps env_file.

          5. Staging needs its own origin

          Staging must be served from a different origin than production. Three reasons:

          • Google OAuth redirect URIs are exact-match: separate URIs prevent a misconfigured prod build from completing a staging OAuth flow (and vice versa).
          • Cookies are scoped by domain: sharing a domain risks the staging SESSION_SECRET-signed cookie being readable in prod.
          • The frontend bakes BACKEND_URL at build time: staging needs its own frontend deploy pointing at the staging backend.

          Use a subdomain (staging.<domain>) once DNS is set up; until then, the Cloudflare Pages preview URL (staging.<project>.pages.dev) works fine. Path-prefix staging (/staging/...) is not a substitute because of cookie scoping.

          6. Lock staging behind Cloudflare Access (Zero Trust)

          The staging hostname must not be publicly reachable. Approach:

          • Add the staging hostname (frontend and backend, both) as an Access application in Cloudflare Zero Trust.
          • Policy: require Google SSO + an allowlist of dev emails (or a Google Workspace group).
          • Free tier covers up to 50 users — sufficient for the dev team.
          • The check happens at Cloudflare's edge, so unauthorized requests never reach the app.

          Gotcha: Cloudflare Access intercepts every request to the protected hostname, including the Google OAuth callback (/api/auth/google/callback). Either configure Access to bypass that path, or use Access's Google identity provider so the user is already signed in to Google by the time the OAuth callback fires (usually no added friction).

          What we are explicitly not doing for access control: relying on an unguessable URL, robots.txt, an app-level email allowlist as the only gate, or "we just don't share the link." Those are obscurity, not access control.

          7. Branch + deploy flow

          • Create a long-lived staging git branch.
          • Wire Cloudflare Pages (frontend) and the backend host to also deploy staging to the staging origin using the staging env files.
          • New flow: feature branch → PR into staging → manual QA on staging (signed in via Cloudflare Access) with a real Google login against the staging Supabase branch → PR staging into main.

          8. Local "real" mode

          For local dev that hits real services with zero blast radius on prod, point backend/.env at the staging Supabase branch + staging Google client. (NEXT_PUBLIC_LOCAL_MODE=true in the frontend is mock-only and not a substitute for this.)

          Suggested first PR

          A small, mergeable starting point:

          • supabase/migrations/0001_initial.sql from the prod pull.
          • backend/.env.staging.example and frontend/.env.staging.example (committed, no secrets).
          • docker-compose.staging.yml override.
          • A short docs/decisions/0017-staging-environment.md ADR recording the split (Supabase Branching as primary, Cloudflare Access as the gate).

          Subsequent PRs handle: enabling Branching + creating the staging branch, OAuth wiring, Cloudflare Access policy, and deploy config.

          Out of scope

          • Full IaC (Terraform/Pulumi) for the Supabase project — useful eventually, overkill for now.
          • Secrets manager (Doppler / 1Password / sops) — nice to have, not blocking.
          • Automated end-to-end tests against staging — separate issue.

          Acceptance criteria

          • Production schema is captured under supabase/migrations/ and applied to a Supabase staging branch (or fallback staging project).
          • Staging is reachable at its own origin (subdomain or Pages preview URL), separate from production.
          • Staging origin is gated by Cloudflare Access — only allowlisted dev emails / Workspace group can reach it.
          • Staging deploys use a separate ENCRYPTION_KEY, SESSION_SECRET, Supabase connection, and Google OAuth redirect URIs.
          • A developer can sign in with a real Google account against staging and exercise upload → chat without touching prod data.
          • main no longer receives un-QA'd changes; merges go via staging.

          Metadata

          Metadata

          Assignees

          Type

          No type

          Projects

          No projects

            Milestone

            No milestone

            Relationships

            None yet

            Development

            No branches or pull requests

            Issue actions

            , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
            Skip to content

            [P1] Set up a staging environment to stop testing on production #100

            Description

            @Jose-Gael-Cruz-Lopez

            Problem

            Right now everything we develop and test gets pushed to main, which is the production branch. Production points at the only Supabase project we have and the only Google OAuth client we have, so any QA we do — uploading a document, signing in with Google, exercising the chat tutor — runs against real prod data and the same ENCRYPTION_KEY. Once we have real users this becomes risky: a bad migration, a half-shipped feature, or a buggy ingestion path could corrupt user data or leak it across rows.

            We need a staging tier that mirrors production closely enough to catch issues, but is fully isolated so we can sign into Google with a real account, hit a real Postgres, and watch the full deployed behavior without touching user data.

            Goal

            Stand up a devstagingproduction promotion flow with environment-separated config, so:

            • Local dev and PRs can be QA'd against staging credentials, never prod.
            • Schema changes are version-controlled and applied to staging first.
            • Prod-encrypted rows can't be decrypted by staging (separate ENCRYPTION_KEY) and vice versa.
            • Staging is reachable only by the dev team, not the public.

            Proposed plan

            1. Capture the production schema as migrations

            • Install Supabase CLI.
            • supabase db pull against the prod project to dump the current schema.
            • Commit the output under supabase/migrations/ so the schema is finally in version control.
            • Worth doing even before the rest of this issue lands — it's the prerequisite for everything else (Branching needs migrations to apply) and removes a single point of failure (the schema only existing in the prod dashboard).

            2. Use Supabase Branching for the staging database (primary approach)

            • Enable Branching on the existing Supabase project.
            • Create a long-lived staging branch. Migrations from supabase/migrations/ get auto-applied on branch creation.
            • Optionally enable the GitHub integration so PRs spawn ephemeral preview branches per PR.
            • Staging gets its own connection string + service key — same project, different DB.
            • Add a scripts/seed_staging.py (uses db/connection.py::table()) to insert fake users / documents / sessions. Idempotent.

            Caveats to verify before committing:

            • Branching is a paid-plan feature (Pro+) with limited free preview-branch quota — confirm this fits the current plan.
            • Auth provider config, storage buckets, and edge function secrets don't always copy cleanly across branches; may need to be re-set per-branch.

            Fallback: if Branching turns out not to fit (plan cost, missing parity), fall back to a fully separate Supabase project (sapling-staging). Same migrations, same seed script, just a separate project in the dashboard.

            3. Duplicate (or extend) the Google OAuth client

            Two options:

            • Cheap: add staging redirect URIs (https://staging.<domain>/api/auth/google/callback, …/api/calendar/callback, plus localhost) to the existing client. Mixes scopes but is fastest.
            • Clean: create a second OAuth 2.0 client for staging and put its client_id/client_secret in the staging env file.

            4. Split env files

            Currently there is one backend/.env (see backend/.env.example) and one frontend/.env.example. Add:

            • backend/.env.staging — staging Supabase URL + service key (from the staging branch), staging Google client, differentENCRYPTION_KEY, differentSESSION_SECRET.
            • frontend/.env.staging — staging NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, BACKEND_URL.
            • Either parameterize docker-compose.yml with an ENV_FILE arg, or add a docker-compose.staging.yml override that swaps env_file.

            5. Staging needs its own origin

            Staging must be served from a different origin than production. Three reasons:

            • Google OAuth redirect URIs are exact-match: separate URIs prevent a misconfigured prod build from completing a staging OAuth flow (and vice versa).
            • Cookies are scoped by domain: sharing a domain risks the staging SESSION_SECRET-signed cookie being readable in prod.
            • The frontend bakes BACKEND_URL at build time: staging needs its own frontend deploy pointing at the staging backend.

            Use a subdomain (staging.<domain>) once DNS is set up; until then, the Cloudflare Pages preview URL (staging.<project>.pages.dev) works fine. Path-prefix staging (/staging/...) is not a substitute because of cookie scoping.

            6. Lock staging behind Cloudflare Access (Zero Trust)

            The staging hostname must not be publicly reachable. Approach:

            • Add the staging hostname (frontend and backend, both) as an Access application in Cloudflare Zero Trust.
            • Policy: require Google SSO + an allowlist of dev emails (or a Google Workspace group).
            • Free tier covers up to 50 users — sufficient for the dev team.
            • The check happens at Cloudflare's edge, so unauthorized requests never reach the app.

            Gotcha: Cloudflare Access intercepts every request to the protected hostname, including the Google OAuth callback (/api/auth/google/callback). Either configure Access to bypass that path, or use Access's Google identity provider so the user is already signed in to Google by the time the OAuth callback fires (usually no added friction).

            What we are explicitly not doing for access control: relying on an unguessable URL, robots.txt, an app-level email allowlist as the only gate, or "we just don't share the link." Those are obscurity, not access control.

            7. Branch + deploy flow

            • Create a long-lived staging git branch.
            • Wire Cloudflare Pages (frontend) and the backend host to also deploy staging to the staging origin using the staging env files.
            • New flow: feature branch → PR into staging → manual QA on staging (signed in via Cloudflare Access) with a real Google login against the staging Supabase branch → PR staging into main.

            8. Local "real" mode

            For local dev that hits real services with zero blast radius on prod, point backend/.env at the staging Supabase branch + staging Google client. (NEXT_PUBLIC_LOCAL_MODE=true in the frontend is mock-only and not a substitute for this.)

            Suggested first PR

            A small, mergeable starting point:

            • supabase/migrations/0001_initial.sql from the prod pull.
            • backend/.env.staging.example and frontend/.env.staging.example (committed, no secrets).
            • docker-compose.staging.yml override.
            • A short docs/decisions/0017-staging-environment.md ADR recording the split (Supabase Branching as primary, Cloudflare Access as the gate).

            Subsequent PRs handle: enabling Branching + creating the staging branch, OAuth wiring, Cloudflare Access policy, and deploy config.

            Out of scope

            • Full IaC (Terraform/Pulumi) for the Supabase project — useful eventually, overkill for now.
            • Secrets manager (Doppler / 1Password / sops) — nice to have, not blocking.
            • Automated end-to-end tests against staging — separate issue.

            Acceptance criteria

            • Production schema is captured under supabase/migrations/ and applied to a Supabase staging branch (or fallback staging project).
            • Staging is reachable at its own origin (subdomain or Pages preview URL), separate from production.
            • Staging origin is gated by Cloudflare Access — only allowlisted dev emails / Workspace group can reach it.
            • Staging deploys use a separate ENCRYPTION_KEY, SESSION_SECRET, Supabase connection, and Google OAuth redirect URIs.
            • A developer can sign in with a real Google account against staging and exercise upload → chat without touching prod data.
            • main no longer receives un-QA'd changes; merges go via staging.

            Metadata

            Metadata

            Assignees

            Type

            No type

            Projects

            No projects

              Milestone

              No milestone

              Relationships

              None yet

              Development

              No branches or pull requests

              Issue actions

              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
              Skip to content

              [P1] Set up a staging environment to stop testing on production #100

              Description

              @Jose-Gael-Cruz-Lopez

              Problem

              Right now everything we develop and test gets pushed to main, which is the production branch. Production points at the only Supabase project we have and the only Google OAuth client we have, so any QA we do — uploading a document, signing in with Google, exercising the chat tutor — runs against real prod data and the same ENCRYPTION_KEY. Once we have real users this becomes risky: a bad migration, a half-shipped feature, or a buggy ingestion path could corrupt user data or leak it across rows.

              We need a staging tier that mirrors production closely enough to catch issues, but is fully isolated so we can sign into Google with a real account, hit a real Postgres, and watch the full deployed behavior without touching user data.

              Goal

              Stand up a devstagingproduction promotion flow with environment-separated config, so:

              • Local dev and PRs can be QA'd against staging credentials, never prod.
              • Schema changes are version-controlled and applied to staging first.
              • Prod-encrypted rows can't be decrypted by staging (separate ENCRYPTION_KEY) and vice versa.
              • Staging is reachable only by the dev team, not the public.

              Proposed plan

              1. Capture the production schema as migrations

              • Install Supabase CLI.
              • supabase db pull against the prod project to dump the current schema.
              • Commit the output under supabase/migrations/ so the schema is finally in version control.
              • Worth doing even before the rest of this issue lands — it's the prerequisite for everything else (Branching needs migrations to apply) and removes a single point of failure (the schema only existing in the prod dashboard).

              2. Use Supabase Branching for the staging database (primary approach)

              • Enable Branching on the existing Supabase project.
              • Create a long-lived staging branch. Migrations from supabase/migrations/ get auto-applied on branch creation.
              • Optionally enable the GitHub integration so PRs spawn ephemeral preview branches per PR.
              • Staging gets its own connection string + service key — same project, different DB.
              • Add a scripts/seed_staging.py (uses db/connection.py::table()) to insert fake users / documents / sessions. Idempotent.

              Caveats to verify before committing:

              • Branching is a paid-plan feature (Pro+) with limited free preview-branch quota — confirm this fits the current plan.
              • Auth provider config, storage buckets, and edge function secrets don't always copy cleanly across branches; may need to be re-set per-branch.

              Fallback: if Branching turns out not to fit (plan cost, missing parity), fall back to a fully separate Supabase project (sapling-staging). Same migrations, same seed script, just a separate project in the dashboard.

              3. Duplicate (or extend) the Google OAuth client

              Two options:

              • Cheap: add staging redirect URIs (https://staging.<domain>/api/auth/google/callback, …/api/calendar/callback, plus localhost) to the existing client. Mixes scopes but is fastest.
              • Clean: create a second OAuth 2.0 client for staging and put its client_id/client_secret in the staging env file.

              4. Split env files

              Currently there is one backend/.env (see backend/.env.example) and one frontend/.env.example. Add:

              • backend/.env.staging — staging Supabase URL + service key (from the staging branch), staging Google client, differentENCRYPTION_KEY, differentSESSION_SECRET.
              • frontend/.env.staging — staging NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, BACKEND_URL.
              • Either parameterize docker-compose.yml with an ENV_FILE arg, or add a docker-compose.staging.yml override that swaps env_file.

              5. Staging needs its own origin

              Staging must be served from a different origin than production. Three reasons:

              • Google OAuth redirect URIs are exact-match: separate URIs prevent a misconfigured prod build from completing a staging OAuth flow (and vice versa).
              • Cookies are scoped by domain: sharing a domain risks the staging SESSION_SECRET-signed cookie being readable in prod.
              • The frontend bakes BACKEND_URL at build time: staging needs its own frontend deploy pointing at the staging backend.

              Use a subdomain (staging.<domain>) once DNS is set up; until then, the Cloudflare Pages preview URL (staging.<project>.pages.dev) works fine. Path-prefix staging (/staging/...) is not a substitute because of cookie scoping.

              6. Lock staging behind Cloudflare Access (Zero Trust)

              The staging hostname must not be publicly reachable. Approach:

              • Add the staging hostname (frontend and backend, both) as an Access application in Cloudflare Zero Trust.
              • Policy: require Google SSO + an allowlist of dev emails (or a Google Workspace group).
              • Free tier covers up to 50 users — sufficient for the dev team.
              • The check happens at Cloudflare's edge, so unauthorized requests never reach the app.

              Gotcha: Cloudflare Access intercepts every request to the protected hostname, including the Google OAuth callback (/api/auth/google/callback). Either configure Access to bypass that path, or use Access's Google identity provider so the user is already signed in to Google by the time the OAuth callback fires (usually no added friction).

              What we are explicitly not doing for access control: relying on an unguessable URL, robots.txt, an app-level email allowlist as the only gate, or "we just don't share the link." Those are obscurity, not access control.

              7. Branch + deploy flow

              • Create a long-lived staging git branch.
              • Wire Cloudflare Pages (frontend) and the backend host to also deploy staging to the staging origin using the staging env files.
              • New flow: feature branch → PR into staging → manual QA on staging (signed in via Cloudflare Access) with a real Google login against the staging Supabase branch → PR staging into main.

              8. Local "real" mode

              For local dev that hits real services with zero blast radius on prod, point backend/.env at the staging Supabase branch + staging Google client. (NEXT_PUBLIC_LOCAL_MODE=true in the frontend is mock-only and not a substitute for this.)

              Suggested first PR

              A small, mergeable starting point:

              • supabase/migrations/0001_initial.sql from the prod pull.
              • backend/.env.staging.example and frontend/.env.staging.example (committed, no secrets).
              • docker-compose.staging.yml override.
              • A short docs/decisions/0017-staging-environment.md ADR recording the split (Supabase Branching as primary, Cloudflare Access as the gate).

              Subsequent PRs handle: enabling Branching + creating the staging branch, OAuth wiring, Cloudflare Access policy, and deploy config.

              Out of scope

              • Full IaC (Terraform/Pulumi) for the Supabase project — useful eventually, overkill for now.
              • Secrets manager (Doppler / 1Password / sops) — nice to have, not blocking.
              • Automated end-to-end tests against staging — separate issue.

              Acceptance criteria

              • Production schema is captured under supabase/migrations/ and applied to a Supabase staging branch (or fallback staging project).
              • Staging is reachable at its own origin (subdomain or Pages preview URL), separate from production.
              • Staging origin is gated by Cloudflare Access — only allowlisted dev emails / Workspace group can reach it.
              • Staging deploys use a separate ENCRYPTION_KEY, SESSION_SECRET, Supabase connection, and Google OAuth redirect URIs.
              • A developer can sign in with a real Google account against staging and exercise upload → chat without touching prod data.
              • main no longer receives un-QA'd changes; merges go via staging.

              Metadata

              Metadata

              Assignees

              Type

              No type

              Projects

              No projects

                Milestone

                No milestone

                Relationships

                None yet

                Development

                No branches or pull requests

                Issue actions

                , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
                Skip to content

                [P1] Set up a staging environment to stop testing on production #100

                Description

                @Jose-Gael-Cruz-Lopez

                Problem

                Right now everything we develop and test gets pushed to main, which is the production branch. Production points at the only Supabase project we have and the only Google OAuth client we have, so any QA we do — uploading a document, signing in with Google, exercising the chat tutor — runs against real prod data and the same ENCRYPTION_KEY. Once we have real users this becomes risky: a bad migration, a half-shipped feature, or a buggy ingestion path could corrupt user data or leak it across rows.

                We need a staging tier that mirrors production closely enough to catch issues, but is fully isolated so we can sign into Google with a real account, hit a real Postgres, and watch the full deployed behavior without touching user data.

                Goal

                Stand up a devstagingproduction promotion flow with environment-separated config, so:

                • Local dev and PRs can be QA'd against staging credentials, never prod.
                • Schema changes are version-controlled and applied to staging first.
                • Prod-encrypted rows can't be decrypted by staging (separate ENCRYPTION_KEY) and vice versa.
                • Staging is reachable only by the dev team, not the public.

                Proposed plan

                1. Capture the production schema as migrations

                • Install Supabase CLI.
                • supabase db pull against the prod project to dump the current schema.
                • Commit the output under supabase/migrations/ so the schema is finally in version control.
                • Worth doing even before the rest of this issue lands — it's the prerequisite for everything else (Branching needs migrations to apply) and removes a single point of failure (the schema only existing in the prod dashboard).

                2. Use Supabase Branching for the staging database (primary approach)

                • Enable Branching on the existing Supabase project.
                • Create a long-lived staging branch. Migrations from supabase/migrations/ get auto-applied on branch creation.
                • Optionally enable the GitHub integration so PRs spawn ephemeral preview branches per PR.
                • Staging gets its own connection string + service key — same project, different DB.
                • Add a scripts/seed_staging.py (uses db/connection.py::table()) to insert fake users / documents / sessions. Idempotent.

                Caveats to verify before committing:

                • Branching is a paid-plan feature (Pro+) with limited free preview-branch quota — confirm this fits the current plan.
                • Auth provider config, storage buckets, and edge function secrets don't always copy cleanly across branches; may need to be re-set per-branch.

                Fallback: if Branching turns out not to fit (plan cost, missing parity), fall back to a fully separate Supabase project (sapling-staging). Same migrations, same seed script, just a separate project in the dashboard.

                3. Duplicate (or extend) the Google OAuth client

                Two options:

                • Cheap: add staging redirect URIs (https://staging.<domain>/api/auth/google/callback, …/api/calendar/callback, plus localhost) to the existing client. Mixes scopes but is fastest.
                • Clean: create a second OAuth 2.0 client for staging and put its client_id/client_secret in the staging env file.

                4. Split env files

                Currently there is one backend/.env (see backend/.env.example) and one frontend/.env.example. Add:

                • backend/.env.staging — staging Supabase URL + service key (from the staging branch), staging Google client, differentENCRYPTION_KEY, differentSESSION_SECRET.
                • frontend/.env.staging — staging NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, BACKEND_URL.
                • Either parameterize docker-compose.yml with an ENV_FILE arg, or add a docker-compose.staging.yml override that swaps env_file.

                5. Staging needs its own origin

                Staging must be served from a different origin than production. Three reasons:

                • Google OAuth redirect URIs are exact-match: separate URIs prevent a misconfigured prod build from completing a staging OAuth flow (and vice versa).
                • Cookies are scoped by domain: sharing a domain risks the staging SESSION_SECRET-signed cookie being readable in prod.
                • The frontend bakes BACKEND_URL at build time: staging needs its own frontend deploy pointing at the staging backend.

                Use a subdomain (staging.<domain>) once DNS is set up; until then, the Cloudflare Pages preview URL (staging.<project>.pages.dev) works fine. Path-prefix staging (/staging/...) is not a substitute because of cookie scoping.

                6. Lock staging behind Cloudflare Access (Zero Trust)

                The staging hostname must not be publicly reachable. Approach:

                • Add the staging hostname (frontend and backend, both) as an Access application in Cloudflare Zero Trust.
                • Policy: require Google SSO + an allowlist of dev emails (or a Google Workspace group).
                • Free tier covers up to 50 users — sufficient for the dev team.
                • The check happens at Cloudflare's edge, so unauthorized requests never reach the app.

                Gotcha: Cloudflare Access intercepts every request to the protected hostname, including the Google OAuth callback (/api/auth/google/callback). Either configure Access to bypass that path, or use Access's Google identity provider so the user is already signed in to Google by the time the OAuth callback fires (usually no added friction).

                What we are explicitly not doing for access control: relying on an unguessable URL, robots.txt, an app-level email allowlist as the only gate, or "we just don't share the link." Those are obscurity, not access control.

                7. Branch + deploy flow

                • Create a long-lived staging git branch.
                • Wire Cloudflare Pages (frontend) and the backend host to also deploy staging to the staging origin using the staging env files.
                • New flow: feature branch → PR into staging → manual QA on staging (signed in via Cloudflare Access) with a real Google login against the staging Supabase branch → PR staging into main.

                8. Local "real" mode

                For local dev that hits real services with zero blast radius on prod, point backend/.env at the staging Supabase branch + staging Google client. (NEXT_PUBLIC_LOCAL_MODE=true in the frontend is mock-only and not a substitute for this.)

                Suggested first PR

                A small, mergeable starting point:

                • supabase/migrations/0001_initial.sql from the prod pull.
                • backend/.env.staging.example and frontend/.env.staging.example (committed, no secrets).
                • docker-compose.staging.yml override.
                • A short docs/decisions/0017-staging-environment.md ADR recording the split (Supabase Branching as primary, Cloudflare Access as the gate).

                Subsequent PRs handle: enabling Branching + creating the staging branch, OAuth wiring, Cloudflare Access policy, and deploy config.

                Out of scope

                • Full IaC (Terraform/Pulumi) for the Supabase project — useful eventually, overkill for now.
                • Secrets manager (Doppler / 1Password / sops) — nice to have, not blocking.
                • Automated end-to-end tests against staging — separate issue.

                Acceptance criteria

                • Production schema is captured under supabase/migrations/ and applied to a Supabase staging branch (or fallback staging project).
                • Staging is reachable at its own origin (subdomain or Pages preview URL), separate from production.
                • Staging origin is gated by Cloudflare Access — only allowlisted dev emails / Workspace group can reach it.
                • Staging deploys use a separate ENCRYPTION_KEY, SESSION_SECRET, Supabase connection, and Google OAuth redirect URIs.
                • A developer can sign in with a real Google account against staging and exercise upload → chat without touching prod data.
                • main no longer receives un-QA'd changes; merges go via staging.

                Metadata

                Metadata

                Assignees

                Type

                No type

                Projects

                No projects

                  Milestone

                  No milestone

                  Relationships

                  None yet

                  Development

                  No branches or pull requests

                  Issue actions