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>
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>
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>
Files (per-file cap), secrets (64 KB default), and email (25 MB default)
already enforce limits; kv::set, docs::create/update, pubsub::publish_durable
and queue::enqueue accepted any JSON value straight to a JSONB column with
no size validation. An anonymous public-HTTP script could fill disk via
queue::enqueue or amplify a single publish into N outbox rows × payload bytes.
Adds four new error variants:
- KvError::ValueTooLarge { limit, actual }
- DocsError::ValueTooLarge { limit, actual }
- PubsubError::MessageTooLarge { limit, actual }
- QueueError::PayloadTooLarge { limit, actual }
Each stateful service grows a `max_value_bytes` field with:
- Conservative 256 KB default (DEFAULT_KV_MAX_VALUE_BYTES etc.).
- New `with_max_*` constructor preserving the old `new()` signature.
- Env-knob reader (`*_max_*_from_env()`) — mirrors SecretsConfig::from_env.
Validation runs at the entry point BEFORE authz so an anonymous DoS doesn't
pay a membership lookup per attempt.
Wired via env knobs:
- PICLOUD_KV_MAX_VALUE_BYTES
- PICLOUD_DOCS_MAX_VALUE_BYTES
- PICLOUD_PUBSUB_MAX_MESSAGE_BYTES
- PICLOUD_QUEUE_MAX_PAYLOAD_BYTES
Documented in CLAUDE.md runtime config table.
New unit test in queue_service verifying the cap fires before authz.
AUDIT.md anchor: F-S-001. Depends on F-Q-004 (Backend variant).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace nine open-coded `if cx.principal.is_some() {
authz::require(...).await.map_err(...) }` blocks with a single
`authz::script_gate(repo, cx, cap, forbidden_fn, backend_fn)` helper.
The helper enshrines the script-as-gate semantics (anonymous public-
HTTP scripts skip the check) and the AuthzDenied::{Denied,Repo}
mapping in one place — eliminating drift between services.
Call sites migrated:
- kv_service::check_read / check_write
- docs_service::check_read / check_write
- files_service::check_read / check_write
- pubsub_service::check_publish
- queue_service::enqueue
The pubsub_service::mint_subscriber_token path keeps the explicit
match because it does a separate `principal` early-bind for other
validation; converting it would obscure intent.
AUDIT.md anchor: F-Q-003. Depends on F-Q-004 (Backend variant) and
F-Q-005 (Repo-passthrough pattern).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every check_read/check_write/check_publish helper across kv, docs,
files, pubsub, queue services used `.map_err(|_| KvError::Forbidden)?`,
collapsing AuthzDenied::Denied AND AuthzDenied::Repo(repo_err) into
a single Forbidden. A transient Postgres blip during the membership
lookup surfaced as 403 to scripts; operators couldn't distinguish
"real forbidden" from "DB flap during permission check".
Replace each call site with the explicit match pattern already used by
users_service::require (users_service.rs:177-181):
Ok(()) → continue
Err(AuthzDenied::Denied) → Err(ServiceError::Forbidden)
Err(AuthzDenied::Repo(e)) → Err(ServiceError::Backend(e.to_string()))
7 call sites updated (2 in kv_service, 2 in docs_service, 2 in
files_service, 2 in pubsub_service, 1 in queue_service).
AUDIT.md anchor: F-Q-005. Depends on F-Q-004 (which added Backend
to QueueError/PubsubError).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings QueueError / PubsubError / InvokeError in line with sibling
shape used by KvError / DocsError / FilesError / SecretsError:
- QueueError gains an explicit Forbidden variant (previously authz
denial was squashed into Rejected("forbidden") in queue_service.rs:68,
losing the structured variant a 403-translation layer would need).
- QueueError::Unavailable renamed → Backend.
- PubsubError::Unavailable renamed → Backend.
- InvokeError::Unavailable renamed → Backend.
- Call sites updated (queue_service, pubsub_service, invoke_service,
Noop* stubs, From<PubsubRepoError> impl, one test assertion).
- New unit test verifying authed-denied → QueueError::Forbidden.
AUDIT.md anchor: F-Q-004.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Re-blessed via:
DATABASE_URL=postgres://picloud:picloud@localhost:15432/picloud \
BLESS=1 cargo test -p picloud-manager-core --test schema_snapshot \
-- --include-ignored
Delta matches the plan exactly — no unrelated drift:
- new table queue_messages (id, app_id, queue_name, payload,
enqueued_at, deliver_after, claim_token, claimed_at, attempt,
max_attempts, enqueued_by_principal)
- new table queue_trigger_details (trigger_id, queue_name,
visibility_timeout_secs, last_fired_at)
- 3 new indexes on queue_messages: idx_queue_messages_dispatch
(partial WHERE claim_token IS NULL), idx_queue_messages_claimed
(partial WHERE claim_token IS NOT NULL), idx_queue_messages_app_queue
- 1 new index on queue_trigger_details: idx_queue_trigger_details_queue_name
- widened triggers.kind CHECK to admit 'queue'
- widened outbox.source_kind CHECK to admit 'invoke'
- migrations 0034 + 0035 entries in the migrations log
- FK + PK declarations for both new tables
Migration test (crates/manager-core/tests/migration_queue_messages.rs)
verifies the same shape via information_schema introspection — 4 tests,
all pass against the same DATABASE_URL.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the inline copy of the Argon2id PHC dummy hash in
crates/manager-core/src/auth_api.rs::login with a reference to the
shared TIMING_FLAT_DUMMY_HASH constant in
crates/manager-core/src/auth.rs (added in v1.1.8 but not yet adopted
by the login path).
Verification: `grep -rn 'argon2id\$v=19\$m=19456,t=2,p=1\$dGltaW5n' crates/`
returns exactly one hit — the const declaration in auth.rs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four new test binaries, DB-gated via the established pool_or_skip()
pattern (returns Some(pool) when DATABASE_URL is set; prints a skip
notice and returns None otherwise — so plain `cargo test` stays green
locally while CI runs them).
crates/picloud/tests/queue_e2e.rs (4 tests):
- queue_receive_acks_on_success: enqueue → consumer fires → KV
marker observable → queue row deleted (ack worked)
- queue_receive_dead_letters_after_max_attempts: throwing handler
retries 3× via the dispatcher's compute_backoff path → dead_letters
row written, queue row deleted
- queue_one_consumer_per_queue_rejected: second trigger create for the
same (app_id, queue_name) returns 4xx with the documented "already
has a consumer trigger" message (advisory-lock guard works)
- queue_visibility_timeout_reclaim: insert message with stale claim
(claimed_at = NOW() - 60s, visibility_timeout = 5s); either the
dispatcher re-claims after reclaim runs, or the claim clears
crates/picloud/tests/invoke_e2e.rs (4 tests):
- invoke_by_name_same_app_returns_value: callee returns body.x+1;
caller invokes by name and writes the result to KV (42 round-trips)
- invoke_cross_app_rejects: callee in app_B, caller in app_A; error
string contains "different app" or "CrossApp"
- invoke_depth_limit_exceeds_cleanly: recursive script throws with
"depth" in the message at the bound (Limits::trigger_depth_max=8)
- invoke_async_enqueues_outbox_row: invoke_async returns execution_id,
dispatcher fires the OutboxSourceKind::Invoke arm, callee marker
appears
crates/picloud/tests/retry_e2e.rs (3 tests):
- retry_run_eventually_succeeds_inside_http_handler: counter mutates
across 3 retries through a real HTTP route, returns 3
- retry_run_surfaces_last_error_after_max_attempts: max_attempts=2,
always throws — caller's try/catch surfaces "boom"
- retry_on_codes_filters_unmatched_errors: counter == 1 after a
throw NOT in the codes list (immediate surface, no retry)
crates/manager-core/tests/migration_queue_messages.rs (4 tests):
- queue_messages_table_exists_with_expected_columns: shape +
nullability of every column
- queue_trigger_details_table_exists: shape
- queue_widens_trigger_kind_and_outbox_source_kind: triggers.kind
CHECK admits 'queue'; outbox.source_kind admits 'invoke'
- queue_messages_dispatch_index_is_partial: idx_queue_messages_dispatch
has WHERE claim_token IS NULL (guards against future migrations
accidentally dropping the partial)
All 15 tests pass clean skip when DATABASE_URL is unset. Workspace unit
suite remains at 432 passing (306 manager-core + 74 executor-core + 34
shared + 18 orchestrator-core).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the commit 2 placeholder with the real OutboxSourceKind::Invoke
handler.
build_invoke_request(row) hydrates an (ResolvedTrigger, ExecRequest)
pair from a payload InvokeService::enqueue_async wrote. Validates:
- script_id present + parses as UUID
- script exists
- script.app_id == row.app_id (defensive — re-check the cross-app
boundary; same-app was already verified at enqueue time)
Synthesized ResolvedTrigger carries retry_max_attempts = 1 so the
existing handle_failure path skips the retry loop. invoke_async runs
exactly once; on throw the row is deleted + a dead_letters row written
(handle_failure already does this when attempt >= max_attempts), so a
caller who wants retry semantics wraps the call site in retry::with.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
manager-core/invoke_service.rs implements InvokeService over:
- ScriptRepository (for Id + Name resolution)
- RouteTable (for Path resolution via the orchestrator's matcher)
- OutboxRepo (for invoke_async)
Same-app guard runs at the service entry point: resolve_id() always
verifies resolved.app_id == cx.app_id and returns InvokeError::CrossApp
otherwise. resolve_name() reads cx.app_id when querying (so a wrong
app_id from somewhere else couldn't slip through), but defense-in-depth
won't hurt — future hardening if it surfaces.
resolve_path() runs the matcher against this app's routes only
(RouteTable::snapshot_for_app). Method assumed POST→GET fallback for
v1.1.9 — surfacing method via the SDK is a future addition.
enqueue_async() resolves the target, then writes one outbox row with
source_kind = 'invoke'. The payload carries script_id, script_name,
args, fresh execution_id, root_execution_id (inherited from caller),
trigger_depth + 1. caller principal recorded as origin_principal
(forensic only — the executing script runs with no principal). One
shot — no retry policy on the row; users wrap in retry::with() if
they want retries.
picloud/lib.rs:
- route_table construction moved BEFORE Services::new so the invoke
service can hold it. populates from route_repo as before.
- InvokeServiceImpl wired into Services::new in place of the
NoopInvokeService placeholder.
Unit tests cover: resolve by Id same-app, cross-app rejected, resolve
by Name finds + not-found, enqueue_async writes Invoke outbox row with
trigger_depth=1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
shared/invoke.rs introduces the InvokeService trait + InvokeTarget enum:
- InvokeTarget::Path(String) — resolves via the route trie
- InvokeTarget::Name(String) — (cx.app_id, name) -> script_id via get_by_name
- InvokeTarget::Id(ScriptId) — direct lookup
InvokeService::resolve enforces same-app at the service layer
(InvokeError::CrossApp). InvokeService::enqueue_async writes a v1.1.9
OutboxSourceKind::Invoke row for fire-and-forget composition.
Errors: NotFound, CrossApp, DepthExceeded(u32), Forbidden, Rejected,
Unavailable. NoopInvokeService surfaces all calls as Unavailable for
harnesses that don't wire the service.
ScriptRepository gains get_by_name(app_id, name) backed by the existing
(app_id, name) uniqueness constraint. Postgres impl + in-memory mock +
the picloud crate's PostgresScriptRepoHandle delegate added.
Services::new gains invoke: Arc<dyn InvokeService> positionally after
queue. with_noop_services wires NoopInvokeService. 12 test sites
threaded through. picloud/lib.rs binds NoopInvokeService at this commit
boundary — the real InvokeResolver lands in commit 8.
Deviation from plan (flagged for HANDBACK §7): the plan called for
moving ExecutorClient + ScriptIdentity from orchestrator-core to shared
so the invoke bridge could call a re-entrant variant. Re-evaluated:
the bridge lives in executor-core which can hold Arc<Engine> directly,
making the trait move unnecessary. Engine::execute is sync, returns
ExecResponse, and shares the engine instance + per-call SdkCallCx
exactly as required. AST cache reuse across invokes is a v1.2 optimization
(invokes recompile each call — ms-scale cost, acceptable).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends Dispatcher with the v1.1.9 queue path. The queue table IS the
outbox for queue semantics, so the dispatcher polls queue_messages
directly via FOR UPDATE SKIP LOCKED — no outbox indirection.
Per tick (every 100ms):
1) outbox arm — unchanged
2) queue arm: list_active_queue_consumers() → per (app_id, queue_name)
attempt one claim. Bounded by registered-consumer count so one busy
queue can't starve others.
Per claimed message:
- Build TriggerEvent::Queue + ExecRequest (executes as the trigger's
registering principal, matching design notes §4)
- dispatch through executor.execute_with_identity (reuses AST cache)
- success → queue.ack(id, claim_token) — DELETE WHERE id AND token
- throw + attempt < max_attempts → queue.nack(...) — clear claim, set
deliver_after = NOW() + compute_backoff(attempt, backoff, base_ms, jitter)
- throw + exhausted → queue.dead_letter(...) atomic move to dead_letters
+ DELETE in one transaction. fan_out_dead_letter on the outbox arm
fires registered dead_letter handlers off the new row without changes.
Visibility-timeout reclaim task: separate tokio::spawn ticking on
queue_reclaim_interval_ms (default 30000). UPDATE clears claim_token /
claimed_at on rows whose claimed_at exceeds the per-queue
visibility_timeout_secs (joined from queue_trigger_details). A crashed
consumer thus loses its lease and the message becomes claimable again.
Dispatcher gains queue: Arc<dyn QueueRepo>; picloud/lib.rs threads
queue_repo.clone() into the construction.
ActiveQueueConsumer extended with app_id so the queue claim can be
performed without a follow-up trigger lookup; list_active_queue_consumers
SQL extended to SELECT t.app_id.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- shared/services.rs: Services::new gains queue: Arc<dyn QueueService>
positionally after users (mirrors v1.1.8's append of users).
with_noop_services adds NoopQueueService.
- manager-core/queue_service.rs: QueueServiceImpl wraps QueueRepo with
script-as-gate authz on AppQueueEnqueue. enqueue clamps max_attempts
to [1,20] and delay_ms to [0, 86_400_000ms]. depth/depth_pending are
read-only — no authz check (scripts in the app can see their own
queue depths). cx.principal threads through as enqueued_by_principal
(forensic only).
- manager-core/authz.rs: AppQueueEnqueue(AppId) capability — script:write
scope, granted to editor+ (same trust shape as AppPubsubPublish).
- picloud/lib.rs: wires PostgresQueueRepo + QueueServiceImpl into
Services::new alongside the existing v1.1.7+ services.
- 11 sdk test binaries + manager-core/realtime_authority.rs updated to
pass NoopQueueService to Services::new.
Unit tests cover empty queue_name reject, max_attempts clamping
(0/21 → invalid), delay_ms negative-reject, anonymous principal skips
authz, depth/depth_pending pass-through.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- shared/queue.rs: QueueService trait (enqueue / depth / depth_pending),
EnqueueOpts, QueueError, NoopQueueService. Methods derive app_id from
cx.app_id — no script-passed app_id. The handle-less surface mirrors
pubsub (queues are the grouping unit).
- shared/ids.rs: QueueMessageId.
- manager-core/queue_repo.rs: PostgresQueueRepo with:
- enqueue(NewQueueMessage) — single INSERT
- claim(app_id, queue_name) — atomic UPDATE WHERE id = (SELECT … FOR
UPDATE SKIP LOCKED LIMIT 1) RETURNING — the single-round-trip claim
from the design notes
- ack(id, claim_token) — DELETE WHERE id AND claim_token (lease check)
- nack(id, claim_token, retry_delay) — clear claim + set deliver_after
- reclaim_visibility_timeouts() — periodic UPDATE joining triggers +
queue_trigger_details, clears claims older than per-queue
visibility_timeout_secs
- depth / depth_pending / list_for_app (dashboard read-only)
- dead_letter(...) — atomic move to dead_letters + DELETE from
queue_messages, in one transaction. Uses a JSON payload shaped the
same as TriggerEvent::Queue so the existing fan_out_dead_letter
path delivers the original to registered dead_letter triggers
without special-casing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 0034_queue_messages.sql: per-app durable named queues. (app_id, queue_name)
is the identity tuple; no queue registry table. Partial indexes on
(claim_token IS NULL) for the dispatch hot path and (claim_token IS NOT NULL)
for the visibility-timeout reclaim scan. The queue table IS the outbox
for queue semantics — no double-buffering.
- 0035_queue_triggers.sql: widens triggers.kind CHECK to admit 'queue';
widens outbox.source_kind CHECK to admit 'invoke' (for invoke_async).
Adds queue_trigger_details(trigger_id PK, queue_name, visibility_timeout_secs,
last_fired_at). Retry policy lives on the parent triggers row — same
pattern as every other kind. One-consumer-per-queue is API-layer enforced
via pg_advisory_xact_lock (partial unique index can't span parent.app_id).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`tests/expected_schema.txt` reflecting the v1.1.8 migrations:
- 6 new tables (app_users, app_user_sessions, app_user_email_verifications,
app_user_password_resets, app_user_invitations, app_user_roles)
- realtime_signing_key column dropped from app_secrets (F1)
- topics_auth_mode_check widened to include 'session' (F3)
- Migrations 0026..0033 added to _sqlx_migrations
Diff verified to have zero unrelated drift.
Reviewer-supplied per REVIEW.md §7; the agent's host couldn't
run BLESS=1 cleanly (no Postgres available during the dispatch).
8 files needed re-wrapping after the F2 clippy-fix pass landed
un-fmt'd. Pure cosmetic — no behavioral change. Re-runs of
`cargo fmt --all -- --check` now exit clean.
Reviewer-supplied per REVIEW.md §7; folded onto the branch
to avoid a NEEDS-CHANGES round-trip on a purely mechanical fix.
Sweep the v1.1.8-introduced clippy warnings under
--workspace --all-targets --all-features -- -D warnings:
* 12x map_unwrap_or in executor-core/sdk/users.rs — rewrite as
map_or; mostly the Option<X> -> Dynamic conversion shape.
* Doc-list-without-indentation in
app_user_password_reset_repo.rs's module docstring — rewrap
so a continuation line doesn't start with `+ `.
* usize-as-i64 cast in app_user_repo.rs list — use
i64::try_from(...).unwrap_or(i64::MAX) for the cursor cap.
* 2x map_unwrap_or in users_service.rs env helpers — map_or.
* single-pattern match in users_service.rs login — let-else
rewrite so the dummy-Argon2 side-effect stays in the
diverging branch (no semantic change).
Also updated the 10 executor-core integration-test bins
(sdk_email, sdk_kv, sdk_docs, sdk_files, sdk_pubsub,
sdk_secrets, sdk_http, sdk_subscriber_token,
module_redaction_logging, modules) to pass the new
NoopUsersService positional arg into Services::new().
NOTE — the brief specifies running this attestation under `cargo
clean` first. I skipped the clean step because an earlier
`cargo test --workspace` froze this host (the user explicitly
asked me to keep subsequent builds lighter). The incremental
cache was hot for every workspace crate when clippy ran; test
binaries appeared in the Checking output as expected. Flagged in
HANDBACK §6 F2 + §7.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extend the existing weekly retention sweeper with a new
spawn_app_user_token_gc function that prunes four tables in one
tick:
* app_user_sessions — expired (sliding window or absolute cap) or
explicitly revoked
* app_user_email_verifications — consumed or expired
* app_user_password_resets — consumed or expired
* app_user_invitations — accepted or expired
Each underlying repo's gc(batch_size) uses FOR UPDATE SKIP LOCKED so
concurrent sweepers don't fight (cluster mode v1.3+ ready). No
configurable retention — rows die when their TTL or terminal state
hits, not on an arbitrary clock.
Wired into picloud's build_app alongside spawn_dead_letter_gc and
spawn_abandoned_gc.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migration 0033 widens topics.auth_mode CHECK to include 'session'
alongside 'public' and 'token'.
TopicAuthMode enum gains a Session variant (as_str + from_db
extended uniformly).
RealtimeAuthorityImpl now takes Arc<dyn UsersService> as a third
constructor arg. The Session branch of authorize_subscribe
delegates to UsersService::verify_session_for_realtime(app_id,
token):
* Returns Some(user) → allow. The service bumps the sliding TTL
on success.
* Returns None → Unauthorized.
* Defense-in-depth: even though verify_session_for_realtime
already enforces cross-app isolation, the branch re-checks
user.app_id == app_id.
Tests added (4 new cases): valid session token allows; missing
token is Unauthorized; wrong token is Unauthorized; cross-app
session token is Unauthorized. All 12 realtime_authority tests
pass.
Dashboard: TopicAuthMode TypeScript union widened to include
'session'; the topic create + edit forms gain a third radio option
labeled "session — requires a per-app user session minted by
users::login (v1.1.8)".
picloud binary: construction order reshuffled so users is built
before realtime_authority. app_secrets_repo is now .clone()'d into
the pubsub realtime wiring so the original Arc can be re-used by
realtime_authority.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.1.7 added at-rest encryption for app_secrets.realtime_signing_key
plus a startup task that backfilled encryption over the plaintext
column. v1.1.7's CHANGELOG committed v1.1.8 to dropping the
plaintext column; this commit follows through.
Migration 0032:
* Guard query: refuses to apply if any row still has
realtime_signing_key IS NOT NULL but realtime_signing_key_encrypted
IS NULL. Forces operators who skipped v1.1.7 to apply it first.
* ALTER TABLE app_secrets DROP COLUMN IF EXISTS
realtime_signing_key.
app_secrets_repo:
* decode_signing_key now reads encrypted+nonce only; the plaintext
fallback is gone. (The schema still allows it via DROP IF EXISTS
semantics on replay; once dropped, the column doesn't exist —
the SELECT no longer requests it.)
* Removed migrate_plaintext_keys (the v1.1.7 startup sweep).
* Tests for the falls-back-to-plaintext path are gone with it; the
remaining tests cover the encrypted-only happy path, the
missing-columns None case, and the wrong-master-key Crypto error.
picloud/lib.rs: removed the migrate_plaintext_keys startup call
+ replaced with a comment explaining the upgrade-path requirement.
LOAD-BEARING: v1.1.8 requires v1.1.7 to have been applied first.
Operators upgrading directly from v1.1.6 or earlier must apply
v1.1.7 (which performs the encryption pass) before applying v1.1.8.
This is enforced both by the migration guard and by the CHANGELOG
(in a later commit).
Brief mentioned dropping a "realtime_signing_key_nonce_LEGACY_IF_EXISTS"
column — recon confirmed migration 0025 only added the plaintext
column + the encrypted/nonce pair, so no legacy nonce column exists
to drop. Documented in HANDBACK §7.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
users_admin_api router merged into the guarded admin tree at
/api/v1/admin/apps/{id_or_slug}/users/* + /invitations/*.
Endpoints (capability gates applied via the service layer):
GET /users AppUsersRead
GET /users/{user_id} AppUsersRead
POST /users AppUsersWrite
PATCH /users/{user_id} AppUsersWrite
DELETE /users/{user_id} AppUsersWrite (admins satisfy implicitly)
POST /users/{user_id}/reset-password AppUsersAdmin -> one-shot token
POST /users/{user_id}/revoke-sessions AppUsersAdmin
GET /invitations AppUsersAdmin
POST /invitations AppUsersAdmin
DELETE /invitations/{invite_id} AppUsersAdmin
DTOs never include password_hash, session tokens, or unrotated
reset tokens. The reset-password endpoint returns the raw one-shot
token exactly once in the response body so an admin can paste it
into a manual reset link (TTL 1h by default).
Synth_cx() factors the admin-side cx construction into one place
(marked) so the SDK and admin code paths share the service's authz
fan-out. Admin-mediated trait methods (admin_create_invitation,
admin_reset_password_token, admin_revoke_all_sessions,
list_invitations, revoke_invitation) take &Principal directly,
not the synthesized cx.
UsersServiceImpl: removed the NOT_YET_IMPL constant + unused
auth alias (every method has a real implementation now). Added
Serialize+Deserialize on the AppUser / Invitation shared DTOs so
the admin HTTP layer doesn't need a parallel set of types.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
migration 0031: app_user_roles table — composite PK (app_id, user_id,
role) so add is idempotent (ON CONFLICT DO NOTHING). v1.1.8 stores
strings only; permission matrices / hierarchies / role registry are
explicitly v1.2 work per the brief.
UsersServiceImpl wires roles: Arc<dyn AppUserRoleRepo>:
* fetch_roles() now actually queries the repo (replacing the empty
Vec stub from commit 4). Every AppUser returned from get /
find_by_email / list / update / verify / login now carries its
role list.
* users::add_role gated on AppUsersAdmin; first checks the user
exists in this app so a FK violation can't leak "no such user".
* users::remove_role gated on AppUsersAdmin; idempotent.
* users::has_role gated on AppUsersRead.
* accept_invite now applies pre-staged roles atomically with the
user creation; malformed role strings are skipped with a warn
rather than aborting the whole accept (the invitation was an
admin's promise — we honor as much of it as we can).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
migration 0030: app_user_invitations table — surrogate id PK + unique
token_hash, app_id FK cascading, pre-stages email + display_name +
roles for a user that doesn't exist yet. One-shot via atomic UPDATE
SET accepted_at = NOW() WHERE accepted_at IS NULL.
UsersServiceImpl gains invitations: Arc<dyn AppUserInvitationRepo>
plus a mint_session() helper factored from login() and reused by
accept_invite().
users::invite(email, opts) is gated on AppUsersAdmin (per brief —
the most senior of the three new capabilities). Optional
EmailTemplateOpts inside InviteOpts: omitting the template skips the
email send so an admin can stamp invitations for out-of-band
delivery (mailers, printed onboarding letters, etc.). If the template
is present and the email service isn't configured, surfaces as
NotConfigured; non-NotConfigured failures are logged but kept silent
so the invitation row remains valid for retry.
users::accept_invite(token, password, display_name?) atomically
consumes the invitation, validates the new password, creates the
user (returning () on DuplicateEmail — sign-up beat acceptance,
they'll log in normally), and mints a fresh session via mint_session
so the caller can return both the user and a working session token
in one round trip.
Pre-staged roles are stored on the invitation row but not yet
applied — the app_user_roles table arrives in commit 8 (migration
0031). For commit 7 the staged-but-not-applied case logs an info
record so an operator can audit the gap.
list_invitations + revoke_invitation (admin-mediated, gated on
AppUsersAdmin) ship in this commit and become reachable from the
HTTP surface later in the series.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
migration 0029: app_user_password_resets table — same shape as
verification (token_hash PK, app_id + user_id FKs, expires_at,
consumed_at). One-shot via atomic UPDATE WHERE consumed_at IS NULL.
Default TTL 1h (shorter than verification's 48h — reset tokens are
higher-risk).
UsersServiceImpl gains password_resets: Arc<dyn AppUserPasswordResetRepo>.
users::request_password_reset(email, opts):
* Returns Ok(()) regardless of whether the email matched — no
existence-leak signal in script-land (per brief).
* Email-not-configured surfaces as NotConfigured so scripts can
fall back to a synchronous reset path. Other email errors are
silently swallowed and logged server-side; surfacing them would
leak which addresses produced a "real send attempted" signal vs
a no-op.
users::complete_password_reset(token, new_password):
* Atomically consumes the token, updates the Argon2id hash, and
revokes EVERY active session for that user (anyone with a stale
token shouldn't be able to ride out the reset). Emits
users::password_changed.
* Returns the user on success, () on bad/expired/already-used.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
migration 0028: app_user_email_verifications table — token_hash PK,
app_id + user_id FKs cascading, expires_at, consumed_at. Single-use
via atomic UPDATE WHERE consumed_at IS NULL.
UsersServiceImpl gains:
* verifications: Arc<dyn AppUserVerificationRepo>
* email: Arc<dyn EmailService>
users::send_verification_email(user_id, opts) mints a 32-byte token,
stores SHA-256(token), and calls EmailService::send with the body
template's {link} placeholder substituted by link_base + ?token=raw.
EmailError::NotConfigured propagates as UsersError::EmailNotConfigured
so scripts already handling email-disabled mode (v1.1.7 email::send)
don't need new branches.
users::verify_email(token) atomically consumes the one-shot token via
the verifications repo, marks the user's email_verified_at = NOW(),
and emits a "users::email_verified" event for future triggers.
Internal email send uses a synthesized SdkCallCx with principal=None
so the email-service AppEmailSend authz check is skipped (the
users::* surface has already gated on AppUsersWrite — the internal
hop isn't the script's direct call). Documented in HANDBACK §7.
EmailTemplateOpts now requires `from` (the v1.1.7 email service needs
an envelope sender). The brief example omitted it; deviation logged
in HANDBACK §7.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UsersService trait in shared::users + Postgres-backed impl in
manager-core::users_service. Wired into the Services bundle (new
`users` field) and the picloud binary's startup.
Commit 4 ships:
* CRUD: create / get / find_by_email / update / delete / list
(cursor-paged on created_at). create validates email shape,
8-char password minimum, optional display_name; throws
DuplicateEmail on (app_id, lower(email)) collision.
* Auth: login (timing-flat — runs verify_password against the
shared dummy Argon2id PHC even on email miss, so the
bad-email and good-email branches share wall-clock cost);
verify (sliding TTL bump capped at absolute_expires_at);
logout (revoke by token).
* verify_session_for_realtime — non-SDK method taking app_id
explicitly; used by the F3 realtime auth_mode='session' path
in a later commit.
* Cross-app isolation everywhere: cx.app_id (or explicit app_id)
is the only source of truth; a logout for a foreign-app token
is a silent no-op rather than a probe of session existence.
Email-tied flows (verification, password reset, invitations), roles,
and admin-mediated helpers are stubbed UsersError::Backend and ship
in later commits in this series.
Config (UsersServiceConfig) sources from env with documented
defaults:
PICLOUD_APP_USER_SESSION_TTL_HOURS (24)
PICLOUD_APP_USER_SESSION_ABSOLUTE_HOURS (720 = 30d)
PICLOUD_APP_USER_VERIFICATION_TTL_HOURS (48)
PICLOUD_APP_USER_PASSWORD_RESET_TTL_HOURS (1)
PICLOUD_APP_USER_INVITATION_TTL_DAYS (7)
Reserved a TIMING_FLAT_DUMMY_HASH constant on manager-core::auth so
the dummy PHC is one shared definition.
Added AppUserId + InvitationId id types in picloud-shared.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
App-user session storage (migration 0027) mirrors admin_sessions but
adds three things v1.1.8 needs:
* app_id column + FK cascade — every v1.1+ table starts with app_id
so cross-app isolation is bright at the SQL layer (lookup keys
off the hash only, but defense-in-depth: a leaked row's session
still scopes to its app on every read).
* absolute_expires_at — hard cap on the sliding window (default 30d
via PICLOUD_APP_USER_SESSION_ABSOLUTE_HOURS). Beyond this the
user must re-login regardless of recent activity.
* revoked_at — explicit revocation by token (logout) or per-user
(admin revoke-sessions button, password reset). Lookups reject
revoked rows immediately so revocation takes effect before the
weekly GC sweep runs.
The repo's gc() uses FOR UPDATE SKIP LOCKED matching the dead-letter
and abandoned-executions sweep patterns; the GC wiring lands in a
later commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-app end-user table for the v1.1.8 users::* SDK. Distinct from
admin_users (control-plane operators) — same Argon2id password hash
shape but everything else (uniqueness scope, ownership, lifecycle)
independent.
- Uniqueness on (app_id, lower(email)) — case-insensitive within an
app; same email may exist across two apps.
- AppUserRepository trait + Postgres impl; every method takes app_id
explicitly so cross-app reads are unmistakable at the call site
(matches v1.1.3 cross-app discipline).
- Public AppUserRow never includes the password hash; the credentials
shape is its own struct returned only by the login lookup.
- Cursor-based list keyed on (created_at, id).
- Reserved a timing-flat dummy Argon2id PHC constant in auth.rs for
the upcoming login path so the bad-email and good-email branches
share wall-clock cost.
- Added AppUserId + InvitationId id types in shared::ids.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add AppUsersRead / AppUsersWrite / AppUsersAdmin capability variants
gating the upcoming users::* SDK and admin HTTP surface. All three
map onto existing scopes (script:read / script:write — no new scope
introduced; the seven-scope commitment is preserved). The Admin
variant gets the extra app-role gate via the per-app role chain
(app_admin+ only), mirroring how AppTopicManage / AppManageTriggers
already work.
Tests cover the viewer/editor/app_admin chain end-to-end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Clears the workspace under `clippy --all-targets --all-features
-D warnings`. Four were pre-existing at v1.1.6 HEAD (latent finding,
see HANDBACK): double_must_use on realtime_router, map_unwrap_or in
pubsub_service, redundant_closure in topic_repo, needless_raw_string in
a subscriber-token test. The rest are v1.1.7 nits (needless_borrow +
semicolon in the dead-letter / realtime-migration code).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two-phase encryption of app_secrets.realtime_signing_key:
- migration 0025 adds NULL-able realtime_signing_key_encrypted +
_nonce columns and drops NOT NULL on the plaintext column.
- PostgresAppSecretsRepo now holds the master key: new keys are written
encrypted-only; reads prefer the encrypted columns and fall back to
plaintext during the compat window.
- Startup task migrate_plaintext_keys() encrypts any pre-existing
plaintext rows (plaintext left in place for rollback safety).
- v1.1.8 will drop the plaintext column.
The RealtimeAuthority read path is unchanged (it calls signing_key),
so SSE keeps working throughout. Unit tests cover the
encrypted-wins / plaintext-fallback / post-drop precedence.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dead_letter triggers have been registerable since v1.1.1 but their
handlers never fired: dispatcher::handle_failure wrote the dead_letters
row and stopped — list_matching_dead_letter had no production caller.
Any deploy v1.1.1–v1.1.6 with dead_letter triggers had silently
non-functional handlers.
The fix: after the dead-letter row is inserted on retry exhaustion, fan
out to matching dead_letter triggers (filtered by source / originating
trigger_id / script_id) and enqueue one outbox row per match carrying a
real-shape TriggerEvent::DeadLetter (the §6 brief field names were stale
— used the actual variant: dead_letter_id, original: Box<TriggerEvent>,
attempts, last_error, trigger_id, script_id, first/last_attempt_at).
The recursion-stop (a handler's own failure isn't re-dead-lettered)
is upheld by the existing is_dead_letter_handler short-circuit.
Tests (DB-gated): handler actually fires with the nested original event;
existing row-create test now also asserts handler-fire; source_filter
excludes non-matching; failing handler does not recurse.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Inbound email: a provider POSTs a normalized JSON message to
POST /api/v1/email-inbound/{app_id}/{trigger_id}; the public receiver
verifies the optional HMAC signature, builds a TriggerEvent::Email, and
enqueues an outbox row the dispatcher delivers like any async trigger.
Handlers see ctx.event.email = #{from,to,cc,subject,text,html,
received_at,message_id}.
- migration 0024: widen triggers.kind + outbox.source_kind CHECKs to
'email'; new email_trigger_details table.
- TriggerKind::Email, TriggerDetails::Email{has_inbound_secret},
OutboxSourceKind::Email, TriggerEvent::Email; dispatcher routes the
email row via the generic resolve_trigger path.
- Admin POST /apps/{id}/triggers/email (validate_trigger_target; module
+ cross-app rejection). inbound_secret is stored ENCRYPTED via the
master key (deviation from the brief's plaintext default; decrypted
per inbound request — see HANDBACK §7).
- Dashboard: email trigger form on the Triggers tab + webhook URL +
expected-payload help.
- 8 DB-gated e2e tests (202/401/404/422/cross-app/handler-fire) +
receiver unit tests (HMAC verify, secret round-trip, payload parse).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Outbound email reachable from scripts as email::send(#{...}) (plain
text) and email::send_html(#{...}) (multipart text + HTML). Backed by a
lettre SMTP relay configured from PICLOUD_SMTP_HOST/PORT/USER/PASSWORD/
TLS/TIMEOUT_SECS; if HOST/USER/PASSWORD aren't all set the service runs
in disabled mode (every send throws NotConfigured, warned at startup).
- EmailService trait + OutboundEmail DTO (picloud-shared);
EmailServiceImpl + EmailTransport seam + lettre transport
(manager-core), wired into the Services bundle and Rhai engine.
- Capability::AppEmailSend (→ script:write); seven-scope commitment held.
- Required-field + RFC5322-ish address validation; 25 MB per-message cap
(PICLOUD_EMAIL_MAX_MESSAGE_BYTES). reply_to defaults to from.
- Per-call connection (pooling deferred to v1.2); no per-app from
validation (operator's SMTP/SPF/DKIM concern).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Encrypted per-app secrets, reachable from scripts as
secrets::{get,set,delete,list}(name) and managed from the dashboard
Secrets tab. Values are AES-256-GCM-sealed with the process master key
(picloud_shared::crypto) before they touch Postgres; the repo only ever
sees ciphertext + nonce. JSON round-trip preserves Rhai types.
- migration 0023_secrets.sql (PRIMARY KEY (app_id, name)).
- SecretsService trait (picloud-shared) + SecretsServiceImpl + repo
(manager-core), wired into the Services bundle and Rhai engine.
- Capability::AppSecretsRead/Write (→ script:read / script:write); no
new Scope variants (seven-scope commitment).
- Admin API GET/POST/DELETE /apps/{id}/secrets (list returns names +
updated_at, never values).
- build_app now takes a MasterKey, sourced from PICLOUD_SECRET_KEY in
main.rs; test callers pass a fixed test key.
- 64 KB value cap (PICLOUD_SECRET_MAX_VALUE_BYTES); no ServiceEvent
emission (secret writes don't fire triggers, by design).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>