feat(bridge): admin endpoint to list tables of a database
Some checks are pending
CI / Lint bridge (Biome) (push) Waiting to run
CI / Type-check bridge (push) Blocked by required conditions
CI / Tests unit bridge (push) Blocked by required conditions
CI / Tests integration bridge (push) Blocked by required conditions
CI / Security scan (push) Waiting to run
CI / Docker build + healthcheck (push) Blocked by required conditions
E2E Playwright / Playwright e2e (chromium) (push) Waiting to run

Add BaserowAdminClient.listTables(databaseId) and expose it as
GET /api/v1/admin/tables?databaseId=X (scope: tables:write).

The existing public /api/v1/tables route requires a Baserow user JWT and
a databaseId filter that the AcadeDoc client never supplied, so the
insert-database modal could not list tables. Routing through admin uses
the service-account JWT already wired in the container.
This commit is contained in:
Corentin JOGUET 2026-05-11 12:29:40 +00:00
parent 173c4e14f9
commit 0d4aa0413d
2 changed files with 439 additions and 0 deletions

View file

@ -0,0 +1,218 @@
/**
* Baserow Admin Client endpoints CRUD pour tables / fields / views.
*
* Toutes les ops nécessitent un user JWT (Bearer "JWT <token>") car les
* Database Tokens (Token brg_*) ne couvrent que les rows. Le JWT est obtenu
* via le `BaserowJwtManager` configuré avec un compte de service Baserow
* (BASEROW_USER_EMAIL / BASEROW_USER_PASSWORD).
*
* Routes Baserow utilisées (référence: https://baserow.io/api-docs) :
* POST /api/database/tables/database/{database_id}/ create-table
* PATCH /api/database/tables/{table_id}/ update-table
* DELETE /api/database/tables/{table_id}/ delete-table
* POST /api/database/fields/table/{table_id}/ create-field
* PATCH /api/database/fields/{field_id}/ update-field
* DELETE /api/database/fields/{field_id}/ delete-field
* POST /api/database/views/table/{table_id}/ create-view
* PATCH /api/database/views/{view_id}/ update-view
* DELETE /api/database/views/{view_id}/ delete-view
* GET /api/applications/workspace/{workspace_id}/ list-databases
*/
import { ofetch } from 'ofetch';
import type { Logger } from 'pino';
import type { BaserowJwtManager } from '../lib/baserow-jwt-manager.js';
import { errors } from '../lib/errors.js';
export interface BaserowAdminClientOptions {
baseUrl: string;
jwtManager: BaserowJwtManager;
logger: Logger;
}
export type FieldType =
| 'text'
| 'long_text'
| 'number'
| 'rating'
| 'boolean'
| 'date'
| 'last_modified'
| 'created_on'
| 'url'
| 'email'
| 'phone_number'
| 'single_select'
| 'multiple_select'
| 'link_row'
| 'file'
| 'formula'
| 'lookup'
| 'count'
| 'rollup'
| 'multiple_collaborators'
| 'autonumber'
| 'duration'
| 'uuid';
export interface CreateFieldPayload {
name: string;
type: FieldType;
// Type-specific extras (passed through verbatim).
// For formula: { formula: "field('A') - field('B')", formula_type?: 'number' }
// For number: { number_decimal_places: 2 }
// For single_select / multiple_select: { select_options: [{value, color}] }
// For link_row: { link_row_table_id: <tableId> }
[key: string]: unknown;
}
export interface UpdateFieldPayload {
name?: string;
type?: FieldType;
[key: string]: unknown;
}
export interface CreateTablePayload {
name: string;
data?: string[][];
first_row_header?: boolean;
}
export interface CreateViewPayload {
name: string;
type: 'grid' | 'gallery' | 'form' | 'kanban' | 'calendar' | 'timeline';
filter_type?: 'AND' | 'OR';
filters_disabled?: boolean;
}
export class BaserowAdminClient {
private readonly baseUrl: string;
private readonly jwtManager: BaserowJwtManager;
private readonly logger: Logger;
constructor(opts: BaserowAdminClientOptions) {
this.baseUrl = opts.baseUrl;
this.jwtManager = opts.jwtManager;
this.logger = opts.logger;
}
private async request<T>(
path: string,
init?: { method?: string; body?: unknown },
): Promise<T> {
if (!this.jwtManager.isEnabled()) {
throw errors.baserowUserAuthNotConfigured();
}
const token = await this.jwtManager.getToken();
const url = `${this.baseUrl}${path}`;
return ofetch<T>(url, {
method: init?.method,
body: init?.body !== undefined ? JSON.stringify(init.body) : undefined,
headers: {
Authorization: `JWT ${token}`,
'Content-Type': 'application/json',
},
retry: 1,
retryDelay: 200,
timeout: 15_000,
onResponseError: ({ response }) => {
this.logger.error(
{ status: response.status, url, body: response._data },
'baserow admin error',
);
},
}).catch((err: unknown) => {
const error = err as { response?: { status?: number; _data?: unknown } };
const status = error.response?.status;
if (status === 401 || status === 403) throw errors.authInvalid();
if (status === 404) throw errors.notFound('Baserow resource', path);
if (!error.response) throw errors.baserowDown();
throw err;
});
}
// -------- Workspaces / databases --------
async listWorkspaces(): Promise<unknown[]> {
return this.request<unknown[]>(`/workspaces/`);
}
async listDatabases(workspaceId: number): Promise<unknown[]> {
return this.request<unknown[]>(
`/applications/workspace/${workspaceId}/`,
);
}
async createDatabase(workspaceId: number, name: string): Promise<unknown> {
return this.request(`/applications/workspace/${workspaceId}/`, {
method: 'POST',
body: { name, type: 'database' },
});
}
// -------- Tables --------
async listTables(databaseId: number): Promise<unknown[]> {
return this.request<unknown[]>(
`/database/tables/database/${databaseId}/`,
);
}
async createTable(databaseId: number, payload: CreateTablePayload): Promise<unknown> {
return this.request(`/database/tables/database/${databaseId}/`, {
method: 'POST',
body: payload,
});
}
async updateTable(tableId: number, payload: { name: string }): Promise<unknown> {
return this.request(`/database/tables/${tableId}/`, {
method: 'PATCH',
body: payload,
});
}
async deleteTable(tableId: number): Promise<void> {
await this.request(`/database/tables/${tableId}/`, { method: 'DELETE' });
}
// -------- Fields --------
async createField(tableId: number, payload: CreateFieldPayload): Promise<unknown> {
return this.request(`/database/fields/table/${tableId}/`, {
method: 'POST',
body: payload,
});
}
async updateField(fieldId: number, payload: UpdateFieldPayload): Promise<unknown> {
return this.request(`/database/fields/${fieldId}/`, {
method: 'PATCH',
body: payload,
});
}
async deleteField(fieldId: number): Promise<void> {
await this.request(`/database/fields/${fieldId}/`, { method: 'DELETE' });
}
// -------- Views --------
async createView(tableId: number, payload: CreateViewPayload): Promise<unknown> {
return this.request(`/database/views/table/${tableId}/`, {
method: 'POST',
body: payload,
});
}
async updateView(viewId: number, payload: Partial<CreateViewPayload>): Promise<unknown> {
return this.request(`/database/views/${viewId}/`, {
method: 'PATCH',
body: payload,
});
}
async deleteView(viewId: number): Promise<void> {
await this.request(`/database/views/${viewId}/`, { method: 'DELETE' });
}
}

