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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ The frontend is hosted on Vercel at `https://debate.pi-stack.org`. Merging to `m

- Vite development server: `8080` by default outside Superset; Superset allocates `8080-8179` per worktree.
- Automated browser server: isolated `127.0.0.1:18080`, never an existing dev server.
- Tailnet previews for Adam: `npm run dev:preview` listens locally on `9200-9299` and is published on tailnet HTTPS `9100-9149`; it pins the app to staging. See [docs/development.md](docs/development.md#preview-on-the-tailnet).
- Local Supabase: CLI defaults beginning at `54321`. The stack is not parallel-safe across worktrees.

## Focused verification
Expand Down
12 changes: 12 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ npm run dev -- --host 127.0.0.1 --port 8081

Automated browser tests own `127.0.0.1:18080` by default and always start a fresh server.

## Preview on the tailnet

A plain `npm run dev` talks to the production Supabase project unless the browser toggle says staging. For anything Adam opens from another device, run the staging preview instead:

```bash
npm run dev:preview -- --port 9201 --strictPort
```

`dev:preview` binds to `127.0.0.1` and sets `VITE_STAGING_PREVIEW=true`. In that mode Vite also accepts `*.ts.net` hostnames, so `tailscale serve` can forward to it however it was started. The app is then pinned to staging and calls Supabase at `/__staging-supabase` on its own origin, which Vite proxies to the staging project (see `stagingPreviewProxy` in [`vite.config.ts`](../vite.config.ts)). Requests stay same-origin, so Edge Function CORS lists need no preview entries. The staging strip shows, without the switch to production.

Two ports are involved. The dev server listens locally on a port in `9200-9299`. The `tailnet-preview` skill publishes it on a tailnet HTTPS port in `9100-9149`, and that HTTPS origin is what the browser sees. Staging Auth allows redirects to `https://codex-remote.tailab0ab3.ts.net:91*/**`, so Google sign-in returns to the preview only when the published port starts with `91`. If you run `tailscale serve` by hand, publish on `9100-9199`. Demo codes and email codes must be staging ones: staging has its own users.

## Environment behavior

