feat(v1.1.7-secrets): secrets SDK + table + admin API + dashboard

Encrypted per-app secrets, reachable from scripts as
secrets::{get,set,delete,list}(name) and managed from the dashboard
Secrets tab. Values are AES-256-GCM-sealed with the process master key
(picloud_shared::crypto) before they touch Postgres; the repo only ever
sees ciphertext + nonce. JSON round-trip preserves Rhai types.

- migration 0023_secrets.sql (PRIMARY KEY (app_id, name)).
- SecretsService trait (picloud-shared) + SecretsServiceImpl + repo
  (manager-core), wired into the Services bundle and Rhai engine.
- Capability::AppSecretsRead/Write (→ script:read / script:write); no
  new Scope variants (seven-scope commitment).
- Admin API GET/POST/DELETE /apps/{id}/secrets (list returns names +
  updated_at, never values).
- build_app now takes a MasterKey, sourced from PICLOUD_SECRET_KEY in
  main.rs; test callers pass a fixed test key.
- 64 KB value cap (PICLOUD_SECRET_MAX_VALUE_BYTES); no ServiceEvent
  emission (secret writes don't fire triggers, by design).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-04 21:37:17 +02:00
parent dc2e4fa01f
commit 2d11090d1a
28 changed files with 1959 additions and 35 deletions

View File

@@ -292,6 +292,11 @@ export interface UpdateTopicInput {
auth_mode?: TopicAuthMode;
}
export interface SecretListItem {
name: string;
updated_at: string;
}
export interface ExecutionResult {
status: number;
headers: Record<string, string>;
@@ -714,6 +719,27 @@ export const api = {
)
},
secrets: {
// List returns names + last-modified ONLY — values never leave the
// server (v1.1.7).
list: (idOrSlug: string) =>
adminRequest<{ secrets: SecretListItem[]; next_cursor: string | null }>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/secrets`
),
// `value` is any JSON value; the dashboard sends a single-line
// string. Overwrites if the name already exists.
set: (idOrSlug: string, name: string, value: unknown) =>
adminRequest<null>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/secrets`, {
method: 'POST',
body: JSON.stringify({ name, value })
}),
remove: (idOrSlug: string, name: string) =>
adminRequest<null>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/secrets/${encodeURIComponent(name)}`,
{ method: 'DELETE' }
)
},
execute: async (
id: string,
body: unknown,

View File

@@ -13,7 +13,8 @@
type Script,
type Trigger,
type Topic,
type TopicAuthMode
type TopicAuthMode,
type SecretListItem
} from '$lib/api';
import CodeEditor from '$lib/CodeEditor.svelte';
import ConfirmModal from '$lib/ConfirmModal.svelte';
@@ -27,7 +28,7 @@
const SAMPLE_SOURCE =
'#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
type Tab = 'scripts' | 'domains' | 'members' | 'settings' | 'triggers' | 'topics';
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.
@@ -290,6 +291,83 @@
}
}
// 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);
@@ -334,7 +412,8 @@
loadMembers(app.id),
loadEligibleUsers(),
loadTriggers(app.id),
loadTopics(app.id)
loadTopics(app.id),
loadSecrets(app.id)
);
}
await Promise.all(loaders);
@@ -607,7 +686,8 @@
(activeTab === 'settings' ||
activeTab === 'members' ||
activeTab === 'triggers' ||
activeTab === 'topics')
activeTab === 'topics' ||
activeTab === 'secrets')
) {
activeTab = 'scripts';
}
@@ -660,6 +740,11 @@
class:active={activeTab === 'topics'}
onclick={() => (activeTab = 'topics')}>Topics ({topics.length})</button
>
<button
type="button"
class:active={activeTab === 'secrets'}
onclick={() => (activeTab = 'secrets')}>Secrets ({secrets.length})</button
>
<button
type="button"
class:active={activeTab === 'settings'}
@@ -1131,6 +1216,76 @@
</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>
@@ -1364,6 +1519,38 @@
</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>