feat(palace): REST API routes with search, CRUD, KG, wake-up, maintenance (N5)
This commit is contained in:
parent
793c25cded
commit
be2f4ff0ad
182
backend/src/modules/palace/routes.test.ts
Normal file
182
backend/src/modules/palace/routes.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
182
backend/src/modules/palace/routes.ts
Normal file
182
backend/src/modules/palace/routes.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user