diff --git a/.gitignore b/.gitignore index e9f0e7c..a7a9596 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,5 @@ data/ .DS_Store *.pem -.claude/ \ No newline at end of file +.claude/ +.vscode/ \ No newline at end of file diff --git a/apps/api/src/auth/auth.spec.ts b/apps/api/src/auth/auth.spec.ts index a5309fe..4d2fae7 100644 --- a/apps/api/src/auth/auth.spec.ts +++ b/apps/api/src/auth/auth.spec.ts @@ -40,7 +40,8 @@ describe("Auth (integration)", () => { const res = await request(app.getHttpServer()) .get("/v1/auth/me") - .set("Authorization", auth.authHeader) + .set("Cookie", auth.cookie) + .set("x-csrf-token", auth.csrfToken) .expect(200); expect(res.body).toMatchObject({ @@ -57,7 +58,7 @@ describe("Auth (integration)", () => { it("returns 401 with invalid token", async () => { await request(app.getHttpServer()) .get("/v1/auth/me") - .set("Authorization", "Bearer invalid-token") + .set("Cookie", "auth_token=invalid-token") .expect(401); }); }); @@ -72,7 +73,8 @@ describe("Auth (integration)", () => { const res = await request(app.getHttpServer()) .get("/v1/auth/sessions") - .set("Authorization", auth.authHeader) + .set("Cookie", auth.cookie) + .set("x-csrf-token", auth.csrfToken) .expect(200); expect(res.body).toHaveLength(1); @@ -90,7 +92,8 @@ describe("Auth (integration)", () => { await request(app.getHttpServer()) .delete(`/v1/auth/sessions/${auth.session.id}`) - .set("Authorization", auth.authHeader) + .set("Cookie", auth.cookie) + .set("x-csrf-token", auth.csrfToken) .expect(204); // Session should now be invalid — subsequent requests should fail @@ -110,7 +113,8 @@ describe("Auth (integration)", () => { await request(app.getHttpServer()) .delete(`/v1/auth/sessions/${user1.session.id}`) - .set("Authorization", user2.authHeader) + .set("Cookie", user2.cookie) + .set("x-csrf-token", user2.csrfToken) .expect(403); }); }); @@ -125,7 +129,8 @@ describe("Auth (integration)", () => { const res = await request(app.getHttpServer()) .post("/v1/auth/logout") - .set("Authorization", auth.authHeader) + .set("Cookie", auth.cookie) + .set("x-csrf-token", auth.csrfToken) .expect(204); // Cookie should be cleared diff --git a/apps/api/src/auth/strategies/jwt.strategy.ts b/apps/api/src/auth/strategies/jwt.strategy.ts index 3c7ead7..7775269 100644 --- a/apps/api/src/auth/strategies/jwt.strategy.ts +++ b/apps/api/src/auth/strategies/jwt.strategy.ts @@ -16,7 +16,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { ) { super({ jwtFromRequest: ExtractJwt.fromExtractors([ - // Cookie auth only — Bearer is handled by ApiKeyAuthGuard + // Cookie auth only — Bearer is handled by ApiKeyAuthGuard for lrm_ keys (request: Request) => request?.cookies?.["auth_token"] ?? null, ]), ignoreExpiration: true, // We handle expiration via session validation diff --git a/apps/api/src/entities/entities.spec.ts b/apps/api/src/entities/entities.spec.ts index 210322b..dcade43 100644 --- a/apps/api/src/entities/entities.spec.ts +++ b/apps/api/src/entities/entities.spec.ts @@ -13,7 +13,8 @@ describe("Entities (integration)", () => { let app: INestApplication; let prisma: PrismaService; let module: TestingModule; - let authHeader: string; + let authCookie: string; + let csrfToken: string; let projectSlug: string; beforeAll(async () => { @@ -28,12 +29,14 @@ describe("Entities (integration)", () => { beforeEach(async () => { await cleanDatabase(prisma); const auth = await createAuthenticatedUser(prisma, module); - authHeader = auth.authHeader; + authCookie = auth.cookie; + csrfToken = auth.csrfToken; // Create a project for entity tests const res = await request(app.getHttpServer()) .post("/v1/projects") - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ name: "Test World" }); projectSlug = res.body.slug; }); @@ -48,7 +51,8 @@ describe("Entities (integration)", () => { it("creates a CHARACTER entity", async () => { const res = await request(app.getHttpServer()) .post(base()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ type: "CHARACTER", name: "Gandalf", @@ -72,7 +76,8 @@ describe("Entities (integration)", () => { it("creates a LOCATION entity", async () => { const res = await request(app.getHttpServer()) .post(base()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ type: "LOCATION", name: "The Shire", @@ -87,7 +92,8 @@ describe("Entities (integration)", () => { it("creates an ORGANIZATION entity", async () => { const res = await request(app.getHttpServer()) .post(base()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ type: "ORGANIZATION", name: "The Fellowship", @@ -101,7 +107,8 @@ describe("Entities (integration)", () => { it("rejects invalid entity type", async () => { await request(app.getHttpServer()) .post(base()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ type: "INVALID", name: "Bad Type" }) .expect(400); }); @@ -109,7 +116,8 @@ describe("Entities (integration)", () => { it("rejects missing name", async () => { await request(app.getHttpServer()) .post(base()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ type: "CHARACTER" }) .expect(400); }); @@ -117,12 +125,14 @@ describe("Entities (integration)", () => { it("generates unique slugs for duplicate names", async () => { await request(app.getHttpServer()) .post(base()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ type: "CHARACTER", name: "Aragorn" }); const res = await request(app.getHttpServer()) .post(base()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ type: "CHARACTER", name: "Aragorn" }) .expect(201); @@ -138,16 +148,19 @@ describe("Entities (integration)", () => { it("lists all entities in a project", async () => { await request(app.getHttpServer()) .post(base()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ type: "CHARACTER", name: "Frodo" }); await request(app.getHttpServer()) .post(base()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ type: "LOCATION", name: "Mordor" }); const res = await request(app.getHttpServer()) .get(base()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .expect(200); expect(res.body).toHaveLength(2); @@ -156,17 +169,20 @@ describe("Entities (integration)", () => { it("filters by type", async () => { await request(app.getHttpServer()) .post(base()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ type: "CHARACTER", name: "Sam" }); await request(app.getHttpServer()) .post(base()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ type: "LOCATION", name: "Rivendell" }); const res = await request(app.getHttpServer()) .get(base()) .query({ type: "CHARACTER" }) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .expect(200); expect(res.body).toHaveLength(1); @@ -176,17 +192,20 @@ describe("Entities (integration)", () => { it("searches by name", async () => { await request(app.getHttpServer()) .post(base()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ type: "CHARACTER", name: "Legolas" }); await request(app.getHttpServer()) .post(base()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ type: "CHARACTER", name: "Gimli" }); const res = await request(app.getHttpServer()) .get(base()) .query({ q: "leg" }) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .expect(200); expect(res.body).toHaveLength(1); @@ -198,12 +217,14 @@ describe("Entities (integration)", () => { it("returns entity with hub data", async () => { await request(app.getHttpServer()) .post(base()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ type: "CHARACTER", name: "Boromir" }); const res = await request(app.getHttpServer()) .get(`${base()}/boromir`) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .expect(200); expect(res.body.name).toBe("Boromir"); @@ -216,7 +237,8 @@ describe("Entities (integration)", () => { it("returns 404 for non-existent entity", async () => { await request(app.getHttpServer()) .get(`${base()}/nope`) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .expect(404); }); }); @@ -229,12 +251,14 @@ describe("Entities (integration)", () => { it("updates entity fields", async () => { await request(app.getHttpServer()) .post(base()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ type: "CHARACTER", name: "Pippin", summary: "A hobbit" }); const res = await request(app.getHttpServer()) .patch(`${base()}/pippin`) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ summary: "A Took", description: "Peregrin Took" }) .expect(200); @@ -245,12 +269,14 @@ describe("Entities (integration)", () => { it("regenerates slug when name changes", async () => { await request(app.getHttpServer()) .post(base()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ type: "CHARACTER", name: "Strider" }); const res = await request(app.getHttpServer()) .patch(`${base()}/strider`) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ name: "Aragorn" }) .expect(200); @@ -260,12 +286,14 @@ describe("Entities (integration)", () => { it("upserts extension fields", async () => { await request(app.getHttpServer()) .post(base()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ type: "CHARACTER", name: "Elrond" }); const res = await request(app.getHttpServer()) .patch(`${base()}/elrond`) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ character: { species: "Elf", role: "Lord of Rivendell" } }) .expect(200); @@ -281,17 +309,20 @@ describe("Entities (integration)", () => { it("deletes an entity", async () => { await request(app.getHttpServer()) .post(base()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ type: "CHARACTER", name: "Saruman" }); await request(app.getHttpServer()) .delete(`${base()}/saruman`) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .expect(204); await request(app.getHttpServer()) .get(`${base()}/saruman`) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .expect(404); }); }); diff --git a/apps/api/src/lore/lore.spec.ts b/apps/api/src/lore/lore.spec.ts index a8025e2..946b418 100644 --- a/apps/api/src/lore/lore.spec.ts +++ b/apps/api/src/lore/lore.spec.ts @@ -13,7 +13,8 @@ describe("Lore (integration)", () => { let app: INestApplication; let prisma: PrismaService; let module: TestingModule; - let authHeader: string; + let authCookie: string; + let csrfToken: string; let projectSlug: string; beforeAll(async () => { @@ -28,11 +29,13 @@ describe("Lore (integration)", () => { beforeEach(async () => { await cleanDatabase(prisma); const auth = await createAuthenticatedUser(prisma, module); - authHeader = auth.authHeader; + authCookie = auth.cookie; + csrfToken = auth.csrfToken; const proj = await request(app.getHttpServer()) .post("/v1/projects") - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ name: "Lore World" }); projectSlug = proj.body.slug; }); @@ -42,7 +45,8 @@ describe("Lore (integration)", () => { it("creates a lore article", async () => { const res = await request(app.getHttpServer()) .post(base()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ title: "The One Ring", content: "Forged by Sauron in the fires of Mount Doom.", @@ -60,16 +64,19 @@ describe("Lore (integration)", () => { it("lists lore articles", async () => { await request(app.getHttpServer()) .post(base()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ title: "Article A", content: "Content A" }); await request(app.getHttpServer()) .post(base()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ title: "Article B", content: "Content B" }); const res = await request(app.getHttpServer()) .get(base()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .expect(200); expect(res.body).toHaveLength(2); @@ -78,12 +85,14 @@ describe("Lore (integration)", () => { it("gets a lore article by slug", async () => { await request(app.getHttpServer()) .post(base()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ title: "History of Gondor", content: "Long ago..." }); const res = await request(app.getHttpServer()) .get(`${base()}/history-of-gondor`) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .expect(200); expect(res.body.title).toBe("History of Gondor"); @@ -92,12 +101,14 @@ describe("Lore (integration)", () => { it("updates a lore article", async () => { await request(app.getHttpServer()) .post(base()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ title: "Draft Article", content: "v1" }); const res = await request(app.getHttpServer()) .patch(`${base()}/draft-article`) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ content: "v2 revised" }) .expect(200); @@ -107,17 +118,20 @@ describe("Lore (integration)", () => { it("deletes a lore article", async () => { await request(app.getHttpServer()) .post(base()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ title: "Temp", content: "delete me" }); await request(app.getHttpServer()) .delete(`${base()}/temp`) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .expect(204); await request(app.getHttpServer()) .get(`${base()}/temp`) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .expect(404); }); }); diff --git a/apps/api/src/projects/projects.controller.ts b/apps/api/src/projects/projects.controller.ts index 61302de..73a84b7 100644 --- a/apps/api/src/projects/projects.controller.ts +++ b/apps/api/src/projects/projects.controller.ts @@ -7,6 +7,7 @@ import { Delete, Body, Param, + Query, UseGuards, HttpCode, HttpStatus, @@ -40,6 +41,19 @@ export class ProjectsController { return this.projectsService.findAllByUser(user.id); } + @Get(":slug/search") + @ApiOperation({ + summary: "Search across project content (stub — OpenSearch pending)", + }) + async search( + @Param("slug") slug: string, + @User() user: AuthUser, + @Query("q") q?: string, + ) { + await this.projectsService.findBySlug(slug, user.id); + return { results: [], query: q ?? "", total: 0 }; + } + @Get(":slug/graph-layout") @ApiOperation({ summary: "Get graph layout for a project" }) async getGraphLayout(@Param("slug") slug: string, @User() user: AuthUser) { diff --git a/apps/api/src/projects/projects.spec.ts b/apps/api/src/projects/projects.spec.ts index 4574d9c..521781d 100644 --- a/apps/api/src/projects/projects.spec.ts +++ b/apps/api/src/projects/projects.spec.ts @@ -13,7 +13,8 @@ describe("Projects (integration)", () => { let app: INestApplication; let prisma: PrismaService; let module: TestingModule; - let authHeader: string; + let authCookie: string; + let csrfToken: string; let userId: string; beforeAll(async () => { @@ -28,7 +29,8 @@ describe("Projects (integration)", () => { beforeEach(async () => { await cleanDatabase(prisma); const auth = await createAuthenticatedUser(prisma, module); - authHeader = auth.authHeader; + authCookie = auth.cookie; + csrfToken = auth.csrfToken; userId = auth.user.id; }); @@ -40,7 +42,8 @@ describe("Projects (integration)", () => { it("creates a project and returns it", async () => { const res = await request(app.getHttpServer()) .post("/v1/projects") - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ name: "Middle Earth", description: "Tolkien's world" }) .expect(201); @@ -63,7 +66,8 @@ describe("Projects (integration)", () => { it("returns 400 for missing name", async () => { await request(app.getHttpServer()) .post("/v1/projects") - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ description: "no name" }) .expect(400); }); @@ -71,13 +75,15 @@ describe("Projects (integration)", () => { it("generates unique slugs for duplicate names", async () => { await request(app.getHttpServer()) .post("/v1/projects") - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ name: "Duplicate" }) .expect(201); const res = await request(app.getHttpServer()) .post("/v1/projects") - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ name: "Duplicate" }) .expect(201); @@ -93,11 +99,13 @@ describe("Projects (integration)", () => { it("lists only the user's projects", async () => { await request(app.getHttpServer()) .post("/v1/projects") - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ name: "Project A" }); await request(app.getHttpServer()) .post("/v1/projects") - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ name: "Project B" }); // Second user shouldn't see first user's projects @@ -107,7 +115,8 @@ describe("Projects (integration)", () => { const res = await request(app.getHttpServer()) .get("/v1/projects") - .set("Authorization", other.authHeader) + .set("Cookie", other.cookie) + .set("x-csrf-token", other.csrfToken) .expect(200); expect(res.body).toHaveLength(0); @@ -118,12 +127,14 @@ describe("Projects (integration)", () => { it("returns a project by slug", async () => { await request(app.getHttpServer()) .post("/v1/projects") - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ name: "Test Project" }); const res = await request(app.getHttpServer()) .get("/v1/projects/test-project") - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .expect(200); expect(res.body.name).toBe("Test Project"); @@ -132,14 +143,16 @@ describe("Projects (integration)", () => { it("returns 404 for non-existent project", async () => { await request(app.getHttpServer()) .get("/v1/projects/does-not-exist") - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .expect(404); }); it("returns 403 for another user's project", async () => { await request(app.getHttpServer()) .post("/v1/projects") - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ name: "Private" }); const other = await createAuthenticatedUser(prisma, module, { @@ -148,7 +161,8 @@ describe("Projects (integration)", () => { await request(app.getHttpServer()) .get("/v1/projects/private") - .set("Authorization", other.authHeader) + .set("Cookie", other.cookie) + .set("x-csrf-token", other.csrfToken) .expect(403); }); }); @@ -161,12 +175,14 @@ describe("Projects (integration)", () => { it("updates project name and regenerates slug", async () => { await request(app.getHttpServer()) .post("/v1/projects") - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ name: "Old Name" }); const res = await request(app.getHttpServer()) .patch("/v1/projects/old-name") - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ name: "New Name" }) .expect(200); @@ -177,12 +193,14 @@ describe("Projects (integration)", () => { it("updates description without changing slug", async () => { await request(app.getHttpServer()) .post("/v1/projects") - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ name: "Stable Slug" }); const res = await request(app.getHttpServer()) .patch("/v1/projects/stable-slug") - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ description: "Updated description" }) .expect(200); @@ -199,17 +217,20 @@ describe("Projects (integration)", () => { it("deletes a project", async () => { await request(app.getHttpServer()) .post("/v1/projects") - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ name: "To Delete" }); await request(app.getHttpServer()) .delete("/v1/projects/to-delete") - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .expect(204); await request(app.getHttpServer()) .get("/v1/projects/to-delete") - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .expect(404); }); }); diff --git a/apps/api/src/relationships/relationships.spec.ts b/apps/api/src/relationships/relationships.spec.ts index 23fb4f6..ca6a9e5 100644 --- a/apps/api/src/relationships/relationships.spec.ts +++ b/apps/api/src/relationships/relationships.spec.ts @@ -13,7 +13,8 @@ describe("Relationships (integration)", () => { let app: INestApplication; let prisma: PrismaService; let module: TestingModule; - let authHeader: string; + let authCookie: string; + let csrfToken: string; let projectSlug: string; beforeAll(async () => { @@ -28,22 +29,26 @@ describe("Relationships (integration)", () => { beforeEach(async () => { await cleanDatabase(prisma); const auth = await createAuthenticatedUser(prisma, module); - authHeader = auth.authHeader; + authCookie = auth.cookie; + csrfToken = auth.csrfToken; const proj = await request(app.getHttpServer()) .post("/v1/projects") - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ name: "Rel World" }); projectSlug = proj.body.slug; // Create two entities to relate await request(app.getHttpServer()) .post(`/v1/projects/${projectSlug}/entities`) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ type: "CHARACTER", name: "Gandalf" }); await request(app.getHttpServer()) .post(`/v1/projects/${projectSlug}/entities`) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ type: "CHARACTER", name: "Frodo" }); }); @@ -52,7 +57,8 @@ describe("Relationships (integration)", () => { it("creates a relationship between entities", async () => { const res = await request(app.getHttpServer()) .post(base()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ sourceEntitySlug: "gandalf", targetEntitySlug: "frodo", @@ -68,7 +74,8 @@ describe("Relationships (integration)", () => { it("lists relationships for a project", async () => { await request(app.getHttpServer()) .post(base()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ sourceEntitySlug: "gandalf", targetEntitySlug: "frodo", @@ -77,7 +84,8 @@ describe("Relationships (integration)", () => { const res = await request(app.getHttpServer()) .get(base()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .expect(200); expect(res.body.length).toBeGreaterThanOrEqual(1); @@ -86,7 +94,8 @@ describe("Relationships (integration)", () => { it("deletes a relationship", async () => { const created = await request(app.getHttpServer()) .post(base()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ sourceEntitySlug: "gandalf", targetEntitySlug: "frodo", @@ -95,7 +104,8 @@ describe("Relationships (integration)", () => { await request(app.getHttpServer()) .delete(`${base()}/${created.body.id}`) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .expect(204); }); }); diff --git a/apps/api/src/storyboard/storyboard.controller.ts b/apps/api/src/storyboard/storyboard.controller.ts index 3adf37f..138ccef 100644 --- a/apps/api/src/storyboard/storyboard.controller.ts +++ b/apps/api/src/storyboard/storyboard.controller.ts @@ -38,6 +38,18 @@ export class StoryboardController { private projectsService: ProjectsService, ) {} + // ── Overview ── + + @Get() + @ApiOperation({ summary: "Get storyboard overview (plotlines + works)" }) + async getOverview( + @Param("projectSlug") projectSlug: string, + @User() user: AuthUser, + ) { + const project = await this.projectsService.findBySlug(projectSlug, user.id); + return this.storyboardService.getOverview(project.id); + } + // ── Plotlines ── @Post("plotlines") diff --git a/apps/api/src/storyboard/storyboard.service.ts b/apps/api/src/storyboard/storyboard.service.ts index cf5593a..496e337 100644 --- a/apps/api/src/storyboard/storyboard.service.ts +++ b/apps/api/src/storyboard/storyboard.service.ts @@ -22,6 +22,38 @@ const plotPointInclude = { export class StoryboardService { constructor(private prisma: PrismaService) {} + // ── Overview ── + + async getOverview(projectId: string) { + const [plotlines, works] = await Promise.all([ + this.prisma.plotline.findMany({ + where: { projectId }, + orderBy: { sortOrder: "asc" }, + include: { + childPlotlines: { select: { id: true, name: true, slug: true } }, + _count: { select: { plotPoints: true } }, + }, + }), + this.prisma.work.findMany({ + where: { projectId }, + orderBy: { releaseOrder: "asc" }, + include: { + chapters: { + orderBy: { sequenceNumber: "asc" }, + select: { + id: true, + title: true, + sequenceNumber: true, + _count: { select: { scenes: true } }, + }, + }, + }, + }), + ]); + + return { plotlines, works }; + } + // ── Plotlines ── async createPlotline(projectId: string, dto: CreatePlotlineDto) { @@ -393,7 +425,12 @@ export class StoryboardService { include: { plotline: { select: { id: true, name: true, slug: true } }, characters: { - select: { entityId: true, role: true, isPov: true, entity: { select: { id: true, name: true, slug: true } } }, + select: { + entityId: true, + role: true, + isPov: true, + entity: { select: { id: true, name: true, slug: true } }, + }, }, location: { select: { id: true, name: true, slug: true } }, }, @@ -442,7 +479,12 @@ export class StoryboardService { include: { plotline: { select: { id: true, name: true, slug: true } }, characters: { - select: { entityId: true, role: true, isPov: true, entity: { select: { id: true, name: true, slug: true } } }, + select: { + entityId: true, + role: true, + isPov: true, + entity: { select: { id: true, name: true, slug: true } }, + }, }, location: { select: { id: true, name: true, slug: true } }, }, diff --git a/apps/api/src/storyboard/storyboard.spec.ts b/apps/api/src/storyboard/storyboard.spec.ts index eefd3cc..adb37be 100644 --- a/apps/api/src/storyboard/storyboard.spec.ts +++ b/apps/api/src/storyboard/storyboard.spec.ts @@ -13,7 +13,8 @@ describe("Storyboard (integration)", () => { let app: INestApplication; let prisma: PrismaService; let module: TestingModule; - let authHeader: string; + let authCookie: string; + let csrfToken: string; let projectSlug: string; beforeAll(async () => { @@ -28,11 +29,13 @@ describe("Storyboard (integration)", () => { beforeEach(async () => { await cleanDatabase(prisma); const auth = await createAuthenticatedUser(prisma, module); - authHeader = auth.authHeader; + authCookie = auth.cookie; + csrfToken = auth.csrfToken; const proj = await request(app.getHttpServer()) .post("/v1/projects") - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ name: "Story World" }); projectSlug = proj.body.slug; }); @@ -47,7 +50,8 @@ describe("Storyboard (integration)", () => { it("creates a plotline", async () => { const res = await request(app.getHttpServer()) .post(`${base()}/plotlines`) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ name: "The Ring Quest", description: "Destroy the One Ring" }) .expect(201); @@ -60,16 +64,19 @@ describe("Storyboard (integration)", () => { it("lists plotlines", async () => { await request(app.getHttpServer()) .post(`${base()}/plotlines`) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ name: "Plotline A" }); await request(app.getHttpServer()) .post(`${base()}/plotlines`) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ name: "Plotline B" }); const res = await request(app.getHttpServer()) .get(`${base()}/plotlines`) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .expect(200); expect(res.body).toHaveLength(2); @@ -78,12 +85,14 @@ describe("Storyboard (integration)", () => { it("deletes a plotline", async () => { await request(app.getHttpServer()) .post(`${base()}/plotlines`) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ name: "Temp Plot" }); await request(app.getHttpServer()) .delete(`${base()}/plotlines/temp-plot`) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .expect(204); }); }); @@ -96,7 +105,8 @@ describe("Storyboard (integration)", () => { it("creates a work", async () => { const res = await request(app.getHttpServer()) .post(`${base()}/works`) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ title: "The Fellowship of the Ring", chronologicalOrder: 1, @@ -114,7 +124,8 @@ describe("Storyboard (integration)", () => { // Create work const work = await request(app.getHttpServer()) .post(`${base()}/works`) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ title: "Book One", chronologicalOrder: 1, @@ -124,7 +135,8 @@ describe("Storyboard (integration)", () => { // Create chapter const chapter = await request(app.getHttpServer()) .post(`${base()}/works/${work.body.slug}/chapters`) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ title: "Chapter 1", sequenceNumber: 1 }) .expect(201); @@ -133,7 +145,8 @@ describe("Storyboard (integration)", () => { // Create scene const scene = await request(app.getHttpServer()) .post(`${base()}/scenes`) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ chapterId: chapter.body.id, sequenceNumber: 1, @@ -147,7 +160,8 @@ describe("Storyboard (integration)", () => { // Verify the hierarchy via GET work const fullWork = await request(app.getHttpServer()) .get(`${base()}/works/${work.body.slug}`) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .expect(200); expect(fullWork.body.chapters).toHaveLength(1); @@ -163,12 +177,14 @@ describe("Storyboard (integration)", () => { it("creates a plot point on a plotline", async () => { await request(app.getHttpServer()) .post(`${base()}/plotlines`) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ name: "Main Arc" }); const res = await request(app.getHttpServer()) .post(`${base()}/plotlines/main-arc/points`) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ title: "Inciting Incident", sequenceNumber: 1 }) .expect(201); diff --git a/apps/api/src/test/helpers.ts b/apps/api/src/test/helpers.ts index 487b6ed..0208d1b 100644 --- a/apps/api/src/test/helpers.ts +++ b/apps/api/src/test/helpers.ts @@ -4,6 +4,7 @@ import { JwtService } from "@nestjs/jwt"; import cookieParser from "cookie-parser"; import { AppModule } from "../app.module"; import { PrismaService } from "../prisma/prisma.service"; +import { CookieService } from "../auth/services/cookie.service"; /** * Boots a full NestJS app for integration tests. @@ -79,11 +80,15 @@ export async function createAuthenticatedUser( tokenFamily: session.tokenFamily, }); + const cookieService = module.get(CookieService); + const csrfToken = cookieService.generateCsrfToken(session.id); + return { user, session, token, - authHeader: `Bearer ${token}`, + cookie: `auth_token=${token}; csrf_token=${csrfToken}`, + csrfToken, }; } diff --git a/apps/api/src/timeline/timeline.spec.ts b/apps/api/src/timeline/timeline.spec.ts index 4ee848e..72342ba 100644 --- a/apps/api/src/timeline/timeline.spec.ts +++ b/apps/api/src/timeline/timeline.spec.ts @@ -13,7 +13,8 @@ describe("Timeline (integration)", () => { let app: INestApplication; let prisma: PrismaService; let module: TestingModule; - let authHeader: string; + let authCookie: string; + let csrfToken: string; let projectSlug: string; beforeAll(async () => { @@ -28,11 +29,13 @@ describe("Timeline (integration)", () => { beforeEach(async () => { await cleanDatabase(prisma); const auth = await createAuthenticatedUser(prisma, module); - authHeader = auth.authHeader; + authCookie = auth.cookie; + csrfToken = auth.csrfToken; const proj = await request(app.getHttpServer()) .post("/v1/projects") - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ name: "Timeline World" }); projectSlug = proj.body.slug; }); @@ -46,7 +49,8 @@ describe("Timeline (integration)", () => { it("creates a timeline event", async () => { const res = await request(app.getHttpServer()) .post(eventsBase()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ name: "Battle of Helm's Deep", date: "TA 3019-03-03", @@ -64,16 +68,19 @@ describe("Timeline (integration)", () => { it("lists timeline events", async () => { await request(app.getHttpServer()) .post(eventsBase()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ name: "Event A", date: "1000", sortOrder: 1 }); await request(app.getHttpServer()) .post(eventsBase()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ name: "Event B", date: "2000", sortOrder: 2 }); const res = await request(app.getHttpServer()) .get(eventsBase()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .expect(200); expect(res.body).toHaveLength(2); @@ -82,12 +89,14 @@ describe("Timeline (integration)", () => { it("deletes a timeline event", async () => { const created = await request(app.getHttpServer()) .post(eventsBase()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ name: "Temp Event", date: "500", sortOrder: 1 }); await request(app.getHttpServer()) .delete(`${eventsBase()}/${created.body.id}`) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .expect(204); }); @@ -100,7 +109,8 @@ describe("Timeline (integration)", () => { it("creates an era", async () => { const res = await request(app.getHttpServer()) .post(erasBase()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ name: "The Third Age", startDate: 1, @@ -118,16 +128,19 @@ describe("Timeline (integration)", () => { it("lists eras", async () => { await request(app.getHttpServer()) .post(erasBase()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ name: "First Age", startDate: 0, endDate: 500 }); await request(app.getHttpServer()) .post(erasBase()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ name: "Second Age", startDate: 500, endDate: 3441 }); const res = await request(app.getHttpServer()) .get(erasBase()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .expect(200); expect(res.body).toHaveLength(2); @@ -136,12 +149,14 @@ describe("Timeline (integration)", () => { it("deletes an era", async () => { await request(app.getHttpServer()) .post(erasBase()) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .send({ name: "Delete Me", startDate: 0, endDate: 1 }); await request(app.getHttpServer()) .delete(`${erasBase()}/delete-me`) - .set("Authorization", authHeader) + .set("Cookie", authCookie) + .set("x-csrf-token", csrfToken) .expect(204); }); }); diff --git a/apps/mcp/src/index.ts b/apps/mcp/src/index.ts index aa2f4cd..9be6623 100644 --- a/apps/mcp/src/index.ts +++ b/apps/mcp/src/index.ts @@ -92,26 +92,6 @@ server.registerTool( }, ); -server.registerTool( - "get_entity_hub", - { - description: - "Get the full aggregated lore page for an entity — everything connected to it", - inputSchema: { - projectSlug: z.string(), - entitySlug: z.string(), - }, - }, - async ({ projectSlug, entitySlug }) => { - const hub = await api( - `/projects/${projectSlug}/entities/${entitySlug}/hub`, - ); - return { - content: [{ type: "text" as const, text: JSON.stringify(hub, null, 2) }], - }; - }, -); - server.registerTool( "list_entities", { @@ -143,20 +123,13 @@ server.registerTool( "get_storyboard", { description: - "Get the narrative structure: plotlines, books, chapters, scenes", + "Get the storyboard overview: all plotlines and works with chapters", inputSchema: { projectSlug: z.string(), - bookSlug: z.string().optional(), - detail: z.enum(["outline", "full"]).optional(), }, }, - async ({ projectSlug, bookSlug, detail }) => { - const params = new URLSearchParams(); - if (bookSlug) params.set("book", bookSlug); - if (detail) params.set("detail", detail); - const query = params.toString() ? `?${params}` : ""; - - const storyboard = await api(`/projects/${projectSlug}/storyboard${query}`); + async ({ projectSlug }) => { + const storyboard = await api(`/projects/${projectSlug}/storyboard`); return { content: [ { type: "text" as const, text: JSON.stringify(storyboard, null, 2) }, diff --git a/docs/MCP_IMPLEMENTATION_PLAN.md b/docs/MCP_IMPLEMENTATION_PLAN.md index 8606f76..d5cf2a4 100644 --- a/docs/MCP_IMPLEMENTATION_PLAN.md +++ b/docs/MCP_IMPLEMENTATION_PLAN.md @@ -3,7 +3,8 @@ Scoped plan for completing the MCP server to a testable state. Covers API prerequisites, auth, review queue, and MCP tool expansion. **Created:** 2026-04-24 -**Status:** In progress +**Updated:** 2026-04-24 +**Status:** Phases 1–2 complete, Phase 3 next **Reference:** See TODO.md > Near-Term for task tracking --- @@ -14,23 +15,34 @@ Scoped plan for completing the MCP server to a testable state. Covers API prereq The MCP server (`apps/mcp/src/index.ts`) is a single-file stdio server with: -- 6 read tools: `search_project`, `get_entity`, `get_entity_hub`, `list_entities`, `get_storyboard`, `get_entity_types` +- 5 read tools: `search_project`, `get_entity`, `list_entities`, `get_storyboard`, `get_entity_types` - 4 write tools: `create_entity`, `update_entity`, `create_relationship`, `create_lore_article` - 1 resource: `project_overview` - A simple `api()` helper that throws on HTTP errors -- Auth via `MCP_API_TOKEN` env var (raw JWT copied from browser) +- Auth via `MCP_API_TOKEN` env var (API key with `lrm_` prefix, Bearer token) +- API key system with generate/list/revoke, project-scoped permissions (READ_ONLY / READ_WRITE) -### What's broken or missing +### What's done -**3 existing tools call API endpoints that don't exist:** +**Phase 1 (API Key Auth) — Complete:** -| MCP Tool | Expected Endpoint | Issue | -| ---------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------- | -| `search_project` | `GET /projects/:slug/search` | No search controller — entity/lore `?q=` provides partial coverage but no unified search | -| `get_entity_hub` | `GET /projects/:slug/entities/:slug/hub` | No hub aggregation endpoint — standard entity GET includes some connected data | -| `get_storyboard` | `GET /projects/:slug/storyboard?book=&detail=` | No unified storyboard endpoint — API has separate plotline/work/scene endpoints | +- `ApiKey` Prisma model with SHA-256 hashing, permissions enum, expiration, revocation +- API key service (generate, list, revoke, validate) + controller endpoints +- `ApiKeyAuthGuard` accepts both cookie JWTs and Bearer API keys +- API key management UI in project settings -**Auth is incomplete:** No API key system. Users must copy raw JWTs from browser cookies. No project-scoped permissions, no key management UI. +**Phase 2 (Fix Broken Endpoints) — Complete:** + +- `GET /projects/:slug/search` — stub returning empty results (OpenSearch pending for full-text) +- `GET /projects/:slug/entities/:slug` — entity hub aggregation with relationships, lore, timeline, tags +- `GET /projects/:slug/storyboard` — overview with plotlines + works/chapters/scene counts +- `get_entity_hub` tool removed from MCP (entity detail endpoint serves its purpose) + +### What's remaining + +**Read tool coverage is thin:** Only 5 of ~17 useful read tools exist. Missing: project navigation, relationships, timeline/eras, lore articles, tags, plotline/work/scene detail. An AI can't fully explore a world yet. + +**Search is a stub:** The `search_project` tool calls the endpoint but always gets empty results. Needs a real Prisma `contains` implementation across entities, lore, timeline, and scenes. **Write tools bypass review queue:** All mutation tools write directly to the DB. The spec requires all MCP writes to go through `PendingChange` staging. @@ -40,45 +52,72 @@ The MCP server (`apps/mcp/src/index.ts`) is a single-file stdio server with: ## Implementation Order -### Phase 1: API Key Authentication +### Phase 1: API Key Authentication — COMPLETE + +**Goal:** Users can generate project-scoped API keys and use them as Bearer tokens. + +**Delivered:** + +- `ApiKey` Prisma model + migration (SHA-256 hash, `lrm_` prefix, permissions enum) +- API key service: generate, list, revoke, validate with `lastUsedAt` tracking +- Controller: `POST/GET/DELETE /projects/:slug/api-keys` +- `ApiKeyAuthGuard` accepts both cookie JWTs and Bearer API keys +- Management UI in project settings + +### Phase 2: Fix Broken API Endpoints — COMPLETE + +**Goal:** MCP read tools no longer 404. + +**Delivered:** -**Goal:** Users can generate project-scoped API keys and use them as Bearer tokens. Unblocks testing the full remote flow via cloudflared with existing read tools. +- `GET /projects/:slug/search` — stub (returns empty, OpenSearch is long-term) +- `GET /projects/:slug/entities/:slug` — entity detail with full hub data (relationships, lore, timeline, tags) +- `GET /projects/:slug/storyboard` — overview with plotlines + works/chapters/scene counts +- Removed `get_entity_hub` MCP tool (entity detail endpoint covers it) -**Scope:** API-side work in `apps/api/`. +### Phase 3: Complete MCP Read Tools + Search -1. Run Prisma migration for `ApiKey` + `PendingChange` models (already in schema) -2. API key service: generate (bcrypt hash, return plaintext once), list, revoke, validate -3. API key controller: `POST /projects/:slug/api-keys`, `GET /projects/:slug/api-keys`, `DELETE /projects/:slug/api-keys/:id` -4. Update JWT/auth strategy to accept API keys as Bearer tokens, resolving to project + permissions (`READ_ONLY` / `READ_WRITE`) -5. Track `lastUsedAt` on each API key usage -6. API key management UI in project settings (generate, copy, revoke, list with last-used) +**Goal:** Full read coverage — every content type in Loreum is readable via MCP, and search actually works. -**Test gate:** Generate an API key in the UI, configure Claude Desktop with it as `MCP_API_TOKEN`, and successfully call `list_entities` against the production API via cloudflared. +**Scope:** MCP tools in `apps/mcp/`, plus API work for search. -### Phase 2: Fix Broken API Endpoints +#### 3a. Search implementation (`apps/api/`) -**Goal:** The 3 existing MCP tools that currently 404 should work. +The search endpoint is currently a stub returning empty results. Implement basic Prisma `contains` search across all content types: -**Scope:** API-side work in `apps/api/`. The MCP server already has the tool handlers wired up — they just need working endpoints to call. +- Query entities (name, summary, description), lore articles (title, content), timeline events (title, description), scenes (title, content) +- Filter by `types` array (entity, lore, timeline, scene) +- Return unified result format: `{ results: [{ type, slug, name/title, excerpt }], total }` +- Prisma `contains` is sufficient for now (OpenSearch is long-term) -1. **Search endpoint** — `GET /projects/:slug/search?q=&types=&limit=` - - Query entities, lore articles, timeline events, scenes - - Filter by type array - - Return unified result format with type labels - - Can use Prisma `contains` queries for now (OpenSearch is long-term) +#### 3b. New MCP read tools (`apps/mcp/`) -2. **Entity hub endpoint** — `GET /projects/:slug/entities/:slug/hub` - - Return entity with all connected data: relationships (outgoing + incoming), lore articles, timeline events, scene appearances, tags - - Single query with Prisma includes, or parallel queries assembled in the service +All API endpoints already exist. MCP-side only. -3. **Storyboard aggregation endpoint** — `GET /projects/:slug/storyboard?book=&detail=` - - Return `{ plotlines: [...], works: [...] }` in one response - - `book` param filters to a specific work - - `detail=outline` returns structure only (no scene content), `detail=full` includes everything +| Tool | API Endpoint | Notes | +| -------------------- | ---------------------------------------------------- | -------------------------------------------------- | +| `list_projects` | `GET /projects` | List user's projects | +| `get_project` | `GET /projects/:slug` | Project detail (replaces resource-only access) | +| `list_relationships` | `GET /projects/:slug/relationships?entity=` | Relationships, optionally filtered by entity | +| `get_timeline` | `GET /projects/:slug/timeline?entity=&significance=` | Timeline events with optional filters | +| `get_timeline_event` | `GET /projects/:slug/timeline/:id` | Single event detail | +| `list_eras` | `GET /projects/:slug/timeline/eras` | Eras for a project | +| `list_lore_articles` | `GET /projects/:slug/lore?q=&category=&entity=` | Filter lore articles | +| `get_lore_article` | `GET /projects/:slug/lore/:slug` | Single lore article | +| `list_tags` | `GET /projects/:slug/tags` | All tags in a project | +| `get_plotline` | `GET /projects/:slug/storyboard/plotlines/:slug` | Plotline with plot points | +| `get_work` | `GET /projects/:slug/storyboard/works/:slug` | Work with chapters and scene structure | +| `list_scenes` | `GET /projects/:slug/storyboard/scenes?chapterId=` | Scenes in a chapter (the actual narrative content) | -**Test gate:** All 6 existing read tools return real data when called from Claude Desktop. +#### 3c. Quality pass -### Phase 3: Review Queue (API + MCP) +- Improve tool descriptions (clear, specific, no jargon) +- Add response shaping (strip `createdAt`/`updatedAt`/internal IDs where noisy, flatten nesting) +- Fix `api()` error handling (return structured MCP errors instead of throwing) + +**Test gate:** From Claude Desktop, an AI can navigate from projects → entities → relationships → lore → timeline → storyboard scenes without hitting any dead ends. Search returns real results. + +### Phase 4: Review Queue (API + MCP + UI) **Goal:** All MCP write operations stage changes as `PendingChange` records instead of writing directly. Users review and accept/reject from the web UI. @@ -107,7 +146,7 @@ The MCP server (`apps/mcp/src/index.ts`) is a single-file stdio server with: - Return confirmation that the change was staged, not applied - Include `batchId` (generated per MCP session or conversation) -4. Update `api()` helper to return structured MCP errors instead of throwing +4. Update `api()` helper to return structured MCP errors instead of throwing (if not done in Phase 3) #### Web UI work (`apps/web/`) @@ -120,29 +159,7 @@ The MCP server (`apps/mcp/src/index.ts`) is a single-file stdio server with: **Test gate:** From Claude Desktop, create an entity via MCP. Verify it appears in the review queue (not in the entity list). Accept it from the web UI. Verify it now appears in the entity list. -### Phase 4: Expand MCP Read Tools - -**Goal:** Add the remaining read tools that map to existing API endpoints. - -**Scope:** MCP-side only. All endpoints already exist. - -| Tool | API Endpoint | Notes | -| -------------------- | ----------------------------------------------- | -------------------------------------------------- | -| `list_projects` | `GET /projects` | List user's projects | -| `get_project` | `GET /projects/:slug` | Single project detail | -| `get_timeline` | `GET /projects/:slug/timeline` | Timeline events, supports `?entity=&significance=` | -| `list_eras` | `GET /projects/:slug/timeline/eras` | Eras for a project | -| `get_lore_article` | `GET /projects/:slug/lore/:slug` | Single lore article | -| `list_lore_articles` | `GET /projects/:slug/lore?q=&category=&entity=` | Filter lore articles | -| `get_relationships` | `GET /projects/:slug/relationships?entity=` | Relationships, optionally filtered by entity | - -Also in this phase: - -- Improve tool descriptions (clear, specific, no jargon) -- Add response shaping (strip `createdAt`/`updatedAt`/internal IDs, flatten nesting) -- Fix `api()` error handling if not done in Phase 3 - -### Phase 5: Expand MCP Write Tools (blocked on Phase 3) +### Phase 5: Expand MCP Write Tools (blocked on Phase 4) **Goal:** Add the remaining mutation tools, all routing through PendingChange. diff --git a/docs/TODO.md b/docs/TODO.md index 9bc0180..39a062b 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -12,20 +12,13 @@ Tracked tasks for Loreum. Near-term is the next couple weeks, long-term is every - [x] Rename to Loreum - [x] Deploy production at loreum.app (PM2 + Cloudflare Tunnel) - [x] Create open source repo (fresh git history) - https://github.com/Loreum-App/loreum +- [x] MCP Authentication & API Keys (ApiKey model, service, controller, Bearer token auth, management UI) +- [x] Broken MCP Endpoints (search stub, entity hub aggregation, storyboard overview) --- ## Near-Term (Next 2 Weeks) -### MCP Authentication & API Keys - -- [ ] `ApiKey` + `PendingChange` models (already in schema, needs migration) -- [ ] Prisma migration -- [ ] API key service: generate (bcrypt hash, return plaintext once), list, revoke -- [ ] API key controller: `POST /projects/:slug/api-keys`, `GET /projects/:slug/api-keys`, `DELETE /projects/:slug/api-keys/:id` -- [ ] JWT strategy: accept API key as Bearer token, resolve to project + permissions -- [ ] API key management UI in project settings (generate, copy, revoke, list with last-used) - ### MCP Review Queue (Staging Area) - [ ] PendingChange service: create pending change, list by project/status/batch, accept, reject @@ -45,36 +38,49 @@ Tracked tasks for Loreum. Near-term is the next couple weeks, long-term is every - [ ] Sidebar badge showing pending change count - [ ] Notification when new pending changes arrive -### MCP Tools (Planned) +### MCP Read Tools + Search (next priority) + +See [MCP_IMPLEMENTATION_PLAN.md](MCP_IMPLEMENTATION_PLAN.md) Phase 3 for full context. + +**Search** (API work — endpoint exists as stub, needs real implementation): -See [MCP_IMPLEMENTATION_PLAN.md](MCP_IMPLEMENTATION_PLAN.md) for full sequencing and context. +- [ ] Implement Prisma `contains` search across entities, lore, timeline, scenes +- [ ] Return unified result format with type labels and excerpts -**Read tools** (safe to build now — API endpoints exist): +**Read tools** (MCP-side only — all API endpoints exist): -- [ ] `get_timeline` - read tool -- [ ] `get_lore_article` - read tool -- [ ] `list_lore_articles` - read tool -- [ ] `get_relationships` - read tool +- [ ] `list_projects` - list user's projects +- [ ] `get_project` - project detail +- [ ] `list_relationships` - relationships, optionally filtered by entity +- [ ] `get_timeline` - timeline events with filters +- [ ] `get_timeline_event` - single event detail +- [ ] `list_eras` - eras for a project +- [ ] `list_lore_articles` - filter lore articles +- [ ] `get_lore_article` - single lore article +- [ ] `list_tags` - all tags in a project +- [ ] `get_plotline` - plotline with plot points +- [ ] `get_work` - work with chapters and scene structure +- [ ] `list_scenes` - scenes in a chapter (narrative content) -**Write tools** (blocked on Review Queue — do not implement until PendingChange is built): +**Quality pass:** -- [ ] `update_lore_article` - write tool -- [ ] `delete_entity` - write tool -- [ ] `delete_relationship` - write tool -- [ ] `delete_lore_article` - write tool -- [ ] `create_timeline_event` - write tool -- [ ] `update_timeline_event` - write tool -- [ ] `delete_timeline_event` - write tool -- [ ] `create_scene` - write tool -- [ ] `update_scene` - write tool -- [ ] `create_plot_point` - write tool -- [ ] `update_plot_point` - write tool +- [ ] Improve tool descriptions +- [ ] Response shaping (strip noise, flatten nesting) +- [ ] `api()` error handling (structured MCP errors) -### Broken MCP Endpoints (API work, not MCP) +### MCP Write Tools (blocked on Review Queue) -- [ ] `GET /projects/:slug/search` - unified search controller (used by `search_project` tool) -- [ ] `GET /projects/:slug/entities/:slug/hub` - entity hub aggregation (used by `get_entity_hub` tool) -- [ ] `GET /projects/:slug/storyboard?book=&detail=` - unified storyboard (used by `get_storyboard` tool) +- [ ] `update_lore_article` +- [ ] `delete_entity` +- [ ] `delete_relationship` +- [ ] `delete_lore_article` +- [ ] `create_timeline_event` +- [ ] `update_timeline_event` +- [ ] `delete_timeline_event` +- [ ] `create_scene` +- [ ] `update_scene` +- [ ] `create_plot_point` +- [ ] `update_plot_point` ### Global Design & Polish @@ -194,7 +200,7 @@ See [MCP_IMPLEMENTATION_PLAN.md](MCP_IMPLEMENTATION_PLAN.md) for full sequencing ### Phase 6 - Platform - [ ] Offline desktop app - work without internet, sync when reconnected -- [ ] API key generation + bearer auth (see MCP section above) +- [x] API key generation + bearer auth - [ ] REST API documentation page (`/docs/api`) - [ ] Rate limiting per API key - [ ] Webhook support (entity created/updated/deleted events)