Skip to content

feat(mupot-106): enforce surface capability gates at route level#119

Merged
servathadi merged 1 commit into
mainfrom
feat/mupot-106-capability-gates
Jun 11, 2026
Merged

feat(mupot-106): enforce surface capability gates at route level#119
servathadi merged 1 commit into
mainfrom
feat/mupot-106-capability-gates

Conversation

@servathadi

Copy link
Copy Markdown
Contributor

Summary

Closes #106. Role-preset allow/deny lists were documentation-only — a member token could reach any route that didn't call requireCapability. This PR makes the deny-lists real.

New infrastructure (src/auth/capability.ts)

  • hasSurfaceCap(env, auth, surface) — queries gate_grants table for free-text surface grants; owner/admin bypass via rank
  • requireSurfaceCap(surface) — Hono middleware wrapper

Mint wires grants (src/dashboard/keys.ts)

  • mintScopedKey now writes one gate_grants row per entry in preset.allows at mint time (INSERT OR IGNORE, idempotent on re-mint)

Route-level gates

  • POST /api/tasks/:id/verdict (src/tasks/index.ts): requires outreach:send-gated when gate_owner='gate:loops' AND verdict='approved'. Reject path unblocked.
  • POST /brain/loops/:id/control (src/dashboard/index.ts): requires content:write for all actions; additionally requires budget:write for budget_override.

Migration

  • migrations/0022_surface_caps.sql: no-DDL migration record. gate_grants (migration 0008) already supports free-text capability strings.

Per-surface enforcement table

Surface Status File:line
outreach:send-gated ENFORCED src/tasks/index.ts ~L530 (verdict endpoint)
budget:write ENFORCED src/dashboard/index.ts ~L215 (loop control, budget_override)
content:write ENFORCED src/dashboard/index.ts ~L209 (loop control, all actions)
mcpwp:write GAP No mcpwp write route exists yet; requireSurfaceCap ready to wire
provision EXISTING Admin rank-gate in src/mcp/tools/*.ts — unchanged

Test plan

22 new tests in tests/surface-caps.test.ts. Full suite: 865/865 passing (was 843). Typecheck clean.

🤖 Generated with Claude Code

Role-preset allow/deny lists were documentation-only: a member token
could reach any route that didn't call requireCapability. This PR makes
the deny-lists real.

Changes:
- src/auth/capability.ts: hasSurfaceCap(env, auth, surface) + requireSurfaceCap(surface)
  middleware. Owner/admin bypass via rank. D1 query against gate_grants
  (capability, principal_type, principal_id).
- src/dashboard/keys.ts: mintScopedKey now writes one gate_grants row per
  entry in preset.allows at mint time (INSERT OR IGNORE, idempotent).
- src/tasks/index.ts: POST /:id/verdict gates on outreach:send-gated when
  gate_owner='gate:loops' AND verdict='approved'. Reject path unblocked.
- src/dashboard/index.ts: POST /brain/loops/:id/control gates on
  content:write (all actions) and budget:write (budget_override only).
  Admin/owner bypass preserved.
- migrations/0022_surface_caps.sql: no-DDL migration record (gate_grants
  already supports free-text capabilities).

Per-surface enforcement table:
  outreach:send-gated  ENFORCED  src/tasks/index.ts ~L530 (verdict endpoint)
  budget:write         ENFORCED  src/dashboard/index.ts ~L215 (loop control)
  content:write        ENFORCED  src/dashboard/index.ts ~L209 (loop control)
  mcpwp:write          GAP       no mcpwp write route exists yet; gate ready to wire
  provision            EXISTING  admin rank-gate in src/mcp/tools/*.ts (unchanged)

Tests: 22 new tests in tests/surface-caps.test.ts; 833/833 passing (was 811).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@servathadi servathadi merged commit b2eccea into main Jun 11, 2026
5 checks passed
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.

S-MUPOT-RBAC — granular requireCapability gates (make deny-lists REAL enforcement)

1 participant