diff --git a/src/databases/officer_db/src/schema/dashboards.ts b/src/databases/officer_db/src/schema/dashboards.ts index 3f61fb0c..214b874a 100644 --- a/src/databases/officer_db/src/schema/dashboards.ts +++ b/src/databases/officer_db/src/schema/dashboards.ts @@ -1,6 +1,20 @@ -import { pgTable, serial, text, integer, timestamp, jsonb, uniqueIndex } from 'drizzle-orm/pg-core'; +import type { AnyPgColumn } from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; +import { pgTable, serial, text, integer, timestamp, jsonb, uniqueIndex, check } from 'drizzle-orm/pg-core'; import { users } from './auth'; +/** + * A `LayoutNode` is an object, and the whole write path to these columns is `unknown` — the PATCH body is + * `Record`, the query layer casts `as never` to satisfy drizzle, and jsonb accepts + * anything. So the only place the rule can actually be enforced is here. + * + * `'[]'` reached both columns as a *default* and turned `normalizeLayout` into a white screen; the client + * now refuses to hand over a non-object and the read omits one, but neither of those stops a bad value + * being stored — they stop it being served. This does. NULL stays legal: it means "none stored", which is + * the honest state of a dashboard created without a layout. + */ +const layoutIsObject = (column: AnyPgColumn) => sql`${column} IS NULL OR jsonb_typeof(${column}) = 'object'`; + export const dashboards = pgTable( 'dashboards', { @@ -29,7 +43,10 @@ export const dashboards = pgTable( createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, - (table) => [uniqueIndex('uq_dashboards_user_id').on(table.userId, table.id)], + (table) => [ + uniqueIndex('uq_dashboards_user_id').on(table.userId, table.id), + check('ck_dashboards_layout_object', layoutIsObject(table.layout)), + ], ); export const screens = pgTable( @@ -46,7 +63,10 @@ export const screens = pgTable( hostTerminals: jsonb('host_terminals').notNull().default({}), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, - (table) => [uniqueIndex('uq_screens_user_name').on(table.userId, table.name)], + (table) => [ + uniqueIndex('uq_screens_user_name').on(table.userId, table.name), + check('ck_screens_layout_object', layoutIsObject(table.layout)), + ], ); export const dashboardDefaults = pgTable('dashboard_defaults', { diff --git a/src/servers/api/dashboards/dashboards.ts b/src/servers/api/dashboards/dashboards.ts index b0ec103b..f9c1c7ad 100644 --- a/src/servers/api/dashboards/dashboards.ts +++ b/src/servers/api/dashboards/dashboards.ts @@ -19,6 +19,12 @@ export const dashboardsRouter = createRouter(); // `apps/Terminal/index.tsx`; the 400 at the bottom of the PATCH loop is what tells you when it is not. const PANEL_STATE_PREFIXES = ['tmux', 'nvim', 'claude-code']; +// A `LayoutNode` is an object. Everything from the PATCH body down to jsonb is `unknown`, so without this +// the only thing standing between a wrong-shaped body and the layout columns is the CHECK constraint — +// which does hold the line, but as a 500 that says nothing the caller can act on. Say what was wrong +// instead. Null is handled separately by both layout branches: there it means "delete", not "store". +const isLayout = (value: unknown): boolean => typeof value === 'object' && value !== null && !Array.isArray(value); + // GET /dashboards dashboardsRouter.get('/', async (ctx) => { const userId = ctx.get('user').id; @@ -51,6 +57,7 @@ dashboardsRouter.patch('/', async (ctx) => { if (value === null) { await deleteDashboard(userId, id); } else { + if (!isLayout(value)) return ctx.json({ error: `"${key}" must be a layout object` }, 400); await upsertDashboard(userId, id, { layout: value }); } continue; @@ -110,6 +117,7 @@ dashboardsRouter.patch('/', async (ctx) => { if (value === null) { await deleteScreen(userId, name); } else { + if (!isLayout(value)) return ctx.json({ error: `"${key}" must be a layout object` }, 400); await upsertScreen(userId, name, { layout: value }); } continue;