diff --git a/backend/migrations/20260717_01_dms_connectors.sql b/backend/migrations/20260717_01_dms_connectors.sql new file mode 100644 index 000000000..f3e009bfa --- /dev/null +++ b/backend/migrations/20260717_01_dms_connectors.sql @@ -0,0 +1,144 @@ +-- Migration date: 2026-07-17 +-- +-- Native DMS connectors (iManage / NetDocuments). +-- +-- Per-user connector rows with encrypted OAuth credentials, mirroring the MCP +-- connector tables (20260613_04_user_mcp_connectors.sql + +-- 20260615_01_mcp_connector_oauth.sql) column-for-column so the exact +-- AES-256-GCM crypto and the auth-code + refresh OAuth flow apply unchanged. +-- +-- Security posture (identical to the MCP tables): +-- * RLS ENABLED on every table with NO browser policies, so only the +-- service-role backend can read connector rows and token material. +-- * REVOKE ALL ... FROM anon, authenticated below removes table-level grants +-- too (BYPASSRLS on service_role skips policies, not GRANTs), following +-- 20260508_01_revoke_client_grants_backend_tables.sql. +-- +-- Safe to re-run: every statement is guarded. + +-- --------------------------------------------------------------------------- +-- dms_connectors +-- --------------------------------------------------------------------------- + +create table if not exists public.dms_connectors ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + kind text not null + check (kind in ('imanage', 'netdocuments', 'fake')), + name text not null, + base_url text not null, + auth_type text not null default 'oauth' + check (auth_type in ('oauth')), + enabled boolean not null default true, + encrypted_auth_config text, + auth_config_iv text, + auth_config_tag text, + config jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists idx_dms_connectors_user + on public.dms_connectors(user_id); + +alter table public.dms_connectors enable row level security; + +-- --------------------------------------------------------------------------- +-- dms_connector_oauth_tokens (mirrors user_mcp_oauth_tokens) +-- --------------------------------------------------------------------------- + +create table if not exists public.dms_connector_oauth_tokens ( + id uuid primary key default gen_random_uuid(), + connector_id uuid not null references public.dms_connectors(id) on delete cascade, + encrypted_access_token text, + access_token_iv text, + access_token_tag text, + encrypted_refresh_token text, + refresh_token_iv text, + refresh_token_tag text, + token_type text, + scope text, + expires_at timestamptz, + authorization_server text, + token_endpoint text, + client_id text, + encrypted_client_secret text, + client_secret_iv text, + client_secret_tag text, + resource text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique(connector_id) +); + +alter table public.dms_connector_oauth_tokens enable row level security; + +-- --------------------------------------------------------------------------- +-- dms_connector_oauth_states (mirrors user_mcp_oauth_states) +-- --------------------------------------------------------------------------- + +create table if not exists public.dms_connector_oauth_states ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + connector_id uuid not null references public.dms_connectors(id) on delete cascade, + state_hash text not null unique, + encrypted_state_config text not null, + state_config_iv text not null, + state_config_tag text not null, + expires_at timestamptz not null, + created_at timestamptz not null default now() +); + +create index if not exists idx_dms_connector_oauth_states_expires + on public.dms_connector_oauth_states(expires_at); + +alter table public.dms_connector_oauth_states enable row level security; + +-- --------------------------------------------------------------------------- +-- dms_document_links — round-trip mapping (document_id <-> external doc/version) +-- so exportDocument can push a Mike document back to the right DMS document. +-- --------------------------------------------------------------------------- + +create table if not exists public.dms_document_links ( + id uuid primary key default gen_random_uuid(), + document_id uuid not null references public.documents(id) on delete cascade, + connector_id uuid not null references public.dms_connectors(id) on delete cascade, + dms_doc_id text not null, + dms_version text, + created_at timestamptz not null default now(), + unique(document_id) +); + +create index if not exists idx_dms_document_links_connector + on public.dms_document_links(connector_id); + +alter table public.dms_document_links enable row level security; + +-- --------------------------------------------------------------------------- +-- Allow 'dms_import' as a document_versions.source (imported docs are +-- distinguishable from interactive uploads). Re-add the check with the extra +-- value; the constraint is additive, no existing rows are affected. +-- --------------------------------------------------------------------------- + +alter table public.document_versions + drop constraint if exists document_versions_source_check; +alter table public.document_versions + add constraint document_versions_source_check + check (source = any (array[ + 'upload'::text, + 'user_upload'::text, + 'assistant_edit'::text, + 'user_accept'::text, + 'user_reject'::text, + 'generated'::text, + 'dms_import'::text + ])); + +-- --------------------------------------------------------------------------- +-- Direct client grant hardening for the new tables +-- --------------------------------------------------------------------------- + +revoke all on public.dms_connectors from anon, authenticated; +revoke all on public.dms_connector_oauth_tokens from anon, authenticated; +revoke all on public.dms_connector_oauth_states from anon, authenticated; +revoke all on public.dms_document_links from anon, authenticated; diff --git a/backend/schema.sql b/backend/schema.sql index 8d298380b..1179ec065 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -285,7 +285,8 @@ create table if not exists public.document_versions ( 'assistant_edit'::text, 'user_accept'::text, 'user_reject'::text, - 'generated'::text + 'generated'::text, + 'dms_import'::text ])) ); @@ -851,6 +852,92 @@ create table if not exists public.courtlistener_opinion_cluster_index ( alter table public.courtlistener_opinion_cluster_index enable row level security; +-- --------------------------------------------------------------------------- +-- DMS connectors (iManage / NetDocuments) — see +-- 20260717_01_dms_connectors.sql. Mirrors the MCP connector tables. +-- --------------------------------------------------------------------------- + +create table if not exists public.dms_connectors ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + kind text not null + check (kind in ('imanage', 'netdocuments', 'fake')), + name text not null, + base_url text not null, + auth_type text not null default 'oauth' + check (auth_type in ('oauth')), + enabled boolean not null default true, + encrypted_auth_config text, + auth_config_iv text, + auth_config_tag text, + config jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists idx_dms_connectors_user + on public.dms_connectors(user_id); + +alter table public.dms_connectors enable row level security; + +create table if not exists public.dms_connector_oauth_tokens ( + id uuid primary key default gen_random_uuid(), + connector_id uuid not null references public.dms_connectors(id) on delete cascade, + encrypted_access_token text, + access_token_iv text, + access_token_tag text, + encrypted_refresh_token text, + refresh_token_iv text, + refresh_token_tag text, + token_type text, + scope text, + expires_at timestamptz, + authorization_server text, + token_endpoint text, + client_id text, + encrypted_client_secret text, + client_secret_iv text, + client_secret_tag text, + resource text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique(connector_id) +); + +alter table public.dms_connector_oauth_tokens enable row level security; + +create table if not exists public.dms_connector_oauth_states ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + connector_id uuid not null references public.dms_connectors(id) on delete cascade, + state_hash text not null unique, + encrypted_state_config text not null, + state_config_iv text not null, + state_config_tag text not null, + expires_at timestamptz not null, + created_at timestamptz not null default now() +); + +create index if not exists idx_dms_connector_oauth_states_expires + on public.dms_connector_oauth_states(expires_at); + +alter table public.dms_connector_oauth_states enable row level security; + +create table if not exists public.dms_document_links ( + id uuid primary key default gen_random_uuid(), + document_id uuid not null references public.documents(id) on delete cascade, + connector_id uuid not null references public.dms_connectors(id) on delete cascade, + dms_doc_id text not null, + dms_version text, + created_at timestamptz not null default now(), + unique(document_id) +); + +create index if not exists idx_dms_document_links_connector + on public.dms_document_links(connector_id); + +alter table public.dms_document_links enable row level security; + -- --------------------------------------------------------------------------- -- Direct client grant hardening -- --------------------------------------------------------------------------- @@ -884,3 +971,7 @@ revoke all on public.user_mcp_connector_tools from anon, authenticated; revoke all on public.user_mcp_tool_audit_logs from anon, authenticated; revoke all on public.courtlistener_citation_index from anon, authenticated; revoke all on public.courtlistener_opinion_cluster_index from anon, authenticated; +revoke all on public.dms_connectors from anon, authenticated; +revoke all on public.dms_connector_oauth_tokens from anon, authenticated; +revoke all on public.dms_connector_oauth_states from anon, authenticated; +revoke all on public.dms_document_links from anon, authenticated; diff --git a/backend/src/index.ts b/backend/src/index.ts index b8d36cf0b..f0156ba64 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,4 +1,19 @@ import "dotenv/config"; + +// DMS secret handling reuses the MCP crypto (lib/mcp/client.ts), whose master +// secret resolves MCP_CONNECTORS_ENCRYPTION_SECRET → USER_API_KEYS_ENCRYPTION_SECRET. +// To let operators name the variable after the DMS feature without a second +// crypto implementation, alias DMS_CONNECTORS_ENCRYPTION_SECRET onto the MCP +// slot when the MCP one is not itself set. Runs at env load, before any +// encrypt/decrypt call (those read process.env lazily). +if ( + process.env.DMS_CONNECTORS_ENCRYPTION_SECRET && + !process.env.MCP_CONNECTORS_ENCRYPTION_SECRET +) { + process.env.MCP_CONNECTORS_ENCRYPTION_SECRET = + process.env.DMS_CONNECTORS_ENCRYPTION_SECRET; +} + import express from "express"; import cors from "cors"; import helmet from "helmet"; diff --git a/backend/src/lib/airgap.ts b/backend/src/lib/airgap.ts new file mode 100644 index 000000000..0e9240255 --- /dev/null +++ b/backend/src/lib/airgap.ts @@ -0,0 +1,8 @@ +// Central air-gap policy. AIRGAPPED must fence off EVERY external egress channel +// in code (not just the LLM), so the "no content leaves" guarantee holds even if +// network isolation is imperfect. + +/** True when the deployment is running in air-gapped mode. */ +export function isAirgapped(env: NodeJS.ProcessEnv = process.env): boolean { + return env.AIRGAPPED === "true"; +} diff --git a/backend/src/lib/dms/__tests__/airgap.test.ts b/backend/src/lib/dms/__tests__/airgap.test.ts new file mode 100644 index 000000000..72a242721 --- /dev/null +++ b/backend/src/lib/dms/__tests__/airgap.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { createDmsConnector, resolveDmsAdapter } from "../servers"; +import { getDmsAdapter } from "../index"; +import { sharedFakeDms } from "../fake"; +import type { DmsConnectorRow } from "../types"; + +// The airgap guard reads process.env.AIRGAPPED at call time (lib/airgap.ts). +const priorAirgap = process.env.AIRGAPPED; + +function row(kind: DmsConnectorRow["kind"]): DmsConnectorRow { + return { + id: "c1", + user_id: "u1", + kind, + name: "Test", + base_url: "https://tenant.example.com", + auth_type: "oauth", + enabled: true, + encrypted_auth_config: null, + auth_config_iv: null, + auth_config_tag: null, + config: { customer_id: "1", library: "ACTIVE", repository: "CAB" }, + created_at: "now", + updated_at: "now", + }; +} + +// A db stub is never reached: the airgap guard throws before any query. +const db = {} as never; + +beforeEach(() => { + process.env.AIRGAPPED = "true"; + sharedFakeDms.reset(); +}); + +afterEach(() => { + if (priorAirgap === undefined) delete process.env.AIRGAPPED; + else process.env.AIRGAPPED = priorAirgap; +}); + +describe("DMS air-gap gating", () => { + it("refuses to create an iManage connector when air-gapped", async () => { + await expect( + createDmsConnector( + "u1", + { kind: "imanage", name: "x", baseUrl: "https://t.imanage.com" }, + db, + ), + ).rejects.toThrow(/air-gapped/); + }); + + it("refuses to create a NetDocuments connector when air-gapped", async () => { + await expect( + createDmsConnector( + "u1", + { + kind: "netdocuments", + name: "x", + baseUrl: "https://t.netdocuments.com", + }, + db, + ), + ).rejects.toThrow(/air-gapped/); + }); + + it("refuses to resolve a cloud adapter when air-gapped", () => { + expect(() => resolveDmsAdapter(row("imanage"), db)).toThrow( + /air-gapped/, + ); + expect(() => resolveDmsAdapter(row("netdocuments"), db)).toThrow( + /air-gapped/, + ); + }); + + it("keeps the in-memory Fake connector fully usable air-gapped", async () => { + // The Fake has no egress, so it is allowed even when AIRGAPPED=true. + const adapter = resolveDmsAdapter(row("fake"), db); + expect(adapter.kind).toBe("fake"); + sharedFakeDms.seedDocument({ + id: "d1", + name: "Local.pdf", + content: "offline", + }); + const doc = await adapter.fetchDocument("d1"); + expect(doc).not.toBeNull(); + await expect(adapter.authenticate()).resolves.toEqual({ ok: true }); + }); + + it("still allows creating a Fake connector air-gapped (guard is per-kind)", () => { + // getDmsAdapter for the fake kind is not gated. + const adapter = getDmsAdapter("fake", { baseUrl: "https://fake.invalid" }); + expect(adapter.kind).toBe("fake"); + }); +}); diff --git a/backend/src/lib/dms/__tests__/fake.test.ts b/backend/src/lib/dms/__tests__/fake.test.ts new file mode 100644 index 000000000..9e3f41b2f --- /dev/null +++ b/backend/src/lib/dms/__tests__/fake.test.ts @@ -0,0 +1,152 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { FakeDMSAdapter } from "../fake"; +import { + getDmsAdapter, + listDmsAdapters, + registerDmsAdapter, + resetDmsRegistryForTests, +} from "../index"; +import type { DmsConnector } from "../adapter"; + +const textDecoder = new TextDecoder(); +const decode = (buf: ArrayBuffer) => textDecoder.decode(new Uint8Array(buf)); + +describe("FakeDMSAdapter", () => { + let dms: FakeDMSAdapter; + + beforeEach(() => { + dms = new FakeDMSAdapter(); + dms.seedFolder({ id: "root", name: "Matters", parentId: null }); + dms.seedFolder({ id: "child", name: "Matter 001", parentId: "root" }); + dms.seedDocument({ + id: "doc-1", + name: "Complaint.pdf", + folderId: "child", + content: "complaint body", + }); + dms.seedDocument({ + id: "doc-2", + name: "Answer.pdf", + folderId: "child", + content: "answer body", + }); + }); + + it("authenticates and reports ready without credentials", async () => { + expect(dms.enabled).toBe(true); + await expect(dms.authenticate()).resolves.toEqual({ ok: true }); + await expect(dms.checkReady()).resolves.toMatchObject({ ok: true }); + }); + + it("lists folders by parent", async () => { + const top = await dms.listFolders(); + expect(top.map((f) => f.id)).toEqual(["root"]); + const children = await dms.listFolders("root"); + expect(children.map((f) => f.id)).toEqual(["child"]); + }); + + it("searches by name and honors folder + limit", async () => { + const all = await dms.search(""); + expect(all).toHaveLength(2); + const hit = await dms.search("complaint"); + expect(hit.map((r) => r.id)).toEqual(["doc-1"]); + expect(hit[0].version).toBe("1"); + const scoped = await dms.search("", { folderId: "child", limit: 1 }); + expect(scoped).toHaveLength(1); + }); + + it("fetches a document with content, metadata and version", async () => { + const doc = await dms.fetchDocument("doc-1"); + expect(doc).not.toBeNull(); + expect(decode(doc!.content)).toBe("complaint body"); + expect(doc!.version).toBe("1"); + expect(doc!.metadata).toMatchObject({ + id: "doc-1", + name: "Complaint.pdf", + extension: "pdf", + folderId: "child", + }); + expect(doc!.metadata.sizeBytes).toBe(doc!.content.byteLength); + }); + + it("returns null for a missing document", async () => { + await expect(dms.fetchDocument("nope")).resolves.toBeNull(); + }); + + it("exports a new version and bumps the version id", async () => { + const encoded = new TextEncoder().encode("edited body"); + const res = await dms.exportDocument( + "doc-1", + encoded.buffer.slice( + encoded.byteOffset, + encoded.byteOffset + encoded.byteLength, + ) as ArrayBuffer, + { newVersion: true }, + ); + expect(res).toEqual({ docId: "doc-1", version: "2" }); + const doc = await dms.fetchDocument("doc-1"); + expect(doc!.version).toBe("2"); + expect(decode(doc!.content)).toBe("edited body"); + }); + + it("overwrites in place when newVersion is false", async () => { + const encoded = new TextEncoder().encode("overwritten"); + await dms.exportDocument( + "doc-1", + encoded.buffer.slice( + encoded.byteOffset, + encoded.byteOffset + encoded.byteLength, + ) as ArrayBuffer, + { newVersion: false }, + ); + const doc = await dms.fetchDocument("doc-1"); + expect(doc!.version).toBe("1"); + expect(decode(doc!.content)).toBe("overwritten"); + }); +}); + +describe("DMS adapter registry", () => { + beforeEach(() => { + resetDmsRegistryForTests(); + }); + + it("exposes the three built-in kinds", () => { + expect(listDmsAdapters().sort()).toEqual([ + "fake", + "imanage", + "netdocuments", + ]); + }); + + it("returns the concrete adapter class for a kind", () => { + const imanage = getDmsAdapter("imanage", { + baseUrl: "https://tenant.imanage.com", + customerId: "1", + library: "ACTIVE", + }); + expect(imanage.kind).toBe("imanage"); + }); + + it("swaps a cloud kind for a Fake without touching callers", async () => { + // Mirrors storage.test.ts setStorageAdapter: register a replacement + // factory and observe getDmsAdapter resolve to it. + const fake = new FakeDMSAdapter(); + fake.seedDocument({ id: "x", name: "X.pdf", content: "x" }); + const swapped: DmsConnector = fake; + registerDmsAdapter("imanage", () => swapped); + + const resolved = getDmsAdapter("imanage", { + baseUrl: "https://tenant.imanage.com", + }); + expect(resolved).toBe(fake); + const doc = await resolved.fetchDocument("x"); + expect(doc).not.toBeNull(); + }); + + it("throws for an unknown kind", () => { + expect(() => + // @ts-expect-error — exercising the runtime guard + getDmsAdapter("worldox", { baseUrl: "https://x" }), + ).toThrow(/No DMS adapter registered/); + }); +}); diff --git a/backend/src/lib/dms/__tests__/fakeDb.ts b/backend/src/lib/dms/__tests__/fakeDb.ts new file mode 100644 index 000000000..8f49a173a --- /dev/null +++ b/backend/src/lib/dms/__tests__/fakeDb.ts @@ -0,0 +1,201 @@ +// A small stateful in-memory stand-in for the Supabase client, supporting the +// exact query shapes the DMS code uses: select/insert/update/delete/upsert with +// eq/in/gt/is filters, order/limit, and single/maybeSingle terminals plus +// direct-await (thenable). Deliberately minimal — not a general Supabase mock. +import crypto from "crypto"; + +type Row = Record; +type Filter = (r: Row) => boolean; + +export interface FakeDb { + from(table: string): QueryBuilder; + _tables: Record; +} + +class QueryBuilder { + private filters: Filter[] = []; + private op: "select" | "insert" | "update" | "delete" | "upsert" = + "select"; + private payload: Row | Row[] | null = null; + private onConflict?: string; + private wantWritten = false; + private orderSpec: { col: string; asc: boolean } | null = null; + private limitN: number | null = null; + private ran: { data: Row[]; error: { message: string } | null } | null = + null; + + constructor( + private readonly tables: Record, + private readonly table: string, + ) {} + + private get rows(): Row[] { + return (this.tables[this.table] ??= []); + } + + private match(rows: Row[]): Row[] { + return rows.filter((r) => this.filters.every((f) => f(r))); + } + + select(_cols?: string): this { + if (this.op !== "select") this.wantWritten = true; + return this; + } + insert(payload: Row | Row[]): this { + this.op = "insert"; + this.payload = payload; + return this; + } + update(payload: Row): this { + this.op = "update"; + this.payload = payload; + return this; + } + upsert(payload: Row | Row[], opts?: { onConflict?: string }): this { + this.op = "upsert"; + this.payload = payload; + this.onConflict = opts?.onConflict; + return this; + } + delete(): this { + this.op = "delete"; + return this; + } + eq(col: string, val: unknown): this { + this.filters.push((r) => r[col] === val); + return this; + } + neq(col: string, val: unknown): this { + this.filters.push((r) => r[col] !== val); + return this; + } + in(col: string, vals: unknown[]): this { + this.filters.push((r) => vals.includes(r[col])); + return this; + } + gt(col: string, val: unknown): this { + this.filters.push((r) => String(r[col]) > String(val)); + return this; + } + lt(col: string, val: unknown): this { + this.filters.push((r) => String(r[col]) < String(val)); + return this; + } + is(col: string, val: unknown): this { + this.filters.push((r) => r[col] === val); + return this; + } + order(col: string, opts?: { ascending?: boolean }): this { + this.orderSpec = { col, asc: opts?.ascending !== false }; + return this; + } + limit(n: number): this { + this.limitN = n; + return this; + } + + private run(): { data: Row[]; error: { message: string } | null } { + if (this.ran) return this.ran; + let result: Row[] = []; + if (this.op === "select") { + result = this.match(this.rows); + if (this.orderSpec) { + const { col, asc } = this.orderSpec; + result = [...result].sort((a, b) => { + const av = String(a[col] ?? ""); + const bv = String(b[col] ?? ""); + return asc ? av.localeCompare(bv) : bv.localeCompare(av); + }); + } + if (this.limitN != null) result = result.slice(0, this.limitN); + } else if (this.op === "insert") { + const arr = Array.isArray(this.payload) + ? this.payload + : [this.payload as Row]; + const inserted = arr.map((r) => stamp(r)); + this.rows.push(...inserted); + result = this.wantWritten ? inserted : []; + } else if (this.op === "update") { + const matched = this.match(this.rows); + for (const r of matched) Object.assign(r, this.payload); + result = this.wantWritten ? matched : []; + } else if (this.op === "upsert") { + const arr = Array.isArray(this.payload) + ? this.payload + : [this.payload as Row]; + const written: Row[] = []; + for (const r of arr) { + const key = this.onConflict; + const idx = key + ? this.rows.findIndex((x) => x[key] === r[key]) + : -1; + if (idx >= 0) { + Object.assign(this.rows[idx], r); + written.push(this.rows[idx]); + } else { + const row = stamp(r); + this.rows.push(row); + written.push(row); + } + } + result = this.wantWritten ? written : []; + } else if (this.op === "delete") { + this.tables[this.table] = this.rows.filter( + (r) => !this.filters.every((f) => f(r)), + ); + result = []; + } + this.ran = { data: result, error: null }; + return this.ran; + } + + single(): Promise<{ data: Row | null; error: { message: string } | null }> { + const { data } = this.run(); + if (!data.length) { + return Promise.resolve({ + data: null, + error: { message: "No rows found" }, + }); + } + return Promise.resolve({ data: data[0], error: null }); + } + maybeSingle(): Promise<{ + data: Row | null; + error: { message: string } | null; + }> { + const { data } = this.run(); + return Promise.resolve({ data: data[0] ?? null, error: null }); + } + then( + resolve: (v: { + data: Row[] | null; + error: { message: string } | null; + }) => unknown, + ) { + const { data, error } = this.run(); + return Promise.resolve(resolve({ data, error })); + } +} + +function stamp(r: Row): Row { + const now = new Date().toISOString(); + return { + id: r.id ?? crypto.randomUUID(), + created_at: r.created_at ?? now, + updated_at: r.updated_at ?? now, + ...r, + }; +} + +export function createFakeSupabase(seed: Record = {}): FakeDb { + const tables: Record = {}; + for (const [k, v] of Object.entries(seed)) { + tables[k] = v.map((r) => ({ ...r })); + } + return { + _tables: tables, + from(table: string) { + return new QueryBuilder(tables, table); + }, + }; +} diff --git a/backend/src/lib/dms/__tests__/imanage.test.ts b/backend/src/lib/dms/__tests__/imanage.test.ts new file mode 100644 index 000000000..5a3b207b9 --- /dev/null +++ b/backend/src/lib/dms/__tests__/imanage.test.ts @@ -0,0 +1,186 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock DNS so the SSRF guard in guardedFetch resolves the tenant host to a +// public IP without touching the network (same approach as the MCP ssrf test). +const { lookupMock } = vi.hoisted(() => ({ lookupMock: vi.fn() })); +vi.mock("dns/promises", () => ({ default: { lookup: lookupMock } })); + +import { IManageAdapter } from "../imanage"; + +const BASE = "https://tenant.imanage.com"; +const TOKEN = "imanage-access-token"; + +function publicDns() { + lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]); +} + +interface Captured { + url: string; + init: RequestInit | undefined; +} + +let calls: Captured[]; + +function mockFetch(handler: (url: string, init?: RequestInit) => Response) { + return vi + .spyOn(globalThis, "fetch") + .mockImplementation((input: unknown, init?: RequestInit) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : (input as Request).url; + calls.push({ url, init }); + return Promise.resolve(handler(url, init)); + }); +} + +function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function adapter() { + return new IManageAdapter({ + baseUrl: BASE, + customerId: "42", + library: "ACTIVE", + getAccessToken: async () => TOKEN, + }); +} + +const ROOT = `${BASE}/api/v2/customers/42/libraries/ACTIVE`; + +beforeEach(() => { + calls = []; + lookupMock.mockReset(); + publicDns(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("IManageAdapter", () => { + it("is enabled only with base url + customer + library", () => { + expect(adapter().enabled).toBe(true); + expect( + new IManageAdapter({ baseUrl: BASE, getAccessToken: async () => TOKEN }) + .enabled, + ).toBe(false); + }); + + it("authenticates with a Bearer token against the workspaces probe", async () => { + mockFetch(() => json({ data: [] })); + const res = await adapter().authenticate(); + expect(res.ok).toBe(true); + expect(calls[0].url).toBe(`${ROOT}/workspaces?limit=1`); + const headers = new Headers(calls[0].init?.headers as HeadersInit); + expect(headers.get("authorization")).toBe(`Bearer ${TOKEN}`); + }); + + it("reports auth failure instead of throwing", async () => { + mockFetch(() => json({ error: "nope" }, 401)); + const res = await adapter().authenticate(); + expect(res.ok).toBe(false); + expect(res.error).toMatch(/401/); + }); + + it("lists workspaces at the top level and folder children below", async () => { + mockFetch((url) => { + if (url.endsWith("/workspaces")) + return json({ data: [{ id: "ws1", name: "Client A" }] }); + return json({ data: [{ id: "f2", name: "Pleadings" }] }); + }); + const dms = adapter(); + const top = await dms.listFolders(); + expect(top).toEqual([{ id: "ws1", name: "Client A", parentId: null }]); + const children = await dms.listFolders("ws1"); + expect(children).toEqual([ + { id: "f2", name: "Pleadings", parentId: "ws1" }, + ]); + expect(calls[1].url).toBe(`${ROOT}/folders/ws1/children`); + }); + + it("searches documents and surfaces the version", async () => { + mockFetch(() => + json({ + data: [ + { id: "d1", name: "Brief.pdf", mime: "application/pdf", version: 3 }, + ], + }), + ); + const results = await adapter().search("brief", { limit: 10 }); + expect(results).toEqual([ + { + id: "d1", + name: "Brief.pdf", + folderId: null, + contentType: "application/pdf", + version: "3", + }, + ]); + expect(calls[0].url).toContain(`${ROOT}/documents/search?`); + expect(calls[0].url).toContain("q=brief"); + }); + + it("fetches a document with metadata + bytes + version", async () => { + const bytes = new TextEncoder().encode("%PDF-1.7 body"); + mockFetch((url) => { + if (url.endsWith("/download")) + return new Response(bytes, { + status: 200, + headers: { "content-length": String(bytes.byteLength) }, + }); + return json({ + data: { + id: "d1", + name: "Brief", + extension: "pdf", + mime: "application/pdf", + version: 3, + size: bytes.byteLength, + }, + }); + }); + const doc = await adapter().fetchDocument("d1"); + expect(doc).not.toBeNull(); + expect(doc!.version).toBe("3"); + expect(doc!.metadata.extension).toBe("pdf"); + expect(doc!.metadata.sizeBytes).toBe(bytes.byteLength); + expect(new Uint8Array(doc!.content)).toEqual(bytes); + expect(calls.some((c) => c.url === `${ROOT}/documents/d1/download`)).toBe( + true, + ); + }); + + it("exports a new version and returns the new version id", async () => { + mockFetch(() => json({ data: { version: 4 } })); + const content = new TextEncoder().encode("new content").buffer; + const res = await adapter().exportDocument("d1", content as ArrayBuffer, { + newVersion: true, + }); + expect(res).toEqual({ docId: "d1", version: "4" }); + expect(calls[0].url).toBe(`${ROOT}/documents/d1/versions`); + expect(calls[0].init?.method).toBe("POST"); + }); + + it("routes every request through the SSRF guard (redirect:manual)", async () => { + mockFetch(() => json({ data: [] })); + await adapter().authenticate(); + // guardedFetch always injects redirect:"manual" + a pinned dispatcher. + expect((calls[0].init as RequestInit).redirect).toBe("manual"); + expect(lookupMock).toHaveBeenCalled(); + }); + + it("rejects a tenant host that resolves to a private IP", async () => { + lookupMock.mockResolvedValue([{ address: "10.0.0.5", family: 4 }]); + mockFetch(() => json({ data: [] })); + const res = await adapter().authenticate(); + expect(res.ok).toBe(false); + expect(res.error).toMatch(/blocked network address/); + }); +}); diff --git a/backend/src/lib/dms/__tests__/netdocuments.test.ts b/backend/src/lib/dms/__tests__/netdocuments.test.ts new file mode 100644 index 000000000..3915c044d --- /dev/null +++ b/backend/src/lib/dms/__tests__/netdocuments.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { lookupMock } = vi.hoisted(() => ({ lookupMock: vi.fn() })); +vi.mock("dns/promises", () => ({ default: { lookup: lookupMock } })); + +import { NetDocumentsAdapter } from "../netdocuments"; + +const BASE = "https://api.netdocuments.com"; +const TOKEN = "netdocs-access-token"; +const V2 = `${BASE}/v2`; + +interface Captured { + url: string; + init: RequestInit | undefined; +} +let calls: Captured[]; + +function mockFetch(handler: (url: string, init?: RequestInit) => Response) { + return vi + .spyOn(globalThis, "fetch") + .mockImplementation((input: unknown, init?: RequestInit) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : (input as Request).url; + calls.push({ url, init }); + return Promise.resolve(handler(url, init)); + }); +} + +function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function adapter() { + return new NetDocumentsAdapter({ + baseUrl: BASE, + repository: "CAB-1", + getAccessToken: async () => TOKEN, + }); +} + +beforeEach(() => { + calls = []; + lookupMock.mockReset(); + lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("NetDocumentsAdapter", () => { + it("is enabled only with base url + cabinet", () => { + expect(adapter().enabled).toBe(true); + expect( + new NetDocumentsAdapter({ + baseUrl: BASE, + getAccessToken: async () => TOKEN, + }).enabled, + ).toBe(false); + }); + + it("authenticates against the cabinet info endpoint with a Bearer token", async () => { + mockFetch(() => json({ id: "CAB-1" })); + const res = await adapter().authenticate(); + expect(res.ok).toBe(true); + expect(calls[0].url).toBe(`${V2}/cabinet/CAB-1/info`); + const headers = new Headers(calls[0].init?.headers as HeadersInit); + expect(headers.get("authorization")).toBe(`Bearer ${TOKEN}`); + }); + + it("lists cabinet folders at top level and folder content below", async () => { + mockFetch((url) => { + if (url.endsWith("/folders")) + return json({ results: [{ id: "fld1", name: "Deals" }] }); + return json({ results: [{ id: "fld2", name: "NDAs" }] }); + }); + const dms = adapter(); + const top = await dms.listFolders(); + expect(top).toEqual([{ id: "fld1", name: "Deals", parentId: null }]); + const children = await dms.listFolders("fld1"); + expect(children).toEqual([{ id: "fld2", name: "NDAs", parentId: "fld1" }]); + expect(calls[1].url).toBe(`${V2}/folder/fld1/content?type=folder`); + }); + + it("searches the cabinet and surfaces the version", async () => { + mockFetch(() => + json({ results: [{ id: "e1", name: "NDA.pdf", ext: "pdf", version: 2 }] }), + ); + const results = await adapter().search("nda", { limit: 5 }); + expect(results).toEqual([ + { + id: "e1", + name: "NDA.pdf", + folderId: null, + contentType: "application/pdf", + version: "2", + }, + ]); + expect(calls[0].url).toContain(`${V2}/search/CAB-1?`); + expect(calls[0].url).toContain("q=nda"); + }); + + it("fetches a document with metadata + bytes + version", async () => { + const bytes = new TextEncoder().encode("%PDF nd body"); + mockFetch((url) => { + if (url.endsWith("/content")) + return new Response(bytes, { + status: 200, + headers: { "content-length": String(bytes.byteLength) }, + }); + return json({ + id: "e1", + name: "NDA.pdf", + ext: "pdf", + version: 2, + size: bytes.byteLength, + }); + }); + const doc = await adapter().fetchDocument("e1"); + expect(doc).not.toBeNull(); + expect(doc!.version).toBe("2"); + expect(doc!.metadata.extension).toBe("pdf"); + expect(new Uint8Array(doc!.content)).toEqual(bytes); + expect(calls.some((c) => c.url === `${V2}/document/e1/content`)).toBe(true); + }); + + it("exports a new version via AddVersion and returns the version id", async () => { + mockFetch(() => json({ data: { version: 3 } })); + const content = new TextEncoder().encode("v3").buffer; + const res = await adapter().exportDocument("e1", content as ArrayBuffer, { + newVersion: true, + }); + expect(res).toEqual({ docId: "e1", version: "3" }); + expect(calls[0].url).toBe(`${V2}/document/e1/version`); + expect(calls[0].init?.method).toBe("POST"); + }); + + it("routes egress through the SSRF guard", async () => { + mockFetch(() => json({ id: "CAB-1" })); + await adapter().authenticate(); + expect((calls[0].init as RequestInit).redirect).toBe("manual"); + expect(lookupMock).toHaveBeenCalled(); + }); +}); diff --git a/backend/src/lib/dms/__tests__/oauth.test.ts b/backend/src/lib/dms/__tests__/oauth.test.ts new file mode 100644 index 000000000..9af6523ca --- /dev/null +++ b/backend/src/lib/dms/__tests__/oauth.test.ts @@ -0,0 +1,196 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock DNS so the guarded token-endpoint fetch clears the SSRF guard. +const { lookupMock } = vi.hoisted(() => ({ lookupMock: vi.fn() })); +vi.mock("dns/promises", () => ({ default: { lookup: lookupMock } })); + +import { + completeDmsConnectorOAuth, + getValidDmsAccessToken, + startDmsConnectorOAuth, +} from "../oauth"; +import { createFakeSupabase, type FakeDb } from "./fakeDb"; + +const BASE = "https://tenant.imanage.com"; +const CONNECTOR_ID = "conn-1"; +const USER_ID = "user-1"; +const REDIRECT = "https://app.example.com/user/dms-connectors/oauth/callback"; + +interface Captured { + url: string; + init: RequestInit | undefined; +} +let calls: Captured[]; + +function mockFetch(handler: (url: string, init?: RequestInit) => Response) { + return vi + .spyOn(globalThis, "fetch") + .mockImplementation((input: unknown, init?: RequestInit) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : (input as Request).url; + calls.push({ url, init }); + return Promise.resolve(handler(url, init)); + }); +} + +function tokenJson(body: Record) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +function seededDb(): FakeDb { + return createFakeSupabase({ + dms_connectors: [ + { + id: CONNECTOR_ID, + user_id: USER_ID, + kind: "imanage", + name: "iManage", + base_url: BASE, + auth_type: "oauth", + enabled: true, + config: {}, + }, + ], + dms_connector_oauth_tokens: [], + dms_connector_oauth_states: [], + }); +} + +function stateFromUrl(url: string): string { + return new URL(url).searchParams.get("state") ?? ""; +} + +beforeAll(() => { + process.env.MCP_CONNECTORS_ENCRYPTION_SECRET = + "dms-test-master-secret-at-least-32-chars"; +}); + +beforeEach(() => { + calls = []; + lookupMock.mockReset(); + lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]); + process.env.IMANAGE_OAUTH_CLIENT_ID = "client-abc"; + process.env.IMANAGE_OAUTH_CLIENT_SECRET = "secret-xyz"; + process.env.IMANAGE_OAUTH_SCOPE = "user"; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("DMS OAuth flow", () => { + it("start returns an authorize URL with PKCE + state and persists state", async () => { + const db = seededDb(); + const { authorizationUrl } = await startDmsConnectorOAuth( + USER_ID, + CONNECTOR_ID, + REDIRECT, + db as never, + ); + const url = new URL(authorizationUrl); + expect(url.origin + url.pathname).toBe(`${BASE}/auth/oauth2/authorize`); + expect(url.searchParams.get("client_id")).toBe("client-abc"); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.get("code_challenge")).toBeTruthy(); + expect(url.searchParams.get("state")).toBeTruthy(); + expect(url.searchParams.get("scope")).toBe("user"); + expect(db._tables.dms_connector_oauth_states).toHaveLength(1); + }); + + it("start fails when no OAuth client credentials are configured", async () => { + delete process.env.IMANAGE_OAUTH_CLIENT_ID; + const db = seededDb(); + await expect( + startDmsConnectorOAuth(USER_ID, CONNECTOR_ID, REDIRECT, db as never), + ).rejects.toThrow(/client credentials/); + }); + + it("callback exchanges the code and stores encrypted tokens", async () => { + const db = seededDb(); + const { authorizationUrl } = await startDmsConnectorOAuth( + USER_ID, + CONNECTOR_ID, + REDIRECT, + db as never, + ); + const state = stateFromUrl(authorizationUrl); + + mockFetch(() => + tokenJson({ + access_token: "access-1", + refresh_token: "refresh-1", + expires_in: 3600, + token_type: "Bearer", + }), + ); + const result = await completeDmsConnectorOAuth( + state, + "auth-code", + db as never, + ); + expect(result).toEqual({ + userId: USER_ID, + connectorId: CONNECTOR_ID, + }); + // Token endpoint hit with the auth-code grant + PKCE verifier. + expect(calls[0].url).toBe(`${BASE}/auth/oauth2/token`); + const body = String((calls[0].init as RequestInit).body); + expect(body).toContain("grant_type=authorization_code"); + expect(body).toContain("code=auth-code"); + expect(body).toContain("code_verifier="); + // Stored token is encrypted (not the plaintext) and the state consumed. + const stored = db._tables.dms_connector_oauth_tokens[0]; + expect(stored.encrypted_access_token).toBeTruthy(); + expect(String(stored.encrypted_access_token)).not.toContain("access-1"); + expect(db._tables.dms_connector_oauth_states).toHaveLength(0); + + // A valid, non-expiring token is returned as-is (no refresh call). + calls.length = 0; + const token = await getValidDmsAccessToken(CONNECTOR_ID, db as never); + expect(token).toBe("access-1"); + expect(calls).toHaveLength(0); + }); + + it("refreshes the access token when it is within the expiry skew", async () => { + const db = seededDb(); + const { authorizationUrl } = await startDmsConnectorOAuth( + USER_ID, + CONNECTOR_ID, + REDIRECT, + db as never, + ); + const state = stateFromUrl(authorizationUrl); + mockFetch(() => + tokenJson({ + access_token: "access-1", + refresh_token: "refresh-1", + expires_in: 3600, + }), + ); + await completeDmsConnectorOAuth(state, "auth-code", db as never); + + // Force the stored token to look near-expiry so the next read refreshes. + db._tables.dms_connector_oauth_tokens[0].expires_at = new Date( + Date.now() + 10_000, + ).toISOString(); + + calls.length = 0; + mockFetch((_url, init) => { + expect(String((init as RequestInit).body)).toContain( + "grant_type=refresh_token", + ); + return tokenJson({ access_token: "access-2", expires_in: 3600 }); + }); + const token = await getValidDmsAccessToken(CONNECTOR_ID, db as never); + expect(token).toBe("access-2"); + expect(calls[0].url).toBe(`${BASE}/auth/oauth2/token`); + }); +}); diff --git a/backend/src/lib/dms/adapter.ts b/backend/src/lib/dms/adapter.ts new file mode 100644 index 000000000..0f7bf0a78 --- /dev/null +++ b/backend/src/lib/dms/adapter.ts @@ -0,0 +1,158 @@ +/** + * Contract every Document Management System (DMS) connector must implement. + * + * This mirrors the StorageAdapter pluggability pattern (lib/storage/adapter.ts) + * exactly: a small interface with a swappable registry (lib/dms/index.ts) so a + * new DMS vendor can be added by implementing this interface and registering a + * factory — no caller needs to change. + * + * The default adapter is FakeDMSAdapter (in-memory, deterministic, usable + * air-gapped). The cloud adapters are iManageAdapter and NetDocumentsAdapter, + * both isolated behind this interface and routing every outbound request + * through the SSRF-guarded egress helper (lib/mcp/client.ts `guardedFetch`). + * + * Like StorageAdapter, methods return empty / null when the connector is + * disabled rather than throwing, so callers degrade gracefully. + * + * NOTE: The iManage and NetDocuments adapters are validated in CI ONLY against + * mocked HTTP transports (see the __tests__ folder). Endpoint paths, paging, + * and version semantics are best-effort from public API docs — LIVE TENANT + * VALIDATION requires real OAuth client credentials, a tenant base URL, and + * library/cabinet IDs, and is an operator acceptance step, not a unit test. + */ + +/** The set of DMS backends Mike knows how to talk to. */ +export type DmsKind = "fake" | "imanage" | "netdocuments"; + +/** A folder (iManage folder / NetDocuments folder) in the DMS tree. */ +export interface DmsFolder { + id: string; + name: string; + /** Parent folder id, or null for a top-level container (library/cabinet). */ + parentId: string | null; +} + +/** A single hit from a DMS search. */ +export interface DmsSearchResult { + id: string; + name: string; + folderId: string | null; + /** MIME type when the DMS reports one. */ + contentType: string | null; + /** Vendor version identifier of the matched document, when known. */ + version: string | null; +} + +/** Metadata describing a fetched document, independent of vendor. */ +export interface DmsDocumentMetadata { + id: string; + name: string; + contentType: string; + /** Normalized file extension ("pdf" | "docx" | "doc"). */ + extension: string; + sizeBytes: number | null; + folderId: string | null; + author?: string | null; + updatedAt?: string | null; +} + +/** A document fetched from the DMS: raw bytes + metadata + vendor version. */ +export interface DmsDocument { + content: ArrayBuffer; + metadata: DmsDocumentMetadata; + /** Vendor version identifier the bytes were fetched at. */ + version: string; +} + +export interface DmsSearchOptions { + /** Restrict the search to a folder subtree when supported. */ + folderId?: string | null; + /** Cap the number of results (adapters clamp to a sane maximum). */ + limit?: number; +} + +export interface DmsExportOptions { + /** + * When true, push the content back as a NEW version of docId rather than + * overwriting the current version. Both iManage and NetDocuments model this + * as an explicit add-version operation. + */ + newVersion?: boolean; + /** Optional filename to record on the exported version. */ + filename?: string; + contentType?: string; +} + +/** Result of an export back to the DMS. */ +export interface DmsExportResult { + docId: string; + /** Vendor version identifier the export produced. */ + version: string; +} + +export interface DmsAuthResult { + ok: boolean; + error?: string; +} + +/** + * Configuration handed to an adapter factory. `getAccessToken` returns a fresh, + * refreshed OAuth bearer token on demand (the real adapters call it per request + * so a near-expiry token is transparently refreshed). The Fake adapter ignores + * everything here. + */ +export interface DmsAdapterConfig { + /** Tenant base URL (already SSRF-validated on save). */ + baseUrl: string; + /** Resolves a valid OAuth access token for the current connector. */ + getAccessToken?: () => Promise; + /** iManage: customer id + library scoping. NetDocuments: cabinet id. */ + customerId?: string | null; + library?: string | null; + repository?: string | null; +} + +export interface DmsConnector { + /** Which backend this adapter talks to. */ + readonly kind: DmsKind; + + /** True when the adapter is fully configured and ready to use. */ + readonly enabled: boolean; + + /** + * Validate that the configured credentials work against the DMS. Returns + * ok:false with an error string rather than throwing on an auth failure. + */ + authenticate(): Promise; + + /** + * List folders under parentId (top-level containers when parentId is + * omitted). Returns [] when the connector is disabled. + */ + listFolders(parentId?: string | null): Promise; + + /** Full-text/metadata search. Returns [] when the connector is disabled. */ + search(query: string, opts?: DmsSearchOptions): Promise; + + /** + * Fetch a document's bytes + metadata + version. Returns null when absent + * or when the connector is disabled. + */ + fetchDocument(docId: string): Promise; + + /** + * Push content back to the DMS, optionally as a new version, and return the + * resulting version id. + */ + exportDocument( + docId: string, + content: ArrayBuffer, + opts?: DmsExportOptions, + ): Promise; + + /** + * Health-check the connector. + * ok:true with latency when reachable, ok:false with error otherwise. + */ + checkReady(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>; +} diff --git a/backend/src/lib/dms/crypto.ts b/backend/src/lib/dms/crypto.ts new file mode 100644 index 000000000..5455745e4 --- /dev/null +++ b/backend/src/lib/dms/crypto.ts @@ -0,0 +1,17 @@ +/** + * DMS secret handling reuses the EXACT AES-256-GCM scheme the MCP connectors + * use (lib/mcp/client.ts). We deliberately do not reinvent crypto: the same + * master secret, the same key derivation, and the same fail-closed decrypt + * path back every encrypted DMS auth config and OAuth token column. + * + * The master secret resolves in this order (see mcp/client.ts::encryptionSecret): + * MCP_CONNECTORS_ENCRYPTION_SECRET → USER_API_KEYS_ENCRYPTION_SECRET + * DMS_CONNECTORS_ENCRYPTION_SECRET is accepted as an alias by copying it onto + * MCP_CONNECTORS_ENCRYPTION_SECRET at startup (see src/index.ts) so operators + * can name the variable after the feature without a second crypto + * implementation. + */ +export { + encryptString, + decryptString, +} from "../mcp/client"; diff --git a/backend/src/lib/dms/fake.ts b/backend/src/lib/dms/fake.ts new file mode 100644 index 000000000..499a476d2 --- /dev/null +++ b/backend/src/lib/dms/fake.ts @@ -0,0 +1,192 @@ +/** + * In-memory DMS connector. Deterministic, dependency-free, and usable + * air-gapped (no network egress ever), so it is the default adapter and the + * backbone of the DMS test suite. + * + * It stores folders and documents (with a per-document version list) in plain + * Maps. Seeding is explicit via seed()/reset() so tests are hermetic. + */ +import type { + DmsAdapterConfig, + DmsAuthResult, + DmsConnector, + DmsDocument, + DmsExportOptions, + DmsExportResult, + DmsFolder, + DmsKind, + DmsSearchOptions, + DmsSearchResult, +} from "./adapter"; + +interface FakeVersion { + version: string; + content: ArrayBuffer; +} + +interface FakeDoc { + id: string; + name: string; + folderId: string | null; + contentType: string; + extension: string; + author: string | null; + updatedAt: string; + versions: FakeVersion[]; +} + +const textEncoder = new TextEncoder(); + +function toArrayBuffer(text: string): ArrayBuffer { + const view = textEncoder.encode(text); + return view.buffer.slice( + view.byteOffset, + view.byteOffset + view.byteLength, + ) as ArrayBuffer; +} + +export class FakeDMSAdapter implements DmsConnector { + public readonly kind: DmsKind = "fake"; + // The Fake is always ready — it needs no credentials and no network. + public readonly enabled = true; + + private readonly folders = new Map(); + private readonly docs = new Map(); + + constructor(_config?: DmsAdapterConfig) { + void _config; + } + + /** Wipe all in-memory state (call in test setup). */ + reset(): void { + this.folders.clear(); + this.docs.clear(); + } + + /** Seed a folder. Chainable for terse test fixtures. */ + seedFolder(folder: DmsFolder): this { + this.folders.set(folder.id, { ...folder }); + return this; + } + + /** + * Seed a document with V1 content. Returns the seeded id. Content may be a + * string (encoded UTF-8) or raw bytes. + */ + seedDocument(doc: { + id: string; + name: string; + folderId?: string | null; + contentType?: string; + extension?: string; + author?: string | null; + content: string | ArrayBuffer; + }): string { + const content = + typeof doc.content === "string" + ? toArrayBuffer(doc.content) + : doc.content; + this.docs.set(doc.id, { + id: doc.id, + name: doc.name, + folderId: doc.folderId ?? null, + contentType: doc.contentType ?? "application/pdf", + extension: doc.extension ?? "pdf", + author: doc.author ?? null, + updatedAt: "2026-01-01T00:00:00.000Z", + versions: [{ version: "1", content }], + }); + return doc.id; + } + + async authenticate(): Promise { + return { ok: true }; + } + + async listFolders(parentId?: string | null): Promise { + const target = parentId ?? null; + return [...this.folders.values()] + .filter((f) => f.parentId === target) + .sort((a, b) => a.id.localeCompare(b.id)); + } + + async search( + query: string, + opts: DmsSearchOptions = {}, + ): Promise { + const needle = query.trim().toLowerCase(); + const limit = opts.limit ?? 50; + return [...this.docs.values()] + .filter((d) => { + if (opts.folderId && d.folderId !== opts.folderId) return false; + if (!needle) return true; + return d.name.toLowerCase().includes(needle); + }) + .sort((a, b) => a.id.localeCompare(b.id)) + .slice(0, limit) + .map((d) => ({ + id: d.id, + name: d.name, + folderId: d.folderId, + contentType: d.contentType, + version: d.versions[d.versions.length - 1]?.version ?? null, + })); + } + + async fetchDocument(docId: string): Promise { + const doc = this.docs.get(docId); + if (!doc) return null; + const latest = doc.versions[doc.versions.length - 1]; + return { + content: latest.content, + version: latest.version, + metadata: { + id: doc.id, + name: doc.name, + contentType: doc.contentType, + extension: doc.extension, + sizeBytes: latest.content.byteLength, + folderId: doc.folderId, + author: doc.author, + updatedAt: doc.updatedAt, + }, + }; + } + + async exportDocument( + docId: string, + content: ArrayBuffer, + opts: DmsExportOptions = {}, + ): Promise { + const doc = this.docs.get(docId); + if (!doc) { + throw new Error(`FakeDMSAdapter: unknown document ${docId}`); + } + if (opts.newVersion === false) { + // Overwrite the current version in place. + const current = doc.versions[doc.versions.length - 1]; + current.content = content; + return { docId, version: current.version }; + } + const nextVersion = String(doc.versions.length + 1); + doc.versions.push({ version: nextVersion, content }); + doc.updatedAt = new Date().toISOString(); + return { docId, version: nextVersion }; + } + + async checkReady(): Promise<{ + ok: boolean; + latencyMs?: number; + error?: string; + }> { + return { ok: true, latencyMs: 0 }; + } +} + +/** + * A process-wide shared Fake instance. The registry's default `fake` factory + * returns this singleton so a connector row of kind `fake` and any direct + * caller observe the same seeded state (mirrors how a real DMS is one backing + * store). Tests seed/reset it explicitly. + */ +export const sharedFakeDms = new FakeDMSAdapter(); diff --git a/backend/src/lib/dms/http.ts b/backend/src/lib/dms/http.ts new file mode 100644 index 000000000..d2ee035bc --- /dev/null +++ b/backend/src/lib/dms/http.ts @@ -0,0 +1,138 @@ +/** + * Shared guarded-egress helpers for the cloud DMS adapters. + * + * EVERY outbound request goes through `guardedFetch` (lib/mcp/client.ts): it is + * HTTPS-only, runs the private-IP SSRF check (`validateRemoteMcpUrl` via + * lib/privateIp.ts `isBlockedIp`), pins the connection to the connect-time + * validated address (no DNS-rebinding/TOCTOU window), and refuses to auto-follow + * redirects (`redirect: "manual"`) so a 3xx to an internal host cannot smuggle + * egress past the guard. iManage/NetDocuments are public SaaS, so they clear the + * private-IP guard but still gain the TLS/redirect/pinning protections. + * + * A DMS content endpoint that legitimately 3xx-redirects to a CDN would surface + * as a non-2xx here; the caller must follow it explicitly and re-validate the + * target rather than the guard being relaxed (see the risks note in the spec). + */ +import { guardedFetch } from "../mcp/client"; +import { MAX_UPLOAD_SIZE_BYTES } from "../upload"; + +function authHeaders(token: string, extra?: Record) { + return { + Authorization: `Bearer ${token}`, + Accept: "application/json", + ...(extra ?? {}), + }; +} + +/** GET/POST a JSON endpoint through the guarded fetch and parse the body. */ +export async function dmsJson( + url: string, + token: string, + init?: { + method?: string; + body?: string; + headers?: Record; + }, +): Promise> { + const response = await guardedFetch(url, { + method: init?.method ?? "GET", + headers: authHeaders(token, { + ...(init?.body ? { "Content-Type": "application/json" } : {}), + ...(init?.headers ?? {}), + }), + ...(init?.body ? { body: init.body } : {}), + }); + if (!response.ok) { + throw new Error( + `DMS request to ${redact(url)} failed (${response.status}).`, + ); + } + const parsed = await response.json(); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`DMS response from ${redact(url)} was not an object.`); + } + return parsed as Record; +} + +/** + * Download bytes from a DMS content endpoint, enforcing the same 100MB ceiling + * as the upload pipeline (lib/upload.ts) so a large document cannot exhaust + * memory when buffered as an ArrayBuffer. + */ +export async function dmsBytes( + url: string, + token: string, +): Promise { + const response = await guardedFetch(url, { + method: "GET", + headers: { Authorization: `Bearer ${token}` }, + }); + if (!response.ok) { + throw new Error( + `DMS download from ${redact(url)} failed (${response.status}).`, + ); + } + const declared = Number(response.headers.get("content-length") ?? "0"); + if (declared && declared > MAX_UPLOAD_SIZE_BYTES) { + throw new Error( + `DMS document exceeds the ${MAX_UPLOAD_SIZE_BYTES}-byte limit.`, + ); + } + const buf = await response.arrayBuffer(); + if (buf.byteLength > MAX_UPLOAD_SIZE_BYTES) { + throw new Error( + `DMS document exceeds the ${MAX_UPLOAD_SIZE_BYTES}-byte limit.`, + ); + } + return buf; +} + +/** POST raw bytes (a new document version) through the guarded fetch. */ +export async function dmsPostBytes( + url: string, + token: string, + content: ArrayBuffer, + headers?: Record, +): Promise> { + const response = await guardedFetch(url, { + method: "POST", + headers: authHeaders(token, { + "Content-Type": "application/octet-stream", + ...(headers ?? {}), + }), + body: Buffer.from(content), + }); + if (!response.ok) { + throw new Error( + `DMS export to ${redact(url)} failed (${response.status}).`, + ); + } + const parsed = await response.json().catch(() => ({})); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; +} + +/** Strip query strings from a URL before it reaches a log/error message. */ +function redact(url: string): string { + try { + const u = new URL(url); + return `${u.origin}${u.pathname}`; + } catch { + return "the DMS endpoint"; + } +} + +/** Best-effort mapping of a DMS content type / filename to Mike's file_type. */ +export function normalizeExtension( + name: string | null | undefined, + contentType: string | null | undefined, +): string { + const fromName = (name ?? "").toLowerCase().match(/\.(pdf|docx|doc)$/)?.[1]; + if (fromName) return fromName; + const ct = (contentType ?? "").toLowerCase(); + if (ct.includes("pdf")) return "pdf"; + if (ct.includes("wordprocessingml")) return "docx"; + if (ct.includes("msword")) return "doc"; + return "pdf"; +} diff --git a/backend/src/lib/dms/imanage.ts b/backend/src/lib/dms/imanage.ts new file mode 100644 index 000000000..83279efce --- /dev/null +++ b/backend/src/lib/dms/imanage.ts @@ -0,0 +1,221 @@ +/** + * iManage Work adapter (OAuth2 auth-code + refresh, iManage Work REST /api/v2). + * + * All egress routes through the shared guarded-fetch helpers (lib/dms/http.ts → + * lib/mcp/client.ts `guardedFetch`), so the connector inherits the MCP SSRF + * hardening unchanged. + * + * LIVE TENANT VALIDATION REQUIRED: the endpoint paths, response envelopes, and + * version-numbering below are best-effort from public iManage Work API docs + * (/api/v2 with customer-id + library scoping). They are proven only against + * the mocked HTTP contracts in __tests__/imanage.test.ts. Confirming them + * against a real tenant — which needs OAuth client credentials, the tenant base + * URL, and a customer id + library id — is an operator acceptance step. + */ +import type { + DmsAdapterConfig, + DmsAuthResult, + DmsConnector, + DmsDocument, + DmsExportOptions, + DmsExportResult, + DmsFolder, + DmsKind, + DmsSearchOptions, + DmsSearchResult, +} from "./adapter"; +import { DMS_SEARCH_LIMIT } from "./types"; +import { dmsBytes, dmsJson, dmsPostBytes, normalizeExtension } from "./http"; + +function asArray(value: unknown): Record[] { + if (Array.isArray(value)) return value as Record[]; + return []; +} + +function str(value: unknown): string | null { + return typeof value === "string" && value.length ? value : null; +} + +export class IManageAdapter implements DmsConnector { + public readonly kind: DmsKind = "imanage"; + + private readonly baseUrl: string; + private readonly customerId: string; + private readonly library: string; + private readonly getAccessToken: () => Promise; + + constructor(config: DmsAdapterConfig) { + this.baseUrl = config.baseUrl.replace(/\/+$/, ""); + this.customerId = String(config.customerId ?? ""); + this.library = String(config.library ?? ""); + this.getAccessToken = + config.getAccessToken ?? + (() => { + throw new Error( + "iManage connector has no OAuth token provider configured.", + ); + }); + } + + // Enabled only when we have both a base URL and the customer/library scope + // iManage Work requires to address any resource. + public get enabled(): boolean { + return Boolean(this.baseUrl && this.customerId && this.library); + } + + /** {baseUrl}/api/v2/customers/{customerId}/libraries/{library} */ + private root(): string { + return `${this.baseUrl}/api/v2/customers/${encodeURIComponent( + this.customerId, + )}/libraries/${encodeURIComponent(this.library)}`; + } + + async authenticate(): Promise { + if (!this.enabled) { + return { ok: false, error: "iManage connector is not configured." }; + } + try { + const token = await this.getAccessToken(); + // A cheap, read-only probe: list the library's top-level workspaces. + await dmsJson(`${this.root()}/workspaces?limit=1`, token); + return { ok: true }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } + } + + async listFolders(parentId?: string | null): Promise { + if (!this.enabled) return []; + const token = await this.getAccessToken(); + // Top-level = the library's workspaces; deeper = a folder's children. + const url = parentId + ? `${this.root()}/folders/${encodeURIComponent(parentId)}/children` + : `${this.root()}/workspaces`; + const body = await dmsJson(url, token); + return asArray(body.data).map((row) => ({ + id: String(row.id ?? row.wstype ?? ""), + name: str(row.name) ?? String(row.id ?? ""), + parentId: parentId ?? null, + })); + } + + async search( + query: string, + opts: DmsSearchOptions = {}, + ): Promise { + if (!this.enabled) return []; + const token = await this.getAccessToken(); + const limit = Math.min(opts.limit ?? DMS_SEARCH_LIMIT, DMS_SEARCH_LIMIT); + const params = new URLSearchParams({ + q: query, + limit: String(limit), + }); + if (opts.folderId) params.set("folder_id", opts.folderId); + const body = await dmsJson( + `${this.root()}/documents/search?${params.toString()}`, + token, + ); + return asArray(body.data).map((row) => ({ + id: String(row.id ?? ""), + name: str(row.name) ?? String(row.id ?? ""), + folderId: str(row.folder_id), + contentType: str(row.mime), + version: + row.version !== undefined && row.version !== null + ? String(row.version) + : null, + })); + } + + async fetchDocument(docId: string): Promise { + if (!this.enabled) return null; + const token = await this.getAccessToken(); + let meta: Record; + try { + const body = await dmsJson( + `${this.root()}/documents/${encodeURIComponent(docId)}`, + token, + ); + meta = + body.data && typeof body.data === "object" + ? (body.data as Record) + : body; + } catch { + return null; + } + const content = await dmsBytes( + `${this.root()}/documents/${encodeURIComponent(docId)}/download`, + token, + ); + const name = str(meta.name) ?? docId; + const extension = normalizeExtension( + str(meta.extension) ? `${name}.${String(meta.extension)}` : name, + str(meta.mime), + ); + const version = + meta.version !== undefined && meta.version !== null + ? String(meta.version) + : "1"; + return { + content, + version, + metadata: { + id: str(meta.id) ?? docId, + name, + contentType: str(meta.mime) ?? "application/octet-stream", + extension, + sizeBytes: + typeof meta.size === "number" + ? meta.size + : content.byteLength, + folderId: str(meta.folder_id), + author: str(meta.author), + updatedAt: str(meta.edit_date) ?? str(meta.update_date), + }, + }; + } + + async exportDocument( + docId: string, + content: ArrayBuffer, + opts: DmsExportOptions = {}, + ): Promise { + if (!this.enabled) { + throw new Error("iManage connector is not configured."); + } + const token = await this.getAccessToken(); + // iManage models a round-trip write as a NEW version on the document. + // newVersion defaults to true; an in-place overwrite would target + // /documents/{id}/update, which we intentionally do not do by default + // to preserve the DMS audit trail. + const url = + opts.newVersion === false + ? `${this.root()}/documents/${encodeURIComponent(docId)}/update` + : `${this.root()}/documents/${encodeURIComponent(docId)}/versions`; + const body = await dmsPostBytes(url, token, content, { + ...(opts.filename + ? { "X-Document-Name": opts.filename } + : {}), + }); + const data = + body.data && typeof body.data === "object" + ? (body.data as Record) + : body; + const version = + data.version !== undefined && data.version !== null + ? String(data.version) + : "unknown"; + return { docId, version }; + } + + async checkReady() { + const started = Date.now(); + const result = await this.authenticate(); + return result.ok + ? { ok: true, latencyMs: Date.now() - started } + : { ok: false, error: result.error }; + } +} diff --git a/backend/src/lib/dms/import.ts b/backend/src/lib/dms/import.ts new file mode 100644 index 000000000..7c08e5787 --- /dev/null +++ b/backend/src/lib/dms/import.ts @@ -0,0 +1,116 @@ +/** + * DMS import/export wiring. + * + * Import REUSES the existing upload pipeline: a fetched DMS document is handed + * to createDocumentFromUpload (routes/documents.ts) so it lands as a + * `documents` row + V1 `document_versions` row (source "dms_import"), stored + * via the same uploadFile pipeline as an interactive upload. A + * dms_document_links row records the external doc id + version so a later + * export can round-trip to the right DMS document. + */ +import { createDocumentFromUpload } from "../../routes/documents"; +import type { DmsDocument } from "./adapter"; +import type { Db } from "./types"; + +/** Ensure a filename carries the expected extension for the upload pipeline. */ +function ensureExtension(name: string, suffix: string): string { + const clean = (name || "document").trim() || "document"; + return clean.toLowerCase().endsWith(`.${suffix}`) + ? clean + : `${clean}.${suffix}`; +} + +const IMPORTABLE_SUFFIXES = new Set(["pdf", "docx", "doc"]); + +export interface DmsImportResult { + documentId: string; + doc: unknown; +} + +/** + * Insert a fetched DMS document as a project document + V1 version and record + * its external provenance. The caller MUST have already authorized the user + * against projectId (see lib/dms/servers.ts, which checks checkProjectAccess). + */ +export async function importDmsDocumentToProject( + params: { + userId: string; + projectId: string | null; + connectorId: string; + dmsDocId: string; + document: DmsDocument; + }, + db: Db, +): Promise< + | { ok: true; result: DmsImportResult } + | { ok: false; detail: string } +> { + const { userId, projectId, connectorId, dmsDocId, document } = params; + const suffix = document.metadata.extension; + if (!IMPORTABLE_SUFFIXES.has(suffix)) { + return { + ok: false, + detail: `Unsupported DMS document type "${suffix}". Only PDF, DOCX and DOC can be imported.`, + }; + } + const filename = ensureExtension(document.metadata.name, suffix); + const content = Buffer.from(document.content); + + const created = await createDocumentFromUpload( + { userId, projectId, filename, suffix, content, source: "dms_import" }, + // routes/documents.ts and lib/dms share the same supabase client type. + db as unknown as Parameters[1], + ); + if (!created.ok) { + return { + ok: false, + detail: + created.kind === "processing_failed" + ? created.detail + : "Failed to create document from DMS import.", + }; + } + const doc = created.doc as { id: string }; + + // Record the external mapping so exportDocument can target the right DMS + // document + version later. A failure here should not lose the imported + // document, so it is logged, not fatal. + const { error: linkError } = await db.from("dms_document_links").insert({ + document_id: doc.id, + connector_id: connectorId, + dms_doc_id: dmsDocId, + dms_version: document.version, + }); + if (linkError) { + console.error( + "[dms-connectors] failed to record dms_document_links row", + { documentId: doc.id, connectorId, error: linkError.message }, + ); + } + + return { ok: true, result: { documentId: doc.id, doc: created.doc } }; +} + +/** Look up the DMS provenance link for an imported document, if any. */ +export async function loadDmsDocumentLink( + documentId: string, + db: Db, +): Promise<{ + connector_id: string; + dms_doc_id: string; + dms_version: string | null; +} | null> { + const { data, error } = await db + .from("dms_document_links") + .select("connector_id, dms_doc_id, dms_version") + .eq("document_id", documentId) + .maybeSingle(); + if (error) throw error; + return ( + (data as { + connector_id: string; + dms_doc_id: string; + dms_version: string | null; + } | null) ?? null + ); +} diff --git a/backend/src/lib/dms/index.ts b/backend/src/lib/dms/index.ts new file mode 100644 index 000000000..3886ba587 --- /dev/null +++ b/backend/src/lib/dms/index.ts @@ -0,0 +1,95 @@ +/** + * DMS connector public API + registry. + * + * This mirrors the StorageAdapter pluggability pattern (lib/storage.ts), but + * where storage has a single swappable singleton, a DMS deployment can talk to + * several backends at once, so the registry is keyed by connector kind + * ("fake" | "imanage" | "netdocuments"). To add a vendor, implement + * DMSConnector (lib/dms/adapter.ts) and register a factory: + * + * import { registerDmsAdapter } from "./lib/dms"; + * registerDmsAdapter("worldox", (config) => new WorldoxAdapter(config)); + * + * All callers resolve an adapter via getDmsAdapter(kind, config) and never need + * to know which concrete class backs a kind — the same indirection tests use to + * swap a cloud kind for the in-memory Fake. + */ +import type { DmsAdapterConfig, DmsConnector, DmsKind } from "./adapter"; +import { FakeDMSAdapter, sharedFakeDms } from "./fake"; +import { IManageAdapter } from "./imanage"; +import { NetDocumentsAdapter } from "./netdocuments"; + +export type { DmsConnector, DmsAdapterConfig, DmsKind } from "./adapter"; +export type { + DmsFolder, + DmsSearchResult, + DmsSearchOptions, + DmsDocument, + DmsDocumentMetadata, + DmsExportOptions, + DmsExportResult, +} from "./adapter"; +export { FakeDMSAdapter, sharedFakeDms } from "./fake"; +export { IManageAdapter } from "./imanage"; +export { NetDocumentsAdapter } from "./netdocuments"; + +/** Constructs a connector for a given kind from its per-connector config. */ +export type DmsAdapterFactory = (config: DmsAdapterConfig) => DmsConnector; + +// Default factories. The `fake` factory returns the process-wide shared +// instance so a `fake` connector row and any direct caller observe one backing +// store (as they would with a real DMS). +const registry = new Map([ + ["fake", () => sharedFakeDms], + ["imanage", (config) => new IManageAdapter(config)], + ["netdocuments", (config) => new NetDocumentsAdapter(config)], +]); + +/** + * Replace or add the factory for a connector kind. Tests use this to swap a + * cloud kind ("imanage") for an in-memory FakeDMSAdapter without touching any + * caller — the DMS analog of setStorageAdapter(). + */ +export function registerDmsAdapter( + kind: DmsKind, + factory: DmsAdapterFactory, +): void { + registry.set(kind, factory); +} + +/** Resolve a connector for a kind. Throws for an unknown kind. */ +export function getDmsAdapter( + kind: DmsKind, + config: DmsAdapterConfig, +): DmsConnector { + const factory = registry.get(kind); + if (!factory) { + throw new Error(`No DMS adapter registered for kind "${kind}".`); + } + return factory(config); +} + +/** Every kind with a registered factory. */ +export function listDmsAdapters(): DmsKind[] { + return [...registry.keys()]; +} + +/** The kinds that reach an external cloud SaaS (and must be air-gap gated). */ +export const CLOUD_DMS_KINDS: DmsKind[] = ["imanage", "netdocuments"]; + +/** True when a kind reaches out to the network (everything but the Fake). */ +export function isCloudDmsKind(kind: DmsKind): boolean { + return CLOUD_DMS_KINDS.includes(kind); +} + +/** + * Restore the built-in registry (test helper). Keeps unit tests that swap a + * factory from leaking into one another. + */ +export function resetDmsRegistryForTests(): void { + registry.clear(); + registry.set("fake", () => sharedFakeDms); + registry.set("imanage", (config) => new IManageAdapter(config)); + registry.set("netdocuments", (config) => new NetDocumentsAdapter(config)); + if (sharedFakeDms instanceof FakeDMSAdapter) sharedFakeDms.reset(); +} diff --git a/backend/src/lib/dms/netdocuments.ts b/backend/src/lib/dms/netdocuments.ts new file mode 100644 index 000000000..c657e203c --- /dev/null +++ b/backend/src/lib/dms/netdocuments.ts @@ -0,0 +1,226 @@ +/** + * NetDocuments adapter (OAuth2 /v1/OAuth, REST /v2 cabinets/folders/search + + * document content + AddVersion). + * + * All egress routes through the shared guarded-fetch helpers (lib/dms/http.ts → + * lib/mcp/client.ts `guardedFetch`), so the connector inherits the MCP SSRF + * hardening unchanged. + * + * LIVE TENANT VALIDATION REQUIRED: the endpoint paths, response envelopes, and + * version semantics below are best-effort from public NetDocuments REST API + * docs (/v2 with cabinet/repository routing). They are proven only against the + * mocked HTTP contracts in __tests__/netdocuments.test.ts. Confirming them + * against a real tenant — which needs OAuth client credentials, the tenant base + * URL, and a cabinet id — is an operator acceptance step. + */ +import type { + DmsAdapterConfig, + DmsAuthResult, + DmsConnector, + DmsDocument, + DmsExportOptions, + DmsExportResult, + DmsFolder, + DmsKind, + DmsSearchOptions, + DmsSearchResult, +} from "./adapter"; +import { DMS_SEARCH_LIMIT } from "./types"; +import { dmsBytes, dmsJson, dmsPostBytes, normalizeExtension } from "./http"; + +function asArray(value: unknown): Record[] { + if (Array.isArray(value)) return value as Record[]; + return []; +} + +function str(value: unknown): string | null { + return typeof value === "string" && value.length ? value : null; +} + +export class NetDocumentsAdapter implements DmsConnector { + public readonly kind: DmsKind = "netdocuments"; + + private readonly baseUrl: string; + private readonly cabinet: string; + private readonly getAccessToken: () => Promise; + + constructor(config: DmsAdapterConfig) { + this.baseUrl = config.baseUrl.replace(/\/+$/, ""); + // NetDocuments routes by cabinet ("repository"); accept either config key. + this.cabinet = String(config.repository ?? config.library ?? ""); + this.getAccessToken = + config.getAccessToken ?? + (() => { + throw new Error( + "NetDocuments connector has no OAuth token provider configured.", + ); + }); + } + + public get enabled(): boolean { + return Boolean(this.baseUrl && this.cabinet); + } + + private v2(): string { + return `${this.baseUrl}/v2`; + } + + async authenticate(): Promise { + if (!this.enabled) { + return { + ok: false, + error: "NetDocuments connector is not configured.", + }; + } + try { + const token = await this.getAccessToken(); + // Read-only probe: the cabinet's info endpoint. + await dmsJson( + `${this.v2()}/cabinet/${encodeURIComponent(this.cabinet)}/info`, + token, + ); + return { ok: true }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } + } + + async listFolders(parentId?: string | null): Promise { + if (!this.enabled) return []; + const token = await this.getAccessToken(); + const url = parentId + ? `${this.v2()}/folder/${encodeURIComponent(parentId)}/content?type=folder` + : `${this.v2()}/cabinet/${encodeURIComponent(this.cabinet)}/folders`; + const body = await dmsJson(url, token); + // NetDocuments returns either { results: [...] } or a bare array. + const rows = asArray(body.results).length + ? asArray(body.results) + : asArray(body.data); + return rows.map((row) => ({ + id: String(row.id ?? row.envId ?? ""), + name: str(row.name) ?? String(row.id ?? ""), + parentId: parentId ?? null, + })); + } + + async search( + query: string, + opts: DmsSearchOptions = {}, + ): Promise { + if (!this.enabled) return []; + const token = await this.getAccessToken(); + const limit = Math.min(opts.limit ?? DMS_SEARCH_LIMIT, DMS_SEARCH_LIMIT); + const params = new URLSearchParams({ q: query, max: String(limit) }); + if (opts.folderId) params.set("folder", opts.folderId); + const body = await dmsJson( + `${this.v2()}/search/${encodeURIComponent(this.cabinet)}?${params.toString()}`, + token, + ); + const rows = asArray(body.results).length + ? asArray(body.results) + : asArray(body.data); + return rows.map((row) => ({ + id: String(row.id ?? row.envId ?? ""), + name: str(row.name) ?? String(row.id ?? ""), + folderId: str(row.folderId), + contentType: str(row.ext) ? `application/${row.ext}` : null, + version: + row.version !== undefined && row.version !== null + ? String(row.version) + : null, + })); + } + + async fetchDocument(docId: string): Promise { + if (!this.enabled) return null; + const token = await this.getAccessToken(); + let meta: Record; + try { + const body = await dmsJson( + `${this.v2()}/document/${encodeURIComponent(docId)}/info`, + token, + ); + meta = + body.data && typeof body.data === "object" + ? (body.data as Record) + : body; + } catch { + return null; + } + const content = await dmsBytes( + `${this.v2()}/document/${encodeURIComponent(docId)}/content`, + token, + ); + const name = + str(meta.name) ?? + (str(meta.ext) ? `${docId}.${String(meta.ext)}` : docId); + const extension = normalizeExtension( + str(meta.ext) ? `${name}.${String(meta.ext)}` : name, + null, + ); + const version = + meta.version !== undefined && meta.version !== null + ? String(meta.version) + : "1"; + return { + content, + version, + metadata: { + id: str(meta.id) ?? docId, + name, + contentType: str(meta.ext) + ? `application/${String(meta.ext)}` + : "application/octet-stream", + extension, + sizeBytes: + typeof meta.size === "number" + ? meta.size + : content.byteLength, + folderId: str(meta.folderId), + author: str(meta.author), + updatedAt: str(meta.lastMod) ?? str(meta.modified), + }, + }; + } + + async exportDocument( + docId: string, + content: ArrayBuffer, + opts: DmsExportOptions = {}, + ): Promise { + if (!this.enabled) { + throw new Error("NetDocuments connector is not configured."); + } + const token = await this.getAccessToken(); + // NetDocuments' round-trip write is "AddVersion". An in-place overwrite + // would PUT the content endpoint; we default to AddVersion to preserve + // the DMS version history. + const url = + opts.newVersion === false + ? `${this.v2()}/document/${encodeURIComponent(docId)}/content` + : `${this.v2()}/document/${encodeURIComponent(docId)}/version`; + const body = await dmsPostBytes(url, token, content, { + ...(opts.filename ? { "X-Document-Name": opts.filename } : {}), + }); + const data = + body.data && typeof body.data === "object" + ? (body.data as Record) + : body; + const version = + data.version !== undefined && data.version !== null + ? String(data.version) + : "unknown"; + return { docId, version }; + } + + async checkReady() { + const started = Date.now(); + const result = await this.authenticate(); + return result.ok + ? { ok: true, latencyMs: Date.now() - started } + : { ok: false, error: result.error }; + } +} diff --git a/backend/src/lib/dms/oauth.ts b/backend/src/lib/dms/oauth.ts new file mode 100644 index 000000000..c20b3f7b9 --- /dev/null +++ b/backend/src/lib/dms/oauth.ts @@ -0,0 +1,431 @@ +/** + * DMS OAuth2 (authorization-code + refresh) provider. + * + * Structurally this mirrors lib/mcp/oauth.ts — encrypted token storage, a + * 60-second refresh skew, and a hashed one-time state row — but iManage and + * NetDocuments both expose standard, fixed OAuth endpoints (no MCP dynamic + * discovery / dynamic client registration), so the flow is a direct auth-code + * exchange rather than driven by the MCP SDK. Every token endpoint request goes + * through the SSRF-guarded `guardedFetch`. + * + * LIVE TENANT VALIDATION REQUIRED: the per-vendor authorize/token endpoint + * paths below are best-effort from public docs and are proven only against the + * mocked HTTP in __tests__/oauth.test.ts. Real tenants need OAuth client + * credentials (IMANAGE_/NETDOCS_OAUTH_CLIENT_ID/SECRET/SCOPE) and the correct + * tenant base URL; confirming the endpoints is an operator acceptance step. + */ +import crypto from "crypto"; +import { base64Url, guardedFetch, stateHash } from "../mcp/client"; +import { createServerSupabase } from "../supabase"; +import { decryptString, encryptString } from "./crypto"; +import { + OAUTH_EXPIRY_SKEW_MS, + OAUTH_STATE_TTL_MS, + type Db, + type DmsConnectorRow, + type DmsOAuthTokenRow, +} from "./types"; +import type { DmsKind } from "./adapter"; + +export class DmsOAuthRequiredError extends Error { + code = "oauth_required"; + constructor(message = "OAuth authorization is required for this DMS connector.") { + super(message); + this.name = "DmsOAuthRequiredError"; + } +} + +export function dmsOAuthCallbackUrl(): string { + const base = ( + process.env.API_PUBLIC_URL || + process.env.BACKEND_URL || + `http://localhost:${process.env.PORT ?? "3001"}` + ).replace(/\/+$/, ""); + return `${base}/user/dms-connectors/oauth/callback`; +} + +/** + * Per-vendor authorize + token endpoints, derived from the tenant base URL. + * These are the documented defaults; a deployment can override them per + * connector via the `config` jsonb (authorization_endpoint / token_endpoint). + */ +function oauthEndpoints( + kind: DmsKind, + baseUrl: string, + config: Record | null, +): { authorizationEndpoint: string; tokenEndpoint: string } { + const base = baseUrl.replace(/\/+$/, ""); + const override = (key: string) => + typeof config?.[key] === "string" ? (config[key] as string) : null; + if (kind === "imanage") { + return { + authorizationEndpoint: + override("authorization_endpoint") ?? + `${base}/auth/oauth2/authorize`, + tokenEndpoint: + override("token_endpoint") ?? `${base}/auth/oauth2/token`, + }; + } + // NetDocuments + return { + authorizationEndpoint: + override("authorization_endpoint") ?? `${base}/v1/OAuth`, + tokenEndpoint: override("token_endpoint") ?? `${base}/v1/OAuth/token`, + }; +} + +/** + * OAuth client credentials, read from process.env directly (like mcp/oauth) so + * they resolve at call time rather than at env-module load. + */ +export function dmsOAuthClientEnv(kind: DmsKind): { + clientId?: string; + clientSecret?: string; + scope?: string; +} { + const prefix = kind === "imanage" ? "IMANAGE_OAUTH" : "NETDOCS_OAUTH"; + return { + clientId: process.env[`${prefix}_CLIENT_ID`], + clientSecret: process.env[`${prefix}_CLIENT_SECRET`], + scope: process.env[`${prefix}_SCOPE`], + }; +} + +export async function loadDmsConnector( + userId: string, + connectorId: string, + db: Db, +): Promise { + const { data, error } = await db + .from("dms_connectors") + .select("*") + .eq("user_id", userId) + .eq("id", connectorId) + .single(); + if (error) throw error; + return data as DmsConnectorRow; +} + +export async function loadDmsOAuthToken( + connectorId: string, + db: Db, +): Promise { + const { data, error } = await db + .from("dms_connector_oauth_tokens") + .select("*") + .eq("connector_id", connectorId) + .maybeSingle(); + if (error) throw error; + return (data as DmsOAuthTokenRow | null) ?? null; +} + +function secretPatch(prefix: string, value?: string | null) { + if (!value) { + return { + [`encrypted_${prefix}`]: null, + [`${prefix}_iv`]: null, + [`${prefix}_tag`]: null, + }; + } + const enc = encryptString(value); + return { + [`encrypted_${prefix}`]: enc.encrypted, + [`${prefix}_iv`]: enc.iv, + [`${prefix}_tag`]: enc.tag, + }; +} + +async function storeToken( + connectorId: string, + ctx: { + authorizationServer: string; + tokenEndpoint: string; + clientId: string; + clientSecret?: string; + scope?: string; + resource?: string; + }, + token: Record, + db: Db, +): Promise { + const accessToken = + typeof token.access_token === "string" ? token.access_token : null; + if (!accessToken) { + throw new Error("OAuth token response did not include an access token."); + } + const refreshToken = + typeof token.refresh_token === "string" ? token.refresh_token : undefined; + // Preserve an existing refresh token when the server omits one on refresh. + const existing = await loadDmsOAuthToken(connectorId, db); + const existingRefresh = existing + ? decryptString( + existing.encrypted_refresh_token, + existing.refresh_token_iv, + existing.refresh_token_tag, + ) + : null; + const expiresIn = + typeof token.expires_in === "number" ? token.expires_in : null; + const row = { + connector_id: connectorId, + ...secretPatch("access_token", accessToken), + ...secretPatch("refresh_token", refreshToken ?? existingRefresh), + token_type: + typeof token.token_type === "string" ? token.token_type : "Bearer", + scope: + typeof token.scope === "string" ? token.scope : ctx.scope ?? null, + expires_at: expiresIn + ? new Date(Date.now() + expiresIn * 1000).toISOString() + : null, + authorization_server: ctx.authorizationServer, + token_endpoint: ctx.tokenEndpoint, + client_id: ctx.clientId, + ...secretPatch("client_secret", ctx.clientSecret), + resource: ctx.resource ?? null, + updated_at: new Date().toISOString(), + }; + const { error } = await db + .from("dms_connector_oauth_tokens") + .upsert(row, { onConflict: "connector_id" }); + if (error) throw error; +} + +async function exchangeCode( + tokenEndpoint: string, + params: URLSearchParams, +): Promise> { + const response = await guardedFetch(tokenEndpoint, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: params, + }); + if (!response.ok) { + throw new Error(`OAuth token request failed (${response.status}).`); + } + const parsed = (await response.json()) as Record; + if (!parsed || typeof parsed !== "object") { + throw new Error("OAuth token response was not an object."); + } + return parsed; +} + +/** + * Begin the auth-code flow: persist a hashed one-time state + encrypted PKCE + * verifier and return the authorize URL for the browser to visit. + */ +export async function startDmsConnectorOAuth( + userId: string, + connectorId: string, + redirectUri: string, + db: Db = createServerSupabase(), +): Promise<{ authorizationUrl: string }> { + const connector = await loadDmsConnector(userId, connectorId, db); + const clientEnv = dmsOAuthClientEnv(connector.kind); + if (!clientEnv.clientId) { + throw new Error( + `OAuth client credentials are not configured for ${connector.kind}.`, + ); + } + const { authorizationEndpoint, tokenEndpoint } = oauthEndpoints( + connector.kind, + connector.base_url, + connector.config, + ); + + const stateToken = base64Url(crypto.randomBytes(32)); + const codeVerifier = base64Url(crypto.randomBytes(32)); + const codeChallenge = base64Url( + crypto.createHash("sha256").update(codeVerifier).digest(), + ); + + const enc = encryptString( + JSON.stringify({ + codeVerifier, + redirectUri, + tokenEndpoint, + authorizationServer: authorizationEndpoint, + clientId: clientEnv.clientId, + clientSecret: clientEnv.clientSecret, + scope: clientEnv.scope, + }), + ); + await db + .from("dms_connector_oauth_states") + .delete() + .eq("state_hash", stateHash(stateToken)); + const { error } = await db.from("dms_connector_oauth_states").insert({ + user_id: userId, + connector_id: connectorId, + state_hash: stateHash(stateToken), + encrypted_state_config: enc.encrypted, + state_config_iv: enc.iv, + state_config_tag: enc.tag, + expires_at: new Date(Date.now() + OAUTH_STATE_TTL_MS).toISOString(), + }); + if (error) throw error; + + const url = new URL(authorizationEndpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", clientEnv.clientId); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("state", stateToken); + url.searchParams.set("code_challenge", codeChallenge); + url.searchParams.set("code_challenge_method", "S256"); + if (clientEnv.scope) url.searchParams.set("scope", clientEnv.scope); + return { authorizationUrl: url.toString() }; +} + +/** Complete the auth-code flow: exchange the code and store encrypted tokens. */ +export async function completeDmsConnectorOAuth( + state: string, + code: string, + db: Db = createServerSupabase(), +): Promise<{ userId: string; connectorId: string }> { + const { data, error } = await db + .from("dms_connector_oauth_states") + .select("*") + .eq("state_hash", stateHash(state)) + .gt("expires_at", new Date().toISOString()) + .maybeSingle(); + if (error) throw error; + if (!data) throw new Error("OAuth state is invalid or expired."); + const stateRow = data as { + id: string; + user_id: string; + connector_id: string; + encrypted_state_config: string; + state_config_iv: string; + state_config_tag: string; + }; + const decrypted = decryptString( + stateRow.encrypted_state_config, + stateRow.state_config_iv, + stateRow.state_config_tag, + ); + if (!decrypted) throw new Error("OAuth state could not be decrypted."); + const cfg = JSON.parse(decrypted) as { + codeVerifier: string; + redirectUri: string; + tokenEndpoint: string; + authorizationServer: string; + clientId: string; + clientSecret?: string; + scope?: string; + }; + + const params = new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: cfg.redirectUri, + client_id: cfg.clientId, + code_verifier: cfg.codeVerifier, + }); + if (cfg.clientSecret) params.set("client_secret", cfg.clientSecret); + const token = await exchangeCode(cfg.tokenEndpoint, params); + + await storeToken( + stateRow.connector_id, + { + authorizationServer: cfg.authorizationServer, + tokenEndpoint: cfg.tokenEndpoint, + clientId: cfg.clientId, + clientSecret: cfg.clientSecret, + scope: cfg.scope, + }, + token, + db, + ); + await db + .from("dms_connector_oauth_states") + .delete() + .eq("id", stateRow.id); + return { userId: stateRow.user_id, connectorId: stateRow.connector_id }; +} + +async function refreshAccessToken( + row: DmsOAuthTokenRow, + db: Db, +): Promise { + const refreshToken = decryptString( + row.encrypted_refresh_token, + row.refresh_token_iv, + row.refresh_token_tag, + ); + if (!refreshToken || !row.token_endpoint || !row.client_id) { + throw new DmsOAuthRequiredError( + "OAuth reconnect is required for this DMS connector.", + ); + } + const clientSecret = decryptString( + row.encrypted_client_secret, + row.client_secret_iv, + row.client_secret_tag, + ); + const params = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: row.client_id, + }); + if (clientSecret) params.set("client_secret", clientSecret); + if (row.scope) params.set("scope", row.scope); + let token: Record; + try { + token = await exchangeCode(row.token_endpoint, params); + } catch { + throw new DmsOAuthRequiredError( + "OAuth token refresh failed. Please reconnect.", + ); + } + await storeToken( + row.connector_id, + { + authorizationServer: row.authorization_server ?? "", + tokenEndpoint: row.token_endpoint, + clientId: row.client_id, + clientSecret: clientSecret ?? undefined, + scope: row.scope ?? undefined, + resource: row.resource ?? undefined, + }, + token, + db, + ); + const updated = await loadDmsOAuthToken(row.connector_id, db); + if (!updated) throw new DmsOAuthRequiredError(); + return updated; +} + +/** + * Return a valid access token for the connector, transparently refreshing when + * the stored token is within OAUTH_EXPIRY_SKEW_MS of expiry. This is what the + * cloud adapters call via their `getAccessToken` config hook. + */ +export async function getValidDmsAccessToken( + connectorId: string, + db: Db = createServerSupabase(), +): Promise { + let token = await loadDmsOAuthToken(connectorId, db); + if (!token?.encrypted_access_token) { + throw new DmsOAuthRequiredError(); + } + const expiresAt = token.expires_at ? Date.parse(token.expires_at) : null; + if (expiresAt && expiresAt < Date.now() + OAUTH_EXPIRY_SKEW_MS) { + token = await refreshAccessToken(token, db); + } + const accessToken = decryptString( + token.encrypted_access_token, + token.access_token_iv, + token.access_token_tag, + ); + if (!accessToken) throw new DmsOAuthRequiredError(); + return accessToken; +} + +export function logDmsOAuthError(context: Record, err: unknown) { + console.error("[dms-connectors] oauth error", { + ...context, + error: err instanceof Error ? err.message : String(err), + }); +} diff --git a/backend/src/lib/dms/servers.ts b/backend/src/lib/dms/servers.ts new file mode 100644 index 000000000..a4a51f191 --- /dev/null +++ b/backend/src/lib/dms/servers.ts @@ -0,0 +1,389 @@ +/** + * DMS connector orchestration: CRUD, adapter resolution, and the + * folders/search/import/export operations, with air-gap gating and project + * authorization. Mirrors lib/mcp/servers.ts. + * + * AIR-GAP: iManage and NetDocuments are cloud SaaS with no local fallback + * (unlike the Ollama LLM fallback), so when AIRGAPPED=true every operation on a + * cloud connector — create, authenticate/sync, import, export, folders, search + * — is refused. The in-memory FakeDMSAdapter has no egress and stays fully + * usable air-gapped (it is the connector kind tests rely on). + */ +import { isAirgapped } from "../airgap"; +import { checkProjectAccess } from "../access"; +import { validateRemoteMcpUrl } from "../mcp/client"; +import { createServerSupabase } from "../supabase"; +import { downloadFile } from "../storage"; +import { loadActiveVersion } from "../documentVersions"; +import { + getDmsAdapter, + isCloudDmsKind, + type DmsAdapterConfig, + type DmsConnector, + type DmsExportResult, + type DmsFolder, + type DmsSearchOptions, + type DmsSearchResult, + type DmsKind, +} from "./index"; +import { + getValidDmsAccessToken, + loadDmsConnector, + loadDmsOAuthToken, +} from "./oauth"; +import { importDmsDocumentToProject, loadDmsDocumentLink } from "./import"; +import type { Db, DmsConnectorRow, DmsConnectorSummary } from "./types"; + +const VALID_KINDS: DmsKind[] = ["fake", "imanage", "netdocuments"]; + +function airgapGuard(kind: DmsKind): void { + if (isCloudDmsKind(kind) && isAirgapped()) { + throw new Error( + `The ${kind} DMS connector reaches an external cloud service and is disabled in air-gapped mode.`, + ); + } +} + +function toSummary( + row: DmsConnectorRow, + oauthConnected: boolean, +): DmsConnectorSummary { + return { + id: row.id, + kind: row.kind, + name: row.name, + baseUrl: row.base_url, + authType: row.auth_type, + enabled: row.enabled, + oauthConnected, + config: row.config ?? {}, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +/** + * Build a live adapter for a connector row. Cloud kinds get a getAccessToken + * hook that returns a freshly-refreshed OAuth bearer token per request; the + * fake kind ignores config. Refuses cloud kinds when air-gapped. + */ +export function resolveDmsAdapter( + row: DmsConnectorRow, + db: Db = createServerSupabase(), +): DmsConnector { + airgapGuard(row.kind); + const config = row.config ?? {}; + const adapterConfig: DmsAdapterConfig = { + baseUrl: row.base_url, + getAccessToken: () => getValidDmsAccessToken(row.id, db), + customerId: + typeof config.customer_id === "string" ? config.customer_id : null, + library: typeof config.library === "string" ? config.library : null, + repository: + typeof config.repository === "string" ? config.repository : null, + }; + return getDmsAdapter(row.kind, adapterConfig); +} + +export async function listDmsConnectors( + userId: string, + db: Db = createServerSupabase(), +): Promise { + const { data, error } = await db + .from("dms_connectors") + .select("*") + .eq("user_id", userId) + .order("created_at", { ascending: false }); + if (error) throw error; + const rows = (data ?? []) as DmsConnectorRow[]; + if (!rows.length) return []; + const { data: tokenRows, error: tokenError } = await db + .from("dms_connector_oauth_tokens") + .select("connector_id, encrypted_access_token") + .in( + "connector_id", + rows.map((r) => r.id), + ); + if (tokenError) throw tokenError; + const connected = new Set( + ((tokenRows ?? []) as Array<{ + connector_id: string; + encrypted_access_token: string | null; + }>) + .filter((t) => !!t.encrypted_access_token) + .map((t) => t.connector_id), + ); + return rows.map((row) => toSummary(row, connected.has(row.id))); +} + +export async function getDmsConnector( + userId: string, + connectorId: string, + db: Db = createServerSupabase(), +): Promise { + const row = await loadDmsConnector(userId, connectorId, db); + const token = await loadDmsOAuthToken(connectorId, db); + return toSummary(row, !!token?.encrypted_access_token); +} + +export async function createDmsConnector( + userId: string, + input: { + kind: string; + name: string; + baseUrl: string; + config?: Record; + }, + db: Db = createServerSupabase(), +): Promise { + const kind = input.kind as DmsKind; + if (!VALID_KINDS.includes(kind)) { + throw new Error(`Unknown DMS connector kind "${input.kind}".`); + } + airgapGuard(kind); + const name = input.name.trim().slice(0, 80); + if (!name) throw new Error("Connector name is required."); + + // SSRF: validate the tenant base URL exactly like createUserMcpConnector + // validates serverUrl (HTTPS-only, private-IP guard). The Fake backend is + // in-memory with no egress, so its base URL is not network-validated. + let baseUrl = input.baseUrl.trim(); + if (isCloudDmsKind(kind)) { + baseUrl = await validateRemoteMcpUrl(baseUrl); + } + + const { data, error } = await db + .from("dms_connectors") + .insert({ + user_id: userId, + kind, + name, + base_url: baseUrl, + auth_type: "oauth", + enabled: true, + config: input.config ?? {}, + }) + .select("*") + .single(); + if (error) throw error; + return toSummary(data as DmsConnectorRow, false); +} + +export async function updateDmsConnector( + userId: string, + connectorId: string, + input: { + name?: string; + baseUrl?: string; + enabled?: boolean; + config?: Record; + }, + db: Db = createServerSupabase(), +): Promise { + const current = await loadDmsConnector(userId, connectorId, db); + const update: Record = { + updated_at: new Date().toISOString(), + }; + if (typeof input.name === "string") { + const name = input.name.trim().slice(0, 80); + if (!name) throw new Error("Connector name is required."); + update.name = name; + } + if (typeof input.baseUrl === "string") { + const trimmed = input.baseUrl.trim(); + update.base_url = isCloudDmsKind(current.kind) + ? await validateRemoteMcpUrl(trimmed) + : trimmed; + } + if (typeof input.enabled === "boolean") update.enabled = input.enabled; + if (input.config && typeof input.config === "object") { + update.config = { ...(current.config ?? {}), ...input.config }; + } + const { data, error } = await db + .from("dms_connectors") + .update(update) + .eq("user_id", userId) + .eq("id", connectorId) + .select("*") + .single(); + if (error) throw error; + const token = await loadDmsOAuthToken(connectorId, db); + return toSummary(data as DmsConnectorRow, !!token?.encrypted_access_token); +} + +export async function deleteDmsConnector( + userId: string, + connectorId: string, + db: Db = createServerSupabase(), +): Promise { + const { error } = await db + .from("dms_connectors") + .delete() + .eq("user_id", userId) + .eq("id", connectorId); + if (error) throw error; +} + +/** Authenticate/sync a connector (verifies credentials reach the DMS). */ +export async function syncDmsConnector( + userId: string, + connectorId: string, + db: Db = createServerSupabase(), +): Promise<{ ok: boolean; error?: string }> { + const row = await loadDmsConnector(userId, connectorId, db); + const adapter = resolveDmsAdapter(row, db); + return adapter.authenticate(); +} + +export async function listDmsFolders( + userId: string, + connectorId: string, + parentId: string | null, + db: Db = createServerSupabase(), +): Promise { + const row = await loadDmsConnector(userId, connectorId, db); + return resolveDmsAdapter(row, db).listFolders(parentId); +} + +export async function searchDms( + userId: string, + connectorId: string, + query: string, + opts: DmsSearchOptions, + db: Db = createServerSupabase(), +): Promise { + const row = await loadDmsConnector(userId, connectorId, db); + return resolveDmsAdapter(row, db).search(query, opts); +} + +/** + * Fetch a DMS document and import it into a project the user can access. + * Enforces project authorization via checkProjectAccess before any write. + */ +export async function importDmsDocument( + userId: string, + userEmail: string | null | undefined, + connectorId: string, + dmsDocId: string, + projectId: string | null, + db: Db = createServerSupabase(), +): Promise< + | { ok: true; documentId: string; doc: unknown } + | { ok: false; status: number; detail: string } +> { + if (projectId) { + const access = await checkProjectAccess(projectId, userId, userEmail, db); + if (!access.ok) { + return { + ok: false, + status: 404, + detail: "Project not found or access denied.", + }; + } + } + const row = await loadDmsConnector(userId, connectorId, db); + const adapter = resolveDmsAdapter(row, db); + const document = await adapter.fetchDocument(dmsDocId); + if (!document) { + return { + ok: false, + status: 404, + detail: "Document not found in the DMS.", + }; + } + const imported = await importDmsDocumentToProject( + { userId, projectId, connectorId, dmsDocId, document }, + db, + ); + if (!imported.ok) { + return { ok: false, status: 400, detail: imported.detail }; + } + return { + ok: true, + documentId: imported.result.documentId, + doc: imported.result.doc, + }; +} + +/** + * Export a Mike document's active version back to the DMS, as a new version by + * default. Requires the document to have been imported from this connector + * (a dms_document_links row) so the export targets the right external doc. + */ +export async function exportDocumentToDms( + userId: string, + userEmail: string | null | undefined, + documentId: string, + db: Db = createServerSupabase(), +): Promise< + | { ok: true; result: DmsExportResult } + | { ok: false; status: number; detail: string } +> { + const link = await loadDmsDocumentLink(documentId, db); + if (!link) { + return { + ok: false, + status: 404, + detail: "This document was not imported from a DMS connector.", + }; + } + // Authorize against the document's project (owner check inside the loader). + const { data: doc } = await db + .from("documents") + .select("id, user_id, project_id, current_version_id") + .eq("id", documentId) + .single(); + if (!doc) { + return { ok: false, status: 404, detail: "Document not found." }; + } + const typedDoc = doc as { + user_id: string; + project_id: string | null; + }; + if (typedDoc.user_id !== userId && typedDoc.project_id) { + const access = await checkProjectAccess( + typedDoc.project_id, + userId, + userEmail, + db, + ); + if (!access.ok) { + return { ok: false, status: 404, detail: "Access denied." }; + } + } else if (typedDoc.user_id !== userId) { + return { ok: false, status: 404, detail: "Access denied." }; + } + + const version = await loadActiveVersion(documentId, db); + if (!version?.storage_path) { + return { + ok: false, + status: 400, + detail: "Document has no stored content to export.", + }; + } + const bytes = await downloadFile(version.storage_path); + if (!bytes) { + return { + ok: false, + status: 400, + detail: "Document content could not be read from storage.", + }; + } + + const row = await loadDmsConnector(userId, link.connector_id, db); + const adapter = resolveDmsAdapter(row, db); + const result = await adapter.exportDocument(link.dms_doc_id, bytes, { + newVersion: true, + filename: version.filename ?? undefined, + }); + // Advance the recorded external version so a subsequent export/import stays + // consistent with the DMS. + await db + .from("dms_document_links") + .update({ dms_version: result.version }) + .eq("document_id", documentId); + return { ok: true, result }; +} + +export type { DmsConnectorSummary }; diff --git a/backend/src/lib/dms/types.ts b/backend/src/lib/dms/types.ts new file mode 100644 index 000000000..1f8071452 --- /dev/null +++ b/backend/src/lib/dms/types.ts @@ -0,0 +1,72 @@ +import { createServerSupabase } from "../supabase"; +import type { DmsKind } from "./adapter"; + +export type Db = ReturnType; + +/** Only OAuth2 auth-code + refresh is supported for the cloud DMS backends. */ +export type DmsAuthType = "oauth"; + +/** A row in public.dms_connectors. */ +export interface DmsConnectorRow { + id: string; + user_id: string; + kind: DmsKind; + name: string; + base_url: string; + auth_type: DmsAuthType; + enabled: boolean; + encrypted_auth_config: string | null; + auth_config_iv: string | null; + auth_config_tag: string | null; + config: Record | null; + created_at: string; + updated_at: string; +} + +/** + * A row in public.dms_connector_oauth_tokens. Column-for-column identical to + * user_mcp_oauth_tokens so the same encrypt/refresh helpers apply unchanged. + */ +export interface DmsOAuthTokenRow { + id: string; + connector_id: string; + encrypted_access_token: string | null; + access_token_iv: string | null; + access_token_tag: string | null; + encrypted_refresh_token: string | null; + refresh_token_iv: string | null; + refresh_token_tag: string | null; + token_type: string | null; + scope: string | null; + expires_at: string | null; + authorization_server: string | null; + token_endpoint: string | null; + client_id: string | null; + encrypted_client_secret: string | null; + client_secret_iv: string | null; + client_secret_tag: string | null; + resource: string | null; + created_at: string; + updated_at: string; +} + +/** Summary of a connector returned to callers (never leaks secrets). */ +export interface DmsConnectorSummary { + id: string; + kind: DmsKind; + name: string; + baseUrl: string; + authType: DmsAuthType; + enabled: boolean; + oauthConnected: boolean; + config: Record; + createdAt: string; + updatedAt: string; +} + +/** Refresh the OAuth access token this many ms before it actually expires. */ +export const OAUTH_EXPIRY_SKEW_MS = 60_000; +export const OAUTH_STATE_TTL_MS = 10 * 60 * 1000; + +/** Cap the number of search results an adapter will return. */ +export const DMS_SEARCH_LIMIT = 50; diff --git a/backend/src/lib/dmsConnectors.ts b/backend/src/lib/dmsConnectors.ts new file mode 100644 index 000000000..0024d1896 --- /dev/null +++ b/backend/src/lib/dmsConnectors.ts @@ -0,0 +1,45 @@ +/** + * Barrel re-export for the DMS connector feature, mirroring lib/mcpConnectors.ts + * so callers import from one stable path. + */ +export type { + DmsAuthType, + DmsConnectorSummary, +} from "./dms/types"; +export type { + DmsConnector, + DmsAdapterConfig, + DmsKind, + DmsFolder, + DmsSearchResult, + DmsSearchOptions, + DmsDocument, + DmsExportResult, +} from "./dms/adapter"; +export { + FakeDMSAdapter, + IManageAdapter, + NetDocumentsAdapter, + getDmsAdapter, + registerDmsAdapter, + listDmsAdapters, + isCloudDmsKind, + sharedFakeDms, +} from "./dms"; +export { DmsOAuthRequiredError } from "./dms/oauth"; +export { + startDmsConnectorOAuth, + completeDmsConnectorOAuth, +} from "./dms/oauth"; +export { + listDmsConnectors, + getDmsConnector, + createDmsConnector, + updateDmsConnector, + deleteDmsConnector, + syncDmsConnector, + listDmsFolders, + searchDms, + importDmsDocument, + exportDocumentToDms, +} from "./dms/servers"; diff --git a/backend/src/routes/documents.ts b/backend/src/routes/documents.ts index 22ecd2286..de4c5e70d 100644 --- a/backend/src/routes/documents.ts +++ b/backend/src/routes/documents.ts @@ -1462,3 +1462,160 @@ async function countPdfPages(buf: ArrayBuffer): Promise { return null; } } + +// --------------------------------------------------------------------------- +// Create a document from uploaded bytes (initial upload pipeline), callable +// outside an Express handler. This is the same pipeline handleDocumentUpload +// runs — documents row, storage write, optional Office→PDF rendition, V1 +// document_versions row — parameterized on the version `source` so the DMS +// import (lib/dms/import.ts) can land a fetched document as source +// "dms_import" instead of "upload". +// --------------------------------------------------------------------------- + +export async function createDocumentFromUpload( + params: { + userId: string; + projectId: string | null; + filename: string; + suffix: string; + content: Buffer; + // Provenance recorded on the V1 document_versions row. Defaults to the + // interactive "upload" path; the DMS import pipeline passes "dms_import" so + // a document pulled from iManage/NetDocuments is distinguishable from a + // user upload (must be an allowed document_versions.source value). + source?: string; + }, + db: ReturnType, +): Promise< + | { ok: true; doc: unknown } + | { ok: false; kind: "create_failed" } + | { ok: false; kind: "processing_failed"; detail: string } +> { + const { userId, projectId, filename, suffix, content } = params; + const source = params.source ?? "upload"; + + const { data: doc, error: insertErr } = await db + .from("documents") + .insert({ + project_id: projectId, + user_id: userId, + status: "processing", + library_kind: "file", + library_folder_id: null, + }) + .select("*") + .single(); + + if (insertErr || !doc) { + console.error("[single-documents/upload] failed to create document row", { + userId, + projectId, + filename, + suffix, + error: insertErr, + }); + return { ok: false, kind: "create_failed" }; + } + + try { + const docId = doc.id as string; + const key = storageKey(userId, docId, filename); + const contentType = contentTypeForDocumentType(suffix); + await uploadFile( + key, + content.buffer.slice( + content.byteOffset, + content.byteOffset + content.byteLength, + ) as ArrayBuffer, + contentType, + ); + + const rawBuf = content.buffer.slice( + content.byteOffset, + content.byteOffset + content.byteLength, + ) as ArrayBuffer; + const pageCount = suffix === "pdf" ? await countPdfPages(rawBuf) : null; + + // Convert Office files → PDF for display. PDFs are their own rendition. + let pdfStoragePath: string | null = null; + if (shouldConvertToPdf(suffix)) { + try { + const pdfBuf = await docxToPdf(content); + const pdfKey = convertedPdfKey(userId, docId); + await uploadFile( + pdfKey, + pdfBuf.buffer.slice( + pdfBuf.byteOffset, + pdfBuf.byteOffset + pdfBuf.byteLength, + ) as ArrayBuffer, + "application/pdf", + ); + pdfStoragePath = pdfKey; + } catch (err) { + console.error( + `[upload] Office→PDF conversion failed for ${filename}:`, + err, + ); + } + } else if (suffix === "pdf") { + pdfStoragePath = key; + } + + // storage_path / pdf_storage_path live on document_versions now — + // create the V1 row and point documents.current_version_id at it. + const { data: versionRow, error: verErr } = await db + .from("document_versions") + .insert({ + document_id: docId, + storage_path: key, + pdf_storage_path: pdfStoragePath, + source, + version_number: 1, + filename: filename, + file_type: suffix, + size_bytes: content.byteLength, + page_count: pageCount, + }) + .select("id") + .single(); + if (verErr || !versionRow) { + throw new Error( + `Failed to record upload version: ${verErr?.message ?? "unknown"}`, + ); + } + + await db + .from("documents") + .update({ + current_version_id: versionRow.id, + status: "ready", + updated_at: new Date().toISOString(), + }) + .eq("id", docId); + + const { data: updated } = await db + .from("documents") + .select("*") + .eq("id", docId) + .single(); + // Surface storage paths to the caller for backward compatibility. + const responseDoc = updated + ? { + ...updated, + filename, + storage_path: key, + pdf_storage_path: pdfStoragePath, + folder_id: + (updated.library_folder_id as string | null | undefined) ?? null, + file_type: suffix, + size_bytes: content.byteLength, + page_count: pageCount, + active_version_number: 1, + } + : updated; + return { ok: true, doc: responseDoc }; + } catch (e) { + await db.from("documents").update({ status: "error" }).eq("id", doc.id); + return { ok: false, kind: "processing_failed", detail: String(e) }; + } +} diff --git a/backend/src/routes/user.ts b/backend/src/routes/user.ts index ca77f5952..169f51716 100644 --- a/backend/src/routes/user.ts +++ b/backend/src/routes/user.ts @@ -28,6 +28,19 @@ import { startUserMcpConnectorOAuth, updateUserMcpConnector, } from "../lib/mcpConnectors"; +import { + completeDmsConnectorOAuth, + createDmsConnector, + deleteDmsConnector, + DmsOAuthRequiredError, + getDmsConnector, + importDmsDocument, + listDmsConnectors, + searchDms, + startDmsConnectorOAuth, + syncDmsConnector, + updateDmsConnector, +} from "../lib/dmsConnectors"; import { deleteAllUserChats, deleteAllUserTabularReviews, @@ -954,6 +967,327 @@ userRouter.patch( }, ); +// --------------------------------------------------------------------------- +// DMS connectors (iManage / NetDocuments). Same auth posture as the MCP +// connector routes: requireAuth on reads, requireAuth + requireMfaIfEnrolled on +// writes. The OAuth callback is unauthenticated (the DMS redirects the browser +// to it) and validated by the one-time state token. +// --------------------------------------------------------------------------- + +// GET /user/dms-connectors +userRouter.get("/dms-connectors", requireAuth, async (_req, res) => { + const userId = res.locals.userId as string; + const db = createServerSupabase(); + try { + res.json(await listDmsConnectors(userId, db)); + } catch (err) { + const detail = errorMessage(err); + console.error("[user/dms-connectors] list failed", { + userId, + error: detail, + }); + res.status(500).json({ detail }); + } +}); + +// GET /user/dms-connectors/:connectorId +userRouter.get( + "/dms-connectors/:connectorId", + requireAuth, + async (req, res) => { + const userId = res.locals.userId as string; + const db = createServerSupabase(); + try { + res.json( + await getDmsConnector(userId, req.params.connectorId, db), + ); + } catch (err) { + const detail = errorMessage(err); + console.error("[user/dms-connectors] get failed", { + userId, + connectorId: req.params.connectorId, + error: detail, + }); + res.status(404).json({ detail }); + } + }, +); + +// POST /user/dms-connectors +userRouter.post( + "/dms-connectors", + requireAuth, + requireMfaIfEnrolled, + async (req, res) => { + const userId = res.locals.userId as string; + const kind = typeof req.body?.kind === "string" ? req.body.kind : ""; + const name = typeof req.body?.name === "string" ? req.body.name : ""; + const baseUrl = + typeof req.body?.baseUrl === "string" ? req.body.baseUrl : ""; + const config = + req.body?.config && + typeof req.body.config === "object" && + !Array.isArray(req.body.config) + ? (req.body.config as Record) + : undefined; + const db = createServerSupabase(); + try { + const connector = await createDmsConnector( + userId, + { kind, name, baseUrl, config }, + db, + ); + res.status(201).json(connector); + } catch (err) { + const detail = errorMessage(err); + console.error("[user/dms-connectors] create failed", { + userId, + error: detail, + }); + res.status(400).json({ detail }); + } + }, +); + +// PATCH /user/dms-connectors/:connectorId +userRouter.patch( + "/dms-connectors/:connectorId", + requireAuth, + requireMfaIfEnrolled, + async (req, res) => { + const userId = res.locals.userId as string; + const db = createServerSupabase(); + const body = req.body ?? {}; + try { + const connector = await updateDmsConnector( + userId, + req.params.connectorId, + { + ...(typeof body.name === "string" + ? { name: body.name } + : {}), + ...(typeof body.baseUrl === "string" + ? { baseUrl: body.baseUrl } + : {}), + ...(typeof body.enabled === "boolean" + ? { enabled: body.enabled } + : {}), + ...(body.config && + typeof body.config === "object" && + !Array.isArray(body.config) + ? { config: body.config as Record } + : {}), + }, + db, + ); + res.json(connector); + } catch (err) { + const detail = errorMessage(err); + console.error("[user/dms-connectors] update failed", { + userId, + connectorId: req.params.connectorId, + error: detail, + }); + res.status(400).json({ detail }); + } + }, +); + +// DELETE /user/dms-connectors/:connectorId +userRouter.delete( + "/dms-connectors/:connectorId", + requireAuth, + requireMfaIfEnrolled, + async (req, res) => { + const userId = res.locals.userId as string; + const db = createServerSupabase(); + try { + await deleteDmsConnector(userId, req.params.connectorId, db); + res.status(204).send(); + } catch (err) { + const detail = errorMessage(err); + console.error("[user/dms-connectors] delete failed", { + userId, + connectorId: req.params.connectorId, + error: detail, + }); + res.status(500).json({ detail }); + } + }, +); + +// POST /user/dms-connectors/:connectorId/oauth/start +userRouter.post( + "/dms-connectors/:connectorId/oauth/start", + requireAuth, + requireMfaIfEnrolled, + async (req, res) => { + const userId = res.locals.userId as string; + const db = createServerSupabase(); + try { + const redirectUri = `${backendPublicUrl(req)}/user/dms-connectors/oauth/callback`; + const result = await startDmsConnectorOAuth( + userId, + req.params.connectorId, + redirectUri, + db, + ); + res.json(result); + } catch (err) { + const detail = errorMessage(err); + console.error("[user/dms-connectors] oauth start failed", { + userId, + connectorId: req.params.connectorId, + error: detail, + }); + res.status(400).json({ detail }); + } + }, +); + +// GET /user/dms-connectors/oauth/callback +userRouter.get("/dms-connectors/oauth/callback", async (req, res) => { + const nonce = crypto.randomBytes(16).toString("base64"); + const state = typeof req.query.state === "string" ? req.query.state : ""; + const code = typeof req.query.code === "string" ? req.query.code : ""; + const error = + typeof req.query.error === "string" ? req.query.error : undefined; + const db = createServerSupabase(); + try { + if (error) throw new Error(error); + if (!state || !code) + throw new Error("OAuth callback is missing state or code."); + const result = await completeDmsConnectorOAuth(state, code, db); + res.set("Content-Security-Policy", mcpOAuthPopupCsp(nonce)) + .type("html") + .send( + mcpOAuthPopupHtml( + { success: true, connectorId: result.connectorId }, + nonce, + ), + ); + } catch (err) { + const detail = errorMessage(err); + console.error("[user/dms-connectors] oauth callback failed", { + error: detail, + stateHash: shortHash(state), + hasCode: !!code, + }); + res.status(400) + .set("Content-Security-Policy", mcpOAuthPopupCsp(nonce)) + .type("html") + .send(mcpOAuthPopupHtml({ success: false, detail }, nonce)); + } +}); + +// POST /user/dms-connectors/:connectorId/sync — verify credentials reach the DMS +userRouter.post( + "/dms-connectors/:connectorId/sync", + requireAuth, + requireMfaIfEnrolled, + async (req, res) => { + const userId = res.locals.userId as string; + const db = createServerSupabase(); + try { + res.json(await syncDmsConnector(userId, req.params.connectorId, db)); + } catch (err) { + const detail = errorMessage(err); + console.error("[user/dms-connectors] sync failed", { + userId, + connectorId: req.params.connectorId, + error: detail, + }); + if (err instanceof DmsOAuthRequiredError) { + return void res + .status(401) + .json({ code: err.code, detail }); + } + res.status(400).json({ detail }); + } + }, +); + +// POST /user/dms-connectors/:connectorId/search +userRouter.post( + "/dms-connectors/:connectorId/search", + requireAuth, + requireMfaIfEnrolled, + async (req, res) => { + const userId = res.locals.userId as string; + const db = createServerSupabase(); + const query = typeof req.body?.query === "string" ? req.body.query : ""; + const folderId = + typeof req.body?.folderId === "string" ? req.body.folderId : null; + const limit = + typeof req.body?.limit === "number" ? req.body.limit : undefined; + try { + res.json( + await searchDms( + userId, + req.params.connectorId, + query, + { folderId, limit }, + db, + ), + ); + } catch (err) { + const detail = errorMessage(err); + console.error("[user/dms-connectors] search failed", { + userId, + connectorId: req.params.connectorId, + error: detail, + }); + res.status(400).json({ detail }); + } + }, +); + +// POST /user/dms-connectors/:connectorId/import — pull a DMS doc into a project +userRouter.post( + "/dms-connectors/:connectorId/import", + requireAuth, + requireMfaIfEnrolled, + async (req, res) => { + const userId = res.locals.userId as string; + const userEmail = res.locals.userEmail as string | undefined; + const db = createServerSupabase(); + const dmsDocId = + typeof req.body?.dmsDocId === "string" ? req.body.dmsDocId : ""; + const projectId = + typeof req.body?.projectId === "string" ? req.body.projectId : null; + if (!dmsDocId) + return void res.status(400).json({ detail: "dmsDocId is required." }); + try { + const result = await importDmsDocument( + userId, + userEmail, + req.params.connectorId, + dmsDocId, + projectId, + db, + ); + if (!result.ok) + return void res + .status(result.status) + .json({ detail: result.detail }); + res.status(201).json({ + documentId: result.documentId, + doc: result.doc, + }); + } catch (err) { + const detail = errorMessage(err); + console.error("[user/dms-connectors] import failed", { + userId, + connectorId: req.params.connectorId, + dmsDocId, + error: detail, + }); + const status = err instanceof DmsOAuthRequiredError ? 401 : 500; + res.status(status).json({ detail }); + } + }, +); + // DELETE /user/account userRouter.delete( "/account", diff --git a/backend/tsconfig.json b/backend/tsconfig.json index a4b3abf67..bc27281c0 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -16,5 +16,5 @@ } }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/__tests__/**"] }