Skip to content

Make a cross-workspace reference unrepresentable - #23

Merged
Avik-creator merged 4 commits into
mainfrom
fix/reference-integrity-constraints
Aug 21, 2026
Merged

Avik-creator merged 4 commits into
mainfrom
fix/reference-integrity-constraints

Conversation

@Avik-creator

Copy link
Copy Markdown
Owner

The durable half of #22. That PR stopped the API accepting a bad projectId; this stops the database storing one, and clears the rows written before the guard existed.

What changed

Composite foreign keys on (userId, projectId) and (userId, sessionId) for memories, sessions, and handoffs. Proof it holds against something that never touches the application:

insert into memories(id,"userId","projectId",...) values ('m_evil','u_b','p_a',...)

ERROR: insert or update on table "memories" violates foreign key constraint "memories_project_fk"
DETAIL: Key (userId, projectId)=(u_b, p_a) is not present in table "projects".

MATCH SIMPLE leaves a null reference unconstrained, so "filed nowhere" stays legal — verified, along with the ordinary same-workspace case.

ON DELETE is no action, on purpose

deleteProject already refuses while any record still references the project. So the constraint agrees with the application rather than changing it — it is a backstop, not new behaviour. I confirmed the API still answers 409 and not a 500, then 200 once the project is emptied, and added a test, because the foreign key makes that check load-bearing: if deleteProject ever stopped checking first, the failure would surface as a 500.

Sessions are never hard-deleted anywhere (complete() sets a status; maintenance prunes infrastructure rows only), so the session references cannot dangle either.

The migration needed hand-ordering

drizzle-kit emitted the constraints before the unique indexes they target, which Postgres rejects outright. Reordered to indexes → cleanup → constraints.

The cleanup is required: the constraints cannot be added while a single bad row remains. It sets offending references to null rather than deleting rows — the row is the user's content, only its filing is wrong.

Rehearsed on the path production will take

Not just a fresh database. I built one at migration 0012, inserted the exact rows the old code allowed, then migrated:

                     before   after
memories.projectId      2       0
memories.sessionId      1       0
sessions.projectId      1       0
handoffs.projectId      1       0

Every memory survived, with only the bad reference cleared, and the one legitimate reference left intact:

m_foreign | (null) | filed into A's project
m_fsess   | (null) | points at A's session
m_ghost   | (null) | filed nowhere real
m_null    | (null) | no project at all
m_ok      | p_b    | legit          <- untouched

Also verified from zero: all five constraints present on a database built from nothing.

49/49 tests pass against the constrained database, drizzle-kit generate reports no drift, and both commits typecheck standalone.

One thing to watch on deploy

This is the first migration to run through the auto-migrate added in #13, and the first that rewrites existing rows. If it fails the build fails and the current version keeps serving, which is the behaviour we want — but it is worth watching the deploy rather than assuming.

Composite foreign keys on (userId, projectId) and (userId, sessionId) for
memories, sessions, and handoffs. The API already refuses these, but only at
the edge; nothing stopped a future code path, a script, or a migration writing
one. MATCH SIMPLE leaves a null reference unconstrained, so "filed nowhere"
stays legal.

ON DELETE is no action deliberately: deleteProject already refuses while any
record still references the project, so the constraint agrees with the
application rather than changing what it does.

drizzle-kit ordered the constraints before the unique indexes they target,
which Postgres rejects, so the migration is hand-ordered: indexes, then the
cleanup, then the constraints. The cleanup nulls references that resolve to
nothing or to another workspace, because the constraints cannot be added while
any remain. Nulled rather than deleted: the row is the user's content and only
its filing is wrong.
The foreign key makes this behaviour load-bearing. If deleteProject stopped
checking first, the delete would surface as a 500 rather than a conflict.
@vercel

vercel Bot commented Aug 21, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
personal-task-dashboard Ready Ready Preview, v0 Aug 21, 2026 12:24pm

