diff --git a/docs/api/routines.md b/docs/api/routines.md index 6ac4a2d5b49..729efcdf493 100644 --- a/docs/api/routines.md +++ b/docs/api/routines.md @@ -21,6 +21,13 @@ GET /api/routines/{routineId} Returns routine details including triggers. +Routine and trigger resource IDs must be complete UUIDs from the API response. +Short prefixes and malformed IDs return the same `404` as a missing resource. +UUID lookup accepts PostgreSQL's existing input forms, including uppercase, +compact and braced UUIDs; it does not trim whitespace or resolve prefixes. +Company access and routine-assignee permissions still apply. Public trigger +IDs in webhook URLs are separate opaque identifiers, not resource UUIDs. + ## Create Routine ``` diff --git a/server/src/__tests__/routines-e2e.test.ts b/server/src/__tests__/routines-e2e.test.ts index 1f6742be74f..42e7ef0b465 100644 --- a/server/src/__tests__/routines-e2e.test.ts +++ b/server/src/__tests__/routines-e2e.test.ts @@ -238,6 +238,119 @@ describeEmbeddedPostgres("routine routes end-to-end", () => { return { companyId, agentId, projectId, userId }; } + function routineResourceRequests(app: express.Express, routineId: string, triggerId = routineId) { + const path = `/api/routines/${encodeURIComponent(routineId)}`; + const triggerPath = `/api/routine-triggers/${encodeURIComponent(triggerId)}`; + const nestedId = "12345678-1234-4234-8234-123456789abc"; + return [ + request(app).get(path), + request(app).get(`${path}/runs`), + request(app).get(`${path}/revisions`), + request(app).get(`${path}/description/annotations`), + request(app).get(`${path}/description/annotations/${nestedId}`), + request(app).post(`${path}/description/annotations`).send({ + baseRevisionId: nestedId, baseRevisionNumber: 1, body: "Review this text", + selector: { + quote: { exact: "text" }, + position: { normalizedStart: 0, normalizedEnd: 4, markdownStart: 0, markdownEnd: 4 }, + }, + }), + request(app).post(`${path}/description/annotations/${nestedId}/comments`).send({ body: "Review" }), + request(app).patch(`${path}/description/annotations/${nestedId}`).send({ status: "resolved" }), + request(app).patch(path).send({ title: "Updated routine" }), + request(app).post(`${path}/revisions/${nestedId}/restore`).send({}), + request(app).post(`${path}/triggers`).send({ kind: "api" }), + request(app).post(`${path}/run`).send({}), + request(app).patch(triggerPath).send({ enabled: false }), + request(app).delete(triggerPath), + request(app).post(`${triggerPath}/rotate-secret`).send({}), + ]; + } + + it("returns not found without queries for malformed routine and trigger IDs on every resource route", async () => { + const { companyId, agentId, userId } = await seedFixture(); + const actors = [ + { type: "board", userId, source: "session", companyIds: [companyId] }, + { type: "agent", agentId, companyId }, + { type: "none" }, + ]; + const fullId = "12345678-1234-4234-8234-123456789abc"; + for (const actor of actors) { + const app = await createApp(actor); + const spies = [ + vi.spyOn(db, "select"), vi.spyOn(db, "insert"), + vi.spyOn(db, "update"), vi.spyOn(db, "delete"), + ]; + try { + for (const id of [fullId.slice(0, 8), "not-a-uuid", ` ${fullId}`, `${fullId}\n`, `{${fullId}\n}`]) { + for (const pending of routineResourceRequests(app, id)) { + const response = await pending; + expect(response.status, JSON.stringify(response.body)).toBe(404); + expect(response.body).toEqual({ error: response.req.path.includes("/routine-triggers/") + ? "Routine trigger not found" : "Routine not found" }); + } + } + const invalidBody = await request(app).patch("/api/routines/not-a-uuid").send({ title: 42 }); + expect(invalidBody.status).toBe(400); + for (const spy of spies) expect(spy).not.toHaveBeenCalled(); + } finally { + for (const spy of spies) spy.mockRestore(); + } + } + }, 20_000); + + it("keeps missing and cross-company routine resources indistinguishable", async () => { + const { companyId, agentId, userId } = await seedFixture(); + const routineId = randomUUID(); + const triggerId = randomUUID(); + await db.insert(routines).values({ id: routineId, companyId, assigneeAgentId: agentId, title: "Private routine" }); + await db.insert(routineTriggers).values({ id: triggerId, companyId, routineId, kind: "api" }); + for (const actor of [ + { type: "board", userId, source: "session", isInstanceAdmin: true, companyIds: [randomUUID()] }, + { type: "agent", agentId: randomUUID(), companyId: randomUUID() }, + { type: "none" }, + ]) { + const app = await createApp(actor); + for (const [id, tid] of [[routineId, triggerId], [routineId.toUpperCase(), `{${triggerId}}`], [randomUUID(), randomUUID()]]) { + for (const pending of routineResourceRequests(app, id!, tid!)) { + const response = await pending; + expect(response.status, JSON.stringify(response.body)).toBe(404); + expect(response.body).toEqual({ error: response.req.path.includes("/routine-triggers/") + ? "Routine trigger not found" : "Routine not found" }); + } + } + } + expect(await db.select().from(routines)).toMatchObject([{ id: routineId, title: "Private routine" }]); + expect(await db.select().from(routineTriggers)).toMatchObject([{ id: triggerId, enabled: true }]); + expect(await db.select().from(activityLog)).toEqual([]); + }, 20_000); + + it("preserves UUID routine reads and assignee-only mutations", async () => { + const { companyId, agentId, userId } = await seedFixture(); + const routineId = randomUUID(); + const triggerId = randomUUID(); + await db.insert(routines).values({ id: routineId, companyId, assigneeAgentId: agentId, title: "Private routine" }); + await db.insert(routineTriggers).values({ id: triggerId, companyId, routineId, kind: "api" }); + const owner = await createApp({ type: "agent", agentId, companyId }); + const otherAgent = await createApp({ type: "agent", agentId: randomUUID(), companyId }); + const board = await createApp({ type: "board", userId, source: "session", companyIds: [companyId] }); + for (const app of [owner, otherAgent, board]) { + const response = await request(app).get(`/api/routines/${routineId.toUpperCase()}`); + expect(response.status).toBe(200); + expect(response.body.id).toBe(routineId); + } + for (const [index, pending] of routineResourceRequests(otherAgent, routineId, triggerId).entries()) { + const response = await pending; + expect(response.status, JSON.stringify(response.body)).toBe(index < 2 ? 200 : 403); + } + const updated = await request(owner).patch(`/api/routines/${routineId}`).send({ title: "Owner update" }); + expect(updated.status, JSON.stringify(updated.body)).toBe(200); + expect(updated.body.title).toBe("Owner update"); + const changedTrigger = await request(board).patch(`/api/routine-triggers/${triggerId}`).send({ enabled: false }); + expect(changedTrigger.status, JSON.stringify(changedTrigger.body)).toBe(200); + expect(changedTrigger.body.enabled).toBe(false); + }, 20_000); + it.each(["bearer", "hmac_sha256", "github_hmac", "none"] as const)( "authenticates %s HTTP deliveries, persists payloads, and enforces trigger lifecycle", async (signingMode) => { diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index bc4badd189e..9d4949e30ab 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -246,6 +246,63 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { .then((rows) => rows[0]!); } + it("rejects malformed routine and trigger IDs before any query", async () => { + const { routine, svc } = await seedFixture(); + const malformed = [ + "", routine.id.slice(0, 8), "not-a-uuid", `${routine.id} `, ` ${routine.id}`, + `${routine.id}\n`, `{${routine.id}`, `${routine.id}}`, `{${routine.id}\n}`, + `${routine.id}-`, routine.id.replace("-", "--"), `g${routine.id.slice(1)}`, + `${routine.id}\0`, `é${routine.id.slice(1)}`, + `${routine.id.slice(0, 3)}-${routine.id.slice(3)}`, + ]; + const select = vi.spyOn(db, "select"); + try { + for (const id of malformed) { + await expect(svc.get(id)).resolves.toBeNull(); + await expect(svc.getDetail(id)).resolves.toBeNull(); + await expect(svc.getTrigger(id)).resolves.toBeNull(); + } + expect(select).not.toHaveBeenCalled(); + } finally { + select.mockRestore(); + } + }); + + it("preserves lookup failures for valid routine and trigger IDs", async () => { + const { routine, svc } = await seedFixture(); + const failure = new Error("Database unavailable"); + const select = vi.spyOn(db, "select").mockImplementation(() => { throw failure; }); + try { + await expect(svc.get(routine.id)).rejects.toBe(failure); + await expect(svc.getDetail(routine.id)).rejects.toBe(failure); + await expect(svc.getTrigger(routine.id)).rejects.toBe(failure); + } finally { + select.mockRestore(); + } + }); + + it.each([ + "12345678-1234-4234-8234-123456789abc", + "12345678-1234-7234-8234-123456789abc", + "00000000-0000-0000-0000-000000000000", + "ffffffff-ffff-ffff-ffff-ffffffffffff", + ])("preserves PostgreSQL UUID lookup forms for %s", async (id) => { + const { companyId, svc } = await seedFixture(); + await db.insert(routines).values({ id, companyId, title: "UUID lookup fixture" }); + await db.insert(routineTriggers).values({ id, companyId, routineId: id, kind: "api" }); + const compact = id.replaceAll("-", ""); + const forms = [ + id, id.toUpperCase(), `{${id}}`, compact, + compact.match(/.{4}/g)!.join("-"), + `{${compact.slice(0, 8)}-${compact.slice(8, 16)}-${compact.slice(16)}}`, + ]; + for (const input of forms) { + await expect(svc.get(input)).resolves.toMatchObject({ id, companyId }); + await expect(svc.getDetail(input)).resolves.toMatchObject({ id, companyId }); + await expect(svc.getTrigger(input)).resolves.toMatchObject({ id, companyId, routineId: id }); + } + }); + it("clears transient routine run failures when execution issues resume", async () => { const { companyId, issueSvc, routine, svc } = await seedFixture(); const runId = randomUUID(); diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index c681e73b0bc..98bffd4f184 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -665,6 +665,14 @@ function mapRoutineDescriptionDocument(row: { }; } +// Match PostgreSQL UUID input, including compact/braced forms and any version. +// Do not trim or resolve prefixes: the exact input must be safe for a UUID query. +function isRoutineUuidInput(id: string): boolean { + if (/\s/.test(id)) return false; + const value = id.startsWith("{") && id.endsWith("}") ? id.slice(1, -1) : id; + return /^(?:[0-9a-f]{4}-?){7}[0-9a-f]{4}$/i.test(value); +} + export function routineService( db: Db, deps: { @@ -682,6 +690,7 @@ export function routineService( }); async function getRoutineById(id: string) { + if (!isRoutineUuidInput(id)) return null; return db .select() .from(routines) @@ -766,6 +775,7 @@ export function routineService( } async function getTriggerById(id: string) { + if (!isRoutineUuidInput(id)) return null; return db .select() .from(routineTriggers)