Closes the queue DL fan-out gap (audit Medium) and surfaces the
KV/docs/files non-transactional emit gap to operators.
- handle_queue_failure previously called queue.dead_letter to write the
DL row but never invoked fan_out_dead_letter. The comment claimed
"the outbox arm fires registered dead_letter handlers off the new row"
— but queue DL rows are written via a separate path that bypasses the
outbox entirely, so handlers filtered on source="queue" sat idle
forever. Refactor fan_out_dead_letter to take a DeadLetterFanOutCtx
struct so both the outbox arm and the queue arm can call it; the
queue arm constructs a TriggerEvent::Queue from the claimed message
and passes it through.
- New integration test queue_dead_letter_fans_out_to_dead_letter_handler
registers a dead_letter trigger filtered on "queue", forces queue
exhaustion, asserts the handler fires with the correctly-shaped event.
- KV/docs/files services committed the data write then ran events.emit
as a separate operation, logging-and-swallowing on failure. Bump the
six call sites from tracing::warn to tracing::error with an
event_emit_failure=true marker so operators can grep them. The full
single-tx repo refactor (extending ServiceEventEmitter with
emit_in_tx + tx-aware *_repo methods) is documented in kv_service.rs
as a v1.2 follow-up — it's a meaningful redesign that deserves its
own pass (pubsub_service::fan_out_publish is the reference shape).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes 4 audit findings:
- Route TS interface now carries dispatch_mode (sync|async). Backend's
shared::route::Route has had this since 0012_routes_dispatch_mode but
the TS client silently always created sync routes and the list
display dropped the field. Add a Dispatch select to the new-route
form and an "ASYNC" badge in the route list.
- api.files.downloadUrl pointed to a never-registered backend endpoint.
The dashboard's live Download button was hitting 405. Add the GET
handler (AppFilesRead + FilesRepo::head + FilesRepo::get, Content-
Disposition: inline) at the same path that delete already used.
- F-T-003: adminRequest's 401 handler called goto(login) without a
recursion cap. If the login endpoint itself returned 401, the wrapper
looped until browser nav limits. Track consecutive 401s within a 10s
window and hard-reload to /login?reason=auth-loop after the third,
showing the user an explanatory banner. Any 2xx resets the counter.
- Login flow now honors a ?returnTo= query parameter. adminRequest and
the root layout both append the current location when redirecting to
/login; the login page validates the value is a same-origin admin
path (no open-redirect) and goto's there after successful sign-in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes 4 High-severity audit findings:
- describe_event broken for HTTP-async payloads: was reading source/op
JSON fields that HttpDispatchPayload doesn't carry, producing empty
source on DL rows. from_wire("") then fell back to Kv on replay, the
dispatcher tried to resolve a non-existent trigger, and replay no-op'd
silently. Now branches on OutboxSourceKind: HTTP rows emit
"<method> <path>" + source="http"; Invoke rows emit "invoke_async" +
source="invoke"; TriggerEvent payloads keep top-level field extraction.
- HTTP outbound Authorization leaked across cross-origin redirects.
Manual redirect loop (Policy::none) reused header_map on every hop
and only scrubbed Content-Type for POST->GET. Now compares
url::Url::origin() across hops and strips Authorization,
Proxy-Authorization, Cookie when origin changes — matching reqwest's
default policy.
- F-S-010 inbound email HMAC had no replay protection. Signature was
computed over body only with no timestamp or nonce, so captured POSTs
were replayable indefinitely. Adds X-Picloud-Timestamp header bound
into the HMAC input (signed string is ts || "." || body), 300s
tolerance window, and a process-local LRU keyed on
(ts, sha256(body)) with 600s TTL to catch within-window replays.
Breaking change: webhook senders must include the timestamp header.
- expected_schema.txt re-blessed for migrations 0036-0039 (the previous
snapshot stopped at 0035 so CI would have gone red on first run).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses two interrelated UX complaints:
1. Browser-default controls leaking through the dark theme
(native <select> chevrons, OS checkbox/radio look, date-picker
icons, <details> triangle marker, number input spinners).
2. Subroute pages dropping the main app's tab bar in favor of a back
link, breaking navigation continuity across users/files/queues/
dead-letters/queues-[name].
Design tokens (dashboard/src/routes/+layout.svelte):
- Token vocabulary expanded with 18 new variables covering
text-strong, accent + accent-fg, danger/success/warning bg/fg/border
triplets, bg-elevated-hover, radii (sm/md/lg/pill), shadow-elev-2,
and z scale (popover/modal/toast). 7 alias tokens (--muted,
--link, --text, --color-error, --color-border, --chip-bg,
--code-bg) absorb the orphan references the F-U-004 remediation
partially renamed.
- Global :global(...) resets for <select>, <input type='checkbox'>,
<input type='radio'>, <input type='number'>, <input type='date'>,
and <details>/<summary> ensure native controls track the dark
palette out of the box. No per-page edits needed.
Tab consistency:
- New dashboard/src/lib/AppTabBar.svelte renders all 11 per-app
tabs (Scripts, Domains, Members, Triggers, Topics, Secrets,
Settings, Users, Files, Queues, Dead letters) as <a> links with
an active highlight derived from the URL. Tabs that switch
in-page panels go to ?tab=<id>; tabs that switch routes go to
the subroute. Admin-only tabs are hidden when canAdmin is false.
- New dashboard/src/routes/apps/[slug]/+layout.svelte loads the
app once, handles the historical-slug redirect, exposes the
shared app + canAdmin + canWrite + dead-letter-count state via
Svelte context, and renders the breadcrumb + AppTabBar above
every per-app page. The 5 subroute pages drop their own "← back"
headers since the layout owns them now.
- apps/[slug]/+page.svelte's local-state activeTab becomes URL-
driven via $page.url.searchParams.get('tab'). Defense-in-depth
redirect for non-admin viewers landing on admin-only tabs uses
goto({replaceState:true}) instead of mutating state.
Light-theme leftovers swept on 5 subroute pages:
- dead-letters: error banner, badge, pre/code blocks all swap to
--color-danger-*, --bg-elevated, --text-primary
- files: button.danger, var(--muted,#666) → token-only
- queues + queues/[name]: bare hex fallbacks removed; .toolbar and
.auto-refresh styled with tokens; data-testid for the queues
empty state (already added by previous commit, reaffirmed here)
- users + users/invitations: badge-ok/badge-pending now use
--color-success-bg/fg and --color-warning-bg/fg; chips use
--bg-elevated + --text-strong; .create-form gets a token-styled
surface; row-action buttons gain explicit dark-theme styling
E2E selector updates:
- members.spec.ts, integration.spec.ts, apps.spec.ts — the tab bar
is now <a> elements (role=link) without a count suffix. Test
selectors swap from getByRole('button', name: /^Scripts \(\d+\)$/)
to getByRole('link', { name: 'Scripts' }), etc.
- New navigation/tabs.spec.ts still passes; existing 60+ tests
unchanged except for the selector swap. Two pre-existing failures
(routing.spec.ts:79, integration.spec.ts:89) untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses every finding from the four-agent review of commit aa493b9.
Dashboard — honor redirect_to on subtab loadApp() (closes the silent
historical-slug redirect UX gap):
- queues/+page.svelte
- queues/[name]/+page.svelte
- files/+page.svelte
- dead-letters/+page.svelte
After a rename, the URL bar now reflects the canonical slug instead
of silently rendering the renamed app's data under the stale URL.
Mirrors the established pattern at apps/[slug]/+page.svelte:619-623.
manager-core:
- queues_api.rs IntoResponse now uses the JSON envelope shape
`{"error": "..."}` consistent with every sibling admin api file.
- triggers_api::delete_trigger reordered: cap check fires BEFORE the
trigger load, closing the 404-vs-403 existence side channel an
unauthorized caller could otherwise probe.
- InMemoryAppRepo mocks in topics_api + triggers_api now implement
get_by_slug + get_by_slug_or_history (previously
`unimplemented!()`), unblocking handler-level slug-input tests.
- Added 4 slug-acceptance tests to topics_api and 2 to triggers_api
(slug-resolves, unknown-slug-404, historical-slug-resolves,
create-via-slug). Also added delete-without-cap-is-forbidden test
pinning the new cap-first order.
e2e:
- navigation/tabs.spec.ts split per-tab so a regression on one tab
no longer masks regressions on the others.
- Negative assertion widened: captures every /api/v1/admin/apps/*
response and fails on any 4xx/5xx — not just the literal "Cannot
parse" string. Catches a broader regression shape.
- networkidle replaced with `expect(<main>).toBeVisible()` —
networkidle is officially discouraged for SPAs and was at risk of
timing out behind the queues auto-refresh.
- Cleanup registration moved BEFORE the create-app API call so a
flaky create still gets swept up.
- Queue drilldown route /queues/[name] now covered.
- Stable `data-testid="queues-empty-state"` replaces fragile
UI-copy substring match for the positive assertion.
- Header comment now spells out what this spec does and doesn't
catch.
docs:
- serverless_cloud_blueprint.md: slug-history described as
"200 OK + redirect_to" JSON envelope rather than "301 redirect"
— matches what apps_api actually implements (SPA can't honor a
mid-tree HTTP redirect).
Unit-test gap (acknowledged): queues_api, files_api, secrets_api,
dead_letters_api have zero in-process tests. Adding them properly
needs a shared mock-repo helper crate — the standalone trait surface
(QueueRepo + TriggerRepo + ScriptRepository + AuthzRepo + repo-
specific) is ~30 methods per file. Documented inline in queues_api.rs
near the resolver. Integration coverage via crates/picloud/tests/ and
the new e2e spec cover the same paths end-to-end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Compose convention is that override.yml is a per-developer file for
local-dev overrides (e.g., forwarding PICLOUD_DEV_MODE +
PICLOUD_DEV_INSECURE_KEY to the picloud container without
modifying the tracked docker-compose.yml).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Queues tab at /admin/apps/default/queues was returning
"Cannot parse `app_id` with value `default`: UUID parsing failed".
27 admin endpoints across 6 files used strict `Path<AppId>` instead
of the canonical `Path<String>` + `resolve_app()` pattern from
app_repo.rs:34. Working endpoints (apps, app_members, users_admin)
all use the lenient pattern; this commit brings the remaining 6
files into line:
- queues_api.rs — 2 handlers
- files_api.rs — 2 handlers
- secrets_api.rs — 3 handlers
- topics_api.rs — 4 handlers
- dead_letters_api.rs — 5 handlers
- triggers_api.rs — 10 handlers
The handler bodies (authz, repo calls) are unchanged; only the path
extractor and the per-file `ensure_app_exists` helper (now renamed
`resolve_app`) move. Lib tests updated to pass `.to_string()` at
the call site (Path now takes String, not AppId).
email_inbound_api.rs deliberately stays strict-UUID — it's a public
webhook receiver consumed by external providers, not by the
slug-based dashboard.
Adds Playwright spec `dashboard/tests/e2e/navigation/tabs.spec.ts`
covering every per-app tab (queues, files, dead-letters, users,
invitations, plus the main page hosting triggers/secrets/topics)
with a negative assertion against the "Cannot parse" error text
plus a focused regression test for the original queues report.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Multi-agent audit of main at v1.1.9 covering Security, Performance,
Code Quality, UI/UX, Migration/Schema, and TypeScript client.
115 actionable findings; severity-ranked and dedup'd.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cargo fmt regroups; three clippy fixes:
- F-S-006: hoist MAX_API_KEY_CANDIDATES to module scope to satisfy
clippy::items_after_statements.
- F-S-009: use map_or instead of map().unwrap_or() per
clippy::map_unwrap_or.
- F-P-012: replace `|c| c.encode()` closure with the method itself
per clippy::redundant_closure_for_method_calls.
No behavior change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ConfirmModal focused the first input on mount and listened for Escape,
but didn't trap Tab. Keyboard users could Tab out of the modal into
the underlying page — a strong accessibility violation and a confusing
keyboard-UX moment.
Add a handleTrapTab keydown handler on the dialog div that:
- Enumerates focusables (button:not([disabled]), input:not([disabled]),
select, textarea, a[href], [tabindex]:not(-1)).
- On Tab from the last focusable, wraps to the first.
- On Shift+Tab from the first, wraps to the last.
The dialog ref is bound via bind:this; the handler is no-op if the
dialog isn't mounted yet.
AUDIT.md anchor: F-U-016.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
invoke_service::resolve and enqueue_async performed no authz check —
no AppInvoke capability existed. Same-app isolation was preserved
(cross-app guards work), but within one app an anonymous public-HTTP
script could trigger any other script (e.g. an admin-only worker that
hits secrets/files/external HTTP). Worse: invoke_async runs the
callee with principal: None, so the callee could hold capabilities
the original public caller shouldn't.
- Add Capability::AppInvoke(AppId). app_id() / scope_for_capability
(script:write) / role_satisfies (editor+) are all updated.
- InvokeServiceImpl gains an optional `authz: Option<Arc<dyn AuthzRepo>>`
+ a `with_authz` builder. When set, resolve() runs script_gate on
AppInvoke before doing the cross-app id check.
- picloud/src/lib.rs wires it: `InvokeServiceImpl::new(...).with_authz(...)`.
- Anonymous callers (cx.principal == None) continue to skip the check
via script_gate, preserving the public-HTTP convention.
Existing 5 invoke_service unit tests still pass (the tests use the
authz-less constructor, so the gate is a no-op there).
AUDIT.md anchor: F-S-012.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A 70-line newtype lived in picloud/src/lib.rs wrapping
Arc<PostgresScriptRepository> and re-implementing every ScriptRepository
method as `self.0.method(...).await`. Hand-delegation invited silent
skew when the trait gained a method, and forced 8 call sites to know
about the wrapper.
Add a blanket
impl<T: ScriptRepository + ?Sized> ScriptRepository for Arc<T>
in manager-core::repo. The implementations forward via (**self).method(...)
so any owner of an Arc<dyn ScriptRepository> (or Arc<ConcreteImpl>) can
be passed where the trait is expected.
Migrate the 7 picloud-binary call sites:
Arc::new(PostgresScriptRepoHandle(script_repo.clone())) → script_repo.clone()
Delete the newtype + its hand-delegated impl. Leaves a 6-line comment
documenting why the boilerplate is gone.
AUDIT.md anchor: F-Q-011.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Queue depths change continuously; the page was a load-time snapshot
with no way to update without a hard refresh. Dead-letters has a
Refresh button; queues didn't.
Add:
- A Refresh button that re-fires loadQueues() (and shows "Refreshing…"
while in-flight).
- An "Auto-refresh every 5s" checkbox. setInterval lifecycle is
managed via onDestroy so navigating away cancels cleanly.
Queue-detail page is unchanged in this commit; same pattern can be
applied there in a follow-up.
AUDIT.md anchor: F-U-013 (overview list; detail page deferred).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Listing showed name/content-type/size/created/id; only mutation was
Delete. No download link, no copy-id button (the UUID was rendered
unclickable), no preview, no metadata refresh. Operators had to use
the admin API directly.
- Add api.files.downloadUrl(slug, collection, id) helper that builds
the admin GET URL. The anchor uses HTML `download` so the browser
saves with the original filename.
- Add a ⧉ copy-id button next to the UUID column using
navigator.clipboard.writeText. Silently no-ops where the browser
doesn't expose clipboard (plain-http LAN).
- Preview / metadata-refresh are deferred to a UI overhaul.
AUDIT.md anchor: F-U-009 (partial — download + copy; preview deferred).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The invite-user modal offered only admin/member radios. Owner could
only be granted by editing an existing user — asymmetric with
editRoleOptions which lets owners assign owner directly. The original
intention was preventing the first-owner footgun but the asymmetry
was confusing.
Show the Owner radio only when me?.instance_role === 'owner'. The
help text ("Owners can't be created here — promote via Edit") still
shows for non-owner admins so the friction is preserved where it
matters.
AUDIT.md anchor: F-U-018.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TICK_INTERVAL (100ms) and ASYNC_EXEC_TIMEOUT (300s) were `const`. Every
other timing knob in the file (cron tick, queue reclaim, retry policy)
is env-overridable via TriggerConfig::from_env. Operators on a
constrained Pi or a busier instance could tune retries but not
dispatcher cadence.
Add env knobs:
- PICLOUD_DISPATCHER_TICK_INTERVAL_MS (default 100)
- PICLOUD_DISPATCHER_ASYNC_EXEC_TIMEOUT_SEC (default 300)
Read via tick_interval_from_env() / async_exec_timeout_from_env() at
dispatcher startup. Invalid values fall back with a tracing-warn.
AUDIT.md anchor: F-Q-009.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
http_service::run mapped `Method::from_bytes` failure on a user-supplied
HTTP method to HttpError::Backend with format!("invalid method: {}", …).
Same for HeaderName/HeaderValue validation in build_headers. But that's
user input, not a backend problem — misclassifying as Backend corrupts
retry policies (operators may retry "backend" but not "invalid input").
Add a new HttpError::InvalidArgs(String) variant. Reroute the three
validation paths in http_service (method, header name, header value) to
emit InvalidArgs instead of Backend.
The executor-side `map_http_err` uses Display, so the new variant
surfaces to scripts as "http: invalid method: …" — same format, more
structured behind it.
AUDIT.md anchor: F-Q-008.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
scripts/[id]'s removeRoute used window.confirm('Delete this route?')
and surfaced errors via window.alert(). The rest of the dashboard had
adopted ConfirmModal for this exact case — the browser modal was
unstyled, couldn't show route detail, and the alert dead-end made
errors hard to recover from.
Rebuild around ConfirmModal:
- requestRemoveRoute(route) opens the modal carrying the full Route.
- The modal body shows `method path` plus `on host` if host-bound.
- A muted line explains the consequence ("Existing inbound requests
will 404 on this path immediately").
- Errors surface inline via removeRouteError instead of `alert()`.
AUDIT.md anchor: F-U-007.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Trigger.enabled is a boolean on the DTO and the backend supports
disabled triggers, but the trigger list never showed the state.
Operators couldn't tell from the UI whether a trigger was paused.
Add a small "• disabled" pill next to the kind badge when
t.enabled === false, with a tooltip explaining the consequence. No
PATCH endpoint yet for enabled, so toggle UI is tracked separately
as a follow-up.
AUDIT.md anchor: F-U-006.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
F-U-008: scripts/[id]'s "← Scripts" back-link used `base + '/'` which
the root page redirects to /apps. The breadcrumb already resolves
appSlug; switch the back-link to `{base}/apps/{appSlug}` (falling back
to `{base}/apps` when appSlug isn't loaded yet).
F-U-014: emailInboundUrl built the webhook URL from
window.location.origin — wrong when the admin browses via an internal
LAN address but the public webhook URL is on a different host.
VersionInfo.public_base_url already exists for exactly this case; the
apps/[slug] page now loads /version alongside the app fetch and
prefers public_base_url for the webhook URL display. Falls back to
window.location.origin if /version hasn't loaded.
AUDIT.md anchors: F-U-008, F-U-014.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
InboxRegistry::register returned (Uuid, Receiver) even when the inner
Mutex was poisoned — `if let Ok(mut g) = self.inner.lock()` swallowed
the error, the sender was never inserted, the later deliver(id, …)
saw no entry and returned Abandoned, and the caller's `await rx`
blocked until the orchestrator's outer timeout.
Sister registries (RouteTable, AppDomainTable) already .expect()
panic on poison. Make InboxRegistry::{register, cancel, deliver} loud
in the same way.
A poisoned Mutex means a prior panic inside a critical section — the
process is unrecoverable; silently swallowing it just defers the
crash and corrupts the inbox protocol on the way.
AUDIT.md anchors: F-Q-006 (register), F-Q-010 (consistency policy).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
regex::is_match, find, find_all, replace, replace_all, split, captures
all called Regex::new(pattern) per invocation. A script doing
`regex::is_match("\\d+", x)` in a tight loop paid the compile every
iteration. Regex compile dominates is_match on short strings.
Add a thread-local LruCache<String, Arc<Regex>> (cap 128). The
compile helper now memoises by pattern string, returning Arc<Regex>
on hit. Cap is well above what any sensible script uses but bounded
enough to keep memory predictable on long-running threads.
AUDIT.md anchor: F-P-014.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ORDER BY created_at DESC, id DESC but cursor was `WHERE created_at <
$2` — when two users were created at the same instant, pagination
could skip the second row at a page boundary or return it twice.
- Add ListCursor { created_at, id } with `<rfc3339>_<uuid>` encode /
decode helpers.
- Change WHERE predicate to `(created_at, id) < ($2, $3)` matching the
ORDER BY.
- Surface the opaque cursor string through UsersListOpts /
UsersListPage / users_admin_api ListUsersResponse instead of the raw
DateTime — keeps the wire format stable while the id half stops the
boundary bug.
Same shape as F-P-005 (execution_logs).
AUDIT.md anchor: F-P-012.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
No unique constraint on (app_id, lower(email)) for pending rows meant
calling users::invite("alice@…") N times created N rows with N valid
tokens. Combined with accept_invite returning Ok(None) silently when
the email already exists, an attacker who learned one token could
permanently consume it without effect.
Migration 0040 adds a partial unique index keyed on
(app_id, lower(email)) WHERE accepted_at IS NULL. Re-inviting after a
previous invite was accepted still works — the accepted row falls
outside the index.
The service-layer 409-conflict UX (the audit's secondary suggestion)
is a separate follow-up; this commit closes the data-shape hole.
AUDIT.md anchor: F-S-013.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two (encrypted, nonce) column pairs are each nullable independently in
the current schema:
- email_trigger_details.inbound_secret_encrypted / _nonce
- app_secrets.realtime_signing_key_encrypted / _nonce
A bug or partial write could leave one populated and the other NULL,
silently bypassing signature verification (receivers decrypt only if
the encrypted column is set).
Migration 0038 adds CHECK ((enc IS NULL) = (nonce IS NULL)) on both
tables — defence-in-depth: catches an invariant violation at the DB
boundary even if the writing code regresses.
AUDIT.md anchor: F-M-002.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migration 0017_cron_triggers.sql created idx_cron_triggers_due on
(last_fired_at) with a comment claiming it serves the scheduler. The
actual scheduler query has no last_fired_at predicate — it filters
purely on `t.enabled = TRUE FOR UPDATE OF d SKIP LOCKED`. The index
has been pure write amplification with no read payoff.
Migration 0037 drops it. Reversible by re-running 0017's CREATE INDEX
if the planner story ever changes.
AUDIT.md anchor: F-M-001.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
list_active_queue_consumers fires every 100ms from the dispatcher
queue arm and predicates on `WHERE t.kind='queue' AND t.enabled=TRUE`
with no app_id filter — but the only available index
`idx_triggers_app_kind_enabled` is keyed on `(app_id, kind)` and so
requires an app_id predicate to be useful. Without one, the planner
falls back to a sequential scan every tick.
Add migration 0036 with a partial index on `kind` (WHERE enabled =
TRUE) so the hot dispatcher query becomes an index-only lookup.
AUDIT.md anchor: F-P-010.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
editFlipToExternal warned only on the internal → external transition.
Switching `token → public` or `session → public` while already external
is equally risky (opens topic to anonymous subscribers) but produced
no warning.
Replace with editPermissivenessChange that ranks the (external, auth_mode)
pair on a 0..3 scale and fires the warning whenever the resulting rank
strictly exceeds the current one:
0 internal (any auth)
1 external + session
2 external + token
3 external + public
Keep `editFlipToExternal` as an alias pointing at the new derived so
nothing downstream breaks.
AUDIT.md anchor: F-S-014.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PICLOUD_DEV_MODE=true alone fell through to a fully public deterministic
master key — SHA-256("picloud-dev-master-key-v1.1.7"). The warning was
correct but the gate was a single env var. An operator copying a dev
docker-compose file into prod silently encrypted everything with a
world-known key.
Require both:
- PICLOUD_DEV_MODE=true
- PICLOUD_DEV_INSECURE_KEY=i-understand-this-is-insecure
Without the literal-string acknowledgement, MasterKey::from_env returns
the new DevModeUnacknowledged error and the process refuses to start.
Production deployments aren't affected (they set PICLOUD_SECRET_KEY).
AUDIT.md anchor: F-S-009.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
key_cache: Mutex<HashMap<AppId, Vec<u8>>> was populated on first read
and never evicted, bounded, or cleared on app deletion. Two problems:
(1) Once key rotation lands, every running process keeps accepting
tokens signed by the old key until restart.
(2) Dropping and re-creating an app (FK CASCADE removes app_secrets,
fresh row inserted) made the cache hand out the OLD key bytes.
- Wrap the cache value as `(Instant, Vec<u8>)`; entries expire after
KEY_CACHE_TTL = 5min.
- Stale reads fall through to the secrets repo (no error, just slower).
- New `invalidate_signing_key(app_id)` for explicit eviction once the
app-secrets rotation path lands.
AUDIT.md anchor: F-S-008.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
verify_api_key Argon2-verifies every candidate sharing the 8-char
prefix. With unlimited candidates, an attacker who can provision many
keys against one indexed prefix could force the server into per-request
M×Argon2 — a few hundred concurrent connections trivially DoSes the
manager since the verify runs before any concurrency cap.
Cap the candidate set at MAX_API_KEY_CANDIDATES = 16; log a warn when
truncation kicks in so operators can spot it. With 32-byte random key
bodies the natural collision rate is negligible, so truncation under
benign load shouldn't happen — its presence is the alarm.
Switching the verify hash from Argon2 to SHA-256 (the audit's other
suggested fix) is a larger refactor (new hash column, backfill, dual-
hash verify during migration) and is left for a follow-up.
AUDIT.md anchor: F-S-006 (cap; hash swap deferred).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
users_admin_api::delete_user was a comment-only acknowledgement that
"this should require AppUsersAdmin" while actually calling
service.delete which only gates on AppUsersWrite. v1.1.8 HANDBACK §7
listed this as known. Effect: any editor could delete app users via
the admin HTTP API and dashboard button.
Add an explicit `authz::require(... AppUsersAdmin(app_id))` before the
service call. Delete the stale comment.
AUDIT.md anchor: F-S-005.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Handler returned immediately when email shape was invalid or user was
unknown; on a found user it generated an Argon2 token, INSERT'd into
app_user_password_resets, built a link, and called email.send (tens of
ms + DB write + SMTP RTT). The wall-clock delta was enormous and
externally observable.
Add a dummy generate_session_token() call on both no-match branches so
the observable cost matches the matching branch up to the Argon2 hash.
Doesn't equalise the DB INSERT + SMTP send (those would require either
real side effects or a deterministic dev-mode null sink), but it
neutralises the highest-bit-of-info side channel (handler-immediate-
return vs handler-token-mint).
Combined with F-S-003 (find_by_email now requires a principal),
enumeration via this surface from an anonymous public route is closed.
AUDIT.md anchor: F-S-004.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
find_by_email gated on AppUsersRead via `require`, which short-circuits
when cx.principal == None. An anonymous public-HTTP script could
iterate emails to enumerate registered users, then pivot to login or
request_password_reset.
Tighten: explicitly return Forbidden when cx.principal is None — scripts
that need a "does this email exist" probe from a public route must
wrap the call in their own auth gate.
AUDIT.md anchor: F-S-003.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cargo fmt regroups, plus three Phase-B clippy fixes:
- F-P-004: #[allow(clippy::type_complexity)] on the invoke_ast_cache Mutex.
- F-P-008: hoist `futures::stream::{self, TryStreamExt}` to the top-of-
file imports to satisfy clippy::items_after_statements.
- F-P-005: rename `_legacy_offset` to `legacy_offset` + #[allow(dead_code)]
to satisfy clippy's "field is pub but `_`-prefixed" check.
No behavior change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Files page could only list a collection if the operator already
knew its name and typed it in. No "browse known collections" affordance,
no backend endpoint listing collections.
Closest approximation without new backend: pull registered files-trigger
collection_globs from the existing triggers list and surface them as:
- a <datalist> on the collection input for autocomplete
- chip buttons under the form that one-click set the input
Empty state copy points the operator at the Triggers tab. Backend
endpoint to list known collections directly is still a v1.1.10+ task.
Styling uses the F-U-004 :root tokens so it inherits the dark theme.
AUDIT.md anchor: F-U-003 (frontend-only; backend endpoint deferred).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backend triggers_api.rs has long exposed POST /apps/{id}/triggers/{kv,
docs,files,dead_letter}; the dashboard Triggers tab shipped create
forms only for cron / pubsub / email / queue. Listing showed kv/docs/
files/dead_letter rows but operators couldn't create them from the UI.
This commit adds:
- 4 new CreateXxxTriggerInput types in api.ts.
- 4 new api.triggers.createXxx methods.
- 4 new submitCreateXxx functions in apps/[slug]/+page.svelte.
- 4 new <form class="create-form"> sections under the queue form.
KV / Docs / Files share the (script_id, collection_glob, ops[]) shape
with checkbox UI for the per-kind ops (insert/update/delete vs
create/update/delete). Dead-letter takes (script_id, source_filter,
trigger_id_filter, script_id_filter) — all but script_id optional, with
"leaving blank routes every dead-letter to this script" inline help.
`npm run check` clean (only pre-existing tests/e2e/* Playwright errors
remain, documented out-of-scope in the AUDIT.md methodology notes).
AUDIT.md anchor: F-U-001.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
useEndpoint(path) returned `{ get: () => useResource(...), post: (body)
=> useResource(...) }`. Each of `.get()` and `.post()` called useState
+ useEffect — but React's Rules of Hooks require hook calls at the top
level of a component, in the same order each render. Calling
.get() conditionally, or both .get() and .post() from the same
component, produced undefined behaviour (state leaking between them).
The test suite covered only useTopic, so this was uncaught.
Separately (F-T-002): the JSDoc said .post() was "the mutation variant
(auto-fires once per mount)" — but auto-firing a POST on mount means
typo'd code creates a user (or sends an email) on every render refresh.
Refactor:
- Remove useEndpoint entirely (public API breaking change — clients/ts
v1.1.x has no external consumers yet per CLAUDE.md).
- Add useEndpointGet<Res>(path): QueryState<Res> — flat hook, auto-fires.
- Add useEndpointPost<Req, Res>(path): { mutate, data, loading, error }
— flat hook, event-driven (NOT auto-firing); call `mutate(body)` from
the submit handler.
README updated to demonstrate both shapes side-by-side.
Existing 15 unit tests pass (useTopic unaffected; useEndpoint tests
never existed). Adding a useEndpointGet/Post test pass is finding
F-T-001 follow-up.
AUDIT.md anchor: F-T-001 (+ F-T-002 folded in).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
attach_principal_if_present runs on every request that carries a
Bearer header, including data-plane paths that may not even need
authz. Each call paid three DB round-trips on the session path
(lookup + admin_users.get + touch) plus an Argon2 verify per
prefix-colliding candidate on the API-key path. A hot user with
multiple keys serialized every request behind N×Argon2.
Add a process-shared PrincipalCache keyed on hash_token(bearer) →
(Instant, Principal), TTL 60s. resolve_principal checks the cache
first; on miss falls through to the verify_api_key / verify_session
path and writes back on success.
- Lazy GC: when the cache exceeds 1024 entries, sweep expired before
inserting (kept simple — production sees few unique hot tokens).
- Cache wired through AuthState; constructed once in picloud/src/lib.rs.
- Skip-when-route-doesn't-need-auth scaffolding is deferred to a
follow-up — it requires touching the router shape, which is more
invasive than the 60s cache alone warrants.
AUDIT.md anchor: F-P-009 (cache; skip-path deferred).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TriggerRepo::list_for_app selected parent rows then called
hydrate_one(...) per row serially — one SELECT against the kind-
specific details table per trigger. For N triggers on an app, that's
N+1 queries serialized on every dashboard `GET /apps/{id}/triggers`.
This commit shrinks the wall-clock cost via buffered concurrency
(`try_buffered(8)`): each row's details fetch still runs but they run
in parallel up to a small fan-out cap. Cuts dashboard load time from
sum(N latencies) to max(N latencies) / 8.
Collapsing to a single per-kind LEFT JOIN (the audit's preferred shape)
would cut the query count to ~7 — large enough to defer to v1.2 as
documented in the inline comment.
AUDIT.md anchor: F-P-008 (partial: wall-clock only; round-trip count
deferred).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tick_queue_arm called list_active_queue_consumers() and then iterated
serially, awaiting one queue.claim(app, queue) per consumer. With N
consumers and a 100ms tick the worst-case throughput was
N × (claim + executor) / tick — one slow handler blocked every other
queue's progress on the dispatcher's task.
Replace the for-loop with `futures::stream::iter(consumers)
.for_each_concurrent(QUEUE_DISPATCH_PARALLELISM, …)`. The execution
gate already caps real script-concurrency to its permits, so this just
removes the dispatcher-side serialization.
QUEUE_DISPATCH_PARALLELISM = 32 (matches the default gate). Workspace
gains `futures = 0.3` so `for_each_concurrent` is available.
AUDIT.md anchor: F-P-007.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Scripts called queue::depth(name) / queue::depth_pending(name) per
invocation; each ran an unbounded COUNT(*) over the queue_messages
partial index. On a backed-up queue with millions of rows, every call
scanned the whole partition.
Wrap the predicate in a LIMIT-capped subquery so the worst-case scan
is bounded:
SELECT COUNT(*) FROM (
SELECT 1 FROM queue_messages WHERE ... LIMIT 10000
) sub
QUEUE_DEPTH_SCAN_CAP = 10_000. Callers that need an exact depth on
queues larger than 10k use the admin /apps/{id}/queues endpoint which
tolerates the slower COUNT for its much lower read frequency.
`list_for_app` (dashboard queue overview) is left at full COUNT —
separate finding F-P-006 second-bullet, deferred to v1.2 along with
per-queue running counters.
AUDIT.md anchor: F-P-006 (first half).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
list_for_script used ORDER BY created_at DESC LIMIT $2 OFFSET $3.
Postgres has to scan + discard OFFSET rows on every page, so deep
pagination on a script with many log rows gets linearly slower. Every
sister-table list endpoint in the repo already uses cursor pagination
— this is the outlier.
Add ExecutionLogCursor { created_at, id } with `<rfc3339>_<uuid>`
encode/decode. New trait signature:
list_for_script(script_id, limit, cursor: Option<ExecutionLogCursor>)
Predicate: WHERE script_id=$1 AND (created_at, id) < ($cursor_ts, $cursor_id).
ORDER BY also gains `id DESC` for deterministic ordering at equal-time
boundaries.
API surface:
- /api/v1/admin/scripts/{id}/logs?cursor=<token>&limit=50 is the new
shape (dashboards adopt later — separate finding F-U-012).
- Legacy `offset` query param accepted-and-ignored to keep older
dashboards from 400'ing during rollout.
AUDIT.md anchor: F-P-005.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two paths bypassed the AST cache: LocalExecutorClient::execute (tests +
fallback) and the synchronous invoke() re-entry in the executor SDK.
The latter is the hot one — composed workflows multiplied parse cost
by depth, so a 4-deep invoke chain on a 200-line script paid the parse
budget × 4 per call.
Add a per-Engine HashMap<ScriptId, (updated_at, Arc<AST>)> + a
`compile_for_identity(script_id, updated_at, source)` helper that
behaves like LocalExecutorClient::get_or_compile but lives on the
Engine. Update the SDK invoke synchronous re-entry to:
resolved → compile_for_identity → execute_ast
The orchestrator-core LocalExecutorClient cache (HTTP-path dispatch)
is left untouched — it caches a different access pattern at a
different boundary.
AUDIT.md anchor: F-P-004.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
verify_password (Argon2id, OWASP defaults m=19456 KiB, t=2) is CPU-bound
at tens-to-hundreds of ms and was invoked synchronously on the Tokio
worker. Worse, verify_api_key Argon2-verifies every candidate sharing
the 8-char prefix — a hot user with N keys serialized every admin
request behind N×Argon2.
Wrap each call site in tokio::task::spawn_blocking:
- auth_middleware::verify_api_key (per request carrying a Bearer key)
- auth_api::login (admin login)
- users_service::login (data-plane app-user login, both real-hash and
TIMING_FLAT_DUMMY_HASH branches)
Cold-cache login is now ~2× current latency due to one spawn_blocking
hop, but the worker no longer parks on Argon2 so steady-state under
load is dramatically better. The LRU cache for the hot-path (token →
principal) is finding F-P-009 — separate commit.
AUDIT.md anchor: F-P-002.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
users::list page-fetched users then looped calling
self.fetch_roles(cx.app_id, row.id) — one query per user row, with
default limit 50 and max 500 — so the admin "users" page did 51 to 501
round-trips. The same fetch_roles is also called from
verify_session_for_realtime on every authenticated SSE subscribe.
Add AppUserRoleRepo::list_for_users(app_id, &[user_ids]) returning a
HashMap<AppUserId, Vec<String>>; rewrite list to use it. Single query
with `WHERE user_id = ANY($2)`.
Empty-input fast path returns an empty map without touching Postgres.
AUDIT.md anchor: F-P-001.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both methods short-circuit the authz gate when cx.principal == None.
An attacker hitting any public route could trigger unbounded outbound
email per call to arbitrary or attacker-supplied addresses — exhausting
SMTP-relay quota, getting the relay blacklisted, or burning provider
credits.
Add an in-memory EmailRateLimiter on UsersServiceImpl with two scopes:
- Per-(app, recipient): 5 calls per rolling 1h window
- Per-app daily cap: 200 calls per rolling 24h window
Both windows reset lazily. Token-bucket counts increment only after
both checks pass — a denied recipient doesn't consume the app counter.
Wired:
- send_verification_email: returns UsersError::EmailRateLimited (→ 429
on the admin HTTP surface).
- request_password_reset: returns Ok(()) silently on rate-limit (would
otherwise enable an existence-leak side channel).
UsersError::EmailRateLimited variant added; AppUsersApiError gains the
same variant + 429 mapping. NoopUsersError trait stub unaffected.
AUDIT.md anchor: F-S-002.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
init_db hard-coded max_connections(10). The execution gate
(ExecutionGate, PICLOUD_MAX_CONCURRENT_EXECUTIONS default 32) lets 32
script executions run concurrently, each doing multiple sequential
DB calls — plus the dispatcher tick every 100ms, the cron tick, three
GC sweeps, and the auth middleware running per request all draw from
the same pool. With max=10 vs gate=32, pool starvation surfaces as
acquire_timeout errors and tail-latency spikes under load.
- DEFAULT_DB_MAX_CONNECTIONS = 32 (matches gate default).
- Env knob: PICLOUD_DB_MAX_CONNECTIONS overrides; invalid/zero → default.
- Documented in CLAUDE.md runtime config table.
AUDIT.md anchor: F-P-003.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dashboard route tree has scripts at /scripts/[id], not under
apps/[slug]/scripts. The queue-drilldown page linked to a non-existent
app-scoped scripts route, so clicking the consumer-script name 404'd.
Reverts the link to {base}/scripts/{detail.consumer.script_id}, matching
how every other place in the dashboard navigates to a script page.
AUDIT.md anchor: F-U-002.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Whitespace + import-grouping fixups in the 9 SDK modules touched by
F-Q-002. No behavior change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>