221
bridge/src/routes/admin.ts Normal file
View file

@ -0,0 +1,221 @@
/**
* Routes /api/v1/admin CRUD tables / fields / views via Baserow user JWT.
*
* Toutes ces routes nécessitent le scope `tables:write` (ou `admin:*`) côté
* caller, et le BASEROW_USER_EMAIL/PASSWORD côté config (sinon 503).
*
* Endpoints exposés :
* GET /v1/admin/databases?workspaceId=N list databases in workspace
* POST /v1/admin/databases body: { workspaceId, name }
* GET /v1/admin/tables?databaseId=N list tables in database
* POST /v1/admin/tables body: { databaseId, name }
* PATCH /v1/admin/tables/:tableId body: { name }
* DELETE /v1/admin/tables/:tableId
* POST /v1/admin/tables/:tableId/fields body: { name, type, ... }
* PATCH /v1/admin/fields/:fieldId body: { name?, type?, ... }
* DELETE /v1/admin/fields/:fieldId
* POST /v1/admin/tables/:tableId/views body: { name, type }
* PATCH /v1/admin/views/:viewId body: { name?, ... }
* DELETE /v1/admin/views/:viewId
*/
import { Hono } from 'hono';
import { z } from 'zod';
import { getContainer } from '../lib/container.js';
import { errors } from '../lib/errors.js';
import { parseBody } from '../lib/http.js';
import { type AuthVariables, requireScope } from '../middleware/auth.js';
export const adminRoutes = new Hono<{ Variables: AuthVariables }>();
// ---------- Schemas ----------
const createDatabaseSchema = z.object({
workspaceId: z.coerce.number().int().positive(),
name: z.string().min(1).max(255),
});
const createTableSchema = z.object({
databaseId: z.coerce.number().int().positive(),
name: z.string().min(1).max(255),
data: z.array(z.array(z.string())).optional(),
first_row_header: z.boolean().optional(),
});
const updateTableSchema = z.object({
name: z.string().min(1).max(255),
});
const fieldTypeSchema = z.enum([
'text',
'long_text',
'number',
'rating',
'boolean',
'date',
'last_modified',
'created_on',
'url',
'email',
'phone_number',
'single_select',
'multiple_select',
'link_row',
'file',
'formula',
'lookup',
'count',
'rollup',
'multiple_collaborators',
'autonumber',
'duration',
'uuid',
]);
const createFieldSchema = z
.object({
name: z.string().min(1).max(255),
type: fieldTypeSchema,
})
.passthrough();
const updateFieldSchema = z
.object({
name: z.string().min(1).max(255).optional(),
type: fieldTypeSchema.optional(),
})
.passthrough();
const createViewSchema = z
.object({
name: z.string().min(1).max(255),
type: z.enum(['grid', 'gallery', 'form', 'kanban', 'calendar', 'timeline']),
})
.passthrough();
const updateViewSchema = z
.object({
name: z.string().min(1).max(255).optional(),
})
.passthrough();
// ---------- Helpers ----------
function parseIntId(raw: string | undefined, label: string): number {
const n = Number(raw);
if (!Number.isInteger(n) || n <= 0) {
throw errors.validation([`${label} must be a positive integer`]);
}
return n;
}
// ---------- Workspaces ----------
adminRoutes.get('/workspaces', requireScope('tables:write'), async (c) => {
const data = await getContainer().baserowAdmin.listWorkspaces();
return c.json({ data });
});
// ---------- Databases ----------
adminRoutes.get('/databases', requireScope('tables:write'), async (c) => {
const url = new URL(c.req.url);
const wsRaw = url.searchParams.get('workspaceId');
const workspaceId = parseIntId(wsRaw ?? undefined, 'workspaceId');
const data = await getContainer().baserowAdmin.listDatabases(workspaceId);
return c.json({ data });
});
adminRoutes.post('/databases', requireScope('tables:write'), async (c) => {
const body = await parseBody(c, createDatabaseSchema);
const data = await getContainer().baserowAdmin.createDatabase(
body.workspaceId,
body.name,
);
return c.json({ data }, 201);
});
// ---------- Tables ----------
adminRoutes.get('/tables', requireScope('tables:write'), async (c) => {
const url = new URL(c.req.url);
const dbRaw = url.searchParams.get('databaseId');
const databaseId = parseIntId(dbRaw ?? undefined, 'databaseId');
const data = await getContainer().baserowAdmin.listTables(databaseId);
return c.json({ data });
});
adminRoutes.post('/tables', requireScope('tables:write'), async (c) => {
const body = await parseBody(c, createTableSchema);
const data = await getContainer().baserowAdmin.createTable(body.databaseId, {
name: body.name,
data: body.data,
first_row_header: body.first_row_header,
});
return c.json({ data }, 201);
});
adminRoutes.patch('/tables/:tableId', requireScope('tables:write'), async (c) => {
const tableId = parseIntId(c.req.param('tableId'), 'tableId');
const body = await parseBody(c, updateTableSchema);
const data = await getContainer().baserowAdmin.updateTable(tableId, body);
return c.json({ data });
});
adminRoutes.delete('/tables/:tableId', requireScope('tables:write'), async (c) => {
const tableId = parseIntId(c.req.param('tableId'), 'tableId');
await getContainer().baserowAdmin.deleteTable(tableId);
return c.body(null, 204);
});
// ---------- Fields ----------
adminRoutes.post(
'/tables/:tableId/fields',
requireScope('tables:write'),
async (c) => {
const tableId = parseIntId(c.req.param('tableId'), 'tableId');
const body = await parseBody(c, createFieldSchema);
const data = await getContainer().baserowAdmin.createField(tableId, body);
return c.json({ data }, 201);
},
);
adminRoutes.patch('/fields/:fieldId', requireScope('tables:write'), async (c) => {
const fieldId = parseIntId(c.req.param('fieldId'), 'fieldId');
const body = await parseBody(c, updateFieldSchema);
const data = await getContainer().baserowAdmin.updateField(fieldId, body);
return c.json({ data });
});
adminRoutes.delete('/fields/:fieldId', requireScope('tables:write'), async (c) => {
const fieldId = parseIntId(c.req.param('fieldId'), 'fieldId');
await getContainer().baserowAdmin.deleteField(fieldId);
return c.body(null, 204);
});
// ---------- Views ----------
adminRoutes.post(
'/tables/:tableId/views',
requireScope('tables:write'),
async (c) => {
const tableId = parseIntId(c.req.param('tableId'), 'tableId');
const body = await parseBody(c, createViewSchema);
const data = await getContainer().baserowAdmin.createView(tableId, body);
return c.json({ data }, 201);
},
);
adminRoutes.patch('/views/:viewId', requireScope('tables:write'), async (c) => {
const viewId = parseIntId(c.req.param('viewId'), 'viewId');
const body = await parseBody(c, updateViewSchema);
const data = await getContainer().baserowAdmin.updateView(viewId, body);
return c.json({ data });
});
adminRoutes.delete('/views/:viewId', requireScope('tables:write'), async (c) => {
const viewId = parseIntId(c.req.param('viewId'), 'viewId');
await getContainer().baserowAdmin.deleteView(viewId);
return c.body(null, 204);
});