Files
PiCloud/dashboard/src/lib/api.ts
MechaCat02 bd9c3fa4f0 feat(workflows): M6 — dashboard run-history + DAG view
A per-app "Workflows" tab: list definitions, browse run history, and inspect a
run's per-step progress with a static layered DAG.

- API: the run-detail endpoint now returns each step's `depends_on` (loaded
  from the definition via a new `get_workflow_by_id` reader) so the dashboard
  can draw the graph edges — the run's step rows don't carry them.
- dashboard `$lib/api`: a `workflows` namespace (list / runs / start / run) +
  the matching types.
- `apps/[slug]/workflows/+page.svelte`: workflow table → drill into runs →
  drill into a run. The run view renders a step table plus an SVG DAG (nodes =
  steps colored by status, laid out by longest-path level; edges = depends_on,
  arrowed) and a "Start run" button. Nested/parked steps show their child run.
- AppTabBar + the per-app layout gain the Workflows tab (admin-only).

Tests: `npm run check` clean (0 errors); two Playwright smoke tests
(navigation tabs — workflows loads cleanly + renders its empty state) pass
against the full stack.

With M6 the v1.2 Workflows track (M1 schema/validation → M2 durable
orchestrator → M3 when/templating → M4 nested → M5 SDK/API/CLI → M6 dashboard)
is COMPLETE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 17:57:56 +02:00

1430 lines
42 KiB
TypeScript

