260 lines
10 KiB
TypeScript
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;
|
|
}
|