- `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>
594 lines
16 KiB
TypeScript
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 };
|
|
}
|
|
};
|