Warden plan

07 - wiki_types workspace scoping (D35)

← eval suite index


tier: needs-postgres requires: []


07 - wiki_types workspace scoping (D35)

What it proves

The wiki-types sprint's D35 ruling holds against the code + a live database: wiki_types is workspace-scoped (synthetic id PK + workspace_id NOT NULL + unique(workspace_id, slug)), the bare-slug global PK is gone, migration 0007 guards its delete-and-reseed against destroying customized rows, every wikis.type → wiki_types.slug join self-binds on the wiki's workspace, and the cross-tenant isolation invariant holds end-to-end (a user_modified edit in one workspace is invisible to another). Steps 1-2 are fast file/grep assertions that later sprints re-run as a regression floor; step 3 runs the committed DB-backed isolation test against the branch's own worktree (deps + pg :5433).

Prerequisites

Step 1: schema re-key + migration guard (D35 / WT-1 / WT-3)

set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"

# WT-1: the table is workspace-scoped — synthetic id PK + workspace_id NOT NULL
# FK + unique(workspace_id, slug); the bare-slug PK is gone. Assert against the
# wikiTypes table BLOCK (extracted via awk) so multi-line column formatting
# (Biome splits .notNull()/.references() across lines) doesn't false-negative.
SCHEMA=server/src/db/schema.ts
BLOCK=$(awk '/export const wikiTypes = pgTable\(/,/^\)/' "$SCHEMA")
if printf '%s' "$BLOCK" | grep -q "wiki_types_workspace_slug_uidx" \
   && printf '%s' "$BLOCK" | grep -q "text('workspace_id')" \
   && printf '%s' "$BLOCK" | grep -q "\.notNull()" \
   && printf '%s' "$BLOCK" | grep -q "references(() => workspaces.id"; then
  warden_pass "wiki_types carries workspace_id NOT NULL FK + unique(workspace_id, slug)"
else
  warden_fail "wiki_types is missing the workspace_id column or the (workspace_id, slug) unique index"
fi

# The bare-slug primary key must be gone (that was the cross-tenant gap).
if grep -nE "slug: text\('slug'\)\.primaryKey\(\)" "$SCHEMA" | grep -q .; then
  warden_fail "wiki_types still has a bare-slug primaryKey — the global-registry gap survives"
else
  warden_pass "the bare-slug primary key is gone"
fi

# WT-3: migration 0007 guards the delete-and-reseed so a customized row aborts
# it instead of being silently destroyed.
MIG=server/drizzle/migrations/0007_wiki_types_workspace_scope.sql
if grep -q "RAISE EXCEPTION" "$MIG" && grep -q "user_modified" "$MIG" && grep -q 'DELETE FROM "wiki_types"' "$MIG"; then
  warden_pass "migration 0007 guards its DELETE against user_modified rows"
else
  warden_fail "migration 0007 is missing the user_modified data-loss guard before its DELETE"
fi

Step 2: every wiki_types access is workspace-scoped (WT-2 / WT-6)

set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"

# WT-2: every wikis.type → wiki_types.slug join must self-bind on workspace.
# A bare join (slug eq with NO workspaceId eq on the same statement) would leak
# another tenant's type descriptor. Scan the four known join files; each file
# that joins on wikiTypes.slug must also reference wikiTypes.workspaceId.
JOIN_FILES="server/src/mcp/resolvers.ts server/src/mcp/server.ts server/src/modules/wikis/routes.ts"
leak=0
for f in $JOIN_FILES; do
  if grep -q "eq(wikis.type, wikiTypes.slug)" "$f"; then
    if ! grep -q "eq(wikiTypes.workspaceId, wikis.workspaceId)" "$f"; then
      warden_observe "join in $f does not self-bind on workspaceId"
      leak=1
    fi
  fi
done
[ "$leak" = 0 ] \
  && warden_pass "every wikis.type→wiki_types.slug join self-binds on wikiTypes.workspaceId (WT-2)" \
  || warden_fail "a wiki_types join is missing its workspace self-bind — cross-tenant descriptor leak"

# WT-6: the boot/HTTP/MCP standalone seeder + reads carry a workspace key.
# seedWikiTypes must be per-workspace (takes a workspaceId), never the old
# zero-arg global seed.
if grep -qE "seedWikiTypes\(workspaceId" server/src/bootstrap/seed-wiki-types.ts \
   && ! grep -qE "await seedWikiTypes\(\)" server/src/index.ts; then
  warden_pass "seedWikiTypes is per-workspace (no surviving zero-arg global seed)"
else
  warden_fail "seedWikiTypes still has a global (zero-arg) seed path"
fi

Step 3: cross-tenant isolation holds against a live DB (WT-9)

set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
# shellcheck disable=SC1091
source "${WARDEN_ENV_FILE:?WARDEN_ENV_FILE not set — run this plan via .warden/run.sh}"

# The committed isolation regression seeds two workspaces + a fresh third and
# asserts: fresh workspace gets the full built-in set; a user_modified edit in A
# is invisible to B across list/get/regen; same slug lives independently per
# workspace; the wikis.type join resolves within-workspace only (driven through
# the production listWikis resolver). Run it against the branch's real schema.
if pnpm --filter @robin/server exec vitest run \
     src/mcp/__tests__/wiki-types-isolation.dbtest.test.ts >/tmp/warden-wt-isolation.log 2>&1; then
  warden_pass "cross-tenant wiki_types isolation dbtest passes against a live DB (WT-9)"
else
  tail -20 /tmp/warden-wt-isolation.log
  warden_fail "cross-tenant wiki_types isolation dbtest FAILED — see /tmp/warden-wt-isolation.log"
fi

Shape (a note for the next author)

Backend sprint, no web surface — so no agent-browser E2E here (unlike 06). The acceptance core (WT-9 cross-tenant isolation) is a committed dbtest that CI's verify job already runs; step 3 re-runs it under warden as the accumulating regression floor. Steps 1-2 are the cheap structural guards that would catch a future refactor silently dropping a workspace predicate (the exact class of bug the security review hunted for). If the join file set changes, update the JOIN_FILES list in step 2.