The checked-in [`.env.example`](../.env.example) contains optional client observability variables:
Expand Down
2 changes: 1 addition & 1 deletion docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ Staging is project `debate-trainer-staging` (`brifeyypjauyusmkptkx`). Production
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.
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. Since 2026-10-02 they include tailnet previews (`https://codex-remote.tailab0ab3.ts.net:91*/**`); see [development.md](development.md#preview-on-the-tailnet).

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

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
},
"scripts": {
"dev": "vite",
"dev:preview": "VITE_STAGING_PREVIEW=true vite --host 127.0.0.1",
"docs:check": "node scripts/check-docs.mjs",
"backend:status": "node scripts/backend-status.mjs",
"build": "vite build && node scripts/check-build-boundaries.mjs",
Expand Down
30 changes: 17 additions & 13 deletions src/components/StagingBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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 { getActiveEnv, isEnvPinned } from '@/lib/supabase/env';
import { reloadPage, switchEnv, useReloadOnEnvChange } from '@/lib/supabase/switch-env';

const BANNER_HEIGHT = '2.25rem'; // h-9
Expand All @@ -12,6 +12,8 @@ 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.
* A staging preview build is pinned to staging, so it shows the strip without
* the switch.
*/
export function StagingBanner({ reload = reloadPage }: { reload?: () => void }) {
const { t } = useTranslation();
Expand All @@ -37,18 +39,20 @@ export function StagingBanner({ reload = reloadPage }: { reload?: () => void })
>
<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>
{!isEnvPinned() && (
<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>
);
}
4 changes: 2 additions & 2 deletions src/components/features/settings/EnvironmentSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ 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 { getActiveEnv, isEnvPinned } from '@/lib/supabase/env';
import { reloadPage, switchEnv, type RemoteEnv } from '@/lib/supabase/switch-env';

type SwitchProps = { reload?: () => void };
Expand All @@ -18,7 +18,7 @@ export function EnvironmentSwitcher({ reload = reloadPage }: SwitchProps) {
const [confirmOpen, setConfirmOpen] = useState(false);
const [switching, setSwitching] = useState(false);
const active = getActiveEnv();
if (active === 'local') return null;
if (isEnvPinned()) return null;

const target: RemoteEnv = active === 'staging' ? 'production' : 'staging';
const label = target === 'staging' ? t('settings.switchToStaging') : t('settings.switchToProduction');
Expand Down
56 changes: 41 additions & 15 deletions src/lib/supabase/env.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,36 @@
/** Runtime environment resolver for Supabase. */

import { STAGING_PREVIEW_PROXY_PATH, SUPABASE_PROJECTS } from "./projects";

export type SupabaseEnv = "local" | "production" | "staging";

type EnvironmentInput = {
VITE_LOCAL_PREVIEW?: string;
VITE_LOCAL_SUPABASE_ANON_KEY?: string;
VITE_LOCAL_SUPABASE_URL?: string;
VITE_STAGING_PREVIEW?: string;
};

type StorageReader = { getItem(key: string): string | null };
type SupabaseConfig = { anonKey: string; env: SupabaseEnv; url: string };
/** `pinned` means the environment was fixed at build time and the toggle can't change it. */
type SupabaseConfig = { anonKey: string; env: SupabaseEnv; pinned?: true; url: string };

export const ENV_STORAGE_KEY = "debate-supabase-env";

const ENVS: Record<Exclude<SupabaseEnv, "local">, { anonKey: string; url: string }> = {
production: {
url: "https://uziwdtkzofptihahfooo.supabase.co",
anonKey: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InV6aXdkdGt6b2ZwdGloYWhmb29vIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njk3MDM0MzAsImV4cCI6MjA4NTI3OTQzMH0.caJvRcwlc5w_xGO_HpYxDobmibLbboa_kB2M1IjO3jw",
},
staging: {
url: "https://brifeyypjauyusmkptkx.supabase.co",
anonKey: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJyaWZleXlwamF1eXVzbWtwdGt4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzkyNTI5NjEsImV4cCI6MjA5NDgyODk2MX0.xKYcvponjw5b_ouPhJDnSBBB1CUSw1Jpz35zGiMmH6A",
},
};
const ENVS = SUPABASE_PROJECTS;

export function resolveSupabaseConfig(
environment: EnvironmentInput,
storage: StorageReader,
currentOrigin?: string,
): SupabaseConfig {
const localPreview = environment.VITE_LOCAL_PREVIEW === "true";
const stagingPreview = environment.VITE_STAGING_PREVIEW === "true";
if (localPreview && stagingPreview) {
throw new Error("Local preview and staging preview cannot both be enabled");
}

export function resolveSupabaseConfig(environment: EnvironmentInput, storage: StorageReader): SupabaseConfig {
if (environment.VITE_LOCAL_PREVIEW === "true") {
if (localPreview) {
const url = environment.VITE_LOCAL_SUPABASE_URL?.trim();
const anonKey = environment.VITE_LOCAL_SUPABASE_ANON_KEY?.trim();
if (!url || !anonKey) throw new Error("Local preview configuration is incomplete");
Expand All @@ -37,6 +42,18 @@ export function resolveSupabaseConfig(environment: EnvironmentInput, storage: St
return { anonKey, env: "local", url: parsedUrl.href.replace(/\/$/, "") };
}

// Tailnet previews: always staging, reached through the dev server's proxy on
// this origin. Never production, whatever the toggle says.
if (stagingPreview) {
if (!currentOrigin) throw new Error("Staging preview needs the page origin");
return {
anonKey: ENVS.staging.anonKey,
env: "staging",
pinned: true,
url: `${new URL(currentOrigin).origin}${STAGING_PREVIEW_PROXY_PATH}`,
};
}

return storage.getItem(ENV_STORAGE_KEY) === "staging"
? { ...ENVS.staging, env: "staging" }
: { ...ENVS.production, env: "production" };
Expand All @@ -51,14 +68,23 @@ const storageReader: StorageReader = {
}
},
};
const active = resolveSupabaseConfig(import.meta.env, storageReader);
const active = resolveSupabaseConfig(
import.meta.env,
storageReader,
typeof window === "undefined" ? undefined : window.location.origin,
);

export function getActiveEnv(): SupabaseEnv {
return active.env;
}

/** True when the build fixed the environment (local or staging preview). */
export function isEnvPinned(): boolean {
return active.env === "local" || active.pinned === true;
}

export function setActiveEnv(env: Exclude<SupabaseEnv, "local">): void {
if (active.env === "local") return;
if (isEnvPinned()) return;
if (env === "production") localStorage.removeItem(ENV_STORAGE_KEY);
else localStorage.setItem(ENV_STORAGE_KEY, env);
}
Expand Down
23 changes: 23 additions & 0 deletions src/lib/supabase/projects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Public connection details for the hosted Supabase projects. Side-effect free
* so vite.config.ts can import it as well as the app.
*/
export const SUPABASE_PROJECTS = {
production: {
url: "https://uziwdtkzofptihahfooo.supabase.co",
anonKey: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InV6aXdkdGt6b2ZwdGloYWhmb29vIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njk3MDM0MzAsImV4cCI6MjA4NTI3OTQzMH0.caJvRcwlc5w_xGO_HpYxDobmibLbboa_kB2M1IjO3jw",
},
staging: {
url: "https://brifeyypjauyusmkptkx.supabase.co",
anonKey: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJyaWZleXlwamF1eXVzbWtwdGt4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzkyNTI5NjEsImV4cCI6MjA5NDgyODk2MX0.xKYcvponjw5b_ouPhJDnSBBB1CUSw1Jpz35zGiMmH6A",
},
} as const;

/**
* In staging preview mode the app calls Supabase through this path on its own
* origin, and the dev server proxies it to the staging project. Requests stay
* same-origin, so Edge Function CORS lists need no preview entries. It must not
* collide with a repo folder: Vite serves `supabase/functions/_shared` at
* `/supabase/...` in dev, and the frontend imports from there.
*/
export const STAGING_PREVIEW_PROXY_PATH = "/__staging-supabase";
5 changes: 3 additions & 2 deletions src/lib/supabase/switch-env.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { ENV_STORAGE_KEY, getActiveEnv, setActiveEnv } from '@/lib/supabase/env';
import { ENV_STORAGE_KEY, getActiveEnv, isEnvPinned, setActiveEnv } from '@/lib/supabase/env';

export type RemoteEnv = 'production' | 'staging';

Expand All @@ -25,7 +25,8 @@ export async function switchEnv(target: RemoteEnv, leaveEnvironment: () => Promi
export function useReloadOnEnvChange(discardTabState: () => void, reload: () => void = reloadPage) {
useEffect(() => {
const active = getActiveEnv();
if (active === 'local') return;
// A pinned build ignores the toggle, so other tabs can't move it.
if (isEnvPinned()) return;
const onStorage = (event: StorageEvent) => {
if (event.key !== null && event.key !== ENV_STORAGE_KEY) return;
const next: RemoteEnv = localStorage.getItem(ENV_STORAGE_KEY) === 'staging' ? 'staging' : 'production';
Expand Down
23 changes: 23 additions & 0 deletions src/test/components/EnvironmentSwitcher.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,22 @@ import { StagingBanner } from '@/components/StagingBanner';

const mocks = vi.hoisted(() => ({
env: 'production' as 'local' | 'production' | 'staging',
pinned: false,
setActiveEnv: vi.fn(),
signOut: vi.fn(),
discard: vi.fn(),
}));
vi.mock('@/lib/supabase/env', () => ({
ENV_STORAGE_KEY: 'debate-supabase-env',
getActiveEnv: () => mocks.env,
isEnvPinned: () => mocks.env === 'local' || mocks.pinned,
setActiveEnv: mocks.setActiveEnv,
}));
vi.mock('@/lib/supabase/AuthProvider', () => ({ useAuth: () => ({ leaveEnvironment: mocks.signOut, discardTabState: mocks.discard }) }));

beforeEach(() => {
mocks.env = 'production';
mocks.pinned = false;
mocks.setActiveEnv.mockReset();
mocks.signOut.mockReset().mockResolvedValue(undefined);
mocks.discard.mockReset();
Expand Down Expand Up @@ -75,6 +78,13 @@ describe('EnvironmentSwitcher', () => {
const { container } = render(<EnvironmentSwitcher reload={vi.fn()} />);
expect(container).toBeEmptyDOMElement();
});

it('renders nothing in a staging preview build, which cannot switch', () => {
mocks.env = 'staging';
mocks.pinned = true;
const { container } = render(<EnvironmentSwitcher reload={vi.fn()} />);
expect(container).toBeEmptyDOMElement();
});
});

describe('StagingBanner', () => {
Expand Down Expand Up @@ -117,6 +127,19 @@ describe('StagingBanner', () => {
localStorage.removeItem('debate-supabase-env');
});

it('marks a staging preview build without offering a switch it cannot make', () => {
mocks.env = 'staging';
mocks.pinned = true;
const reload = vi.fn();
render(<StagingBanner reload={reload} />);
expect(screen.getByRole('status')).toHaveTextContent('Staging backend');
expect(screen.queryByRole('button')).not.toBeInTheDocument();
localStorage.setItem('debate-supabase-env', 'production');
window.dispatchEvent(new StorageEvent('storage', { key: 'debate-supabase-env', newValue: 'production' }));
expect(reload).not.toHaveBeenCalled();
localStorage.removeItem('debate-supabase-env');
});

it('ignores other tabs in local preview', () => {
mocks.env = 'local';
const reload = vi.fn();
Expand Down
2 changes: 1 addition & 1 deletion src/test/pages/SettingsAdminNavigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const mocks = vi.hoisted(() => ({
role: vi.fn(),
env: 'production' as 'production' | 'staging',
}));
vi.mock('@/lib/supabase/env', () => ({ getActiveEnv: () => mocks.env, setActiveEnv: vi.fn() }));
vi.mock('@/lib/supabase/env', () => ({ getActiveEnv: () => mocks.env, isEnvPinned: () => false, setActiveEnv: vi.fn() }));
vi.mock('@/lib/supabase/AuthProvider', () => ({ useAuth: () => ({ user: mocks.user, signOut: vi.fn() }) }));
vi.mock('@/lib/supabase/client', () => ({
SUPABASE_URL: 'https://example.invalid',
Expand Down
52 changes: 52 additions & 0 deletions src/test/supabase/local-preview-env.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { resolveSupabaseConfig } from "@/lib/supabase/env";
import { SUPABASE_PROJECTS } from "@/lib/supabase/projects";
import { resolveCollaborationUrl } from "@/lib/workspace/collaboration-config";

describe("local preview Supabase boundary", () => {
Expand Down Expand Up @@ -42,3 +43,54 @@ describe("local preview Supabase boundary", () => {
})).toThrow("Local collaboration URL must use WSS outside loopback");
});
});

describe("staging preview Supabase boundary", () => {
const preview = { VITE_STAGING_PREVIEW: "true" };
const stagingAnonKey = SUPABASE_PROJECTS.staging.anonKey;

it("pins staging behind the same-origin proxy, whatever the toggle says", () => {
for (const stored of [null, "production", "staging"]) {
expect(resolveSupabaseConfig(
preview,
{ getItem: () => stored },
"https://codex-remote.example.ts.net:9201",
)).toEqual({
anonKey: stagingAnonKey,
env: "staging",
pinned: true,
url: "https://codex-remote.example.ts.net:9201/__staging-supabase",
});
}
});

it("never points at the production project", () => {
const config = resolveSupabaseConfig(preview, { getItem: () => null }, "http://127.0.0.1:9201");
expect(config.url).not.toContain(new URL(SUPABASE_PROJECTS.production.url).host);
expect(config.anonKey).not.toBe(SUPABASE_PROJECTS.production.anonKey);
});

it("drops any path from the page origin", () => {
expect(resolveSupabaseConfig(preview, { getItem: () => null }, "https://host.example.ts.net:9201/app/x").url)
.toBe("https://host.example.ts.net:9201/__staging-supabase");
});

it("fails closed without a page origin", () => {
expect(() => resolveSupabaseConfig(preview, { getItem: () => null }))
.toThrow("Staging preview needs the page origin");
});

it("refuses to combine with local preview", () => {
expect(() => resolveSupabaseConfig(
{ ...preview, VITE_LOCAL_PREVIEW: "true" },
{ getItem: () => null },
"http://127.0.0.1:9201",
)).toThrow("Local preview and staging preview cannot both be enabled");
});

it("leaves the normal build on the toggle", () => {
expect(resolveSupabaseConfig({}, { getItem: () => null }, "https://debate.pi-stack.org"))
.toEqual({ ...SUPABASE_PROJECTS.production, env: "production" });
expect(resolveSupabaseConfig({}, { getItem: () => "staging" }, "https://debate.pi-stack.org"))
.toEqual({ ...SUPABASE_PROJECTS.staging, env: "staging" });
});
});
1 change: 1 addition & 0 deletions src/vite-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ interface ImportMetaEnv {
readonly VITE_LOCAL_PREVIEW?: string;
readonly VITE_LOCAL_SUPABASE_ANON_KEY?: string;
readonly VITE_LOCAL_SUPABASE_URL?: string;
readonly VITE_STAGING_PREVIEW?: string;
readonly VITE_COLLABORATION_URL?: string;
readonly VITE_SENTRY_ENVIRONMENT?: string;
readonly VITE_SENTRY_RELEASE?: string;
Expand Down
Loading
Loading