make a layout column refuse a non-layout
This commit is contained in:
@@ -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';
|
import { users } from './auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `LayoutNode` is an object, and the whole write path to these columns is `unknown` — the PATCH body is
|
||||||
|
* `Record<string, unknown>`, 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(
|
export const dashboards = pgTable(
|
||||||
'dashboards',
|
'dashboards',
|
||||||
{
|
{
|
||||||
@@ -29,7 +43,10 @@ export const dashboards = pgTable(
|
|||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp('updated_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(
|
export const screens = pgTable(
|
||||||
@@ -46,7 +63,10 @@ export const screens = pgTable(
|
|||||||
hostTerminals: jsonb('host_terminals').notNull().default({}),
|
hostTerminals: jsonb('host_terminals').notNull().default({}),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
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', {
|
export const dashboardDefaults = pgTable('dashboard_defaults', {
|
||||||
|
|||||||
@@ -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.
|
// `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'];
|
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
|
// GET /dashboards
|
||||||
dashboardsRouter.get('/', async (ctx) => {
|
dashboardsRouter.get('/', async (ctx) => {
|
||||||
const userId = ctx.get('user').id;
|
const userId = ctx.get('user').id;
|
||||||
@@ -51,6 +57,7 @@ dashboardsRouter.patch('/', async (ctx) => {
|
|||||||
if (value === null) {
|
if (value === null) {
|
||||||
await deleteDashboard(userId, id);
|
await deleteDashboard(userId, id);
|
||||||
} else {
|
} else {
|
||||||
|
if (!isLayout(value)) return ctx.json({ error: `"${key}" must be a layout object` }, 400);
|
||||||
await upsertDashboard(userId, id, { layout: value });
|
await upsertDashboard(userId, id, { layout: value });
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -110,6 +117,7 @@ dashboardsRouter.patch('/', async (ctx) => {
|
|||||||
if (value === null) {
|
if (value === null) {
|
||||||
await deleteScreen(userId, name);
|
await deleteScreen(userId, name);
|
||||||
} else {
|
} else {
|
||||||
|
if (!isLayout(value)) return ctx.json({ error: `"${key}" must be a layout object` }, 400);
|
||||||
await upsertScreen(userId, name, { layout: value });
|
await upsertScreen(userId, name, { layout: value });
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
Reference in New Issue
Block a user