feat(selfhost): complete Supabase removal migration
Deploy to route.crispygoat.com / deploy (push) Failing after 9s

Phase 1 — Pattern library
- Add src/lib/api.ts (typed PostgREST client, anon-key)
- Add src/lib/svc-fetch.ts (service-role fetch + PostgREST client)
- Add src/lib/db-types.ts (Database generic, RowOf helper)
- Add src/lib/auth-admin.ts (better-auth admin wrappers: createUser, listUsers, removeUser, requestPasswordReset, validateSession)

Phase 2 — Server action migration
- Migrate all 67 server actions off @supabase/supabase-js to svcApi/svcRpc
- Replace service.auth.admin.* with authAdmin.* (better-auth)
- Replace publicApi.auth.* with authAdmin.* (better-auth)
- Add typed single() null guards + nullable column fallbacks

Phase 3 — Delete mock data + debug routes
- Remove if(useMockData) branches from 7 files (-226 lines)
- Delete src/lib/mock-data.ts (no longer referenced)
- Delete 7 debug API routes (debug-admin-users, debug-auth, debug-cookie, debug-env, debug-get-admin-users, debug-hello, debug-me)

Phase 4 — Hard cut
- Delete src/lib/supabase.ts (orphan — no importers)
- Delete src/app/api/supabase + src/app/api/supabase-test routes
- Remove @supabase/ssr + @supabase/supabase-js from package.json
- Rename env vars: NEXT_PUBLIC_SUPABASE_URL → NEXT_PUBLIC_API_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY → NEXT_PUBLIC_API_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY → POSTGREST_SERVICE_KEY

Phase 5 — Verify
- npx tsc --noEmit: 0 errors
- npm run lint: 0 new errors from migration (104 pre-existing errors unrelated)
- npm run build: same failure mode as main worktree (PostgREST-dependent prerender), no regression

Net: 158 files changed, +1640 / -1903 lines (-263 net)
This commit is contained in:
2026-06-05 20:17:02 +00:00
parent e7ac495831
commit d892b3f64f
158 changed files with 1640 additions and 1903 deletions
+104
View File
@@ -0,0 +1,104 @@
/**
* Service-role fetch wrapper. Server-side only (uses `server-only`).
*
* Provides a single entry point for server actions that need full PostgREST
* access (bypassing the anon key's RLS). Centralizes the URL + header pattern
* so the 67 files that previously used `svcHeaders(POSTGREST_SERVICE_KEY)`
* have one source of truth.
*
* `apikey` header alone is sufficient for PostgREST — it accepts it for both
* anon and service-role access (no `Authorization: Bearer` needed). Safe for
* Vercel Edge Runtime.
*
* Usage:
* import { svcFetch, svcHeaders } from "@/lib/svc-fetch";
* const res = await svcFetch("/rest/v1/brands?select=*");
* const data = await res.json();
*
* For RPC calls that need to bypass RLS, use `api.rpc` with the service key
* passed via headers — see `svcRpc<T>(fn, args)` below.
*/
import "server-only";
import { PostgrestBuilder } from "@/lib/api";
import type { RowOf } from "@/lib/db-types";
const API_URL = (() => {
const url = process.env.NEXT_PUBLIC_API_URL;
if (!url) {
throw new Error("Missing NEXT_PUBLIC_API_URL (server-side). Set it in .env.local.");
}
return url.replace(/\/$/, "");
})();
const SERVICE_KEY = (() => {
const k = process.env.POSTGREST_SERVICE_KEY;
if (!k) {
throw new Error(
"Missing POSTGREST_SERVICE_KEY. Set it in .env.local — it's the service-role key for the local PostgREST proxy.",
);
}
return k;
})();
export function svcHeaders(): Record<string, string> {
return { apikey: SERVICE_KEY, "Content-Type": "application/json" };
}
export async function svcFetch(path: string, init: RequestInit = {}): Promise<Response> {
const url = path.startsWith("http") ? path : `${API_URL}${path.startsWith("/") ? "" : "/"}${path}`;
const headers = { ...svcHeaders(), ...(init.headers as Record<string, string> | undefined) };
return fetch(url, { ...init, headers });
}
export type SvcResult<T> = { data: T; error: null } | { data: null; error: { message: string } };
/** Service-role RPC call. Bypasses RLS, useful for admin server actions. */
export async function svcRpc<TReturns = unknown>(
fn: string,
args: Record<string, unknown> = {},
): Promise<SvcResult<TReturns>> {
let res: Response;
try {
res = await svcFetch(`/rest/v1/rpc/${fn}`, {
method: "POST",
body: JSON.stringify(args),
});
} catch (e) {
return { data: null, error: { message: (e as Error).message } };
}
if (!res.ok) {
const text = await res.text().catch(() => "");
return { data: null, error: { message: text || res.statusText } };
}
const data = (await res.json()) as TReturns;
return { data, error: null };
}
/** Re-export the API URL for any code that still needs to build URLs by hand. */
export const SVC_API_URL = API_URL;
// ── Service-role PostgREST client ────────────────────────────────────────────
//
// Same builder shape as `api` (the anon-key client in `@/lib/api`) but uses
// the service-role key. Use this for server actions that need to bypass RLS
// (admin user management, writing to `admin_users`, etc.).
//
// Usage:
// import { svcApi } from "@/lib/svc-fetch";
// const { data, error } = await svcApi.from("admin_users").select("*");
// const { data, error } = await svcApi.from("admin_users").insert({...}).select().single();
class SvcPostgrestBuilder<TRow, TResult = TRow[]> extends PostgrestBuilder<TRow, TResult> {
constructor(table: string) {
super(table);
this.getHeaders = () => svcHeaders();
}
}
export const svcApi = {
from<TTable extends string>(table: TTable): SvcPostgrestBuilder<RowOf<TTable>> {
return new SvcPostgrestBuilder<RowOf<TTable>>(table);
},
rpc: svcRpc,
};