shadcn is a scaffolding CLI with zero imports, and it sat in dependencies, so
its whole tree shipped. @modelcontextprotocol/server also had zero imports;
the MCP route uses mcp-handler. Moving one and dropping the other takes the
production tree from 28 known vulnerabilities to 2.

The remaining nanoid advisory is a patch release away, so it is pinned. The
last one is an esbuild dev-server issue reachable only through build tooling,
which does not run in production; forcing esbuild across three major versions
in the tree would risk more than it fixes.
…cret in constant time

Every other header was set, so the policy was an omission rather than a
decision. Next emits inline bootstrap scripts with no nonce, so 'unsafe-inline'
is unavoidable without middleware; the value is in closing external origins,
object-src, base-uri and form-action. Verified in a real browser: the landing,
sign-in, sign-up and signed-in dashboard pages all render and hydrate with no
violations.

CRON_SECRET was compared with !==, which is the one place a secret was matched
byte by byte. Negligible over HTTP, but timingSafeEqual costs nothing.
@Avik-creator

Copy link
Copy Markdown
Owner Author

Pushed the security fixes onto this branch as requested, so #23 now carries both the reference constraints and the hardening.

Production dependency tree: 28 vulnerabilities → 1

before:  28 vulnerabilities  (14 high, 12 moderate, 2 low)
after:    1 vulnerability    (1 moderate)

shadcn is a scaffolding CLI with zero imports that sat in dependencies, so its entire tree shipped to production — 20 of the 28 advisory paths traced to it. Moved to devDependencies. @modelcontextprotocol/server also had zero imports (the MCP route uses mcp-handler) and is removed outright.

That left two. nanoid was a patch release away from the fix, so it is pinned via the existing overrides. The last one is an esbuild dev-server advisory reachable only through build tooling, which does not run in production — forcing esbuild across the three major versions present in the tree would risk more than it fixes, so it stays and is noted here deliberately rather than silently.

Content Security Policy

Every other security header was already set, so this read as an omission rather than a decision.

Next emits inline bootstrap scripts with no nonce, so 'unsafe-inline' for scripts is unavoidable without adding middleware. The value is in what it does close: no external script or connect origins, object-src 'none', base-uri 'self', form-action 'self'. 'unsafe-eval' is development-only, for react-refresh.

Verified in a real browser rather than assumed. Headless Brave over CDP, collecting Log.entryAdded, on the landing, sign-in, sign-up and signed-in dashboard pages:

/           title="Mindstate — Private agent memory"   CSP violations: 0
/sign-in                                               CSP violations: 0
/sign-up                                               CSP violations: 0
/dashboard  fully hydrated, signed in                  CSP violations: 0

The dashboard check sets the real session cookie and confirms it landed on /dashboard rather than bouncing to sign-in, so the JS-heaviest page is genuinely covered.

Constant-time cron secret

CRON_SECRET was compared with !== — the one place in the codebase a secret was matched byte by byte. Negligible over HTTP where jitter dominates, but timingSafeEqual costs nothing.

No test covered the accepting path, which is exactly what this change touches, so I checked all four cases including same-length-but-wrong, which is the one that actually exercises the comparison:

correct secret       -> 200
wrong secret         -> 401
right length, wrong  -> 401
no header            -> 401

49/49 tests pass, and all four commits typecheck standalone.

Still not audited

The unauthenticated flows — /api/v1/workspace-claims, /api/v1/agents/bootstrap, /claim/[token] — remain unexamined. Token replay, expiry, and single-use enforcement are untested, and those are the endpoints reachable without any credential. Worth a pass of its own.

@Avik-creator
Avik-creator merged commit 6a7cb94 into main Aug 21, 2026
4 checks passed
@Avik-creator
Avik-creator deleted the fix/reference-integrity-constraints branch August 21, 2026 12:25

This branch was successfully deployed

1 active deployment
Preview — 125c9d5a Deployed Aug 21, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants