feat(palace): REST API routes with search, CRUD, KG, wake-up, maintenance (N5)

This commit is contained in:
saravanakumardb1 2026-04-10 01:35:20 -07:00
parent 793c25cded
commit be2f4ff0ad
4 changed files with 368 additions and 1 deletions

View File

@ -0,0 +1,182 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import type { FastifyInstance } from 'fastify';
const { extractAuthMock } = vi.hoisted(() => ({
extractAuthMock: vi.fn(async () => ({ sub: 'test-user-1', type: 'access', role: 'editor' })),
}));
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock, requireWriter: extractAuthMock }));
vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' }));
vi.mock('../../lib/telemetry.js', () => ({ trackEvent: vi.fn() }));
vi.mock('../../lib/feature-flags.js', () => ({ isFeatureEnabled: vi.fn(() => true) }));
import { resetMemoryDatastore, buildTestApp, authHeader, TEST_USER_ID, TEST_PRODUCT_ID } from '../../test-helpers.js';
import { ensureWing, ensureRoom, storeMemory, addTriple } from './repository.js';
import { palaceRoutes } from './routes.js';
let app: FastifyInstance;
const USER_A = TEST_USER_ID;
const PRODUCT = TEST_PRODUCT_ID;
describe('Palace Routes (N5)', () => {
beforeAll(async () => {
app = await buildTestApp(palaceRoutes);
});
beforeEach(() => {
resetMemoryDatastore();
});
afterAll(async () => {
await app.close();
});
it('GET /api/palace/wings returns user wings', async () => {
await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work');
await ensureWing(USER_A, PRODUCT, 'ws-2', 'Personal');
const res = await app.inject({
method: 'GET',
url: '/api/palace/wings',
headers: authHeader(),
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.payload);
expect(body.length).toBe(2);
});
it('GET /api/palace/wings/:wingId returns wing summary', async () => {
const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work');
const room = await ensureRoom(USER_A, PRODUCT, wing.id, 'auth');
await storeMemory(USER_A, PRODUCT, wing.id, room.id, 'decisions', 'Use JWT');
const res = await app.inject({
method: 'GET',
url: `/api/palace/wings/${wing.id}`,
headers: authHeader(),
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.payload);
expect(body.wing.name).toBe('Work');
expect(body.totalMemories).toBe(1);
});
it('POST /api/palace/memories stores a memory', async () => {
const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work');
const room = await ensureRoom(USER_A, PRODUCT, wing.id, 'auth');
const res = await app.inject({
method: 'POST',
url: '/api/palace/memories',
headers: authHeader(),
payload: {
wingId: wing.id,
roomId: room.id,
hall: 'decisions',
content: 'Use Fastify 5 for all backends',
},
});
expect(res.statusCode).toBe(201);
const body = JSON.parse(res.payload);
expect(body.stored).toBe(true);
expect(body.memory.content).toBe('Use Fastify 5 for all backends');
});
it('GET /api/palace/search returns matching memories', async () => {
const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work');
const room = await ensureRoom(USER_A, PRODUCT, wing.id, 'auth');
await storeMemory(USER_A, PRODUCT, wing.id, room.id, 'decisions', 'Use JWT for authentication');
const res = await app.inject({
method: 'GET',
url: '/api/palace/search?q=JWT',
headers: authHeader(),
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.payload);
expect(body.length).toBe(1);
expect(body[0].content).toContain('JWT');
});
it('DELETE /api/palace/wings/:wingId cascades and returns 204', async () => {
const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work');
const room = await ensureRoom(USER_A, PRODUCT, wing.id, 'auth');
await storeMemory(USER_A, PRODUCT, wing.id, room.id, 'decisions', 'D1');
const res = await app.inject({
method: 'DELETE',
url: `/api/palace/wings/${wing.id}`,
headers: authHeader(),
});
expect(res.statusCode).toBe(204);
});
it('GET /api/palace/wake-up/:wingId returns L0+L1+L2 context', async () => {
const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work');
const room = await ensureRoom(USER_A, PRODUCT, wing.id, 'auth');
await storeMemory(USER_A, PRODUCT, wing.id, room.id, 'decisions', 'Use JWT');
const res = await app.inject({
method: 'GET',
url: `/api/palace/wake-up/${wing.id}`,
headers: authHeader(),
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.payload);
expect(body.wingName).toBe('Work');
expect(body.text.length).toBeGreaterThan(0);
});
it('GET /api/palace/kg/entity/:entity returns user-scoped triples', async () => {
const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work');
await addTriple(USER_A, PRODUCT, wing.id, 'React', 'replaced_by', 'Svelte', 0.8);
const res = await app.inject({
method: 'GET',
url: '/api/palace/kg/entity/React',
headers: authHeader(),
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.payload);
expect(body.length).toBe(1);
});
it('GET /api/palace/stats returns accurate counts', async () => {
const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work');
const room = await ensureRoom(USER_A, PRODUCT, wing.id, 'auth');
await storeMemory(USER_A, PRODUCT, wing.id, room.id, 'decisions', 'D1');
const res = await app.inject({
method: 'GET',
url: '/api/palace/stats',
headers: authHeader(),
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.payload);
expect(body.wings).toBe(1);
expect(body.rooms).toBe(1);
expect(body.memories).toBe(1);
});
it('unauthenticated request returns 401', async () => {
// Temporarily make extractAuth throw UnauthorizedError
const { UnauthorizedError } = await import('@bytelyst/errors');
extractAuthMock.mockRejectedValueOnce(new UnauthorizedError());
const res = await app.inject({
method: 'GET',
url: '/api/palace/wings',
});
expect(res.statusCode).toBe(401);
});
});

View File

@ -0,0 +1,182 @@
/**
* Palace REST API routes JWT-secured endpoints for palace operations.
*/
import type { FastifyApp } from '@bytelyst/fastify-core';
import { BadRequestError, NotFoundError } from '@bytelyst/errors';
import { z } from 'zod';
import { extractAuth } from '../../lib/auth.js';
import { PRODUCT_ID } from '../../lib/product-config.js';
import { embedText } from '../../lib/embeddings.js';
import { config } from '../../lib/config.js';
import * as repo from './repository.js';
import { buildNoteLettWakeUp } from './wakeup.js';
import { PalaceSearchQuerySchema, StoreMemorySchema, HALL_TYPES } from './types.js';
import type { HallType } from './types.js';
type RouteApp = Omit<FastifyApp, 'setReadyState' | 'isReadyState'>;
const ListMemoriesQuerySchema = z.object({
wingId: z.string().max(128).optional(),
roomId: z.string().max(128).optional(),
hall: z.enum(HALL_TYPES).optional(),
limit: z.coerce.number().int().min(1).max(100).default(50),
});
export async function palaceRoutes(app: RouteApp) {
if (!config.PALACE_ENABLED) return;
// ── Search ────────────────────────────────────────
app.get('/palace/search', async req => {
const auth = await extractAuth(req);
const parsed = PalaceSearchQuerySchema.safeParse(req.query);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const { q, wingId, limit } = parsed.data;
const embedding = await embedText(q);
if (embedding) {
return repo.searchHybrid(auth.sub, PRODUCT_ID, q, embedding, wingId, limit);
}
return repo.searchText(auth.sub, PRODUCT_ID, q, wingId, limit);
});
// ── Wings ─────────────────────────────────────────
app.get('/palace/wings', async req => {
const auth = await extractAuth(req);
return repo.listWings(auth.sub, PRODUCT_ID);
});
app.get('/palace/wings/:wingId', async req => {
const auth = await extractAuth(req);
const { wingId } = req.params as { wingId: string };
const summary = await repo.getWingSummary(auth.sub, PRODUCT_ID, wingId);
if (!summary.wing) throw new NotFoundError('Wing not found');
return summary;
});
app.delete('/palace/wings/:wingId', async (req, reply) => {
const auth = await extractAuth(req);
const { wingId } = req.params as { wingId: string };
await repo.deleteWing(auth.sub, PRODUCT_ID, wingId);
reply.code(204).send();
});
// ── Rooms ─────────────────────────────────────────
app.get('/palace/wings/:wingId/rooms', async req => {
const auth = await extractAuth(req);
const { wingId } = req.params as { wingId: string };
return repo.listRooms(auth.sub, PRODUCT_ID, wingId);
});
// ── Memories ──────────────────────────────────────
app.post('/palace/memories', async (req, reply) => {
const auth = await extractAuth(req);
const parsed = StoreMemorySchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const { wingId, roomId, hall, content, sourceNoteId } = parsed.data;
const hallTyped = hall as HallType;
const embedding = await embedText(content);
const isDup = await repo.isNearDuplicate(
auth.sub, PRODUCT_ID, roomId, hallTyped, content, embedding,
);
if (isDup) {
return { stored: false, reason: 'duplicate' };
}
const mem = await repo.storeMemory(
auth.sub, PRODUCT_ID, wingId, roomId,
hallTyped, content, sourceNoteId, embedding,
);
reply.code(201);
return { stored: true, memory: mem };
});
app.get('/palace/memories', async req => {
const auth = await extractAuth(req);
const parsed = ListMemoriesQuerySchema.safeParse(req.query);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const { wingId, roomId, hall, limit } = parsed.data;
return repo.listMemories(auth.sub, PRODUCT_ID, {
wingId,
roomId,
hall: hall as HallType | undefined,
limit,
});
});
app.delete('/palace/memories/:id', async (req, reply) => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
const deleted = await repo.deleteMemory(auth.sub, PRODUCT_ID, id);
if (!deleted) throw new NotFoundError('Memory not found');
reply.code(204).send();
});
// ── Knowledge Graph ───────────────────────────────
app.get('/palace/kg/entity/:entity', async req => {
const auth = await extractAuth(req);
const { entity } = req.params as { entity: string };
return repo.queryEntity(auth.sub, PRODUCT_ID, decodeURIComponent(entity));
});
app.get('/palace/kg/timeline/:entity', async req => {
const auth = await extractAuth(req);
const { entity } = req.params as { entity: string };
return repo.entityTimeline(auth.sub, PRODUCT_ID, decodeURIComponent(entity));
});
app.get('/palace/kg/contradictions', async req => {
const auth = await extractAuth(req);
const wingId = (req.query as { wingId?: string }).wingId;
return repo.findKGContradictions(auth.sub, PRODUCT_ID, wingId);
});
// ── Wake-Up Context ───────────────────────────────
app.get('/palace/wake-up/:wingId', async req => {
const auth = await extractAuth(req);
const { wingId } = req.params as { wingId: string };
const task = (req.query as { task?: string }).task;
return buildNoteLettWakeUp(auth.sub, PRODUCT_ID, wingId, task);
});
// ── Maintenance ───────────────────────────────────
app.post('/palace/backfill-embeddings', async req => {
const auth = await extractAuth(req);
const count = await repo.backfillEmbeddings(auth.sub, PRODUCT_ID);
return { backfilled: count };
});
app.post('/palace/prune', async req => {
const auth = await extractAuth(req);
const query = req.query as { olderThanDays?: string; minRelevance?: string };
const olderThanDays = query.olderThanDays ? Number(query.olderThanDays) : 180;
const minRelevance = query.minRelevance ? Number(query.minRelevance) : 0.1;
const deleted = await repo.pruneOldMemories(auth.sub, PRODUCT_ID, undefined, olderThanDays, minRelevance);
return { pruned: deleted };
});
app.get('/palace/health', async () => {
return repo.healthCheck();
});
app.get('/palace/stats', async req => {
const auth = await extractAuth(req);
return repo.getPalaceStats(auth.sub, PRODUCT_ID);
});
}

View File

@ -40,6 +40,7 @@ vi.mock('./modules/note-prompts/scheduler.js', () => ({
})); }));
vi.mock('./modules/intake/routes.js', () => ({ intakeRoutes: vi.fn() })); vi.mock('./modules/intake/routes.js', () => ({ intakeRoutes: vi.fn() }));
vi.mock('./modules/note-collaborators/routes.js', () => ({ noteCollaboratorRoutes: vi.fn() })); vi.mock('./modules/note-collaborators/routes.js', () => ({ noteCollaboratorRoutes: vi.fn() }));
vi.mock('./modules/palace/routes.js', () => ({ palaceRoutes: vi.fn() }));
vi.mock('./lib/cosmos-init.js', () => ({ initCosmosIfNeeded: initCosmosIfNeededMock })); vi.mock('./lib/cosmos-init.js', () => ({ initCosmosIfNeeded: initCosmosIfNeededMock }));
vi.mock('./lib/datastore.js', () => ({ initDatastore: initDatastoreMock })); vi.mock('./lib/datastore.js', () => ({ initDatastore: initDatastoreMock }));
vi.mock('./lib/config.js', () => ({ vi.mock('./lib/config.js', () => ({
@ -79,7 +80,7 @@ describe('server bootstrap', () => {
expect(initDatastoreMock).toHaveBeenCalledOnce(); expect(initDatastoreMock).toHaveBeenCalledOnce();
expect(createServiceAppMock).toHaveBeenCalledOnce(); expect(createServiceAppMock).toHaveBeenCalledOnce();
expect(registerOptionalJwtContextMock).toHaveBeenCalledOnce(); expect(registerOptionalJwtContextMock).toHaveBeenCalledOnce();
expect(appMock.register).toHaveBeenCalledTimes(13); expect(appMock.register).toHaveBeenCalledTimes(14);
expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4016, host: '0.0.0.0' }); expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4016, host: '0.0.0.0' });
}); });
}); });

