mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
3595a3c034
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
3652 lines
153 KiB
TypeScript
3652 lines
153 KiB
TypeScript
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
|
||
};
|