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