Files
PiCloud/dashboard/src/lib/api.ts
MechaCat02 610fd4ffa2 feat(v1.1.3-modules): dashboard kind dropdown + scripts-list and detail badges
- `Script` type gains `kind: 'endpoint' | 'module'`. `CreateScriptInput`
  + `UpdateScriptInput` carry an optional `kind` field.
- App page's script-create form grows a kind dropdown next to Name +
  Description. Selecting "module" surfaces a hint that modules cannot
  bind to routes / triggers.
- Scripts list renders a small badge after the version: blue
  "endpoint" or purple "module".
- Script detail page renders the same badge next to the H1.

`npm run check` passes (0 errors, 0 warnings).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 22:26:07 +02:00

594 lines
16 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 type DomainShape = 'exact' | 'wildcard' | 'parameterized';
export interface AppDomain {
id: string;
app_id: string;
pattern: string;
shape: DomainShape;
shape_key: string;
created_at: string;
}
export interface AppLookupResponse {
id: string;
slug: string;
name: string;
description: string | null;
created_at: string;
updated_at: string;
/// Present only when the requested slug was a retired redirect.
redirect_to?: string;
/// The caller's role on this app — owners are implicit `app_admin`,
/// admins implicit `editor`, members carry their `app_members.role`.
/// `null` only when a member somehow reaches the endpoint without
/// a membership (the server normally 403s first).
my_role: AppRole | null;
}
export interface SlugCheckResponse {
ok: boolean;
conflict_kind: 'current' | 'historical' | 'invalid' | 'reserved' | null;
current_app: App | null;
reason: string | null;
}
export interface CreateAppInput {
slug: string;
name: string;
description?: string | null;
force_takeover?: boolean;
}
export interface PatchAppInput {
name?: string;
description?: string | null;
slug?: string;
force_takeover?: boolean;
}
export type HostKind = 'any' | 'strict' | 'wildcard';
export type PathKind = 'exact' | 'prefix' | 'param';
export interface Route {
id: string;
app_id: string;
script_id: string;
host_kind: HostKind;
host: string;
host_param_name: string | null;
path_kind: PathKind;
path: string;
method: string | null;
created_at: string;
}
export interface RouteInput {
host_kind: HostKind;
host?: string;
host_param_name?: string | null;
path_kind: PathKind;
path: string;
method?: string | null;
}
export interface CheckRouteResponse {
ok: boolean;
conflicting_route: Route | null;
conflict_reason: string | null;
}
export interface MatchRouteResponse {
matched: null | {
route_id: string;
script_id: string;
params: Record<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 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);
}
}
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();
if (browser && !window.location.pathname.endsWith('/login')) {
void goto(`${base}/login`);
}
}
if (!res.ok) {
const message =
(parsed && typeof parsed === 'object' && 'error' in parsed
? String((parsed as { error: unknown }).error)
: text) || `${res.status} ${res.statusText}`;
throw new ApiError(res.status, message, parsed);
}
return parsed as T;
}
function safeJson(text: string): unknown {
try {
return JSON.parse(text) as unknown;
} catch {
return text;
}
}
export type Scope =
| 'script:read'
| 'script:write'
| 'route:write'
| 'domain:manage'
| 'log:read'
| 'app:admin'
| 'instance:admin';
export const ALL_SCOPES: readonly Scope[] = [
'script:read',
'script:write',
'route:write',
'domain:manage',
'log:read',
'app:admin',
'instance:admin'
] as const;
export function isInstanceScope(s: Scope): boolean {
return s.startsWith('instance:');
}
export interface MeDto {
id: string;
username: string;
instance_role: InstanceRole;
email: string | null;
}
export interface AdminDto {
id: string;
username: string;
is_active: boolean;
instance_role: InstanceRole;
email: string | null;
created_at: string;
last_login_at: string | null;
}
export interface CreateAdminInput {
username: string;
password: string;
instance_role?: InstanceRole;
email?: string | null;
}
export interface PatchAdminInput {
username?: string;
password?: string;
is_active?: boolean;
instance_role?: InstanceRole;
email?: string | null;
}
export interface AppMemberDto {
user_id: string;
username: string;
email: string | null;
instance_role: InstanceRole;
is_active: boolean;
role: AppRole;
created_at: string;
}
export interface GrantAppMemberInput {
user_id: string;
role: AppRole;
}
export interface ApiKeyDto {
id: string;
prefix: string;
name: string;
scopes: Scope[];
app_id: string | null;
expires_at: string | null;
last_used_at: string | null;
created_at: string;
}
export interface MintApiKeyInput {
name: string;
scopes: Scope[];
app_id?: string | null;
expires_at?: string | null;
}
export interface MintApiKeyResponse extends ApiKeyDto {
raw_token: string;
}
interface LoginResponse {
user: MeDto;
token: string;
expires_at: string;
}
export const api = {
health: () => fetch('/healthz').then((r) => r.text()),
version: () => adminRequest<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 })
}
)
},
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 }) }
)
},
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 };
}
};