Foundry Foundry

EPIC-2: Library/Connector Schema + UI

Drafted 2026-05-19. Beta-sprint epic 2 of 5. Sequencing: Week 1 Days 1-2 + 5-6 (local-first). Runs in parallel with EPIC-1 + EPIC-3.

Goal

Implement the access-control primitives that scope MCP queries. Library/Connector schema + CRUD UI in the app. Foundation for all MCP authorization per D34 + D35.

Why this epic exists

The MCP wedge requires per-connector authorization — each MCP client (Claude Desktop, Copilot, Cursor) gets a connector that's scoped to a specific subset of the user's KBs. The schema design landed in D35 (libraries as KB-collection access unit) and the auth model in D34 (DB-backed authorization). This epic implements those.

Scope (in) [part 1/2]

Database schema (Drizzle migration):

  • libraries — (id, org_id, name, owner_user_id, created_at)
  • library_kbs — m:n (library_id, knowledge_base_id, added_at)
  • library_access — (library_id, user_id, can_admin, granted_at, granted_by)
  • connectors — (id, user_id, library_id, name, oauth_client_id, oauth_client_secret_hash, created_at, last_used_at, revoked_at)
  • mcp_audit_loggeneralized schema covering both EPIC-2 mutations and EPIC-3 tool calls: (id, event_type, actor_user_id, target_type NULL, target_id NULL, connector_id NULL, metadata JSONB, created_at). EPIC-2 writes library/access/connector mutation events; EPIC-3 writes tool-call events (with metadata={tool, kb_scope, query, result_count, status}). Single migration, no retrofits later.
  • chat_queries — (id, user_id, kb_id, prompt_text, response_tokens, latency_ms, created_at) — written by the chat API route in the autri app (existing code). Powers EPIC-5 cost report per-user activity breakdown.
  • notifications — (id, user_id, type, title, body, link, read_at, created_at) — replaces email infrastructure entirely per projects/autri/requirements/autri-beta blue-team pass. Written by ingestion finalization (KB-ready) and post-confirmation Lambda (welcome).
  • Indexes: idx_library_access_user, idx_connectors_user, idx_library_kbs_library, idx_audit_log_created_at, idx_audit_log_actor, idx_audit_log_connector, idx_chat_queries_user_created, idx_notifications_user_unread

Locked auth specifications:

  • OAuth client_id format: conn_<uuid-v4>.env-file safe, scannable in logs
  • OAuth client_secret format: 32 bytes random, base64URL encoded (no special chars)
  • client_secret hash algorithm: argon2id with default work factor (memory_cost=65536, time_cost=3, parallelism=4). EPIC-3 uses the same algo at verify time.

Data backfill (one-time script):

  • For each existing user: create default "Personal" library
  • Ensure single-user-personal-org exists for each user (default org if missing — single-user orgs are the personal-tier tenancy boundary)
  • Auto-add all the user's existing KBs to their Personal library
  • Idempotent via ON CONFLICT DO NOTHING on library_access + library_kbs inserts

Server actions (in app/app/(routes)/settings/libraries/actions.ts and connectors/actions.ts):

  • All server actions wrap in requireOrgMember(orgId) helper (and requireLibraryAdmin(libraryId) for admin-only mutations). Explicit at callsite; greppable; hard to forget.
  • createLibrary(name)
  • addKbToLibrary(libraryId, kbId)
  • removeKbFromLibrary(libraryId, kbId)
  • grantLibraryAccess(libraryId, userEmail, canAdmin)rejects with clear error if email doesn't match an existing user (no invite flow in v1; revisit week 4+)
  • revokeLibraryAccess(libraryId, userId)
  • createConnector(libraryId, name) — returns OAuth client_id + plaintext client_secret (one-time display)
  • revokeConnector(connectorId) — sets revoked_at
  • listConnectors(userId)

Notifications server actions:

  • markNotificationRead(notificationId) — sets read_at
  • listNotifications(userId, limit) — paginated, unread-first ordering
  • (Write paths: createNotification(...) called from ingestion finalization + post-confirmation Lambda; not exposed as user-facing action)

Chat API route addition (small inline change, not a new action):

  • The existing chat API route in the autri app writes one chat_queries row per completed chat turn (user_id, kb_id, prompt_text, response_tokens, latency_ms). ~5 lines of code. Powers EPIC-5's per-user activity breakdown.

Audit events written by EPIC-2 server actions (to the generalized mcp_audit_log table):

  • library.created, library.deleted (target_type=library)
  • library_kb.added, library_kb.removed (target_type=library_kb)
  • library_access.granted, library_access.revoked (target_type=library_access)
  • connector.created, connector.revoked (target_type=connector, connector_id populated)

Scope-enforcement helper:

  • getKbScopeForConnector(connectorId: string): Promise<string[]> — returns the KB IDs the connector grants access to. Used by EPIC-3 at every tool call.
  • Returns empty array ([]) if connector is revoked (revoked_at IS NOT NULL). Security-critical — covered by unit test.
  • Implicit defense-in-depth: helper joins through library_kbs, so if a library is deleted (cascade) or all KBs removed from it, helper naturally returns [].

