import type { Workspace, WorkspaceCreate, WorkspaceUpdate, Collection, CollectionCreate, CollectionUpdate, Item, ItemCreate, ItemUpdate, ItemLink, ItemLinkCreate, Comment, CommentCreate, Version, DashboardResponse, SearchResponse, SearchFilters, Activity, ApiError, WorkspaceTemplate, ConventionLibraryResponse, LibraryConvention, PlaybookLibraryResponse, LibraryPlaybook, View, User, UserProfileUpdate, APIToken, APITokenWithSecret, Reaction, TimelineResponse, AgentRole, AgentRoleCreate, AgentRoleUpdate, RoleBoardLane, ChangesResponse, CollectionGrant, ItemGrant, WorkspaceMembership, ShareLink, TOTPSetupResponse, TOTPVerifyResponse, TOTPDisableResponse, AdminBillingStats, AttachmentUploadResult, AttachmentTransformRequest, AttachmentTransformResult, ServerCapabilities, WorkspaceStorageInfo, AttachmentListFilters, AttachmentListResponse, ConnectedApp } from '$lib/types'; const BASE = '/api/v1'; class PadApiError extends Error { code: string; constructor(err: ApiError) { super(err.message); this.code = err.code; } } function getCSRFToken(): string | null { if (typeof document === 'undefined') return null; // Check __Host- prefixed cookie first (secure/TLS mode), fall back to unprefixed const hostMatch = document.cookie.match(/(?:^|;\s*)__Host-pad_csrf=([^;]+)/); if (hostMatch) return hostMatch[1]; const match = document.cookie.match(/(?:^|;\s*)pad_csrf=([^;]+)/); return match ? match[1] : null; } async function request(path: string, options?: RequestInit): Promise { const headers: Record = { 'Content-Type': 'application/json' }; // Attach CSRF token for state-changing requests const method = options?.method?.toUpperCase(); if (method && method !== 'GET' && method !== 'HEAD') { const csrf = getCSRFToken(); if (csrf) headers['X-CSRF-Token'] = csrf; } const resp = await fetch(BASE + path, { headers, credentials: 'same-origin', ...options }); if (resp.status === 401) { // Redirect to login page (avoid infinite loop) if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) { window.location.href = '/login'; } throw new PadApiError({ code: 'unauthorized', message: 'Authentication required' }); } if (!resp.ok) { const body = await resp.json().catch(() => null); if (body?.error) throw new PadApiError(body.error); throw new Error(`API error: ${resp.status}`); } if (resp.status === 204) return undefined as T; return resp.json(); } function qs(params?: Record): string { if (!params) return ''; const filtered: Record = {}; for (const [k, v] of Object.entries(params)) { if (v !== undefined && v !== '') filtered[k] = String(v); } const str = new URLSearchParams(filtered).toString(); return str ? '?' + str : ''; } export interface HealthResponse { status: string; version?: string; commit?: string; build_time?: string; cloud_mode?: boolean; } export interface AuthSession { authenticated: boolean; setup_required: boolean; // 'logs_token' added in TASK-1167 for the first-run logs-token bootstrap // flow. Self-host servers with no users + a loaded bootstrap token surface // this value so SetupRequiredNotice renders the "paste your bootstrap // token from the container logs" branch instead of the local-CLI // instructions. Cloud mode never advertises 'logs_token' (D10/F9). setup_method?: 'local_cli' | 'docker_exec' | 'cloud' | 'logs_token'; auth_method: 'password' | 'cloud'; cloud_mode?: boolean; // mcp_public_url is the canonical URL clients paste into their MCP-capable // agent (e.g. "https://mcp.getpad.dev"). Empty string when PAD_MCP_PUBLIC_URL // is unset on the server — UI code should use the empty string as the gate // for "Remote MCP not exposed by this instance, fall back to CLI flow." mcp_public_url: string; user?: { id: string; email: string; username: string; name: string; role: string; plan?: string }; } export interface LoginResponse { user?: { id: string; email: string; username: string; name: string; role: string; plan?: string }; token?: string; requires_2fa?: boolean; challenge_token?: string; } export const api = { // ── Health / Version ────────────────────────────────────────────────────── health: () => request('/health'), // ── Templates ───────────────────────────────────────────────────────────── templates: { list: () => request('/templates'), }, // ── Workspaces ──────────────────────────────────────────────────────────── workspaces: { list: () => request('/workspaces'), create: (data: WorkspaceCreate) => request('/workspaces', { method: 'POST', body: JSON.stringify(data) }), get: (slug: string) => request(`/workspaces/${slug}`), // me returns the current user's effective workspace context — role, // collection_access mode, visible collection IDs, and direct grants. // Used by the workspace store's permission helpers (PLAN-1100). me: (slug: string) => request(`/workspaces/${slug}/me`), update: (slug: string, data: WorkspaceUpdate) => request(`/workspaces/${slug}`, { method: 'PATCH', body: JSON.stringify(data) }), delete: (slug: string) => request(`/workspaces/${slug}`, { method: 'DELETE' }), reorder: (updates: { slug: string; sort_order: number }[]) => request('/workspaces/reorder', { method: 'PUT', body: JSON.stringify(updates) }), // importBundle uploads a workspace tar.gz bundle to the bundle-import // endpoint. The server dispatches on Content-Type // (application/gzip → bundle path, anything else → legacy JSON path), // so we explicitly set application/gzip and POST the raw File body // rather than going through the JSON-encoding `request` helper. // Mirrors the CLI's `pad workspace import ` flow. importBundle: async (file: File, name?: string): Promise => { const headers: Record = { 'Content-Type': 'application/gzip' }; const csrf = getCSRFToken(); if (csrf) headers['X-CSRF-Token'] = csrf; const url = name ? `${BASE}/workspaces/import?name=${encodeURIComponent(name)}` : `${BASE}/workspaces/import`; const resp = await fetch(url, { method: 'POST', headers, credentials: 'same-origin', body: file }); if (resp.status === 401) { if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) { window.location.href = '/login'; } throw new PadApiError({ code: 'unauthorized', message: 'Authentication required' }); } if (!resp.ok) { const body = await resp.json().catch(() => null); if (body?.error) throw new PadApiError(body.error); throw new Error(`API error: ${resp.status}`); } return resp.json(); } }, // ── Collections ─────────────────────────────────────────────────────────── collections: { list: (ws: string) => request(`/workspaces/${ws}/collections`), create: (ws: string, data: CollectionCreate) => request(`/workspaces/${ws}/collections`, { method: 'POST', body: JSON.stringify(data) }), get: (ws: string, slug: string) => request(`/workspaces/${ws}/collections/${slug}`), update: (ws: string, slug: string, data: CollectionUpdate) => request(`/workspaces/${ws}/collections/${slug}`, { method: 'PATCH', body: JSON.stringify(data) }), delete: (ws: string, slug: string) => request(`/workspaces/${ws}/collections/${slug}`, { method: 'DELETE' }) }, // ── Agent Roles ────────────────────────────────────────────────────────── agentRoles: { list: (ws: string) => request(`/workspaces/${ws}/agent-roles`), create: (ws: string, data: AgentRoleCreate) => request(`/workspaces/${ws}/agent-roles`, { method: 'POST', body: JSON.stringify(data) }), get: (ws: string, idOrSlug: string) => request(`/workspaces/${ws}/agent-roles/${idOrSlug}`), update: (ws: string, idOrSlug: string, data: AgentRoleUpdate) => request(`/workspaces/${ws}/agent-roles/${idOrSlug}`, { method: 'PATCH', body: JSON.stringify(data) }), delete: (ws: string, idOrSlug: string) => request(`/workspaces/${ws}/agent-roles/${idOrSlug}`, { method: 'DELETE' }), board: (ws: string, assignedUserId?: string) => { const params = assignedUserId ? `?assigned_user_id=${assignedUserId}` : ''; return request<{ lanes: RoleBoardLane[] }>(`/workspaces/${ws}/roles/board${params}`); }, reorder: (ws: string, updates: { item_id: string; role_sort_order: number }[]) => request(`/workspaces/${ws}/roles/board/reorder`, { method: 'PUT', body: JSON.stringify(updates) }), reorderLanes: (ws: string, updates: { role_id: string; sort_order: number }[]) => request(`/workspaces/${ws}/roles/board/lane-order`, { method: 'PUT', body: JSON.stringify(updates) }) }, // ── Items ───────────────────────────────────────────────────────────────── items: { /** Cross-collection item listing with optional query params. */ list: ( ws: string, params?: Record ) => request(`/workspaces/${ws}/items${qs(params)}`), /** Items within a specific collection. */ listByCollection: ( ws: string, coll: string, params?: Record ) => request( `/workspaces/${ws}/collections/${coll}/items${qs(params)}` ), create: (ws: string, coll: string, data: ItemCreate) => request(`/workspaces/${ws}/collections/${coll}/items`, { method: 'POST', body: JSON.stringify(data) }), get: (ws: string, slug: string) => request(`/workspaces/${ws}/items/${slug}`), update: (ws: string, slug: string, data: ItemUpdate) => request(`/workspaces/${ws}/items/${slug}`, { method: 'PATCH', body: JSON.stringify(data) }), delete: (ws: string, slug: string) => request(`/workspaces/${ws}/items/${slug}`, { method: 'DELETE' }), restore: (ws: string, slug: string) => request(`/workspaces/${ws}/items/${slug}/restore`, { method: 'POST' }), move: (ws: string, slug: string, targetCollection: string, fieldOverrides?: Record) => request(`/workspaces/${ws}/items/${slug}/move`, { method: 'POST', body: JSON.stringify({ target_collection: targetCollection, field_overrides: fieldOverrides, source: 'web' }) }), /** Get child items linked to a parent item */ children: (ws: string, slug: string) => request(`/workspaces/${ws}/items/${slug}/children`), /** Get completion progress for an item's children */ progress: (ws: string, slug: string) => request<{total: number; done: number; percentage: number}>(`/workspaces/${ws}/items/${slug}/progress`), /** @deprecated Use children() */ tasks: (ws: string, slug: string) => request(`/workspaces/${ws}/items/${slug}/children`), /** @deprecated Use progress() per-item instead */ plansProgress: (ws: string) => request<{item_id: string; total: number; done: number}[]>(`/workspaces/${ws}/plans-progress`), /** Star an item for the current user (idempotent) */ star: (ws: string, itemSlug: string) => request(`/workspaces/${ws}/items/${itemSlug}/star`, { method: 'POST' }), /** Unstar an item for the current user */ unstar: (ws: string, itemSlug: string) => request(`/workspaces/${ws}/items/${itemSlug}/star`, { method: 'DELETE' }), /** Check if an item is starred by the current user */ starStatus: (ws: string, itemSlug: string) => request<{starred: boolean}>(`/workspaces/${ws}/items/${itemSlug}/star`), /** List all starred items in a workspace for the current user */ starred: (ws: string, params?: {include_terminal?: boolean}) => request(`/workspaces/${ws}/starred${qs(params)}`) }, // ── Versions ────────────────────────────────────────────────────────────── versions: { list: (ws: string, itemSlug: string) => request(`/workspaces/${ws}/items/${itemSlug}/versions`), restore: (ws: string, itemSlug: string, versionId: string) => request(`/workspaces/${ws}/items/${itemSlug}/versions/${versionId}/restore`, { method: 'POST' }), /** Activity feed for a single item (all changes, not just content versions). */ activity: (ws: string, itemSlug: string) => request(`/workspaces/${ws}/items/${itemSlug}/activity`) }, // ── Links ───────────────────────────────────────────────────────────────── links: { list: (ws: string, itemSlug: string) => request(`/workspaces/${ws}/items/${itemSlug}/links`), create: (ws: string, itemSlug: string, data: ItemLinkCreate) => request(`/workspaces/${ws}/items/${itemSlug}/links`, { method: 'POST', body: JSON.stringify(data) }), delete: (ws: string, linkId: string) => request(`/workspaces/${ws}/links/${linkId}`, { method: 'DELETE' }) }, // ── Comments ────────────────────────────────────────────────────────────── comments: { list: (ws: string, itemSlug: string) => request(`/workspaces/${ws}/items/${itemSlug}/comments`), create: (ws: string, itemSlug: string, data: CommentCreate) => request(`/workspaces/${ws}/items/${itemSlug}/comments`, { method: 'POST', body: JSON.stringify(data) }), delete: (ws: string, commentId: string) => request(`/workspaces/${ws}/comments/${commentId}`, { method: 'DELETE' }), reply: (ws: string, commentId: string, data: CommentCreate) => request(`/workspaces/${ws}/comments/${commentId}/replies`, { method: 'POST', body: JSON.stringify(data) }), addReaction: (ws: string, commentId: string, emoji: string) => request(`/workspaces/${ws}/comments/${commentId}/reactions`, { method: 'POST', body: JSON.stringify({ emoji }) }), removeReaction: (ws: string, commentId: string, emoji: string) => request(`/workspaces/${ws}/comments/${commentId}/reactions/${encodeURIComponent(emoji)}`, { method: 'DELETE' }) }, // ── Timeline ────────────────────────────────────────────────────────────── timeline: { list: (ws: string, itemSlug: string, params?: { limit?: number; before?: string; before_id?: string }) => { const qs = new URLSearchParams(); if (params?.limit != null) qs.set('limit', String(params.limit)); if (params?.before) qs.set('before', params.before); if (params?.before_id) qs.set('before_id', params.before_id); const suffix = qs.toString() ? `?${qs}` : ''; return request(`/workspaces/${ws}/items/${itemSlug}/timeline${suffix}`); } }, // ── Views ───────────────────────────────────────────────────────────────── views: { list: (ws: string, coll: string) => request(`/workspaces/${ws}/collections/${coll}/views`), create: (ws: string, coll: string, data: { name: string; view_type: string; config: string }) => request(`/workspaces/${ws}/collections/${coll}/views`, { method: 'POST', body: JSON.stringify(data) }), update: (ws: string, coll: string, viewId: string, data: { name?: string; view_type?: string; config?: string; sort_order?: number }) => request(`/workspaces/${ws}/collections/${coll}/views/${viewId}`, { method: 'PATCH', body: JSON.stringify(data) }), delete: (ws: string, coll: string, viewId: string) => request(`/workspaces/${ws}/collections/${coll}/views/${viewId}`, { method: 'DELETE' }) }, // ── Dashboard ───────────────────────────────────────────────────────────── dashboard: { get: (ws: string) => request(`/workspaces/${ws}/dashboard`) }, // ── Incremental Sync ───────────────────────────────────────────────────── changes: { /** Fetch items modified since the given timestamp (unix ms). */ since: (ws: string, sinceMs: number) => request(`/workspaces/${ws}/changes?since=${sinceMs}`) }, // ── Search ──────────────────────────────────────────────────────────────── search: (query: string, filters?: SearchFilters) => { const params: Record = { q: query }; if (filters?.workspace) params.workspace = filters.workspace; if (filters?.collection) params.collection = filters.collection; if (filters?.status) params.status = filters.status; if (filters?.priority) params.priority = filters.priority; if (filters?.limit) params.limit = String(filters.limit); if (filters?.offset) params.offset = String(filters.offset); if (filters?.sort) params.sort = filters.sort; if (filters?.order) params.order = filters.order; if (filters?.fields) { for (const [key, value] of Object.entries(filters.fields)) { params[`field.${key}`] = value; } } return request(`/search?${new URLSearchParams(params).toString()}`); }, // ── Activity ────────────────────────────────────────────────────────────── activity: { list: ( ws: string, params?: Record ) => request(`/workspaces/${ws}/activity${qs(params)}`) }, // ── Convention Library ──────────────────────────────────────────────────── library: { get: () => request('/convention-library'), activate: (ws: string, convention: LibraryConvention) => request(`/workspaces/${ws}/collections/conventions/items`, { method: 'POST', body: JSON.stringify({ title: convention.title, content: convention.content, fields: JSON.stringify({ status: 'active', category: convention.category, trigger: convention.trigger, scope: convention.surfaces?.[0] ?? 'all', priority: convention.enforcement, enforcement: convention.enforcement, surfaces: convention.surfaces, commands: convention.commands ?? [], convention: { category: convention.category, trigger: convention.trigger, surfaces: convention.surfaces, enforcement: convention.enforcement, commands: convention.commands ?? [] } }) }) }), getPlaybooks: () => request('/playbook-library'), activatePlaybook: (ws: string, playbook: LibraryPlaybook) => request(`/workspaces/${ws}/collections/playbooks/items`, { method: 'POST', body: JSON.stringify({ title: playbook.title, content: playbook.content, fields: JSON.stringify({ status: 'active', trigger: playbook.trigger, scope: playbook.scope }) }) }) }, // ── Raw requests ────────────────────────────────────────────────────────── raw: { post: (path: string, data: unknown) => request(path, { method: 'POST', body: JSON.stringify(data) }) }, // ── Members ────────────────────────────────────────────────────────────── members: { list: (ws: string) => request<{ members: { workspace_id: string; user_id: string; role: string; created_at: string; user_name: string; user_email: string }[]; invitations: { id: string; email: string; role: string; code: string; join_url?: string; created_at: string }[]; }>(`/workspaces/${ws}/members`), invite: (ws: string, email: string, role: string) => request<{ added?: boolean; invited?: boolean; code?: string; join_url?: string; email: string; role: string; name?: string; user_id?: string }>( `/workspaces/${ws}/members/invite`, { method: 'POST', body: JSON.stringify({ email, role }) } ), remove: (ws: string, userId: string, revokeGrants: boolean = true) => request(`/workspaces/${ws}/members/${userId}?revoke_grants=${revokeGrants}`, { method: 'DELETE' }), updateRole: (ws: string, userId: string, role: string) => request<{ user_id: string; role: string }>(`/workspaces/${ws}/members/${userId}`, { method: 'PATCH', body: JSON.stringify({ role }) }), cancelInvitation: (ws: string, invitationId: string) => request(`/workspaces/${ws}/members/invitations/${invitationId}`, { method: 'DELETE' }), acceptInvitation: (code: string) => request<{ accepted: boolean; workspace_id: string; role: string }>(`/invitations/${code}/accept`, { method: 'POST' }), getMemberCollectionAccess: (ws: string, userId: string) => request<{ collection_access: string; collection_ids: string[] }>(`/workspaces/${ws}/members/${userId}/collection-access`), setMemberCollectionAccess: (ws: string, userId: string, mode: string, collectionIDs: string[]) => request<{ collection_access: string; collection_ids: string[] }>(`/workspaces/${ws}/members/${userId}/collection-access`, { method: 'PUT', body: JSON.stringify({ mode, collection_ids: collectionIDs }) }) }, // ── Grants ─────────────────────────────────────────────────────────────── grants: { listCollectionGrants: (ws: string, collSlug: string) => request(`/workspaces/${ws}/collections/${collSlug}/grants`), createCollectionGrant: (ws: string, collSlug: string, email: string, permission: string) => request(`/workspaces/${ws}/collections/${collSlug}/grants`, { method: 'POST', body: JSON.stringify({ email, permission }) }), deleteCollectionGrant: (ws: string, collSlug: string, grantId: string) => request(`/workspaces/${ws}/collections/${collSlug}/grants/${grantId}`, { method: 'DELETE' }), listItemGrants: (ws: string, itemSlug: string) => request(`/workspaces/${ws}/items/${itemSlug}/grants`), createItemGrant: (ws: string, itemSlug: string, email: string, permission: string) => request(`/workspaces/${ws}/items/${itemSlug}/grants`, { method: 'POST', body: JSON.stringify({ email, permission }) }), deleteItemGrant: (ws: string, itemSlug: string, grantId: string) => request(`/workspaces/${ws}/items/${itemSlug}/grants/${grantId}`, { method: 'DELETE' }), listUserGrants: (ws: string, userId: string) => request<{ collection_grants: CollectionGrant[]; item_grants: ItemGrant[] }>(`/workspaces/${ws}/users/${userId}/grants`), }, // ── Share Links ───────────────────────────────────────────────────────── shareLinks: { listItemShareLinks: (ws: string, itemSlug: string) => request(`/workspaces/${ws}/items/${itemSlug}/share-links`), createItemShareLink: (ws: string, itemSlug: string) => request(`/workspaces/${ws}/items/${itemSlug}/share-links`, { method: 'POST' }), listCollectionShareLinks: (ws: string, collSlug: string) => request(`/workspaces/${ws}/collections/${collSlug}/share-links`), createCollectionShareLink: (ws: string, collSlug: string) => request(`/workspaces/${ws}/collections/${collSlug}/share-links`, { method: 'POST' }), deleteShareLink: (ws: string, linkId: string) => request(`/workspaces/${ws}/share-links/${linkId}`, { method: 'DELETE' }), }, // ── Public Share (no auth) ────────────────────────────────────────────── share: { get: (token: string, password?: string) => { const headers: Record = {}; if (password) headers['X-Share-Password'] = password; return fetch(`${BASE}/s/${token}`, { credentials: 'same-origin', headers }).then(async (resp) => { if (!resp.ok) { const body = await resp.json().catch(() => null); if (body?.error) throw new PadApiError(body.error); throw new Error(`API error: ${resp.status}`); } return resp.json(); }); }, }, // ── Auth ────────────────────────────────────────────────────────────────── auth: { session: (): Promise => fetch(BASE + '/auth/session', { credentials: 'same-origin' }).then((r) => r.json()), login: (email: string, password: string) => request('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }), verify2FA: (challengeToken: string, code?: string, recoveryCode?: string) => request<{ user: { id: string; email: string; username: string; name: string; role: string }; token: string }>('/auth/2fa/login-verify', { method: 'POST', body: JSON.stringify({ challenge_token: challengeToken, code: code || undefined, recovery_code: recoveryCode || undefined }) }), register: (email: string, name: string, password: string, username?: string, invitation_code?: string) => request<{ user: { id: string; email: string; username: string; name: string; role: string }; token: string }>('/auth/register', { method: 'POST', body: JSON.stringify({ email, name, password, ...(username ? { username } : {}), ...(invitation_code ? { invitation_code } : {}) }) }), // First-run bootstrap with a logs-token (TASK-1167). The token is // transmitted via the X-Bootstrap-Token header — never the URL or // query string — to keep it out of access logs, proxy logs, and // browser history (F6 / D9). Same response shape as register, so // the caller can reuse the post-registration redirect logic. // // Content-Type is set explicitly here because request() builds its // default headers BEFORE spreading caller options, so any caller- // provided `headers` object replaces the defaults entirely. The // /auth/bootstrap endpoint is pre-auth and CSRF-exempt, so no // X-CSRF-Token is needed. bootstrap: (email: string, name: string, password: string, token: string) => request<{ user: { id: string; email: string; username: string; name: string; role: string }; token: string }>('/auth/bootstrap', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Bootstrap-Token': token }, body: JSON.stringify({ email, name, password }) }), checkUsername: (username: string) => request<{ available: boolean; reason: string | null; message: string | null }>(`/auth/check-username?username=${encodeURIComponent(username)}`), logout: () => request<{ ok: boolean }>('/auth/logout', { method: 'POST' }), forgotPassword: (email: string) => request<{ ok: boolean; message: string }>('/auth/forgot-password', { method: 'POST', body: JSON.stringify({ email }) }), resetPassword: (token: string, password: string) => request<{ ok: boolean; user: { id: string; email: string; username: string; name: string; role: string }; token: string }>('/auth/reset-password', { method: 'POST', body: JSON.stringify({ token, password }) }), me: () => request('/auth/me'), updateProfile: (data: UserProfileUpdate) => request('/auth/me', { method: 'PATCH', body: JSON.stringify(data) }), unlinkProvider: (provider: string) => request<{ ok: boolean; provider: string }>('/auth/oauth-unlink', { method: 'POST', body: JSON.stringify({ provider }) }), totp: { setup: () => request('/auth/2fa/setup', { method: 'POST' }), verify: (code: string, secret: string) => request('/auth/2fa/verify', { method: 'POST', body: JSON.stringify({ code, secret }) }), disable: (password: string) => request('/auth/2fa/disable', { method: 'POST', body: JSON.stringify({ password }) }) }, tokens: { list: () => request('/auth/tokens'), create: (name: string) => request('/auth/tokens', { method: 'POST', body: JSON.stringify({ name }) }), delete: (tokenId: string) => request(`/auth/tokens/${tokenId}`, { method: 'DELETE' }) }, cli: { getSession: (code: string) => request<{ status: string; token?: string; user?: { id: string; email: string; name: string; role: string } }>(`/auth/cli/sessions/${code}`), approveSession: (code: string) => request<{ approved: boolean; user: { id: string; email: string; name: string; role: string } }>(`/auth/cli/sessions/${code}/approve`, { method: 'POST' }) } }, // ── Attachments ────────────────────────────────────────────────────────── // // The upload endpoint takes multipart/form-data, not JSON, so it // bypasses the shared `request` helper (which sets Content-Type: // application/json). It still uses fetch directly with cookies and // CSRF — same behavior every other state-changing request gets. // // downloadUrl is a pure URL builder so callers can wire it directly // into , anchor href, etc. — no fetch needed. attachments: { /** * Upload a file via multipart POST. Returns the persisted * attachment metadata + the canonical download URL. * * @param workspaceSlug workspace slug (not ID) * @param file the File / Blob to upload * @param itemId optional parent item UUID — pass undefined * for a free-floating upload * @param onProgress optional progress callback. Note: fetch() * has no upload-progress API; pass this only * when the caller wraps with XMLHttpRequest. * Currently unused by this method but kept * in the signature so the editor plugin can * opt in later (TASK-875). */ async upload( workspaceSlug: string, file: File | Blob, itemId?: string, _onProgress?: (loaded: number, total: number) => void ): Promise { const fd = new FormData(); // FormData.append needs a filename string for Blob inputs; // File already carries its own name. if (file instanceof File) { fd.append('file', file); } else { fd.append('file', file, 'upload.bin'); } if (itemId) fd.append('item_id', itemId); const headers: Record = {}; const csrf = getCSRFToken(); if (csrf) headers['X-CSRF-Token'] = csrf; const resp = await fetch(`${BASE}/workspaces/${workspaceSlug}/attachments`, { method: 'POST', headers, credentials: 'same-origin', body: fd }); if (resp.status === 401) { if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) { window.location.href = '/login'; } throw new PadApiError({ code: 'unauthorized', message: 'Authentication required' }); } if (!resp.ok) { const body = await resp.json().catch(() => null); if (body?.error) throw new PadApiError(body.error); throw new Error(`upload failed: ${resp.status}`); } return (await resp.json()) as AttachmentUploadResult; }, /** * Build the GET URL for an attachment. Suitable for and * — the browser sends the auth cookie automatically. * * `variant` is optional and currently supports "thumb-sm" or * "thumb-md"; the server falls back to the original if no * derived row exists. */ downloadUrl( workspaceSlug: string, attachmentId: string, variant?: 'thumb-sm' | 'thumb-md' | 'original' ): string { const base = `${BASE}/workspaces/${workspaceSlug}/attachments/${attachmentId}`; return variant ? `${base}?variant=${encodeURIComponent(variant)}` : base; }, /** * Apply a server-side image transform (rotate / crop) to an * attachment, producing a NEW attachment row whose UUID the * editor swaps into the corresponding node. The original is * left in place and reclaimed by orphan GC after the grace * period (TASK-886) once nothing references it. * * Returns the same shape as the upload endpoint so callers * have everything they need (id, dimensions, etc.) to update * the editor node attrs without a follow-up GET. * * Only callable on attachments whose MIME the server's * configured Processor supports (the response is 415 when * not). Editors should gate the UI on * `server.capabilities()` upfront so users don't see a * disabled-then-enabled spinner cycle on each click. */ async transform( workspaceSlug: string, attachmentId: string, payload: AttachmentTransformRequest ): Promise { const headers: Record = { 'Content-Type': 'application/json' }; const csrf = getCSRFToken(); if (csrf) headers['X-CSRF-Token'] = csrf; const resp = await fetch( `${BASE}/workspaces/${workspaceSlug}/attachments/${attachmentId}/transform`, { method: 'POST', headers, credentials: 'same-origin', body: JSON.stringify(payload) } ); if (resp.status === 401) { if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) { window.location.href = '/login'; } throw new PadApiError({ code: 'unauthorized', message: 'Authentication required' }); } if (!resp.ok) { const body = await resp.json().catch(() => null); if (body?.error) throw new PadApiError(body.error); throw new Error(`transform failed: ${resp.status}`); } return (await resp.json()) as AttachmentTransformResult; }, /** * Workspace storage usage summary: bytes consumed by live * attachments + the effective limit for the workspace owner's * plan + a flag for whether an admin-set per-user override is * configured. * * Server caches per-workspace for ~30s — uploads invalidate * the cache eagerly so the bar doesn't lag behind a new * upload, but multiple page loads in the cache window collapse * to a single DB read. * * `limit_bytes === -1` means unlimited (pro / self-hosted / * unowned workspaces). Callers should branch on that to render * a counter rather than a capped usage bar. */ storageUsage(workspaceSlug: string): Promise { return request( `/workspaces/${workspaceSlug}/storage/usage` ); }, /** * Paginated list of attachments in a workspace, used by the * Settings → Storage page. Hides derived blobs (thumbnails) by * default — those are managed automatically and shouldn't show * as user-visible rows. * * `total` in the response is the count of all matching rows * (across all pages); pair it with `limit` + `offset` to render * a classic paginator. Server clamps limit to [1, 200]. */ list( workspaceSlug: string, filters: AttachmentListFilters = {} ): Promise { const params = new URLSearchParams(); if (filters.category) params.set('category', filters.category); if (filters.item) params.set('item', filters.item); if (filters.collection) params.set('collection', filters.collection); if (filters.sort) params.set('sort', filters.sort); if (filters.limit !== undefined) params.set('limit', String(filters.limit)); if (filters.offset !== undefined) params.set('offset', String(filters.offset)); const qs = params.toString(); const suffix = qs ? `?${qs}` : ''; return request( `/workspaces/${workspaceSlug}/attachments${suffix}` ); }, /** * Soft-delete an attachment by ID. The blob on disk stays put * (content-addressed dedupe means the same hash may still be * referenced) — orphan GC reclaims past the grace period. * * Returns 204 No Content. Refuses to delete derived * (thumbnail) rows — caller must delete the original. */ async delete(workspaceSlug: string, attachmentId: string): Promise { await request( `/workspaces/${workspaceSlug}/attachments/${attachmentId}`, { method: 'DELETE' } ); } }, // ── Server capabilities ───────────────────────────────────────────────── // // Reports what the configured image processor can do (formats, // transcode flag, max-pixels ceiling). Public endpoint — the // editor reads it pre-login on shared-item preview surfaces. The // response is static for the lifetime of the binary, so callers // can cache freely. server: { capabilities: () => request('/server/capabilities') }, // ── Connected apps (TASK-954) ──────────────────────────────────────────── // // Lists every active OAuth grant chain the user has authorized // (Claude Desktop, Cursor, …) and lets them revoke one. Cloud-mode- // only — the route returns 404 outside cloud mode, the page hides // the nav link in self-host. connectedApps: { list: () => request<{ items: ConnectedApp[] }>('/connected-apps'), revoke: (id: string) => request(`/connected-apps/${encodeURIComponent(id)}`, { method: 'DELETE' }) }, // ── Admin ──────────────────────────────────────────────────────────────── admin: { getSettings: () => request>('/admin/settings'), updateSettings: (settings: Record) => request<{ ok: boolean }>('/admin/settings', { method: 'PATCH', body: JSON.stringify(settings) }), testEmail: (to?: string) => request<{ ok: boolean; sent_to: string }>('/admin/test-email', { method: 'POST', body: JSON.stringify(to ? { to } : {}) }), // Billing stats for the admin Billing dashboard (TASK-828 / PLAN-825). // Returns merged Stripe-derived metrics (active subs, MRR, ARR, churn, // cancellations) plus local users-table aggregates (customers_by_plan, // new_signups_30d). Always 200 — degraded states surface as the // stripe_configured + cloud_unreachable booleans on the body. // Cloud-mode only (returns 404 in self-host). getBillingStats: () => request('/admin/billing-stats') } }; export { PadApiError };