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
10 changes: 10 additions & 0 deletions app/actions/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,16 @@ export async function togglePageSelection(platform: string, pageId: string, sele
*/
export async function getWorkspaceSettings(workspaceId: string,
): Promise<WorkspaceSettings | null> {
// Every export in a 'use server' file is a POST-reachable endpoint, so the
// caller-supplied workspaceId has to be checked here rather than trusted.
// RLS on workspace_settings already limits the read, but this keeps the
// action safe if the reader is ever switched to the admin client.
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error("Unauthorized")

await requireWorkspacePermission(supabase, user.id, workspaceId, "workspace:read")

const settings = await getWorkspaceSettingsWithSecrets(workspaceId)
return settings ? sanitizeWorkspaceSettingsForClient(settings) : null
}
Expand Down
3 changes: 2 additions & 1 deletion app/api/cron/scheduler/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import { createAdminClient } from '@/utils/supabase/admin'
import { timingSafeStringEqual } from '@/lib/developer-api/key-format'

export const runtime = 'nodejs'
export const maxDuration = 60
Expand All @@ -10,7 +11,7 @@ function isAuthorizedCronRequest(request: NextRequest) {
const cronSecret = process.env.CRON_SECRET

if (cronSecret) {
return request.headers.get('authorization') === `Bearer ${cronSecret}`
return timingSafeStringEqual(request.headers.get('authorization') || '', `Bearer ${cronSecret}`)
}

// Production must never run scheduler ticks without a configured shared secret.
Expand Down
49 changes: 48 additions & 1 deletion app/api/developer/oauth/authorize/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { NextRequest, NextResponse } from "next/server"
import { createDeveloperOAuthCode, getDeveloperOAuthScope, normalizeDeveloperOAuthResource } from "@/lib/developer-api/oauth"
import { getDeveloperApiKeyPepper } from "@/lib/developer-api/key-format"
import {
getDeveloperOAuthClient,
isAcceptableRedirectUri,
isRegisteredRedirectUri,
} from "@/lib/developer-api/oauth-clients"

export const runtime = "nodejs"

Expand Down Expand Up @@ -44,7 +49,41 @@ function validateAuthorizeParams(searchParams: URLSearchParams) {
if (searchParams.get("response_type") !== "code") return "response_type must be code"
if (searchParams.get("code_challenge_method") !== "S256") return "code_challenge_method must be S256"
const redirectUri = searchParams.get("redirect_uri") || ""
if (!redirectUri.startsWith("https://")) return "redirect_uri must be HTTPS"
if (!isAcceptableRedirectUri(redirectUri)) return "redirect_uri must be HTTPS and carry no fragment"
return null
}

/**
* Binds redirect_uri to the client that registered it.
*
* Without this the authorization code — which encrypts the operator's raw
* Developer API key — could be delivered to any HTTPS host an attacker chose,
* while the consent page rendered on the genuine SwiftFlow origin. PKCE does
* not help there: it binds the code to whoever made the request, which in that
* attack is the attacker. Exact matching against the registered set is the
* control that closes it.
*
* Failures render an error page and never redirect, so an unregistered URI
* cannot be used to bounce the user somewhere.
*/
async function resolveAuthorizeClient(params: URLSearchParams): Promise<string | null> {
const clientId = params.get("client_id") || ""
const redirectUri = params.get("redirect_uri") || ""

let client
try {
client = await getDeveloperOAuthClient(clientId)
} catch (error) {
console.error("[oauth/authorize] Client lookup failed:", error)
return "Could not verify the connector registration. Try again."
}

if (!client) {
return "Unknown client_id. Register the connector before authorizing."
}
if (!isRegisteredRedirectUri(client, redirectUri)) {
return "redirect_uri does not match a registered redirect URI for this client."
}
return null
}

Expand All @@ -60,6 +99,9 @@ export async function GET(request: NextRequest) {
const error = validateAuthorizeParams(request.nextUrl.searchParams)
if (error) return errorPage(error)

const clientError = await resolveAuthorizeClient(request.nextUrl.searchParams)
if (clientError) return errorPage(clientError)

const hiddenFields = Array.from(request.nextUrl.searchParams.entries())
.map(([key, value]) => `<input type="hidden" name="${escapeHtml(key)}" value="${escapeHtml(value)}">`)
.join("\n")
Expand Down Expand Up @@ -98,6 +140,11 @@ export async function POST(request: NextRequest) {
const error = validateAuthorizeParams(params)
if (error) return errorPage(error)

// Re-checked on POST as well: the GET check guards the page render, but the
// form fields are attacker-controllable on the way back in.
const clientError = await resolveAuthorizeClient(params)
if (clientError) return errorPage(clientError)

const apiKey = form.get("api_key")
if (typeof apiKey !== "string" || !apiKey.startsWith("sf_live_")) {
return errorPage("Enter a valid SwiftFlow Developer API key")
Expand Down
39 changes: 35 additions & 4 deletions app/api/developer/oauth/register/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto"
import { NextRequest, NextResponse } from "next/server"
import { getDeveloperOAuthScope } from "@/lib/developer-api/oauth"
import { isAcceptableRedirectUri, registerDeveloperOAuthClient } from "@/lib/developer-api/oauth-clients"

export const runtime = "nodejs"

Expand All @@ -23,12 +24,42 @@ async function readRegistrationMetadata(request: NextRequest): Promise<Registrat

export async function POST(request: NextRequest) {
const metadata = await readRegistrationMetadata(request)

// redirect_uris becomes the exact-match allowlist enforced at /authorize, so
// a registration without at least one usable HTTPS URI cannot be honoured.
// Previously this endpoint persisted nothing and /authorize accepted any
// https URI, which is what made the connector phishable.
const redirectUris = Array.isArray(metadata.redirect_uris)
? metadata.redirect_uris.filter(isAcceptableRedirectUri)
: []

if (redirectUris.length === 0) {
return NextResponse.json({
error: "invalid_redirect_uri",
error_description: "At least one HTTPS redirect_uri without a fragment is required",
}, { status: 400 })
}

const clientId = `swiftflow-mcp-${randomUUID()}`
const clientName = metadata.client_name || "SwiftFlow MCP Connector"
const scope = metadata.scope || getDeveloperOAuthScope()

try {
await registerDeveloperOAuthClient({ clientId, clientName, redirectUris, scope })
} catch (error) {
console.error("[oauth/register] Failed to persist client:", error)
return NextResponse.json({
error: "server_error",
error_description: "Could not complete client registration",
}, { status: 500 })
}

return NextResponse.json({
client_id: `swiftflow-mcp-${randomUUID()}`,
client_id: clientId,
client_id_issued_at: Math.floor(Date.now() / 1000),
client_name: metadata.client_name || "SwiftFlow MCP Connector",
redirect_uris: Array.isArray(metadata.redirect_uris) ? metadata.redirect_uris : [],
scope: metadata.scope || getDeveloperOAuthScope(),
client_name: clientName,
redirect_uris: redirectUris,
scope,
token_endpoint_auth_method: metadata.token_endpoint_auth_method === "none" ? "none" : "none",
response_types: ["code"],
grant_types: Array.isArray(metadata.grant_types) && metadata.grant_types.includes("refresh_token")
Expand Down
22 changes: 22 additions & 0 deletions app/api/developer/oauth/token/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
verifyPkceChallenge,
} from "@/lib/developer-api/oauth"
import { getDeveloperApiKeyPepper } from "@/lib/developer-api/key-format"
import { claimDeveloperOAuthCode } from "@/lib/developer-api/oauth-clients"

export const runtime = "nodejs"

Expand Down Expand Up @@ -157,6 +158,27 @@ export async function POST(request: NextRequest) {
return tokenError("invalid_grant", "PKCE verification failed")
}

// Codes are single use. This runs only after every other check passes, so a
// failed exchange does not burn an otherwise valid code. Codes minted before
// the jti field existed carry none and skip the check; they expire within the
// 10 minute code TTL.
if (payload.jti) {
let claimed: boolean
try {
claimed = await claimDeveloperOAuthCode({
jti: payload.jti,
clientId: payload.clientId,
expiresAt: new Date(payload.exp * 1000),
})
} catch (error) {
console.error("[oauth/token] Failed to record code redemption:", error)
return tokenError("server_error", "Could not complete the token exchange", 500)
}
if (!claimed) {
return tokenError("invalid_grant", "Authorization code has already been redeemed")
}
}

return tokenResponse({
apiKey: payload.apiKey,
clientId: payload.clientId,
Expand Down
5 changes: 4 additions & 1 deletion app/api/webhooks/instagram/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { buildInstagramMessagingAutomationEvents } from '@/lib/webhooks/instagra
import { createSupabaseWebhookInboxStore } from '@/lib/webhooks/supabase-inbox-store';
import { readRawBodyWithLimit, RequestBodyTooLargeError } from '@/lib/security/phase1-validation';
import { requireSupabaseServiceRoleKey } from '@/lib/supabase/service-key';
import { timingSafeStringEqual } from '@/lib/developer-api/key-format';

// Meta webhook payloads are small (batched entries stay well under this cap).
const MAX_WEBHOOK_BODY_BYTES = 1024 * 1024;
Expand Down Expand Up @@ -106,7 +107,9 @@ export async function GET(request: NextRequest) {

console.log('[WEBHOOK] Verification request:', { mode, hasToken: !!token, hasChallenge: !!challenge });

if (mode === 'subscribe' && token === process.env.META_WEBHOOK_VERIFY_TOKEN) {
const verifyToken = process.env.META_WEBHOOK_VERIFY_TOKEN || '';

if (mode === 'subscribe' && verifyToken && timingSafeStringEqual(token || '', verifyToken)) {
console.log('[WEBHOOK] Verification successful');
// Must return the challenge as plain text, not JSON
return new NextResponse(challenge, { status: 200 });
Expand Down
115 changes: 115 additions & 0 deletions docs/operations/database-size-reclaim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# Reclaiming database size

One-time runbook for the Supabase warning:

> You have projects that are exceeding 0.5 GB of database size.

## Diagnosis (2026-09-12)

Total database: **492 MB**. Almost none of it was application data.

| Schema | Size | Share | What it is |
|---|---:|---:|---|
| `net` | 272 MB | 55% | pg_net HTTP response log |
| `cron` | 202 MB | 41% | pg_cron job run history |
| `pg_catalog` | 18 MB | 4% | system catalogs |
| **`public`** | **15 MB** | **3%** | **all SwiftFlow application data** |

Both large tables are byproducts of `scheduler-tick-cron`, which fires every
minute and therefore writes ~1,440 rows/day to each.

They are two different problems:

- **`cron.job_run_details` — 289,479 real rows** spanning 2026-02-23 to now.
pg_cron has no built-in retention, so this is genuine unbounded accumulation.
- **`net._http_response` — only 360 live rows** covering the last ~6 hours
(pg_net expires its own rows), but **271 MB of heap with 0 dead tuples**.
That is bloat: space freed by past deletes that was never returned to the OS.
It will not keep growing much, but it will never shrink on its own either.

### What this is NOT

The `retention-cleanup` edge function and `workspace_retention_policies` target
`oauth_page_sessions`, `workspace_invites`, `webhook_events` and similar
**public-schema** tables. Those total ~15 MB. Enabling retention cleanup is
worth doing on its own merits, but it would reclaim about 3% here and would not
resolve the warning.

## Prevention (already in the repo)

`supabase/migrations/20260912170000_prune_cron_run_history.sql` schedules
`purge-cron-run-history`, which trims `cron.job_run_details` to 7 days nightly
at 03:17 UTC. Apply it with `npx supabase db push --linked`.

That stops future growth. It does not reclaim what is already on disk, because
`DELETE` marks space reusable rather than returning it — and `VACUUM FULL` is
not available here: both tables are owned by `supabase_admin`, not `postgres`.

## One-time reclaim

`postgres` holds TRUNCATE on both tables, and TRUNCATE returns the space
immediately without needing table ownership. Run in the Supabase dashboard SQL
editor.

### 1. pg_net response log — reclaims ~271 MB

```sql
truncate net._http_response;
```

Safe because: the table is `UNLOGGED`, pg_net already expires rows on a ~6 hour
TTL, and nothing in this codebase reads it. The `scheduler-tick-cron` command
calls `net.http_post(...)` without reading the response back, so discarding
responses cannot affect scheduling. Worst case is losing the response record of
a request in flight at that instant, which nothing consumes.

### 2. pg_cron run history — reclaims ~195 MB

Simplest, and what is recommended:

```sql
truncate cron.job_run_details;
```

Safe because: pg_cron never reads this table to decide anything — it is a log.
No application code queries it. Scheduling, job definitions and the
`scheduler-tick` heartbeat are stored in `cron.job`, which this does not touch.

If you would rather keep recent history, this variant preserves the last two
days. It is slightly more involved and briefly races the every-minute tick
(harmless — at worst one run's row is re-inserted or missed):

```sql
create temp table cron_history_keep as
select * from cron.job_run_details
where end_time > now() - interval '2 days';

truncate cron.job_run_details;

insert into cron.job_run_details select * from cron_history_keep;
drop table cron_history_keep;
```

### 3. Verify

```sql
select pg_size_pretty(pg_database_size(current_database())) as total_db_size;

select n.nspname as schema, pg_size_pretty(sum(pg_total_relation_size(c.oid))) as size
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where c.relkind in ('r','m','i')
group by n.nspname
order by sum(pg_total_relation_size(c.oid)) desc
limit 6;
```

Expect the total to drop from ~492 MB to well under 50 MB.

## If it grows back

The nightly purge bounds `cron.job_run_details`. If `net._http_response`
bloats again over months, re-run the truncate in step 1 — it is safe to repeat.

The root driver is the every-minute tick. Reducing that frequency would cut
both logs proportionally, but it directly increases automation latency for
delay-node resumes, so it is not recommended as a size fix.
Loading
Loading