// 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<string, string>;
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<string, string>;
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 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<string, string>;
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<T>(path: string, init?: RequestInit): Promise<T> {
const headers: Record<string, string> = {
'content-type': 'application/json',
...((init?.headers as Record<string, string>) ?? {})
};
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<VersionInfo>('/version'),
auth: {
login: async (username: string, password: string): Promise<MeDto> => {
const r = await adminRequest<LoginResponse>('/api/v1/admin/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password })
});
setSession(r.user, r.token);
return r.user;
},
logout: async (): Promise<void> => {
try {
await adminRequest<null>('/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<MeDto>('/api/v1/admin/auth/me')
},
admins: {
list: () => adminRequest<AdminDto[]>('/api/v1/admin/admins'),
get: (id: string) => adminRequest<AdminDto>(`/api/v1/admin/admins/${id}`),
create: (input: CreateAdminInput) =>
adminRequest<AdminDto>('/api/v1/admin/admins', {
method: 'POST',
body: JSON.stringify(input)
}),
update: (id: string, input: PatchAdminInput) =>
adminRequest<AdminDto>(`/api/v1/admin/admins/${id}`, {
method: 'PATCH',
body: JSON.stringify(input)
}),
remove: (id: string) =>
adminRequest<null>(`/api/v1/admin/admins/${id}`, { method: 'DELETE' })
},
apiKeys: {
list: () => adminRequest<ApiKeyDto[]>('/api/v1/admin/api-keys'),
mint: (input: MintApiKeyInput) =>
adminRequest<MintApiKeyResponse>('/api/v1/admin/api-keys', {
method: 'POST',
body: JSON.stringify(input)
}),
revoke: (id: string) =>
adminRequest<null>(`/api/v1/admin/api-keys/${id}`, { method: 'DELETE' })
},
routes: {
listForScript: (scriptId: string) =>
adminRequest<Route[]>(`/api/v1/admin/scripts/${scriptId}/routes`),
create: (scriptId: string, input: RouteInput) =>
adminRequest<Route>(`/api/v1/admin/scripts/${scriptId}/routes`, {
method: 'POST',
body: JSON.stringify(input)
}),
remove: (routeId: string) =>
adminRequest<null>(`/api/v1/admin/routes/${routeId}`, { method: 'DELETE' }),
check: (appId: string, input: RouteInput) =>
adminRequest<CheckRouteResponse>('/api/v1/admin/routes:check', {
method: 'POST',
body: JSON.stringify({ ...input, app_id: appId })
}),
match: (appId: string, url: string, method = 'GET') =>
adminRequest<MatchRouteResponse>('/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<Script[]>(`/api/v1/admin/scripts${qs}`);
},
get: (id: string) => adminRequest<Script>(`/api/v1/admin/scripts/${id}`),
create: (input: CreateScriptInput) =>
adminRequest<Script>('/api/v1/admin/scripts', {
method: 'POST',
body: JSON.stringify(input)
}),
update: (id: string, input: UpdateScriptInput) =>
adminRequest<Script>(`/api/v1/admin/scripts/${id}`, {
method: 'PUT',
body: JSON.stringify(input)
}),
remove: (id: string) =>
adminRequest<null>(`/api/v1/admin/scripts/${id}`, { method: 'DELETE' }),
logs: (id: string, opts: { limit?: number; offset?: number } = {}) => {
const params = new URLSearchParams();
if (opts.limit !== undefined) params.set('limit', String(opts.limit));
if (opts.offset !== undefined) params.set('offset', String(opts.offset));
const qs = params.toString();
return adminRequest<ExecutionLog[]>(
`/api/v1/admin/scripts/${id}/logs${qs ? `?${qs}` : ''}`
);
}
},
apps: {
list: () => adminRequest<App[]>('/api/v1/admin/apps'),
get: (idOrSlug: string) =>
adminRequest<AppLookupResponse>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}`),
create: (input: CreateAppInput) =>
adminRequest<App>('/api/v1/admin/apps', {
method: 'POST',
body: JSON.stringify(input)
}),
update: (idOrSlug: string, input: PatchAppInput) =>
adminRequest<App>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}`, {
method: 'PATCH',
body: JSON.stringify(input)
}),
remove: (idOrSlug: string, opts: { force?: boolean } = {}) => {
const qs = opts.force ? '?force=true' : '';
return adminRequest<null>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}${qs}`,
{ method: 'DELETE' }
);
},
slugCheck: (idOrSlug: string, newSlug: string) =>
adminRequest<SlugCheckResponse>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/slug:check`,
{
method: 'POST',
body: JSON.stringify({ new_slug: newSlug })
}
)
},
groups: {
list: () => adminRequest<Group[]>('/api/v1/admin/groups'),
get: (idOrSlug: string) =>
adminRequest<GroupDetail>(`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}`),
create: (input: CreateGroupInput) =>
adminRequest<Group>('/api/v1/admin/groups', {
method: 'POST',
body: JSON.stringify(input)
}),
update: (idOrSlug: string, input: PatchGroupInput) =>
adminRequest<Group>(`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}`, {
method: 'PATCH',
body: JSON.stringify(input)
}),
reparent: (idOrSlug: string, parent: string | null) =>
adminRequest<Group>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/reparent`,
{ method: 'POST', body: JSON.stringify({ parent }) }
),
delete: (idOrSlug: string) =>
adminRequest<null>(`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}`, {
method: 'DELETE'
}),
members: {
list: (idOrSlug: string) =>
adminRequest<GroupMember[]>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members`
),
grant: (idOrSlug: string, input: GrantAppMemberInput) =>
adminRequest<GroupMember>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members`,
{ method: 'POST', body: JSON.stringify(input) }
),
update: (idOrSlug: string, userId: string, role: AppRole) =>
adminRequest<GroupMember>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members/${userId}`,
{ method: 'PATCH', body: JSON.stringify({ role }) }
),
remove: (idOrSlug: string, userId: string) =>
adminRequest<null>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members/${userId}`,
{ method: 'DELETE' }
)
},
// --- v1.2 read-only inspection surfaces (all GET) --------------
// The group's OWN vars (not the inherited/resolved view).
vars: (idOrSlug: string) =>
adminRequest<{ vars: GroupVarItem[] }>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/vars`
),
// Secret NAMES + env + updated_at only — never values. Requires
// GroupSecretsWrite; a 403 is surfaced as a permission note, not a crash.
secrets: (idOrSlug: string) =>
adminRequest<{ secrets: GroupSecretItem[]; next_cursor: string | null }>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/secrets`
),
// Declared shared collections (kv/docs/files/topic/queue).
collections: (idOrSlug: string) =>
adminRequest<GroupCollection[]>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/collections`
),
scripts: (idOrSlug: string) =>
adminRequest<Script[]>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/scripts`
),
triggers: (idOrSlug: string) =>
adminRequest<GroupTriggerTemplate[]>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/triggers`
),
routes: (idOrSlug: string) =>
adminRequest<GroupRouteTemplate[]>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/routes`
),
suppressions: (idOrSlug: string) =>
adminRequest<SuppressionInfo[]>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/suppressions`
),
extensionPoints: (idOrSlug: string) =>
adminRequest<ExtensionPointInfo[]>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/extension-points`
),
// Shared-KV browser: list keys, then get a single value.
groupKv: {
list: (
idOrSlug: string,
collection: string,
opts: { cursor?: string; limit?: number } = {}
) => {
const params = new URLSearchParams();
params.set('collection', collection);
if (opts.cursor) params.set('cursor', opts.cursor);
if (opts.limit !== undefined) params.set('limit', String(opts.limit));
return adminRequest<{ keys: string[]; next_cursor: string | null }>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/kv?${params.toString()}`
);
},
get: (idOrSlug: string, collection: string, key: string) =>
adminRequest<{ value: unknown }>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/kv/${encodeURIComponent(collection)}/${encodeURIComponent(key)}`
)
},
// Shared-docs browser: list documents (id + data), then get one.
groupDocs: {
list: (
idOrSlug: string,
collection: string,
opts: { cursor?: string; limit?: number } = {}
) => {
const params = new URLSearchParams();
params.set('collection', collection);
if (opts.cursor) params.set('cursor', opts.cursor);
if (opts.limit !== undefined) params.set('limit', String(opts.limit));
return adminRequest<{ docs: GroupDocEntry[]; next_cursor: string | null }>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/docs?${params.toString()}`
);
},
get: (idOrSlug: string, collection: string, docId: string) =>
adminRequest<{ id: string; data: unknown }>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/docs/${encodeURIComponent(collection)}/${docId}`
)
},
// Shared-files browser: list metadata + download URL (GET streams bytes).
groupFiles: {
list: (
idOrSlug: string,
collection: string,
opts: { cursor?: string; limit?: number } = {}
) => {
const params = new URLSearchParams();
params.set('collection', collection);
if (opts.cursor) params.set('cursor', opts.cursor);
if (opts.limit !== undefined) params.set('limit', String(opts.limit));
return adminRequest<{ files: FileMeta[]; next_cursor: string | null }>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/files?${params.toString()}`
);
},
downloadUrl: (idOrSlug: string, collection: string, fileId: string): string =>
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/files/${encodeURIComponent(collection)}/${fileId}`
},
// §11.6 D3 shared-queue dead-letters (newest-first).
deadLetters: (
idOrSlug: string,
opts: { unresolved?: boolean; limit?: number } = {}
) => {
const params = new URLSearchParams();
if (opts.unresolved) params.set('unresolved', 'true');
if (opts.limit !== undefined) params.set('limit', String(opts.limit));
const qs = params.toString();
return adminRequest<GroupDeadLetter[]>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/dead-letters${qs ? `?${qs}` : ''}`
);
}
},
projects: {
list: () => adminRequest<ProjectListItem[]>('/api/v1/admin/projects')
},
domains: {
listForApp: (idOrSlug: string) =>
adminRequest<AppDomain[]>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/domains`
),
create: (idOrSlug: string, pattern: string) =>
adminRequest<AppDomain>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/domains`,
{ method: 'POST', body: JSON.stringify({ pattern }) }
),
remove: (idOrSlug: string, domainId: string) =>
adminRequest<null>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/domains/${domainId}`,
{ method: 'DELETE' }
)
},
appMembers: {
list: (idOrSlug: string) =>
adminRequest<AppMemberDto[]>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/members`
),
add: (idOrSlug: string, input: GrantAppMemberInput) =>
adminRequest<AppMemberDto>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/members`,
{ method: 'POST', body: JSON.stringify(input) }
),
setRole: (idOrSlug: string, userId: string, role: AppRole) =>
adminRequest<AppMemberDto>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/members/${userId}`,
{ method: 'PATCH', body: JSON.stringify({ role }) }
),
remove: (idOrSlug: string, userId: string) =>
adminRequest<null>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/members/${userId}`,
{ method: 'DELETE' }
)
},
deadLetters: {
count: (idOrSlug: string) =>
adminRequest<{ unresolved: number }>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/dead_letters/count`
),
list: (idOrSlug: string, opts: { unresolved?: boolean; limit?: number; offset?: number } = {}) => {
const params = new URLSearchParams();
if (opts.unresolved) params.set('unresolved', 'true');
if (opts.limit !== undefined) params.set('limit', String(opts.limit));
if (opts.offset !== undefined) params.set('offset', String(opts.offset));
const qs = params.toString();
return adminRequest<{ dead_letters: DeadLetterRow[] }>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/dead_letters${qs ? `?${qs}` : ''}`
);
},
get: (idOrSlug: string, dlId: string) =>
adminRequest<DeadLetterRow>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/dead_letters/${dlId}`
),
replay: (idOrSlug: string, dlId: string) =>
adminRequest<null>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/dead_letters/${dlId}/replay`,
{ method: 'POST' }
),
resolve: (idOrSlug: string, dlId: string, reason: string) =>
adminRequest<null>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/dead_letters/${dlId}/resolve`,
{ method: 'POST', body: JSON.stringify({ reason }) }
)
},
triggers: {
list: (idOrSlug: string) =>
adminRequest<{ triggers: Trigger[] }>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers`
),
createCron: (idOrSlug: string, input: CreateCronTriggerInput) =>
adminRequest<Trigger>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/cron`,
{ method: 'POST', body: JSON.stringify(input) }
),
createPubsub: (idOrSlug: string, input: CreatePubsubTriggerInput) =>
adminRequest<Trigger>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/pubsub`,
{ method: 'POST', body: JSON.stringify(input) }
),
createEmail: (idOrSlug: string, input: CreateEmailTriggerInput) =>
adminRequest<Trigger>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/email`,
{ method: 'POST', body: JSON.stringify(input) }
),
createQueue: (idOrSlug: string, input: CreateQueueTriggerInput) =>
adminRequest<Trigger>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/queue`,
{ method: 'POST', body: JSON.stringify(input) }
),
// F-U-001: matching frontend methods for the four data-plane
// trigger kinds the backend has long supported.
createKv: (idOrSlug: string, input: CreateKvTriggerInput) =>
adminRequest<Trigger>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/kv`,
{ method: 'POST', body: JSON.stringify(input) }
),
createDocs: (idOrSlug: string, input: CreateDocsTriggerInput) =>
adminRequest<Trigger>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/docs`,
{ method: 'POST', body: JSON.stringify(input) }
),
createFiles: (idOrSlug: string, input: CreateFilesTriggerInput) =>
adminRequest<Trigger>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/files`,
{ method: 'POST', body: JSON.stringify(input) }
),
createDeadLetter: (idOrSlug: string, input: CreateDeadLetterTriggerInput) =>
adminRequest<Trigger>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/dead_letter`,
{ method: 'POST', body: JSON.stringify(input) }
),
remove: (idOrSlug: string, triggerId: string) =>
adminRequest<null>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/${triggerId}`,
{ method: 'DELETE' }
)
},
queues: {
list: (idOrSlug: string) =>
adminRequest<QueueSummary[]>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/queues`
),
get: (idOrSlug: string, name: string) =>
adminRequest<QueueDetail>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/queues/${encodeURIComponent(name)}`
)
},
workflows: {
list: (idOrSlug: string) =>
adminRequest<WorkflowSummary[]>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/workflows`
),
runs: (idOrSlug: string, name: string) =>
adminRequest<WorkflowRun[]>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/workflows/${encodeURIComponent(name)}/runs`
),
start: (idOrSlug: string, name: string, input: unknown) =>
adminRequest<WorkflowRun>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/workflows/${encodeURIComponent(name)}/runs`,
{ method: 'POST', body: JSON.stringify({ input }) }
),
run: (idOrSlug: string, runId: string) =>
adminRequest<WorkflowRunDetail>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/workflow-runs/${encodeURIComponent(runId)}`
)
},
topics: {
list: (idOrSlug: string) =>
adminRequest<{ topics: Topic[] }>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/topics`
),
create: (idOrSlug: string, input: CreateTopicInput) =>
adminRequest<Topic>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/topics`, {
method: 'POST',
body: JSON.stringify(input)
}),
update: (idOrSlug: string, name: string, input: UpdateTopicInput) =>
adminRequest<Topic>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/topics/${encodeURIComponent(name)}`,
{ method: 'PATCH', body: JSON.stringify(input) }
),
remove: (idOrSlug: string, name: string) =>
adminRequest<null>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/topics/${encodeURIComponent(name)}`,
{ method: 'DELETE' }
)
},
files: {
list: (idOrSlug: string, collection: string, opts: { cursor?: string; limit?: number } = {}) => {
const params = new URLSearchParams();
params.set('collection', collection);
if (opts.cursor) params.set('cursor', opts.cursor);
if (opts.limit !== undefined) params.set('limit', String(opts.limit));
return adminRequest<{ files: FileMeta[]; next_cursor: string | null }>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/files?${params.toString()}`
);
},
remove: (idOrSlug: string, collection: string, fileId: string) =>
adminRequest<null>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/files/${encodeURIComponent(collection)}/${fileId}`,
{ method: 'DELETE' }
),
// Build the admin download URL. GET returns the file bytes
// inline with Content-Disposition set from the stored filename;
// the browser handles the download.
downloadUrl: (idOrSlug: string, collection: string, fileId: string): string =>
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/files/${encodeURIComponent(collection)}/${fileId}`
},
secrets: {
// List returns names + last-modified ONLY — values never leave the
// server (v1.1.7).
list: (idOrSlug: string) =>
adminRequest<{ secrets: SecretListItem[]; next_cursor: string | null }>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/secrets`
),
// `value` is any JSON value; the dashboard sends a single-line
// string. Overwrites if the name already exists.
set: (idOrSlug: string, name: string, value: unknown) =>
adminRequest<null>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/secrets`, {
method: 'POST',
body: JSON.stringify({ name, value })
}),
remove: (idOrSlug: string, name: string) =>
adminRequest<null>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/secrets/${encodeURIComponent(name)}`,
{ method: 'DELETE' }
)
},
users: {
list: (idOrSlug: string, opts: { cursor?: string; limit?: number } = {}) => {
const params = new URLSearchParams();
if (opts.cursor) params.set('cursor', opts.cursor);
if (opts.limit !== undefined) params.set('limit', String(opts.limit));
const qs = params.toString();
return adminRequest<{ users: AppUser[]; next_cursor: string | null }>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/users${qs ? `?${qs}` : ''}`
);
},
get: (idOrSlug: string, userId: string) =>
adminRequest<AppUser>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/users/${userId}`
),
create: (idOrSlug: string, input: CreateAppUserInput) =>
adminRequest<AppUser>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/users`, {
method: 'POST',
body: JSON.stringify(input)
}),
patch: (idOrSlug: string, userId: string, input: PatchAppUserInput) =>
adminRequest<AppUser>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/users/${userId}`,
{ method: 'PATCH', body: JSON.stringify(input) }
),
remove: (idOrSlug: string, userId: string) =>
adminRequest<null>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/users/${userId}`,
{ method: 'DELETE' }
),
resetPassword: (idOrSlug: string, userId: string) =>
adminRequest<ResetPasswordResponse>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/users/${userId}/reset-password`,
{ method: 'POST' }
),
revokeSessions: (idOrSlug: string, userId: string) =>
adminRequest<RevokeSessionsResponse>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/users/${userId}/revoke-sessions`,
{ method: 'POST' }
)
},
appInvitations: {
list: (idOrSlug: string) =>
adminRequest<{ invitations: Invitation[] }>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/invitations`
),
create: (idOrSlug: string, input: CreateInvitationInput) =>
adminRequest<Invitation>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/invitations`,
{ method: 'POST', body: JSON.stringify(input) }
),
remove: (idOrSlug: string, inviteId: string) =>
adminRequest<null>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/invitations/${inviteId}`,
{ method: 'DELETE' }
)
},
execute: async (
id: string,
body: unknown,
extraHeaders?: Record<string, string>
): Promise<ExecutionResult> => {
const res = await fetch(`/api/v1/execute/${id}`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...(extraHeaders ?? {}) },
body: body === undefined ? '{}' : JSON.stringify(body)
});
const text = await res.text();
const parsedBody: unknown = text ? safeJson(text) : null;
const headers: Record<string, string> = {};
res.headers.forEach((value, key) => {
headers[key] = value;
});
return { status: res.status, headers, body: parsedBody };
}
};