Post-merge hardening: expiring download tokens, AI rate limits, streamed chat error handling

kveton bundled a set of post-merge hardening commits that missed a review cycle on the previous branch, pairing them with one new fix for chat stream errors. The result is a PR that tightens several security-adjacent gaps and fixes the most visible user-facing failure mode: a chat that hangs mid-stream when the backend sends an error frame.

securitychat-ui

Download tokens now expire. The token payload gains an exp field (default TTL 7 days, configurable via DOWNLOAD_TOKEN_TTL_SECONDS), and verifyDownload rejects tokens past their expiry. A DOWNLOAD_TOKEN_ALLOW_LEGACY=false escape hatch exists for migrating existing no-expiry links. AI routes pick up a rate limiter: MIKE_AI_RATE_LIMIT_MAX=60 requests per 15-minute window applied to chat POST endpoints, project chat, tabular generate/regenerate/chat, and title generation.

Document access checks on the tabular route get a performance fix. The previous code called ensureDocAccess per document, which checked project access once per row. The new path checks project access once per unique project ID using a projectAccessCache map, then fast-paths documents owned directly by the requesting user.

The workflow column in chat_messages was missing from the schema. Migration 010_chat_message_workflow.sql adds it; without it, writes that included workflow metadata were silently dropping the field or failing in ways that corrupted Matter chat history. The chat and project-chat routes now check insert errors explicitly rather than fire-and-forget.

Document load errors get a sendSafeDocumentError() helper that consolidates the scattered inline error-dispatch logic: demo budget errors go to 402, upload-too-large to 413, known safe request validation messages to 400, everything else to a generic 500 with an opaque error code rather than raw exception text.

The new piece is streamed chat error handling (a2842e05). SSE frames with type === "error" now break the read loop and throw a user-readable error from messageFromStreamError(), which maps demo_budget_exceeded and insufficient_quota to specific messages and falls back to a generic string. Previously these frames were ignored and the chat would hang.

postgresCompat.ts gets a jsonbColumnsByTable registry and a columnParam() method that appends ::jsonb for known jsonb columns on insert and update, fixing incorrect string writes for fields like capabilities, content, workflow, and shared_with.

So what The streamed chat error handling and the expiring download tokens are individually useful regardless of whether you use Case.dev. The jsonb column registry in `postgresCompat.ts` is relevant only if you're using that shim - but it's a good example of the kind of maintenance burden that accumulates when you maintain a homegrown SQL query builder. The tabular access cache optimization is a minor perf fix that only matters at scale.

View this fork on GitHub →

Spotted something wrong? Or know the PR text has fresher detail than the writeup above?

Commits in this thread

6 commits from CaseMark/mikeoss-casedotdev, oldest first. Source extracted verbatim from the harvested git log.

SHA Subject Author Date
3011eb79 Persist workflow metadata for chat messages kveton 2026-05-04 ↗ GitHub
d5539959 Harden document access and request limits kveton 2026-05-04 ↗ GitHub
4e770c9b Normalize document load errors kveton 2026-05-04 ↗ GitHub
a2842e05 Handle streamed chat errors kveton 2026-05-04 ↗ GitHub
e0f2d13d Optimize tabular document access checks kveton 2026-05-04 ↗ GitHub
3aadb6f8 Serialize jsonb values in Postgres compat writes kveton 2026-05-04 ↗ GitHub

Capture this thread into my fork

Download a single Markdown prompt that tells Claude how to port every commit above into your working tree — adapting paths and structure to match your repo. Run it via claude -p < capture-thread-281.md from inside the repo you want the changes in.

⬇ Download capture-thread-281.md