Files
pad/web/src/lib/api/client.ts
T
xarmian 3595a3c034 fix(web): the copy and push dialogs send an idempotency key, one per opening (TASK-3597) (#2090)
The server has taken `op_key` on copy and push since TASK-3585; the web
dialogs never sent one. Each opening of CopyItemDialog and PushToAgentDialog
now mints a key, so a double dispatch replays the first answer instead of
copying or publishing twice. A refusal releases the key server-side, so a
corrected resend reuses it. A targeted push miss is a completed answer the
server stores, so the push dialog mints a new key before it re-arms;
otherwise the resend would replay the miss, or 422 for a new target.

The client methods take an optional opKey and add `op_key` to the body only
when given, so keyless bodies are byte-identical (pinned in
pushCopyOpKey.test.ts). Comments that said the copy or push endpoint has no
idempotency key now say the call site sends none.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 21:10:08 -04:00

3652 lines
153 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
BuiltinListEntry,
BuiltinStateResponse,
DecisionSettings,
MCPSettings,
DecisionSettingsInput,
Workspace,
DeletedWorkspace,
MyInvitationsResponse,
InvitationAccepted,
InvitationAcceptedFields,
WorkspaceCreate,
WorkspaceUpdate,
Collection,
CollectionCreate,
CollectionUpdate,
Backlink,
RelationBacklinksPage,
ItemDecisionsResponse,
Item,
BulkItemsRequest,
BulkItemsResponse,
ItemChangeRow,
ItemChangesResponse,
ItemCopyPreflight,
ItemCopyPreflightRequest,
ItemCopyResult,
ItemCreate,
TagCount,
ItemIndexResponse,
ItemIndexRow,
ItemUpdate,
ClientWriteStamp,
ItemLink,
ItemLinkCreate,
Comment,
CommentCreate,
Version,
ItemVersionDiff,
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,
WorkspaceTabsResponse,
ShareLink,
SharePayload,
TOTPSetupResponse,
TOTPVerifyResponse,
TOTPDisableResponse,
AdminBillingStats,
AttachmentUploadResult,
AttachmentTransformRequest,
AttachmentTransformResult,
ServerCapabilities,
LiveSessionsResponse,
ItemPushResult,
WorkspaceStorageInfo,
AttachmentListFilters,
AttachmentListResponse,
ConnectedApp,
AppGrant,
AppInstallCode,
AppInstallConfirmResult,
AppInstallList,
AppInstallPreview,
AppInstallStateResult,
AppUpgradeConfirmResult,
ClaimCodeResponse,
ImportArtifactResult,
ItemAppAction,
WorkspaceApp,
TutorialsResponse,
UIDismissalKey,
UIDismissalsResponse,
ImportOutcome,
ArchivedCollection,
CollectionFieldUsage
} from '$lib/types';
import { runSessionExpiryKeepers } from '$lib/stores/sessionExpiryKeep';
import { reportWorkspaceWrite } from './workspaceWrites';
import { noteServerDate } from './serverClock';
import { uploadImportBundle } from './importUpload';
import { reserveKeepalive } from './keepaliveBudget';
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, requested?: number }
*
* `upgrade_url` is absent when the request came from a mobile shell
* (PLAN-3291 DR-3).
*
* `requested` appears only where one operation would add MORE THAN ONE of
* the feature at once — today workspace import (BUG-3103) — and is the
* count that WOULD land, as against `current`, which is what the workspace
* holds now. Do not render prose from either: `message` already carries the
* server's finished sentence, including the branch that names `requested`,
* and rebuilding it here would fork the wording (TASK-788).
*/
details?: Record<string, unknown>;
/**
* Milliseconds the client waited (or would have waited) before a
* retry, derived from the 429 `Retry-After` header. Only set on
* `code === 'rate_limited'` errors (TASK-2026); undefined otherwise.
* Lets a caller/UI say "try again in ~Ns" instead of a bare toast.
*/
retryAfterMs?: number;
constructor(err: ApiError) {
super(err.message);
this.code = err.code;
this.details = err.details;
}
}
/**
* Server-busy / rate-limit handling (TASK-2026, BUG-3192). When the server
* returns 429 we retry idempotent GET/HEAD requests up to
* `MAX_RATE_LIMIT_RETRIES` times, each after a jittered delay no shorter than
* the server's `Retry-After`, then (if it still 429s) surface a distinct
* `rate_limited` PadApiError so the UI can show a "server busy" message
* rather than a generic failure. This is the client half of the rate-limit
* story; the server-side raise lives separately (IDEA-1842).
*
* WHY MORE THAN ONE, AND WHY JITTER (BUG-3192). Two tabs opened together
* each load an item page's worth of requests against one per-user burst.
* The refused requests all carry the same `Retry-After`, so a single fixed
* retry sent them back together, the herd was refused again, and the tab
* rendered errors it never recovered from without a reload.
*/
// Absolute ceiling on how long a single Retry-After delay may block the
// UI. A hostile or misconfigured `Retry-After: 86400` must not hang the
// app for a day — clamp to a few seconds and let the user retry by hand.
const MAX_RETRY_AFTER_MS = 5_000;
// Backoff FLOOR used when a 429 arrives with no (or an unparseable)
// Retry-After header, doubled per attempt so an unhinted retry still spaces
// itself out.
const DEFAULT_RETRY_BACKOFF_MS = 750;
// Automatic retries of one idempotent request after a 429, before the
// `rate_limited` error surfaces. Bounded so a sustained storm still reaches
// the "server busy" UI in at most a few `MAX_RETRY_AFTER_MS` windows.
export const MAX_RATE_LIMIT_RETRIES = 3;
/**
* How long retry number `attempt` (0-based) waits after a 429, or `null`
* for "do not retry".
*
* Uniform over a window starting at the FLOOR the server asked for (the
* UNCLAMPED `Retry-After`, or the doubling default) and reaching up to
* `min(2 × floor, MAX_RETRY_AFTER_MS)`. The randomness is the point: refused
* requests carry one `Retry-After`, and without it they return together.
*
* It never goes BELOW the floor, which would be a retry the server has
* already said it will refuse. So a floor ABOVE the cap is not clamped down
* to it: that would retry early into a bucket that said wait and burn an
* attempt doing it. The request surfaces `rate_limited` at once instead.
*/
export function rateLimitRetryDelayMs(
retryAfterMs: number | null,
attempt: number,
random: () => number = Math.random,
): number | null {
const floor = retryAfterMs ?? DEFAULT_RETRY_BACKOFF_MS * 2 ** attempt;
if (!Number.isFinite(floor) || floor > MAX_RETRY_AFTER_MS) return null;
const ceiling = Math.min(floor * 2, MAX_RETRY_AFTER_MS);
return floor + random() * (ceiling - floor);
}
// Global soft cooldown (epoch ms). Every observed 429 pushes this forward
// to `now + Retry-After` (never backward — see `armRateLimitCooldown`).
// Before firing an idempotent GET/HEAD we wait out the remaining window
// (bounded by MAX_RETRY_AFTER_MS) so INDEPENDENT call sites don't burst a
// busy server — e.g. localIndex.bootstrap's reconcile 429s, then the
// collection page immediately fires a follow-up deltaSync (Codex P1 on the
// first cut of TASK-2026). We deliberately do NOT clear this on a
// successful response: a success from an unrelated request that was already
// in flight when the 429 landed is not evidence the rate limit lifted
// (Codex round 2). The cooldown simply expires by wall-clock. Zero = no
// cooldown active.
let rateLimitedUntil = 0;
/**
* Push the global rate-limit cooldown out to `now + backoffMs`, never
* pulling it in. Called on EVERY 429 (including one that then triggers an
* automatic retry) so a concurrent request started during the
* retry's own backoff sleep still sees the cooldown. TASK-2026.
*/
function armRateLimitCooldown(backoffMs: number): void {
const until = Date.now() + backoffMs;
if (until > rateLimitedUntil) rateLimitedUntil = until;
}
function clampRetryMs(ms: number): number {
if (!Number.isFinite(ms) || ms < 0) return 0;
return Math.min(ms, MAX_RETRY_AFTER_MS);
}
/**
* Parse an HTTP `Retry-After` header into a clamped millisecond delay.
* Supports both wire forms: delta-seconds (`Retry-After: 3`) and an
* HTTP-date (`Retry-After: Wed, 21 Oct 2026 07:28:00 GMT`). Returns null
* when the header is absent or unparseable so the caller can fall back to
* `DEFAULT_RETRY_BACKOFF_MS`. All results are clamped to
* `MAX_RETRY_AFTER_MS`. TASK-2026.
*/
export function parseRetryAfterMs(header: string | null | undefined): number | null {
const raw = parseRetryAfterRawMs(header);
return raw === null ? null : clampRetryMs(raw);
}
/**
* `parseRetryAfterMs` WITHOUT the clamp: what the server actually asked for.
* The retry decision needs it (BUG-3192): a 12 s `Retry-After` clamped to 5 s
* reads as "retry at 5 s", which is a retry into a bucket that said wait.
*/
function parseRetryAfterRawMs(header: string | null | undefined): number | null {
if (!header) return null;
const trimmed = header.trim();
if (trimmed === '') return null;
// Delta-seconds form: a bare non-negative integer.
if (/^\d+$/.test(trimmed)) {
return Number(trimmed) * 1000;
}
// HTTP-date form. Date.parse returns NaN for garbage.
const when = Date.parse(trimmed);
if (Number.isNaN(when)) return null;
const delta = when - Date.now();
return delta <= 0 ? 0 : delta;
}
function sleep(ms: number): Promise<void> {
if (ms <= 0) return Promise.resolve();
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Returns true when `err` is the distinct 429 server-busy error. Mirrors
* the `isPlanLimitError` idiom so call sites can branch on rate limiting
* and show a "Server busy, try again shortly" message. TASK-2026.
*/
function isRateLimitError(err: unknown): err is PadApiError {
return err instanceof PadApiError && err.code === 'rate_limited';
}
/**
* 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 true when `err` is the optimistic-concurrency 409 (BUG-2265):
* the resource was modified by another writer since the caller last read it.
* `err.details` carries `{ ref, expected_updated_at, actual_updated_at }`.
* Callers can catch this to transparently refetch-and-retry (quick actions)
* or surface a "reload to see the latest" message (full-form edits). Mirrors
* isRateLimitError / isPlanLimitError; shared by the item and collection
* update paths, whose 409 wire shape is identical.
*/
function isUpdateConflictError(err: unknown): err is PadApiError {
return err instanceof PadApiError && err.code === 'update_conflict';
}
/**
* True when `err` is the server refusing a content write because THIS tab has
* already applied a newer one (BUG-3080, `client_write`). Nothing was written,
* and the newer write owns the outcome — callers treat it as success and never
* retry it.
*/
function isSupersededWriteError(err: unknown): err is PadApiError {
return err instanceof PadApiError && err.code === 'superseded_write';
}
/**
* True when the server refused a write because the item holds collaborative
* edits no editor has saved yet (BUG-3133 for token-guarded content writes,
* BUG-3031 for version restore). Nothing was written. `err.details` carries
* `{ ref, pending_rows }`. Retrying unchanged gets the same answer; the caller
* either waits for the edits to be saved or resends with the override.
*/
function isContentPendingFlushError(err: unknown): err is PadApiError {
return err instanceof PadApiError && err.code === 'content_pending_flush';
}
/**
* Returns true when `err` is a 404 not_found. BUG-2265 (Codex round 6): a
* competing RENAME can kill the slug a write targeted, so the request returns
* 404 instead of 409 — retry paths must treat BOTH as "resolve the fresh
* collection by stable id and retry", surfacing an error only if it's truly
* gone.
*/
function isNotFoundError(err: unknown): err is PadApiError {
return err instanceof PadApiError && err.code === 'not_found';
}
/**
* A move refused for a destination value only the user can supply: a required
* field with no value (`missing_required_fields`), or a change between open,
* done and abandoned the caller did not name (`state_change_requires_value`,
* BUG-2367). The copy dialog's needs_value picker collects exactly these, so a
* move that meets one is handed to it (BUG-3200).
*/
export function isMoveNeedsValueRefusal(err: unknown): boolean {
return (
err instanceof PadApiError &&
(err.code === 'missing_required_fields' || err.code === 'state_change_requires_value')
);
}
/**
* True when `err` is either an optimistic-concurrency 409 OR a rename-induced
* 404 — the two ways a concurrent collection change can defeat a slug-targeted
* write (BUG-2265). Retry paths branch on this to resolve-by-id and retry.
*/
function isConflictOrNotFound(err: unknown): err is PadApiError {
return isUpdateConflictError(err) || isNotFoundError(err);
}
/**
* Returns a human-readable statement of a plan limit error, with no upgrade
* copy (showPlanLimitToast adds that where commerce is allowed).
* 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."
*/
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.
// Neutral on purpose: this string reaches the mobile apps, which may carry
// no upgrade call to action (PLAN-3291 DR-3). showPlanLimitToast adds the
// upgrade copy where commerce is allowed.
return err.message || 'Plan limit reached.';
}
/**
* `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.
*
* That decision covers a 403 on a READ the user actually made. It never
* covered a revocation the user does not click into — one that writes no
* row and so reaches no endpoint — which left the cache listing titles
* from a collection the caller could no longer open. IDEA-2898 closes
* that half elsewhere (an access fingerprint on the item doors, compared
* in localIndex); this handler is unchanged and still owns the 403.
*
* 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.
*/
/**
* Why the workspace became unreachable (BUG-2983).
*
* `forbidden` is the original 403 case: membership exists in some form and
* something narrower denied the read.
*
* `gone` is a 404 on a workspace-scoped path that carries NO sub-resource
* identifier, which the server can only answer that way when the WORKSPACE
* itself did not resolve — `RequireWorkspaceAccess` resolves through
* `GetWorkspacesBySlugForUser`, so a non-member and a deleted workspace are
* the same 404 by design (the existence-oracle posture: a non-member must not
* be able to tell "exists but forbidden" from "does not exist").
*
* Both mean STOP ASKING AND PURGE, which is why they share one seam. They are
* still distinguished here because the two must not be WORDED alike: the
* server deliberately refuses to say which of the two happened, so the user is
* told the workspace is not available to them or no longer exists — never that
* access was "revoked", which asserts the half the server withheld.
*/
export type AccessRevokedReason = 'forbidden' | 'gone';
export type AccessRevokedScope = {
kind: 'workspace';
workspace: string;
/** Optional for compatibility with handlers written before BUG-2983. */
reason?: AccessRevokedReason;
/**
* WHO the request was issued as, captured before it left (codex round 1 P1).
*
* A refusal is a fact about a (user, workspace) pair, and a response can
* outlive the identity that asked: sign out, or switch users, while a
* request is in flight and the 404 lands when someone else is signed in.
* Recording it against whoever is signed in AT RESPONSE TIME would refuse a
* workspace to a user who may well be a member of it, permanently — the
* identity-change clear has already run by then.
*
* `undefined` when no provider is registered (tests, SSR).
*/
identity?: string | null;
};
type AccessRevokedHandler = (scope: AccessRevokedScope) => void;
let accessRevokedHandler: AccessRevokedHandler | null = null;
/**
* Supplies the signed-in user id, registered by the app at startup.
*
* A callback rather than a store import for the reason `setAccessRevokedHandler`
* is one: importing the auth store here would make a cycle.
*/
let identityProvider: (() => string | null) | null = null;
/** Register the identity source used to stamp requests (BUG-2983). */
export function setIdentityProvider(provider: (() => string | null) | null): void {
identityProvider = provider;
}
function currentIdentity(): string | null | undefined {
if (!identityProvider) return undefined;
try {
return identityProvider();
} catch {
return undefined;
}
}
/**
* 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;
}
/**
* Fired once per surfaced 429 so the UI can show a distinct "server busy"
* notification instead of a generic failure (TASK-2080). The optional
* `retryAfterMs` is the parsed `Retry-After` delay (present only when the
* server sent a usable header) so the handler can say "try again in ~Ns".
*
* This is the client → UI seam that keeps client.ts free of a toast-store
* (Svelte) import, mirroring `setAccessRevokedHandler`. The chokepoint is
* the single `throw err` in the 429 branch of `request()`, so EVERY 429 that
* actually surfaces (auto-retry exhausted, or a non-idempotent write) calls
* this exactly once. Burst DEDUPE is the handler's job, not the client's —
* the client stays a dumb signal source.
*/
type RateLimitHandler = (retryAfterMs?: number) => void;
let rateLimitHandler: RateLimitHandler | null = null;
/**
* Register a single callback fired when the API client surfaces a 429
* `rate_limited` error. The app wires this once at startup (from
* +layout.svelte) to a deduped "server busy" toast. Calling this again
* replaces the previous handler; pass null to clear. Handler failures are
* swallowed so the 429 still propagates cleanly. TASK-2080.
*/
export function setRateLimitHandler(handler: RateLimitHandler | null): void {
rateLimitHandler = handler;
}
/**
* Best-effort fire of the registered rate-limit handler. Swallows handler
* failures so a broken UI hook can't corrupt the API error path. TASK-2080.
*/
function notifyRateLimited(retryAfterMs?: number): void {
if (!rateLimitHandler) return;
try {
rateLimitHandler(retryAfterMs);
} catch (err) {
// eslint-disable-next-line no-console
console.warn('rate-limit handler threw', err);
}
}
type NetworkUnreachableHandler = () => void;
let networkUnreachableHandler: NetworkUnreachableHandler | null = null;
/**
* Register a single callback fired when a request cannot reach the server at
* all (TASK-2202): the browser's fetch rejected before any response, which is
* what an offline laptop or a dead route looks like. +layout.svelte wires it to
* a deduped "can't reach the server" toast, the same split as
* setRateLimitHandler, so client.ts stays free of the toast store.
*/
export function setNetworkUnreachableHandler(handler: NetworkUnreachableHandler | null): void {
networkUnreachableHandler = handler;
}
function notifyNetworkUnreachable(): void {
if (!networkUnreachableHandler) return;
try {
networkUnreachableHandler();
} catch (err) {
// eslint-disable-next-line no-console
console.warn('network-unreachable handler threw', err);
}
}
/**
* The error a request throws when the server could not be reached (TASK-2202).
* Its own code, so call sites and the toast can tell it from a refusal the
* server made. A WRITE's message does not claim the change was lost: a fetch
* can fail after the request left (a connection reset mid-response), so, as
* with request_timeout, "not saved" is a claim nobody can make.
*/
export function networkUnreachableError(isIdempotent: boolean): PadApiError {
return new PadApiError({
code: 'network_unreachable',
message: isIdempotent
? "Can't reach the server. Check your connection and try again."
: "Can't reach the server, so your change may not have been saved. Check your connection and try again.",
});
}
/**
* `fetch` for a direct WRITE that bypasses request() (TASK-2202, codex r1):
* attachment upload and transform, artifact import, checkout. Same rule as
* requestAttempt: an abort keeps its own path (each site's deadline turns its
* timeout into request_timeout), and any other rejection, which happened
* before a response existed, reports the outage and becomes
* network_unreachable instead of a raw TypeError.
*/
async function fetchWrite(input: string, init: RequestInit): Promise<Response> {
try {
return await fetch(input, init);
} catch (err) {
if (init.signal?.aborted) throw err;
notifyNetworkUnreachable();
throw networkUnreachableError(false);
}
}
/** True for the error a request throws when the server could not be reached. */
export function isNetworkUnreachable(err: unknown): boolean {
return err instanceof PadApiError && err.code === 'network_unreachable';
}
/**
* 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;
}
/**
* Workspace-level reads whose 404 can only be the workspace itself.
*
* `items-index` / `items-changes` are the local index's own loads; `items` and
* `collections` are the list endpoints. None of them names a second thing that
* could be missing.
*/
const WORKSPACE_LEVEL_READS = new Set(['items-index', 'items-changes', 'items', 'collections']);
/**
* True when a 404 body is the server's workspace-resolution refusal: code
* `not_found` carrying `details.scope: "workspace"` (BUG-3069). Anything else,
* including an unparseable body, is false, and the caller falls back to the
* request-shape rule.
*/
export function isWorkspaceNotFoundBody(body: unknown): boolean {
const error = (body as { error?: { code?: unknown; details?: unknown } } | null)?.error;
if (!error || error.code !== 'not_found') return false;
const details = error.details as { scope?: unknown } | null | undefined;
return details?.scope === 'workspace';
}
/**
* The workspace a 404 proves unreachable, or null (BUG-2983).
*
* DELIBERATELY NARROWER than `parseAccessRevokedScope`, and the difference is
* the whole correctness of folding 404 into that seam. That parser accepts
* `/workspaces/{ws}/items/{ref}`, which is right for a 403 — a read the caller
* was refused — and WRONG for a 404, where the missing thing is far more often
* the ITEM: a deleted ref, a stale link, a typo. Purging a workspace the caller
* can read perfectly well, because one item is gone, would be a much worse bug
* than the storm this fixes.
*
* So: only paths with NO sub-resource identifier qualify —
* `/workspaces/{ws}` and `/workspaces/{ws}/{endpoint}`. On those the only
* `not_found` the server can produce is `RequireWorkspaceAccess`'s, because
* there is no second thing to be missing.
*
* THE BODY DECIDES FIRST (BUG-3069). A server that marks its workspace refusal
* with `details.scope: "workspace"` has said which thing it refused, so any
* path under `/workspaces/{ws}` qualifies, item reads included: the marker is
* only ever written when the workspace itself did not resolve. The code stays
* `not_found` (a new code would have broken every consumer keyed on it), so
* the marker, not the code, is the contract.
*
* The request-shape rule below is the FALLBACK for a server that predates the
* marker, where the workspace 404 and an item 404 are byte-identical and the
* shape is the only evidence. It is not consulted when the marker is present.
*/
function parseWorkspaceGoneScope(
path: string,
markedWorkspace = false
): AccessRevokedScope | null {
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[0] !== 'workspaces' || !parts[1]) return null;
if (markedWorkspace) return { kind: 'workspace', workspace: parts[1], reason: 'gone' };
// `/workspaces/{ws}` (2) or `/workspaces/{ws}/{endpoint}` (3). Anything
// longer names a sub-resource that can itself be missing.
if (parts.length > 3) return null;
// AN ALLOW-LIST, not "depth 3 is safe" (codex round 1 P2). Depth alone was
// too broad: a workspace-level endpoint may have its OWN not_found — a
// singleton that has never been created, a feature-gated route — and one
// such endpoint answering 404 would purge a workspace the caller reads
// fine. These four are the local-first read model's own loads, and the only
// `not_found` any of them can produce is the workspace's. Same posture as
// `parseAccessRevokedScope`'s whitelist, and the same reason: the seam is
// destructive, so it opts endpoints IN.
if (parts.length === 3 && !WORKSPACE_LEVEL_READS.has(parts[2])) return null;
return { kind: 'workspace', workspace: parts[1], reason: 'gone' };
}
/**
* 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, issuedAs: string | null | undefined): void {
if (!accessRevokedHandler) return;
const scope = parseAccessRevokedScope(path);
if (!scope) return;
scope.reason = 'forbidden';
scope.identity = issuedAs;
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;
}
/**
* The session-expiry redirect every 401 below takes. It runs the open items'
* keepers first (BUG-3571 part 2): the redirect does not change the identity
* epoch, so nothing else keeps the signed-out user's unsaved text, and the
* unload flush that follows goes out on a dead session.
*/
function redirectToLogin(href: string): void {
runSessionExpiryKeepers();
window.location.href = href;
}
/**
* 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']);
/**
* AUTHENTICATED credential-reconfirmation endpoints (TASK-1962). Unlike the
* auth-form paths above, these sit BEHIND RequireAuth, so a 401 is
* ambiguous: it can mean either a wrong second factor (e.g.
* `totp_invalid` — must surface verbatim so the user can retry inline) OR
* a genuinely expired session (`unauthorized` — must redirect to /login
* like every other endpoint). We disambiguate on the error CODE below: a
* non-`unauthorized` 401 body is a credential failure and is thrown
* verbatim; an `unauthorized` (or bodyless) 401 falls through to the
* standard redirect. `/auth/delete-account`'s wrong-password case is a 403
* and its billing/partial-delete failures are 500s, so `totp_invalid` is
* the only 401 that needs this treatment.
*/
const CREDENTIAL_RECONFIRM_401_PATHS = new Set(['/auth/delete-account']);
/**
* The longest one `request()` attempt may take, headers and body, before it is
* abandoned with `request_timeout` (BUG-3211). Without it a request the server
* accepted but never answered (a stalled proxy, a half-open connection after a
* laptop sleep, a wedged handler) was awaited for as long as the browser cared
* to, and every gate held across the await held with it: the sync lock, the
* local index's dedup promises, the pane's save indicator.
*
* RECEIPT: the full e2e suite at 8 workers against a local server (SQLite,
* rate limits off; BUG-3211 checkpoint 2): 50,936 requests over 89 routes, and
* not one ordinary request/response route above 2.5s. The slowest were login
* (52 of 263 over 1s, password hashing under load) and item GET / create. The
* collab WebSocket and the SSE stream are long-lived by design and do not go
* through here.
*
* NOT MEASURED, and the reason the value sits 12x above that tail rather than
* near it: Postgres, large workspaces (a bootstrap or items-changes after a
* long absence), and slow networks. A false timeout on a WRITE is the costly
* direction — the server may finish it anyway — so this errs long.
*/
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
/**
* `attachments.transform`'s bound: server-side image decode and re-encode, not
* covered by the receipt above, so given 4x the default rather than a guess
* fitted to nothing.
*/
export const TRANSFORM_TIMEOUT_MS = 120_000;
let requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS;
/** Test seam: shorten the request timeout; call with no argument to restore. */
export function setRequestTimeoutForTests(ms?: number): void {
requestTimeoutMs = ms ?? DEFAULT_REQUEST_TIMEOUT_MS;
}
/**
* An abort signal that fires after `ms`, and also when the caller's own signal
* does. Linked by hand rather than with `AbortSignal.any`, which needs Safari
* 17.4 / Chrome 116. `timedOut()` tells the two apart: a caller's abort stays
* the caller's (an AbortError it asked for), a timeout becomes `request_timeout`.
*/
function requestDeadline(ms: number, callerSignal?: AbortSignal | null) {
const controller = new AbortController();
let timedOut = false;
const onCallerAbort = () => controller.abort(callerSignal?.reason);
if (callerSignal?.aborted) controller.abort(callerSignal.reason);
else callerSignal?.addEventListener('abort', onCallerAbort, { once: true });
const timer = setTimeout(() => {
timedOut = true;
controller.abort(new DOMException('request timed out', 'TimeoutError'));
}, ms);
return {
signal: controller.signal,
timedOut: () => timedOut,
clear: () => {
clearTimeout(timer);
callerSignal?.removeEventListener('abort', onCallerAbort);
},
};
}
/**
* The error a timed-out request throws. A WRITE's message says it may have
* landed: aborting the request does not stop the server, so "failed" would be
* a claim nobody can make, and a user who re-sends on the strength of it can
* duplicate a create. Never retried automatically, for the same reason.
*/
export function requestTimeoutError(isIdempotent: boolean): PadApiError {
return new PadApiError({
code: 'request_timeout',
message: isIdempotent
? 'The server did not respond in time. Please try again.'
: 'The server did not respond in time. Your change may still have been saved — check before trying again.',
});
}
/**
* Run `fn` — a fetch AND every read of its body — under `request()`'s deadline
* (BUG-3211, BUG-3216), for the direct fetches that bypass `request()` because
* they read their responses their own way. `fn` must pass `signal` to its fetch.
*
* `idempotent` picks the timeout's message: a write's says the change may still
* have been saved. `ms` is the site's budget; the default is the API's.
*/
export async function withRequestDeadline<T>(
fn: (signal: AbortSignal) => Promise<T>,
opts: { idempotent: boolean; ms?: number; signal?: AbortSignal | null },
): Promise<T> {
const deadline = requestDeadline(opts.ms ?? requestTimeoutMs, opts.signal);
try {
return await fn(deadline.signal);
} catch (err) {
if (deadline.timedOut()) throw requestTimeoutError(opts.idempotent);
throw err;
} finally {
deadline.clear();
}
}
/**
* A plain fetch-and-parse under `request()`'s deadline, for the few direct
* fetches that sit under a gate or a dedup promise (BUG-3211). GET-shaped:
* a timeout reads as the idempotent `request_timeout`.
*/
function fetchJsonWithDeadline<T>(url: string, init?: RequestInit): Promise<T> {
return withRequestDeadline(
async (signal) => (await (await fetch(url, { ...init, signal })).json()) as T,
{ idempotent: true, signal: init?.signal },
);
}
async function request<T>(
path: string,
options?: RequestInit,
// Internal: how many automatic re-invocations after a 429 this call is
// (0 for a caller's own request). Bounded by MAX_RATE_LIMIT_RETRIES so a
// retried request that also 429s cannot loop. Never passed by external
// callers. TASK-2026, BUG-3192.
rateLimitAttempt = 0,
): Promise<T> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
// WHO is asking, captured BEFORE the request leaves (BUG-2983, codex round 1
// P1). A response can outlive the identity that asked — a sign-out or user
// switch mid-flight — and a refusal recorded against whoever is signed in at
// RESPONSE time would lock a workspace away from a user who may be a member
// of it, permanently, since the identity-change clear has already run.
const issuedAs = currentIdentity();
// Attach CSRF token for state-changing requests
const method = options?.method?.toUpperCase();
const isIdempotent = method === undefined || method === 'GET' || method === 'HEAD';
if (method && method !== 'GET' && method !== 'HEAD') {
const csrf = getCSRFToken();
if (csrf) headers['X-CSRF-Token'] = csrf;
}
// Rate-limit cooldown (TASK-2026). If a recent 429 asked us to back
// off, wait out the remaining window before firing another idempotent
// request so independent call sites don't hammer a busy server. An
// internal retry (`rateLimitAttempt > 0`) already slept, so it skips
// this. Non-idempotent writes are never delayed here — they
// don't auto-retry and shouldn't be silently stalled by an unrelated
// GET's cooldown. The loop re-reads the deadline after each nap so a
// cooldown EXTENDED by another 429 mid-sleep is honored (Codex round
// 2 P2), but the total wait is capped at MAX_RETRY_AFTER_MS so a
// sustained 429 storm can't stall a single request indefinitely.
if (isIdempotent && rateLimitAttempt === 0) {
const waitStart = Date.now();
let remaining = rateLimitedUntil - Date.now();
while (remaining > 0 && Date.now() - waitStart < MAX_RETRY_AFTER_MS) {
await sleep(Math.min(remaining, MAX_RETRY_AFTER_MS - (Date.now() - waitStart)));
remaining = rateLimitedUntil - Date.now();
}
}
// Armed per ATTEMPT and only now, so the client's own deliberate waits —
// the cooldown above, a 429 retry's sleep below — never count against it.
const deadline = requestDeadline(requestTimeoutMs, options?.signal);
try {
const result = await requestAttempt<T>(path, options, rateLimitAttempt, headers, method, isIdempotent, issuedAs, deadline);
// Only GET/HEAD are retried after a 429, and neither is a write, so a
// write reaches here once. Reported only to the identity that issued
// it: a write that answers after a sign-out or account switch must not
// keep a tab in the NEXT account's bar (codex round 1). Same user id
// is enough, not an epoch: A's write keeping A's own tab is correct.
if (currentIdentity() === issuedAs) reportWorkspaceWrite(path, method);
return result;
} catch (err) {
if (deadline.timedOut()) throw requestTimeoutError(isIdempotent);
throw err;
} finally {
deadline.clear();
}
}
/**
* One attempt of `request()`, from the fetch to the parsed body, under the
* attempt's deadline. Every body read is awaited HERE, so a body that stalls
* after its headers is covered too.
*/
async function requestAttempt<T>(
path: string,
options: RequestInit | undefined,
rateLimitAttempt: number,
headers: Record<string, string>,
method: string | undefined,
isIdempotent: boolean,
issuedAs: ReturnType<typeof currentIdentity>,
deadline: ReturnType<typeof requestDeadline>,
): Promise<T> {
let resp: Response;
try {
resp = await fetch(BASE + path, {
headers,
credentials: 'same-origin',
...options,
signal: deadline.signal,
});
} catch (err) {
// An abort, the caller's or our deadline's, keeps its own path:
// request() turns a timeout into request_timeout, and a caller's abort
// stays the AbortError it asked for. Anything else rejected before a
// response existed, so the server was not reached (TASK-2202). Only
// the fetch is wrapped: a TypeError from code below is a bug, not an
// outage, and must not be reported as one.
if (deadline.signal.aborted) throw err;
notifyNetworkUnreachable();
throw networkUnreachableError(isIdempotent);
}
// Every response, error statuses included, is a reading of the server's
// clock; the sync cursor is stamped from these, never from Date.now()
// (BUG-3207).
noteServerDate(resp.headers?.get?.('Date'));
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' });
}
// Credential-reconfirmation endpoints: surface a wrong-second-factor
// 401 verbatim, but let a genuine session-expiry `unauthorized` 401
// fall through to the redirect below (see CREDENTIAL_RECONFIRM_401_PATHS).
if (CREDENTIAL_RECONFIRM_401_PATHS.has(barePath)) {
const body = await resp.json().catch(() => null);
if (body?.error && body.error.code !== 'unauthorized') {
throw new PadApiError(body.error);
}
// else: expired session — continue to the standard redirect.
}
// 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;
redirectToLogin(`/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, issuedAs);
}
}
if (resp.status === 404) {
// A workspace that does not resolve is answered 404, not 403 (BUG-2983).
// `RequireWorkspaceAccess` resolves through `GetWorkspacesBySlugForUser`,
// so a non-member gets the same not_found a deleted workspace does — the
// existence-oracle posture. That means the 403 seam above, and every
// 401/403 branch in `localIndex`, is UNREACHABLE for the commonest case
// there is: following a link into someone else's workspace.
//
// Left unhandled, the caller asked again. And again: `bootstrap` records
// the failure as `bootstrapState = 'error'` but only early-returns on
// `'ready'`, and its synchronous prefix both READS and WRITES that
// `$state` inside the calling `$effect`'s tracking scope — so each write
// re-fired the effect, which refetched, which wrote again. Measured at
// one request per tick, unbounded, until the rate limiter answered 429
// (which the client then politely retried once, adding to the pile).
//
// Same seam as the 403 for the same reason: both mean STOP ASKING AND
// PURGE. The server marks its workspace refusal with
// `details.scope: "workspace"` (BUG-3069); without the marker (an older
// server) the narrower path rule decides, because a 404 is usually about
// the ITEM — see `parseWorkspaceGoneScope`.
//
// The body is read HERE, once, and the error thrown from here, so the
// generic `!resp.ok` branch below never tries to read it a second time.
const body = await resp.json().catch(() => null);
if (method === undefined || method === 'GET' || method === 'HEAD') {
const scope = parseWorkspaceGoneScope(path, isWorkspaceNotFoundBody(body));
if (scope) scope.identity = issuedAs;
if (scope && accessRevokedHandler) {
try {
accessRevokedHandler(scope);
} catch (err) {
// eslint-disable-next-line no-console
console.warn('access-revoked handler threw', err);
}
}
}
if (body?.error) throw new PadApiError(body.error);
throw new Error(`API error: ${resp.status}`);
}
if (resp.status === 429) {
// Rate limited (TASK-2026, BUG-3192). Retry idempotent requests up
// to MAX_RATE_LIMIT_RETRIES times, each no sooner than Retry-After
// and jittered (rateLimitRetryDelayMs); then surface a distinct
// `rate_limited` error so callers can show "server busy" rather
// than a generic failure.
const retryAfterHeader = resp.headers?.get('Retry-After');
const retryAfterMs = parseRetryAfterMs(retryAfterHeader);
const backoffMs = retryAfterMs ?? DEFAULT_RETRY_BACKOFF_MS;
// Arm the global cooldown on EVERY 429 — including this one that we
// may be about to retry — so a concurrent idempotent request that
// started during the retry's own backoff sleep also backs off
// instead of immediately re-hitting the busy server (Codex round 2 P1).
armRateLimitCooldown(backoffMs);
// Only GET/HEAD auto-retry — retrying a POST/PATCH/DELETE could
// duplicate a write the server may have already partially applied.
// The delay is computed from the UNCLAMPED Retry-After: a server asking
// for longer than the cap gets no retry at all (null), rather than an
// early one at the cap.
const retryDelayMs =
isIdempotent && rateLimitAttempt < MAX_RATE_LIMIT_RETRIES
? rateLimitRetryDelayMs(parseRetryAfterRawMs(retryAfterHeader), rateLimitAttempt)
: null;
if (retryDelayMs !== null) {
// This attempt is over; the retry arms its own deadline.
deadline.clear();
await sleep(retryDelayMs);
return request<T>(path, options, rateLimitAttempt + 1);
}
// Prefer the server's structured error envelope if present, but
// force the distinct `rate_limited` code + a clear message so UI
// can branch on it regardless of what the proxy/server sent.
const body = await resp.json().catch(() => null);
const err = new PadApiError({
code: 'rate_limited',
message: body?.error?.message || 'Server busy — please try again in a moment.',
details: body?.error?.details,
});
if (retryAfterMs != null) err.retryAfterMs = retryAfterMs;
// Single UI seam for the "server busy" notification (TASK-2080).
// Every 429 that actually surfaces funnels through this throw, so
// the registered handler fires exactly once per surfaced 429; the
// handler owns burst dedupe so a storm doesn't stack toasts.
notifyRateLimited(err.retryAfterMs);
throw err;
}
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 (await resp.json()) as T;
}
/**
* 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 URL clients paste into their MCP-capable agent
// (e.g. "https://mcp.getpad.dev"). Empty string while MCP is not
// available on this instance — UI code should use the empty string as
// the gate for "Remote MCP not exposed, fall back to CLI flow."
mcp_public_url: string;
// mcp_auth is how agents authenticate to that URL: ['oauth', 'pat'], or
// ['pat'] where the instance serves no OAuth (an http self-host), and
// [] while MCP is not available (PLAN-2310 DR-8). Absent on servers that
// predate it, which set mcp_public_url only where OAuth was served.
mcp_auth?: string[];
// billing_available is true when Pad Pro can be bought now: cloud mode,
// PAD_BILLING=auto, and the pad-cloud sidecar serving an offer (TASK-3577;
// TASK-800 before it). billing_offer is that offer, null otherwise. Use
// authStore.billingAvailable / billingOffer rather than these fields.
billing_available?: boolean;
billing_offer?: BillingOffer | null;
// mcp_available / oauth_available (PLAN-2310 DR-7): whether MCP, and its
// OAuth path, are available right now. Always true on Pad Cloud; on a
// self-hosted install they follow the admin toggle, the configured origin
// and its scheme. Absent on older servers: treat as cloud_mode.
mcp_available?: boolean;
oauth_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;
// account_disabled is true when the request's credential belongs to a
// disabled account (BUG-3349): authenticated is false, and the sign-in
// page explains why instead of offering a sign-in that cannot work.
// Absent on older servers and for every other caller.
account_disabled?: 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;
}
// Module-scope cache for GET /server/capabilities. The response is static
// for the lifetime of the binary (image-processor formats / limits), so every
// caller can share one fetch. The Editor remounts on each item switch (its
// `{#key}` rebuilds ~20 Tiptap extensions for Y.Doc safety) and each remount
// fired this request; caching makes every remount past the first a warm no-op
// (TASK-2120). We memoize the PROMISE (not just the value) so concurrent
// callers dedupe onto one in-flight request, and we clear the slot on failure
// so a transient network blip / pre-login rejection can't poison capabilities
// for the rest of the session — a later call retries.
let serverCapabilitiesCache: Promise<ServerCapabilities> | null = null;
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. The server
* hoists the playbook's `status` (from the fields JSON) to a
* top-level key so callers can read the draft/active gate without
* parsing fields (BUG-2020).
*/
get: (ws: string, ref: string) =>
request<Item & { status: string }>(`/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[]; allow_draft?: boolean }
) =>
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' }),
// restore un-deletes a soft-deleted workspace that's still inside the
// purge window, returning the restored Workspace. Mirrors
// POST /workspaces/{slug}/restore (TASK-1970).
restore: (slug: string) =>
request<Workspace>(`/workspaces/${slug}/restore`, { method: 'POST' }),
// listDeleted returns the caller's soft-deleted workspaces still inside
// the restore window, each with purge_at + days_left. Backs the
// restore UI (GET /workspaces/deleted, TASK-1970).
listDeleted: () => request<DeletedWorkspace[]>('/workspaces/deleted'),
reorder: (updates: { slug: string; sort_order: number }[]) =>
request<void>('/workspaces/reorder', {
method: 'PUT',
body: JSON.stringify(updates)
}),
// The caller's open set of workspace tabs (PLAN-3002 U1). Every write
// answers with the whole visibility-filtered set, so the tabs store
// commits the answer instead of re-reading.
tabs: {
list: () => request<WorkspaceTabsResponse>('/me/workspace-tabs'),
// A second ephemeral open replaces the first, at its position.
open: (slug: string, ephemeral: boolean) =>
request<WorkspaceTabsResponse>('/me/workspace-tabs', {
method: 'POST',
body: JSON.stringify({ slug, ephemeral })
}),
// The full order. Slugs that are not open are ignored.
reorder: (slugs: string[]) =>
request<WorkspaceTabsResponse>('/me/workspace-tabs', {
method: 'PUT',
body: JSON.stringify(slugs)
}),
close: (slug: string) =>
request<WorkspaceTabsResponse>(`/me/workspace-tabs/${encodeURIComponent(slug)}`, {
method: 'DELETE'
}),
// `pin` can only be true. `last_route: ''` clears; the server refuses
// a route outside the workspace, and 404s a workspace with no tab.
update: (slug: string, data: { pin?: true; last_route?: string }) =>
request<WorkspaceTabsResponse>(`/me/workspace-tabs/${encodeURIComponent(slug)}`, {
method: 'PATCH',
body: JSON.stringify(data)
})
},
// 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.
// BUG-3032: the stale-body count comes back as a RESPONSE HEADER, so it
// is returned alongside the workspace rather than inside it. An
// intersection type rather than a new shape: every existing caller reads
// Workspace fields and is unaffected, and a server that does not set the
// header simply leaves the field undefined.
//
// BUG-3475: over XMLHttpRequest (./importUpload), for upload progress
// and the stall / finishing bounds. It rejects with an
// ImportTransportError when NO response arrived; the server may then
// have finished, kept or removed the import, which `importStatus`
// answers for the same `importKey`.
importBundle: async (
file: File,
name?: string,
opts: {
importKey?: string;
onProgress?: (sent: number, total: number) => void;
onUploaded?: () => void;
signal?: AbortSignal;
} = {}
): Promise<Workspace & { stale_bodies?: number }> => {
const headers: Record<string, string> = { 'Content-Type': 'application/gzip' };
const csrf = getCSRFToken();
if (csrf) headers['X-CSRF-Token'] = csrf;
const q = new URLSearchParams();
if (name) q.set('name', name);
if (opts.importKey) q.set('import_key', opts.importKey);
const qs = q.toString();
const url = `${BASE}/workspaces/import${qs ? `?${qs}` : ''}`;
const resp = await uploadImportBundle({
url,
headers,
body: file,
onProgress: opts.onProgress,
onUploaded: opts.onUploaded,
signal: opts.signal
});
if (resp.status === 401) {
if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
redirectToLogin('/login');
}
throw new PadApiError({ code: 'unauthorized', message: 'Authentication required' });
}
let parsed: unknown = null;
try {
parsed = JSON.parse(resp.body);
} catch {
parsed = null;
}
if (resp.status < 200 || resp.status >= 300) {
const body = parsed as { error?: { code: string; message: string } } | null;
if (body?.error) throw new PadApiError(body.error);
throw new Error(`API error: ${resp.status}`);
}
const ws = parsed as Workspace;
// Absent, unparseable or zero all mean "nothing to report": the
// field stays undefined rather than 0, so a caller can render on
// presence without treating a clean import as a fact worth showing.
//
// A POSITIVE DECIMAL INTEGER is required, spelled the one way the CLI's
// staleBodyImportCount accepts it (codex rounds 2 P2, 3 P2, 4 P2 and
// the round-5 nit). NOT "the one way strconv.Atoi accepts it": Atoi is
// LOOSER than both readers, and the Go side deliberately rejects three
// forms it would take — a leading `+`, values above 2^53-1, and a
// repeated header — so that the two agree exactly. A response
// header is middlebox- and attacker-influenced input, and `Number()`
// is lenient in ways that reach the toast and that the CLI rejects:
// it trims (`" 7 "`), takes decimals (`"2.5"`), exponent notation
// (`"7e0"` → 7), hex (`"0x7"` → 7), and silently rounds integers past
// 2^53. The regex is therefore the gate and Number() only converts
// what it already admitted — so the two transports suppress and
// display exactly the same set of values.
//
// MAX_SAFE_INTEGER bounds it because Atoi refuses an oversized int
// and JS would quietly round one; the count is of items in one
// workspace, so any value near that bound is a malformed header
// rather than a fact.
const raw = resp.header('X-Pad-Import-Stale-Bodies');
if (raw !== null && /^[0-9]+$/.test(raw)) {
const n = Number(raw);
if (n > 0 && n <= Number.MAX_SAFE_INTEGER) {
return { ...ws, stale_bodies: n };
}
}
return ws;
},
// BUG-3475: what became of the caller's own keyed import. null when
// this server holds no such key (never started here, expired, or lost
// to a restart): the outcome is UNKNOWN, never "nothing was created".
importStatus: async (key: string): Promise<ImportOutcome | null> => {
try {
return await request<ImportOutcome>(`/workspaces/import-status?key=${encodeURIComponent(key)}`);
} catch (err) {
if (err instanceof PadApiError && err.code === 'not_found') return null;
throw err;
}
}
},
// ── Collections ───────────────────────────────────────────────────────────
collections: {
list: (ws: string) =>
request<Collection[]>(`/workspaces/${ws}/collections`),
// TASK-2189: archive is reversible. Owner-only, like the archive.
archived: (ws: string) =>
request<ArchivedCollection[]>(`/workspaces/${ws}/archived-collections`),
// By id or slug; its items come back with it.
restore: (ws: string, ref: string) =>
request<Collection>(`/workspaces/${ws}/archived-collections/${encodeURIComponent(ref)}/restore`, {
method: 'POST'
}),
// TASK-2188: per field key, how many live items hold a value, and per
// string value how many hold it. Owner-only, like the schema edit it
// informs.
fieldUsage: (ws: string, slug: string) =>
request<CollectionFieldUsage>(`/workspaces/${ws}/collections/${encodeURIComponent(slug)}/field-usage`),
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)
}),
// expectedUpdatedAt (BUG-2265): the updated_at of the collection the
// caller resolved. Sent as a query param so the server 409s if a
// concurrent rename re-owned this slug with a DIFFERENT collection —
// never archiving the wrong one. Omit for the legacy unconditional path.
delete: (ws: string, slug: string, expectedUpdatedAt?: string) => {
const q = expectedUpdatedAt
? `?expected_updated_at=${encodeURIComponent(expectedUpdatedAt)}`
: '';
return request<void>(`/workspaces/${ws}/collections/${slug}${q}`, {
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;
includes_unparented_metadata: boolean;
access_epoch?: 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,
includes_unparented_metadata: raw.includes_unparented_metadata === true,
// Passed through UNNORMALIZED, deliberately: the value is
// opaque and only ever compared for equality, and coercing an
// absent field to a string would turn "this server does not
// send it" into a value that can differ from a real one
// (IDEA-2898).
access_epoch: raw.access_epoch,
};
},
/**
* 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;
includes_unparented_metadata: boolean;
access_epoch?: 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,
includes_unparented_metadata: raw.includes_unparented_metadata === true,
// Passed through UNNORMALIZED, deliberately: the value is
// opaque and only ever compared for equality, and coercing an
// absent field to a string would turn "this server does not
// send it" into a value that can differ from a real one
// (IDEA-2898).
access_epoch: raw.access_epoch,
};
},
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}`),
/**
* `opts.keepalive` is passed straight to fetch so the
* beforeunload / unmount flush path can outlive the page
* lifecycle. Used to land pending raw-markdown edits on tab
* close/reload (BUG-2024). The browser refuses a keepalive body
* over 64 KiB, so a larger one goes as an ordinary fetch
* (keepaliveBudget.ts, BUG-3522).
*/
update: (ws: string, slug: string, data: ItemUpdate, opts?: { keepalive?: boolean }) => {
const body = JSON.stringify(data);
// Over the keepalive budget (counting every keepalive body still in
// flight) the request goes as an ordinary fetch rather than be refused
// by the browser (BUG-3522).
const ka = reserveKeepalive(opts?.keepalive, body, `PATCH item ${slug}`);
return request<Item>(`/workspaces/${ws}/items/${slug}`, {
method: 'PATCH',
body,
keepalive: ka.keepalive
}).finally(ka.release);
},
/**
* 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. The browser refuses a keepalive body over
* 64 KiB, so a larger one goes as an ordinary fetch
* (keepaliveBudget.ts, BUG-3522).
*/
flushCollabContent: (
ws: string,
slug: string,
content: string,
opts?: { keepalive?: boolean; opLogCursor?: number; clientWrite?: ClientWriteStamp },
) => {
const body: { content: string; op_log_cursor?: number; client_write?: ClientWriteStamp } = { content };
if (opts?.clientWrite) body.client_write = opts.clientWrite;
// `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;
}
const encoded = JSON.stringify(body);
// Over the keepalive budget (counting every keepalive body still in
// flight) the request goes as an ordinary fetch rather than be refused
// by the browser (BUG-3522).
const ka = reserveKeepalive(opts?.keepalive, encoded, `collab flush of ${slug}`);
return request<Item>(`/workspaces/${ws}/items/${slug}?source=collab-snapshot`, {
method: 'PATCH',
body: encoded,
keepalive: ka.keepalive,
}).finally(ka.release);
},
/**
* Content-free flush-watermark stamp (BUG-3124 unit B). Sent when this
* tab's Y.Doc is caught up (`opLogCursor` is the highest op-log id it
* has applied) AND still renders to the body the server holds, whose
* sha256 is `contentSHA256` (byte for byte, or as the editor's own
* serialization of that body, BUG-3197). The server advances the watermark only if
* both still hold at commit time and writes nothing else — no content,
* no version, no seq bump — so an idle view no longer leaves the item
* reading "pending" forever. `{advanced: false}` is a normal answer.
*/
stampCollabWatermark: (
ws: string,
slug: string,
opLogCursor: number,
contentSHA256: string,
opts?: { keepalive?: boolean },
) =>
request<{ advanced: boolean }>(`/workspaces/${ws}/items/${slug}/collab-watermark`, {
method: 'POST',
body: JSON.stringify({ op_log_cursor: opLogCursor, content_sha256: contentSHA256 }),
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)
}),
/**
* Set sort_order for many items in ONE all-or-nothing request
* (PUT /items/sort-order, TASK-3517): every entry lands or none does.
* Resolves to each item's seq afterwards. A refusal (403 naming an item
* the caller can't edit, 404, 409 when an item changed meanwhile) means
* nothing was written.
*/
reorder: (ws: string, updates: { id: string; sort_order: number }[]) =>
request<{ items: { id: string; seq: number }[] }>(`/workspaces/${ws}/items/sort-order`, {
method: 'PUT',
body: JSON.stringify({ updates })
}).then((r) => r.items),
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'
})
}
),
/**
* Cross-workspace copy DRY RUN (PLAN-2357 / TASK-2365).
*
* Read-only and safe to call repeatedly: it writes nothing, emits no
* activity/SSE/webhook, and advances neither workspace's seq. Call it
* again whenever the destination or an override changes. It is still an
* ordinary authenticated request, so it spends rate-limit budget —
* callers debounce/coalesce (see CopyItemDialog's single-flight runner).
*
* `signal` lets a superseded preview abort in flight; an aborted request
* rejects with a DOMException, which callers discard behind their own
* generation fence rather than surfacing.
*
* See `ItemCopyPreflight` in $lib/types for the response contract and
* the error codes worth branching on.
*/
copyPreflight: (
ws: string,
slug: string,
req: ItemCopyPreflightRequest,
opts?: { signal?: AbortSignal }
) =>
request<ItemCopyPreflight>(`/workspaces/${ws}/items/${slug}/copy/preflight`, {
method: 'POST',
body: JSON.stringify(req),
signal: opts?.signal
}),
/**
* Cross-workspace copy — the MUTATION (PLAN-2357 / TASK-2365). With
* `archive_source: true` it is the MOVE.
*
* `opKey` is sent as `op_key`, the server's idempotency key (TASK-3585):
* a second send of the same request under the same key answers the
* FIRST copy's stored result and copies nothing. A refusal releases the
* key, so a corrected resend may reuse it; a different request under a
* key that already completed is 422 `idempotency_key_reused`. Callers
* mint one key per dialog open (TASK-3597).
*
* ⚠ Still NEVER RETRY THIS CALL WITHOUT A KEY (DR-13): a keyless retry
* after a request that already committed creates a DUPLICATE item, and a
* caller that lost the response cannot tell which happened. On a 500
* `copy_failed`, or on ANY unstructured transport failure once this call
* has been dispatched, the outcome is unknown: tell the user to check
* the destination workspace.
*
* Deliberately no `signal` param: aborting tells you nothing about
* whether the server committed, and the shared `request` never retries
* non-idempotent methods.
*/
copy: (ws: string, slug: string, req: ItemCopyPreflightRequest, opKey?: string) =>
request<ItemCopyResult>(`/workspaces/${ws}/items/${slug}/copy`, {
method: 'POST',
body: JSON.stringify(opKey ? { ...req, op_key: opKey } : req)
}),
/**
* 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,
})}`
),
/**
* The item's latest typed-decision answer per question (TASK-3117).
* Empty when no decision provider is configured.
*/
decisions: (ws: string, slug: string) =>
request<ItemDecisionsResponse>(`/workspaces/${ws}/items/${slug}/decisions`),
/**
* Inbound `relation` FIELD references to an item — the "Referenced
* by" section (PLAN-2857 U5). Distinct from `backlinks`, which is
* `[[...]]` mentions in prose: these are typed field edges and each
* carries the `field_key` it points through.
*
* `total` is the count under the CALLER'S visibility, not the true
* one. A viewer with partial access sees their own number, because a
* true count would disclose the existence of items they cannot read.
*
* No cross-workspace tier: a relation names an item id and PLAN-2857
* v1 excludes cross-workspace targets, so there is nothing foreign to
* page through.
*/
relationBacklinks: (
ws: string,
slug: string,
opts?: { limit?: number; offset?: number }
) =>
request<RelationBacklinksPage>(
`/workspaces/${ws}/items/${slug}/relation-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'
}),
/**
* The installed apps' item actions offered on this item, for the item
* pane's link-out button (SPEC-6 U11, TASK-3414). Shape frozen with U9.
*/
appActions: (ws: string, itemSlug: string) =>
request<ItemAppAction[]>(`/workspaces/${ws}/items/${itemSlug}/app-actions`),
/**
* Mint a single-use context code for an item action and get the URL
* to open (in a new tab, noopener). Every refusal is the same 404.
*/
mintAppAction: (ws: string, itemSlug: string, installId: string, actionKey: string) =>
request<{url: string}>(
`/workspaces/${ws}/items/${itemSlug}/app-actions/${encodeURIComponent(installId)}/${encodeURIComponent(actionKey)}`,
{method: 'POST'}
),
/** 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, most
* recently starred first. `summary` leaves out each body (TASK-2231). */
starred: (ws: string, params?: {include_terminal?: boolean; summary?: boolean}) =>
request<Item[]>(`/workspaces/${ws}/starred${qs(params)}`),
/**
* Push an instruction about this item to the caller's OWN agent
* sessions that are ACCEPTING pushes and can see the item — a
* connected session that has not opted in, or that lacks access to
* the item, does not receive it (IDEA-2544 Phase 1 / PLAN-2558 S3) —
* or to exactly one of them when `targetSessionId` is given (PLAN-2558 S5,
* TASK-2588 — an id from `api.sessions.list()`).
*
* Fire-and-forget: there is no durable inbox and no ack, so a
* resolved promise means "accepted and processed" (`pushed: true` in
* ItemPushResult), never "an agent read it" — and, as of PLAN-2558
* S5, not even "published to the bus" unconditionally: a targeted
* request whose `targetSessionId` matches no live session resolves
* with `delivered_sessions: 0` and the server skips the publish
* entirely (see ItemPushResult's doc comment). Pair the call site
* with `api.sessions.list()` so the user knows whether anything is
* listening BEFORE they click — that pairing is the entire reason
* this endpoint has a web surface.
*
* Self-addressed only; there is no cross-user target, by design (see
* handlePushToItem) — `targetSessionId` can only narrow delivery
* within the caller's OWN sessions, never address anyone else's.
* `message` is collapsed and bounded server-side — use
* `$lib/push/message` to apply the same rules in the composer so
* over-length text is caught before it becomes a 400.
*
* Omitting `targetSessionId` sends the exact pre-S5 body shape
* (`{ message }`, no `target_session_id` key at all) — existing
* broadcast call sites (QuickActionsMenu's S4 dispatch) are
* unaffected by this parameter's addition.
*
* `opKey` is sent as `op_key`, the server's idempotency key (TASK-3585):
* a second send of the same push under the same key replays the stored
* answer and publishes nothing. The server stores EVERY completed
* answer, a targeted miss (`delivered_sessions === 0`) included, so a
* caller that resends after a miss needs a NEW key: the old one would
* replay the miss, or answer 422 `idempotency_key_reused` for a new
* target. A pre-publish refusal releases the key.
*
* NEVER auto-retry this without a key: a keyless retry after an
* ambiguous failure can deliver the same instruction twice. A targeted
* miss is the one outcome a caller may act on safely — see
* ItemPushResult's doc comment.
*/
push: (
ws: string,
itemSlug: string,
message: string,
targetSessionId?: string,
opKey?: string
) =>
request<ItemPushResult>(`/workspaces/${ws}/items/${itemSlug}/push`, {
method: 'POST',
body: JSON.stringify({
message,
...(targetSessionId ? { target_session_id: targetSessionId } : {}),
...(opKey ? { op_key: opKey } : {})
})
})
},
// ── Agent session presence (PLAN-2558 S1) ────────────────────────────────
sessions: {
/**
* The caller's own live event-stream connections.
*
* Deliberately NOT workspace-scoped: the stream is user-scoped across
* every workspace the caller belongs to, so presence is too.
*
* Failure modes a caller must distinguish rather than flatten:
* - 503 `unavailable` — the server has no presence registry wired.
* That is "cannot tell", NOT "nobody is listening"; rendering it as
* zero is the exact lie this endpoint exists to prevent.
* - 401 — no resolved user (workspace token, or the fresh-install
* no-auth window). Also "cannot tell".
*
* The response is `Cache-Control: private, no-store` server-side; it
* is a liveness answer with a short shelf life, so callers should
* re-read it rather than hold one.
*/
list: () => request<LiveSessionsResponse>('/sessions')
},
// ── 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}`),
/** The change one version row records: the bodies before and after its
* write, from one server-side chain walk (PLAN-2348 U2). */
diff: (ws: string, itemSlug: string, versionId: string) =>
request<ItemVersionDiff>(`/workspaces/${ws}/items/${itemSlug}/versions/${versionId}/diff`),
/**
* BUG-3031: the server refuses a restore with `content_pending_flush` while
* the item holds unsaved edits from another editor session; pass
* `overwritePendingEdits` to discard them. Without it the POST carries no
* body, as before.
*/
restore: (
ws: string,
itemSlug: string,
versionId: string,
opts?: { overwritePendingEdits?: boolean }
) =>
request<Item>(`/workspaces/${ws}/items/${itemSlug}/versions/${versionId}/restore`, {
method: 'POST',
...(opts?.overwritePendingEdits
? { body: JSON.stringify({ overwrite_pending_edits: true }) }
: {})
}),
/** 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'
}),
// Item-scoped edit/delete (TASK-2695): the server answers 404 unless
// the comment is on itemRef. WebMCP uses these so its edit-comment /
// delete-comment land on the same check as the CLI and /mcp.
updateOnItem: (ws: string, itemRef: string, commentId: string, data: { body: string }) =>
request<Comment>(
`/workspaces/${ws}/items/${encodeURIComponent(itemRef)}/comments/${encodeURIComponent(commentId)}`,
{ method: 'PATCH', body: JSON.stringify(data) }
),
deleteOnItem: (ws: string, itemRef: string, commentId: string) =>
request<void>(
`/workspaces/${ws}/items/${encodeURIComponent(itemRef)}/comments/${encodeURIComponent(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: {
/**
* One page of an item's timeline, newest first.
*
* Page with the response's `next_before` + `next_before_id`, never with
* the last entry received: the server drops rows that cannot render, so
* a page can carry fewer entries than the rows it consumed — or none,
* with history behind them — and its cursor can deliberately re-cover
* ground. Deriving one from the last visible entry cannot advance past
* such a page (BUG-2765). Send both fields or neither; the id is the
* tie-break among entries sharing a second.
*
* The server is asymmetric about the one-sided forms (BUG-2774):
* `before` alone is accepted for external clients that cannot produce
* an id, while `before_id` alone is a 400 — it matches nothing on its
* own and would silently page from the beginning.
*/
list: (
ws: string,
itemSlug: string,
params?: { limit?: number; before?: string; before_id?: string; kinds?: readonly string[] }
) => {
const qs = new URLSearchParams();
if (params?.limit != null) qs.set('limit', String(params.limit));
// PLAN-2348 U3: only these entry kinds, so a view pages through
// what it renders. Omitted means every kind.
if (params?.kinds?.length) qs.set('kinds', params.kinds.join(','));
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; tz?: string }
) => {
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');
// The viewer's IANA zone: buckets are then local days and hours (TASK-3524).
if (opts?.tz) params.set('tz', opts.tz);
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?.includeArchived) params.include_archived = 'true';
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 ──────────────────────────────────────────────────────────────
// In-app tutorials (TASK-3452). Pad Cloud only: a self-hosted server
// answers 404 and the UI links to getpad.dev/learn instead.
tutorials: {
list: () => request<TutorialsResponse>('/tutorials')
},
// One-time UI suggestions the caller dismissed (TASK-3452). Stored on the
// user, so a dismissal holds on every device.
uiDismissals: {
list: () => request<UIDismissalsResponse>('/me/ui-dismissals'),
dismiss: (key: UIDismissalKey) =>
request<UIDismissalsResponse>(`/me/ui-dismissals/${encodeURIComponent(key)}`, { method: 'PUT' })
},
activity: {
list: (
ws: string,
params?: Record<string, string | number | boolean | undefined>
) => request<Activity[]>(`/workspaces/${ws}/activity${qs(params)}`)
},
// ── Convention Library ────────────────────────────────────────────────────
/**
* Built-in conventions and playbooks (TASK-3462): the state of each item
* made from one relative to Pad's current text, and the opt-in update.
*/
builtins: {
/** Every item made from a built-in whose whole collection the caller sees. */
list: (ws: string) => request<BuiltinListEntry[]>(`/workspaces/${ws}/builtins`),
/** One item's state; 404 `not_builtin` for an item made from no built-in. */
get: (ws: string, ref: string) =>
request<BuiltinStateResponse>(`/workspaces/${ws}/items/${ref}/builtin`),
/**
* Take the library's text. `expected_seq` is the seq read with the
* preview; the server refuses if the item changed since (409
* `update_conflict`), or while a tab holds unflushed edits (409
* `content_pending_flush`, lifted by `overwrite_pending_edits`).
*/
update: (ws: string, ref: string, body: { expected_seq: number; overwrite_pending_edits?: boolean }) =>
request<Item>(`/workspaces/${ws}/items/${ref}/builtin/update`, {
method: 'POST',
body: JSON.stringify(body)
})
},
library: {
get: () => request<ConventionLibraryResponse>('/convention-library'),
/**
* Activate a library convention. The server builds the item and
* records its built-in origin (TASK-3462), so this, the CLI and MCP
* create the same item; the fields blob this used to build itself is
* gone. Named by key when the entry carries one.
*/
activate: (ws: string, convention: LibraryConvention) =>
api.library.activateEntry(ws, convention),
getPlaybooks: () => request<PlaybookLibraryResponse>('/playbook-library'),
/** Activate a library playbook; see `activate`. */
activatePlaybook: (ws: string, playbook: LibraryPlaybook) =>
api.library.activateEntry(ws, playbook),
activateEntry: (ws: string, entry: { key?: string; title: string }) =>
request<Item>(`/workspaces/${ws}/library/activate`, {
method: 'POST',
body: JSON.stringify(entry.key ? { key: entry.key } : { title: entry.title })
}),
/**
* Activate a library convention or playbook by its exact title. The
* server resolves it, conventions first and then playbooks (the
* precedence the CLI `pad library activate` and MCP
* `pad_library.action=activate` use), and answers 404 `not_found`
* when no entry has that title.
*/
activateByTitle: (ws: string, title: string): Promise<Item> =>
request<Item>(`/workspaces/${ws}/library/activate`, {
method: 'POST',
body: 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 }[];
// Installed apps' bot principals (TASK-3392), never in `members`.
// Absent when the instance's policy hides them.
apps?: WorkspaceApp[];
}>(`/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' }),
// `proof` is the mailbox-only secret from the invitation email's link
// (TASK-3352); sending it verifies the address. The code never does.
acceptInvitation: (code: string, proof?: string) =>
request<InvitationAccepted>(`/invitations/${code}/accept`, {
method: 'POST',
...(proof ? { body: JSON.stringify({ proof }) } : {})
}),
// Pending invitations addressed to the caller's VERIFIED email, and
// accepting one by id: the list carries no code (only its hash is
// stored). PLAN-3002 U4b / TASK-3277.
listMyInvitations: () => request<MyInvitationsResponse>('/me/invitations'),
acceptMyInvitation: (id: string) =>
request<InvitationAccepted>(`/me/invitations/${encodeURIComponent(id)}/accept`, {
method: 'POST'
}),
// Declining deletes the invitation (BUG-2136). By id from the in-app
// list, or by code from the join page; both answer 204.
declineMyInvitation: (id: string) =>
request<void>(`/me/invitations/${encodeURIComponent(id)}/decline`, { method: 'POST' }),
declineInvitation: (code: string) =>
request<void>(`/invitations/${encodeURIComponent(code)}/decline`, { 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;
// Under the API deadline (BUG-3216). A GET, so a timeout is idempotent.
return withRequestDeadline(
async (signal) => {
const resp = await fetch(`${BASE}/s/${token}`, { credentials: 'same-origin', headers, signal });
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 (await resp.json()) as SharePayload;
},
{ idempotent: true }
);
},
},
// ── Auth ──────────────────────────────────────────────────────────────────
auth: {
// A direct fetch (its body is read as-is, with none of request()'s status
// handling), but under the same deadline: `authStore.load()` dedups
// every caller onto this one promise, so a session request the server
// never answered left the app's auth load pending for good (BUG-3211).
session: (): Promise<AuthSession> =>
fetchJsonWithDeadline<AuthSession>(BASE + '/auth/session', { credentials: 'same-origin' }),
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,
invitation_proof?: string
) =>
request<{ user: { id: string; email: string; username: string; name: string; role: string; email_verified?: boolean }; token: string; accepted_invitation?: InvitationAcceptedFields; invitation_not_joined?: { code: string; message: string }; workspace_path?: string }>('/auth/register', {
method: 'POST',
body: JSON.stringify({
email,
name,
password,
...(username ? { username } : {}),
...(invitation_code ? { invitation_code } : {}),
...(invitation_proof ? { invitation_proof } : {})
})
}),
// 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; requires_login?: boolean; user?: { id: string; email: string; username: string; name: string; role: string }; token?: string }>('/auth/reset-password', {
method: 'POST',
body: JSON.stringify({ token, password })
}),
// Consume the email-verification link a self-registered Pad Cloud user
// clicks (PLAN-1933 / BUG-1942). This is the token-consume endpoint —
// distinct from admin.verifyEmail, which force-verifies a user by id.
// The token in the link is the credential, so this is a bare POST with
// no form. Returns the freshly-verified user so the SPA can refresh its
// auth store without a second round-trip; 400 invalid_token when the
// link is invalid, expired, or already used; 409 verify_needs_session
// (details {email, can_claim}) when the request has no session for the
// account the link names (BUG-3382).
verifyEmailToken: (token: string) =>
request<{ ok: boolean; user: { id: string; email: string; username: string; name: string; role: string } }>('/auth/verify-email', {
method: 'POST',
body: JSON.stringify({ token })
}),
// Claim the address the verification link was mailed to (BUG-3382).
// Offered when the link is opened without a session for its account
// (409 verify_needs_session with details.can_claim). Resets every
// credential on the account and answers with a set-password path;
// 409 account_claim_needs_support when billing is attached.
claimByVerification: (token: string) =>
request<{ claimed: boolean; reset_path: string; stripped_workspaces: string[]; deleted_workspaces: string[] }>('/auth/verify-email/claim', {
method: 'POST',
body: JSON.stringify({ token })
}),
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 })
}),
// Permanently delete the current user's account (TASK-1960). Routed
// through the shared request() helper so the X-CSRF-Token + credentials
// are attached automatically — a hand-rolled fetch would omit CSRF and
// 403. All fields are optional so the caller sends only what the
// account's auth method requires (password for email/password users,
// totp_code when 2FA is on, confirm as a UI guard).
deleteAccount: (opts: { password?: string; confirm?: boolean; totp_code?: string }) =>
request<{ ok: boolean }>('/auth/delete-account', {
method: 'POST',
body: JSON.stringify({ ...opts })
}),
// GET the account-export endpoint and return the JSON artifact text plus
// the server-suggested filename (Content-Disposition, falling back to
// `pad-account-export.json`). Modeled on exportItemArtifact, NOT request()
// — request() JSON-parses the body and force-redirects on 401, whereas
// here we want the raw text and a PadApiError that surfaces the
// restricted-owner 403 (BUG-1945). Auth is by session; 401 → /login.
exportAccountData: async (): Promise<{ filename: string; text: string }> => {
const resp = await fetch(`${BASE}/auth/export`, {
credentials: 'same-origin'
});
if (resp.status === 401) {
if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
redirectToLogin('/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);
// Non-OK with no JSON error envelope: still surface a PadApiError so
// every failure path (incl. the restricted-owner 403) is one type for
// callers to catch — satisfies TASK-1960's "!ok → PadApiError".
throw new PadApiError({ code: 'export_failed', message: `account export failed: ${resp.status}` });
}
const text = await resp.text();
const disposition = resp.headers.get('Content-Disposition') ?? '';
const filename = parseContentDispositionFilename(disposition) ?? 'pad-account-export.json';
return { filename, text };
},
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 })
}),
// An account with a password gives it; one without (signed up with
// Google, GitHub or Apple) gives a current code or a recovery code
// instead (TASK-2190).
disable: (factor: string | { password?: string; code?: string; recovery_code?: string }) =>
request<TOTPDisableResponse>('/auth/2fa/disable', {
method: 'POST',
body: JSON.stringify(typeof factor === 'string' ? { password: factor } : factor)
})
},
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' }),
/** Same name, scopes and workspace; a new secret. The old one stops
* working at once (TASK-3505). Needs a session, not a PAT. */
rotate: (tokenId: string) =>
request<APITokenWithSecret>(`/auth/tokens/${encodeURIComponent(tokenId)}/rotate`, { method: 'POST' })
},
cli: {
getSession: (code: string) =>
request<{
status: string;
token?: string;
user?: { id: string; email: string; name: string; role: string };
// Pending only (TASK-2253). requester_user_agent is the requester's own claim.
created_at?: string;
requester_ip?: string;
requester_user_agent?: string;
}>(`/auth/cli/sessions/${code}`),
denySession: (code: string) =>
request<{ denied: boolean }>(`/auth/cli/sessions/${code}/deny`, { method: 'POST' }),
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 fetchWrite(`${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')) {
redirectToLogin('/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;
// Its own, longer bound (BUG-3211): the server decodes and re-encodes
// the image, which the request-timeout receipt never measured. A
// timeout here is a WRITE's: the new attachment may still have been
// created.
const deadline = requestDeadline(TRANSFORM_TIMEOUT_MS);
try {
const resp = await fetchWrite(
`${BASE}/workspaces/${workspaceSlug}/attachments/${attachmentId}/transform`,
{
method: 'POST',
headers,
credentials: 'same-origin',
body: JSON.stringify(payload),
signal: deadline.signal
}
);
if (resp.status === 401) {
if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
redirectToLogin('/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;
} catch (err) {
if (deadline.timedOut()) throw requestTimeoutError(false);
throw err;
} finally {
deadline.clear();
}
},
/**
* 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.item_id) params.set('item_id', filters.item_id);
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: () => {
if (!serverCapabilitiesCache) {
serverCapabilitiesCache = request<ServerCapabilities>('/server/capabilities').catch(
(err) => {
// Don't cache failures — drop the slot so the next call retries.
serverCapabilitiesCache = null;
throw err;
}
);
}
return serverCapabilitiesCache;
}
},
// ── 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.
// Installed apps, owner-only (SPEC-6 U8/U9a, TASK-3413).
apps: {
list: (ws: string) => request<AppInstallList>(`/workspaces/${encodeURIComponent(ws)}/apps`),
get: (ws: string, installId: string) =>
request<AppInstallStateResult>(`/workspaces/${encodeURIComponent(ws)}/apps/${encodeURIComponent(installId)}`),
preview: (ws: string, baseUrl: string) =>
request<AppInstallPreview>(`/workspaces/${encodeURIComponent(ws)}/apps/install/preview`, {
method: 'POST',
body: JSON.stringify({ base_url: baseUrl })
}),
discardPending: (ws: string, pendingId: string) =>
request<void>(`/workspaces/${encodeURIComponent(ws)}/apps/install/pending/${encodeURIComponent(pendingId)}`, {
method: 'DELETE'
}),
/** manifestSha256 is the hash the owner REVIEWED: a changed manifest is refused. */
confirm: (ws: string, pendingId: string, manifestSha256: string) =>
request<AppInstallConfirmResult>(
`/workspaces/${encodeURIComponent(ws)}/apps/install/pending/${encodeURIComponent(pendingId)}/confirm`,
{ method: 'POST', body: JSON.stringify({ manifest_sha256: manifestSha256 }) }
),
issueCode: (ws: string, installId: string) =>
request<AppInstallCode>(`/workspaces/${encodeURIComponent(ws)}/apps/${encodeURIComponent(installId)}/install-code`, {
method: 'POST'
}),
/** Stage the app's current manifest and diff it against the install (U8b2). */
upgradePreview: (ws: string, installId: string) =>
request<AppInstallPreview>(
`/workspaces/${encodeURIComponent(ws)}/apps/${encodeURIComponent(installId)}/upgrade/preview`,
{ method: 'POST' }
),
/** manifestSha256 is the hash the owner REVIEWED, as on install. */
upgradeConfirm: (ws: string, installId: string, pendingId: string, manifestSha256: string) =>
request<AppUpgradeConfirmResult>(
`/workspaces/${encodeURIComponent(ws)}/apps/${encodeURIComponent(installId)}/upgrade/confirm`,
{ method: 'POST', body: JSON.stringify({ pending_id: pendingId, manifest_sha256: manifestSha256 }) }
),
/** disable / enable / rotate / uninstall. A 503 deliveries_in_flight is resumed by repeating the call. */
lifecycle: (ws: string, installId: string, action: 'disable' | 'enable' | 'rotate' | 'uninstall') =>
request<AppInstallStateResult>(
`/workspaces/${encodeURIComponent(ws)}/apps/${encodeURIComponent(installId)}/${action}`,
{ method: 'POST' }
)
},
connectedApps: {
list: () => request<{ items: ConnectedApp[]; app_grants?: AppGrant[] }>('/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;
}
) =>
request<ConnectedApp>(`/connected-apps/${encodeURIComponent(id)}/flags`, {
method: 'PATCH',
body: JSON.stringify(flags)
}),
// BUG-3338: copy the user's current memberships into the list and
// turn the wildcard off.
limitToCurrent: (id: string) =>
request<ConnectedApp>(`/connected-apps/${encodeURIComponent(id)}/limit-to-current`, {
method: 'POST'
}),
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')) {
redirectToLogin('/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 fetchWrite(`${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')) {
redirectToLogin('/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)
}),
// MCP over HTTP (PLAN-2310 DR-2). PUT answers 409 set_by_environment
// when PAD_MCP_ENABLED forces the value, 403 on Pad Cloud.
getMCPSettings: () => request<MCPSettings>('/admin/mcp'),
updateMCPSettings: (enabled: boolean) =>
request<MCPSettings>('/admin/mcp', { method: 'PUT', body: JSON.stringify({ enabled }) }),
// Decision provider (TASK-3121). The response never carries the API
// key — only whether one is set. api_key is write-only.
getDecisionSettings: () => request<DecisionSettings>('/admin/decision-provider'),
updateDecisionSettings: (input: DecisionSettingsInput) =>
request<DecisionSettings>('/admin/decision-provider', {
method: 'PUT',
body: JSON.stringify(input)
}),
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, upgrades_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 for the
* chosen billing interval (TASK-3468). Returns `{ url, interval }`: the
* interval the sidecar actually priced, absent from a sidecar that
* predates it. The caller checks it (decideCheckout) before doing
* `window.location.href = url`. A refusal throws BillingCheckoutError
* carrying the HTTP status; 503 also means "this interval is not sold".
* 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: async (
interval: BillingInterval = 'monthly'
): Promise<{ url: string; interval?: string }> => {
// Under the request deadline (BUG-3211): the billing page holds
// `checkoutInProgress` across this await, and its Upgrade button stays
// disabled on "Redirecting…" until it settles.
const deadline = requestDeadline(requestTimeoutMs);
try {
const r = await fetchWrite('/billing/checkout', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ interval }),
signal: deadline.signal
});
if (!r.ok) {
const body = await r.json().catch(() => ({}));
throw new BillingCheckoutError(
(body as { error?: string }).error || `Checkout request failed (${r.status})`,
r.status
);
}
// `interval` is the one the sidecar priced (TASK-3468); a sidecar
// from before TASK-3367 omits it. The caller decides.
return (await r.json()) as { url: string; interval?: string };
} catch (err) {
if (deadline.timedOut()) throw requestTimeoutError(false);
throw err;
} finally {
deadline.clear();
}
}
}
};
/** Billing intervals Pad Pro is sold at (TASK-3468). */
export type BillingInterval = 'monthly' | 'annual';
/** One interval as checkout sells it; unit_amount is in the currency's minor unit (TASK-3577). */
export interface BillingOfferInterval {
interval: BillingInterval;
lookup_key: string;
unit_amount: number;
currency: string;
}
/** What Pad Pro costs right now, as the sidecar serves it (TASK-3577). */
export interface BillingOffer {
intervals: BillingOfferInterval[];
trial_days: number;
}
/** A refused checkout, carrying the sidecar's HTTP status (TASK-3468). */
export class BillingCheckoutError extends Error {
readonly status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'BillingCheckoutError';
this.status = status;
}
}
export {
PadApiError,
isPlanLimitError,
planLimitMessage,
isRateLimitError,
isUpdateConflictError,
isSupersededWriteError,
isContentPendingFlushError,
isNotFoundError,
isConflictOrNotFound
};