Answer 404 for an id that cannot name a row, rather than 500

Measured against a running server: five of the eleven Motion `:id` routes
answered `500 {"error":"Internal error"}` for an id like `nope`, and the
other six only answered 400 because their body schema happened to be
checked first — a valid body would have reached the same cast.

Nothing was wrong with the not-found handling. That branch was never
reached: every id column is a `uuid`, so Postgres refuses the parameter
with `22P02` several layers below it, and the error is not a
MutationError so it leaves as a 500.

404 rather than 400, because a 400 for a malformed id and a 404 for a
well-formed one tells anyone probing which of their guesses are the right
shape — and this feature already routes "somebody else's private draft"
through the same 404 so that no answer distinguishes the reasons a row is
not yours to see.

Also adds the AGENTS.md §5 393px check for the five Motion routes, which
scripts/screenshots.mjs does not photograph. All five measure zero
horizontal overflow at 393 and 1440, light and dark.

The same 500 is reachable on /api/accounts/:id and /api/contracts/:id,
which predates this branch and is left alone here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 18:41:28 -07:00
parent 7a6852e33a
commit 376ef3d597
4 changed files with 212 additions and 2 deletions
+18
View File
@@ -77,6 +77,22 @@ import { MutationError } from '../lib/mutation';
export type MotionTransaction = Parameters<Parameters<Database['transaction']>[0]>[0];
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* Whether a string can name a row at all.
*
* Every id here is a `uuid` column, so a lookup by `nope` never reaches the
* "no such row" branch: Postgres refuses the cast with `22P02` and the request
* leaves as a 500. The reads below therefore answer null for a malformed id,
* which the routes already turn into the 404 an unknown id gets — the same
* answer a private template gives, which is the point of routing both through
* one branch. `requiredId` in `routes/motion.ts` does the same for the writes.
*/
function isUuid(id: string): boolean {
return UUID.test(id);
}
/**
* The subset of a `Principal` a motion read is allowed to see. A service that
* took the whole principal would be one refactor away from consulting teams or
@@ -519,6 +535,7 @@ export class MotionService {
* produces, so a private title cannot be confirmed by probing for one.
*/
async template(viewer: MotionViewer, id: string): Promise<MotionTemplateDetailView | null> {
if (!isUuid(id)) return null;
const [row] = await this.db
.select()
.from(motionTemplates)
@@ -544,6 +561,7 @@ export class MotionService {
}
async engagement(viewer: MotionViewer, id: string): Promise<MotionEngagementDetail | null> {
if (!isUuid(id)) return null;
const [summary] = await this.engagementSummaries(eq(engagements.id, id), 1);
if (!summary) return null;