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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Interface language and AI-output language are separate settings. Interface langu

## Supabase environments and sessions

[`src/lib/supabase/env.ts`](../src/lib/supabase/env.ts) resolves production or staging client configuration. Public URLs and anon keys are browser configuration. Do not copy their values into docs.
[`src/lib/supabase/env.ts`](../src/lib/supabase/env.ts) resolves production or staging client configuration. Public URLs and anon keys are browser configuration. Do not copy their values into docs. Admins switch projects from Settings; see [operations.md](operations.md#switching-the-frontend-to-staging).

[`src/lib/supabase/client.ts`](../src/lib/supabase/client.ts) creates the client with persistent sessions, automatic refresh, PKCE, and environment-specific storage keys.

Expand Down
29 changes: 29 additions & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,35 @@ The staging browser suite disables trace, screenshot, and video capture. It crea

Do not enable the staging repository variable before every prerequisite is present. A skipped staging job is not a pass.

`STAGING_E2E_ENABLED` has been `true` since 2026-09-18, and the job passed through 2026-09-23. Since then it has been skipped because the `Quality` run it waits for fails before any runner starts (see [When GitHub Actions cannot run](#when-github-actions-cannot-run)). Restoring Actions minutes restores the staging job; nothing in the workflow needs changing.

## Staging sync

Staging is project `debate-trainer-staging` (`brifeyypjauyusmkptkx`). Production is `debate-trainer` (`uziwdtkzofptihahfooo`). Every backend change goes to staging first, passes the staging browser suite, and only then goes to production. Steps 1 and 2 are read-only. Steps 3 and 4 change staging and need Adam's authorization each time.

1. Confirm the target. `supabase projects list` must show the staging ref under the staging name. Stop if a command, link, or environment variable points at the production ref.
2. Find what is pending. Run `npm run backend:status -- --project-ref brifeyypjauyusmkptkx --check` from a checkout of the commit you mean to ship. It compares staging with the committed tree and exits 1 when a migration is not applied. Where that script is not available, run its two read-only queries directly:

```bash
supabase migration list --project-ref brifeyypjauyusmkptkx
supabase functions list --project-ref brifeyypjauyusmkptkx
```

A migration with a local version and no remote version is pending. A function whose folder changed (`git log -- supabase/functions/<name> supabase/functions/_shared`) after its remote `updated_at` is stale. Save both lists as the rollback baseline.
3. Apply migrations. Run `supabase db push --project-ref brifeyypjauyusmkptkx --dry-run` and check that it lists exactly the pending files from step 2. Then run it without `--dry-run`. Never use `--include-all` to paper over divergent history.
4. Deploy functions, one at a time, only the ones step 2 marked stale: `supabase functions deploy <name> --project-ref brifeyypjauyusmkptkx --use-api`. [`supabase/config.toml`](../supabase/config.toml) supplies each function's JWT setting. Never use `--prune`.
5. Verify. Rerun step 2; it must show nothing pending. Then start the staging browser suite: dispatch `Protected staging E2E` from `main` with `gh workflow run staging-e2e.yml --ref main`, or, when Actions cannot run, run it locally with the protected values as described in [testing.md](testing.md#protected-staging). For a manual check in the browser, an admin can switch the deployed frontend to staging from Settings and sign in there with a staging demo code. Google and email sign-in on staging work only if the staging Auth redirect URLs include the frontend origin.

Production then follows [Supabase changes](#supabase-changes) with the same list of migrations and functions.

## Switching the frontend to staging

[`src/lib/supabase/env.ts`](../src/lib/supabase/env.ts) reads the `debate-supabase-env` localStorage key once at startup. The value `staging` selects staging, and anything else selects production. Each project keeps its own auth session under `debate-auth-<env>`.

Admins see a **Developer** card in Settings that signs out, sets the key, and reloads. The app's persisted stores are shared by both projects, so the switch also clears this project's stored session, its Google token, and all user-specific local data, including saved results and form drafts. It waits at most 4 seconds for the sign-out request, so an unresponsive project cannot block it. Other open tabs clear their own session storage, including their Google token, and reload as soon as the key changes, so no tab keeps talking to the old project while sharing stores with the new one. The strip publishes its height as `--staging-banner-height`, and the landing navigation and app header sit below it. While staging is active, every page shows a staging strip with **Switch to Production**, and the Settings card is shown to everyone. A new staging session starts signed out, and an admin on production may not be an admin on staging, so the way back cannot depend on that role. The switch is a convenience, not an access control. Staging RLS and its separate user accounts are the boundary.

The switch only works while the `connect-src` directive in [`vercel.json`](../vercel.json) allows the staging project origin. Today the `*.supabase.co` wildcard covers it. If you narrow that wildcard, list both project origins explicitly.

## Backend drift check

`npm run backend:status` shows what the current commit has that a Supabase project lacks. It inspects the project the Supabase CLI resolves, which is `SUPABASE_PROJECT_ID` when that is set and the linked project otherwise; `npm run backend:status -- --project-ref <ref>` inspects another one. It runs `supabase migration list` and `supabase functions list` and changes no schema, data, functions, or secrets. To connect to the database, the CLI creates its usual short-lived login role.
Expand Down
59 changes: 59 additions & 0 deletions e2e/staging-switch.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { expect, test } from '@playwright/test';

// The strip is the way back from staging, so it must stay usable on every
// route, including the landing page with its fixed navigation bar.
for (const path of ['/', '/login']) {
const landing = path === '/';
test(`the staging strip returns to production from ${path}`, async ({ page }) => {
// Never reach a real Supabase project from this test.
await page.route('https://*.supabase.co/**', (route) => route.abort());
await page.addInitScript(() => {
if (!sessionStorage.getItem('staging-switch-test-started')) {
sessionStorage.setItem('staging-switch-test-started', '1');
localStorage.setItem('debate-supabase-env', 'staging');
}
});
await page.goto(path);
// The landing navigation is fixed and slides in from above. Click only once
// it has arrived, so the test sees what a person sees.
if (landing) {
const landingNav = page.locator('nav.fixed');
await landingNav.waitFor();
await expect.poll(() => landingNav.evaluate((nav) => getComputedStyle(nav).transform)).toMatch(/^(none|matrix\(1, 0, 0, 1, 0, 0\))$/);
}

const strip = page.getByRole('status').filter({ hasText: 'Staging backend' });
await expect(strip).toBeVisible();
if (landing) {
// The fixed navigation must sit below the strip, not underneath it.
const stripBox = await strip.boundingBox();
const navBox = await page.locator('nav.fixed').boundingBox();
expect(navBox!.y).toBeGreaterThanOrEqual(stripBox!.y + stripBox!.height - 1);
}
await strip.getByRole('button', { name: 'Switch to Production' }).click({ timeout: 5_000 });

await expect(strip).toHaveCount(0);
expect(await page.evaluate(() => localStorage.getItem('debate-supabase-env'))).toBeNull();
});
}

test('switching in one tab reloads the other tabs onto the same project', async ({ context }) => {
await context.route('https://*.supabase.co/**', (route) => route.abort());
await context.addInitScript(() => {
if (!sessionStorage.getItem('staging-switch-test-started')) {
sessionStorage.setItem('staging-switch-test-started', '1');
localStorage.setItem('debate-supabase-env', 'staging');
}
});
const switching = await context.newPage();
const other = await context.newPage();
await switching.goto('/login');
await other.goto('/login');
const stripIn = (page: typeof other) => page.getByRole('status').filter({ hasText: 'Staging backend' });
await expect(stripIn(other)).toBeVisible();

await stripIn(switching).getByRole('button', { name: 'Switch to Production' }).click();

await expect(stripIn(switching)).toHaveCount(0);
await expect(stripIn(other)).toHaveCount(0);
});
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { ThemeProvider } from "@/components/ThemeProvider";
import { useSessionStorageSync } from "@/hooks/use-session-storage-sync";
import { useOnboardingStore } from "@/hooks/use-onboarding";
import { OfflineIndicator } from "@/components/OfflineIndicator";
import { StagingBanner } from "@/components/StagingBanner";
import { captureException, captureProductEvent, toTelemetrySafeError } from "@/lib/observability";
import { routeLoaders } from "@/lib/route-modules";
import { RouteSkeleton, type RouteSkeletonVariant } from "@/components/loading/RouteSkeleton";
Expand Down Expand Up @@ -278,6 +279,7 @@ const App = () => (
<TooltipProvider delayDuration={200}>
<Toaster />
<OfflineIndicator />
<StagingBanner />
<BrowserRouter useTransitions={false}>
<LocalizedRouteErrorBoundary>
<AppRoutes />
Expand Down
54 changes: 54 additions & 0 deletions src/components/StagingBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FlaskConical } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useAuth } from '@/lib/supabase/AuthProvider';
import { getActiveEnv } from '@/lib/supabase/env';
import { reloadPage, switchEnv, useReloadOnEnvChange } from '@/lib/supabase/switch-env';

const BANNER_HEIGHT = '2.25rem'; // h-9

/**
* Shown on every route while staging is active. A fresh staging session starts
* signed out and Settings needs sign-in, so this is the way back that always works.
* Mounted at the app root in every environment, it also keeps other tabs in step.
*/
export function StagingBanner({ reload = reloadPage }: { reload?: () => void }) {
const { t } = useTranslation();
const { leaveEnvironment, discardTabState } = useAuth();
const [switching, setSwitching] = useState(false);
useReloadOnEnvChange(discardTabState, reload);
const staging = getActiveEnv() === 'staging';
// Fixed and sticky headers offset themselves by this height so the strip
// never covers them and they never cover it.
useEffect(() => {
if (!staging) return;
document.documentElement.style.setProperty('--staging-banner-height', BANNER_HEIGHT);
return () => {
document.documentElement.style.removeProperty('--staging-banner-height');
};
}, [staging]);
if (!staging) return null;

return (
<div
role="status"
className="sticky top-0 z-[60] flex h-9 items-center justify-center gap-2 border-b border-amber-500/60 bg-amber-100 px-3 text-sm font-medium text-amber-950 dark:bg-amber-950 dark:text-amber-100"
>
<FlaskConical className="h-4 w-4" aria-hidden="true" />
{t('settings.stagingBanner', 'Staging backend')}
<Button
size="sm"
variant="ghost"
className="h-7 px-3 underline-offset-2 hover:underline"
disabled={switching}
onClick={() => {
setSwitching(true);
void switchEnv('production', leaveEnvironment, reload);
}}
>
{t('settings.switchToProduction')}
</Button>
</div>
);
}
63 changes: 63 additions & 0 deletions src/components/features/settings/EnvironmentSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Server } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useAuth } from '@/lib/supabase/AuthProvider';
import { getActiveEnv } from '@/lib/supabase/env';
import { reloadPage, switchEnv, type RemoteEnv } from '@/lib/supabase/switch-env';

type SwitchProps = { reload?: () => void };

/** Admin control in Settings. The caller decides who sees it. */
export function EnvironmentSwitcher({ reload = reloadPage }: SwitchProps) {
const { t } = useTranslation();
const { leaveEnvironment } = useAuth();
const [confirmOpen, setConfirmOpen] = useState(false);
const [switching, setSwitching] = useState(false);
const active = getActiveEnv();
if (active === 'local') return null;

const target: RemoteEnv = active === 'staging' ? 'production' : 'staging';
const label = target === 'staging' ? t('settings.switchToStaging') : t('settings.switchToProduction');

return (
<Card className="mt-8 mb-8">
<CardHeader>
<div className="flex items-center gap-2">
<Server className="h-5 w-5 text-muted-foreground" />
<CardTitle>{t('settings.developer')}</CardTitle>
<Badge variant={active === 'staging' ? 'destructive' : 'secondary'}>{active}</Badge>
</div>
<CardDescription>{t('settings.developerDesc')}</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" onClick={() => setConfirmOpen(true)}>{label}</Button>
</CardContent>
<Dialog open={confirmOpen} onOpenChange={(open) => { if (!switching) setConfirmOpen(open); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('settings.envSwitchTitle')}</DialogTitle>
<DialogDescription>{t('settings.envSwitchDesc', { target })}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" disabled={switching} onClick={() => setConfirmOpen(false)}>
{t('common.cancel')}
</Button>
<Button
disabled={switching}
onClick={() => {
setSwitching(true);
void switchEnv(target, leaveEnvironment, reload);
}}
>
{t('settings.envSwitchConfirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}
2 changes: 1 addition & 1 deletion src/components/landing/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const Navbar = () => {
initial={{ y: -100 }}
animate={{ y: 0 }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="fixed left-0 right-0 top-0 z-50 nav-blur"
className="fixed left-0 right-0 top-[var(--staging-banner-height,0px)] z-50 nav-blur"
aria-label={t('nav.mainNavigation')}
>
<div className="section-container">
Expand Down
2 changes: 1 addition & 1 deletion src/components/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ export function AppLayout() {
return (
<div className="native-safe-screen flex flex-col bg-background">
{/* Header */}
<header className="native-safe-top sticky top-0 z-50 border-b border-border/60 bg-card/70 backdrop-blur-xl shadow-[0_1px_3px_hsl(var(--foreground)/0.04)]">
<header className="native-safe-top sticky top-[var(--staging-banner-height,0px)] z-50 border-b border-border/60 bg-card/70 backdrop-blur-xl shadow-[0_1px_3px_hsl(var(--foreground)/0.04)]">
<div className="container flex h-16 items-center justify-between px-4 max-[359px]:px-2">
<Link to="/app" {...homePrefetch} onClick={() => setMobileMenuOpen(false)} className="flex items-center gap-3 shrink-0">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 overflow-hidden shrink-0">
Expand Down
Loading
Loading