View File

@ -13,6 +13,7 @@ import { notePromptRoutes } from './modules/note-prompts/routes.js';
import { promptSchedulerRoutes, startSchedulerLoop, stopSchedulerLoop } from './modules/note-prompts/scheduler.js'; import { promptSchedulerRoutes, startSchedulerLoop, stopSchedulerLoop } from './modules/note-prompts/scheduler.js';
import { intakeRoutes } from './modules/intake/routes.js'; import { intakeRoutes } from './modules/intake/routes.js';
import { noteCollaboratorRoutes } from './modules/note-collaborators/routes.js'; import { noteCollaboratorRoutes } from './modules/note-collaborators/routes.js';
import { palaceRoutes } from './modules/palace/routes.js';
import { initCosmosIfNeeded } from './lib/cosmos-init.js'; import { initCosmosIfNeeded } from './lib/cosmos-init.js';
import { initEncryption } from './lib/field-encrypt.js'; import { initEncryption } from './lib/field-encrypt.js';
import { initDatastore } from './lib/datastore.js'; import { initDatastore } from './lib/datastore.js';
@ -69,6 +70,7 @@ await registerApiPlugin(notePromptRoutes);
await registerApiPlugin(promptSchedulerRoutes); await registerApiPlugin(promptSchedulerRoutes);
await registerApiPlugin(intakeRoutes); await registerApiPlugin(intakeRoutes);
await registerApiPlugin(noteCollaboratorRoutes); await registerApiPlugin(noteCollaboratorRoutes);
await registerApiPlugin(palaceRoutes);
// ── Start scheduler loop (F25) ──────────────────────────────────── // ── Start scheduler loop (F25) ────────────────────────────────────
startSchedulerLoop(); startSchedulerLoop();