maestro/src/bridge/memory-api.ts
oss-sync 29ccaf1e92
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (dfadcd5f)
2026-06-23 06:38:48 +00:00

260 lines
10 KiB
TypeScript

/**
* memory-api.ts — REST router for user memory entries
*
* Mounted at /api/local/memory
*
* Routes:
* GET /entries — list parsed entries + MEMORY.md index
* PUT /entries/:name — upsert entry (frontmatter validation enforced)
* DELETE /entries/:name — remove entry + update index
*
* Auth: all routes require an authenticated user (req.user).
* Owner: each operation scopes to req.user.id — no cross-user access.
*/
import { Router, type Request, type Response } from 'express';
import { join } from 'path';
import { logger } from '../logger.js';
import {
isValidMemoryName,
MEMORY_TYPES,
type MemoryType,
listMemoryEntries,
readMemoryIndexFromDir,
upsertMemoryEntry,
removeMemoryEntry,
} from '../user-folder/memory.js';
import { userMemoryDir } from '../user-folder/paths.js';
import { withUserLock } from '../engine/reflection/user-lock.js';
import { loadConfig } from '../config.js';
// ── Types ──────────────────────────────────────────────────────────────────────
interface AuthedUser { id: string; role: string; }
function getUser(req: Request): AuthedUser | null {
return (req.user as AuthedUser | undefined) ?? null;
}
// ── Deps ───────────────────────────────────────────────────────────────────────
export interface MemoryApiDeps {
/** Root data dir (same as userFolderRoot / dataDir in the rest of the app). */
dataDir: string;
/** When false (local-dev mode), inject a synthetic 'local' user if req.user absent. */
authActive?: boolean;
/**
* Spaces foundation: optional resolver mapping a `spaceId` (from the request
* query/body) to the `{ rootDir, leafId }` folder whose memory dir is
* `userMemoryDir(rootDir, leafId)`. Returns `null` when the space is not
* visible to the viewer (treated as 404 — never silently falls back to the
* user folder). When omitted (or no spaceId on the request), memory
* resolution uses the legacy per-user folder (`dataDir`, `user.id`) —
* byte-identical to today.
*/
resolveSpaceFolder?: (
spaceId: string,
viewer?: Express.User,
) => Promise<{ rootDir: string; leafId: string } | null>;
}
/** Extract a spaceId from the request (query param wins over body). */
function getSpaceId(req: Request): string | undefined {
const fromQuery = typeof req.query?.['spaceId'] === 'string' ? (req.query['spaceId'] as string) : undefined;
const fromBody =
req.body && typeof (req.body as Record<string, unknown>)['spaceId'] === 'string'
? ((req.body as Record<string, unknown>)['spaceId'] as string)
: undefined;
return fromQuery ?? fromBody ?? undefined;
}
// ── Factory ────────────────────────────────────────────────────────────────────
export function createMemoryApi(deps: MemoryApiDeps): Router {
const { dataDir } = deps;
const authActive = deps.authActive ?? true;
const r = Router();
/**
* Resolve the memory folder for a request, honoring an optional `spaceId`.
* - No spaceId → legacy per-user folder (`dataDir`, `user.id`).
* - spaceId + resolver → space folder when visible; `{ forbidden: true }`
* when the resolver returns null. Never falls back to the user folder for
* an unresolvable spaceId.
*/
async function resolveMemoryFolder(
req: Request,
u: AuthedUser,
): Promise<{ rootDir: string; leafId: string } | { forbidden: true }> {
const spaceId = getSpaceId(req);
if (spaceId && deps.resolveSpaceFolder) {
const fc = await deps.resolveSpaceFolder(spaceId, req.user);
if (!fc) return { forbidden: true };
return { rootDir: fc.rootDir, leafId: fc.leafId };
}
return { rootDir: dataDir, leafId: u.id };
}
// JSON body parser for this router
r.use((_req, _res, next) => {
// body-parser already applied globally only for certain routes; apply here too
next();
});
// ── Auth gate ──────────────────────────────────────────────────────────────
r.use((req: Request, res: Response, next) => {
if (!authActive && !getUser(req)) {
// role:'admin' + orgIds:[] mirrors the no-auth convention (space-api /
// deserializeUser). No-auth spaces have owner_id=NULL, so the space
// resolver's buildVisibilityWhere only resolves them under the admin
// (1=1) clause; role:'user' would 404 the user's own space memory, and a
// missing orgIds crashes on `.length`.
(req as any).user = { id: 'local', role: 'admin', orgIds: [] };
}
if (!getUser(req)) {
res.status(401).json({ error: 'Unauthenticated' });
return;
}
next();
});
// ── GET /entries ───────────────────────────────────────────────────────────
r.get('/entries', async (req: Request, res: Response) => {
const u = getUser(req)!;
const folder = await resolveMemoryFolder(req, u);
if ('forbidden' in folder) {
res.status(404).json({ error: 'Space not found' });
return;
}
const memDir = userMemoryDir(folder.rootDir, folder.leafId);
try {
const entries = listMemoryEntries(memDir);
const index = readMemoryIndexFromDir(memDir);
res.json({ entries, index });
} catch (err) {
logger.error(`[memory-api] GET /entries failed leaf=${folder.leafId} err=${err}`);
res.status(500).json({ error: 'Failed to list memory entries' });
}
});
// ── PUT /entries/:name ─────────────────────────────────────────────────────
// Validation thresholds (name pattern, four-value type, body byte cap) are
// shared with the reflection applier's semantic validator at
// `src/engine/reflection/semantic-validator.ts`. The rejection codes here
// match the ReflectionRejectionCode union so the UI can render either source
// consistently. If you add/rename a code, update both files together.
r.put('/entries/:name', async (req: Request, res: Response) => {
const u = getUser(req)!;
const { name } = req.params;
// Validate name (mirrors semantic-validator's rejected_bad_name path)
if (!isValidMemoryName(name)) {
res.status(400).json({ error: 'rejected_bad_name' });
return;
}
const body = req.body as Record<string, unknown> | undefined;
if (!body || typeof body !== 'object') {
res.status(400).json({ error: 'rejected_bad_request' });
return;
}
const { description, type, body: entryBody } = body as {
description?: unknown;
type?: unknown;
body?: unknown;
};
// Validate description
if (typeof description !== 'string' || description.trim() === '') {
res.status(400).json({ error: 'rejected_bad_description' });
return;
}
if (description.includes('\n') || description.includes('\r')) {
res.status(400).json({ error: 'rejected_bad_description' });
return;
}
// Validate type
if (!MEMORY_TYPES.includes(type as MemoryType)) {
res.status(400).json({ error: 'rejected_unknown_type' });
return;
}
// Validate body
if (typeof entryBody !== 'string') {
res.status(400).json({ error: 'rejected_bad_body' });
return;
}
// Body byte-length cap from config
const cfg = loadConfig();
const maxBodyBytes = cfg.reflection.maxEntryBodyBytes;
if (Buffer.byteLength(entryBody, 'utf-8') > maxBodyBytes) {
res.status(400).json({ error: 'rejected_body_too_large' });
return;
}
const folder = await resolveMemoryFolder(req, u);
if ('forbidden' in folder) {
res.status(404).json({ error: 'Space not found' });
return;
}
try {
const result = await withUserLock(folder.rootDir, folder.leafId, async () => {
return upsertMemoryEntry(folder.rootDir, folder.leafId, {
name,
type: type as MemoryType,
description: description.trim(),
body: entryBody,
});
});
logger.info(`[memory-api] PUT /entries/${name} leaf=${folder.leafId} path=${result.path}`);
res.json({ ok: true, name, path: result.path });
} catch (err) {
logger.error(`[memory-api] PUT /entries/${name} failed leaf=${folder.leafId} err=${err}`);
res.status(500).json({ error: 'Failed to upsert memory entry' });
}
});
// ── DELETE /entries/:name ──────────────────────────────────────────────────
r.delete('/entries/:name', async (req: Request, res: Response) => {
const u = getUser(req)!;
const { name } = req.params;
// Validate name — do not leak existence for invalid names
if (!isValidMemoryName(name)) {
res.status(404).json({ error: 'not_found' });
return;
}
const folder = await resolveMemoryFolder(req, u);
if ('forbidden' in folder) {
res.status(404).json({ error: 'Space not found' });
return;
}
try {
const removed = await withUserLock(folder.rootDir, folder.leafId, async () => {
return removeMemoryEntry(folder.rootDir, folder.leafId, name);
});
if (!removed) {
res.status(404).json({ error: 'not_found' });
return;
}
logger.info(`[memory-api] DELETE /entries/${name} leaf=${folder.leafId}`);
res.json({ ok: true, name });
} catch (err) {
logger.error(`[memory-api] DELETE /entries/${name} failed leaf=${folder.leafId} err=${err}`);
res.status(500).json({ error: 'Failed to delete memory entry' });
}
});
return r;
}