mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 13:24:55 +01:00
fix(server): validate project goal ids exist and belong to the company (#12779)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Projects can link to goals, through the `goalIds` list or the legacy
`goalId` field. The project service writes those links on create and
update.
> - The service never checked the goal ids. A nonexistent id died at the
`projects.goal_id` foreign key as an opaque 500, and the caller got no
actionable feedback — observed live on 2026-09-03, where one caller
retried the same bad id four times.
> - The foreign key also only proves a goal exists, not who owns it. A
goal id from another company linked silently on a multi-company
instance.
> - This pull request asserts every resolved goal id exists under the
caller's company before any write, and rejects with a 422 that names the
unknown ids.
> - The benefit is a clear, actionable client error instead of a 500,
and no cross-company goal links.
## Linked Issues or Issue Description
**What happened?**
`POST /companies/:companyId/projects` with a `goalIds` entry that does
not exist fails with an internal error: `insert or update on table
"projects" violates foreign key constraint
"projects_goal_id_goals_id_fk"`. The caller sees a 500 and retries. A
goal id that exists but belongs to a different company is accepted and
linked.
**Expected behavior**
The request fails fast with a 422 that names the unknown goal id(s).
Goals from other companies are rejected the same way. Valid links behave
exactly as before.
**Steps to reproduce**
1. Create a company and no goals.
2. `POST /companies/:companyId/projects` with `{ "name": "Rocket",
"goalIds": ["<any-uuid>"] }`.
3. Before this change: 500 from the foreign key. After: 422 naming the
id.
**Deployment mode**
Any; observed on an authenticated public deployment.
## What Changed
- `assertGoalsBelongToCompany` in the project service: one query for the
resolved ids scoped to the company; unknown ids produce `unprocessable`
(422) with the ids in the message and details
- called on create (before the project row insert, so no partial writes)
and on update (scoped to the existing project's company); both `goalIds`
and the legacy `goalId` field flow through the same resolution
- new embedded-Postgres test file: valid link, nonexistent id on create
with no partial insert, legacy field, another company's goal on create,
and a foreign-goal update that leaves existing links unchanged
## Verification
- `pnpm vitest run src/__tests__/project-goal-validation.test.ts` — 5
passed
- adjacent suites (`project-icon-persistence`,
`project-shortname-resolution`, `issue-goal-fallback`,
`project-goal-telemetry-routes`, `heartbeat-referenced-projects`,
`projects-list-archived-routes`) — 35 passed
## Risks
- Low risk. One extra indexed select per create/update that carries goal
ids. Requests that previously 500ed now 422; requests that silently
linked a foreign goal now fail — both are corrections, not regressions.
- Existing rows with foreign links (written before this check) are
untouched; only new writes validate.
## Model Used
Claude Fable 5 (claude-fable-5) via Claude Code, extended thinking with
tool use.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above (none found for goal-id validation)
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes (doc
comments; no user-facing docs affected)
- [x] I have considered and documented any risks above
This commit is contained in:
1 parent
2177b85eb5
commit
db4eeb1688
2 files changed
+197
-1
No files matched your search
@@ -28,6 +28,7 @@ import {
|
||||
type PluginManagedProjectDeclaration,
|
||||
type PluginManagedProjectResolution,
|
||||
} from "@paperclipai/shared";
|
||||
import { unprocessable } from "../errors.js";
|
||||
import { listCurrentRuntimeServicesForProjectWorkspaces } from "./workspace-runtime-read-model.js";
|
||||
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
|
||||
import { mergeProjectWorkspaceRuntimeConfig, readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
|
||||
@@ -401,6 +402,31 @@ async function attachListMetrics(
|
||||
}
|
||||
|
||||
/** Sync the project_goals join table for a single project. */
|
||||
/**
|
||||
* Every goal a project links to must exist and belong to the same company.
|
||||
* Without this check a nonexistent id only dies at the projects.goal_id
|
||||
* foreign key — an opaque 500 the caller retries (observed live
|
||||
* 2026-09-03: four identical retries of one bad id) — and a goal from
|
||||
* another company would link silently, because the foreign key proves
|
||||
* existence, not ownership.
|
||||
*/
|
||||
async function assertGoalsBelongToCompany(db: Db, companyId: string, goalIds: string[]): Promise<void> {
|
||||
if (goalIds.length === 0) return;
|
||||
const unique = [...new Set(goalIds)];
|
||||
const found = await db
|
||||
.select({ id: goals.id })
|
||||
.from(goals)
|
||||
.where(and(eq(goals.companyId, companyId), inArray(goals.id, unique)));
|
||||
const foundIds = new Set(found.map((row) => row.id));
|
||||
const unknown = unique.filter((goalId) => !foundIds.has(goalId));
|
||||
if (unknown.length > 0) {
|
||||
throw unprocessable(
|
||||
`Unknown goal id(s) for this company: ${unknown.join(", ")}`,
|
||||
{ unknownGoalIds: unknown },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncGoalLinks(db: Db, projectId: string, companyId: string, goalIds: string[]) {
|
||||
// Delete existing links
|
||||
await db.delete(projectGoals).where(eq(projectGoals.projectId, projectId));
|
||||
@@ -547,6 +573,7 @@ export function projectService(db: Db) {
|
||||
): Promise<ProjectWithGoals> => {
|
||||
const { goalIds: inputGoalIds, ...projectData } = data;
|
||||
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
|
||||
if (ids && ids.length > 0) await assertGoalsBelongToCompany(db, companyId, ids);
|
||||
|
||||
// Note: color is intentionally NOT auto-assigned. New projects default to
|
||||
// `color = null` (neutral gray) unless an explicit color is supplied. See PAP-68.
|
||||
@@ -558,7 +585,11 @@ export function projectService(db: Db) {
|
||||
projectData.name = resolveProjectNameForUniqueShortname(projectData.name, existingProjects);
|
||||
|
||||
// Also write goalId to the legacy column (first goal or null)
|
||||
const legacyGoalId = ids && ids.length > 0 ? ids[0] : projectData.goalId ?? null;
|
||||
// The resolved set is canonical for persistence as well as validation:
|
||||
// falling back to the raw legacy field here would write an id that
|
||||
// skipped validation whenever `goalIds: []` and `goalId` arrive
|
||||
// together (goalIds wins resolution, mirroring the update path).
|
||||
const legacyGoalId = ids?.[0] ?? null;
|
||||
|
||||
const row = await db
|
||||
.insert(projects)
|
||||
@@ -794,6 +825,9 @@ export function projectService(db: Db) {
|
||||
.where(eq(projects.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existingProject) return null;
|
||||
if (ids && ids.length > 0) {
|
||||
await assertGoalsBelongToCompany(db, existingProject.companyId, ids);
|
||||
}
|
||||
|
||||
if (projectData.name !== undefined) {
|
||||
const existingShortname = normalizeProjectUrlKey(existingProject.name);
|
||||
|
||||
Reference in new issue
Block a user