UI surface:

  • Settings shell (NEW — half-day budget): /settings/layout.tsx with nav sidebar (Libraries, Connectors, Account placeholder); breadcrumbs; back-link wired from existing pages
  • /settings/libraries — list user's libraries + memberships
  • Library detail page — manage KBs in library + manage user access. v1 UI ships full multi-KB UI (per D34's evolution path — schema-shaped for v2). MCP server (EPIC-3) enforces single-library-per-connector semantics in v1; user-facing constraint is "connector exposes one library to one MCP client."
  • /settings/connectors — list user's connectors
  • Connector creation flow — select library → name → "Generate" → returns endpoint URL + client_secret (shown ONCE, with copy buttons + prominent "save this now" warning)
  • Connector revocation — confirmation modal → soft-delete via revoked_at

Scope (in) [part 2/2]

Tests:

  • Unit: KB-in-multiple-libraries works
  • Unit: library deletion cascades to library_kbs + library_access + connectors
  • Unit: connector revocation sets revoked_at correctly
  • Unit: scope helper returns expected KB set for active connector
  • Unit: scope helper returns [] for revoked connector (security)
  • Unit: grantLibraryAccess with non-existent email rejects with clear error
  • Unit: backfill is idempotent (run twice, same end state)
  • Unit: audit events written correctly for each mutation type
  • Unit: chat API route writes a chat_queries row per turn
  • Unit: createNotification writes a row; markNotificationRead updates read_at
  • Integration test: happy-path full flow — create library → add KB → create connector → call scope helper → verify KB IDs returned

Out of scope

  • MCP server implementation (EPIC-3 — consumes this epic's scope helper)
  • Team invite flow (deferred — beta is single-user-per-org; grantLibraryAccess only works for existing users in v1)
  • Library admin UI for team admins (deferred to v1.1)
  • OAuth client_secret rotation flow (deferred to v1.1 — for beta, revoke + create new). Schema constraint: rotation requires multiple-hash storage; v1 schema stores one hash, so rotation is "revoke + recreate" for now.
  • Audit logging UI / dashboard (events written, UI deferred to EPIC-3 or later)
  • Multi-library-per-connector or per-user-MCP (v2/v3 per D34)
  • mcp_audit_log retention policy / cleanup (defer until table grows)

Dependencies

  • Existing autri app + DB schema (already in place — organizations, users, knowledge_bases tables)
  • Drizzle migration pipeline working (already in place)
  • shadcn/ui components for the new pages (already in use elsewhere in the app)
  • requireOrgMember(orgId) helper — needs to be written (or extracted from existing auth code if present). Half-day budget assumes ~1 hour for this helper if not already present.
  • argon2 package (Node) — node-argon2 or equivalent; add to dependencies
  • No existing /settings/* route shell — confirmed via codebase check. Day 5 morning budget covers building it.

Deliverables

  • Working DB schema for library/connector model + mcp_audit_log (migration applied)
  • Backfill complete: every existing user has Personal library with their KBs; idempotent
  • Functional Libraries + Connectors UI pages, hosted in a new /settings/* shell
  • requireOrgMember(orgId) + requireLibraryAdmin(libraryId) helpers, used by all server actions
  • Scope-enforcement query helper exported from @autri/db (or wherever lives most cleanly); revoked-connector behavior security-tested
  • Audit events written for all library/access/connector mutations
  • Unit + integration tests covering the listed cases

Implementation plan

Day 1 — Schema + backfill

  1. Write Drizzle migration: db/migrations/0NN_library_connector.sql (4 tables + mcp_audit_log + indexes)
  2. Backfill script: db/scripts/backfill-personal-libraries.ts — idempotent (ON CONFLICT DO NOTHING); creates single-user-personal-org for each user if missing; creates Personal library; adds existing KBs
  3. Run migration on local Docker Postgres
  4. Run backfill, verify with SELECT * FROM libraries + SELECT * FROM library_kbs
  5. Re-run backfill to verify idempotency (no errors, no duplicate rows)

Day 2 — Auth helpers + server actions + scope helper

  1. Write requireOrgMember(orgId) + requireLibraryAdmin(libraryId) helpers (or extract from existing auth code if present)
  2. Add node-argon2 (or equivalent) dependency
  3. Implement all 8 server actions, each wrapped in the appropriate require-helper, each writing the corresponding audit event
  4. Implement getKbScopeForConnector — including the revoked-connector empty-array branch
  5. Write unit tests:
    • KB-in-multiple-libraries
    • Library cascade delete
    • Connector revocation
    • Scope helper (active connector)
    • Scope helper (revoked connector returns []) — security
    • grantLibraryAccess rejects non-existent email
    • Backfill idempotency
  6. Write integration test: full flow (create library → add KB → create connector → scope helper)
  7. Run test suite, fix issues

Day 5 morning — Settings shell

  1. /settings/layout.tsx with sidebar nav (Libraries, Connectors, Account placeholder)
  2. Breadcrumb pattern; back-link from existing routes
  3. Match design tokens (dark mode default, IBM Plex, brand gold)
  4. Smoke test: navigate to /settings, confirm shell renders correctly

Day 5 afternoon — Libraries UI

  1. /settings/libraries page: list view with create button
  2. Library detail page: multi-KB management + access management (full v1 UI, NOT constrained to single-KB)
  3. Validation: create library → add 2 KBs → grant access → confirm DB state + audit events

Day 6 — Connectors UI

  1. /settings/connectors page: list + create + revoke
  2. Create flow: dropdown for library selection, name input, "Generate" → modal displays URL + client_secret (one-time, with copy buttons and prominent "save this now" warning)
  3. Revoke flow: confirmation modal → sets revoked_at
  4. Validation: full flow end-to-end, confirm client_secret never displayed again after creation; confirm connector.revoked audit event written

last_used_at write-path: EPIC-3 owns updating connectors.last_used_at on every MCP request. Boundary noted here so it doesn't get dropped.

Risks

  • Backfill performance with many existing KBs. Low risk — beta has ~3 users with ~6 KBs total. Mitigation: backfill script paginates if needed; snapshot local Postgres before running.
  • UI for library management is genuinely new surface area. Pixel-pinning to design tokens takes time. Mitigation: lean on existing shadcn/ui patterns from the rest of the app; settings shell is the new pattern but uses standard layouts.
  • OAuth client_secret display UX. Must be shown once, never stored visibly again. Mitigation: dedicated modal with prominent "save this now" copy + copy-to-clipboard + secondary "I've saved it" acknowledge button before dismiss.
  • Migration rollback complexity. If we hit issues, rolling back the migration with backfilled data is awkward. Mitigation: snapshot local Postgres before running; backfill is idempotent so re-runs are safe.
  • requireOrgMember helper extraction. If the existing auth surface doesn't expose org-membership cleanly, writing the helper may take longer than the 1-hour budget. Mitigation: timebox at 2 hours; if longer, inline checks per action for v1 and refactor to helper post-beta.
  • Argon2 dependency adds native build step. node-argon2 requires a C compiler. If CI/Docker images don't have one, build fails. Mitigation: verify in local Docker first; switch to @node-rs/argon2 (Rust-based, pure Node binding) if native build is problematic.

Definition of done

  • Drizzle migration applied; all 4 access-control tables + mcp_audit_log + indexes exist
  • Backfill complete: every existing user has Personal library, KBs linked; re-running is a no-op (idempotency verified)
  • requireOrgMember(orgId) + requireLibraryAdmin(libraryId) helpers implemented
  • All 8 server actions implemented, each wrapped in the appropriate helper, each writing audit events
  • grantLibraryAccess rejects non-existent email with clear error
  • Settings shell (/settings/layout.tsx) exists with sidebar nav + breadcrumbs
  • Libraries UI: list / create / detail / manage multi-KB / manage access all work
  • Connectors UI: list / create / revoke all work
  • OAuth client_id follows conn_<uuid-v4> format; client_secret is 32-byte base64URL
  • client_secret hashed with argon2id; shown once on create, never stored visibly
  • Scope-enforcement helper returns correct KB IDs for an active connector
  • Scope-enforcement helper returns [] for a revoked connector (security)
  • Audit events written: library.* / library_kb.* / library_access.* / connector.*
  • Unit tests pass (8 enumerated cases)
  • Integration test passes (full flow)
  • Manual smoke test: Dan creates a library, adds 2 KBs, generates a connector, sees URL + client_secret modal, copies, dismisses, can never see client_secret again

Notes / open questions

Locked this triage pass (2026-05-19):

  • Server-action authorization: requireOrgMember(orgId) wrapper pattern at every callsite
  • v1 UI: full multi-KB library UI ships; MCP server (EPIC-3) enforces single-library-per-connector semantics
  • client_secret hash: argon2id; format: 32-byte base64URL
  • client_id format: conn_<uuid-v4>
  • grantLibraryAccess on non-existent email: reject with clear error (no invite flow in v1)
  • Audit events written by EPIC-2 for all library/access/connector mutations (to mcp_audit_log shared with EPIC-3)
  • Backfill idempotency strategy: ON CONFLICT DO NOTHING
  • Settings shell budget: half-day (Day 5 morning) — confirmed no /settings/* exists in the app today

Still open (low-risk, decide during implementation):

  • Should connectors.last_used_at update on every MCP request, or just periodically? Lean: every-request for now (cheap), revisit if it becomes a DB hotspot. (EPIC-3 decision)
  • Should we soft-delete libraries (add deleted_at) or hard-delete? Lean: hard-delete with cascade — simpler, and we have no audit requirement to retain deleted entities (audit events live in their own table).
  • Should library names be unique within an org? Lean: yes (uniqueness constraint), prevents user confusion. Add to migration.
  • mcp_audit_log retention policy: defer until the table grows past ~1M rows; for beta, retention is unbounded.

Review

🔒

Enter your access token to view annotations