Skip to content

URL: https://mkdocs.justinsforge.com/memory/handoffs/creatortrack-overnight-audit-2026-07-09/

CreatorTrack Overnight Audit, 2026-07-09/10

Where the work lives

Branch audit/overnight-0709, worktree /home/justinwieb/forge-suite-wt-manual/audit-overnight-0709. Two commits sit on top of main, nothing pushed, nothing merged, main checkout untouched:

5cc67c5 audit: round 2 confirmed fixes
1c0517e audit: round 1 confirmed fixes

The workflow ran as a fanout job (wf_369c8431-8c3): a wave of finder agents scans a zone, a verifier agent confirms or rejects each finding as a real reproducible bug, a fixer agent patches the confirmed ones inside its owned files, and a gate agent typechecks/lints/commits. Nothing gets committed without gate sign-off.

Scope

All API routes under app/api/** plus their backing lib/*.ts modules, split into 12 zones (attachments/files, collection/database, chat, tasks, projects, finance (jobs/recurring/icons/categorize), calendar sync (push+pull), google-tasks-sync, knowledge, crm/campaigns, workspace-transfer/onboarding, presence/notifications/kit-tokens/misc). Five audit dimensions per zone:

  1. Correctness (logic bugs, dead branches, wrong assumptions)
  2. Workspace-scoping / auth (cross-tenant leakage, missing RLS/membership checks)
  3. Races (unlocked read-modify-write, concurrent double-submit)
  4. Fail-loud (swallowed errors, silent-success on failure)
  5. kind:"project" pattern (rows that should carry the Projects-app discriminator but don't, breaking Kind-filtered queries)

Round 1: confirmed fixes (commit 1c0517e)

34 files, +934/-291. Gate: PASSED, committed as 1c0517e0b162f3e5128f91b936466b362c1698ec. Typecheck: 6 pre-existing errors, all in unmodified marketing/layout files (missing generated PNG-module/LayoutProps types, needs a real next build), zero in the 32 touched TS files. Lint: 462 pre-existing repo-wide problems (baseline), zero in the modified files.

Representative confirmed bugs by dimension:

  • Fail-loud: lib/campaigns-jobs.ts catch swallowed job failures by letting a recordRun DB error replace the original error and skip notifyFailure; now notifyFailure fires first, recordRun gets its own try/catch with a Why swallowed comment, original error always rethrown.
  • Workspace-scoping/auth: lib/chat.ts (many tool handlers, update_task/append_page/insert_block/rename_page/set_page_icon/delete_page/insert_entity_link) were stamping recordAiPageEdit/write calls with the chat session's workspace id instead of the target node's own workspace id; now resolved off Number(node.workspaceId) per the already-confirmed edit_block/delete_block pattern. Also delete_task (forever) now validates via getTaskById and rejects cross-workspace ids before calling deleteNodeForever.
  • kind:"project" pattern: app/api/tasks/projects/[id]/uncheck/route.ts:line swapped getProjectById (drops list rows via the Kind filter) for a direct row/workspace resolution so list-type project rows resolve instead of 404ing.
  • Workspace-scoping/auth: app/api/finance/categorize/rules/route.ts GET/POST were missing workspace_id = <activeWs> on the counts query and both halves of the rules union/delete/toggle; now scoped via resolveActiveFinanceWorkspace.
  • Races: lib/calendar-sync-push.ts pushOne had no lock; overlapping fire-and-forget pushes created duplicate orphaned Google events. Now wraps in one asUser tx that takes pg_advisory_xact_lock(hashtext('cal-sync:<ws>:<kind>'), hashtext(ctId)) before read/create/patch/upsert.
  • Correctness: lib/calendar-sync-push.ts all-day exclusive end date was computed as start+24h instead of advancing the date string, breaking on DST fall-back (empty range 400).
  • Workspace-scoping/auth: lib/clips.ts (clipFootage/clipPoster/keyframeImage) gained an optional pinWorkspaceId, wired through the Demo wrappers, so demo-owner elevation can no longer reach other workspaces' rows.
  • Correctness: app/api/tasks/settings/route.ts was missing "projectRel" from its local GROUP_BYS whitelist, silently dropping that groupBy patch.
  • Correctness/fail-loud: app/api/kit/tokens/route.ts token-save regex had no theme awareness and no g flag, always rewriting only the dark :root declaration even when the caller was editing light mode, and reporting {ok:true} regardless. Now theme-aware, 422s instead of silently corrupting on ambiguous dual-declared tokens.
  • Races: app/api/chat/route.ts schedulePersist's queued re-fire in .finally could race and clobber the terminal done/error write; added a finalized flag plus a drainPersist() helper awaited before both terminal writes.
  • Fail-loud: app/api/footage/[id]/play/route.ts (and the sibling review-token route fixed in round 2) decided stream success/failure off stdout "end" instead of ffmpeg's exit code, so a crashed transcode still returned a clean 200 with a truncated file; now decides on close(code) with a bounded 4KB stderr tail surfaced on failure.
  • Correctness: lib/workspace-transfer.ts transferProject didn't pass kind: "project" to createProject, orphaning the destination row past the Kind filter; requireMembership didn't reject archived/trashed workspaces as transfer targets.
  • Workspace-scoping/auth: lib/finance-recurring.ts migrated wholesale from asUser to asFinanceUser with active-workspace scoping added to every finance.* query (reads, edits, deletes, addRecurring's account/history/series lookups); cross-workspace recId/merchantKey/accountId rewrites are no longer possible.
  • Correctness: lib/google-contacts-sync.ts persisted the People nextSyncToken even when the pull loop had per-item errors, silently dropping failed contacts forever; token now held back on any failure so they re-pull next run.
  • Workspace-scoping/auth: lib/finance-jobs.ts ownerWorkspaceId() (lowest-id membership, archived included) replaced with resolveActiveFinanceWorkspace(me), the same resolver the finance read layer uses, so Sync/rescan/initial-pull target the workspace the caller is actually viewing.
  • kind:"project" pattern / correctness: db/migrations/20260710T134604_..._calendar_wsgoogle_checks_and_per_user_sync_keys.sql widens source_account CHECKs to accept wsgoogle:% and re-keys both unique constraints on user_id (not applied to any DB; deploy does not auto-run migrations).
  • Workspace-scoping/auth: lib/finance-jobs/holdings.ts gated the global Fidelity inbox scan/import/archive to only the CAPTURE_EMAIL finance owner's first-membership workspace; other fan-out workspaces get price/constituent refresh only, killing a cross-tenant CSV import + file-loss path.
  • Races: lib/google-tasks-sync.ts inbound sync advanced its cursor even when an item failed to apply, permanently skipping it; now an inboundFailed flag holds the cursor back on any inbound failure.
  • Fail-loud: lib/attachments.ts deleteAttachment used to unlink the shared content-hash blob on metadata delete, but bytes are shared across all workspaces via app.node_attachments/app.files with no refcount visibility under RLS; unlink removed entirely (orphans left to offline GC), metadata delete unchanged.

Round 2: confirmed fixes (commit 5cc67c5)

23 files, +588/-165. Gate: PASSED, committed as 5cc67c51e497788045f0370a62111ead9fcca676. Typecheck: same 6 pre-existing errors, none in the 23 touched files. Lint: 2 findings inside changed files, both independently verified as pre-existing in base commit 37d2a87 (identical lines, outside the audit's diff hunks): lib/projects.ts:417 prefer-const and lib/tasks.ts:11 unused import; neither introduced by the fixers.

Representative confirmed bugs by dimension:

  • Correctness/kind:"project" pattern: app/api/collection/prop/[id]/duplicate/route.ts trusted the body's workspaceId/nodeId while copying cell values by source propId alone, letting a propId from another database graft a foreign column and cross-workspace orphan rows into the target db. Now requires the source prop to actually live in the claimed database first.
  • Workspace-scoping/auth: app/api/collection/prop/route.ts accepted a client-supplied workspaceId that could be stale or point at a different workspace the caller also belonged to, minting props whose workspace_id disagreed with their node's (invisible to RLS reads). Now resolves workspaceId from the database node itself.
  • Workspace-scoping/auth: app/api/collection/relation/route.ts let any member link arbitrary visible rows through any propId, corrupting rollups and mis-scoping the relation row under a caller-picked workspaceId. Now validates the propId is a relation prop on fromRow's parent collection, toRow lives in the prop's configured target db, and stamps the relation with fromRow's own workspace.
  • Correctness/fail-loud: app/api/files/upload/route.ts Number(null)/Number("") both coerce to 0, slipping past the integer check, writing the blob, then dying as a 500 on the RLS insert. Raw form field now validated before coercion (rejects missing/empty/non-integer/<=0).
  • Correctness: app/api/fitness/detail/exercise/[name]/route.ts double-decodeURIComponent'd a route param Next.js already decodes, throwing on titles with a literal % (e.g. "Squat 80%").
  • kind:"project" pattern: app/api/projects/[id]/route.ts PATCH/DELETE now call getProjectById(me, rowId, { includeArchived: true }) so restore ({archived:false}) finds the archived row instead of 404ing, and DELETE 404s upfront instead of archiving a task/CRM/list row then 500ing after the archive already committed.
  • Fail-loud: app/api/review/[token]/play/route.ts same ffmpeg exit-code fix as round 1's footage route (mirrors it directly), so guest reviewers get a stream error instead of a silently truncated clip.
  • kind:"project" pattern / correctness: app/api/tasks/[id]/route.ts gained an isTaskRow guard resolving the row against the workspace's actual tasks.db before any write; previously updateTask/archiveTask would mutate any database_row (e.g. a projects-db row) and only the post-write re-read would throw, leaving the wrong row mutated under a 500.
  • Correctness: app/api/projects/route.ts template branch called createProjectFromTemplate whose option type silently drops priority/tags/assignedTo; now composes createProject (full payload) then applyTemplateToProject in-route.
  • Correctness: app/api/kit/tokens/route.ts (round-2 half of the round-1 fix) accepts {theme}, computes light-block bounds per token, 422s cleanly on ambiguous dual-declared tokens with no theme supplied.
  • Correctness: lib/workspace-transfer.ts copyTaskInto was dropping dueEnd and linking via the legacy list projectId instead of projectRelId; transferProject task-matching now mirrors lib/tasks.ts's own matching logic and carries icon/recurring/priority/tags from source.
  • Correctness: lib/knowledge-readlater.ts decodeEntities numeric-entity replacer had no bounds check, throwing an uncaught RangeError on malformed HTML entities (also covers lib/news-article.ts via the exported extractReadable); now bounds-checks and falls back to U+FFFD/empty string.

Fenced-off scope, owned by other live sessions

Not touched, not audited in this pass:

  • Links / Link-In-Bio module
  • Ideas-planner apps
  • Admin analytics
  • Performance work

These are all live under other concurrent sessions per MEMORY.md; the audit fenced them off to avoid stepping on in-flight work.

Round 3: stopped mid-find, by Justin's call

Round 3 started (7 finder agents dispatched, all returned) but was stopped before verification for diminishing returns against daytime usage; no verifier, fixer, or gate ran. Nothing here is confirmed real, nothing is committed, but listed below so no lead is silently dropped.

Unverified round-3 leads (raw finder output, NOT verified)

  • app/api/kit/tokens/route.ts:94 [correctness] Token save 422s for every dual-declared token because the sole caller never sends the required theme param (follow-on gap from the round-1/2 fix)
  • app/api/workspace/[id]/invites/[inviteId]/route.ts:43 [workspace-scoping/auth] Invite-nudge endpoint has no cooldown: unbounded email-spam relay
  • lib/workspace-transfer.ts:134 [races] transferTask "move" has no concurrency/idempotency guard: double-submit duplicates the task
  • lib/workspace-transfer.ts:158 [correctness] transferProject multi-step copy is non-transactional: mid-loop failure strands a partial copy, retry duplicates
  • lib/onboarding.ts:67 [races] patchOnboarding read-modify-write with no lock: concurrent step patches silently drop flags
  • app/api/workspace/icon/route.ts:20 [fail-loud] bare catch discards the error entirely, no log, no rethrow
  • lib/calendar.ts:491 [correctness] Google-to-Google calendar move is delete-then-create with no failure recovery: event destroyed if create fails
  • lib/google-tasks-sync.ts:484 [correctness] Outbound sweep's listTasks(view:'all') excludes done/archived tasks, making archived-cleanup dead code
  • lib/calendar-sync-push.ts:479 [correctness] Same view:'all' filtering bug blocks removal of done/archived task mirrors
  • lib/calendar.ts:356 [correctness] Creating/promoting a recurring event caches the recurrence MASTER row that sync never reconciles, duplicating the first occurrence
  • lib/google-tasks-settings.ts:93 [races] setGtasksState read-modify-write across two transactions with no lock: cursor persist can race an account switch
  • app/api/tasks/projects/route.ts:49 [correctness] List/project creation non-atomic: createRow commits before updateProject, phantom Kind-less row on failure
  • lib/projects.ts:363 [correctness] createProject orphans a Kind-less row when the follow-up updateProject/reload throws
  • app/api/projects/route.ts:76 [correctness] Template-based project creation non-atomic/non-idempotent: project persists with partial tasks on template-apply failure
  • lib/projects.ts:538 [races] addTaskField duplicate-name guard is an unlocked check-then-insert
  • app/api/ai/generate/route.ts:101 [correctness] AI writer model override validated against the legacy provider, not the one the request actually runs on
  • lib/chat.ts:2471 [workspace-scoping/auth] replace_in_page/set_page_banner still stamp activity/snapshot rows with the chat workspace id, not the node's own (same class as round-1 fix, these two were explicitly left untouched then as "not in the confirmed finding")
  • app/api/chat/route.ts:302 [races] No per-conversation in-flight guard: a second POST mid-turn forks history and duplicates tool mutations
  • lib/chat.ts:1915 [correctness] get_habits computes todayDone against the full period goal, always reporting weekly-frequency habits not-done
  • lib/chat.ts:2539 [correctness] delete_page trashes any node type with no type check, including system app nodes and databases
  • lib/notifications.ts:330 [workspace-scoping/auth] notifyMentions dispatches to attacker-supplied user ids with no membership check: cross-tenant notification injection
  • app/api/capture/route.ts:150 [correctness] Capture "task" verb stores the bot-supplied reminder string verbatim; lexicographic compare fires naive-local reminders early or never
  • app/api/files/[id]/route.ts:106 [workspace-scoping/auth] Stored XSS: uploaded files served inline with client-supplied mime, no nosniff
  • app/api/attachments/[id]/route.ts:38 [workspace-scoping/auth] Same stored-XSS vector on the attachments download route
  • lib/media-search.ts:194 [correctness] searchMediaCached's in-process cache grows unbounded, never evicted

Rejected findings (rough counts)

Verifier votes (not 1:1 with finding count; multiple verifier passes per finding in some rounds): round 1, 119 confirmed-real votes vs 25 rejected; round 2, 93 confirmed-real vs 12 rejected. Round 3 has zero verifier votes (stopped before verification stage).

Review commands (do not execute; for Justin's review only)

# Review the full round 1 + round 2 diff against main
git -C /home/justinwieb/forge-suite-wt-manual/audit-overnight-0709 diff main

# Review each round's commit individually
git -C /home/justinwieb/forge-suite-wt-manual/audit-overnight-0709 show 1c0517e
git -C /home/justinwieb/forge-suite-wt-manual/audit-overnight-0709 show 5cc67c5

# When ready to merge into main
git -C /home/justinwieb/forge-suite-wt-manual/creatortrack checkout main
git -C /home/justinwieb/forge-suite-wt-manual/creatortrack merge audit/overnight-0709

Note: git diff main --stat on the worktree shows a larger file list than the two audit commits alone (125 files, +1894/-4495) because main has moved forward independently since the branch was cut (workspace-switcher refactor, map-pins/knowledge/tasks dead-code removal, etc. from other live sessions). The audit's actual changes are exactly the two commits above; diffing directly against main..HEAD (as in the log/diff-stat at the top of this doc) isolates them from that drift, but a plain diff main will also surface main's independent moves as reverse-diffs. Rebase or a targeted three-dot diff before merging to avoid confusing the two.

[Claude Code]

URL: https://mkdocs.justinsforge.com/memory/handoffs/creatortrack-overnight-audit-2026-07-09/