// Thin client for the PiCloud control-plane and data-plane APIs. // // The dashboard primarily targets `/api/v1/admin/*` (manager). The // data-plane (`/api/v1/execute/*`, orchestrator) is reachable through // the same Caddy upstream so the "Test invoke" panel can hit it // without any cross-origin gymnastics. import { goto } from '$app/navigation'; import { base } from '$app/paths'; import { browser } from '$app/environment'; import { clearSession, getToken, setSession, type InstanceRole } from './auth'; export type { InstanceRole }; export interface ScriptSandbox { max_operations?: number; max_string_size?: number; max_array_size?: number; max_map_size?: number; max_call_levels?: number; max_expr_depth?: number; } export type ScriptKind = 'endpoint' | 'module'; export interface Script { id: string; app_id: string; name: string; description: string | null; version: number; source: string; /** v1.1.3 — 'endpoint' (default) handles routes/triggers; 'module' is imported by other scripts. */ kind: ScriptKind; timeout_seconds: number; memory_limit_mb: number; sandbox: ScriptSandbox; created_at: string; updated_at: string; } export interface App { id: string; slug: string; name: string; description: string | null; created_at: string; updated_at: string; } export type AppRole = 'app_admin' | 'editor' | 'viewer'; export type DomainShape = 'exact' | 'wildcard' | 'parameterized'; export interface AppDomain { id: string; app_id: string; pattern: string; shape: DomainShape; shape_key: string; created_at: string; } export interface AppLookupResponse { id: string; slug: string; name: string; description: string | null; created_at: string; updated_at: string; /// Present only when the requested slug was a retired redirect. redirect_to?: string; /// The caller's role on this app — owners are implicit `app_admin`, /// admins implicit `editor`, members carry their `app_members.role`. /// `null` only when a member somehow reaches the endpoint without /// a membership (the server normally 403s first). my_role: AppRole | null; } export interface SlugCheckResponse { ok: boolean; conflict_kind: 'current' | 'historical' | 'invalid' | 'reserved' | null; current_app: App | null; reason: string | null; } export interface CreateAppInput { slug: string; name: string; description?: string | null; force_takeover?: boolean; } export interface PatchAppInput { name?: string; description?: string | null; slug?: string; force_takeover?: boolean; } export type HostKind = 'any' | 'strict' | 'wildcard'; export type PathKind = 'exact' | 'prefix' | 'param'; export interface Route { id: string; app_id: string; script_id: string; host_kind: HostKind; host: string; host_param_name: string | null; path_kind: PathKind; path: string; method: string | null; created_at: string; } export interface RouteInput { host_kind: HostKind; host?: string; host_param_name?: string | null; path_kind: PathKind; path: string; method?: string | null; } export interface CheckRouteResponse { ok: boolean; conflicting_route: Route | null; conflict_reason: string | null; } export interface MatchRouteResponse { matched: null | { route_id: string; script_id: string; params: Record; rest: string | null; host_param: [string, string] | null; }; } export interface VersionInfo { product: string; sdk: string; api: number; schema: number; wire: number; public_base_url: string; } export type ExecutionStatus = 'success' | 'error' | 'timeout' | 'budget_exceeded'; export interface ScriptLogEntry { timestamp: string; level: 'trace' | 'info' | 'warn' | 'error'; message: string; data: unknown; } export interface ExecutionLog { id: string; script_id: string; request_id: string; request_path: string; request_headers: Record; request_body: unknown; response_code: number | null; response_body: unknown; script_logs: ScriptLogEntry[]; duration_ms: number; status: ExecutionStatus; created_at: string; } export interface CreateScriptInput { app_id: string; name: string; description?: string | null; source: string; /** Defaults to 'endpoint' server-side if omitted. v1.1.3. */ kind?: ScriptKind; timeout_seconds?: number; memory_limit_mb?: number; } export interface UpdateScriptInput { name?: string; description?: string | null; source?: string; timeout_seconds?: number; memory_limit_mb?: number; sandbox?: ScriptSandbox; /** v1.1.3 — endpoint→module rejected if routes/triggers reference the script. */ kind?: ScriptKind; } export interface DeadLetterRow { id: string; app_id: string; source: string; op: string; trigger_id: string | null; script_id: string | null; payload: unknown; attempt_count: number; first_attempt_at: string; last_attempt_at: string; last_error: string; created_at: string; resolved_at: string | null; resolution: 'replayed' | 'ignored' | 'handled_by_script' | 'handler_failed' | null; } export interface ExecutionResult { status: number; headers: Record; body: unknown; } export class ApiError extends Error { constructor( public readonly status: number, message: string, public readonly body: unknown ) { super(message); } } async function adminRequest(path: string, init?: RequestInit): Promise { const headers: Record = { 'content-type': 'application/json', ...((init?.headers as Record) ?? {}) }; const tok = getToken(); if (tok && !headers['authorization']) { headers['authorization'] = `Bearer ${tok}`; } const res = await fetch(path, { ...init, headers }); const text = await res.text(); const parsed: unknown = text ? safeJson(text) : null; if (res.status === 401) { // Token gone stale or never present. Drop any cached session // and bounce to login — unless we're already on it, in which // case throw and let the login form render the error. clearSession(); if (browser && !window.location.pathname.endsWith('/login')) { void goto(`${base}/login`); } } if (!res.ok) { const message = (parsed && typeof parsed === 'object' && 'error' in parsed ? String((parsed as { error: unknown }).error) : text) || `${res.status} ${res.statusText}`; throw new ApiError(res.status, message, parsed); } return parsed as T; } function safeJson(text: string): unknown { try { return JSON.parse(text) as unknown; } catch { return text; } } export type Scope = | 'script:read' | 'script:write' | 'route:write' | 'domain:manage' | 'log:read' | 'app:admin' | 'instance:admin'; export const ALL_SCOPES: readonly Scope[] = [ 'script:read', 'script:write', 'route:write', 'domain:manage', 'log:read', 'app:admin', 'instance:admin' ] as const; export function isInstanceScope(s: Scope): boolean { return s.startsWith('instance:'); } export interface MeDto { id: string; username: string; instance_role: InstanceRole; email: string | null; } export interface AdminDto { id: string; username: string; is_active: boolean; instance_role: InstanceRole; email: string | null; created_at: string; last_login_at: string | null; } export interface CreateAdminInput { username: string; password: string; instance_role?: InstanceRole; email?: string | null; } export interface PatchAdminInput { username?: string; password?: string; is_active?: boolean; instance_role?: InstanceRole; email?: string | null; } export interface AppMemberDto { user_id: string; username: string; email: string | null; instance_role: InstanceRole; is_active: boolean; role: AppRole; created_at: string; } export interface GrantAppMemberInput { user_id: string; role: AppRole; } export interface ApiKeyDto { id: string; prefix: string; name: string; scopes: Scope[]; app_id: string | null; expires_at: string | null; last_used_at: string | null; created_at: string; } export interface MintApiKeyInput { name: string; scopes: Scope[]; app_id?: string | null; expires_at?: string | null; } export interface MintApiKeyResponse extends ApiKeyDto { raw_token: string; } interface LoginResponse { user: MeDto; token: string; expires_at: string; } export const api = { health: () => fetch('/healthz').then((r) => r.text()), version: () => adminRequest('/version'), auth: { login: async (username: string, password: string): Promise => { const r = await adminRequest('/api/v1/admin/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }); setSession(r.user, r.token); return r.user; }, logout: async (): Promise => { try { await adminRequest('/api/v1/admin/auth/logout', { method: 'POST' }); } finally { // Always clear locally — logout is idempotent server-side // and we don't want a network blip to strand the SPA in // a "logged out on server, still logged in client-side" // state. clearSession(); } }, me: () => adminRequest('/api/v1/admin/auth/me') }, admins: { list: () => adminRequest('/api/v1/admin/admins'), get: (id: string) => adminRequest(`/api/v1/admin/admins/${id}`), create: (input: CreateAdminInput) => adminRequest('/api/v1/admin/admins', { method: 'POST', body: JSON.stringify(input) }), update: (id: string, input: PatchAdminInput) => adminRequest(`/api/v1/admin/admins/${id}`, { method: 'PATCH', body: JSON.stringify(input) }), remove: (id: string) => adminRequest(`/api/v1/admin/admins/${id}`, { method: 'DELETE' }) }, apiKeys: { list: () => adminRequest('/api/v1/admin/api-keys'), mint: (input: MintApiKeyInput) => adminRequest('/api/v1/admin/api-keys', { method: 'POST', body: JSON.stringify(input) }), revoke: (id: string) => adminRequest(`/api/v1/admin/api-keys/${id}`, { method: 'DELETE' }) }, routes: { listForScript: (scriptId: string) => adminRequest(`/api/v1/admin/scripts/${scriptId}/routes`), create: (scriptId: string, input: RouteInput) => adminRequest(`/api/v1/admin/scripts/${scriptId}/routes`, { method: 'POST', body: JSON.stringify(input) }), remove: (routeId: string) => adminRequest(`/api/v1/admin/routes/${routeId}`, { method: 'DELETE' }), check: (appId: string, input: RouteInput) => adminRequest('/api/v1/admin/routes:check', { method: 'POST', body: JSON.stringify({ ...input, app_id: appId }) }), match: (appId: string, url: string, method = 'GET') => adminRequest('/api/v1/admin/routes:match', { method: 'POST', body: JSON.stringify({ app_id: appId, url, method }) }) }, scripts: { list: (opts: { app?: string } = {}) => { const qs = opts.app ? `?app=${encodeURIComponent(opts.app)}` : ''; return adminRequest(`/api/v1/admin/scripts${qs}`); }, get: (id: string) => adminRequest