feat(v1.1.4): outbound HTTP SDK + cron triggers
HTTP (`http::*`):
- `HttpService` trait (picloud-shared) + reqwest-backed `HttpServiceImpl`
(manager-core), wired into the `Services` bundle.
- SSRF deny-list applied to the resolved IP via a custom reqwest
`dns_resolver` (covers every redirect hop + defeats DNS rebinding) plus
a literal-IP check at URL-parse time. Scheme/port restrictions, request
+ response body caps (stream-with-cap), layered timeout. Error reason is
a CIDR category, never the IP. `PICLOUD_HTTP_ALLOW_PRIVATE` dev override
(logs a startup warning).
- Rhai bridge with three-arg split `verb(url, body, opts)` (resolves the
brief's body-vs-opts contradiction; unknown opt keys throw). Body
dispatch by type; response `#{status,headers,body,body_raw}` with JSON
auto-parse; non-2xx does not throw.
- `Capability::AppHttpRequest` → existing `script:write` scope (no new
Scope variant). `SdkCallCx` gains `script_id` (attribution + User-Agent).
Cron triggers (4th trigger kind):
- Migration 0017 widens the kind/source_kind CHECKs and adds
`cron_trigger_details`. `cron`/`chrono-tz` parse + validate 6-field
schedules and IANA timezones.
- `spawn_cron_scheduler` polls due triggers and enqueues to the universal
outbox; the dispatcher delivers them (one-line match-arm extension).
Catch-up fires exactly once per trigger per tick, not once per missed
window. `ctx.event.cron` for handlers.
- `POST /api/v1/admin/apps/{id}/triggers/cron` reuses the v1.1.3
cross-app + kind!=module target check.
- Dashboard: admin-gated Triggers tab (cron create form + list).
Follow-ups: redact module backend errors at the resolver boundary (log
original at error level); pin `rhai = "=1.24"`; CHANGELOG incl. retroactive
v1.1.3 cross-app-trigger security note. Version bumps: workspace 1.1.4,
SDK 1.5, dashboard 0.10.0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picloud-dashboard",
|
||||
"version": "0.9.0",
|
||||
"version": "0.10.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -211,6 +211,42 @@ export interface DeadLetterRow {
|
||||
resolution: 'replayed' | 'ignored' | 'handled_by_script' | 'handler_failed' | null;
|
||||
}
|
||||
|
||||
export type TriggerKind = 'kv' | 'docs' | 'dead_letter' | 'cron';
|
||||
export type TriggerDispatchMode = 'sync' | 'async';
|
||||
|
||||
/// Per-kind detail, tagged by `kind` to match the Rust serde shape.
|
||||
export type TriggerDetails =
|
||||
| { kind: 'kv'; collection_glob: string; ops: string[] }
|
||||
| { kind: 'docs'; collection_glob: string; ops: string[] }
|
||||
| { kind: 'dead_letter'; source_filter?: string; trigger_id_filter?: string; script_id_filter?: string }
|
||||
| { kind: 'cron'; schedule: string; timezone: string; last_fired_at?: string | null };
|
||||
|
||||
export interface Trigger {
|
||||
id: string;
|
||||
app_id: string;
|
||||
script_id: string;
|
||||
kind: TriggerKind;
|
||||
enabled: boolean;
|
||||
dispatch_mode: TriggerDispatchMode;
|
||||
retry_max_attempts: number;
|
||||
retry_backoff: 'exponential' | 'linear' | 'constant';
|
||||
retry_base_ms: number;
|
||||
registered_by_principal: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
details: TriggerDetails;
|
||||
}
|
||||
|
||||
export interface CreateCronTriggerInput {
|
||||
script_id: string;
|
||||
schedule: string;
|
||||
timezone: string;
|
||||
dispatch_mode?: TriggerDispatchMode;
|
||||
retry_max_attempts?: number;
|
||||
retry_backoff?: 'exponential' | 'linear' | 'constant';
|
||||
retry_base_ms?: number;
|
||||
}
|
||||
|
||||
export interface ExecutionResult {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
@@ -572,6 +608,23 @@ export const api = {
|
||||
)
|
||||
},
|
||||
|
||||
triggers: {
|
||||
list: (idOrSlug: string) =>
|
||||
adminRequest<{ triggers: Trigger[] }>(
|
||||
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers`
|
||||
),
|
||||
createCron: (idOrSlug: string, input: CreateCronTriggerInput) =>
|
||||
adminRequest<Trigger>(
|
||||
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/cron`,
|
||||
{ method: 'POST', body: JSON.stringify(input) }
|
||||
),
|
||||
remove: (idOrSlug: string, triggerId: string) =>
|
||||
adminRequest<null>(
|
||||
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/${triggerId}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
},
|
||||
|
||||
execute: async (
|
||||
id: string,
|
||||
body: unknown,
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
type AppDomain,
|
||||
type AppMemberDto,
|
||||
type AppRole,
|
||||
type Script
|
||||
type Script,
|
||||
type Trigger
|
||||
} from '$lib/api';
|
||||
import CodeEditor from '$lib/CodeEditor.svelte';
|
||||
import ConfirmModal from '$lib/ConfirmModal.svelte';
|
||||
@@ -24,7 +25,26 @@
|
||||
const SAMPLE_SOURCE =
|
||||
'#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
|
||||
|
||||
type Tab = 'scripts' | 'domains' | 'members' | 'settings';
|
||||
type Tab = 'scripts' | 'domains' | 'members' | 'settings' | 'triggers';
|
||||
|
||||
// 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);
|
||||
@@ -91,6 +111,63 @@
|
||||
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);
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Members tab
|
||||
let eligibleUsers = $state<AdminDto[]>([]);
|
||||
let eligibleLoadError = $state<string | null>(null);
|
||||
@@ -131,7 +208,7 @@
|
||||
loadDeadLetterCount(app.id)
|
||||
];
|
||||
if (canAdmin) {
|
||||
loaders.push(loadMembers(app.id), loadEligibleUsers());
|
||||
loaders.push(loadMembers(app.id), loadEligibleUsers(), loadTriggers(app.id));
|
||||
}
|
||||
await Promise.all(loaders);
|
||||
} catch (e) {
|
||||
@@ -398,7 +475,10 @@
|
||||
// backend still 403s the underlying calls, but no point showing an
|
||||
// empty tab.
|
||||
$effect(() => {
|
||||
if (!canAdmin && (activeTab === 'settings' || activeTab === 'members')) {
|
||||
if (
|
||||
!canAdmin &&
|
||||
(activeTab === 'settings' || activeTab === 'members' || activeTab === 'triggers')
|
||||
) {
|
||||
activeTab = 'scripts';
|
||||
}
|
||||
});
|
||||
@@ -440,6 +520,11 @@
|
||||
class:active={activeTab === 'members'}
|
||||
onclick={() => (activeTab = 'members')}>Members ({members.length})</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class:active={activeTab === 'triggers'}
|
||||
onclick={() => (activeTab = 'triggers')}>Triggers ({triggers.length})</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class:active={activeTab === 'settings'}
|
||||
@@ -698,6 +783,91 @@
|
||||
</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>
|
||||
|
||||
{#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.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'}
|
||||
<code>{t.details.collection_glob}</code>
|
||||
<span class="muted">— {t.details.ops.join(', ') || 'any op'}</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 === 'settings' && canAdmin}
|
||||
<section>
|
||||
<h2>Settings</h2>
|
||||
@@ -855,6 +1025,23 @@
|
||||
{/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}
|
||||
|
||||
<style>
|
||||
|
||||
Reference in New Issue
Block a user