Files
PiCloud/dashboard/src/routes/apps/[slug]/+page.svelte
MechaCat02 05ed9b00bb fix(stage-6): dashboard hardening + audit Lows cherry-pick
Closes 4 dashboard hardening findings and 5 of the Lows from the audit.

Dashboard hardening:
- Subtabs no longer re-fetch the app via api.apps.get on every page
  load. users/files/dead-letters drop the fetch outright (the variable
  was set but never read); queues + queues/[name] now consume the
  layout's AppContext via getContext for the page title. The layout's
  reloadApp() owns the historical-slug redirect — subtab-local redirect
  blocks are removed so there's no race.
- The global :global(details > summary::before) chevron is now scoped
  to details.chevron. The script editor's "Advanced sandbox" details
  and the inbound-email-shape help-text both opt in; the script
  exec-list logs no longer inherit a spurious chevron.
- deriveTab now matches the path segment by anchored ===, so a future
  /apps/<slug>/queues-archived route wouldn't activate the queues tab.

Lows cherry-pick:
- ExecError gains Serialize/Deserialize derives + a snake_case tag so
  RemoteExecutorClient (cluster mode v1.3+) can round-trip the variant.
- triggers_api rejects queue triggers whose visibility_timeout_secs is
  below the dispatcher's per-message executor budget; with no minimum
  the reclaim task races the handler and the queue silently
  double-delivers. Existing test using 5s updated to 30s.
- New migration 0040: execution_logs.script_id cascade switched from
  ON DELETE CASCADE to ON DELETE SET NULL so deleting a script no
  longer wipes the forensic history that motivated the delete.
- New migration 0041: dead_letters composite index on
  (app_id, created_at DESC) so the "list all" dashboard view stops
  falling back to seqscan + sort when unresolved=false.
- Schema snapshot re-blessed.

Deferred to v1.2: the ExecRequest principal serde(skip) marker
(documented in-place; the cluster-mode PR will introduce the wire-safe
snapshot at that point) and the `pic --help` mention of
`picloud admin reset-password` (one-line follow-up).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-10 21:50:58 +02:00

