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.
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.
Spotted something wrong? Or know the PR text has fresher detail than the writeup above?