mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-11 10:56:45 +08:00
* feat(auth): slide UI session expiry instead of fixing it at login A session token's exp was stamped once at login and never moved, so every continuously active user was logged out 30 days later, always mid-task. The session now re-issues itself once it drops below half its TTL, turning that scheduled logout into an idle bound. Renewal is stateless: it copies the claims of a token we signed and moves exp, so there is no session table and no DB read on the hot path. - Browsers are renewed inline by middleware.Auth on safe requests, keeping the session HttpOnly — no client change and no multi-tab coordination. - Clients holding the session as a string (Desktop, mobile) get POST /api/auth/refresh, which only ever accepts a UI JWT. PATs, agent task tokens and cloud-node PATs are refused: a machine credential must not become an interactive session. An expired JWT cannot be revived. - CSRF tokens are now bound to a new sid claim, stable across renewals, so re-issuing the auth cookie no longer invalidates a CSRF token another tab already read. The pre-existing binding stays valid so sessions that predate this keep working until they renew. - CloudFront cookies are re-signed alongside a renewal, so the CDN policy cannot outlive its session. The idle bound is one-sided and the comments say so: a session last used while it still had over half its TTL left is not extended, so the guaranteed survivable gap is TTL/2, not TTL. Refs MUL-7436 Co-authored-by: multica-agent <[email protected]> * feat(auth): renew the session on Desktop, mobile, and legacy token-mode web Completes the client half of sliding sessions. Browsers on cookie auth need nothing — the server re-issues their cookie inline — so this covers the clients that carry the session as a string and have to persist a new one. Renewal follows use, not a clock. Checks run at launch, on returning to the foreground, and on interaction; there is deliberately no interval timer, because a timer would keep an abandoned but still-open window (or a backgrounded phone) signed in forever. The server decides whether a session is renewable and tells the client when to ask again, derived from the deployment's configured TTL — so no client reads exp, hardcodes a cadence, or has to reason about clock skew. Renewal is not a session event, and each step protects that: - Concurrent triggers collapse into one request, so two valid tokens never race to be the one stored. On mobile the single-flight flag is set before the first await, since reading the Keychain is itself async. - A response that lands after logout or an account switch is discarded by comparing against the session the attempt started from; writing it back would resurrect a session the app has already torn down. - Offline, 5xx and an unreadable body all keep the current session, which is still valid for at least another half TTL. Only a real 401 ends a session, and that path is untouched — nothing here can clear tabs or drafts. - WebSocket clients now read the token at connect time instead of pinning the one captured when they were built, so a reconnect after a renewal authenticates with the current credential. Web also retries a rejected CSRF token once. Session-bound CSRF makes that unreachable in the steady state; it covers the single transition where a session predating that binding is renewed for the first time. Refs MUL-7436 Co-authored-by: multica-agent <[email protected]> * fix(auth): address review blockers on sliding session renewal Six fixes from review, each with a regression test that fails without it. An expired session was answered 403, not 401. The CSRF gate runs before the JWT is validated, and it read `sid` through the expiry-enforcing parser — so an expired cookie lost its sid, its perfectly good CSRF token failed to verify, and the user's first action after expiry was a rejected write instead of a return to the login page. Reading the session id and deciding access are now different questions with different parsers: the first only needs the signature, the second still enforces expiry. Rolling back was not survivable. A previous release can verify only the token-bound CSRF binding, so once a session was issued or renewed by this one, a rollback left every signed-in user able to read and unable to write, with no client retry able to help. Both bindings are now issued: clients echo both, the server accepts either, and the token-bound cookie is re-minted on every renewal so a rollback taken at any moment still finds a usable pair. Mobile could resurrect a session it had just ended. Keychain deletes are async, so a renewal that sampled storage after logout began but before the delete landed would write its replacement back. A session epoch, incremented synchronously at the start of logout, a 401 teardown and a new sign-in, closes the window comparing tokens cannot — including the case where logout lands during the renewal's own write, which is now undone rather than left behind. Mobile only checked at launch and on foreground, so an app held in the foreground and used continuously would never check again and could expire under the user's hands. Every touch now passes through a capture-phase responder that declines the gesture it observes. CloudFront cookies were re-signed by a middleware not every renewing route mounts — the plugin bridge renews without it, and would have slid the session forward while leaving the CDN policy behind. Re-signing moved next to the renewal itself, so the two cannot diverge by route. The renewal check interval could exceed the window it has to land in: a flat five-minute floor meant any TTL under ten minutes produced a cadence longer than the renewal window, logging out clients that followed it exactly. The cadence is now capped at half the threshold, which outranks both clamps. Refs MUL-7436 Co-authored-by: multica-agent <[email protected]> * fix(auth): close the CORS, credential-write and short-TTL gaps Three blockers from the second review round. The two CSRF values now travel in one header instead of two. A second header name would have to be in the server's CORS allowlist, and that list is version-specific: a browser holding a cookie this release issued, talking to a rolled-back server, would send a header that server never allowlisted. The preflight fails, the request never reaches a handler, and no retry helps — precisely the half-logged-in state the second cookie exists to prevent. With one header name the server just tries both bindings, and the client switches to the token-bound value when a server rejects the session-bound one. It switches back as soon as the cookie changes, keyed on the rejected value rather than a flag, so it cannot oscillate once per renewal. A test now pins the CSRF header to the allowlist so a future second header fails here rather than in production. Mobile credential writes are serialized, and a stale renewal is refused rather than undone. Compensating after the fact meant deleting whatever was stored — which, after a logout and a fresh sign-in, is the new account's token: the app would be logged in in memory and signed out on the next launch. The check and the write now happen inside one serialized unit, so a logout or a sign-in cannot land between them and a refusal writes nothing at all. AUTH_TOKEN_TTL has a documented one-minute minimum. Clients are told when to check again in whole seconds, so a shorter TTL asks the wire to carry a sub-second cadence: it truncates, and below about four seconds it serialises to 0. Values under the minimum are clamped up with a startup warning rather than honoured. Mobile's floors were never actually lowered in the previous commit — 1h/1m, not the 5s/5m claimed — so both clients now floor at 5s, below anything the server can send, and fall back to 30s, which fits the smallest supported window. The cadence tests assert the serialised integer a client actually receives, not the internal time.Duration. Refs MUL-7436 Co-authored-by: multica-agent <[email protected]> * fix(auth): keep a failed renewal cheap and a renewed token shared Two blockers from the full-PR review, plus the documentation the config change owes. A failed check no longer consumes the normal cadence. The two were one number, so a single 503 spent whatever the server had last suggested — with the 30-day default that is three days. A client told to check again in three days on day 12, returning on day 29 with one day of session left and hitting one 503, would not have been allowed to retry until day 32: two days after the session it was trying to save had expired. Failures now buy a short backing-off delay of their own, reset by the next success, so recovery follows the network rather than the cadence. The bearer token is read from shared storage per request instead of cached per client. Desktop runs one ApiClient per window over one localStorage, so a session renewed in one window left the others sending a credential on its way out until it expired and took the whole session down. A 401 is also now matched against the credential that produced it: a request that went out just before a renewal can be answered after it, and treating that as expiry would tear down a session that is demonstrably alive, clearing tabs and drafts with it. Documentation catches up with both the behaviour and the new minimum, in all four languages: sessions are an idle bound rather than a countdown from sign-in, the bound is one-sided, and AUTH_TOKEN_TTL clamps up at 60s. The rationale for that minimum is corrected too — it is the 5s floor every client applies to the derived cadence, not whole-second serialisation, which only binds far below it. Also removes two claims that were not true: that a failed renewal leaves at least half a TTL (it happens inside the window, so less), and that an open untouched window never renews (cookie-mode sessions slide on any authenticated request, background polling included). Refs MUL-7436 Co-authored-by: multica-agent <[email protected]> * fix(auth): back off on a renewal response that carries no cadence A 200 whose body fails schema validation does not throw — parseWithFallback returns zeroes — so both clients reset the failure count without moving the next-check deadline, and every subsequent interaction fired another request at a server already answering badly. A missing cadence is now treated as a failure and takes the same short backoff; a token that did arrive is still applied. Also settles the documentation wording: the 30 days is not an idle timer. Renewal only happens past the halfway point, so an unused session can end sooner than 30 days after its last use, and saying otherwise promised precision the design does not have. Refs MUL-7436 Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: J <[email protected]> Co-authored-by: multica-agent <[email protected]>
1392 lines
45 KiB
TypeScript
1392 lines
45 KiB
TypeScript
/**
|
|
* Mobile-owned fetch wrapper. Mirrors the surface area of
|
|
* packages/core/api/client.ts that mobile actually uses, but lives in
|
|
* apps/mobile/ so we control retry/timeout/error handling independently.
|
|
*
|
|
* Types are imported via `import type` from @multica/core/types — zero
|
|
* runtime coupling. Zod schemas + fallbacks are imported from
|
|
* @multica/core/api/schemas (pure data, on the mobile sharing whitelist).
|
|
*
|
|
* Design checklist (apps/mobile/CLAUDE.md "Lessons → ApiClient capability list"):
|
|
* 1. Zod parseWithFallback for endpoints with schemas (drift defense)
|
|
* 2. onUnauthorized callback on 401 (auto sign-out, avoids retry loops)
|
|
* 3. X-Request-ID per request + structured logger (debug + tracing)
|
|
* 4. Bearer auth + X-Workspace-Slug — NOT cookie auth (no CSRF, no credentials)
|
|
*/
|
|
import type {
|
|
Agent,
|
|
AgentTask,
|
|
Attachment,
|
|
ChatMessage,
|
|
ChatPendingTask,
|
|
ChatSession,
|
|
Comment,
|
|
CreateIssueRequest,
|
|
CreateLabelRequest,
|
|
CreateProjectRequest,
|
|
CreateProjectResourceRequest,
|
|
InboxItem,
|
|
InboxWorkspaceUnread,
|
|
Issue,
|
|
IssueLabelsResponse,
|
|
Label,
|
|
IssueReaction,
|
|
ListIssuesParams,
|
|
ListIssuesResponse,
|
|
ListLabelsResponse,
|
|
ListProjectResourcesResponse,
|
|
ListProjectsResponse,
|
|
MemberWithUser,
|
|
PinnedItem,
|
|
PinnedItemType,
|
|
Project,
|
|
ProjectResource,
|
|
Reaction,
|
|
ReorderPinsRequest,
|
|
RuntimeDevice,
|
|
SearchIssuesResponse,
|
|
SearchProjectsResponse,
|
|
ListIssueStatusesResponse,
|
|
SendChatMessageResponse,
|
|
Squad,
|
|
NotificationPreferenceResponse,
|
|
NotificationPreferences,
|
|
TaskMessagePayload,
|
|
TimelineEntry,
|
|
UpdateIssueRequest,
|
|
UpdateMeRequest,
|
|
UpdateProjectRequest,
|
|
User,
|
|
Workspace,
|
|
WorkspaceSubscriptionSummary,
|
|
} from "@multica/core/types";
|
|
import {
|
|
AppConfigSchema,
|
|
EMPTY_APP_CONFIG,
|
|
EMPTY_REFRESH_SESSION_RESPONSE,
|
|
RefreshSessionResponseSchema,
|
|
EMPTY_LIST_ISSUE_STATUSES_RESPONSE,
|
|
EMPTY_LIST_ISSUES_RESPONSE,
|
|
EMPTY_TIMELINE_ENTRIES,
|
|
IssueSchema,
|
|
ListIssuesResponseSchema,
|
|
ListIssueStatusesResponseSchema,
|
|
TimelineEntriesSchema,
|
|
WorkspaceSubscriptionSummarySchema,
|
|
} from "@multica/core/api/schemas";
|
|
import type {
|
|
AppConfigResponse,
|
|
RefreshSessionResponse,
|
|
} from "@multica/core/api/schemas";
|
|
import {
|
|
ActiveTasksResponseSchema,
|
|
AgentListSchema,
|
|
AgentTaskListSchema,
|
|
AttachmentListSchema,
|
|
AttachmentSchema,
|
|
ChatMessageListSchema,
|
|
CommentSchema,
|
|
ChatPendingTaskSchema,
|
|
ChatSessionListSchema,
|
|
ChatSessionSchema,
|
|
EMPTY_ACTIVE_TASKS_RESPONSE,
|
|
EMPTY_AGENT_LIST,
|
|
EMPTY_AGENT_TASK_LIST,
|
|
EMPTY_ATTACHMENT_LIST,
|
|
EMPTY_CHAT_MESSAGE_LIST,
|
|
EMPTY_CHAT_PENDING_TASK,
|
|
EMPTY_CHAT_SESSION_LIST,
|
|
EMPTY_COMMENT,
|
|
EMPTY_INBOX_LIST,
|
|
EMPTY_INBOX_UNREAD_SUMMARY,
|
|
EMPTY_ISSUE_FALLBACK,
|
|
EMPTY_LIST_LABELS_RESPONSE,
|
|
EMPTY_LIST_PROJECT_RESOURCES_RESPONSE,
|
|
EMPTY_LIST_PROJECTS_RESPONSE,
|
|
EMPTY_MEMBER_LIST,
|
|
EMPTY_NOTIFICATION_PREFERENCES,
|
|
EMPTY_PIN_LIST,
|
|
EMPTY_PROJECT,
|
|
EMPTY_RUNTIME_LIST,
|
|
EMPTY_SEARCH_ISSUES_RESPONSE,
|
|
EMPTY_SEARCH_PROJECTS_RESPONSE,
|
|
EMPTY_SQUAD_LIST,
|
|
EMPTY_USER,
|
|
EMPTY_WORKSPACE_LIST,
|
|
InboxListSchema,
|
|
InboxUnreadSummarySchema,
|
|
NotificationPreferenceResponseSchema,
|
|
ListLabelsResponseSchema,
|
|
ListProjectResourcesResponseSchema,
|
|
ListProjectsResponseSchema,
|
|
MemberListSchema,
|
|
PinListSchema,
|
|
PinnedItemSchema,
|
|
ProjectSchema,
|
|
RuntimeListSchema,
|
|
SearchIssuesResponseSchema,
|
|
SearchProjectsResponseSchema,
|
|
SendChatMessageResponseSchema,
|
|
SquadListSchema,
|
|
TaskMessageListSchema,
|
|
EMPTY_TASK_MESSAGE_LIST,
|
|
UserSchema,
|
|
WorkspaceListSchema,
|
|
} from "./schemas";
|
|
import type { ZodType } from "zod";
|
|
import { getCurrentSlug } from "./workspace-store";
|
|
import { parseWithFallback } from "@/lib/parse-response";
|
|
import { createRequestId } from "@/lib/request-id";
|
|
import { buildCommentUpdateBody } from "./revision";
|
|
|
|
const API_URL = process.env.EXPO_PUBLIC_API_URL;
|
|
|
|
if (!API_URL) {
|
|
throw new Error(
|
|
"EXPO_PUBLIC_API_URL is not set. Add it to apps/mobile/.env.development.local " +
|
|
"(see apps/mobile/.env.staging for an example).",
|
|
);
|
|
}
|
|
|
|
export interface LoginResponse {
|
|
token: string;
|
|
user: User;
|
|
}
|
|
|
|
/** Mobile file payload for `uploadFile`. RN doesn't have a browser `File`
|
|
* object; the fetch `FormData` polyfill accepts `{ uri, name, type }`
|
|
* directly and streams from disk. expo-image-picker / expo-document-picker
|
|
* return assets that map straight onto this shape. */
|
|
export interface FileAsset {
|
|
uri: string;
|
|
name: string;
|
|
type: string;
|
|
}
|
|
|
|
/** Web mirrors this from `packages/core/constants/upload.ts`. Mobile keeps
|
|
* its own copy per the `mirror, don't import` rule in apps/mobile/CLAUDE.md. */
|
|
const MAX_FILE_SIZE = 100 * 1024 * 1024;
|
|
|
|
/** Hard ceiling for every HTTP request. Mobile-specific because iOS may
|
|
* suspend a backgrounded network task without ever resolving/rejecting
|
|
* the JS-side fetch promise (facebook/react-native#35384). Without this
|
|
* timeout, a refetch fired after returning to foreground can leave the
|
|
* query stuck in `isRefetching` state forever (visible as the
|
|
* pull-to-refresh spinner never going away). 30s is generous for any
|
|
* reasonable Multica payload size on cellular. */
|
|
const FETCH_TIMEOUT_MS = 30_000;
|
|
|
|
export class ApiError extends Error {
|
|
readonly status: number;
|
|
readonly body?: unknown;
|
|
constructor(message: string, status: number, body?: unknown) {
|
|
super(message);
|
|
this.name = "ApiError";
|
|
this.status = status;
|
|
this.body = body;
|
|
}
|
|
}
|
|
|
|
export interface ApiClientOptions {
|
|
/** Called once when the server returns 401. The platform layer wires this
|
|
* to clear the token + navigate to /login so a stale token doesn't keep
|
|
* every subsequent request looping on 401. */
|
|
onUnauthorized?: () => void;
|
|
}
|
|
|
|
class ApiClient {
|
|
private token: string | null = null;
|
|
private options: ApiClientOptions = {};
|
|
|
|
setToken(token: string | null) {
|
|
this.token = token;
|
|
}
|
|
|
|
/** The bearer token in use, or null when signed out. Session renewal reads
|
|
* it to confirm the session it started from is still the live one, and the
|
|
* WS client reads it so a reconnect uses the current credential. */
|
|
getToken(): string | null {
|
|
return this.token;
|
|
}
|
|
|
|
setOptions(options: ApiClientOptions) {
|
|
this.options = { ...this.options, ...options };
|
|
}
|
|
|
|
private async fetch<T>(
|
|
path: string,
|
|
init: RequestInit & { signal?: AbortSignal } = {},
|
|
): Promise<T> {
|
|
const rid = createRequestId();
|
|
const start = Date.now();
|
|
const method = init.method ?? "GET";
|
|
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
"X-Client-Platform": "mobile",
|
|
"X-Client-OS": "ios",
|
|
"X-Client-Version": "0.1.0",
|
|
"X-Request-ID": rid,
|
|
...((init.headers as Record<string, string>) ?? {}),
|
|
};
|
|
if (this.token) {
|
|
headers["Authorization"] = `Bearer ${this.token}`;
|
|
}
|
|
// Backend middleware (server/internal/middleware/workspace.go) resolves
|
|
// slug → ws UUID and gates membership. Mirrors packages/core/api/client.ts.
|
|
const slug = getCurrentSlug();
|
|
if (slug && !headers["X-Workspace-Slug"]) {
|
|
headers["X-Workspace-Slug"] = slug;
|
|
}
|
|
|
|
// Timeout + caller-signal forwarding.
|
|
//
|
|
// Hermes does NOT support AbortSignal.timeout() or AbortSignal.any() —
|
|
// see facebook/react-native#42042 and livekit#4014. So we manually
|
|
// compose a single controller that aborts on:
|
|
// (a) caller-side signal (TQ cancelling a stale/inactive query, etc),
|
|
// (b) 30s timeout (defends against iOS suspending the network task
|
|
// silently during background — fetch() then never resolves;
|
|
// facebook/react-native#35384). Without this, a refetch
|
|
// triggered by WS reconnect can leave the FlatList pull-to-refresh
|
|
// spinner stuck on the screen indefinitely.
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => {
|
|
controller.abort(new Error(`request timed out after ${FETCH_TIMEOUT_MS}ms`));
|
|
}, FETCH_TIMEOUT_MS);
|
|
const callerSignal = init.signal;
|
|
const onCallerAbort = () => controller.abort(callerSignal?.reason);
|
|
if (callerSignal) {
|
|
if (callerSignal.aborted) controller.abort(callerSignal.reason);
|
|
else callerSignal.addEventListener("abort", onCallerAbort);
|
|
}
|
|
|
|
console.log(`[api] → ${method} ${path}`, { rid });
|
|
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(`${API_URL}${path}`, {
|
|
...init,
|
|
signal: controller.signal,
|
|
headers,
|
|
});
|
|
} catch (err) {
|
|
clearTimeout(timeoutId);
|
|
callerSignal?.removeEventListener("abort", onCallerAbort);
|
|
// Re-throw with a clearer message if this was our own timeout abort.
|
|
if (
|
|
err instanceof Error &&
|
|
err.name === "AbortError" &&
|
|
!callerSignal?.aborted
|
|
) {
|
|
const duration = Date.now() - start;
|
|
console.warn(`[api] ← TIMEOUT ${path}`, {
|
|
rid,
|
|
duration: `${duration}ms`,
|
|
});
|
|
throw new ApiError(
|
|
`Request timed out after ${FETCH_TIMEOUT_MS}ms`,
|
|
0,
|
|
undefined,
|
|
);
|
|
}
|
|
throw err;
|
|
}
|
|
clearTimeout(timeoutId);
|
|
callerSignal?.removeEventListener("abort", onCallerAbort);
|
|
const duration = Date.now() - start;
|
|
|
|
if (!res.ok) {
|
|
// 401 sign-out hook: invoke once, let the platform layer (auth-store)
|
|
// clear the token + navigate. Subsequent requests in flight will also
|
|
// 401 and re-enter here, so the callback must be idempotent.
|
|
if (res.status === 401) {
|
|
this.options.onUnauthorized?.();
|
|
}
|
|
|
|
let body: unknown;
|
|
try {
|
|
body = await res.json();
|
|
} catch {
|
|
body = undefined;
|
|
}
|
|
const message =
|
|
(body && typeof body === "object" && "message" in body
|
|
? String((body as { message: unknown }).message)
|
|
: null) ?? `${res.status} ${res.statusText}`;
|
|
|
|
const level = res.status === 404 ? "warn" : "error";
|
|
console[level](`[api] ← ${res.status} ${path}`, {
|
|
rid,
|
|
duration: `${duration}ms`,
|
|
error: message,
|
|
});
|
|
|
|
throw new ApiError(message, res.status, body);
|
|
}
|
|
|
|
console.log(`[api] ← ${res.status} ${path}`, {
|
|
rid,
|
|
duration: `${duration}ms`,
|
|
});
|
|
|
|
if (res.status === 204) return undefined as T;
|
|
return (await res.json()) as T;
|
|
}
|
|
|
|
/**
|
|
* Read-side helper: GET + zod parse + fallback in one call. Collapses
|
|
* the boilerplate that every list/detail endpoint repeats:
|
|
*
|
|
* const raw = await this.fetch<unknown>(path, { signal: opts?.signal });
|
|
* return parseWithFallback(raw, Schema, FALLBACK, { endpoint: "name" });
|
|
*
|
|
* Always uses GET (no method arg) — write endpoints that need parsing
|
|
* still go through `this.fetch` + `parseWithFallback` directly because
|
|
* they carry a body and care about method semantics. Use
|
|
* `fetchValidatedWith` for those (PATCH / PUT / POST).
|
|
*
|
|
* The `endpoint` label defaults to the request path — override only when
|
|
* the path has dynamic segments and you want stable telemetry labels.
|
|
*/
|
|
private async fetchValidated<T>(
|
|
path: string,
|
|
schema: ZodType,
|
|
fallback: T,
|
|
opts?: { signal?: AbortSignal; endpoint?: string },
|
|
): Promise<T> {
|
|
const raw = await this.fetch<unknown>(path, { signal: opts?.signal });
|
|
return parseWithFallback(raw, schema, fallback, {
|
|
endpoint: opts?.endpoint ?? path,
|
|
});
|
|
}
|
|
|
|
/** Same as fetchValidated but supports any HTTP method + body. Used by
|
|
* PATCH/PUT/POST endpoints whose response we still want to validate
|
|
* (e.g. updateMe returns User, updateNotificationPreferences returns
|
|
* NotificationPreferenceResponse). */
|
|
private async fetchValidatedWith<T>(
|
|
path: string,
|
|
schema: ZodType,
|
|
fallback: T,
|
|
init: RequestInit,
|
|
opts?: { signal?: AbortSignal; endpoint?: string },
|
|
): Promise<T> {
|
|
// `opts.signal` wins if both are passed, but absent opts.signal does
|
|
// NOT clear init.signal — important because forgetting `?? init.signal`
|
|
// would silently strip a caller's abort signal when they used the
|
|
// RequestInit shape but no opts.
|
|
const raw = await this.fetch<unknown>(path, {
|
|
...init,
|
|
signal: opts?.signal ?? init.signal ?? undefined,
|
|
});
|
|
return parseWithFallback(raw, schema, fallback, {
|
|
endpoint: opts?.endpoint ?? `${init.method ?? "GET"} ${path}`,
|
|
});
|
|
}
|
|
|
|
// --- Auth ---
|
|
async sendCode(email: string): Promise<void> {
|
|
await this.fetch<void>("/auth/send-code", {
|
|
method: "POST",
|
|
body: JSON.stringify({ email }),
|
|
});
|
|
}
|
|
|
|
async verifyCode(email: string, code: string): Promise<LoginResponse> {
|
|
return this.fetch<LoginResponse>("/auth/verify-code", {
|
|
method: "POST",
|
|
body: JSON.stringify({ email, code }),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Ask the server to extend this session if it has entered its renewal
|
|
* window (MUL-7436). The server owns that decision — the app never reads
|
|
* `exp`, so a device with a skewed clock behaves exactly like one without.
|
|
*/
|
|
async refreshSession(): Promise<RefreshSessionResponse> {
|
|
return this.fetchValidatedWith(
|
|
"/api/auth/refresh",
|
|
RefreshSessionResponseSchema,
|
|
EMPTY_REFRESH_SESSION_RESPONSE,
|
|
{ method: "POST" },
|
|
{ endpoint: "refreshSession" },
|
|
);
|
|
}
|
|
|
|
async getMe(opts?: { signal?: AbortSignal }): Promise<User> {
|
|
return this.fetchValidated(
|
|
"/api/me",
|
|
UserSchema,
|
|
EMPTY_USER,
|
|
{ ...opts, endpoint: "getMe" },
|
|
);
|
|
}
|
|
|
|
async getConfig(opts?: { signal?: AbortSignal }): Promise<AppConfigResponse> {
|
|
return this.fetchValidated<AppConfigResponse>(
|
|
"/api/config",
|
|
AppConfigSchema,
|
|
EMPTY_APP_CONFIG,
|
|
{ ...opts, endpoint: "getConfig" },
|
|
);
|
|
}
|
|
|
|
async getWorkspaceSubscriptionSummary(opts?: {
|
|
signal?: AbortSignal;
|
|
}): Promise<WorkspaceSubscriptionSummary | null> {
|
|
return this.fetchValidated<WorkspaceSubscriptionSummary | null>(
|
|
"/api/cloud-subscriptions/summary",
|
|
WorkspaceSubscriptionSummarySchema,
|
|
null,
|
|
{ ...opts, endpoint: "getWorkspaceSubscriptionSummary" },
|
|
);
|
|
}
|
|
|
|
// PATCH /api/me — name, avatar_url, language. Server returns the updated
|
|
// user; we parse so a partial drift doesn't bleed into the auth store.
|
|
async updateMe(data: UpdateMeRequest): Promise<User> {
|
|
return this.fetchValidatedWith(
|
|
"/api/me",
|
|
UserSchema,
|
|
EMPTY_USER,
|
|
{ method: "PATCH", body: JSON.stringify(data) },
|
|
{ endpoint: "updateMe" },
|
|
);
|
|
}
|
|
|
|
// --- Notification preferences ---
|
|
async getNotificationPreferences(
|
|
opts?: { signal?: AbortSignal },
|
|
): Promise<NotificationPreferenceResponse> {
|
|
return this.fetchValidated(
|
|
"/api/notification-preferences",
|
|
NotificationPreferenceResponseSchema,
|
|
EMPTY_NOTIFICATION_PREFERENCES,
|
|
{ ...opts, endpoint: "getNotificationPreferences" },
|
|
);
|
|
}
|
|
|
|
async updateNotificationPreferences(
|
|
preferences: NotificationPreferences,
|
|
workspaceSlug?: string,
|
|
): Promise<NotificationPreferenceResponse> {
|
|
return this.fetchValidatedWith(
|
|
"/api/notification-preferences",
|
|
NotificationPreferenceResponseSchema,
|
|
EMPTY_NOTIFICATION_PREFERENCES,
|
|
{
|
|
method: "PATCH",
|
|
headers: workspaceSlug
|
|
? { "X-Workspace-Slug": workspaceSlug }
|
|
: undefined,
|
|
body: JSON.stringify({ preferences }),
|
|
},
|
|
{ endpoint: "updateNotificationPreferences" },
|
|
);
|
|
}
|
|
|
|
// --- Workspaces ---
|
|
async listWorkspaces(opts?: {
|
|
signal?: AbortSignal;
|
|
}): Promise<Workspace[]> {
|
|
const raw = await this.fetch<unknown>("/api/workspaces", {
|
|
signal: opts?.signal,
|
|
});
|
|
return parseWithFallback(raw, WorkspaceListSchema, EMPTY_WORKSPACE_LIST, {
|
|
endpoint: "listWorkspaces",
|
|
});
|
|
}
|
|
|
|
// --- Inbox ---
|
|
async listInbox(opts?: { signal?: AbortSignal }): Promise<InboxItem[]> {
|
|
const raw = await this.fetch<unknown>("/api/inbox", {
|
|
signal: opts?.signal,
|
|
});
|
|
return parseWithFallback(raw, InboxListSchema, EMPTY_INBOX_LIST, {
|
|
endpoint: "listInbox",
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Cross-workspace unread inbox counts, one entry per workspace with unread
|
|
* items. Backs the inbox tab badge — see lib/unread-counts.ts for why the
|
|
* badge reads this instead of counting `listInbox()` locally.
|
|
*/
|
|
async getInboxUnreadSummary(opts?: {
|
|
signal?: AbortSignal;
|
|
}): Promise<InboxWorkspaceUnread[]> {
|
|
const raw = await this.fetch<unknown>("/api/inbox/unread-summary", {
|
|
signal: opts?.signal,
|
|
});
|
|
return parseWithFallback(
|
|
raw,
|
|
InboxUnreadSummarySchema,
|
|
EMPTY_INBOX_UNREAD_SUMMARY,
|
|
{ endpoint: "getInboxUnreadSummary" },
|
|
);
|
|
}
|
|
|
|
async markInboxRead(id: string): Promise<InboxItem> {
|
|
return this.fetch<InboxItem>(`/api/inbox/${id}/read`, { method: "POST" });
|
|
}
|
|
|
|
// Archive endpoints — write surface. Match web's surface in
|
|
// packages/core/api/client.ts:981-1003. No parseWithFallback (mirrors
|
|
// markInboxRead above and the project write endpoints): a malformed
|
|
// archive response should surface naturally so the optimistic patch
|
|
// rolls back.
|
|
async archiveInbox(id: string): Promise<InboxItem> {
|
|
return this.fetch<InboxItem>(`/api/inbox/${id}/archive`, { method: "POST" });
|
|
}
|
|
|
|
async markAllInboxRead(): Promise<{ count: number }> {
|
|
return this.fetch<{ count: number }>("/api/inbox/mark-all-read", {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
async archiveAllInbox(): Promise<{ count: number }> {
|
|
return this.fetch<{ count: number }>("/api/inbox/archive-all", {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
async archiveAllReadInbox(): Promise<{ count: number }> {
|
|
return this.fetch<{ count: number }>("/api/inbox/archive-all-read", {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
async archiveCompletedInbox(): Promise<{ count: number }> {
|
|
return this.fetch<{ count: number }>("/api/inbox/archive-completed", {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
// --- Members & Agents (for actor name/avatar lookup) ---
|
|
async listMembers(
|
|
workspaceId: string,
|
|
opts?: { signal?: AbortSignal },
|
|
): Promise<MemberWithUser[]> {
|
|
const raw = await this.fetch<unknown>(
|
|
`/api/workspaces/${workspaceId}/members`,
|
|
{ signal: opts?.signal },
|
|
);
|
|
return parseWithFallback(raw, MemberListSchema, EMPTY_MEMBER_LIST, {
|
|
endpoint: "listMembers",
|
|
});
|
|
}
|
|
|
|
async listAgents(opts?: { signal?: AbortSignal }): Promise<Agent[]> {
|
|
const raw = await this.fetch<unknown>("/api/agents", {
|
|
signal: opts?.signal,
|
|
});
|
|
return parseWithFallback(raw, AgentListSchema, EMPTY_AGENT_LIST, {
|
|
endpoint: "listAgents",
|
|
});
|
|
}
|
|
|
|
// Workspace runtimes — feeds the presence dot's availability dimension
|
|
// (runtime.status + last_seen_at). Backend route registered in
|
|
// server/cmd/server/router.go:514 (GET /api/runtimes).
|
|
async listRuntimes(opts?: { signal?: AbortSignal }): Promise<RuntimeDevice[]> {
|
|
const raw = await this.fetch<unknown>("/api/runtimes", {
|
|
signal: opts?.signal,
|
|
});
|
|
return parseWithFallback(raw, RuntimeListSchema, EMPTY_RUNTIME_LIST, {
|
|
endpoint: "listRuntimes",
|
|
});
|
|
}
|
|
|
|
// Workspace-wide active agent tasks + each agent's most recent terminal —
|
|
// feeds the workload dimension of presence (currently unused in the mobile
|
|
// dot; reserved for the P1 long-press peek sheet). Listed here now so the
|
|
// realtime invalidation path can be wired in one PR. Backend route at
|
|
// server/cmd/server/router.go:539 (GET /api/agent-task-snapshot).
|
|
async listAgentTaskSnapshot(
|
|
opts?: { signal?: AbortSignal },
|
|
): Promise<AgentTask[]> {
|
|
const raw = await this.fetch<unknown>("/api/agent-task-snapshot", {
|
|
signal: opts?.signal,
|
|
});
|
|
return parseWithFallback(raw, AgentTaskListSchema, EMPTY_AGENT_TASK_LIST, {
|
|
endpoint: "listAgentTaskSnapshot",
|
|
});
|
|
}
|
|
|
|
async listSquads(opts?: { signal?: AbortSignal }): Promise<Squad[]> {
|
|
const raw = await this.fetch<unknown>("/api/squads", {
|
|
signal: opts?.signal,
|
|
});
|
|
return parseWithFallback(raw, SquadListSchema, EMPTY_SQUAD_LIST, {
|
|
endpoint: "listSquads",
|
|
});
|
|
}
|
|
|
|
// --- Issues ---
|
|
async listIssues(
|
|
params: ListIssuesParams = {},
|
|
opts?: { signal?: AbortSignal },
|
|
): Promise<ListIssuesResponse> {
|
|
const search = new URLSearchParams();
|
|
for (const [k, v] of Object.entries(params)) {
|
|
if (v == null) continue;
|
|
if (Array.isArray(v)) {
|
|
// Backend parses comma-separated lists (server/internal/handler/issue.go
|
|
// uses strings.Split on a single query value). Match web's serialization
|
|
// in packages/core/api/client.ts:407 — repeated keys would silently
|
|
// collapse to the first value only.
|
|
if (v.length > 0) search.set(k, v.map(String).join(","));
|
|
} else {
|
|
search.set(k, String(v));
|
|
}
|
|
}
|
|
const qs = search.toString();
|
|
const raw = await this.fetch<unknown>(
|
|
`/api/issues${qs ? `?${qs}` : ""}`,
|
|
{ signal: opts?.signal },
|
|
);
|
|
return parseWithFallback(raw, ListIssuesResponseSchema, EMPTY_LIST_ISSUES_RESPONSE, {
|
|
endpoint: "GET /api/issues",
|
|
});
|
|
}
|
|
|
|
/** Workspace-wide issue search. Backend `GET /api/issues/search` with
|
|
* workspace resolved by the `X-Workspace-Slug` middleware (same as
|
|
* `listIssues`). Caller passes its own `AbortController.signal` so the
|
|
* search modal can cancel an in-flight request when the user types
|
|
* again — see app/(app)/[workspace]/search.tsx. */
|
|
async searchIssues(
|
|
params: { q: string; limit?: number; include_closed?: boolean; offset?: number },
|
|
opts?: { signal?: AbortSignal },
|
|
): Promise<SearchIssuesResponse> {
|
|
const search = new URLSearchParams();
|
|
for (const [k, v] of Object.entries(params)) {
|
|
if (v == null) continue;
|
|
search.set(k, String(v));
|
|
}
|
|
const raw = await this.fetch<unknown>(
|
|
`/api/issues/search?${search.toString()}`,
|
|
{ signal: opts?.signal },
|
|
);
|
|
return parseWithFallback(raw, SearchIssuesResponseSchema, EMPTY_SEARCH_ISSUES_RESPONSE, {
|
|
endpoint: "GET /api/issues/search",
|
|
});
|
|
}
|
|
|
|
async getIssue(
|
|
id: string,
|
|
opts?: { signal?: AbortSignal },
|
|
): Promise<Issue> {
|
|
return this.fetchValidated(
|
|
`/api/issues/${id}`,
|
|
IssueSchema,
|
|
EMPTY_ISSUE_FALLBACK,
|
|
{ ...opts, endpoint: "getIssue" },
|
|
);
|
|
}
|
|
|
|
// Write endpoint — mirrors POST /api/issues
|
|
// (server/cmd/server/router.go:320, server/internal/handler/issue.go
|
|
// CreateIssue). Mobile sends only the fields the form fills in; backend
|
|
// applies its own defaults for anything omitted.
|
|
async createIssue(body: CreateIssueRequest): Promise<Issue> {
|
|
return this.fetch<Issue>("/api/issues", {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
// Timeline returns the full ASC entry list in one shot — server-side
|
|
// pagination was dropped in #2322 (p99 ~30 entries per issue, cursors
|
|
// were pure overhead and split reply threads at page boundaries).
|
|
// Call WITHOUT pagination params: the legacy `limit/before/after/around`
|
|
// path returns the old wrapped shape for back-compat, which mobile must
|
|
// NOT trigger. See server/internal/handler/activity.go:60-69.
|
|
async listTimeline(
|
|
issueId: string,
|
|
opts?: { signal?: AbortSignal },
|
|
): Promise<TimelineEntry[]> {
|
|
return this.fetchValidated(
|
|
`/api/issues/${issueId}/timeline`,
|
|
TimelineEntriesSchema,
|
|
EMPTY_TIMELINE_ENTRIES,
|
|
{ ...opts, endpoint: "GET /api/issues/:id/timeline" },
|
|
);
|
|
}
|
|
|
|
// GET /api/issues/:id/attachments — list of file attachments hooked to
|
|
// the issue (or its comments). Mobile uses this to resolve `mc://file/<id>`
|
|
// markdown image URIs to their `download_url` HTTPS endpoint; without it,
|
|
// iOS image loader doesn't understand the mc: scheme and renders broken.
|
|
async listAttachments(
|
|
issueId: string,
|
|
opts?: { signal?: AbortSignal },
|
|
): Promise<Attachment[]> {
|
|
return this.fetchValidated(
|
|
`/api/issues/${issueId}/attachments`,
|
|
AttachmentListSchema,
|
|
EMPTY_ATTACHMENT_LIST,
|
|
{ ...opts, endpoint: "GET /api/issues/:id/attachments" },
|
|
);
|
|
}
|
|
|
|
// Active tasks for an issue (status in queued/dispatched/running). Returns
|
|
// the inner `tasks` array directly — handler wraps it in `{ tasks: [] }`
|
|
// (server/internal/handler/daemon.go:1866) so the response object survives
|
|
// future field additions without breaking the cache shape.
|
|
async listActiveTasksForIssue(
|
|
issueId: string,
|
|
opts?: { signal?: AbortSignal },
|
|
): Promise<AgentTask[]> {
|
|
const parsed = await this.fetchValidated(
|
|
`/api/issues/${issueId}/active-task`,
|
|
ActiveTasksResponseSchema,
|
|
EMPTY_ACTIVE_TASKS_RESPONSE,
|
|
{ ...opts, endpoint: "GET /api/issues/:id/active-task" },
|
|
);
|
|
return parsed.tasks;
|
|
}
|
|
|
|
// All tasks (any status) for an issue — drives the "Runs" history section.
|
|
// Path is `/task-runs` (server/cmd/server/router.go:353), NOT `/tasks` —
|
|
// the latter doesn't exist on this scope.
|
|
async listTasksByIssue(
|
|
issueId: string,
|
|
opts?: { signal?: AbortSignal },
|
|
): Promise<AgentTask[]> {
|
|
return this.fetchValidated(
|
|
`/api/issues/${issueId}/task-runs`,
|
|
AgentTaskListSchema,
|
|
EMPTY_AGENT_TASK_LIST,
|
|
{ ...opts, endpoint: "GET /api/issues/:id/task-runs" },
|
|
);
|
|
}
|
|
|
|
async createComment(
|
|
issueId: string,
|
|
content: string,
|
|
opts?: { parentId?: string; type?: string; attachmentIds?: string[] },
|
|
): Promise<Comment> {
|
|
// Body shape mirrors backend `CreateCommentRequest`
|
|
// (server/internal/handler/comment.go:165). `parent_id` is sent only
|
|
// when present so top-level comments don't carry an explicit null.
|
|
// `type` defaults to "comment" matching web client.ts:686.
|
|
return this.fetchValidatedWith(
|
|
`/api/issues/${issueId}/comments`,
|
|
CommentSchema,
|
|
EMPTY_COMMENT,
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
content,
|
|
type: opts?.type ?? "comment",
|
|
...(opts?.parentId ? { parent_id: opts.parentId } : {}),
|
|
...(opts?.attachmentIds ? { attachment_ids: opts.attachmentIds } : {}),
|
|
}),
|
|
},
|
|
{ endpoint: "createComment" },
|
|
);
|
|
}
|
|
|
|
// PUT /api/comments/:id — content edit (+ optional attachment swap).
|
|
async updateComment(
|
|
commentId: string,
|
|
content: string,
|
|
attachmentIds?: string[],
|
|
contentBase?: string,
|
|
): Promise<Comment> {
|
|
return this.fetchValidatedWith(
|
|
`/api/comments/${commentId}`,
|
|
CommentSchema,
|
|
EMPTY_COMMENT,
|
|
{
|
|
method: "PUT",
|
|
body: JSON.stringify(
|
|
buildCommentUpdateBody(content, attachmentIds, contentBase),
|
|
),
|
|
},
|
|
{ endpoint: "updateComment" },
|
|
);
|
|
}
|
|
|
|
// DELETE /api/comments/:id — 204 No Content on success; this.fetch
|
|
// already short-circuits 204 → undefined. `keepReplies` calls the route
|
|
// only servers that keep a deleted comment's replies expose (#8296): if the
|
|
// request reaches an older server it fails instead of deleting the replies
|
|
// too. Pass it only when the server declared
|
|
// `comment_delete_keep_replies_supported`. Mirrors packages/core/api/client.ts.
|
|
async deleteComment(
|
|
commentId: string,
|
|
opts: { keepReplies?: boolean } = {},
|
|
): Promise<void> {
|
|
const path = opts.keepReplies === true
|
|
? `/api/comments/${commentId}/keep-replies`
|
|
: `/api/comments/${commentId}`;
|
|
await this.fetch<void>(path, { method: "DELETE" });
|
|
}
|
|
|
|
// POST /api/comments/:id/resolve — marks the thread root resolved; only
|
|
// meaningful for root comments. Backend mirrors web semantics.
|
|
async resolveComment(commentId: string): Promise<Comment> {
|
|
return this.fetchValidatedWith(
|
|
`/api/comments/${commentId}/resolve`,
|
|
CommentSchema,
|
|
EMPTY_COMMENT,
|
|
{ method: "POST" },
|
|
{ endpoint: "resolveComment" },
|
|
);
|
|
}
|
|
|
|
// DELETE /api/comments/:id/resolve — un-resolves the thread.
|
|
async unresolveComment(commentId: string): Promise<Comment> {
|
|
return this.fetchValidatedWith(
|
|
`/api/comments/${commentId}/resolve`,
|
|
CommentSchema,
|
|
EMPTY_COMMENT,
|
|
{ method: "DELETE" },
|
|
{ endpoint: "unresolveComment" },
|
|
);
|
|
}
|
|
|
|
// --- Reactions ---
|
|
// Comment reactions: POST/DELETE /api/comments/{id}/reactions
|
|
// Issue reactions: POST/DELETE /api/issues/{id}/reactions
|
|
// Mirror surface from packages/core/api/client.ts:541-573.
|
|
async addReaction(commentId: string, emoji: string): Promise<Reaction> {
|
|
return this.fetch<Reaction>(`/api/comments/${commentId}/reactions`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ emoji }),
|
|
});
|
|
}
|
|
|
|
async removeReaction(commentId: string, emoji: string): Promise<void> {
|
|
await this.fetch<void>(`/api/comments/${commentId}/reactions`, {
|
|
method: "DELETE",
|
|
body: JSON.stringify({ emoji }),
|
|
});
|
|
}
|
|
|
|
async addIssueReaction(
|
|
issueId: string,
|
|
emoji: string,
|
|
): Promise<IssueReaction> {
|
|
return this.fetch<IssueReaction>(`/api/issues/${issueId}/reactions`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ emoji }),
|
|
});
|
|
}
|
|
|
|
async removeIssueReaction(issueId: string, emoji: string): Promise<void> {
|
|
await this.fetch<void>(`/api/issues/${issueId}/reactions`, {
|
|
method: "DELETE",
|
|
body: JSON.stringify({ emoji }),
|
|
});
|
|
}
|
|
|
|
// --- Issue update ---
|
|
// Write endpoint — the mutation surface handles errors via rollback, so
|
|
// we let bad responses surface naturally (no parseWithFallback).
|
|
// Method is PUT to match backend router (server/cmd/server/router.go:327)
|
|
// and web client (packages/core/api/client.ts:465).
|
|
async updateIssue(id: string, body: UpdateIssueRequest): Promise<Issue> {
|
|
return this.fetch<Issue>(`/api/issues/${id}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
// Backend returns 204 No Content on success
|
|
// (server/internal/handler/issue.go DeleteIssue). this.fetch already
|
|
// short-circuits 204 → undefined (api.ts:270), so no body parsing needed.
|
|
async deleteIssue(id: string): Promise<void> {
|
|
await this.fetch<void>(`/api/issues/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
// --- Labels ---
|
|
async listLabels(opts?: {
|
|
signal?: AbortSignal;
|
|
}): Promise<ListLabelsResponse> {
|
|
const raw = await this.fetch<unknown>("/api/labels", {
|
|
signal: opts?.signal,
|
|
});
|
|
return parseWithFallback(
|
|
raw,
|
|
ListLabelsResponseSchema,
|
|
EMPTY_LIST_LABELS_RESPONSE,
|
|
{ endpoint: "GET /api/labels" },
|
|
);
|
|
}
|
|
|
|
// Create a new label and return it. Response is consumed by the
|
|
// create-and-attach flow in label picker, so raw `this.fetch<Label>` is
|
|
// used — same convention as createProject (cache rollback on failure is
|
|
// preferable to a parseWithFallback fallback that would mask server errors).
|
|
async createLabel(body: CreateLabelRequest): Promise<Label> {
|
|
return this.fetch<Label>("/api/labels", {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
async attachLabel(
|
|
issueId: string,
|
|
labelId: string,
|
|
): Promise<IssueLabelsResponse> {
|
|
return this.fetch<IssueLabelsResponse>(
|
|
`/api/issues/${issueId}/labels`,
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify({ label_id: labelId }),
|
|
},
|
|
);
|
|
}
|
|
|
|
async detachLabel(
|
|
issueId: string,
|
|
labelId: string,
|
|
): Promise<IssueLabelsResponse> {
|
|
return this.fetch<IssueLabelsResponse>(
|
|
`/api/issues/${issueId}/labels/${labelId}`,
|
|
{ method: "DELETE" },
|
|
);
|
|
}
|
|
|
|
// --- Issue status catalog (MUL-6243) ---
|
|
/**
|
|
* The workspace's issue statuses — the 7 built-ins plus any custom ones an
|
|
* admin defined. Reads are open to every workspace member; the catalog
|
|
* mutations are owner/admin only and live on web's settings screen, which is
|
|
* why mobile ships the read alone.
|
|
*
|
|
* `include_archived` is on by design. Archiving retires a status from FUTURE
|
|
* assignment but leaves the issues already on it, and those issues must keep
|
|
* their real name, colour and category — dropping archived rows here would
|
|
* degrade them to a raw key with a guessed category. Pickers filter them out
|
|
* via `IssueStatusCatalog.activeStatuses` instead.
|
|
*/
|
|
async listIssueStatuses(
|
|
includeArchived = false,
|
|
opts?: { signal?: AbortSignal },
|
|
): Promise<ListIssueStatusesResponse> {
|
|
const query = includeArchived ? "?include_archived=true" : "";
|
|
return this.fetchValidated(
|
|
`/api/issue-statuses${query}`,
|
|
ListIssueStatusesResponseSchema,
|
|
EMPTY_LIST_ISSUE_STATUSES_RESPONSE,
|
|
{ ...opts, endpoint: "GET /api/issue-statuses" },
|
|
);
|
|
}
|
|
|
|
// --- Projects ---
|
|
async listProjects(opts?: {
|
|
signal?: AbortSignal;
|
|
}): Promise<ListProjectsResponse> {
|
|
const raw = await this.fetch<unknown>("/api/projects", {
|
|
signal: opts?.signal,
|
|
});
|
|
return parseWithFallback(
|
|
raw,
|
|
ListProjectsResponseSchema,
|
|
EMPTY_LIST_PROJECTS_RESPONSE,
|
|
{ endpoint: "GET /api/projects" },
|
|
);
|
|
}
|
|
|
|
/** Workspace-wide project search. See `searchIssues` for the signal
|
|
* contract. */
|
|
async searchProjects(
|
|
params: { q: string; limit?: number; include_closed?: boolean; offset?: number },
|
|
opts?: { signal?: AbortSignal },
|
|
): Promise<SearchProjectsResponse> {
|
|
const search = new URLSearchParams();
|
|
for (const [k, v] of Object.entries(params)) {
|
|
if (v == null) continue;
|
|
search.set(k, String(v));
|
|
}
|
|
const raw = await this.fetch<unknown>(
|
|
`/api/projects/search?${search.toString()}`,
|
|
{ signal: opts?.signal },
|
|
);
|
|
return parseWithFallback(
|
|
raw,
|
|
SearchProjectsResponseSchema,
|
|
EMPTY_SEARCH_PROJECTS_RESPONSE,
|
|
{ endpoint: "GET /api/projects/search" },
|
|
);
|
|
}
|
|
|
|
async getProject(
|
|
id: string,
|
|
opts?: { signal?: AbortSignal },
|
|
): Promise<Project> {
|
|
const raw = await this.fetch<unknown>(`/api/projects/${id}`, {
|
|
signal: opts?.signal,
|
|
});
|
|
// Drift-safe parse — UI checks `data.id === ""` to render the
|
|
// "project not found / shape drifted" error state instead of a
|
|
// half-populated detail page.
|
|
return parseWithFallback(raw, ProjectSchema, EMPTY_PROJECT, {
|
|
endpoint: "GET /api/projects/:id",
|
|
});
|
|
}
|
|
|
|
// Write endpoints — no parseWithFallback (mirrors updateIssue:430). A
|
|
// malformed write response surfaces as an error so the optimistic
|
|
// patch rolls back; pretending the write succeeded with empty data
|
|
// would silently desync caches.
|
|
async createProject(body: CreateProjectRequest): Promise<Project> {
|
|
return this.fetch<Project>("/api/projects", {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
async updateProject(
|
|
id: string,
|
|
body: UpdateProjectRequest,
|
|
): Promise<Project> {
|
|
return this.fetch<Project>(`/api/projects/${id}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
async deleteProject(id: string): Promise<void> {
|
|
await this.fetch<void>(`/api/projects/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
// --- Project resources ---
|
|
async listProjectResources(
|
|
projectId: string,
|
|
opts?: { signal?: AbortSignal },
|
|
): Promise<ListProjectResourcesResponse> {
|
|
const raw = await this.fetch<unknown>(
|
|
`/api/projects/${projectId}/resources`,
|
|
{ signal: opts?.signal },
|
|
);
|
|
return parseWithFallback(
|
|
raw,
|
|
ListProjectResourcesResponseSchema,
|
|
EMPTY_LIST_PROJECT_RESOURCES_RESPONSE,
|
|
{ endpoint: "GET /api/projects/:id/resources" },
|
|
);
|
|
}
|
|
|
|
async createProjectResource(
|
|
projectId: string,
|
|
body: CreateProjectResourceRequest,
|
|
): Promise<ProjectResource> {
|
|
return this.fetch<ProjectResource>(
|
|
`/api/projects/${projectId}/resources`,
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
},
|
|
);
|
|
}
|
|
|
|
async deleteProjectResource(
|
|
projectId: string,
|
|
resourceId: string,
|
|
): Promise<void> {
|
|
await this.fetch<void>(
|
|
`/api/projects/${projectId}/resources/${resourceId}`,
|
|
{ method: "DELETE" },
|
|
);
|
|
}
|
|
|
|
// --- Chat ---
|
|
// Mirrors the surface area of packages/core/api/client.ts chat methods.
|
|
// v1 omits getChatSession + updateChatSession (rename) — see the v1 cut
|
|
// list in /Users/qingnaiyuan/.claude/plans/plan-velvety-puddle.md.
|
|
|
|
async listChatSessions(
|
|
opts?: { signal?: AbortSignal },
|
|
): Promise<ChatSession[]> {
|
|
const raw = await this.fetch<unknown>("/api/chat/sessions", {
|
|
signal: opts?.signal,
|
|
});
|
|
return parseWithFallback(
|
|
raw,
|
|
ChatSessionListSchema,
|
|
EMPTY_CHAT_SESSION_LIST,
|
|
{ endpoint: "GET /api/chat/sessions" },
|
|
);
|
|
}
|
|
|
|
async createChatSession(
|
|
data: { agent_id: string; title?: string },
|
|
): Promise<ChatSession> {
|
|
// Strict parse — a malformed create response derails the optimistic
|
|
// burst (we need the new session id to seed caches). Fallback would
|
|
// be worse than the throw.
|
|
const raw = await this.fetch<unknown>("/api/chat/sessions", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
const parsed = ChatSessionSchema.safeParse(raw);
|
|
if (!parsed.success) {
|
|
console.error("[api] ← shape mismatch POST /api/chat/sessions", {
|
|
issues: parsed.error.issues,
|
|
});
|
|
throw new ApiError("Create chat session response invalid", 0, raw);
|
|
}
|
|
return parsed.data;
|
|
}
|
|
|
|
async deleteChatSession(id: string): Promise<void> {
|
|
await this.fetch<void>(`/api/chat/sessions/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
async listChatMessages(
|
|
sessionId: string,
|
|
opts?: { signal?: AbortSignal },
|
|
): Promise<ChatMessage[]> {
|
|
const raw = await this.fetch<unknown>(
|
|
`/api/chat/sessions/${sessionId}/messages`,
|
|
{ signal: opts?.signal },
|
|
);
|
|
return parseWithFallback(
|
|
raw,
|
|
ChatMessageListSchema,
|
|
EMPTY_CHAT_MESSAGE_LIST,
|
|
{ endpoint: "GET /api/chat/sessions/:id/messages" },
|
|
);
|
|
}
|
|
|
|
async sendChatMessage(
|
|
sessionId: string,
|
|
content: string,
|
|
opts?: { attachmentIds?: string[] },
|
|
): Promise<SendChatMessageResponse> {
|
|
// Strict parse — we need task_id + created_at to anchor the optimistic
|
|
// StatusPill. Fallback would silently break the elapsed-time timer.
|
|
//
|
|
// `attachment_ids` mirrors the comment / issue create payloads —
|
|
// server-side `chat.go` back-fills `chat_message_id` on the listed
|
|
// attachments after the message row is inserted (see
|
|
// server/internal/handler/chat.go:410-456).
|
|
const body: { content: string; attachment_ids?: string[] } = { content };
|
|
if (opts?.attachmentIds && opts.attachmentIds.length > 0) {
|
|
body.attachment_ids = opts.attachmentIds;
|
|
}
|
|
const raw = await this.fetch<unknown>(
|
|
`/api/chat/sessions/${sessionId}/messages`,
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
},
|
|
);
|
|
const parsed = SendChatMessageResponseSchema.safeParse(raw);
|
|
if (!parsed.success) {
|
|
console.error("[api] ← shape mismatch POST /api/chat/sessions/:id/messages", {
|
|
issues: parsed.error.issues,
|
|
});
|
|
throw new ApiError("Send message response invalid", 0, raw);
|
|
}
|
|
return parsed.data;
|
|
}
|
|
|
|
async getPendingChatTask(
|
|
sessionId: string,
|
|
opts?: { signal?: AbortSignal },
|
|
): Promise<ChatPendingTask> {
|
|
const raw = await this.fetch<unknown>(
|
|
`/api/chat/sessions/${sessionId}/pending-task`,
|
|
{ signal: opts?.signal },
|
|
);
|
|
return parseWithFallback(
|
|
raw,
|
|
ChatPendingTaskSchema,
|
|
EMPTY_CHAT_PENDING_TASK,
|
|
{ endpoint: "GET /api/chat/sessions/:id/pending-task" },
|
|
);
|
|
}
|
|
|
|
async markChatSessionRead(sessionId: string): Promise<void> {
|
|
await this.fetch<void>(
|
|
`/api/chat/sessions/${sessionId}/read`,
|
|
{ method: "POST" },
|
|
);
|
|
}
|
|
|
|
async cancelTaskById(taskId: string): Promise<void> {
|
|
await this.fetch<void>(`/api/tasks/${taskId}/cancel`, { method: "POST" });
|
|
}
|
|
|
|
/** Live execution timeline for a task — used by the chat screen to
|
|
* render the "thinking → tool_use → tool_result → final text" trace
|
|
* beneath an in-flight assistant bubble. `task:message` WS events
|
|
* append to the same cache key in real time (see
|
|
* use-chat-session-realtime.ts). */
|
|
async listTaskMessages(
|
|
taskId: string,
|
|
opts?: { signal?: AbortSignal },
|
|
): Promise<TaskMessagePayload[]> {
|
|
return this.fetchValidated(
|
|
`/api/tasks/${taskId}/messages`,
|
|
TaskMessageListSchema,
|
|
EMPTY_TASK_MESSAGE_LIST,
|
|
{ ...opts, endpoint: "GET /api/tasks/:id/messages" },
|
|
);
|
|
}
|
|
|
|
// --- Pins ---
|
|
//
|
|
// Pin metadata only — title / status / icon for each row come from
|
|
// `issueDetailOptions` / `projectDetailOptions` on the consumer side.
|
|
// Endpoints mirror packages/core/api/client.ts:1551-1572.
|
|
|
|
async listPins(opts?: { signal?: AbortSignal }): Promise<PinnedItem[]> {
|
|
return this.fetchValidated(
|
|
"/api/pins",
|
|
PinListSchema,
|
|
EMPTY_PIN_LIST,
|
|
{ ...opts, endpoint: "listPins" },
|
|
);
|
|
}
|
|
|
|
async createPin(data: {
|
|
item_type: PinnedItemType;
|
|
item_id: string;
|
|
}): Promise<PinnedItem> {
|
|
return this.fetchValidatedWith(
|
|
"/api/pins",
|
|
PinnedItemSchema,
|
|
// Mirror EMPTY_PIN_LIST element shape — onSuccess uses the returned
|
|
// pin's id/position so a stub with empty id is detectable downstream.
|
|
{
|
|
id: "",
|
|
workspace_id: "",
|
|
user_id: "",
|
|
item_type: data.item_type,
|
|
item_id: data.item_id,
|
|
position: 0,
|
|
created_at: "",
|
|
},
|
|
{ method: "POST", body: JSON.stringify(data) },
|
|
{ endpoint: "createPin" },
|
|
);
|
|
}
|
|
|
|
async deletePin(itemType: PinnedItemType, itemId: string): Promise<void> {
|
|
await this.fetch<void>(`/api/pins/${itemType}/${itemId}`, {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
async reorderPins(data: ReorderPinsRequest): Promise<void> {
|
|
await this.fetch<void>("/api/pins/reorder", {
|
|
method: "PUT",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
// --- File Upload ---
|
|
|
|
/**
|
|
* Multipart-stream a file to `/api/upload-file`. Mirrors the web
|
|
* implementation in `packages/core/api/client.ts:uploadFile` but with the
|
|
* RN-shaped `FileAsset` instead of a browser `File`. The fetch FormData
|
|
* polyfill recognises `{ uri, name, type }` and reads the file off disk.
|
|
*
|
|
* `opts.issueId` / `opts.commentId` link the attachment record. Pass
|
|
* `issueId` when uploading from a comment composer / reply input; leave
|
|
* both empty when uploading from a not-yet-created issue (the attachment
|
|
* is hooked to the issue once it's created — same flow as web).
|
|
*
|
|
* Does NOT use `this.fetch` because:
|
|
* - FormData must not have a `Content-Type` header preset (the browser /
|
|
* RN fetch needs to set the multipart boundary itself).
|
|
* - `this.fetch` hard-codes `application/json`.
|
|
*
|
|
* So we re-implement the auth + slug + logging shell inline.
|
|
*/
|
|
async uploadFile(
|
|
asset: FileAsset,
|
|
opts?: { issueId?: string; commentId?: string },
|
|
): Promise<Attachment> {
|
|
const rid = createRequestId();
|
|
const start = Date.now();
|
|
const path = "/api/upload-file";
|
|
|
|
const headers: Record<string, string> = {
|
|
// No Content-Type — let fetch set the multipart boundary.
|
|
"X-Client-Platform": "mobile",
|
|
"X-Client-OS": "ios",
|
|
"X-Client-Version": "0.1.0",
|
|
"X-Request-ID": rid,
|
|
};
|
|
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
|
|
const slug = getCurrentSlug();
|
|
if (slug) headers["X-Workspace-Slug"] = slug;
|
|
|
|
const formData = new FormData();
|
|
// RN's FormData accepts `{ uri, name, type }` as the file value.
|
|
// `as never` quiets TS (the global FormData type expects `Blob | string`).
|
|
formData.append(
|
|
"file",
|
|
{ uri: asset.uri, name: asset.name, type: asset.type } as never,
|
|
);
|
|
if (opts?.issueId) formData.append("issue_id", opts.issueId);
|
|
if (opts?.commentId) formData.append("comment_id", opts.commentId);
|
|
|
|
console.log(`[api] → POST ${path}`, { rid, filename: asset.name });
|
|
|
|
const res = await fetch(`${API_URL}${path}`, {
|
|
method: "POST",
|
|
headers,
|
|
body: formData,
|
|
});
|
|
const duration = Date.now() - start;
|
|
|
|
if (!res.ok) {
|
|
if (res.status === 401) this.options.onUnauthorized?.();
|
|
let body: unknown;
|
|
try {
|
|
body = await res.json();
|
|
} catch {
|
|
body = undefined;
|
|
}
|
|
const message =
|
|
(body && typeof body === "object" && "message" in body
|
|
? String((body as { message: unknown }).message)
|
|
: null) ?? `Upload failed: ${res.status}`;
|
|
console.error(`[api] ← ${res.status} ${path}`, {
|
|
rid,
|
|
duration: `${duration}ms`,
|
|
error: message,
|
|
});
|
|
throw new ApiError(message, res.status, body);
|
|
}
|
|
|
|
console.log(`[api] ← ${res.status} ${path}`, {
|
|
rid,
|
|
duration: `${duration}ms`,
|
|
});
|
|
|
|
// Strict validation: parseWithFallback's silent-fallback pattern doesn't
|
|
// fit here — an attachment without a `url` would be inserted into the
|
|
// user's text as ``. Throw on shape mismatch so the
|
|
// caller's Alert path fires instead of letting a broken link land in
|
|
// the editor.
|
|
const json: unknown = await res.json();
|
|
const parsed = AttachmentSchema.safeParse(json);
|
|
if (!parsed.success) {
|
|
console.error(`[api] ← shape mismatch ${path}`, {
|
|
rid,
|
|
error: parsed.error.message,
|
|
});
|
|
throw new ApiError("Upload response invalid", res.status, json);
|
|
}
|
|
return parsed.data;
|
|
}
|
|
}
|
|
|
|
export { MAX_FILE_SIZE };
|
|
|
|
export const api = new ApiClient();
|