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>
84 KiB
84 KiB
PiCloud Codebase Audit — 2026-06-07
Scope: main at v1.1.9 head, commit 450bada.
Methodology: Multi-agent audit — orchestrator + 6 parallel subagents sliced by (Security data-plane, Security auth/secrets, Performance, Code Quality, UI/UX, Migration+TS client). Reviewer-verified on the highest-severity findings (Q-001, T-001, U-006, P-001, P-004, S-002).
Categories: Security (S), Performance (P), Code Quality (Q), UI/UX (U), Migration/Schema (M), TypeScript client (T).
Executive summary
- One Critical: the manager-core / orchestrator-core boundary has inverted.
manager-core/Cargo.tomldepends onpicloud-orchestrator-coreand the dispatcher / route_admin / apps_api / repo modules reach intoRouteTable,ExecutionGate,ExecutorClient,ScriptResolver, andInProcessBroadcasterfor behavior, not just DTOs. CLAUDE.md's "Working Rules" call this exact pattern the bright line that keeps cluster mode a swap-not-rewrite. Today it's a swap-and-rewrite. See F-Q-001. - Load-bearing risk: every stateful Rhai SDK service silently accepts unbounded payloads.
kv::set,docs::create/update,pubsub::publish_durable,queue::enqueue— none of them cap value size. Files + secrets + email have caps; the other four do not. An anonymous public-HTTP script can OOM Postgres JSONB columns or amplify one publish into N outbox rows × M MB each. See F-S-001. - Biggest performance leak: Argon2id on the async worker, on the hottest paths.
attach_principal_if_presentmiddleware runs an Argon2 verify per API-key candidate for every request carrying a Bearer header (including the data plane). Login and password reset do the same on async workers. Compounded by an unspawned-blocking call and no rate limit, the auth surface is one DoS vector for memory and three for CPU. See F-P-002, F-S-006. - Pool sized for 10, gate sized for 32.
init_dbopensmax_connections = 10; the execution gate defaults to 32. Pool starvation under load is not a question of if. See F-P-003. - Most user-visible UX gap: the dashboard can't create half the trigger kinds. Backend exposes create for KV/docs/files/dead-letter triggers; the dashboard lists them but ships create forms only for cron/pubsub/email/queue. Operators must hit the API directly. See F-U-001. The queues drilldown also has a broken link that 404s — F-U-002.
- A handful of dashboard pages render in light-theme fallback colors on a dark-theme app. Dead-letters, Files, App-Users, Invitations, Queues all reference
var(--name, #fff)style fallbacks for CSS variables the dashboard never defines, so users see#666text on#0f172abackgrounds. See F-U-004. - TypeScript client has a Rules-of-Hooks violation.
useEndpoint().get()/.post()returns hook-calling functions; calling them conditionally or both in the same component produces undefined state. The test suite doesn't exercise it. See F-T-001. - The cleanest surface is the migration set. 35 sequential migrations with consistent FK-cascade discipline, partial indexes that match their predicates, and a working schema-snapshot test. Findings here are largely "redundant index" or "missing belt-and-suspenders CHECK" — refinement, not breakage.
Counts by severity
| Severity | Count |
|---|---|
| Critical | 1 |
| High | 18 |
| Medium | 35 |
| Low | 39 |
| Info | 22 |
Findings
Security
Critical
(See Code Quality for the only Critical finding — the manager-core ↔ orchestrator-core boundary breach.)
High
F-S-001 — Unbounded payload sizes on kv::set, docs::*, pubsub::publish_durable, queue::enqueue
- Severity: High
- Location: crates/manager-core/src/queue_service.rs:33-92, crates/manager-core/src/kv_service.rs:93, crates/manager-core/src/docs_service.rs:107, crates/manager-core/src/pubsub_service.rs:138
- Summary: Four stateful SDK services accept any JSON value and write it straight to a
JSONBcolumn with no size validation. Files (per-file cap, env-overridable), secrets (64 KB default), and email (25 MB default) all enforce limits; these four don't. An anonymous public-HTTP script can fill disk viaqueue::enqueue(up to Postgres's ~1 GB JSONB limit per row), or amplify a singlepublish_durableinto N outbox rows × payload bytes. Fix shape: add a per-servicemax_value_bytesconfig (default ~256 KB) validated at the service entry, before authz/repo. Mirror the existingMAX_VALUE_BYTESconstant pattern insecrets_service.rs.
F-S-002 — users::request_password_reset and send_verification_email are unrate-limited and reachable from anonymous scripts
- Severity: High
- Location: crates/manager-core/src/users_service.rs:633, crates/manager-core/src/users_service.rs:575
- Summary: Both methods short-circuit the authz gate when
cx.principal == None. An attacker who can call any public route can trigger unbounded outbound email per call to arbitrary or attacker-supplied addresses — exhausting SMTP-relay quota, getting the relay blacklisted, or burning provider credits. No per-app rate limit, per-recipient cooldown, or per-execution counter. Fix shape: token-bucket rate limit keyed on(cx.app_id, recipient_email), with a per-app daily cap. Optionally requirecx.principal.is_some()when invoked from a public route.
F-P-001 — users::list N+1: one fetch_roles query per user row
- Category: Performance
- Severity: High
- Location: crates/manager-core/src/users_service.rs:466-471
- Summary:
listpage-fetches users then loops callingself.fetch_roles(cx.app_id, row.id)— one query per user. Default limit 50, max 500, so the admin "users" page does 51-501 round-trips. The samefetch_rolesis also called fromverify_session_for_realtime(one extra query on every authenticated SSE subscribe). Fix: addAppUserRoleRepo::list_for_users(app_id, &[user_ids])returning aHashMap<AppUserId, Vec<String>>and join in a single query (WHERE user_id = ANY($2)).
F-P-002 — Argon2id password / API-key verify runs synchronously on the Tokio async worker
- Category: Performance
- Severity: High
- Location: crates/manager-core/src/auth_middleware.rs:206-208, crates/manager-core/src/auth_api.rs:98, crates/manager-core/src/users_service.rs:502
- Summary:
verify_password(Argon2id default params: m=19456 KiB, t=2) is CPU-bound at tens-to-hundreds of ms and is invoked synchronously on the Tokio worker. Worse,verify_api_keyArgon2-verifies every candidate sharing the 8-char prefix on every authenticated/api/v1/admin/*request — a single hot user with N keys serializes every admin request behind N×Argon2 verifies. Fix: wrap each verify intokio::task::spawn_blocking, and maintain a short-lived (60-300s) cacheLruCache<(prefix, raw)→user_id>so a hot API key doesn't re-Argon2 every request.
F-P-003 — Postgres pool max_connections=10 vs ExecutionGate default 32
- Category: Performance
- Severity: High
- Location: crates/picloud/src/lib.rs:583-588 vs crates/orchestrator-core/src/gate.rs
- Summary:
init_dbhard-codesmax_connections(10). The execution gate allows 32 concurrent script executions, each doing multiple sequential DB calls (script resolve, every SDK call inside the script, log sink, outbox emit), plus the dispatcher tick every 100ms, the cron tick, three GC sweeps, and the auth middleware running per request. Pool starvation manifests asacquire_timeout(5 s) errors and tail-latency spikes. Fix: scalemax_connectionsto roughlygate.max_permits × estimated_queries_per_exec + headroom, expose aPICLOUD_DB_MAX_CONNECTIONSenv knob, and document the relationship.
F-P-004 — LocalExecutorClient::execute and invoke() re-entry recompile AST every call
- Category: Performance
- Severity: High
- Location: crates/orchestrator-core/src/client.rs:178-216, crates/executor-core/src/sdk/invoke.rs:192-200
- Summary: Two code paths bypass the AST cache:
ExecutorClient::execute(used in tests + fallback) callsengine.execute(&source, req)which compiles inline; andinvoke()synchronous re-entry callsself_engine.execute(&resolved.source, req), re-parsing every callee on every invoke. Composed workflows multiply parse cost by depth. Fix: haveInvokeService::resolvereturn(ScriptIdentity, source)and route through the cachedLocalExecutorClient::get_or_compile, or exposeEngine::execute_with_identityso the engine itself can cache.
F-P-005 — execution_logs admin list uses OFFSET pagination
- Category: Performance
- Severity: High
- Location: crates/manager-core/src/repo.rs:511-535
- Summary:
list_for_scriptdoesORDER BY created_at DESC LIMIT $2 OFFSET $3. Postgres has to scan + discardOFFSETrows on every page; deep pages get linearly slower. Sister tables in the same repo all use cursor pagination. Fix: switch to keyset cursor on(created_at, id)—WHERE script_id=$1 AND (created_at, id) < ($cursor_ts, $cursor_id). Same shape as every other list endpoint.
F-P-006 — queue::depth / depth_pending are COUNT(*) reachable per script call
- Category: Performance
- Severity: High
- Location: crates/manager-core/src/queue_repo.rs:264-287, crates/manager-core/src/queue_repo.rs:289-322
- Summary: Scripts call
queue::depth(name)/queue::depth_pending(name)per invocation; each is an unboundedCOUNT(*)over the queue_messages partial index. On a backed-up queue with millions of rows, every call scans the whole partition.list_for_app(dashboard queue overview) does 3×COUNT(*) FILTER (...)in one pass — same scan magnified. Fix: maintain per-(app_id, queue_name)running counters in a small table (incremented on enqueue, decremented on ack/dead-letter), or downgradedepth()to a presence check (SELECT 1 … LIMIT 1).
F-P-007 — Dispatcher tick loops queue consumers serially (one claim per consumer per tick)
- Category: Performance
- Severity: High
- Location: crates/manager-core/src/dispatcher.rs:152-164, crates/manager-core/src/dispatcher.rs:166-290
- Summary:
tick_queue_armcallslist_active_queue_consumers()then iterates serially, awaiting onequeue.claim(app, queue)per consumer per tick. With N queue consumers and a 100ms tick, worst-case throughput isN × (claim + executor) / tick. Each iteration is a fulldispatch_one_queue(resolve script, resolve principal, executor round-trip) sequential on the dispatcher's task. Fix: bound concurrency withfutures::stream::iter(consumers).for_each_concurrent(N, …)(the execution gate caps anyway), and/or cache the consumer list across ticks.
F-P-008 — TriggerRepo::list_for_app is N+1 — one detail-table query per parent row
- Category: Performance
- Severity: High
- Location: crates/manager-core/src/trigger_repo.rs:971-987 (calls
hydrate_oneat ~:1351 per row) - Summary:
list_for_appselects parent rows then for each one issues aSELECT … FROM <kind>_trigger_details WHERE trigger_id = $1. For N triggers on an app, that's N+1 queries on every dashboardGET /apps/{id}/triggersload. Fix: split by kind and issue one-per-kindJOIN <details> ON details.trigger_id = t.id WHERE t.app_id=$1 AND t.kind='<k>', or write a single CTE with LEFT JOIN to each*_trigger_detailstable.
F-P-009 — attach_principal_if_present runs on every data-plane request, including paths that don't need auth
- Category: Performance
- Severity: High
- Location: crates/manager-core/src/auth_middleware.rs:141, crates/manager-core/src/auth_middleware.rs:192-229
- Summary: Every request hitting
/api/v1/execute/...or any user-route path goes throughattach_principal_if_present. If the request carries anyAuthorization: Bearer pic_...header, the middleware does: prefix lookup + Argon2 verify per candidate +admin_users.get+touch_last_usedUPDATE. Three DB round-trips + Argon2id per request — on a path that may not need authz at all. Fix: short-circuit when the route doesn't require auth; add a sliding-window cache of(token → principal)valid for 60-300s.
F-Q-001 — manager-core depends on orchestrator-core for behavior, reversing the architectural arrow
- Category: Code Quality
- Severity: Critical
- Location: crates/manager-core/Cargo.toml:13, crates/manager-core/src/dispatcher.rs:28, crates/manager-core/src/route_admin.rs:15, crates/manager-core/src/apps_api.rs, crates/manager-core/src/invoke_service.rs:15, crates/manager-core/src/pubsub_service.rs:519
- Summary: CLAUDE.md's bright line: "the orchestrator never imports
executor-coredirectly — define a trait inshared". The same rule should hold formanager-core ↔ orchestrator-core. Instead,manager-corepullspicloud_orchestrator_core::routing::{RouteTable, AppDomainTable, matcher::CompiledRoute, pattern, conflict}plus{ExecutorClient, ExecutionGate, ScriptIdentity, ScriptResolver, ResolverError, InProcessBroadcaster}.RouteTableis a statefulArc'd object with methods (replace_all,match_request_for_app) — not a DTO. This kills the cluster-mode swap: when manager and orchestrator are separate binaries the control plane shouldn't link the orchestrator's routing-table impl. Fix: moveRouteTable,AppDomainTable,CompiledRoute,CompiledAppDomain,ScriptResolver,ResolverError,ExecutorClient,ExecutionGate,ScriptIdentity, and the broadcaster trait intoshared/(traits + DTOs); keep only in-process impls inorchestrator-core.
F-Q-002 — Every SDK module open-codes its own block_on helper
- Category: Code Quality
- Severity: High
- Location: crates/executor-core/src/sdk/kv.rs:178, crates/executor-core/src/sdk/docs.rs:240, crates/executor-core/src/sdk/pubsub.rs:162, crates/executor-core/src/sdk/users.rs:593, crates/executor-core/src/sdk/dead_letters.rs:69, crates/executor-core/src/sdk/secrets.rs:138, crates/executor-core/src/sdk/files.rs:266, crates/executor-core/src/sdk/email.rs:136, crates/executor-core/src/sdk/http.rs:369, crates/executor-core/src/sdk/queue.rs:120
- Summary: Ten SDK modules each define a near-identical
block_onthat grabsTokioHandle::try_current(), callsblock_on, and wraps the error intoEvalAltResult::ErrorRuntimewith a service prefix. The shapes diverge only by whichErrorvariant they pin and the prefix string. Promotesdk::bridge::block_on::<E, F>(service_name, fut)(or ablock_on_kind!macro) — keeps each call site to one line and ensures uniform error formatting + a single point for future tracing instrumentation.
F-Q-003 — Each stateful service open-codes the same check_read / check_write authz idiom
- Category: Code Quality
- Severity: High
- Location: crates/manager-core/src/kv_service.rs:48-64, crates/manager-core/src/docs_service.rs:57-73, crates/manager-core/src/files_service.rs:51-71, crates/manager-core/src/pubsub_service.rs:116-127, crates/manager-core/src/queue_service.rs:61-68
- Summary: Every service has the same pattern:
if cx.principal.is_some() { authz::require(...).await.map_err(|_| <Service>Error::Forbidden) }. That's 9 hand-rolled copies of the same 6-line idiom, each with its own error type.users_servicedoes this slightly differently (returnsRepovsDenied). Promote a single helperauthz::script_gate<E>(authz, cx, cap, deny_to: impl Fn(AuthzDenied) -> E). Eliminates drift risk: e.g.,queue_servicemaps toRejected("forbidden")instead ofForbiddenbecause there's noForbiddenvariant — see F-Q-004.
F-Q-004 — Sibling services have inconsistent error-variant shapes, and QueueError is missing Forbidden entirely
- Category: Code Quality
- Severity: High
- Location: crates/shared/src/kv.rs:139, crates/shared/src/queue.rs:60-77, crates/shared/src/pubsub.rs:85, crates/shared/src/invoke.rs:110
- Summary: Same semantic error has two names: "Backend" in
KvError/DocsError/FilesError/SecretsError; "Unavailable" inQueueError/PubsubError/InvokeError. Worse,QueueErrorhas noForbiddenvariant at all, so authz denial gets squashed intoQueueError::Rejected("forbidden".into())(queue_service.rs:68), losing the structured variant a 403-translation layer would need. Andqueue_service::depth/depth_pendingskip authz entirely. Fix: pick one name (Backend), addForbiddento every service's error enum uniformly, and gatedepth/depth_pendingonAppQueueRead(or document why they're public).
F-Q-005 — Authz failures collapse AuthzDenied::Repo into Forbidden via map_err(|_| …)
- Category: Code Quality
- Severity: High
- Location: crates/manager-core/src/kv_service.rs:52,61, crates/manager-core/src/docs_service.rs:61,70, crates/manager-core/src/files_service.rs:55,68, crates/manager-core/src/pubsub_service.rs:124,226
- Summary: Every
check_read/check_writeuses.map_err(|_| KvError::Forbidden)?— collapsing bothAuthzDenied::DeniedANDAuthzDenied::Repo(repo_err)intoForbidden. A DB blip in the authz repo surfaces as 403 to scripts; operators can't distinguish "real forbidden" from "Postgres flap during permission check".users_service::require(users_service.rs:177-181) gets this right by mapping separately. Apply the same pattern uniformly.
F-Q-006 — InboxRegistry::register silently no-ops on lock poisoning, leaking a dead id
- Category: Code Quality
- Severity: High
- Location: crates/orchestrator-core/src/inbox.rs:46-53
- Summary:
register()returns(Uuid, Receiver)even when the innerMutexis poisoned — theif let Ok(mut g) = self.inner.lock()swallows the error, the sender is never inserted, the laterdeliver(id, …)will see no entry and returnAbandoned. The caller'sawait rxblocks until the orchestrator's outer timeout. Sister registriesRouteTable(routing/table.rs:36) andAppDomainTablecorrectly panic with.expect("route table poisoned"). Fix: switch toexpect("inbox poisoned")and document the policy.
F-T-001 — useEndpoint violates React Rules of Hooks
- Category: Code Quality
- Severity: High
- Location: clients/typescript/src/react/index.ts:73-101
- Summary:
useEndpoint(path)returns{ get: () => useResource(…), post: (body) => useResource(…) }. Each of.get()and.post()callsuseResource, which callsuseStateanduseEffect. React's Rules of Hooks require hook calls in the top level of a component, in the same order each render. CallinguseEndpoint(path).get()conditionally, or calling both.get()and.post()from the same component, will violate hook ordering and produce undefined behavior (state from one call leaking into the other).react.test.tsxdoesn't exerciseuseEndpointat all — onlyuseTopic— so this is uncaught. Fix: refactor to a flat hook (useEndpointGet(path)/useEndpointPost(path)) with consistent hook order.
F-U-001 — Dashboard cannot create KV, Docs, Files, or Dead-letter triggers
- Category: UI/UX
- Severity: High
- Location: dashboard/src/routes/apps/[slug]/+page.svelte:1092-1342, dashboard/src/lib/api.ts:771-801
- Summary: Backend
triggers_api.rsexposesPOST /apps/{id}/triggers/{kv,docs,files,dead_letter}, but the dashboard's Triggers tab ships create forms only for cron, pubsub, email, and queue. Listing shows kv/docs/files/dead_letter rows (withcollection_globandops) but operators can't create them from the UI. Add four moresubmitCreate*forms (collection glob + ops checkboxes).
F-U-002 — Queue drilldown links to a non-existent app-scoped scripts route
- Category: UI/UX
- Severity: High
- Location: dashboard/src/routes/apps/[slug]/queues/[name]/+page.svelte:71
- Summary: The consumer-script link is
{base}/apps/{slug}/scripts/{detail.consumer.script_id}but the actual script detail route is{base}/scripts/{id}— there is noapps/[slug]/scripts/[id]directory. Clicking the consumer name 404s. Either move scripts under apps in the route tree (the breadcrumb on the script page already wants this), or fix the link to{base}/scripts/{detail.consumer.script_id}.
F-U-003 — Files page has no collection list — operators must guess collection names
- Category: UI/UX
- Severity: High
- Location: dashboard/src/routes/apps/[slug]/files/+page.svelte:96-110
- Summary: The Files page can only list a collection if you already know its name and type it in. There's no "browse known collections" affordance, no list endpoint (
GET /apps/{id}/files/collectionsis missing in the backend too — flag for v1.1.10+), so first-time operators have no recourse. Minimum fix: show a hint listing collection-glob patterns drawn from any registeredfilestriggers. Longer-term: backend endpoint to list known collections.
Medium
F-S-003 — users::find_by_email exposes user existence to anonymous callers
- Severity: Medium
- Location: crates/manager-core/src/users_service.rs:400-413
- Summary:
find_by_emailrequiresAppUsersRead, which means anonymous public-HTTP scripts can call it (the gate short-circuits onNone). An attacker hitting any public route can iterate emails to enumerate registered users, then pivot tologin/request_password_reset. The reset path is silent-on-miss;find_by_emailis not. Fix: require an authenticated principal forfind_by_email, or document that scripts must wrap it in their own auth check before calling from a public route.
F-S-004 — request_password_reset has an observable timing oracle for email existence
- Severity: Medium
- Location: crates/manager-core/src/users_service.rs:646-651
- Summary: Handler returns immediately when email shape is invalid or user is unknown; on found user it generates an Argon2 token, INSERTs into
app_user_password_resets, builds a link, and callsemail.send(tens of ms + DB write + SMTP RTT). The wall-clock delta is enormous and externally observable. Combined with F-S-003, this gives a reliable enumeration oracle. Fix: when no user matches, still do a dummygenerate_session_token()and a dummyemail.send(against/dev/nullor a fixed sleep). Better: run the full code path unconditionally and discard the result on no-match.
F-S-005 — users_admin_api::delete_user doesn't enforce AppUsersAdmin despite the brief
- Severity: Medium
- Location: crates/manager-core/src/users_admin_api.rs:250-272
- Summary: Handler comment acknowledges the brief required
AppUsersAdminfor the irreversible dashboard delete, but the implementation callsservice.delete(...)which only gates onAppUsersWrite. v1.1.8 HANDBACK §7 lists this as known. Effect: any editor can delete app users via the admin HTTP API and dashboard button, not just admins. Fix: add an explicitauthz::require(... AppUsersAdmin(app_id))beforeservice.delete.
F-S-006 — API-key bearer creates Argon2 CPU amplifier for arbitrary callers
- Severity: Medium
- Location: crates/manager-core/src/auth_middleware.rs:192-211
- Summary:
verify_api_keyArgon2id-verifies every candidate sharing the 8-char prefix. An unauthenticated attacker can submit unlimitedAuthorization: Bearer pic_<8>...requests and force the server into per-request Argon2 (OWASP defaults: ~tens of ms each). A few hundred concurrent connections trivially DoSes the manager — the verify runs before any concurrency cap. The 32-byte random body has astronomical entropy, so Argon2 is overkill here. Fix: switch the key-verify hash to SHA-256 (high-entropy tokens don't need Argon2), cap the candidate set at a small constant (e.g. 16) and log when truncation occurs, and add per-IP rate limit at the reverse proxy.
F-S-007 — No rate limit on auth/login — Argon2 verify is a memory DoS
- Severity: Medium
- Location: crates/manager-core/src/auth_api.rs:74-101
- Summary: Login always runs Argon2 verify (timing-flat — good), but no rate limit, no failed-attempt lockout, no captcha. OWASP-default Argon2 cost (m=19456 KiB ≈ 19 MB RAM per verify) makes this a credible memory + CPU DoS: a few hundred concurrent
POST /admin/auth/loginexhausts manager memory. Combined with F-S-006, the auth surface has no brute-force defense. Fix: per-IP throttling at the reverse proxy (documented Caddy rate-limit module) and a per-username sliding-window lockout inadmin_user_repo.
F-S-008 — Realtime authority key cache never invalidates
- Severity: Medium
- Location: crates/manager-core/src/realtime_authority.rs:34,56-72
- Summary:
key_cache: Mutex<HashMap<AppId, Vec<u8>>>is populated on first read and never evicted, bounded, or cleared on app deletion. (1) Once key rotation lands, every running process keeps accepting tokens signed by the old key until restart. (2) Today, if an operator drops and re-creates an app (FK CASCADE removesapp_secrets, fresh row inserted), the cache hands out the old key bytes. Also unbounded growth on a many-app install. Fix: TTL on cache entries, rotation eviction hook, or simplify by removing the cache (DB lookup is cheap; only fires on subscribe).
F-S-009 — PICLOUD_DEV_MODE deterministic master key is fatal if accidentally enabled in prod
- Severity: Medium
- Location: crates/shared/src/crypto.rs:206-231
- Summary: When
PICLOUD_DEV_MODE=trueANDPICLOUD_SECRET_KEYis unset, the master key becomesSHA-256("picloud-dev-master-key-v1.1.7")— a fully public value. The warning is correct but the gate is a single env var. An operator who copies a dev docker-compose file into prod silently encrypts everything with a world-known key. Fix: add a startup check that refuses dev mode when prod indicators are present (non-localhostPICLOUD_PUBLIC_BASE_URL, etc.), or require BOTHPICLOUD_DEV_MODE=trueANDPICLOUD_DEV_INSECURE_KEY=i-understand-this-is-insecure.
F-S-010 — Inbound-email HMAC verification has no replay protection
- Severity: Medium
- Location: crates/manager-core/src/email_inbound_api.rs:83-144
- Summary: Receiver verifies
HMAC-SHA256(secret, body)constant-time — good. But the signature covers only the body. No timestamp binding, no nonce/idempotency check. A captured webhook request can be replayed indefinitely, re-firing the trigger and enqueueing duplicate executions. Industry standard (Stripe, Slack) includes a timestamp in the signed payload and rejects requests outside a ~5-minute window. Fix: addX-Picloud-Timestampto the HMAC input, reject requests outside the configured window, and dedupe onmessage_idin a TTL'd cache.
F-S-011 — admin_sessions has no revoked_at — password change can't invalidate live sessions
- Severity: Medium
- Location: crates/manager-core/src/admin_session_repo.rs:1-152
- Summary: Unlike
app_user_sessions(v1.1.8 — hasrevoked_atandrevoke_for_user),admin_sessionsonly hasexpires_atand adelete_for_user(user_id)helper. No admin-side "revoke all sessions" gesture; an admin password change must explicitly calldelete_for_user(audit the flow to confirm it does). Fix: add arevoked_atcolumn for explicit revocation distinct from TTL expiry, and add a "log out all sessions" action.
F-S-012 — invoke_async has no authz check — anonymous scripts can fire any script in the app
- Severity: Medium
- Location: crates/manager-core/src/invoke_service.rs:121-161, crates/executor-core/src/sdk/invoke.rs:86-110
- Summary:
enqueue_asyncand the syncinvoke()perform noauthz::requirecheck — noAppInvokecapability exists. Same-app isolation is preserved (cross-app guard works), but within one app, an anonymous public-HTTP script can trigger any other script (e.g. an admin-only worker that hits secrets/files/external HTTP). Worse:invoke_asyncruns the callee withprincipal: None, so the callee may itself hold capabilities the original public caller shouldn't. Fix: addCapability::AppInvoke(app_id)and gateinvoke()/invoke_async()on it; consider a "private" vs "public" script tag for the anonymous case.
F-S-013 — users::invite accumulates pending invitations per email without dedup
- Severity: Medium
- Location: crates/manager-core/src/users_service.rs:720-778, crates/manager-core/migrations/0030_app_user_invitations.sql:16-26
- Summary: No unique constraint on
(app_id, email)for pending rows. Callingusers::invite("alice@…")N times creates N rows with N valid tokens. Combined withaccept_invitereturningOk(None)silently when the email already exists, an attacker who learns one invitation token can permanently consume it without effect. Fix: partial unique index(app_id, lower(email)) WHERE accepted_at IS NULL+ handle conflict as 409 with "reissue?" UX.
F-S-014 — Topic edit modal warns only on internal → external flip, not on any other permissive change
- Severity: Medium
- Location: dashboard/src/routes/apps/[slug]/+page.svelte:1678-1722
- Summary: Warning fires only for the internal→external transition (
editFlipToExternal). Switchingtoken → publicorsession → publicwhile already external is equally risky (opens topic to anonymous subscribers) but produces no warning. Fix: fire the warning whenever the resulting(external, auth_mode)pair is strictly more permissive than the current one.
F-P-010 — list_active_queue_consumers (called every 100ms) lacks a matching index
- Category: Performance
- Severity: Medium
- Location: crates/manager-core/src/trigger_repo.rs:1290-1303 vs crates/manager-core/migrations/0008_triggers.sql:47-49
- Summary: Query is
WHERE t.kind='queue' AND t.enabled=TRUEwith noapp_idfilter; available indexidx_triggers_app_kind_enabled (app_id, kind) WHERE enabled = TRUErequires anapp_idpredicate to be useful. With many apps × many trigger kinds, this query becomes a sequential scan every 100ms. Fix: addCREATE INDEX idx_triggers_kind_enabled ON triggers(kind) WHERE enabled = TRUE, or maintain an in-memory consumer registry refreshed by admin writes.
F-P-011 — RouteTable::replace_all rebuilds the entire per-app trie on every route CRUD write
- Category: Performance
- Severity: Medium
- Location: crates/orchestrator-core/src/routing/table.rs:31-38, crates/manager-core/src/route_admin.rs:372-379
- Summary: Every admin route create/delete calls
refresh_table→routes.list_all()→ compile all rows →replace_all. With a thousand routes a single edit reparses every route under the writer lock. Fix: incremental update — push compiledCompiledRouteinto the per-app slice on create, remove by id on delete.
F-P-012 — app_user_repo::list cursor lacks an (created_at, id) tiebreaker
- Category: Performance
- Severity: Medium
- Location: crates/manager-core/src/app_user_repo.rs:200-225
- Summary:
ORDER BY created_at DESC, id DESCbut cursor isWHERE created_at < $2— when two users were created at the same instant, pagination can skip the second row at a page boundary or return it twice. Fix: encode(created_at, id)in the cursor and useWHERE (created_at, id) < ($ts, $id). Same shape used elsewhere in this repo.
F-P-013 — RhaiEngine + every SDK module registered per call
- Category: Performance
- Severity: Medium
- Location: crates/executor-core/src/engine.rs:167-204, crates/executor-core/src/sdk/mod.rs:49-68
- Summary: Each
execute_astcallsbuild_engine(new Rhai engine, every limit setter, disable-symbol, register all stdlib static modules), thensdk::register_allregisters 12 service modules. At 32 concurrent executions on a Pi, this is real per-call overhead even with AST caching. Fix shape: pool pre-built Rhai engines with stateless modules pre-registered, reset per-call state, and only install per-call closures that needSdkCallCx.
F-P-014 — regex::* SDK functions compile patterns on every call
- Category: Performance
- Severity: Medium
- Location: crates/executor-core/src/sdk/stdlib/regex.rs:23-25
- Summary:
is_match,find,find_all,replace,replace_all,split,capturesall callRegex::new(pattern)per invocation. A script doingregex::is_match("\\d+", x)in a tight loop pays the compile every iteration. Fix: thread-localLruCache<String, Arc<Regex>>bounded to e.g. 128 entries. Regex compile dominatesis_matchon short strings.
F-P-015 — OutboxEventEmitter inserts one row per matching trigger
- Category: Performance
- Severity: Medium
- Location: crates/manager-core/src/outbox_event_emitter.rs:88-103
- Summary: For each matched trigger, one separate
outbox.insertround-trip. A KV mutation matched by 5 triggers becomes 5 sequential INSERTs inside the script's hot path, each cloning the JSONB payload. Fix: extendOutboxRepo::insert_many(rows)and emit one multi-rowINSERT … SELECT * FROM UNNEST(...); bind payload once.
F-P-016 — app_user_session_repo::gc OR chain mismatches the available index
- Category: Performance
- Severity: Medium
- Location: crates/manager-core/src/app_user_session_repo.rs:186-200 vs crates/manager-core/migrations/0027_app_user_sessions.sql:34-35
- Summary: GC inner SELECT predicate is
expires_at <= NOW() OR absolute_expires_at <= NOW() OR revoked_at IS NOT NULL. Available partial index is(expires_at) WHERE revoked_at IS NULL— only helps the first disjunct on non-revoked rows. The OR chain forces a sequential scan. Fix: addCREATE INDEX … ON app_user_sessions(revoked_at) WHERE revoked_at IS NOT NULL, and rewrite the GC as three UNION ALL subqueries (one per disjunct).
F-Q-007 — DispatcherError truncates the entire error chain into String
- Category: Code Quality
- Severity: Medium
- Location: crates/manager-core/src/dispatcher.rs:957-963 and many call sites (lines 129, 157, 176, 228, 235, 353, 365, 379, 391, 411, 453, 462, 488, 494, 545, 615, 724, 755, 779)
- Summary:
DispatcherError::{Outbox, ResolveTrigger}wrapString. Every?calls.to_string()on the source, killing#[source]introspection and the cause chain. Logs show "outbox: database error: ..." with no programmatic way to discriminate. Fix: convert to#[from] sqlx::Error(or structured variants) sotracing::error!(error.cause_chain = ?err)works.
F-Q-008 — HttpError::Backend misclassifies validation errors
- Category: Code Quality
- Severity: Medium
- Location: crates/manager-core/src/http_service.rs:209,340,342
- Summary:
Method::from_bytesfailing on a user-supplied HTTP method getsHttpError::Backend(format!("invalid method: {}", req.method))— but that's user input, not a backend problem. Same for header-name / header-value validation. Misclassifying asBackendcorrupts retry policies (operators may retry "backend" but not "invalid input"). Fix: addHttpError::InvalidArgs(String).
F-Q-009 — dispatcher::TICK_INTERVAL and ASYNC_EXEC_TIMEOUT are hard-coded; siblings are env-overridable
- Category: Code Quality
- Severity: Medium
- Location: crates/manager-core/src/dispatcher.rs:73,80
- Summary:
TICK_INTERVAL = 100msandASYNC_EXEC_TIMEOUT = 300sareconst. Every other timing knob in the file (cron tick, queue reclaim, retry policy) is env-overridable viaTriggerConfig::from_env(). Operators on a constrained Pi or a busier instance can tune retries but not dispatcher cadence. Fix: promote toTriggerConfigwithPICLOUD_DISPATCHER_TICK_INTERVAL_MSandPICLOUD_DISPATCHER_ASYNC_EXEC_TIMEOUT_SEC.
F-Q-010 — Mutex poisoning policy inconsistent across orchestrator-core registries
- Category: Code Quality
- Severity: Medium
- Location: crates/orchestrator-core/src/inbox.rs:49,75 (silent), crates/orchestrator-core/src/routing/table.rs:36 (loud
expect) - Summary: Two registries playing the same architectural role (in-memory caches behind
Arc) handle poisoning oppositely.RouteTable/AppDomainTableexpect("route table poisoned");InboxRegistrysilently swallows. Settle on one policy — the loudexpectis correct (poisoning means a prior panic; nothing is recoverable). See also F-Q-006.
F-Q-011 — PostgresScriptRepoHandle newtype hand-delegates 11 methods
- Category: Code Quality
- Severity: Medium
- Location: crates/picloud/src/lib.rs:622-693
- Summary: A 70-line newtype wraps
Arc<PostgresScriptRepository>and re-implements everyScriptRepositorymethod viaself.0.method(...).await. Comment claims it exists because the resolver wantsimpl ScriptRepository"owned." Fix: provide a blanketimpl<T: ScriptRepository + ?Sized> ScriptRepository for Arc<T>inmanager-core, or accept&dyn/Arc<dyn>ScriptRepository everywhere. Hand-delegation invites silent skew when the trait gains a method.
F-Q-012 — build_app is ~480 lines of pure wiring
- Category: Code Quality
- Severity: Medium
- Location: crates/picloud/src/lib.rs:101-578 (
#[allow(clippy::too_many_lines)]) - Summary: Constructs ~30
Arc<dyn ...>handles, ~15*Statestructs, ~12 routers, spawns 6 background tasks — all in one function with an opt-out clippy allow. Fix: extractwire_services(pool, master_key) -> Services,wire_admin_states(...) -> AdminStates,spawn_background_tasks(...). Current shape makes the "field-missing" mistake in F-Q-013 easy.
F-Q-013 — Services::new takes 13 positional Arc<dyn …> args — adding a service silently breaks all callers
- Category: Code Quality
- Severity: Medium
- Location: crates/shared/src/services.rs:118-148, crates/picloud/src/lib.rs:296-310
- Summary: Constructor is
#[allow(clippy::too_many_arguments)]and takes 13 positional handles (kv, docs, dl, events, modules, http, files, pubsub, secrets, email, users, queue, invoke). Adding workflows in v1.2 means every caller breaks silently if order is wrong. Fix: builder (Services::builder().kv(kv).docs(docs)….build()) or struct-literal constructor — both make field-name binding type-checked.
F-Q-014 — Hundreds of #[ignore]'d integration tests with no CI hook
- Category: Code Quality
- Severity: Medium
- Location: crates/picloud/tests/api.rs (~60 ignored), crates/picloud/tests/authz.rs (~30 ignored),
crates/picloud-cli/tests/*(~70 ignored) - Summary: Every test is
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]. Hundreds of tests don't run unless a developer explicitly runscargo test -- --ignoredwith Postgres. No marker for "this was the test that was supposed to catch X." Fix: flip to theschema_snapshot.rs/dispatcher_e2e.rsconvention (auto-skip when Postgres is absent, no#[ignore]), or document the CI workflow that runs them.
F-M-001 — idx_cron_triggers_due index doesn't match the actual scheduler query
- Category: Migration/Schema
- Severity: Medium
- Location: crates/manager-core/migrations/0017_cron_triggers.sql:41, crates/manager-core/src/cron_scheduler.rs:117-123
- Summary: Index is on
(last_fired_at)with a comment claiming it serves the scheduler. The actual query isWHERE t.enabled = TRUE FOR UPDATE OF d SKIP LOCKED— nolast_fired_atpredicate. The index is unused; the query is effectively a full-table scan of cron details joined to enabled triggers. Fix: either the query should filter onlast_fired_at(to skip very-recently-fired rows), or the index should be dropped.
F-M-002 — email_trigger_details encrypted-secret columns lack coupled-nullness CHECK
- Category: Migration/Schema
- Severity: Medium
- Location: crates/manager-core/migrations/0024_email_triggers.sql:28-32
- Summary:
inbound_secret_encryptedandinbound_secret_nonceare each nullable independently. There is no CHECK that they be either both NULL or both NOT NULL. A bug or partial write could leave one populated and the other NULL — silently bypassing signature verification (receiver decrypts only if the encrypted column is set). Fix:CHECK ((inbound_secret_encrypted IS NULL) = (inbound_secret_nonce IS NULL)). Same pattern applies toapp_secrets.realtime_signing_key_encrypted/nonce(0025).
F-M-003 — outbox.trigger_id is polymorphic — no FK, orphans accumulate after route/trigger delete
- Category: Migration/Schema
- Severity: Medium
- Location: crates/manager-core/migrations/0009_outbox.sql:29
- Summary: The migration notes
trigger_idis polymorphic (routes.idwhensource_kind='http', elsetriggers.id) and has no FK. Deleting a route or trigger leaves orphan outbox rows pointing at non-existent parents. Dispatcher tolerates this (rows fail dispatch and dead-letter), but a route delete + dispatcher restart can produce orphan dead-letters referencing a script that no longer matches the original route. Fix: explicit cleanup in route/trigger delete paths; consider source-kind-keyed FKs via a partial constraint or trigger.
F-T-002 — useEndpoint().post() auto-fires on mount — a typo creates user records or sends emails per refresh
- Category: Code Quality
- Severity: Medium
- Location: clients/typescript/src/react/index.ts:73-100
- Summary: README says "the mutation variant (auto-fires once per mount)" — but a typical mutation is event-driven (click → POST), not fire-on-mount idempotent. Auto-firing a POST on mount creates user records, sends emails, etc. unconditionally.
useEndpoint('/api/users').post({ name })on a typo will create a user on every refresh. Fix: refactor to{ mutate, data, loading, error }pattern;mutate(body)is event-driven, not auto-firing.
F-U-004 — Five dashboard pages render light-theme fallback colors on the dark-theme app
- Category: UI/UX
- Severity: Medium
- Location: dashboard/src/routes/apps/[slug]/dead-letters/+page.svelte:175-309, dashboard/src/routes/apps/[slug]/files/+page.svelte:174-228, dashboard/src/routes/apps/[slug]/users/+page.svelte:364-466, dashboard/src/routes/apps/[slug]/users/invitations/+page.svelte:254-325, dashboard/src/routes/apps/[slug]/queues/+page.svelte:93-128
- Summary: Styles use
var(--text-muted, #666),var(--bg-secondary, #f5f5f5),var(--border, #e0e0e0),.error { color: #b00020; },var(--color-link)(undefined). The dashboard never defines any of these CSS custom properties, so the fallbacks are what users see. Result: light-grey text on near-white headers over the dark slate-900 page background; the dead-letters error banner is white-on-red; borders disappear on Queues. Fix: align to the explicit slate palette used byapps/[slug]/+page.svelte. Or, better: define CSS custom properties on a root scope.
F-U-005 — Trigger create forms hide dispatch_mode and retry-policy knobs
- Category: UI/UX
- Severity: Medium
- Location: dashboard/src/routes/apps/[slug]/+page.svelte:1101-1296
- Summary:
Trigger.dispatch_mode,retry_max_attempts,retry_backoff,retry_base_msare all editable via the create API and visible in the list, but the cron/pubsub/email create forms never expose them — only queue hasretry_max_attempts. Operators have no way to set sync vs async dispatch or backoff strategy from the UI. Fix: add an<details>Advanced</details>block per trigger form.
F-U-006 — Trigger enabled flag never displayed; no toggle UI
- Category: UI/UX
- Severity: Medium
- Location: dashboard/src/routes/apps/[slug]/+page.svelte:1300-1340
- Summary:
Trigger.enabled: booleanis on the DTO and the backend supports disabled triggers, but the trigger list never shows the state and offers no pause/resume action. Add at minimum a "• disabled" pill; ideally a toggle. If no PATCH endpoint exists, flag for backend.
F-U-007 — Native confirm() and alert() used for route deletion
- Category: UI/UX
- Severity: Medium
- Location: dashboard/src/routes/scripts/[id]/+page.svelte:282,287
- Summary:
removeRoute()useswindow.confirm('Delete this route?')and surfaces errors viawindow.alert(). The rest of the dashboard adoptedConfirmModalfor exactly this case. The browser modal is unstyled, can't show route detail, and the alert is a dead-end. Fix: convert to aConfirmModalwith route details in the body and an inline error region.
F-U-008 — Script-page "← Scripts" back-link drops out to /apps
- Category: UI/UX
- Severity: Medium
- Location: dashboard/src/routes/scripts/[id]/+page.svelte:420
- Summary: "← Scripts" link uses
base + '/', which the root page redirects to/apps. After deleting a script the user is also bounced tobase + '/'. The breadcrumb already resolvesappSlug; the back-link should be{base}/apps/{appSlug}.
F-U-009 — Files page exposes no upload, download, or copy-id affordance
- Category: UI/UX
- Severity: Medium
- Location: dashboard/src/routes/apps/[slug]/files/+page.svelte:120-153
- Summary: Listing shows name/content-type/size/created/id; only mutation is Delete. No download link, no copy-id button (the UUID is shown but unclickable), no preview, no metadata refresh. Operators must use the admin API directly. Fix: add download link per row and a copy-id button.
F-U-010 — No instance-level configuration page surfacing PICLOUD_* env knobs
- Category: UI/UX
- Severity: Medium
- Location: (missing)
dashboard/src/routes/config/ - Summary: The platform reads ~25
PICLOUD_*env vars at startup (concurrency cap, session TTL, sandbox ceilings, files root + max size, SMTP timeout/TLS/port, secret max bytes, email max bytes, queue reclaim, trigger retry, HTTP allow-private, script/module cache sizes, public base URL, cookie secure). None are visible from the dashboard. Operators debugging "why is upload rejected" or "why did session expire early" have to ssh in. Fix: add a read-only/admin/configpage using a newGET /api/v1/admin/configendpoint that returns non-secret current values.
F-U-011 — Cron trigger schedule has no helper, preview, or field-count validation
- Category: UI/UX
- Severity: Medium
- Location: dashboard/src/routes/apps/[slug]/+page.svelte:1112-1118
- Summary: Cron input is a plain text box with placeholder
0 0 9 * * MON-FRI. Description says "6-field cron expressions (with seconds)" but a user typing a typical 5-field crontab (0 9 * * 1-5) gets a confusing 422 from the server. No "next 3 fires" preview, no client-side validation. Fix: split-count check before submit, info popover listing the 6 positions, and a cron-parser preview of the next fires.
F-U-012 — Logs viewer hard-capped at 50 rows, no pagination or filtering
- Category: UI/UX
- Severity: Medium
- Location: dashboard/src/routes/scripts/[id]/+page.svelte:369,773-839
- Summary:
api.scripts.logs(id, { limit: 50 })is the only call; backend supportsoffset(see api.ts:659) but there's no "load more". No filter by status (success/error/timeout/budget_exceeded), no filter by request path or log level, no time range. Operators investigating a failing script see only the most recent 50 with no way to drill back. Fix: offset pagination, status filter dropdown, and a level filter.
F-U-013 — Queues read-only pages have no auto-refresh or refresh button
- Category: UI/UX
- Severity: Medium
- Location: dashboard/src/routes/apps/[slug]/queues/+page.svelte:56-91, dashboard/src/routes/apps/[slug]/queues/[name]/+page.svelte:50-90
- Summary: Queue depths change continuously; the page shows a page-load snapshot with no way to update without a hard refresh. Dead-letters has a Refresh button; queues doesn't. Fix: add a Refresh button; auto-refresh-every-5s toggle would make this page useful for live monitoring.
F-U-014 — Email-inbound webhook URL uses window.location.origin — wrong under reverse proxy
- Category: UI/UX
- Severity: Medium
- Location: dashboard/src/routes/apps/[slug]/+page.svelte:246-248
- Summary:
emailInboundUrl()builds${window.location.origin}/api/v1/email-inbound/.... If the admin browses via an internal LAN address but the public webhook URL is on a different host, the dashboard hands the operator the wrong URL.VersionInfo.public_base_urlalready exists for exactly this case. Fix: useversion.public_base_urlinstead ofwindow.location.origin.
F-U-015 — No session-expiry warning; silent 401 redirect drops in-flight form state
- Category: UI/UX
- Severity: Medium
- Location: dashboard/src/lib/api.ts:438-446
- Summary: On any 401 the fetch wrapper immediately calls
clearSession()andgoto('/login'). A user mid-edit on a long Rhai script loses the entire scratch. No warning before expiry, no interstitial showing lost state, no draft auto-save. Fix: session-warning toast ~5min before TTL; localStorage-backed source drafts; "your session expired, sign in again" interstitial preserving form state.
F-U-016 — Several modals lack focus trap — Tab moves focus to background elements
- Category: UI/UX
- Severity: Medium
- Location: dashboard/src/lib/ConfirmModal.svelte:100-156, dashboard/src/routes/users/+page.svelte:402-476
- Summary:
ConfirmModalfocuses the first input on mount and listens for Escape but doesn't trap Tab. Keyboard users can Tab out of the modal into the underlying page. Fix: implement focus trap (viainerton the rest of the document, or a Tab-bound key handler).
F-U-017 — Users tables grid columns overflow below ~700px viewport
- Category: UI/UX
- Severity: Medium
- Location: dashboard/src/routes/users/+page.svelte:727-734, dashboard/src/routes/profile/+page.svelte:669-677
- Summary: Both tables use 7- and 8-column CSS grids with no
@mediarule for mobile. Columns squash and the search input pushes header controls off-screen on narrow viewports. Fix: switch to card layout belowmin-width: 600px, or horizontal scroll with sticky first column.
F-U-018 — Dashboard can't create instance admins in the "owner" role
- Category: UI/UX
- Severity: Medium
- Location: dashboard/src/routes/users/+page.svelte:452-462
- Summary: Invite-user modal offers radios "admin" and "member" only. Owner can be granted only by editing an existing user. This is intentional friction (prevents the first-owner footgun) but is asymmetric with
editRoleOptions(line 111) where owners can assign owner directly. Document the distinction visibly above the role group, or expose "Owner" for the bootstrapping case.
Low
- F-S-015 —
users::loginrequiresAppUsersWriteso authenticated viewer-role scripts can't authenticate users — sharp edge for "login form on an authed admin page". users_service.rs:478-485 — Low - F-S-016 — Dispatcher
trigger_depth > maxvs invoke SDKcx.trigger_depth + 1 > maxdiffer by one level; align the boundary semantics and add a unit test. dispatcher.rs:342, invoke.rs:141 - F-S-017 —
SmtpConfigderivesDebugwith rawpassword: String— accidentaltracing::debug!(?cfg)leaks. email_service.rs:88-97. Hand-implementDebugwith redaction. - F-S-018 —
OutboxEventEmitterlog-and-ignore on emit failure: triggers can silently miss events if the outbox insert fails after the primary write. kv_service.rs:114-130, docs_service.rs:122-138, files_service.rs:74-102. Bump a metric on emit failure; long-term make primary + outbox a single tx. - F-S-019 —
retry::runallows cumulative sleep far exceeding the wall-clock timeout, tying up a gate permit. retry.rs:189-222. Cap cumulative sleep insideretry::run. - F-S-020 —
request_password_resetemail-send clamping accumulates dead reset tokens when SMTP misconfigured. Mark the just-inserted row consumed whenemail.sendreturns non-NotConfigured failures. users_service.rs:678-690 - F-S-021 — Bearer token in
Set-Cookienot validated against cookie-octet alphabet — if the token format ever changes, header injection becomes possible. auth_api.rs:207-223. Guard with[A-Za-z0-9_-]+. - F-S-022 —
attach_principal_if_presentswallows DB errors as anonymous — a transient DB blip strips authed callers and converts authed writes into anonymous writes that skip the capability gate (e.g. onsecrets_service). auth_middleware.rs:119-130 - F-S-023 —
users::logincross-app belt-and-suspenders check is unreachable in practice. Assert the invariant at the repo boundary instead. users_service.rs:478-514 - F-S-024 —
PICLOUD_COOKIE_SECUREenv-var parsing is inverted-boolean readability. Replace with explicitis_truthyparse identical to crypto.rs. auth_api.rs:212-218 - F-S-025 — Realtime signing key plaintext lingers in
Vec<u8>allocations without zeroize; heap dumps can leak. UseZeroizing<Vec<u8>>for the realtime signing key andZeroizeOnDropforMasterKey. app_secrets_repo.rs:78-91, realtime_authority.rs:34, shared/crypto.rs:117 - F-S-026 — Inbound-email decrypt failure → 401 conflates DB blip with bad signature; provider retries hammer the endpoint indefinitely. email_inbound_api.rs:149-166. Surface decryption failure as a logged 500.
- F-S-027 — Migration 0006 backfills every Phase-3a admin to
'owner'— documented but permissive. Future audits may surface unexpected owner sprawl. 0006_users_authz.sql:24-30 - F-S-028 — API-key prefix collisions force the middleware to Argon2-verify every candidate; cap the candidate set at a small constant (e.g. 16). auth_middleware.rs:198-211
- F-S-029 —
admin_reset_password_tokenreturns raw token in JSON without rate limit. Mint a new token doesn't invalidate prior unconsumed tokens. users_service.rs:902-927 - F-S-030 —
GeneratedSession/GeneratedAccept/GeneratedTokenderiveDebugwith raw tokens — same footgun as F-S-017. shared/users.rs:89-102, auth.rs:72 - F-S-031 —
auth_middleware::touchfailure becomes 500, blocking authed requests on transient DB blips. Fail-soft (log + continue with existing expiry). auth_middleware.rs:170-176 - F-S-032 — Cookie
SameSite=Laxis not exploitable today (admin POSTs accept JSON only), but adding aForm<...>extractor would make it CSRF-able. Switch admin cookie toSameSite=Strict. auth_api.rs:175,220-223 - F-P-017 —
execute_by_idclones request body, headers, and path before executing — wasteful on large bodies. Serialize logged shape from&reqafter a Result branch. orchestrator-core/src/api.rs:117-159 - F-P-018 — Sync HTTP path serializes body twice and clones a 3rd time for audit log. orchestrator-core/src/api.rs:363-373
- F-P-019 —
realtime_authority::signing_keyclones aVec<u8>underMutexon every cache hit. UseArc<[u8]>so clone is a refcount bump. realtime_authority.rs:56-72 - F-P-020 — Files orphan sweep walks the entire blob tree synchronously every 6h. Skip subtrees by mtime, or track temp files in a
files_pending_tmptable. files_sweep.rs:75-111 - F-P-021 —
dispatcher.handle_failuredecodes payload JSON 3× per failure. Decode once at top ofhandle_failure. dispatcher.rs:741-848 - F-P-022 —
InProcessBroadcaster::publishallocates aStringfrom the&strtopic per publish for the lookup key, even when no subscriber exists. Use a borrowed equivalent (raw_entry_mut). orchestrator-core/src/realtime.rs:107-117 - F-P-023 —
dead_letters_apiunresolved_countisCOUNT(*)per dashboard load. Partial index covers it today; cap at 100 with a "100+" UI state. dead_letter_repo.rs:172-181 - F-P-024 — Inline
block_onforsigning_keylookup in the realtime hot path on cold cache — fleet of reconnecting subscribers hits Postgres N times. Warm cache for active apps at startup. realtime_authority.rs:99-102 - F-P-025 —
attach_principal_if_presentmiddleware applied twice on overlapping routers (data_plane_routed+user_routes). Confirm no double-Argon2. picloud/src/lib.rs:546-553 - F-Q-015 —
pubsub_service::publish_durablespawns a task only to immediately await itsJoinHandle— accomplishes nothing the inline.awaitwouldn't. pubsub_service.rs:193-198 - F-Q-016 —
KvError::from(KvRepoError)doesBackend(e.to_string())for any repo error; files_service has the right shape (pattern-match and promote). Adopt uniformly. kv_service.rs:74-78, files_service.rs:111-119 - F-Q-017 —
kv_service.rsanddocs_service.rsinline emit-and-log boilerplate 17 lines × 2 callsites;files_service/users_servicealready extract it. Apply theemithelper pattern. kv_service.rs:111-130, docs_service.rs:122-138 - F-Q-018 —
auth_api::loginhas a stale.unwrap()after the contract has been narrowed; future refactor of the predicate ordering can re-enable the panic. Uselet Some(user_id) = user_id else { … };. auth_api.rs:102 - F-Q-019 — SDK trait methods in
shared/lack per-method rustdoc on most public surfaces. shared/src/{kv,docs,queue,files,secrets,email,pubsub,users}.rs - F-Q-020 — Several tests use
assert!(... .is_ok())instead of asserting on the value — auth, realtime_authority, http_service especially. client.rs:408, http_service.rs:774, auth.rs:186, realtime_authority.rs:251 - F-Q-021 —
gc::SWEEP_INTERVAL(weekly) and 5000-row batch hardcoded; retention is env-overridable but cadence isn't. Promote toTriggerConfig. gc.rs:25,30 - F-Q-022 —
SmtpConfig::from_envuses bare literal.unwrap_or(30)for default timeout. Name itDEFAULT_SMTP_TIMEOUT_SECS. email_service.rs:128 - F-M-004 — Redundant or under-targeted indexes:
secrets (app_id)duplicates PK leftmost prefix;idx_kv_entries_app_collectionduplicates PK;idx_queue_trigger_details_queue_namelacksapp_id;idx_triggers_app_pubsub_enabledduplicates broaderidx_triggers_app_kind_enabled. Each costs write amplification without enabling new plans. 0007_kv.sql:28, 0020_pubsub_triggers.sql:32, 0023_secrets.sql:24, 0035_queue_triggers.sql:40 - F-M-005 —
triggers.retry_max_attempts/retry_base_msandqueue_messages.max_attempts/attempthave no CHECK against negative or absurd values. Apply thescripts.timeout_seconds CHECK (> 0 AND <= 300)discipline. 0008_triggers.sql:35,38, 0034_queue_messages.sql:28-29 - F-M-006 —
dead_letters.resolutionis NULL-able with no coupled-with-resolved_atCHECK. Same pattern as F-M-002. 0010_dead_letters.sql:37-40 - F-M-007 —
routes_unique_binding_idxusesCOALESCE(method, '')allowing ambiguous binding (an empty-string method conflicts with a NULL method). AddCHECK method IS NULL OR method IN ('GET','POST',…). 0005_apps.sql:100 - F-T-003 —
subscribeTopic401 refresh path recurses without backoff. IfonTokenExpiredreturns a stale token, unbounded recursion. Trackconsecutive401Count. subscribe.ts:63-75 - F-T-004 —
subscribeTopicresetsattempt = 0after a successful connect, but a stream that errors immediately after one byte means the next reconnect isbase * 2^0(no backoff) — hot reconnect loop possible. Gate the reset on receiving the first event or an uptime threshold. subscribe.ts:82-96 - F-T-005 —
useTopicexcludesoptsfrom deps — a caller changingvalidate: zSchemaAtozSchemaBfor the same topic won't take effect. Document thatoptsis captured once per topic. react/index.ts:39-55 - F-T-006 —
parseBodyswallows JSON parse errors and silently falls through to text; the caller'svalidate.parsethen receives raw text and throws a confusing "expected object". endpoint.ts:84-95 - F-T-007 —
joinUrldoesn't normalize repeated slashes (https://x/v1//userssurvives). Strip multiple consecutive/in the path portion. endpoint.ts:102-106 - F-T-008 —
tsup.config.tsexternals don't includereact/jsx-runtime— if anyone uses JSX syntax, the build bundles a copy. Pre-emptive add. tsup.config.ts:17 - F-T-009 —
AuthClienttoken storage is in-memory only; SSR and persistence-across-refresh aren't documented. Add a README section on theonTokencallback + storage trade-off. auth.ts:26 - F-T-010 —
AuthClient.loginhardcoded to{ email, password }— overload with arbitrary credentials object would suit the hybrid-model intent. auth.ts:39-48 - F-U-019 — Email-trigger inbound webhook URL has no copy button (compare API-key reveal pattern with clipboard support). +page.svelte:1321
- F-U-020 — Version info fetched via
api.version()but never displayed; header is the natural place for a build-info chip. scripts/[id]/+page.svelte:50 - F-U-021 — Several pages override
.container { max-width }inside a layout that already caps at 64rem — the override is silently ignored, and dead-letters' 8-column table overflows on narrow viewports. dead-letters/+page.svelte:176-180, files/+page.svelte:175-179 - F-U-022 —
$effectblocks reference variables viavoid slug;to force tracking — Svelte 5 cargo-cult; the synchronous deref insideload()already registers the dependency. dead-letters/+page.svelte:32-37 - F-U-023 — Logs viewer expanded JSON has no pretty-print toggle, copy button, or syntax highlighting. scripts/[id]/+page.svelte:804-811
- F-U-024 — Test-invoke headers field accepts any JSON value but the runtime coerces all to strings;
{x-foo: null}becomes header"null"with no warning. scripts/[id]/+page.svelte:178-188 - F-U-025 — Apps list refetches dead-letter counts in N+1 (one per app) on every mount. Backend endpoint to batch counts would be O(1). apps/+page.svelte:19-33
- F-U-026 — Login form doesn't reset password on failed attempt — typo persists into next try. Either clear the password or add a "show password" toggle. login/+page.svelte:24-36
- F-U-027 — App-user create form has no password strength meter or visibility toggle. The instance Users page uses
generatePassword(16)+ reveal-once — much safer for the admin-bootstraps-user flow. apps/[slug]/users/+page.svelte:202-205 - F-U-028 — App-user roles input on Invitations is comma-separated string with no autocomplete from existing roles. Render as chips with autocomplete. apps/[slug]/users/invitations/+page.svelte:146-149
- F-U-029 — Trigger creation dropdown doesn't decorate
<option>text with existing-trigger counts per script — operators don't notice they're piling on. apps/[slug]/+page.svelte:1101-1296 - F-U-030 — Members tab eligibleUsers errors only display once per session and disappear after submit attempt; the empty state for
app_adminusers is a dead-end with no actionable workflow. apps/[slug]/+page.svelte:519-540 - F-U-031 — Members table inactive rows are opacity-greyed but the ActionMenu items remain fully active and the row gives no screen-reader cue. Add
aria-disabled="true". apps/[slug]/+page.svelte:1048-1090 - F-U-032 — Modal backdrop has no focus restoration on close — keyboard users lose their place. Capture
document.activeElementon mount, re-focus on destroy. ConfirmModal.svelte:91-97 - F-U-033 — Apps-list "No apps yet. Create one above…" misleads
memberinstance-role users who can't create apps. Branch the empty-state copy. apps/+page.svelte:217-218 - F-U-034 —
ConfirmModal.confirmPhrasePromptdefaults to "Type the slug to confirm:" regardless of context (e.g. script delete uses script name, not slug). Make default neutral. ConfirmModal.svelte:121-122 - F-U-035 — Triggers list shows full UUIDs inline; dead-letters truncates to 8 chars without tooltip or link. Render script name with full UUID as
titleand link to/scripts/{id}. apps/[slug]/+page.svelte:1329, dead-letters/+page.svelte:131 - F-U-036 — No copy-to-clipboard for any UUID shown anywhere. Reusable
<CopyableId>component. profile/+page.svelte:230, files/+page.svelte:138 - F-U-037 — Timestamps via
new Date(iso).toLocaleString()give no timezone hint. Show ISO UTC astitle=tooltip or use relative time with absolute on hover. (Several pages.) - F-U-038 — Cron timezone dropdown is a hand-rolled 14-entry list; missing common zones. Use
Intl.supportedValuesOf('timeZone')with datalist autocomplete. apps/[slug]/+page.svelte:34-50 - F-U-039 — Pubsub topic-pattern input has no datalist from registered topics. Drawing from
topics[].namewould catch typos. apps/[slug]/+page.svelte:1152-1179 - F-U-040 — Dashboard doesn't display
public_base_urlanywhere as a "this instance lives at" hint. Add to footer/header chip. api.ts:142-149 - F-U-041 — Apps page DL-badge has
titlebut noaria-label; screen readers hear only the bare count. apps/+page.svelte:228-232
Info
- F-S-033 —
users::accept_invitedeliberately skips authz ("the invitation token IS the authorization"); document the threat model explicitly. users_service.rs:780-787 - F-S-034 — HTTP outbox dispatch runs with
principal: Noneeven when caller was authenticated — design-as-documented but operators should know inbound-anon and triggered-from-authed-HTTP are indistinguishable inside the callee. dispatcher.rs:566 - F-S-035 —
users::admin_create_invitationsynthesizes aSdkCallCxwith randomscript_id/execution_id. Forensic noise; consider sentinelnil()values. users_service.rs:982-992 - F-S-036 — Dispatcher uses true
rand::thread_rng();retry::runusesDefaultHasher-based pseudo-randomness that gives identical jitter for same(span, raw)pairs — defeats jitter purpose under concurrent retries. Unify. dispatcher.rs:1041 vs retry.rs:249-261 - F-S-037 — SSRF DNS-rebind protection depends on reqwest re-resolving DNS on every connect. Verify connection-pool behavior empirically; disable connection pooling or set short idle timeout. ssrf.rs:154-221
- F-S-038 —
meendpoint has no scope check — an API key with onlyscript:readlearns the owning admin's username/instance_role/email. Document intent or filter for non-session principals. auth_api.rs:180-201 - F-S-039 — Login response body includes raw session token in addition to setting cookie — same-origin XSS gadgets can read it. Document that the dashboard SPA should discard the response-body token and rely on the cookie. auth_api.rs:140-157
- F-S-040 — Reveal-password modal exposes plaintext immediately on open. For shoulder-surfing protection, render as
type="password"initially with a "Show value" button. apps/[slug]/+page.svelte:1744-1756 - F-S-041 — Queue dispatcher livelocks when the registering principal becomes Inactive — message re-claimed every visibility-timeout cycle, attempt bumped each time, never reaching max_attempts. Catch
Inactive/NotFoundand dead-letter with reason "principal unavailable". dispatcher.rs:231-235 - F-Q-023 —
ServicesArc<dyn>bundle +#[allow(clippy::too_many_arguments)]hides drift signal. See F-Q-013. shared/services.rs:117 - F-M-008 —
app_secrets/secrets/outbox/queue_messages/dead_lettersJSONBvalue columns are NOT NULL but allow'null'::jsonb. Probably not worth a CHECK; flag. 0007_kv.sql:18 - F-M-009 — Migration 0006 default
'owner'widens authority on upgrade — see F-S-027. (Migration framing of same issue.) - F-M-010 — All migrations forward-only. Migration 0032 (drop plaintext realtime key) is a destructive point-of-no-return; CHANGELOG should mark it explicitly. 0032_drop_plaintext_realtime_signing_key.sql
- F-M-011 — Asymmetric defaults:
triggers.dispatch_mode = 'async',routes.dispatch_mode = 'sync'. Document in one place. 0008_triggers.sql:30, 0012_routes_dispatch_mode.sql:15 - F-M-012 —
app_userslacks indexes onemail_verified_at/last_login_atdespite likely admin-filter views. May be premature; flag for v1.2. 0026_app_users.sql:24-25 - F-M-013 — Free-text columns (
apps.slug,*_email,api_keys.scopes TEXT[],app_user_invitations.roles TEXT[],routes.host_kind'strict' vs 'exact' naming) lack DB-level shape CHECKs. Validation lives in Rust; consistent across the repo; flagging for defense-in-depth conversation. - F-M-014 —
pgcryptoextension declared once in 0001; subsequent migrations rely ongen_random_uuid()without re-declaring. Belt-and-suspendersCREATE EXTENSION IF NOT EXISTS pgcryptoin each migration that uses it. - F-T-011 —
subscribeTopicsendsLast-Event-IDheader even though server-side replay isn't implemented (README is honest). Test suite doesn't assert the header is actually sent. subscribe.ts:51-52 - F-T-012 — Svelte
topicStoreitemsclosure resets when last subscriber unsubscribes — non-obvious lifecycle; README doesn't call it out. svelte/index.ts:20-32 - F-T-013 —
react.test.tsxfakeClient bypasses type safety withas unknown as PicloudClient. Public API additions won't be caught. ExposePicloudClientLikeinterface. tests/react.test.tsx:19 - F-T-014 —
package.jsonhas both modernexportsand legacymain/module/types; consistent today, butexports-only would be a single source of truth. clients/typescript/package.json:11-30 - F-U-042 —
let appBySlug = $derived(...)should beconst. profile/+page.svelte:28 - F-U-043 —
/profile?denied=usersdeep-link doesn'treplaceState-strip the param; refresh keeps the banner. profile/+page.svelte:35 - F-U-044 — Test invoke result clobbers earlier with no history; small ring buffer would help iterative testing. scripts/[id]/+page.svelte:158-197
- F-U-045 —
route-utils.tsandslugify.tslack vitest coverage; both are pure-function modules — easy to add. dashboard/src/lib/
Methodology notes
- Subagents: 6 parallel
general-purposeagents — Security data-plane, Security auth/secrets, Performance, Code Quality (Rust crates), UI/UX (dashboard), Migration/Schema + TypeScript client. Orchestrator (this conversation) read the project docs and verified the highest-severity findings against actual file/line state before writing the report. - Wall-clock: Single message of parallel agent dispatch + post-processing; agents took 4–22 minutes each.
- Verification: F-Q-001 (manager-core cross-crate behavior import) confirmed via
grep. F-T-001 (Rules of Hooks) confirmed by reading clients/typescript/src/react/index.ts. F-P-001 (users::list N+1) confirmed at users_service.rs:466-471. F-P-003 (pool=10) confirmed at picloud/src/lib.rs:583-588. F-S-001 (unbounded queue payload) confirmed at queue_service.rs:33-92. F-U-002 (queue drilldown 404) confirmed via grep against the routes tree. - Coverage gaps:
- The
dashboard/tests/e2e/Playwright suite was out of scope per the brief (149 known errors from missing@playwright/test). - The
clients/typescript/package got a shallower pass than the Rust crates — appropriate since it's a deliberately minimal surface per v1.1.6 hybrid-model decision. - The orchestrator did not run the test suites; subagent line-number citations are accurate at audit time but may drift if files change.
- SQL query plans were not measured — performance findings are static-analysis based, citing the predicate-vs-index mismatch shape rather than EXPLAIN output.
- Caddyfile + docker-compose configurations not audited (the brief focused on application code).
crates/picloud-cli/got a light pass (mainly the#[ignore]'d tests count).
- The
- Notes to next reviewer:
- The Critical (F-Q-001) is structural and load-bearing for the cluster-mode roadmap (v1.3+) — addressing it is a v1.2+ release in its own right, not a v1.1.10 patch.
- The High-severity performance findings (F-P-001..F-P-009) cluster around two themes: (1) auth middleware doing Argon2id on the async runtime per request, (2) N+1 / OFFSET / COUNT(*) anti-patterns that an instance with real traffic will feel immediately. A focused "perf clean-up" release (call it v1.1.10) could land 6–8 of these as a single bundled PR.
- F-S-001 (unbounded payloads) is a one-PR fix that hardens four services at once; consider doing it before any external preview.
- The dashboard CSS-variable issue (F-U-004) is one global theme-token fix that closes 5+ pages worth of light-on-dark rendering.
- The TypeScript client
useEndpoint(F-T-001) is a public API correctness bug — fix or undocument before any 1.x stability claim on the client.