Files
PiCloud/dashboard/src/lib/api.ts
MechaCat02 8725939172 feat(dashboard): group tree, detail page, and members tab
SvelteKit UI for Phase-2 groups, mirroring the apps/users patterns:
- api.ts: Group/GroupDetail/GroupMember types + an api.groups client
  (list/get/create/update/reparent/delete + nested members CRUD); optional
  group selector wired into app create.
- routes/groups: a collapsible tree overview (assembled from parent_id)
  with a New-group form, and a detail page with breadcrumb, subgroups/apps
  lists, a rename form (no slug field — slug is frozen), a reparent
  control, a delete button that surfaces the 409 RESTRICT message, and a
  Members tab reusing RoleChip/ConfirmModal/ActionMenu.
- +layout.svelte: a Groups nav entry beside Apps.

npm run check: 0 errors; npm run build: success.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:15:44 +02:00

1147 lines
33 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.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;
}
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' }
)
}
},
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)}`
)
},
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 };
}
};