// 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 interface Group { id: string; parent_id: string | null; slug: string; name: string; description: string | null; structure_version: number; created_at: string; updated_at: string; } export interface GroupDetail extends Group { /** Root → … → this node breadcrumb. */ path: Group[]; subgroups: Group[]; apps: App[]; } export interface GroupMember { user_id: string; username: string; email: string | null; instance_role: InstanceRole; is_active: boolean; role: AppRole; created_at: string; } export interface CreateGroupInput { slug: string; name: string; description?: string | null; /** Parent group slug or id; omit (or null) for a root group. */ parent?: string | null; } export interface PatchGroupInput { name?: string; description?: string | null; } 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; /** Parent group slug or id; omit for the root group. */ group?: string | null; } 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 type DispatchMode = 'sync' | 'async'; 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; dispatch_mode: DispatchMode; created_at: string; } export interface RouteInput { host_kind: HostKind; host?: string; host_param_name?: string | null; path_kind: PathKind; path: string; method?: string | null; dispatch_mode?: DispatchMode; } 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; } /** A2: one `(key, count)` row of a metrics breakdown. */ export interface MetricsCount { key: string; count: number; } /** A2: one hour-bucket of the metrics time series. */ export interface MetricsBucket { ts: string; total: number; errors: number; } /** A2: the observability rollup for one app over a trailing window. */ export interface MetricsSummary { window_hours: number; total: number; errors: number; error_rate: number; avg_duration_ms: number; p50_duration_ms: number; p95_duration_ms: number; by_status: MetricsCount[]; by_source: MetricsCount[]; series: MetricsBucket[]; } 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 type TriggerKind = | 'kv' | 'docs' | 'dead_letter' | 'cron' | 'files' | 'pubsub' | 'email' | 'queue'; export type TriggerDispatchMode = 'sync' | 'async'; /// Per-kind detail, tagged by `kind` to match the Rust serde shape. export type TriggerDetails = | { kind: 'kv'; collection_glob: string; ops: string[] } | { kind: 'docs'; collection_glob: string; ops: string[] } | { kind: 'dead_letter'; source_filter?: string; trigger_id_filter?: string; script_id_filter?: string } | { kind: 'cron'; schedule: string; timezone: string; last_fired_at?: string | null } | { kind: 'files'; collection_glob: string; ops: string[] } | { kind: 'pubsub'; topic_pattern: string } | { kind: 'email'; has_inbound_secret: boolean } | { kind: 'queue'; queue_name: string; visibility_timeout_secs: number; last_fired_at?: string | null }; export interface CreateEmailTriggerInput { script_id: string; /// Shared HMAC secret; null/omitted means the receiver accepts /// unsigned POSTs (URL secrecy is then the only guard). inbound_secret?: string | null; } /// v1.1.8 per-app end-user record returned by the admin users API. /// Never carries the password hash or session tokens. export interface AppUser { id: string; app_id: string; email: string; display_name: string | null; email_verified_at: string | null; last_login_at: string | null; created_at: string; updated_at: string; roles: string[]; } export interface CreateAppUserInput { email: string; password: string; display_name?: string | null; } export interface PatchAppUserInput { /// Triple-state: present-with-string sets, present-with-null clears, /// absent leaves alone. The serde shape on the server is the same. display_name?: string | null; } export interface ResetPasswordResponse { token: string; expires_in_seconds: number; } export interface RevokeSessionsResponse { revoked: number; } /// v1.1.8 pending-invitation row. export interface Invitation { id: string; app_id: string; email: string; display_name: string | null; roles: string[]; created_at: string; expires_at: string; } export interface InvitationTemplate { link_base: string; from: string; subject: string; body_template: string; } export interface CreateInvitationInput { email: string; display_name?: string | null; roles?: string[]; /// Optional. Omit to issue an invitation without sending an email /// (the admin delivers the token out-of-band). template?: InvitationTemplate; } /// v1.1.5 file metadata as the admin files endpoint returns it. export interface FileMeta { id: string; collection: string; name: string; content_type: string; size: number; checksum: string; created_at: string; updated_at: string; } export interface Trigger { id: string; app_id: string; script_id: string; kind: TriggerKind; enabled: boolean; dispatch_mode: TriggerDispatchMode; retry_max_attempts: number; retry_backoff: 'exponential' | 'linear' | 'constant'; retry_base_ms: number; registered_by_principal: string; created_at: string; updated_at: string; details: TriggerDetails; } export interface CreateCronTriggerInput { script_id: string; schedule: string; timezone: string; dispatch_mode?: TriggerDispatchMode; retry_max_attempts?: number; retry_backoff?: 'exponential' | 'linear' | 'constant'; retry_base_ms?: number; } export interface CreatePubsubTriggerInput { script_id: string; topic_pattern: string; dispatch_mode?: TriggerDispatchMode; retry_max_attempts?: number; retry_backoff?: 'exponential' | 'linear' | 'constant'; retry_base_ms?: number; } // F-U-001: dashboard create surfaces for the four data-plane triggers. export interface CreateKvTriggerInput { script_id: string; collection_glob: string; ops?: Array<'insert' | 'update' | 'delete'>; dispatch_mode?: TriggerDispatchMode; retry_max_attempts?: number; retry_backoff?: 'exponential' | 'linear' | 'constant'; retry_base_ms?: number; } export interface CreateDocsTriggerInput { script_id: string; collection_glob: string; ops?: Array<'create' | 'update' | 'delete'>; dispatch_mode?: TriggerDispatchMode; retry_max_attempts?: number; retry_backoff?: 'exponential' | 'linear' | 'constant'; retry_base_ms?: number; } export interface CreateFilesTriggerInput { script_id: string; collection_glob: string; ops?: Array<'create' | 'update' | 'delete'>; dispatch_mode?: TriggerDispatchMode; retry_max_attempts?: number; retry_backoff?: 'exponential' | 'linear' | 'constant'; retry_base_ms?: number; } export interface CreateDeadLetterTriggerInput { script_id: string; source_filter?: string | null; trigger_id_filter?: string | null; script_id_filter?: string | null; } // v1.1.9 — queue:receive trigger. export interface CreateQueueTriggerInput { script_id: string; queue_name: string; visibility_timeout_secs?: number; dispatch_mode?: TriggerDispatchMode; retry_max_attempts?: number; retry_backoff?: 'exponential' | 'linear' | 'constant'; retry_base_ms?: number; } // v1.2 Workflows — read/run types (admin workflows API). export interface WorkflowSummary { name: string; enabled: boolean; steps: number; } export type WorkflowRunStatus = | 'pending' | 'running' | 'succeeded' | 'failed' | 'canceled'; export interface WorkflowRun { id: string; workflow_id?: string; status: WorkflowRunStatus; error?: string | null; workflow_depth: number; created_at: string; input?: unknown; output?: unknown; started_at?: string | null; finished_at?: string | null; } export type WorkflowStepStatus = | 'pending' | 'ready' | 'running' | 'succeeded' | 'failed' | 'skipped'; export interface WorkflowRunStep { name: string; status: WorkflowStepStatus; attempt: number; max_attempts: number; error?: string | null; child_run_id?: string | null; depends_on: string[]; output?: unknown; } export interface WorkflowRunDetail { run: WorkflowRun; steps: WorkflowRunStep[]; } // v1.1.9 — read-only queue inspection types (admin queues API). export interface QueueSummary { queue_name: string; total: number; pending: number; claimed: number; } export interface QueueConsumer { trigger_id: string; script_id: string; script_name: string; visibility_timeout_secs: number; last_fired_at: string | null; } export interface QueueDetail extends QueueSummary { consumer: QueueConsumer | null; } // v1.1.6 — externally-subscribable realtime topics. export type TopicAuthMode = 'public' | 'token' | 'session'; export interface Topic { name: string; external_subscribable: boolean; auth_mode: TopicAuthMode; created_at: string; updated_at: string; } export interface CreateTopicInput { name: string; external_subscribable: boolean; auth_mode: TopicAuthMode; } export interface UpdateTopicInput { external_subscribable?: boolean; auth_mode?: TopicAuthMode; } export interface SecretListItem { name: string; updated_at: string; } // --------------------------------------------------------------------------- // v1.2 group read-only inspection surfaces. Every shape below mirrors a Rust // handler in crates/manager-core/src (vars_api, secrets_api, apply_api, // group_blobs_api, group_scripts_api, projects_api). All READ-ONLY — group // shared-collection writes + template authoring happen via the `pic` CLI. // --------------------------------------------------------------------------- /// One group var row (vars_api::VarItem). `env` is `*` (env-agnostic) or a /// concrete env name; a tombstone suppresses an inherited key. export interface GroupVarItem { key: string; env: string; value: unknown; is_tombstone: boolean; updated_at: string; } /// One group secret row (secrets_api::SecretItem). NAMES + env + updated_at /// only — values never leave the server. export interface GroupSecretItem { name: string; env: string; updated_at: string; } /// §11.6 shared-collection kinds. kv/docs/files have a data browser; topic and /// queue are storeless / consumed differently (no per-group data endpoint). export type GroupCollectionKind = 'kv' | 'docs' | 'files' | 'topic' | 'queue'; /// One declared shared collection (apply_service::CollectionInfo). export interface GroupCollection { name: string; kind: GroupCollectionKind; } /// One group trigger-template row (apply_service::TriggerTemplateInfo). export interface GroupTriggerTemplate { kind: string; target: string; script: string; enabled: boolean; sealed: boolean; shared: boolean; } /// One group route-template row (apply_service::RouteTemplateInfo). export interface GroupRouteTemplate { method: string; host: string; path_kind: string; path: string; script: string; dispatch: string; enabled: boolean; sealed: boolean; } /// One group/app suppression row (apply_service::SuppressionInfo). export interface SuppressionInfo { target_kind: string; reference: string; } /// One extension-point row (apply_service::ExtensionPointInfo). export interface ExtensionPointInfo { name: string; declared_here: boolean; provider: string | null; } /// One shared-collection doc entry (group_blobs_api::DocEntry). export interface GroupDocEntry { id: string; data: unknown; } /// One group shared-queue dead-letter (group_blobs_api::DeadLetterDto). Note: /// this shape is distinct from the per-app `DeadLetterRow` — it carries a /// `collection` and no trigger/script fields. export interface GroupDeadLetter { id: string; collection: string; source: string; op: string; attempt_count: number; last_error: string; created_at: string; resolved_at: string | null; payload: unknown; } /// One project row (projects_api::ProjectListItem). export interface ProjectListItem { slug: string; name: string; owned_groups: number; created_at: string; } 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); } } // F-T-003: if the server keeps returning 401 (e.g. the bounce to // /login itself triggered another 401-emitting request), the previous // implementation would just keep calling `goto(login)` — a tight loop. // Track consecutive 401s; after the threshold within the window, hard- // reload to a static auth-loop page so the user sees something other // than a spinning dashboard. Any 2xx response resets the count. let consecutive401Count = 0; let first401At = 0; const AUTH_LOOP_THRESHOLD = 3; const AUTH_LOOP_WINDOW_MS = 10_000; function loginUrlWithReturnTo(): string { if (!browser) return `${base}/login`; const here = `${window.location.pathname}${window.location.search}`; // Don't loop returnTo back to /login itself. if (here === `${base}/login` || here.startsWith(`${base}/login?`)) { return `${base}/login`; } return `${base}/login?returnTo=${encodeURIComponent(here)}`; } 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(); const now = Date.now(); if (now - first401At > AUTH_LOOP_WINDOW_MS) { first401At = now; consecutive401Count = 1; } else { consecutive401Count += 1; } if (browser && !window.location.pathname.endsWith('/login')) { if (consecutive401Count >= AUTH_LOOP_THRESHOLD) { // Hard-reload via location.assign — Svelte's goto keeps // the SPA state which is what's keeping the loop alive. window.location.assign(`${base}/login?reason=auth-loop`); } else { void goto(loginUrlWithReturnTo()); } } } else if (res.ok) { // Any successful response resets the loop counter — the user has // a valid session again. consecutive401Count = 0; first401At = 0; } 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