Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
62899a8
Add a per-agent, Docker-free local Supabase stack helper
Programcode Oct 16, 2026
77ffc84
Plan the 25-item pre-ship security pass
Programcode Oct 16, 2026
ce8883b
Document the per-agent stack and run the full database suite on it
Programcode Oct 16, 2026
786017b
Harden promo redemption, client grants and account deletion
Programcode Oct 16, 2026
4b566b7
Serve Edge Functions on the per-agent stack
Programcode Oct 16, 2026
7b8e7d7
Render Markdown images from model output as links
Programcode Oct 16, 2026
f406e95
Clear tool drafts on sign-out and user switch
Programcode Oct 16, 2026
3e17be4
Tighten the CSP to the hosts the app uses
Programcode Oct 16, 2026
fea03d1
Guard the public build and trim client telemetry
Programcode Oct 16, 2026
c7d59d1
fix(edge): claim Stripe events before applying current subscription s…
Programcode Oct 16, 2026
f5097e9
fix(edge): authenticate and allowlist AI features before processing i…
Programcode Oct 16, 2026
71b7a85
fix(edge): conceal internal and provider errors from clients
Programcode Oct 16, 2026
46e8622
fix(edge): validate promo inputs and omit attempted codes from logs
Programcode Oct 16, 2026
63590ba
fix(edge): rate limit unknown demo callers without extending refusals
Programcode Oct 16, 2026
d375141
fix(edge): validate import bodies and public hosts before reserving q…
Programcode Oct 16, 2026
9dfab93
fix(edge): restrict export origins and vary CORS responses by Origin
Programcode Oct 16, 2026
2af63d8
fix(edge): keep prompt and research text out of diagnostic logs
Programcode Oct 16, 2026
9bd27e4
Recover abandoned webhook claims and take payment status from Stripe
Programcode Oct 16, 2026
9671fc0
Pin supabase-js in Edge Functions to the client's version
Programcode Oct 16, 2026
7b234b8
Record the pre-ship security findings and new database rules
Programcode Oct 16, 2026
66ca201
Describe the explicit Supabase hosts in the CSP
Programcode Oct 16, 2026
660d00f
Give the webhook a fake Stripe in the full-stack gate
Programcode Oct 16, 2026
d5a2a8e
Find Library source fields by placeholder in full-stack journeys
Programcode Oct 16, 2026
af01c87
Claim webhook events on the database clock and retry in-flight ones
Programcode Oct 16, 2026
4323a68
Report a webhook duplicate only for a completed claim
Programcode Oct 16, 2026
0dae75e
Update the security report for the review fixes
Programcode Oct 16, 2026
633317c
Merge remote-tracking branch 'origin/main' into security/preship-pass
Programcode Oct 17, 2026
ebb3f40
Drop hosted rate_limits and global_prompts policies by catalog
Programcode Oct 17, 2026
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
16 changes: 16 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,22 @@ supabase stop --no-backup

The default local stack begins at port `54321` and uses shared container names, so it is not parallel-safe across worktrees. The wrapper holds `~/.superset/debatelab-db.lock` through database tests and the real-Auth collaboration browser journey, and stops the stack and owned processes on exit. Outside Superset, coordinate ownership before starting it. The agent that starts the stack stops it before finishing, including after failures when safe.

### A private database per agent

When several agents need a database at once, each can run its own Docker-free project with [`scripts/agent-stack.sh`](../scripts/agent-stack.sh). It uses the Supabase CLI's experimental stack engine (CLI 2.121 or newer) on the native runtime: no lock, no shared ports, and a cold start of about 20 seconds.

```bash
scripts/agent-stack.sh --name my-task start # applies every migration; prints API_URL and DB_URL
scripts/agent-stack.sh --name my-task test # what npm run test:db runs: pgTAP and the concurrency scripts
scripts/agent-stack.sh --name my-task psql -c 'select 1'
scripts/agent-stack.sh --name my-task advisors # Supabase security advisors
scripts/agent-stack.sh --name my-task reset # re-apply migrations after editing them
scripts/agent-stack.sh --name my-task serve fn.env # Edge Functions with fn.env's variables (foreground)
scripts/agent-stack.sh --name my-task destroy # always, including after failures
```

Ports are assigned by the CLI; read them from the env file `start` prints. The stack cannot serve the magic-link email template, so the script runs the project from an overlay directory under `~/.cache/debatelab-stacks/` that drops that block and symlinks everything else. Use it for migrations, pgTAP, the concurrency scripts and RLS queries. The full-stack browser journeys still need `./.superset/db-gate.sh`, which is also what CI runs.

Never point local tests at staging or production. Never apply a migration to an unverified project.

## Browser workflow
Expand Down
2 changes: 1 addition & 1 deletion docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ Admins see a **Developer** card in Settings that signs out, sets the key, and re

A build can pin one project with the `VITE_SUPABASE_ENV` build variable (`production` or `staging`; any other non-empty value fails the build). A pinned build ignores the localStorage key, hides the Settings card and the strip's **Switch to Production** button, and does not reload when another tab switches. The strip still shows on a pinned staging build. The staging Vercel project uses this so each PR gets a staging preview; see [Per-PR previews](#per-pr-previews).

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.
The switch only works while the `connect-src` directive in [`vercel.json`](../vercel.json) allows the staging project origin. It lists exactly the two project origins from [`src/lib/supabase/projects.ts`](../src/lib/supabase/projects.ts), and `src/test/vercel-deployment-config.test.ts` fails if they drift apart.

## Per-PR previews

