diff --git a/backend/src/modules/palace/routes.test.ts b/backend/src/modules/palace/routes.test.ts new file mode 100644 index 0000000..0032a72 --- /dev/null +++ b/backend/src/modules/palace/routes.test.ts @@ -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); + }); +}); diff --git a/backend/src/modules/palace/routes.ts b/backend/src/modules/palace/routes.ts new file mode 100644 index 0000000..16e48e6 --- /dev/null +++ b/backend/src/modules/palace/routes.ts @@ -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; + +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); + }); +} diff --git a/backend/src/server.test.ts b/backend/src/server.test.ts index b37080d..cc4c615 100644 --- a/backend/src/server.test.ts +++ b/backend/src/server.test.ts @@ -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' }); }); }); diff --git a/backend/src/server.ts b/backend/src/server.ts index 165219a..d36971f 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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();