mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 13:28:57 +00:00
a04091517c
Wave 5 of PLAN-1933 (DR-1 model b) — surfaces the unverified-email state in the web UI and makes it actionable. - AuthSession user type gains `email_verified` (owns the session user-type change); register response user type gains it too. - authStore.emailVerified getter, default TRUE (mirrors `emailConfigured ?? true`) — a missing field or a self-host instance must never show the banner. - VerifyEmailBanner rendered in the workspace layout above ConnectBanner, shown only when `cloudMode && user && !emailVerified`, with a Resend button hitting POST /auth/resend-verification and a "sent" confirmation (enumeration-safe, always 200). - api.auth.resendVerification client method. - /register shows a "check your email to verify your account" state after a cloud self-serve signup returns an unverified user, instead of navigating in and implying full access; includes resend + continue actions. Gates: make check (lint + go test + govulncheck + web-check) green; cd web && npm run check → 0 errors. Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2057 lines
82 KiB
TypeScript
2057 lines
82 KiB
TypeScript
import type {
|
|
Workspace,
|
|
WorkspaceCreate,
|
|
WorkspaceUpdate,
|
|
Collection,
|
|
CollectionCreate,
|
|
CollectionUpdate,
|
|
Backlink,
|
|
Item,
|
|
BulkItemsRequest,
|
|
BulkItemsResponse,
|
|
ItemChangeRow,
|
|
ItemChangesResponse,
|
|
ItemCreate,
|
|
TagCount,
|
|
ItemIndexResponse,
|
|
ItemIndexRow,
|
|
ItemUpdate,
|
|
ItemLink,
|
|
ItemLinkCreate,
|
|
Comment,
|
|
CommentCreate,
|
|
Version,
|
|
DashboardResponse,
|
|
DashboardSuggestion,
|
|
StandupResponse,
|
|
ChangelogResponse,
|
|
GraphResponse,
|
|
ReportData,
|
|
ReportLayout,
|
|
ReportWindow,
|
|
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,
|
|
SharePayload,
|
|
TOTPSetupResponse,
|
|
TOTPVerifyResponse,
|
|
TOTPDisableResponse,
|
|
AdminBillingStats,
|
|
AttachmentUploadResult,
|
|
AttachmentTransformRequest,
|
|
AttachmentTransformResult,
|
|
ServerCapabilities,
|
|
WorkspaceStorageInfo,
|
|
AttachmentListFilters,
|
|
AttachmentListResponse,
|
|
ConnectedApp,
|
|
ClaimCodeResponse,
|
|
ImportArtifactResult
|
|
} from '$lib/types';
|
|
|
|
const BASE = '/api/v1';
|
|
|
|
class PadApiError extends Error {
|
|
code: string;
|
|
/**
|
|
* Structured details for error codes that carry recovery
|
|
* information. Shape varies by code; call sites branching on
|
|
* `code` cast to the matching payload. The canonical consumer
|
|
* today is `open_children` (IDEA-1494 / BUG-1538), whose details
|
|
* carry `{ open_children, hidden_blocker_count, done_field,
|
|
* attempted_value }`. Undefined for codes that don't supply it.
|
|
*
|
|
* For `plan_limit_exceeded` (TASK-788) the shape is:
|
|
* { feature: string, limit: number, current: number, plan: string, upgrade_url: string }
|
|
*/
|
|
details?: Record<string, unknown>;
|
|
constructor(err: ApiError) {
|
|
super(err.message);
|
|
this.code = err.code;
|
|
this.details = err.details;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns true when `err` is a PadApiError with code "plan_limit_exceeded".
|
|
* Use this at every write-operation catch block to branch on plan-gating
|
|
* rather than showing a generic "Failed to …" toast. TASK-788.
|
|
*/
|
|
function isPlanLimitError(err: unknown): err is PadApiError {
|
|
return err instanceof PadApiError && err.code === 'plan_limit_exceeded';
|
|
}
|
|
|
|
/**
|
|
* Returns a human-readable upgrade-signal message for a plan limit error.
|
|
* Falls back to the server-supplied `err.message` if details are unavailable,
|
|
* so the function is always safe to call. TASK-788.
|
|
*
|
|
* Example output:
|
|
* "You've reached the 3-member limit on the free plan. Upgrade to Pro →"
|
|
*/
|
|
function planLimitMessage(err: PadApiError): string {
|
|
// The server already sends a good sentence in err.message (TASK-788).
|
|
// We use it directly here so there is a single source of truth for the
|
|
// wording; callers append the upgrade link separately in the UI.
|
|
return err.message || 'Plan limit reached. Upgrade to Pro to continue.';
|
|
}
|
|
|
|
/**
|
|
* `AccessRevokedScope` describes WHAT the 403 response was for so the
|
|
* registered handler can purge the right slice of the local cache.
|
|
* Per DOC-1342 design decision #3: the local cache is "what you could
|
|
* see last time you synced" — a 403 mid-session means access was
|
|
* revoked, and the offending entry should drop.
|
|
*
|
|
* The scope is the WHOLE WORKSPACE. Pad's server returns 403 from
|
|
* the workspace-access middleware (see internal/server/middleware_auth.go:
|
|
* `permission_denied`, `not a member of this workspace`), and item-
|
|
* level visibility misses return 404. So a 403 on any workspace-scoped
|
|
* endpoint means access to the workspace is gone — purging a single
|
|
* item or collection would leave the rest of the cache stale.
|
|
* Per-item granular purge was attempted in TASK-1360 round 1 and
|
|
* round 2 (Codex P1 each) — both got the scope wrong; this is the
|
|
* conservative fix.
|
|
*/
|
|
export type AccessRevokedScope = { kind: 'workspace'; workspace: string };
|
|
|
|
type AccessRevokedHandler = (scope: AccessRevokedScope) => void;
|
|
|
|
let accessRevokedHandler: AccessRevokedHandler | null = null;
|
|
|
|
/**
|
|
* Register a single callback fired when the API client sees a 403
|
|
* Forbidden response on a workspace-scoped item or collection
|
|
* endpoint. The app calls this once at startup (typically from
|
|
* +layout.svelte) to wire `localIndex` purges into the API error
|
|
* path without forming a client.ts → store circular dependency.
|
|
*
|
|
* Calling this multiple times replaces the previous handler.
|
|
* Handler failures are caught and logged; the 403 still propagates
|
|
* to the caller as a `PadApiError`.
|
|
*/
|
|
export function setAccessRevokedHandler(handler: AccessRevokedHandler | null): void {
|
|
accessRevokedHandler = handler;
|
|
}
|
|
|
|
/**
|
|
* Parse a URL path and decide whether a 403 on it indicates that the
|
|
* caller's READ access to the workspace's item set has been revoked.
|
|
* Returns null for any path that doesn't qualify — auth, admin,
|
|
* server health, OR workspace-scoped endpoints whose 403 doesn't
|
|
* imply workspace-wide read loss (members list, storage usage, etc.
|
|
* 403 from those for grant-only guests is expected and shouldn't
|
|
* purge the cache — Codex P1 round 3 of TASK-1360).
|
|
*
|
|
* The whitelist is the set of endpoints the local-first read model
|
|
* actually consumes for items:
|
|
*
|
|
* GET /workspaces/{ws}/items
|
|
* GET /workspaces/{ws}/items/{slug}
|
|
* GET /workspaces/{ws}/items-index
|
|
* GET /workspaces/{ws}/items-changes
|
|
* GET /workspaces/{ws}/collections/{coll}/items
|
|
*
|
|
* A 403 on any of these means the local cache is stale-by-permission
|
|
* (membership revoked or item-grant scope shrunk to nothing). A 403
|
|
* on anything else stays opaque to the local index.
|
|
*/
|
|
function parseAccessRevokedScope(path: string): AccessRevokedScope | null {
|
|
// Strip the BASE prefix and any leading slash / query string.
|
|
let stripped = path.startsWith(BASE) ? path.slice(BASE.length) : path;
|
|
const qIdx = stripped.indexOf('?');
|
|
if (qIdx >= 0) stripped = stripped.slice(0, qIdx);
|
|
if (stripped.startsWith('/')) stripped = stripped.slice(1);
|
|
const parts = stripped.split('/');
|
|
if (parts.length < 3 || parts[0] !== 'workspaces' || !parts[1]) {
|
|
return null;
|
|
}
|
|
const ws = parts[1];
|
|
const tail = parts[2];
|
|
|
|
// /workspaces/{ws}/items-index | /workspaces/{ws}/items-changes
|
|
if (parts.length === 3 && (tail === 'items-index' || tail === 'items-changes')) {
|
|
return { kind: 'workspace', workspace: ws };
|
|
}
|
|
// /workspaces/{ws}/items (list)
|
|
// /workspaces/{ws}/items/{idOrSlug} (single read — exact, no subroute)
|
|
if (parts.length === 3 && tail === 'items') {
|
|
return { kind: 'workspace', workspace: ws };
|
|
}
|
|
if (parts.length === 4 && tail === 'items') {
|
|
return { kind: 'workspace', workspace: ws };
|
|
}
|
|
// /workspaces/{ws}/collections/{coll}/items
|
|
if (
|
|
parts.length === 5 &&
|
|
tail === 'collections' &&
|
|
parts[4] === 'items'
|
|
) {
|
|
return { kind: 'workspace', workspace: ws };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Fire the registered access-revoked handler for a 403 response. The
|
|
* handler is called best-effort — its failures are swallowed so the
|
|
* caller still sees a clean PadApiError. Public for testing.
|
|
*/
|
|
function notifyAccessRevoked(path: string): void {
|
|
if (!accessRevokedHandler) return;
|
|
const scope = parseAccessRevokedScope(path);
|
|
if (!scope) return;
|
|
try {
|
|
accessRevokedHandler(scope);
|
|
} catch (err) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn('access-revoked handler threw', err);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Endpoints hit only from explicit, unauthenticated auth-form submissions
|
|
* (login, register, 2FA challenge). A 401 from these means "the
|
|
* credentials/code the user just typed were wrong", not "your session
|
|
* expired" — so the interceptor must NOT redirect or mask the response
|
|
* body here; the server's real message ("Invalid email or password",
|
|
* "Invalid 2FA verification", ...) needs to reach the form. Mirrors the
|
|
* grouping `middleware_ratelimit.go` already uses for these same three
|
|
* paths. BUG-1929 / IDEA-1927 §B2.
|
|
*
|
|
* `register` never actually 401s server-side today (only 400/403) but is
|
|
* included for defense-in-depth per the audit.
|
|
*/
|
|
const AUTH_FORM_401_PATHS = new Set(['/auth/login', '/auth/register', '/auth/2fa/login-verify']);
|
|
|
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
|
const headers: Record<string, string> = { '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) {
|
|
const barePath = path.split('?')[0];
|
|
if (AUTH_FORM_401_PATHS.has(barePath)) {
|
|
const body = await resp.json().catch(() => null);
|
|
if (body?.error) throw new PadApiError(body.error);
|
|
throw new PadApiError({ code: 'unauthorized', message: 'Authentication failed' });
|
|
}
|
|
// Redirect to login page (avoid infinite loop), preserving the
|
|
// current location as a return-to target so a genuine session
|
|
// expiry doesn't strand the user wherever they were (BUG-1929 /
|
|
// IDEA-1927 §B2(c)) — /login already validates and consumes
|
|
// `?redirect=` (see $lib/auth/redirect.ts).
|
|
if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
|
|
const target = window.location.pathname + window.location.search;
|
|
window.location.href = `/login?redirect=${encodeURIComponent(target)}`;
|
|
}
|
|
throw new PadApiError({ code: 'unauthorized', message: 'Authentication required' });
|
|
}
|
|
if (resp.status === 403) {
|
|
// Signal the registered access-revoked handler BEFORE
|
|
// throwing, so the local cache purges its stale entry as
|
|
// part of the same error path (DOC-1342 decision #3, TASK-1360).
|
|
// The handler is best-effort and never throws into the API
|
|
// client.
|
|
//
|
|
// Restricted to GET / HEAD requests. A 403 on
|
|
// POST/PATCH/DELETE usually means "you can READ but not
|
|
// WRITE this resource" — purging on those would wipe
|
|
// perfectly accessible cache rows for a read-only user who
|
|
// tried (and was correctly denied) to create / update /
|
|
// archive an item (Codex P1 round 1 of TASK-1360). Read 403
|
|
// is the canonical "visibility revoked" signal.
|
|
//
|
|
// `method` is already uppercased above; undefined means GET
|
|
// (the fetch default).
|
|
if (method === undefined || method === 'GET' || method === 'HEAD') {
|
|
notifyAccessRevoked(path);
|
|
}
|
|
}
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* Pull the filename out of a Content-Disposition header value. Handles both
|
|
* the RFC 5987 extended `filename*=charset'lang'value` form (preferred when
|
|
* present) and the plain quoted/unquoted `filename="..."` form. Returns null
|
|
* when no filename token is present so the caller can fall back to a computed
|
|
* default.
|
|
*
|
|
* The extended form is `filename*=<charset>'<lang>'<percent-encoded-value>`,
|
|
* e.g. `filename*=UTF-8'en'r%C3%A9sum%C3%A9.pad.md`. Per RFC 5987 we drop the
|
|
* charset + language by splitting on the first two `'` characters, then
|
|
* percent-decode the remaining value. Malformed input (missing the two `'`
|
|
* delimiters, or a bad percent-encoding) falls through to the plain form.
|
|
*/
|
|
function parseContentDispositionFilename(disposition: string): string | null {
|
|
if (!disposition) return null;
|
|
// RFC 5987 extended form takes precedence — it's percent-encoded.
|
|
const extended = disposition.match(/filename\*=([^;]+)/i);
|
|
if (extended?.[1]) {
|
|
// Strip surrounding whitespace/quotes the producer may have added.
|
|
const raw = extended[1].trim().replace(/^"|"$/g, '');
|
|
// Split on the first two `'` to drop `charset` and `lang`, leaving the
|
|
// percent-encoded value. A well-formed header has exactly two before
|
|
// the value (the value itself can't contain a bare `'`).
|
|
const firstQuote = raw.indexOf("'");
|
|
const secondQuote = firstQuote >= 0 ? raw.indexOf("'", firstQuote + 1) : -1;
|
|
if (secondQuote >= 0) {
|
|
const value = raw.slice(secondQuote + 1);
|
|
try {
|
|
return decodeURIComponent(value);
|
|
} catch {
|
|
// Malformed percent-encoding — fall through to the plain form.
|
|
}
|
|
} else {
|
|
// No charset'lang' prefix present — treat the whole token as the value.
|
|
try {
|
|
return decodeURIComponent(raw);
|
|
} catch {
|
|
// Fall through to the plain form on a malformed percent-encoding.
|
|
}
|
|
}
|
|
}
|
|
const plain = disposition.match(/filename="?([^";]+)"?/i);
|
|
if (plain?.[1]) return plain[1].trim();
|
|
return null;
|
|
}
|
|
|
|
function qs(params?: Record<string, string | number | boolean | undefined>): string {
|
|
if (!params) return '';
|
|
const filtered: Record<string, string> = {};
|
|
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).
|
|
//
|
|
// 'open' added for PAD_BYPASS_SETUP_TOKEN: self-host operators on
|
|
// trusted networks who explicitly opted into open-bootstrap. The
|
|
// /setup form works without a token, and SetupRequiredNotice points
|
|
// directly at /setup with no copy-from-logs instructions. Cloud
|
|
// mode also never advertises 'open'.
|
|
setup_method?: 'local_cli' | 'docker_exec' | 'cloud' | 'logs_token' | 'open';
|
|
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;
|
|
// billing_available is true when PAD_BILLING_AVAILABLE=true on the server
|
|
// AND the deployment is in cloud mode. Use authStore.billingAvailable rather
|
|
// than reading this field directly. TASK-800.
|
|
billing_available?: boolean;
|
|
// email_configured is false when the self-host server has no transactional
|
|
// email provider (no Maileroo key). The /forgot-password page reads it to
|
|
// replace "we emailed you a link" with host-recovery guidance, since no
|
|
// email can actually be sent. Absent on older servers — treat as true.
|
|
email_configured?: boolean;
|
|
// version is the server build version (same value as HealthResponse.version),
|
|
// surfaced on /auth/session so clients — notably the mobile shells — can read
|
|
// it in the call they already make on connect (IDEA-1826). Empty string only
|
|
// when no version was stamped at build time; "dev" on dev builds.
|
|
version?: string;
|
|
// webmcp_enabled gates the browser-side WebMCP surface (PLAN-1888 DR-6).
|
|
// The web client registers document.modelContext tools only when true.
|
|
// Absent on older servers and default false on fresh instances — treat
|
|
// absent as false.
|
|
webmcp_enabled?: boolean;
|
|
// email_verified is false ONLY for a Pad Cloud self-serve signup that
|
|
// hasn't confirmed its email yet (PLAN-1933 DR-3). OAuth / invited /
|
|
// admin-created / pre-existing accounts are verified, and self-hosted
|
|
// instances never emit an unverified user. Absent on older servers —
|
|
// authStore.emailVerified treats absent as TRUE so the verification
|
|
// banner never shows on self-host or before the field lands.
|
|
user?: { id: string; email: string; username: string; name: string; role: string; plan?: string; email_verified?: boolean };
|
|
}
|
|
|
|
// ── WebMCP tool-surface (PLAN-1888 / TASK-1892) ────────────────────────────
|
|
// Mirrors internal/mcp/tool_surface.go's serialized payload. The browser
|
|
// WebMCP module (web/src/lib/webmcp/) fetches this once per workspace entry
|
|
// to build document.modelContext tool descriptors from the live catalog.
|
|
|
|
/** One action of a catalog tool, with its read-only classification. */
|
|
export interface ToolSurfaceAction {
|
|
name: string;
|
|
/** True when this action performs no mutation (DR-2 read set). */
|
|
read_only: boolean;
|
|
}
|
|
|
|
/** One parameter of a catalog tool's flat param union. */
|
|
export interface ToolSurfaceParam {
|
|
name: string;
|
|
type: string;
|
|
description?: string;
|
|
enum?: string[];
|
|
}
|
|
|
|
/** One catalog tool (pad_item, pad_search, …) as served over REST. */
|
|
export interface ToolSurfaceTool {
|
|
name: string;
|
|
description: string;
|
|
/**
|
|
* True when the underlying ToolDef declared `Schema.Workspace` — i.e.
|
|
* the serializer emitted a `workspace` param. The WebMCP builder MUST
|
|
* strip that param and force the route wsSlug (DR-4).
|
|
*/
|
|
workspace: boolean;
|
|
actions: ToolSurfaceAction[];
|
|
params: ToolSurfaceParam[];
|
|
}
|
|
|
|
export interface ToolSurfaceResponse {
|
|
tool_surface_version: string;
|
|
rollout_status?: string;
|
|
tools: ToolSurfaceTool[];
|
|
}
|
|
|
|
/**
|
|
* Playbook metadata as returned by `GET /workspaces/{ws}/playbooks` — the
|
|
* same projection the bootstrap blob carries (ref, title, slug,
|
|
* invocation_slug, trigger, scope, status, has_arguments, summary). Loosely
|
|
* typed (Record) since the WebMCP read path forwards it verbatim as JSON.
|
|
*/
|
|
export type PlaybookMeta = Record<string, unknown>;
|
|
|
|
// ImportURLResponse mirrors internal/server/handlers_import.go's
|
|
// importURLResponse. Side-effect-free: no DB writes happen on the
|
|
// server during this call; the editor decides whether to splice the
|
|
// markdown into an item.
|
|
export interface ImportURLResponse {
|
|
markdown: string;
|
|
detected_type: 'openapi' | 'generic';
|
|
title?: string;
|
|
source_url: string;
|
|
fetched_at: string;
|
|
content_type: 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;
|
|
}
|
|
|
|
// Non-consuming invitation preview (BUG-1934). `found` is false for
|
|
// invalid/expired/missing codes (the endpoint is always-200 for enumeration
|
|
// safety); the other fields are only present when `found` is true.
|
|
export interface InvitationPreview {
|
|
found: boolean;
|
|
email?: string;
|
|
workspace_name?: string;
|
|
has_account?: boolean;
|
|
}
|
|
|
|
export const api = {
|
|
// ── Health / Version ──────────────────────────────────────────────────────
|
|
|
|
health: () => request<HealthResponse>('/health'),
|
|
|
|
// ── Templates ─────────────────────────────────────────────────────────────
|
|
|
|
templates: {
|
|
list: () => request<WorkspaceTemplate[]>('/templates'),
|
|
},
|
|
|
|
// ── WebMCP tool-surface (PLAN-1888 / TASK-1892) ───────────────────────────
|
|
|
|
mcp: {
|
|
/**
|
|
* The catalog tool-surface descriptor blob (`GET /api/v1/mcp/tool-surface`,
|
|
* added in Phase 2). Session-authed; the browser WebMCP module fetches it
|
|
* once per workspace entry to build document.modelContext tools.
|
|
*/
|
|
toolSurface: () => request<ToolSurfaceResponse>('/mcp/tool-surface'),
|
|
},
|
|
|
|
// ── Playbooks ─────────────────────────────────────────────────────────────
|
|
|
|
playbooks: {
|
|
/** Playbook metadata for the workspace (bootstrap-shaped projection). */
|
|
list: (ws: string) =>
|
|
request<PlaybookMeta[]>(`/workspaces/${ws}/playbooks`),
|
|
|
|
/** Full playbook item by ref / slug / invocation_slug. */
|
|
get: (ws: string, ref: string) =>
|
|
request<Item>(`/workspaces/${ws}/playbooks/${ref}`),
|
|
|
|
/**
|
|
* Bind args to a playbook's declared spec and return the body +
|
|
* bound args + any unsatisfied required args. Side-effect-free
|
|
* server-side (the server only parses; the agent executes) —
|
|
* mirrors the CLI's `pad playbook run` and the MCP
|
|
* `pad_playbook.action=run`. Pass either a pre-parsed `args` map
|
|
* or raw CLI tokens (`raw_args`); the server merges them.
|
|
*/
|
|
run: (
|
|
ws: string,
|
|
ref: string,
|
|
body?: { args?: Record<string, unknown>; raw_args?: string[] }
|
|
) =>
|
|
request<unknown>(`/workspaces/${ws}/playbooks/${ref}/run`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(body ?? {})
|
|
}),
|
|
},
|
|
|
|
// ── Workspaces ────────────────────────────────────────────────────────────
|
|
|
|
workspaces: {
|
|
list: () => request<Workspace[]>('/workspaces'),
|
|
|
|
create: (data: WorkspaceCreate) =>
|
|
request<Workspace>('/workspaces', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data)
|
|
}),
|
|
|
|
get: (slug: string) => request<Workspace>(`/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<WorkspaceMembership>(`/workspaces/${slug}/me`),
|
|
|
|
update: (slug: string, data: WorkspaceUpdate) =>
|
|
request<Workspace>(`/workspaces/${slug}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data)
|
|
}),
|
|
|
|
delete: (slug: string) =>
|
|
request<void>(`/workspaces/${slug}`, { method: 'DELETE' }),
|
|
|
|
reorder: (updates: { slug: string; sort_order: number }[]) =>
|
|
request<void>('/workspaces/reorder', {
|
|
method: 'PUT',
|
|
body: JSON.stringify(updates)
|
|
}),
|
|
|
|
// Generate a 6-digit stateless claim code for this workspace, OR
|
|
// (when an active OAuth connection of the calling user already
|
|
// covers it) report `suppressed: true` so the modal can render
|
|
// the "your agent can already see this workspace" hint instead.
|
|
// Backed by GET /api/v1/workspaces/{slug}/claim-code
|
|
// (PLAN-1519 / TASK-1525 / IDEA-1517 §4).
|
|
claimCode: (slug: string) =>
|
|
request<ClaimCodeResponse>(`/workspaces/${slug}/claim-code`),
|
|
|
|
// 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 <bundle.tar.gz>` flow.
|
|
importBundle: async (file: File, name?: string): Promise<Workspace> => {
|
|
const headers: Record<string, string> = { '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<Collection[]>(`/workspaces/${ws}/collections`),
|
|
|
|
create: (ws: string, data: CollectionCreate) =>
|
|
request<Collection>(`/workspaces/${ws}/collections`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(data)
|
|
}),
|
|
|
|
get: (ws: string, slug: string) =>
|
|
request<Collection>(`/workspaces/${ws}/collections/${slug}`),
|
|
|
|
update: (ws: string, slug: string, data: CollectionUpdate) =>
|
|
request<Collection>(`/workspaces/${ws}/collections/${slug}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data)
|
|
}),
|
|
|
|
delete: (ws: string, slug: string) =>
|
|
request<void>(`/workspaces/${ws}/collections/${slug}`, {
|
|
method: 'DELETE'
|
|
})
|
|
},
|
|
|
|
// ── Agent Roles ──────────────────────────────────────────────────────────
|
|
|
|
agentRoles: {
|
|
list: (ws: string) =>
|
|
request<AgentRole[]>(`/workspaces/${ws}/agent-roles`),
|
|
|
|
create: (ws: string, data: AgentRoleCreate) =>
|
|
request<AgentRole>(`/workspaces/${ws}/agent-roles`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(data)
|
|
}),
|
|
|
|
get: (ws: string, idOrSlug: string) =>
|
|
request<AgentRole>(`/workspaces/${ws}/agent-roles/${idOrSlug}`),
|
|
|
|
update: (ws: string, idOrSlug: string, data: AgentRoleUpdate) =>
|
|
request<AgentRole>(`/workspaces/${ws}/agent-roles/${idOrSlug}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data)
|
|
}),
|
|
|
|
delete: (ws: string, idOrSlug: string) =>
|
|
request<void>(`/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<void>(`/workspaces/${ws}/roles/board/reorder`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(updates)
|
|
}),
|
|
reorderLanes: (ws: string, updates: { role_id: string; sort_order: number }[]) =>
|
|
request<void>(`/workspaces/${ws}/roles/board/lane-order`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(updates)
|
|
})
|
|
},
|
|
|
|
// ── Items ─────────────────────────────────────────────────────────────────
|
|
|
|
tags: {
|
|
/**
|
|
* Distinct tags used across the workspace's items with per-tag item
|
|
* counts, ordered by count desc then tag asc. Powers the tags index
|
|
* page and tag-input autocomplete.
|
|
*/
|
|
list: (ws: string) => request<TagCount[]>(`/workspaces/${ws}/tags`),
|
|
},
|
|
|
|
items: {
|
|
/** Cross-collection item listing with optional query params. */
|
|
list: (
|
|
ws: string,
|
|
params?: Record<string, string | number | boolean | undefined>
|
|
) => request<Item[]>(`/workspaces/${ws}/items${qs(params)}`),
|
|
|
|
/** Items within a specific collection. */
|
|
listByCollection: (
|
|
ws: string,
|
|
coll: string,
|
|
params?: Record<string, string | number | boolean | undefined>
|
|
) =>
|
|
request<Item[]>(
|
|
`/workspaces/${ws}/collections/${coll}/items${qs(params)}`
|
|
),
|
|
|
|
/**
|
|
* Skinny-projection cross-collection listing for the local-first
|
|
* read model (PLAN-1343 / TASK-1344). Returns every item in a
|
|
* workspace MINUS the rich-text `content` body, plus a `total`
|
|
* count and a real workspace-scoped `seq` cursor (TASK-1353).
|
|
*
|
|
* The response `cursor` is the decimal-encoded `MAX(seq)` across
|
|
* the requested scope (or the workspace's true `MAX(seq)` when
|
|
* the filtered set is empty, so /items-changes?since=cursor
|
|
* starts at the right floor). Each row carries its own `seq`
|
|
* field so the client can reason about ordering without parsing
|
|
* the cursor.
|
|
*
|
|
* Optional filters mirror the server: `collection` narrows to one
|
|
* collection slug, `include_archived` flips the soft-delete gate.
|
|
*
|
|
* Endpoint: `GET /api/v1/workspaces/{ws}/items-index`. The path is
|
|
* deliberately at workspace level (sibling to `/plans-progress`)
|
|
* rather than `/items/index` to avoid colliding with any item
|
|
* whose slug is `"index"` — see PR #486 Codex round 1.
|
|
*
|
|
* The server's `ListItemsIndex` query doesn't scan `i.content`, but
|
|
* the Go struct serializes the zero value (`content: ""`) over the
|
|
* wire because `models.Item.Content` has no `omitempty`. Strip it
|
|
* here so the returned shape matches `ItemIndexRow`'s
|
|
* `Omit<Item, 'content'>` contract — preventing downstream code
|
|
* from spreading a row back into the canonical item store and
|
|
* silently blanking the rich-text body. Per Codex round 1 [P2]
|
|
* on PR #487.
|
|
*/
|
|
listIndex: async (
|
|
ws: string,
|
|
opts?: { collection?: string; includeArchived?: boolean }
|
|
): Promise<ItemIndexResponse> => {
|
|
const raw = await request<{
|
|
items: (ItemIndexRow & { content?: string })[];
|
|
total: number;
|
|
cursor: string;
|
|
}>(
|
|
`/workspaces/${ws}/items-index${qs({
|
|
collection: opts?.collection,
|
|
include_archived: opts?.includeArchived ? 'true' : undefined,
|
|
})}`
|
|
);
|
|
const items: ItemIndexRow[] = raw.items.map((row) => {
|
|
// Destructure to discard the always-empty `content` key so
|
|
// the returned object truly has no `content` property —
|
|
// `delete row.content` would mutate the parsed JSON in
|
|
// place, but the explicit rest pattern survives strict
|
|
// linting and produces a new shallow copy per row.
|
|
const { content: _ignored, ...rest } = row;
|
|
return rest;
|
|
});
|
|
return { items, total: raw.total, cursor: raw.cursor };
|
|
},
|
|
|
|
/**
|
|
* Delta-fetch sibling of `listIndex` (PLAN-1343 / TASK-1354).
|
|
*
|
|
* Endpoint: `GET /api/v1/workspaces/{ws}/items-changes?since=<cursor>`.
|
|
* Returns every row that has mutated since the caller's `since`
|
|
* cursor — including soft-deleted tombstones (`deleted: true`) so
|
|
* the client can remove them from its local index without a
|
|
* second roundtrip. Rows are sorted ascending by `seq`.
|
|
*
|
|
* Cursor contract: re-pass the response's `cursor` as `since`
|
|
* on the next poll for no overlap and no gap. When the response
|
|
* is empty the server returns the caller's `since` unchanged
|
|
* (position preserved). Treat the value as opaque.
|
|
*
|
|
* Limit: defaults to the server's `DefaultItemChangesLimit`
|
|
* (currently 5000). Clients that need a smaller page (low-RAM
|
|
* mobile resume) or a larger one can pass `limit`; the server
|
|
* clamps to `MaxItemChangesLimit` (50000). When the response is
|
|
* truncated, the returned cursor sits at the last row's seq so
|
|
* the client can resume.
|
|
*/
|
|
changes: async (
|
|
ws: string,
|
|
sinceCursor: string,
|
|
opts?: { limit?: number }
|
|
): Promise<ItemChangesResponse> => {
|
|
const raw = await request<{
|
|
changes: (ItemChangeRow & { content?: string })[];
|
|
cursor: string;
|
|
}>(
|
|
`/workspaces/${ws}/items-changes${qs({
|
|
since: sinceCursor,
|
|
limit: opts?.limit,
|
|
})}`
|
|
);
|
|
// Mirror `listIndex`'s defensive content-strip: the server's
|
|
// skinny scan omits `i.content`, but the Go zero-value would
|
|
// serialize as `content: ""` if any non-omitempty wrapper
|
|
// surfaced it. Strip explicitly so callers never accidentally
|
|
// spread a delta row into the canonical item store and blank
|
|
// out the rich-text body.
|
|
const changes: ItemChangeRow[] = raw.changes.map((row) => {
|
|
const { content: _ignored, ...rest } = row;
|
|
return rest;
|
|
});
|
|
return { changes, cursor: raw.cursor };
|
|
},
|
|
|
|
create: (ws: string, coll: string, data: ItemCreate) =>
|
|
request<Item>(`/workspaces/${ws}/collections/${coll}/items`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(data)
|
|
}),
|
|
|
|
get: (ws: string, slug: string) =>
|
|
request<Item>(`/workspaces/${ws}/items/${slug}`),
|
|
|
|
update: (ws: string, slug: string, data: ItemUpdate) =>
|
|
request<Item>(`/workspaces/${ws}/items/${slug}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data)
|
|
}),
|
|
|
|
/**
|
|
* flushCollabContent PATCHes items.content with the
|
|
* `?source=collab-snapshot` query param so the server
|
|
* skips the applier-routing path. Used by the editor's
|
|
* 5s-idle + on-disconnect flush (TASK-1260) — the
|
|
* connected tab IS the canonical source of truth for
|
|
* Y.Doc state, and routing through the applier would
|
|
* loop the request back to itself.
|
|
*
|
|
* `keepalive` is passed straight to fetch so the
|
|
* unmount / beforeunload flush path can outlive the
|
|
* page lifecycle (browser holds the request open until
|
|
* it completes or hits the ~64KB body cap; markdown
|
|
* bodies are well under that for typical items).
|
|
*/
|
|
flushCollabContent: (
|
|
ws: string,
|
|
slug: string,
|
|
content: string,
|
|
opts?: { keepalive?: boolean; opLogCursor?: number },
|
|
) => {
|
|
const body: { content: string; op_log_cursor?: number } = { content };
|
|
// `op_log_cursor` (TASK-1319) is the highest
|
|
// item_yjs_updates.id this tab has applied. The server
|
|
// advances `items.content_flushed_op_log_id` only when
|
|
// the cursor matches the current MAX(op-log.id) — proving
|
|
// the markdown captures every persisted op. Cursors below
|
|
// MAX leave the watermark untouched (peer ops outside this
|
|
// tab's view exist; the GC sweeper must not delete them).
|
|
//
|
|
// Always include the cursor when the caller passed it,
|
|
// INCLUDING zero. The server's stale-snapshot gate
|
|
// (round 12 [P1]) needs to see cursor=0 explicitly to
|
|
// reject flushes from clients whose Y.Doc is populated
|
|
// but whose cursor was never anchored (network blip
|
|
// during the post-replay cursor write). Omitting on 0
|
|
// would silently bypass the gate. Per Codex round 13
|
|
// [P1] of TASK-1319.
|
|
if (opts?.opLogCursor !== undefined) {
|
|
body.op_log_cursor = opts.opLogCursor;
|
|
}
|
|
return request<Item>(`/workspaces/${ws}/items/${slug}?source=collab-snapshot`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(body),
|
|
keepalive: opts?.keepalive,
|
|
});
|
|
},
|
|
|
|
delete: (ws: string, slug: string) =>
|
|
request<void>(`/workspaces/${ws}/items/${slug}`, {
|
|
method: 'DELETE'
|
|
}),
|
|
|
|
/**
|
|
* Apply one mutation verb (archive / move / tag / untag /
|
|
* set-priority / assign) to many items in a single request
|
|
* (TASK-1668 / TASK-1669). Editor/owner gated.
|
|
*
|
|
* Emits ONE `items_bulk_updated` SSE event per affected collection
|
|
* + one webhook instead of per-item fan-out — used by the lane-
|
|
* header bulk actions, which operate on a whole filtered lane.
|
|
*
|
|
* The call resolves 200 even when some rows fail: inspect the
|
|
* `failed` array (each carries `error` and, for structured
|
|
* rejections like `open_children`, `code` + `details`). `updated`
|
|
* lists the rows that changed and `total === updated + failed`.
|
|
*/
|
|
bulk: (ws: string, data: BulkItemsRequest) =>
|
|
request<BulkItemsResponse>(`/workspaces/${ws}/items/bulk`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(data)
|
|
}),
|
|
|
|
restore: (ws: string, slug: string) =>
|
|
request<Item>(`/workspaces/${ws}/items/${slug}/restore`, {
|
|
method: 'POST'
|
|
}),
|
|
|
|
/**
|
|
* Move an item to a different collection. The server applies
|
|
* the same open-children guard the PATCH path uses (IDEA-1494)
|
|
* — pass `force: true` to override when moving a parent whose
|
|
* current done-field value would land terminal in the target
|
|
* collection. Mirrors the CLI's `--force` and the server's
|
|
* `?force=true` query param on POST /move.
|
|
*/
|
|
move: (
|
|
ws: string,
|
|
slug: string,
|
|
targetCollection: string,
|
|
fieldOverrides?: Record<string, any>,
|
|
opts?: { force?: boolean }
|
|
) =>
|
|
request<Item>(
|
|
`/workspaces/${ws}/items/${slug}/move${opts?.force ? '?force=true' : ''}`,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
target_collection: targetCollection,
|
|
field_overrides: fieldOverrides,
|
|
source: 'web'
|
|
})
|
|
}
|
|
),
|
|
|
|
/**
|
|
* Inbound `[[...]]` references to an item — the "Mentioned in"
|
|
* panel data source (PLAN-1593 / TASK-1596). Returns same-
|
|
* workspace backlinks first, then cross-workspace backlinks
|
|
* (each cross-ws row carries `source_workspace_slug`). The
|
|
* server applies visibility filtering: a guest with only
|
|
* partial workspace access sees only sources they're permitted
|
|
* to read.
|
|
*
|
|
* Pagination: `limit` defaults to 50 server-side (max 300);
|
|
* `offset` enables "Load more" affordances. The panel loads
|
|
* the first page on mount; if `combined.length === limit`, the
|
|
* server probably has more rows and the UI exposes a "Show
|
|
* older" button.
|
|
*/
|
|
backlinks: (
|
|
ws: string,
|
|
slug: string,
|
|
opts?: { limit?: number; offset?: number }
|
|
) =>
|
|
request<Backlink[]>(
|
|
`/workspaces/${ws}/items/${slug}/backlinks${qs({
|
|
limit: opts?.limit,
|
|
offset: opts?.offset,
|
|
})}`
|
|
),
|
|
|
|
/** Get child items linked to a parent item */
|
|
children: (ws: string, slug: string) =>
|
|
request<Item[]>(`/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<Item[]>(`/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`),
|
|
|
|
/**
|
|
* Child-item completion progress for every item in a collection
|
|
* (BUG-1509). Returns `{item_id, total, done}` for ALL items in
|
|
* the collection — those with no linked children have total=0.
|
|
* The server enforces the same visibility/guest-grant rules as
|
|
* /plans-progress so restricted callers can't enumerate hidden
|
|
* child counts. Pass `includeArchived: true` to match the
|
|
* collection page's archived-items toggle.
|
|
*/
|
|
collectionChildProgress: (ws: string, coll: string, opts?: { includeArchived?: boolean }) =>
|
|
request<{item_id: string; total: number; done: number}[]>(
|
|
`/workspaces/${ws}/collections/${coll}/child-progress${qs({
|
|
include_archived: opts?.includeArchived ? 'true' : undefined,
|
|
})}`
|
|
),
|
|
|
|
/**
|
|
* Markdown-checkbox progress for items in a single collection.
|
|
* The server scans `- [ ]` / `- [x]` markers in each item's
|
|
* `content` and returns `{item_id, total, done}` for items with
|
|
* at least one checkbox. Pairs with `listIndex` (TASK-1349):
|
|
* the index endpoint omits content for bandwidth, this endpoint
|
|
* supplies the small derived counts the views need to render
|
|
* progress badges.
|
|
*/
|
|
collectionCheckboxProgress: (
|
|
ws: string,
|
|
coll: string,
|
|
opts?: { includeArchived?: boolean }
|
|
) =>
|
|
request<{item_id: string; total: number; done: number}[]>(
|
|
`/workspaces/${ws}/collections/${coll}/checkbox-progress${qs({
|
|
include_archived: opts?.includeArchived ? 'true' : undefined,
|
|
})}`
|
|
),
|
|
|
|
/** Star an item for the current user (idempotent) */
|
|
star: (ws: string, itemSlug: string) =>
|
|
request<void>(`/workspaces/${ws}/items/${itemSlug}/star`, {
|
|
method: 'POST'
|
|
}),
|
|
|
|
/** Unstar an item for the current user */
|
|
unstar: (ws: string, itemSlug: string) =>
|
|
request<void>(`/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<Item[]>(`/workspaces/${ws}/starred${qs(params)}`)
|
|
},
|
|
|
|
// ── Versions ──────────────────────────────────────────────────────────────
|
|
|
|
versions: {
|
|
list: (ws: string, itemSlug: string) =>
|
|
request<Version[]>(`/workspaces/${ws}/items/${itemSlug}/versions`),
|
|
|
|
/**
|
|
* Fetch a single version with its diff resolved to full content. The
|
|
* timeline serves raw reverse-patch text for diff versions; the version
|
|
* card calls this to reconstruct real content when expanded (BUG-1612).
|
|
*/
|
|
get: (ws: string, itemSlug: string, versionId: string) =>
|
|
request<Version>(`/workspaces/${ws}/items/${itemSlug}/versions/${versionId}`),
|
|
|
|
restore: (ws: string, itemSlug: string, versionId: string) =>
|
|
request<Item>(`/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<Activity[]>(`/workspaces/${ws}/items/${itemSlug}/activity`)
|
|
},
|
|
|
|
// ── Links ─────────────────────────────────────────────────────────────────
|
|
|
|
links: {
|
|
list: (ws: string, itemSlug: string) =>
|
|
request<ItemLink[]>(`/workspaces/${ws}/items/${itemSlug}/links`),
|
|
|
|
create: (ws: string, itemSlug: string, data: ItemLinkCreate) =>
|
|
request<ItemLink>(`/workspaces/${ws}/items/${itemSlug}/links`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(data)
|
|
}),
|
|
|
|
delete: (ws: string, linkId: string) =>
|
|
request<void>(`/workspaces/${ws}/links/${linkId}`, {
|
|
method: 'DELETE'
|
|
})
|
|
},
|
|
|
|
// ── Comments ──────────────────────────────────────────────────────────────
|
|
|
|
comments: {
|
|
list: (ws: string, itemSlug: string) =>
|
|
request<Comment[]>(`/workspaces/${ws}/items/${itemSlug}/comments`),
|
|
|
|
create: (ws: string, itemSlug: string, data: CommentCreate) =>
|
|
request<Comment>(`/workspaces/${ws}/items/${itemSlug}/comments`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(data)
|
|
}),
|
|
|
|
update: (ws: string, commentId: string, data: { body: string }) =>
|
|
request<Comment>(`/workspaces/${ws}/comments/${commentId}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data)
|
|
}),
|
|
|
|
delete: (ws: string, commentId: string) =>
|
|
request<void>(`/workspaces/${ws}/comments/${commentId}`, {
|
|
method: 'DELETE'
|
|
}),
|
|
|
|
reply: (ws: string, commentId: string, data: CommentCreate) =>
|
|
request<Comment>(`/workspaces/${ws}/comments/${commentId}/replies`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(data)
|
|
}),
|
|
|
|
addReaction: (ws: string, commentId: string, emoji: string) =>
|
|
request<Reaction>(`/workspaces/${ws}/comments/${commentId}/reactions`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ emoji })
|
|
}),
|
|
|
|
removeReaction: (ws: string, commentId: string, emoji: string) =>
|
|
request<void>(`/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<TimelineResponse>(`/workspaces/${ws}/items/${itemSlug}/timeline${suffix}`);
|
|
}
|
|
},
|
|
|
|
// ── Views ─────────────────────────────────────────────────────────────────
|
|
|
|
views: {
|
|
list: (ws: string, coll: string) =>
|
|
request<View[]>(`/workspaces/${ws}/collections/${coll}/views`),
|
|
|
|
create: (ws: string, coll: string, data: { name: string; view_type: string; config: string }) =>
|
|
request<View>(`/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<View>(`/workspaces/${ws}/collections/${coll}/views/${viewId}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data)
|
|
}),
|
|
|
|
delete: (ws: string, coll: string, viewId: string) =>
|
|
request<void>(`/workspaces/${ws}/collections/${coll}/views/${viewId}`, {
|
|
method: 'DELETE'
|
|
})
|
|
},
|
|
|
|
// ── Dashboard ─────────────────────────────────────────────────────────────
|
|
|
|
dashboard: {
|
|
get: (ws: string) =>
|
|
request<DashboardResponse>(`/workspaces/${ws}/dashboard`)
|
|
},
|
|
|
|
// ── Project intelligence: next / standup / changelog (PLAN-1888 / TASK-1894) ──
|
|
// Same data `pad project next|standup|changelog` computes — see the Go
|
|
// handlers in internal/server/handlers_project_intel.go.
|
|
|
|
/** The dashboard's suggested_next array — a bare array, matching the CLI's
|
|
* `pad project next --format json` output. */
|
|
next: (ws: string) => request<DashboardSuggestion[]>(`/workspaces/${ws}/next`),
|
|
|
|
/** Daily standup report: recently completed, in-progress, blockers, suggested-next. */
|
|
standup: (ws: string, opts?: { days?: number }) => {
|
|
const params = new URLSearchParams();
|
|
if (opts?.days != null) params.set('days', String(opts.days));
|
|
const qs = params.toString();
|
|
return request<StandupResponse>(`/workspaces/${ws}/standup${qs ? `?${qs}` : ''}`);
|
|
},
|
|
|
|
/** Changelog of completed items, grouped by collection. `since` takes
|
|
* precedence over `days` when both are given (mirrors the CLI). */
|
|
changelog: (ws: string, opts?: { days?: number; since?: string; parent?: string }) => {
|
|
const params = new URLSearchParams();
|
|
if (opts?.days != null) params.set('days', String(opts.days));
|
|
if (opts?.since) params.set('since', opts.since);
|
|
if (opts?.parent) params.set('parent', opts.parent);
|
|
const qs = params.toString();
|
|
return request<ChangelogResponse>(`/workspaces/${ws}/changelog${qs ? `?${qs}` : ''}`);
|
|
},
|
|
|
|
// ── Agent bootstrap ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* One-round-trip agent context for a workspace — user + collections +
|
|
* always-on conventions + roles + playbook metadata + dashboard +
|
|
* `needs_onboarding`. Mirrors the CLI `pad bootstrap`, the MCP
|
|
* `pad_meta.action=bootstrap`, and the `pad://workspace/{ws}/bootstrap`
|
|
* resource. Read-only. Typed loosely (`unknown`) because the
|
|
* AgentBootstrap shape lives in the Go server package and is not
|
|
* mirrored as a TS interface.
|
|
*/
|
|
agentBootstrap: (ws: string) =>
|
|
request<unknown>(`/workspaces/${ws}/agent/bootstrap`),
|
|
|
|
// ── Workspace Graph (PLAN-1730 / TASK-1732) ───────────────────────────────
|
|
|
|
graph: {
|
|
/**
|
|
* Whole-workspace graph: nodes + typed edges. Active items only by
|
|
* default; includeTerminal=true returns the full history.
|
|
*/
|
|
get: (ws: string, includeTerminal = false) =>
|
|
request<GraphResponse>(
|
|
`/workspaces/${ws}/graph${includeTerminal ? '?include_terminal=true' : ''}`
|
|
),
|
|
|
|
/**
|
|
* Focused neighborhood around a single item (PLAN-1780): the nodes
|
|
* reachable within `depth` hops (server default 2, clamped [1,5])
|
|
* plus the edges among them. The response's `truncated` flag is set
|
|
* when the neighborhood hit the server's node cap. includeTerminal
|
|
* pulls done items into the neighborhood (the focused item itself is
|
|
* always returned, even when terminal).
|
|
*/
|
|
getFocused: (
|
|
ws: string,
|
|
focusRef: string,
|
|
opts: { depth?: number; includeTerminal?: boolean } = {}
|
|
) => {
|
|
const params = new URLSearchParams({ focus: focusRef });
|
|
if (opts.depth != null) params.set('depth', String(opts.depth));
|
|
if (opts.includeTerminal) params.set('include_terminal', 'true');
|
|
return request<GraphResponse>(`/workspaces/${ws}/graph?${params}`);
|
|
}
|
|
},
|
|
|
|
// ── Project Report (PLAN-1628 / TASK-1630) ────────────────────────────────
|
|
|
|
report: {
|
|
/**
|
|
* Windowed project report. window ∈ {day, week, 2wk, month} (default
|
|
* week). collections optionally restricts to the given slugs.
|
|
*/
|
|
get: (
|
|
ws: string,
|
|
opts?: { window?: ReportWindow; collections?: string[]; offset?: number; includeItems?: boolean }
|
|
) => {
|
|
const params = new URLSearchParams();
|
|
if (opts?.window) params.set('window', opts.window);
|
|
if (opts?.collections?.length) params.set('collections', opts.collections.join(','));
|
|
if (opts?.offset && opts.offset > 0) params.set('offset', String(opts.offset));
|
|
if (opts?.includeItems) params.set('include_items', 'true');
|
|
const qs = params.toString();
|
|
return request<ReportData>(`/workspaces/${ws}/report${qs ? `?${qs}` : ''}`);
|
|
},
|
|
|
|
/** The current user's saved Insights layout for the workspace. */
|
|
getLayout: (ws: string) => request<ReportLayout>(`/workspaces/${ws}/report/layout`),
|
|
|
|
/** Persist the current user's Insights layout (per-user, per-workspace). */
|
|
saveLayout: (ws: string, layout: ReportLayout) =>
|
|
request<ReportLayout>(`/workspaces/${ws}/report/layout`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(layout)
|
|
})
|
|
},
|
|
|
|
// ── Incremental Sync ─────────────────────────────────────────────────────
|
|
|
|
changes: {
|
|
/** Fetch items modified since the given timestamp (unix ms). */
|
|
since: (ws: string, sinceMs: number) =>
|
|
request<ChangesResponse>(`/workspaces/${ws}/changes?since=${sinceMs}`)
|
|
},
|
|
|
|
// ── Search ────────────────────────────────────────────────────────────────
|
|
|
|
search: (query: string, filters?: SearchFilters) => {
|
|
const params: Record<string, string> = { 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<SearchResponse>(`/search?${new URLSearchParams(params).toString()}`);
|
|
},
|
|
|
|
// ── Activity ──────────────────────────────────────────────────────────────
|
|
|
|
activity: {
|
|
list: (
|
|
ws: string,
|
|
params?: Record<string, string | number | boolean | undefined>
|
|
) => request<Activity[]>(`/workspaces/${ws}/activity${qs(params)}`)
|
|
},
|
|
|
|
// ── Convention Library ────────────────────────────────────────────────────
|
|
|
|
library: {
|
|
get: () => request<ConventionLibraryResponse>('/convention-library'),
|
|
|
|
activate: (ws: string, convention: LibraryConvention) =>
|
|
request<Item>(`/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<PlaybookLibraryResponse>('/playbook-library'),
|
|
|
|
activatePlaybook: (ws: string, playbook: LibraryPlaybook) => {
|
|
// Forward invocation_slug + arguments only when set so legacy
|
|
// library entries (without them) seed with the original
|
|
// three-field shape. Mirrors ShipPlaybook() in
|
|
// internal/collections/templates_startup_ship.go.
|
|
const fields: Record<string, unknown> = {
|
|
status: 'active',
|
|
trigger: playbook.trigger,
|
|
scope: playbook.scope
|
|
};
|
|
if (playbook.invocation_slug) {
|
|
fields.invocation_slug = playbook.invocation_slug;
|
|
}
|
|
if (playbook.arguments && playbook.arguments.length > 0) {
|
|
fields.arguments = playbook.arguments;
|
|
}
|
|
return request<Item>(`/workspaces/${ws}/collections/playbooks/items`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
title: playbook.title,
|
|
content: playbook.content,
|
|
fields: JSON.stringify(fields)
|
|
})
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Activate a library convention or playbook by its exact title.
|
|
* Resolves the title against the global library client-side
|
|
* (conventions first, then playbooks — the same precedence the CLI
|
|
* `pad library activate` and the MCP `pad_library.action=activate`
|
|
* use) and creates the matching workspace item. There is no
|
|
* server-side activate-by-title endpoint; the resolution lives in
|
|
* the client, mirroring cmd/pad/main.go::libraryActivateCmd.
|
|
*
|
|
* Throws when no entry matches the title.
|
|
*/
|
|
activateByTitle: async (ws: string, title: string): Promise<Item> => {
|
|
const conv = await api.library.get();
|
|
for (const cat of conv.categories ?? []) {
|
|
const match = (cat.conventions ?? []).find((c) => c.title === title);
|
|
if (match) return api.library.activate(ws, match);
|
|
}
|
|
const plib = await api.library.getPlaybooks();
|
|
for (const cat of plib.categories ?? []) {
|
|
const match = (cat.playbooks ?? []).find((p) => p.title === title);
|
|
if (match) return api.library.activatePlaybook(ws, match);
|
|
}
|
|
throw new PadApiError({
|
|
code: 'not_found',
|
|
message: `no library convention or playbook titled ${JSON.stringify(title)}`
|
|
});
|
|
}
|
|
},
|
|
|
|
// ── Raw requests ──────────────────────────────────────────────────────────
|
|
|
|
raw: {
|
|
post: (path: string, data: unknown) =>
|
|
request<any>(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<void>(`/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<void>(`/workspaces/${ws}/members/invitations/${invitationId}`, { method: 'DELETE' }),
|
|
acceptInvitation: (code: string) =>
|
|
request<{ accepted: boolean; workspace_id: string; role: string }>(`/invitations/${code}/accept`, {
|
|
method: 'POST'
|
|
}),
|
|
// Non-consuming, public preview of an invitation (BUG-1934). Used by the
|
|
// /join page to prefill the invited email read-only and pick
|
|
// register-vs-login mode. Always resolves (HTTP 200); check `found`.
|
|
previewInvitation: (code: string) =>
|
|
request<InvitationPreview>(`/invitations/${encodeURIComponent(code)}/preview`),
|
|
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<CollectionGrant[]>(`/workspaces/${ws}/collections/${collSlug}/grants`),
|
|
createCollectionGrant: (ws: string, collSlug: string, email: string, permission: string) =>
|
|
request<CollectionGrant>(`/workspaces/${ws}/collections/${collSlug}/grants`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ email, permission })
|
|
}),
|
|
deleteCollectionGrant: (ws: string, collSlug: string, grantId: string) =>
|
|
request<void>(`/workspaces/${ws}/collections/${collSlug}/grants/${grantId}`, { method: 'DELETE' }),
|
|
listItemGrants: (ws: string, itemSlug: string) =>
|
|
request<ItemGrant[]>(`/workspaces/${ws}/items/${itemSlug}/grants`),
|
|
createItemGrant: (ws: string, itemSlug: string, email: string, permission: string) =>
|
|
request<ItemGrant>(`/workspaces/${ws}/items/${itemSlug}/grants`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ email, permission })
|
|
}),
|
|
deleteItemGrant: (ws: string, itemSlug: string, grantId: string) =>
|
|
request<void>(`/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<ShareLink[]>(`/workspaces/${ws}/items/${itemSlug}/share-links`),
|
|
createItemShareLink: (ws: string, itemSlug: string) =>
|
|
request<ShareLink>(`/workspaces/${ws}/items/${itemSlug}/share-links`, { method: 'POST' }),
|
|
listCollectionShareLinks: (ws: string, collSlug: string) =>
|
|
request<ShareLink[]>(`/workspaces/${ws}/collections/${collSlug}/share-links`),
|
|
createCollectionShareLink: (ws: string, collSlug: string) =>
|
|
request<ShareLink>(`/workspaces/${ws}/collections/${collSlug}/share-links`, { method: 'POST' }),
|
|
deleteShareLink: (ws: string, linkId: string) =>
|
|
request<void>(`/workspaces/${ws}/share-links/${linkId}`, { method: 'DELETE' }),
|
|
},
|
|
|
|
// ── Public Share (no auth) ──────────────────────────────────────────────
|
|
|
|
share: {
|
|
get: (token: string, password?: string): Promise<SharePayload> => {
|
|
const headers: Record<string, string> = {};
|
|
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() as Promise<SharePayload>;
|
|
});
|
|
},
|
|
},
|
|
|
|
// ── Auth ──────────────────────────────────────────────────────────────────
|
|
|
|
auth: {
|
|
session: (): Promise<AuthSession> => fetch(BASE + '/auth/session', { credentials: 'same-origin' }).then((r) => r.json()),
|
|
login: (email: string, password: string) =>
|
|
request<LoginResponse>('/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; email_verified?: boolean }; 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.
|
|
//
|
|
// When token === '' the header is omitted entirely. This is the
|
|
// PAD_BYPASS_SETUP_TOKEN open-bootstrap path: the server-side
|
|
// gate accepts the request without a token when bypass is on
|
|
// AND the user count is still zero. Sending an empty header
|
|
// would also work (the server treats "" as missing) but the
|
|
// omitted-header form is more explicit + slightly less weird.
|
|
//
|
|
// 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',
|
|
...(token ? { '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 })
|
|
}),
|
|
// Re-send the email-verification link for an unverified Pad Cloud
|
|
// account (PLAN-1933 DR-5 / TASK-1940). Enumeration-safe: the server
|
|
// always returns 200 with the same body whether or not the address
|
|
// maps to an unverified account, so callers should show a neutral
|
|
// "if your account still needs verification, a link was sent"
|
|
// confirmation rather than branching on the response.
|
|
resendVerification: (email: string) =>
|
|
request<{ ok: boolean; message: string }>('/auth/resend-verification', {
|
|
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<User>('/auth/me'),
|
|
updateProfile: (data: UserProfileUpdate) =>
|
|
request<User>('/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<TOTPSetupResponse>('/auth/2fa/setup', { method: 'POST' }),
|
|
verify: (code: string, secret: string) =>
|
|
request<TOTPVerifyResponse>('/auth/2fa/verify', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ code, secret })
|
|
}),
|
|
disable: (password: string) =>
|
|
request<TOTPDisableResponse>('/auth/2fa/disable', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ password })
|
|
})
|
|
},
|
|
tokens: {
|
|
list: () => request<APIToken[]>('/auth/tokens'),
|
|
create: (name: string) =>
|
|
request<APITokenWithSecret>('/auth/tokens', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ name })
|
|
}),
|
|
delete: (tokenId: string) =>
|
|
request<void>(`/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 <img src=...>, 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<AttachmentUploadResult> {
|
|
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');
|
|
}
|
|
// item_id also rides in the query string (not just the form
|
|
// body) so the server can authorize the upload via the item's
|
|
// grant chain BEFORE spooling the multipart payload (BUG-1661).
|
|
if (itemId) fd.append('item_id', itemId);
|
|
|
|
const headers: Record<string, string> = {};
|
|
const csrf = getCSRFToken();
|
|
if (csrf) headers['X-CSRF-Token'] = csrf;
|
|
|
|
const qs = itemId ? `?item_id=${encodeURIComponent(itemId)}` : '';
|
|
const resp = await fetch(`${BASE}/workspaces/${workspaceSlug}/attachments${qs}`, {
|
|
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 <img src> and
|
|
* <a href> — 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<AttachmentTransformResult> {
|
|
const headers: Record<string, string> = { '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<WorkspaceStorageInfo> {
|
|
return request<WorkspaceStorageInfo>(
|
|
`/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<AttachmentListResponse> {
|
|
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<AttachmentListResponse>(
|
|
`/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<void> {
|
|
await request<void>(
|
|
`/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<ServerCapabilities>('/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<void>(`/connected-apps/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
|
|
|
// PLAN-1519 / TASK-1524 / IDEA-1517 §3: per-connection mutations.
|
|
// Each method returns the updated ConnectedApp DTO so callers
|
|
// can re-render without a separate list refresh.
|
|
rename: (id: string, name: string) =>
|
|
request<ConnectedApp>(`/connected-apps/${encodeURIComponent(id)}/name`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ name })
|
|
}),
|
|
updateFlags: (
|
|
id: string,
|
|
flags: {
|
|
may_create_workspaces: boolean;
|
|
all_current_workspaces: boolean;
|
|
include_future_workspaces: boolean;
|
|
}
|
|
) =>
|
|
request<ConnectedApp>(`/connected-apps/${encodeURIComponent(id)}/flags`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(flags)
|
|
}),
|
|
addWorkspace: (id: string, workspaceSlug: string) =>
|
|
request<ConnectedApp>(`/connected-apps/${encodeURIComponent(id)}/workspaces`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ workspace: workspaceSlug })
|
|
}),
|
|
removeWorkspace: (id: string, workspaceSlug: string) =>
|
|
request<ConnectedApp>(
|
|
`/connected-apps/${encodeURIComponent(id)}/workspaces/${encodeURIComponent(workspaceSlug)}`,
|
|
{ method: 'DELETE' }
|
|
)
|
|
},
|
|
|
|
// ── URL Import ───────────────────────────────────────────────────────────
|
|
|
|
// Server-side fetch + convert primitive used by the editor's
|
|
// "Insert from URL" modal. Side-effect-free — the server returns
|
|
// markdown plus metadata; the client decides whether to splice it
|
|
// into an item. See PLAN-1467 / TASK-1472 / internal/urlimport.
|
|
importURL: (url: string) =>
|
|
request<ImportURLResponse>('/import/url', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ url })
|
|
}),
|
|
|
|
// ── Artifact Export / Import ───────────────────────────────────────────────
|
|
//
|
|
// Round-trip an item as a Markdown+frontmatter artifact (the `.pad.md`
|
|
// shape). Both bypass the shared `request` helper: export consumes the
|
|
// raw response body as text (not JSON) and reads the filename out of the
|
|
// Content-Disposition header, and import sends raw Markdown bytes with a
|
|
// text/markdown Content-Type rather than JSON.
|
|
|
|
/**
|
|
* GET the export endpoint and return the artifact text plus the filename
|
|
* the server suggested via Content-Disposition (falling back to
|
|
* `<ref>.pad.md` when the header is missing or unparseable). Auth is by
|
|
* item visibility; a 4xx surfaces as a PadApiError like every other call.
|
|
*/
|
|
exportItemArtifact: async (
|
|
ws: string,
|
|
ref: string
|
|
): Promise<{ filename: string; text: string }> => {
|
|
const resp = await fetch(`${BASE}/workspaces/${ws}/items/${ref}/export`, {
|
|
credentials: 'same-origin'
|
|
});
|
|
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(`export failed: ${resp.status}`);
|
|
}
|
|
const text = await resp.text();
|
|
const disposition = resp.headers.get('Content-Disposition') ?? '';
|
|
const filename = parseContentDispositionFilename(disposition) ?? `${ref}.pad.md`;
|
|
return { filename, text };
|
|
},
|
|
|
|
/**
|
|
* POST the raw artifact bytes (text/markdown) and parse the JSON result.
|
|
* Editor-gated server-side; oversized / malformed / over-quota artifacts
|
|
* 4xx with the standard `{ error: { code, message } }` envelope, surfaced
|
|
* here as a PadApiError so callers can show `err.message` cleanly.
|
|
*/
|
|
importArtifact: async (ws: string, body: string): Promise<ImportArtifactResult> => {
|
|
const headers: Record<string, string> = { 'Content-Type': 'text/markdown' };
|
|
const csrf = getCSRFToken();
|
|
if (csrf) headers['X-CSRF-Token'] = csrf;
|
|
const resp = await fetch(`${BASE}/workspaces/${ws}/import-artifact`, {
|
|
method: 'POST',
|
|
headers,
|
|
credentials: 'same-origin',
|
|
body
|
|
});
|
|
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 errBody = await resp.json().catch(() => null);
|
|
if (errBody?.error) throw new PadApiError(errBody.error);
|
|
throw new Error(`import failed: ${resp.status}`);
|
|
}
|
|
return (await resp.json()) as ImportArtifactResult;
|
|
},
|
|
|
|
// ── Admin ────────────────────────────────────────────────────────────────
|
|
|
|
admin: {
|
|
getSettings: () => request<Record<string, string>>('/admin/settings'),
|
|
updateSettings: (settings: Record<string, string>) =>
|
|
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<AdminBillingStats>('/admin/billing-stats'),
|
|
/**
|
|
* Force-verify a user's email address — the admin override for a
|
|
* locked-out unverified account (PLAN-1933 DR-7 / TASK-1939).
|
|
* Web-console only (no CLI, no MCP). Backs the "Mark email verified"
|
|
* button in the admin user panel; the button is shown only when the
|
|
* target user is unverified. Admin-only server-side (non-admin → 403).
|
|
*/
|
|
verifyEmail: (userId: string) =>
|
|
request<{ ok: boolean; message: string }>(`/admin/users/${userId}/verify-email`, {
|
|
method: 'POST'
|
|
})
|
|
},
|
|
|
|
// ── Billing (user-facing Stripe Checkout / Portal) ────────────────────
|
|
//
|
|
// These endpoints are served by the pad-cloud sidecar (not the pad binary)
|
|
// at the same origin via nginx reverse-proxy. They do NOT use the /api/v1/
|
|
// prefix. TASK-800.
|
|
|
|
billing: {
|
|
/**
|
|
* POST /billing/checkout — create a Stripe Checkout session.
|
|
* Returns `{ url: string }` on success; the caller must do
|
|
* `window.location.href = url` to start the Stripe-hosted flow.
|
|
* Returns HTTP 503 with `{ error: string }` when Stripe is not
|
|
* configured (PAD_BILLING_AVAILABLE not yet set on the sidecar).
|
|
*
|
|
* Cross-service notes (pad-cloud sidecar conventions, not pad's):
|
|
*
|
|
* CSRF: pad-cloud uses Origin/Referer-based CSRF (`validateOrigin` in
|
|
* pad-cloud/stripe.go), NOT header-token CSRF. `credentials: 'same-origin'`
|
|
* is what enables this — browsers automatically attach the matching Origin
|
|
* header on same-origin POST. No `X-CSRF-Token` forwarding needed.
|
|
*
|
|
* Error envelope: pad-cloud returns flat `{ error: string }` (its own
|
|
* convention) rather than pad's nested `{ error: { code, message } }` from
|
|
* TASK-788. Parse accordingly below.
|
|
*/
|
|
createCheckoutSession: (): Promise<{ url: string }> =>
|
|
fetch('/billing/checkout', {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({})
|
|
}).then(async (r) => {
|
|
if (!r.ok) {
|
|
const body = await r.json().catch(() => ({}));
|
|
throw new Error(
|
|
(body as { error?: string }).error || `Checkout request failed (${r.status})`
|
|
);
|
|
}
|
|
return r.json() as Promise<{ url: string }>;
|
|
})
|
|
}
|
|
};
|
|
|
|
export { PadApiError, isPlanLimitError, planLimitMessage };
|