platform.ts233 lines · main
1import { Hono } from "hono";
2
3import { projectRateLimit } from "../middleware/rate-limit.js";
4import { requireProjectAuth, requireProjectRole } from "../middleware/project-auth.js";
5import type { ProjectAppEnv as AppEnv } from "../types/app-env.js";
6import {
7 listProjectTables,
8 getFullSchema,
9 executeQuery,
10 getTableColumns,
11 getTableRows,
12 insertRow,
13 deleteRow,
14 updateCell,
15 listRelationships,
16 createTable,
17 dropTable,
18} from "../services/studio.js";
19import type { StudioColumnSpec } from "../services/studio.js";
20import { audit, hashIp } from "../services/audit.js";
21import { log } from "../lib/logger.js";
22
23/**
24 * /platform/* proxy — Supabase-Studio-compatible API surface.
25 *
26 * Studio's generated API types expect paths like
27 * /platform/pg-meta/{ref}/tables
28 * /platform/auth/{ref}/config
29 * /platform/rest/v1/{ref}/{table}
30 *
31 * These routes map the Supabase-style paths to briven's internal
32 * studio service. Auth is enforced via requireProjectAuth('ref') +
33 * requireProjectRole per route (NOT a wildcard .use) — Hono only resolves
34 * `:ref` on the matched route, so a `/platform/*` middleware never saw the
35 * project id and used to 403 every request.
36 *
37 * Unknown /platform/* paths return 404 with a logged warning so we can add
38 * mappings as needed.
39 */
40
41const platformRouter = new Hono<AppEnv>();
42
43/** Shared gate for every :ref route: resolve project + admin role. */
44const platformRefAuth = [
45 requireProjectAuth("ref"),
46 projectRateLimit("mutate"),
47 requireProjectRole("admin"),
48] as const;
49
50// ── pg-meta (schema introspection) ──────────────────────────────────────
51
52platformRouter.get(
53 "/platform/pg-meta/:ref/tables",
54 ...platformRefAuth,
55 async (c) => {
56 const tables = await listProjectTables(c.req.param("ref"));
57 return c.json(tables);
58 }
59);
60
61platformRouter.get(
62 "/platform/pg-meta/:ref/schemas",
63 ...platformRefAuth,
64 async (c) => {
65 const schema = await getFullSchema(c.req.param("ref"));
66 return c.json(schema);
67 }
68);
69
70platformRouter.get(
71 "/platform/pg-meta/:ref/foreign-tables",
72 ...platformRefAuth,
73 async (c) => {
74 // Return relationships as foreign-table info
75 const rels = await listRelationships(c.req.param("ref"));
76 return c.json(rels);
77 }
78);
79
80platformRouter.post(
81 "/platform/pg-meta/:ref/query",
82 ...platformRefAuth,
83 async (c) => {
84 const projectId = c.req.param("ref");
85 const body = (await c.req.json().catch(() => null)) as { sql?: string } | null;
86 if (!body || typeof body.sql !== "string" || body.sql.trim() === "") {
87 return c.json({ code: "validation_failed", message: "expected { sql: string }" }, 400);
88 }
89 try {
90 const result = await executeQuery(projectId, body.sql);
91 const user = c.get("user");
92 await audit({
93 actorId: user?.id ?? null,
94 projectId,
95 action: "studio.query.run",
96 ipHash: hashIp(c.req.raw.headers.get("cf-connecting-ip") ?? null),
97 userAgent: c.req.header("user-agent") ?? null,
98 metadata: { sqlPreview: body.sql.slice(0, 1024), elapsedMs: result.elapsedMs },
99 });
100 return c.json(result);
101 } catch (err) {
102 const message = err instanceof Error ? err.message : "query failed";
103 return c.json({ code: "query_failed", message }, 400);
104 }
105 }
106);
107
108// ── Table columns ───────────────────────────────────────────────────────
109
110platformRouter.get(
111 "/platform/pg-meta/:ref/columns",
112 ...platformRefAuth,
113 async (c) => {
114 const projectId = c.req.param("ref");
115 const tableName = c.req.query("table");
116 if (!tableName) {
117 return c.json({ code: "validation_failed", message: "?table= required" }, 400);
118 }
119 const columns = await getTableColumns(projectId, tableName);
120 return c.json(columns);
121 }
122);
123
124// ── Row CRUD (mapped from /platform/rest/v1) ────────────────────────────
125
126platformRouter.get(
127 "/platform/rest/v1/:ref/:table",
128 ...platformRefAuth,
129 async (c) => {
130 const projectId = c.req.param("ref");
131 const tableName = c.req.param("table");
132 const limit = Number(c.req.query("limit") || "100");
133 const offset = Number(c.req.query("offset") || "0");
134 const rows = await getTableRows(projectId, tableName, { limit, offset });
135 return c.json(rows);
136 }
137);
138
139platformRouter.post(
140 "/platform/rest/v1/:ref/:table",
141 ...platformRefAuth,
142 async (c) => {
143 const projectId = c.req.param("ref");
144 const tableName = c.req.param("table");
145 const body = await c.req.json<Record<string, unknown>>();
146 const result = await insertRow({ projectId, tableName, values: body });
147 return c.json(result, 201);
148 }
149);
150
151platformRouter.patch(
152 "/platform/rest/v1/:ref/:table",
153 ...platformRefAuth,
154 async (c) => {
155 const projectId = c.req.param("ref");
156 const tableName = c.req.param("table");
157 const body = await c.req.json<{ primaryKey: Array<{ column: string; value: string | number }>; values: Record<string, unknown> }>();
158 if (!body.primaryKey || !body.values) {
159 return c.json({ code: "validation_failed", message: "expected { primaryKey, values }" }, 400);
160 }
161 // Postgres updateCell sets one column at a time; apply each value in the patch.
162 for (const [column, value] of Object.entries(body.values)) {
163 await updateCell({ projectId, tableName, primaryKey: body.primaryKey, column, value });
164 }
165 return c.json({ ok: true });
166 }
167);
168
169platformRouter.delete(
170 "/platform/rest/v1/:ref/:table",
171 ...platformRefAuth,
172 async (c) => {
173 const projectId = c.req.param("ref");
174 const tableName = c.req.param("table");
175 const body = await c.req.json<{ primaryKey: Array<{ column: string; value: string | number }> }>();
176 if (!body.primaryKey) {
177 return c.json({ code: "validation_failed", message: "expected { primaryKey }" }, 400);
178 }
179 await deleteRow({ projectId, tableName, primaryKey: body.primaryKey });
180 return c.json({ ok: true });
181 }
182);
183
184// ── Table create/drop ───────────────────────────────────────────────────
185
186platformRouter.post(
187 "/platform/pg-meta/:ref/tables",
188 ...platformRefAuth,
189 async (c) => {
190 const projectId = c.req.param("ref");
191 const body = await c.req.json<{ name: string; schema?: string; comment?: string; columns?: StudioColumnSpec[] }>();
192 if (!body.name) {
193 return c.json({ code: "validation_failed", message: "expected { name }" }, 400);
194 }
195 // Postgres requires at least one column + a primary key; default to a
196 // uuid `id` PK when the caller didn't specify columns.
197 const columns: StudioColumnSpec[] =
198 body.columns && body.columns.length > 0
199 ? body.columns
200 : [{ name: "id", type: "uuid", primaryKey: true, notNull: true, defaultExpr: "gen_random_uuid()" }];
201 await createTable({ projectId, tableName: body.name, columns });
202 return c.json({ name: body.name }, 201);
203 }
204);
205
206platformRouter.delete(
207 "/platform/pg-meta/:ref/tables/:id",
208 ...platformRefAuth,
209 async (c) => {
210 const projectId = c.req.param("ref");
211 const tableName = c.req.param("id");
212 await dropTable(projectId, tableName);
213 return c.json({ ok: true });
214 }
215);
216
217// ── Catch-all for unmapped /platform/* paths ───────────────────────────
218
219platformRouter.all("/platform/*", async (c) => {
220 log.warn("platform_unmapped", {
221 method: c.req.method,
222 path: c.req.path,
223 });
224 return c.json(
225 {
226 code: "platform_not_implemented",
227 message: `No briven handler for ${c.req.method} ${c.req.path}`,
228 },
229 404
230 );
231});
232
233export { platformRouter };