Expand Down
37 changes: 23 additions & 14 deletions e2e/full-stack/billing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,25 +38,27 @@ function sendWebhook(event: object, signature = true) {
});
}

function subscriptionEvent(type: string, subscriptionId: string, status: string) {
function subscriptionObject(subscriptionId: string, status: string) {
const now = Math.floor(Date.now() / 1000);
return {
id: subscriptionId,
object: "subscription",
status,
cancel_at_period_end: false,
current_period_start: now,
current_period_end: now + 30 * 86_400,
cancel_at: null,
canceled_at: null,
ended_at: null,
};
}

function subscriptionEvent(type: string, subscriptionId: string, status: string) {
return {
id: `evt_${randomUUID()}`,
object: "event",
type,
data: {
object: {
id: subscriptionId,
object: "subscription",
status,
cancel_at_period_end: false,
current_period_start: now,
current_period_end: now + 30 * 86_400,
cancel_at: null,
canceled_at: null,
ended_at: null,
},
},
data: { object: subscriptionObject(subscriptionId, status) },
};
}

Expand Down Expand Up @@ -114,6 +116,8 @@ test("signed Stripe webhooks grant and revoke Pro, and forged ones change nothin
expect(forged.status).toBe(400);
expect(await tier(userId)).toBe("expired");

// The webhook applies Stripe's current state, which the fake Stripe holds.
await fake.stripeSubscription(subscriptionObject(subscriptionId, "active"));
const accepted = await sendWebhook(activated);
expect(accepted.status).toBe(200);
expect(await tier(userId)).toBe("pro");
Expand All @@ -124,9 +128,14 @@ test("signed Stripe webhooks grant and revoke Pro, and forged ones change nothin
await expect(page.getByText("Pro", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Manage Subscription" })).toBeVisible();

await fake.stripeSubscription(subscriptionObject(subscriptionId, "canceled"));
const canceled = await sendWebhook(subscriptionEvent("customer.subscription.deleted", subscriptionId, "canceled"));
expect(canceled.status).toBe(200);
expect(await tier(userId)).toBe("expired");
// An older "active" update that Stripe delivers late must not revive the plan.
const stale = await sendWebhook(subscriptionEvent("customer.subscription.updated", subscriptionId, "active"));
expect(stale.status).toBe(200);
expect(await tier(userId)).toBe("expired");
const { data: row } = await admin.from("subscriptions").select("status").eq("stripe_subscription_id", subscriptionId).single();
expect(row?.status).toBe("canceled");

Expand Down
15 changes: 15 additions & 0 deletions e2e/full-stack/fake-openrouter.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const chatModel = (requested) => (liveModel === "as-requested" ? requested : liv
const maxUpstreamCalls = Number(process.env.MAX_UPSTREAM_CALLS ?? 200);
if (upstream && !liveModel) throw new Error("LIVE_MODEL is required with UPSTREAM");
const requests = [];
// Subscription state the fake Stripe returns, set by tests through /__subscription.
const subscriptions = new Map();
let failures = [];
let upstreamCalls = 0;
// Delay between streamed chunks; a test can slow it to keep a draft on screen.
Expand Down Expand Up @@ -317,6 +319,11 @@ createServer(async (req, res) => {
streamDelayMs = typeof ms === "number" && ms >= 0 ? ms : undefined;
return json(res, 200, { ok: true });
}
if (url.pathname === "/__subscription" && req.method === "POST") {
const subscription = JSON.parse(await readBody(req) || "{}");
subscriptions.set(subscription.id, subscription);
return json(res, 200, { ok: true });
}
if (url.pathname === "/__fail" && req.method === "POST") {
const { feature, count = 1, status = 500 } = JSON.parse(await readBody(req) || "{}");
failures.push({ feature, count, status });
Expand All @@ -332,6 +339,14 @@ createServer(async (req, res) => {
requests.push({ feature: "jev", model: body.model, status: 200 });
return json(res, 200, { model: body.model, answers: jevAnswers(body.questions ?? {}), usage: { input_tokens: 40, output_tokens: 8, cost: 0.00001 } });
}
const subscriptionPath = url.pathname.match(/^\/v1\/subscriptions\/([^/]+)$/);
if (subscriptionPath && req.method === "GET") {
// The webhook applies Stripe's current subscription state, not the event's.
const subscription = subscriptions.get(decodeURIComponent(subscriptionPath[1]));
if (!subscription) return json(res, 404, { error: { type: "invalid_request_error", message: "No such subscription" } });
requests.push({ feature: "stripe-subscription", user: subscription.id });
return json(res, 200, subscription);
}
if (url.pathname === "/v1/customers/search" && req.method === "GET") {
// Account deletion searches Stripe (through STRIPE_API_URL) for the user's
// customers. Test accounts have none.
Expand Down
4 changes: 4 additions & 0 deletions e2e/full-stack/stack-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export const fake = {
async fail(feature: string, status = 500, count = 1) {
await fetch(`${fakeUrl}/__fail`, { method: "POST", body: JSON.stringify({ feature, status, count }) });
},
/** Sets what the fake Stripe returns for GET /v1/subscriptions/:id. */
async stripeSubscription(subscription: { id: string; status: string }) {
await fetch(`${fakeUrl}/__subscription`, { method: "POST", body: JSON.stringify(subscription) });
},
/** Milliseconds between streamed chunks (fake mode only) until the next reset. */
async streamDelay(ms: number) {
await fetch(`${fakeUrl}/__stream-delay`, { method: "POST", body: JSON.stringify({ ms }) });
Expand Down
Loading
Loading