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:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user