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/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/datastore.js', () => ({ initDatastore: initDatastoreMock }));
|
||||
vi.mock('./lib/config.js', () => ({
|
||||
@ -79,7 +80,7 @@ describe('server bootstrap', () => {
|
||||
expect(initDatastoreMock).toHaveBeenCalledOnce();
|
||||
expect(createServiceAppMock).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' });
|
||||
});
|
||||
});
|
||||
|
||||
@ -13,6 +13,7 @@ import { notePromptRoutes } from './modules/note-prompts/routes.js';
|
||||
import { promptSchedulerRoutes, startSchedulerLoop, stopSchedulerLoop } from './modules/note-prompts/scheduler.js';
|
||||
import { intakeRoutes } from './modules/intake/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 { initEncryption } from './lib/field-encrypt.js';
|
||||
import { initDatastore } from './lib/datastore.js';
|
||||
@ -69,6 +70,7 @@ await registerApiPlugin(notePromptRoutes);
|
||||
await registerApiPlugin(promptSchedulerRoutes);
|
||||
await registerApiPlugin(intakeRoutes);
|
||||
await registerApiPlugin(noteCollaboratorRoutes);
|
||||
await registerApiPlugin(palaceRoutes);
|
||||
|
||||
// ── Start scheduler loop (F25) ────────────────────────────────────
|
||||
startSchedulerLoop();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user