Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
462 lines
16 KiB
JavaScript
462 lines
16 KiB
JavaScript
// columns/columnCrudController.js — List, create, get, update, delete columns
|
|
|
|
import express from 'express';
|
|
import { dbAll, dbGet, dbRun, toBool } from '../../../database/connection.js';
|
|
import { VALID_COLUMN_TYPES } from '../../../services/ColumnService.js';
|
|
import { validateVerificationConfig } from '../../../services/verification/validateConfig.js';
|
|
import { getSystemTableColumns } from '../../../services/SystemTableService.js';
|
|
import { apiLogger } from '../../../utils/logger.js';
|
|
import { success, created, notFound, badRequest, error } from '../../../utils/response.js';
|
|
import { verifyTableAccess } from './helpers.js';
|
|
|
|
// ADR-0016 Phase 2: per-column visibility for file-type columns.
|
|
// Persisted in `table_columns.config` JSONB (no migration). New columns
|
|
// default to 'private'; updates that omit `visibility` keep the existing
|
|
// value (handled inline below).
|
|
const FILE_VISIBILITY_VALUES = ['private', 'internal', 'public'];
|
|
|
|
function isFileColumnType(type) {
|
|
return type === 'file' || type === 'image';
|
|
}
|
|
|
|
const router = express.Router();
|
|
|
|
// ============================================================
|
|
// GET /api/v3/tables/:tableId/columns
|
|
// List all columns for a table
|
|
// ============================================================
|
|
/**
|
|
* @swagger
|
|
* /api/v3/tables/{tableId}/columns:
|
|
* get:
|
|
* summary: List all columns for a table
|
|
* tags: [Columns]
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: tableId
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: List of columns
|
|
*/
|
|
router.get('/tables/:tableId/columns', verifyTableAccess, async (req, res) => {
|
|
try {
|
|
const { tableId } = req.params;
|
|
const { mode } = req.query;
|
|
const table = req.table;
|
|
let columns;
|
|
|
|
if (table.is_system && table.sync_target) {
|
|
const systemColumns = getSystemTableColumns(table.sync_target);
|
|
columns = systemColumns.map((col, idx) => ({
|
|
id: `col-${tableId}-${idx}`,
|
|
table_id: parseInt(tableId),
|
|
name: col.name,
|
|
display_name: col.displayName,
|
|
column_type: col.type,
|
|
config: col.config || {},
|
|
is_required: col.isRequired || false,
|
|
order_index: col.orderIndex,
|
|
is_visible: true,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString()
|
|
}));
|
|
} else {
|
|
columns = await dbAll(`
|
|
SELECT
|
|
id,
|
|
table_id,
|
|
column_name AS name,
|
|
display_name,
|
|
type AS column_type,
|
|
config,
|
|
is_required,
|
|
order_index,
|
|
is_visible,
|
|
width,
|
|
is_readonly,
|
|
is_locked,
|
|
default_value,
|
|
formula,
|
|
mapping,
|
|
created_at,
|
|
updated_at
|
|
FROM table_columns
|
|
WHERE table_id = ?
|
|
ORDER BY order_index ASC
|
|
`, [tableId]);
|
|
|
|
columns = columns.map(col => ({
|
|
...col,
|
|
config: col.config ? (typeof col.config === 'string' ? JSON.parse(col.config) : col.config) : {},
|
|
mapping: col.mapping ? (typeof col.mapping === 'string' ? JSON.parse(col.mapping) : col.mapping) : null,
|
|
is_required: toBool(col.is_required),
|
|
is_visible: toBool(col.is_visible),
|
|
is_readonly: toBool(col.is_readonly),
|
|
is_locked: toBool(col.is_locked)
|
|
}));
|
|
}
|
|
|
|
return success(res, columns);
|
|
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'GET /tables/:tableId/columns error');
|
|
return error(res, 'COLUMN_LIST_FAILED', 'Failed to list columns', 500, err.message);
|
|
}
|
|
});
|
|
|
|
// ============================================================
|
|
// POST /api/v3/tables/:tableId/columns
|
|
// Create a new column
|
|
// ============================================================
|
|
/**
|
|
* @swagger
|
|
* /api/v3/tables/{tableId}/columns:
|
|
* post:
|
|
* summary: Create a new column
|
|
* tags: [Columns]
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: tableId
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [name, display_name, column_type]
|
|
* properties:
|
|
* name:
|
|
* type: string
|
|
* display_name:
|
|
* type: string
|
|
* column_type:
|
|
* type: string
|
|
* config:
|
|
* type: object
|
|
* is_required:
|
|
* type: boolean
|
|
* responses:
|
|
* 201:
|
|
* description: Column created
|
|
*/
|
|
router.post('/tables/:tableId/columns', verifyTableAccess, async (req, res) => {
|
|
try {
|
|
const { tableId } = req.params;
|
|
const {
|
|
name,
|
|
display_name,
|
|
column_type,
|
|
config = {},
|
|
is_required = false,
|
|
is_readonly = false,
|
|
order_index,
|
|
width = 200
|
|
} = req.body;
|
|
|
|
if (!name || !display_name || !column_type) {
|
|
return badRequest(res, 'name, display_name, and column_type are required');
|
|
}
|
|
|
|
if (!VALID_COLUMN_TYPES.includes(column_type)) {
|
|
return badRequest(res, `Invalid column type. Must be one of: ${VALID_COLUMN_TYPES.join(', ')}`);
|
|
}
|
|
|
|
// ADR-0016 Phase 2: file-type columns get a `visibility` enum in their
|
|
// `config`. Validate caller input; default new columns to 'private'.
|
|
// Non-file columns must NOT carry a `visibility` key (it's meaningless
|
|
// and confusing in the UI).
|
|
let persistedConfig = config;
|
|
if (isFileColumnType(column_type)) {
|
|
const cfg = { ...(config || {}) };
|
|
if (cfg.visibility === undefined || cfg.visibility === null) {
|
|
cfg.visibility = 'private';
|
|
} else if (!FILE_VISIBILITY_VALUES.includes(cfg.visibility)) {
|
|
return badRequest(
|
|
res,
|
|
`Invalid visibility '${cfg.visibility}'. Must be one of: ${FILE_VISIBILITY_VALUES.join(', ')}`
|
|
);
|
|
}
|
|
persistedConfig = cfg;
|
|
} else if (
|
|
config &&
|
|
Object.prototype.hasOwnProperty.call(config, 'visibility')
|
|
) {
|
|
return badRequest(res, '`visibility` is only valid on file-type columns');
|
|
}
|
|
|
|
// ADR-0011 Phase A/C: flag-gate + config validation for verification columns.
|
|
// Normalized form is persisted so consumers (guards, verify endpoint) see
|
|
// canonical ADR-0011 fields (`available_methods`, `required_methods`,
|
|
// `cooldown_seconds`, …) regardless of which legacy keys the caller sent.
|
|
if (column_type === 'verification') {
|
|
if (process.env.VERIFICATION_COLUMN_ENABLED !== 'true') {
|
|
return badRequest(res, 'Verification column type is disabled (set VERIFICATION_COLUMN_ENABLED=true to enable)');
|
|
}
|
|
const cfgCheck = validateVerificationConfig(config);
|
|
if (!cfgCheck.ok) {
|
|
return badRequest(res, `Invalid verification config: ${cfgCheck.error}`);
|
|
}
|
|
persistedConfig = cfgCheck.normalized;
|
|
}
|
|
|
|
const existing = await dbGet(
|
|
'SELECT id FROM table_columns WHERE table_id = ? AND column_name = ?',
|
|
[tableId, name]
|
|
);
|
|
if (existing) {
|
|
return badRequest(res, `Column "${name}" already exists in this table`);
|
|
}
|
|
|
|
let orderIdx = order_index;
|
|
if (orderIdx === undefined || orderIdx === null) {
|
|
const maxOrder = await dbGet(
|
|
'SELECT MAX(order_index) as max_order FROM table_columns WHERE table_id = ?',
|
|
[tableId]
|
|
);
|
|
orderIdx = (maxOrder?.max_order ?? -1) + 1;
|
|
}
|
|
|
|
const result = await dbRun(`
|
|
INSERT INTO table_columns (
|
|
table_id, column_name, display_name, type, config,
|
|
is_required, is_readonly, order_index, is_visible, width
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?)
|
|
`, [
|
|
tableId, name, display_name, column_type,
|
|
JSON.stringify(persistedConfig), is_required ? 1 : 0,
|
|
is_readonly ? 1 : 0, orderIdx, width
|
|
]);
|
|
|
|
const column = await dbGet(`
|
|
SELECT
|
|
id, table_id, column_name AS name, display_name, type AS column_type, config,
|
|
is_required, is_readonly, order_index, is_visible, width,
|
|
created_at, updated_at
|
|
FROM table_columns
|
|
WHERE id = ?
|
|
`, [result.lastInsertRowid]);
|
|
|
|
const response = {
|
|
...column,
|
|
config: column.config ? JSON.parse(column.config) : {},
|
|
is_required: toBool(column.is_required),
|
|
is_readonly: toBool(column.is_readonly),
|
|
is_visible: toBool(column.is_visible)
|
|
};
|
|
|
|
apiLogger.info({ tableId, columnId: column.id, name }, 'Column created');
|
|
return created(res, response);
|
|
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'POST /tables/:tableId/columns error');
|
|
return error(res, 'COLUMN_CREATE_FAILED', 'Failed to create column', 500, err.message);
|
|
}
|
|
});
|
|
|
|
// ============================================================
|
|
// GET /api/v3/tables/:tableId/columns/:columnId
|
|
// Get a single column
|
|
// ============================================================
|
|
router.get('/tables/:tableId/columns/:columnId', verifyTableAccess, async (req, res) => {
|
|
try {
|
|
const { columnId } = req.params;
|
|
|
|
const column = await dbGet(`
|
|
SELECT
|
|
id, table_id, column_name AS name, display_name, type AS column_type, config,
|
|
is_required, is_readonly, order_index, is_visible, width, is_locked,
|
|
default_value, formula, mapping,
|
|
created_at, updated_at
|
|
FROM table_columns
|
|
WHERE id = ?
|
|
`, [columnId]);
|
|
|
|
if (!column) {
|
|
return notFound(res, 'Column');
|
|
}
|
|
|
|
const response = {
|
|
...column,
|
|
config: column.config ? JSON.parse(column.config) : {},
|
|
mapping: column.mapping ? JSON.parse(column.mapping) : null,
|
|
is_required: toBool(column.is_required),
|
|
is_readonly: toBool(column.is_readonly),
|
|
is_visible: toBool(column.is_visible),
|
|
is_locked: toBool(column.is_locked)
|
|
};
|
|
|
|
return success(res, response);
|
|
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'GET /tables/:tableId/columns/:columnId error');
|
|
return error(res, 'COLUMN_GET_FAILED', 'Failed to get column', 500, err.message);
|
|
}
|
|
});
|
|
|
|
// ============================================================
|
|
// PATCH /api/v3/tables/:tableId/columns/:columnId
|
|
// Update a column
|
|
// ============================================================
|
|
router.patch('/tables/:tableId/columns/:columnId', verifyTableAccess, async (req, res) => {
|
|
try {
|
|
const { columnId } = req.params;
|
|
const updates = req.body;
|
|
|
|
const existingColumn = await dbGet('SELECT id, type FROM table_columns WHERE id = ?', [columnId]);
|
|
if (!existingColumn) {
|
|
return notFound(res, 'Column');
|
|
}
|
|
|
|
const allowedFields = [
|
|
'display_name', 'column_type', 'config', 'is_required', 'order_index', 'is_visible',
|
|
'width', 'is_readonly', 'default_value', 'formula', 'mapping'
|
|
];
|
|
const jsonFields = ['config', 'mapping'];
|
|
const booleanFields = ['is_visible', 'is_required', 'is_readonly'];
|
|
|
|
const targetType = updates.column_type || existingColumn.type;
|
|
|
|
// ADR-0016 Phase 2: validate `config.visibility` on file-type columns
|
|
// when config is being touched. We deliberately do not auto-fill
|
|
// missing values on PATCH — the guard already falls back to 'private'
|
|
// if it's absent — so callers can clear the field by sending an empty
|
|
// config without us silently re-stamping 'private' on every update.
|
|
if (
|
|
Object.prototype.hasOwnProperty.call(updates, 'config') &&
|
|
updates.config &&
|
|
Object.prototype.hasOwnProperty.call(updates.config, 'visibility')
|
|
) {
|
|
const v = updates.config.visibility;
|
|
if (isFileColumnType(targetType)) {
|
|
if (!FILE_VISIBILITY_VALUES.includes(v)) {
|
|
return badRequest(
|
|
res,
|
|
`Invalid visibility '${v}'. Must be one of: ${FILE_VISIBILITY_VALUES.join(', ')}`
|
|
);
|
|
}
|
|
} else {
|
|
return badRequest(res, '`visibility` is only valid on file-type columns');
|
|
}
|
|
}
|
|
|
|
// ADR-0011 Phase F follow-up: if column is (or becomes) a verification
|
|
// column AND config is being updated, normalize it through the same
|
|
// validator used on CREATE. This enforces the canonical FLAT shape and
|
|
// prevents clients from persisting nested `config.verification.*`
|
|
// (which silently broke enforceVerificationGuards in the original bug).
|
|
if (targetType === 'verification' && Object.prototype.hasOwnProperty.call(updates, 'config')) {
|
|
if (process.env.VERIFICATION_COLUMN_ENABLED !== 'true') {
|
|
return badRequest(res, 'Verification column type is disabled (set VERIFICATION_COLUMN_ENABLED=true to enable)');
|
|
}
|
|
const cfgCheck = validateVerificationConfig(updates.config || {});
|
|
if (!cfgCheck.ok) {
|
|
return badRequest(res, `Invalid verification config: ${cfgCheck.error}`);
|
|
}
|
|
updates.config = cfgCheck.normalized;
|
|
}
|
|
|
|
const updateFields = [];
|
|
const values = [];
|
|
|
|
for (const [key, value] of Object.entries(updates)) {
|
|
if (allowedFields.includes(key)) {
|
|
updateFields.push(`${key === 'column_type' ? 'type' : key} = ?`);
|
|
|
|
let processedValue = value;
|
|
if (jsonFields.includes(key)) {
|
|
processedValue = JSON.stringify(value);
|
|
} else if (booleanFields.includes(key)) {
|
|
processedValue = value ? 1 : 0;
|
|
}
|
|
|
|
values.push(processedValue);
|
|
}
|
|
}
|
|
|
|
if (updateFields.length === 0) {
|
|
return badRequest(res, 'No valid fields to update');
|
|
}
|
|
|
|
updateFields.push('updated_at = CURRENT_TIMESTAMP');
|
|
values.push(columnId);
|
|
|
|
await dbRun(`
|
|
UPDATE table_columns
|
|
SET ${updateFields.join(', ')}
|
|
WHERE id = ?
|
|
`, values);
|
|
|
|
const updated = await dbGet(`
|
|
SELECT
|
|
id, table_id, column_name AS name, display_name, type AS column_type, config,
|
|
is_required, is_readonly, order_index, is_visible, width, is_locked,
|
|
default_value, formula, mapping,
|
|
created_at, updated_at
|
|
FROM table_columns
|
|
WHERE id = ?
|
|
`, [columnId]);
|
|
|
|
const response = {
|
|
...updated,
|
|
config: updated.config ? JSON.parse(updated.config) : {},
|
|
mapping: updated.mapping ? JSON.parse(updated.mapping) : null,
|
|
is_required: toBool(updated.is_required),
|
|
is_readonly: toBool(updated.is_readonly),
|
|
is_visible: toBool(updated.is_visible),
|
|
is_locked: toBool(updated.is_locked)
|
|
};
|
|
|
|
apiLogger.info({ columnId, updates: Object.keys(updates) }, 'Column updated');
|
|
return success(res, response);
|
|
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'PATCH /tables/:tableId/columns/:columnId error');
|
|
return error(res, 'COLUMN_UPDATE_FAILED', 'Failed to update column', 500, err.message);
|
|
}
|
|
});
|
|
|
|
// ============================================================
|
|
// DELETE /api/v3/tables/:tableId/columns/:columnId
|
|
// Delete a column
|
|
// ============================================================
|
|
router.delete('/tables/:tableId/columns/:columnId', verifyTableAccess, async (req, res) => {
|
|
try {
|
|
const { columnId } = req.params;
|
|
|
|
const column = await dbGet(
|
|
'SELECT id, column_name AS name, is_locked FROM table_columns WHERE id = ?',
|
|
[columnId]
|
|
);
|
|
if (!column) {
|
|
return notFound(res, 'Column');
|
|
}
|
|
|
|
if (toBool(column.is_locked)) {
|
|
return badRequest(res, 'Cannot delete system column');
|
|
}
|
|
|
|
await dbRun('DELETE FROM table_columns WHERE id = ?', [columnId]);
|
|
|
|
apiLogger.info({ columnId, name: column.name }, 'Column deleted');
|
|
return success(res, {
|
|
message: `Column "${column.name}" deleted successfully`,
|
|
columnId: column.id
|
|
});
|
|
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'DELETE /tables/:tableId/columns/:columnId error');
|
|
return error(res, 'COLUMN_DELETE_FAILED', 'Failed to delete column', 500, err.message);
|
|
}
|
|
});
|
|
|
|
export default router;
|