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_log— generalized 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 perprojects/autri/requirements/autri-betablue-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_idformat:conn_<uuid-v4>—.env-file safe, scannable in logs - OAuth
client_secretformat: 32 bytes random, base64URL encoded (no special chars) client_secrethash 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 NOTHINGonlibrary_access+library_kbsinserts
Server actions (in app/app/(routes)/settings/libraries/actions.ts and connectors/actions.ts):
- All server actions wrap in
requireOrgMember(orgId)helper (andrequireLibraryAdmin(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 OAuthclient_id+ plaintextclient_secret(one-time display)revokeConnector(connectorId)— setsrevoked_atlistConnectors(userId)
Notifications server actions:
markNotificationRead(notificationId)— setsread_atlistNotifications(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_queriesrow 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.tsxwith 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_atcorrectly - Unit: scope helper returns expected KB set for active connector
- Unit: scope helper returns
[]for revoked connector (security) - Unit:
grantLibraryAccesswith 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_queriesrow per turn - Unit:
createNotificationwrites a row;markNotificationReadupdatesread_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;
grantLibraryAccessonly works for existing users in v1) - Library admin UI for team admins (deferred to v1.1)
- OAuth
client_secretrotation 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_logretention policy / cleanup (defer until table grows)
Dependencies
- Existing autri app + DB schema (already in place —
organizations,users,knowledge_basestables) - 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.argon2package (Node) —node-argon2or 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
- Write Drizzle migration:
db/migrations/0NN_library_connector.sql(4 tables +mcp_audit_log+ indexes) - 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 - Run migration on local Docker Postgres
- Run backfill, verify with
SELECT * FROM libraries+SELECT * FROM library_kbs - Re-run backfill to verify idempotency (no errors, no duplicate rows)
Day 2 — Auth helpers + server actions + scope helper
- Write
requireOrgMember(orgId)+requireLibraryAdmin(libraryId)helpers (or extract from existing auth code if present) - Add
node-argon2(or equivalent) dependency - Implement all 8 server actions, each wrapped in the appropriate require-helper, each writing the corresponding audit event
- Implement
getKbScopeForConnector— including the revoked-connector empty-array branch - Write unit tests:
- KB-in-multiple-libraries
- Library cascade delete
- Connector revocation
- Scope helper (active connector)
- Scope helper (revoked connector returns
[]) — security grantLibraryAccessrejects non-existent email- Backfill idempotency
- Write integration test: full flow (create library → add KB → create connector → scope helper)
- Run test suite, fix issues
Day 5 morning — Settings shell
/settings/layout.tsxwith sidebar nav (Libraries, Connectors, Account placeholder)- Breadcrumb pattern; back-link from existing routes
- Match design tokens (dark mode default, IBM Plex, brand gold)
- Smoke test: navigate to
/settings, confirm shell renders correctly
Day 5 afternoon — Libraries UI
/settings/librariespage: list view with create button- Library detail page: multi-KB management + access management (full v1 UI, NOT constrained to single-KB)
- Validation: create library → add 2 KBs → grant access → confirm DB state + audit events
Day 6 — Connectors UI
/settings/connectorspage: list + create + revoke- 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) - Revoke flow: confirmation modal → sets
revoked_at - Validation: full flow end-to-end, confirm
client_secretnever displayed again after creation; confirmconnector.revokedaudit 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_secretdisplay 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.
requireOrgMemberhelper 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-argon2requires 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
-
grantLibraryAccessrejects 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_idfollowsconn_<uuid-v4>format;client_secretis 32-byte base64URL -
client_secrethashed 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_secretmodal, copies, dismisses, can never seeclient_secretagain
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_secrethash: argon2id; format: 32-byte base64URLclient_idformat:conn_<uuid-v4>grantLibraryAccesson 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_logshared 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_atupdate 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_logretention policy: defer until the table grows past ~1M rows; for beta, retention is unbounded.