Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
138 lines
4.9 KiB
JavaScript
138 lines
4.9 KiB
JavaScript
// Admin-only endpoints — app-owner (space-11 owner) gated.
|
|
//
|
|
// Mount in server.js:
|
|
// app.use('/api/v3/admin', authenticate, adminRoutesV3);
|
|
//
|
|
// Authz mirrors routes/v3/secrets.js and routes/v3/owner.js: only the
|
|
// Development space (id 11) owner may access. Admin role alone is NOT
|
|
// enough. Per ADR-0040 §RBAC and reused by ADR-0064 §Decision for the
|
|
// global notification-defaults layer.
|
|
|
|
import express from 'express';
|
|
|
|
import { dbGet, dbRun, safeJsonParse } from '../../../database/connection.js';
|
|
import { apiLogger } from '../../../utils/logger.js';
|
|
import { success, error, badRequest, forbidden } from '../../../utils/response.js';
|
|
import { notifyInvalidate } from '../../../services/notifications/resolveChatPrefs.js';
|
|
|
|
const log = apiLogger.child({ module: 'admin_api' });
|
|
const OWNER_SPACE_ID = 11;
|
|
const GLOBAL_CHAT_PREFS_KEY = 'chat_notifications_global';
|
|
|
|
const router = express.Router();
|
|
|
|
async function requireOwner(req, res) {
|
|
if (!req.user?.id) {
|
|
forbidden(res, 'Authentication required');
|
|
return false;
|
|
}
|
|
const space = await dbGet('SELECT id, owner_id FROM spaces WHERE id = ?', [OWNER_SPACE_ID]);
|
|
if (!space) {
|
|
error(res, 'OWNER_SPACE_MISSING', `Space ${OWNER_SPACE_ID} not found`, 500);
|
|
return false;
|
|
}
|
|
if (space.owner_id !== req.user.id) {
|
|
forbidden(res, 'Owner-only endpoint');
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// ─── Prefs validation (mirrors notificationPrefsController.js) ──────────
|
|
|
|
const TOP_SCALAR_TYPES = {
|
|
enabled: 'boolean',
|
|
sound_enabled: 'boolean',
|
|
sound_volume: 'number',
|
|
};
|
|
const NESTED_BLOCKS = ['humans', 'agents'];
|
|
const NESTED_KEYS = ['sound', 'popup', 'badge'];
|
|
|
|
function validatePrefsShape(input) {
|
|
if (input === null || input === undefined) return { ok: true, value: null };
|
|
if (typeof input !== 'object' || Array.isArray(input)) {
|
|
return { ok: false, reason: 'prefs must be an object' };
|
|
}
|
|
const out = {};
|
|
for (const [k, v] of Object.entries(input)) {
|
|
if (k in TOP_SCALAR_TYPES) {
|
|
if (v === null) continue;
|
|
// eslint-disable-next-line valid-typeof
|
|
if (typeof v !== TOP_SCALAR_TYPES[k]) {
|
|
return { ok: false, reason: `${k} must be ${TOP_SCALAR_TYPES[k]}` };
|
|
}
|
|
if (k === 'sound_volume' && (v < 0 || v > 1)) {
|
|
return { ok: false, reason: 'sound_volume must be in [0, 1]' };
|
|
}
|
|
out[k] = v;
|
|
} else if (NESTED_BLOCKS.includes(k)) {
|
|
if (v === null) continue;
|
|
if (typeof v !== 'object' || Array.isArray(v)) {
|
|
return { ok: false, reason: `${k} must be an object` };
|
|
}
|
|
const block = {};
|
|
for (const [nk, nv] of Object.entries(v)) {
|
|
if (!NESTED_KEYS.includes(nk)) {
|
|
return { ok: false, reason: `unknown key ${k}.${nk}` };
|
|
}
|
|
if (nv === null) continue;
|
|
if (typeof nv !== 'boolean') {
|
|
return { ok: false, reason: `${k}.${nk} must be boolean` };
|
|
}
|
|
block[nk] = nv;
|
|
}
|
|
out[k] = block;
|
|
} else {
|
|
return { ok: false, reason: `unknown key: ${k}` };
|
|
}
|
|
}
|
|
return { ok: true, value: out };
|
|
}
|
|
|
|
// ─── Routes ─────────────────────────────────────────────────────────────
|
|
|
|
// GET /api/v3/admin/global/chat-notifications — read the global default JSON.
|
|
router.get('/global/chat-notifications', async (req, res) => {
|
|
if (!(await requireOwner(req, res))) return;
|
|
try {
|
|
const row = await dbGet(
|
|
`SELECT value FROM _app_settings WHERE key = ?`,
|
|
[GLOBAL_CHAT_PREFS_KEY]
|
|
);
|
|
const prefs = safeJsonParse(row?.value, null);
|
|
return success(res, { prefs });
|
|
} catch (err) {
|
|
log.error({ err }, 'admin/global/chat-notifications GET failed');
|
|
return error(res, 'GET_GLOBAL_PREFS_ERROR', err.message, 500);
|
|
}
|
|
});
|
|
|
|
// PUT /api/v3/admin/global/chat-notifications — overwrite the global default.
|
|
router.put('/global/chat-notifications', async (req, res) => {
|
|
if (!(await requireOwner(req, res))) return;
|
|
try {
|
|
const v = validatePrefsShape(req.body?.prefs);
|
|
if (!v.ok) return badRequest(res, v.reason);
|
|
|
|
const valueJson = JSON.stringify(v.value || {});
|
|
await dbRun(
|
|
`INSERT INTO _app_settings (key, value, updated_by, updated_at)
|
|
VALUES (?, ?::jsonb, ?, NOW())
|
|
ON CONFLICT (key) DO UPDATE
|
|
SET value = EXCLUDED.value,
|
|
updated_by = EXCLUDED.updated_by,
|
|
updated_at = NOW()`,
|
|
[GLOBAL_CHAT_PREFS_KEY, valueJson, req.user.id]
|
|
);
|
|
// Global write: cache-bust everything.
|
|
await notifyInvalidate({ scope: 'global' });
|
|
log.info({ userId: req.user.id }, 'admin/global/chat-notifications updated');
|
|
return success(res, { prefs: v.value });
|
|
} catch (err) {
|
|
log.error({ err }, 'admin/global/chat-notifications PUT failed');
|
|
return error(res, 'PUT_GLOBAL_PREFS_ERROR', err.message, 500);
|
|
}
|
|
});
|
|
|
|
export default router;
|
|
export { validatePrefsShape };
|