2360 lines
66 KiB
Svelte
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { base } from '$app/paths';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import {
api,
ApiError,
type AdminDto,
type App,
type AppDomain,
type AppMemberDto,
type AppRole,
type Script,
type Trigger,
type Topic,
type TopicAuthMode,
type SecretListItem
} from '$lib/api';
import CodeEditor from '$lib/CodeEditor.svelte';
import ConfirmModal from '$lib/ConfirmModal.svelte';
import ActionMenu from '$lib/ActionMenu.svelte';
import RoleChip from '$lib/RoleChip.svelte';
import { currentUser } from '$lib/auth';
import { canAdminApp, canWriteApp } from '$lib/capabilities';
const me = $derived($currentUser);
const SAMPLE_SOURCE =
'#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
type Tab = 'scripts' | 'domains' | 'members' | 'settings' | 'triggers' | 'topics' | 'secrets';
// Common IANA timezones offered in the cron form dropdown. Not
// exhaustive — the backend validates any IANA name via chrono-tz.
const COMMON_TIMEZONES = [
'UTC',
'America/Los_Angeles',
'America/Denver',
'America/Chicago',
'America/New_York',
'America/Sao_Paulo',
'Europe/London',
'Europe/Berlin',
'Europe/Paris',
'Europe/Moscow',
'Asia/Kolkata',
'Asia/Shanghai',
'Asia/Tokyo',
'Australia/Sydney'
];
let slug = $derived(page.params.slug ?? '');
let app = $state<App | null>(null);
let myRole = $state<AppRole | null>(null);
let loadError = $state<string | null>(null);
let loading = $state(true);
/// F-U-014: the canonical public origin (from `/version`). Used to
/// build webhook URLs the operator shares with external systems so
/// they don't accidentally inherit the admin's LAN address.
let publicBaseUrl = $state<string | null>(null);
// Tab is URL-driven so the layout's <AppTabBar> can link directly to
// `?tab=<id>` and the in-page panel switches accordingly. The legacy
// state-based switching was incompatible with cross-route tab
// continuity (the user complained that subroute pages dropped the
// tab bar — now the layout owns it everywhere).
const isValidTab = (s: string | null): s is Tab =>
s === 'scripts' ||
s === 'domains' ||
s === 'members' ||
s === 'settings' ||
s === 'triggers' ||
s === 'topics' ||
s === 'secrets';
let activeTab = $derived.by<Tab>(() => {
const qp = page.url.searchParams.get('tab');
return isValidTab(qp) ? qp : 'scripts';
});
let scripts = $state<Script[]>([]);
let domains = $state<AppDomain[]>([]);
let members = $state<AppMemberDto[]>([]);
/// v1.1.1 dead-letters surface — design notes §4 mandates the
/// dashboard surface this since there's no default handler.
let unresolvedDeadLetters = $state<number>(0);
async function loadDeadLetterCount(idOrSlug: string) {
try {
const r = await api.deadLetters.count(idOrSlug);
unresolvedDeadLetters = r.unresolved;
} catch {
// Non-fatal: the page renders fine without the badge if
// the count endpoint is unreachable (e.g. older server).
unresolvedDeadLetters = 0;
}
}
// Derive UI gates from the capabilities helper so the rules stay
// in lockstep with the backend's `can()`. canAdminApp also covers
// the Members + Settings + Domains-mutation tabs; canWriteApp
// covers New script.
const canWrite = $derived(canWriteApp(me, myRole));
const canAdmin = $derived(canAdminApp(me, myRole));
// Script create
let showCreateScript = $state(false);
let createScriptName = $state('');
let createScriptDescription = $state('');
let createScriptSource = $state(SAMPLE_SOURCE);
// v1.1.3: endpoint (default — handles routes/triggers) vs module
// (library imported by other scripts). Modules cannot be bound to
// routes or used as trigger targets.
let createScriptKind = $state<'endpoint' | 'module'>('endpoint');
let creatingScript = $state(false);
let createScriptError = $state<string | null>(null);
// Domain create
let createDomainPattern = $state('');
let creatingDomain = $state(false);
let createDomainError = $state<string | null>(null);
// Settings
let editName = $state('');
let editDescription = $state('');
let editSlug = $state('');
let savingSettings = $state(false);
let settingsError = $state<string | null>(null);
let slugTakeoverNeeded = $state<App | null>(null);
// Delete confirmations
let confirmingDeleteApp = $state(false);
let deletingApp = $state(false);
let deleteAppError = $state<string | null>(null);
let domainToRemove = $state<AppDomain | null>(null);
let removingDomain = $state(false);
let removeDomainError = $state<string | null>(null);
// Triggers tab (v1.1.4 — cron triggers). Admin-gated, like Members.
let triggers = $state<Trigger[]>([]);
let createCronScriptId = $state('');
let createCronSchedule = $state('0 0 9 * * MON-FRI');
let createCronTimezone = $state('UTC');
let creatingCron = $state(false);
let createCronError = $state<string | null>(null);
// Pub/Sub triggers (v1.1.5).
let createPubsubScriptId = $state('');
let createPubsubTopic = $state('');
let creatingPubsub = $state(false);
let createPubsubError = $state<string | null>(null);
// Email triggers (v1.1.7).
let createEmailScriptId = $state('');
let createEmailSecret = $state('');
let creatingEmail = $state(false);
let createEmailError = $state<string | null>(null);
// Queue triggers (v1.1.9).
let createQueueScriptId = $state('');
let createQueueName = $state('');
let createQueueVisibilityTimeout = $state(30);
let createQueueMaxAttempts = $state(3);
let creatingQueue = $state(false);
let createQueueError = $state<string | null>(null);
// F-U-001: KV / Docs / Files / Dead-letter trigger forms.
let createKvScriptId = $state('');
let createKvCollectionGlob = $state('*');
let createKvOps = $state<{ insert: boolean; update: boolean; delete: boolean }>({
insert: true,
update: true,
delete: true
});
let creatingKv = $state(false);
let createKvError = $state<string | null>(null);
let createDocsScriptId = $state('');
let createDocsCollectionGlob = $state('*');
let createDocsOps = $state<{ create: boolean; update: boolean; delete: boolean }>({
create: true,
update: true,
delete: true
});
let creatingDocs = $state(false);
let createDocsError = $state<string | null>(null);
let createFilesScriptId = $state('');
let createFilesCollectionGlob = $state('*');
let createFilesOps = $state<{ create: boolean; update: boolean; delete: boolean }>({
create: true,
update: true,
delete: true
});
let creatingFiles = $state(false);
let createFilesError = $state<string | null>(null);
let createDeadLetterScriptId = $state('');
let createDeadLetterSourceFilter = $state('');
let createDeadLetterTriggerIdFilter = $state('');
let createDeadLetterScriptIdFilter = $state('');
let creatingDeadLetter = $state(false);
let createDeadLetterError = $state<string | null>(null);
let triggerToRemove = $state<Trigger | null>(null);
let removingTrigger = $state(false);
// Endpoint scripts only — modules can't be trigger targets.
const endpointScripts = $derived(scripts.filter((s) => s.kind === 'endpoint'));
async function loadTriggers(idOrSlug: string) {
try {
const r = await api.triggers.list(idOrSlug);
triggers = r.triggers;
} catch {
triggers = [];
}
}
async function submitCreateCron(e: SubmitEvent) {
e.preventDefault();
if (!app) return;
creatingCron = true;
createCronError = null;
try {
await api.triggers.createCron(app.id, {
script_id: createCronScriptId,
schedule: createCronSchedule.trim(),
timezone: createCronTimezone
});
createCronScriptId = '';
await loadTriggers(app.id);
} catch (err) {
createCronError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
creatingCron = false;
}
}
async function submitCreatePubsub(e: SubmitEvent) {
e.preventDefault();
if (!app) return;
creatingPubsub = true;
createPubsubError = null;
try {
await api.triggers.createPubsub(app.id, {
script_id: createPubsubScriptId,
topic_pattern: createPubsubTopic.trim()
});
createPubsubScriptId = '';
createPubsubTopic = '';
await loadTriggers(app.id);
} catch (err) {
createPubsubError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
creatingPubsub = false;
}
}
async function submitCreateEmail(e: SubmitEvent) {
e.preventDefault();
if (!app) return;
creatingEmail = true;
createEmailError = null;
try {
await api.triggers.createEmail(app.id, {
script_id: createEmailScriptId,
inbound_secret: createEmailSecret.trim() === '' ? null : createEmailSecret
});
createEmailScriptId = '';
createEmailSecret = '';
await loadTriggers(app.id);
} catch (err) {
createEmailError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
creatingEmail = false;
}
}
// F-U-001: handlers for kv / docs / files / dead-letter trigger creation.
function selectedOps<T extends Record<string, boolean>>(ops: T): Array<keyof T> {
return (Object.keys(ops) as Array<keyof T>).filter((k) => ops[k]);
}
async function submitCreateKv(e: SubmitEvent) {
e.preventDefault();
if (!app) return;
creatingKv = true;
createKvError = null;
try {
await api.triggers.createKv(app.id, {
script_id: createKvScriptId,
collection_glob: createKvCollectionGlob.trim(),
ops: selectedOps(createKvOps) as Array<'insert' | 'update' | 'delete'>
});
createKvScriptId = '';
createKvCollectionGlob = '*';
await loadTriggers(app.id);
} catch (err) {
createKvError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
creatingKv = false;
}
}
async function submitCreateDocs(e: SubmitEvent) {
e.preventDefault();
if (!app) return;
creatingDocs = true;
createDocsError = null;
try {
await api.triggers.createDocs(app.id, {
script_id: createDocsScriptId,
collection_glob: createDocsCollectionGlob.trim(),
ops: selectedOps(createDocsOps) as Array<'create' | 'update' | 'delete'>
});
createDocsScriptId = '';
createDocsCollectionGlob = '*';
await loadTriggers(app.id);
} catch (err) {
createDocsError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
creatingDocs = false;
}
}
async function submitCreateFiles(e: SubmitEvent) {
e.preventDefault();
if (!app) return;
creatingFiles = true;
createFilesError = null;
try {
await api.triggers.createFiles(app.id, {
script_id: createFilesScriptId,
collection_glob: createFilesCollectionGlob.trim(),
ops: selectedOps(createFilesOps) as Array<'create' | 'update' | 'delete'>
});
createFilesScriptId = '';
createFilesCollectionGlob = '*';
await loadTriggers(app.id);
} catch (err) {
createFilesError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
creatingFiles = false;
}
}
async function submitCreateDeadLetter(e: SubmitEvent) {
e.preventDefault();
if (!app) return;
creatingDeadLetter = true;
createDeadLetterError = null;
try {
await api.triggers.createDeadLetter(app.id, {
script_id: createDeadLetterScriptId,
source_filter:
createDeadLetterSourceFilter.trim() === '' ? null : createDeadLetterSourceFilter.trim(),
trigger_id_filter:
createDeadLetterTriggerIdFilter.trim() === ''
? null
: createDeadLetterTriggerIdFilter.trim(),
script_id_filter:
createDeadLetterScriptIdFilter.trim() === ''
? null
: createDeadLetterScriptIdFilter.trim()
});
createDeadLetterScriptId = '';
createDeadLetterSourceFilter = '';
createDeadLetterTriggerIdFilter = '';
createDeadLetterScriptIdFilter = '';
await loadTriggers(app.id);
} catch (err) {
createDeadLetterError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
creatingDeadLetter = false;
}
}
async function submitCreateQueue(e: SubmitEvent) {
e.preventDefault();
if (!app) return;
creatingQueue = true;
createQueueError = null;
try {
await api.triggers.createQueue(app.id, {
script_id: createQueueScriptId,
queue_name: createQueueName.trim(),
visibility_timeout_secs: createQueueVisibilityTimeout,
retry_max_attempts: createQueueMaxAttempts
});
createQueueScriptId = '';
createQueueName = '';
createQueueVisibilityTimeout = 30;
createQueueMaxAttempts = 3;
await loadTriggers(app.id);
} catch (err) {
createQueueError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
creatingQueue = false;
}
}
// The inbound-email webhook URL for a given email trigger (shown so
// the operator can configure their provider).
// F-U-014: prefer the server-known public_base_url (from /version)
// so the URL we hand the operator works from outside the admin's
// network. Fall back to window.location.origin only if /version
// hasn't loaded yet (visibly distinguishable in the UI).
function emailInboundUrl(triggerId: string): string {
if (!app) return '';
const origin = publicBaseUrl ?? window.location.origin;
return `${origin.replace(/\/$/, '')}/api/v1/email-inbound/${app.id}/${triggerId}`;
}
async function confirmRemoveTrigger() {
if (!app || !triggerToRemove) return;
removingTrigger = true;
try {
await api.triggers.remove(app.id, triggerToRemove.id);
triggerToRemove = null;
await loadTriggers(app.id);
} catch (err) {
createCronError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
removingTrigger = false;
}
}
// Topics tab (v1.1.6 — externally-subscribable realtime topics). Admin-gated.
let topics = $state<Topic[]>([]);
let createTopicName = $state('');
let createTopicExternal = $state(false);
let createTopicAuthMode = $state<TopicAuthMode>('public');
let creatingTopic = $state(false);
let createTopicError = $state<string | null>(null);
// Edit modal.
let topicToEdit = $state<Topic | null>(null);
let editTopicExternal = $state(false);
let editTopicAuthMode = $state<TopicAuthMode>('public');
let savingTopic = $state(false);
let editTopicError = $state<string | null>(null);
// F-S-014: warn whenever the resulting (external, auth_mode) pair is
// strictly more permissive than the current one — not just on the
// internal → external flip. The order least → most permissive is:
// internal (any auth) < external+session < external+token < external+public
const editPermissivenessChange = $derived.by(() => {
if (!topicToEdit) return false;
const rank = (external: boolean, mode: TopicAuthMode) => {
if (!external) return 0;
if (mode === 'session') return 1;
if (mode === 'token') return 2;
return 3; // public
};
const before = rank(topicToEdit.external_subscribable, topicToEdit.auth_mode);
const after = rank(editTopicExternal, editTopicAuthMode);
return after > before;
});
// Kept for narrow callers; semantics now covered by editPermissivenessChange.
const editFlipToExternal = $derived(editPermissivenessChange);
// Delete confirm.
let topicToRemove = $state<Topic | null>(null);
let removingTopic = $state(false);
async function loadTopics(idOrSlug: string) {
try {
const r = await api.topics.list(idOrSlug);
topics = r.topics;
} catch {
topics = [];
}
}
async function submitCreateTopic(e: SubmitEvent) {
e.preventDefault();
if (!app) return;
creatingTopic = true;
createTopicError = null;
try {
await api.topics.create(app.id, {
name: createTopicName.trim(),
external_subscribable: createTopicExternal,
auth_mode: createTopicAuthMode
});
createTopicName = '';
createTopicExternal = false;
createTopicAuthMode = 'public';
await loadTopics(app.id);
} catch (err) {
createTopicError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
creatingTopic = false;
}
}
function openEditTopic(t: Topic) {
topicToEdit = t;
editTopicExternal = t.external_subscribable;
editTopicAuthMode = t.auth_mode;
editTopicError = null;
}
async function confirmEditTopic() {
if (!app || !topicToEdit) return;
savingTopic = true;
editTopicError = null;
try {
await api.topics.update(app.id, topicToEdit.name, {
external_subscribable: editTopicExternal,
auth_mode: editTopicAuthMode
});
topicToEdit = null;
await loadTopics(app.id);
} catch (err) {
editTopicError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
savingTopic = false;
}
}
async function confirmRemoveTopic() {
if (!app || !topicToRemove) return;
removingTopic = true;
try {
await api.topics.remove(app.id, topicToRemove.name);
topicToRemove = null;
await loadTopics(app.id);
} catch (err) {
createTopicError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
removingTopic = false;
}
}
// Secrets tab (v1.1.7). The dashboard only ever sees names +
// last-modified — values never leave the server. The create form's
// value input is masked by default; revealing it requires a confirm.
let secrets = $state<SecretListItem[]>([]);
let createSecretName = $state('');
let createSecretValue = $state('');
let showSecretValue = $state(false);
let revealConfirm = $state(false);
let creatingSecret = $state(false);
let createSecretError = $state<string | null>(null);
let secretToRemove = $state<SecretListItem | null>(null);
let removingSecret = $state(false);
// True when the name already exists — set is an overwrite.
const secretNameExists = $derived(
secrets.some((s) => s.name === createSecretName.trim())
);
async function loadSecrets(idOrSlug: string) {
try {
const r = await api.secrets.list(idOrSlug);
secrets = r.secrets;
} catch {
secrets = [];
}
}
function toggleShowSecretValue(e: Event) {
const target = e.currentTarget as HTMLInputElement;
if (target.checked) {
// Revealing a secret on screen is sensitive — gate behind a
// confirm. Revert the checkbox until the user confirms.
target.checked = false;
revealConfirm = true;
} else {
showSecretValue = false;
}
}
function confirmRevealSecret() {
showSecretValue = true;
revealConfirm = false;
}
async function submitCreateSecret(e: SubmitEvent) {
e.preventDefault();
if (!app) return;
creatingSecret = true;
createSecretError = null;
try {
await api.secrets.set(app.id, createSecretName.trim(), createSecretValue);
createSecretName = '';
createSecretValue = '';
showSecretValue = false;
await loadSecrets(app.id);
} catch (err) {
createSecretError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
creatingSecret = false;
}
}
async function confirmRemoveSecret() {
if (!app || !secretToRemove) return;
removingSecret = true;
try {
await api.secrets.remove(app.id, secretToRemove.name);
secretToRemove = null;
await loadSecrets(app.id);
} catch (err) {
createSecretError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
removingSecret = false;
}
}
// Members tab
let eligibleUsers = $state<AdminDto[]>([]);
let eligibleLoadError = $state<string | null>(null);
let addMemberUserId = $state('');
let addMemberRole = $state<AppRole>('viewer');
let addingMember = $state(false);
let addMemberError = $state<string | null>(null);
let memberToRemove = $state<AppMemberDto | null>(null);
let removingMember = $state(false);
let removeMemberError = $state<string | null>(null);
let roleChangeBusy = $state<string | null>(null);
let memberActionError = $state<string | null>(null);
async function loadApp() {
loading = true;
loadError = null;
try {
// F-U-014: fire the /version request alongside the app fetch so
// emailInboundUrl can prefer the server-known public_base_url.
api
.version()
.then((v) => (publicBaseUrl = v.public_base_url || null))
.catch(() => (publicBaseUrl = null));
const fetched = await api.apps.get(slug);
if (fetched.redirect_to && fetched.redirect_to !== slug) {
await goto(`${base}/apps/${fetched.redirect_to}`, { replaceState: true });
return;
}
app = {
id: fetched.id,
slug: fetched.slug,
name: fetched.name,
description: fetched.description,
created_at: fetched.created_at,
updated_at: fetched.updated_at
};
myRole = fetched.my_role;
editName = app.name;
editDescription = app.description ?? '';
editSlug = app.slug;
const loaders: Promise<unknown>[] = [
loadScripts(app.id),
loadDomains(app.id),
loadDeadLetterCount(app.id)
];
if (canAdmin) {
loaders.push(
loadMembers(app.id),
loadEligibleUsers(),
loadTriggers(app.id),
loadTopics(app.id),
loadSecrets(app.id)
);
}
await Promise.all(loaders);
} catch (e) {
loadError = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
async function loadScripts(appId: string) {
try {
scripts = await api.scripts.list({ app: appId });
} catch (e) {
scripts = [];
loadError = e instanceof Error ? e.message : String(e);
}
}
async function loadDomains(appId: string) {
try {
domains = await api.domains.listForApp(appId);
} catch (e) {
domains = [];
loadError = e instanceof Error ? e.message : String(e);
}
}
async function loadMembers(appId: string) {
try {
members = await api.appMembers.list(appId);
} catch (e) {
members = [];
memberActionError = e instanceof Error ? e.message : String(e);
}
}
async function loadEligibleUsers() {
eligibleLoadError = null;
try {
const all = await api.admins.list();
// Only inactive=false members are valid invite targets — the
// API rejects everyone else anyway, so filter upfront.
eligibleUsers = all.filter(
(u) => u.is_active && u.instance_role === 'member'
);
} catch (e) {
eligibleUsers = [];
// member-with-app_admin can hit /apps/.../members but cannot
// browse /admins (gated on InstanceManageUsers). The add form
// will render disabled with the explanatory message below.
eligibleLoadError =
e instanceof ApiError && e.status === 403
? 'Only instance owners/admins can browse the user directory to invite new members.'
: e instanceof Error
? e.message
: String(e);
}
}
const eligibleAfterFilter = $derived(
eligibleUsers.filter((u) => !members.some((m) => m.user_id === u.id))
);
async function submitCreateScript(event: Event) {
event.preventDefault();
if (!app) return;
creatingScript = true;
createScriptError = null;
try {
await api.scripts.create({
app_id: app.id,
name: createScriptName.trim(),
description: createScriptDescription.trim() || null,
source: createScriptSource,
kind: createScriptKind
});
showCreateScript = false;
createScriptName = '';
createScriptDescription = '';
createScriptSource = SAMPLE_SOURCE;
createScriptKind = 'endpoint';
await loadScripts(app.id);
} catch (e) {
createScriptError = e instanceof Error ? e.message : String(e);
if (e instanceof ApiError && e.status === 422) {
createScriptError = `Validation: ${createScriptError}`;
}
} finally {
creatingScript = false;
}
}
async function submitCreateDomain(event: Event) {
event.preventDefault();
if (!app) return;
creatingDomain = true;
createDomainError = null;
try {
await api.domains.create(app.id, createDomainPattern.trim());
createDomainPattern = '';
await loadDomains(app.id);
} catch (e) {
createDomainError = e instanceof Error ? e.message : String(e);
} finally {
creatingDomain = false;
}
}
function askRemoveDomain(d: AppDomain) {
removeDomainError = null;
domainToRemove = d;
}
async function confirmRemoveDomain() {
if (!app || !domainToRemove) return;
removingDomain = true;
removeDomainError = null;
try {
await api.domains.remove(app.id, domainToRemove.id);
domainToRemove = null;
await loadDomains(app.id);
} catch (e) {
removeDomainError = e instanceof Error ? e.message : String(e);
} finally {
removingDomain = false;
}
}
async function saveSettings(event: Event, forceTakeover = false) {
event.preventDefault();
if (!app) return;
savingSettings = true;
settingsError = null;
if (!forceTakeover) slugTakeoverNeeded = null;
try {
const slugChanged = editSlug.trim() !== app.slug;
const updated = await api.apps.update(app.id, {
name: editName.trim() !== app.name ? editName.trim() : undefined,
description:
editDescription !== (app.description ?? '')
? editDescription || null
: undefined,
slug: slugChanged ? editSlug.trim() : undefined,
force_takeover: forceTakeover || undefined
});
if (slugChanged) {
await goto(`${base}/apps/${updated.slug}`, { replaceState: true });
return;
}
app = updated;
} catch (e) {
if (e instanceof ApiError && e.status === 409 && e.body) {
const body = e.body as { conflict_kind?: string; current_app?: App };
if (body.conflict_kind === 'historical' && body.current_app) {
slugTakeoverNeeded = body.current_app;
settingsError = null;
return;
}
}
settingsError = e instanceof Error ? e.message : String(e);
} finally {
savingSettings = false;
}
}
async function submitAddMember(event: Event) {
event.preventDefault();
if (!app || !addMemberUserId) return;
addingMember = true;
addMemberError = null;
try {
await api.appMembers.add(app.id, {
user_id: addMemberUserId,
role: addMemberRole
});
addMemberUserId = '';
addMemberRole = 'viewer';
await loadMembers(app.id);
} catch (e) {
addMemberError = e instanceof Error ? e.message : String(e);
} finally {
addingMember = false;
}
}
async function changeMemberRole(member: AppMemberDto, role: AppRole) {
if (!app || member.role === role) return;
roleChangeBusy = member.user_id;
memberActionError = null;
try {
await api.appMembers.setRole(app.id, member.user_id, role);
await loadMembers(app.id);
} catch (e) {
memberActionError = e instanceof Error ? e.message : String(e);
} finally {
roleChangeBusy = null;
}
}
function askRemoveMember(member: AppMemberDto) {
removeMemberError = null;
memberToRemove = member;
}
async function confirmRemoveMember() {
if (!app || !memberToRemove) return;
removingMember = true;
removeMemberError = null;
try {
const removedSelf = !!me && memberToRemove.user_id === me.id;
await api.appMembers.remove(app.id, memberToRemove.user_id);
memberToRemove = null;
if (removedSelf) {
// We just revoked our own access to this app; the next
// fetch of /apps/{slug} would 403. Bounce back to the
// apps list rather than render a broken tab.
await goto(`${base}/apps`);
return;
}
await loadMembers(app.id);
} catch (e) {
removeMemberError = e instanceof Error ? e.message : String(e);
} finally {
removingMember = false;
}
}
function shortDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString();
} catch {
return iso;
}
}
function askDeleteApp() {
deleteAppError = null;
confirmingDeleteApp = true;
}
async function confirmDeleteApp() {
if (!app) return;
deletingApp = true;
deleteAppError = null;
try {
// force=true cascades scripts (and thereby their routes +
// execution logs); domains and slug-history rows cascade off
// the app row itself.
await api.apps.remove(app.id, { force: true });
await goto(`${base}/apps`);
} catch (e) {
deleteAppError = e instanceof Error ? e.message : String(e);
} finally {
deletingApp = false;
}
}
$effect(() => {
void loadApp();
});
// Defense-in-depth: a viewer / editor following a stale link to an
// admin-only tab (?tab=settings, etc.) gets bounced back to Scripts.
// The backend still 403s the underlying calls, but no point showing
// an empty tab. activeTab is URL-driven now so we update the URL.
$effect(() => {
if (
!canAdmin &&
(activeTab === 'settings' ||
activeTab === 'members' ||
activeTab === 'triggers' ||
activeTab === 'topics' ||
activeTab === 'secrets')
) {
void goto(`${base}/apps/${slug}?tab=scripts`, { replaceState: true });
}
});
</script>
{#if app}
{#if activeTab === 'scripts'}
<section>
<div class="row">
<h2>Scripts</h2>
{#if canWrite}
<button
type="button"
onclick={() => (showCreateScript = !showCreateScript)}
>
{showCreateScript ? 'Cancel' : 'New script'}
</button>
{/if}
</div>
{#if showCreateScript && canWrite}
<form class="create-form" onsubmit={submitCreateScript}>
<div class="row">
<label>
<span>Name</span>
<input bind:value={createScriptName} required placeholder="echo" />
</label>
<label>
<span>Kind</span>
<select bind:value={createScriptKind}>
<option value="endpoint">Endpoint (handles HTTP / triggers)</option>
<option value="module">Module (imported by other scripts)</option>
</select>
</label>
<label>
<span>Description</span>
<input bind:value={createScriptDescription} placeholder="optional" />
</label>
</div>
<label class="full">
<span>Source (Rhai)</span>
<CodeEditor bind:value={createScriptSource} language="rhai" minHeight="14rem" />
</label>
{#if createScriptKind === 'module'}
<p class="muted small">
Modules expose <code>fn</code> and <code>const</code> declarations to other
scripts via <code>import "name" as alias;</code>. They cannot be bound to
routes or used as trigger targets.
</p>
{/if}
{#if createScriptError}
<div class="error">{createScriptError}</div>
{/if}
<div class="actions">
<button type="submit" disabled={creatingScript}>
{creatingScript ? 'Creating…' : 'Create script'}
</button>
</div>
</form>
{/if}
{#if scripts.length === 0}
<p class="muted">No scripts in this app yet.</p>
{:else}
<ul class="list">
{#each scripts as script (script.id)}
<li>
<a href="{base}/scripts/{script.id}">
<div class="primary">
<strong>{script.name}</strong>
<span class="muted">v{script.version}</span>
{#if script.kind === 'module'}
<span class="kind-badge kind-module" title="Library imported by other scripts">module</span>
{:else}
<span class="kind-badge kind-endpoint" title="Handles HTTP routes and trigger events">endpoint</span>
{/if}
</div>
<div class="secondary muted">{script.description ?? '—'}</div>
</a>
</li>
{/each}
</ul>
{/if}
</section>
{:else if activeTab === 'domains'}
<section>
<h2>Domain claims</h2>
<p class="muted">
Hosts this app answers on. Routes inside this app can only bind to
these. Use <code>app.example.com</code> for exact, <code>*.example.com</code> for
wildcard, or <code>{'{'}tenant{'}'}.example.com</code> to bind a capture.
</p>
{#if canAdmin}
<form class="create-form inline" onsubmit={submitCreateDomain}>
<input
bind:value={createDomainPattern}
required
placeholder="app.example.com"
/>
<button type="submit" disabled={creatingDomain}>
{creatingDomain ? 'Adding…' : 'Add domain'}
</button>
</form>
{#if createDomainError}
<div class="error">{createDomainError}</div>
{/if}
{/if}
{#if domains.length === 0}
<p class="muted">No domain claims yet.</p>
{:else}
<ul class="list">
{#each domains as d (d.id)}
<li class="domain-row">
<div>
<code>{d.pattern}</code>
<span class="muted">{d.shape}</span>
</div>
{#if canAdmin}
<button
type="button"
class="secondary danger"
onclick={() => askRemoveDomain(d)}
>
Delete
</button>
{/if}
</li>
{/each}
</ul>
{/if}
</section>
{:else if activeTab === 'members' && canAdmin}
<section>
<h2>Members</h2>
<p class="muted">
Users with explicit access to this app. Instance owners and admins
already have implicit access — they are not listed here. Use the Users
page to invite a <code>member</code> first, then grant them app access
below.
</p>
<form class="create-form" onsubmit={submitAddMember}>
<div class="row">
<label class="grow">
<span>User</span>
<select
bind:value={addMemberUserId}
disabled={!!eligibleLoadError || eligibleAfterFilter.length === 0}
required
>
<option value="" disabled>Pick a member to invite…</option>
{#each eligibleAfterFilter as u (u.id)}
<option value={u.id}>{u.username}{u.email ? ` (${u.email})` : ''}</option>
{/each}
</select>
</label>
<label>
<span>Role</span>
<select bind:value={addMemberRole} disabled={!!eligibleLoadError}>
<option value="viewer">viewer</option>
<option value="editor">editor</option>
<option value="app_admin">app admin</option>
</select>
</label>
</div>
{#if eligibleLoadError}
<p class="muted">{eligibleLoadError}</p>
{:else if eligibleAfterFilter.length === 0}
<p class="muted">
No eligible users to invite. Create a <code>member</code> on the Users
page first.
</p>
{/if}
{#if addMemberError}
<div class="error">{addMemberError}</div>
{/if}
<div class="actions">
<button
type="submit"
disabled={addingMember || !addMemberUserId || !!eligibleLoadError}
>
{addingMember ? 'Adding…' : 'Add member'}
</button>
</div>
</form>
{#if memberActionError}
<div class="error">{memberActionError}</div>
{/if}
{#if members.length === 0}
<p class="muted">No explicit members yet.</p>
{:else}
<div class="table">
<div class="row head-row">
<div>User</div>
<div>Instance</div>
<div>App role</div>
<div>Joined</div>
<div class="actions-col"></div>
</div>
{#each members as m (m.user_id)}
<div class="row member-row" class:inactive={!m.is_active}>
<div>
<strong>{m.username}</strong>
{#if m.email}<span class="muted">{m.email}</span>{/if}
{#if !m.is_active}<span class="muted">(inactive)</span>{/if}
</div>
<div><RoleChip role={m.instance_role} size="sm" /></div>
<div><RoleChip appRole={m.role} size="sm" /></div>
<div>{shortDate(m.created_at)}</div>
<div class="actions-col">
<ActionMenu
label="Member actions for {m.username}"
items={[
{
label: 'Make app admin',
disabled:
m.role === 'app_admin' || roleChangeBusy === m.user_id,
onClick: () => changeMemberRole(m, 'app_admin')
},
{
label: 'Make editor',
disabled:
m.role === 'editor' || roleChangeBusy === m.user_id,
onClick: () => changeMemberRole(m, 'editor')
},
{
label: 'Make viewer',
disabled:
m.role === 'viewer' || roleChangeBusy === m.user_id,
onClick: () => changeMemberRole(m, 'viewer')
},
{
label: 'Remove from app',
danger: true,
onClick: () => askRemoveMember(m)
}
]}
/>
</div>
</div>
{/each}
</div>
{/if}
</section>
{:else if activeTab === 'triggers' && canAdmin}
<section>
<h2>Cron triggers</h2>
<p class="muted">
Run an endpoint script on a schedule. Schedules are 6-field cron
expressions (with seconds): <code>sec min hour day-of-month month day-of-week</code>.
The timezone disambiguates schedules like "every weekday at 9am".
</p>
<form class="create-form" onsubmit={submitCreateCron}>
<div class="row">
<label>
<span>Target script</span>
<select bind:value={createCronScriptId} required>
<option value="" disabled>Select an endpoint script…</option>
{#each endpointScripts as s (s.id)}
<option value={s.id}>{s.name}</option>
{/each}
</select>
</label>
<label>
<span>Schedule</span>
<input
bind:value={createCronSchedule}
required
placeholder="0 0 9 * * MON-FRI"
/>
</label>
<label>
<span>Timezone</span>
<select bind:value={createCronTimezone}>
{#each COMMON_TIMEZONES as tz (tz)}
<option value={tz}>{tz}</option>
{/each}
</select>
</label>
</div>
{#if endpointScripts.length === 0}
<p class="muted small">
This app has no endpoint scripts yet — create one first (modules
can't be trigger targets).
</p>
{/if}
{#if createCronError}
<div class="error">{createCronError}</div>
{/if}
<div class="actions">
<button type="submit" disabled={creatingCron || !createCronScriptId}>
{creatingCron ? 'Creating…' : 'Create cron trigger'}
</button>
</div>
</form>
<h2>Pub/Sub triggers</h2>
<p class="muted">
Subscribe an endpoint script to durable pub/sub messages. Topic
patterns are an exact topic (<code>user.created</code>), a prefix
wildcard (<code>user.*</code>), or <code>*</code> for every topic.
</p>
<form class="create-form" onsubmit={submitCreatePubsub}>
<div class="row">
<label>
<span>Target script</span>
<select bind:value={createPubsubScriptId} required>
<option value="" disabled>Select an endpoint script…</option>
{#each endpointScripts as s (s.id)}
<option value={s.id}>{s.name}</option>
{/each}
</select>
</label>
<label>
<span>Topic pattern</span>
<input bind:value={createPubsubTopic} required placeholder="user.*" />
</label>
</div>
{#if createPubsubError}
<div class="error">{createPubsubError}</div>
{/if}
<div class="actions">
<button
type="submit"
disabled={creatingPubsub || !createPubsubScriptId || !createPubsubTopic.trim()}
>
{creatingPubsub ? 'Creating…' : 'Create pub/sub trigger'}
</button>
</div>
</form>
<h2>Email triggers</h2>
<p class="muted">
Fire an endpoint script when your email provider POSTs an inbound
message to PiCloud. Configure your provider (Mailgun / Postmark /
SendGrid / SES) to POST the generic JSON shape below to the trigger's
webhook URL. Set a shared secret to require an
<code>X-Picloud-Signature</code> HMAC-SHA256 (hex of the request body);
leave it blank to accept unsigned POSTs (URL secrecy only).
</p>
<details class="muted small chevron">
<summary>Expected inbound JSON shape</summary>
<pre>{`{
"from": "sender@external.com",
"to": ["alice@myapp.com"],
"cc": [],
"subject": "...",
"text": "...",
"html": "...",
"message_id": "<abc@external.com>"
}`}</pre>
</details>
<form class="create-form" onsubmit={submitCreateEmail}>
<div class="row">
<label>
<span>Target script</span>
<select bind:value={createEmailScriptId} required>
<option value="" disabled>Select an endpoint script…</option>
{#each endpointScripts as s (s.id)}
<option value={s.id}>{s.name}</option>
{/each}
</select>
</label>
<label class="grow">
<span>Inbound HMAC secret (optional)</span>
<input
type="password"
bind:value={createEmailSecret}
placeholder="leave blank to accept unsigned POSTs"
/>
</label>
</div>
{#if createEmailError}
<div class="error">{createEmailError}</div>
{/if}
<div class="actions">
<button type="submit" disabled={creatingEmail || !createEmailScriptId}>
{creatingEmail ? 'Creating…' : 'Create email trigger'}
</button>
</div>
</form>
<h3>Queue:receive trigger (v1.1.9)</h3>
<p class="muted">
Register a script as the consumer for a per-app durable queue. Exactly
one consumer per queue is allowed — the registration will be rejected
if another consumer already exists. Use the
<a href="{base}/apps/{slug}/queues">Queues</a> tab for a read-only view
of message depths.
</p>
<form class="create-form" onsubmit={submitCreateQueue}>
<div class="row">
<label>
<span>Target script</span>
<select bind:value={createQueueScriptId} required>
<option value="" disabled>Select an endpoint script…</option>
{#each endpointScripts as s (s.id)}
<option value={s.id}>{s.name}</option>
{/each}
</select>
</label>
<label class="grow">
<span>Queue name</span>
<input
type="text"
bind:value={createQueueName}
placeholder="payments.process"
required
/>
</label>
</div>
<div class="row">
<label>
<span>Visibility timeout (53600s)</span>
<input
type="number"
bind:value={createQueueVisibilityTimeout}
min="5"
max="3600"
required
/>
</label>
<label>
<span>Max attempts (120)</span>
<input
type="number"
bind:value={createQueueMaxAttempts}
min="1"
max="20"
required
/>
</label>
</div>
{#if createQueueError}
<div class="error">{createQueueError}</div>
{/if}
<div class="actions">
<button
type="submit"
disabled={creatingQueue || !createQueueScriptId || !createQueueName}
>
{creatingQueue ? 'Creating…' : 'Create queue:receive trigger'}
</button>
</div>
</form>
<!-- F-U-001: KV / Docs / Files / Dead-letter trigger create forms. -->
<h3>Create kv:event trigger</h3>
<form class="create-form" onsubmit={submitCreateKv}>
<div class="row">
<label>
<span>Target script</span>
<select bind:value={createKvScriptId} required>
<option value="" disabled>Select an endpoint script…</option>
{#each endpointScripts as s (s.id)}
<option value={s.id}>{s.name}</option>
{/each}
</select>
</label>
<label class="grow">
<span>Collection glob</span>
<input
type="text"
bind:value={createKvCollectionGlob}
placeholder="users.* or *"
required
/>
</label>
</div>
<div class="row">
<span>Ops:</span>
<label><input type="checkbox" bind:checked={createKvOps.insert} /> insert</label>
<label><input type="checkbox" bind:checked={createKvOps.update} /> update</label>
<label><input type="checkbox" bind:checked={createKvOps.delete} /> delete</label>
</div>
{#if createKvError}<div class="error">{createKvError}</div>{/if}
<div class="actions">
<button type="submit" disabled={creatingKv || !createKvScriptId || !createKvCollectionGlob}>
{creatingKv ? 'Creating…' : 'Create kv:event trigger'}
</button>
</div>
</form>
<h3>Create docs:event trigger</h3>
<form class="create-form" onsubmit={submitCreateDocs}>
<div class="row">
<label>
<span>Target script</span>
<select bind:value={createDocsScriptId} required>
<option value="" disabled>Select an endpoint script…</option>
{#each endpointScripts as s (s.id)}
<option value={s.id}>{s.name}</option>
{/each}
</select>
</label>
<label class="grow">
<span>Collection glob</span>
<input
type="text"
bind:value={createDocsCollectionGlob}
placeholder="users.* or *"
required
/>
</label>
</div>
<div class="row">
<span>Ops:</span>
<label><input type="checkbox" bind:checked={createDocsOps.create} /> create</label>
<label><input type="checkbox" bind:checked={createDocsOps.update} /> update</label>
<label><input type="checkbox" bind:checked={createDocsOps.delete} /> delete</label>
</div>
{#if createDocsError}<div class="error">{createDocsError}</div>{/if}
<div class="actions">
<button
type="submit"
disabled={creatingDocs || !createDocsScriptId || !createDocsCollectionGlob}
>
{creatingDocs ? 'Creating…' : 'Create docs:event trigger'}
</button>
</div>
</form>
<h3>Create files:event trigger</h3>
<form class="create-form" onsubmit={submitCreateFiles}>
<div class="row">
<label>
<span>Target script</span>
<select bind:value={createFilesScriptId} required>
<option value="" disabled>Select an endpoint script…</option>
{#each endpointScripts as s (s.id)}
<option value={s.id}>{s.name}</option>
{/each}
</select>
</label>
<label class="grow">
<span>Collection glob</span>
<input
type="text"
bind:value={createFilesCollectionGlob}
placeholder="avatars.* or *"
required
/>
</label>
</div>
<div class="row">
<span>Ops:</span>
<label><input type="checkbox" bind:checked={createFilesOps.create} /> create</label>
<label><input type="checkbox" bind:checked={createFilesOps.update} /> update</label>
<label><input type="checkbox" bind:checked={createFilesOps.delete} /> delete</label>
</div>
{#if createFilesError}<div class="error">{createFilesError}</div>{/if}
<div class="actions">
<button
type="submit"
disabled={creatingFiles || !createFilesScriptId || !createFilesCollectionGlob}
>
{creatingFiles ? 'Creating…' : 'Create files:event trigger'}
</button>
</div>
</form>
<h3>Create dead_letter trigger</h3>
<p class="hint">
Fires when any other trigger or route dead-letters. All filter fields are optional;
leaving them blank routes every dead-letter to this script.
</p>
<form class="create-form" onsubmit={submitCreateDeadLetter}>
<div class="row">
<label>
<span>Target script</span>
<select bind:value={createDeadLetterScriptId} required>
<option value="" disabled>Select an endpoint script…</option>
{#each endpointScripts as s (s.id)}
<option value={s.id}>{s.name}</option>
{/each}
</select>
</label>
<label class="grow">
<span>Source filter (optional)</span>
<input
type="text"
bind:value={createDeadLetterSourceFilter}
placeholder="kv, docs, queue, pubsub, …"
/>
</label>
</div>
<div class="row">
<label class="grow">
<span>Trigger ID filter (optional)</span>
<input type="text" bind:value={createDeadLetterTriggerIdFilter} />
</label>
<label class="grow">
<span>Script ID filter (optional)</span>
<input type="text" bind:value={createDeadLetterScriptIdFilter} />
</label>
</div>
{#if createDeadLetterError}<div class="error">{createDeadLetterError}</div>{/if}
<div class="actions">
<button type="submit" disabled={creatingDeadLetter || !createDeadLetterScriptId}>
{creatingDeadLetter ? 'Creating…' : 'Create dead_letter trigger'}
</button>
</div>
</form>
{#if triggers.length === 0}
<p class="muted">No triggers in this app yet.</p>
{:else}
<ul class="list">
{#each triggers as t (t.id)}
<li class="domain-row">
<div>
<span class="kind-badge">{t.kind}</span>
{#if !t.enabled}
<!-- F-U-006: surface the trigger.enabled flag. No PATCH endpoint
yet, so this is read-only; toggle UI tracked for a follow-up. -->
<span class="muted small" title="Trigger is disabled — events are not dispatched.">• disabled</span>
{/if}
{#if t.details.kind === 'cron'}
<code>{t.details.schedule}</code>
<span class="muted">{t.details.timezone}</span>
<span class="muted small">
last fired: {t.details.last_fired_at ?? 'never'}
</span>
{:else if t.details.kind === 'kv' || t.details.kind === 'docs' || t.details.kind === 'files'}
<code>{t.details.collection_glob}</code>
<span class="muted">{t.details.ops.join(', ') || 'any op'}</span>
{:else if t.details.kind === 'pubsub'}
<code>{t.details.topic_pattern}</code>
{:else if t.details.kind === 'email'}
<span class="muted">
{t.details.has_inbound_secret ? 'signed (HMAC)' : 'unsigned'}
</span>
<code class="webhook-url">{emailInboundUrl(t.id)}</code>
{:else if t.details.kind === 'queue'}
<code>{t.details.queue_name}</code>
<span class="muted">— vt {t.details.visibility_timeout_secs}s</span>
<span class="muted small">
last fired: {t.details.last_fired_at ?? 'never'}
</span>
{/if}
<span class="muted small">{t.script_id}</span>
</div>
<button
type="button"
class="secondary danger"
onclick={() => (triggerToRemove = t)}
>
Delete
</button>
</li>
{/each}
</ul>
{/if}
</section>
{:else if activeTab === 'topics' && canAdmin}
<section>
<h2>Realtime topics</h2>
<p class="muted">
Pub/sub topics are <strong>internal-only</strong> by default — scripts
subscribe via triggers, browsers can't. Register a topic here and mark it
<strong>externally subscribable</strong> to let frontend clients connect over
SSE at <code>/realtime/topics/&lt;name&gt;</code>. <code>public</code> topics
need no auth; <code>token</code> topics require a subscriber token minted by a
script via <code>pubsub::subscriber_token</code>.
</p>
<form class="create-form" onsubmit={submitCreateTopic}>
<div class="row">
<label class="grow">
<span>Topic name</span>
<input bind:value={createTopicName} required placeholder="chat-room-updates" />
</label>
</div>
<label class="checkbox-row">
<input type="checkbox" bind:checked={createTopicExternal} />
<span>Externally subscribable (allow browser SSE clients to subscribe)</span>
</label>
{#if createTopicExternal}
<fieldset class="auth-mode">
<legend>Auth mode</legend>
<label class="radio-row">
<input type="radio" value="public" bind:group={createTopicAuthMode} />
<span><strong>public</strong> — anyone with the URL can subscribe</span>
</label>
<label class="radio-row">
<input type="radio" value="token" bind:group={createTopicAuthMode} />
<span><strong>token</strong> — requires a valid subscriber token</span>
</label>
<label class="radio-row">
<input type="radio" value="session" bind:group={createTopicAuthMode} />
<span>
<strong>session</strong> — requires a per-app user session
minted by <code>users::login</code> (v1.1.8)
</span>
</label>
</fieldset>
{/if}
{#if createTopicError}
<div class="error">{createTopicError}</div>
{/if}
<div class="actions">
<button type="submit" disabled={creatingTopic || !createTopicName.trim()}>
{creatingTopic ? 'Creating…' : 'Register topic'}
</button>
</div>
</form>
{#if topics.length === 0}
<p class="muted">No registered topics in this app yet.</p>
{:else}
<ul class="list">
{#each topics as t (t.name)}
<li class="domain-row">
<div>
<code>{t.name}</code>
{#if t.external_subscribable}
<span class="badge badge-external" title="Browser clients can subscribe over SSE">
external
</span>
<span class="badge badge-auth">{t.auth_mode}</span>
{:else}
<span class="badge badge-internal" title="Internal-only: scripts subscribe via triggers">
internal
</span>
{/if}
<span class="muted small">· {shortDate(t.created_at)}</span>
</div>
<div class="topic-actions">
<button type="button" class="secondary" onclick={() => openEditTopic(t)}>
Edit
</button>
<button
type="button"
class="secondary danger"
onclick={() => (topicToRemove = t)}
>
Delete
</button>
</div>
</li>
{/each}
</ul>
{/if}
</section>
{:else if activeTab === 'secrets' && canAdmin}
<section>
<h2>Secrets</h2>
<p class="muted">
Encrypted per-app configuration (API keys, OAuth tokens, webhook signing
keys), available to scripts as <code>secrets::get("name")</code>. Values are
encrypted at rest with the process master key and
<strong>never leave the server</strong> — this list shows names and
last-modified times only.
</p>
<form class="create-form" onsubmit={submitCreateSecret}>
<div class="row">
<label class="grow">
<span>Name</span>
<input bind:value={createSecretName} required placeholder="stripe_key" />
</label>
</div>
<label class="grow">
<span>Value</span>
{#if showSecretValue}
<input type="text" bind:value={createSecretValue} placeholder="sk_live_…" />
{:else}
<input type="password" bind:value={createSecretValue} placeholder="sk_live_…" />
{/if}
</label>
<label class="checkbox-row">
<input type="checkbox" checked={showSecretValue} onchange={toggleShowSecretValue} />
<span>Show value</span>
</label>
{#if secretNameExists && createSecretName.trim()}
<p class="muted small">
A secret named <code>{createSecretName.trim()}</code> already exists — saving
overwrites it.
</p>
{/if}
{#if createSecretError}
<div class="error">{createSecretError}</div>
{/if}
<div class="actions">
<button type="submit" disabled={creatingSecret || !createSecretName.trim()}>
{creatingSecret ? 'Saving…' : secretNameExists ? 'Overwrite secret' : 'Save secret'}
</button>
</div>
</form>
{#if secrets.length === 0}
<p class="muted">No secrets in this app yet.</p>
{:else}
<ul class="list">
{#each secrets as s (s.name)}
<li class="domain-row">
<div>
<code>{s.name}</code>
<span class="muted small">· updated {shortDate(s.updated_at)}</span>
</div>
<div class="topic-actions">
<button
type="button"
class="secondary danger"
onclick={() => (secretToRemove = s)}
>
Delete
</button>
</div>
</li>
{/each}
</ul>
{/if}
</section>
{:else if activeTab === 'settings' && canAdmin}
<section>
<h2>Settings</h2>
<form class="create-form" onsubmit={(e) => saveSettings(e)}>
<label>
<span>Name</span>
<input bind:value={editName} required />
</label>
<label>
<span>Description</span>
<input bind:value={editDescription} />
</label>
<label>
<span>Slug</span>
<input
bind:value={editSlug}
required
pattern="[a-z0-9][a-z0-9-]*"
/>
<small class="muted">
Renaming records the old slug as a permanent 301 redirect.
</small>
</label>
{#if slugTakeoverNeeded}
<div class="warning">
<strong>Slug previously redirected.</strong>
<p>
<code>{editSlug}</code> currently redirects to
<code>{slugTakeoverNeeded.slug}</code>. Renaming to it will break old
links.
</p>
<div class="actions">
<button
type="button"
class="secondary"
onclick={() => (slugTakeoverNeeded = null)}
>
Cancel
</button>
<button
type="button"
onclick={(e) => saveSettings(e, true)}
disabled={savingSettings}
>
{savingSettings ? 'Renaming…' : 'Rename anyway'}
</button>
</div>
</div>
{:else if settingsError}
<div class="error">{settingsError}</div>
{/if}
{#if !slugTakeoverNeeded}
<div class="actions">
<button type="submit" disabled={savingSettings}>
{savingSettings ? 'Saving…' : 'Save changes'}
</button>
</div>
{/if}
</form>
<div class="danger-zone">
<h3>Delete app</h3>
<p class="muted">
Permanently removes the app along with all its scripts, routes,
execution logs, and domain claims.
</p>
<button type="button" class="danger" onclick={askDeleteApp}>Delete app</button>
</div>
</section>
{/if}
{#if confirmingDeleteApp}
<ConfirmModal
title="Delete app “{app.name}”"
variant="danger"
confirmLabel="Delete app"
busyLabel="Deleting…"
confirmPhrase={app.slug}
confirmPhrasePrompt="Type the app slug to confirm:"
busy={deletingApp}
onConfirm={confirmDeleteApp}
onCancel={() => (confirmingDeleteApp = false)}
>
<p>
This will <strong>permanently delete</strong> everything inside
<strong>{app.name}</strong>. There is no undo.
</p>
<ul class="impact-list">
<li>
<span>Scripts</span><strong>{scripts.length}</strong>
</li>
<li>
<span>Domain claims</span><strong>{domains.length}</strong>
</li>
<li>
<span>Routes &amp; execution logs</span><strong>all</strong>
</li>
</ul>
{#if domains.length > 0}
<p>The following hosts will stop pointing at this app:</p>
<ul class="impact-list">
{#each domains as d (d.id)}
<li>
<code>{d.pattern}</code><span class="muted">{d.shape}</span>
</li>
{/each}
</ul>
{/if}
{#if deleteAppError}
<p class="modal-error">{deleteAppError}</p>
{/if}
</ConfirmModal>
{/if}
{#if domainToRemove}
<ConfirmModal
title="Delete domain claim"
variant="danger"
confirmLabel="Delete claim"
busyLabel="Deleting…"
busy={removingDomain}
onConfirm={confirmRemoveDomain}
onCancel={() => (domainToRemove = null)}
>
<p>
<strong>{app.name}</strong> will stop answering on
<code>{domainToRemove.pattern}</code>.
</p>
<p class="muted">
Routes already bound to this host are blocked from deletion by the
API; if so, youll see an error here.
</p>
{#if removeDomainError}
<p class="modal-error">{removeDomainError}</p>
{/if}
</ConfirmModal>
{/if}
{#if memberToRemove}
<ConfirmModal
title="Remove {memberToRemove.username} from {app.name}"
variant="danger"
confirmLabel="Remove member"
busyLabel="Removing…"
busy={removingMember}
onConfirm={confirmRemoveMember}
onCancel={() => (memberToRemove = null)}
>
<p>
<strong>{memberToRemove.username}</strong> will lose access to this
app. Their other app memberships and account are untouched.
</p>
{#if removeMemberError}
<p class="modal-error">{removeMemberError}</p>
{/if}
</ConfirmModal>
{/if}
{#if triggerToRemove}
<ConfirmModal
title="Delete trigger"
variant="danger"
confirmLabel="Delete trigger"
busyLabel="Deleting…"
busy={removingTrigger}
onConfirm={confirmRemoveTrigger}
onCancel={() => (triggerToRemove = null)}
>
<p>
This {triggerToRemove.kind} trigger will stop firing. The target
script is not affected.
</p>
</ConfirmModal>
{/if}
{#if topicToEdit}
<ConfirmModal
title="Edit topic “{topicToEdit.name}”"
confirmLabel="Save changes"
busyLabel="Saving…"
busy={savingTopic}
onConfirm={confirmEditTopic}
onCancel={() => (topicToEdit = null)}
>
<label class="checkbox-row">
<input type="checkbox" bind:checked={editTopicExternal} />
<span>Externally subscribable</span>
</label>
{#if editTopicExternal}
<fieldset class="auth-mode">
<legend>Auth mode</legend>
<label class="radio-row">
<input type="radio" value="public" bind:group={editTopicAuthMode} />
<span><strong>public</strong> — anyone with the URL can subscribe</span>
</label>
<label class="radio-row">
<input type="radio" value="token" bind:group={editTopicAuthMode} />
<span><strong>token</strong> — requires a valid subscriber token</span>
</label>
<label class="radio-row">
<input type="radio" value="session" bind:group={editTopicAuthMode} />
<span>
<strong>session</strong> — requires a per-app user session minted
by <code>users::login</code> (v1.1.8)
</span>
</label>
</fieldset>
{/if}
{#if editFlipToExternal}
<div class="warning">
Marking <strong>{topicToEdit.name}</strong> externally-subscribable means
anyone with the URL can subscribe to this topic (if auth_mode is
<code>public</code>) or anyone with a valid token can subscribe (if
auth_mode is <code>token</code>). Are you sure?
</div>
{/if}
{#if editTopicError}
<p class="modal-error">{editTopicError}</p>
{/if}
</ConfirmModal>
{/if}
{#if topicToRemove}
<ConfirmModal
title="Delete topic “{topicToRemove.name}”"
variant="danger"
confirmLabel="Delete topic"
busyLabel="Deleting…"
busy={removingTopic}
onConfirm={confirmRemoveTopic}
onCancel={() => (topicToRemove = null)}
>
<p>
Unregistering <code>{topicToRemove.name}</code> disconnects any live SSE
subscribers immediately. Scripts can still <code>publish_durable</code> to
it (internal triggers keep working) — it just won't be externally
subscribable.
</p>
</ConfirmModal>
{/if}
{#if revealConfirm}
<ConfirmModal
title="Reveal secret value?"
confirmLabel="Show value"
onConfirm={confirmRevealSecret}
onCancel={() => (revealConfirm = false)}
>
<p>
The value you type will be shown in plain text on screen. Make sure no one
is looking over your shoulder and that screen-sharing is off.
</p>
</ConfirmModal>
{/if}
{#if secretToRemove}
<ConfirmModal
title="Delete secret “{secretToRemove.name}”"
variant="danger"
confirmLabel="Delete secret"
busyLabel="Deleting…"
busy={removingSecret}
onConfirm={confirmRemoveSecret}
onCancel={() => (secretToRemove = null)}
>
<p>
Deleting <code>{secretToRemove.name}</code> is permanent. Any script calling
<code>secrets::get("{secretToRemove.name}")</code> will get <code>()</code>
until you set it again.
</p>
</ConfirmModal>
{/if}
{/if}
<style>
h2 {
font-size: 1.125rem;
margin: 0 0 1rem;
}
h3 {
font-size: 1rem;
margin: 0 0 0.5rem;
}
button {
background: #38bdf8;
color: #0b1220;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 600;
cursor: pointer;
}
button.secondary {
background: transparent;
color: #94a3b8;
border: 1px solid #334155;
}
button.danger {
background: #7f1d1d;
color: #fecaca;
}
button.secondary.danger {
background: transparent;
color: #fca5a5;
border-color: #7f1d1d;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.muted {
color: #64748b;
}
.error {
border: 1px solid #b91c1c;
background: #450a0a;
color: #fecaca;
padding: 1rem;
border-radius: 0.5rem;
margin: 1rem 0;
}
.warning {
border: 1px solid #ca8a04;
background: #3f2e07;
color: #fde68a;
padding: 1rem;
border-radius: 0.5rem;
margin: 1rem 0;
}
.warning code {
background: #1e293b;
padding: 0.1rem 0.3rem;
border-radius: 0.25rem;
}
.create-form {
background: #1e293b;
border-radius: 0.5rem;
padding: 1.25rem;
margin-bottom: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.create-form.inline {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.create-form .row {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 0.75rem;
}
.create-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
color: #cbd5e1;
}
.create-form label.full {
grid-column: 1 / -1;
}
.create-form input {
background: #0b1220;
color: #e2e8f0;
border: 1px solid #334155;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
font: inherit;
flex: 1;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.list a {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.85rem 1rem;
background: #1e293b;
border-radius: 0.375rem;
text-decoration: none;
color: inherit;
}
.list a:hover {
background: #283549;
}
.domain-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.85rem 1rem;
background: #1e293b;
border-radius: 0.375rem;
}
.domain-row code {
background: #0b1220;
padding: 0.15rem 0.4rem;
border-radius: 0.25rem;
}
.primary {
display: flex;
gap: 0.5rem;
align-items: baseline;
}
.secondary {
font-size: 0.875rem;
}
.danger-zone {
margin-top: 2rem;
padding: 1rem;
border: 1px solid #7f1d1d;
border-radius: 0.5rem;
background: #1e0a0a;
}
.create-form select {
background: #0b1220;
color: #e2e8f0;
border: 1px solid #334155;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
font: inherit;
}
.create-form .row > label.grow {
grid-column: span 2;
}
.table {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.table .row {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr 3rem;
gap: 0.75rem;
padding: 0.85rem 1rem;
background: #1e293b;
border-radius: 0.375rem;
align-items: center;
margin: 0;
}
.table .head-row {
background: transparent;
padding: 0.25rem 1rem;
color: #64748b;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.table .member-row.inactive {
opacity: 0.55;
}
.table .member-row strong {
margin-right: 0.4rem;
}
.table .member-row .muted {
font-size: 0.8rem;
}
.table .actions-col {
display: flex;
justify-content: flex-end;
}
.kind-badge {
display: inline-block;
padding: 0 0.45rem;
margin-left: 0.5rem;
border-radius: 0.25rem;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
line-height: 1.5;
}
.kind-endpoint {
background: #1e3a5f;
color: #93c5fd;
}
.kind-module {
background: #3f2e7d;
color: #c4b5fd;
}
.small {
font-size: 0.85rem;
}
.checkbox-row,
.radio-row {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.checkbox-row input,
.radio-row input {
flex: none;
width: auto;
}
.auth-mode {
border: 1px solid #334155;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.auth-mode legend {
font-size: 0.8rem;
color: #94a3b8;
padding: 0 0.3rem;
}
.topic-actions {
display: flex;
gap: 0.5rem;
}
.badge {
display: inline-block;
padding: 0 0.45rem;
margin-left: 0.4rem;
border-radius: 0.25rem;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
line-height: 1.5;
}
.badge-external {
background: #064e3b;
color: #6ee7b7;
}
.badge-internal {
background: #334155;
color: #cbd5e1;
}
.badge-auth {
background: #1e3a5f;
color: #93c5fd;
}
</style>