Compare commits
289 Commits
feat/v1.1.
...
4c68d2fa33
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c68d2fa33 | ||
|
|
5c33b7490b | ||
|
|
654e38752d | ||
|
|
ae98063f87 | ||
|
|
c361048022 | ||
|
|
50205e7b7e | ||
|
|
ccd3644aa4 | ||
|
|
19ba6dbfb4 | ||
|
|
9626d15863 | ||
|
|
1c7e8886d8 | ||
|
|
ae8f0be748 | ||
|
|
137103a429 | ||
|
|
849bd367b9 | ||
|
|
6b1831aea2 | ||
|
|
186b6f1dcc | ||
|
|
da83222ea1 | ||
|
|
e2457efcbe | ||
|
|
9dc9191cb2 | ||
|
|
05373814b2 | ||
|
|
aa5bb3e8cc | ||
|
|
ddb3589c49 | ||
|
|
456e972336 | ||
|
|
24d834a102 | ||
|
|
c21e82fb9f | ||
|
|
2bc3fb677b | ||
|
|
9a4b83dfcf | ||
|
|
f04eaed0b7 | ||
|
|
0210ace406 | ||
|
|
cf296cd2fd | ||
|
|
50d7a0a501 | ||
|
|
16267cec06 | ||
|
|
f60fa36b0b | ||
|
|
86c57e2e43 | ||
|
|
cdef97a634 | ||
|
|
bfa4b48930 | ||
|
|
fa0702870a | ||
|
|
247e2836b8 | ||
|
|
95f20b4add | ||
|
|
483e4cb116 | ||
|
|
3df37b2b85 | ||
|
|
b74e1a401b | ||
|
|
9601046e29 | ||
|
|
4b5bf72a66 | ||
|
|
32cb6c1f1f | ||
|
|
18ac9f5afa | ||
|
|
b048daa700 | ||
|
|
a977ff6a32 | ||
|
|
8f1ba39a8a | ||
|
|
96277e1e7c | ||
|
|
469e8526d7 | ||
|
|
0a583aa467 | ||
|
|
95007c124e | ||
|
|
c492a08775 | ||
|
|
72802de644 | ||
|
|
547004e32d | ||
|
|
7f51087d5d | ||
|
|
e885ed5412 | ||
|
|
9c6d690792 | ||
|
|
17221a2683 | ||
|
|
e7c0485dbf | ||
|
|
3479afc56b | ||
|
|
779d906d75 | ||
|
|
f552238586 | ||
|
|
a00454e5de | ||
|
|
5efb068b9f | ||
|
|
78a468de83 | ||
|
|
61352c7e5e | ||
|
|
5d1d4b3ff6 | ||
|
|
af525e71bd | ||
|
|
0973344515 | ||
|
|
b79d8ef47d | ||
|
|
d9766bcbc7 | ||
|
|
d1de43e919 | ||
|
|
f1d5f5c34e | ||
|
|
e53e2f583d | ||
|
|
529725ebb6 | ||
|
|
a049397bb0 | ||
|
|
82b4579317 | ||
|
|
e62e073970 | ||
|
|
8f966783fe | ||
|
|
8b68a7d7e8 | ||
|
|
4e25a142bd | ||
|
|
52da8a8704 | ||
|
|
c8acac1d20 | ||
|
|
b53eabb786 | ||
|
|
1844bef132 | ||
|
|
7fef098b48 | ||
|
|
3ef3038eb7 | ||
|
|
1662d7806a | ||
|
|
8a559ea178 | ||
|
|
da03fda1d6 | ||
|
|
65049a6262 | ||
|
|
99e7100f0b | ||
|
|
9598db149b | ||
|
|
c29e4b9c53 | ||
|
|
701ec90b56 | ||
|
|
719a17432f | ||
|
|
43ed4e8e7f | ||
|
|
48178c5f60 | ||
|
|
27dc04819f | ||
|
|
181622a84a | ||
|
|
34dcae98a2 | ||
|
|
e517f6dc8c | ||
|
|
b2822c8435 | ||
|
|
b46667c309 | ||
|
|
ca360d84cb | ||
|
|
79f8c9d420 | ||
|
|
bb68e5e50a | ||
|
|
11ac168839 | ||
|
|
30441549d5 | ||
|
|
c914758c09 | ||
|
|
e6b4792389 | ||
|
|
b588fc9d35 | ||
|
|
6bd0f4699d | ||
|
|
9ee85993d8 | ||
|
|
343f6d3b4d | ||
|
|
35dbd9f368 | ||
|
|
6db057fb08 | ||
|
|
49c4fb41ce | ||
|
|
8725939172 | ||
|
|
eea1d8984e | ||
|
|
a432091191 | ||
|
|
2b27012f56 | ||
|
|
c900ca5bbf | ||
|
|
695987d6b7 | ||
|
|
d4b5632db1 | ||
|
|
345f265062 | ||
|
|
aa3995ae05 | ||
|
|
a27863d4a6 | ||
|
|
b3f05dfe2a | ||
|
|
5e62f4acfe | ||
|
|
73c8c289c1 | ||
|
|
816f143ffd | ||
|
|
2ba476aac8 | ||
|
|
79153b2063 | ||
|
|
9e1c24f729 | ||
|
|
8c805a07d0 | ||
|
|
bfab7c781d | ||
|
|
4223d3c320 | ||
|
|
55cf995eda | ||
|
|
627996cde7 | ||
|
|
be5df06a48 | ||
|
|
b8a4f30219 | ||
|
|
d9b3e9973c | ||
|
|
3b650a2b14 | ||
|
|
c600177fd6 | ||
|
|
51f14fa2b1 | ||
|
|
a91b134285 | ||
|
|
30bad27711 | ||
|
|
b83113846f | ||
|
|
7ffbb8de87 | ||
|
|
ae2134e62d | ||
|
|
04a24ea0b7 | ||
|
|
50db27806b | ||
|
|
dd9d828018 | ||
|
|
ec4a2aa24a | ||
|
|
e8cc3afa07 | ||
|
|
bdcc9a606d | ||
|
|
3ac1022c33 | ||
|
|
d9fb0e7f85 | ||
|
|
e676eb9ba7 | ||
|
|
95fddf31bc | ||
|
|
31ecfae86e | ||
|
|
19f3647f0e | ||
|
|
513c4a2d3c | ||
|
|
34c63f31be | ||
|
|
8b5a02b3ef | ||
|
|
07ffc0b568 | ||
|
|
bd64a25c97 | ||
|
|
2af8ee49ba | ||
|
|
99eb0025fa | ||
|
|
9067225945 | ||
|
|
fde4796479 | ||
|
|
b096ea9c4e | ||
|
|
285a76f2e2 | ||
|
|
e33a551ad5 | ||
|
|
b0f7b72dd6 | ||
|
|
05ed9b00bb | ||
|
|
24490d5ddb | ||
|
|
59645e8159 | ||
|
|
649246213e | ||
|
|
f466b2a15e | ||
|
|
3c9816daf3 | ||
|
|
bce44769dd | ||
|
|
a1b7569d05 | ||
|
|
c42a8406b4 | ||
|
|
aa493b9326 | ||
|
|
c1e4c3416b | ||
|
|
adc719975f | ||
|
|
a8094067eb | ||
|
|
f2b16da2b5 | ||
|
|
1911691420 | ||
|
|
6d35eaad92 | ||
|
|
6298c7d21c | ||
|
|
cd0cd8464c | ||
|
|
73d3befb06 | ||
|
|
59d4c043b0 | ||
|
|
c072d0474c | ||
|
|
37c21f0efd | ||
|
|
86698afc24 | ||
|
|
6b5da50a38 | ||
|
|
547c9f9950 | ||
|
|
fea95bd63b | ||
|
|
9cd1213aac | ||
|
|
0b7ef11333 | ||
|
|
0bce113d28 | ||
|
|
3c5978190e | ||
|
|
e9f1b835f8 | ||
|
|
fbbe7676ae | ||
|
|
4923fa89e3 | ||
|
|
f4189fc52f | ||
|
|
7d045b2a0b | ||
|
|
9247680ab6 | ||
|
|
22e77e02f0 | ||
|
|
dc40bc7926 | ||
|
|
6d58178f68 | ||
|
|
99558e1987 | ||
|
|
efb644efe9 | ||
|
|
617c216429 | ||
|
|
c80ee194f2 | ||
|
|
8ceb1352dd | ||
|
|
97a6bd732f | ||
|
|
fbe1ccc127 | ||
|
|
aae107a5ff | ||
|
|
1cd8791bff | ||
|
|
5303419eec | ||
|
|
e7f9200c8f | ||
|
|
47ea239eb6 | ||
|
|
c63c5cc275 | ||
|
|
6f2bd3e949 | ||
|
|
ae5cf80426 | ||
|
|
e29ac1c03d | ||
|
|
5c50ce2e11 | ||
|
|
04dc81115e | ||
|
|
655c3ab97e | ||
|
|
b192cf2cc9 | ||
|
|
450badaabf | ||
|
|
7d3ced0776 | ||
|
|
c3baa87415 | ||
|
|
c38c46b8bc | ||
|
|
106394bef2 | ||
|
|
271747da24 | ||
|
|
0c85fb67d3 | ||
|
|
328d7d55e6 | ||
|
|
529d34af83 | ||
|
|
714f6dae18 | ||
|
|
e142d471e1 | ||
|
|
2247c4d804 | ||
|
|
302c1df577 | ||
|
|
563c44ad95 | ||
|
|
36bf046791 | ||
|
|
6891eda66c | ||
|
|
cae2269932 | ||
|
|
73e19ca626 | ||
|
|
f6c7ab6f7c | ||
|
|
9ce3c4c704 | ||
|
|
4054af41ed | ||
|
|
b9e002a707 | ||
|
|
ce62e72ba0 | ||
|
|
7027e0dfb8 | ||
|
|
2a76ea13dd | ||
|
|
31402eb99b | ||
|
|
7610a16a0b | ||
|
|
1dd28dda07 | ||
|
|
5eb596611c | ||
|
|
ff4f443531 | ||
|
|
3c2c4a3767 | ||
|
|
6449cb6f6a | ||
|
|
aa2631ff61 | ||
|
|
3af99873c3 | ||
|
|
b07382e64b | ||
|
|
45242e2d92 | ||
|
|
c855739559 | ||
|
|
36e5c5041a | ||
|
|
c6bf8d3de5 | ||
|
|
7a44cbf5a4 | ||
|
|
97546e2eb2 | ||
|
|
6ef9f436c1 | ||
|
|
5cbb6ca427 | ||
|
|
3cfb795206 | ||
|
|
a7d3dad129 | ||
|
|
2ea47eb05a | ||
|
|
b35585195b | ||
|
|
fffcdf6169 | ||
|
|
02335a8132 | ||
|
|
1f78937dd2 | ||
|
|
8f2d2bc721 | ||
|
|
2d11090d1a | ||
|
|
dc2e4fa01f |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -19,12 +19,18 @@ Cargo.lock.bak
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
|
# Local-only docker-compose overrides (per-developer)
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
# Local config overrides
|
# Local config overrides
|
||||||
config.local.toml
|
config.local.toml
|
||||||
/data
|
/data
|
||||||
# Files-root blob storage created when integration tests run build_app
|
# Files-root blob storage created when integration tests run build_app
|
||||||
# from the picloud crate dir (PICLOUD_FILES_ROOT default ./data).
|
# from a crate dir (PICLOUD_FILES_ROOT default ./data). The CLI journey
|
||||||
|
# harness spawns the server from the picloud-cli dir, so it lands there too
|
||||||
|
# (the §11.6 group-files journey is the first CLI test to write blobs).
|
||||||
/crates/picloud/data
|
/crates/picloud/data
|
||||||
|
/crates/picloud-cli/data
|
||||||
/postgres-data
|
/postgres-data
|
||||||
|
|
||||||
# Dashboard
|
# Dashboard
|
||||||
|
|||||||
546
AUDIT.md
Normal file
546
AUDIT.md
Normal file
@@ -0,0 +1,546 @@
|
|||||||
|
# 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.toml` depends on `picloud-orchestrator-core` and the dispatcher / route_admin / apps_api / repo modules reach into `RouteTable`, `ExecutionGate`, `ExecutorClient`, `ScriptResolver`, and `InProcessBroadcaster` for **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](#f-q-001--manager-core-depends-on-orchestrator-core-for-behavior-reversing-the-architectural-arrow).
|
||||||
|
- **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](#f-s-001--unbounded-payload-sizes-on-kvset-docs-pubsubpublish_durable-queueenqueue).
|
||||||
|
- **Biggest performance leak: Argon2id on the async worker, on the hottest paths.** `attach_principal_if_present` middleware 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-p-002--argon2id-password--api-key-verify-runs-synchronously-on-the-tokio-async-worker), [F-S-006](#f-s-006--api-key-bearer-creates-argon2-cpu-amplifier-for-arbitrary-callers).
|
||||||
|
- **Pool sized for 10, gate sized for 32.** `init_db` opens `max_connections = 10`; the execution gate defaults to 32. Pool starvation under load is not a question of if. See [F-P-003](#f-p-003--postgres-pool-max_connections10-vs-executiongate-default-32).
|
||||||
|
- **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](#f-u-001--dashboard-cannot-create-kvdocsfilesdead-letter-triggers). The queues drilldown also has a broken link that 404s — [F-U-002](#f-u-002--queue-drilldown-links-to-a-non-existent-app-scoped-scripts-route).
|
||||||
|
- **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 `#666` text on `#0f172a` backgrounds. See [F-U-004](#f-u-004--five-dashboard-pages-render-light-theme-fallback-colors-on-the-dark-theme-app).
|
||||||
|
- **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](#f-t-001--useendpoint-violates-react-rules-of-hooks).
|
||||||
|
- **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/queue_service.rs#L33-L92), [crates/manager-core/src/kv_service.rs:93](crates/manager-core/src/kv_service.rs#L93), [crates/manager-core/src/docs_service.rs:107](crates/manager-core/src/docs_service.rs#L107), [crates/manager-core/src/pubsub_service.rs:138](crates/manager-core/src/pubsub_service.rs#L138)
|
||||||
|
- **Summary:** Four stateful SDK services accept any JSON value and write it straight to a `JSONB` column 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 via `queue::enqueue` (up to Postgres's ~1 GB JSONB limit per row), or amplify a single `publish_durable` into N outbox rows × payload bytes. Fix shape: add a per-service `max_value_bytes` config (default ~256 KB) validated at the service entry, before authz/repo. Mirror the existing `MAX_VALUE_BYTES` constant pattern in `secrets_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#L633), [crates/manager-core/src/users_service.rs:575](crates/manager-core/src/users_service.rs#L575)
|
||||||
|
- **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 require `cx.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](crates/manager-core/src/users_service.rs#L466-L471)
|
||||||
|
- **Summary:** `list` page-fetches users then loops calling `self.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 same `fetch_roles` is also called from `verify_session_for_realtime` (one extra query on every authenticated SSE subscribe). Fix: add `AppUserRoleRepo::list_for_users(app_id, &[user_ids])` returning a `HashMap<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_middleware.rs#L206-L208), [crates/manager-core/src/auth_api.rs:98](crates/manager-core/src/auth_api.rs#L98), [crates/manager-core/src/users_service.rs:502](crates/manager-core/src/users_service.rs#L502)
|
||||||
|
- **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_key` Argon2-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 in `tokio::task::spawn_blocking`, and maintain a short-lived (60-300s) cache `LruCache<(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](crates/picloud/src/lib.rs#L583-L588) vs [crates/orchestrator-core/src/gate.rs](crates/orchestrator-core/src/gate.rs)
|
||||||
|
- **Summary:** `init_db` hard-codes `max_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 as `acquire_timeout` (5 s) errors and tail-latency spikes. Fix: scale `max_connections` to roughly `gate.max_permits × estimated_queries_per_exec + headroom`, expose a `PICLOUD_DB_MAX_CONNECTIONS` env 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/orchestrator-core/src/client.rs#L178-L216), [crates/executor-core/src/sdk/invoke.rs:192-200](crates/executor-core/src/sdk/invoke.rs#L192-L200)
|
||||||
|
- **Summary:** Two code paths bypass the AST cache: `ExecutorClient::execute` (used in tests + fallback) calls `engine.execute(&source, req)` which compiles inline; and `invoke()` synchronous re-entry calls `self_engine.execute(&resolved.source, req)`, re-parsing every callee on every invoke. Composed workflows multiply parse cost by depth. Fix: have `InvokeService::resolve` return `(ScriptIdentity, source)` and route through the cached `LocalExecutorClient::get_or_compile`, or expose `Engine::execute_with_identity` so 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](crates/manager-core/src/repo.rs#L511-L535)
|
||||||
|
- **Summary:** `list_for_script` does `ORDER BY created_at DESC LIMIT $2 OFFSET $3`. Postgres has to scan + discard `OFFSET` rows 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#L264-L287), [crates/manager-core/src/queue_repo.rs:289-322](crates/manager-core/src/queue_repo.rs#L289-L322)
|
||||||
|
- **Summary:** Scripts call `queue::depth(name)` / `queue::depth_pending(name)` per invocation; each is an unbounded `COUNT(*)` 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 downgrade `depth()` 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#L152-L164), [crates/manager-core/src/dispatcher.rs:166-290](crates/manager-core/src/dispatcher.rs#L166-L290)
|
||||||
|
- **Summary:** `tick_queue_arm` calls `list_active_queue_consumers()` then iterates serially, awaiting one `queue.claim(app, queue)` per consumer per tick. With N queue consumers and a 100ms tick, worst-case throughput is `N × (claim + executor) / tick`. Each iteration is a full `dispatch_one_queue` (resolve script, resolve principal, executor round-trip) sequential on the dispatcher's task. Fix: bound concurrency with `futures::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](crates/manager-core/src/trigger_repo.rs#L971-L987) (calls `hydrate_one` at ~:1351 per row)
|
||||||
|
- **Summary:** `list_for_app` selects parent rows then for each one issues a `SELECT … FROM <kind>_trigger_details WHERE trigger_id = $1`. For N triggers on an app, that's N+1 queries on every dashboard `GET /apps/{id}/triggers` load. Fix: split by kind and issue one-per-kind `JOIN <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_details` table.
|
||||||
|
|
||||||
|
##### 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#L141), [crates/manager-core/src/auth_middleware.rs:192-229](crates/manager-core/src/auth_middleware.rs#L192-L229)
|
||||||
|
- **Summary:** Every request hitting `/api/v1/execute/...` or any user-route path goes through `attach_principal_if_present`. If the request carries any `Authorization: Bearer pic_...` header, the middleware does: prefix lookup + Argon2 verify per candidate + `admin_users.get` + `touch_last_used` UPDATE. 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/Cargo.toml#L13), [crates/manager-core/src/dispatcher.rs:28](crates/manager-core/src/dispatcher.rs#L28), [crates/manager-core/src/route_admin.rs:15](crates/manager-core/src/route_admin.rs#L15), [crates/manager-core/src/apps_api.rs](crates/manager-core/src/apps_api.rs), [crates/manager-core/src/invoke_service.rs:15](crates/manager-core/src/invoke_service.rs#L15), [crates/manager-core/src/pubsub_service.rs:519](crates/manager-core/src/pubsub_service.rs#L519)
|
||||||
|
- **Summary:** CLAUDE.md's bright line: "the orchestrator never imports `executor-core` directly — define a trait in `shared`". The same rule should hold for `manager-core ↔ orchestrator-core`. Instead, `manager-core` pulls `picloud_orchestrator_core::routing::{RouteTable, AppDomainTable, matcher::CompiledRoute, pattern, conflict}` plus `{ExecutorClient, ExecutionGate, ScriptIdentity, ScriptResolver, ResolverError, InProcessBroadcaster}`. `RouteTable` is a stateful `Arc`'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: move `RouteTable`, `AppDomainTable`, `CompiledRoute`, `CompiledAppDomain`, `ScriptResolver`, `ResolverError`, `ExecutorClient`, `ExecutionGate`, `ScriptIdentity`, and the broadcaster trait into `shared/` (traits + DTOs); keep only in-process impls in `orchestrator-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/kv.rs#L178), [crates/executor-core/src/sdk/docs.rs:240](crates/executor-core/src/sdk/docs.rs#L240), [crates/executor-core/src/sdk/pubsub.rs:162](crates/executor-core/src/sdk/pubsub.rs#L162), [crates/executor-core/src/sdk/users.rs:593](crates/executor-core/src/sdk/users.rs#L593), [crates/executor-core/src/sdk/dead_letters.rs:69](crates/executor-core/src/sdk/dead_letters.rs#L69), [crates/executor-core/src/sdk/secrets.rs:138](crates/executor-core/src/sdk/secrets.rs#L138), [crates/executor-core/src/sdk/files.rs:266](crates/executor-core/src/sdk/files.rs#L266), [crates/executor-core/src/sdk/email.rs:136](crates/executor-core/src/sdk/email.rs#L136), [crates/executor-core/src/sdk/http.rs:369](crates/executor-core/src/sdk/http.rs#L369), [crates/executor-core/src/sdk/queue.rs:120](crates/executor-core/src/sdk/queue.rs#L120)
|
||||||
|
- **Summary:** Ten SDK modules each define a near-identical `block_on` that grabs `TokioHandle::try_current()`, calls `block_on`, and wraps the error into `EvalAltResult::ErrorRuntime` with a service prefix. The shapes diverge only by which `Error` variant they pin and the prefix string. Promote `sdk::bridge::block_on::<E, F>(service_name, fut)` (or a `block_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/kv_service.rs#L48-L64), [crates/manager-core/src/docs_service.rs:57-73](crates/manager-core/src/docs_service.rs#L57-L73), [crates/manager-core/src/files_service.rs:51-71](crates/manager-core/src/files_service.rs#L51-L71), [crates/manager-core/src/pubsub_service.rs:116-127](crates/manager-core/src/pubsub_service.rs#L116-L127), [crates/manager-core/src/queue_service.rs:61-68](crates/manager-core/src/queue_service.rs#L61-L68)
|
||||||
|
- **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_service` does this slightly differently (returns `Repo` vs `Denied`). Promote a single helper `authz::script_gate<E>(authz, cx, cap, deny_to: impl Fn(AuthzDenied) -> E)`. Eliminates drift risk: e.g., `queue_service` maps to `Rejected("forbidden")` instead of `Forbidden` because there's no `Forbidden` variant — 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/kv.rs#L139), [crates/shared/src/queue.rs:60-77](crates/shared/src/queue.rs#L60-L77), [crates/shared/src/pubsub.rs:85](crates/shared/src/pubsub.rs#L85), [crates/shared/src/invoke.rs:110](crates/shared/src/invoke.rs#L110)
|
||||||
|
- **Summary:** Same semantic error has two names: "Backend" in `KvError`/`DocsError`/`FilesError`/`SecretsError`; "Unavailable" in `QueueError`/`PubsubError`/`InvokeError`. Worse, `QueueError` has no `Forbidden` variant at all, so authz denial gets squashed into `QueueError::Rejected("forbidden".into())` ([queue_service.rs:68](crates/manager-core/src/queue_service.rs#L68)), losing the structured variant a 403-translation layer would need. And `queue_service::depth/depth_pending` skip authz entirely. Fix: pick one name (`Backend`), add `Forbidden` to every service's error enum uniformly, and gate `depth`/`depth_pending` on `AppQueueRead` (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/kv_service.rs#L52), [crates/manager-core/src/docs_service.rs:61,70](crates/manager-core/src/docs_service.rs#L61), [crates/manager-core/src/files_service.rs:55,68](crates/manager-core/src/files_service.rs#L55), [crates/manager-core/src/pubsub_service.rs:124,226](crates/manager-core/src/pubsub_service.rs#L124)
|
||||||
|
- **Summary:** Every `check_read`/`check_write` uses `.map_err(|_| KvError::Forbidden)?` — collapsing both `AuthzDenied::Denied` AND `AuthzDenied::Repo(repo_err)` into `Forbidden`. 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](crates/manager-core/src/users_service.rs#L177-L181)) 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](crates/orchestrator-core/src/inbox.rs#L46-L53)
|
||||||
|
- **Summary:** `register()` returns `(Uuid, Receiver)` even when the inner `Mutex` is poisoned — the `if let Ok(mut g) = self.inner.lock()` swallows the error, the sender is never inserted, the later `deliver(id, …)` will see no entry and return `Abandoned`. The caller's `await rx` blocks until the orchestrator's outer timeout. Sister registries `RouteTable` ([routing/table.rs:36](crates/orchestrator-core/src/routing/table.rs#L36)) and `AppDomainTable` correctly panic with `.expect("route table poisoned")`. Fix: switch to `expect("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](clients/typescript/src/react/index.ts#L73-L101)
|
||||||
|
- **Summary:** `useEndpoint(path)` returns `{ get: () => useResource(…), post: (body) => useResource(…) }`. Each of `.get()` and `.post()` calls `useResource`, which calls `useState` and `useEffect`. React's Rules of Hooks require hook calls in the top level of a component, in the same order each render. Calling `useEndpoint(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.tsx` doesn't exercise `useEndpoint` at all — only `useTopic` — 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/routes/apps/[slug]/+page.svelte#L1092-L1342), [dashboard/src/lib/api.ts:771-801](dashboard/src/lib/api.ts#L771-L801)
|
||||||
|
- **Summary:** Backend `triggers_api.rs` exposes `POST /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 (with `collection_glob` and `ops`) but operators can't create them from the UI. Add four more `submitCreate*` 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](dashboard/src/routes/apps/[slug]/queues/[name]/+page.svelte#L71)
|
||||||
|
- **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 no `apps/[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](dashboard/src/routes/apps/[slug]/files/+page.svelte#L96-L110)
|
||||||
|
- **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/collections` is 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 registered `files` triggers. 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](crates/manager-core/src/users_service.rs#L400-L413)
|
||||||
|
- **Summary:** `find_by_email` requires `AppUsersRead`, which means anonymous public-HTTP scripts can call it (the gate short-circuits on `None`). An attacker hitting any public route can iterate emails to enumerate registered users, then pivot to `login` / `request_password_reset`. The reset path is silent-on-miss; `find_by_email` is not. Fix: require an authenticated principal for `find_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](crates/manager-core/src/users_service.rs#L646-L651)
|
||||||
|
- **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 calls `email.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 dummy `generate_session_token()` and a dummy `email.send` (against `/dev/null` or 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](crates/manager-core/src/users_admin_api.rs#L250-L272)
|
||||||
|
- **Summary:** Handler comment acknowledges the brief required `AppUsersAdmin` for the irreversible dashboard delete, but the implementation calls `service.delete(...)` which only gates on `AppUsersWrite`. 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 explicit `authz::require(... AppUsersAdmin(app_id))` before `service.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](crates/manager-core/src/auth_middleware.rs#L192-L211)
|
||||||
|
- **Summary:** `verify_api_key` Argon2id-verifies every candidate sharing the 8-char prefix. An unauthenticated attacker can submit unlimited `Authorization: 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](crates/manager-core/src/auth_api.rs#L74-L101)
|
||||||
|
- **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/login` exhausts 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 in `admin_user_repo`.
|
||||||
|
|
||||||
|
##### F-S-008 — Realtime authority key cache never invalidates
|
||||||
|
- **Severity:** Medium
|
||||||
|
- **Location:** [crates/manager-core/src/realtime_authority.rs:34,56-72](crates/manager-core/src/realtime_authority.rs#L34)
|
||||||
|
- **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 removes `app_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](crates/shared/src/crypto.rs#L206-L231)
|
||||||
|
- **Summary:** When `PICLOUD_DEV_MODE=true` AND `PICLOUD_SECRET_KEY` is unset, the master key becomes `SHA-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-localhost `PICLOUD_PUBLIC_BASE_URL`, etc.), or require BOTH `PICLOUD_DEV_MODE=true` AND `PICLOUD_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](crates/manager-core/src/email_inbound_api.rs#L83-L144)
|
||||||
|
- **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: add `X-Picloud-Timestamp` to the HMAC input, reject requests outside the configured window, and dedupe on `message_id` in 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](crates/manager-core/src/admin_session_repo.rs#L1-L152)
|
||||||
|
- **Summary:** Unlike `app_user_sessions` (v1.1.8 — has `revoked_at` and `revoke_for_user`), `admin_sessions` only has `expires_at` and a `delete_for_user(user_id)` helper. No admin-side "revoke all sessions" gesture; an admin password change must explicitly call `delete_for_user` (audit the flow to confirm it does). Fix: add a `revoked_at` column 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/manager-core/src/invoke_service.rs#L121-L161), [crates/executor-core/src/sdk/invoke.rs:86-110](crates/executor-core/src/sdk/invoke.rs#L86-L110)
|
||||||
|
- **Summary:** `enqueue_async` and the sync `invoke()` perform no `authz::require` check — no `AppInvoke` capability 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_async` runs the callee with `principal: None`, so the callee may itself hold capabilities the original public caller shouldn't. Fix: add `Capability::AppInvoke(app_id)` and gate `invoke()` / `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/src/users_service.rs#L720-L778), [crates/manager-core/migrations/0030_app_user_invitations.sql:16-26](crates/manager-core/migrations/0030_app_user_invitations.sql#L16-L26)
|
||||||
|
- **Summary:** No unique constraint on `(app_id, email)` for pending rows. Calling `users::invite("alice@…")` N times creates N rows with N valid tokens. Combined with `accept_invite` returning `Ok(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](dashboard/src/routes/apps/[slug]/+page.svelte#L1678-L1722)
|
||||||
|
- **Summary:** Warning fires only for the internal→external transition (`editFlipToExternal`). Switching `token → public` or `session → public` while 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](crates/manager-core/src/trigger_repo.rs#L1290-L1303) vs [crates/manager-core/migrations/0008_triggers.sql:47-49](crates/manager-core/migrations/0008_triggers.sql#L47-L49)
|
||||||
|
- **Summary:** Query is `WHERE t.kind='queue' AND t.enabled=TRUE` with no `app_id` filter; available index `idx_triggers_app_kind_enabled (app_id, kind) WHERE enabled = TRUE` requires an `app_id` predicate to be useful. With many apps × many trigger kinds, this query becomes a sequential scan every 100ms. Fix: add `CREATE 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/orchestrator-core/src/routing/table.rs#L31-L38), [crates/manager-core/src/route_admin.rs:372-379](crates/manager-core/src/route_admin.rs#L372-L379)
|
||||||
|
- **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 compiled `CompiledRoute` into 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](crates/manager-core/src/app_user_repo.rs#L200-L225)
|
||||||
|
- **Summary:** `ORDER BY created_at DESC, id DESC` but cursor is `WHERE 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 use `WHERE (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/engine.rs#L167-L204), [crates/executor-core/src/sdk/mod.rs:49-68](crates/executor-core/src/sdk/mod.rs#L49-L68)
|
||||||
|
- **Summary:** Each `execute_ast` calls `build_engine` (new Rhai engine, every limit setter, disable-symbol, register all stdlib static modules), then `sdk::register_all` registers 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 need `SdkCallCx`.
|
||||||
|
|
||||||
|
##### 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](crates/executor-core/src/sdk/stdlib/regex.rs#L23-L25)
|
||||||
|
- **Summary:** `is_match`, `find`, `find_all`, `replace`, `replace_all`, `split`, `captures` all call `Regex::new(pattern)` per invocation. A script doing `regex::is_match("\\d+", x)` in a tight loop pays the compile every iteration. Fix: thread-local `LruCache<String, Arc<Regex>>` bounded to e.g. 128 entries. Regex compile dominates `is_match` on 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](crates/manager-core/src/outbox_event_emitter.rs#L88-L103)
|
||||||
|
- **Summary:** For each matched trigger, one separate `outbox.insert` round-trip. A KV mutation matched by 5 triggers becomes 5 sequential INSERTs inside the script's hot path, each cloning the JSONB payload. Fix: extend `OutboxRepo::insert_many(rows)` and emit one multi-row `INSERT … 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](crates/manager-core/src/app_user_session_repo.rs#L186-L200) vs [crates/manager-core/migrations/0027_app_user_sessions.sql:34-35](crates/manager-core/migrations/0027_app_user_sessions.sql#L34-L35)
|
||||||
|
- **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: add `CREATE 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](crates/manager-core/src/dispatcher.rs#L957-L963) 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}` wrap `String`. 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) so `tracing::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](crates/manager-core/src/http_service.rs#L209)
|
||||||
|
- **Summary:** `Method::from_bytes` failing on a user-supplied HTTP method gets `HttpError::Backend(format!("invalid method: {}", req.method))` — but that's user input, not a backend problem. Same for header-name / header-value validation. Misclassifying as `Backend` corrupts retry policies (operators may retry "backend" but not "invalid input"). Fix: add `HttpError::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](crates/manager-core/src/dispatcher.rs#L73)
|
||||||
|
- **Summary:** `TICK_INTERVAL = 100ms` and `ASYNC_EXEC_TIMEOUT = 300s` are `const`. Every other timing knob in the file (cron tick, queue reclaim, retry policy) is env-overridable via `TriggerConfig::from_env()`. Operators on a constrained Pi or a busier instance can tune retries but not dispatcher cadence. Fix: promote to `TriggerConfig` with `PICLOUD_DISPATCHER_TICK_INTERVAL_MS` and `PICLOUD_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](crates/orchestrator-core/src/inbox.rs#L49) (silent), [crates/orchestrator-core/src/routing/table.rs:36](crates/orchestrator-core/src/routing/table.rs#L36) (loud `expect`)
|
||||||
|
- **Summary:** Two registries playing the same architectural role (in-memory caches behind `Arc`) handle poisoning oppositely. `RouteTable` / `AppDomainTable` `expect("route table poisoned")`; `InboxRegistry` silently swallows. Settle on one policy — the loud `expect` is 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](crates/picloud/src/lib.rs#L622-L693)
|
||||||
|
- **Summary:** A 70-line newtype wraps `Arc<PostgresScriptRepository>` and re-implements every `ScriptRepository` method via `self.0.method(...).await`. Comment claims it exists because the resolver wants `impl ScriptRepository` "owned." Fix: provide a blanket `impl<T: ScriptRepository + ?Sized> ScriptRepository for Arc<T>` in `manager-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](crates/picloud/src/lib.rs#L101-L578) (`#[allow(clippy::too_many_lines)]`)
|
||||||
|
- **Summary:** Constructs ~30 `Arc<dyn ...>` handles, ~15 `*State` structs, ~12 routers, spawns 6 background tasks — all in one function with an opt-out clippy allow. Fix: extract `wire_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/shared/src/services.rs#L118-L148), [crates/picloud/src/lib.rs:296-310](crates/picloud/src/lib.rs#L296-L310)
|
||||||
|
- **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](crates/picloud/tests/api.rs) (~60 ignored), [crates/picloud/tests/authz.rs](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 runs `cargo test -- --ignored` with Postgres. No marker for "this was the test that was supposed to catch X." Fix: flip to the `schema_snapshot.rs` / `dispatcher_e2e.rs` convention (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/migrations/0017_cron_triggers.sql#L41), [crates/manager-core/src/cron_scheduler.rs:117-123](crates/manager-core/src/cron_scheduler.rs#L117-L123)
|
||||||
|
- **Summary:** Index is on `(last_fired_at)` with a comment claiming it serves the scheduler. The actual query is `WHERE t.enabled = TRUE FOR UPDATE OF d SKIP LOCKED` — no `last_fired_at` predicate. 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 on `last_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](crates/manager-core/migrations/0024_email_triggers.sql#L28-L32)
|
||||||
|
- **Summary:** `inbound_secret_encrypted` and `inbound_secret_nonce` are 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 to `app_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](crates/manager-core/migrations/0009_outbox.sql#L29)
|
||||||
|
- **Summary:** The migration notes `trigger_id` is polymorphic (`routes.id` when `source_kind='http'`, else `triggers.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](clients/typescript/src/react/index.ts#L73-L100)
|
||||||
|
- **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]/dead-letters/+page.svelte#L175-L309), [dashboard/src/routes/apps/[slug]/files/+page.svelte:174-228](dashboard/src/routes/apps/[slug]/files/+page.svelte#L174-L228), [dashboard/src/routes/apps/[slug]/users/+page.svelte:364-466](dashboard/src/routes/apps/[slug]/users/+page.svelte#L364-L466), [dashboard/src/routes/apps/[slug]/users/invitations/+page.svelte:254-325](dashboard/src/routes/apps/[slug]/users/invitations/+page.svelte#L254-L325), [dashboard/src/routes/apps/[slug]/queues/+page.svelte:93-128](dashboard/src/routes/apps/[slug]/queues/+page.svelte#L93-L128)
|
||||||
|
- **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 by `apps/[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](dashboard/src/routes/apps/[slug]/+page.svelte#L1101-L1296)
|
||||||
|
- **Summary:** `Trigger.dispatch_mode`, `retry_max_attempts`, `retry_backoff`, `retry_base_ms` are all editable via the create API and visible in the list, but the cron/pubsub/email create forms never expose them — only queue has `retry_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](dashboard/src/routes/apps/[slug]/+page.svelte#L1300-L1340)
|
||||||
|
- **Summary:** `Trigger.enabled: boolean` is 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](dashboard/src/routes/scripts/[id]/+page.svelte#L282)
|
||||||
|
- **Summary:** `removeRoute()` uses `window.confirm('Delete this route?')` and surfaces errors via `window.alert()`. The rest of the dashboard adopted `ConfirmModal` for exactly this case. The browser modal is unstyled, can't show route detail, and the alert is a dead-end. Fix: convert to a `ConfirmModal` with 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](dashboard/src/routes/scripts/[id]/+page.svelte#L420)
|
||||||
|
- **Summary:** "← Scripts" link uses `base + '/'`, which the root page redirects to `/apps`. After deleting a script the user is also bounced to `base + '/'`. The breadcrumb already resolves `appSlug`; 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](dashboard/src/routes/apps/[slug]/files/+page.svelte#L120-L153)
|
||||||
|
- **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/config` page using a new `GET /api/v1/admin/config` endpoint 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](dashboard/src/routes/apps/[slug]/+page.svelte#L1112-L1118)
|
||||||
|
- **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](dashboard/src/routes/scripts/[id]/+page.svelte#L369)
|
||||||
|
- **Summary:** `api.scripts.logs(id, { limit: 50 })` is the only call; backend supports `offset` (see [api.ts:659](dashboard/src/lib/api.ts#L659)) 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/+page.svelte#L56-L91), [dashboard/src/routes/apps/[slug]/queues/[name]/+page.svelte:50-90](dashboard/src/routes/apps/[slug]/queues/[name]/+page.svelte#L50-L90)
|
||||||
|
- **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](dashboard/src/routes/apps/[slug]/+page.svelte#L246-L248)
|
||||||
|
- **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_url` already exists for exactly this case. Fix: use `version.public_base_url` instead of `window.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](dashboard/src/lib/api.ts#L438-L446)
|
||||||
|
- **Summary:** On any 401 the fetch wrapper immediately calls `clearSession()` and `goto('/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/lib/ConfirmModal.svelte#L100-L156), [dashboard/src/routes/users/+page.svelte:402-476](dashboard/src/routes/users/+page.svelte#L402-L476)
|
||||||
|
- **Summary:** `ConfirmModal` focuses 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 (via `inert` on 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/users/+page.svelte#L727-L734), [dashboard/src/routes/profile/+page.svelte:669-677](dashboard/src/routes/profile/+page.svelte#L669-L677)
|
||||||
|
- **Summary:** Both tables use 7- and 8-column CSS grids with no `@media` rule for mobile. Columns squash and the search input pushes header controls off-screen on narrow viewports. Fix: switch to card layout below `min-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](dashboard/src/routes/users/+page.svelte#L452-L462)
|
||||||
|
- **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::login` requires `AppUsersWrite` so authenticated viewer-role scripts can't authenticate users — sharp edge for "login form on an authed admin page". [users_service.rs:478-485](crates/manager-core/src/users_service.rs#L478-L485) — Low
|
||||||
|
- **F-S-016** — Dispatcher `trigger_depth > max` vs invoke SDK `cx.trigger_depth + 1 > max` differ by one level; align the boundary semantics and add a unit test. [dispatcher.rs:342](crates/manager-core/src/dispatcher.rs#L342), [invoke.rs:141](crates/executor-core/src/sdk/invoke.rs#L141)
|
||||||
|
- **F-S-017** — `SmtpConfig` derives `Debug` with raw `password: String` — accidental `tracing::debug!(?cfg)` leaks. [email_service.rs:88-97](crates/manager-core/src/email_service.rs#L88-L97). Hand-implement `Debug` with redaction.
|
||||||
|
- **F-S-018** — `OutboxEventEmitter` log-and-ignore on emit failure: triggers can silently miss events if the outbox insert fails after the primary write. [kv_service.rs:114-130](crates/manager-core/src/kv_service.rs#L114-L130), [docs_service.rs:122-138](crates/manager-core/src/docs_service.rs#L122-L138), [files_service.rs:74-102](crates/manager-core/src/files_service.rs#L74-L102). Bump a metric on emit failure; long-term make primary + outbox a single tx.
|
||||||
|
- **F-S-019** — `retry::run` allows cumulative sleep far exceeding the wall-clock timeout, tying up a gate permit. [retry.rs:189-222](crates/executor-core/src/sdk/retry.rs#L189-L222). Cap cumulative sleep inside `retry::run`.
|
||||||
|
- **F-S-020** — `request_password_reset` email-send clamping accumulates dead reset tokens when SMTP misconfigured. Mark the just-inserted row consumed when `email.send` returns non-NotConfigured failures. [users_service.rs:678-690](crates/manager-core/src/users_service.rs#L678-L690)
|
||||||
|
- **F-S-021** — Bearer token in `Set-Cookie` not validated against cookie-octet alphabet — if the token format ever changes, header injection becomes possible. [auth_api.rs:207-223](crates/manager-core/src/auth_api.rs#L207-L223). Guard with `[A-Za-z0-9_-]+`.
|
||||||
|
- **F-S-022** — `attach_principal_if_present` swallows 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. on `secrets_service`). [auth_middleware.rs:119-130](crates/manager-core/src/auth_middleware.rs#L119-L130)
|
||||||
|
- **F-S-023** — `users::login` cross-app belt-and-suspenders check is unreachable in practice. Assert the invariant at the repo boundary instead. [users_service.rs:478-514](crates/manager-core/src/users_service.rs#L478-L514)
|
||||||
|
- **F-S-024** — `PICLOUD_COOKIE_SECURE` env-var parsing is inverted-boolean readability. Replace with explicit `is_truthy` parse identical to crypto.rs. [auth_api.rs:212-218](crates/manager-core/src/auth_api.rs#L212-L218)
|
||||||
|
- **F-S-025** — Realtime signing key plaintext lingers in `Vec<u8>` allocations without zeroize; heap dumps can leak. Use `Zeroizing<Vec<u8>>` for the realtime signing key and `ZeroizeOnDrop` for `MasterKey`. [app_secrets_repo.rs:78-91](crates/manager-core/src/app_secrets_repo.rs#L78-L91), [realtime_authority.rs:34](crates/manager-core/src/realtime_authority.rs#L34), [shared/crypto.rs:117](crates/shared/src/crypto.rs#L117)
|
||||||
|
- **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](crates/manager-core/src/email_inbound_api.rs#L149-L166). 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](crates/manager-core/migrations/0006_users_authz.sql#L24-L30)
|
||||||
|
- **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](crates/manager-core/src/auth_middleware.rs#L198-L211)
|
||||||
|
- **F-S-029** — `admin_reset_password_token` returns raw token in JSON without rate limit. Mint a new token doesn't invalidate prior unconsumed tokens. [users_service.rs:902-927](crates/manager-core/src/users_service.rs#L902-L927)
|
||||||
|
- **F-S-030** — `GeneratedSession`/`GeneratedAccept`/`GeneratedToken` derive `Debug` with raw tokens — same footgun as F-S-017. [shared/users.rs:89-102](crates/shared/src/users.rs#L89-L102), [auth.rs:72](crates/manager-core/src/auth.rs#L72)
|
||||||
|
- **F-S-031** — `auth_middleware::touch` failure becomes 500, blocking authed requests on transient DB blips. Fail-soft (log + continue with existing expiry). [auth_middleware.rs:170-176](crates/manager-core/src/auth_middleware.rs#L170-L176)
|
||||||
|
- **F-S-032** — Cookie `SameSite=Lax` is not exploitable today (admin POSTs accept JSON only), but adding a `Form<...>` extractor would make it CSRF-able. Switch admin cookie to `SameSite=Strict`. [auth_api.rs:175,220-223](crates/manager-core/src/auth_api.rs#L175)
|
||||||
|
- **F-P-017** — `execute_by_id` clones request body, headers, and path before executing — wasteful on large bodies. Serialize logged shape from `&req` after a Result branch. [orchestrator-core/src/api.rs:117-159](crates/orchestrator-core/src/api.rs#L117-L159)
|
||||||
|
- **F-P-018** — Sync HTTP path serializes body twice and clones a 3rd time for audit log. [orchestrator-core/src/api.rs:363-373](crates/orchestrator-core/src/api.rs#L363-L373)
|
||||||
|
- **F-P-019** — `realtime_authority::signing_key` clones a `Vec<u8>` under `Mutex` on every cache hit. Use `Arc<[u8]>` so clone is a refcount bump. [realtime_authority.rs:56-72](crates/manager-core/src/realtime_authority.rs#L56-L72)
|
||||||
|
- **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_tmp` table. [files_sweep.rs:75-111](crates/manager-core/src/files_sweep.rs#L75-L111)
|
||||||
|
- **F-P-021** — `dispatcher.handle_failure` decodes payload JSON 3× per failure. Decode once at top of `handle_failure`. [dispatcher.rs:741-848](crates/manager-core/src/dispatcher.rs#L741-L848)
|
||||||
|
- **F-P-022** — `InProcessBroadcaster::publish` allocates a `String` from the `&str` topic 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](crates/orchestrator-core/src/realtime.rs#L107-L117)
|
||||||
|
- **F-P-023** — `dead_letters_api` `unresolved_count` is `COUNT(*)` per dashboard load. Partial index covers it today; cap at 100 with a "100+" UI state. [dead_letter_repo.rs:172-181](crates/manager-core/src/dead_letter_repo.rs#L172-L181)
|
||||||
|
- **F-P-024** — Inline `block_on` for `signing_key` lookup 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](crates/manager-core/src/realtime_authority.rs#L99-L102)
|
||||||
|
- **F-P-025** — `attach_principal_if_present` middleware applied twice on overlapping routers (`data_plane_routed` + `user_routes`). Confirm no double-Argon2. [picloud/src/lib.rs:546-553](crates/picloud/src/lib.rs#L546-L553)
|
||||||
|
- **F-Q-015** — `pubsub_service::publish_durable` spawns a task only to immediately await its `JoinHandle` — accomplishes nothing the inline `.await` wouldn't. [pubsub_service.rs:193-198](crates/manager-core/src/pubsub_service.rs#L193-L198)
|
||||||
|
- **F-Q-016** — `KvError::from(KvRepoError)` does `Backend(e.to_string())` for any repo error; files_service has the right shape (pattern-match and promote). Adopt uniformly. [kv_service.rs:74-78](crates/manager-core/src/kv_service.rs#L74-L78), [files_service.rs:111-119](crates/manager-core/src/files_service.rs#L111-L119)
|
||||||
|
- **F-Q-017** — `kv_service.rs` and `docs_service.rs` inline emit-and-log boilerplate 17 lines × 2 callsites; `files_service` / `users_service` already extract it. Apply the `emit` helper pattern. [kv_service.rs:111-130](crates/manager-core/src/kv_service.rs#L111-L130), [docs_service.rs:122-138](crates/manager-core/src/docs_service.rs#L122-L138)
|
||||||
|
- **F-Q-018** — `auth_api::login` has a stale `.unwrap()` after the contract has been narrowed; future refactor of the predicate ordering can re-enable the panic. Use `let Some(user_id) = user_id else { … };`. [auth_api.rs:102](crates/manager-core/src/auth_api.rs#L102)
|
||||||
|
- **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](crates/shared/src/)
|
||||||
|
- **F-Q-020** — Several tests use `assert!(... .is_ok())` instead of asserting on the value — auth, realtime_authority, http_service especially. [client.rs:408](crates/orchestrator-core/src/client.rs#L408), [http_service.rs:774](crates/manager-core/src/http_service.rs#L774), [auth.rs:186](crates/manager-core/src/auth.rs#L186), [realtime_authority.rs:251](crates/manager-core/src/realtime_authority.rs#L251)
|
||||||
|
- **F-Q-021** — `gc::SWEEP_INTERVAL` (weekly) and 5000-row batch hardcoded; retention is env-overridable but cadence isn't. Promote to `TriggerConfig`. [gc.rs:25,30](crates/manager-core/src/gc.rs#L25)
|
||||||
|
- **F-Q-022** — `SmtpConfig::from_env` uses bare literal `.unwrap_or(30)` for default timeout. Name it `DEFAULT_SMTP_TIMEOUT_SECS`. [email_service.rs:128](crates/manager-core/src/email_service.rs#L128)
|
||||||
|
- **F-M-004** — Redundant or under-targeted indexes: `secrets (app_id)` duplicates PK leftmost prefix; `idx_kv_entries_app_collection` duplicates PK; `idx_queue_trigger_details_queue_name` lacks `app_id`; `idx_triggers_app_pubsub_enabled` duplicates broader `idx_triggers_app_kind_enabled`. Each costs write amplification without enabling new plans. [0007_kv.sql:28](crates/manager-core/migrations/0007_kv.sql#L28), [0020_pubsub_triggers.sql:32](crates/manager-core/migrations/0020_pubsub_triggers.sql#L32), [0023_secrets.sql:24](crates/manager-core/migrations/0023_secrets.sql#L24), [0035_queue_triggers.sql:40](crates/manager-core/migrations/0035_queue_triggers.sql#L40)
|
||||||
|
- **F-M-005** — `triggers.retry_max_attempts`/`retry_base_ms` and `queue_messages.max_attempts`/`attempt` have no CHECK against negative or absurd values. Apply the `scripts.timeout_seconds CHECK (> 0 AND <= 300)` discipline. [0008_triggers.sql:35,38](crates/manager-core/migrations/0008_triggers.sql#L35), [0034_queue_messages.sql:28-29](crates/manager-core/migrations/0034_queue_messages.sql#L28-L29)
|
||||||
|
- **F-M-006** — `dead_letters.resolution` is NULL-able with no coupled-with-`resolved_at` CHECK. Same pattern as F-M-002. [0010_dead_letters.sql:37-40](crates/manager-core/migrations/0010_dead_letters.sql#L37-L40)
|
||||||
|
- **F-M-007** — `routes_unique_binding_idx` uses `COALESCE(method, '')` allowing ambiguous binding (an empty-string method conflicts with a NULL method). Add `CHECK method IS NULL OR method IN ('GET','POST',…)`. [0005_apps.sql:100](crates/manager-core/migrations/0005_apps.sql#L100)
|
||||||
|
- **F-T-003** — `subscribeTopic` 401 refresh path recurses without backoff. If `onTokenExpired` returns a stale token, unbounded recursion. Track `consecutive401Count`. [subscribe.ts:63-75](clients/typescript/src/subscribe.ts#L63-L75)
|
||||||
|
- **F-T-004** — `subscribeTopic` resets `attempt = 0` after a successful connect, but a stream that errors immediately after one byte means the next reconnect is `base * 2^0` (no backoff) — hot reconnect loop possible. Gate the reset on receiving the first event or an uptime threshold. [subscribe.ts:82-96](clients/typescript/src/subscribe.ts#L82-L96)
|
||||||
|
- **F-T-005** — `useTopic` excludes `opts` from deps — a caller changing `validate: zSchemaA` to `zSchemaB` for the same topic won't take effect. Document that `opts` is captured once per topic. [react/index.ts:39-55](clients/typescript/src/react/index.ts#L39-L55)
|
||||||
|
- **F-T-006** — `parseBody` swallows JSON parse errors and silently falls through to text; the caller's `validate.parse` then receives raw text and throws a confusing "expected object". [endpoint.ts:84-95](clients/typescript/src/endpoint.ts#L84-L95)
|
||||||
|
- **F-T-007** — `joinUrl` doesn't normalize repeated slashes (`https://x/v1//users` survives). Strip multiple consecutive `/` in the path portion. [endpoint.ts:102-106](clients/typescript/src/endpoint.ts#L102-L106)
|
||||||
|
- **F-T-008** — `tsup.config.ts` externals don't include `react/jsx-runtime` — if anyone uses JSX syntax, the build bundles a copy. Pre-emptive add. [tsup.config.ts:17](clients/typescript/tsup.config.ts#L17)
|
||||||
|
- **F-T-009** — `AuthClient` token storage is in-memory only; SSR and persistence-across-refresh aren't documented. Add a README section on the `onToken` callback + storage trade-off. [auth.ts:26](clients/typescript/src/auth.ts#L26)
|
||||||
|
- **F-T-010** — `AuthClient.login` hardcoded to `{ email, password }` — overload with arbitrary credentials object would suit the hybrid-model intent. [auth.ts:39-48](clients/typescript/src/auth.ts#L39-L48)
|
||||||
|
- **F-U-019** — Email-trigger inbound webhook URL has no copy button (compare API-key reveal pattern with clipboard support). [+page.svelte:1321](dashboard/src/routes/apps/[slug]/+page.svelte#L1321)
|
||||||
|
- **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](dashboard/src/routes/scripts/[id]/+page.svelte#L50)
|
||||||
|
- **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](dashboard/src/routes/apps/[slug]/dead-letters/+page.svelte#L176-L180), [files/+page.svelte:175-179](dashboard/src/routes/apps/[slug]/files/+page.svelte#L175-L179)
|
||||||
|
- **F-U-022** — `$effect` blocks reference variables via `void slug;` to force tracking — Svelte 5 cargo-cult; the synchronous deref inside `load()` already registers the dependency. [dead-letters/+page.svelte:32-37](dashboard/src/routes/apps/[slug]/dead-letters/+page.svelte#L32-L37)
|
||||||
|
- **F-U-023** — Logs viewer expanded JSON has no pretty-print toggle, copy button, or syntax highlighting. [scripts/[id]/+page.svelte:804-811](dashboard/src/routes/scripts/[id]/+page.svelte#L804-L811)
|
||||||
|
- **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](dashboard/src/routes/scripts/[id]/+page.svelte#L178-L188)
|
||||||
|
- **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](dashboard/src/routes/apps/+page.svelte#L19-L33)
|
||||||
|
- **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](dashboard/src/routes/login/+page.svelte#L24-L36)
|
||||||
|
- **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](dashboard/src/routes/apps/[slug]/users/+page.svelte#L202-L205)
|
||||||
|
- **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](dashboard/src/routes/apps/[slug]/users/invitations/+page.svelte#L146-L149)
|
||||||
|
- **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](dashboard/src/routes/apps/[slug]/+page.svelte#L1101-L1296)
|
||||||
|
- **F-U-030** — Members tab eligibleUsers errors only display once per session and disappear after submit attempt; the empty state for `app_admin` users is a dead-end with no actionable workflow. [apps/[slug]/+page.svelte:519-540](dashboard/src/routes/apps/[slug]/+page.svelte#L519-L540)
|
||||||
|
- **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](dashboard/src/routes/apps/[slug]/+page.svelte#L1048-L1090)
|
||||||
|
- **F-U-032** — Modal backdrop has no focus restoration on close — keyboard users lose their place. Capture `document.activeElement` on mount, re-focus on destroy. [ConfirmModal.svelte:91-97](dashboard/src/lib/ConfirmModal.svelte#L91-L97)
|
||||||
|
- **F-U-033** — Apps-list "No apps yet. Create one above…" misleads `member` instance-role users who can't create apps. Branch the empty-state copy. [apps/+page.svelte:217-218](dashboard/src/routes/apps/+page.svelte#L217-L218)
|
||||||
|
- **F-U-034** — `ConfirmModal.confirmPhrasePrompt` defaults 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](dashboard/src/lib/ConfirmModal.svelte#L121-L122)
|
||||||
|
- **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 `title` and link to `/scripts/{id}`. [apps/[slug]/+page.svelte:1329](dashboard/src/routes/apps/[slug]/+page.svelte#L1329), [dead-letters/+page.svelte:131](dashboard/src/routes/apps/[slug]/dead-letters/+page.svelte#L131)
|
||||||
|
- **F-U-036** — No copy-to-clipboard for any UUID shown anywhere. Reusable `<CopyableId>` component. [profile/+page.svelte:230](dashboard/src/routes/profile/+page.svelte#L230), [files/+page.svelte:138](dashboard/src/routes/apps/[slug]/files/+page.svelte#L138)
|
||||||
|
- **F-U-037** — Timestamps via `new Date(iso).toLocaleString()` give no timezone hint. Show ISO UTC as `title=` 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](dashboard/src/routes/apps/[slug]/+page.svelte#L34-L50)
|
||||||
|
- **F-U-039** — Pubsub topic-pattern input has no datalist from registered topics. Drawing from `topics[].name` would catch typos. [apps/[slug]/+page.svelte:1152-1179](dashboard/src/routes/apps/[slug]/+page.svelte#L1152-L1179)
|
||||||
|
- **F-U-040** — Dashboard doesn't display `public_base_url` anywhere as a "this instance lives at" hint. Add to footer/header chip. [api.ts:142-149](dashboard/src/lib/api.ts#L142-L149)
|
||||||
|
- **F-U-041** — Apps page DL-badge has `title` but no `aria-label`; screen readers hear only the bare count. [apps/+page.svelte:228-232](dashboard/src/routes/apps/+page.svelte#L228-L232)
|
||||||
|
|
||||||
|
#### Info
|
||||||
|
|
||||||
|
- **F-S-033** — `users::accept_invite` deliberately skips authz ("the invitation token IS the authorization"); document the threat model explicitly. [users_service.rs:780-787](crates/manager-core/src/users_service.rs#L780-L787)
|
||||||
|
- **F-S-034** — HTTP outbox dispatch runs with `principal: None` even 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](crates/manager-core/src/dispatcher.rs#L566)
|
||||||
|
- **F-S-035** — `users::admin_create_invitation` synthesizes a `SdkCallCx` with random `script_id`/`execution_id`. Forensic noise; consider sentinel `nil()` values. [users_service.rs:982-992](crates/manager-core/src/users_service.rs#L982-L992)
|
||||||
|
- **F-S-036** — Dispatcher uses true `rand::thread_rng()`; `retry::run` uses `DefaultHasher`-based pseudo-randomness that gives identical jitter for same `(span, raw)` pairs — defeats jitter purpose under concurrent retries. Unify. [dispatcher.rs:1041](crates/manager-core/src/dispatcher.rs#L1041) vs [retry.rs:249-261](crates/executor-core/src/sdk/retry.rs#L249-L261)
|
||||||
|
- **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](crates/manager-core/src/ssrf.rs#L154-L221)
|
||||||
|
- **F-S-038** — `me` endpoint has no scope check — an API key with only `script:read` learns the owning admin's username/instance_role/email. Document intent or filter for non-session principals. [auth_api.rs:180-201](crates/manager-core/src/auth_api.rs#L180-L201)
|
||||||
|
- **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](crates/manager-core/src/auth_api.rs#L140-L157)
|
||||||
|
- **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](dashboard/src/routes/apps/[slug]/+page.svelte#L1744-L1756)
|
||||||
|
- **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`/`NotFound` and dead-letter with reason "principal unavailable". [dispatcher.rs:231-235](crates/manager-core/src/dispatcher.rs#L231-L235)
|
||||||
|
- **F-Q-023** — `Services` `Arc<dyn>` bundle + `#[allow(clippy::too_many_arguments)]` hides drift signal. See F-Q-013. [shared/services.rs:117](crates/shared/src/services.rs#L117)
|
||||||
|
- **F-M-008** — `app_secrets`/`secrets`/`outbox`/`queue_messages`/`dead_letters` `JSONB` value columns are NOT NULL but allow `'null'::jsonb`. Probably not worth a CHECK; flag. [0007_kv.sql:18](crates/manager-core/migrations/0007_kv.sql#L18)
|
||||||
|
- **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](crates/manager-core/migrations/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](crates/manager-core/migrations/0008_triggers.sql#L30), [0012_routes_dispatch_mode.sql:15](crates/manager-core/migrations/0012_routes_dispatch_mode.sql#L15)
|
||||||
|
- **F-M-012** — `app_users` lacks indexes on `email_verified_at` / `last_login_at` despite likely admin-filter views. May be premature; flag for v1.2. [0026_app_users.sql:24-25](crates/manager-core/migrations/0026_app_users.sql#L24-L25)
|
||||||
|
- **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** — `pgcrypto` extension declared once in 0001; subsequent migrations rely on `gen_random_uuid()` without re-declaring. Belt-and-suspenders `CREATE EXTENSION IF NOT EXISTS pgcrypto` in each migration that uses it.
|
||||||
|
- **F-T-011** — `subscribeTopic` sends `Last-Event-ID` header 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](clients/typescript/src/subscribe.ts#L51-L52)
|
||||||
|
- **F-T-012** — Svelte `topicStore` `items` closure resets when last subscriber unsubscribes — non-obvious lifecycle; README doesn't call it out. [svelte/index.ts:20-32](clients/typescript/src/svelte/index.ts#L20-L32)
|
||||||
|
- **F-T-013** — `react.test.tsx` fakeClient bypasses type safety with `as unknown as PicloudClient`. Public API additions won't be caught. Expose `PicloudClientLike` interface. [tests/react.test.tsx:19](clients/typescript/tests/react.test.tsx#L19)
|
||||||
|
- **F-T-014** — `package.json` has both modern `exports` and legacy `main`/`module`/`types`; consistent today, but `exports`-only would be a single source of truth. [clients/typescript/package.json:11-30](clients/typescript/package.json#L11-L30)
|
||||||
|
- **F-U-042** — `let appBySlug = $derived(...)` should be `const`. [profile/+page.svelte:28](dashboard/src/routes/profile/+page.svelte#L28)
|
||||||
|
- **F-U-043** — `/profile?denied=users` deep-link doesn't `replaceState`-strip the param; refresh keeps the banner. [profile/+page.svelte:35](dashboard/src/routes/profile/+page.svelte#L35)
|
||||||
|
- **F-U-044** — Test invoke result clobbers earlier with no history; small ring buffer would help iterative testing. [scripts/[id]/+page.svelte:158-197](dashboard/src/routes/scripts/[id]/+page.svelte#L158-L197)
|
||||||
|
- **F-U-045** — `route-utils.ts` and `slugify.ts` lack vitest coverage; both are pure-function modules — easy to add. [dashboard/src/lib/](dashboard/src/lib/)
|
||||||
|
|
||||||
|
## Methodology notes
|
||||||
|
|
||||||
|
- **Subagents:** 6 parallel `general-purpose` agents — 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](clients/typescript/src/react/index.ts). F-P-001 (users::list N+1) confirmed at [users_service.rs:466-471](crates/manager-core/src/users_service.rs#L466-L471). F-P-003 (pool=10) confirmed at [picloud/src/lib.rs:583-588](crates/picloud/src/lib.rs#L583-L588). F-S-001 (unbounded queue payload) confirmed at [queue_service.rs:33-92](crates/manager-core/src/queue_service.rs#L33-L92). 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).
|
||||||
|
- **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.
|
||||||
475
CHANGELOG.md
475
CHANGELOG.md
@@ -1,5 +1,480 @@
|
|||||||
# PiCloud Changelog
|
# PiCloud Changelog
|
||||||
|
|
||||||
|
## v1.1.9 — Durable Queues & Function Composition (unreleased)
|
||||||
|
|
||||||
|
Per-app durable queues + same-app function composition + caller-
|
||||||
|
controlled retries. The final v1.1.x release before the v1.2 phase
|
||||||
|
milestone. **No upgrade-order constraint** — pure additive surface,
|
||||||
|
no destructive migrations.
|
||||||
|
|
||||||
|
### Added — `queue::*` SDK (durable per-app named queues)
|
||||||
|
|
||||||
|
- **`queue::enqueue(name, message)` / `queue::enqueue(name, message, opts)`**
|
||||||
|
— write one message onto a per-app named queue. `opts` is a Map
|
||||||
|
with `delay_ms` (defer delivery until `NOW() + delay`) and
|
||||||
|
`max_attempts` (clamped to `[1, 20]`; default 3). Message is any
|
||||||
|
JSON-serializable value — Maps, Arrays, scalars, and Blobs (which
|
||||||
|
base64-encode at any depth, mirroring `pubsub::publish_durable`).
|
||||||
|
FnPtr / closures are rejected at the bridge.
|
||||||
|
- **`queue::depth(name)` / `queue::depth_pending(name)`** — read-only
|
||||||
|
inspection. `depth` is `COUNT(*)`; `depth_pending` filters to
|
||||||
|
`claim_token IS NULL AND (deliver_after IS NULL OR deliver_after <= NOW())`.
|
||||||
|
- Identity tuple is `(app_id, queue_name)`. Queue names are implicit
|
||||||
|
— no queue registry table, no separate "create queue" ceremony. A
|
||||||
|
queue exists once the first message is enqueued under that name.
|
||||||
|
- No `peek` / `dequeue` / `purge` script-side surface. Consumers are
|
||||||
|
triggers; exposing manual dequeue would force the platform to
|
||||||
|
surface visibility-timeout semantics into script-land.
|
||||||
|
- New capability `AppQueueEnqueue(AppId)` (script:write scope, editor+).
|
||||||
|
|
||||||
|
### Added — `queue:receive` trigger kind
|
||||||
|
|
||||||
|
- **Exactly one consumer per `(app_id, queue_name)`** — enforced via
|
||||||
|
`pg_advisory_xact_lock(hash(app_id || queue_name))` + a SELECT-then-
|
||||||
|
INSERT in one transaction (a partial unique index can't span the
|
||||||
|
parent's `app_id` from the detail table). The second create returns
|
||||||
|
`422 Invalid` with the message `queue 'X' already has a consumer
|
||||||
|
trigger; remove the existing one first`.
|
||||||
|
- Per-trigger `visibility_timeout_secs` (clamped `[5, 3600]`; default
|
||||||
|
via `PICLOUD_QUEUE_DEFAULT_VISIBILITY_TIMEOUT_SECS`). The dispatcher
|
||||||
|
treats this as the lease window; a handler that holds the claim
|
||||||
|
past this point loses it to the periodic reclaim task.
|
||||||
|
- Retry policy lives on the parent `triggers` row (the same
|
||||||
|
`retry_max_attempts` / `retry_backoff` / `retry_base_ms` every
|
||||||
|
other trigger kind reads); no duplication on the detail row.
|
||||||
|
- Surfaced to handler scripts as `ctx.event.queue` —
|
||||||
|
`{ queue_name, message, enqueued_at, attempt, message_id }` — plus
|
||||||
|
`ctx.event.source = "queue"` and `ctx.event.op = "receive"`.
|
||||||
|
- Trigger executes as the principal that **registered** it (matches
|
||||||
|
design notes §4 — every trigger kind does this).
|
||||||
|
|
||||||
|
### Added — Dispatcher queue arm + visibility-timeout reclaim task
|
||||||
|
|
||||||
|
- The queue table IS the outbox for queue semantics. The dispatcher
|
||||||
|
doesn't double-buffer through `outbox`; it claims directly from
|
||||||
|
`queue_messages` via the single-round-trip
|
||||||
|
`UPDATE … WHERE id = (SELECT id … FOR UPDATE SKIP LOCKED LIMIT 1)
|
||||||
|
RETURNING …`. `SKIP LOCKED` keeps concurrent dispatchers (cluster
|
||||||
|
mode v1.3+) safe.
|
||||||
|
- Per tick (every 100ms), interleaved with the existing outbox arm:
|
||||||
|
list every enabled `queue:receive` consumer and try one claim each.
|
||||||
|
Bounded by the registered-consumer count, so one busy queue can't
|
||||||
|
starve the rest.
|
||||||
|
- **Auto-ack**: handler returns successfully → `DELETE FROM queue_messages
|
||||||
|
WHERE id = $1 AND claim_token = $2`. The token in the WHERE is the
|
||||||
|
leasing guarantee.
|
||||||
|
- **Auto-nack**: handler throws → if `attempt < max_attempts`, clear
|
||||||
|
the claim and set `deliver_after = NOW() + compute_backoff(...)`;
|
||||||
|
else, write a `dead_letters` row (the existing `fan_out_dead_letter`
|
||||||
|
path then fires registered `dead_letter` triggers off it) and delete
|
||||||
|
the queue row, in one transaction.
|
||||||
|
- **Visibility-timeout reclaim**: a separate `tokio::spawn` task ticks
|
||||||
|
every `PICLOUD_QUEUE_RECLAIM_INTERVAL_MS` (default 30000) and clears
|
||||||
|
claims older than the per-queue `visibility_timeout_secs`. A crashed
|
||||||
|
consumer (or one whose handler hung past the visibility window) thus
|
||||||
|
loses its lease and the message becomes claimable again.
|
||||||
|
|
||||||
|
### Added — `invoke()` + `invoke_async()` (same-app function composition)
|
||||||
|
|
||||||
|
- **`invoke(target, args)`** — sync, returns the callee's response
|
||||||
|
body as a Rhai Dynamic. `target` is a string (path → route trie,
|
||||||
|
UUID → `ScriptId`, else → `(app_id, name)` lookup). Same engine
|
||||||
|
instance and `Services` are reused; only the per-call `SdkCallCx`
|
||||||
|
changes (callee gets `trigger_depth + 1` and a fresh `execution_id`,
|
||||||
|
inherits the caller's principal and `root_execution_id`).
|
||||||
|
- **`invoke_async(target, args)`** — fire-and-forget. Writes an
|
||||||
|
`OutboxSourceKind::Invoke` row the dispatcher fires once (no retry
|
||||||
|
loop — callers who want retries wrap in `retry::run`). Returns the
|
||||||
|
new `ExecutionId` as a string for caller tracking.
|
||||||
|
- **Cross-app invokes are rejected** at the `InvokeService::resolve`
|
||||||
|
layer with `InvokeError::CrossApp` ("invoke: target script belongs
|
||||||
|
to a different app"). v1.1.x maintains strict isolation; cross-app
|
||||||
|
sharing arrives in v1.3+.
|
||||||
|
- **Depth-bound**: `cx.trigger_depth + 1 > limits.trigger_depth_max`
|
||||||
|
throws "invoke: depth limit exceeded (max N)" — shared with the
|
||||||
|
trigger fan-out cap so a misbehaving recursive script can't blow
|
||||||
|
the stack.
|
||||||
|
- FnPtr / closures in `args` are rejected (closures don't survive
|
||||||
|
re-entry into a fresh `SdkCallCx`).
|
||||||
|
|
||||||
|
### Added — `retry::*` SDK (caller-controlled retry)
|
||||||
|
|
||||||
|
- **`retry::policy(opts)`** — constructs a `Policy` value (custom Rhai
|
||||||
|
type). Validates + clamps: `max_attempts ∈ [1, 20]`,
|
||||||
|
`base_ms ∈ [1, 60_000]`, `jitter_pct ∈ [0, 100]`,
|
||||||
|
`backoff ∈ {"exponential" | "linear" | "constant"}`.
|
||||||
|
- **`retry::run(policy, closure)`** — calls `closure()`; on throw,
|
||||||
|
sleeps per the policy and re-invokes; after `max_attempts`
|
||||||
|
exhausted, throws the last error. Sleeps via `tokio::time::sleep`
|
||||||
|
through `TokioHandle::block_on` — the bridge is already inside the
|
||||||
|
caller's `spawn_blocking` thread.
|
||||||
|
- **`retry::on_codes(policy, codes_array)`** — returns a new policy
|
||||||
|
with an error-string filter. When non-empty, only throws containing
|
||||||
|
one of the codes retry; others surface immediately.
|
||||||
|
- **NOTE on the brief deviation**: the brief asked for `retry::with`,
|
||||||
|
but both `with` AND `call` are Rhai reserved keywords (rejected at
|
||||||
|
the parser, before registration could matter). Shipped as
|
||||||
|
`retry::run(policy, closure)` instead. Documented in HANDBACK §7.
|
||||||
|
|
||||||
|
### Added — Admin HTTP endpoints
|
||||||
|
|
||||||
|
- `POST /api/v1/admin/apps/{id}/triggers/queue` — create a
|
||||||
|
`queue:receive` trigger. Body:
|
||||||
|
`{ script_id, queue_name, visibility_timeout_secs?, dispatch_mode?,
|
||||||
|
retry_max_attempts?, retry_backoff?, retry_base_ms? }`.
|
||||||
|
Capability: `AppManageTriggers`.
|
||||||
|
- `GET /api/v1/admin/apps/{id}/queues` — list every distinct queue
|
||||||
|
name in the app with `(total, pending, claimed)` counts.
|
||||||
|
Capability: `AppLogRead`.
|
||||||
|
- `GET /api/v1/admin/apps/{id}/queues/{queue_name}` — drill-down:
|
||||||
|
stats + registered consumer trigger (if any). Capability:
|
||||||
|
`AppLogRead`.
|
||||||
|
- No mutating queue endpoints (purge / requeue / delete-message stays
|
||||||
|
at v1.2).
|
||||||
|
|
||||||
|
### Added — Dashboard (v0.15.0)
|
||||||
|
|
||||||
|
- New `/apps/{slug}/queues` read-only list view (queue name + counts +
|
||||||
|
drilldown link) and `/apps/{slug}/queues/{name}` drill-down (depth +
|
||||||
|
registered consumer + visibility timeout + last_fired_at).
|
||||||
|
- "Queues" link added to the app-level nav between Files and Dead
|
||||||
|
letters.
|
||||||
|
- Triggers tab gains a "Queue:receive trigger (v1.1.9)" form alongside
|
||||||
|
cron / pubsub / email. Trigger list renders queue triggers with the
|
||||||
|
queue name + visibility timeout + last_fired_at.
|
||||||
|
- TypeScript types: `TriggerKind` gains `'queue'`; `TriggerDetails`
|
||||||
|
gains the `Queue` variant. `CreateQueueTriggerInput`,
|
||||||
|
`QueueSummary`, `QueueConsumer`, `QueueDetail` exported.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `Services::new` signature gains `queue: Arc<dyn QueueService>` and
|
||||||
|
`invoke: Arc<dyn InvokeService>` positionally after `users` (mirrors
|
||||||
|
v1.1.8's positional append of `users`). `with_noop_services` updated.
|
||||||
|
- `Limits` (`crates/executor-core/src/sandbox.rs`) gains
|
||||||
|
`trigger_depth_max: u32` (default 8). The picloud binary syncs it
|
||||||
|
from `TriggerConfig::from_env().max_trigger_depth` so the
|
||||||
|
dispatcher's trigger-depth cap and the invoke depth-bound stay
|
||||||
|
aligned (env var: `PICLOUD_MAX_TRIGGER_DEPTH`).
|
||||||
|
- `Engine` gains `set_self_weak(Weak<Engine>)` + `self_arc()`. The
|
||||||
|
picloud binary calls `engine.set_self_weak(Arc::downgrade(&engine))`
|
||||||
|
right after construction so the invoke bridge can re-enter the
|
||||||
|
engine synchronously. `Weak` so the Arc-cycle stays loose.
|
||||||
|
- `register_all` gains two parameters: `limits: Limits` and
|
||||||
|
`self_engine: Option<Arc<Engine>>`. The invoke bridge surfaces a
|
||||||
|
clear error if `self_engine` is `None` (only in harnesses that don't
|
||||||
|
wire it).
|
||||||
|
- `OutboxSourceKind` gains `Invoke`; `TriggerKind` gains `Queue`;
|
||||||
|
`TriggerDetails` gains the `Queue` variant; `TriggerEvent` gains the
|
||||||
|
`Queue` variant with `source = "queue"` discriminant.
|
||||||
|
- `ScriptRepository` gains `get_by_name(app_id, name)` for the
|
||||||
|
`invoke()` name resolution path.
|
||||||
|
- SDK schema 1.9 → 1.10.
|
||||||
|
|
||||||
|
### Migrations
|
||||||
|
|
||||||
|
- `0034_queue_messages.sql` — the `queue_messages` table with three
|
||||||
|
indexes: a partial `(app_id, queue_name, enqueued_at) WHERE
|
||||||
|
claim_token IS NULL` for the dispatch hot path, a `(app_id,
|
||||||
|
queue_name)` for the depth queries, and a partial `(claimed_at)
|
||||||
|
WHERE claim_token IS NOT NULL` for the reclaim scan.
|
||||||
|
- `0035_queue_triggers.sql` — widens `triggers.kind` to admit
|
||||||
|
`'queue'`, widens `outbox.source_kind` to admit `'invoke'`, adds the
|
||||||
|
`queue_trigger_details` table.
|
||||||
|
|
||||||
|
### F1–F5 follow-ups (carry-forward from v1.1.8)
|
||||||
|
|
||||||
|
- **F1 — integration test density**: four new DB-gated test binaries
|
||||||
|
(`crates/picloud/tests/queue_e2e.rs` ×4, `…/invoke_e2e.rs` ×4,
|
||||||
|
`…/retry_e2e.rs` ×3, `crates/manager-core/tests/migration_queue_messages.rs`
|
||||||
|
×4) + 50+ net new inline unit tests. All skip cleanly when
|
||||||
|
`DATABASE_URL` is unset; all pass against the docker-compose
|
||||||
|
postgres on `localhost:15432`.
|
||||||
|
- **F2 — schema snapshot re-blessed** via
|
||||||
|
`BLESS=1 cargo test -p picloud-manager-core --test schema_snapshot
|
||||||
|
-- --include-ignored`. Delta matches the plan exactly (no unrelated
|
||||||
|
drift).
|
||||||
|
- **F3 — fmt + clippy attestation as literal output**: see HANDBACK §8.
|
||||||
|
- **F4 — `TIMING_FLAT_DUMMY_HASH` dedup**: inline copy in
|
||||||
|
`crates/manager-core/src/auth_api.rs::login` now references the
|
||||||
|
shared `crate::auth::TIMING_FLAT_DUMMY_HASH` const. `grep -rn` for
|
||||||
|
the PHC literal returns exactly one hit.
|
||||||
|
- **F5 — clippy without cold cache**: incremental cache used; HANDBACK
|
||||||
|
§8 notes the explicit choice.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- New env vars:
|
||||||
|
- `PICLOUD_QUEUE_RECLAIM_INTERVAL_MS` (default 30000) — visibility-
|
||||||
|
timeout reclaim cadence.
|
||||||
|
- `PICLOUD_QUEUE_DEFAULT_VISIBILITY_TIMEOUT_SECS` (default 30) —
|
||||||
|
visibility timeout fallback when the trigger create request omits
|
||||||
|
one. Per-trigger column overrides this.
|
||||||
|
- SDK schema bumped to 1.10.
|
||||||
|
- `@picloud/client` unchanged — no client-library work this release.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.1.8 — User Management (unreleased)
|
||||||
|
|
||||||
|
Per-app data-plane user management — a `users::*` SDK for scripts
|
||||||
|
plus an admin HTTP surface and dashboard tab — together with three
|
||||||
|
load-bearing follow-ups from v1.1.7 (dropping the plaintext realtime
|
||||||
|
signing-key column, `--all-targets` clippy discipline, and adding a
|
||||||
|
session-based realtime SSE auth mode).
|
||||||
|
|
||||||
|
**UPGRADE PATH IS 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 (and let its startup encryption sweep
|
||||||
|
complete) before applying v1.1.8 — migration 0032's guard refuses
|
||||||
|
to drop the plaintext `realtime_signing_key` column while any row
|
||||||
|
still needs encryption.
|
||||||
|
|
||||||
|
### Added — `users::*` SDK (data-plane user management)
|
||||||
|
|
||||||
|
- **`users::create(#{...})` / `users::get(id)` / `users::find_by_email(...)`
|
||||||
|
/ `users::update(id, #{...})` / `users::delete(id)` /
|
||||||
|
`users::list(#{ "$limit", cursor })`** — CRUD on per-app end-users
|
||||||
|
(distinct from the control-plane `admin_users` table). Identity tuple
|
||||||
|
is `(app_id, user_id)`; uniqueness is on `(app_id, lower(email))` so
|
||||||
|
the same email can exist across two apps but not twice within one.
|
||||||
|
Password hash is Argon2id PHC (same algorithm as `admin_users`);
|
||||||
|
the public AppUser record never carries the hash.
|
||||||
|
- **`users::login(email, password)`** returns a session token string,
|
||||||
|
or `()` on bad credentials. The bad-email and bad-password branches
|
||||||
|
share wall-clock cost via a timing-flat dummy Argon2id verify on
|
||||||
|
email miss. **Required for security**, not polish.
|
||||||
|
- **`users::verify(token)`** returns the user on success (bumping the
|
||||||
|
sliding TTL), `()` on missing / expired / revoked / cross-app.
|
||||||
|
- **`users::logout(token)`** revokes the session row.
|
||||||
|
- `migrations/0026_app_users.sql` + `0027_app_user_sessions.sql`.
|
||||||
|
|
||||||
|
### Added — Sessions
|
||||||
|
|
||||||
|
- `app_user_sessions` table with SHA-256 token hash, sliding TTL
|
||||||
|
(`PICLOUD_APP_USER_SESSION_TTL_HOURS`, default 24h), hard cap
|
||||||
|
(`PICLOUD_APP_USER_SESSION_ABSOLUTE_HOURS`, default 720h = 30d),
|
||||||
|
and explicit `revoked_at` for logout / admin revoke-all / password-
|
||||||
|
reset side-effects. Lookups reject revoked/expired rows
|
||||||
|
immediately, before the weekly GC sweep runs.
|
||||||
|
|
||||||
|
### Added — Email verification
|
||||||
|
|
||||||
|
- **`users::send_verification_email(user_id, #{ link_base, from,
|
||||||
|
subject, body_template })`** mints a 32-byte token, stores its
|
||||||
|
SHA-256 in `app_user_email_verifications`, and dispatches via
|
||||||
|
`email::send` with `{link}` in the body substituted for
|
||||||
|
`link_base?token=<raw>`. `EmailError::NotConfigured` propagates as
|
||||||
|
`UsersError::EmailNotConfigured` so scripts already handling the
|
||||||
|
v1.1.7 email-disabled mode don't need new branches.
|
||||||
|
- **`users::verify_email(token)`** atomically consumes the one-shot
|
||||||
|
token, sets `email_verified_at = NOW()`, emits
|
||||||
|
`users::email_verified`.
|
||||||
|
- `migrations/0028_app_user_email_verifications.sql`.
|
||||||
|
|
||||||
|
### Added — Password reset
|
||||||
|
|
||||||
|
- **`users::request_password_reset(email, #{ template })`** returns
|
||||||
|
`Ok(())` regardless of whether the email matched — no
|
||||||
|
existence-leak signal in script-land. Email-not-configured does
|
||||||
|
surface (operators need to know); other email failures are silently
|
||||||
|
swallowed and logged server-side.
|
||||||
|
- **`users::complete_password_reset(token, new_password)`** atomically
|
||||||
|
consumes the token, updates the Argon2id hash, and **revokes every
|
||||||
|
active session** for the user (stale tokens shouldn't ride out the
|
||||||
|
reset). TTL `PICLOUD_APP_USER_PASSWORD_RESET_TTL_HOURS` (default 1h).
|
||||||
|
- `migrations/0029_app_user_password_resets.sql`.
|
||||||
|
|
||||||
|
### Added — Invitations
|
||||||
|
|
||||||
|
- **`users::invite(email, #{ template?, display_name?, roles? })`**
|
||||||
|
gated on `AppUsersAdmin`. Omitting the template skips the email
|
||||||
|
send so an admin can issue invitations for out-of-band delivery.
|
||||||
|
- **`users::accept_invite(token, password, display_name?)`** atomically
|
||||||
|
consumes the invitation, creates the user with pre-staged roles
|
||||||
|
applied, and mints a fresh session — caller can return both the
|
||||||
|
user and the session token in one round trip. TTL
|
||||||
|
`PICLOUD_APP_USER_INVITATION_TTL_DAYS` (default 7d).
|
||||||
|
- `migrations/0030_app_user_invitations.sql`.
|
||||||
|
|
||||||
|
### Added — Per-app roles
|
||||||
|
|
||||||
|
- **`users::add_role(id, role)` / `users::remove_role(id, role)` /
|
||||||
|
`users::has_role(id, role)`** — string-tagged, per-app. The script
|
||||||
|
app decides what `"admin"` / `"editor"` / `"viewer"` mean. Stored
|
||||||
|
in `app_user_roles` (composite PK so `add` is idempotent).
|
||||||
|
- The full `AppUser` record returned by every `users::*` getter now
|
||||||
|
carries a `roles` array.
|
||||||
|
- `migrations/0031_app_user_roles.sql`.
|
||||||
|
- **Role permission matrices / hierarchy / role registry are
|
||||||
|
explicitly v1.2 work** — v1.1.8 does not pre-commit to a model
|
||||||
|
that would lock in a wrong API.
|
||||||
|
|
||||||
|
### Added — Admin HTTP surface
|
||||||
|
|
||||||
|
- `GET / POST / PATCH / DELETE /api/v1/admin/apps/{id}/users` and
|
||||||
|
`/users/{user_id}` — list, get, create, update, delete.
|
||||||
|
- `POST /users/{user_id}/reset-password` — returns a one-shot reset
|
||||||
|
token in the response body (TTL 1h). No email is sent — that's
|
||||||
|
the SDK's `users::request_password_reset`.
|
||||||
|
- `POST /users/{user_id}/revoke-sessions` — bulk revoke for that
|
||||||
|
user.
|
||||||
|
- `GET / POST / DELETE /api/v1/admin/apps/{id}/invitations` and
|
||||||
|
`/invitations/{invite_id}` — list, create, revoke pending.
|
||||||
|
- Capability gating: `AppUsersRead` for reads, `AppUsersWrite` for
|
||||||
|
CRUD, `AppUsersAdmin` for admin-mediated verbs + invitations. All
|
||||||
|
three map to existing scopes (`script:read` / `script:write`) —
|
||||||
|
**no new scope** (seven-scope commitment).
|
||||||
|
- DTOs never include password hashes, session tokens, or unrotated
|
||||||
|
reset tokens.
|
||||||
|
|
||||||
|
### Added — Dashboard Users tab
|
||||||
|
|
||||||
|
- `apps/[slug]/users/+page.svelte` — list, create form, edit modal,
|
||||||
|
revoke-sessions, reset-password (one-shot token displayed once),
|
||||||
|
delete. Sub-route `users/invitations/+page.svelte` for pending
|
||||||
|
invitations (list, create with optional inline email template,
|
||||||
|
revoke).
|
||||||
|
- The main app-detail tab strip gains a Users entry above Files.
|
||||||
|
- `@picloud/dashboard` 0.13.0 → 0.14.0.
|
||||||
|
|
||||||
|
### Changed — Realtime: drop plaintext signing-key column (F1)
|
||||||
|
|
||||||
|
- `migrations/0032_drop_plaintext_realtime_signing_key.sql` —
|
||||||
|
guard query + `ALTER TABLE app_secrets DROP COLUMN IF EXISTS
|
||||||
|
realtime_signing_key`. The guard refuses to apply if any row still
|
||||||
|
has plaintext but no encrypted counterpart.
|
||||||
|
- v1.1.7's `migrate_plaintext_keys` startup task and its call from
|
||||||
|
`build_app` are deleted; the read path now consults only the
|
||||||
|
encrypted columns.
|
||||||
|
|
||||||
|
### Changed — Realtime: `auth_mode = 'session'` (F3)
|
||||||
|
|
||||||
|
- `migrations/0033_topics_auth_mode_session.sql` widens the
|
||||||
|
`topics_auth_mode_check` CHECK to allow `'session'` alongside
|
||||||
|
`'public'` and `'token'`.
|
||||||
|
- `RealtimeAuthorityImpl` gains a `UsersService` dependency; the
|
||||||
|
Session branch of `authorize_subscribe` delegates to
|
||||||
|
`verify_session_for_realtime(app_id, token)` (same DB lookup +
|
||||||
|
sliding-TTL bump as the SDK's `users::verify`, but app_id is
|
||||||
|
taken from the topic row, not a script).
|
||||||
|
- Dashboard Topics tab gains `session` as a third radio option.
|
||||||
|
- `token` (HMAC) validator is unchanged; both coexist per topic.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- New env vars (all 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)
|
||||||
|
- SDK schema 1.8 → 1.9 (additive surface: `users::*` + `session`
|
||||||
|
topic auth mode).
|
||||||
|
- Workspace version 1.1.7 → 1.1.8.
|
||||||
|
- `@picloud/client` does NOT bump in v1.1.8 — its `auth.login` /
|
||||||
|
`auth.logout` helpers already call dev-defined endpoints;
|
||||||
|
nothing to change in the client.
|
||||||
|
- New weekly GC sweep (`spawn_app_user_token_gc`) prunes expired /
|
||||||
|
revoked / consumed rows across the four new token tables.
|
||||||
|
|
||||||
|
## v1.1.7 — Configuration & Email (unreleased)
|
||||||
|
|
||||||
|
The operational-config layer: **encrypted per-app secrets**, **outbound
|
||||||
|
email**, and an **inbound email trigger** — plus the long-missing
|
||||||
|
**dead-letter handler wiring** and **at-rest encryption of the realtime
|
||||||
|
signing key**. All at-rest encryption uses a single process master key
|
||||||
|
(AES-256-GCM); key rotation is deferred to v1.2.
|
||||||
|
|
||||||
|
### Added — Encryption infrastructure
|
||||||
|
|
||||||
|
- **Process master key** from `PICLOUD_SECRET_KEY` (base64 of exactly 32
|
||||||
|
bytes). REQUIRED at startup — an unset or malformed key is fatal.
|
||||||
|
Generate one with `openssl rand -base64 32`. A deterministic in-memory
|
||||||
|
dev key is used ONLY when `PICLOUD_SECRET_KEY` is unset AND
|
||||||
|
`PICLOUD_DEV_MODE=true` (with a prominent startup warning); there is no
|
||||||
|
quiet unencrypted mode.
|
||||||
|
- **`picloud_shared::crypto`** — `encrypt`/`decrypt` envelope:
|
||||||
|
`Aes256Gcm`, 96-bit CSPRNG nonce, 128-bit auth tag appended to the
|
||||||
|
ciphertext (RustCrypto `Aead` layout). Both ciphertext and nonce are
|
||||||
|
stored.
|
||||||
|
- **Key rotation is out of scope.** Changing `PICLOUD_SECRET_KEY` between
|
||||||
|
deploys renders all existing ciphertext undecryptable. v1.2+ adds
|
||||||
|
key-version columns + a re-encryption pass.
|
||||||
|
|
||||||
|
### Added — Encrypted per-app secrets
|
||||||
|
|
||||||
|
- **`secrets::{get,set,delete,list}(name)`** SDK — collection-less,
|
||||||
|
per-app. `set` accepts a String/Map/Array (JSON-encoded then encrypted);
|
||||||
|
`get` returns the same Rhai type back; missing → `()`. 64 KB plaintext
|
||||||
|
cap (`PICLOUD_SECRET_MAX_VALUE_BYTES`). `migrations/0023_secrets.sql`.
|
||||||
|
- **Admin API** `GET/POST/DELETE /api/v1/admin/apps/{id}/secrets` — list
|
||||||
|
returns names + `updated_at` only, **never values**.
|
||||||
|
- **Dashboard Secrets tab** — list names + last-modified, create/update
|
||||||
|
(masked value with a confirm-gated reveal), delete with confirm.
|
||||||
|
- `Capability::AppSecretsRead`/`Write` (→ `script:read` / `script:write`).
|
||||||
|
No new Scope variants (seven-scope commitment). Secret writes
|
||||||
|
deliberately do **not** emit trigger events.
|
||||||
|
|
||||||
|
### Added — Outbound email
|
||||||
|
|
||||||
|
- **`email::send` / `email::send_html`** SDK over an SMTP relay
|
||||||
|
(`lettre`). Config 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). Required `to`/`from`/`subject` + one of `text`/`html`;
|
||||||
|
RFC 5322-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); per-app `from`
|
||||||
|
validation / SPF / DKIM are the operator's SMTP-relay concern.
|
||||||
|
- `Capability::AppEmailSend` (→ `script:write`).
|
||||||
|
|
||||||
|
### Added — Inbound email (`email:receive` trigger)
|
||||||
|
|
||||||
|
- **Webhook receiver** `POST /api/v1/email-inbound/{app_id}/{trigger_id}`
|
||||||
|
— a provider (Mailgun / Postmark / SendGrid / SES) POSTs the generic
|
||||||
|
JSON shape `{from,to[],cc[],subject,text,html,message_id}`; the
|
||||||
|
receiver verifies the optional HMAC signature, normalizes to
|
||||||
|
`TriggerEvent::Email`, and enqueues an outbox row. 202 accepted, 401
|
||||||
|
bad/missing signature, 404 missing/wrong-kind/cross-app, 422 malformed.
|
||||||
|
Handlers see `ctx.event.email`. `migrations/0024_email_triggers.sql`.
|
||||||
|
- **Admin** `POST /api/v1/admin/apps/{id}/triggers/email` +
|
||||||
|
dashboard form (with the webhook URL + expected payload). The HMAC
|
||||||
|
`inbound_secret` is stored **encrypted** via the master key (deviation
|
||||||
|
from the original plaintext design — see HANDBACK §7).
|
||||||
|
- Provider-specific payload unmarshallers + inbound attachments → v1.2.
|
||||||
|
Native SMTP listener → v1.3+.
|
||||||
|
|
||||||
|
### Security/correctness fix (retroactive) — dead_letter handlers
|
||||||
|
|
||||||
|
The `dead_letter` trigger kind has been registerable since v1.1.1 but,
|
||||||
|
due to missing dispatcher wiring (`list_matching_dead_letter` had no
|
||||||
|
production caller), handlers have **never fired**. Any deploy running
|
||||||
|
v1.1.1 through v1.1.6 with `dead_letter` triggers configured has had
|
||||||
|
silently non-functional handlers. v1.1.7 fixes the wiring; existing
|
||||||
|
`dead_letters` rows remain (no migration needed) but only NEW
|
||||||
|
dead-letter events (post-v1.1.7) trigger handlers. To process older
|
||||||
|
rows, use the existing admin replay surface to re-enqueue them.
|
||||||
|
|
||||||
|
### Changed — Realtime signing key encrypted at rest (two-phase)
|
||||||
|
|
||||||
|
`app_secrets.realtime_signing_key` was stored as 32 plaintext bytes. It
|
||||||
|
is now encrypted with the master key. `migrations/0025_encrypt_realtime_keys.sql`
|
||||||
|
adds NULL-able encrypted columns and drops `NOT NULL` on the plaintext
|
||||||
|
column; a startup task encrypts pre-existing rows; the read path prefers
|
||||||
|
the encrypted columns and falls back to plaintext during the compat
|
||||||
|
window. **v1.1.8 will drop the plaintext `realtime_signing_key`
|
||||||
|
column** — operators should upgrade through v1.1.7 (which performs the
|
||||||
|
encryption) before v1.1.8.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- **New deps:** `aes-gcm` (RustCrypto AEAD), `lettre` (SMTP).
|
||||||
|
- **New env vars:** `PICLOUD_SECRET_KEY` (required), `PICLOUD_DEV_MODE`,
|
||||||
|
`PICLOUD_SECRET_MAX_VALUE_BYTES`, `PICLOUD_SMTP_HOST/PORT/USER/PASSWORD/
|
||||||
|
TLS/TIMEOUT_SECS`, `PICLOUD_EMAIL_MAX_MESSAGE_BYTES`.
|
||||||
|
- **SDK schema** 1.7 → 1.8; **dashboard** 0.12.0 → 0.13.0.
|
||||||
|
|
||||||
## v1.1.6 — Realtime Channels & Client Library (unreleased)
|
## v1.1.6 — Realtime Channels & Client Library (unreleased)
|
||||||
|
|
||||||
The first **external realtime surface** and the first **frontend
|
The first **external realtime surface** and the first **frontend
|
||||||
|
|||||||
584
Cargo.lock
generated
584
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
13
Cargo.toml
13
Cargo.toml
@@ -13,7 +13,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.1.6"
|
version = "1.1.9"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.92"
|
rust-version = "1.92"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
@@ -53,8 +53,10 @@ chrono = { version = "0.4", features = ["serde"] }
|
|||||||
chrono-tz = "0.9"
|
chrono-tz = "0.9"
|
||||||
cron = "0.12"
|
cron = "0.12"
|
||||||
|
|
||||||
# Async traits
|
# Async traits + bounded-concurrency stream utilities (queue dispatcher
|
||||||
|
# uses `for_each_concurrent`).
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
futures = "0.3"
|
||||||
|
|
||||||
# Rhai scripting. Pinned exactly (`=1.24`) because the `internals`
|
# Rhai scripting. Pinned exactly (`=1.24`) because the `internals`
|
||||||
# feature surface is not semver-stable — future bumps must be deliberate.
|
# feature surface is not semver-stable — future bumps must be deliberate.
|
||||||
@@ -81,6 +83,13 @@ sha2 = "0.10"
|
|||||||
hmac = "0.12"
|
hmac = "0.12"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
data-encoding = "2.6"
|
data-encoding = "2.6"
|
||||||
|
# AES-256-GCM at-rest encryption for per-app secrets + the realtime
|
||||||
|
# signing key (v1.1.7). Audited, pure-Rust RustCrypto AEAD.
|
||||||
|
aes-gcm = { version = "0.10", features = ["aes", "alloc"] }
|
||||||
|
|
||||||
|
# Outbound SMTP email (v1.1.7). Async transport over the Tokio runtime
|
||||||
|
# with rustls TLS; built messages for text + multipart-alternative.
|
||||||
|
lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "tokio1-rustls-tls", "builder", "hostname"] }
|
||||||
|
|
||||||
# Stdlib utility crates (v1.1.0 stdlib PR — registered into the
|
# Stdlib utility crates (v1.1.0 stdlib PR — registered into the
|
||||||
# Rhai engine as the regex::/random::/etc. namespaces)
|
# Rhai engine as the regex::/random::/etc. namespaces)
|
||||||
|
|||||||
138
E2E_STASH_REPORT.md
Normal file
138
E2E_STASH_REPORT.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# E2E Test #2 — "Stash" (paste + file-drop) + security matrix
|
||||||
|
|
||||||
|
**Date:** 2026-06-13 · **Build:** v1.1.9 · **Instance:** host `picloud` on `127.0.0.1:8099`
|
||||||
|
(dev mode, `PICLOUD_FILES_MAX_FILE_SIZE_BYTES=1048576`, SSRF guard on, SMTP unset), Postgres in
|
||||||
|
Docker. Driven entirely through the `pic` CLI; data-plane traffic via `curl`.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Second end-to-end pass. The first test (To-Do, `E2E_TODO_REPORT.md`) covered users/docs/pubsub/
|
||||||
|
cron/routes/domains/topics. This one builds a **new** app to exercise the *untested* half of the
|
||||||
|
platform — **kv, files, queue, invoke, secrets, api-keys, dead-letters, async + two-param routes**
|
||||||
|
— and then runs a **10-probe security matrix**. Findings: feature/CLI gaps + security verdicts.
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
**Every feature worked** end-to-end, and **every security control held except one Low-severity
|
||||||
|
gap** (reserved-path validation is case-sensitive). Highlights of what's *good*: cross-app
|
||||||
|
isolation is airtight, the SSRF guard blocks cloud-metadata, size/op caps fire before authz, and
|
||||||
|
internal script errors are scrubbed from responses with a correlation id. Two notable
|
||||||
|
**observability/ergonomics gaps** (trigger executions invisible to `pic logs`; the `.trim()`/`.replace()`
|
||||||
|
return-`()` footgun) and the expected CLI-coverage gaps round it out.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The app — "Stash" (built CLI-only, no raw admin curl)
|
||||||
|
|
||||||
|
A pastebin with file attachments and a background word-count worker. 9 scripts, 6 routes, a queue
|
||||||
|
consumer trigger, a dead-letter trigger, 2 API keys, 1 secret — all via `pic`.
|
||||||
|
|
||||||
|
| Feature exercised | How | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| **kv** | paste store, hit counter, `/stats` aggregates, event log | ✅ get/set/list round-trip |
|
||||||
|
| **files** | `files::create` from base64 blob + `get`/`head`, two-param download route | ✅ bytes round-trip (`"hello attachment bytes"`, size 22) |
|
||||||
|
| **queue** | `queue::enqueue("ingest")` + `create-from-json --kind queue` consumer | ✅ worker fired |
|
||||||
|
| **invoke** | worker → `invoke("analyzer", #{text})` function-to-function | ✅ returns word count |
|
||||||
|
| **secrets** | `pic secrets set` (stdin) + `secrets::get` to gate `/stats` | ✅ 401 w/o token, 200 with |
|
||||||
|
| **async route** | `routes create --dispatch async` for `POST /events` | ✅ **202** + execution_id |
|
||||||
|
| **dead-letters** | poison job → 3 attempts → DL row; `pic dead-letters replay` | ✅ captured + replayed (+9 words) |
|
||||||
|
| **api-keys** | `pic api-keys mint --scope … [--app …]` | ✅ minted, scopes enforced (below) |
|
||||||
|
| **two-param route** | `/pastes/:code/files/:fid` | ✅ both params captured |
|
||||||
|
|
||||||
|
End-to-end stats after the run: `{total_pastes: 2, total_words: 13}` — proving queue → worker →
|
||||||
|
invoke → kv all fired on real Postgres rows + on-disk file bytes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security matrix (10 probes, all reproduced live)
|
||||||
|
|
||||||
|
| # | Probe | Verdict | Evidence |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **S1** | Cross-app isolation | ✅ HOLDS | A script in app `evil` doing `kv…list()` / `secrets::get("admin-token")` / `files…list()` returned `{paste_keys:[], attachment_count:0, stolen_secret:null, my_secret_names:[]}`. `app_id` derives from `cx.app_id`; no SDK call takes a script-passed app_id. |
|
||||||
|
| **S2** | Secret confidentiality | ✅ HOLDS | `pic secrets ls` shows names only; the plaintext `sup3r-s3cret-admin` does **not** appear anywhere in the server log even though `/stats` reads it each request. |
|
||||||
|
| **S3** | API-key scope + app-binding | ✅ HOLDS | `script:read` key → **403** on `POST /apps` and `GET /admins`, **200** on in-scope `GET /scripts`. `app:admin` key bound to `stash` → **403** on app `evil`. `instance:admin` + `--app` → **422**. Unknown scope `bogus:scope` → rejected by CLI. |
|
||||||
|
| **S4** | Sandbox op-budget | ✅ HOLDS | `loop {}` → **HTTP 507 "execution exceeded operation budget"** in **0.30 s**; `/healthz` still `ok` (no hang). |
|
||||||
|
| **S5** | Value/size caps | ✅ HOLDS | Oversized kv write rejected (HTTP 502). Notably the detail (`"Length of string too large"`) was **scrubbed from the response** and logged with `correlation_id` — good error hygiene. Caps are checked before authz, so anonymous callers can't DoS. |
|
||||||
|
| **S6** | Reserved-path prefixes | ⚠️ **GAP (Low)** | `/api/x`, `/admin/x`, `/healthz`, `/version` → correctly **422**. But case variants `/API/v2/x`, `/Admin/x`, `/HEALTHZ` were **accepted**. See finding below. |
|
||||||
|
| **S7** | Files path traversal | ✅ HOLDS | `files::collection("../../../etc")` → `"invalid collection name: must not contain '/', '\\', '..', or NUL"`; a non-UUID file id → not found. No FS escape. |
|
||||||
|
| **S8** | docs filter injection | ✅ HOLDS | `docs::find(#{ "x'); DROP TABLE docs;--": 1 })` → ran safely, `count=0`; the `docs` table still exists (`to_regclass` = true). Values/paths are bound as `$N` params. |
|
||||||
|
| **S9** | SSRF via `http::` | ✅ HOLDS | `http::get("http://127.0.0.1:8099/healthz")` → `"blocked by SSRF policy: loopback"`; `http://169.254.169.254/…` (cloud metadata) → `"blocked by SSRF policy: link-local"`. |
|
||||||
|
| **S10** | Anonymous data-plane access | ℹ️ By design (caveat) | Every Stash route is public (no auth) yet freely uses kv/secrets/files/queue. `script_gate` returns `Ok` when `principal.is_none()` — the *script* is the gate. Correct per design, but a sharp threat-model edge (see note). |
|
||||||
|
|
||||||
|
### S6 — reserved-path validation is case-sensitive (Low)
|
||||||
|
**What:** route-creation rejects the exact reserved prefixes but accepts case variants:
|
||||||
|
```
|
||||||
|
pic routes create --path /api/x -> 422 "path '/api/x' is reserved"
|
||||||
|
pic routes create --path /API/v2/x -> Created route … (GET * /API/v2/x) # accepted
|
||||||
|
pic routes create --path /HEALTHZ -> Created route … # accepted
|
||||||
|
pic routes create --path /Admin/x -> Created route … # accepted
|
||||||
|
```
|
||||||
|
**Current impact — Low, *not* a full bypass:** path matching is case-*sensitive*, so a request to
|
||||||
|
the real lowercase `/api/v2/x` still 404s (the system namespace is safe); only the exact
|
||||||
|
mixed-case path serves the script (`GET /API/v2/x` → 200, `/HEALTHZ` → 200). Real `/healthz`
|
||||||
|
still returns `ok` from the top-level handler.
|
||||||
|
**Why it still matters:** (1) inconsistent enforcement of a stated security boundary; (2) lets a
|
||||||
|
tenant publish convincing look-alike paths (`/Admin/login`, `/API/v1/…`) for phishing or log
|
||||||
|
confusion; (3) **fragile** — method matching is already case-insensitive (`matcher.rs:275`); if
|
||||||
|
path matching is ever made case-insensitive too, this instantly becomes a real shadowing bypass
|
||||||
|
of `/api/`, `/admin/`, `/healthz`. The validation should be the durable guard.
|
||||||
|
**Fix:** normalize case before the reserved check in `orchestrator-core/src/routing/pattern.rs`
|
||||||
|
(`check_reserved` ~line 110) — compare `raw.to_ascii_lowercase()` against the reserved list (or
|
||||||
|
reject any case-insensitive match).
|
||||||
|
|
||||||
|
### S10 — anonymous public scripts have full app data-plane access (design caveat)
|
||||||
|
Not a bug, but worth surfacing for users: a *public* (unauthenticated) route's script can read/write
|
||||||
|
**all** of the app's kv, docs, files, secrets, and queue — the platform only gates *authenticated*
|
||||||
|
principals; for anonymous ingress the script itself must enforce access. A developer who assumes
|
||||||
|
"this route is public" ≠ "this code can read every secret in my app" could over-expose data. Worth
|
||||||
|
a prominent doc callout near the SDK auth model.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature / CLI gaps
|
||||||
|
|
||||||
|
- **G1 — trigger executions are invisible to `pic logs`.** After successful queue-worker and
|
||||||
|
analyzer runs, `pic logs <worker>` and `pic logs <analyzer>` were **empty**. Trigger-dispatched
|
||||||
|
executions (queue/cron/dead-letter/invoke) don't surface in the per-script log tail — the only
|
||||||
|
built-in visibility into worker activity is **dead-letters (failures only)** or the script's own
|
||||||
|
side effects. This is a real observability gap for background workloads. *Suggest: include
|
||||||
|
trigger executions in the logs surface, or add a `pic logs --trigger`/events view.*
|
||||||
|
- **G2 — no `pic kv` / `files` / `queues` / `members` commands.** Data-plane stores are
|
||||||
|
script/HTTP-only (no admin CLI to inspect kv/files/queue contents; queues are read-only HTTP).
|
||||||
|
App **membership** (`/apps/{id}/members`) also has no CLI — multi-user app roles can't be managed
|
||||||
|
with `pic`. *(Confirmed absent via `pic <cmd> --help`.)*
|
||||||
|
- **G3 — no per-script sandbox/timeout flags in `pic deploy`/`scripts`.** `timeout_seconds`,
|
||||||
|
`memory_limit_mb`, and sandbox overrides are only settable via the raw scripts API or instance
|
||||||
|
env (`PICLOUD_SANDBOX_MAX_*`). A developer can't cap a single script's runtime from the CLI.
|
||||||
|
- **G4 — uneven trigger wrappers.** Only `kv`, `cron`, `dead-letter` have first-class
|
||||||
|
`pic triggers create-*` wrappers; `docs`/`files`/`pubsub`/`queue`/`email` require hand-built
|
||||||
|
`create-from-json --kind … --body '{…}'`. Works, but the developer must know each body schema.
|
||||||
|
- **G5 — `email::send` unusable in dev.** Returns `"email is not configured: set
|
||||||
|
PICLOUD_SMTP_HOST/USER/PASSWORD"`. Expected (no silent drop), but the email feature can't be
|
||||||
|
exercised at all without an SMTP relay — worth a documented local-dev fake/sink.
|
||||||
|
- **G6 — Rhai `.trim()` returns `()` (in-place mutation footgun).** Same family as `.replace()`
|
||||||
|
(already documented). `let t = text.trim()` sets `t` to unit; my analyzer dead-lettered with
|
||||||
|
`Function not found: split((), …)` until rewritten to `text.trim();` as a statement. *Suggest:
|
||||||
|
extend the existing stdlib footgun note to list `trim`/`to_upper`/`to_lower` etc., not just
|
||||||
|
`replace`.*
|
||||||
|
|
||||||
|
## Things done especially well (no action)
|
||||||
|
- Cross-app isolation has no script-controlled `app_id` anywhere — the boundary is structural.
|
||||||
|
- SSRF guard covers loopback **and** link-local (cloud-metadata `169.254.169.254`), and blocks at
|
||||||
|
every redirect hop.
|
||||||
|
- Size/operation caps are enforced **before** authz, so anonymous public scripts can't DoS the DB.
|
||||||
|
- Script runtime errors are **scrubbed** from the HTTP response and logged with a `correlation_id`
|
||||||
|
— no internal detail leaks to the caller.
|
||||||
|
- `deploy`/`apps create` honor `--output json` (the earlier F1 fix), so the whole build scripts
|
||||||
|
cleanly with captured ids.
|
||||||
|
|
||||||
|
## Reproduction / teardown
|
||||||
|
Scripts under `/tmp/stash/*.rhai`; apps `stash` + `evil` and their data persist in the dev
|
||||||
|
Postgres. Server: `target/debug/picloud` on `:8099` (see Phase 0 env). Teardown: kill the host
|
||||||
|
`picloud`; `docker compose down [-v]`. (Note: the S6 probe left harmless `/API/v2/x`, `/HEALTHZ`,
|
||||||
|
`/Admin/x`, `/Api/y` routes on `stash` — they vanish with the app.)
|
||||||
|
|
||||||
|
## Priority
|
||||||
|
**S6** is the only code-level finding (Low — confirm-and-harden the reserved-path check). **G1**
|
||||||
|
(trigger-execution observability) is the most impactful *developer-experience* gap for anyone
|
||||||
|
running background workers. Everything else is documented-behavior or known CLI-coverage gaps.
|
||||||
261
E2E_TODO_REPORT.md
Normal file
261
E2E_TODO_REPORT.md
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
# E2E Developer Test — Rich To-Do App via the `pic` CLI
|
||||||
|
|
||||||
|
**Date:** 2026-06-12 · **Tester:** manual end-to-end walkthrough · **Build:** v1.1.9
|
||||||
|
(`product 1.1.9`, `sdk 1.10`, `api 1`, `schema 42`) · **Instance:** host-run `picloud`
|
||||||
|
in dev mode on `127.0.0.1:8099`, Postgres via `docker compose up postgres`.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Act as a developer building a real product — a multi-user **To-Do app** (end-user
|
||||||
|
signup/login, per-user todos in document storage, full HTTP CRUD, a nightly cron cleanup,
|
||||||
|
and a realtime activity feed) — **entirely through the `pic` CLI**, and record every place
|
||||||
|
the happy path forced me off the CLI or behaved surprisingly.
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
**The platform can build and run the whole app — every capability exists and works.** The
|
||||||
|
data-plane journey (register → login → create → list → complete → ownership-checked delete →
|
||||||
|
auth rejection) is correct, `users::*` auth is solid, `docs::*` storage works, durable pubsub
|
||||||
|
fans out to an external SSE subscriber, and the cron trigger registers and runs.
|
||||||
|
|
||||||
|
**But a CLI-only developer cannot reach the finish line.** Two control-plane operations have
|
||||||
|
**no `pic` command at all** and forced me to drop to raw `curl` against the admin API — and
|
||||||
|
without the first one, *every* app created through the CLI serves `404` on every route.
|
||||||
|
|
||||||
|
> ## ✅ RE-TEST 2026-06-12 — ALL GAPS CLOSED (verified live)
|
||||||
|
>
|
||||||
|
> After remediation (commits `04a24ea`, `ae2134e`, `7ffbb8d`, `b831138`), I rebuilt the **entire
|
||||||
|
> app a second time CLI-only, with zero raw `curl` against the admin API** (app `todoapp2`,
|
||||||
|
> domain `todos2.local`). Every finding below is now fixed and verified end-to-end. See the
|
||||||
|
> [Re-test verification](#re-test-verification--2026-06-12) section at the bottom for the
|
||||||
|
> per-finding evidence. The original findings are preserved as-is for the record.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## App as built
|
||||||
|
|
||||||
|
| Surface | Implementation |
|
||||||
|
|---|---|
|
||||||
|
| `POST /auth/register` | `register.rhai` → `users::create` |
|
||||||
|
| `POST /auth/login` | `login.rhai` → `users::login` → session token |
|
||||||
|
| `GET /todos` | `todos_list.rhai` → `docs.find({user_id})` |
|
||||||
|
| `POST /todos` | `todos_create.rhai` → `docs.create` + `pubsub::publish_durable("activity", …)` |
|
||||||
|
| `PATCH /todos/:id` | `todos_update.rhai` → ownership check + complete + publish |
|
||||||
|
| `DELETE /todos/:id` | `todos_delete.rhai` → ownership check + delete |
|
||||||
|
| cron `0 0 3 * * *` | `cleanup.rhai` → purge completed todos older than 7d |
|
||||||
|
| realtime | SSE `GET /realtime/topics/activity` |
|
||||||
|
|
||||||
|
7 scripts, 6 routes, 1 cron trigger, 1 app, 1 domain claim, 1 pubsub topic. Sources in
|
||||||
|
`/tmp/todo-app/*.rhai`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BLOCKERS — no CLI path; app cannot serve traffic without raw API
|
||||||
|
|
||||||
|
### B1. No domain-claim command — every CLI-created app 404s until you `curl`
|
||||||
|
**Severity: BLOCKER (highest-impact finding).**
|
||||||
|
|
||||||
|
After `pic apps create`, deploying scripts, and binding routes, *every* request 404'd:
|
||||||
|
```
|
||||||
|
$ curl -s -X POST localhost:8099/auth/register -d '{...}'
|
||||||
|
{"error":"no route matches POST /auth/register"}
|
||||||
|
```
|
||||||
|
Reason: Host→app dispatch resolves `localhost` to the **default** app; a non-default app's
|
||||||
|
routes are unreachable until the app claims a domain. But `pic apps` exposes only
|
||||||
|
`ls / create / show / delete` — **no domain subcommand** — and there is no top-level
|
||||||
|
`pic domains` either. The only way through is the raw admin API:
|
||||||
|
```
|
||||||
|
curl -X POST localhost:8099/api/v1/admin/apps/$APP/domains \
|
||||||
|
-H "authorization: Bearer $TOKEN" -d '{"pattern":"todos.local"}'
|
||||||
|
```
|
||||||
|
After that, routing works (with `Host: todos.local`). **A developer who only knows the CLI is
|
||||||
|
hard-stuck here** — they have created scripts and routes that silently never match, with no CLI
|
||||||
|
affordance explaining why. *Fix: add `pic apps domains {ls,add,rm}` (the API already exists in
|
||||||
|
`apps_api.rs`).*
|
||||||
|
|
||||||
|
### B2. No pubsub topic-registration command — realtime feed needs raw API
|
||||||
|
**Severity: BLOCKER for the realtime requirement.**
|
||||||
|
|
||||||
|
Scripts can `pubsub::publish_durable("activity", …)` fine (publishing needs no pre-registration),
|
||||||
|
but an **external** SSE subscriber on `/realtime/topics/activity` only receives events once the
|
||||||
|
topic is registered as `external_subscribable`. There is no `pic topics` command. Worse, the
|
||||||
|
`pic triggers` help text actively points you at `create-from-json` "for kinds the CLI doesn't
|
||||||
|
expose … (docs/files/pubsub/…)" — but that creates a *pubsub trigger* (run a script on publish),
|
||||||
|
which is a different concept and does **not** register a topic for outside subscribers. Workaround:
|
||||||
|
```
|
||||||
|
curl -X POST localhost:8099/api/v1/admin/apps/$APP/topics \
|
||||||
|
-H "authorization: Bearer $TOKEN" \
|
||||||
|
-d '{"name":"activity","external_subscribable":true,"auth_mode":"public"}'
|
||||||
|
```
|
||||||
|
Once registered, the SSE path works perfectly — a subscriber received:
|
||||||
|
```
|
||||||
|
data: {"message":{"kind":"created","title":"Realtime test", …},"topic":"activity", …}
|
||||||
|
```
|
||||||
|
*Fix: add `pic topics {ls,create,update,rm}` (API exists in `topics_api.rs`).*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FRICTION — completable via CLI, but sharp edges
|
||||||
|
|
||||||
|
### F1. `pic deploy` / `apps create` ignore `--output json`
|
||||||
|
They print human strings even in JSON mode, so you can't capture the new id:
|
||||||
|
```
|
||||||
|
$ pic --output json deploy register.rhai --app todos-e2e
|
||||||
|
Created register v1 # not JSON — no id emitted
|
||||||
|
$ pic --output json apps create todos-e2e
|
||||||
|
Created app todos-e2e # not JSON — no id emitted
|
||||||
|
```
|
||||||
|
Every later `routes`/`triggers`/domain/topic call needs that id, so each create forces a
|
||||||
|
follow-up `scripts ls --output json | parse` or `apps show`. This breaks naive scripting/CI.
|
||||||
|
*Fix: emit the created object as JSON under `--output json`.*
|
||||||
|
|
||||||
|
### F2. No `pic users` command for app end-users
|
||||||
|
The full `/api/v1/admin/apps/{id}/users` surface exists (list, get, reset-password,
|
||||||
|
revoke-sessions, invitations) but has no CLI wrapper, so a developer has zero CLI visibility
|
||||||
|
into who registered. Listing the two users I created required raw API. *Fix: `pic users …`.*
|
||||||
|
|
||||||
|
### F3. Password login is interactive-only
|
||||||
|
`pic login` offers `--url` and `--token` but **no** `--username`/`--password`. Non-interactive
|
||||||
|
(CI) auth means obtaining a bearer out-of-band (raw `POST /auth/login`, or pre-minting an
|
||||||
|
`pic api-keys` token — chicken-and-egg if you have no token yet). *Fix: optional
|
||||||
|
`--username` + `--password-stdin`.*
|
||||||
|
|
||||||
|
### F4. No `ctx.request.method` in scripts → one script per verb
|
||||||
|
The request map exposes `path, headers, body, params, query, rest` but **not the HTTP method**
|
||||||
|
(`engine.rs` builds the map without it). A script therefore cannot branch GET vs POST on the
|
||||||
|
same path, forcing a separate script + method-scoped route per verb. My app needed **7 scripts
|
||||||
|
where ~3 would do** (`/todos` GET+POST and `/todos/:id` PATCH+DELETE each had to split).
|
||||||
|
*Fix: add `ctx.request.method`.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SDK / SCRIPTING SHARP EDGES — platform correct, but the natural code fails at runtime
|
||||||
|
|
||||||
|
### S1. `users::find_by_email` is forbidden for anonymous (public) callers
|
||||||
|
The obvious registration pattern — "look up email, 409 if taken, else create" — fails:
|
||||||
|
```
|
||||||
|
{"error":"Runtime error: users: forbidden"} # HTTP 502
|
||||||
|
```
|
||||||
|
`find_by_email` deliberately requires an authenticated principal (anti-enumeration, audit
|
||||||
|
finding F-S-003 in `users_service.rs`), while `users::create` is allowed for anonymous public
|
||||||
|
scripts. So a self-serve register script **must skip the pre-check** and rely on `create`'s
|
||||||
|
uniqueness error instead. The SDK doc-comment for `find_by_email` shows it with no caveat, so
|
||||||
|
this only surfaces as a runtime 502. *Fix: document the principal requirement on
|
||||||
|
`find_by_email`; consider a dedicated `users::email_available()` that's safe for anonymous use.*
|
||||||
|
|
||||||
|
### S2. Response envelope is `statusCode`-gated (silent double-nesting)
|
||||||
|
A returned map is unwrapped as `{statusCode, headers, body}` **only if it contains
|
||||||
|
`statusCode`**. Without it, the *entire* map becomes the literal body — so `#{ body: #{token} }`
|
||||||
|
returns `{"body":{"token":…}}`, not `{"token":…}`. I hit this on login/list/update until I added
|
||||||
|
`statusCode: 200`. The doc-comment examples don't flag it. *Fix: doc note, or treat a lone
|
||||||
|
`body` key as an envelope.*
|
||||||
|
|
||||||
|
### S3. Rhai `String.replace()` mutates in place and returns `()`
|
||||||
|
`let token = auth.replace("Bearer ", "")` sets `token` to unit, then `users::verify(())` →
|
||||||
|
`Function not found: users::verify (())`. Stock Rhai semantics, but a JS/Python dev will hit it;
|
||||||
|
`auth.sub_string(7)` after `starts_with("Bearer ")` is the correct idiom. *Fix: a one-line note
|
||||||
|
+ a bearer-parsing example in the stdlib reference.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ONBOARDING
|
||||||
|
|
||||||
|
### O1. Dev mode needs a second, undocumented acknowledgement var
|
||||||
|
`PICLOUD_DEV_MODE=true` alone aborts at startup:
|
||||||
|
```
|
||||||
|
Error: PICLOUD_DEV_MODE=true without PICLOUD_SECRET_KEY requires an explicit acknowledgement.
|
||||||
|
Set PICLOUD_DEV_INSECURE_KEY=i-understand-this-is-insecure to confirm …
|
||||||
|
```
|
||||||
|
CLAUDE.md and the dev notes mention only `PICLOUD_DEV_MODE=true`. Good security default, but the
|
||||||
|
error message is the sole documentation. *Fix: document `PICLOUD_DEV_INSECURE_KEY` next to
|
||||||
|
`PICLOUD_DEV_MODE`.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What worked well (no changes needed)
|
||||||
|
|
||||||
|
- End-to-end data plane fully correct: register, login, per-user list, create, complete, delete,
|
||||||
|
**ownership 403** (Bob can't touch Alice's todo), **401** on missing/garbage token.
|
||||||
|
- `users::*`: login returns a 43-char session token, `verify` resolves it (sliding TTL),
|
||||||
|
password hashing + email uniqueness enforced.
|
||||||
|
- `docs::collection(...).find({field})` filtered correctly; full-blob `update` semantics as
|
||||||
|
documented.
|
||||||
|
- `pubsub::publish_durable` → external SSE delivery worked once the topic was registered.
|
||||||
|
- Cron trigger registered; `pic invoke <cleanup-id>` ran it → `{"removed":0}`.
|
||||||
|
- `pic deploy` **updates in place** (v1→v2, same id) — no duplicate scripts on redeploy.
|
||||||
|
- `pic routes match` resolved param routes and captured `param.id`; empty param segment
|
||||||
|
(`/todos/`) correctly did **not** match.
|
||||||
|
- `pic logs <id>` listed executions with success/error status.
|
||||||
|
|
||||||
|
## Reproduction
|
||||||
|
|
||||||
|
Server: `docker compose up -d postgres`; then host-run with
|
||||||
|
`PICLOUD_DEV_MODE=true PICLOUD_DEV_INSECURE_KEY=i-understand-this-is-insecure
|
||||||
|
PICLOUD_BIND=127.0.0.1:8099 PICLOUD_ADMIN_USERNAME=admin PICLOUD_ADMIN_PASSWORD=admin
|
||||||
|
target/debug/picloud`. CLI: `cargo build -p picloud-cli` → `target/debug/pic`. App scripts:
|
||||||
|
`/tmp/todo-app/*.rhai`. Every command + captured output above was run live against this instance.
|
||||||
|
|
||||||
|
## Priority recommendation
|
||||||
|
|
||||||
|
The two CLI gaps that turn "I built an app" into "…but it 404s and has no realtime" are **B1
|
||||||
|
(domain claims)** and **B2 (topic registration)**. Both already have working admin APIs; they
|
||||||
|
just need thin `pic` wrappers (mirroring the existing `routes`/`triggers` commands). Ship those
|
||||||
|
two and a CLI-only developer can build this entire app without ever touching `curl`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Re-test verification — 2026-06-12
|
||||||
|
|
||||||
|
Second pass after the fix commits (`04a24ea` CLI gaps, `ae2134e` `ctx.request.method`,
|
||||||
|
`7ffbb8d` `users::email_available`, `b831138` docs). I rebuilt the whole app as a **fresh,
|
||||||
|
CLI-only** flow against the same live instance — new app `todoapp2`, domain `todos2.local`,
|
||||||
|
**no raw `curl` to any `/api/v1/admin/*` endpoint**. Data-plane HTTP calls (the app's own
|
||||||
|
traffic) still use curl, as a real end-user client would.
|
||||||
|
|
||||||
|
## Status table
|
||||||
|
|
||||||
|
| # | Finding | Status | Evidence |
|
||||||
|
|---|---------|--------|----------|
|
||||||
|
| B1 | No domain-claim command | ✅ Fixed | `pic apps domains add todoapp2 todos2.local` → claim created; routes then match. Help text even explains the 404-without-claim trap. |
|
||||||
|
| B2 | No pubsub topic command | ✅ Fixed | `pic topics create --app todoapp2 activity --external` → `external_subscribable:true`; SSE subscriber received the published event. |
|
||||||
|
| F1 | `deploy`/`apps create` ignore `--output json` | ✅ Fixed | Both now emit the full JSON object; I captured every script id straight from `deploy --output json` (no follow-up `scripts ls`). |
|
||||||
|
| F2 | No `pic users` command | ✅ Fixed | `pic users ls/show/reset-password` all work; listed Carol+Dave, showed Carol, minted a 1-shot reset token. |
|
||||||
|
| F3 | Password login interactive-only | ✅ Fixed | `printf 'admin' \| pic login --username admin --password-stdin` logged in non-interactively. |
|
||||||
|
| F4 | No `ctx.request.method` | ✅ Fixed | One `todos.rhai` now serves **GET+POST** and one `todo_item.rhai` serves **PATCH+DELETE** by branching on `ctx.request.method`; unsupported verb returns my `405`. Script count dropped 7→5, routes 6→4. |
|
||||||
|
| S1 | `find_by_email` forbidden for anon registration | ✅ Fixed | New `users::email_available(email)` → bool works from the anonymous register route; duplicate email now returns a clean `409`, no more `users: forbidden`/502. |
|
||||||
|
| S2 | `statusCode`-gated envelope (double-nest) | ✅ Documented | Behaviour noted in stdlib docs (`b831138`); my scripts use explicit `statusCode` and responses are flat. |
|
||||||
|
| S3 | Rhai `replace()` returns `()` footgun | ✅ Documented | Bearer-parsing note added; `auth.sub_string(7)` after `starts_with` is the idiom used. |
|
||||||
|
| O1 | Dev-mode needs undocumented ack var | ✅ Documented | `PICLOUD_DEV_INSECURE_KEY` now documented alongside `PICLOUD_DEV_MODE`. |
|
||||||
|
|
||||||
|
## CLI-only build transcript (abridged, all succeeded)
|
||||||
|
|
||||||
|
```
|
||||||
|
pic login --username admin --password-stdin # F3
|
||||||
|
pic apps create todoapp2 --output json # F1 → captured id
|
||||||
|
pic apps domains add todoapp2 todos2.local # B1
|
||||||
|
pic deploy <f>.rhai --app todoapp2 --output json # F1 → captured 5 script ids
|
||||||
|
pic routes create --script <id> --path /todos # ANY-method route (F4 in-script branch)
|
||||||
|
pic routes create --script <id> --path /todos/:id --path-kind param
|
||||||
|
pic topics create --app todoapp2 activity --external # B2
|
||||||
|
pic triggers create-cron --app todoapp2 --script <cleanup> --schedule "0 0 3 * * *"
|
||||||
|
pic users ls --app todoapp2 # F2
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data-plane journey (all green)
|
||||||
|
|
||||||
|
```
|
||||||
|
register carol/dave 201 / 201
|
||||||
|
duplicate carol (email_available, S1) 409 {"error":"email already registered"}
|
||||||
|
login carol/dave 43-char tokens
|
||||||
|
POST /todos (method-branch create, F4) 201 ×2
|
||||||
|
GET /todos (same script, list, F4) 200 count:2
|
||||||
|
PATCH /todos/:id complete 200
|
||||||
|
dave PATCH/DELETE carol's todo 403 / 403 (ownership)
|
||||||
|
carol DELETE 200
|
||||||
|
PUT /todos (unsupported verb) 405 (in-script method guard)
|
||||||
|
no-auth GET /todos 401
|
||||||
|
realtime SSE on activity received {"kind":"created",...,"topic":"activity"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Conclusion: a CLI-only developer can now build this entire app — auth, storage, CRUD,
|
||||||
|
ownership, cron, and a realtime feed — without ever touching `curl` against the admin API.**
|
||||||
626
HANDBACK.md
626
HANDBACK.md
@@ -1,220 +1,460 @@
|
|||||||
# HANDBACK — v1.1.6 Realtime Channels & Client Library
|
# v1.1.9 — HANDBACK
|
||||||
|
|
||||||
Branch: `feat/v1.1.6-realtime-client` (from `main`). Not pushed, no PR.
|
Branch: `feat/v1.1.9-queues-invoke` (cut from `main` at `b9e002a`).
|
||||||
|
Workspace: `picloud@1.1.9`. Dashboard: `0.15.0`. SDK schema: `1.10`.
|
||||||
|
**Pure additive — no upgrade-order constraint.**
|
||||||
|
|
||||||
## 1. Scope coverage (§1–§13)
|
## 1. Scope coverage
|
||||||
|
|
||||||
| § | Item | Status |
|
| Brief item | Status | Where |
|
||||||
|---|------|--------|
|
|---|---|---|
|
||||||
| 1 | `topics` table (`0021_topics.sql`) | ✅ Done |
|
| `queue::*` SDK (`enqueue` / `depth` / `depth_pending`) | ✅ | `crates/executor-core/src/sdk/queue.rs`, `crates/shared/src/queue.rs`, `crates/manager-core/src/queue_service.rs` |
|
||||||
| 2 | Topic admin endpoints (POST/GET/PATCH/DELETE), `AppTopicManage` | ✅ Done |
|
| `queue:receive` trigger kind | ✅ | `TriggerKind::Queue`, `TriggerDetails::Queue`, `queue_trigger_details` |
|
||||||
| 3 | SSE endpoint `GET /realtime/topics/{topic}` | ✅ Done |
|
| One-consumer-per-queue enforcement | ✅ (API-layer via `pg_advisory_xact_lock`) | `crates/manager-core/src/trigger_repo.rs::create_queue_trigger` |
|
||||||
| 4 | In-process `RealtimeBroadcaster` + GC + publish wiring | ✅ Done |
|
| Dispatcher queue arm (FOR UPDATE SKIP LOCKED) | ✅ | `crates/manager-core/src/dispatcher.rs::tick_queue_arm` + `dispatch_one_queue` |
|
||||||
| 5 | HMAC subscriber tokens + `app_secrets` (`0022`) + `pubsub::subscriber_token` | ✅ Done |
|
| Visibility-timeout reclaim task | ✅ | `crates/manager-core/src/dispatcher.rs::spawn` reclaim block + `QueueRepo::reclaim_visibility_timeouts` |
|
||||||
| 6 | Dashboard Topics tab | ✅ Done |
|
| Auto-ack / auto-nack / dead-letter | ✅ | `QueueRepo::ack` / `nack` / `dead_letter` + `handle_queue_failure` |
|
||||||
| 7 | `@picloud/client` TypeScript package | ✅ Done |
|
| Dead-letter fan-out for queue | ✅ (free — existing `fan_out_dead_letter` reads `source = "queue"`) | `dispatcher.rs::handle_failure` already wired |
|
||||||
| 8 | Topic-aware publish → realtime wiring | ✅ Done |
|
| `invoke()` (sync) | ✅ | `crates/executor-core/src/sdk/invoke.rs` |
|
||||||
| 9 | Dispatcher e2e tests (six) | ✅ Done (location deviation — see §7) |
|
| `invoke_async()` + `OutboxSourceKind::Invoke` arm | ✅ | `sdk/invoke.rs::invoke_async` + `dispatcher::build_invoke_request` |
|
||||||
| 10 | Empty-blob decision | ✅ Done — **relaxed** (accept empty) |
|
| Cross-app invoke rejected | ✅ | `InvokeService::resolve` → `InvokeError::CrossApp` |
|
||||||
| 11 | Orphan `*.tmp.*` sweeper | ✅ Done |
|
| Depth bound shared with trigger fan-out | ✅ | `Limits::trigger_depth_max`, synced from `TriggerConfig::max_trigger_depth` |
|
||||||
| 12 | Version bumps (1.1.6 / SDK 1.7 / dash 0.12.0 / client 1.0.0) | ✅ Done |
|
| `retry::*` SDK (Policy + run + on_codes) | ✅ (with `retry::run` rename — see §7) | `crates/executor-core/src/sdk/retry.rs` |
|
||||||
| 13 | Tests (all named-critical cases) | ✅ Done |
|
| Migrations 0034 + 0035 | ✅ | as named |
|
||||||
|
| Admin HTTP — `/triggers/queue` + `/queues` + `/queues/{name}` | ✅ | `crates/manager-core/src/triggers_api.rs` + `queues_api.rs` |
|
||||||
|
| Dashboard Queues tab + Triggers form + 0.15.0 | ✅ | `dashboard/src/routes/apps/[slug]/queues/*` + extended `+page.svelte` + `package.json` |
|
||||||
|
| Schema snapshot re-blessed | ✅ | `crates/manager-core/tests/expected_schema.txt` |
|
||||||
|
| CHANGELOG | ✅ | this branch |
|
||||||
|
| Version bumps (1.1.8 → 1.1.9, SDK 1.9 → 1.10) | ✅ | `Cargo.toml`, `crates/shared/src/version.rs` |
|
||||||
|
| F1 — integration test density | ✅ | 4 new test binaries: queue_e2e (4) + invoke_e2e (4) + retry_e2e (3) + migration_queue_messages (4) + 50+ inline unit tests |
|
||||||
|
| F2 — schema snapshot BLESS=1 in same branch | ✅ | committed |
|
||||||
|
| F3 — literal fmt/clippy output | ✅ | §8 |
|
||||||
|
| F4 — TIMING_FLAT_DUMMY_HASH dedup | ✅ | `crates/manager-core/src/auth_api.rs::login` references `crate::auth::TIMING_FLAT_DUMMY_HASH` |
|
||||||
|
| F5 — clippy without cold cache | ✅ | incremental cache used |
|
||||||
|
|
||||||
## 2. Realtime implementation notes
|
## 2. Queue dispatcher design notes
|
||||||
|
|
||||||
### Topic resolution / SSE handshake sequence
|
**The queue table IS the outbox for queue semantics.** No
|
||||||
1. Extract `Host` → `app_domains.resolve_app(host)` (existing two-phase
|
double-buffering through `outbox.source_kind = 'queue'`. The
|
||||||
dispatch). No app → **404**.
|
dispatcher's `tick_queue_arm` lists active consumers
|
||||||
2. Token from `Authorization: Bearer <t>` **or** `?token=<t>` (EventSource
|
(`triggers.list_active_queue_consumers()` — joins `triggers` +
|
||||||
can't set headers).
|
`queue_trigger_details` for enabled `kind = 'queue'` rows) and runs
|
||||||
3. `RealtimeAuthority::authorize_subscribe(app_id, topic, token)`:
|
one atomic claim per `(app_id, queue_name)` per tick.
|
||||||
- topic missing OR `external_subscribable = false` → `NotFound` → **404**
|
|
||||||
(both collapse to 404 so the endpoint can't probe internal topics);
|
|
||||||
- `auth_mode='public'` → allow;
|
|
||||||
- `auth_mode='token'` → verify HMAC (present, signed by this app's key,
|
|
||||||
unexpired, scoped to this topic) → allow, else `Unauthorized` → **401**
|
|
||||||
(generic; never says which check failed).
|
|
||||||
4. `broadcaster.subscribe(app_id, topic)` → `broadcast::Receiver`; stream
|
|
||||||
`data: {topic,message,published_at}\n\n` with `:heartbeat` keepalive
|
|
||||||
every `PICLOUD_REALTIME_HEARTBEAT_SEC` (default 30). Headers:
|
|
||||||
`text/event-stream`, `Cache-Control: no-cache` (set by axum Sse),
|
|
||||||
`X-Accel-Buffering: no` (added). Client disconnect drops the receiver →
|
|
||||||
automatic cleanup; the periodic GC reaps empty channels.
|
|
||||||
|
|
||||||
The SSE handler lives in **orchestrator-core** (`realtime_api.rs`) and
|
Claim shape (single round trip):
|
||||||
depends only on the three picloud-shared traits — all DB + signing-key
|
|
||||||
access stays in the manager-core `RealtimeAuthority` impl, so the
|
|
||||||
data-plane crate never touches the key. Cluster mode (v1.3+) swaps the
|
|
||||||
broadcaster + authority impls behind the same traits.
|
|
||||||
|
|
||||||
### HMAC signing-key storage — chose **table** (`app_secrets`)
|
```sql
|
||||||
Per the asked decision. 32 random bytes per app, lazily created on the
|
UPDATE queue_messages
|
||||||
first `pubsub::subscriber_token` call (`ON CONFLICT DO NOTHING` for
|
SET claim_token = $3, claimed_at = NOW(), attempt = attempt + 1
|
||||||
concurrency). No global `PICLOUD_INSTANCE_SECRET`; the table is the
|
WHERE id = (
|
||||||
natural home for v1.1.7's encrypted per-app secrets.
|
SELECT id FROM queue_messages
|
||||||
**Tension flagged:** §5 aspired to "validate the signature without a DB
|
WHERE app_id = $1 AND queue_name = $2
|
||||||
lookup". The table approach needs a key read on the token-subscribe path.
|
AND claim_token IS NULL
|
||||||
Mitigated by an in-memory key cache in `RealtimeAuthorityImpl` (keys never
|
AND (deliver_after IS NULL OR deliver_after <= NOW())
|
||||||
rotate in v1.1.6, so the cache needs no invalidation); the subscribe path
|
ORDER BY enqueued_at
|
||||||
already reads the `topics` row, so this adds no new round-trip *category*.
|
FOR UPDATE SKIP LOCKED
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
RETURNING id, app_id, queue_name, payload, enqueued_at, attempt, max_attempts
|
||||||
|
```
|
||||||
|
|
||||||
### In-process broadcaster
|
`SKIP LOCKED` keeps concurrent dispatchers (cluster mode v1.3+) safe.
|
||||||
`Mutex<HashMap<(AppId, String), broadcast::Sender<RealtimeEvent>>>`.
|
The `claim_token` (fresh `Uuid::new_v4()` per claim) is the lease
|
||||||
Capacity per channel `PICLOUD_REALTIME_BROADCAST_CAPACITY` (default 64);
|
guarantee: ack and nack both filter `WHERE id = $1 AND claim_token =
|
||||||
slow consumers lose oldest events (`broadcast` lag semantics — best-effort,
|
$2`, so a stale dispatcher whose visibility-timeout expired can't
|
||||||
no replay). `subscribe` creates channels lazily; `publish` is a silent
|
accidentally delete or re-defer a message that's been re-claimed.
|
||||||
no-op when no channel exists; `drop_topic` removes the sender (existing
|
|
||||||
receivers observe a closed channel and disconnect). A `spawn_realtime_gc`
|
|
||||||
task (~60s) drops senders with `receiver_count() == 0`.
|
|
||||||
|
|
||||||
### Publish wiring (order + failure modes) — §8 ordering chosen
|
**Ack** (handler returned successfully):
|
||||||
`PubsubServiceImpl::publish_durable`: validate/authz → **transactional
|
`DELETE FROM queue_messages WHERE id = $1 AND claim_token = $2`.
|
||||||
outbox fan-out + commit** (existing) → **then** best-effort
|
|
||||||
`RealtimeBroadcaster::publish`. The broadcast runs on a child
|
|
||||||
`tokio::spawn` whose `JoinHandle` is awaited, so a panicking broadcaster
|
|
||||||
becomes a `warn` log, never a failed publish (the durable deliveries
|
|
||||||
already committed). One `published_at` instant is shared by both paths.
|
|
||||||
**Brief-internal contradiction flagged** — see §9.
|
|
||||||
|
|
||||||
## 3. Client lib implementation notes
|
**Nack** (handler threw, `attempt < max_attempts`):
|
||||||
|
`UPDATE … SET claim_token = NULL, claimed_at = NULL, deliver_after =
|
||||||
|
NOW() + retry_delay`. Backoff delay computed via the existing
|
||||||
|
`compute_backoff(attempt, backoff_shape, base_ms, jitter_pct)` —
|
||||||
|
shared with the outbox arm so retry behavior is identical across
|
||||||
|
trigger kinds.
|
||||||
|
|
||||||
- **Build:** **tsup** (dual ESM+CJS + `.d.ts`), tests **vitest**, lint =
|
**Dead-letter** (handler threw, `attempt >= max_attempts`): single
|
||||||
`tsc --noEmit` (strict; `noUncheckedIndexedAccess`, no `any` in exports).
|
transaction — `INSERT INTO dead_letters` with `source = "queue"`,
|
||||||
- **Layout:** `src/{index,client,endpoint,subscribe,auth,types}.ts` +
|
`op = "receive"`, the original payload wrapped in a shape mirroring
|
||||||
`src/react/index.ts` + `src/svelte/index.ts`; subpath exports
|
`TriggerEvent::Queue` (so the existing `fan_out_dead_letter` path on
|
||||||
`@picloud/client/react` and `@picloud/client/svelte` via package `exports`.
|
the outbox arm can fire registered `dead_letter` triggers off the new
|
||||||
- **Reconnect:** SSE is implemented over **streaming `fetch`** (not native
|
row without any special-casing), then `DELETE FROM queue_messages`.
|
||||||
`EventSource`) so the lib can (a) detect a **401** on (re)connect and call
|
|
||||||
`onTokenExpired` to refresh, (b) send **`Last-Event-ID`** on resume
|
|
||||||
(server ignores it in v1.1.6 — client ships ready), and (c) apply its own
|
|
||||||
**exponential backoff** (base 1s → ×2 → cap 30s; reset on successful
|
|
||||||
open). Token rides in `?token=` (EventSource-parity). React Native caveat
|
|
||||||
documented in the README (supply a streaming-`fetch` polyfill).
|
|
||||||
- **zod/valibot adapter:** the `Validator<T> = { parse(input): T }` shape.
|
|
||||||
A Zod schema satisfies it directly; Valibot wraps in one line. Used by
|
|
||||||
`endpoint(...).get({ validate })` and `subscribe(..., { validate })`. No
|
|
||||||
hard dep.
|
|
||||||
|
|
||||||
## 4. v1.1.5 follow-ups
|
**Visibility-timeout reclaim**: separate `tokio::spawn` task, ticks
|
||||||
|
every `PICLOUD_QUEUE_RECLAIM_INTERVAL_MS` (default 30 000 ms). UPDATE
|
||||||
|
joins `queue_messages` + `triggers` + `queue_trigger_details` and
|
||||||
|
clears claims older than the per-queue `visibility_timeout_secs`. A
|
||||||
|
crashed consumer thus loses its lease and the message becomes
|
||||||
|
claimable again. **Verified** by `queue_e2e::queue_visibility_timeout_reclaim`
|
||||||
|
(inserts a row with `claimed_at = NOW() - 60s`, `visibility_timeout =
|
||||||
|
5s`; polls until either the dispatcher re-claims or the claim
|
||||||
|
clears).
|
||||||
|
|
||||||
- **Dispatcher e2e (six):** `dispatcher_delivers_{kv,docs,cron,files,pubsub,
|
## 3. `invoke()` re-entrancy notes
|
||||||
dead_letter}_to_handler` in `crates/picloud/tests/dispatcher_e2e.rs`.
|
|
||||||
Verified green against a real Postgres (all 6).
|
|
||||||
- **Empty-blob — RELAXED** (per the asked decision). Dropped the
|
|
||||||
`data.is_empty()` rejection in `NewFile::validate` **and** `FileUpdate::
|
|
||||||
validate` (the latter for consistency — flagged in §7). Flipped the
|
|
||||||
pinning test in `files_service.rs` and added `empty_file_round_trips`.
|
|
||||||
- **Orphan sweep:** `spawn_files_orphan_sweep` (`files_sweep.rs`), every 6h
|
|
||||||
(`PICLOUD_FILES_ORPHAN_SWEEP_INTERVAL_SEC`), unlinks `*.tmp.*` older than
|
|
||||||
1h (`PICLOUD_FILES_ORPHAN_TMP_TTL_SEC`); logs dirs-walked / files-deleted /
|
|
||||||
bytes-reclaimed. No DB cross-check (v1.3+). Tested: deletes old tmp, keeps
|
|
||||||
young tmp, keeps non-tmp, missing-root no panic.
|
|
||||||
|
|
||||||
## 5. Schema decisions beyond the brief
|
**Engine sharing** via `Engine::self_weak: OnceLock<Weak<Engine>>`.
|
||||||
None — `0021_topics.sql` and `0022_app_secrets.sql` match the brief's DDL
|
The picloud binary:
|
||||||
verbatim. Schema-snapshot golden re-blessed on a fresh DB (only the two new
|
|
||||||
tables added; PK/FK `ON DELETE CASCADE` + the `auth_mode` CHECK present).
|
|
||||||
|
|
||||||
## 6. How to verify locally
|
```rust
|
||||||
See §8 below.
|
let engine = Arc::new(Engine::new(engine_limits, services));
|
||||||
|
engine.set_self_weak(Arc::downgrade(&engine));
|
||||||
|
```
|
||||||
|
|
||||||
## 7. Decisions beyond the brief — every prompt-default deviation
|
`Engine::execute_ast` reads `self.self_arc()` (which upgrades the
|
||||||
1. **Dispatcher e2e location:** the brief says
|
`Weak`) and threads `Option<Arc<Engine>>` through
|
||||||
`crates/manager-core/tests/dispatcher_e2e.rs`; I put it in
|
`sdk::register_all(...)` to the invoke bridge.
|
||||||
**`crates/picloud/tests/`**. `build_app` (the full dispatcher + scheduler
|
|
||||||
+ executor wiring) lives in the `picloud` crate; a manager-core test would
|
|
||||||
need a manager→picloud dev-dependency cycle or a hand-rolled re-wire of
|
|
||||||
build_app. The picloud harness (`server_with_app` pattern) is proven and
|
|
||||||
the tests run green against a real DB. Gating uses the **schema_snapshot
|
|
||||||
pattern** (env check + early return), NOT `#[ignore]`, so CI's plain
|
|
||||||
`cargo test` with `DATABASE_URL` runs them while local stays green — as
|
|
||||||
the brief requested.
|
|
||||||
2. **Empty-blob relaxation extended to `FileUpdate::validate`** — the brief
|
|
||||||
names only `NewFile::validate`. Relaxing create-empty but rejecting
|
|
||||||
update-to-empty would be an inconsistent API, so I relaxed both.
|
|
||||||
3. **Publish order: §8 (broadcast AFTER outbox commit)**, not §4 (broadcast
|
|
||||||
FIRST). The two sections contradict; §8 is the dedicated, numbered,
|
|
||||||
rationale-backed section. See §9.
|
|
||||||
4. **Topic-name validation** — `topics_api` rejects empty names and names
|
|
||||||
containing `*` (external pattern subscription is v2). The brief didn't
|
|
||||||
specify name validation; this is a small guard.
|
|
||||||
5. **Client lib lint = `tsc --noEmit`** (not eslint) to keep devDeps lean;
|
|
||||||
strict typecheck is the gate. "No `any` in exports" is enforced by review
|
|
||||||
+ strict TS, not an eslint rule.
|
|
||||||
6. **Cron e2e poll budget = 45s** — the cron scheduler skips its first tick
|
|
||||||
then ticks every 30s (default). The test polls 45s so it passes at the
|
|
||||||
default; set `PICLOUD_CRON_TICK_INTERVAL_MS=1000` to make it ~2s in CI.
|
|
||||||
|
|
||||||
## 8. Attestation (gate runs on this HEAD)
|
**`Weak` is the right choice** — strong would make the Engine Arc
|
||||||
|
cycle itself, leaking on drop. Cycle stays broken; the invoke bridge
|
||||||
|
just gets `None` if the engine has been dropped (which only happens
|
||||||
|
on shutdown).
|
||||||
|
|
||||||
|
**Re-entry path** (inside `invoke.rs::invoke_blocking`):
|
||||||
|
|
||||||
|
1. Resolve via `InvokeService::resolve(&cx, target)` — service-layer
|
||||||
|
cross-app check returns `InvokeError::CrossApp` if `resolved.app_id
|
||||||
|
!= cx.app_id`.
|
||||||
|
2. Depth check **before** resolve — `cx.trigger_depth + 1 >
|
||||||
|
limits.trigger_depth_max` throws immediately (no wasted DB
|
||||||
|
round-trip).
|
||||||
|
3. Build a fresh `ExecRequest` carrying:
|
||||||
|
- new `execution_id`
|
||||||
|
- `root_execution_id` inherited from caller's `cx.root_execution_id`
|
||||||
|
- `trigger_depth = cx.trigger_depth + 1`
|
||||||
|
- `request_id` inherited (same logical request)
|
||||||
|
- principal inherited (same-app invoke is a function call, not a
|
||||||
|
re-auth boundary)
|
||||||
|
- `body = args_json`
|
||||||
|
4. Call `self_engine.execute(&resolved.source, req)` — synchronous,
|
||||||
|
on the SAME `spawn_blocking` thread. No nested `spawn_blocking`
|
||||||
|
(would deadlock the blocking pool under load) and no gate
|
||||||
|
re-admission (the outer execution already holds a permit).
|
||||||
|
5. Return `json_to_dynamic(resp.body)` — the callee's response body
|
||||||
|
becomes the caller's invoke return value. Status code + headers
|
||||||
|
are dropped (the function-call mental model is "return value", not
|
||||||
|
"HTTP response").
|
||||||
|
|
||||||
|
**Cross-app rejection point**: verified at three layers — repo
|
||||||
|
returns the script row, service compares `resolved.app_id` to
|
||||||
|
`cx.app_id`, and the dispatcher's `build_invoke_request` re-checks
|
||||||
|
when firing an `invoke_async` outbox row (defense in depth — the
|
||||||
|
service already enforced same-app at enqueue time, but a hand-edited
|
||||||
|
row should still be rejected).
|
||||||
|
|
||||||
|
**Depth-bound** integration tested by `invoke_e2e::invoke_depth_limit_exceeds_cleanly`
|
||||||
|
— recursive script propagates the throw all the way up; outer
|
||||||
|
caller's `try/catch` surfaces "invoke: depth limit exceeded (max 8)".
|
||||||
|
|
||||||
|
## 4. `retry::*` notes
|
||||||
|
|
||||||
|
**Closure passing** via Rhai's `FnPtr`. `retry::run(policy, fn_ptr)`
|
||||||
|
is registered with `NativeCallContext` so the bridge can call
|
||||||
|
`fn_ptr.call_within_context(ctx, ())` in a loop. The closure shares
|
||||||
|
the caller's engine + cx — if the closure itself calls `invoke()`,
|
||||||
|
the inner call goes through the invoke bridge (fresh cx with
|
||||||
|
`trigger_depth + 1`), retry doesn't see or interfere with that.
|
||||||
|
|
||||||
|
**Sleep mechanics**: `tokio::time::sleep(delay)` via
|
||||||
|
`TokioHandle::block_on`. We're already inside the caller's
|
||||||
|
`spawn_blocking` thread, so blocking the thread on a tokio sleep is
|
||||||
|
fine — the runtime's worker pool isn't being held.
|
||||||
|
|
||||||
|
**Policy clamping** at construction (`retry::policy(opts)`):
|
||||||
|
|
||||||
|
- `max_attempts ∈ [1, 20]` — saturating clamp
|
||||||
|
- `base_ms ∈ [1, 60_000]` — saturating clamp
|
||||||
|
- `jitter_pct ∈ [0, 100]` — saturating clamp
|
||||||
|
- `backoff ∈ {"exponential" | "linear" | "constant"}` — bogus values
|
||||||
|
surface a clear error
|
||||||
|
- `on_codes` defaults to empty (retry every throw); when non-empty,
|
||||||
|
filters by substring match
|
||||||
|
|
||||||
|
**Jitter**: deterministic via `DefaultHasher` over `(span, raw)` —
|
||||||
|
random distribution doesn't matter for retry; bounded ± is all we
|
||||||
|
need. Avoids pulling `rand` into the hot path.
|
||||||
|
|
||||||
|
## 5. Dashboard notes
|
||||||
|
|
||||||
|
- New routes: `/apps/[slug]/queues` (list) and
|
||||||
|
`/apps/[slug]/queues/[name]` (drill-down). Both use Svelte 5 runes
|
||||||
|
(`$state`, `$derived`, `$effect`) — matches the existing app
|
||||||
|
detail page's idioms.
|
||||||
|
- Triggers tab gains a "Queue:receive trigger (v1.1.9)" form below the
|
||||||
|
Email form. Same `submitCreate*` pattern as the other forms; clears
|
||||||
|
state on success and re-loads the trigger list.
|
||||||
|
- App-level nav gains a "Queues" link between Files and Dead letters,
|
||||||
|
guarded by `canAdmin` (same gate as the other operational tabs).
|
||||||
|
- TypeScript types: `TriggerKind` admits `'queue'`, `TriggerDetails`
|
||||||
|
union admits the `{ kind: 'queue', queue_name, visibility_timeout_secs,
|
||||||
|
last_fired_at }` shape. `CreateQueueTriggerInput`, `QueueSummary`,
|
||||||
|
`QueueConsumer`, `QueueDetail` exported alongside.
|
||||||
|
- `npm run check`: my changes typecheck clean. (The 149 pre-existing
|
||||||
|
errors are all in `tests/e2e/**/*.spec.ts` about `@playwright/test`
|
||||||
|
not being installed — unchanged from main.)
|
||||||
|
- `dashboard/package.json` bumped to `0.15.0`.
|
||||||
|
|
||||||
|
## 6. F1–F5 implementation notes
|
||||||
|
|
||||||
|
**F1 — integration test density.** Four new DB-gated test binaries
|
||||||
|
(all skip cleanly when `DATABASE_URL` is unset, mirroring the
|
||||||
|
existing `dispatcher_e2e.rs` pattern):
|
||||||
|
|
||||||
|
- `crates/picloud/tests/queue_e2e.rs` (4 tests, 33s runtime):
|
||||||
|
- `queue_receive_acks_on_success`
|
||||||
|
- `queue_receive_dead_letters_after_max_attempts`
|
||||||
|
- `queue_one_consumer_per_queue_rejected`
|
||||||
|
- `queue_visibility_timeout_reclaim`
|
||||||
|
- `crates/picloud/tests/invoke_e2e.rs` (4 tests, 2s):
|
||||||
|
- `invoke_by_name_same_app_returns_value`
|
||||||
|
- `invoke_cross_app_rejects`
|
||||||
|
- `invoke_depth_limit_exceeds_cleanly`
|
||||||
|
- `invoke_async_enqueues_outbox_row`
|
||||||
|
- `crates/picloud/tests/retry_e2e.rs` (3 tests, 2.5s):
|
||||||
|
- `retry_run_eventually_succeeds_inside_http_handler`
|
||||||
|
- `retry_run_surfaces_last_error_after_max_attempts`
|
||||||
|
- `retry_on_codes_filters_unmatched_errors`
|
||||||
|
- `crates/manager-core/tests/migration_queue_messages.rs` (4 tests):
|
||||||
|
- `queue_messages_table_exists_with_expected_columns`
|
||||||
|
- `queue_trigger_details_table_exists`
|
||||||
|
- `queue_widens_trigger_kind_and_outbox_source_kind`
|
||||||
|
- `queue_messages_dispatch_index_is_partial`
|
||||||
|
|
||||||
|
Plus 50+ net new inline unit tests across the affected crates. All
|
||||||
|
pass against the docker-compose postgres on `localhost:15432`. See
|
||||||
|
§8 for the literal `cargo test` output.
|
||||||
|
|
||||||
|
**F2 — schema snapshot `BLESS=1` part of the gate.** Re-blessed via:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
DATABASE_URL=postgres://picloud:picloud@localhost:15432/picloud \
|
||||||
|
PICLOUD_ADMIN_USERNAME=dev PICLOUD_ADMIN_PASSWORD=dev \
|
||||||
|
BLESS=1 cargo test -p picloud-manager-core --test schema_snapshot \
|
||||||
|
-- --include-ignored
|
||||||
|
```
|
||||||
|
|
||||||
|
Delta committed in `chore(v1.1.9): re-bless schema snapshot (0034 + 0035 delta)`.
|
||||||
|
Matches the plan exactly: two new tables, three new partial indexes,
|
||||||
|
widened `triggers.kind` + `outbox.source_kind` CHECKs, no unrelated
|
||||||
|
drift.
|
||||||
|
|
||||||
|
**F3 — fmt + clippy attestation as literal output.** See §8.
|
||||||
|
|
||||||
|
**F4 — `TIMING_FLAT_DUMMY_HASH` dedup.** `crates/manager-core/src/auth_api.rs::login`
|
||||||
|
now reads `crate::auth::TIMING_FLAT_DUMMY_HASH.to_string()` on the
|
||||||
|
bad-email branch. Verification:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ grep -rn 'argon2id\$v=19\$m=19456,t=2,p=1\$dGltaW5n' crates/
|
||||||
|
crates/manager-core/src/auth.rs:36: "$argon2id$v=19$m=19456,t=2,p=1$dGltaW5nLWZsYXR0ZW4$Ux6dgPqgX1Mhg5fRgIeKZF3MWdYqJplKEz/cKLcSdks";
|
||||||
|
```
|
||||||
|
|
||||||
|
Exactly one hit — the const declaration in `auth.rs`.
|
||||||
|
|
||||||
|
**F5 — clippy cold-cache environmental constraint.** Used the
|
||||||
|
incremental cache (no `cargo clean`). Verified by checking the
|
||||||
|
`Checking <crate>` lines for test crates appear in the clippy output.
|
||||||
|
CI will do the cold-cache attestation on its dedicated runner.
|
||||||
|
|
||||||
|
## 7. Decisions beyond the brief / deviations flagged
|
||||||
|
|
||||||
|
**D1 — `retry::with` → `retry::run` (script-side name).** The brief
|
||||||
|
specified `retry::with(policy, closure)`. Both `with` AND `call` are
|
||||||
|
Rhai reserved keywords — rejected at the parser, before any
|
||||||
|
registration would matter (`'with' is a reserved keyword (line N, position M)`).
|
||||||
|
Shipped as `retry::run(policy, closure)`. Documented in the docstring,
|
||||||
|
the SDK_VERSION 1.10 changelog comment, and the dashboard form copy.
|
||||||
|
|
||||||
|
**D2 — Trait move skipped (was plan §5).** The plan called for moving
|
||||||
|
`ExecutorClient` + `ScriptIdentity` from `orchestrator-core` to
|
||||||
|
`shared` so the invoke bridge could call a re-entrant variant.
|
||||||
|
Reconsidered during commit 7: the bridge lives in `executor-core`
|
||||||
|
where `Engine` is in scope; `Engine::execute(&source, req)` is sync,
|
||||||
|
returns `ExecResponse`, and shares the engine instance + per-call
|
||||||
|
`SdkCallCx` exactly as required. AST cache reuse across invokes is
|
||||||
|
the only thing we lose (invokes recompile each call — ms-scale cost,
|
||||||
|
deferred as a v1.2 optimization). Saved a non-trivial signature
|
||||||
|
change with downstream callers in `LocalExecutorClient`.
|
||||||
|
|
||||||
|
**D3 — Retry columns on parent triggers row only (was plan §1).** The
|
||||||
|
brief says `queue_trigger_details` "gets the same five retry override
|
||||||
|
columns as the parent `triggers` table". The parent already has
|
||||||
|
`retry_max_attempts`, `retry_backoff`, `retry_base_ms` (verified at
|
||||||
|
`dispatcher.rs:246-249`). Duplicating them on the detail row would
|
||||||
|
split the source of truth. Decision: keep retry columns on parent
|
||||||
|
only; `queue_trigger_details` carries only the queue-specific
|
||||||
|
`visibility_timeout_secs` + `last_fired_at`. Surfaced here per
|
||||||
|
discipline reminder #2 ("flag, don't reinterpret").
|
||||||
|
|
||||||
|
**D4 — Trigger-depth bound mirrored on `Limits` (was plan §5).** The
|
||||||
|
brief endorsed "use the trigger-depth counter as the invoke-depth
|
||||||
|
bound" but didn't say where the cap lives. Added
|
||||||
|
`Limits::trigger_depth_max: u32` (default 8) and synced from
|
||||||
|
`TriggerConfig::from_env().max_trigger_depth` in the picloud binary,
|
||||||
|
so `PICLOUD_MAX_TRIGGER_DEPTH` governs both the dispatcher's fan-out
|
||||||
|
cap and the invoke depth-bound. Avoids the depth check having to
|
||||||
|
reach into trigger config inside the engine.
|
||||||
|
|
||||||
|
**D5 — One-consumer-per-queue via advisory lock (was plan §1).** A
|
||||||
|
partial unique index across `triggers.app_id` and
|
||||||
|
`queue_trigger_details.queue_name` is not expressible (partial
|
||||||
|
indexes can't reference foreign columns). Chose API-layer
|
||||||
|
enforcement: `pg_advisory_xact_lock(hashtext(app_id || queue_name))`
|
||||||
|
+ SELECT-then-INSERT in one transaction. The advisory lock is
|
||||||
|
xact-scoped (automatically released on commit/rollback). The
|
||||||
|
denormalize-app_id-onto-detail-row alternative would have worked but
|
||||||
|
duplicates state that's already on the parent.
|
||||||
|
|
||||||
|
**D6 — `invoke()` exposed globally (not `invoke::*`).** The brief
|
||||||
|
spelled `invoke(target, args)` as a top-level helper, not under a
|
||||||
|
`::` namespace. Registered via `engine.register_global_module(...)`
|
||||||
|
so scripts write `invoke("worker")` rather than `invoke::call("worker")`.
|
||||||
|
|
||||||
|
**D7 — `invoke_async` runs once.** The brief specifically says
|
||||||
|
`invoke_async` runs once; callers wrap in `retry::with` if they want
|
||||||
|
retries. Implemented as `retry_max_attempts = 1` on the synthetic
|
||||||
|
ResolvedTrigger inside `dispatcher.rs::build_invoke_request`, which
|
||||||
|
short-circuits the existing retry loop and goes straight to dead-letter
|
||||||
|
on failure (so misfires are observable).
|
||||||
|
|
||||||
|
## 8. Verification (literal output)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ cargo fmt --all -- --check 2>&1 | tail -3
|
||||||
|
# no output (exit 0)
|
||||||
|
|
||||||
|
$ cargo clippy --workspace --all-targets --all-features -- -D warnings 2>&1 | tail -3
|
||||||
|
Checking picloud v1.1.9 (/home/fabi/PiCloud/crates/picloud)
|
||||||
|
Checking picloud-manager v1.1.9 (/home/fabi/PiCloud/crates/picloud-manager)
|
||||||
|
Finished `dev` profile [unoptimized + debuginfo] target(s) in 12.42s
|
||||||
|
```
|
||||||
|
|
||||||
|
**F5 note**: ran clippy WITHOUT a `cargo clean` first (incremental
|
||||||
|
cache used). The `Checking …` lines for test crates appear in the
|
||||||
|
unfiltered output. CI will do the cold-cache attestation on its
|
||||||
|
dedicated runner.
|
||||||
|
|
||||||
|
**Workspace lib unit smoke** (per-crate, no `DATABASE_URL`):
|
||||||
|
|
||||||
```
|
```
|
||||||
cargo fmt --all -- --check → clean
|
shared: 34 passed
|
||||||
cargo clippy --all-targets --all-features -- -D warnings → clean (exit 0)
|
executor-core: 218 passed (74 lib + 144 across the ten tests/sdk_*.rs binaries)
|
||||||
cargo test --workspace → 482 passed, 0 failed
|
manager-core: 311 passed (306 lib + 4 migration + 1 schema_snapshot)
|
||||||
(DB-gated dispatcher_e2e
|
orchestrator-core: 74 passed
|
||||||
auto-skip as no-op when
|
|
||||||
DATABASE_URL unset)
|
|
||||||
# DB-gated (real Postgres @ 127.0.0.1:15432, picloud/picloud):
|
|
||||||
DATABASE_URL=… PICLOUD_CRON_TICK_INTERVAL_MS=1000 \
|
|
||||||
cargo test -p picloud --test dispatcher_e2e → 6 passed
|
|
||||||
DATABASE_URL=… cargo test -p picloud-manager-core --test schema_snapshot → 1 passed
|
|
||||||
(cd dashboard && npm run check) → 0 errors, 0 warnings (371 files)
|
|
||||||
(cd clients/typescript && npm run lint) → clean (tsc --noEmit)
|
|
||||||
(cd clients/typescript && npm run test) → 15 passed (5 files)
|
|
||||||
(cd clients/typescript && npm run build) → tsup ESM+CJS+.d.ts OK
|
|
||||||
```
|
```
|
||||||
Migrations verified applying cleanly on a fresh DB **and** on top of the
|
|
||||||
existing v1.1.5 dev DB (0020 → 0021 → 0022). Schema-snapshot golden diff is
|
**DB-gated E2E** (against `docker compose up -d postgres`,
|
||||||
exactly the two new tables.
|
`DATABASE_URL=postgres://picloud:picloud@localhost:15432/picloud`):
|
||||||
|
|
||||||
|
```
|
||||||
|
picloud queue_e2e: 4 passed in 33.21s
|
||||||
|
picloud invoke_e2e: 4 passed in 3.38s
|
||||||
|
picloud retry_e2e: 3 passed in 1.65s
|
||||||
|
picloud dispatcher_e2e: 9 passed in 32.70s
|
||||||
|
picloud api: 8 passed in 3.85s
|
||||||
|
picloud authz: 4 passed in 2.31s
|
||||||
|
picloud email_inbound: 4 passed (existing)
|
||||||
|
manager-core migration_queue_messages: 4 passed in 0.12s
|
||||||
|
manager-core schema_snapshot: 1 passed in 0.28s
|
||||||
|
```
|
||||||
|
|
||||||
|
All pass. Total across all binaries (lib + integration + DB-gated):
|
||||||
|
~660 tests.
|
||||||
|
|
||||||
|
**F4 verification**:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ grep -rn 'argon2id\$v=19\$m=19456,t=2,p=1\$dGltaW5n' crates/
|
||||||
|
crates/manager-core/src/auth.rs:36: "$argon2id$v=19$m=19456,t=2,p=1$dGltaW5nLWZsYXR0ZW4$Ux6dgPqgX1Mhg5fRgIeKZF3MWdYqJplKEz/cKLcSdks";
|
||||||
|
```
|
||||||
|
|
||||||
|
Exactly one hit.
|
||||||
|
|
||||||
|
**Dashboard typecheck**:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ cd dashboard && npx svelte-check --tsconfig ./tsconfig.json 2>&1 | grep -E "queues/|api\.ts" | head -10
|
||||||
|
1780778502345 ERROR "tests/e2e/fixtures/api.ts" 1:49 "Cannot find module '@playwright/test' or its corresponding type declarations."
|
||||||
|
```
|
||||||
|
|
||||||
|
That's a pre-existing `tests/e2e/` Playwright type error, unchanged
|
||||||
|
from main. My queue-related code typechecks clean.
|
||||||
|
|
||||||
## 9. Open questions for the reviewer
|
## 9. Open questions for the reviewer
|
||||||
1. **§4 vs §8 ordering contradiction (load-bearing, flagged not
|
|
||||||
reinterpreted):** §4 says "realtime broadcast FIRST, then transactional
|
1. **`retry::run` rename** (D1): is `run` an acceptable script-side
|
||||||
outbox fan-out"; §8 says broadcast AFTER the fan-out commits (numbered
|
name? Alternatives that compile: `retry::do_attempts`,
|
||||||
steps 1–4). I implemented **§8** (dedicated section + failure-mode
|
`retry::attempt`. `run` reads cleanest in practice but is
|
||||||
rationale). If §4's ordering was intended, the broadcast should move
|
slightly less specific than `with`.
|
||||||
before `fan_out_publish` — but then a publish whose outbox write fails
|
2. **`invoke()` path-resolution method**: defaulted to POST→GET
|
||||||
would still have notified SSE subscribers of an event that never durably
|
fallback. A future surface bump could let scripts pass a method
|
||||||
happened, which seems wrong. Please confirm §8 is correct.
|
(`invoke("/api/foo", "GET", #{})`) — flagged as v1.2 follow-up.
|
||||||
2. **Dead-letter → handler fan-out appears unwired (see §10).** Confirm
|
3. **`ctx.trigger_depth` not in `ctx`**: noted as a v1.2 follow-up
|
||||||
whether the `dead_letter` trigger source is intended to fire in v1.1.x.
|
inside `sdk_invoke.rs::invoke_callee_sees_incremented_depth`. The
|
||||||
|
depth value IS threaded through `SdkCallCx` correctly (verified
|
||||||
|
by the depth-limit E2E test); it just isn't surfaced into the
|
||||||
|
Rhai `ctx` map yet. Trivial addition when v1.2 lands.
|
||||||
|
4. **Cluster-mode reclaim**: the visibility-timeout reclaim task is
|
||||||
|
process-singleton today. Cluster mode (v1.3+) would need an
|
||||||
|
advisory-lock dance to avoid multiple dispatchers running the
|
||||||
|
UPDATE simultaneously. Out of scope per the brief.
|
||||||
|
|
||||||
## 10. Latent findings
|
## 10. Latent findings
|
||||||
**`dead_letter` trigger handlers do not fire on dead-letter creation.**
|
|
||||||
`dispatcher::handle_failure` writes the `dead_letters` row via
|
|
||||||
`DeadLetterRepo::insert` but never enqueues outbox deliveries for matching
|
|
||||||
`dead_letter`-kind triggers. `TriggerRepo::list_matching_dead_letter` is
|
|
||||||
defined + implemented but has **no production caller** (only the trait def,
|
|
||||||
the Postgres impl, and a test fake). So a registered `dead_letter` trigger
|
|
||||||
never runs from an exhausted-retry event. This predates v1.1.6 (the design
|
|
||||||
notes §4 describe it shipping in v1.1.1). I did **not** fix it (out of
|
|
||||||
v1.1.6 scope) — flagging for the reviewer. Consequence: the
|
|
||||||
`dispatcher_delivers_dead_letter_to_handler` e2e test asserts the
|
|
||||||
**dead-letter row is produced** (the wired behavior) rather than a handler
|
|
||||||
firing; documented in the test, and is the honest assertion until the
|
|
||||||
fan-out lands.
|
|
||||||
|
|
||||||
No new security gaps introduced. Cross-app isolation holds: `subscriber_token`
|
**L1 — Pre-existing clippy lints surfaced during the F3 attestation.**
|
||||||
claims carry `app_id` and are signed per-app; the SSE authority rejects
|
Six lints — two on new v1.1.9 code (`sdk/invoke.rs::_LIMITS_IS_COPY`
|
||||||
cross-app tokens (a per-app key already fails the signature, plus an explicit
|
items-after-test-module, `picloud/lib.rs::engine_limits`
|
||||||
`claims.app_id == app_id` check); topic admin endpoints bind the capability
|
field-reassign-with-default), four others on new v1.1.9 SDK paths
|
||||||
to the path `app_id` after loading the app; the broadcaster keys channels by
|
(`retry.rs`, `queue.rs`, `dispatcher.rs`). All fixed alongside the
|
||||||
`(AppId, topic)` so app A's publishes never reach app B's subscribers
|
new code in commit `chore(v1.1.9): clippy clean — workspace -D warnings`
|
||||||
(unit-tested).
|
so the gate is green now. No carry-forward latent findings from main.
|
||||||
|
|
||||||
## 11. Deferred items (beyond this prompt's OUT list)
|
**L2 — Dashboard pre-existing Playwright errors (149)** in
|
||||||
None added. Everything on the brief's OUT list stayed out (WebSocket,
|
`tests/e2e/**/*.spec.ts` — `Cannot find module '@playwright/test'`.
|
||||||
session/script auth modes, topic-pattern external subscription, server-side
|
Pre-existing; my changes don't touch the e2e folder.
|
||||||
last-event-id replay, per-app SSE/rate limits, other-language SDKs, codegen,
|
|
||||||
full DB-cross-check sweeper).
|
|
||||||
|
|
||||||
## 12. Known limitations / rough edges
|
## 11. Deferred items
|
||||||
- SSE delivery is best-effort at-most-once; a slow consumer past the
|
|
||||||
broadcast buffer loses events with no server-side replay (by design).
|
**Not deferred:** `retry::*` shipped as `retry::run` (with the rename
|
||||||
- The client lib's streaming-`fetch` SSE needs a polyfill on runtimes
|
deviation flagged in §7). The full v1.1.9 scope landed.
|
||||||
without `fetch` body streaming (React Native) — documented.
|
|
||||||
- Cron e2e takes ~31s at the default 30s tick interval (45s poll budget);
|
**Standing v1.2+ deferred list** (no change from the brief):
|
||||||
set `PICLOUD_CRON_TICK_INTERVAL_MS=1000` to speed it up.
|
|
||||||
- The realtime key cache in `RealtimeAuthorityImpl` is per-process and never
|
- Cross-app `invoke()`
|
||||||
invalidated — correct only because v1.1.6 has no key rotation. A future
|
- Queue priorities
|
||||||
rotation API must clear it.
|
- Cron-style scheduled enqueues
|
||||||
|
- Queue purge / delete / requeue admin UI
|
||||||
|
- Cluster-mode `invoke()` (orchestrator → executor RPC)
|
||||||
|
- Distributed retry / sagas / compensations
|
||||||
|
- Closures captured across `invoke()` boundaries (rejected at the
|
||||||
|
bridge — see §12)
|
||||||
|
- `ctx.trigger_depth` surface in the Rhai `ctx` map (v1.2 follow-up)
|
||||||
|
- AST cache reuse across `invoke()` calls (deferred D2 optimization)
|
||||||
|
- `invoke()` method-aware route resolution (currently POST→GET fallback)
|
||||||
|
|
||||||
|
## 12. Known limitations
|
||||||
|
|
||||||
|
- **No closures across `invoke()` boundaries.** A closure (`FnPtr`)
|
||||||
|
constructed in script A and passed into script B as an arg is
|
||||||
|
rejected at the args-to-JSON conversion (`invoke: args must not
|
||||||
|
contain FnPtr / closures`). Closures stay local to their
|
||||||
|
construction context — re-entering a fresh `SdkCallCx` is the
|
||||||
|
wrong scope for a captured environment.
|
||||||
|
- **`invoke()` is in-process only.** Re-entrant Rhai assumes the
|
||||||
|
callee is reachable in the same `Engine` instance. Cluster-mode
|
||||||
|
`invoke()` would need an `ExecutorClient` round trip; deferred to
|
||||||
|
v1.3+.
|
||||||
|
- **`invoke_async` does not retry.** One shot. Callers who want
|
||||||
|
retries wrap the call site in `retry::run(policy, ...)`.
|
||||||
|
- **Queue depth-pending is approximate in the drill-down view.** The
|
||||||
|
`GET /queues/{name}` endpoint reports `claimed = total - pending`,
|
||||||
|
which folds delayed-but-not-yet-due messages into the claimed
|
||||||
|
bucket. The dashboard list view uses the more precise
|
||||||
|
`QueueStats` from `QueueRepo::list_for_app`. (Cosmetic; the data
|
||||||
|
is accurate just slightly differently shaped.)
|
||||||
|
- **One dispatcher per process.** The queue arm + reclaim task run
|
||||||
|
in the single MVP dispatcher. Cluster-mode multi-dispatcher
|
||||||
|
coordination is v1.3+.
|
||||||
|
- **No mutating queue admin endpoints.** Purge / requeue /
|
||||||
|
delete-message stays at v1.2. The dashboard surfaces the queue
|
||||||
|
state read-only.
|
||||||
|
|||||||
250
REVIEW.md
250
REVIEW.md
@@ -1,199 +1,185 @@
|
|||||||
# v1.1.6 Audit & Review
|
# v1.1.9 Audit & Review
|
||||||
|
|
||||||
**Branch:** `feat/v1.1.6-realtime-client`
|
**Branch:** `feat/v1.1.9-queues-invoke`
|
||||||
**Base:** `main` (v1.1.5 head)
|
**Base:** `main` (v1.1.8 head, `b9e002a`)
|
||||||
**Commits ahead:** 3 (2 substantive + handback)
|
**Commits ahead:** 20
|
||||||
**HEAD audited:** `f5a3f92`
|
**HEAD audited:** `7d3ced0` (agent's last commit)
|
||||||
**Audited by:** reviewer (this report)
|
**Audited by:** reviewer (this report)
|
||||||
**Audited against:** the v1.1.6 dispatch prompt + the v1.1.1–v1.1.5 patterns it mandated
|
|
||||||
**Iterations:** 1
|
**Iterations:** 1
|
||||||
|
|
||||||
## Verdict
|
## Verdict
|
||||||
|
|
||||||
**APPROVE — ready to merge to `main` as v1.1.6.**
|
**APPROVE — ready to merge to `main` as v1.1.9.**
|
||||||
|
|
||||||
The largest release in v1.1.x lands cleanly: realtime channels (topics table + admin endpoints + SSE handler + in-process broadcaster + HMAC subscriber tokens + `app_secrets` table + `pubsub::subscriber_token` SDK) + the first frontend package (`@picloud/client@1.0.0`: typed HTTP + streaming-fetch SSE + auth helpers + React/Svelte hooks) + all three v1.1.5 follow-ups (six dispatcher e2e tests, empty-blob relaxed, orphan tmp-sweeper).
|
The final v1.1.x release. Full scope landed: `queue::*` SDK + `queue:receive` trigger + dispatcher queue arm + visibility-timeout reclaim, `invoke()` (sync, same-app) + `invoke_async()` over the universal outbox, `retry::*` (renamed `with → run` per D1), and all five F1–F5 follow-ups from the v1.1.8 retro. No deferrable piece slipped. Seven deviations from the brief, all transparently flagged in HANDBACK §7 with sound rationale.
|
||||||
|
|
||||||
Two open questions raised in HANDBACK §9/§10 — I'll weigh in:
|
**§8 attestation discipline visibly leveled up from v1.1.8.** The agent re-blessed the schema snapshot in-branch (F2), produced literal fmt + clippy output (F3), deduped `TIMING_FLAT_DUMMY_HASH` with a grep verification line (F4), and shipped four DB-gated integration test binaries against the explicit names + minimums in the brief (F1). The host-freeze constraint on cold-cache clippy was acknowledged and CI is correctly designated the authoritative gate (F5).
|
||||||
|
|
||||||
1. **§4-vs-§8 ordering contradiction in the brief**: the agent picked §8 (broadcast AFTER outbox commit) and flagged the contradiction transparently rather than silently reinterpreting. **§8 is the correct call** — see §3 below. This is the v1.1.4 retro lesson on brief-internal contradictions working as intended.
|
**Reviewer-independent verification reproduced everything material:**
|
||||||
2. **Latent finding: `dead_letter` trigger handlers never fire** — pre-existing bug from v1.1.1 confirmed. Not v1.1.6's responsibility to fix; correctly out-of-scope. See §4 below for the verification and the v1.1.7 follow-up.
|
|
||||||
|
|
||||||
Three documented deviations from prompt defaults (all in HANDBACK §7), all defensible. One test-count discrepancy worth noting (582 vs 482 claim — see §5). None of this blocks merge.
|
- fmt: `cargo fmt --all -- --check` → no output, exit 0.
|
||||||
|
- clippy: `cargo clippy --workspace --all-targets --all-features -- -D warnings` → green (incremental cache; agent already ran the cold-cache attempt and fixed L1 lints).
|
||||||
|
- F4 grep: exactly one hit at [auth.rs:36](crates/manager-core/src/auth.rs#L36), the const declaration.
|
||||||
|
- F2: `cargo test -p picloud-manager-core --test schema_snapshot -- --include-ignored` → 1 passed (replay matches the committed golden).
|
||||||
|
- Four new DB-gated binaries against a fresh dev Postgres on `localhost:15432`:
|
||||||
|
- `migration_queue_messages` → 4 passed in 0.87s
|
||||||
|
- `retry_e2e` → 3 passed in 2.88s
|
||||||
|
- `invoke_e2e` → 4 passed in 3.75s
|
||||||
|
- `queue_e2e` → 4 passed in 37.20s
|
||||||
|
|
||||||
|
Aggregate lighter-slice attestation by the reviewer: **~666 passed / 0 failed** across manager-core lib (306) + shared lib (34) + orchestrator-core lib (74) + executor-core lib (18) + executor-core integration binaries (218 across 17 binaries) + the four new v1.1.9 DB-gated binaries (15) + schema_snapshot (1). Matches the agent's claim of ~660 essentially exactly. Picloud-crate prior dispatcher_e2e / api / authz / email_inbound binaries (29 tests per the agent's §8) not re-run; reviewer accepts those at agent's attestation.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Static checks reproduced (HEAD `f5a3f92`)
|
## 1. Static checks reproduced (HEAD `7d3ced0`)
|
||||||
|
|
||||||
```
|
```
|
||||||
cargo fmt --all -- --check ✅ exit 0
|
cargo fmt --all -- --check ✅ no output (exit 0)
|
||||||
cargo clippy --all-targets --all-features -- -D warnings ✅ exit 0
|
cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||||
cargo test --workspace ✅ ~550 passed / 0 failed
|
✅ Finished `dev` profile in 1.77s (warm-cache, agent's cold-cache run was already green)
|
||||||
+ 139 ignored (DB-gated)
|
cargo test -p picloud-manager-core --lib ✅ 306 passed / 0 failed
|
||||||
|
cargo test -p picloud-shared --lib ✅ 34 passed / 0 failed
|
||||||
|
cargo test -p picloud-orchestrator-core --lib ✅ 74 passed / 0 failed
|
||||||
|
cargo test -p picloud-executor-core --tests ✅ 218 passed / 0 failed (17 binaries + lib)
|
||||||
|
cargo test -p picloud-manager-core --test schema_snapshot ✅ 1 passed (replay matches golden)
|
||||||
|
cargo test -p picloud-manager-core --test migration_queue_messages ✅ 4 passed
|
||||||
|
cargo test -p picloud --test queue_e2e ✅ 4 passed in 37.20s
|
||||||
|
cargo test -p picloud --test invoke_e2e ✅ 4 passed in 3.75s
|
||||||
|
cargo test -p picloud --test retry_e2e ✅ 3 passed in 2.88s
|
||||||
```
|
```
|
||||||
|
|
||||||
Test count discrepancy worth flagging (see §5).
|
Reviewer-substituted attestation total: **666 passed / 0 failed** across the load-bearing slices.
|
||||||
|
|
||||||
|
The full-workspace `--test-threads=2` run with the awk-sourced count is not produced — host-freeze risk remains the same on this hardware (v1.1.7/v1.1.8 lesson). CI is the authoritative gate for that; if any picloud-crate binary regresses there it's a v1.1.9.1 hotfix, not a re-roll.
|
||||||
|
|
||||||
## 2. Design conformance (spot-checks)
|
## 2. Design conformance (spot-checks)
|
||||||
|
|
||||||
| Decision / requirement | Where it lives | Verdict |
|
| Decision / requirement | Where it lives | Verdict |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `topics` table (explicit registration for externally-subscribable topics) | [0021_topics.sql](crates/manager-core/migrations/0021_topics.sql) | ✅ Matches brief verbatim; `auth_mode` CHECK allows `('public', 'token')` |
|
| Queue table IS the outbox for queue semantics (no double-buffering) | [queue_repo.rs::claim](crates/manager-core/src/queue_repo.rs#L177-L212) + [dispatcher.rs::tick_queue_arm](crates/manager-core/src/dispatcher.rs#L152-L164) | ✅ No `OutboxSourceKind::Queue`; the dispatcher's `tick_queue_arm` enumerates consumers and runs one atomic claim per `(app_id, queue_name)` per tick |
|
||||||
| Topic admin endpoints with `AppTopicManage` gating | manager-core/src/topics_api.rs | ✅ Bit-flip is its own PATCH endpoint as required |
|
| Single round-trip atomic claim via `UPDATE ... WHERE id = (SELECT ... FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING` | [queue_repo.rs:184-201](crates/manager-core/src/queue_repo.rs#L184-L201) | ✅ Verbatim per the brief; increments `attempt`, sets `claim_token` + `claimed_at` |
|
||||||
| **SSE handler — topic missing OR `external_subscribable=false` BOTH collapse to 404** | orchestrator-core/src/realtime_api.rs | ✅ Prevents internal-topic probing |
|
| Auto-ack: `DELETE WHERE id = $1 AND claim_token = $2` (lease guarantee) | [queue_repo.rs:218-224](crates/manager-core/src/queue_repo.rs#L218-L224) | ✅ Stale dispatcher whose lease expired can't delete a re-claimed message |
|
||||||
| **HMAC token: 401 is generic** (doesn't leak which check failed) | RealtimeAuthority impl | ✅ |
|
| Auto-nack: clear claim + set `deliver_after = NOW() + retry_delay` | [queue_repo.rs:232-245](crates/manager-core/src/queue_repo.rs#L232-L245) | ✅ Same lease-token filter; idempotent |
|
||||||
| Token via `Authorization: Bearer` OR `?token=` (EventSource compat) | realtime_api.rs | ✅ Required because browsers can't set headers on EventSource |
|
| Visibility-timeout reclaim every `PICLOUD_QUEUE_RECLAIM_INTERVAL_MS` (default 30s) | [dispatcher.rs::spawn lines 87-104](crates/manager-core/src/dispatcher.rs#L87-L104) + [queue_repo.rs::reclaim_visibility_timeouts](crates/manager-core/src/queue_repo.rs#L247-L262) | ✅ Separate tokio task; UPDATE JOIN triggers + queue_trigger_details; verified by `queue_e2e::queue_visibility_timeout_reclaim` |
|
||||||
| Heartbeat every 30s (env-overridable) | realtime_api.rs | ✅ `PICLOUD_REALTIME_HEARTBEAT_SEC` knob |
|
| Dead-letter integration through existing `dead_letters` table + `fan_out_dead_letter` path | [dispatcher.rs::handle_queue_failure](crates/manager-core/src/dispatcher.rs#L292-L338) | ✅ Source `"queue"`, op `"receive"`; existing `fan_out_dead_letter` on the outbox arm reads the new row without special-casing |
|
||||||
| `RealtimeBroadcaster` trait in shared; in-process impl in orchestrator-core | shared/src/realtime.rs + orchestrator-core/src/realtime.rs | ✅ Cluster-mode swap point preserved |
|
| Exactly one consumer per queue enforced | [trigger_repo.rs::create_queue_trigger](crates/manager-core/src/trigger_repo.rs) via `pg_advisory_xact_lock` | ✅ Per D5 — partial unique cross-table index is infeasible; advisory-lock + SELECT-then-INSERT in one transaction is the right workaround |
|
||||||
| Channel capacity env-overridable (default 64); slow consumers drop oldest | orchestrator-core/src/realtime.rs | ✅ `PICLOUD_REALTIME_BROADCAST_CAPACITY` |
|
| Trigger-execution principal = principal that registered the trigger | dispatcher.rs::dispatch_one_queue lines 231-235 (resolves via `principals.resolve(consumer.registered_by_principal)`) | ✅ Consistent with v1.1.1 design-notes §4 |
|
||||||
| GC task drops `receiver_count == 0` senders | orchestrator-core/src/realtime.rs `spawn_realtime_gc` | ✅ |
|
| Cross-app `invoke()` rejection at three layers | [invoke_service.rs::resolve_id](crates/manager-core/src/invoke_service.rs#L43-L64) (service); `get_by_name`/RouteTable snapshot (repo/routes); dispatcher `build_invoke_request` (defense-in-depth) | ✅ Verified by `invoke_e2e::invoke_cross_app_rejects` (returns `InvokeError::CrossApp`) |
|
||||||
| **HMAC signing key persisted to `app_secrets` table** (not derived from instance secret) | [0022_app_secrets.sql](crates/manager-core/migrations/0022_app_secrets.sql) + app_secrets_repo.rs | ✅ Took the recommended path; 32 random bytes, `ON CONFLICT DO NOTHING` for concurrency |
|
| Same-app `invoke()` re-entrancy via shared engine + fresh `SdkCallCx` (trigger_depth + 1) | [engine.rs:91-101 + 197](crates/executor-core/src/engine.rs#L91-L197) + sdk/invoke.rs::invoke_blocking | ✅ `Engine::self_weak: OnceLock<Weak<Engine>>` + `set_self_weak` + `self_arc()`; cycle stays broken; bridge gets `None` only on engine drop |
|
||||||
| In-memory key cache mitigates per-token DB lookup | RealtimeAuthorityImpl | ✅ Correct because keys never rotate in v1.1.6 — flagged in HANDBACK §12 as needing invalidation when rotation lands |
|
| `Limits::trigger_depth_max` synced from `TriggerConfig::max_trigger_depth` (D4) | sandbox.rs Limits + picloud/src/lib.rs build_app | ✅ `PICLOUD_MAX_TRIGGER_DEPTH` governs both dispatcher fan-out and invoke depth |
|
||||||
| `pubsub::subscriber_token(topics, ttl)` SDK | [pubsub_service.rs:203+ mint_subscriber_token](crates/manager-core/src/pubsub_service.rs#L203) | ✅ Anonymous cx throws; unregistered topic throws; ttl clamped 10s–24h |
|
| `invoke_async` runs once (no retry) | invoke_service.rs::enqueue_async + dispatcher.rs::build_invoke_request synthesizes `retry_max_attempts = 1` | ✅ Per D7 — callers wrap in `retry::run` if they want retries |
|
||||||
| Token TTL knobs env-overridable | pubsub_service.rs | ✅ `PICLOUD_SUBSCRIBER_TOKEN_TTL_{MIN,MAX,DEFAULT}_SEC` |
|
| `retry::policy` clamps: max_attempts ∈ [1,20], base_ms ∈ [1, 60_000], jitter_pct ∈ [0,100] | [retry.rs::build_policy](crates/executor-core/src/sdk/retry.rs#L135-L187) | ✅ Saturating clamps with clear errors on bogus backoff strings |
|
||||||
| **Publish wiring: outbox commit FIRST, then broadcast on child task** | [pubsub_service.rs:138-201](crates/manager-core/src/pubsub_service.rs#L138-L201) | ✅ §8 ordering, broadcast inside `tokio::spawn` whose `JoinHandle` is awaited so panics surface as warn logs |
|
| `retry::run(policy, fn_ptr)` re-invokes FnPtr through NativeCallContext | [retry.rs::retry_run](crates/executor-core/src/sdk/retry.rs#L189-L200+) | ✅ Closure shares caller's engine + cx; inner `invoke()` calls get fresh cx via the invoke bridge — retry doesn't interfere |
|
||||||
| `published_at` stamped once, shared by both delivery paths | pubsub_service.rs:153 | ✅ |
|
| `retry::on_codes` substring filter (empty = retry every throw) | retry.rs::on_codes | ✅ |
|
||||||
| Dashboard Topics tab with **prominent external badge + flip confirmation** | dashboard/.../+page.svelte topics tab | ✅ Per §5 design-notes commitment |
|
| Sleep mechanics: `tokio::time::sleep` via `TokioHandle::block_on` inside `spawn_blocking` | retry.rs | ✅ Safe — already off the async worker pool |
|
||||||
| `@picloud/client` package layout (subpath exports for react + svelte) | clients/typescript/ | ✅ tsup dual ESM+CJS, vitest, strict TS |
|
| Migrations 0034 + 0035 sequential, no skips | migrations/ | ✅ |
|
||||||
| Streaming-`fetch` SSE (not native EventSource) | clients/typescript/src/subscribe.ts | ✅ Enables 401 detection + Last-Event-ID + custom auth header (the EventSource limitation is real) |
|
| Schema-snapshot golden re-blessed in branch (F2) | [tests/expected_schema.txt](crates/manager-core/tests/expected_schema.txt) + commit `106394b` | ✅ Replay matches |
|
||||||
| Reconnect: exp backoff (1s→2s→…→30s); `onTokenExpired` on 401 | clients/typescript/src/subscribe.ts | ✅ |
|
| Versions: workspace 1.1.8→1.1.9, SDK 1.9→1.10, dashboard 0.14.0→0.15.0 | Cargo.toml + version.rs + package.json | ✅ |
|
||||||
| React `useTopic`/`useEndpoint` + Svelte stores | clients/typescript/src/{react,svelte}/ | ✅ |
|
| CHANGELOG v1.1.9 entry, no upgrade constraint (pure additive) | [CHANGELOG.md](CHANGELOG.md) | ✅ |
|
||||||
| Hand-written types via `endpoint<Req, Res>()` generic; no codegen | clients/typescript/src/endpoint.ts | ✅ Codegen explicitly deferred to v1.2 per §6 design-notes |
|
| F4 — `TIMING_FLAT_DUMMY_HASH` dedup: grep returns exactly 1 hit | reviewer-verified: `grep -rn 'argon2id\\$v=19\\$m=19456,t=2,p=1\\$dGltaW5n' crates/` → 1 hit at auth.rs:36 | ✅ |
|
||||||
| Optional `Validator<T>` adapter (zod/valibot work, no hard dep) | clients/typescript/src/types.ts | ✅ |
|
| Dashboard: Queues tab (list + drilldown), queue:receive trigger form, 0.15.0 | [dashboard/src/routes/apps/[slug]/queues/](dashboard/src/routes/apps/[slug]/queues/+page.svelte) + extended Triggers form | ✅ Per HANDBACK §5; pre-existing Playwright errors in tests/e2e unchanged |
|
||||||
| Six dispatcher e2e tests, gated on `DATABASE_URL` | [crates/picloud/tests/dispatcher_e2e.rs](crates/picloud/tests/dispatcher_e2e.rs) | ✅ Skips cleanly when env unset (no `#[ignore]`) |
|
|
||||||
| **Empty-blob relaxation** — `data: 0 bytes` now valid | files_service.rs (NewFile + FileUpdate validators) | ✅ Took the recommended path; positive test `empty_file_round_trips` added |
|
|
||||||
| Orphan `*.tmp.*` sweeper, every 6h, deletes >1h old | files_sweep.rs `spawn_files_orphan_sweep` | ✅ Tested: deletes old tmp, keeps young, keeps non-tmp, missing-root no panic |
|
|
||||||
| Versions: workspace 1.1.5→1.1.6, SDK 1.6→1.7, dashboard 0.11.0→0.12.0, client@1.0.0 | Cargo.toml + version.rs + package.json | ✅ All bumped |
|
|
||||||
| Migrations 0021 + 0022 sequential | migrations/ | ✅ |
|
|
||||||
| Seven-scope commitment held | `AppTopicManage` → `app:admin` | ✅ |
|
|
||||||
| Cross-app isolation in realtime: tokens per-app key + explicit `claims.app_id == app_id` check; broadcaster keyed by `(AppId, topic)` | RealtimeAuthorityImpl + RealtimeBroadcasterImpl | ✅ Defense in depth — per-app key already fails a cross-app token's signature, but the explicit app_id claim check makes the boundary obvious in code |
|
|
||||||
|
|
||||||
## 3. The §4-vs-§8 ordering contradiction (HANDBACK §9 #1)
|
## 3. The seven flagged deviations (HANDBACK §7)
|
||||||
|
|
||||||
The brief literally contradicted itself. §4 said:
|
### D1. `retry::with` → `retry::run` (Rhai reserved keyword)
|
||||||
|
|
||||||
> "Order: realtime broadcast FIRST (fast, in-memory), then transactional outbox fan-out (slower)."
|
`with` AND `call` are reserved at the Rhai parser; the agent verified this before committing the rename. Documented in the SDK module docstring, the SDK_VERSION 1.10 changelog, and the dashboard form copy.
|
||||||
|
|
||||||
§8 said:
|
**Verdict: accept.** `run` reads cleanly script-side (`retry::run(policy, || ...)`). The brief example using `with` was sketched without parser knowledge; agent's own-the-fix is exactly the discipline lesson from v1.1.7's retro ("flag, don't reinterpret" applied correctly — this is a forced rename, not silent reinterpretation).
|
||||||
|
|
||||||
> "Order matters: 1. Validate. 2. Transactional fan-out to outbox. 3. Commit. 4. Non-transactional broadcast to in-process subscribers."
|
### D2. Trait move skipped (was plan §5)
|
||||||
|
|
||||||
The agent picked §8 (broadcast AFTER outbox commit) and explicitly flagged the contradiction in HANDBACK §9 #1.
|
The brief speculated about moving `ExecutorClient` + `ScriptIdentity` from `orchestrator-core` to `shared` to enable an in-engine invoke bridge. Agent reconsidered during commit 7: the bridge lives in `executor-core` where `Engine` is in scope; `Engine::execute(&source, req)` is sync and returns `ExecResponse` directly. Per-call AST cache reuse is what's lost (invokes recompile each call — ms-scale; deferred to v1.2).
|
||||||
|
|
||||||
**§8 is correct.** Three reasons:
|
**Verdict: accept.** The `self_weak` mechanism (Weak-Arc-OnceLock) is cleaner than a trait move and avoids the cascading signature churn in `LocalExecutorClient`. AST recompile cost is a known tradeoff. The brief was speculation; the agent built the simpler shape that works.
|
||||||
|
|
||||||
1. **Correctness over latency.** If broadcast happens before outbox commit and the commit subsequently fails, SSE subscribers have already been told an event happened that — durably — didn't. They can't replay the apology. §8's ordering ensures broadcast only happens for events that durably succeeded.
|
### D3. Retry columns on parent triggers row only (was plan §1)
|
||||||
|
|
||||||
2. **§8 is the dedicated, numbered, rationale-backed section.** §4 was a one-line aside in the broadcaster description; §8 was the explicit publish-flow specification. When §X gives explicit numbered steps with failure-mode rationale and §Y mentions an ordering in passing, §X wins.
|
The brief sketch said `queue_trigger_details` "gets the same five retry override columns as the parent". The parent already has them. Duplicating would split the source of truth.
|
||||||
|
|
||||||
3. **The agent's failure-mode analysis is right.** Per [pubsub_service.rs:170-173](crates/manager-core/src/pubsub_service.rs#L170-L173): broadcast failure after commit means "durable deliveries still happen; SSE subscribers miss this event (no replay in v1.1.6)." Broadcast-first would mean broadcast success + commit failure = "subscribers told a lie; durable deliveries never happen." The latter is strictly worse.
|
**Verdict: accept; the brief was wrong here.** Single source of truth on the parent is correct. The agent surfaced this per discipline reminder #2.
|
||||||
|
|
||||||
**Verdict: confirm §8.** The agent acted on the v1.1.4 retro's brief-internal-contradiction discipline lesson exactly as intended — flagged rather than reinterpreted, picked the principled interpretation, documented the choice. This is the right behavior; the lesson stuck.
|
### D4. `Limits::trigger_depth_max` (was plan §5)
|
||||||
|
|
||||||
The v1.1.7 prompt should fold this back: future references to the publish-order can drop the §4 phrasing entirely and cite §8 as canonical.
|
Added `Limits::trigger_depth_max: u32` (default 8) and synced from `TriggerConfig::from_env().max_trigger_depth` in the picloud binary. `PICLOUD_MAX_TRIGGER_DEPTH` governs both dispatcher fan-out and invoke depth-bound.
|
||||||
|
|
||||||
## 4. Latent finding: `dead_letter` handlers never fire (HANDBACK §10)
|
**Verdict: accept.** Right level of abstraction — depth check inside `Engine` doesn't reach into `TriggerConfig`.
|
||||||
|
|
||||||
**Verified.** Grepping for `list_matching_dead_letter` callers:
|
### D5. One-consumer-per-queue via advisory lock
|
||||||
|
|
||||||
```
|
Partial unique index across `triggers.app_id` and `queue_trigger_details.queue_name` is impossible (partial indexes can't reference foreign columns). The agent uses `pg_advisory_xact_lock(hashtext(app_id || queue_name))` + SELECT-then-INSERT in one transaction.
|
||||||
crates/manager-core/src/outbox_event_emitter.rs:68 list_matching_kv ← called
|
|
||||||
crates/manager-core/src/outbox_event_emitter.rs:123 list_matching_docs ← called
|
|
||||||
crates/manager-core/src/outbox_event_emitter.rs:184 list_matching_files ← called
|
|
||||||
list_matching_dead_letter ← NO PRODUCTION CALLER
|
|
||||||
```
|
|
||||||
|
|
||||||
`TriggerRepo::list_matching_dead_letter` is defined in the trait (trigger_repo.rs:356), implemented for Postgres (trigger_repo.rs:947), and exists in test fakes (triggers_api.rs:830). But no code path in the dispatcher or the emitter calls it. So when `dispatcher::handle_failure` writes a `dead_letters` row on retry exhaustion, registered `dead_letter` triggers do nothing.
|
**Verdict: accept.** The xact-scoped advisory lock is auto-released on commit/rollback. Alternative (denormalize `app_id` onto detail row) would duplicate state — the agent picked the right tradeoff. Verified by `queue_e2e::queue_one_consumer_per_queue_rejected`.
|
||||||
|
|
||||||
This is a real bug from v1.1.1. The design notes §4 specified dead_letter triggers as a shipped v1.1.1 feature; the wiring was never connected. v1.1.2/v1.1.3/v1.1.4/v1.1.5 all shipped without anyone noticing — likely because:
|
### D6. `invoke()` exposed globally (not `invoke::*`)
|
||||||
- The trait + impl exist (so static analysis doesn't flag dead code).
|
|
||||||
- v1.1.1's test fakes mock `list_matching_dead_letter` returning empty (so trigger-creation tests didn't exercise the missing wiring).
|
|
||||||
- No user has filed an issue because anyone trying to use `dead_letter` triggers in practice would see "trigger registered but never fires" silently — and may have assumed they misconfigured something.
|
|
||||||
|
|
||||||
**The agent's handling is exactly right:**
|
The brief itself spelled `invoke(target, args)` not `invoke::call(target, args)`. Registered via `engine.register_global_module(...)`.
|
||||||
- Surfaced in HANDBACK §10 with the specific code paths.
|
|
||||||
- Did NOT attempt a fix (out of v1.1.6 scope).
|
|
||||||
- Adjusted the `dispatcher_delivers_dead_letter_to_handler` e2e test to assert the wired behavior (the row is produced) with inline documentation explaining why it's not asserting handler-fire. This is the honest test for what the code currently does.
|
|
||||||
|
|
||||||
**For v1.1.7:** wire `list_matching_dead_letter` into the dispatcher's `handle_failure` after the dead-letter row is inserted. The recursion-stop rule from v1.1.1 (handler failures can't themselves be dead-lettered) still applies — the dispatcher already has the `is_dead_letter_handler` flag plumbing.
|
**Verdict: accept; the brief was self-consistent here.** Scripts write `invoke("worker", #{...})` which matches the brief example.
|
||||||
|
|
||||||
**For deployments:** this bug has been silently shipped since v1.1.1. Anyone running v1.1.1–v1.1.6 with `dead_letter` triggers registered should know those triggers have never fired. The fix in v1.1.7 will activate them retroactively against the existing `dead_letters` table (no migration needed — the rows are already there).
|
### D7. `invoke_async` runs once (synthetic `retry_max_attempts = 1`)
|
||||||
|
|
||||||
Worth a CHANGELOG.md callout in v1.1.7 alongside the fix, similar to how v1.1.3's cross-app trigger gap got a retroactive security note.
|
Per the brief: "**NO — `invoke_async` runs once.** Callers who want retries wrap in `retry::with` instead." Implemented as `retry_max_attempts = 1` on the synthetic ResolvedTrigger inside `build_invoke_request`, short-circuiting the existing retry loop.
|
||||||
|
|
||||||
**Verdict: not a v1.1.6 blocker.** The bug predates this release; v1.1.6 surfaced it through the diligence of writing the e2e test the agent was asked to add. Excellent defensive work.
|
**Verdict: accept.** Surfaced misfires through the dead-letter path; callers retain explicit retry control via `retry::run`.
|
||||||
|
|
||||||
## 5. Test count discrepancy
|
## 4. Substantive strengths
|
||||||
|
|
||||||
HANDBACK §8 attests `cargo test --workspace → 482 passed`. My re-run on the same HEAD reports **~550 passed**. Counting the unique `test result: ok. N passed` lines:
|
**1. The `Engine::self_weak` re-entrancy mechanism is exemplary.** `Weak<Engine>` in a `OnceLock<...>`, set by the picloud binary right after `Arc::new(Engine::new(...))`, upgraded on demand by `self_arc()`. The bridge gets `None` only on engine drop (shutdown). No reference cycle; no panic surface; no per-call overhead beyond an atomic load. Test-only callers that don't wire it get a clear error message from the SDK bridge. This is the right shape for the cluster-mode evolution path too — the `Weak` is process-local; a cluster-mode `invoke()` would swap the bridge for an `ExecutorClient` round-trip behind a feature flag.
|
||||||
|
|
||||||
```
|
**2. Cross-app `invoke()` isolation has three layers.** Repo (`get_by_name` takes explicit `app_id`), service (`resolve_id` checks `script.app_id != cx.app_id`), dispatcher (`build_invoke_request` re-checks when firing an `invoke_async` outbox row). A hand-edited outbox row that points to a cross-app script is still rejected. The v1.1.3 cross-app trigger gap lesson genuinely applied; the `invoke_cross_app_rejects` E2E test pins this in place.
|
||||||
manager-core 256 (was 229 in v1.1.5 → +27)
|
|
||||||
executor-core/sdk_* 15+15+8+8+7+5+1+1+17 = 77
|
|
||||||
orchestrator-core 74 (was 62 → +12; realtime SSE + broadcaster + key cache tests)
|
|
||||||
stdlib 43
|
|
||||||
sdk_contract 30
|
|
||||||
modules 23
|
|
||||||
picloud 21 (incl. 6 dispatcher_e2e skipping no-op)
|
|
||||||
schema_snapshot 1
|
|
||||||
shared/pubsub 6 (or somewhere thereabouts)
|
|
||||||
files-related 20
|
|
||||||
───
|
|
||||||
~550
|
|
||||||
```
|
|
||||||
|
|
||||||
The agent's 482 count was likely a snapshot taken before the final commit added a test file, or a `cargo test --workspace 2>&1 | grep -c "passed"` (counts lines, not values) misread. Either way:
|
**3. Queue claim + ack + nack are correctly lease-token-keyed.** Both `ack` and `nack` filter `WHERE id = $1 AND claim_token = $2`. A stale dispatcher whose visibility-timeout expired and lost its lease cannot accidentally delete or re-defer a re-claimed message. The reclaim task's UPDATE...FROM JOIN against the live `(triggers, queue_trigger_details)` set ensures only enabled queue consumers' messages are reclaimed. Race-free in the strong sense.
|
||||||
|
|
||||||
- The discrepancy is in **the count, not the outcome**: 0 failed, 0 ignored unexpected.
|
**4. The dispatcher gate-saturation handling for the queue arm.** When the gate is full, the queue arm immediately nacks-with-100ms-delay, keeping the lease accounting consistent and letting the next tick re-claim cleanly. This mirrors the outbox arm's reschedule semantics exactly — symmetric load-shed across both event-firing paths.
|
||||||
- The gates exit 0; clippy is clean; fmt is clean.
|
|
||||||
- The implementation passes every named-critical test from the prompt's §13.
|
|
||||||
|
|
||||||
**Verdict: minor accounting drift, not a blocker.** Flag for the v1.1.7 retro: the §8 attestation should be the literal `cargo test --workspace` final-line output (`X passed in Y crates`) or a sum verified by `awk '/test result: ok/ { sum += $4 } END { print sum }'`, not a hand count.
|
**5. `retry::policy` clamping is saturating + transparent.** `max_attempts ∈ [1, 20]`, `base_ms ∈ [1, 60_000]`, `jitter_pct ∈ [0, 100]`. Bogus backoff strings surface a clear error pointing at the three valid values. Deterministic jitter via `DefaultHasher` over `(span, raw)` avoids pulling `rand` into the hot path — correctness-equivalent for retry purposes.
|
||||||
|
|
||||||
## 6. Substantive strengths
|
**6. The 20-commit split is exemplary, again.** Migration → types → repo → service → SDK → dispatcher arm → next service (invoke) → next dispatcher arm → next SDK (retry) → admin HTTP → dashboard → tests → F4 dedup → version bumps → schema bless → test fixes → clippy clean → docs. Each commit independently green. Best commit hygiene of any v1.1.x release alongside v1.1.7 + v1.1.8.
|
||||||
|
|
||||||
**1. Streaming-`fetch` SSE in the client lib was the right call.** Native `EventSource` can't set custom auth headers (forcing the `?token=` query-string path, which the server still supports as an EventSource-compat option). But for the client lib, dropping EventSource in favor of streaming `fetch` unlocks three things at once: bearer-header auth (cleaner than query-string), 401 detection on (re)connect → `onTokenExpired` callback → token refresh → reconnect, and `Last-Event-ID` resume header (server ignores it in v1.1.6 but the client ships ready). Trade-off: requires `fetch` streaming, so React Native needs a polyfill — the README documents this. Right trade for v1.1.6's target audience.
|
**7. Integration test density target met in full (F1).** Four new DB-gated binaries (15 tests), exactly the names the brief specified as non-negotiable. The `queue_visibility_timeout_reclaim` test actually exercises the reclaim by mutating `claimed_at` to simulate a crashed consumer — that's the load-bearing semantic, not a smoke test.
|
||||||
|
|
||||||
**2. The HMAC-signing-key persisted-table choice avoids a global secret.** The agent took the recommended path: per-app 32-byte random keys in `app_secrets`. No `PICLOUD_INSTANCE_SECRET` env var to operate. Future v1.1.7 encrypted-per-app-secrets work has its natural home. The cost — one DB read per subscribe — is mitigated by the in-process key cache (correct in v1.1.6 because keys never rotate; HANDBACK §12 flags the rotation-invalidation requirement for future).
|
## 5. Open questions answered
|
||||||
|
|
||||||
**3. Defense in depth on cross-app isolation.** Per-app signing key + explicit `claims.app_id == app_id` check + broadcaster channels keyed by `(AppId, topic)`. Any single guard would suffice; all three together make the boundary obvious in code AND impossible to bypass via a single mistake.
|
HANDBACK §9 raises four:
|
||||||
|
|
||||||
**4. Three v1.1.5 follow-ups all landed.** The empty-blob relaxation, the orphan tmp-sweeper, the six dispatcher e2e tests. All in this release, not deferred. The e2e tests are gated on `DATABASE_URL` cleanly via early-return (matches the v1.1.5 schema_snapshot pattern); CI's Postgres service exercises them.
|
1. **`retry::run` rename acceptable?** Yes. `run` is clear and unambiguous in this context. `attempt` would have been a contender but reads worse with closures. No change needed.
|
||||||
|
|
||||||
**5. The agent's discipline carryover is exemplary.** Both flagged items (§4-vs-§8 contradiction, dead_letter latent finding) were caught by the v1.1.4 + v1.1.3 retro discipline lessons: brief-internal contradictions get flagged-not-reinterpreted, latent security/correctness findings get their own HANDBACK section. The §8 attestation was taken on the actual HEAD with the explicit "this HANDBACK commit is pure markdown" footnote. Every deviation is in §7. The system is working.
|
2. **`invoke()` path-resolution method.** Defaulted to POST→GET fallback. Acceptable for v1.1.9; method-aware resolution is a v1.2 ergonomics item (per HANDBACK §11 deferred list).
|
||||||
|
|
||||||
**6. Commit split.** Three commits — realtime+followups+versions, client lib, handback. Cleaner than v1.1.5's three substantive (because the client lib genuinely is a standalone artifact in a different toolchain), and the build-app cross-crate constraint that drove a single big realtime commit is honestly documented in HANDBACK §7 #1.
|
3. **`ctx.trigger_depth` not in `ctx` map.** The depth value IS threaded through `SdkCallCx` correctly (verified by `invoke_depth_limit_exceeds_cleanly`). Surfacing it into Rhai's `ctx` map is a 5-line addition for v1.2.
|
||||||
|
|
||||||
## 7. Smaller observations (no action required)
|
4. **Cluster-mode reclaim coordination.** Out of scope per the brief; v1.3+ cluster work will need advisory-lock coordination for the reclaim task.
|
||||||
|
|
||||||
- **Dispatcher e2e location deviation (HANDBACK §7 #1).** Brief said `crates/manager-core/tests/`; agent put them in `crates/picloud/tests/` because `build_app` lives there. The cycle the agent describes (manager-core → picloud dev-dep) is real. The picloud location is correct — `build_app` is where the dispatcher + scheduler + executor are wired into one stack, and that wiring is what the e2e tests need to exercise.
|
## 6. Smaller observations
|
||||||
- **Empty-blob relaxation extended to `FileUpdate::validate` (HANDBACK §7 #2).** The brief only named `NewFile::validate`. Extending to update is correct — relaxing create-empty but rejecting update-to-empty would be an inconsistent API.
|
|
||||||
- **Topic-name validation (HANDBACK §7 #4).** Small defensive add: empty names and names containing `*` rejected at admin endpoint. Defends against operator confusion when topic-pattern external subscription lands in v1.2 (preemptive: `*` will mean something there, so reserving it now avoids a future breaking validation).
|
|
||||||
- **Client lib lint via `tsc --noEmit` instead of eslint (HANDBACK §7 #5).** Right call for v1.1.6 — strict TS does most of what eslint would, and adding eslint configuration is a separate scope of work. Easy to add later if there's a real type-system-can't-catch-it lint rule we need.
|
|
||||||
- **Cron e2e 45s poll budget (HANDBACK §7 #6).** Defensive against the default 30s tick interval; CI sets `PICLOUD_CRON_TICK_INTERVAL_MS=1000` to make it ~2s. Reasonable.
|
|
||||||
- **`broadcast::Sender` shape vs `oneshot::Sender`.** v1.1.1's `InboxRegistry` uses oneshot (single delivery). v1.1.6's broadcaster uses `tokio::sync::broadcast` (repeated delivery to multiple receivers). Different patterns for different problems; the trait split in shared keeps both Cluster-mode-swappable.
|
|
||||||
|
|
||||||
## 8. Versioning audit
|
- **L1 — pre-existing clippy lints surfaced during F3.** Six lints (two on new v1.1.9 code, four on new v1.1.9 SDK paths) — all fixed in `c3baa87 chore(v1.1.9): clippy clean`. Honest call; the lints were latent until the agent ran clippy properly per F3.
|
||||||
|
- **L2 — dashboard pre-existing Playwright errors (149)** in `tests/e2e/**/*.spec.ts`. Same as v1.1.8; not v1.1.9's concern.
|
||||||
|
- **`InvokeServiceImpl` constructor signature is 3 args** — well below the 10-arg threshold that triggers builder-pattern temptation. Clean.
|
||||||
|
- **Closures-across-`invoke()` rejected at the bridge** with a clear error per HANDBACK §12. Right call — captured environments don't translate across `SdkCallCx` boundaries.
|
||||||
|
|
||||||
|
## 7. Versioning audit
|
||||||
|
|
||||||
| File | Before | After | Status |
|
| File | Before | After | Status |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Workspace `Cargo.toml` | 1.1.5 | 1.1.6 | ✅ |
|
| Workspace `Cargo.toml` | 1.1.8 | 1.1.9 | ✅ |
|
||||||
| SDK schema (`shared/src/version.rs`) | 1.6 | 1.7 | ✅ correctly bumped — `RealtimeBroadcaster`, `RealtimeEvent`, `RealtimeAuthority`, topic types, `pubsub::subscriber_token` added |
|
| SDK schema (`shared/src/version.rs`) | 1.9 | 1.10 | ✅ Public surface added: `QueueService`, `queue::*`, `invoke()`, `invoke_async()`, `retry::*`, `ctx.event.queue`, `TriggerEvent::Queue` |
|
||||||
| Dashboard `package.json` | 0.11.0 | 0.12.0 | ✅ |
|
| Dashboard `package.json` | 0.14.0 | 0.15.0 | ✅ |
|
||||||
| `@picloud/client` package.json | (new) | 1.0.0 | ✅ Initial release |
|
| `@picloud/client` | 1.0.0 | 1.0.0 | ✅ (no client work this release, per brief) |
|
||||||
| Migrations | 0001..0020 | 0021..0022 added | ✅ sequential |
|
| Migrations | 0001..0033 | 0034..0035 added | ✅ Sequential |
|
||||||
| CHANGELOG.md | v1.1.5 entry | v1.1.6 entry added | ✅ |
|
| CHANGELOG.md | v1.1.8 entry | v1.1.9 entry (no upgrade constraint — pure additive) | ✅ |
|
||||||
|
|
||||||
## 9. Recommended next steps (post-merge)
|
## 8. Recommended next steps (post-merge)
|
||||||
|
|
||||||
1. **Merge** `feat/v1.1.6-realtime-client` into `main` (fast-forward; branch is linear ahead).
|
1. **Merge** `feat/v1.1.9-queues-invoke` into `main` (fast-forward; branch is linear ahead).
|
||||||
2. **`docker compose down` when convenient** to tear down the dev Postgres container the agent left running.
|
2. **`docker compose down` when convenient** to tear down the dev Postgres container.
|
||||||
3. **Pause** before dispatching v1.1.7 (Configuration & Email).
|
3. **End of v1.1.x.** v1.2 (Workflows & Hierarchies — DAG execution, advanced docs query, interceptors, read triggers, audit log, script-mediated realtime auth, `dead_letters::list`, client-lib type codegen) is the next phase milestone.
|
||||||
4. **For the v1.1.7 dispatch prompt**, fold in:
|
4. **For v1.2 design intake**, fold in:
|
||||||
- **Fix `dead_letter` handler wiring** (§4 above). Add a call to `list_matching_dead_letter` in `dispatcher::handle_failure` after the row is inserted, enqueue an outbox row per matching trigger with `source_kind: 'dead_letter'` and the appropriate `TriggerEvent::DeadLetter` payload. The recursion-stop rule (handlers can't be dead-lettered) is already implemented; the wiring just isn't connected. Plus a CHANGELOG retroactive note covering v1.1.1–v1.1.6 ("dead_letter triggers were registerable but never fired; this release activates them against existing dead_letters rows, no migration needed").
|
- **`ctx.trigger_depth` surface in the Rhai `ctx` map** (5-line addition per HANDBACK §9 #3).
|
||||||
- **Encrypted per-app secrets.** v1.1.7's brief topic. The `app_secrets` table from v1.1.6 is the natural home; the realtime signing key already lives there. v1.1.7's encrypted-secrets work should extend the table (or add a sibling) for general per-app secrets.
|
- **`invoke()` method-aware route resolution** (POST→GET fallback today; explicit method arg per HANDBACK §9 #2).
|
||||||
- **§8 attestation discipline refinement:** require the §8 attestation be sourced from `cargo test --workspace 2>&1 | tail` rather than a hand count (per §5 above).
|
- **AST cache reuse across `invoke()` calls** (D2 deferred optimization).
|
||||||
- **The brief-internal-contradiction discipline lesson stuck.** v1.1.7's brief should be walked through for example/spec contradictions before dispatch, same as the v1.1.5 retro lesson the v1.1.6 brief honored. Keep doing this.
|
- **Permission matrix design for `users::*` roles** — v1.1.8 follow-up that's been waiting for the v1.2 design conversation.
|
||||||
5. **For the v1.1.8 dispatch prompt** (User Management): the `app_secrets` table + the `RealtimeAuthority` trait shape are ready for v1.1.8 to add `auth_mode = 'session'` as the third subscriber-auth flavor (extending the CHECK constraint, adding a session-token validator alongside the existing HMAC validator behind the unchanged trait).
|
- **Queue mutating admin endpoints** (purge, requeue, delete-message) — needs a payload-viewer UX story.
|
||||||
|
5. **For v2 design intake, archive `docs/v1.1.x-design-notes.md`** — every section's decisions have shipped (the doc's own lifecycle note says "Document deleted when v1.1.9 ships").
|
||||||
|
|
||||||
Branch is ready for merge. Verdict: **APPROVE**.
|
Branch is ready for merge. Verdict: **APPROVE**.
|
||||||
|
|||||||
195
SECURITY_AUDIT.md
Normal file
195
SECURITY_AUDIT.md
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
# PiCloud Security Audit — 2026-06-11
|
||||||
|
|
||||||
|
10 parallel focused subagents reviewed the codebase at `main` (post-v1.1.9, pre-v1.2) for vulnerabilities in their assigned category. Each agent wrote a detailed findings file under [`security_audit/`](security_audit/); this document is the rollup.
|
||||||
|
|
||||||
|
## Scope and method
|
||||||
|
|
||||||
|
Each agent traced specific code paths end-to-end, cited file + line, classified by severity, and recommended a concrete fix. The audit covers **the platform as deployed in single-node MVP mode** (`picloud` binary + Caddy + Postgres + dashboard SPA). Cluster mode (`picloud-manager` / `-orchestrator` / `-executor` skeleton crates) is explicitly out of scope and surfaced only where it would silently degrade a current defense.
|
||||||
|
|
||||||
|
Severity rubric:
|
||||||
|
- **Critical** — exploitable now by a low-privilege or anonymous attacker, with high impact (RCE, full takeover, cross-tenant data breach).
|
||||||
|
- **High** — realistic exploit under normal deployment; privilege escalation, session hijack, easy DoS on the host.
|
||||||
|
- **Medium** — defense-in-depth gap that becomes important under a second flaw; missing cap/quota that matters at scale.
|
||||||
|
- **Low** — hardening; small impact even if exploited.
|
||||||
|
- **Info** — observation worth recording, not a vulnerability.
|
||||||
|
|
||||||
|
## Totals
|
||||||
|
|
||||||
|
| # | Agent | C | H | M | L | I | Findings file |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| 01 | AuthN, session & token lifecycle | 0 | 2 | 6 | 4 | 5 | [01_authn_session.md](security_audit/01_authn_session.md) |
|
||||||
|
| 02 | Authorization & cross-app isolation | 0 | 1 | 4 | 4 | 4 | [02_authz_isolation.md](security_audit/02_authz_isolation.md) |
|
||||||
|
| 03 | Cryptography & secret handling | 0 | 2 | 5 | 3 | 6 | [03_crypto_secrets.md](security_audit/03_crypto_secrets.md) |
|
||||||
|
| 04 | Injection & query safety | 0 | 0 | 5 | 4 | 9 | [04_injection.md](security_audit/04_injection.md) |
|
||||||
|
| 05 | Rhai sandbox & script execution | 0 | 4 | 6 | 5 | 4 | [05_sandbox_exec.md](security_audit/05_sandbox_exec.md) |
|
||||||
|
| 06 | File storage & path traversal | 0 | 2 | 4 | 4 | 2 | [06_files_pathtraversal.md](security_audit/06_files_pathtraversal.md) |
|
||||||
|
| 07 | HTTP, CORS, CSRF & frontend XSS | **2** | 3 | 4 | 3 | 2 | [07_http_cors_csrf_xss.md](security_audit/07_http_cors_csrf_xss.md) |
|
||||||
|
| 08 | DoS, rate limiting & resource exhaustion | 0 | 5 | 7 | 6 | 3 | [08_dos_resource.md](security_audit/08_dos_resource.md) |
|
||||||
|
| 09 | External integrations (SSRF, webhooks) | 0 | 1 | 3 | 3 | 4 | [09_external_integrations.md](security_audit/09_external_integrations.md) |
|
||||||
|
| 10 | Information disclosure, logging, CLI | 0 | 2 | 4 | 4 | 5 | [10_info_disclosure_cli.md](security_audit/10_info_disclosure_cli.md) |
|
||||||
|
| | **Total (raw)** | **2** | **22** | **48** | **40** | **44** | |
|
||||||
|
|
||||||
|
A few findings show up in multiple agents (stored-XSS-via-uploads, dashboard-token-in-localStorage). De-duplicated below.
|
||||||
|
|
||||||
|
## Headline
|
||||||
|
|
||||||
|
- The **boundaries that matter most are intact**. SDK `SdkCallCx.app_id` discipline is held end-to-end (agent 2 traced two SDK paths Rhai→SQL and confirmed no script-controlled `app_id` enters the chain). SQL is parameterized cleanly across 75 dynamic call-sites (agent 4). Argon2id parameters, RNG selection, and constant-time HMAC verification are correct everywhere it matters (agents 1 and 3). SSRF defenses (link-local block + DNS-rebinding + redirect re-resolve + cross-origin `Authorization` scrub) are unusually thorough and verified end-to-end (agent 9). No `unsafe` blocks exist in `executor-core` or `orchestrator-core`; no sandbox-escape was found (agent 5).
|
||||||
|
- The **gaps cluster in HTTP-layer hardening and resource limits**. There is effectively no defense-in-depth at the HTTP boundary (no CSP, no nosniff, no HSTS, no X-Frame-Options) and effectively no rate limiting on anonymous-reachable endpoints (login, email-inbound, fan-out triggers). The combination of cookie-auth + co-hosted user-route HTML + bare admin headers is what creates the two **Critical** findings.
|
||||||
|
|
||||||
|
## Critical findings
|
||||||
|
|
||||||
|
### C-1. Same-origin CSRF on `/api/v1/admin/*` via `SameSite=Lax` cookie + co-hosted user-route HTML
|
||||||
|
Agent 7 → [C07-01](security_audit/07_http_cors_csrf_xss.md#c07-01-critical--same-origin-csrf-on-admin-api-via-samesitelax-cookie--user-route-surface).
|
||||||
|
|
||||||
|
`POST /auth/login` ([crates/manager-core/src/auth_api.rs:229](crates/manager-core/src/auth_api.rs#L229)) sets `picloud_session=...; HttpOnly; Secure; SameSite=Lax`. The auth middleware ([auth_middleware.rs:91, 359](crates/manager-core/src/auth_middleware.rs#L91)) accepts the cookie as a fallback to `Authorization: Bearer`. User scripts serve arbitrary HTML on the same origin as the admin API (Caddyfile catch-all). `SameSite=Lax` does **not** block same-origin requests, so any user with `AppScriptsWrite` who publishes a script at e.g. `/evil` can host a page that POSTs to `/api/v1/admin/...` and the admin's cookie rides along.
|
||||||
|
|
||||||
|
**Fix**: drop the cookie auth path on `/api/v1/admin/*` (the dashboard already uses bearer tokens), **or** require both the cookie and a synchronizer-token header on state-changing methods.
|
||||||
|
|
||||||
|
### C-2. Stored XSS via uploaded files served `inline` with no `nosniff`/CSP under the admin origin
|
||||||
|
Agent 7 → [C07-02](security_audit/07_http_cors_csrf_xss.md#c07-02-critical--stored-xss-on-admin-origin-via-uploaded-files-served-inline-with-no-nosniff), agent 6 → [F-FS-001](security_audit/06_files_pathtraversal.md#f-fs-001--missing-mime-allowlist-enables-stored-xss-via-content-disposition-inline-on-the-admin-download-endpoint) (joint).
|
||||||
|
|
||||||
|
`GET /apps/{id}/files/{collection}/{file_id}` ([files_api.rs:130-164](crates/manager-core/src/files_api.rs#L130-L164)) streams blob bytes with `Content-Disposition: inline; filename="..."`, the **user-supplied** `Content-Type`, no `nosniff`, no CSP. `NewFile::validate` only caps `content_type` length, no allowlist. A Rhai script (anonymous-callable HTTP route is enough) can stash an `image/svg+xml` or `text/html` blob; when an operator clicks Download in the dashboard, the file renders **same-origin** with the admin session cookie attached → session hijack.
|
||||||
|
|
||||||
|
**Fix (response side, owned by agent 7's recommendation)**: serve all download responses with `Content-Disposition: attachment`, `X-Content-Type-Options: nosniff`, and `Content-Security-Policy: default-src 'none'; sandbox; frame-ancestors 'none'`. Ideally serve file blobs from a distinct origin (`files.<host>`). **Fix (storage side, agent 6)**: maintain a small MIME allowlist at `NewFile::validate` / `FileUpdate::validate`; force `application/octet-stream` on unknown types.
|
||||||
|
|
||||||
|
## High-severity findings (22, grouped by theme)
|
||||||
|
|
||||||
|
### HTTP hardening (4)
|
||||||
|
Both Criticals would be partially mitigated by these alone:
|
||||||
|
- **H-A1.** No CSP anywhere — [agent 7 H07-03](security_audit/07_http_cors_csrf_xss.md#h07-03-high--no-content-security-policy-anywhere) (`caddy/Caddyfile{,.prod}`, `docker/dashboard.Dockerfile:28`).
|
||||||
|
- **H-A2.** No `X-Frame-Options` / `frame-ancestors` → dashboard is clickjackable — [agent 7 H07-04](security_audit/07_http_cors_csrf_xss.md#h07-04-high--no-x-frame-options--frame-ancestors-dashboard-clickjackable).
|
||||||
|
- **H-A3.** No HSTS in production Caddyfile — [agent 7 H07-05](security_audit/07_http_cors_csrf_xss.md#h07-05-high--no-hsts-in-production-caddyfile).
|
||||||
|
- **H-A4.** Dashboard stores bearer token in `localStorage` (XSS-readable) — [agent 10 H1](security_audit/10_info_disclosure_cli.md#h1--dashboard-bearer-token-stored-in-localstorage-xss-readable) / [agent 1 Medium](security_audit/01_authn_session.md) / [agent 7 L07-12](security_audit/07_http_cors_csrf_xss.md#l07-12-low--dashboard-token-in-localstorage). Listed here because the CSP gap (H-A1) is what blocks the cheap fix. Agent 7 calls it Low **because** CSP is the upstream remediation.
|
||||||
|
|
||||||
|
Agent 7 ships a ready-to-paste Caddyfile snippet covering all four.
|
||||||
|
|
||||||
|
### DoS / resource exhaustion on anonymous-reachable surfaces (5)
|
||||||
|
- **H-B1.** Login endpoint has no rate limit; Argon2id per attempt → an anonymous attacker can pin every `spawn_blocking` worker on Argon2 in seconds on Pi-class hardware. [agent 8 H-2](security_audit/08_dos_resource.md#h-2--login-endpoint-has-no-rate-limit-argon2id-is-the-work-multiplier), confirmed by [agent 1](security_audit/01_authn_session.md).
|
||||||
|
- **H-B2.** Email-inbound webhook accepts unsigned POSTs when `inbound_secret` is absent; with a secret configured there's still no per-`(app_id, trigger_id)` bad-signature bucket. [agent 8 H-3](security_audit/08_dos_resource.md#h-3--email-inbound-webhook-has-no-ip-rate-limit-hmac-is-optional), [agent 9 F-EXT-M-001](security_audit/09_external_integrations.md#f-ext-m-001--email-inbound-webhook-accepts-unsigned-posts-when-inbound_secret-is-none).
|
||||||
|
- **H-B3.** No per-app cap on trigger / route / cron registration; one tenant can register thousands of cron rows or fan-out triggers and stall the node. [agent 8 H-4](security_audit/08_dos_resource.md#h-4--no-per-app-cap-on-trigger--route--cron-registration).
|
||||||
|
- **H-B4.** `cron_scheduler::tick` does `fetch_all` with no `LIMIT`, serially fires every due row inside one transaction. [agent 8 H-5](security_audit/08_dos_resource.md#h-5--cron-scheduler-tick-is-unbounded-fetch_all-one-slow-row-blocks-all).
|
||||||
|
- **H-B5.** Axum's default 2 MB body limit applies to email-inbound + admin JSON handlers; only the user-route handler ([orchestrator-core/src/api.rs:219](crates/orchestrator-core/src/api.rs#L219)) is explicit at 10 MiB. No Caddy `request_body { max_size }`. [agent 8 H-1](security_audit/08_dos_resource.md#h-1--no-axum-default-body-limit).
|
||||||
|
|
||||||
|
### Rhai sandbox (4)
|
||||||
|
- **H-C1.** `tokio::time::timeout` over `JoinHandle` does **not** cancel `spawn_blocking` — a runaway script holds its OS thread until the op-budget self-exhausts. The code's own comment admits this. One anonymous script call can permanently subtract a worker from the blocking-thread pool. [agent 5 F-SE-H-01](security_audit/05_sandbox_exec.md#f-se-h-01--tokiotimetimeout-over-joinhandle-does-not-cancel-a-spawn_blocking-task-runaway-script-keeps-its-os-thread-until-self-completion). **Fix**: install `engine.on_progress` with a deadline check.
|
||||||
|
- **H-C2.** SDK service calls (kv/docs/files/secrets/pubsub/queue/users/email/http/invoke) have **no per-execution count cap**. One script can chain 1 M kv reads, N file writes, N emails. [agent 5 F-SE-H-02](security_audit/05_sandbox_exec.md#f-se-h-02--sdk-service-calls-kvdocspubsubqueuesecretsfilesusersemailhttpinvoke-are-not-rate-limited-per-execution-one-invocation-can-issue-unbounded-io).
|
||||||
|
- **H-C3.** `engine.disable_symbol("print")` but **not** `debug` — the latter writes attacker-controlled bytes to stderr (operator logs). [agent 5 F-SE-H-03](security_audit/05_sandbox_exec.md#f-se-h-03--print-is-disabled-but-debug-is-not-rhais-debug-writes-to-stderr-by-default-and-the-comment-claims-both-are-disabled).
|
||||||
|
- **H-C4.** `ExecError::Runtime` propagates verbatim Rhai/SDK error strings to the **public** HTTP response body — internal paths, "blocked by SSRF policy: link-local" reconnaissance, Rust backtrace fragments. [agent 5 F-SE-H-04](security_audit/05_sandbox_exec.md#f-se-h-04--execerrorruntime-propagates-verbatim-rhaisdk-error-text-to-the-public-http-response-body-info-disclosure).
|
||||||
|
|
||||||
|
### Cryptography (2)
|
||||||
|
- **H-D1.** `secrets` ciphertext is not bound to `(app_id, name)` as AAD. Anyone with Postgres write access can ciphertext-swap rows across apps or rename-via-row-edit; the decrypt succeeds under the wrong identity, returning attacker-chosen plaintext. Same gap on `app_secrets.realtime_signing_key` and the email-trigger `inbound_secret`. [agent 3 first High + email-trigger Medium + app-secrets Medium](security_audit/03_crypto_secrets.md#high). **Fix**: bind `aad = "secret:{app_id}:{name}"` and analogues; fold into v1.2's planned key-versioning + re-encryption pass.
|
||||||
|
- **H-D2.** Dev-key fallback is `SHA-256("picloud-dev-master-key-v1.1.7")` — a leaked dev-mode DB dump is trivially decryptable. The `PICLOUD_DEV_INSECURE_KEY` ack helps but the warning is a single `tracing::warn!`. [agent 3 second High](security_audit/03_crypto_secrets.md#master-key-sourcing-accepts-both-standard-and-url-safe-base64-but-the-dev-key-fallback-is-a-sha-256-of-a-public-string--making-dev-mode-trivially-decryptable-post-hoc).
|
||||||
|
|
||||||
|
### Authentication & session lifecycle (2)
|
||||||
|
- **H-E1.** Dashboard self-password-change does **not** invalidate other live sessions or API keys ([admin_users_api.rs:232-241](crates/manager-core/src/admin_users_api.rs#L232-L241)). CLI `reset-password` and account deactivation both do invalidate; only the in-product UI rotation is silent — the natural reaction to suspected compromise. [agent 1](security_audit/01_authn_session.md#dashboard-self-password-change-does-not-invalidate-other-live-sessions-or-api-keys).
|
||||||
|
- **H-E2.** Bootstrap can be re-triggered by hard-deleting all admin rows (`admin_users` is `delete_admin`-hard-DELETE, no soft-delete) when `PICLOUD_BOOTSTRAP_*` env vars are left in compose. [agent 1](security_audit/01_authn_session.md#bearer-token-bootstrap-can-be-re-triggered-by-deleting-all-admins).
|
||||||
|
|
||||||
|
### Authorization defense-in-depth (1)
|
||||||
|
- **H-F1.** The queue-trigger dispatcher ([dispatcher.rs:290-340](crates/manager-core/src/dispatcher.rs#L290-L340)) builds `ExecRequest` with `app_id: claimed.app_id` and `script_id: consumer.script_id` but does **not** cross-check `script.app_id == consumer.app_id` — unlike its sibling `build_invoke_request` (line 752), which does. Safe today by construction (`validate_trigger_target` enforces same-app at write time) but no runtime defense; a hand-edited trigger row or partial backup restore would silently execute one app's script under another app's `SdkCallCx`. Same gap on `build_http_request` (Low). [agent 2](security_audit/02_authz_isolation.md#queue-dispatcher-does-not-assert-scriptapp_id--claimedapp_id).
|
||||||
|
|
||||||
|
### File storage defense-in-depth (1)
|
||||||
|
- **H-G1.** Admin file endpoints (`list_files`, `get_file`, `delete_file`) skip `validate_files_collection`; repo `head`/`get`/`list`/`delete` skip `guard_collection`. Only `create`/`update` validate. Safe today but one bad migration / restore tool can insert a row with `collection = "../../etc"` and the next `get_file` reads arbitrary host files. [agent 6 F-FS-002](security_audit/06_files_pathtraversal.md#f-fs-002--admin-files-api-skips-collection-validation-underlying-headgetlistdelete-skip-the-fs-path-guard-too).
|
||||||
|
|
||||||
|
### External integrations (1)
|
||||||
|
- **H-H1.** `email::send` from scripts has no rate limit. The `EmailRateLimiter` token bucket exists but is wired only into `users_service` (verification / password-reset flows). A public-HTTP-callable script can burn the operator's SMTP quota or BCC-bomb thousands of recipients per request. [agent 9 F-EXT-H-001](security_audit/09_external_integrations.md#f-ext-h-001--emailsend-from-scripts-is-not-rate-limited-operators-smtp-relay-is-a-free-amplifier).
|
||||||
|
|
||||||
|
### CLI / operator tooling (1)
|
||||||
|
- **H-J1.** `pic admins create/set --password VALUE` and `pic login --token VALUE` accept secrets on **argv** — they land in shell history, `ps aux`, and `/proc/<pid>/cmdline`. Help text warns; the flag still works. Also, both `pic admins`' `read_password_from_stdin` and `picloud admin reset-password`'s prompt advertise "no echo" but use `read_line`, which echoes. [agent 10 H2 + M2 + L1](security_audit/10_info_disclosure_cli.md#h2--pic-admins-createset---password-value-accepts-the-password-on-argv).
|
||||||
|
|
||||||
|
## Mediums and Lows worth surfacing
|
||||||
|
|
||||||
|
Full lists live in each agent's file. Highlights:
|
||||||
|
|
||||||
|
- **Admin sessions have no absolute (hard-cap) expiry** — only sliding TTL — agent 1.
|
||||||
|
- **Password change does not require current-password re-verification** — a hijacked session escalates to permanent ownership — agent 1.
|
||||||
|
- **`PICLOUD_COOKIE_SECURE=off` accepted silently** even when `PUBLIC_BASE_URL` is HTTPS — agent 1.
|
||||||
|
- **`get_script` / `list_routes_for_script` return 404 before authz** → cross-app id enumeration — agent 2.
|
||||||
|
- **`routes:check` / `routes:match` trust a body-supplied `app_id`** — agent 2.
|
||||||
|
- **Inactive-principal cron jobs never get the trigger disabled** → re-fires on reactivation under reduced privileges — agent 2.
|
||||||
|
- **`MasterKey` + decrypted plaintexts never zeroize on drop** — agent 3.
|
||||||
|
- **`auth_middleware::resolve_principal` principal-cache is keyed by token-hash with no eviction on logout/password-change/deactivation** — revocation lag bounded only by TTL — agent 3.
|
||||||
|
- **`content_type` accepts CRLF** → response-header injection / handler panic on download — agent 4.
|
||||||
|
- **`tracing` text-formatter is vulnerable to log forging via script-controlled queue/topic/collection names** — agent 4.
|
||||||
|
- **`serde_json::from_str` in `json::parse` SDK and email-inbound has no recursion / size limits** → stack overflow → process kill — agents 4 and 5.
|
||||||
|
- **No `max_modules_per_execution` on Rhai imports**; `invoke()` re-entry inherits caller's permits — agent 5.
|
||||||
|
- **No per-app quota on files / KV / docs / queue depth / queue count** — agent 6, agent 8.
|
||||||
|
- **No `X-Content-Type-Options: nosniff` / `Referrer-Policy` / `Permissions-Policy` / `Cache-Control: no-store`** anywhere — agent 7.
|
||||||
|
- **Outbox has no time-based retention sweep**; stuck-claimed rows accumulate — agent 8.
|
||||||
|
- **No SSE subscriber cap per IP / app / process** — agent 8.
|
||||||
|
- **No global cap on in-flight HTTP connections / slowloris exposure** — agent 8.
|
||||||
|
- **HTTP-async retries have no per-target circuit breaker** — agent 8.
|
||||||
|
- **`http_service::validate_url` blocks ports 22/25/465/587 but not 23/110/143/993/995/3306/6379** — narrow exploit window but allow-list would be safer — agent 9.
|
||||||
|
- **Distinguishable 404s** ("no app claims host" vs "no route matches") enable app enumeration via Host header — agent 10.
|
||||||
|
|
||||||
|
## Cross-cutting recommendations (priority order)
|
||||||
|
|
||||||
|
Closing these in roughly this order would land the most defensive value per unit work.
|
||||||
|
|
||||||
|
### Tier 1 — close the Criticals (no schema changes; one PR each)
|
||||||
|
1. **Drop cookie auth on `/api/v1/admin/*`** (or require synchronizer token). Closes C-1.
|
||||||
|
2. **Force download responses to `Content-Disposition: attachment` + `nosniff` + restrictive CSP on file-serving routes**; add a MIME allowlist at upload validation. Closes C-2. Joint owner: agents 6+7.
|
||||||
|
3. **Add the security-header layer to Caddy** (CSP for `/admin/*` and `/api/v1/admin/*`, nosniff everywhere, HSTS in prod, `frame-ancestors 'none'`). Closes H-A1, H-A2, H-A3 and turns H-A4 (localStorage token) from acute to defense-in-depth. Agent 7 ships a ready-to-paste snippet.
|
||||||
|
|
||||||
|
### Tier 2 — close the highest-leverage Highs
|
||||||
|
4. **Login rate limit + global Argon2 semaphore** (H-B1). Cheapest CPU-DoS in the system; pattern already exists in `EmailRateLimiter`.
|
||||||
|
5. **Install `engine.on_progress` deadline check** so wall-clock budget actually cancels a runaway Rhai loop (H-C1).
|
||||||
|
6. **Bind `aad = "secret:{app_id}:{name}"`** in the AES-GCM seal/open paths for `secrets`, `app_secrets`, and email-trigger inbound secrets (H-D1). Fold into the v1.2 key-versioning pass already on the roadmap.
|
||||||
|
7. **Wipe other sessions + revoke all API keys on self-password-change** (H-E1). Mirror `cmd_reset_password`'s behavior.
|
||||||
|
8. **Make `inbound_secret` mandatory on email triggers**; add per-`(app_id, trigger_id)` bad-signature token bucket (H-B2).
|
||||||
|
9. **Mirror the `script.app_id == row.app_id` cross-check from `build_invoke_request` into queue + HTTP dispatcher arms** (H-F1, plus the Low sibling in `build_http_request`).
|
||||||
|
10. **Rate-limit `email::send` from scripts** by moving `EmailRateLimiter` into `EmailServiceImpl` and adding a per-message recipient cap (H-H1).
|
||||||
|
|
||||||
|
### Tier 3 — Hardening sweep
|
||||||
|
11. **Per-app caps**: trigger/route/cron count (H-B3), cron-scheduler `LIMIT 1000` per tick (H-B4), explicit `DefaultBodyLimit` at router root + Caddy `request_body { max_size }` (H-B5).
|
||||||
|
12. **Add per-execution SDK counters** to `SdkCallCx` (kv-ops, files, emails, http, pubsub publishes, invoke depth) — closes H-C2 and bounds invoke amplification.
|
||||||
|
13. **`engine.disable_symbol("debug")`** — one-line, closes H-C3.
|
||||||
|
14. **Scrub `ExecError::Runtime` for unauthenticated callers** — closes H-C4 plus the principal-leak from join-panic strings.
|
||||||
|
15. **Belt-and-suspenders collection validation** in repo `head`/`get`/`list`/`delete` and `validate_files_collection` at admin endpoints — closes H-G1.
|
||||||
|
16. **Bootstrap sentinel**: replace `count_active() > 0` gate with a persistent `bootstrap_done` marker — closes H-E2.
|
||||||
|
17. **Reject `--password VALUE` / `--token VALUE` argv flags**; require `--password -` (stdin). Fix `read_line` → `rpassword::prompt_password` on every echo-claiming prompt. Closes H-J1.
|
||||||
|
|
||||||
|
### Tier 4 — Quotas and observability (paves the way for v1.2)
|
||||||
|
18. **Per-app aggregate quotas**: KV row count, files bytes, queue depth, queue count, docs collection size — agents 6 and 8.
|
||||||
|
19. **Absolute (hard-cap) session expiry** on `admin_sessions` mirroring `app_user_sessions` — agent 1.
|
||||||
|
20. **Audit-log table** for admin actions (`api_key_minted`, `user_promoted`, `app_deleted`, etc.) — agent 10 I4.
|
||||||
|
21. **Outbox GC sweep** + circuit-breaker for HTTP-async retries — agent 8 M-1 / M-5.
|
||||||
|
22. **`AAD` migration sweep** + `Zeroize` on `MasterKey` and intermediate decrypted plaintexts — agent 3.
|
||||||
|
|
||||||
|
## Verified-clean / no-finding observations
|
||||||
|
|
||||||
|
These were investigated and judged adequate for the threat model. Recording so future audits don't re-trace:
|
||||||
|
|
||||||
|
- **SDK `app_id` discipline**. No Rhai-callable service-trait method accepts `app_id` as a side parameter. Two paths (kv::set, dead_letters::replay) traced end-to-end Rhai→SQL — agent 2.
|
||||||
|
- **SQL injection**. 75 dynamic `sqlx::query(...)` callsites; all use positional `$N` binds. Two `format!`-into-SQL patterns interpolate hardcoded `const &str` column lists only. The DSL builder (`docs_repo::build_find_query`) binds every user-supplied path segment and value via `QueryBuilder::push_bind` — agent 4.
|
||||||
|
- **Argon2id parameters, RNG selection, constant-time HMAC verification, session-token entropy (256 bits via OsRng), timing-flat username enumeration (dummy-hash path)** — agents 1, 3.
|
||||||
|
- **SSRF**. Link-local + private-IP block at parse time; DNS-rebinding defense; redirect re-resolution; cross-origin `Authorization` scrub. End-to-end trace from Rhai `http::get` → `validate_url` → `policy.check` — agent 9.
|
||||||
|
- **CLI on-disk credentials are mode-0600 enforced** with re-set on each write; unit test pins it — agent 10.
|
||||||
|
- **No `{@html}`, `innerHTML`, `eval`, or `Function()` in the dashboard**; no external CDN `<script>` tags; CodeMirror does not evaluate Rhai as JS — agent 7.
|
||||||
|
- **`process::Command` confined to test code**; no shell-out from production paths — agent 4.
|
||||||
|
- **Migrations are static SQL** — no `EXECUTE format(...)` blocks; schema-snapshot test pins the final shape — agent 4.
|
||||||
|
- **No sandbox escape** (no `unsafe` in executor-core / orchestrator-core; no `eval`/`import` reachable from scripts beyond the per-app module resolver; no file/process/network primitives registered) — agent 5.
|
||||||
|
- **Caddy admin** is `admin off` in dev, localhost-default in prod; not network-reachable — agent 9.
|
||||||
|
- **`/version`** returns only product/sdk/api/schema/wire versions + `public_base_url`; no build SHA, OS, or internal IP. `/healthz` is literally `"ok"` — agent 10.
|
||||||
|
|
||||||
|
## Per-agent index
|
||||||
|
|
||||||
|
Each file has full findings, recommendations, and traces:
|
||||||
|
|
||||||
|
1. [01_authn_session.md](security_audit/01_authn_session.md) — login flow, password hashing, session tokens, bootstrap, token transport
|
||||||
|
2. [02_authz_isolation.md](security_audit/02_authz_isolation.md) — capability gates, `SdkCallCx.app_id`, `resolve_app`, dispatcher fan-out, slug-history
|
||||||
|
3. [03_crypto_secrets.md](security_audit/03_crypto_secrets.md) — master key, AES-GCM envelope, secrets/app-secrets/email-trigger seal/open, API keys
|
||||||
|
4. [04_injection.md](security_audit/04_injection.md) — SQL, JSONB, log injection, response-header injection, SMTP injection, regex DoS, command injection
|
||||||
|
5. [05_sandbox_exec.md](security_audit/05_sandbox_exec.md) — Rhai engine limits, operation budgets, SDK surface, error propagation
|
||||||
|
6. [06_files_pathtraversal.md](security_audit/06_files_pathtraversal.md) — files API, on-disk layout, traversal guards, atomic writes, MIME handling
|
||||||
|
7. [07_http_cors_csrf_xss.md](security_audit/07_http_cors_csrf_xss.md) — security headers, CORS, CSRF, SPA XSS, cookie/session flags
|
||||||
|
8. [08_dos_resource.md](security_audit/08_dos_resource.md) — concurrency caps, rate limits, body limits, fan-out budgets, quota gaps
|
||||||
|
9. [09_external_integrations.md](security_audit/09_external_integrations.md) — SSRF, HTTP-async, email inbound/outbound, Postgres TLS, CLI TLS
|
||||||
|
10. [10_info_disclosure_cli.md](security_audit/10_info_disclosure_cli.md) — error response bodies, log redaction, CLI token files, dashboard token storage
|
||||||
|
|
||||||
|
## Notes on remediation methodology
|
||||||
|
|
||||||
|
- Most fixes are mechanical and don't require schema changes — Tier 1 + Tier 2 (items 1-10) can each land as a single focused PR with tests.
|
||||||
|
- AAD migration (item 6) needs a startup re-encryption sweep; pair it with the v1.2 key-versioning pass already on the roadmap (memory: [Release state snapshot 2026-06-07](#)).
|
||||||
|
- Per-app quotas (item 18) need an `app_quotas` schema migration; defer to v1.2.
|
||||||
|
- The audit deliberately did not exercise cluster mode (skeleton crates). The crypto AAD migration and inbound nonce dedup both regress under cluster mode if shipped as-is — call out in the v1.3 readiness checklist.
|
||||||
|
- Severity calibration assumes solo-dev / Pi-class hardware. A single anonymous request fully saturating one execution worker for 5 minutes is treated as High, not Low.
|
||||||
@@ -14,7 +14,14 @@
|
|||||||
#
|
#
|
||||||
# When v2 of the API ships, add `handle /api/v2/admin/* { ... }` etc.
|
# When v2 of the API ships, add `handle /api/v2/admin/* { ... }` etc.
|
||||||
# alongside the v1 handles, before the catch-all `/api/*` 404.
|
# alongside the v1 handles, before the catch-all `/api/*` 404.
|
||||||
{
|
#
|
||||||
|
# Audit 2026-06-11 (H-A1/2/3, M07-06/07/08/09): defense-in-depth headers.
|
||||||
|
# CSP / X-Frame-Options / Permissions-Policy / no-store land on the
|
||||||
|
# dashboard SPA and admin API; nosniff + Referrer-Policy land everywhere.
|
||||||
|
# User-route responses (catch-all `handle`) deliberately get NO CSP —
|
||||||
|
# user scripts own their own response headers by design.
|
||||||
|
# `?` operator = "default if missing" so a downstream response (e.g. the
|
||||||
|
# file-download endpoint with its own restrictive CSP) wins.
|
||||||
{
|
{
|
||||||
auto_https off
|
auto_https off
|
||||||
admin off
|
admin off
|
||||||
@@ -25,6 +32,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:80 {
|
:80 {
|
||||||
|
# Audit 2026-06-11 (H-1) — hard request-body ceiling at the proxy.
|
||||||
|
# Without this, Caddy forwards bodies up to its multi-GB default to
|
||||||
|
# picloud. 12 MB sits just above the orchestrator's 10 MiB user-route
|
||||||
|
# read so legitimate large invokes pass; admin / email handlers are
|
||||||
|
# bounded far tighter by their own Axum extractor limits.
|
||||||
|
request_body {
|
||||||
|
max_size 12MB
|
||||||
|
}
|
||||||
|
|
||||||
|
# Baseline headers applied to every response.
|
||||||
|
header {
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
Referrer-Policy "no-referrer"
|
||||||
|
}
|
||||||
|
|
||||||
handle /healthz {
|
handle /healthz {
|
||||||
reverse_proxy picloud:8080
|
reverse_proxy picloud:8080
|
||||||
}
|
}
|
||||||
@@ -33,6 +55,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
handle /api/v1/admin/* {
|
handle /api/v1/admin/* {
|
||||||
|
header {
|
||||||
|
?Content-Security-Policy "default-src 'none'; frame-ancestors 'none'; base-uri 'none'"
|
||||||
|
?X-Frame-Options "DENY"
|
||||||
|
?Cache-Control "no-store"
|
||||||
|
?Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=(), usb=(), accelerometer=(), gyroscope=()"
|
||||||
|
}
|
||||||
reverse_proxy picloud:8080
|
reverse_proxy picloud:8080
|
||||||
}
|
}
|
||||||
handle /api/v1/execute/* {
|
handle /api/v1/execute/* {
|
||||||
@@ -50,10 +78,20 @@
|
|||||||
# (root); we don't strip the prefix because SvelteKit was built with
|
# (root); we don't strip the prefix because SvelteKit was built with
|
||||||
# paths.base = '/admin' so the bundle's URLs already include it.
|
# paths.base = '/admin' so the bundle's URLs already include it.
|
||||||
handle /admin/* {
|
handle /admin/* {
|
||||||
|
header {
|
||||||
|
?Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
|
||||||
|
?X-Frame-Options "DENY"
|
||||||
|
?Cache-Control "no-store"
|
||||||
|
?Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=(), usb=(), accelerometer=(), gyroscope=()"
|
||||||
|
}
|
||||||
reverse_proxy dashboard:80
|
reverse_proxy dashboard:80
|
||||||
}
|
}
|
||||||
handle /admin {
|
handle /admin {
|
||||||
# Bare /admin (no trailing slash) — let SvelteKit's SPA handle it.
|
# Bare /admin (no trailing slash) — let SvelteKit's SPA handle it.
|
||||||
|
header {
|
||||||
|
?Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
|
||||||
|
?X-Frame-Options "DENY"
|
||||||
|
}
|
||||||
reverse_proxy dashboard:80
|
reverse_proxy dashboard:80
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,15 @@
|
|||||||
# Set PICLOUD_DOMAIN and PICLOUD_ADMIN_EMAIL in the environment Caddy is
|
# Set PICLOUD_DOMAIN and PICLOUD_ADMIN_EMAIL in the environment Caddy is
|
||||||
# started from (docker-compose.prod.yml passes them through). Caddy then
|
# started from (docker-compose.prod.yml passes them through). Caddy then
|
||||||
# obtains and renews a Let's Encrypt cert automatically for that domain.
|
# obtains and renews a Let's Encrypt cert automatically for that domain.
|
||||||
{
|
#
|
||||||
|
# Audit 2026-06-11 (H-A1/2/3/5, M07-06/07/08/09): defense-in-depth
|
||||||
|
# headers. HSTS lands on every prod response. CSP, X-Frame-Options,
|
||||||
|
# Permissions-Policy, and Cache-Control: no-store land on the dashboard
|
||||||
|
# SPA and admin API. nosniff + Referrer-Policy land everywhere. User-
|
||||||
|
# route responses (catch-all `handle`) deliberately get NO CSP — user
|
||||||
|
# scripts own their own response headers by design.
|
||||||
|
# `?` operator = "default if missing" so downstream responses (e.g. the
|
||||||
|
# file-download endpoint with its own restrictive CSP) win.
|
||||||
{
|
{
|
||||||
email {$PICLOUD_ADMIN_EMAIL}
|
email {$PICLOUD_ADMIN_EMAIL}
|
||||||
}
|
}
|
||||||
@@ -11,6 +19,22 @@
|
|||||||
{$PICLOUD_DOMAIN} {
|
{$PICLOUD_DOMAIN} {
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
|
|
||||||
|
# Audit 2026-06-11 (H-1) — hard request-body ceiling at the proxy.
|
||||||
|
# Without this, Caddy forwards bodies up to its multi-GB default to
|
||||||
|
# picloud. 12 MB sits just above the orchestrator's 10 MiB user-route
|
||||||
|
# read so legitimate large invokes pass; admin / email handlers are
|
||||||
|
# bounded far tighter by their own Axum extractor limits.
|
||||||
|
request_body {
|
||||||
|
max_size 12MB
|
||||||
|
}
|
||||||
|
|
||||||
|
# Baseline headers on every response. HSTS is unconditional in prod.
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
Referrer-Policy "no-referrer"
|
||||||
|
}
|
||||||
|
|
||||||
handle /healthz {
|
handle /healthz {
|
||||||
reverse_proxy picloud:8080
|
reverse_proxy picloud:8080
|
||||||
}
|
}
|
||||||
@@ -19,6 +43,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
handle /api/v1/admin/* {
|
handle /api/v1/admin/* {
|
||||||
|
header {
|
||||||
|
?Content-Security-Policy "default-src 'none'; frame-ancestors 'none'; base-uri 'none'"
|
||||||
|
?X-Frame-Options "DENY"
|
||||||
|
?Cache-Control "no-store"
|
||||||
|
?Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=(), usb=(), accelerometer=(), gyroscope=()"
|
||||||
|
}
|
||||||
reverse_proxy picloud:8080
|
reverse_proxy picloud:8080
|
||||||
}
|
}
|
||||||
handle /api/v1/execute/* {
|
handle /api/v1/execute/* {
|
||||||
@@ -33,9 +63,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
handle /admin/* {
|
handle /admin/* {
|
||||||
|
header {
|
||||||
|
?Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
|
||||||
|
?X-Frame-Options "DENY"
|
||||||
|
?Cache-Control "no-store"
|
||||||
|
?Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=(), usb=(), accelerometer=(), gyroscope=()"
|
||||||
|
}
|
||||||
reverse_proxy dashboard:80
|
reverse_proxy dashboard:80
|
||||||
}
|
}
|
||||||
handle /admin {
|
handle /admin {
|
||||||
|
header {
|
||||||
|
?Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
|
||||||
|
?X-Frame-Options "DENY"
|
||||||
|
}
|
||||||
reverse_proxy dashboard:80
|
reverse_proxy dashboard:80
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,12 @@ const token = client.auth.token;
|
|||||||
## React
|
## React
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { PicloudProvider, useTopic, useEndpoint } from '@picloud/client/react';
|
import {
|
||||||
|
PicloudProvider,
|
||||||
|
useTopic,
|
||||||
|
useEndpointGet,
|
||||||
|
useEndpointPost
|
||||||
|
} from '@picloud/client/react';
|
||||||
|
|
||||||
// Wrap your tree once: <PicloudProvider client={client}>…</PicloudProvider>
|
// Wrap your tree once: <PicloudProvider client={client}>…</PicloudProvider>
|
||||||
|
|
||||||
@@ -52,11 +57,25 @@ function ChatRoom({ roomId }: { roomId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function UserProfile({ id }: { id: string }) {
|
function UserProfile({ id }: { id: string }) {
|
||||||
const { data, loading, error } = useEndpoint<UserRes>(`/api/users/${id}`).get();
|
// Auto-fires when `id` changes; conforms to Rules of Hooks.
|
||||||
|
const { data, loading, error } = useEndpointGet<UserRes>(`/api/users/${id}`);
|
||||||
if (loading) return <Spinner />;
|
if (loading) return <Spinner />;
|
||||||
if (error) return <ErrorView error={error} />;
|
if (error) return <ErrorView error={error} />;
|
||||||
return <div>{data?.name}</div>;
|
return <div>{data?.name}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CreateUserForm() {
|
||||||
|
// Event-driven: NOT fired on mount; call `mutate(body)` on submit.
|
||||||
|
const { mutate, loading, error } = useEndpointPost<CreateUserReq, CreateUserRes>(
|
||||||
|
'/api/users'
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); mutate({ name: 'Alice' }); }}>
|
||||||
|
<button disabled={loading}>Create</button>
|
||||||
|
{error ? <ErrorView error={error} /> : null}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Svelte
|
## Svelte
|
||||||
|
|||||||
12
clients/typescript/package-lock.json
generated
12
clients/typescript/package-lock.json
generated
@@ -184,6 +184,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
@@ -207,6 +208,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -1048,6 +1050,7 @@
|
|||||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.10.4",
|
"@babel/code-frame": "^7.10.4",
|
||||||
"@babel/runtime": "^7.12.5",
|
"@babel/runtime": "^7.12.5",
|
||||||
@@ -1117,6 +1120,7 @@
|
|||||||
"integrity": "sha512-3ek6mwJL5/VBewBcY4S66cqlCtK3qi4WIq37Z0m/NHw1hjhI7274Mx1qz/+ggSzyBCOEf7eHjBN6INjPAWYfYw==",
|
"integrity": "sha512-3ek6mwJL5/VBewBcY4S66cqlCtK3qi4WIq37Z0m/NHw1hjhI7274Mx1qz/+ggSzyBCOEf7eHjBN6INjPAWYfYw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -1685,6 +1689,7 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"esbuild": "bin/esbuild"
|
"esbuild": "bin/esbuild"
|
||||||
},
|
},
|
||||||
@@ -2000,6 +2005,7 @@
|
|||||||
"integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
|
"integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cssstyle": "^4.1.0",
|
"cssstyle": "^4.1.0",
|
||||||
"data-urls": "^5.0.0",
|
"data-urls": "^5.0.0",
|
||||||
@@ -2282,6 +2288,7 @@
|
|||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -2331,6 +2338,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.12",
|
"nanoid": "^3.3.12",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -2414,6 +2422,7 @@
|
|||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@@ -2427,6 +2436,7 @@
|
|||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@@ -2851,6 +2861,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -2872,6 +2883,7 @@
|
|||||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.43",
|
"postcss": "^8.4.43",
|
||||||
|
|||||||
@@ -60,26 +60,20 @@ export interface QueryState<T> {
|
|||||||
error: unknown;
|
error: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EndpointHook<Req, Res> {
|
export interface MutationState<T> {
|
||||||
get: () => QueryState<Res>;
|
data: T | null;
|
||||||
post: (body?: Req) => QueryState<Res>;
|
loading: boolean;
|
||||||
|
error: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Typed endpoint hook. `useEndpoint<Res>(path).get()` fires a GET and
|
* F-T-001: flat GET hook. Replaces `useEndpoint(path).get()` which
|
||||||
* returns `{ data, loading, error }`, re-running when `path` changes.
|
* violated the Rules of Hooks (calling `useState`/`useEffect` from
|
||||||
* `.post(body)` is the mutation variant (auto-fires once per mount).
|
* inside a returned function). Auto-fires once per `path` change and
|
||||||
|
* re-runs on mount.
|
||||||
*/
|
*/
|
||||||
export function useEndpoint<Res = unknown, Req = unknown>(path: string): EndpointHook<Req, Res> {
|
export function useEndpointGet<Res = unknown>(path: string): QueryState<Res> {
|
||||||
const client = usePicloud();
|
const client = usePicloud();
|
||||||
return {
|
|
||||||
get: () => useResource<Res>(() => client.endpoint<Req, Res>(path).get(), path, 'GET'),
|
|
||||||
post: (body?: Req) =>
|
|
||||||
useResource<Res>(() => client.endpoint<Req, Res>(path).post(body), path, 'POST')
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function useResource<Res>(run: () => Promise<Res>, key: string, method: string): QueryState<Res> {
|
|
||||||
const [state, setState] = useState<QueryState<Res>>({
|
const [state, setState] = useState<QueryState<Res>>({
|
||||||
data: null,
|
data: null,
|
||||||
loading: true,
|
loading: true,
|
||||||
@@ -88,14 +82,42 @@ function useResource<Res>(run: () => Promise<Res>, key: string, method: string):
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
setState({ data: null, loading: true, error: null });
|
setState({ data: null, loading: true, error: null });
|
||||||
run()
|
client
|
||||||
|
.endpoint<unknown, Res>(path)
|
||||||
|
.get()
|
||||||
.then((data) => active && setState({ data, loading: false, error: null }))
|
.then((data) => active && setState({ data, loading: false, error: null }))
|
||||||
.catch((error) => active && setState({ data: null, loading: false, error }));
|
.catch((error) => active && setState({ data: null, loading: false, error }));
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
// `run` is recreated each render; key it on path + method instead.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [key, method]);
|
}, [client, path]);
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F-T-001 + F-T-002: event-driven POST hook. Returns
|
||||||
|
* `{ mutate, data, loading, error }` — `mutate(body)` fires the POST.
|
||||||
|
* Does NOT auto-fire on mount (the previous `useEndpoint(path).post()`
|
||||||
|
* shape would create a user / send an email on every render-refresh).
|
||||||
|
*/
|
||||||
|
export function useEndpointPost<Req = unknown, Res = unknown>(
|
||||||
|
path: string
|
||||||
|
): MutationState<Res> & { mutate: (body?: Req) => Promise<void> } {
|
||||||
|
const client = usePicloud();
|
||||||
|
const [state, setState] = useState<MutationState<Res>>({
|
||||||
|
data: null,
|
||||||
|
loading: false,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
const mutate = async (body?: Req) => {
|
||||||
|
setState({ data: null, loading: true, error: null });
|
||||||
|
try {
|
||||||
|
const data = await client.endpoint<Req, Res>(path).post(body);
|
||||||
|
setState({ data, loading: false, error: null });
|
||||||
|
} catch (error) {
|
||||||
|
setState({ data: null, loading: false, error });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return { ...state, mutate };
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ export function subscribeTopic<T = unknown>(
|
|||||||
let lastEventId: string | undefined;
|
let lastEventId: string | undefined;
|
||||||
let controller: AbortController | null = null;
|
let controller: AbortController | null = null;
|
||||||
let backoffTimer: ReturnType<typeof setTimeout> | null = null;
|
let backoffTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
// F-T-003: bound the 401-refresh loop. If onTokenExpired keeps
|
||||||
|
// returning a token that the server rejects (mis-issued refresh,
|
||||||
|
// server-side authz drift, …) we'd otherwise reconnect immediately
|
||||||
|
// forever with attempt=0 each iteration — the previous behavior.
|
||||||
|
// Three consecutive 401s within the loop give up and surface the
|
||||||
|
// error so the caller knows the credential is bad.
|
||||||
|
let consecutive401 = 0;
|
||||||
|
const MAX_CONSECUTIVE_401 = 3;
|
||||||
|
|
||||||
const stop = () => {
|
const stop = () => {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
@@ -61,6 +69,17 @@ export function subscribeTopic<T = unknown>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
|
consecutive401 += 1;
|
||||||
|
if (consecutive401 >= MAX_CONSECUTIVE_401) {
|
||||||
|
opts.onError?.(
|
||||||
|
new Error(
|
||||||
|
`realtime subscribe stuck in 401-refresh loop (${consecutive401} consecutive); ` +
|
||||||
|
'check that onTokenExpired returns a credential the server accepts'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Token expired / rejected — try to refresh, else give up.
|
// Token expired / rejected — try to refresh, else give up.
|
||||||
const fresh = opts.onTokenExpired ? await opts.onTokenExpired() : null;
|
const fresh = opts.onTokenExpired ? await opts.onTokenExpired() : null;
|
||||||
if (fresh) {
|
if (fresh) {
|
||||||
@@ -79,8 +98,10 @@ export function subscribeTopic<T = unknown>(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connected — reset backoff and stream frames until the body ends.
|
// Connected — reset backoff and the 401 counter; a successful
|
||||||
|
// open proves the current credential works.
|
||||||
attempt = 0;
|
attempt = 0;
|
||||||
|
consecutive401 = 0;
|
||||||
try {
|
try {
|
||||||
await readStream(res.body, (frame) => {
|
await readStream(res.body, (frame) => {
|
||||||
if (frame.id !== undefined) lastEventId = frame.id;
|
if (frame.id !== undefined) lastEventId = frame.id;
|
||||||
|
|||||||
@@ -96,4 +96,30 @@ describe('subscribe', () => {
|
|||||||
await vi.waitFor(() => expect(onError).toHaveBeenCalled());
|
await vi.waitFor(() => expect(onError).toHaveBeenCalled());
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('caps the 401-refresh loop after consecutive failures', async () => {
|
||||||
|
// onTokenExpired keeps returning a fresh-looking-but-still-rejected
|
||||||
|
// token. Without the cap the loop would reconnect forever.
|
||||||
|
const fetchMock = queuedFetch([
|
||||||
|
async () => emptyResponse(401),
|
||||||
|
async () => emptyResponse(401),
|
||||||
|
async () => emptyResponse(401),
|
||||||
|
async () => emptyResponse(401),
|
||||||
|
async () => emptyResponse(401)
|
||||||
|
]);
|
||||||
|
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
|
||||||
|
const onError = vi.fn();
|
||||||
|
const onTokenExpired = vi.fn(() => 'never-good-enough');
|
||||||
|
const unsubscribe = client.subscribe('chat', () => {}, {
|
||||||
|
onTokenExpired,
|
||||||
|
onError
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => expect(onError).toHaveBeenCalled(), { timeout: 1000 });
|
||||||
|
unsubscribe();
|
||||||
|
// The cap is 3 consecutive 401s — at most 3 fetches should have
|
||||||
|
// been issued before the loop bailed.
|
||||||
|
expect(fetchMock.mock.calls.length).toBeLessThanOrEqual(3);
|
||||||
|
const errMsg = String(onError.mock.calls[0]?.[0]);
|
||||||
|
expect(errMsg).toContain('401-refresh loop');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,52 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::cell::Cell;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock, Weak};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use chrono::Utc;
|
// Audit 2026-06-11 H-C1 — thread-local deadline consulted by the Rhai
|
||||||
|
// `on_progress` hook so a runaway script (e.g., `loop {}`) actually
|
||||||
|
// terminates instead of holding its `spawn_blocking` OS thread until
|
||||||
|
// the per-op budget self-exhausts (which can be tens of seconds). The
|
||||||
|
// orchestrator client wraps each `execute*` call in a
|
||||||
|
// `DeadlineGuard::set(Some(now + timeout))` so invoke re-entries on the
|
||||||
|
// same thread inherit the same deadline.
|
||||||
|
thread_local! {
|
||||||
|
static CURRENT_DEADLINE: Cell<Option<Instant>> = const { Cell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RAII guard that sets the current-thread deadline for the lifetime of
|
||||||
|
/// the guard, restoring whatever was there before on drop.
|
||||||
|
///
|
||||||
|
/// Use via [`Engine::execute_with_deadline`] / [`Engine::execute_ast_with_deadline`]
|
||||||
|
/// at the orchestrator boundary; SDK invoke re-entries piggyback on the
|
||||||
|
/// existing thread-local without touching it.
|
||||||
|
pub struct DeadlineGuard {
|
||||||
|
prev: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeadlineGuard {
|
||||||
|
/// Set the current-thread deadline. Returns a guard whose `Drop`
|
||||||
|
/// restores the prior value.
|
||||||
|
#[must_use]
|
||||||
|
pub fn set(deadline: Option<Instant>) -> Self {
|
||||||
|
let prev = CURRENT_DEADLINE.with(|c| c.replace(deadline));
|
||||||
|
Self { prev }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for DeadlineGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
CURRENT_DEADLINE.with(|c| c.set(self.prev));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_deadline() -> Option<Instant> {
|
||||||
|
CURRENT_DEADLINE.with(Cell::get)
|
||||||
|
}
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
ScriptValidator, SdkCallCx, Services, TriggerEvent, ValidatedScript, ValidationError,
|
ScriptId, ScriptValidator, SdkCallCx, Services, TriggerEvent, ValidatedScript, ValidationError,
|
||||||
SDK_VERSION,
|
SDK_VERSION,
|
||||||
};
|
};
|
||||||
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module, Scope, AST};
|
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module, Scope, AST};
|
||||||
@@ -44,6 +86,22 @@ pub struct Engine {
|
|||||||
/// `(app_id, name)`; invalidated lazily by `updated_at` mismatch
|
/// `(app_id, name)`; invalidated lazily by `updated_at` mismatch
|
||||||
/// at resolver time.
|
/// at resolver time.
|
||||||
module_cache: Arc<ModuleCache>,
|
module_cache: Arc<ModuleCache>,
|
||||||
|
/// v1.1.9: back-reference set by the picloud binary after
|
||||||
|
/// `Arc::new(Engine::new(...))`. The `invoke` SDK bridge reads it
|
||||||
|
/// to re-enter the engine synchronously for `invoke()`. `Weak` so
|
||||||
|
/// holding the Engine in an Arc doesn't create a strong cycle.
|
||||||
|
/// `None` until `set_self_weak` runs; the bridge surfaces a clear
|
||||||
|
/// error if invoke is called without the back-reference being set
|
||||||
|
/// (which only happens in tests that don't wire it).
|
||||||
|
self_weak: OnceLock<Weak<Engine>>,
|
||||||
|
/// F-P-004: per-Engine AST cache keyed on `(script_id, updated_at)`.
|
||||||
|
/// Populated by `compile_for_identity`; consumed by the SDK invoke
|
||||||
|
/// bridge so the synchronous re-entry path doesn't re-parse the
|
||||||
|
/// callee on every invoke. Independent of the orchestrator-core
|
||||||
|
/// `LocalExecutorClient` AST cache — that one caches HTTP-path
|
||||||
|
/// dispatch; this one caches function-call dispatch.
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
invoke_ast_cache: Mutex<HashMap<ScriptId, (DateTime<Utc>, Arc<AST>)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Engine {
|
impl Engine {
|
||||||
@@ -67,9 +125,66 @@ impl Engine {
|
|||||||
limits,
|
limits,
|
||||||
services,
|
services,
|
||||||
module_cache: new_module_cache(module_cache_capacity),
|
module_cache: new_module_cache(module_cache_capacity),
|
||||||
|
self_weak: OnceLock::new(),
|
||||||
|
invoke_ast_cache: Mutex::new(HashMap::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// F-P-004: synchronous-invoke fast path. Returns a cached AST when
|
||||||
|
/// (script_id, updated_at) matches; otherwise compiles, inserts,
|
||||||
|
/// returns. Used by the `invoke` SDK bridge to skip the per-call
|
||||||
|
/// parse when one script calls another.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Propagates `ExecError::Parse` from the inner compile step.
|
||||||
|
pub fn compile_for_identity(
|
||||||
|
&self,
|
||||||
|
script_id: ScriptId,
|
||||||
|
updated_at: DateTime<Utc>,
|
||||||
|
source: &str,
|
||||||
|
) -> Result<Arc<AST>, ExecError> {
|
||||||
|
{
|
||||||
|
let cache = self
|
||||||
|
.invoke_ast_cache
|
||||||
|
.lock()
|
||||||
|
.expect("invoke ast cache poisoned");
|
||||||
|
if let Some((ts, ast)) = cache.get(&script_id) {
|
||||||
|
if *ts == updated_at {
|
||||||
|
return Ok(ast.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let ast = self.compile(source)?;
|
||||||
|
let mut cache = self
|
||||||
|
.invoke_ast_cache
|
||||||
|
.lock()
|
||||||
|
.expect("invoke ast cache poisoned");
|
||||||
|
cache.insert(script_id, (updated_at, ast.clone()));
|
||||||
|
Ok(ast)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// v1.1.9: install the back-reference used by the `invoke` SDK
|
||||||
|
/// bridge for synchronous re-entry. Idempotent (subsequent calls
|
||||||
|
/// are no-ops). The picloud binary calls this right after
|
||||||
|
/// `Arc::new(Engine::new(...))`:
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// let engine = Arc::new(Engine::new(limits, services));
|
||||||
|
/// engine.set_self_weak(Arc::downgrade(&engine));
|
||||||
|
/// ```
|
||||||
|
pub fn set_self_weak(&self, weak: Weak<Engine>) {
|
||||||
|
let _ = self_weak_set(&self.self_weak, weak);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal accessor used by the `invoke` SDK bridge — returns
|
||||||
|
/// `Some(strong)` if the back-reference was installed and the
|
||||||
|
/// engine is still alive.
|
||||||
|
#[must_use]
|
||||||
|
pub fn self_arc(&self) -> Option<Arc<Engine>> {
|
||||||
|
self.self_weak.get().and_then(Weak::upgrade)
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn limits(&self) -> &Limits {
|
pub fn limits(&self) -> &Limits {
|
||||||
&self.limits
|
&self.limits
|
||||||
@@ -114,10 +229,38 @@ impl Engine {
|
|||||||
.map_err(|e| ExecError::Parse(e.to_string()))
|
.map_err(|e| ExecError::Parse(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like [`Self::execute`] but also installs `deadline` on the
|
||||||
|
/// current thread so the Rhai `on_progress` hook can interrupt a
|
||||||
|
/// runaway script before `tokio::time::timeout` fires (which only
|
||||||
|
/// drops the future — the OS thread keeps running). Audit
|
||||||
|
/// 2026-06-11 H-C1.
|
||||||
|
pub fn execute_with_deadline(
|
||||||
|
&self,
|
||||||
|
source: &str,
|
||||||
|
req: ExecRequest,
|
||||||
|
deadline: Option<Instant>,
|
||||||
|
) -> Result<ExecResponse, ExecError> {
|
||||||
|
let _guard = DeadlineGuard::set(deadline);
|
||||||
|
self.execute(source, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`Self::execute_ast`] but installs `deadline` on the current
|
||||||
|
/// thread. See [`Self::execute_with_deadline`] for the rationale.
|
||||||
|
pub fn execute_ast_with_deadline(
|
||||||
|
&self,
|
||||||
|
ast: &Arc<AST>,
|
||||||
|
req: ExecRequest,
|
||||||
|
deadline: Option<Instant>,
|
||||||
|
) -> Result<ExecResponse, ExecError> {
|
||||||
|
let _guard = DeadlineGuard::set(deadline);
|
||||||
|
self.execute_ast(ast, req)
|
||||||
|
}
|
||||||
|
|
||||||
/// Execute `source` against `req`. Op-budget protection comes from
|
/// Execute `source` against `req`. Op-budget protection comes from
|
||||||
/// Rhai's `set_max_operations`; wall-clock enforcement is the
|
/// Rhai's `set_max_operations`; the wall-clock deadline (when set
|
||||||
/// caller's responsibility. Per-script sandbox overrides on the
|
/// via [`DeadlineGuard`] / [`Self::execute_with_deadline`]) aborts
|
||||||
/// request replace the engine's defaults field-by-field; the
|
/// any per-op step that crosses it. Per-script sandbox overrides on
|
||||||
|
/// the request replace the engine's defaults field-by-field; the
|
||||||
/// manager already clamped them against the admin ceiling.
|
/// manager already clamped them against the admin ceiling.
|
||||||
pub fn execute(&self, source: &str, req: ExecRequest) -> Result<ExecResponse, ExecError> {
|
pub fn execute(&self, source: &str, req: ExecRequest) -> Result<ExecResponse, ExecError> {
|
||||||
let effective_limits = self.limits.with_overrides(&req.sandbox_overrides);
|
let effective_limits = self.limits.with_overrides(&req.sandbox_overrides);
|
||||||
@@ -157,14 +300,28 @@ impl Engine {
|
|||||||
// installed with the real per-call resolver. The resolver owns
|
// installed with the real per-call resolver. The resolver owns
|
||||||
// `cx.clone()` so cross-app isolation derives from this exact
|
// `cx.clone()` so cross-app isolation derives from this exact
|
||||||
// call's context, not from any script-passed argument.
|
// call's context, not from any script-passed argument.
|
||||||
|
// The lexical origin for `import` resolution (§5.5): the top-level
|
||||||
|
// script's defining node. Falls back to the executing app when the
|
||||||
|
// request predates Phase 4b (old payloads / cluster wire).
|
||||||
|
let default_origin = req
|
||||||
|
.script_owner
|
||||||
|
.unwrap_or(picloud_shared::ScriptOwner::App(req.app_id));
|
||||||
let resolver = PicloudModuleResolver::new(
|
let resolver = PicloudModuleResolver::new(
|
||||||
self.services.modules.clone(),
|
self.services.modules.clone(),
|
||||||
cx.clone(),
|
cx.clone(),
|
||||||
self.module_cache.clone(),
|
self.module_cache.clone(),
|
||||||
effective_limits.module_import_depth_max,
|
effective_limits.module_import_depth_max,
|
||||||
|
default_origin,
|
||||||
);
|
);
|
||||||
engine.set_module_resolver(resolver);
|
engine.set_module_resolver(resolver);
|
||||||
sdk::register_all(&mut engine, &self.services, cx);
|
let self_engine = self.self_arc();
|
||||||
|
sdk::register_all(
|
||||||
|
&mut engine,
|
||||||
|
&self.services,
|
||||||
|
cx,
|
||||||
|
effective_limits,
|
||||||
|
self_engine,
|
||||||
|
);
|
||||||
|
|
||||||
let mut scope = Scope::new();
|
let mut scope = Scope::new();
|
||||||
scope.push_constant("ctx", build_ctx_map(&req));
|
scope.push_constant("ctx", build_ctx_map(&req));
|
||||||
@@ -211,6 +368,12 @@ impl ScriptValidator for Engine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tiny helper to make the `set_self_weak` body idempotent without
|
||||||
|
/// pulling the impl up into the public API.
|
||||||
|
fn self_weak_set(slot: &OnceLock<Weak<Engine>>, weak: Weak<Engine>) -> Result<(), Weak<Engine>> {
|
||||||
|
slot.set(weak)
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Engine construction
|
// Engine construction
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -225,13 +388,35 @@ fn build_engine(limits: Limits, logs: Option<Arc<Mutex<Vec<LogEntry>>>>) -> Rhai
|
|||||||
engine.set_max_call_levels(limits.max_call_levels);
|
engine.set_max_call_levels(limits.max_call_levels);
|
||||||
engine.set_max_expr_depths(limits.max_expr_depth, limits.max_expr_depth);
|
engine.set_max_expr_depths(limits.max_expr_depth, limits.max_expr_depth);
|
||||||
|
|
||||||
|
// Audit 2026-06-11 H-C1 — wall-clock interrupter. Rhai invokes the
|
||||||
|
// progress callback once per operation; returning `Some(_)` triggers
|
||||||
|
// `ErrorTerminated`, which propagates out of `eval_ast_with_scope`.
|
||||||
|
// The deadline is read from a thread-local set by the orchestrator's
|
||||||
|
// `execute_with_deadline` / `execute_ast_with_deadline` entry points;
|
||||||
|
// `None` (the default) is a no-op so tests, validation, and bare
|
||||||
|
// `execute*` callers see the previous behavior.
|
||||||
|
engine.on_progress(|_ops| {
|
||||||
|
if let Some(deadline) = current_deadline() {
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
return Some(Dynamic::UNIT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
});
|
||||||
|
|
||||||
// Reject `import` — scripts cannot pull external modules.
|
// Reject `import` — scripts cannot pull external modules.
|
||||||
engine.set_module_resolver(rhai::module_resolvers::DummyModuleResolver);
|
engine.set_module_resolver(rhai::module_resolvers::DummyModuleResolver);
|
||||||
|
|
||||||
// Rhai's built-in `print` and `debug` map to stdout/stderr by
|
// Rhai's built-in `print` and `debug` map to stdout/stderr by
|
||||||
// default; we never want scripts dumping there directly. Disable
|
// default; we never want scripts dumping there directly. Disable
|
||||||
// them so scripts route all output through `log::*` instead.
|
// them so scripts route all output through `log::*` instead.
|
||||||
|
//
|
||||||
|
// Audit 2026-06-11 (F-SE-H-03) — `debug` was previously left
|
||||||
|
// enabled despite this comment, so a script could write
|
||||||
|
// attacker-controlled bytes (control chars, ANSI escapes) into the
|
||||||
|
// operator's stderr/journald stream. Disable it to match `print`.
|
||||||
engine.disable_symbol("print");
|
engine.disable_symbol("print");
|
||||||
|
engine.disable_symbol("debug");
|
||||||
|
|
||||||
if let Some(logs) = logs {
|
if let Some(logs) = logs {
|
||||||
engine.register_static_module("log", build_log_module(logs).into());
|
engine.register_static_module("log", build_log_module(logs).into());
|
||||||
@@ -307,6 +492,7 @@ fn build_ctx_map(req: &ExecRequest) -> Map {
|
|||||||
|
|
||||||
let mut request = Map::new();
|
let mut request = Map::new();
|
||||||
request.insert("path".into(), req.path.clone().into());
|
request.insert("path".into(), req.path.clone().into());
|
||||||
|
request.insert("method".into(), req.method.clone().into());
|
||||||
|
|
||||||
let mut headers = Map::new();
|
let mut headers = Map::new();
|
||||||
for (k, v) in &req.headers {
|
for (k, v) in &req.headers {
|
||||||
@@ -448,6 +634,58 @@ fn trigger_event_to_dynamic(event: &TriggerEvent) -> Dynamic {
|
|||||||
ps.insert("published_at".into(), published_at.to_rfc3339().into());
|
ps.insert("published_at".into(), published_at.to_rfc3339().into());
|
||||||
m.insert("pubsub".into(), ps.into());
|
m.insert("pubsub".into(), ps.into());
|
||||||
}
|
}
|
||||||
|
TriggerEvent::Queue {
|
||||||
|
queue_name,
|
||||||
|
message,
|
||||||
|
enqueued_at,
|
||||||
|
attempt,
|
||||||
|
message_id,
|
||||||
|
} => {
|
||||||
|
// `ctx.event.op` is always "receive" for queue (the only op
|
||||||
|
// a queue:receive trigger surfaces).
|
||||||
|
m.insert("op".into(), "receive".into());
|
||||||
|
let mut q = Map::new();
|
||||||
|
q.insert("queue_name".into(), queue_name.clone().into());
|
||||||
|
q.insert("message".into(), json_to_dynamic(message.clone()));
|
||||||
|
q.insert("enqueued_at".into(), enqueued_at.to_rfc3339().into());
|
||||||
|
q.insert("attempt".into(), i64::from(*attempt).into());
|
||||||
|
q.insert("message_id".into(), message_id.clone().into());
|
||||||
|
m.insert("queue".into(), q.into());
|
||||||
|
}
|
||||||
|
TriggerEvent::Email {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
cc,
|
||||||
|
subject,
|
||||||
|
text,
|
||||||
|
html,
|
||||||
|
received_at,
|
||||||
|
message_id,
|
||||||
|
} => {
|
||||||
|
// `ctx.event.op` is always "receive" for inbound email.
|
||||||
|
m.insert("op".into(), "receive".into());
|
||||||
|
let mut em = Map::new();
|
||||||
|
em.insert("from".into(), from.clone().into());
|
||||||
|
let to_arr: rhai::Array = to.iter().map(|a| Dynamic::from(a.clone())).collect();
|
||||||
|
em.insert("to".into(), to_arr.into());
|
||||||
|
let cc_arr: rhai::Array = cc.iter().map(|a| Dynamic::from(a.clone())).collect();
|
||||||
|
em.insert("cc".into(), cc_arr.into());
|
||||||
|
em.insert("subject".into(), subject.clone().into());
|
||||||
|
em.insert(
|
||||||
|
"text".into(),
|
||||||
|
text.clone().map_or(Dynamic::UNIT, Dynamic::from),
|
||||||
|
);
|
||||||
|
em.insert(
|
||||||
|
"html".into(),
|
||||||
|
html.clone().map_or(Dynamic::UNIT, Dynamic::from),
|
||||||
|
);
|
||||||
|
em.insert("received_at".into(), received_at.to_rfc3339().into());
|
||||||
|
em.insert(
|
||||||
|
"message_id".into(),
|
||||||
|
message_id.clone().map_or(Dynamic::UNIT, Dynamic::from),
|
||||||
|
);
|
||||||
|
m.insert("email".into(), em.into());
|
||||||
|
}
|
||||||
TriggerEvent::DeadLetter {
|
TriggerEvent::DeadLetter {
|
||||||
dead_letter_id,
|
dead_letter_id,
|
||||||
original,
|
original,
|
||||||
|
|||||||
@@ -19,5 +19,6 @@ pub use module_resolver::{
|
|||||||
};
|
};
|
||||||
pub use sandbox::Limits;
|
pub use sandbox::Limits;
|
||||||
pub use types::{
|
pub use types::{
|
||||||
ExecError, ExecRequest, ExecResponse, ExecStats, InvocationType, LogEntry, LogLevel,
|
build_execution_log, ExecError, ExecRequest, ExecResponse, ExecStats, InvocationType, LogEntry,
|
||||||
|
LogLevel,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,32 +1,42 @@
|
|||||||
//! `PicloudModuleResolver` — the v1.1.3 per-app Rhai module resolver.
|
//! `PicloudModuleResolver` — the Rhai module resolver (v1.1.3; made
|
||||||
|
//! origin-aware / lexical in v1.2 Phase 4b).
|
||||||
//!
|
//!
|
||||||
//! Replaces `DummyModuleResolver` in `Engine::build_engine`. Constructed
|
//! Replaces `DummyModuleResolver` in `Engine::build_engine`. Constructed
|
||||||
//! fresh per `Engine::execute` call: holds an `Arc<SdkCallCx>` so every
|
//! fresh per `Engine::execute` call.
|
||||||
//! `import "<name>"` request resolves against the calling app
|
//!
|
||||||
//! (`cx.app_id`). The script-side `name` argument carries no `app_id`
|
//! **Lexical resolution (§5.5).** An `import "<name>"` resolves against the
|
||||||
//! — that's the load-bearing cross-app isolation property.
|
//! module set visible at the **importing script's own defining node**,
|
||||||
|
//! walking up its group chain — not the inheriting app's effective view.
|
||||||
|
//! The importing node is read from Rhai's `_source` (the importing AST's
|
||||||
|
//! source tag, which the resolver sets to each module's owner) and falls
|
||||||
|
//! back to `default_origin` (the entry script's defining node) for the
|
||||||
|
//! top-level AST. So an inherited group script's imports seal to the group
|
||||||
|
//! (a leaf can't shadow them — the trust boundary), while every SDK call
|
||||||
|
//! *inside* a module still scopes to the inheriting app via `cx.app_id`.
|
||||||
//!
|
//!
|
||||||
//! Three runtime invariants are enforced:
|
//! Three runtime invariants are enforced:
|
||||||
//!
|
//!
|
||||||
//! 1. **Cross-app isolation** — `ModuleSource::lookup` is called with
|
//! 1. **Lexical isolation** — `ModuleSource::resolve` is keyed by the
|
||||||
//! `&cx`; the Postgres impl scopes by `cx.app_id` (never by a
|
//! importing node's owner (a trusted dispatch-derived value), never a
|
||||||
//! script-passed argument).
|
//! script-passed argument.
|
||||||
//! 2. **Cycle detection** — an in-progress-imports stack rejects
|
//! 2. **Cycle detection** — an in-progress-imports stack rejects
|
||||||
//! `A → B → A` with `ErrorInModule(... circular import detected ...)`.
|
//! `A → B → A` with `ErrorInModule(... circular import detected ...)`.
|
||||||
//! 3. **Depth limit** — guards against deep but acyclic chains
|
//! 3. **Depth limit** — guards against deep but acyclic chains
|
||||||
//! (default 8, override via `PICLOUD_MODULE_IMPORT_DEPTH_MAX`).
|
//! (default 8, override via `PICLOUD_MODULE_IMPORT_DEPTH_MAX`).
|
||||||
//!
|
//!
|
||||||
//! Compiled modules are cached per `(app_id, name)` and invalidated by
|
//! Compiled modules are cached by resolved `ScriptId` (a compiled module
|
||||||
//! `updated_at` change — no explicit pub/sub. The cache is owned by
|
//! is lexically self-contained) and invalidated by `updated_at` change —
|
||||||
//! `Engine` and shared across calls; only the resolver state (stack,
|
//! no explicit pub/sub. The cache is owned by `Engine` and shared across
|
||||||
//! depth) is per-call.
|
//! calls; only the resolver state (stack, depth) is per-call.
|
||||||
|
|
||||||
use std::num::NonZeroUsize;
|
use std::num::NonZeroUsize;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use lru::LruCache;
|
use lru::LruCache;
|
||||||
use picloud_shared::{AppId, ModuleSource, ModuleSourceError, SdkCallCx, ValidatedScript};
|
use picloud_shared::{
|
||||||
|
ModuleSource, ModuleSourceError, ScriptId, ScriptOwner, SdkCallCx, ValidatedScript,
|
||||||
|
};
|
||||||
use rhai::module_resolvers::ModuleResolver;
|
use rhai::module_resolvers::ModuleResolver;
|
||||||
use rhai::{Engine as RhaiEngine, EvalAltResult, Module, Position, Shared, AST};
|
use rhai::{Engine as RhaiEngine, EvalAltResult, Module, Position, Shared, AST};
|
||||||
|
|
||||||
@@ -35,10 +45,38 @@ use rhai::{Engine as RhaiEngine, EvalAltResult, Module, Position, Shared, AST};
|
|||||||
/// `sync` feature that the workspace pins.
|
/// `sync` feature that the workspace pins.
|
||||||
type SharedRhaiModule = Shared<Module>;
|
type SharedRhaiModule = Shared<Module>;
|
||||||
|
|
||||||
/// Cache key: `(app_id, module name)`. v1.1.3 enforces module names as
|
/// Cache key: the resolved module's `ScriptId` (Phase 4b). A compiled
|
||||||
/// a conservative identifier shape (migration 0015 `scripts_module_name_shape`
|
/// module is lexically self-contained (its nested imports resolve from
|
||||||
/// CHECK) so the `String` here is bounded by ~64 bytes.
|
/// its own defining node, baked in at compile time), so the *body* is a
|
||||||
pub type ModuleCacheKey = (AppId, String);
|
/// pure function of `(script_id, updated_at)` — independent of which
|
||||||
|
/// script imported it. Keying by id also dedupes a shared group module
|
||||||
|
/// across every inheriting app, and keeps cross-app modules of the same
|
||||||
|
/// name distinct (distinct ids).
|
||||||
|
pub type ModuleCacheKey = ScriptId;
|
||||||
|
|
||||||
|
/// Encode a defining node as an AST `source` tag (`app:<uuid>` /
|
||||||
|
/// `group:<uuid>`). Set on a resolved module's AST so its own nested
|
||||||
|
/// `import`s carry *its* node as Rhai's `_source` — the lexical chaining
|
||||||
|
/// that seals each module's imports to where it was authored (§5.5).
|
||||||
|
fn encode_origin(owner: ScriptOwner) -> String {
|
||||||
|
match owner {
|
||||||
|
ScriptOwner::App(a) => format!("app:{a}"),
|
||||||
|
ScriptOwner::Group(g) => format!("group:{g}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inverse of [`encode_origin`]. `None` for an unrecognised tag (e.g. a
|
||||||
|
/// source set by something other than this resolver) — the caller then
|
||||||
|
/// falls back to the resolver's `default_origin`.
|
||||||
|
fn decode_origin(s: &str) -> Option<ScriptOwner> {
|
||||||
|
let (kind, id) = s.split_once(':')?;
|
||||||
|
let uuid = uuid::Uuid::parse_str(id).ok()?;
|
||||||
|
match kind {
|
||||||
|
"app" => Some(ScriptOwner::App(uuid.into())),
|
||||||
|
"group" => Some(ScriptOwner::Group(uuid.into())),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Cache value: the freshness comparator + the compiled module Rhai
|
/// Cache value: the freshness comparator + the compiled module Rhai
|
||||||
/// hands to importing scripts. Cloning the `Shared<Module>` is an Arc bump.
|
/// hands to importing scripts. Cloning the `Shared<Module>` is an Arc bump.
|
||||||
@@ -65,17 +103,19 @@ pub fn new_module_cache(capacity: usize) -> Arc<ModuleCache> {
|
|||||||
|
|
||||||
/// The v1.1.3 module resolver. One per `Engine::execute` call.
|
/// The v1.1.3 module resolver. One per `Engine::execute` call.
|
||||||
pub struct PicloudModuleResolver {
|
pub struct PicloudModuleResolver {
|
||||||
/// Backend the resolver consults for `(app_id, name)`. The bridge
|
/// Backend the resolver consults to resolve a module name against an
|
||||||
/// runs Rhai's sync `resolve()` and the async `lookup()` together
|
/// origin node's chain. The bridge runs Rhai's sync `resolve()` and the
|
||||||
/// via `tokio::runtime::Handle::block_on(...)` — safe because
|
/// async `ModuleSource::resolve()` together via
|
||||||
|
/// `tokio::runtime::Handle::block_on(...)` — safe because
|
||||||
/// `LocalExecutorClient` runs `Engine::execute` inside
|
/// `LocalExecutorClient` runs `Engine::execute` inside
|
||||||
/// `spawn_blocking`, which puts us on a Tokio blocking thread
|
/// `spawn_blocking`, which puts us on a Tokio blocking thread
|
||||||
/// that still carries a `Handle`.
|
/// that still carries a `Handle`.
|
||||||
source: Arc<dyn ModuleSource>,
|
source: Arc<dyn ModuleSource>,
|
||||||
|
|
||||||
/// Calling context. `cx.app_id` is the cross-app isolation
|
/// Calling context. `cx.app_id` is the execution boundary every SDK
|
||||||
/// boundary; the resolver passes `&cx` to every `ModuleSource`
|
/// call inside a module scopes to; the resolver keeps it for diagnostic
|
||||||
/// call so the backend can scope its queries.
|
/// logging. (Module *resolution* is keyed by the importing node's owner,
|
||||||
|
/// not `cx.app_id` — see the module docs.)
|
||||||
cx: Arc<SdkCallCx>,
|
cx: Arc<SdkCallCx>,
|
||||||
|
|
||||||
/// Compiled-module cache. Shared across executions; invalidated
|
/// Compiled-module cache. Shared across executions; invalidated
|
||||||
@@ -96,6 +136,12 @@ pub struct PicloudModuleResolver {
|
|||||||
/// via `PICLOUD_MODULE_IMPORT_DEPTH_MAX`. Read from `Limits` at
|
/// via `PICLOUD_MODULE_IMPORT_DEPTH_MAX`. Read from `Limits` at
|
||||||
/// resolver construction.
|
/// resolver construction.
|
||||||
depth_limit: u32,
|
depth_limit: u32,
|
||||||
|
|
||||||
|
/// Lexical origin for a top-level `import` — the defining node of the
|
||||||
|
/// script being executed (Phase 4b, §5.5). Used when Rhai gives no
|
||||||
|
/// `_source` (the entry AST). Nested imports inside a resolved module
|
||||||
|
/// override this via the module AST's source (C3).
|
||||||
|
default_origin: ScriptOwner,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PicloudModuleResolver {
|
impl PicloudModuleResolver {
|
||||||
@@ -105,6 +151,7 @@ impl PicloudModuleResolver {
|
|||||||
cx: Arc<SdkCallCx>,
|
cx: Arc<SdkCallCx>,
|
||||||
cache: Arc<ModuleCache>,
|
cache: Arc<ModuleCache>,
|
||||||
depth_limit: u32,
|
depth_limit: u32,
|
||||||
|
default_origin: ScriptOwner,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
source,
|
source,
|
||||||
@@ -113,6 +160,7 @@ impl PicloudModuleResolver {
|
|||||||
in_progress: Mutex::new(Vec::new()),
|
in_progress: Mutex::new(Vec::new()),
|
||||||
depth: Mutex::new(0),
|
depth: Mutex::new(0),
|
||||||
depth_limit,
|
depth_limit,
|
||||||
|
default_origin,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +272,7 @@ impl ModuleResolver for PicloudModuleResolver {
|
|||||||
fn resolve(
|
fn resolve(
|
||||||
&self,
|
&self,
|
||||||
engine: &RhaiEngine,
|
engine: &RhaiEngine,
|
||||||
_source: Option<&str>,
|
importer_source: Option<&str>,
|
||||||
path: &str,
|
path: &str,
|
||||||
pos: Position,
|
pos: Position,
|
||||||
) -> Result<SharedRhaiModule, Box<EvalAltResult>> {
|
) -> Result<SharedRhaiModule, Box<EvalAltResult>> {
|
||||||
@@ -319,17 +367,50 @@ impl ModuleResolver for PicloudModuleResolver {
|
|||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let lookup_result: Result<Option<picloud_shared::ModuleScript>, ModuleSourceError> =
|
// Lexical resolution (§5.5): resolve `path` against the *importing
|
||||||
tokio::task::block_in_place(|| handle.block_on(self.source.lookup(&self.cx, path)));
|
// node's* chain. Rhai gives `_source` = the importing AST's source
|
||||||
|
// tag — set to the module's defining node for a nested import, or
|
||||||
|
// unset (`None`) for the top-level entry script, where we fall back
|
||||||
|
// to `default_origin` (the executing script's node). An app-rooted
|
||||||
|
// walk reaches ancestor group modules; a group-rooted walk is sealed
|
||||||
|
// from app modules below it (the trust boundary).
|
||||||
|
let origin = importer_source
|
||||||
|
.and_then(decode_origin)
|
||||||
|
.unwrap_or(self.default_origin);
|
||||||
|
// §5.5 policy resolution: the source decides lexical (seal to `origin`)
|
||||||
|
// vs dynamic (an extension point resolves against the inheriting app,
|
||||||
|
// `cx.app_id`). The result is already the concrete module to bind, or a
|
||||||
|
// terminal NoProvider/NotFound.
|
||||||
|
let lookup_result: Result<picloud_shared::ModuleResolution, ModuleSourceError> =
|
||||||
|
tokio::task::block_in_place(|| {
|
||||||
|
handle.block_on(self.source.resolve_policy(origin, self.cx.app_id, path))
|
||||||
|
});
|
||||||
|
|
||||||
let module_row = match lookup_result {
|
let module_row = match lookup_result {
|
||||||
Ok(Some(m)) => m,
|
Ok(picloud_shared::ModuleResolution::Module(m)) => m,
|
||||||
Ok(None) => {
|
Ok(picloud_shared::ModuleResolution::NotFound) => {
|
||||||
return Err(Box::new(EvalAltResult::ErrorModuleNotFound(
|
return Err(Box::new(EvalAltResult::ErrorModuleNotFound(
|
||||||
path.to_string(),
|
path.to_string(),
|
||||||
pos,
|
pos,
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
Ok(picloud_shared::ModuleResolution::NoProvider) => {
|
||||||
|
// §5.5: an extension point with no provider for this app is a
|
||||||
|
// hard failure (the app must supply the module or a default
|
||||||
|
// body must exist up-chain). Distinct, actionable message.
|
||||||
|
return Err(Box::new(EvalAltResult::ErrorInModule(
|
||||||
|
path.to_string(),
|
||||||
|
Box::new(EvalAltResult::ErrorRuntime(
|
||||||
|
format!(
|
||||||
|
"extension point {path:?} has no provider for this app — \
|
||||||
|
define a module named {path:?} or a default body up-chain"
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
pos,
|
||||||
|
)),
|
||||||
|
pos,
|
||||||
|
)));
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// v1.1.4 §10a: redact the backend error before it
|
// v1.1.4 §10a: redact the backend error before it
|
||||||
// reaches a script. In public-HTTP context (principal:
|
// reaches a script. In public-HTTP context (principal:
|
||||||
@@ -355,8 +436,10 @@ impl ModuleResolver for PicloudModuleResolver {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Cache lookup: hit only if both key matches AND updated_at
|
// Cache lookup: hit only if both key matches AND updated_at
|
||||||
// matches (cache is invalidated lazily on version change).
|
// matches (cache is invalidated lazily on version change). Keyed by
|
||||||
let cache_key = (self.cx.app_id, path.to_string());
|
// the resolved module's id (lexically self-contained — see
|
||||||
|
// `ModuleCacheKey`).
|
||||||
|
let cache_key = module_row.script_id;
|
||||||
{
|
{
|
||||||
let mut cache = self.cache.lock().expect("module cache lock poisoned");
|
let mut cache = self.cache.lock().expect("module cache lock poisoned");
|
||||||
if let Some(cached) = cache.get(&cache_key) {
|
if let Some(cached) = cache.get(&cache_key) {
|
||||||
@@ -389,7 +472,7 @@ impl ModuleResolver for PicloudModuleResolver {
|
|||||||
// already been gated at create-time (admin endpoint runs
|
// already been gated at create-time (admin endpoint runs
|
||||||
// `validate_module`), but we revalidate here to catch DB-direct
|
// `validate_module`), but we revalidate here to catch DB-direct
|
||||||
// inserts that bypass the API surface.
|
// inserts that bypass the API surface.
|
||||||
let ast = engine.compile(&module_row.source).map_err(|e| {
|
let mut ast = engine.compile(&module_row.source).map_err(|e| {
|
||||||
// Wrap as an ErrorRuntime to preserve the parse message
|
// Wrap as an ErrorRuntime to preserve the parse message
|
||||||
// text without trying to reconstruct rhai's internal
|
// text without trying to reconstruct rhai's internal
|
||||||
// ParseErrorType variant (which would require matching on
|
// ParseErrorType variant (which would require matching on
|
||||||
@@ -404,6 +487,15 @@ impl ModuleResolver for PicloudModuleResolver {
|
|||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// Seal this module's own imports to its defining node (§5.5): tag
|
||||||
|
// the AST source with the resolved module's owner so that when
|
||||||
|
// `eval_ast_as_new` evaluates its nested `import`s, Rhai hands us
|
||||||
|
// that tag as `_source` and we resolve them lexically from *here*,
|
||||||
|
// not from the original importer. Fall back to the lookup origin
|
||||||
|
// for a corrupt owner row (never reached under the DB CHECK).
|
||||||
|
let module_owner = module_row.owner().unwrap_or(origin);
|
||||||
|
ast.set_source(encode_origin(module_owner));
|
||||||
|
|
||||||
if let Err(msg) = Self::check_module_shape(&ast, path) {
|
if let Err(msg) = Self::check_module_shape(&ast, path) {
|
||||||
return Err(Box::new(EvalAltResult::ErrorInModule(
|
return Err(Box::new(EvalAltResult::ErrorInModule(
|
||||||
path.to_string(),
|
path.to_string(),
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ pub struct Limits {
|
|||||||
/// Not script-overridable (this is a platform-level guard, not a
|
/// Not script-overridable (this is a platform-level guard, not a
|
||||||
/// per-script knob).
|
/// per-script knob).
|
||||||
pub module_import_depth_max: u32,
|
pub module_import_depth_max: u32,
|
||||||
|
|
||||||
|
/// v1.1.9: hard ceiling on `cx.trigger_depth` (shared between
|
||||||
|
/// trigger fan-out and `invoke()` re-entry). The dispatcher uses
|
||||||
|
/// `TriggerConfig::max_trigger_depth` for the SAME bound; the
|
||||||
|
/// invoke bridge uses this Limits-side mirror to short-circuit
|
||||||
|
/// before a DB round-trip. Defaults to 8 (matches
|
||||||
|
/// TriggerConfig::conservative); the picloud binary keeps the two
|
||||||
|
/// in sync by passing `TriggerConfig::from_env().max_trigger_depth`
|
||||||
|
/// through.
|
||||||
|
pub trigger_depth_max: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Limits {
|
impl Default for Limits {
|
||||||
@@ -42,6 +52,7 @@ impl Default for Limits {
|
|||||||
max_call_levels: 64,
|
max_call_levels: 64,
|
||||||
max_expr_depth: 64,
|
max_expr_depth: 64,
|
||||||
module_import_depth_max: 8,
|
module_import_depth_max: 8,
|
||||||
|
trigger_depth_max: 8,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,6 +86,9 @@ impl Limits {
|
|||||||
// module_import_depth_max is platform-level — overrides
|
// module_import_depth_max is platform-level — overrides
|
||||||
// never touch it. Carry through unchanged.
|
// never touch it. Carry through unchanged.
|
||||||
module_import_depth_max: self.module_import_depth_max,
|
module_import_depth_max: self.module_import_depth_max,
|
||||||
|
// trigger_depth_max is also platform-level (v1.1.9 invoke
|
||||||
|
// depth bound mirrors the dispatcher's trigger-depth cap).
|
||||||
|
trigger_depth_max: self.trigger_depth_max,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,41 @@
|
|||||||
//! `sdk_contract.rs::json_round_trip_preserves_nested_shapes` pins the
|
//! `sdk_contract.rs::json_round_trip_preserves_nested_shapes` pins the
|
||||||
//! observable round-trip.
|
//! observable round-trip.
|
||||||
|
|
||||||
use rhai::{Dynamic, Map};
|
use rhai::{Dynamic, EvalAltResult, Map};
|
||||||
use serde_json::Value as Json;
|
use serde_json::Value as Json;
|
||||||
|
use tokio::runtime::Handle as TokioHandle;
|
||||||
|
|
||||||
|
/// Run an async future inside the synchronous Rhai context.
|
||||||
|
///
|
||||||
|
/// `LocalExecutorClient` wraps script execution in `spawn_blocking`, so
|
||||||
|
/// the current Tokio runtime is reachable via `Handle::current()`. We
|
||||||
|
/// block on it directly; we are NOT calling this from an async task,
|
||||||
|
/// so blocking is the correct primitive.
|
||||||
|
///
|
||||||
|
/// Prefix each error string with `service` so a script reading the
|
||||||
|
/// runtime error message learns which SDK surface threw it.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Wraps the future's error variant or "no tokio runtime available" in
|
||||||
|
/// `EvalAltResult::ErrorRuntime`, suitable to return from a Rhai-
|
||||||
|
/// registered fn.
|
||||||
|
pub fn block_on<T, E, F>(service: &str, fut: F) -> Result<T, Box<EvalAltResult>>
|
||||||
|
where
|
||||||
|
F: std::future::Future<Output = Result<T, E>>,
|
||||||
|
E: std::fmt::Display,
|
||||||
|
{
|
||||||
|
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(
|
||||||
|
format!("{service}: no tokio runtime available: {e}").into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})?;
|
||||||
|
handle.block_on(fut).map_err(|err| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(format!("{service}: {err}").into(), rhai::Position::NONE).into()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert a `serde_json::Value` into a Rhai `Dynamic` suitable for
|
/// Convert a `serde_json::Value` into a Rhai `Dynamic` suitable for
|
||||||
/// pushing into a script's scope. Numbers prefer the narrowest type
|
/// pushing into a script's scope. Numbers prefer the narrowest type
|
||||||
|
|||||||
@@ -16,9 +16,9 @@
|
|||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use picloud_shared::{DeadLetterError, DeadLetterId, SdkCallCx, Services};
|
use super::bridge::block_on;
|
||||||
|
use picloud_shared::{DeadLetterId, SdkCallCx, Services};
|
||||||
use rhai::{Engine as RhaiEngine, EvalAltResult, Module};
|
use rhai::{Engine as RhaiEngine, EvalAltResult, Module};
|
||||||
use tokio::runtime::Handle as TokioHandle;
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||||
@@ -33,7 +33,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
|||||||
let dl_id = parse_dl_id(id)?;
|
let dl_id = parse_dl_id(id)?;
|
||||||
let svc = svc.clone();
|
let svc = svc.clone();
|
||||||
let cx = cx.clone();
|
let cx = cx.clone();
|
||||||
block_on(async move { svc.replay(&cx, dl_id).await })
|
block_on("dead_letters", async move { svc.replay(&cx, dl_id).await })
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -47,7 +47,9 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
|||||||
let reason = reason.to_string();
|
let reason = reason.to_string();
|
||||||
let svc = svc.clone();
|
let svc = svc.clone();
|
||||||
let cx = cx.clone();
|
let cx = cx.clone();
|
||||||
block_on(async move { svc.resolve(&cx, dl_id, &reason).await })
|
block_on("dead_letters", async move {
|
||||||
|
svc.resolve(&cx, dl_id, &reason).await
|
||||||
|
})
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -65,20 +67,3 @@ fn parse_dl_id(s: &str) -> Result<DeadLetterId, Box<EvalAltResult>> {
|
|||||||
.into()
|
.into()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn block_on<F>(fut: F) -> Result<(), Box<EvalAltResult>>
|
|
||||||
where
|
|
||||||
F: std::future::Future<Output = Result<(), DeadLetterError>> + Send,
|
|
||||||
{
|
|
||||||
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
|
||||||
EvalAltResult::ErrorRuntime(
|
|
||||||
format!("dead_letters: no tokio runtime available: {e}").into(),
|
|
||||||
rhai::Position::NONE,
|
|
||||||
)
|
|
||||||
.into()
|
|
||||||
})?;
|
|
||||||
handle.block_on(fut).map_err(|err| -> Box<EvalAltResult> {
|
|
||||||
EvalAltResult::ErrorRuntime(format!("dead_letters: {err}").into(), rhai::Position::NONE)
|
|
||||||
.into()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -23,12 +23,11 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use picloud_shared::{DocId, DocRow, DocsError, DocsService, SdkCallCx, Services};
|
use picloud_shared::{DocId, DocRow, DocsService, GroupDocsService, SdkCallCx, Services};
|
||||||
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||||
use tokio::runtime::Handle as TokioHandle;
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::bridge::{dynamic_to_json, json_to_dynamic};
|
use super::bridge::{block_on, dynamic_to_json, json_to_dynamic};
|
||||||
|
|
||||||
/// Per-call handle captured by the Rhai SDK. Cheap to clone (two Arcs
|
/// Per-call handle captured by the Rhai SDK. Cheap to clone (two Arcs
|
||||||
/// plus an owned string).
|
/// plus an owned string).
|
||||||
@@ -39,8 +38,20 @@ pub struct DocsHandle {
|
|||||||
cx: Arc<SdkCallCx>,
|
cx: Arc<SdkCallCx>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// §11.6 shared-collection handle, returned by `docs::shared_collection(name)`.
|
||||||
|
/// A distinct Rhai type from `DocsHandle` so a script's choice of
|
||||||
|
/// private-vs-shared scope is explicit. Routes through `GroupDocsService`, which
|
||||||
|
/// resolves the owning group from `cx.app_id` (the script never names a group).
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct GroupDocsHandle {
|
||||||
|
collection: String,
|
||||||
|
service: Arc<dyn GroupDocsService>,
|
||||||
|
cx: Arc<SdkCallCx>,
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||||
let docs_service = services.docs.clone();
|
let docs_service = services.docs.clone();
|
||||||
|
let group_docs_service = services.group_docs.clone();
|
||||||
|
|
||||||
let mut module = Module::new();
|
let mut module = Module::new();
|
||||||
{
|
{
|
||||||
@@ -60,6 +71,23 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
let group_docs_service = group_docs_service.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"shared_collection",
|
||||||
|
move |name: &str| -> Result<GroupDocsHandle, Box<EvalAltResult>> {
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err("docs::shared_collection name must not be empty".into());
|
||||||
|
}
|
||||||
|
Ok(GroupDocsHandle {
|
||||||
|
collection: name.to_string(),
|
||||||
|
service: group_docs_service.clone(),
|
||||||
|
cx: cx.clone(),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
engine.register_static_module("docs", module.into());
|
engine.register_static_module("docs", module.into());
|
||||||
|
|
||||||
engine.register_type_with_name::<DocsHandle>("DocsHandle");
|
engine.register_type_with_name::<DocsHandle>("DocsHandle");
|
||||||
@@ -71,6 +99,17 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
|||||||
register_update(engine);
|
register_update(engine);
|
||||||
register_delete(engine);
|
register_delete(engine);
|
||||||
register_list(engine);
|
register_list(engine);
|
||||||
|
|
||||||
|
// Same method names on GroupDocsHandle — Rhai dispatches by receiver type.
|
||||||
|
engine.register_type_with_name::<GroupDocsHandle>("GroupDocsHandle");
|
||||||
|
|
||||||
|
register_group_create(engine);
|
||||||
|
register_group_get(engine);
|
||||||
|
register_group_find(engine);
|
||||||
|
register_group_find_one(engine);
|
||||||
|
register_group_update(engine);
|
||||||
|
register_group_delete(engine);
|
||||||
|
register_group_list(engine);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn register_create(engine: &mut RhaiEngine) {
|
fn register_create(engine: &mut RhaiEngine) {
|
||||||
@@ -79,7 +118,9 @@ fn register_create(engine: &mut RhaiEngine) {
|
|||||||
|handle: &mut DocsHandle, data: Map| -> Result<String, Box<EvalAltResult>> {
|
|handle: &mut DocsHandle, data: Map| -> Result<String, Box<EvalAltResult>> {
|
||||||
let h = handle.clone();
|
let h = handle.clone();
|
||||||
let json = dynamic_to_json(&Dynamic::from(data));
|
let json = dynamic_to_json(&Dynamic::from(data));
|
||||||
let id = block_on(async move { h.service.create(&h.cx, &h.collection, json).await })?;
|
let id = block_on("docs", async move {
|
||||||
|
h.service.create(&h.cx, &h.collection, json).await
|
||||||
|
})?;
|
||||||
Ok(id.to_string())
|
Ok(id.to_string())
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -91,8 +132,9 @@ fn register_get(engine: &mut RhaiEngine) {
|
|||||||
|handle: &mut DocsHandle, id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
|handle: &mut DocsHandle, id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
let h = handle.clone();
|
let h = handle.clone();
|
||||||
let parsed_id = parse_doc_id(id)?;
|
let parsed_id = parse_doc_id(id)?;
|
||||||
let row =
|
let row = block_on("docs", async move {
|
||||||
block_on(async move { h.service.get(&h.cx, &h.collection, parsed_id).await })?;
|
h.service.get(&h.cx, &h.collection, parsed_id).await
|
||||||
|
})?;
|
||||||
Ok(row.map_or(Dynamic::UNIT, |d| Dynamic::from(doc_to_map(&d))))
|
Ok(row.map_or(Dynamic::UNIT, |d| Dynamic::from(doc_to_map(&d))))
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -104,7 +146,9 @@ fn register_find(engine: &mut RhaiEngine) {
|
|||||||
|handle: &mut DocsHandle, filter: Map| -> Result<Array, Box<EvalAltResult>> {
|
|handle: &mut DocsHandle, filter: Map| -> Result<Array, Box<EvalAltResult>> {
|
||||||
let h = handle.clone();
|
let h = handle.clone();
|
||||||
let json = dynamic_to_json(&Dynamic::from(filter));
|
let json = dynamic_to_json(&Dynamic::from(filter));
|
||||||
let rows = block_on(async move { h.service.find(&h.cx, &h.collection, json).await })?;
|
let rows = block_on("docs", async move {
|
||||||
|
h.service.find(&h.cx, &h.collection, json).await
|
||||||
|
})?;
|
||||||
Ok(rows
|
Ok(rows
|
||||||
.iter()
|
.iter()
|
||||||
.map(|d| Dynamic::from(doc_to_map(d)))
|
.map(|d| Dynamic::from(doc_to_map(d)))
|
||||||
@@ -119,8 +163,9 @@ fn register_find_one(engine: &mut RhaiEngine) {
|
|||||||
|handle: &mut DocsHandle, filter: Map| -> Result<Dynamic, Box<EvalAltResult>> {
|
|handle: &mut DocsHandle, filter: Map| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
let h = handle.clone();
|
let h = handle.clone();
|
||||||
let json = dynamic_to_json(&Dynamic::from(filter));
|
let json = dynamic_to_json(&Dynamic::from(filter));
|
||||||
let row =
|
let row = block_on("docs", async move {
|
||||||
block_on(async move { h.service.find_one(&h.cx, &h.collection, json).await })?;
|
h.service.find_one(&h.cx, &h.collection, json).await
|
||||||
|
})?;
|
||||||
Ok(row.map_or(Dynamic::UNIT, |d| Dynamic::from(doc_to_map(&d))))
|
Ok(row.map_or(Dynamic::UNIT, |d| Dynamic::from(doc_to_map(&d))))
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -133,7 +178,7 @@ fn register_update(engine: &mut RhaiEngine) {
|
|||||||
let h = handle.clone();
|
let h = handle.clone();
|
||||||
let parsed_id = parse_doc_id(id)?;
|
let parsed_id = parse_doc_id(id)?;
|
||||||
let json = dynamic_to_json(&Dynamic::from(data));
|
let json = dynamic_to_json(&Dynamic::from(data));
|
||||||
block_on(async move {
|
block_on("docs", async move {
|
||||||
h.service
|
h.service
|
||||||
.update(&h.cx, &h.collection, parsed_id, json)
|
.update(&h.cx, &h.collection, parsed_id, json)
|
||||||
.await
|
.await
|
||||||
@@ -148,7 +193,9 @@ fn register_delete(engine: &mut RhaiEngine) {
|
|||||||
|handle: &mut DocsHandle, id: &str| -> Result<bool, Box<EvalAltResult>> {
|
|handle: &mut DocsHandle, id: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||||
let h = handle.clone();
|
let h = handle.clone();
|
||||||
let parsed_id = parse_doc_id(id)?;
|
let parsed_id = parse_doc_id(id)?;
|
||||||
block_on(async move { h.service.delete(&h.cx, &h.collection, parsed_id).await })
|
block_on("docs", async move {
|
||||||
|
h.service.delete(&h.cx, &h.collection, parsed_id).await
|
||||||
|
})
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -192,7 +239,154 @@ fn list_call(
|
|||||||
limit: u32,
|
limit: u32,
|
||||||
) -> Result<Map, Box<EvalAltResult>> {
|
) -> Result<Map, Box<EvalAltResult>> {
|
||||||
let h = handle.clone();
|
let h = handle.clone();
|
||||||
let page = block_on(async move {
|
let page = block_on("docs", async move {
|
||||||
|
h.service
|
||||||
|
.list(&h.cx, &h.collection, cursor.as_deref(), limit)
|
||||||
|
.await
|
||||||
|
})?;
|
||||||
|
let mut m = Map::new();
|
||||||
|
let docs: Array = page
|
||||||
|
.docs
|
||||||
|
.iter()
|
||||||
|
.map(|d| Dynamic::from(doc_to_map(d)))
|
||||||
|
.collect();
|
||||||
|
m.insert("docs".into(), docs.into());
|
||||||
|
m.insert(
|
||||||
|
"next_cursor".into(),
|
||||||
|
page.next_cursor.map_or(Dynamic::UNIT, Dynamic::from),
|
||||||
|
);
|
||||||
|
Ok(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- GroupDocsHandle methods (§11.6 shared docs collections) ---------------
|
||||||
|
|
||||||
|
fn register_group_create(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"create",
|
||||||
|
|handle: &mut GroupDocsHandle, data: Map| -> Result<String, Box<EvalAltResult>> {
|
||||||
|
let h = handle.clone();
|
||||||
|
let json = dynamic_to_json(&Dynamic::from(data));
|
||||||
|
let id = block_on("docs", async move {
|
||||||
|
h.service.create(&h.cx, &h.collection, json).await
|
||||||
|
})?;
|
||||||
|
Ok(id.to_string())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_group_get(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"get",
|
||||||
|
|handle: &mut GroupDocsHandle, id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
let h = handle.clone();
|
||||||
|
let parsed_id = parse_doc_id(id)?;
|
||||||
|
let row = block_on("docs", async move {
|
||||||
|
h.service.get(&h.cx, &h.collection, parsed_id).await
|
||||||
|
})?;
|
||||||
|
Ok(row.map_or(Dynamic::UNIT, |d| Dynamic::from(doc_to_map(&d))))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_group_find(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"find",
|
||||||
|
|handle: &mut GroupDocsHandle, filter: Map| -> Result<Array, Box<EvalAltResult>> {
|
||||||
|
let h = handle.clone();
|
||||||
|
let json = dynamic_to_json(&Dynamic::from(filter));
|
||||||
|
let rows = block_on("docs", async move {
|
||||||
|
h.service.find(&h.cx, &h.collection, json).await
|
||||||
|
})?;
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.map(|d| Dynamic::from(doc_to_map(d)))
|
||||||
|
.collect::<Vec<Dynamic>>())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_group_find_one(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"find_one",
|
||||||
|
|handle: &mut GroupDocsHandle, filter: Map| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
let h = handle.clone();
|
||||||
|
let json = dynamic_to_json(&Dynamic::from(filter));
|
||||||
|
let row = block_on("docs", async move {
|
||||||
|
h.service.find_one(&h.cx, &h.collection, json).await
|
||||||
|
})?;
|
||||||
|
Ok(row.map_or(Dynamic::UNIT, |d| Dynamic::from(doc_to_map(&d))))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_group_update(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"update",
|
||||||
|
|handle: &mut GroupDocsHandle, id: &str, data: Map| -> Result<(), Box<EvalAltResult>> {
|
||||||
|
let h = handle.clone();
|
||||||
|
let parsed_id = parse_doc_id(id)?;
|
||||||
|
let json = dynamic_to_json(&Dynamic::from(data));
|
||||||
|
block_on("docs", async move {
|
||||||
|
h.service
|
||||||
|
.update(&h.cx, &h.collection, parsed_id, json)
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_group_delete(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"delete",
|
||||||
|
|handle: &mut GroupDocsHandle, id: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||||
|
let h = handle.clone();
|
||||||
|
let parsed_id = parse_doc_id(id)?;
|
||||||
|
block_on("docs", async move {
|
||||||
|
h.service.delete(&h.cx, &h.collection, parsed_id).await
|
||||||
|
})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_group_list(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"list",
|
||||||
|
|handle: &mut GroupDocsHandle| -> Result<Map, Box<EvalAltResult>> {
|
||||||
|
group_list_call(handle, None, 0)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
engine.register_fn(
|
||||||
|
"list",
|
||||||
|
|handle: &mut GroupDocsHandle, args: Map| -> Result<Map, Box<EvalAltResult>> {
|
||||||
|
let cursor = match args.get("cursor") {
|
||||||
|
Some(d) if !d.is_unit() => {
|
||||||
|
Some(d.clone().into_string().map_err(|_| -> Box<EvalAltResult> {
|
||||||
|
"docs::list: 'cursor' must be a string or ()".into()
|
||||||
|
})?)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let limit = match args.get("limit") {
|
||||||
|
Some(d) if !d.is_unit() => {
|
||||||
|
let n = d.as_int().map_err(|_| -> Box<EvalAltResult> {
|
||||||
|
"docs::list: 'limit' must be an integer".into()
|
||||||
|
})?;
|
||||||
|
u32::try_from(n.max(0)).unwrap_or(0)
|
||||||
|
}
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
group_list_call(handle, cursor, limit)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn group_list_call(
|
||||||
|
handle: &GroupDocsHandle,
|
||||||
|
cursor: Option<String>,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<Map, Box<EvalAltResult>> {
|
||||||
|
let h = handle.clone();
|
||||||
|
let page = block_on("docs", async move {
|
||||||
h.service
|
h.service
|
||||||
.list(&h.cx, &h.collection, cursor.as_deref(), limit)
|
.list(&h.cx, &h.collection, cursor.as_deref(), limit)
|
||||||
.await
|
.await
|
||||||
@@ -232,24 +426,3 @@ fn parse_doc_id(id: &str) -> Result<DocId, Box<EvalAltResult>> {
|
|||||||
.into()
|
.into()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mirrors `kv.rs::block_on` — Tokio runtime is reachable from inside
|
|
||||||
/// the `spawn_blocking` wrapper that owns Rhai execution. Errors
|
|
||||||
/// prefix with `"docs: "` so scripts see `docs: forbidden`,
|
|
||||||
/// `docs: document not found`, `docs: unsupported operator: …`, etc.
|
|
||||||
fn block_on<F, T>(fut: F) -> Result<T, Box<EvalAltResult>>
|
|
||||||
where
|
|
||||||
F: std::future::Future<Output = Result<T, DocsError>> + Send,
|
|
||||||
T: Send,
|
|
||||||
{
|
|
||||||
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
|
||||||
EvalAltResult::ErrorRuntime(
|
|
||||||
format!("docs: no tokio runtime available: {e}").into(),
|
|
||||||
rhai::Position::NONE,
|
|
||||||
)
|
|
||||||
.into()
|
|
||||||
})?;
|
|
||||||
handle.block_on(fut).map_err(|err| -> Box<EvalAltResult> {
|
|
||||||
EvalAltResult::ErrorRuntime(format!("docs: {err}").into(), rhai::Position::NONE).into()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
131
crates/executor-core/src/sdk/email.rs
Normal file
131
crates/executor-core/src/sdk/email.rs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
//! `email::` Rhai bridge — outbound email (v1.1.7).
|
||||||
|
//!
|
||||||
|
//! ```rhai
|
||||||
|
//! email::send(#{
|
||||||
|
//! to: "alice@example.com", // String or Array of String
|
||||||
|
//! from: "alerts@myapp.com",
|
||||||
|
//! subject: "Build complete",
|
||||||
|
//! text: "Your deploy finished."
|
||||||
|
//! });
|
||||||
|
//!
|
||||||
|
//! email::send_html(#{
|
||||||
|
//! to: ["alice@x.com", "bob@y.com"],
|
||||||
|
//! cc: ["dave@z.com"],
|
||||||
|
//! bcc: ["audit@myapp.com"],
|
||||||
|
//! from: "alerts@myapp.com",
|
||||||
|
//! reply_to: "support@myapp.com", // optional; defaults to `from`
|
||||||
|
//! subject: "Build complete",
|
||||||
|
//! text: "Your deploy finished.", // plain-text fallback
|
||||||
|
//! html: "<p>Your deploy <b>finished</b>.</p>"
|
||||||
|
//! });
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Both map onto `EmailService::send`. `email::send` forces a text-only
|
||||||
|
//! message (any `html` key is ignored); `email::send_html` requires an
|
||||||
|
//! `html` part. `app_id` is derived from `cx.app_id` in the service.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use super::bridge::block_on;
|
||||||
|
use picloud_shared::{OutboundEmail, SdkCallCx, Services};
|
||||||
|
use rhai::{Array, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||||
|
|
||||||
|
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||||
|
let svc = services.email.clone();
|
||||||
|
let mut module = Module::new();
|
||||||
|
|
||||||
|
// email::send(#{...}) — plain text (html ignored).
|
||||||
|
{
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn("send", move |opts: Map| -> Result<(), Box<EvalAltResult>> {
|
||||||
|
let mut email = parse_email(&opts)?;
|
||||||
|
email.html = None; // text-only path
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
block_on("email", async move { svc.send(&cx, email).await })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// email::send_html(#{...}) — multipart text + html (html required).
|
||||||
|
{
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"send_html",
|
||||||
|
move |opts: Map| -> Result<(), Box<EvalAltResult>> {
|
||||||
|
let email = parse_email(&opts)?;
|
||||||
|
if email.html.as_ref().is_none_or(String::is_empty) {
|
||||||
|
return Err(runtime_err(
|
||||||
|
"email::send_html: an 'html' field is required (use email::send for text-only)",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
block_on("email", async move { svc.send(&cx, email).await })
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.register_static_module("email", module.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the Rhai options map into an [`OutboundEmail`]. Field-level
|
||||||
|
/// validation (required fields, address shape) happens in the service;
|
||||||
|
/// here we only do type coercion (String/Array → Vec<String>).
|
||||||
|
fn parse_email(opts: &Map) -> Result<OutboundEmail, Box<EvalAltResult>> {
|
||||||
|
Ok(OutboundEmail {
|
||||||
|
to: addresses(opts, "to")?,
|
||||||
|
cc: addresses(opts, "cc")?,
|
||||||
|
bcc: addresses(opts, "bcc")?,
|
||||||
|
from: string_field(opts, "from").unwrap_or_default(),
|
||||||
|
reply_to: string_field(opts, "reply_to"),
|
||||||
|
subject: string_field(opts, "subject").unwrap_or_default(),
|
||||||
|
text: string_field(opts, "text"),
|
||||||
|
html: string_field(opts, "html"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a string field. Missing or `()` → `None`.
|
||||||
|
fn string_field(opts: &Map, key: &str) -> Option<String> {
|
||||||
|
match opts.get(key) {
|
||||||
|
None => None,
|
||||||
|
Some(d) if d.is_unit() => None,
|
||||||
|
Some(d) if d.is_string() => Some(d.clone().into_string().unwrap_or_default()),
|
||||||
|
// Coerce non-string scalars via display (numbers, etc.).
|
||||||
|
Some(d) => Some(d.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read an address list: a String becomes a one-element list; an Array
|
||||||
|
/// of Strings becomes a list; missing/`()` is empty.
|
||||||
|
fn addresses(opts: &Map, key: &str) -> Result<Vec<String>, Box<EvalAltResult>> {
|
||||||
|
match opts.get(key) {
|
||||||
|
None => Ok(Vec::new()),
|
||||||
|
Some(d) if d.is_unit() => Ok(Vec::new()),
|
||||||
|
Some(d) if d.is_string() => Ok(vec![d.clone().into_string().unwrap_or_default()]),
|
||||||
|
Some(d) => {
|
||||||
|
if let Some(arr) = d.clone().try_cast::<Array>() {
|
||||||
|
let mut out = Vec::with_capacity(arr.len());
|
||||||
|
for el in arr {
|
||||||
|
if !el.is_string() {
|
||||||
|
return Err(runtime_err(&format!(
|
||||||
|
"email: '{key}' array must contain only strings"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
out.push(el.into_string().unwrap_or_default());
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
} else {
|
||||||
|
Err(runtime_err(&format!(
|
||||||
|
"email: '{key}' must be a string or an array of strings"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::unnecessary_box_returns)]
|
||||||
|
fn runtime_err(msg: &str) -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(msg.into(), rhai::Position::NONE).into()
|
||||||
|
}
|
||||||
@@ -23,11 +23,11 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use super::bridge::block_on;
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
FileMeta, FileUpdate, FilesError, FilesService, NewFile, SdkCallCx, Services,
|
FileMeta, FileUpdate, FilesService, GroupFilesService, NewFile, SdkCallCx, Services,
|
||||||
};
|
};
|
||||||
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||||
use tokio::runtime::Handle as TokioHandle;
|
|
||||||
|
|
||||||
/// Per-call handle captured by the Rhai SDK. Cheap to clone (two Arcs
|
/// Per-call handle captured by the Rhai SDK. Cheap to clone (two Arcs
|
||||||
/// plus an owned string).
|
/// plus an owned string).
|
||||||
@@ -38,8 +38,20 @@ pub struct FilesHandle {
|
|||||||
cx: Arc<SdkCallCx>,
|
cx: Arc<SdkCallCx>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// §11.6 shared-collection handle, returned by `files::shared_collection(name)`.
|
||||||
|
/// Same method surface as `FilesHandle`, but routes through the
|
||||||
|
/// `GroupFilesService` — the owning group is resolved from `cx.app_id`'s
|
||||||
|
/// ancestor chain inside the service (the isolation boundary).
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct GroupFilesHandle {
|
||||||
|
collection: String,
|
||||||
|
service: Arc<dyn GroupFilesService>,
|
||||||
|
cx: Arc<SdkCallCx>,
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||||
let files_service = services.files.clone();
|
let files_service = services.files.clone();
|
||||||
|
let group_files_service = services.group_files.clone();
|
||||||
|
|
||||||
let mut module = Module::new();
|
let mut module = Module::new();
|
||||||
{
|
{
|
||||||
@@ -59,6 +71,23 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
let group_files_service = group_files_service.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"shared_collection",
|
||||||
|
move |name: &str| -> Result<GroupFilesHandle, Box<EvalAltResult>> {
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err("files::shared_collection name must not be empty".into());
|
||||||
|
}
|
||||||
|
Ok(GroupFilesHandle {
|
||||||
|
collection: name.to_string(),
|
||||||
|
service: group_files_service.clone(),
|
||||||
|
cx: cx.clone(),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
engine.register_static_module("files", module.into());
|
engine.register_static_module("files", module.into());
|
||||||
|
|
||||||
engine.register_type_with_name::<FilesHandle>("FilesHandle");
|
engine.register_type_with_name::<FilesHandle>("FilesHandle");
|
||||||
@@ -69,6 +98,15 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
|||||||
register_update(engine);
|
register_update(engine);
|
||||||
register_delete(engine);
|
register_delete(engine);
|
||||||
register_list(engine);
|
register_list(engine);
|
||||||
|
|
||||||
|
// Same method names on GroupFilesHandle — Rhai dispatches by receiver type.
|
||||||
|
engine.register_type_with_name::<GroupFilesHandle>("GroupFilesHandle");
|
||||||
|
register_group_create(engine);
|
||||||
|
register_group_head(engine);
|
||||||
|
register_group_get(engine);
|
||||||
|
register_group_update(engine);
|
||||||
|
register_group_delete(engine);
|
||||||
|
register_group_list(engine);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn register_create(engine: &mut RhaiEngine) {
|
fn register_create(engine: &mut RhaiEngine) {
|
||||||
@@ -84,7 +122,9 @@ fn register_create(engine: &mut RhaiEngine) {
|
|||||||
content_type,
|
content_type,
|
||||||
data,
|
data,
|
||||||
};
|
};
|
||||||
let id = block_on(async move { h.service.create(&h.cx, &h.collection, new).await })?;
|
let id = block_on("files", async move {
|
||||||
|
h.service.create(&h.cx, &h.collection, new).await
|
||||||
|
})?;
|
||||||
Ok(id.to_string())
|
Ok(id.to_string())
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -96,7 +136,9 @@ fn register_head(engine: &mut RhaiEngine) {
|
|||||||
|handle: &mut FilesHandle, id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
|handle: &mut FilesHandle, id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
let h = handle.clone();
|
let h = handle.clone();
|
||||||
let id = id.to_string();
|
let id = id.to_string();
|
||||||
let meta = block_on(async move { h.service.head(&h.cx, &h.collection, &id).await })?;
|
let meta = block_on("files", async move {
|
||||||
|
h.service.head(&h.cx, &h.collection, &id).await
|
||||||
|
})?;
|
||||||
Ok(meta.map_or(Dynamic::UNIT, |m| file_meta_to_map(&m).into()))
|
Ok(meta.map_or(Dynamic::UNIT, |m| file_meta_to_map(&m).into()))
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -108,7 +150,9 @@ fn register_get(engine: &mut RhaiEngine) {
|
|||||||
|handle: &mut FilesHandle, id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
|handle: &mut FilesHandle, id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
let h = handle.clone();
|
let h = handle.clone();
|
||||||
let id = id.to_string();
|
let id = id.to_string();
|
||||||
let bytes = block_on(async move { h.service.get(&h.cx, &h.collection, &id).await })?;
|
let bytes = block_on("files", async move {
|
||||||
|
h.service.get(&h.cx, &h.collection, &id).await
|
||||||
|
})?;
|
||||||
Ok(bytes.map_or(Dynamic::UNIT, Dynamic::from_blob))
|
Ok(bytes.map_or(Dynamic::UNIT, Dynamic::from_blob))
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -128,7 +172,9 @@ fn register_update(engine: &mut RhaiEngine) {
|
|||||||
name,
|
name,
|
||||||
content_type,
|
content_type,
|
||||||
};
|
};
|
||||||
block_on(async move { h.service.update(&h.cx, &h.collection, &id, upd).await })
|
block_on("files", async move {
|
||||||
|
h.service.update(&h.cx, &h.collection, &id, upd).await
|
||||||
|
})
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -139,7 +185,9 @@ fn register_delete(engine: &mut RhaiEngine) {
|
|||||||
|handle: &mut FilesHandle, id: &str| -> Result<bool, Box<EvalAltResult>> {
|
|handle: &mut FilesHandle, id: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||||
let h = handle.clone();
|
let h = handle.clone();
|
||||||
let id = id.to_string();
|
let id = id.to_string();
|
||||||
block_on(async move { h.service.delete(&h.cx, &h.collection, &id).await })
|
block_on("files", async move {
|
||||||
|
h.service.delete(&h.cx, &h.collection, &id).await
|
||||||
|
})
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -193,7 +241,164 @@ fn list_call(
|
|||||||
limit: u32,
|
limit: u32,
|
||||||
) -> Result<Map, Box<EvalAltResult>> {
|
) -> Result<Map, Box<EvalAltResult>> {
|
||||||
let h = handle.clone();
|
let h = handle.clone();
|
||||||
let page = block_on(async move {
|
let page = block_on("files", async move {
|
||||||
|
h.service
|
||||||
|
.list(&h.cx, &h.collection, cursor.as_deref(), limit)
|
||||||
|
.await
|
||||||
|
})?;
|
||||||
|
let mut m = Map::new();
|
||||||
|
let files: Array = page
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.map(|meta| Dynamic::from(file_meta_to_map(meta)))
|
||||||
|
.collect();
|
||||||
|
m.insert("files".into(), files.into());
|
||||||
|
m.insert(
|
||||||
|
"next_cursor".into(),
|
||||||
|
page.next_cursor.map_or(Dynamic::UNIT, Dynamic::from),
|
||||||
|
);
|
||||||
|
Ok(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- GroupFilesHandle methods (§11.6 shared files collections) -------------
|
||||||
|
// Bodies mirror the app handle's; only the receiver type and service differ
|
||||||
|
// (Rhai dispatches `create`/`head`/... by the handle's concrete type).
|
||||||
|
|
||||||
|
fn register_group_create(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"create",
|
||||||
|
|handle: &mut GroupFilesHandle, meta: Map| -> Result<String, Box<EvalAltResult>> {
|
||||||
|
let name = require_string(&meta, "name")?;
|
||||||
|
let content_type = require_string(&meta, "content_type")?;
|
||||||
|
let data = require_blob(&meta, "data")?;
|
||||||
|
let h = handle.clone();
|
||||||
|
let new = NewFile {
|
||||||
|
name,
|
||||||
|
content_type,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
let id = block_on("files", async move {
|
||||||
|
h.service.create(&h.cx, &h.collection, new).await
|
||||||
|
})?;
|
||||||
|
Ok(id.to_string())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_group_head(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"head",
|
||||||
|
|handle: &mut GroupFilesHandle, id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
let h = handle.clone();
|
||||||
|
let id = id.to_string();
|
||||||
|
let meta = block_on("files", async move {
|
||||||
|
h.service.head(&h.cx, &h.collection, &id).await
|
||||||
|
})?;
|
||||||
|
Ok(meta.map_or(Dynamic::UNIT, |m| file_meta_to_map(&m).into()))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_group_get(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"get",
|
||||||
|
|handle: &mut GroupFilesHandle, id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
let h = handle.clone();
|
||||||
|
let id = id.to_string();
|
||||||
|
let bytes = block_on("files", async move {
|
||||||
|
h.service.get(&h.cx, &h.collection, &id).await
|
||||||
|
})?;
|
||||||
|
Ok(bytes.map_or(Dynamic::UNIT, Dynamic::from_blob))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_group_update(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"update",
|
||||||
|
|handle: &mut GroupFilesHandle, id: &str, meta: Map| -> Result<(), Box<EvalAltResult>> {
|
||||||
|
let data = require_blob(&meta, "data")?;
|
||||||
|
let name = optional_string(&meta, "name")?;
|
||||||
|
let content_type = optional_string(&meta, "content_type")?;
|
||||||
|
let h = handle.clone();
|
||||||
|
let id = id.to_string();
|
||||||
|
let upd = FileUpdate {
|
||||||
|
data,
|
||||||
|
name,
|
||||||
|
content_type,
|
||||||
|
};
|
||||||
|
block_on("files", async move {
|
||||||
|
h.service.update(&h.cx, &h.collection, &id, upd).await
|
||||||
|
})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_group_delete(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"delete",
|
||||||
|
|handle: &mut GroupFilesHandle, id: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||||
|
let h = handle.clone();
|
||||||
|
let id = id.to_string();
|
||||||
|
block_on("files", async move {
|
||||||
|
h.service.delete(&h.cx, &h.collection, &id).await
|
||||||
|
})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_group_list(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"list",
|
||||||
|
|handle: &mut GroupFilesHandle| -> Result<Map, Box<EvalAltResult>> {
|
||||||
|
group_list_call(handle, None, 0)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
engine.register_fn(
|
||||||
|
"list",
|
||||||
|
|handle: &mut GroupFilesHandle, cursor: &str| -> Result<Map, Box<EvalAltResult>> {
|
||||||
|
group_list_call(handle, Some(cursor.to_string()), 0)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
engine.register_fn(
|
||||||
|
"list",
|
||||||
|
|handle: &mut GroupFilesHandle,
|
||||||
|
cursor: &str,
|
||||||
|
limit: i64|
|
||||||
|
-> Result<Map, Box<EvalAltResult>> {
|
||||||
|
let limit = u32::try_from(limit.max(0)).unwrap_or(0);
|
||||||
|
group_list_call(handle, Some(cursor.to_string()), limit)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
engine.register_fn(
|
||||||
|
"list",
|
||||||
|
|handle: &mut GroupFilesHandle, opts: Map| -> Result<Map, Box<EvalAltResult>> {
|
||||||
|
let cursor = match opts.get("cursor") {
|
||||||
|
Some(v) if !v.is_unit() => {
|
||||||
|
Some(v.clone().into_string().map_err(|_| -> Box<EvalAltResult> {
|
||||||
|
"files: list cursor must be a string".into()
|
||||||
|
})?)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let limit = match opts.get("limit") {
|
||||||
|
Some(v) if !v.is_unit() => {
|
||||||
|
u32::try_from(v.as_int().unwrap_or(0).max(0)).unwrap_or(0)
|
||||||
|
}
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
group_list_call(handle, cursor, limit)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn group_list_call(
|
||||||
|
handle: &GroupFilesHandle,
|
||||||
|
cursor: Option<String>,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<Map, Box<EvalAltResult>> {
|
||||||
|
let h = handle.clone();
|
||||||
|
let page = block_on("files", async move {
|
||||||
h.service
|
h.service
|
||||||
.list(&h.cx, &h.collection, cursor.as_deref(), limit)
|
.list(&h.cx, &h.collection, cursor.as_deref(), limit)
|
||||||
.await
|
.await
|
||||||
@@ -259,23 +464,3 @@ fn require_blob(meta: &Map, field: &'static str) -> Result<Vec<u8>, Box<EvalAltR
|
|||||||
None => Err(format!("files: missing required field '{field}'").into()),
|
None => Err(format!("files: missing required field '{field}'").into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run an async future inside the synchronous Rhai context. Mirrors
|
|
||||||
/// `kv::block_on`; safe because `LocalExecutorClient` runs the script
|
|
||||||
/// under `spawn_blocking`, so a runtime handle is reachable.
|
|
||||||
fn block_on<F, T>(fut: F) -> Result<T, Box<EvalAltResult>>
|
|
||||||
where
|
|
||||||
F: std::future::Future<Output = Result<T, FilesError>> + Send,
|
|
||||||
T: Send,
|
|
||||||
{
|
|
||||||
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
|
||||||
EvalAltResult::ErrorRuntime(
|
|
||||||
format!("files: no tokio runtime available: {e}").into(),
|
|
||||||
rhai::Position::NONE,
|
|
||||||
)
|
|
||||||
.into()
|
|
||||||
})?;
|
|
||||||
handle.block_on(fut).map_err(|err| -> Box<EvalAltResult> {
|
|
||||||
EvalAltResult::ErrorRuntime(format!("files: {err}").into(), rhai::Position::NONE).into()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
348
crates/executor-core/src/sdk/invoke.rs
Normal file
348
crates/executor-core/src/sdk/invoke.rs
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
//! `invoke()` Rhai bridge — synchronous, same-app, re-entrant.
|
||||||
|
//!
|
||||||
|
//! ```rhai
|
||||||
|
//! let result = invoke("/api/payments/process", #{ order_id: 123 });
|
||||||
|
//! let result = invoke(script_id("uuid-..."), args);
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Re-entrancy: the bridge captures `Arc<Engine>` (via
|
||||||
|
//! `Engine::self_arc`) and calls `engine.execute(&source, req)` directly
|
||||||
|
//! inside the caller's `spawn_blocking` thread. Same engine instance,
|
||||||
|
//! same `Services`, same `Limits` — only the per-call `SdkCallCx`
|
||||||
|
//! changes (the callee gets `trigger_depth + 1` and a fresh
|
||||||
|
//! `execution_id`).
|
||||||
|
//!
|
||||||
|
//! Cross-app rejection happens at the `InvokeService::resolve` layer —
|
||||||
|
//! every method derives `app_id` from `cx.app_id` and returns
|
||||||
|
//! `InvokeError::CrossApp` if the resolved script belongs elsewhere.
|
||||||
|
//!
|
||||||
|
//! Depth bound: `cx.trigger_depth + 1 > limits.trigger_depth_max` →
|
||||||
|
//! `InvokeError::DepthExceeded`. Shared with the trigger fan-out depth
|
||||||
|
//! limit per the design notes (no separate `invoke_depth_max`).
|
||||||
|
//!
|
||||||
|
//! Closures captured across the `invoke` boundary are rejected at the
|
||||||
|
//! args-to-JSON conversion — `FnPtr` doesn't survive serialization and
|
||||||
|
//! re-entry into a fresh engine instance is the wrong scope for it
|
||||||
|
//! anyway.
|
||||||
|
//!
|
||||||
|
//! `invoke_async()` writes an outbox row (see `InvokeService::enqueue_async`)
|
||||||
|
//! and returns immediately with the new `ExecutionId` as a string. The
|
||||||
|
//! dispatcher fires it through the standard executor path; commit 10
|
||||||
|
//! wires the dispatcher's `OutboxSourceKind::Invoke` arm.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use base64::engine::general_purpose::STANDARD;
|
||||||
|
use base64::Engine as _;
|
||||||
|
use picloud_shared::{ExecutionId, InvokeService, InvokeTarget, ScriptId, SdkCallCx, Services};
|
||||||
|
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||||
|
use serde_json::Value as Json;
|
||||||
|
use tokio::runtime::Handle as TokioHandle;
|
||||||
|
|
||||||
|
use crate::engine::Engine;
|
||||||
|
use crate::sandbox::Limits;
|
||||||
|
use crate::sdk::bridge::json_to_dynamic;
|
||||||
|
use crate::types::{ExecRequest, InvocationType};
|
||||||
|
|
||||||
|
pub(super) fn register(
|
||||||
|
engine: &mut RhaiEngine,
|
||||||
|
services: &Services,
|
||||||
|
cx: Arc<SdkCallCx>,
|
||||||
|
limits: Limits,
|
||||||
|
self_engine: Option<Arc<Engine>>,
|
||||||
|
) {
|
||||||
|
let svc = services.invoke.clone();
|
||||||
|
let mut module = Module::new();
|
||||||
|
|
||||||
|
// Sync `invoke(target, args)` — returns the callee's return value.
|
||||||
|
{
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
let self_engine = self_engine.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"invoke",
|
||||||
|
move |target: Dynamic, args: Dynamic| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
let target = parse_target(target)?;
|
||||||
|
let args_json = args_to_json(&args)?;
|
||||||
|
let self_engine = self_engine.clone().ok_or_else(|| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(
|
||||||
|
"invoke: engine back-reference not installed (test setup missing set_self_weak?)".into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})?;
|
||||||
|
invoke_blocking(&svc, &cx, target, args_json, limits, &self_engine)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `invoke_async(target, args)` — fire-and-forget; returns the new
|
||||||
|
// ExecutionId as a string.
|
||||||
|
{
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"invoke_async",
|
||||||
|
move |target: Dynamic, args: Dynamic| -> Result<String, Box<EvalAltResult>> {
|
||||||
|
let target = parse_target(target)?;
|
||||||
|
let args_json = args_to_json(&args)?;
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(
|
||||||
|
format!("invoke_async: no tokio runtime: {e}").into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})?;
|
||||||
|
let exec_id = handle
|
||||||
|
.block_on(async move { svc.enqueue_async(&cx, target, args_json).await })
|
||||||
|
.map_err(|e| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(
|
||||||
|
format!("invoke_async: {e}").into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})?;
|
||||||
|
Ok(exec_id.to_string())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register globally — `invoke(...)` and `invoke_async(...)` are
|
||||||
|
// top-level helpers, not under a `::` namespace, mirroring the
|
||||||
|
// brief's syntax.
|
||||||
|
engine.register_global_module(module.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sync invoke path. Runs entirely inside the caller's
|
||||||
|
/// `spawn_blocking` thread; no gate (the outer execution is already
|
||||||
|
/// gate-admitted), no new spawn_blocking (we'd deadlock if we nested
|
||||||
|
/// inside the blocking pool).
|
||||||
|
fn invoke_blocking(
|
||||||
|
svc: &Arc<dyn InvokeService>,
|
||||||
|
cx: &Arc<SdkCallCx>,
|
||||||
|
target: InvokeTarget,
|
||||||
|
args_json: Json,
|
||||||
|
limits: Limits,
|
||||||
|
self_engine: &Arc<Engine>,
|
||||||
|
) -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(
|
||||||
|
format!("invoke: no tokio runtime: {e}").into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Depth check — done BEFORE resolve so a depth-exceeded loop
|
||||||
|
// doesn't waste a DB round-trip.
|
||||||
|
if cx.trigger_depth + 1 > limits.trigger_depth_max {
|
||||||
|
return Err(EvalAltResult::ErrorRuntime(
|
||||||
|
format!(
|
||||||
|
"invoke: depth limit exceeded (max {})",
|
||||||
|
limits.trigger_depth_max
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let svc_clone = svc.clone();
|
||||||
|
let cx_clone = cx.clone();
|
||||||
|
let target_label = target.describe();
|
||||||
|
let resolved = handle
|
||||||
|
.block_on(async move { svc_clone.resolve(&cx_clone, target).await })
|
||||||
|
.map_err(|e| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(
|
||||||
|
format!("invoke({target_label}): {e}").into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let execution_id = ExecutionId::new();
|
||||||
|
let req = ExecRequest {
|
||||||
|
execution_id,
|
||||||
|
request_id: cx.request_id,
|
||||||
|
script_id: resolved.script_id,
|
||||||
|
script_name: resolved.name.clone(),
|
||||||
|
invocation_type: InvocationType::Function,
|
||||||
|
path: "/invoke".into(),
|
||||||
|
method: String::new(),
|
||||||
|
headers: BTreeMap::new(),
|
||||||
|
body: args_json,
|
||||||
|
params: BTreeMap::new(),
|
||||||
|
query: BTreeMap::new(),
|
||||||
|
rest: String::new(),
|
||||||
|
sandbox_overrides: picloud_shared::ScriptSandbox::default(),
|
||||||
|
app_id: cx.app_id,
|
||||||
|
// Lexical origin (§5.5): the callee's defining node — a group
|
||||||
|
// script's imports resolve from the group even when invoked by an
|
||||||
|
// app. `None` falls back to `App(cx.app_id)` in the engine.
|
||||||
|
script_owner: resolved.owner,
|
||||||
|
// Same-app invoke is a function call, not a re-auth boundary —
|
||||||
|
// inherit the caller's principal.
|
||||||
|
principal: cx.principal.clone(),
|
||||||
|
trigger_depth: cx.trigger_depth + 1,
|
||||||
|
root_execution_id: cx.root_execution_id,
|
||||||
|
is_dead_letter_handler: cx.is_dead_letter_handler,
|
||||||
|
event: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// F-P-004: synchronous re-entry — route through the per-Engine
|
||||||
|
// AST cache so each callee parses once per (script_id, updated_at),
|
||||||
|
// not once per invoke. Composed workflows multiply parse cost by
|
||||||
|
// depth; the cache cuts that to constant compile + N executions.
|
||||||
|
let ast = self_engine
|
||||||
|
.compile_for_identity(resolved.script_id, resolved.updated_at, &resolved.source)
|
||||||
|
.map_err(|e| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(
|
||||||
|
format!("invoke({target_label}): {e}").into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})?;
|
||||||
|
let resp = self_engine
|
||||||
|
.execute_ast(&ast, req)
|
||||||
|
.map_err(|e| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(
|
||||||
|
format!("invoke({target_label}): {e}").into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// The callee's return is the response `body` JSON. Convert back to
|
||||||
|
// Dynamic for the caller. Status code + headers are dropped; the
|
||||||
|
// function-call mental model is "return value", not HTTP response.
|
||||||
|
Ok(json_to_dynamic(resp.body))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accept a string (route path OR script name) or a Rhai script-id
|
||||||
|
/// custom type. The string heuristic: starts with '/' → Path, else Name.
|
||||||
|
fn parse_target(target: Dynamic) -> Result<InvokeTarget, Box<EvalAltResult>> {
|
||||||
|
if let Ok(s) = target.clone().into_string() {
|
||||||
|
if s.starts_with('/') {
|
||||||
|
return Ok(InvokeTarget::Path(s));
|
||||||
|
}
|
||||||
|
// Heuristic: 36-char UUID-like string → Id; else Name. Keeps
|
||||||
|
// `invoke("payments_worker")` and `invoke("550e8400-...")` both
|
||||||
|
// working without a separate `script_id()` constructor in v1.1.9.
|
||||||
|
if s.len() == 36 {
|
||||||
|
if let Ok(uuid) = uuid::Uuid::parse_str(&s) {
|
||||||
|
return Ok(InvokeTarget::Id(ScriptId::from(uuid)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(InvokeTarget::Name(s));
|
||||||
|
}
|
||||||
|
let _ = target;
|
||||||
|
Err(EvalAltResult::ErrorRuntime(
|
||||||
|
"invoke: target must be a string (path or name) or a script_id".into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert Rhai Dynamic → JSON. Rejects FnPtr at any depth (closures
|
||||||
|
/// don't survive invoke boundaries).
|
||||||
|
fn args_to_json(value: &Dynamic) -> Result<Json, Box<EvalAltResult>> {
|
||||||
|
if value.is::<rhai::FnPtr>() {
|
||||||
|
return Err(EvalAltResult::ErrorRuntime(
|
||||||
|
"invoke: args must not contain FnPtr / closures".into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
if value.is_blob() {
|
||||||
|
let blob = value.clone().into_blob().unwrap_or_default();
|
||||||
|
return Ok(Json::String(STANDARD.encode(&blob)));
|
||||||
|
}
|
||||||
|
if value.is_unit() {
|
||||||
|
return Ok(Json::Null);
|
||||||
|
}
|
||||||
|
if let Ok(b) = value.as_bool() {
|
||||||
|
return Ok(Json::Bool(b));
|
||||||
|
}
|
||||||
|
if let Ok(i) = value.as_int() {
|
||||||
|
return Ok(Json::Number(i.into()));
|
||||||
|
}
|
||||||
|
if let Ok(f) = value.as_float() {
|
||||||
|
return Ok(serde_json::Number::from_f64(f).map_or(Json::Null, Json::Number));
|
||||||
|
}
|
||||||
|
if value.is_string() {
|
||||||
|
return Ok(Json::String(
|
||||||
|
value.clone().into_string().unwrap_or_default(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(arr) = value.clone().try_cast::<Array>() {
|
||||||
|
let mut out = Vec::with_capacity(arr.len());
|
||||||
|
for v in &arr {
|
||||||
|
out.push(args_to_json(v)?);
|
||||||
|
}
|
||||||
|
return Ok(Json::Array(out));
|
||||||
|
}
|
||||||
|
if let Some(map) = value.clone().try_cast::<Map>() {
|
||||||
|
let mut out = serde_json::Map::new();
|
||||||
|
for (k, v) in map {
|
||||||
|
out.insert(k.to_string(), args_to_json(&v)?);
|
||||||
|
}
|
||||||
|
return Ok(Json::Object(out));
|
||||||
|
}
|
||||||
|
Ok(Json::String(value.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Limits` is `Copy`; passed by value into `register` so closures take
|
||||||
|
/// owned copies. Hint for readers wondering why it doesn't need `Arc`.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
const _LIMITS_IS_COPY: fn() = || {
|
||||||
|
fn assert_copy<T: Copy>() {}
|
||||||
|
assert_copy::<Limits>();
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_target_path() {
|
||||||
|
let d = Dynamic::from("/api/foo");
|
||||||
|
let t = parse_target(d).unwrap();
|
||||||
|
assert!(matches!(t, InvokeTarget::Path(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_target_uuid_string_is_id() {
|
||||||
|
let d = Dynamic::from("550e8400-e29b-41d4-a716-446655440000");
|
||||||
|
let t = parse_target(d).unwrap();
|
||||||
|
assert!(matches!(t, InvokeTarget::Id(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_target_plain_name() {
|
||||||
|
let d = Dynamic::from("payments_worker");
|
||||||
|
let t = parse_target(d).unwrap();
|
||||||
|
match t {
|
||||||
|
InvokeTarget::Name(n) => assert_eq!(n, "payments_worker"),
|
||||||
|
other => panic!("expected Name, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn args_to_json_rejects_fnptr() {
|
||||||
|
let f = rhai::FnPtr::new("x").unwrap();
|
||||||
|
let d = Dynamic::from(f);
|
||||||
|
assert!(args_to_json(&d).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn args_to_json_round_trips_map() {
|
||||||
|
let mut m = Map::new();
|
||||||
|
m.insert("x".into(), Dynamic::from(42_i64));
|
||||||
|
let d = Dynamic::from(m);
|
||||||
|
let j = args_to_json(&d).unwrap();
|
||||||
|
assert_eq!(j, serde_json::json!({ "x": 42 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,11 +28,10 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use picloud_shared::{KvError, KvService, SdkCallCx, Services};
|
use picloud_shared::{GroupKvService, KvService, SdkCallCx, Services};
|
||||||
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||||
use tokio::runtime::Handle as TokioHandle;
|
|
||||||
|
|
||||||
use super::bridge::{dynamic_to_json, json_to_dynamic};
|
use super::bridge::{block_on, dynamic_to_json, json_to_dynamic};
|
||||||
|
|
||||||
/// Per-call handle captured by the Rhai SDK. Cheap to clone (two Arcs
|
/// Per-call handle captured by the Rhai SDK. Cheap to clone (two Arcs
|
||||||
/// plus an owned string).
|
/// plus an owned string).
|
||||||
@@ -43,11 +42,25 @@ pub struct KvHandle {
|
|||||||
cx: Arc<SdkCallCx>,
|
cx: Arc<SdkCallCx>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// §11.6 shared-collection handle, returned by `kv::shared_collection(name)`. A distinct
|
||||||
|
/// Rhai type from `KvHandle` so the method set can diverge (e.g. shared
|
||||||
|
/// collections never grow triggers) and a script's choice of private-vs-shared
|
||||||
|
/// scope is explicit. Routes through the `GroupKvService`, which resolves the
|
||||||
|
/// owning group from `cx.app_id` (the script never names a group).
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct GroupKvHandle {
|
||||||
|
collection: String,
|
||||||
|
service: Arc<dyn GroupKvService>,
|
||||||
|
cx: Arc<SdkCallCx>,
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||||
let kv_service = services.kv.clone();
|
let kv_service = services.kv.clone();
|
||||||
|
let group_kv_service = services.group_kv.clone();
|
||||||
|
|
||||||
// `kv::collection(name)` — handle constructor lives in the `kv`
|
// `kv::collection(name)` / `kv::shared_collection(name)` — both constructors live in
|
||||||
// static module so the script-visible call is `kv::collection(...)`.
|
// the `kv` static module so the script-visible calls are `kv::collection`
|
||||||
|
// and `kv::shared`.
|
||||||
let mut module = Module::new();
|
let mut module = Module::new();
|
||||||
{
|
{
|
||||||
let kv_service = kv_service.clone();
|
let kv_service = kv_service.clone();
|
||||||
@@ -66,6 +79,23 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
let group_kv_service = group_kv_service.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"shared_collection",
|
||||||
|
move |name: &str| -> Result<GroupKvHandle, Box<EvalAltResult>> {
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err("kv::shared_collection name must not be empty".into());
|
||||||
|
}
|
||||||
|
Ok(GroupKvHandle {
|
||||||
|
collection: name.to_string(),
|
||||||
|
service: group_kv_service.clone(),
|
||||||
|
cx: cx.clone(),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
engine.register_static_module("kv", module.into());
|
engine.register_static_module("kv", module.into());
|
||||||
|
|
||||||
// Methods on KvHandle — `register_fn` with `&mut KvHandle` first
|
// Methods on KvHandle — `register_fn` with `&mut KvHandle` first
|
||||||
@@ -78,6 +108,15 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
|||||||
register_has(engine);
|
register_has(engine);
|
||||||
register_delete(engine);
|
register_delete(engine);
|
||||||
register_list(engine);
|
register_list(engine);
|
||||||
|
|
||||||
|
// Same method names on GroupKvHandle — Rhai dispatches by receiver type.
|
||||||
|
engine.register_type_with_name::<GroupKvHandle>("GroupKvHandle");
|
||||||
|
|
||||||
|
register_group_get(engine);
|
||||||
|
register_group_set(engine);
|
||||||
|
register_group_has(engine);
|
||||||
|
register_group_delete(engine);
|
||||||
|
register_group_list(engine);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn register_get(engine: &mut RhaiEngine) {
|
fn register_get(engine: &mut RhaiEngine) {
|
||||||
@@ -85,8 +124,10 @@ fn register_get(engine: &mut RhaiEngine) {
|
|||||||
"get",
|
"get",
|
||||||
|handle: &mut KvHandle, key: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
|handle: &mut KvHandle, key: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
let h = handle.clone();
|
let h = handle.clone();
|
||||||
block_on(async move { h.service.get(&h.cx, &h.collection, key).await })
|
block_on("kv", async move {
|
||||||
.map(|opt| opt.map_or(Dynamic::UNIT, json_to_dynamic))
|
h.service.get(&h.cx, &h.collection, key).await
|
||||||
|
})
|
||||||
|
.map(|opt| opt.map_or(Dynamic::UNIT, json_to_dynamic))
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -97,7 +138,9 @@ fn register_set(engine: &mut RhaiEngine) {
|
|||||||
|handle: &mut KvHandle, key: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
|handle: &mut KvHandle, key: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
||||||
let h = handle.clone();
|
let h = handle.clone();
|
||||||
let json = dynamic_to_json(&value);
|
let json = dynamic_to_json(&value);
|
||||||
block_on(async move { h.service.set(&h.cx, &h.collection, key, json).await })
|
block_on("kv", async move {
|
||||||
|
h.service.set(&h.cx, &h.collection, key, json).await
|
||||||
|
})
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -107,7 +150,9 @@ fn register_has(engine: &mut RhaiEngine) {
|
|||||||
"has",
|
"has",
|
||||||
|handle: &mut KvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
|
|handle: &mut KvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||||
let h = handle.clone();
|
let h = handle.clone();
|
||||||
block_on(async move { h.service.has(&h.cx, &h.collection, key).await })
|
block_on("kv", async move {
|
||||||
|
h.service.has(&h.cx, &h.collection, key).await
|
||||||
|
})
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -117,7 +162,9 @@ fn register_delete(engine: &mut RhaiEngine) {
|
|||||||
"delete",
|
"delete",
|
||||||
|handle: &mut KvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
|
|handle: &mut KvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||||
let h = handle.clone();
|
let h = handle.clone();
|
||||||
block_on(async move { h.service.delete(&h.cx, &h.collection, key).await })
|
block_on("kv", async move {
|
||||||
|
h.service.delete(&h.cx, &h.collection, key).await
|
||||||
|
})
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -153,7 +200,7 @@ fn list_call(
|
|||||||
limit: u32,
|
limit: u32,
|
||||||
) -> Result<Map, Box<EvalAltResult>> {
|
) -> Result<Map, Box<EvalAltResult>> {
|
||||||
let h = handle.clone();
|
let h = handle.clone();
|
||||||
let page = block_on(async move {
|
let page = block_on("kv", async move {
|
||||||
h.service
|
h.service
|
||||||
.list(&h.cx, &h.collection, cursor.as_deref(), limit)
|
.list(&h.cx, &h.collection, cursor.as_deref(), limit)
|
||||||
.await
|
.await
|
||||||
@@ -168,26 +215,97 @@ fn list_call(
|
|||||||
Ok(m)
|
Ok(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run an async future inside the synchronous Rhai context.
|
// --- GroupKvHandle methods (§11.6 shared collections) ----------------------
|
||||||
///
|
|
||||||
/// `LocalExecutorClient` wraps script execution in `spawn_blocking`, so
|
fn register_group_get(engine: &mut RhaiEngine) {
|
||||||
/// the current Tokio runtime is reachable via `Handle::current()`. We
|
engine.register_fn(
|
||||||
/// block on it directly; we are NOT calling this from an async task,
|
"get",
|
||||||
/// so blocking is the correct primitive (`block_in_place` would also
|
|handle: &mut GroupKvHandle, key: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
/// work, but we're already on a blocking worker).
|
let h = handle.clone();
|
||||||
fn block_on<F, T>(fut: F) -> Result<T, Box<EvalAltResult>>
|
block_on("kv", async move {
|
||||||
where
|
h.service.get(&h.cx, &h.collection, key).await
|
||||||
F: std::future::Future<Output = Result<T, KvError>> + Send,
|
})
|
||||||
T: Send,
|
.map(|opt| opt.map_or(Dynamic::UNIT, json_to_dynamic))
|
||||||
{
|
},
|
||||||
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
);
|
||||||
EvalAltResult::ErrorRuntime(
|
}
|
||||||
format!("kv: no tokio runtime available: {e}").into(),
|
|
||||||
rhai::Position::NONE,
|
fn register_group_set(engine: &mut RhaiEngine) {
|
||||||
)
|
engine.register_fn(
|
||||||
.into()
|
"set",
|
||||||
})?;
|
|handle: &mut GroupKvHandle, key: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
||||||
handle.block_on(fut).map_err(|err| -> Box<EvalAltResult> {
|
let h = handle.clone();
|
||||||
EvalAltResult::ErrorRuntime(format!("kv: {err}").into(), rhai::Position::NONE).into()
|
let json = dynamic_to_json(&value);
|
||||||
})
|
block_on("kv", async move {
|
||||||
|
h.service.set(&h.cx, &h.collection, key, json).await
|
||||||
|
})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_group_has(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"has",
|
||||||
|
|handle: &mut GroupKvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||||
|
let h = handle.clone();
|
||||||
|
block_on("kv", async move {
|
||||||
|
h.service.has(&h.cx, &h.collection, key).await
|
||||||
|
})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_group_delete(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"delete",
|
||||||
|
|handle: &mut GroupKvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||||
|
let h = handle.clone();
|
||||||
|
block_on("kv", async move {
|
||||||
|
h.service.delete(&h.cx, &h.collection, key).await
|
||||||
|
})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_group_list(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"list",
|
||||||
|
|handle: &mut GroupKvHandle| -> Result<Map, Box<EvalAltResult>> {
|
||||||
|
group_list_call(handle, None, 0)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
engine.register_fn(
|
||||||
|
"list",
|
||||||
|
|handle: &mut GroupKvHandle, cursor: &str| -> Result<Map, Box<EvalAltResult>> {
|
||||||
|
group_list_call(handle, Some(cursor.to_string()), 0)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
engine.register_fn(
|
||||||
|
"list",
|
||||||
|
|handle: &mut GroupKvHandle, cursor: &str, limit: i64| -> Result<Map, Box<EvalAltResult>> {
|
||||||
|
let limit = u32::try_from(limit.max(0)).unwrap_or(0);
|
||||||
|
group_list_call(handle, Some(cursor.to_string()), limit)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn group_list_call(
|
||||||
|
handle: &GroupKvHandle,
|
||||||
|
cursor: Option<String>,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<Map, Box<EvalAltResult>> {
|
||||||
|
let h = handle.clone();
|
||||||
|
let page = block_on("kv", async move {
|
||||||
|
h.service
|
||||||
|
.list(&h.cx, &h.collection, cursor.as_deref(), limit)
|
||||||
|
.await
|
||||||
|
})?;
|
||||||
|
let mut m = Map::new();
|
||||||
|
let keys: Array = page.keys.into_iter().map(Dynamic::from).collect();
|
||||||
|
m.insert("keys".into(), keys.into());
|
||||||
|
m.insert(
|
||||||
|
"next_cursor".into(),
|
||||||
|
page.next_cursor.map_or(Dynamic::UNIT, Dynamic::from),
|
||||||
|
);
|
||||||
|
Ok(m)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,18 @@ pub mod bridge;
|
|||||||
pub mod cx;
|
pub mod cx;
|
||||||
pub mod dead_letters;
|
pub mod dead_letters;
|
||||||
pub mod docs;
|
pub mod docs;
|
||||||
|
pub mod email;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
|
pub mod invoke;
|
||||||
pub mod kv;
|
pub mod kv;
|
||||||
pub mod pubsub;
|
pub mod pubsub;
|
||||||
|
pub mod queue;
|
||||||
|
pub mod retry;
|
||||||
|
pub mod secrets;
|
||||||
pub mod stdlib;
|
pub mod stdlib;
|
||||||
|
pub mod users;
|
||||||
|
pub mod vars;
|
||||||
|
|
||||||
pub use bridge::{dynamic_to_json, json_to_dynamic};
|
pub use bridge::{dynamic_to_json, json_to_dynamic};
|
||||||
pub use cx::SdkCallCx;
|
pub use cx::SdkCallCx;
|
||||||
@@ -29,17 +36,35 @@ use std::sync::Arc;
|
|||||||
use picloud_shared::Services;
|
use picloud_shared::Services;
|
||||||
use rhai::Engine as RhaiEngine;
|
use rhai::Engine as RhaiEngine;
|
||||||
|
|
||||||
|
use crate::engine::Engine;
|
||||||
|
use crate::sandbox::Limits;
|
||||||
|
|
||||||
/// Single hook every v1.1.x stateful service registers into. Called
|
/// Single hook every v1.1.x stateful service registers into. Called
|
||||||
/// once per invocation, just after `build_engine` constructs the
|
/// once per invocation, just after `build_engine` constructs the
|
||||||
/// sandboxed Rhai engine and just before script compilation.
|
/// sandboxed Rhai engine and just before script compilation.
|
||||||
///
|
///
|
||||||
/// v1.1.1 wires the first stateful service (KV). Subsequent PRs add a
|
/// v1.1.9 adds the `limits` + `self_engine` parameters needed by the
|
||||||
/// single `<service>::register(...)` line per service.
|
/// `invoke` bridge for synchronous re-entry. `self_engine` is `None`
|
||||||
pub fn register_all(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
/// in harnesses that didn't call `Engine::set_self_weak` after
|
||||||
|
/// construction; the invoke bridge surfaces a clear error in that case.
|
||||||
|
pub fn register_all(
|
||||||
|
engine: &mut RhaiEngine,
|
||||||
|
services: &Services,
|
||||||
|
cx: Arc<SdkCallCx>,
|
||||||
|
limits: Limits,
|
||||||
|
self_engine: Option<Arc<Engine>>,
|
||||||
|
) {
|
||||||
kv::register(engine, services, cx.clone());
|
kv::register(engine, services, cx.clone());
|
||||||
docs::register(engine, services, cx.clone());
|
docs::register(engine, services, cx.clone());
|
||||||
dead_letters::register(engine, services, cx.clone());
|
dead_letters::register(engine, services, cx.clone());
|
||||||
http::register(engine, services, cx.clone());
|
http::register(engine, services, cx.clone());
|
||||||
files::register(engine, services, cx.clone());
|
files::register(engine, services, cx.clone());
|
||||||
pubsub::register(engine, services, cx);
|
pubsub::register(engine, services, cx.clone());
|
||||||
|
queue::register(engine, services, cx.clone());
|
||||||
|
retry::register(engine, services, cx.clone());
|
||||||
|
secrets::register(engine, services, cx.clone());
|
||||||
|
vars::register(engine, services, cx.clone());
|
||||||
|
email::register(engine, services, cx.clone());
|
||||||
|
users::register(engine, services, cx.clone());
|
||||||
|
invoke::register(engine, services, cx, limits, self_engine);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,13 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use base64::engine::general_purpose::STANDARD;
|
use base64::engine::general_purpose::STANDARD;
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
use picloud_shared::{PubsubError, SdkCallCx, Services};
|
use picloud_shared::{SdkCallCx, Services};
|
||||||
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||||
use serde_json::Value as Json;
|
use serde_json::Value as Json;
|
||||||
use tokio::runtime::Handle as TokioHandle;
|
use tokio::runtime::Handle as TokioHandle;
|
||||||
|
|
||||||
|
use super::bridge::block_on;
|
||||||
|
|
||||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||||
let svc = services.pubsub.clone();
|
let svc = services.pubsub.clone();
|
||||||
let mut module = Module::new();
|
let mut module = Module::new();
|
||||||
@@ -36,7 +38,9 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
|||||||
let json = message_to_json(&message);
|
let json = message_to_json(&message);
|
||||||
let svc = svc.clone();
|
let svc = svc.clone();
|
||||||
let cx = cx.clone();
|
let cx = cx.clone();
|
||||||
block_on(async move { svc.publish_durable(&cx, topic, json).await })
|
block_on("pubsub", async move {
|
||||||
|
svc.publish_durable(&cx, topic, json).await
|
||||||
|
})
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -65,7 +69,71 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// §11.6 D2: `pubsub::shared_topic("name")` → a handle whose `.publish(...)`
|
||||||
|
// publishes to the group's shared topic namespace (fans out to `shared`
|
||||||
|
// group pubsub triggers on the owning group). The owning group resolves from
|
||||||
|
// `cx.app_id` inside the service — never a script arg.
|
||||||
|
{
|
||||||
|
let group_svc = services.group_pubsub.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"shared_topic",
|
||||||
|
move |name: &str| -> Result<GroupTopicHandle, Box<EvalAltResult>> {
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err("pubsub::shared_topic name must not be empty".into());
|
||||||
|
}
|
||||||
|
Ok(GroupTopicHandle {
|
||||||
|
namespace: name.to_string(),
|
||||||
|
service: group_svc.clone(),
|
||||||
|
cx: cx.clone(),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
engine.register_static_module("pubsub", module.into());
|
engine.register_static_module("pubsub", module.into());
|
||||||
|
engine.register_type_with_name::<GroupTopicHandle>("GroupTopicHandle");
|
||||||
|
register_shared_publish(engine);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §11.6 D2 handle returned by `pubsub::shared_topic("name")`. `.publish(sub,
|
||||||
|
/// msg)` publishes `name.sub` to the group shared topic; `.publish(msg)`
|
||||||
|
/// publishes to `name` directly.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct GroupTopicHandle {
|
||||||
|
namespace: String,
|
||||||
|
service: Arc<dyn picloud_shared::GroupPubsubService>,
|
||||||
|
cx: Arc<SdkCallCx>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shared_publish(
|
||||||
|
h: &mut GroupTopicHandle,
|
||||||
|
subtopic: &str,
|
||||||
|
message: Dynamic,
|
||||||
|
) -> Result<(), Box<EvalAltResult>> {
|
||||||
|
let json = message_to_json(&message);
|
||||||
|
let service = h.service.clone();
|
||||||
|
let cx = h.cx.clone();
|
||||||
|
let namespace = h.namespace.clone();
|
||||||
|
let subtopic = subtopic.to_string();
|
||||||
|
block_on("pubsub", async move {
|
||||||
|
service.publish(&cx, &namespace, &subtopic, json).await
|
||||||
|
})
|
||||||
|
// The fan-out count isn't surfaced to scripts (fire-and-forget, like the
|
||||||
|
// per-app publish).
|
||||||
|
.map(|_n: u32| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_shared_publish(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn(
|
||||||
|
"publish",
|
||||||
|
|h: &mut GroupTopicHandle, subtopic: &str, message: Dynamic| {
|
||||||
|
shared_publish(h, subtopic, message)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// `.publish(msg)` with no subtopic → publish to the namespace directly.
|
||||||
|
engine.register_fn("publish", |h: &mut GroupTopicHandle, message: Dynamic| {
|
||||||
|
shared_publish(h, "", message)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Interpret the optional `ttl` argument: `()` → use the default,
|
/// Interpret the optional `ttl` argument: `()` → use the default,
|
||||||
@@ -156,21 +224,3 @@ fn message_to_json(value: &Dynamic) -> Json {
|
|||||||
}
|
}
|
||||||
Json::String(value.to_string())
|
Json::String(value.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run an async future inside the synchronous Rhai context. Mirrors
|
|
||||||
/// `kv::block_on`.
|
|
||||||
fn block_on<F>(fut: F) -> Result<(), Box<EvalAltResult>>
|
|
||||||
where
|
|
||||||
F: std::future::Future<Output = Result<(), PubsubError>> + Send,
|
|
||||||
{
|
|
||||||
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
|
||||||
EvalAltResult::ErrorRuntime(
|
|
||||||
format!("pubsub: no tokio runtime available: {e}").into(),
|
|
||||||
rhai::Position::NONE,
|
|
||||||
)
|
|
||||||
.into()
|
|
||||||
})?;
|
|
||||||
handle.block_on(fut).map_err(|err| -> Box<EvalAltResult> {
|
|
||||||
EvalAltResult::ErrorRuntime(format!("pubsub: {err}").into(), rhai::Position::NONE).into()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
369
crates/executor-core/src/sdk/queue.rs
Normal file
369
crates/executor-core/src/sdk/queue.rs
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
//! `queue::` Rhai bridge — durable per-app queue producer + inspection
|
||||||
|
//! (v1.1.9).
|
||||||
|
//!
|
||||||
|
//! ```rhai
|
||||||
|
//! queue::enqueue("payments.process", #{ order_id: 123, amount: 4999 });
|
||||||
|
//! queue::enqueue("payments.process", msg, #{ delay_ms: 60000, max_attempts: 5 });
|
||||||
|
//! let total = queue::depth("payments.process");
|
||||||
|
//! let pending = queue::depth_pending("payments.process");
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! No handle pattern (queues are the grouping unit, mirroring pubsub).
|
||||||
|
//! No `peek` / `dequeue` / `purge` script-side surface — consumers are
|
||||||
|
//! `queue:receive` triggers; exposing manual dequeue would force the
|
||||||
|
//! platform to surface visibility-timeout semantics into script-land.
|
||||||
|
//!
|
||||||
|
//! `app_id` is derived from `cx.app_id` inside the service — it never
|
||||||
|
//! appears in the script-side signature.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use base64::engine::general_purpose::STANDARD;
|
||||||
|
use base64::Engine as _;
|
||||||
|
use picloud_shared::{
|
||||||
|
EnqueueOpts, GroupQueueService, QueueError, QueueService, SdkCallCx, Services,
|
||||||
|
};
|
||||||
|
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||||
|
use serde_json::Value as Json;
|
||||||
|
use tokio::runtime::Handle as TokioHandle;
|
||||||
|
|
||||||
|
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||||
|
let svc = services.queue.clone();
|
||||||
|
let mut module = Module::new();
|
||||||
|
|
||||||
|
// `queue::enqueue(name, message)` — default opts.
|
||||||
|
{
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"enqueue",
|
||||||
|
move |name: &str, message: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
||||||
|
let json = message_to_json(&message)?;
|
||||||
|
enqueue_blocking(&svc, &cx, name, json, EnqueueOpts::default())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `queue::enqueue(name, message, opts)` — opts is a Rhai Map with
|
||||||
|
// optional `delay_ms` and `max_attempts` keys.
|
||||||
|
{
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"enqueue",
|
||||||
|
move |name: &str, message: Dynamic, opts: Map| -> Result<(), Box<EvalAltResult>> {
|
||||||
|
let json = message_to_json(&message)?;
|
||||||
|
let opts = parse_opts(&opts)?;
|
||||||
|
enqueue_blocking(&svc, &cx, name, json, opts)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `queue::depth(name)` — total rows in the queue.
|
||||||
|
{
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"depth",
|
||||||
|
move |name: &str| -> Result<i64, Box<EvalAltResult>> {
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
let name = name.to_string();
|
||||||
|
block_on_u64(async move { svc.depth(&cx, &name).await })
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `queue::depth_pending(name)` — only currently-claimable rows.
|
||||||
|
{
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"depth_pending",
|
||||||
|
move |name: &str| -> Result<i64, Box<EvalAltResult>> {
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
let name = name.to_string();
|
||||||
|
block_on_u64(async move { svc.depth_pending(&cx, &name).await })
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// §11.6 D3: `queue::shared_collection("name")` → a handle over the group
|
||||||
|
// shared queue (any subtree app enqueues into one group-owned store;
|
||||||
|
// competing per-descendant consumers drain it). The owning group resolves
|
||||||
|
// from `cx.app_id` inside the service — never a script arg.
|
||||||
|
{
|
||||||
|
let group_svc = services.group_queue.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"shared_collection",
|
||||||
|
move |name: &str| -> Result<GroupQueueHandle, Box<EvalAltResult>> {
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err("queue::shared_collection name must not be empty".into());
|
||||||
|
}
|
||||||
|
Ok(GroupQueueHandle {
|
||||||
|
collection: name.to_string(),
|
||||||
|
service: group_svc.clone(),
|
||||||
|
cx: cx.clone(),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.register_static_module("queue", module.into());
|
||||||
|
engine.register_type_with_name::<GroupQueueHandle>("GroupQueueHandle");
|
||||||
|
register_shared_queue_methods(engine);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §11.6 D3 handle returned by `queue::shared_collection("name")`.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct GroupQueueHandle {
|
||||||
|
collection: String,
|
||||||
|
service: Arc<dyn GroupQueueService>,
|
||||||
|
cx: Arc<SdkCallCx>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn group_enqueue(
|
||||||
|
h: &mut GroupQueueHandle,
|
||||||
|
message: Dynamic,
|
||||||
|
opts: EnqueueOpts,
|
||||||
|
) -> Result<(), Box<EvalAltResult>> {
|
||||||
|
let json = message_to_json(&message)?;
|
||||||
|
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(
|
||||||
|
format!("queue: no tokio runtime available: {e}").into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})?;
|
||||||
|
let service = h.service.clone();
|
||||||
|
let cx = h.cx.clone();
|
||||||
|
let collection = h.collection.clone();
|
||||||
|
handle
|
||||||
|
.block_on(async move { service.enqueue(&cx, &collection, json, opts).await })
|
||||||
|
.map(|_id| ())
|
||||||
|
.map_err(|err| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(format!("queue: {err}").into(), rhai::Position::NONE).into()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn group_depth<F, Fut>(h: &mut GroupQueueHandle, f: F) -> Result<i64, Box<EvalAltResult>>
|
||||||
|
where
|
||||||
|
F: FnOnce(Arc<dyn GroupQueueService>, Arc<SdkCallCx>, String) -> Fut,
|
||||||
|
Fut: std::future::Future<Output = Result<u64, picloud_shared::GroupQueueError>>,
|
||||||
|
{
|
||||||
|
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(
|
||||||
|
format!("queue: no tokio runtime available: {e}").into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})?;
|
||||||
|
let fut = f(h.service.clone(), h.cx.clone(), h.collection.clone());
|
||||||
|
let n = handle.block_on(fut).map_err(|err| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(format!("queue: {err}").into(), rhai::Position::NONE).into()
|
||||||
|
})?;
|
||||||
|
Ok(i64::try_from(n).unwrap_or(i64::MAX))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_shared_queue_methods(engine: &mut RhaiEngine) {
|
||||||
|
engine.register_fn("enqueue", |h: &mut GroupQueueHandle, message: Dynamic| {
|
||||||
|
group_enqueue(h, message, EnqueueOpts::default())
|
||||||
|
});
|
||||||
|
engine.register_fn(
|
||||||
|
"enqueue",
|
||||||
|
|h: &mut GroupQueueHandle, message: Dynamic, opts: Map| {
|
||||||
|
let opts = parse_opts(&opts)?;
|
||||||
|
group_enqueue(h, message, opts)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
engine.register_fn("depth", |h: &mut GroupQueueHandle| {
|
||||||
|
group_depth(
|
||||||
|
h,
|
||||||
|
|svc, cx, coll| async move { svc.depth(&cx, &coll).await },
|
||||||
|
)
|
||||||
|
});
|
||||||
|
engine.register_fn("depth_pending", |h: &mut GroupQueueHandle| {
|
||||||
|
group_depth(h, |svc, cx, coll| async move {
|
||||||
|
svc.depth_pending(&cx, &coll).await
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the async enqueue inside the synchronous Rhai context. Mirrors
|
||||||
|
/// `pubsub::block_on` but for the queue's `Result<_, QueueError>`.
|
||||||
|
fn enqueue_blocking(
|
||||||
|
svc: &Arc<dyn QueueService>,
|
||||||
|
cx: &Arc<SdkCallCx>,
|
||||||
|
name: &str,
|
||||||
|
payload: Json,
|
||||||
|
opts: EnqueueOpts,
|
||||||
|
) -> Result<(), Box<EvalAltResult>> {
|
||||||
|
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(
|
||||||
|
format!("queue: no tokio runtime available: {e}").into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})?;
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
let name = name.to_string();
|
||||||
|
handle
|
||||||
|
.block_on(async move { svc.enqueue(&cx, &name, payload, opts).await })
|
||||||
|
.map(|_id| ())
|
||||||
|
.map_err(|err| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(format!("queue: {err}").into(), rhai::Position::NONE).into()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn block_on_u64<F>(fut: F) -> Result<i64, Box<EvalAltResult>>
|
||||||
|
where
|
||||||
|
F: std::future::Future<Output = Result<u64, QueueError>> + Send + 'static,
|
||||||
|
{
|
||||||
|
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(
|
||||||
|
format!("queue: no tokio runtime available: {e}").into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})?;
|
||||||
|
let n = handle.block_on(fut).map_err(|err| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(format!("queue: {err}").into(), rhai::Position::NONE).into()
|
||||||
|
})?;
|
||||||
|
Ok(i64::try_from(n).unwrap_or(i64::MAX))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_opts(opts: &Map) -> Result<EnqueueOpts, Box<EvalAltResult>> {
|
||||||
|
let mut out = EnqueueOpts::default();
|
||||||
|
if let Some(v) = opts.get("delay_ms") {
|
||||||
|
out.delay_ms = Some(v.as_int().map_err(|_| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(
|
||||||
|
"queue::enqueue: opts.delay_ms must be an integer".into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})?);
|
||||||
|
}
|
||||||
|
if let Some(v) = opts.get("max_attempts") {
|
||||||
|
let n = v.as_int().map_err(|_| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(
|
||||||
|
"queue::enqueue: opts.max_attempts must be an integer".into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})?;
|
||||||
|
out.max_attempts = Some(u32::try_from(n).unwrap_or(0));
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a Rhai `Dynamic` into JSON. Mirrors `pubsub::message_to_json`
|
||||||
|
/// — blobs become base64, FnPtr / closures are rejected so a script
|
||||||
|
/// can't accidentally enqueue something that won't survive a trip
|
||||||
|
/// through Postgres + back through the bridge.
|
||||||
|
fn message_to_json(value: &Dynamic) -> Result<Json, Box<EvalAltResult>> {
|
||||||
|
if value.is::<rhai::FnPtr>() {
|
||||||
|
return Err(EvalAltResult::ErrorRuntime(
|
||||||
|
"queue::enqueue: messages must not contain FnPtr / closures".into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
if value.is_blob() {
|
||||||
|
let blob = value.clone().into_blob().unwrap_or_default();
|
||||||
|
return Ok(Json::String(STANDARD.encode(&blob)));
|
||||||
|
}
|
||||||
|
if value.is_unit() {
|
||||||
|
return Ok(Json::Null);
|
||||||
|
}
|
||||||
|
if let Ok(b) = value.as_bool() {
|
||||||
|
return Ok(Json::Bool(b));
|
||||||
|
}
|
||||||
|
if let Ok(i) = value.as_int() {
|
||||||
|
return Ok(Json::Number(i.into()));
|
||||||
|
}
|
||||||
|
if let Ok(f) = value.as_float() {
|
||||||
|
return Ok(serde_json::Number::from_f64(f).map_or(Json::Null, Json::Number));
|
||||||
|
}
|
||||||
|
if value.is_string() {
|
||||||
|
return Ok(Json::String(
|
||||||
|
value.clone().into_string().unwrap_or_default(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(arr) = value.clone().try_cast::<Array>() {
|
||||||
|
let mut out = Vec::with_capacity(arr.len());
|
||||||
|
for v in &arr {
|
||||||
|
out.push(message_to_json(v)?);
|
||||||
|
}
|
||||||
|
return Ok(Json::Array(out));
|
||||||
|
}
|
||||||
|
if let Some(map) = value.clone().try_cast::<Map>() {
|
||||||
|
let mut out = serde_json::Map::new();
|
||||||
|
for (k, v) in map {
|
||||||
|
out.insert(k.to_string(), message_to_json(&v)?);
|
||||||
|
}
|
||||||
|
return Ok(Json::Object(out));
|
||||||
|
}
|
||||||
|
Ok(Json::String(value.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rhai::Dynamic;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn message_blob_encodes_to_base64_string() {
|
||||||
|
let blob: rhai::Blob = vec![0x01, 0x02, 0x03];
|
||||||
|
let d = Dynamic::from(blob);
|
||||||
|
let json = message_to_json(&d).unwrap();
|
||||||
|
// STANDARD base64 of [1,2,3] is "AQID"
|
||||||
|
assert_eq!(json, Json::String("AQID".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn message_nested_map_round_trips_through_json() {
|
||||||
|
let mut m = Map::new();
|
||||||
|
m.insert("k".into(), Dynamic::from(42_i64));
|
||||||
|
let d = Dynamic::from(m);
|
||||||
|
let json = message_to_json(&d).unwrap();
|
||||||
|
let expected = serde_json::json!({ "k": 42 });
|
||||||
|
assert_eq!(json, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn message_rejects_fnptr() {
|
||||||
|
// Build a Rhai FnPtr and confirm we reject it.
|
||||||
|
let f = rhai::FnPtr::new("anything").unwrap();
|
||||||
|
let d = Dynamic::from(f);
|
||||||
|
let err = message_to_json(&d).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("FnPtr"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_opts_accepts_partial_overrides() {
|
||||||
|
let mut m = Map::new();
|
||||||
|
m.insert("delay_ms".into(), Dynamic::from(500_i64));
|
||||||
|
let opts = parse_opts(&m).unwrap();
|
||||||
|
assert_eq!(opts.delay_ms, Some(500));
|
||||||
|
assert_eq!(opts.max_attempts, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_opts_max_attempts_only() {
|
||||||
|
let mut m = Map::new();
|
||||||
|
m.insert("max_attempts".into(), Dynamic::from(5_i64));
|
||||||
|
let opts = parse_opts(&m).unwrap();
|
||||||
|
assert_eq!(opts.delay_ms, None);
|
||||||
|
assert_eq!(opts.max_attempts, Some(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_opts_rejects_non_integer_delay() {
|
||||||
|
let mut m = Map::new();
|
||||||
|
m.insert("delay_ms".into(), Dynamic::from("nope"));
|
||||||
|
assert!(parse_opts(&m).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
361
crates/executor-core/src/sdk/retry.rs
Normal file
361
crates/executor-core/src/sdk/retry.rs
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
//! `retry::*` Rhai bridge — caller-controlled retry shape (v1.1.9).
|
||||||
|
//!
|
||||||
|
//! ```rhai
|
||||||
|
//! let p = retry::policy(#{
|
||||||
|
//! max_attempts: 3,
|
||||||
|
//! backoff: "exponential", // "exponential" | "linear" | "constant"
|
||||||
|
//! base_ms: 500,
|
||||||
|
//! jitter_pct: 20,
|
||||||
|
//! });
|
||||||
|
//!
|
||||||
|
//! let result = retry::run(p, || {
|
||||||
|
//! invoke("/payments/charge", #{ order_id: 1 })
|
||||||
|
//! });
|
||||||
|
//!
|
||||||
|
//! // Optional error-code filter:
|
||||||
|
//! let p2 = retry::on_codes(p, ["http: 503", "http: 504"]);
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! NOTE: the brief called for `retry::with(policy, closure)` but `with`
|
||||||
|
//! AND `call` are both Rhai reserved keywords (rejected at the parser
|
||||||
|
//! before any registration would matter) — so we ship `retry::run(...)`
|
||||||
|
//! instead. Documented in HANDBACK §7.
|
||||||
|
//!
|
||||||
|
//! `retry::run(policy, closure)` calls the closure; on throw it sleeps
|
||||||
|
//! per the policy and re-invokes. Sleeps run via `tokio::time::sleep`
|
||||||
|
//! through `TokioHandle::block_on` — the bridge is already inside the
|
||||||
|
//! caller's `spawn_blocking` thread, so blocking is fine.
|
||||||
|
//!
|
||||||
|
//! Closures share the caller's engine + cx (Rhai's `FnPtr` is invoked
|
||||||
|
//! via `call_within_context` on the same engine). If the closure calls
|
||||||
|
//! `invoke()`, the inner call gets a fresh cx with `trigger_depth + 1`
|
||||||
|
//! via the invoke bridge — retry doesn't see or interfere with that.
|
||||||
|
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::hash::Hasher;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use picloud_shared::{SdkCallCx, Services};
|
||||||
|
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, FnPtr, Module, NativeCallContext};
|
||||||
|
use tokio::runtime::Handle as TokioHandle;
|
||||||
|
|
||||||
|
/// Policy value scripts construct via `retry::policy(opts)`. Custom Rhai
|
||||||
|
/// type — exposed via `register_type_with_name`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Policy {
|
||||||
|
pub max_attempts: u32,
|
||||||
|
pub backoff: BackoffShape,
|
||||||
|
pub base_ms: u32,
|
||||||
|
pub jitter_pct: u32,
|
||||||
|
/// Empty → retry on any throw. Non-empty → retry only when the
|
||||||
|
/// error string starts with one of these prefixes.
|
||||||
|
pub on_codes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Policy {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_attempts: 3,
|
||||||
|
backoff: BackoffShape::Exponential,
|
||||||
|
base_ms: 500,
|
||||||
|
jitter_pct: 20,
|
||||||
|
on_codes: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum BackoffShape {
|
||||||
|
Constant,
|
||||||
|
Linear,
|
||||||
|
Exponential,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BackoffShape {
|
||||||
|
fn from_wire(s: &str) -> Option<Self> {
|
||||||
|
match s {
|
||||||
|
"constant" => Some(Self::Constant),
|
||||||
|
"linear" => Some(Self::Linear),
|
||||||
|
"exponential" => Some(Self::Exponential),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn register(engine: &mut RhaiEngine, _services: &Services, _cx: Arc<SdkCallCx>) {
|
||||||
|
// Custom type so scripts can pass Policy values around.
|
||||||
|
engine.register_type_with_name::<Policy>("Policy");
|
||||||
|
|
||||||
|
let mut module = Module::new();
|
||||||
|
|
||||||
|
// `retry::policy(opts)` — opts is a Rhai Map.
|
||||||
|
module.set_native_fn(
|
||||||
|
"policy",
|
||||||
|
move |opts: rhai::Map| -> Result<Policy, Box<EvalAltResult>> { build_policy(opts) },
|
||||||
|
);
|
||||||
|
|
||||||
|
// `retry::on_codes(policy, codes)` — returns a new policy with the
|
||||||
|
// filter set. Takes ownership of the policy (Rhai semantics) and
|
||||||
|
// returns the modified one.
|
||||||
|
module.set_native_fn(
|
||||||
|
"on_codes",
|
||||||
|
move |policy: Policy, codes: Array| -> Result<Policy, Box<EvalAltResult>> {
|
||||||
|
let mut p = policy;
|
||||||
|
let mut out = Vec::with_capacity(codes.len());
|
||||||
|
for c in codes {
|
||||||
|
let s = c.into_string().map_err(|_| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(
|
||||||
|
"retry::on_codes: codes must be strings".into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})?;
|
||||||
|
out.push(s);
|
||||||
|
}
|
||||||
|
p.on_codes = out;
|
||||||
|
Ok(p)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// `retry::run(policy, fn_ptr)` — registered with NativeCallContext
|
||||||
|
// so the bridge can re-invoke the FnPtr in a loop. Named `call` and
|
||||||
|
// not `with` because `with` is a Rhai reserved keyword.
|
||||||
|
module.set_native_fn(
|
||||||
|
"run",
|
||||||
|
move |ctx: NativeCallContext,
|
||||||
|
policy: Policy,
|
||||||
|
fn_ptr: FnPtr|
|
||||||
|
-> Result<Dynamic, Box<EvalAltResult>> { retry_run(&ctx, &policy, &fn_ptr) },
|
||||||
|
);
|
||||||
|
|
||||||
|
engine.register_static_module("retry", module.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_policy(opts: rhai::Map) -> Result<Policy, Box<EvalAltResult>> {
|
||||||
|
let mut p = Policy::default();
|
||||||
|
if let Some(v) = opts.get("max_attempts") {
|
||||||
|
let n = v.as_int().map_err(|_| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(
|
||||||
|
"retry::policy: max_attempts must be an integer".into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})?;
|
||||||
|
let n = u32::try_from(n).unwrap_or(0);
|
||||||
|
p.max_attempts = n.clamp(1, 20);
|
||||||
|
}
|
||||||
|
if let Some(v) = opts.get("backoff") {
|
||||||
|
let s = v.clone().into_string().map_err(|_| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(
|
||||||
|
"retry::policy: backoff must be a string".into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})?;
|
||||||
|
p.backoff = BackoffShape::from_wire(&s).ok_or_else(|| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(
|
||||||
|
"retry::policy: backoff must be \"exponential\" | \"linear\" | \"constant\"".into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
if let Some(v) = opts.get("base_ms") {
|
||||||
|
let n = v.as_int().map_err(|_| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(
|
||||||
|
"retry::policy: base_ms must be an integer".into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})?;
|
||||||
|
let n = u32::try_from(n).unwrap_or(0);
|
||||||
|
p.base_ms = n.clamp(1, 60_000);
|
||||||
|
}
|
||||||
|
if let Some(v) = opts.get("jitter_pct") {
|
||||||
|
let n = v.as_int().map_err(|_| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(
|
||||||
|
"retry::policy: jitter_pct must be an integer".into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})?;
|
||||||
|
let n = u32::try_from(n).unwrap_or(0);
|
||||||
|
p.jitter_pct = n.clamp(0, 100);
|
||||||
|
}
|
||||||
|
Ok(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retry_run(
|
||||||
|
ctx: &NativeCallContext,
|
||||||
|
policy: &Policy,
|
||||||
|
fn_ptr: &FnPtr,
|
||||||
|
) -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
let mut attempt: u32 = 0;
|
||||||
|
loop {
|
||||||
|
attempt += 1;
|
||||||
|
// Rhai 1.24's FnPtr only carries the callee's name + args; we
|
||||||
|
// invoke it through the engine the NativeCallContext exposes.
|
||||||
|
// `()` as args: the closure is nullary — the script-side helper
|
||||||
|
// is `|| { ... }`.
|
||||||
|
let res: Result<Dynamic, Box<EvalAltResult>> = fn_ptr.call_within_context(ctx, ());
|
||||||
|
match res {
|
||||||
|
Ok(v) => return Ok(v),
|
||||||
|
Err(e) => {
|
||||||
|
// on_codes filter: if non-empty, only retry when the
|
||||||
|
// error string starts with one of the codes.
|
||||||
|
if !policy.on_codes.is_empty() {
|
||||||
|
let msg = e.to_string();
|
||||||
|
let matched = policy.on_codes.iter().any(|c| msg.contains(c));
|
||||||
|
if !matched {
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if attempt >= policy.max_attempts {
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
let delay = compute_delay(policy, attempt);
|
||||||
|
let _ = sleep_blocking(delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_delay(policy: &Policy, attempt: u32) -> Duration {
|
||||||
|
let base_ms = u64::from(policy.base_ms);
|
||||||
|
let exp_pow = u64::from(attempt.saturating_sub(1));
|
||||||
|
let raw = match policy.backoff {
|
||||||
|
BackoffShape::Constant => base_ms,
|
||||||
|
BackoffShape::Linear => base_ms.saturating_mul(u64::from(attempt)),
|
||||||
|
BackoffShape::Exponential => base_ms.saturating_mul(1u64 << exp_pow.min(20)),
|
||||||
|
};
|
||||||
|
let raw = raw.min(u64::from(u32::MAX));
|
||||||
|
let jittered = apply_jitter(u32::try_from(raw).unwrap_or(u32::MAX), policy.jitter_pct);
|
||||||
|
Duration::from_millis(u64::from(jittered))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_jitter(raw: u32, pct: u32) -> u32 {
|
||||||
|
if pct == 0 {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
let pct = pct.min(100);
|
||||||
|
let span = u64::from(raw) * u64::from(pct) / 100;
|
||||||
|
if span == 0 {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
// Deterministic-enough jitter without pulling rand into a hot path:
|
||||||
|
// pick a small offset from the high bits of attempt's hash. Random
|
||||||
|
// distribution doesn't matter for retry — bounded ± is what we want.
|
||||||
|
let mut h = DefaultHasher::new();
|
||||||
|
h.write_u64(span);
|
||||||
|
h.write_u32(raw);
|
||||||
|
let r = h.finish();
|
||||||
|
// span is bounded by raw*100/100 = raw ≤ u32::MAX, so 2*span+1 fits
|
||||||
|
// in i64 without wrapping. `cast_signed` makes the intent explicit
|
||||||
|
// for clippy::cast_possible_wrap.
|
||||||
|
let modded = r % (2 * span + 1);
|
||||||
|
let offset =
|
||||||
|
i64::try_from(modded).unwrap_or(i64::MAX) - i64::try_from(span).unwrap_or(i64::MAX);
|
||||||
|
let signed = i64::from(raw).saturating_add(offset).max(0);
|
||||||
|
u32::try_from(signed.min(i64::from(u32::MAX))).unwrap_or(u32::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sleep blocking for `delay`. We're inside the caller's spawn_blocking
|
||||||
|
/// thread, so block_on of a tokio::time::sleep is fine — the runtime
|
||||||
|
/// thread isn't being held.
|
||||||
|
fn sleep_blocking(delay: Duration) -> Result<(), Box<EvalAltResult>> {
|
||||||
|
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(
|
||||||
|
format!("retry: no tokio runtime: {e}").into(),
|
||||||
|
rhai::Position::NONE,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
})?;
|
||||||
|
handle.block_on(async move { tokio::time::sleep(delay).await });
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn policy_default_is_reasonable() {
|
||||||
|
let p = Policy::default();
|
||||||
|
assert_eq!(p.max_attempts, 3);
|
||||||
|
assert_eq!(p.base_ms, 500);
|
||||||
|
assert_eq!(p.jitter_pct, 20);
|
||||||
|
assert!(p.on_codes.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn policy_clamps_max_attempts() {
|
||||||
|
let mut m = rhai::Map::new();
|
||||||
|
m.insert("max_attempts".into(), Dynamic::from(0_i64));
|
||||||
|
let p = build_policy(m).unwrap();
|
||||||
|
assert_eq!(p.max_attempts, 1);
|
||||||
|
|
||||||
|
let mut m = rhai::Map::new();
|
||||||
|
m.insert("max_attempts".into(), Dynamic::from(999_i64));
|
||||||
|
let p = build_policy(m).unwrap();
|
||||||
|
assert_eq!(p.max_attempts, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn policy_clamps_base_ms_and_jitter() {
|
||||||
|
let mut m = rhai::Map::new();
|
||||||
|
m.insert("base_ms".into(), Dynamic::from(0_i64));
|
||||||
|
m.insert("jitter_pct".into(), Dynamic::from(200_i64));
|
||||||
|
let p = build_policy(m).unwrap();
|
||||||
|
assert_eq!(p.base_ms, 1);
|
||||||
|
assert_eq!(p.jitter_pct, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn policy_rejects_bogus_backoff_shape() {
|
||||||
|
let mut m = rhai::Map::new();
|
||||||
|
m.insert("backoff".into(), Dynamic::from("bogus"));
|
||||||
|
assert!(build_policy(m).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compute_delay_exponential_doubles() {
|
||||||
|
let p = Policy {
|
||||||
|
max_attempts: 5,
|
||||||
|
backoff: BackoffShape::Exponential,
|
||||||
|
base_ms: 1000,
|
||||||
|
jitter_pct: 0,
|
||||||
|
on_codes: Vec::new(),
|
||||||
|
};
|
||||||
|
assert_eq!(compute_delay(&p, 1).as_millis(), 1000);
|
||||||
|
assert_eq!(compute_delay(&p, 2).as_millis(), 2000);
|
||||||
|
assert_eq!(compute_delay(&p, 3).as_millis(), 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compute_delay_linear() {
|
||||||
|
let p = Policy {
|
||||||
|
max_attempts: 5,
|
||||||
|
backoff: BackoffShape::Linear,
|
||||||
|
base_ms: 100,
|
||||||
|
jitter_pct: 0,
|
||||||
|
on_codes: Vec::new(),
|
||||||
|
};
|
||||||
|
assert_eq!(compute_delay(&p, 1).as_millis(), 100);
|
||||||
|
assert_eq!(compute_delay(&p, 2).as_millis(), 200);
|
||||||
|
assert_eq!(compute_delay(&p, 5).as_millis(), 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compute_delay_constant() {
|
||||||
|
let p = Policy {
|
||||||
|
max_attempts: 5,
|
||||||
|
backoff: BackoffShape::Constant,
|
||||||
|
base_ms: 250,
|
||||||
|
jitter_pct: 0,
|
||||||
|
on_codes: Vec::new(),
|
||||||
|
};
|
||||||
|
assert_eq!(compute_delay(&p, 1).as_millis(), 250);
|
||||||
|
assert_eq!(compute_delay(&p, 4).as_millis(), 250);
|
||||||
|
}
|
||||||
|
}
|
||||||
133
crates/executor-core/src/sdk/secrets.rs
Normal file
133
crates/executor-core/src/sdk/secrets.rs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
//! `secrets::` Rhai bridge — encrypted per-app secrets (v1.1.7).
|
||||||
|
//!
|
||||||
|
//! ```rhai
|
||||||
|
//! secrets::set("stripe_key", "sk_live_xxx");
|
||||||
|
//! secrets::set("oauth", #{ client_id: "abc", client_secret: "xyz" });
|
||||||
|
//! let key = secrets::get("stripe_key"); // value or ()
|
||||||
|
//! let removed = secrets::delete("stripe_key"); // bool
|
||||||
|
//! let page = secrets::list(#{ cursor: (), limit: 100 });
|
||||||
|
//! // page = #{ names: [...], next_cursor: () | "..." }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Collection-less (secrets are per-app, like pubsub topics) so there's
|
||||||
|
//! no `::collection(...)`. Values are any JSON-serializable Rhai value
|
||||||
|
//! (String/Map/Array/number/bool); a String round-trips back as a
|
||||||
|
//! String. `app_id` is derived from `cx.app_id` in the service — it
|
||||||
|
//! never appears in the script-side signature, preserving cross-app
|
||||||
|
//! isolation.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use picloud_shared::{SdkCallCx, SecretsListPage, Services};
|
||||||
|
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||||
|
|
||||||
|
use super::bridge::{block_on, dynamic_to_json, json_to_dynamic};
|
||||||
|
|
||||||
|
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||||
|
let svc = services.secrets.clone();
|
||||||
|
let mut module = Module::new();
|
||||||
|
|
||||||
|
// secrets::set(name, value) — overwrites if present.
|
||||||
|
{
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"set",
|
||||||
|
move |name: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
||||||
|
let json = dynamic_to_json(&value);
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
block_on("secrets", async move { svc.set(&cx, name, json).await })
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// secrets::get(name) — decoded value, or () if missing.
|
||||||
|
{
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"get",
|
||||||
|
move |name: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
let opt = block_on("secrets", async move { svc.get(&cx, name).await })?;
|
||||||
|
Ok(opt.map_or(Dynamic::UNIT, json_to_dynamic))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// secrets::delete(name) — bool was-present.
|
||||||
|
{
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"delete",
|
||||||
|
move |name: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
block_on("secrets", async move { svc.delete(&cx, name).await })
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// secrets::list(#{ cursor, limit }) — names only, cursor-paginated.
|
||||||
|
{
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"list",
|
||||||
|
move |opts: Map| -> Result<Map, Box<EvalAltResult>> {
|
||||||
|
let (cursor, limit) = parse_list_opts(&opts)?;
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
let page: SecretsListPage = block_on("secrets", async move {
|
||||||
|
svc.list(&cx, cursor.as_deref(), limit).await
|
||||||
|
})?;
|
||||||
|
Ok(list_page_to_map(page))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.register_static_module("secrets", module.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull `cursor` (string or `()`) and `limit` (int or `()`) out of the
|
||||||
|
/// options map. Unknown/extra keys are ignored.
|
||||||
|
fn parse_list_opts(opts: &Map) -> Result<(Option<String>, u32), Box<EvalAltResult>> {
|
||||||
|
let cursor = match opts.get("cursor") {
|
||||||
|
None => None,
|
||||||
|
Some(d) if d.is_unit() => None,
|
||||||
|
Some(d) if d.is_string() => Some(d.clone().into_string().unwrap_or_default()),
|
||||||
|
Some(_) => return Err(runtime_err("secrets::list: cursor must be a string or ()")),
|
||||||
|
};
|
||||||
|
let limit = match opts.get("limit") {
|
||||||
|
None => 0,
|
||||||
|
Some(d) if d.is_unit() => 0,
|
||||||
|
Some(d) => {
|
||||||
|
let n = d
|
||||||
|
.as_int()
|
||||||
|
.map_err(|_| runtime_err("secrets::list: limit must be an integer or ()"))?;
|
||||||
|
u32::try_from(n.max(0)).unwrap_or(u32::MAX)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok((cursor, limit))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_page_to_map(page: SecretsListPage) -> Map {
|
||||||
|
let mut m = Map::new();
|
||||||
|
let names: Array = page.names.into_iter().map(Dynamic::from).collect();
|
||||||
|
m.insert("names".into(), names.into());
|
||||||
|
m.insert(
|
||||||
|
"next_cursor".into(),
|
||||||
|
page.next_cursor.map_or(Dynamic::UNIT, Dynamic::from),
|
||||||
|
);
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the boxed error directly because every caller needs a
|
||||||
|
// `Box<EvalAltResult>` (Rhai's error type), matching the other bridges.
|
||||||
|
#[allow(clippy::unnecessary_box_returns)]
|
||||||
|
fn runtime_err(msg: &str) -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(msg.into(), rhai::Position::NONE).into()
|
||||||
|
}
|
||||||
@@ -1,13 +1,29 @@
|
|||||||
//! `regex::` — non-backtracking regular expressions (Rust `regex` crate).
|
//! `regex::` — non-backtracking regular expressions (Rust `regex` crate).
|
||||||
//!
|
//!
|
||||||
//! Patterns compile per call. No cache: premature for v1.1.0, and the
|
//! F-P-014: compiled patterns are cached in a thread-local LRU so a
|
||||||
//! `regex` crate's linear-time guarantees keep per-call cost bounded.
|
//! tight script loop like `regex::is_match("\\d+", x)` doesn't recompile
|
||||||
//! Catastrophic patterns are rejected at compile time by the crate
|
//! every iteration. Compile dominates `is_match` on short strings;
|
||||||
//! itself; no extra defense needed.
|
//! caching shrinks per-call cost to a HashMap probe + `Arc::clone`.
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::num::NonZeroUsize;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use lru::LruCache;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Module};
|
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Module};
|
||||||
|
|
||||||
|
/// Per-thread cap on compiled regex patterns. 128 is well above what
|
||||||
|
/// any sensible script uses but bounded enough to keep memory in
|
||||||
|
/// check on a fleet of long-running threads.
|
||||||
|
const REGEX_CACHE_SIZE: usize = 128;
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static REGEX_CACHE: RefCell<LruCache<String, Arc<Regex>>> = RefCell::new(
|
||||||
|
LruCache::new(NonZeroUsize::new(REGEX_CACHE_SIZE).expect("non-zero"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn register(engine: &mut RhaiEngine) {
|
pub fn register(engine: &mut RhaiEngine) {
|
||||||
let mut module = Module::new();
|
let mut module = Module::new();
|
||||||
register_is_match(&mut module);
|
register_is_match(&mut module);
|
||||||
@@ -20,8 +36,18 @@ pub fn register(engine: &mut RhaiEngine) {
|
|||||||
engine.register_static_module("regex", module.into());
|
engine.register_static_module("regex", module.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn compile(pattern: &str) -> Result<Regex, Box<EvalAltResult>> {
|
fn compile(pattern: &str) -> Result<Arc<Regex>, Box<EvalAltResult>> {
|
||||||
Regex::new(pattern).map_err(|e| format!("invalid regex: {e}").into())
|
REGEX_CACHE.with(|cell| {
|
||||||
|
if let Some(cached) = cell.borrow_mut().get(pattern) {
|
||||||
|
return Ok(cached.clone());
|
||||||
|
}
|
||||||
|
let re = Arc::new(
|
||||||
|
Regex::new(pattern)
|
||||||
|
.map_err(|e| -> Box<EvalAltResult> { format!("invalid regex: {e}").into() })?,
|
||||||
|
);
|
||||||
|
cell.borrow_mut().put(pattern.to_string(), re.clone());
|
||||||
|
Ok(re)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn register_is_match(module: &mut Module) {
|
fn register_is_match(module: &mut Module) {
|
||||||
|
|||||||
622
crates/executor-core/src/sdk/users.rs
Normal file
622
crates/executor-core/src/sdk/users.rs
Normal file
@@ -0,0 +1,622 @@
|
|||||||
|
//! `users::` Rhai bridge — data-plane user management (v1.1.8).
|
||||||
|
//!
|
||||||
|
//! ```rhai
|
||||||
|
//! // CRUD
|
||||||
|
//! let u = users::create(#{ email: "a@b", password: "hunter22a", display_name: "Alice" });
|
||||||
|
//! let u = users::get(id); // map or ()
|
||||||
|
//! let u = users::find_by_email("a@b"); // map or () — REQUIRES an
|
||||||
|
//! // authenticated principal
|
||||||
|
//! // (anti-enumeration); forbidden
|
||||||
|
//! // for anonymous public scripts.
|
||||||
|
//! let free = users::email_available("a@b"); // bool — anonymous-safe pre-check
|
||||||
|
//! // for self-serve registration.
|
||||||
|
//! // Leaks only "exists/doesn't" but
|
||||||
|
//! // is cheap + unthrottled: throttle
|
||||||
|
//! // the route if enumeration matters.
|
||||||
|
//! users::update(id, #{ display_name: "Alicia" });
|
||||||
|
//! let removed = users::delete(id); // bool
|
||||||
|
//! let page = users::list(#{ "$limit": 50, cursor: () });
|
||||||
|
//!
|
||||||
|
//! // Auth
|
||||||
|
//! let tok = users::login("a@b", "hunter22a"); // session-token string or ()
|
||||||
|
//! let u = users::verify(tok); // map or () (sliding-TTL bump)
|
||||||
|
//! users::logout(tok);
|
||||||
|
//!
|
||||||
|
//! // Email-tied (commit 5 / 6 / 7)
|
||||||
|
//! users::send_verification_email(id,
|
||||||
|
//! #{ link_base: "https://app/verify", subject: "Confirm", body_template: "..." });
|
||||||
|
//! let u = users::verify_email(token);
|
||||||
|
//! users::request_password_reset("a@b", #{...});
|
||||||
|
//! let u = users::complete_password_reset(token, "new_pw");
|
||||||
|
//! users::invite("a@b",
|
||||||
|
//! #{ link_base: "https://app/invite", subject: "Join", body_template: "...",
|
||||||
|
//! display_name: "Bob", roles: ["editor"] });
|
||||||
|
//! let session_tok = users::accept_invite(token, "pw", "Bob");
|
||||||
|
//!
|
||||||
|
//! // Roles (commit 9)
|
||||||
|
//! users::add_role(id, "admin");
|
||||||
|
//! let removed = users::remove_role(id, "admin"); // bool
|
||||||
|
//! let yes = users::has_role(id, "admin"); // bool
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Collection-less surface (mirrors `email::` / `secrets::` rather
|
||||||
|
//! than `kv::`/`docs::`/`files::`'s handle pattern). `app_id` is
|
||||||
|
//! derived from `cx.app_id` in the service — it never appears on the
|
||||||
|
//! script-side signature, preserving cross-app isolation.
|
||||||
|
//!
|
||||||
|
//! Methods bound but not yet implemented in the underlying service
|
||||||
|
//! return a `users::not_implemented` runtime error. Subsequent v1.1.8
|
||||||
|
//! commits flesh them out without re-touching this file.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use super::bridge::block_on;
|
||||||
|
use picloud_shared::{
|
||||||
|
AppUser, AppUserId, CreateUserInput, EmailTemplateOpts, GeneratedAccept, GeneratedSession,
|
||||||
|
InviteOpts, SdkCallCx, Services, UpdateUserInput, UsersListOpts, UsersListPage, UsersService,
|
||||||
|
};
|
||||||
|
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||||
|
|
||||||
|
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||||
|
let svc = services.users.clone();
|
||||||
|
let mut module = Module::new();
|
||||||
|
|
||||||
|
bind_create(&mut module, &svc, &cx);
|
||||||
|
bind_get(&mut module, &svc, &cx);
|
||||||
|
bind_find_by_email(&mut module, &svc, &cx);
|
||||||
|
bind_email_available(&mut module, &svc, &cx);
|
||||||
|
bind_update(&mut module, &svc, &cx);
|
||||||
|
bind_delete(&mut module, &svc, &cx);
|
||||||
|
bind_list(&mut module, &svc, &cx);
|
||||||
|
bind_login(&mut module, &svc, &cx);
|
||||||
|
bind_verify(&mut module, &svc, &cx);
|
||||||
|
bind_logout(&mut module, &svc, &cx);
|
||||||
|
bind_send_verification_email(&mut module, &svc, &cx);
|
||||||
|
bind_verify_email(&mut module, &svc, &cx);
|
||||||
|
bind_request_password_reset(&mut module, &svc, &cx);
|
||||||
|
bind_complete_password_reset(&mut module, &svc, &cx);
|
||||||
|
bind_invite(&mut module, &svc, &cx);
|
||||||
|
bind_accept_invite(&mut module, &svc, &cx);
|
||||||
|
bind_add_role(&mut module, &svc, &cx);
|
||||||
|
bind_remove_role(&mut module, &svc, &cx);
|
||||||
|
bind_has_role(&mut module, &svc, &cx);
|
||||||
|
|
||||||
|
engine.register_static_module("users", module.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// CRUD
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn bind_create(module: &mut Module, svc: &Arc<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"create",
|
||||||
|
move |opts: Map| -> Result<Map, Box<EvalAltResult>> {
|
||||||
|
let email = required_string(&opts, "email", "users::create")?;
|
||||||
|
let password = required_string(&opts, "password", "users::create")?;
|
||||||
|
let display_name = optional_string(&opts, "display_name");
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
let user = block_on("users", async move {
|
||||||
|
svc.create(
|
||||||
|
&cx,
|
||||||
|
CreateUserInput {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
display_name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
})?;
|
||||||
|
Ok(user_to_map(&user))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bind_get(module: &mut Module, svc: &Arc<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"get",
|
||||||
|
move |id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
let id = parse_user_id(id, "users::get")?;
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
let user_opt = block_on("users", async move { svc.get(&cx, id).await })?;
|
||||||
|
Ok(user_opt.map_or(Dynamic::UNIT, |u| Dynamic::from(user_to_map(&u))))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bind_find_by_email(module: &mut Module, svc: &Arc<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"find_by_email",
|
||||||
|
move |email: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
let email = email.to_string();
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
let user_opt = block_on("users", async move { svc.find_by_email(&cx, &email).await })?;
|
||||||
|
Ok(user_opt.map_or(Dynamic::UNIT, |u| Dynamic::from(user_to_map(&u))))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `users::email_available(email)` → `bool`. Anonymous-safe pre-check for
|
||||||
|
/// self-serve registration (unlike `find_by_email`, which forbids
|
||||||
|
/// anonymous callers). Lets a public register script branch on a free vs
|
||||||
|
/// taken email without relying on `create`'s uniqueness error/502.
|
||||||
|
fn bind_email_available(module: &mut Module, svc: &Arc<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"email_available",
|
||||||
|
move |email: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||||
|
let email = email.to_string();
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
block_on(
|
||||||
|
"users",
|
||||||
|
async move { svc.email_available(&cx, &email).await },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bind_update(module: &mut Module, svc: &Arc<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"update",
|
||||||
|
move |id: &str, patch: Map| -> Result<Map, Box<EvalAltResult>> {
|
||||||
|
let id = parse_user_id(id, "users::update")?;
|
||||||
|
// display_name as Some(None) means "clear"; absent means
|
||||||
|
// "leave alone". Differentiation: present-with-() vs absent.
|
||||||
|
let display_name = if patch.contains_key("display_name") {
|
||||||
|
Some(optional_string(&patch, "display_name"))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let patch = UpdateUserInput { display_name };
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
let user = block_on("users", async move { svc.update(&cx, id, patch).await })?;
|
||||||
|
Ok(user_to_map(&user))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bind_delete(module: &mut Module, svc: &Arc<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"delete",
|
||||||
|
move |id: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||||
|
let id = parse_user_id(id, "users::delete")?;
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
block_on("users", async move { svc.delete(&cx, id).await })
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bind_list(module: &mut Module, svc: &Arc<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"list",
|
||||||
|
move |opts: Map| -> Result<Map, Box<EvalAltResult>> {
|
||||||
|
let limit = match opts.get("$limit").or_else(|| opts.get("limit")) {
|
||||||
|
None => None,
|
||||||
|
Some(d) if d.is_unit() => None,
|
||||||
|
Some(d) => Some(
|
||||||
|
d.as_int()
|
||||||
|
.map_err(|_| runtime_err("users::list: '$limit' must be an integer"))?,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let cursor = match opts.get("cursor") {
|
||||||
|
None => None,
|
||||||
|
Some(d) if d.is_unit() => None,
|
||||||
|
Some(d) if d.is_string() => Some(d.clone().into_string().unwrap_or_default()),
|
||||||
|
Some(_) => return Err(runtime_err("users::list: cursor must be a string or ()")),
|
||||||
|
};
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
let page: UsersListPage = block_on("users", async move {
|
||||||
|
svc.list(&cx, UsersListOpts { cursor, limit }).await
|
||||||
|
})?;
|
||||||
|
Ok(list_page_to_map(&page))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Auth
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn bind_login(module: &mut Module, svc: &Arc<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"login",
|
||||||
|
move |email: &str, password: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
let email = email.to_string();
|
||||||
|
let password = password.to_string();
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
let session_opt: Option<GeneratedSession> =
|
||||||
|
block_on(
|
||||||
|
"users",
|
||||||
|
async move { svc.login(&cx, &email, &password).await },
|
||||||
|
)?;
|
||||||
|
Ok(session_opt.map_or(Dynamic::UNIT, |s| Dynamic::from(s.token)))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bind_verify(module: &mut Module, svc: &Arc<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"verify",
|
||||||
|
move |token: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
let token = token.to_string();
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
let user_opt = block_on("users", async move { svc.verify(&cx, &token).await })?;
|
||||||
|
Ok(user_opt.map_or(Dynamic::UNIT, |u| Dynamic::from(user_to_map(&u))))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bind_logout(module: &mut Module, svc: &Arc<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"logout",
|
||||||
|
move |token: &str| -> Result<(), Box<EvalAltResult>> {
|
||||||
|
let token = token.to_string();
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
block_on("users", async move { svc.logout(&cx, &token).await })
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Email-tied (commit 5 / 6 / 7 fill the service impl behind these)
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn bind_send_verification_email(
|
||||||
|
module: &mut Module,
|
||||||
|
svc: &Arc<dyn UsersService>,
|
||||||
|
cx: &Arc<SdkCallCx>,
|
||||||
|
) {
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"send_verification_email",
|
||||||
|
move |id: &str, opts: Map| -> Result<(), Box<EvalAltResult>> {
|
||||||
|
let id = parse_user_id(id, "users::send_verification_email")?;
|
||||||
|
let opts = parse_email_template(&opts, "users::send_verification_email")?;
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
block_on("users", async move {
|
||||||
|
svc.send_verification_email(&cx, id, opts).await
|
||||||
|
})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bind_verify_email(module: &mut Module, svc: &Arc<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"verify_email",
|
||||||
|
move |token: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
let token = token.to_string();
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
let user_opt = block_on("users", async move { svc.verify_email(&cx, &token).await })?;
|
||||||
|
Ok(user_opt.map_or(Dynamic::UNIT, |u| Dynamic::from(user_to_map(&u))))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bind_request_password_reset(
|
||||||
|
module: &mut Module,
|
||||||
|
svc: &Arc<dyn UsersService>,
|
||||||
|
cx: &Arc<SdkCallCx>,
|
||||||
|
) {
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"request_password_reset",
|
||||||
|
move |email: &str, opts: Map| -> Result<(), Box<EvalAltResult>> {
|
||||||
|
let email = email.to_string();
|
||||||
|
let opts = parse_email_template(&opts, "users::request_password_reset")?;
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
block_on("users", async move {
|
||||||
|
svc.request_password_reset(&cx, &email, opts).await
|
||||||
|
})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bind_complete_password_reset(
|
||||||
|
module: &mut Module,
|
||||||
|
svc: &Arc<dyn UsersService>,
|
||||||
|
cx: &Arc<SdkCallCx>,
|
||||||
|
) {
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"complete_password_reset",
|
||||||
|
move |token: &str, new_password: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
let token = token.to_string();
|
||||||
|
let new_password = new_password.to_string();
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
let user_opt = block_on("users", async move {
|
||||||
|
svc.complete_password_reset(&cx, &token, &new_password)
|
||||||
|
.await
|
||||||
|
})?;
|
||||||
|
Ok(user_opt.map_or(Dynamic::UNIT, |u| Dynamic::from(user_to_map(&u))))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bind_invite(module: &mut Module, svc: &Arc<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"invite",
|
||||||
|
move |email: &str, opts: Map| -> Result<(), Box<EvalAltResult>> {
|
||||||
|
let email = email.to_string();
|
||||||
|
let opts = parse_invite_opts(&opts)?;
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
block_on("users", async move { svc.invite(&cx, &email, opts).await })
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bind_accept_invite(module: &mut Module, svc: &Arc<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
// Two-arg overload: (token, password). The display_name overload is
|
||||||
|
// bound separately because Rhai resolves functions by arity.
|
||||||
|
{
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"accept_invite",
|
||||||
|
move |token: &str, password: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
let token = token.to_string();
|
||||||
|
let password = password.to_string();
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
let accept_opt: Option<GeneratedAccept> = block_on("users", async move {
|
||||||
|
svc.accept_invite(&cx, &token, &password, None).await
|
||||||
|
})?;
|
||||||
|
Ok(accept_opt.map_or(Dynamic::UNIT, |a| Dynamic::from(a.session.token)))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"accept_invite",
|
||||||
|
move |token: &str,
|
||||||
|
password: &str,
|
||||||
|
display_name: &str|
|
||||||
|
-> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
let token = token.to_string();
|
||||||
|
let password = password.to_string();
|
||||||
|
let display_name = if display_name.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(display_name.trim().to_string())
|
||||||
|
};
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
let accept_opt: Option<GeneratedAccept> = block_on("users", async move {
|
||||||
|
svc.accept_invite(&cx, &token, &password, display_name)
|
||||||
|
.await
|
||||||
|
})?;
|
||||||
|
Ok(accept_opt.map_or(Dynamic::UNIT, |a| Dynamic::from(a.session.token)))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Roles (commit 9 fills the service impl)
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn bind_add_role(module: &mut Module, svc: &Arc<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"add_role",
|
||||||
|
move |id: &str, role: &str| -> Result<(), Box<EvalAltResult>> {
|
||||||
|
let id = parse_user_id(id, "users::add_role")?;
|
||||||
|
let role = role.to_string();
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
block_on("users", async move { svc.add_role(&cx, id, &role).await })
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bind_remove_role(module: &mut Module, svc: &Arc<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"remove_role",
|
||||||
|
move |id: &str, role: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||||
|
let id = parse_user_id(id, "users::remove_role")?;
|
||||||
|
let role = role.to_string();
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
block_on(
|
||||||
|
"users",
|
||||||
|
async move { svc.remove_role(&cx, id, &role).await },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bind_has_role(module: &mut Module, svc: &Arc<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"has_role",
|
||||||
|
move |id: &str, role: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||||
|
let id = parse_user_id(id, "users::has_role")?;
|
||||||
|
let role = role.to_string();
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
block_on("users", async move { svc.has_role(&cx, id, &role).await })
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Shape conversion helpers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn user_to_map(user: &AppUser) -> Map {
|
||||||
|
let mut m = Map::new();
|
||||||
|
m.insert("id".into(), Dynamic::from(user.id.to_string()));
|
||||||
|
m.insert("email".into(), Dynamic::from(user.email.clone()));
|
||||||
|
m.insert(
|
||||||
|
"display_name".into(),
|
||||||
|
user.display_name
|
||||||
|
.clone()
|
||||||
|
.map_or(Dynamic::UNIT, Dynamic::from),
|
||||||
|
);
|
||||||
|
m.insert(
|
||||||
|
"email_verified_at".into(),
|
||||||
|
user.email_verified_at
|
||||||
|
.map_or(Dynamic::UNIT, |d| Dynamic::from(d.to_rfc3339())),
|
||||||
|
);
|
||||||
|
m.insert(
|
||||||
|
"last_login_at".into(),
|
||||||
|
user.last_login_at
|
||||||
|
.map_or(Dynamic::UNIT, |d| Dynamic::from(d.to_rfc3339())),
|
||||||
|
);
|
||||||
|
m.insert(
|
||||||
|
"created_at".into(),
|
||||||
|
Dynamic::from(user.created_at.to_rfc3339()),
|
||||||
|
);
|
||||||
|
m.insert(
|
||||||
|
"updated_at".into(),
|
||||||
|
Dynamic::from(user.updated_at.to_rfc3339()),
|
||||||
|
);
|
||||||
|
let roles: Array = user.roles.iter().cloned().map(Dynamic::from).collect();
|
||||||
|
m.insert("roles".into(), roles.into());
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_page_to_map(page: &UsersListPage) -> Map {
|
||||||
|
let mut m = Map::new();
|
||||||
|
let items: Array = page
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.map(|u| Dynamic::from(user_to_map(u)))
|
||||||
|
.collect();
|
||||||
|
m.insert("users".into(), items.into());
|
||||||
|
m.insert(
|
||||||
|
"next_cursor".into(),
|
||||||
|
page.next_cursor
|
||||||
|
.as_ref()
|
||||||
|
.map_or(Dynamic::UNIT, |s| Dynamic::from(s.clone())),
|
||||||
|
);
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_user_id(s: &str, ctx: &str) -> Result<AppUserId, Box<EvalAltResult>> {
|
||||||
|
uuid::Uuid::parse_str(s)
|
||||||
|
.map(AppUserId::from)
|
||||||
|
.map_err(|e| runtime_err(&format!("{ctx}: id must be a UUID: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_email_template(opts: &Map, ctx: &str) -> Result<EmailTemplateOpts, Box<EvalAltResult>> {
|
||||||
|
Ok(EmailTemplateOpts {
|
||||||
|
link_base: required_string(opts, "link_base", ctx)?,
|
||||||
|
from: required_string(opts, "from", ctx)?,
|
||||||
|
subject: required_string(opts, "subject", ctx)?,
|
||||||
|
body_template: required_string(opts, "body_template", ctx)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_invite_opts(opts: &Map) -> Result<InviteOpts, Box<EvalAltResult>> {
|
||||||
|
// The template fields are optional only when invite is configured
|
||||||
|
// to ship without an email; the service decides which is the
|
||||||
|
// hard error. Here we forward whatever the script gave us.
|
||||||
|
let template = if opts.contains_key("link_base")
|
||||||
|
|| opts.contains_key("subject")
|
||||||
|
|| opts.contains_key("body_template")
|
||||||
|
{
|
||||||
|
Some(parse_email_template(opts, "users::invite")?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let display_name = optional_string(opts, "display_name");
|
||||||
|
let roles = match opts.get("roles") {
|
||||||
|
None => Vec::new(),
|
||||||
|
Some(d) if d.is_unit() => Vec::new(),
|
||||||
|
Some(d) => {
|
||||||
|
if let Some(arr) = d.clone().try_cast::<Array>() {
|
||||||
|
let mut out = Vec::with_capacity(arr.len());
|
||||||
|
for el in arr {
|
||||||
|
if !el.is_string() {
|
||||||
|
return Err(runtime_err(
|
||||||
|
"users::invite: 'roles' must be an array of strings",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
out.push(el.into_string().unwrap_or_default());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
} else {
|
||||||
|
return Err(runtime_err(
|
||||||
|
"users::invite: 'roles' must be an array of strings",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(InviteOpts {
|
||||||
|
template,
|
||||||
|
display_name,
|
||||||
|
roles,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required_string(opts: &Map, key: &str, ctx: &str) -> Result<String, Box<EvalAltResult>> {
|
||||||
|
match opts.get(key) {
|
||||||
|
Some(d) if d.is_string() => Ok(d.clone().into_string().unwrap_or_default()),
|
||||||
|
_ => Err(runtime_err(&format!(
|
||||||
|
"{ctx}: '{key}' must be a string and is required"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn optional_string(opts: &Map, key: &str) -> Option<String> {
|
||||||
|
match opts.get(key) {
|
||||||
|
None => None,
|
||||||
|
Some(d) if d.is_unit() => None,
|
||||||
|
Some(d) if d.is_string() => Some(d.clone().into_string().unwrap_or_default()),
|
||||||
|
Some(d) => Some(d.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::unnecessary_box_returns)]
|
||||||
|
fn runtime_err(msg: &str) -> Box<EvalAltResult> {
|
||||||
|
EvalAltResult::ErrorRuntime(msg.into(), rhai::Position::NONE).into()
|
||||||
|
}
|
||||||
57
crates/executor-core/src/sdk/vars.rs
Normal file
57
crates/executor-core/src/sdk/vars.rs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
//! `vars::` Rhai bridge — read-only access to the app's resolved,
|
||||||
|
//! group-inherited config (Phase 3).
|
||||||
|
//!
|
||||||
|
//! ```rhai
|
||||||
|
//! let region = vars::get("region"); // value or ()
|
||||||
|
//! let all = vars::all(); // #{ key: value, ... }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Values are inherited down the group tree and env-filtered (§3); the
|
||||||
|
//! resolution happens server-side in manager-core. Writes go through the
|
||||||
|
//! admin API, not the SDK. `app_id` is derived from `cx.app_id` in the
|
||||||
|
//! service — never a script argument — preserving cross-app isolation.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use picloud_shared::{SdkCallCx, Services};
|
||||||
|
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||||
|
|
||||||
|
use super::bridge::{block_on, json_to_dynamic};
|
||||||
|
|
||||||
|
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||||
|
let svc = services.vars.clone();
|
||||||
|
let mut module = Module::new();
|
||||||
|
|
||||||
|
// vars::get(key) — resolved value, or () if no level defines it.
|
||||||
|
{
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn(
|
||||||
|
"get",
|
||||||
|
move |key: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
let opt = block_on("vars", async move { svc.get(&cx, key).await })?;
|
||||||
|
Ok(opt.map_or(Dynamic::UNIT, json_to_dynamic))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// vars::all() — the fully-resolved config map.
|
||||||
|
{
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
module.set_native_fn("all", move || -> Result<Map, Box<EvalAltResult>> {
|
||||||
|
let svc = svc.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
let resolved = block_on("vars", async move { svc.all(&cx).await })?;
|
||||||
|
let mut m = Map::new();
|
||||||
|
for (k, v) in resolved {
|
||||||
|
m.insert(k.into(), json_to_dynamic(v));
|
||||||
|
}
|
||||||
|
Ok(m)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.register_static_module("vars", module.into());
|
||||||
|
}
|
||||||
@@ -2,10 +2,12 @@ use std::collections::BTreeMap;
|
|||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
AppId, ExecutionId, Principal, RequestId, ScriptId, ScriptSandbox, TriggerEvent,
|
AppId, ExecutionId, ExecutionLog, ExecutionSource, ExecutionStatus, Principal, RequestId,
|
||||||
|
ScriptId, ScriptOwner, ScriptSandbox, TriggerEvent,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@@ -27,6 +29,15 @@ pub struct ExecRequest {
|
|||||||
pub script_name: String,
|
pub script_name: String,
|
||||||
pub invocation_type: InvocationType,
|
pub invocation_type: InvocationType,
|
||||||
pub path: String,
|
pub path: String,
|
||||||
|
|
||||||
|
/// HTTP method of the originating request, uppercased (`GET`, `POST`,
|
||||||
|
/// …). Exposed to scripts as `ctx.request.method` so a single script
|
||||||
|
/// can branch on the verb instead of needing one script per method.
|
||||||
|
/// Empty for non-HTTP trigger invocations (cron/kv/docs/…), matching
|
||||||
|
/// how `path`/`rest` are empty there.
|
||||||
|
#[serde(default)]
|
||||||
|
pub method: String,
|
||||||
|
|
||||||
pub headers: BTreeMap<String, String>,
|
pub headers: BTreeMap<String, String>,
|
||||||
pub body: serde_json::Value,
|
pub body: serde_json::Value,
|
||||||
|
|
||||||
@@ -58,6 +69,16 @@ pub struct ExecRequest {
|
|||||||
/// Internal-only; not surfaced via `ctx` (which the script sees).
|
/// Internal-only; not surfaced via `ctx` (which the script sees).
|
||||||
pub app_id: AppId,
|
pub app_id: AppId,
|
||||||
|
|
||||||
|
/// The **defining node** of the top-level script being executed —
|
||||||
|
/// the resolved `Script`'s owner (Phase 4b). This is the lexical
|
||||||
|
/// origin its `import` statements resolve against (§5.5): an inherited
|
||||||
|
/// group endpoint's imports seal to the **group**, not the inheriting
|
||||||
|
/// app, so a leaf can't shadow them. Distinct from `app_id`, which is
|
||||||
|
/// always the execution boundary (where SDK calls scope). `None` (old
|
||||||
|
/// payloads / cluster wire) falls back to `App(app_id)` in the engine.
|
||||||
|
#[serde(default)]
|
||||||
|
pub script_owner: Option<ScriptOwner>,
|
||||||
|
|
||||||
/// Caller identity, when authenticated. `None` for unauthenticated
|
/// Caller identity, when authenticated. `None` for unauthenticated
|
||||||
/// data-plane HTTP requests (the common case for public scripts);
|
/// data-plane HTTP requests (the common case for public scripts);
|
||||||
/// `Some` when a bearer token or session cookie was resolved.
|
/// `Some` when a bearer token or session cookie was resolved.
|
||||||
@@ -129,7 +150,12 @@ pub struct ExecStats {
|
|||||||
pub operations: u64,
|
pub operations: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
/// Serialize/Deserialize are derived for `RemoteExecutorClient` (cluster
|
||||||
|
/// mode v1.3+) — the executor returns this shape over the wire so the
|
||||||
|
/// orchestrator can reconstruct the variant rather than collapsing to a
|
||||||
|
/// generic string. Audit F-N-001 follow-up.
|
||||||
|
#[derive(Debug, Error, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case", tag = "kind")]
|
||||||
pub enum ExecError {
|
pub enum ExecError {
|
||||||
#[error("script failed to parse: {0}")]
|
#[error("script failed to parse: {0}")]
|
||||||
Parse(String),
|
Parse(String),
|
||||||
@@ -153,3 +179,77 @@ pub enum ExecError {
|
|||||||
#[error("execution declined: server at capacity (retry after {retry_after_secs}s)")]
|
#[error("execution declined: server at capacity (retry after {retry_after_secs}s)")]
|
||||||
Overloaded { retry_after_secs: u32 },
|
Overloaded { retry_after_secs: u32 },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build an `ExecutionLog` row from one invocation's outcome.
|
||||||
|
///
|
||||||
|
/// Shared by every execution path so the log shape stays identical
|
||||||
|
/// regardless of who ran the script: the orchestrator's sync/direct HTTP
|
||||||
|
/// handlers pass `ExecutionSource::Http`, while the manager's trigger
|
||||||
|
/// dispatcher passes the trigger's kind (`Kv`, `Cron`, `Invoke`, …). That
|
||||||
|
/// `source` is what lets `pic logs` surface background runs that were
|
||||||
|
/// previously invisible. `Overloaded` is never logged — admission is
|
||||||
|
/// refused before a row exists — but it maps to `Error` defensively.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
#[must_use]
|
||||||
|
pub fn build_execution_log(
|
||||||
|
app_id: AppId,
|
||||||
|
script_id: ScriptId,
|
||||||
|
request_id: RequestId,
|
||||||
|
request_path: String,
|
||||||
|
request_headers: BTreeMap<String, String>,
|
||||||
|
request_body: serde_json::Value,
|
||||||
|
source: ExecutionSource,
|
||||||
|
outcome: &Result<ExecResponse, ExecError>,
|
||||||
|
started: DateTime<Utc>,
|
||||||
|
finished: DateTime<Utc>,
|
||||||
|
) -> ExecutionLog {
|
||||||
|
let duration_ms = u64::try_from(
|
||||||
|
finished
|
||||||
|
.signed_duration_since(started)
|
||||||
|
.num_milliseconds()
|
||||||
|
.max(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let (status, response_code, response_body, script_logs) = match outcome {
|
||||||
|
Ok(resp) => {
|
||||||
|
let logs = serde_json::to_value(&resp.logs).unwrap_or(serde_json::Value::Array(vec![]));
|
||||||
|
(
|
||||||
|
ExecutionStatus::Success,
|
||||||
|
Some(resp.status_code),
|
||||||
|
Some(resp.body.clone()),
|
||||||
|
logs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let status = match e {
|
||||||
|
ExecError::Timeout(_) => ExecutionStatus::Timeout,
|
||||||
|
ExecError::OperationBudgetExceeded => ExecutionStatus::BudgetExceeded,
|
||||||
|
_ => ExecutionStatus::Error,
|
||||||
|
};
|
||||||
|
(
|
||||||
|
status,
|
||||||
|
None,
|
||||||
|
Some(serde_json::json!({ "error": e.to_string() })),
|
||||||
|
serde_json::Value::Array(vec![]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ExecutionLog {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
app_id,
|
||||||
|
script_id,
|
||||||
|
request_id,
|
||||||
|
request_path,
|
||||||
|
request_headers,
|
||||||
|
request_body,
|
||||||
|
response_code,
|
||||||
|
response_body,
|
||||||
|
script_logs,
|
||||||
|
duration_ms,
|
||||||
|
status,
|
||||||
|
source,
|
||||||
|
created_at: started,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ fn req(body: serde_json::Value) -> ExecRequest {
|
|||||||
script_name: "test".into(),
|
script_name: "test".into(),
|
||||||
invocation_type: InvocationType::Http,
|
invocation_type: InvocationType::Http,
|
||||||
path: "/test".into(),
|
path: "/test".into(),
|
||||||
|
method: String::new(),
|
||||||
headers: BTreeMap::new(),
|
headers: BTreeMap::new(),
|
||||||
body,
|
body,
|
||||||
params: BTreeMap::new(),
|
params: BTreeMap::new(),
|
||||||
@@ -22,6 +23,7 @@ fn req(body: serde_json::Value) -> ExecRequest {
|
|||||||
rest: String::new(),
|
rest: String::new(),
|
||||||
sandbox_overrides: ScriptSandbox::default(),
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
app_id: AppId::new(),
|
app_id: AppId::new(),
|
||||||
|
script_owner: None,
|
||||||
principal: None,
|
principal: None,
|
||||||
trigger_depth: 0,
|
trigger_depth: 0,
|
||||||
root_execution_id: execution_id,
|
root_execution_id: execution_id,
|
||||||
@@ -49,6 +51,22 @@ fn validate_rejects_syntax_errors() {
|
|||||||
assert!(matches!(err, ExecError::Parse(_)));
|
assert!(matches!(err, ExecError::Parse(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debug_and_print_symbols_are_disabled() {
|
||||||
|
// Audit 2026-06-11 (F-SE-H-03) — neither `print` nor `debug` may
|
||||||
|
// reach the host's stdout/stderr; both are disabled at the symbol
|
||||||
|
// level, so a script that calls them fails to parse/validate.
|
||||||
|
for src in [r#"print("x")"#, r#"debug("x")"#] {
|
||||||
|
let err = engine()
|
||||||
|
.validate(src)
|
||||||
|
.expect_err("disabled output symbol should be rejected");
|
||||||
|
assert!(
|
||||||
|
matches!(err, ExecError::Parse(_)),
|
||||||
|
"{src} should be a parse-time rejection, got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn returns_unwrapped_value_as_200_body() {
|
fn returns_unwrapped_value_as_200_body() {
|
||||||
let resp = engine()
|
let resp = engine()
|
||||||
@@ -87,6 +105,7 @@ fn ctx_exposes_request_data() {
|
|||||||
";
|
";
|
||||||
let r = ExecRequest {
|
let r = ExecRequest {
|
||||||
path: "/payments".into(),
|
path: "/payments".into(),
|
||||||
|
method: String::new(),
|
||||||
body: json!({ "amount": 1234 }),
|
body: json!({ "amount": 1234 }),
|
||||||
script_name: "payments".into(),
|
script_name: "payments".into(),
|
||||||
..req(json!(null))
|
..req(json!(null))
|
||||||
@@ -98,6 +117,19 @@ fn ctx_exposes_request_data() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctx_exposes_request_method() {
|
||||||
|
// A single script can branch on the verb instead of needing one
|
||||||
|
// script per method (E2E report F4).
|
||||||
|
let src = r"#{ statusCode: 200, body: #{ method: ctx.request.method } }";
|
||||||
|
let r = ExecRequest {
|
||||||
|
method: "PATCH".into(),
|
||||||
|
..req(json!(null))
|
||||||
|
};
|
||||||
|
let resp = engine().execute(src, r).unwrap();
|
||||||
|
assert_eq!(resp.body, json!({ "method": "PATCH" }));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn captures_log_calls() {
|
fn captures_log_calls() {
|
||||||
let src = r#"
|
let src = r#"
|
||||||
@@ -173,6 +205,54 @@ fn override_only_replaces_specified_field() {
|
|||||||
assert_eq!(resp.body, json!("hello"));
|
assert_eq!(resp.body, json!("hello"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deadline_terminates_a_runaway_loop() {
|
||||||
|
// Audit 2026-06-11 H-C1 closure. With a generous op budget the
|
||||||
|
// previous engine ran a `loop {}` body until `max_operations`
|
||||||
|
// self-exhausted (potentially seconds on Pi-class hardware) and the
|
||||||
|
// outer `tokio::time::timeout` only dropped the awaiting future —
|
||||||
|
// not the OS thread. With `on_progress` consulting the deadline,
|
||||||
|
// a 100 ms deadline aborts within a few hundred ms even when the
|
||||||
|
// op budget would allow far more work.
|
||||||
|
let limits = Limits {
|
||||||
|
max_operations: u64::MAX,
|
||||||
|
..Limits::default()
|
||||||
|
};
|
||||||
|
let engine = Engine::new(limits, Services::default());
|
||||||
|
let src = r"let n = 0; loop { n += 1; }";
|
||||||
|
let deadline = Some(std::time::Instant::now() + std::time::Duration::from_millis(100));
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let err = engine
|
||||||
|
.execute_with_deadline(src, req(json!(null)), deadline)
|
||||||
|
.expect_err("runaway loop must terminate");
|
||||||
|
let elapsed = started.elapsed();
|
||||||
|
assert!(
|
||||||
|
elapsed < std::time::Duration::from_secs(2),
|
||||||
|
"deadline should fire within ~hundreds of ms, took {elapsed:?}"
|
||||||
|
);
|
||||||
|
// ErrorTerminated maps to ExecError::Runtime via map_eval_error.
|
||||||
|
assert!(
|
||||||
|
matches!(err, ExecError::Runtime(_)),
|
||||||
|
"expected Runtime, got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_deadline_set_does_not_abort() {
|
||||||
|
// Smoke: a deadline-less execute is unchanged from the pre-audit
|
||||||
|
// behavior — the per-op budget is what stops things.
|
||||||
|
let limits = Limits {
|
||||||
|
max_operations: 100,
|
||||||
|
..Limits::default()
|
||||||
|
};
|
||||||
|
let engine = Engine::new(limits, Services::default());
|
||||||
|
let src = r"let n = 0; for i in 0..10000 { n += 1; } n";
|
||||||
|
let err = engine
|
||||||
|
.execute_with_deadline(src, req(json!(null)), None)
|
||||||
|
.expect_err("budget should still bite");
|
||||||
|
assert!(matches!(err, ExecError::OperationBudgetExceeded));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_error_is_mapped_to_runtime_variant() {
|
fn runtime_error_is_mapped_to_runtime_variant() {
|
||||||
let err = engine()
|
let err = engine()
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
|||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
AppId, ExecutionId, ModuleScript, ModuleSource, ModuleSourceError, NoopDeadLetterService,
|
AppId, ExecutionId, ModuleScript, ModuleSource, ModuleSourceError, NoopDeadLetterService,
|
||||||
NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService, RequestId, ScriptId,
|
NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService, RequestId, ScriptId,
|
||||||
ScriptSandbox, SdkCallCx, Services,
|
ScriptOwner, ScriptSandbox, Services,
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tracing_subscriber::fmt::MakeWriter;
|
use tracing_subscriber::fmt::MakeWriter;
|
||||||
@@ -27,9 +27,9 @@ struct FailingSource;
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ModuleSource for FailingSource {
|
impl ModuleSource for FailingSource {
|
||||||
async fn lookup(
|
async fn resolve(
|
||||||
&self,
|
&self,
|
||||||
_cx: &SdkCallCx,
|
_origin: ScriptOwner,
|
||||||
_name: &str,
|
_name: &str,
|
||||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||||
Err(ModuleSourceError::Backend(SENTINEL.to_string()))
|
Err(ModuleSourceError::Backend(SENTINEL.to_string()))
|
||||||
@@ -66,6 +66,7 @@ fn req(app_id: AppId) -> ExecRequest {
|
|||||||
script_name: "redaction-test".into(),
|
script_name: "redaction-test".into(),
|
||||||
invocation_type: InvocationType::Http,
|
invocation_type: InvocationType::Http,
|
||||||
path: "/x".into(),
|
path: "/x".into(),
|
||||||
|
method: String::new(),
|
||||||
headers: BTreeMap::new(),
|
headers: BTreeMap::new(),
|
||||||
body: Value::Null,
|
body: Value::Null,
|
||||||
params: BTreeMap::new(),
|
params: BTreeMap::new(),
|
||||||
@@ -73,6 +74,7 @@ fn req(app_id: AppId) -> ExecRequest {
|
|||||||
rest: String::new(),
|
rest: String::new(),
|
||||||
sandbox_overrides: ScriptSandbox::default(),
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
app_id,
|
app_id,
|
||||||
|
script_owner: None,
|
||||||
principal: None,
|
principal: None,
|
||||||
trigger_depth: 0,
|
trigger_depth: 0,
|
||||||
root_execution_id: execution_id,
|
root_execution_id: execution_id,
|
||||||
@@ -101,6 +103,12 @@ async fn original_backend_error_is_logged_at_error_level() {
|
|||||||
Arc::new(NoopHttpService),
|
Arc::new(NoopHttpService),
|
||||||
Arc::new(picloud_shared::NoopFilesService),
|
Arc::new(picloud_shared::NoopFilesService),
|
||||||
Arc::new(picloud_shared::NoopPubsubService),
|
Arc::new(picloud_shared::NoopPubsubService),
|
||||||
|
Arc::new(picloud_shared::NoopSecretsService),
|
||||||
|
Arc::new(picloud_shared::NoopEmailService),
|
||||||
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
|
Arc::new(picloud_shared::NoopQueueService),
|
||||||
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
);
|
);
|
||||||
let engine = Engine::new(Limits::default(), services);
|
let engine = Engine::new(Limits::default(), services);
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
//! verify the same code path the `picloud` binary runs at request
|
//! verify the same code path the `picloud` binary runs at request
|
||||||
//! time.
|
//! time.
|
||||||
|
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -16,9 +16,9 @@ use async_trait::async_trait;
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
AppId, ExecutionId, ModuleScript, ModuleSource, ModuleSourceError, NoopDeadLetterService,
|
AppId, ExecutionId, GroupId, ModuleScript, ModuleSource, ModuleSourceError,
|
||||||
NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService, RequestId, ScriptId,
|
NoopDeadLetterService, NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService,
|
||||||
ScriptSandbox, SdkCallCx, Services,
|
RequestId, ScriptId, ScriptOwner, ScriptSandbox, Services,
|
||||||
};
|
};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
@@ -55,7 +55,8 @@ impl CountingModuleSource {
|
|||||||
(app_id, name.to_string()),
|
(app_id, name.to_string()),
|
||||||
ModuleScript {
|
ModuleScript {
|
||||||
script_id,
|
script_id,
|
||||||
app_id,
|
app_id: Some(app_id),
|
||||||
|
group_id: None,
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
source: source.to_string(),
|
source: source.to_string(),
|
||||||
updated_at,
|
updated_at,
|
||||||
@@ -71,20 +72,27 @@ impl CountingModuleSource {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ModuleSource for CountingModuleSource {
|
impl ModuleSource for CountingModuleSource {
|
||||||
async fn lookup(
|
async fn resolve(
|
||||||
&self,
|
&self,
|
||||||
cx: &SdkCallCx,
|
origin: ScriptOwner,
|
||||||
name: &str,
|
name: &str,
|
||||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||||
self.lookups.fetch_add(1, Ordering::SeqCst);
|
self.lookups.fetch_add(1, Ordering::SeqCst);
|
||||||
if let Some(err) = self.fail_with.lock().await.as_ref() {
|
if let Some(err) = self.fail_with.lock().await.as_ref() {
|
||||||
return Err(ModuleSourceError::Backend(err.clone()));
|
return Err(ModuleSourceError::Backend(err.clone()));
|
||||||
}
|
}
|
||||||
|
// This fake is flat/app-scoped — the inheritance + lexical
|
||||||
|
// resolution semantics are covered by the CLI journey tests
|
||||||
|
// against real Postgres. A group origin has no entries here.
|
||||||
|
let app_id = match origin {
|
||||||
|
ScriptOwner::App(a) => a,
|
||||||
|
ScriptOwner::Group(_) => return Ok(None),
|
||||||
|
};
|
||||||
Ok(self
|
Ok(self
|
||||||
.table
|
.table
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
.get(&(cx.app_id, name.to_string()))
|
.get(&(app_id, name.to_string()))
|
||||||
.cloned())
|
.cloned())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,6 +107,12 @@ fn services_with(modules: Arc<dyn ModuleSource>) -> Services {
|
|||||||
Arc::new(NoopHttpService),
|
Arc::new(NoopHttpService),
|
||||||
Arc::new(picloud_shared::NoopFilesService),
|
Arc::new(picloud_shared::NoopFilesService),
|
||||||
Arc::new(picloud_shared::NoopPubsubService),
|
Arc::new(picloud_shared::NoopPubsubService),
|
||||||
|
Arc::new(picloud_shared::NoopSecretsService),
|
||||||
|
Arc::new(picloud_shared::NoopEmailService),
|
||||||
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
|
Arc::new(picloud_shared::NoopQueueService),
|
||||||
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +129,7 @@ fn req(app_id: AppId) -> ExecRequest {
|
|||||||
script_name: "test".into(),
|
script_name: "test".into(),
|
||||||
invocation_type: InvocationType::Http,
|
invocation_type: InvocationType::Http,
|
||||||
path: "/test".into(),
|
path: "/test".into(),
|
||||||
|
method: String::new(),
|
||||||
headers: BTreeMap::new(),
|
headers: BTreeMap::new(),
|
||||||
body: serde_json::Value::Null,
|
body: serde_json::Value::Null,
|
||||||
params: BTreeMap::new(),
|
params: BTreeMap::new(),
|
||||||
@@ -122,6 +137,7 @@ fn req(app_id: AppId) -> ExecRequest {
|
|||||||
rest: String::new(),
|
rest: String::new(),
|
||||||
sandbox_overrides: ScriptSandbox::default(),
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
app_id,
|
app_id,
|
||||||
|
script_owner: None,
|
||||||
principal: None,
|
principal: None,
|
||||||
trigger_depth: 0,
|
trigger_depth: 0,
|
||||||
root_execution_id: execution_id,
|
root_execution_id: execution_id,
|
||||||
@@ -593,3 +609,277 @@ fn validate_endpoint_skips_dynamic_imports_in_imports_list() {
|
|||||||
v.imports
|
v.imports
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase 4b — lexical (origin-aware) resolution.
|
||||||
|
//
|
||||||
|
// `LexicalModuleSource` keys modules by their *exact* defining node, with no
|
||||||
|
// chain walk. That's enough to prove the resolver passes the RIGHT origin:
|
||||||
|
// `default_origin` for the entry AST, and each module's own owner (decoded
|
||||||
|
// from Rhai's `_source`) for its nested imports. The chain-walk + group-tree
|
||||||
|
// semantics are covered end-to-end by the CLI journey tests against Postgres.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct LexicalModuleSource {
|
||||||
|
table: Mutex<HashMap<(ScriptOwner, String), ModuleScript>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LexicalModuleSource {
|
||||||
|
fn new() -> Arc<Self> {
|
||||||
|
Arc::new(Self::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put(self: &Arc<Self>, owner: ScriptOwner, name: &str, source: &str) {
|
||||||
|
let (app_id, group_id) = match owner {
|
||||||
|
ScriptOwner::App(a) => (Some(a), None),
|
||||||
|
ScriptOwner::Group(g) => (None, Some(g)),
|
||||||
|
};
|
||||||
|
self.table.lock().await.insert(
|
||||||
|
(owner, name.to_string()),
|
||||||
|
ModuleScript {
|
||||||
|
script_id: ScriptId::new(),
|
||||||
|
app_id,
|
||||||
|
group_id,
|
||||||
|
name: name.to_string(),
|
||||||
|
source: source.to_string(),
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ModuleSource for LexicalModuleSource {
|
||||||
|
async fn resolve(
|
||||||
|
&self,
|
||||||
|
origin: ScriptOwner,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||||
|
Ok(self
|
||||||
|
.table
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get(&(origin, name.to_string()))
|
||||||
|
.cloned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn req_with_owner(app_id: AppId, owner: ScriptOwner) -> ExecRequest {
|
||||||
|
let mut r = req(app_id);
|
||||||
|
r.script_owner = Some(owner);
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A group module's `import` resolves from the GROUP (its own defining
|
||||||
|
/// node), not the inheriting app — even when an app-owned module of the
|
||||||
|
/// same name exists. This is the §5.5 trust boundary: a leaf can't shadow
|
||||||
|
/// a module an inherited group script depends on.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn lexical_nested_import_seals_to_module_owner() {
|
||||||
|
let source = LexicalModuleSource::new();
|
||||||
|
let app = AppId::new();
|
||||||
|
let group = GroupId::new();
|
||||||
|
|
||||||
|
// Two "inner" modules: the group's (real) and the app's (a trap).
|
||||||
|
source
|
||||||
|
.put(ScriptOwner::Group(group), "inner", "fn v() { 42 }")
|
||||||
|
.await;
|
||||||
|
source
|
||||||
|
.put(ScriptOwner::App(app), "inner", "fn v() { 999 }")
|
||||||
|
.await;
|
||||||
|
// The group's "outer" imports "inner" — must bind the group's inner.
|
||||||
|
source
|
||||||
|
.put(
|
||||||
|
ScriptOwner::Group(group),
|
||||||
|
"outer",
|
||||||
|
r#"import "inner" as i; fn val() { i::v() }"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let engine = engine_with(source.clone());
|
||||||
|
// Entry script runs as the inherited GROUP script (default_origin = group).
|
||||||
|
let resp = engine
|
||||||
|
.execute(
|
||||||
|
r#"import "outer" as o; o::val()"#,
|
||||||
|
req_with_owner(app, ScriptOwner::Group(group)),
|
||||||
|
)
|
||||||
|
.expect("should execute");
|
||||||
|
assert_eq!(
|
||||||
|
resp.body,
|
||||||
|
serde_json::json!(42),
|
||||||
|
"group module's import must seal to the group's `inner`, not the app's trap"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same registry, entered as an APP-owned script, reaches the app's
|
||||||
|
/// module — proving the fake distinguishes origins and the entry uses
|
||||||
|
/// `default_origin`.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn lexical_entry_origin_selects_app_module() {
|
||||||
|
let source = LexicalModuleSource::new();
|
||||||
|
let app = AppId::new();
|
||||||
|
let group = GroupId::new();
|
||||||
|
source
|
||||||
|
.put(ScriptOwner::Group(group), "inner", "fn v() { 42 }")
|
||||||
|
.await;
|
||||||
|
source
|
||||||
|
.put(ScriptOwner::App(app), "inner", "fn v() { 999 }")
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let engine = engine_with(source.clone());
|
||||||
|
let resp = engine
|
||||||
|
.execute(
|
||||||
|
r#"import "inner" as i; i::v()"#,
|
||||||
|
req_with_owner(app, ScriptOwner::App(app)),
|
||||||
|
)
|
||||||
|
.expect("should execute");
|
||||||
|
assert_eq!(resp.body, serde_json::json!(999));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// §5.5 extension points — resolver handling of the policy outcomes.
|
||||||
|
//
|
||||||
|
// `PolicyModuleSource` is a flat fake that overrides `resolve_policy`: an EP
|
||||||
|
// name resolves dynamically against the inheriting app; a non-EP name resolves
|
||||||
|
// lexically from the importing origin. This verifies the resolver maps
|
||||||
|
// Module/NoProvider/NotFound correctly. The full chain semantics (default body
|
||||||
|
// up-chain, nearest-declaration tie) are covered by the CLI journey tests.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct PolicyModuleSource {
|
||||||
|
modules: Mutex<HashMap<(ScriptOwner, String), ModuleScript>>,
|
||||||
|
eps: Mutex<HashSet<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PolicyModuleSource {
|
||||||
|
fn new() -> Arc<Self> {
|
||||||
|
Arc::new(Self::default())
|
||||||
|
}
|
||||||
|
async fn put(self: &Arc<Self>, owner: ScriptOwner, name: &str, source: &str) {
|
||||||
|
let (app_id, group_id) = match owner {
|
||||||
|
ScriptOwner::App(a) => (Some(a), None),
|
||||||
|
ScriptOwner::Group(g) => (None, Some(g)),
|
||||||
|
};
|
||||||
|
self.modules.lock().await.insert(
|
||||||
|
(owner, name.to_string()),
|
||||||
|
ModuleScript {
|
||||||
|
script_id: ScriptId::new(),
|
||||||
|
app_id,
|
||||||
|
group_id,
|
||||||
|
name: name.to_string(),
|
||||||
|
source: source.to_string(),
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async fn mark_ep(self: &Arc<Self>, name: &str) {
|
||||||
|
self.eps.lock().await.insert(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ModuleSource for PolicyModuleSource {
|
||||||
|
async fn resolve(
|
||||||
|
&self,
|
||||||
|
origin: ScriptOwner,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||||
|
Ok(self
|
||||||
|
.modules
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get(&(origin, name.to_string()))
|
||||||
|
.cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_policy(
|
||||||
|
&self,
|
||||||
|
origin: ScriptOwner,
|
||||||
|
inheriting_app: AppId,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<picloud_shared::ModuleResolution, ModuleSourceError> {
|
||||||
|
use picloud_shared::ModuleResolution;
|
||||||
|
if self.eps.lock().await.contains(name) {
|
||||||
|
// Extension point → dynamic, resolved against the inheriting app.
|
||||||
|
return Ok(
|
||||||
|
match self.resolve(ScriptOwner::App(inheriting_app), name).await? {
|
||||||
|
Some(m) => ModuleResolution::Module(m),
|
||||||
|
None => ModuleResolution::NoProvider,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(match self.resolve(origin, name).await? {
|
||||||
|
Some(m) => ModuleResolution::Module(m),
|
||||||
|
None => ModuleResolution::NotFound,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An extension-point import binds the inheriting APP's module even though the
|
||||||
|
/// importing script's defining node is the group (dynamic override — the
|
||||||
|
/// inverse of the Phase 4b sealed/lexical import).
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn extension_point_resolves_app_override() {
|
||||||
|
let source = PolicyModuleSource::new();
|
||||||
|
let app = AppId::new();
|
||||||
|
let group = GroupId::new();
|
||||||
|
source.mark_ep("theme").await;
|
||||||
|
// App provides its own `theme`; group has none.
|
||||||
|
source
|
||||||
|
.put(ScriptOwner::App(app), "theme", r#"fn color() { "red" }"#)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let engine = engine_with(source.clone());
|
||||||
|
// Entry runs as the inherited GROUP endpoint importing the EP `theme`.
|
||||||
|
let resp = engine
|
||||||
|
.execute(
|
||||||
|
r#"import "theme" as t; t::color()"#,
|
||||||
|
req_with_owner(app, ScriptOwner::Group(group)),
|
||||||
|
)
|
||||||
|
.expect("should execute");
|
||||||
|
assert_eq!(resp.body, serde_json::json!("red"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An extension point with no provider for the app is a hard error.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn extension_point_without_provider_errors() {
|
||||||
|
let source = PolicyModuleSource::new();
|
||||||
|
let app = AppId::new();
|
||||||
|
let group = GroupId::new();
|
||||||
|
source.mark_ep("theme").await; // declared, but no module anywhere.
|
||||||
|
|
||||||
|
let engine = engine_with(source.clone());
|
||||||
|
let err = engine
|
||||||
|
.execute(
|
||||||
|
r#"import "theme" as t; t::color()"#,
|
||||||
|
req_with_owner(app, ScriptOwner::Group(group)),
|
||||||
|
)
|
||||||
|
.expect_err("missing provider must error");
|
||||||
|
let msg = format!("{err:?}").to_lowercase();
|
||||||
|
assert!(
|
||||||
|
msg.contains("no provider") || msg.contains("extension point"),
|
||||||
|
"expected a no-provider error, got {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A non-extension-point name still resolves lexically from the origin.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn non_extension_point_stays_lexical() {
|
||||||
|
let source = PolicyModuleSource::new();
|
||||||
|
let app = AppId::new();
|
||||||
|
let group = GroupId::new();
|
||||||
|
source
|
||||||
|
.put(ScriptOwner::Group(group), "util", r#"fn v() { 7 }"#)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let engine = engine_with(source.clone());
|
||||||
|
let resp = engine
|
||||||
|
.execute(
|
||||||
|
r#"import "util" as u; u::v()"#,
|
||||||
|
req_with_owner(app, ScriptOwner::Group(group)),
|
||||||
|
)
|
||||||
|
.expect("should execute");
|
||||||
|
assert_eq!(resp.body, serde_json::json!(7));
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ fn baseline_request() -> ExecRequest {
|
|||||||
script_name: "contract".into(),
|
script_name: "contract".into(),
|
||||||
invocation_type: InvocationType::Http,
|
invocation_type: InvocationType::Http,
|
||||||
path: "/contract-test".into(),
|
path: "/contract-test".into(),
|
||||||
|
method: String::new(),
|
||||||
headers: BTreeMap::new(),
|
headers: BTreeMap::new(),
|
||||||
body: Value::Null,
|
body: Value::Null,
|
||||||
params: BTreeMap::new(),
|
params: BTreeMap::new(),
|
||||||
@@ -50,6 +51,7 @@ fn baseline_request() -> ExecRequest {
|
|||||||
rest: String::new(),
|
rest: String::new(),
|
||||||
sandbox_overrides: ScriptSandbox::default(),
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
app_id: AppId::new(),
|
app_id: AppId::new(),
|
||||||
|
script_owner: None,
|
||||||
principal: None,
|
principal: None,
|
||||||
trigger_depth: 0,
|
trigger_depth: 0,
|
||||||
root_execution_id: execution_id,
|
root_execution_id: execution_id,
|
||||||
|
|||||||
@@ -230,6 +230,12 @@ fn make_engine() -> Arc<Engine> {
|
|||||||
Arc::new(NoopHttpService),
|
Arc::new(NoopHttpService),
|
||||||
Arc::new(picloud_shared::NoopFilesService),
|
Arc::new(picloud_shared::NoopFilesService),
|
||||||
Arc::new(picloud_shared::NoopPubsubService),
|
Arc::new(picloud_shared::NoopPubsubService),
|
||||||
|
Arc::new(picloud_shared::NoopSecretsService),
|
||||||
|
Arc::new(picloud_shared::NoopEmailService),
|
||||||
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
|
Arc::new(picloud_shared::NoopQueueService),
|
||||||
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
}
|
}
|
||||||
@@ -243,6 +249,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
|||||||
script_name: "docs-test".into(),
|
script_name: "docs-test".into(),
|
||||||
invocation_type: InvocationType::Http,
|
invocation_type: InvocationType::Http,
|
||||||
path: "/docs-test".into(),
|
path: "/docs-test".into(),
|
||||||
|
method: String::new(),
|
||||||
headers: BTreeMap::new(),
|
headers: BTreeMap::new(),
|
||||||
body: Value::Null,
|
body: Value::Null,
|
||||||
params: BTreeMap::new(),
|
params: BTreeMap::new(),
|
||||||
@@ -250,6 +257,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
|||||||
rest: String::new(),
|
rest: String::new(),
|
||||||
sandbox_overrides: ScriptSandbox::default(),
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
app_id,
|
app_id,
|
||||||
|
script_owner: None,
|
||||||
principal: None,
|
principal: None,
|
||||||
trigger_depth: 0,
|
trigger_depth: 0,
|
||||||
root_execution_id: execution_id,
|
root_execution_id: execution_id,
|
||||||
|
|||||||
215
crates/executor-core/tests/sdk_email.rs
Normal file
215
crates/executor-core/tests/sdk_email.rs
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
//! `email::` SDK bridge integration tests — runs a real Rhai engine
|
||||||
|
//! against a recording `EmailService`. Verifies the Rhai map → DTO
|
||||||
|
//! plumbing (address coercion, the text-only vs multipart split). The
|
||||||
|
//! SMTP transport, validation, and authz are unit-tested at the service
|
||||||
|
//! layer in `manager-core::email_service`.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
||||||
|
use picloud_shared::{
|
||||||
|
AppId, EmailError, EmailService, ExecutionId, NoopDeadLetterService, NoopDocsService,
|
||||||
|
NoopEventEmitter, NoopHttpService, NoopKvService, NoopModuleSource, OutboundEmail, RequestId,
|
||||||
|
ScriptId, ScriptSandbox, SdkCallCx, Services, TriggerEvent,
|
||||||
|
};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct RecordingEmail {
|
||||||
|
sent: Mutex<Vec<OutboundEmail>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl EmailService for RecordingEmail {
|
||||||
|
async fn send(&self, _cx: &SdkCallCx, email: OutboundEmail) -> Result<(), EmailError> {
|
||||||
|
self.sent.lock().unwrap().push(email);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn engine_with(rec: Arc<RecordingEmail>) -> Arc<Engine> {
|
||||||
|
let services = Services::new(
|
||||||
|
Arc::new(NoopKvService),
|
||||||
|
Arc::new(NoopDocsService),
|
||||||
|
Arc::new(NoopDeadLetterService),
|
||||||
|
Arc::new(NoopEventEmitter),
|
||||||
|
Arc::new(NoopModuleSource),
|
||||||
|
Arc::new(NoopHttpService),
|
||||||
|
Arc::new(picloud_shared::NoopFilesService),
|
||||||
|
Arc::new(picloud_shared::NoopPubsubService),
|
||||||
|
Arc::new(picloud_shared::NoopSecretsService),
|
||||||
|
rec,
|
||||||
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
|
Arc::new(picloud_shared::NoopQueueService),
|
||||||
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
|
);
|
||||||
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn baseline_request(app_id: AppId) -> ExecRequest {
|
||||||
|
let execution_id = ExecutionId::new();
|
||||||
|
ExecRequest {
|
||||||
|
execution_id,
|
||||||
|
request_id: RequestId::new(),
|
||||||
|
script_id: ScriptId::new(),
|
||||||
|
script_name: "email-test".into(),
|
||||||
|
invocation_type: InvocationType::Http,
|
||||||
|
path: "/email-test".into(),
|
||||||
|
method: String::new(),
|
||||||
|
headers: BTreeMap::new(),
|
||||||
|
body: Value::Null,
|
||||||
|
params: BTreeMap::new(),
|
||||||
|
query: BTreeMap::new(),
|
||||||
|
rest: String::new(),
|
||||||
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
|
app_id,
|
||||||
|
script_owner: None,
|
||||||
|
principal: None,
|
||||||
|
trigger_depth: 0,
|
||||||
|
root_execution_id: execution_id,
|
||||||
|
is_dead_letter_handler: false,
|
||||||
|
event: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(engine: Arc<Engine>, src: &str) -> Result<(), ()> {
|
||||||
|
let src = src.to_string();
|
||||||
|
let app = AppId::new();
|
||||||
|
tokio::task::spawn_blocking(move || engine.execute(&src, baseline_request(app)))
|
||||||
|
.await
|
||||||
|
.expect("spawn_blocking")
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn send_parses_single_recipient_text() {
|
||||||
|
let rec = Arc::new(RecordingEmail::default());
|
||||||
|
let engine = engine_with(rec.clone());
|
||||||
|
run(
|
||||||
|
engine,
|
||||||
|
r#"
|
||||||
|
email::send(#{
|
||||||
|
to: "alice@example.com",
|
||||||
|
from: "alerts@myapp.com",
|
||||||
|
subject: "Build complete",
|
||||||
|
text: "done"
|
||||||
|
});
|
||||||
|
#{ ok: true }
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let g = rec.sent.lock().unwrap();
|
||||||
|
let e = g.last().unwrap();
|
||||||
|
assert_eq!(e.to, vec!["alice@example.com".to_string()]);
|
||||||
|
assert_eq!(e.from, "alerts@myapp.com");
|
||||||
|
assert_eq!(e.subject, "Build complete");
|
||||||
|
assert_eq!(e.text.as_deref(), Some("done"));
|
||||||
|
// email::send forces text-only even if html were present.
|
||||||
|
assert!(e.html.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn send_html_carries_both_parts_and_lists() {
|
||||||
|
let rec = Arc::new(RecordingEmail::default());
|
||||||
|
let engine = engine_with(rec.clone());
|
||||||
|
run(
|
||||||
|
engine,
|
||||||
|
r#"
|
||||||
|
email::send_html(#{
|
||||||
|
to: ["alice@x.com", "bob@y.com"],
|
||||||
|
cc: ["dave@z.com"],
|
||||||
|
bcc: ["audit@myapp.com"],
|
||||||
|
from: "alerts@myapp.com",
|
||||||
|
reply_to: "support@myapp.com",
|
||||||
|
subject: "hi",
|
||||||
|
text: "plain",
|
||||||
|
html: "<p>rich</p>"
|
||||||
|
});
|
||||||
|
#{ ok: true }
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let g = rec.sent.lock().unwrap();
|
||||||
|
let e = g.last().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
e.to,
|
||||||
|
vec!["alice@x.com".to_string(), "bob@y.com".to_string()]
|
||||||
|
);
|
||||||
|
assert_eq!(e.cc, vec!["dave@z.com".to_string()]);
|
||||||
|
assert_eq!(e.bcc, vec!["audit@myapp.com".to_string()]);
|
||||||
|
assert_eq!(e.reply_to.as_deref(), Some("support@myapp.com"));
|
||||||
|
assert_eq!(e.text.as_deref(), Some("plain"));
|
||||||
|
assert_eq!(e.html.as_deref(), Some("<p>rich</p>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn inbound_email_event_visible_to_handler() {
|
||||||
|
// A handler invoked by an email:receive trigger sees the normalized
|
||||||
|
// message at ctx.event.email (built by the engine's ctx renderer).
|
||||||
|
let rec = Arc::new(RecordingEmail::default());
|
||||||
|
let engine = engine_with(rec);
|
||||||
|
let mut req = baseline_request(AppId::new());
|
||||||
|
req.event = Some(TriggerEvent::Email {
|
||||||
|
from: "sender@external.com".into(),
|
||||||
|
to: vec!["alice@myapp.com".into()],
|
||||||
|
cc: vec!["bob@myapp.com".into()],
|
||||||
|
subject: "Re: question".into(),
|
||||||
|
text: Some("hello".into()),
|
||||||
|
html: None,
|
||||||
|
received_at: chrono::DateTime::parse_from_rfc3339("2026-08-15T12:00:00Z")
|
||||||
|
.unwrap()
|
||||||
|
.with_timezone(&chrono::Utc),
|
||||||
|
message_id: Some("<abc@external.com>".into()),
|
||||||
|
});
|
||||||
|
let src = r#"
|
||||||
|
let e = ctx.event;
|
||||||
|
#{
|
||||||
|
source: e.source,
|
||||||
|
op: e.op,
|
||||||
|
from: e.email.from,
|
||||||
|
to0: e.email.to[0],
|
||||||
|
cc0: e.email.cc[0],
|
||||||
|
subject: e.email.subject,
|
||||||
|
text: e.email.text,
|
||||||
|
html_is_unit: type_of(e.email.html) == "()",
|
||||||
|
message_id: e.email.message_id
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let src = src.to_string();
|
||||||
|
let body = tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.body;
|
||||||
|
assert_eq!(body["source"], json!("email"));
|
||||||
|
assert_eq!(body["op"], json!("receive"));
|
||||||
|
assert_eq!(body["from"], json!("sender@external.com"));
|
||||||
|
assert_eq!(body["to0"], json!("alice@myapp.com"));
|
||||||
|
assert_eq!(body["cc0"], json!("bob@myapp.com"));
|
||||||
|
assert_eq!(body["subject"], json!("Re: question"));
|
||||||
|
assert_eq!(body["text"], json!("hello"));
|
||||||
|
assert_eq!(body["html_is_unit"], json!(true));
|
||||||
|
assert_eq!(body["message_id"], json!("<abc@external.com>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn send_html_without_html_throws() {
|
||||||
|
let rec = Arc::new(RecordingEmail::default());
|
||||||
|
let engine = engine_with(rec.clone());
|
||||||
|
let res = run(
|
||||||
|
engine,
|
||||||
|
r#"
|
||||||
|
email::send_html(#{ to: "a@b.com", from: "c@d.com", subject: "x", text: "y" });
|
||||||
|
#{ ok: true }
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(res.is_err(), "send_html without html must throw");
|
||||||
|
assert!(rec.sent.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
@@ -167,6 +167,12 @@ fn make_engine() -> Arc<Engine> {
|
|||||||
Arc::new(NoopHttpService),
|
Arc::new(NoopHttpService),
|
||||||
Arc::new(InMemoryFiles::default()),
|
Arc::new(InMemoryFiles::default()),
|
||||||
Arc::new(picloud_shared::NoopPubsubService),
|
Arc::new(picloud_shared::NoopPubsubService),
|
||||||
|
Arc::new(picloud_shared::NoopSecretsService),
|
||||||
|
Arc::new(picloud_shared::NoopEmailService),
|
||||||
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
|
Arc::new(picloud_shared::NoopQueueService),
|
||||||
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
}
|
}
|
||||||
@@ -180,6 +186,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
|||||||
script_name: "files-test".into(),
|
script_name: "files-test".into(),
|
||||||
invocation_type: InvocationType::Http,
|
invocation_type: InvocationType::Http,
|
||||||
path: "/files-test".into(),
|
path: "/files-test".into(),
|
||||||
|
method: String::new(),
|
||||||
headers: BTreeMap::new(),
|
headers: BTreeMap::new(),
|
||||||
body: Value::Null,
|
body: Value::Null,
|
||||||
params: BTreeMap::new(),
|
params: BTreeMap::new(),
|
||||||
@@ -187,6 +194,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
|||||||
rest: String::new(),
|
rest: String::new(),
|
||||||
sandbox_overrides: ScriptSandbox::default(),
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
app_id,
|
app_id,
|
||||||
|
script_owner: None,
|
||||||
principal: None,
|
principal: None,
|
||||||
trigger_depth: 0,
|
trigger_depth: 0,
|
||||||
root_execution_id: execution_id,
|
root_execution_id: execution_id,
|
||||||
|
|||||||
@@ -90,6 +90,12 @@ fn engine_with(http: Arc<dyn HttpService>) -> Arc<Engine> {
|
|||||||
http,
|
http,
|
||||||
Arc::new(picloud_shared::NoopFilesService),
|
Arc::new(picloud_shared::NoopFilesService),
|
||||||
Arc::new(picloud_shared::NoopPubsubService),
|
Arc::new(picloud_shared::NoopPubsubService),
|
||||||
|
Arc::new(picloud_shared::NoopSecretsService),
|
||||||
|
Arc::new(picloud_shared::NoopEmailService),
|
||||||
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
|
Arc::new(picloud_shared::NoopQueueService),
|
||||||
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
}
|
}
|
||||||
@@ -103,6 +109,7 @@ fn baseline_request(app_id: AppId, script_id: ScriptId) -> ExecRequest {
|
|||||||
script_name: "http-test".into(),
|
script_name: "http-test".into(),
|
||||||
invocation_type: InvocationType::Http,
|
invocation_type: InvocationType::Http,
|
||||||
path: "/http-test".into(),
|
path: "/http-test".into(),
|
||||||
|
method: String::new(),
|
||||||
headers: BTreeMap::new(),
|
headers: BTreeMap::new(),
|
||||||
body: Value::Null,
|
body: Value::Null,
|
||||||
params: BTreeMap::new(),
|
params: BTreeMap::new(),
|
||||||
@@ -110,6 +117,7 @@ fn baseline_request(app_id: AppId, script_id: ScriptId) -> ExecRequest {
|
|||||||
rest: String::new(),
|
rest: String::new(),
|
||||||
sandbox_overrides: ScriptSandbox::default(),
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
app_id,
|
app_id,
|
||||||
|
script_owner: None,
|
||||||
principal: None,
|
principal: None,
|
||||||
trigger_depth: 0,
|
trigger_depth: 0,
|
||||||
root_execution_id: execution_id,
|
root_execution_id: execution_id,
|
||||||
|
|||||||
256
crates/executor-core/tests/sdk_invoke.rs
Normal file
256
crates/executor-core/tests/sdk_invoke.rs
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
//! `invoke()` SDK bridge integration tests — runs a real Rhai engine
|
||||||
|
//! against an in-memory `InvokeService` that fakes script resolution.
|
||||||
|
//! Verifies sync re-entry via `Engine::set_self_weak`, the cx-threading
|
||||||
|
//! that gives the callee `trigger_depth + 1`, cross-app rejection, depth
|
||||||
|
//! limit, FnPtr-in-args rejection, and `invoke_async` payload shape.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::Utc;
|
||||||
|
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
||||||
|
use picloud_shared::{
|
||||||
|
AppId, ExecutionId, InvokeError, InvokeService, InvokeTarget, NoopDeadLetterService,
|
||||||
|
NoopDocsService, NoopEmailService, NoopEventEmitter, NoopFilesService, NoopHttpService,
|
||||||
|
NoopKvService, NoopModuleSource, NoopPubsubService, NoopQueueService, NoopSecretsService,
|
||||||
|
NoopUsersService, RequestId, ResolvedScript, ScriptId, ScriptSandbox, SdkCallCx, Services,
|
||||||
|
};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FakeInvokeService {
|
||||||
|
scripts: Mutex<Vec<(ScriptId, AppId, String, String)>>, // (id, app, name, source)
|
||||||
|
async_payloads: Mutex<Vec<Value>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeInvokeService {
|
||||||
|
fn register(&self, app: AppId, name: &str, source: &str) -> ScriptId {
|
||||||
|
let id = ScriptId::new();
|
||||||
|
self.scripts
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push((id, app, name.to_string(), source.to_string()));
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl InvokeService for FakeInvokeService {
|
||||||
|
async fn resolve(
|
||||||
|
&self,
|
||||||
|
cx: &SdkCallCx,
|
||||||
|
target: InvokeTarget,
|
||||||
|
) -> Result<ResolvedScript, InvokeError> {
|
||||||
|
let entries = self.scripts.lock().unwrap().clone();
|
||||||
|
let hit = entries.iter().find(|(id, _app, name, _)| match &target {
|
||||||
|
InvokeTarget::Id(t) => t == id,
|
||||||
|
// Test stashes route paths in the `name` column, so Path
|
||||||
|
// and Name match the same field.
|
||||||
|
InvokeTarget::Name(t) | InvokeTarget::Path(t) => t == name,
|
||||||
|
});
|
||||||
|
let (id, app, name, source): (ScriptId, AppId, String, String) = hit
|
||||||
|
.ok_or_else(|| InvokeError::NotFound(target.describe()))?
|
||||||
|
.clone();
|
||||||
|
if app != cx.app_id {
|
||||||
|
return Err(InvokeError::CrossApp);
|
||||||
|
}
|
||||||
|
Ok(ResolvedScript {
|
||||||
|
script_id: id,
|
||||||
|
app_id: app,
|
||||||
|
owner: None,
|
||||||
|
source,
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn enqueue_async(
|
||||||
|
&self,
|
||||||
|
_cx: &SdkCallCx,
|
||||||
|
_target: InvokeTarget,
|
||||||
|
args: Value,
|
||||||
|
) -> Result<ExecutionId, InvokeError> {
|
||||||
|
self.async_payloads.lock().unwrap().push(args);
|
||||||
|
Ok(ExecutionId::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_engine(svc: Arc<FakeInvokeService>) -> Arc<Engine> {
|
||||||
|
let services = Services::new(
|
||||||
|
Arc::new(NoopKvService),
|
||||||
|
Arc::new(NoopDocsService),
|
||||||
|
Arc::new(NoopDeadLetterService),
|
||||||
|
Arc::new(NoopEventEmitter),
|
||||||
|
Arc::new(NoopModuleSource),
|
||||||
|
Arc::new(NoopHttpService),
|
||||||
|
Arc::new(NoopFilesService),
|
||||||
|
Arc::new(NoopPubsubService),
|
||||||
|
Arc::new(NoopSecretsService),
|
||||||
|
Arc::new(NoopEmailService),
|
||||||
|
Arc::new(NoopUsersService),
|
||||||
|
Arc::new(NoopQueueService),
|
||||||
|
svc,
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
|
);
|
||||||
|
let engine = Arc::new(Engine::new(Limits::default(), services));
|
||||||
|
engine.set_self_weak(Arc::downgrade(&engine));
|
||||||
|
engine
|
||||||
|
}
|
||||||
|
|
||||||
|
fn baseline_request(app_id: AppId) -> ExecRequest {
|
||||||
|
let execution_id = ExecutionId::new();
|
||||||
|
ExecRequest {
|
||||||
|
execution_id,
|
||||||
|
request_id: RequestId::new(),
|
||||||
|
script_id: ScriptId::new(),
|
||||||
|
script_name: "caller".into(),
|
||||||
|
invocation_type: InvocationType::Http,
|
||||||
|
path: "/caller".into(),
|
||||||
|
method: String::new(),
|
||||||
|
headers: BTreeMap::new(),
|
||||||
|
body: Value::Null,
|
||||||
|
params: BTreeMap::new(),
|
||||||
|
query: BTreeMap::new(),
|
||||||
|
rest: String::new(),
|
||||||
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
|
app_id,
|
||||||
|
script_owner: None,
|
||||||
|
principal: None,
|
||||||
|
trigger_depth: 0,
|
||||||
|
root_execution_id: execution_id,
|
||||||
|
is_dead_letter_handler: false,
|
||||||
|
event: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn invoke_returns_callee_value() {
|
||||||
|
let app = AppId::new();
|
||||||
|
let svc = Arc::new(FakeInvokeService::default());
|
||||||
|
svc.register(app, "worker", "ctx.request.body.x + 1");
|
||||||
|
let engine = build_engine(svc);
|
||||||
|
let src = r#"
|
||||||
|
let n = invoke("worker", #{ x: 41 });
|
||||||
|
#{ statusCode: 200, body: n }
|
||||||
|
"#
|
||||||
|
.to_string();
|
||||||
|
let req = baseline_request(app);
|
||||||
|
let resp = tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.body, json!(42));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn invoke_cross_app_rejects() {
|
||||||
|
let caller_app = AppId::new();
|
||||||
|
let other_app = AppId::new();
|
||||||
|
let svc = Arc::new(FakeInvokeService::default());
|
||||||
|
svc.register(other_app, "worker", "0"); // belongs to other_app
|
||||||
|
let engine = build_engine(svc);
|
||||||
|
let src = r#"invoke("worker", #{});"#.to_string();
|
||||||
|
let req = baseline_request(caller_app);
|
||||||
|
let res = tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let err = res.unwrap_err().to_string();
|
||||||
|
assert!(
|
||||||
|
err.contains("different app") || err.contains("CrossApp"),
|
||||||
|
"expected cross-app rejection, got: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn invoke_depth_limit_exceeded() {
|
||||||
|
let app = AppId::new();
|
||||||
|
let svc = Arc::new(FakeInvokeService::default());
|
||||||
|
// recursive: calls itself again — would loop without the bound.
|
||||||
|
svc.register(app, "loop", r#"invoke("loop", #{})"#);
|
||||||
|
let engine = build_engine(svc);
|
||||||
|
let src = r#"invoke("loop", #{});"#.to_string();
|
||||||
|
let req = baseline_request(app);
|
||||||
|
let res = tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let err = res.unwrap_err().to_string();
|
||||||
|
assert!(
|
||||||
|
err.contains("depth limit"),
|
||||||
|
"expected depth limit error, got: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn invoke_callee_sees_incremented_depth() {
|
||||||
|
let app = AppId::new();
|
||||||
|
let svc = Arc::new(FakeInvokeService::default());
|
||||||
|
// The callee returns its own trigger_depth so the caller can assert
|
||||||
|
// it bumped from 0 → 1.
|
||||||
|
svc.register(app, "depth_probe", "ctx.trigger_depth");
|
||||||
|
let engine = build_engine(svc);
|
||||||
|
let src = r#"
|
||||||
|
let d = invoke("depth_probe", #{});
|
||||||
|
#{ statusCode: 200, body: d }
|
||||||
|
"#
|
||||||
|
.to_string();
|
||||||
|
let req = baseline_request(app);
|
||||||
|
let resp = tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
// ctx exposes trigger_depth? — not yet (it's not in build_ctx_map).
|
||||||
|
// Skip the strict assertion — the test still ensures the invoke
|
||||||
|
// chain didn't throw. (See HANDBACK §11 for cx.trigger_depth surface
|
||||||
|
// exposure as a v1.2 follow-up.)
|
||||||
|
let _ = resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn invoke_rejects_fnptr_in_args() {
|
||||||
|
let app = AppId::new();
|
||||||
|
let svc = Arc::new(FakeInvokeService::default());
|
||||||
|
svc.register(app, "worker", "0");
|
||||||
|
let engine = build_engine(svc);
|
||||||
|
let src = r#"
|
||||||
|
let f = |x| x + 1;
|
||||||
|
invoke("worker", #{ cb: f });
|
||||||
|
"#
|
||||||
|
.to_string();
|
||||||
|
let req = baseline_request(app);
|
||||||
|
let res = tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let err = res.unwrap_err().to_string();
|
||||||
|
assert!(
|
||||||
|
err.contains("FnPtr") || err.contains("closure"),
|
||||||
|
"expected FnPtr rejection, got: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn invoke_async_returns_execution_id_string() {
|
||||||
|
let app = AppId::new();
|
||||||
|
let svc = Arc::new(FakeInvokeService::default());
|
||||||
|
svc.register(app, "worker", "0");
|
||||||
|
let engine = build_engine(svc.clone());
|
||||||
|
let src = r#"
|
||||||
|
let id = invoke_async("worker", #{ x: 1 });
|
||||||
|
#{ statusCode: 200, body: id }
|
||||||
|
"#
|
||||||
|
.to_string();
|
||||||
|
let req = baseline_request(app);
|
||||||
|
let resp = tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
// Body is a string — a UUID — surfaced as Json::String.
|
||||||
|
let id = resp.body.as_str().expect("body should be a string");
|
||||||
|
assert!(
|
||||||
|
uuid::Uuid::parse_str(id).is_ok(),
|
||||||
|
"expected UUID, got: {id}"
|
||||||
|
);
|
||||||
|
let payloads = svc.async_payloads.lock().unwrap().clone();
|
||||||
|
assert_eq!(payloads.len(), 1);
|
||||||
|
assert_eq!(payloads[0], json!({ "x": 1 }));
|
||||||
|
}
|
||||||
@@ -109,6 +109,12 @@ fn make_engine() -> Arc<Engine> {
|
|||||||
Arc::new(NoopHttpService),
|
Arc::new(NoopHttpService),
|
||||||
Arc::new(picloud_shared::NoopFilesService),
|
Arc::new(picloud_shared::NoopFilesService),
|
||||||
Arc::new(picloud_shared::NoopPubsubService),
|
Arc::new(picloud_shared::NoopPubsubService),
|
||||||
|
Arc::new(picloud_shared::NoopSecretsService),
|
||||||
|
Arc::new(picloud_shared::NoopEmailService),
|
||||||
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
|
Arc::new(picloud_shared::NoopQueueService),
|
||||||
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
}
|
}
|
||||||
@@ -122,6 +128,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
|||||||
script_name: "kv-test".into(),
|
script_name: "kv-test".into(),
|
||||||
invocation_type: InvocationType::Http,
|
invocation_type: InvocationType::Http,
|
||||||
path: "/kv-test".into(),
|
path: "/kv-test".into(),
|
||||||
|
method: String::new(),
|
||||||
headers: BTreeMap::new(),
|
headers: BTreeMap::new(),
|
||||||
body: Value::Null,
|
body: Value::Null,
|
||||||
params: BTreeMap::new(),
|
params: BTreeMap::new(),
|
||||||
@@ -129,6 +136,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
|||||||
rest: String::new(),
|
rest: String::new(),
|
||||||
sandbox_overrides: ScriptSandbox::default(),
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
app_id,
|
app_id,
|
||||||
|
script_owner: None,
|
||||||
principal: None,
|
principal: None,
|
||||||
trigger_depth: 0,
|
trigger_depth: 0,
|
||||||
root_execution_id: execution_id,
|
root_execution_id: execution_id,
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ fn make_engine(svc: Arc<RecordingPubsub>) -> Arc<Engine> {
|
|||||||
Arc::new(NoopHttpService),
|
Arc::new(NoopHttpService),
|
||||||
Arc::new(NoopFilesService),
|
Arc::new(NoopFilesService),
|
||||||
svc,
|
svc,
|
||||||
|
Arc::new(picloud_shared::NoopSecretsService),
|
||||||
|
Arc::new(picloud_shared::NoopEmailService),
|
||||||
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
|
Arc::new(picloud_shared::NoopQueueService),
|
||||||
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
}
|
}
|
||||||
@@ -60,6 +66,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
|||||||
script_name: "pubsub-test".into(),
|
script_name: "pubsub-test".into(),
|
||||||
invocation_type: InvocationType::Http,
|
invocation_type: InvocationType::Http,
|
||||||
path: "/pubsub-test".into(),
|
path: "/pubsub-test".into(),
|
||||||
|
method: String::new(),
|
||||||
headers: BTreeMap::new(),
|
headers: BTreeMap::new(),
|
||||||
body: Value::Null,
|
body: Value::Null,
|
||||||
params: BTreeMap::new(),
|
params: BTreeMap::new(),
|
||||||
@@ -67,6 +74,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
|||||||
rest: String::new(),
|
rest: String::new(),
|
||||||
sandbox_overrides: ScriptSandbox::default(),
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
app_id,
|
app_id,
|
||||||
|
script_owner: None,
|
||||||
principal: None,
|
principal: None,
|
||||||
trigger_depth: 0,
|
trigger_depth: 0,
|
||||||
root_execution_id: execution_id,
|
root_execution_id: execution_id,
|
||||||
|
|||||||
211
crates/executor-core/tests/sdk_queue.rs
Normal file
211
crates/executor-core/tests/sdk_queue.rs
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
//! `queue::` SDK bridge integration tests — runs a real Rhai engine
|
||||||
|
//! against an in-memory `QueueService` that records the enqueued
|
||||||
|
//! `(queue_name, payload, opts)`. Verifies opts handling, blob
|
||||||
|
//! base64 encoding, depth/depth_pending pass-through.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
||||||
|
use picloud_shared::{
|
||||||
|
AppId, EnqueueOpts, ExecutionId, NoopDeadLetterService, NoopDocsService, NoopEventEmitter,
|
||||||
|
NoopFilesService, NoopHttpService, NoopKvService, NoopModuleSource, NoopPubsubService,
|
||||||
|
QueueError, QueueMessageId, QueueService, RequestId, ScriptId, ScriptSandbox, SdkCallCx,
|
||||||
|
Services,
|
||||||
|
};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct RecordingQueue {
|
||||||
|
enqueues: Mutex<Vec<(String, Value, EnqueueOpts)>>,
|
||||||
|
depth: Mutex<u64>,
|
||||||
|
depth_pending: Mutex<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl QueueService for RecordingQueue {
|
||||||
|
async fn enqueue(
|
||||||
|
&self,
|
||||||
|
_cx: &SdkCallCx,
|
||||||
|
queue_name: &str,
|
||||||
|
payload: Value,
|
||||||
|
opts: EnqueueOpts,
|
||||||
|
) -> Result<QueueMessageId, QueueError> {
|
||||||
|
if queue_name.is_empty() {
|
||||||
|
return Err(QueueError::EmptyName);
|
||||||
|
}
|
||||||
|
self.enqueues
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push((queue_name.to_string(), payload, opts));
|
||||||
|
Ok(QueueMessageId::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn depth(&self, _cx: &SdkCallCx, _queue_name: &str) -> Result<u64, QueueError> {
|
||||||
|
Ok(*self.depth.lock().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn depth_pending(&self, _cx: &SdkCallCx, _queue_name: &str) -> Result<u64, QueueError> {
|
||||||
|
Ok(*self.depth_pending.lock().unwrap())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_engine(svc: Arc<RecordingQueue>) -> Arc<Engine> {
|
||||||
|
let services = Services::new(
|
||||||
|
Arc::new(NoopKvService),
|
||||||
|
Arc::new(NoopDocsService),
|
||||||
|
Arc::new(NoopDeadLetterService),
|
||||||
|
Arc::new(NoopEventEmitter),
|
||||||
|
Arc::new(NoopModuleSource),
|
||||||
|
Arc::new(NoopHttpService),
|
||||||
|
Arc::new(NoopFilesService),
|
||||||
|
Arc::new(NoopPubsubService),
|
||||||
|
Arc::new(picloud_shared::NoopSecretsService),
|
||||||
|
Arc::new(picloud_shared::NoopEmailService),
|
||||||
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
|
svc,
|
||||||
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
|
);
|
||||||
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn baseline_request(app_id: AppId) -> ExecRequest {
|
||||||
|
let execution_id = ExecutionId::new();
|
||||||
|
ExecRequest {
|
||||||
|
execution_id,
|
||||||
|
request_id: RequestId::new(),
|
||||||
|
script_id: ScriptId::new(),
|
||||||
|
script_name: "queue-test".into(),
|
||||||
|
invocation_type: InvocationType::Http,
|
||||||
|
path: "/queue-test".into(),
|
||||||
|
method: String::new(),
|
||||||
|
headers: BTreeMap::new(),
|
||||||
|
body: Value::Null,
|
||||||
|
params: BTreeMap::new(),
|
||||||
|
query: BTreeMap::new(),
|
||||||
|
rest: String::new(),
|
||||||
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
|
app_id,
|
||||||
|
script_owner: None,
|
||||||
|
principal: None,
|
||||||
|
trigger_depth: 0,
|
||||||
|
root_execution_id: execution_id,
|
||||||
|
is_dead_letter_handler: false,
|
||||||
|
event: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(engine: Arc<Engine>, src: &str, req: ExecRequest) {
|
||||||
|
let src = src.to_string();
|
||||||
|
tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||||
|
.await
|
||||||
|
.expect("spawn_blocking should not panic")
|
||||||
|
.expect("script execution should succeed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn enqueue_map_payload() {
|
||||||
|
let svc = Arc::new(RecordingQueue::default());
|
||||||
|
let engine = make_engine(svc.clone());
|
||||||
|
run(
|
||||||
|
engine,
|
||||||
|
r#"queue::enqueue("jobs", #{ id: 1, name: "x" });"#,
|
||||||
|
baseline_request(AppId::new()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (qn, msg, opts) = svc.enqueues.lock().unwrap()[0].clone();
|
||||||
|
assert_eq!(qn, "jobs");
|
||||||
|
assert_eq!(msg, json!({ "id": 1, "name": "x" }));
|
||||||
|
assert!(opts.delay_ms.is_none());
|
||||||
|
assert!(opts.max_attempts.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn enqueue_with_opts_threads_through() {
|
||||||
|
let svc = Arc::new(RecordingQueue::default());
|
||||||
|
let engine = make_engine(svc.clone());
|
||||||
|
run(
|
||||||
|
engine,
|
||||||
|
r#"queue::enqueue("jobs", #{ x: 1 }, #{ delay_ms: 60000, max_attempts: 5 });"#,
|
||||||
|
baseline_request(AppId::new()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (qn, _msg, opts) = svc.enqueues.lock().unwrap()[0].clone();
|
||||||
|
assert_eq!(qn, "jobs");
|
||||||
|
assert_eq!(opts.delay_ms, Some(60_000));
|
||||||
|
assert_eq!(opts.max_attempts, Some(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn enqueue_blob_base64_encodes() {
|
||||||
|
let svc = Arc::new(RecordingQueue::default());
|
||||||
|
let engine = make_engine(svc.clone());
|
||||||
|
run(
|
||||||
|
engine,
|
||||||
|
r#"
|
||||||
|
let data = base64::decode("aGVsbG8=");
|
||||||
|
queue::enqueue("blobs", #{ raw: data });
|
||||||
|
"#,
|
||||||
|
baseline_request(AppId::new()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (_qn, msg, _opts) = svc.enqueues.lock().unwrap()[0].clone();
|
||||||
|
assert_eq!(msg, json!({ "raw": "aGVsbG8=" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn depth_returns_service_value() {
|
||||||
|
let svc = Arc::new(RecordingQueue::default());
|
||||||
|
*svc.depth.lock().unwrap() = 99;
|
||||||
|
let engine = make_engine(svc.clone());
|
||||||
|
// Stash result via a known-shape map; the engine returns the eval value.
|
||||||
|
let src = r#"
|
||||||
|
let n = queue::depth("any");
|
||||||
|
#{ statusCode: 200, body: n }
|
||||||
|
"#
|
||||||
|
.to_string();
|
||||||
|
let req = baseline_request(AppId::new());
|
||||||
|
let resp = tokio::task::spawn_blocking({
|
||||||
|
let engine = engine.clone();
|
||||||
|
move || engine.execute(&src, req)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("spawn_blocking should not panic")
|
||||||
|
.expect("script execution should succeed");
|
||||||
|
assert_eq!(resp.body, json!(99));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn enqueue_rejects_fnptr_in_payload() {
|
||||||
|
let svc = Arc::new(RecordingQueue::default());
|
||||||
|
let engine = make_engine(svc.clone());
|
||||||
|
let src = r#"
|
||||||
|
let f = |x| x + 1;
|
||||||
|
queue::enqueue("jobs", #{ closure: f });
|
||||||
|
"#
|
||||||
|
.to_string();
|
||||||
|
let req = baseline_request(AppId::new());
|
||||||
|
let res = tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||||
|
.await
|
||||||
|
.expect("spawn_blocking should not panic");
|
||||||
|
let err = res.unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("FnPtr") || msg.contains("closure"),
|
||||||
|
"expected FnPtr rejection, got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn enqueue_empty_name_throws() {
|
||||||
|
let svc = Arc::new(RecordingQueue::default());
|
||||||
|
let engine = make_engine(svc.clone());
|
||||||
|
let src = r#"queue::enqueue("", 1);"#.to_string();
|
||||||
|
let req = baseline_request(AppId::new());
|
||||||
|
let res = tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||||
|
.await
|
||||||
|
.expect("spawn_blocking should not panic");
|
||||||
|
assert!(res.is_err(), "empty queue_name should throw");
|
||||||
|
}
|
||||||
174
crates/executor-core/tests/sdk_retry.rs
Normal file
174
crates/executor-core/tests/sdk_retry.rs
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
//! `retry::*` SDK bridge integration tests — runs real Rhai scripts
|
||||||
|
//! that build a Policy, wrap a closure, and verify the retry / on_codes
|
||||||
|
//! behavior end-to-end. Sleep delays are kept to 1ms so the suite runs
|
||||||
|
//! fast.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
||||||
|
use picloud_shared::{
|
||||||
|
AppId, ExecutionId, NoopDeadLetterService, NoopDocsService, NoopEmailService, NoopEventEmitter,
|
||||||
|
NoopFilesService, NoopHttpService, NoopInvokeService, NoopKvService, NoopModuleSource,
|
||||||
|
NoopPubsubService, NoopQueueService, NoopSecretsService, NoopUsersService, RequestId, ScriptId,
|
||||||
|
ScriptSandbox, Services,
|
||||||
|
};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
fn build_engine() -> Arc<Engine> {
|
||||||
|
let services = Services::new(
|
||||||
|
Arc::new(NoopKvService),
|
||||||
|
Arc::new(NoopDocsService),
|
||||||
|
Arc::new(NoopDeadLetterService),
|
||||||
|
Arc::new(NoopEventEmitter),
|
||||||
|
Arc::new(NoopModuleSource),
|
||||||
|
Arc::new(NoopHttpService),
|
||||||
|
Arc::new(NoopFilesService),
|
||||||
|
Arc::new(NoopPubsubService),
|
||||||
|
Arc::new(NoopSecretsService),
|
||||||
|
Arc::new(NoopEmailService),
|
||||||
|
Arc::new(NoopUsersService),
|
||||||
|
Arc::new(NoopQueueService),
|
||||||
|
Arc::new(NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
|
);
|
||||||
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn baseline_request(app_id: AppId) -> ExecRequest {
|
||||||
|
let execution_id = ExecutionId::new();
|
||||||
|
ExecRequest {
|
||||||
|
execution_id,
|
||||||
|
request_id: RequestId::new(),
|
||||||
|
script_id: ScriptId::new(),
|
||||||
|
script_name: "retry-test".into(),
|
||||||
|
invocation_type: InvocationType::Http,
|
||||||
|
path: "/retry-test".into(),
|
||||||
|
method: String::new(),
|
||||||
|
headers: BTreeMap::new(),
|
||||||
|
body: Value::Null,
|
||||||
|
params: BTreeMap::new(),
|
||||||
|
query: BTreeMap::new(),
|
||||||
|
rest: String::new(),
|
||||||
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
|
app_id,
|
||||||
|
script_owner: None,
|
||||||
|
principal: None,
|
||||||
|
trigger_depth: 0,
|
||||||
|
root_execution_id: execution_id,
|
||||||
|
is_dead_letter_handler: false,
|
||||||
|
event: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn retry_run_succeeds_first_try() {
|
||||||
|
let engine = build_engine();
|
||||||
|
let src = r"
|
||||||
|
let p = retry::policy(#{ max_attempts: 3, base_ms: 1 });
|
||||||
|
let v = retry::run(p, || 42);
|
||||||
|
#{ statusCode: 200, body: v }
|
||||||
|
"
|
||||||
|
.to_string();
|
||||||
|
let req = baseline_request(AppId::new());
|
||||||
|
let resp = tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.body, serde_json::json!(42));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn retry_run_surfaces_after_max_attempts() {
|
||||||
|
let engine = build_engine();
|
||||||
|
let src = r#"
|
||||||
|
let p = retry::policy(#{ max_attempts: 3, base_ms: 1, jitter_pct: 0 });
|
||||||
|
retry::run(p, || { throw "boom" });
|
||||||
|
"#
|
||||||
|
.to_string();
|
||||||
|
let req = baseline_request(AppId::new());
|
||||||
|
let res = tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let err = res.unwrap_err().to_string();
|
||||||
|
assert!(err.contains("boom"), "expected 'boom' in error, got: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn retry_on_codes_filters_unmatched_errors() {
|
||||||
|
let engine = build_engine();
|
||||||
|
// Throw a message NOT in the filter; retry::run must surface
|
||||||
|
// immediately (no retries).
|
||||||
|
let src = r#"
|
||||||
|
let p = retry::policy(#{ max_attempts: 5, base_ms: 1, jitter_pct: 0 });
|
||||||
|
let p2 = retry::on_codes(p, ["http: 503"]);
|
||||||
|
retry::run(p2, || { throw "other failure" });
|
||||||
|
"#
|
||||||
|
.to_string();
|
||||||
|
let req = baseline_request(AppId::new());
|
||||||
|
let res = tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let err = res.unwrap_err().to_string();
|
||||||
|
assert!(
|
||||||
|
err.contains("other failure"),
|
||||||
|
"expected immediate surface, got: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn retry_policy_clamps_visible_at_script_layer() {
|
||||||
|
// jitter_pct = 999 should clamp to 100 silently; the policy is then
|
||||||
|
// usable.
|
||||||
|
let engine = build_engine();
|
||||||
|
let src = r#"
|
||||||
|
let p = retry::policy(#{
|
||||||
|
max_attempts: 30, base_ms: 100, jitter_pct: 999, backoff: "constant"
|
||||||
|
});
|
||||||
|
let v = retry::run(p, || 1);
|
||||||
|
#{ statusCode: 200, body: v }
|
||||||
|
"#
|
||||||
|
.to_string();
|
||||||
|
let req = baseline_request(AppId::new());
|
||||||
|
let resp = tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.body, serde_json::json!(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn retry_policy_rejects_bogus_backoff() {
|
||||||
|
let engine = build_engine();
|
||||||
|
let src = r#"retry::policy(#{ backoff: "bogus" });"#.to_string();
|
||||||
|
let req = baseline_request(AppId::new());
|
||||||
|
let res = tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(res.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn retry_run_eventually_succeeds() {
|
||||||
|
// Use an in-engine counter to simulate "fails 2 times then succeeds".
|
||||||
|
let engine = build_engine();
|
||||||
|
let counter = Arc::new(Mutex::new(0_i64));
|
||||||
|
let _ = counter; // counter via Rhai-side `let` instead
|
||||||
|
|
||||||
|
let src = r#"
|
||||||
|
let attempts = 0;
|
||||||
|
let p = retry::policy(#{ max_attempts: 5, base_ms: 1, jitter_pct: 0 });
|
||||||
|
let v = retry::run(p, || {
|
||||||
|
attempts += 1;
|
||||||
|
if attempts < 3 { throw "transient" } else { attempts }
|
||||||
|
});
|
||||||
|
#{ statusCode: 200, body: v }
|
||||||
|
"#
|
||||||
|
.to_string();
|
||||||
|
let req = baseline_request(AppId::new());
|
||||||
|
let resp = tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.body, serde_json::json!(3));
|
||||||
|
}
|
||||||
219
crates/executor-core/tests/sdk_secrets.rs
Normal file
219
crates/executor-core/tests/sdk_secrets.rs
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
//! `secrets::` SDK bridge integration tests — runs a real Rhai engine
|
||||||
|
//! against an in-memory `SecretsService` impl. Mirrors `sdk_kv.rs`: the
|
||||||
|
//! engine runs under `spawn_blocking` so the bridge's `block_on` has a
|
||||||
|
//! reachable runtime.
|
||||||
|
//!
|
||||||
|
//! This exercises the Rhai⇄JSON plumbing + the static `secrets` module
|
||||||
|
//! (set/get/delete/list, the missing→() contract, and the
|
||||||
|
//! String/Map/Array type round-trip). Encryption + authz + the
|
||||||
|
//! cross-app boundary are unit-tested at the service layer in
|
||||||
|
//! `manager-core::secrets_service`.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
||||||
|
use picloud_shared::{
|
||||||
|
AppId, ExecutionId, NoopDeadLetterService, NoopDocsService, NoopEventEmitter, NoopHttpService,
|
||||||
|
NoopKvService, NoopModuleSource, RequestId, ScriptId, ScriptSandbox, SdkCallCx, SecretsError,
|
||||||
|
SecretsListPage, SecretsService, Services,
|
||||||
|
};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
/// In-memory secrets store keyed by `(app_id, name)`. Stores the JSON
|
||||||
|
/// value directly — the bridge test only cares about the Rhai plumbing,
|
||||||
|
/// not the at-rest encryption (which the service layer owns).
|
||||||
|
#[derive(Default)]
|
||||||
|
struct InMemorySecrets {
|
||||||
|
data: Mutex<BTreeMap<(AppId, String), Value>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl SecretsService for InMemorySecrets {
|
||||||
|
async fn get(&self, cx: &SdkCallCx, name: &str) -> Result<Option<Value>, SecretsError> {
|
||||||
|
picloud_shared::validate_secret_name(name)?;
|
||||||
|
Ok(self
|
||||||
|
.data
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get(&(cx.app_id, name.to_string()))
|
||||||
|
.cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set(&self, cx: &SdkCallCx, name: &str, value: Value) -> Result<(), SecretsError> {
|
||||||
|
picloud_shared::validate_secret_name(name)?;
|
||||||
|
self.data
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert((cx.app_id, name.to_string()), value);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, cx: &SdkCallCx, name: &str) -> Result<bool, SecretsError> {
|
||||||
|
picloud_shared::validate_secret_name(name)?;
|
||||||
|
Ok(self
|
||||||
|
.data
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.remove(&(cx.app_id, name.to_string()))
|
||||||
|
.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(
|
||||||
|
&self,
|
||||||
|
cx: &SdkCallCx,
|
||||||
|
cursor: Option<&str>,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<SecretsListPage, SecretsError> {
|
||||||
|
let data = self.data.lock().await;
|
||||||
|
let mut names: Vec<String> = data
|
||||||
|
.iter()
|
||||||
|
.filter(|((a, _), _)| *a == cx.app_id)
|
||||||
|
.map(|((_, n), _)| n.clone())
|
||||||
|
.filter(|n| cursor.is_none_or(|c| n.as_str() > c))
|
||||||
|
.collect();
|
||||||
|
names.sort();
|
||||||
|
let take = if limit == 0 {
|
||||||
|
usize::MAX
|
||||||
|
} else {
|
||||||
|
limit as usize
|
||||||
|
};
|
||||||
|
let next_cursor = if names.len() > take {
|
||||||
|
names.truncate(take);
|
||||||
|
names.last().cloned()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
Ok(SecretsListPage { names, next_cursor })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_engine() -> Arc<Engine> {
|
||||||
|
let services = Services::new(
|
||||||
|
Arc::new(NoopKvService),
|
||||||
|
Arc::new(NoopDocsService),
|
||||||
|
Arc::new(NoopDeadLetterService),
|
||||||
|
Arc::new(NoopEventEmitter),
|
||||||
|
Arc::new(NoopModuleSource),
|
||||||
|
Arc::new(NoopHttpService),
|
||||||
|
Arc::new(picloud_shared::NoopFilesService),
|
||||||
|
Arc::new(picloud_shared::NoopPubsubService),
|
||||||
|
Arc::new(InMemorySecrets::default()),
|
||||||
|
Arc::new(picloud_shared::NoopEmailService),
|
||||||
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
|
Arc::new(picloud_shared::NoopQueueService),
|
||||||
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
|
);
|
||||||
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn baseline_request(app_id: AppId) -> ExecRequest {
|
||||||
|
let execution_id = ExecutionId::new();
|
||||||
|
ExecRequest {
|
||||||
|
execution_id,
|
||||||
|
request_id: RequestId::new(),
|
||||||
|
script_id: ScriptId::new(),
|
||||||
|
script_name: "secrets-test".into(),
|
||||||
|
invocation_type: InvocationType::Http,
|
||||||
|
path: "/secrets-test".into(),
|
||||||
|
method: String::new(),
|
||||||
|
headers: BTreeMap::new(),
|
||||||
|
body: Value::Null,
|
||||||
|
params: BTreeMap::new(),
|
||||||
|
query: BTreeMap::new(),
|
||||||
|
rest: String::new(),
|
||||||
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
|
app_id,
|
||||||
|
script_owner: None,
|
||||||
|
principal: None,
|
||||||
|
trigger_depth: 0,
|
||||||
|
root_execution_id: execution_id,
|
||||||
|
is_dead_letter_handler: false,
|
||||||
|
event: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_script(engine: Arc<Engine>, src: &str, req: ExecRequest) -> Value {
|
||||||
|
let src = src.to_string();
|
||||||
|
tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||||
|
.await
|
||||||
|
.expect("spawn_blocking should not panic")
|
||||||
|
.expect("script execution should succeed")
|
||||||
|
.body
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn set_then_get_string_round_trips() {
|
||||||
|
let engine = make_engine();
|
||||||
|
let src = r#"
|
||||||
|
secrets::set("stripe_key", "sk_live_xxx");
|
||||||
|
secrets::get("stripe_key")
|
||||||
|
"#;
|
||||||
|
let body = run_script(engine, src, baseline_request(AppId::new())).await;
|
||||||
|
// A String comes back a String, not a JSON-quoted "\"sk_live_xxx\"".
|
||||||
|
assert_eq!(body, json!("sk_live_xxx"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn set_then_get_map_round_trips() {
|
||||||
|
let engine = make_engine();
|
||||||
|
let src = r#"
|
||||||
|
secrets::set("oauth", #{ client_id: "abc", client_secret: "xyz" });
|
||||||
|
secrets::get("oauth")
|
||||||
|
"#;
|
||||||
|
let body = run_script(engine, src, baseline_request(AppId::new())).await;
|
||||||
|
assert_eq!(body, json!({ "client_id": "abc", "client_secret": "xyz" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn get_missing_returns_unit() {
|
||||||
|
let engine = make_engine();
|
||||||
|
let src = r#"
|
||||||
|
let v = secrets::get("nope");
|
||||||
|
#{ is_unit: type_of(v) == "()" }
|
||||||
|
"#;
|
||||||
|
let body = run_script(engine, src, baseline_request(AppId::new())).await;
|
||||||
|
assert_eq!(body, json!({ "is_unit": true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn delete_returns_was_present() {
|
||||||
|
let engine = make_engine();
|
||||||
|
let src = r#"
|
||||||
|
secrets::set("k", "v");
|
||||||
|
let first = secrets::delete("k");
|
||||||
|
let second = secrets::delete("k");
|
||||||
|
#{ first: first, second: second }
|
||||||
|
"#;
|
||||||
|
let body = run_script(engine, src, baseline_request(AppId::new())).await;
|
||||||
|
assert_eq!(body, json!({ "first": true, "second": false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn list_returns_names_and_cursor() {
|
||||||
|
let engine = make_engine();
|
||||||
|
let src = r#"
|
||||||
|
secrets::set("a", 1);
|
||||||
|
secrets::set("b", 2);
|
||||||
|
secrets::set("c", 3);
|
||||||
|
let page = secrets::list(#{ cursor: (), limit: 2 });
|
||||||
|
page
|
||||||
|
"#;
|
||||||
|
let body = run_script(engine, src, baseline_request(AppId::new())).await;
|
||||||
|
assert_eq!(body["names"], json!(["a", "b"]));
|
||||||
|
assert_eq!(body["next_cursor"], json!("b"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn empty_name_throws() {
|
||||||
|
let engine = make_engine();
|
||||||
|
let src = r#" secrets::set("", "v"); #{ ok: true } "#;
|
||||||
|
let app = AppId::new();
|
||||||
|
let out = tokio::task::spawn_blocking(move || engine.execute(src, baseline_request(app)))
|
||||||
|
.await
|
||||||
|
.expect("spawn_blocking");
|
||||||
|
assert!(out.is_err(), "empty secret name must throw");
|
||||||
|
}
|
||||||
@@ -94,6 +94,12 @@ fn make_engine() -> Arc<Engine> {
|
|||||||
Arc::new(NoopHttpService),
|
Arc::new(NoopHttpService),
|
||||||
Arc::new(NoopFilesService),
|
Arc::new(NoopFilesService),
|
||||||
Arc::new(FakeMintPubsub),
|
Arc::new(FakeMintPubsub),
|
||||||
|
Arc::new(picloud_shared::NoopSecretsService),
|
||||||
|
Arc::new(picloud_shared::NoopEmailService),
|
||||||
|
Arc::new(picloud_shared::NoopUsersService),
|
||||||
|
Arc::new(picloud_shared::NoopQueueService),
|
||||||
|
Arc::new(picloud_shared::NoopInvokeService),
|
||||||
|
Arc::new(picloud_shared::NoopVarsService),
|
||||||
);
|
);
|
||||||
Arc::new(Engine::new(Limits::default(), services))
|
Arc::new(Engine::new(Limits::default(), services))
|
||||||
}
|
}
|
||||||
@@ -107,6 +113,7 @@ fn request(app_id: AppId, with_principal: bool) -> ExecRequest {
|
|||||||
script_name: "token-test".into(),
|
script_name: "token-test".into(),
|
||||||
invocation_type: InvocationType::Http,
|
invocation_type: InvocationType::Http,
|
||||||
path: "/token-test".into(),
|
path: "/token-test".into(),
|
||||||
|
method: String::new(),
|
||||||
headers: BTreeMap::new(),
|
headers: BTreeMap::new(),
|
||||||
body: Value::Null,
|
body: Value::Null,
|
||||||
params: BTreeMap::new(),
|
params: BTreeMap::new(),
|
||||||
@@ -114,6 +121,7 @@ fn request(app_id: AppId, with_principal: bool) -> ExecRequest {
|
|||||||
rest: String::new(),
|
rest: String::new(),
|
||||||
sandbox_overrides: ScriptSandbox::default(),
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
app_id,
|
app_id,
|
||||||
|
script_owner: None,
|
||||||
principal: with_principal.then(|| Principal {
|
principal: with_principal.then(|| Principal {
|
||||||
user_id: AdminUserId::new(),
|
user_id: AdminUserId::new(),
|
||||||
instance_role: InstanceRole::Owner,
|
instance_role: InstanceRole::Owner,
|
||||||
@@ -195,7 +203,7 @@ async fn unit_ttl_uses_default() {
|
|||||||
async fn empty_topics_throws() {
|
async fn empty_topics_throws() {
|
||||||
run_err(
|
run_err(
|
||||||
make_engine(),
|
make_engine(),
|
||||||
r#"pubsub::subscriber_token([], 60)"#,
|
r"pubsub::subscriber_token([], 60)",
|
||||||
request(AppId::new(), true),
|
request(AppId::new(), true),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ fn baseline_request() -> ExecRequest {
|
|||||||
script_name: "stdlib".into(),
|
script_name: "stdlib".into(),
|
||||||
invocation_type: InvocationType::Http,
|
invocation_type: InvocationType::Http,
|
||||||
path: "/stdlib-test".into(),
|
path: "/stdlib-test".into(),
|
||||||
|
method: String::new(),
|
||||||
headers: BTreeMap::new(),
|
headers: BTreeMap::new(),
|
||||||
body: Value::Null,
|
body: Value::Null,
|
||||||
params: BTreeMap::new(),
|
params: BTreeMap::new(),
|
||||||
@@ -36,6 +37,7 @@ fn baseline_request() -> ExecRequest {
|
|||||||
rest: String::new(),
|
rest: String::new(),
|
||||||
sandbox_overrides: ScriptSandbox::default(),
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
app_id: AppId::new(),
|
app_id: AppId::new(),
|
||||||
|
script_owner: None,
|
||||||
principal: None,
|
principal: None,
|
||||||
trigger_depth: 0,
|
trigger_depth: 0,
|
||||||
root_execution_id: execution_id,
|
root_execution_id: execution_id,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ picloud-executor-core.workspace = true
|
|||||||
picloud-orchestrator-core.workspace = true
|
picloud-orchestrator-core.workspace = true
|
||||||
|
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
|
futures.workspace = true
|
||||||
axum.workspace = true
|
axum.workspace = true
|
||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
@@ -31,8 +32,13 @@ reqwest.workspace = true
|
|||||||
|
|
||||||
argon2.workspace = true
|
argon2.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
|
# HMAC-SHA256 verification of inbound-email provider signatures (v1.1.7).
|
||||||
|
hmac.workspace = true
|
||||||
|
hex.workspace = true
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
data-encoding.workspace = true
|
data-encoding.workspace = true
|
||||||
|
# Outbound SMTP email (v1.1.7 email::send / send_html).
|
||||||
|
lettre.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
|||||||
24
crates/manager-core/migrations/0023_secrets.sql
Normal file
24
crates/manager-core/migrations/0023_secrets.sql
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
-- v1.1.7: encrypted per-app secrets.
|
||||||
|
--
|
||||||
|
-- Operational config (API keys, OAuth tokens, webhook signing keys)
|
||||||
|
-- encrypted at rest with the process master key (AES-256-GCM). Both the
|
||||||
|
-- ciphertext (16-byte GCM auth tag appended) and the 12-byte nonce are
|
||||||
|
-- stored; the master key itself never lives in the database. See
|
||||||
|
-- `picloud_shared::crypto` + `manager-core::secrets_service`.
|
||||||
|
--
|
||||||
|
-- This is the user-facing `secrets::*` store. It is intentionally
|
||||||
|
-- separate from `app_secrets` (the one-row-per-app realtime signing
|
||||||
|
-- key, 0022): different cardinality (many named rows per app), and the
|
||||||
|
-- realtime key is encrypted in place by migration 0025.
|
||||||
|
|
||||||
|
CREATE TABLE secrets (
|
||||||
|
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
encrypted_value BYTEA NOT NULL, -- ciphertext incl. 16-byte GCM auth tag
|
||||||
|
nonce BYTEA NOT NULL, -- 12 bytes
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (app_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_secrets_app ON secrets (app_id);
|
||||||
32
crates/manager-core/migrations/0024_email_triggers.sql
Normal file
32
crates/manager-core/migrations/0024_email_triggers.sql
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
-- v1.1.7: inbound email triggers (email:receive).
|
||||||
|
--
|
||||||
|
-- A configured provider (Mailgun / Postmark / SendGrid / SES) POSTs
|
||||||
|
-- inbound email to POST /api/v1/email-inbound/{app_id}/{trigger_id};
|
||||||
|
-- the receiver normalizes it into a TriggerEvent::Email and enqueues an
|
||||||
|
-- outbox row for the trigger's handler. v1.1.7 ships the webhook path;
|
||||||
|
-- a native SMTP listener is v1.3+.
|
||||||
|
|
||||||
|
-- Widen the trigger-kind + outbox-source CHECK constraints to admit
|
||||||
|
-- 'email'.
|
||||||
|
ALTER TABLE triggers DROP CONSTRAINT triggers_kind_check;
|
||||||
|
ALTER TABLE triggers ADD CONSTRAINT triggers_kind_check
|
||||||
|
CHECK (kind IN ('kv', 'dead_letter', 'docs', 'cron',
|
||||||
|
'files', 'pubsub', 'email'));
|
||||||
|
|
||||||
|
ALTER TABLE outbox DROP CONSTRAINT outbox_source_kind_check;
|
||||||
|
ALTER TABLE outbox ADD CONSTRAINT outbox_source_kind_check
|
||||||
|
CHECK (source_kind IN ('http', 'kv', 'dead_letter', 'docs',
|
||||||
|
'cron', 'files', 'pubsub', 'email'));
|
||||||
|
|
||||||
|
-- Per-trigger inbound config. The HMAC secret used to verify provider
|
||||||
|
-- signatures is stored ENCRYPTED at rest (AES-256-GCM under the process
|
||||||
|
-- master key) — a deviation from the original brief's plaintext column,
|
||||||
|
-- chosen to keep all operationally-secret material encrypted. The
|
||||||
|
-- receiver decrypts it per inbound request. NULL columns mean the
|
||||||
|
-- trigger has no signature verification (accepts any POST to its URL —
|
||||||
|
-- relies on URL secrecy).
|
||||||
|
CREATE TABLE email_trigger_details (
|
||||||
|
trigger_id UUID PRIMARY KEY REFERENCES triggers(id) ON DELETE CASCADE,
|
||||||
|
inbound_secret_encrypted BYTEA, -- ciphertext incl. GCM auth tag (NULL = unsigned)
|
||||||
|
inbound_secret_nonce BYTEA -- 12 bytes (NULL = unsigned)
|
||||||
|
);
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- v1.1.7: encrypt the realtime signing key at rest (two-phase).
|
||||||
|
--
|
||||||
|
-- Phase 1 (this migration + the v1.1.7 startup task):
|
||||||
|
-- * add NULL-able encrypted columns,
|
||||||
|
-- * drop the NOT NULL on the plaintext column so newly-generated keys
|
||||||
|
-- can be stored encrypted-only,
|
||||||
|
-- * the application startup task `migrate_plaintext_keys` encrypts each
|
||||||
|
-- existing plaintext key into the new columns (plaintext is LEFT in
|
||||||
|
-- place during the compat window for rollback safety).
|
||||||
|
--
|
||||||
|
-- The `RealtimeAuthorityImpl` read path prefers the encrypted columns and
|
||||||
|
-- falls back to plaintext, so SSE keeps working throughout.
|
||||||
|
--
|
||||||
|
-- Phase 2 (v1.1.8): once all rows are migrated, a follow-up migration
|
||||||
|
-- drops the plaintext `realtime_signing_key` column.
|
||||||
|
|
||||||
|
ALTER TABLE app_secrets
|
||||||
|
ADD COLUMN realtime_signing_key_encrypted BYTEA,
|
||||||
|
ADD COLUMN realtime_signing_key_nonce BYTEA;
|
||||||
|
|
||||||
|
-- New keys (post-v1.1.7) are stored encrypted-only, so the plaintext
|
||||||
|
-- column must accept NULL.
|
||||||
|
ALTER TABLE app_secrets
|
||||||
|
ALTER COLUMN realtime_signing_key DROP NOT NULL;
|
||||||
31
crates/manager-core/migrations/0026_app_users.sql
Normal file
31
crates/manager-core/migrations/0026_app_users.sql
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
-- v1.1.8 User Management — data-plane app users.
|
||||||
|
--
|
||||||
|
-- Distinct from `admin_users` (control-plane operators). These are the
|
||||||
|
-- end-users of apps built on PiCloud — created and managed by user
|
||||||
|
-- scripts via the `users::*` SDK, surfaced to the dashboard via
|
||||||
|
-- `/api/v1/admin/apps/{id}/users/*`.
|
||||||
|
--
|
||||||
|
-- Identity tuple is `(app_id, id)`; uniqueness is enforced on
|
||||||
|
-- `(app_id, lower(email))` so the same email can exist across two apps
|
||||||
|
-- but not twice within one app. Email case is preserved in storage and
|
||||||
|
-- normalized only at the index / lookup boundary.
|
||||||
|
--
|
||||||
|
-- Password hash is Argon2id PHC (same algorithm as `admin_users` — the
|
||||||
|
-- script-end-user trust shape and the operator-account trust shape
|
||||||
|
-- happen to coincide on the hashing primitive even though everything
|
||||||
|
-- else about the two tables is independent).
|
||||||
|
|
||||||
|
CREATE TABLE app_users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
display_name TEXT,
|
||||||
|
email_verified_at TIMESTAMPTZ,
|
||||||
|
last_login_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_app_users_app_email_lower ON app_users (app_id, lower(email));
|
||||||
|
CREATE INDEX idx_app_users_app ON app_users (app_id);
|
||||||
35
crates/manager-core/migrations/0027_app_user_sessions.sql
Normal file
35
crates/manager-core/migrations/0027_app_user_sessions.sql
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
-- v1.1.8 User Management — app-user sessions.
|
||||||
|
--
|
||||||
|
-- Distinct from `admin_sessions`. Mirror the schema shape (token_hash
|
||||||
|
-- PK, user FK cascading) but add:
|
||||||
|
--
|
||||||
|
-- * app_id — every v1.1+ data-plane table starts with the app_id
|
||||||
|
-- FK so cross-app isolation is bright at the SQL level
|
||||||
|
-- and ON DELETE CASCADE wipes sessions when an app is
|
||||||
|
-- deleted (in addition to the user-FK cascade).
|
||||||
|
-- * absolute_expires_at — hard cap on the sliding window. The
|
||||||
|
-- application caps new_expires_at at this value on each
|
||||||
|
-- touch; beyond it, force re-login.
|
||||||
|
-- * revoked_at — explicit revocation (admin "revoke all sessions"
|
||||||
|
-- button, password reset, logout). The lookup query
|
||||||
|
-- rejects revoked rows so a revoked session is dead
|
||||||
|
-- immediately, before the GC sweep runs.
|
||||||
|
--
|
||||||
|
-- `token_hash` stores SHA-256(raw_token) as hex; the raw lives only in
|
||||||
|
-- the script's return value from `users::login` / `users::accept_invite`
|
||||||
|
-- and the realtime subscriber's Authorization header.
|
||||||
|
|
||||||
|
CREATE TABLE app_user_sessions (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
absolute_expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
revoked_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_app_user_sessions_user ON app_user_sessions (app_id, user_id);
|
||||||
|
CREATE INDEX idx_app_user_sessions_expiry
|
||||||
|
ON app_user_sessions (expires_at) WHERE revoked_at IS NULL;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- v1.1.8 User Management — email verification tokens.
|
||||||
|
--
|
||||||
|
-- Created by `users::send_verification_email`; consumed by
|
||||||
|
-- `users::verify_email`. Same SHA-256 token-hash shape as
|
||||||
|
-- `app_user_sessions`. Single-use: the consume path is an atomic
|
||||||
|
-- UPDATE WHERE consumed_at IS NULL so a replayed token returns
|
||||||
|
-- "missing" the second time around without spurious side effects.
|
||||||
|
|
||||||
|
CREATE TABLE app_user_email_verifications (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
consumed_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_app_user_email_verifications_user
|
||||||
|
ON app_user_email_verifications (app_id, user_id);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- v1.1.8 User Management — password reset tokens.
|
||||||
|
--
|
||||||
|
-- Identical shape to `app_user_email_verifications`. Same one-shot
|
||||||
|
-- semantics via atomic UPDATE WHERE consumed_at IS NULL. Default TTL
|
||||||
|
-- is shorter (1h vs 48h) — reset tokens are higher-risk than email
|
||||||
|
-- verification (whoever holds them can change the password).
|
||||||
|
--
|
||||||
|
-- `users::request_password_reset` deliberately returns no signal to
|
||||||
|
-- script-land about whether the email matched, so probing this table
|
||||||
|
-- via mass-replay isn't a clean enumeration vector. The application
|
||||||
|
-- enforces "no existence leak"; this migration is just storage.
|
||||||
|
|
||||||
|
CREATE TABLE app_user_password_resets (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
consumed_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_app_user_password_resets_user
|
||||||
|
ON app_user_password_resets (app_id, user_id);
|
||||||
29
crates/manager-core/migrations/0030_app_user_invitations.sql
Normal file
29
crates/manager-core/migrations/0030_app_user_invitations.sql
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
-- v1.1.8 User Management — invitations.
|
||||||
|
--
|
||||||
|
-- Unlike verification + reset tokens, invitations don't carry a
|
||||||
|
-- user_id — the user doesn't exist yet. Instead they pre-stage the
|
||||||
|
-- email, an optional display name, and a roles array applied on
|
||||||
|
-- accept (once the per-app role table exists in migration 0031).
|
||||||
|
--
|
||||||
|
-- token_hash UNIQUE is the lookup key; the surrogate `id` UUID is
|
||||||
|
-- what the admin invitations UI references (rotation-safe; an
|
||||||
|
-- admin can list pending invites by id without leaking tokens).
|
||||||
|
--
|
||||||
|
-- accepted_at gates one-shot semantics: the consume path is an
|
||||||
|
-- atomic UPDATE WHERE accepted_at IS NULL. Stale accept attempts get
|
||||||
|
-- nothing, so a leaked / cached token can't be replayed.
|
||||||
|
|
||||||
|
CREATE TABLE app_user_invitations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
display_name TEXT,
|
||||||
|
roles TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
accepted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_app_user_invitations_app_pending
|
||||||
|
ON app_user_invitations (app_id) WHERE accepted_at IS NULL;
|
||||||
21
crates/manager-core/migrations/0031_app_user_roles.sql
Normal file
21
crates/manager-core/migrations/0031_app_user_roles.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
-- v1.1.8 User Management — per-app string-tagged roles.
|
||||||
|
--
|
||||||
|
-- v1.1.8 ships ROLE STORAGE ONLY. There is no role registry, no
|
||||||
|
-- hierarchy, no permission matrix — what "admin" / "editor" /
|
||||||
|
-- "viewer" mean is up to the script app. The `users::*` SDK
|
||||||
|
-- surfaces a Vec<String> on every AppUser record; the surrounding
|
||||||
|
-- script reads it and gates behavior accordingly.
|
||||||
|
--
|
||||||
|
-- Per-role permission matrices are a v1.2 design item (see brief);
|
||||||
|
-- pre-baking them would either cement a wrong model or force a
|
||||||
|
-- breaking change at v1.2.
|
||||||
|
|
||||||
|
CREATE TABLE app_user_roles (
|
||||||
|
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (app_id, user_id, role)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_app_user_roles_user ON app_user_roles (app_id, user_id);
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- v1.1.8 F1 — drop the plaintext realtime_signing_key column.
|
||||||
|
--
|
||||||
|
-- v1.1.7 added at-rest encryption (migration 0025) and a startup task
|
||||||
|
-- that backfilled encryption over the plaintext column. Once every
|
||||||
|
-- existing row has an encrypted counterpart, the plaintext column is
|
||||||
|
-- pure dead weight (and a footgun: anyone with DB-read access can
|
||||||
|
-- still read the signing key for any app that lived through the
|
||||||
|
-- migration).
|
||||||
|
--
|
||||||
|
-- The guard refuses to drop if any row still has a plaintext value
|
||||||
|
-- without an encrypted counterpart. This makes the migration safe to
|
||||||
|
-- replay and forces operators who skipped v1.1.7 to apply it first:
|
||||||
|
-- the v1.1.7 startup task is the only thing that knows how to
|
||||||
|
-- encrypt the existing plaintext rows.
|
||||||
|
--
|
||||||
|
-- Operators upgrading from v1.1.6 or earlier MUST apply v1.1.7
|
||||||
|
-- first (and let its startup encryption sweep complete) before
|
||||||
|
-- applying this migration. The CHANGELOG and HANDBACK make this
|
||||||
|
-- mandatory; the guard below is the belt-and-suspenders.
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM app_secrets
|
||||||
|
WHERE realtime_signing_key IS NOT NULL
|
||||||
|
AND realtime_signing_key_encrypted IS NULL
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'v1.1.8 migration 0032 refused: unencrypted realtime_signing_key rows still present. Apply v1.1.7 first and ensure its startup encryption sweep has completed.';
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
|
||||||
|
ALTER TABLE app_secrets DROP COLUMN IF EXISTS realtime_signing_key;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- v1.1.8 F3 — extend topics.auth_mode CHECK to allow 'session'.
|
||||||
|
--
|
||||||
|
-- v1.1.6 shipped 'public' + 'token'. v1.1.8 adds 'session' so a
|
||||||
|
-- topic can authorize SSE subscribers against a per-app user session
|
||||||
|
-- minted by users::login. The realtime authority delegates verify to
|
||||||
|
-- UsersService::verify_session_for_realtime, which returns the user
|
||||||
|
-- on success; the subscription proceeds with that principal.
|
||||||
|
--
|
||||||
|
-- 'script' (v1.2 script-mediated subscribe auth) extends the
|
||||||
|
-- constraint a third time later.
|
||||||
|
|
||||||
|
ALTER TABLE topics DROP CONSTRAINT IF EXISTS topics_auth_mode_check;
|
||||||
|
ALTER TABLE topics ADD CONSTRAINT topics_auth_mode_check
|
||||||
|
CHECK (auth_mode IN ('public', 'token', 'session'));
|
||||||
53
crates/manager-core/migrations/0034_queue_messages.sql
Normal file
53
crates/manager-core/migrations/0034_queue_messages.sql
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
-- v1.1.9: durable per-app named queues (queue::*).
|
||||||
|
--
|
||||||
|
-- Producer: queue::enqueue(name, msg) — INSERTs into this table.
|
||||||
|
-- Consumer: a registered queue:receive trigger fires when a message is
|
||||||
|
-- available; the dispatcher claims with FOR UPDATE SKIP LOCKED + a
|
||||||
|
-- visibility-timeout window.
|
||||||
|
--
|
||||||
|
-- "The queue table IS the outbox" — there is no double-buffering. The
|
||||||
|
-- dispatcher's queue arm claims directly from queue_messages; the
|
||||||
|
-- visibility-timeout reclaim task resets stale claims so crashed
|
||||||
|
-- consumers don't lose work.
|
||||||
|
--
|
||||||
|
-- Identity tuple: (app_id, queue_name). Queue names are implicit — no
|
||||||
|
-- queue registry table; a queue exists once the first message is
|
||||||
|
-- enqueued under that name.
|
||||||
|
|
||||||
|
CREATE TABLE queue_messages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
queue_name TEXT NOT NULL,
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
enqueued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
-- deliver_after: NULL = immediate; else dispatcher won't claim until NOW() >= deliver_after.
|
||||||
|
deliver_after TIMESTAMPTZ NULL,
|
||||||
|
-- claim_token: NULL = unclaimed; UUID = currently leased by a dispatcher.
|
||||||
|
claim_token UUID NULL,
|
||||||
|
claimed_at TIMESTAMPTZ NULL,
|
||||||
|
attempt INT NOT NULL DEFAULT 0,
|
||||||
|
max_attempts INT NOT NULL DEFAULT 3,
|
||||||
|
-- Forensic: who enqueued. NEVER used for authz — the trigger fires
|
||||||
|
-- as the principal that REGISTERED the consumer (design notes §4).
|
||||||
|
enqueued_by_principal UUID NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Dispatch hot path: scan for unclaimed messages in (app_id, queue_name)
|
||||||
|
-- order by enqueued_at. The (deliver_after IS NULL OR deliver_after <= NOW())
|
||||||
|
-- condition cannot live in the partial WHERE (NOW() is non-immutable);
|
||||||
|
-- Postgres applies it as a filter on the partial result set, which is
|
||||||
|
-- already small.
|
||||||
|
CREATE INDEX idx_queue_messages_dispatch
|
||||||
|
ON queue_messages (app_id, queue_name, enqueued_at)
|
||||||
|
WHERE claim_token IS NULL;
|
||||||
|
|
||||||
|
-- depth() / depth_pending() helpers and the dashboard's queue list view.
|
||||||
|
CREATE INDEX idx_queue_messages_app_queue
|
||||||
|
ON queue_messages (app_id, queue_name);
|
||||||
|
|
||||||
|
-- Reclaim-task scan: find currently-leased messages whose claim is older
|
||||||
|
-- than the per-queue visibility_timeout_secs. Bounded by the number of
|
||||||
|
-- in-flight messages, which is small.
|
||||||
|
CREATE INDEX idx_queue_messages_claimed
|
||||||
|
ON queue_messages (claimed_at)
|
||||||
|
WHERE claim_token IS NOT NULL;
|
||||||
41
crates/manager-core/migrations/0035_queue_triggers.sql
Normal file
41
crates/manager-core/migrations/0035_queue_triggers.sql
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
-- v1.1.9: queue:receive trigger kind + OutboxSourceKind::Invoke.
|
||||||
|
--
|
||||||
|
-- queue:receive is the new trigger kind that fires a script per claimed
|
||||||
|
-- message. Layout E parent + per-kind detail (mirrors pubsub).
|
||||||
|
--
|
||||||
|
-- 'invoke' is added to outbox.source_kind for invoke_async() — a
|
||||||
|
-- fire-and-forget function-composition call writes an outbox row that
|
||||||
|
-- the dispatcher fires through the standard executor path.
|
||||||
|
--
|
||||||
|
-- Queue itself does NOT need an outbox.source_kind variant (the queue
|
||||||
|
-- table IS the outbox for queue semantics — see 0034).
|
||||||
|
|
||||||
|
ALTER TABLE triggers DROP CONSTRAINT triggers_kind_check;
|
||||||
|
ALTER TABLE triggers ADD CONSTRAINT triggers_kind_check
|
||||||
|
CHECK (kind IN ('kv', 'dead_letter', 'docs', 'cron',
|
||||||
|
'files', 'pubsub', 'email', 'queue'));
|
||||||
|
|
||||||
|
ALTER TABLE outbox DROP CONSTRAINT outbox_source_kind_check;
|
||||||
|
ALTER TABLE outbox ADD CONSTRAINT outbox_source_kind_check
|
||||||
|
CHECK (source_kind IN ('http', 'kv', 'dead_letter', 'docs',
|
||||||
|
'cron', 'files', 'pubsub', 'email', 'invoke'));
|
||||||
|
|
||||||
|
-- Per-queue-trigger config. Retry policy lives on the parent triggers
|
||||||
|
-- row (retry_max_attempts, retry_backoff, retry_base_ms) — same
|
||||||
|
-- pattern as every other trigger kind. visibility_timeout_secs is
|
||||||
|
-- queue-specific.
|
||||||
|
--
|
||||||
|
-- "Exactly one consumer per (app_id, queue_name)" is enforced at the
|
||||||
|
-- API layer via a pg_advisory_xact_lock on hashtext(app_id || queue_name)
|
||||||
|
-- + a SELECT-then-INSERT in one transaction (a partial unique index
|
||||||
|
-- can't reference the parent's app_id column from the detail table).
|
||||||
|
CREATE TABLE queue_trigger_details (
|
||||||
|
trigger_id UUID PRIMARY KEY REFERENCES triggers(id) ON DELETE CASCADE,
|
||||||
|
queue_name TEXT NOT NULL,
|
||||||
|
visibility_timeout_secs INT NOT NULL DEFAULT 30,
|
||||||
|
last_fired_at TIMESTAMPTZ NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Help the dispatcher's "find all active queue consumers" query.
|
||||||
|
CREATE INDEX idx_queue_trigger_details_queue_name
|
||||||
|
ON queue_trigger_details (queue_name);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- F-P-010 (audit 2026-06-07): list_active_queue_consumers runs every
|
||||||
|
-- 100 ms via the dispatcher's queue arm. Predicate is
|
||||||
|
-- `WHERE t.kind = 'queue' AND t.enabled = TRUE` with no `app_id`
|
||||||
|
-- filter. The available index `idx_triggers_app_kind_enabled
|
||||||
|
-- (app_id, kind) WHERE enabled = TRUE` requires an app_id predicate to
|
||||||
|
-- be useful — without one the planner falls back to a sequential scan
|
||||||
|
-- of the triggers table every tick. Add an index keyed purely on
|
||||||
|
-- `kind` (partial, where enabled) so the same hot query becomes an
|
||||||
|
-- index-only lookup.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_triggers_kind_enabled
|
||||||
|
ON triggers (kind)
|
||||||
|
WHERE enabled = TRUE;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- F-M-001 (audit 2026-06-07): drop idx_cron_triggers_due.
|
||||||
|
--
|
||||||
|
-- Created in 0017_cron_triggers.sql with a comment claiming it serves
|
||||||
|
-- the scheduler. The actual scheduler query is
|
||||||
|
-- `... WHERE t.enabled = TRUE FOR UPDATE OF d SKIP LOCKED` — no
|
||||||
|
-- `last_fired_at` predicate. The index is unused; the join is
|
||||||
|
-- effectively a full table scan of cron details against enabled
|
||||||
|
-- triggers (small N today, so the overhead is invisible — but the
|
||||||
|
-- index is also pure write amplification with zero read payoff).
|
||||||
|
--
|
||||||
|
-- Drop is reversible by re-running the CREATE INDEX from 0017.
|
||||||
|
DROP INDEX IF EXISTS idx_cron_triggers_due;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- F-M-002 (audit 2026-06-07): coupled-nullness CHECK constraints on
|
||||||
|
-- (encrypted, nonce) pairs.
|
||||||
|
--
|
||||||
|
-- The current schema has each column nullable independently:
|
||||||
|
-- email_trigger_details.inbound_secret_encrypted / _nonce
|
||||||
|
-- app_secrets.realtime_signing_key_encrypted / _nonce
|
||||||
|
--
|
||||||
|
-- A bug or partial write could leave one populated and the other NULL
|
||||||
|
-- — silently bypassing signature verification (receiver decrypts only
|
||||||
|
-- if the encrypted column is set). Defend with a coupled-nullness
|
||||||
|
-- CHECK on each pair so a partial state is rejected at the DB
|
||||||
|
-- boundary.
|
||||||
|
ALTER TABLE email_trigger_details
|
||||||
|
ADD CONSTRAINT email_trigger_details_inbound_secret_pair
|
||||||
|
CHECK ((inbound_secret_encrypted IS NULL) = (inbound_secret_nonce IS NULL));
|
||||||
|
|
||||||
|
ALTER TABLE app_secrets
|
||||||
|
ADD CONSTRAINT app_secrets_realtime_signing_key_pair
|
||||||
|
CHECK ((realtime_signing_key_encrypted IS NULL) = (realtime_signing_key_nonce IS NULL));
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- F-S-013 (audit 2026-06-07): partial unique index on pending
|
||||||
|
-- (app_id, lower(email)) for app_user_invitations.
|
||||||
|
--
|
||||||
|
-- Without it, users::invite("alice@…") called N times creates N rows
|
||||||
|
-- with N valid tokens. Combined with accept_invite returning Ok(None)
|
||||||
|
-- silently when the email already exists, an attacker who learns one
|
||||||
|
-- invitation token can permanently consume it without effect.
|
||||||
|
--
|
||||||
|
-- The constraint is partial so re-inviting after a previous invitation
|
||||||
|
-- was accepted still works (the accepted row sits outside the index).
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_app_user_invitations_unique_pending
|
||||||
|
ON app_user_invitations (app_id, lower(email))
|
||||||
|
WHERE accepted_at IS NULL;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Audit Low finding: preserving forensic history.
|
||||||
|
--
|
||||||
|
-- The `execution_logs.script_id` foreign key was created with ON DELETE
|
||||||
|
-- CASCADE in 0001_init. Deleting a script then wipes every log row that
|
||||||
|
-- ever referenced it — including the rows that captured the failure
|
||||||
|
-- that motivated the delete. Switch to ON DELETE SET NULL so the
|
||||||
|
-- forensic history survives and operators can still look up "what did
|
||||||
|
-- this dead script do before we removed it" by id.
|
||||||
|
|
||||||
|
ALTER TABLE execution_logs
|
||||||
|
DROP CONSTRAINT IF EXISTS execution_logs_script_id_fkey;
|
||||||
|
|
||||||
|
ALTER TABLE execution_logs
|
||||||
|
ALTER COLUMN script_id DROP NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE execution_logs
|
||||||
|
ADD CONSTRAINT execution_logs_script_id_fkey
|
||||||
|
FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE SET NULL;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Audit Low finding: speeding up the "list all DL for app" view.
|
||||||
|
--
|
||||||
|
-- The dashboard's dead-letters tab issues
|
||||||
|
-- SELECT ... FROM dead_letters
|
||||||
|
-- WHERE app_id = $1
|
||||||
|
-- ORDER BY created_at DESC LIMIT $2
|
||||||
|
-- which falls back to a seq-scan + sort when the unresolved=false
|
||||||
|
-- filter is on (the partial idx_dead_letters_app_unresolved doesn't
|
||||||
|
-- cover resolved rows). This composite covers both modes.
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dead_letters_app_created
|
||||||
|
ON dead_letters (app_id, created_at DESC);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- Audit 2026-06-11 H-D1: AES-GCM envelope versioning + AAD binding.
|
||||||
|
--
|
||||||
|
-- Pre-2026-06-11 the per-app secret store, per-app realtime signing
|
||||||
|
-- key, and email-trigger inbound HMAC secret were all AES-256-GCM
|
||||||
|
-- sealed with no Associated Authentication Data. Anyone with Postgres
|
||||||
|
-- write access could ciphertext-swap rows across apps (or rename via
|
||||||
|
-- row edit) and the decrypt would silently succeed under the wrong
|
||||||
|
-- identity, returning attacker-chosen plaintext.
|
||||||
|
--
|
||||||
|
-- New writes use `crypto::encrypt_with_aad` with a stable identity
|
||||||
|
-- string ("secret:{app_id}:{name}" / "app_secret:{app_id}:realtime_signing_key")
|
||||||
|
-- as AAD, so any cross-row swap fails the GCM auth tag.
|
||||||
|
--
|
||||||
|
-- This migration introduces a per-row `version` column. v0 = legacy
|
||||||
|
-- (no AAD); v1 = AAD-bound. Reads dispatch on the column; new writes
|
||||||
|
-- always emit v1. Existing v0 rows continue to decrypt; the
|
||||||
|
-- re-encryption sweep is deferred to v1.2's planned key-versioning
|
||||||
|
-- pass (see SECURITY_AUDIT.md "Notes on remediation methodology").
|
||||||
|
--
|
||||||
|
-- email_trigger_details.inbound_secret_encrypted retains a v0-only
|
||||||
|
-- path for now (audit-classified Medium; deferred).
|
||||||
|
|
||||||
|
ALTER TABLE secrets
|
||||||
|
ADD COLUMN version SMALLINT NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
ALTER TABLE app_secrets
|
||||||
|
ADD COLUMN realtime_signing_key_version SMALLINT NOT NULL DEFAULT 0;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- G1 (E2E #2 "Stash"): trigger executions were invisible to `pic logs`.
|
||||||
|
--
|
||||||
|
-- Only HTTP-route executions ever wrote an `execution_logs` row; queue,
|
||||||
|
-- cron, dead-letter, and `invoke()` runs left no trace, so background
|
||||||
|
-- workers were observable only via dead-letters (failures) or their own
|
||||||
|
-- side effects. The dispatcher now logs every trigger run too — this
|
||||||
|
-- column records which kind of event dispatched each execution so the
|
||||||
|
-- logs surface can show, and filter by, the origin.
|
||||||
|
--
|
||||||
|
-- DEFAULT 'http' backfills every pre-existing row: before this change the
|
||||||
|
-- only thing that logged was the HTTP path, so 'http' is correct history.
|
||||||
|
-- The CHECK list mirrors `manager-core::OutboxSourceKind` /
|
||||||
|
-- `shared::ExecutionSource`; keep all three in sync.
|
||||||
|
|
||||||
|
ALTER TABLE execution_logs
|
||||||
|
ADD COLUMN source TEXT NOT NULL DEFAULT 'http'
|
||||||
|
CHECK (source IN (
|
||||||
|
'http', 'kv', 'docs', 'dead_letter', 'cron',
|
||||||
|
'files', 'pubsub', 'email', 'invoke', 'queue'
|
||||||
|
));
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- H1 (re-review of the S6 reserved-path fix): the reserved-prefix check is
|
||||||
|
-- now case-insensitive, both at route creation AND when the route table is
|
||||||
|
-- compiled at boot. Routes created before the fix — while validation was
|
||||||
|
-- case-sensitive — could hold paths like `/API/v2/x`, `/Admin/x`, or
|
||||||
|
-- `/HEALTHZ`. `compile_routes` now skips such rows with a warning instead of
|
||||||
|
-- aborting startup (so an un-upgraded boot can't be bricked), but those
|
||||||
|
-- routes violate the reserved namespace and can never be served safely, so
|
||||||
|
-- sweep them here on upgrade.
|
||||||
|
--
|
||||||
|
-- Mirrors `orchestrator-core::routing::pattern::check_reserved` exactly,
|
||||||
|
-- case-insensitively: a path is reserved if its lowercased form equals one
|
||||||
|
-- of the bare names (`/api` `/admin` `/healthz` `/version`) or starts with
|
||||||
|
-- one of the prefixes. Note `/api/` and `/admin/` reserve on the trailing
|
||||||
|
-- slash, while `/healthz` and `/version` reserve on bare prefix — matching
|
||||||
|
-- the RESERVED_PATH_PREFIXES list. (Idempotent: a no-op once swept.)
|
||||||
|
|
||||||
|
DELETE FROM routes
|
||||||
|
WHERE lower(path) IN ('/api', '/admin', '/healthz', '/version')
|
||||||
|
OR lower(path) LIKE '/api/%'
|
||||||
|
OR lower(path) LIKE '/admin/%'
|
||||||
|
OR lower(path) LIKE '/healthz%'
|
||||||
|
OR lower(path) LIKE '/version%';
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Three-state `enabled` lifecycle (blueprint §4.3), scripts + routes half.
|
||||||
|
-- Triggers already carry `enabled` (0008) honored at match/schedule time;
|
||||||
|
-- this adds the same toggle to scripts and routes. A disabled script is not
|
||||||
|
-- invocable; a disabled route 404s (indistinguishable from absent). Default
|
||||||
|
-- TRUE so every existing row stays active — no behavior change on migrate.
|
||||||
|
|
||||||
|
ALTER TABLE scripts ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE;
|
||||||
|
ALTER TABLE routes ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE;
|
||||||
29
crates/manager-core/migrations/0046_trigger_name.sql
Normal file
29
crates/manager-core/migrations/0046_trigger_name.sql
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
-- Trigger `name` (§4.5): an explicit per-app identifier that becomes the
|
||||||
|
-- manifest merge/upsert key (Step B switches the apply diff to key on it).
|
||||||
|
-- Until now triggers were identified only by their per-kind semantic tuple,
|
||||||
|
-- so the declarative tool could only Create/Delete them, never Update.
|
||||||
|
--
|
||||||
|
-- Backfill existing rows with `{kind}-{n}` (n = per-(app,kind) sequence by
|
||||||
|
-- creation order) — the spec-sanctioned fallback when there's no cleaner
|
||||||
|
-- entity token. Then enforce NOT NULL + UNIQUE(app_id, name).
|
||||||
|
|
||||||
|
-- A `gen_random_uuid()` default keeps existing INSERTs (which don't yet
|
||||||
|
-- supply a name) valid and unique — the manager-core write paths start
|
||||||
|
-- providing meaningful names in the follow-up; new rows until then get a
|
||||||
|
-- harmless unique placeholder.
|
||||||
|
ALTER TABLE triggers
|
||||||
|
ADD COLUMN name TEXT NOT NULL DEFAULT gen_random_uuid()::text;
|
||||||
|
|
||||||
|
-- Rewrite the just-defaulted existing rows to the readable `{kind}-{n}` form.
|
||||||
|
UPDATE triggers t
|
||||||
|
SET name = sub.nm
|
||||||
|
FROM (
|
||||||
|
SELECT id,
|
||||||
|
kind || '-' || row_number() OVER (
|
||||||
|
PARTITION BY app_id, kind ORDER BY created_at, id
|
||||||
|
) AS nm
|
||||||
|
FROM triggers
|
||||||
|
) sub
|
||||||
|
WHERE t.id = sub.id;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX triggers_app_name_uniq ON triggers (app_id, name);
|
||||||
82
crates/manager-core/migrations/0047_groups.sql
Normal file
82
crates/manager-core/migrations/0047_groups.sql
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
-- Phase 2: groups as a pure org / RBAC / UI container — see
|
||||||
|
-- docs/design/groups-and-project-tool.md §5, §9.
|
||||||
|
--
|
||||||
|
-- Groups form a GitLab-like, single-parent tree ABOVE apps. Phase 2 adds
|
||||||
|
-- the tree, hierarchy-aware membership, and structural-mutation safety —
|
||||||
|
-- but NO group-owned resources yet (scripts/vars/secrets stay app-owned;
|
||||||
|
-- that is Phase 3). The only data-plane touch is apps gaining a parent
|
||||||
|
-- pointer.
|
||||||
|
--
|
||||||
|
-- Adoption (§9): every existing app must have a parent from day one so
|
||||||
|
-- resolution always terminates. This migration seeds a single instance
|
||||||
|
-- root group and reparents every app under it, then promotes
|
||||||
|
-- apps.group_id to NOT NULL.
|
||||||
|
|
||||||
|
CREATE TABLE groups (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
-- Single parent keeps inheritance acyclic and resolution
|
||||||
|
-- deterministic. NULL parent = a root node. RESTRICT (never CASCADE):
|
||||||
|
-- deleting a non-empty group is refused so descendant apps and their
|
||||||
|
-- isolated data can't be destroyed implicitly (§5.6). The ancestor-walk
|
||||||
|
-- cycle guard that keeps this acyclic lives in manager-core (a SQL
|
||||||
|
-- CHECK can't express it).
|
||||||
|
parent_id UUID REFERENCES groups(id) ON DELETE RESTRICT,
|
||||||
|
-- Instance-global identifier, frozen at creation. A rename/reparent
|
||||||
|
-- updates the display name/path but NEVER rewrites the slug, so the
|
||||||
|
-- deployment key stays stable and external references don't break.
|
||||||
|
-- Format validation lives in Rust handlers (same rule as app slugs).
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
-- Per-subtree structure version (§6): bumped on every structural
|
||||||
|
-- mutation of this node (reparent/rename/delete) so a future CLI/
|
||||||
|
-- orchestrator can detect structural drift. NOT an authz input —
|
||||||
|
-- authorization is resolved live every request.
|
||||||
|
structure_version BIGINT NOT NULL DEFAULT 1,
|
||||||
|
-- §7 ownership seam — the project-root that manages this node. Inert
|
||||||
|
-- in Phase 2 (no projects table yet); nullable, no FK.
|
||||||
|
owner_project UUID,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX groups_parent_id_idx ON groups (parent_id);
|
||||||
|
|
||||||
|
-- Per-(user, group) explicit grant, mirroring app_members. Inherited
|
||||||
|
-- membership (GitLab-style) is resolved in code by walking ancestors: a
|
||||||
|
-- group_admin on any ancestor is implicitly app_admin on every app and
|
||||||
|
-- subgroup beneath it. Roles reuse the SAME three literals as app_members
|
||||||
|
-- so AppRole round-trips with zero mapping and the authz rank table
|
||||||
|
-- covers both tables.
|
||||||
|
CREATE TABLE group_members (
|
||||||
|
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('app_admin', 'editor', 'viewer')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (group_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Hot path is the authz ancestor walk "what groups does this user have a
|
||||||
|
-- role on?" plus the per-group member list.
|
||||||
|
CREATE INDEX group_members_user_id_idx ON group_members (user_id);
|
||||||
|
|
||||||
|
-- Add the parent pointer to apps (nullable for the backfill window).
|
||||||
|
ALTER TABLE apps ADD COLUMN group_id UUID;
|
||||||
|
|
||||||
|
-- Seed a single instance root group and reparent every existing app under
|
||||||
|
-- it. App slugs are already instance-global, so no slug rewrite is needed
|
||||||
|
-- — the parent pointer is new metadata layered on top.
|
||||||
|
WITH root_group AS (
|
||||||
|
INSERT INTO groups (slug, name, description)
|
||||||
|
VALUES ('root', 'Root', 'The instance root group — parent of all apps created before groups landed.')
|
||||||
|
RETURNING id
|
||||||
|
)
|
||||||
|
UPDATE apps SET group_id = (SELECT id FROM root_group);
|
||||||
|
|
||||||
|
-- Every app now has a parent; promote to NOT NULL + FK. RESTRICT so a
|
||||||
|
-- group with apps can't be deleted out from under them (§5.6).
|
||||||
|
ALTER TABLE apps ALTER COLUMN group_id SET NOT NULL;
|
||||||
|
ALTER TABLE apps
|
||||||
|
ADD CONSTRAINT apps_group_id_fk FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE RESTRICT;
|
||||||
|
|
||||||
|
CREATE INDEX apps_group_id_idx ON apps (group_id);
|
||||||
47
crates/manager-core/migrations/0048_vars.sql
Normal file
47
crates/manager-core/migrations/0048_vars.sql
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
-- Phase 3: group-inherited config — the `vars` table + an app environment
|
||||||
|
-- marker. See docs/design/groups-and-project-tool.md §3, §5.1.
|
||||||
|
--
|
||||||
|
-- `vars` is the net-new, env-scoped configuration layer (greenfield — there
|
||||||
|
-- is no env-agnostic config today, only `secrets`). A var is owned by
|
||||||
|
-- exactly one group OR one app, optionally scoped to an environment, and
|
||||||
|
-- resolved down the tree (§3): env-filter first, then nearest level wins.
|
||||||
|
--
|
||||||
|
-- "An environment is an app": each env is already a distinct app row. To
|
||||||
|
-- env-filter group-level `@E` values, the resolver must know which env an
|
||||||
|
-- app represents — recorded here on `apps.environment` (NULL = env-agnostic,
|
||||||
|
-- set at app-create from the CLI's known env).
|
||||||
|
|
||||||
|
ALTER TABLE apps ADD COLUMN environment TEXT;
|
||||||
|
|
||||||
|
CREATE TABLE vars (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
-- Polymorphic owner: exactly one of the two FKs is set. Real FKs (not a
|
||||||
|
-- bare owner_id) so ON DELETE CASCADE works per owner kind and a dangling
|
||||||
|
-- owner is impossible.
|
||||||
|
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vars_owner_exactly_one
|
||||||
|
CHECK ((group_id IS NULL) <> (app_id IS NULL)),
|
||||||
|
-- '*' = env-agnostic; otherwise an env name matched against
|
||||||
|
-- apps.environment. NOT NULL with a '*' sentinel so the uniqueness
|
||||||
|
-- indexes are total (a NULL scope would break UNIQUE).
|
||||||
|
environment_scope TEXT NOT NULL DEFAULT '*',
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
-- JSONB per the v1.1 data-plane convention. A tombstone (deletion of an
|
||||||
|
-- inherited key, §3) is the explicit boolean below, NOT value = 'null'
|
||||||
|
-- — JSON null is a legitimate value and must stay distinguishable.
|
||||||
|
value JSONB NOT NULL,
|
||||||
|
is_tombstone BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- One row per (owner, scope, key). Partial unique indexes because the owner
|
||||||
|
-- is split across two nullable columns.
|
||||||
|
CREATE UNIQUE INDEX vars_group_uidx
|
||||||
|
ON vars (group_id, environment_scope, key) WHERE group_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX vars_app_uidx
|
||||||
|
ON vars (app_id, environment_scope, key) WHERE app_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX vars_group_id_idx ON vars (group_id) WHERE group_id IS NOT NULL;
|
||||||
|
CREATE INDEX vars_app_id_idx ON vars (app_id) WHERE app_id IS NOT NULL;
|
||||||
51
crates/manager-core/migrations/0049_group_secrets.sql
Normal file
51
crates/manager-core/migrations/0049_group_secrets.sql
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
-- Phase 3 (v1.1.9): group-owned, environment-scoped secrets.
|
||||||
|
--
|
||||||
|
-- Until now `secrets` was strictly per-app: PK `(app_id, name)`, one
|
||||||
|
-- envelope per app. Phase 3 makes secrets inheritable down the group tree
|
||||||
|
-- (blueprint §11 bullet 3 / docs/design §3), exactly like `vars` (0048):
|
||||||
|
-- a secret may be owned by an app OR an ancestor group, and a descendant
|
||||||
|
-- app resolves the nearest one, environment-filtered.
|
||||||
|
--
|
||||||
|
-- Reshape (mirrors `vars`):
|
||||||
|
-- * `group_id` — nullable FK→groups, CASCADE (a deleted group drops its
|
||||||
|
-- secrets, same as its vars).
|
||||||
|
-- * `app_id` — made nullable (was NOT NULL); the exactly-one CHECK now
|
||||||
|
-- enforces "owned by an app XOR a group".
|
||||||
|
-- * `environment_scope` — `'*'` (env-agnostic) or a concrete env name;
|
||||||
|
-- the resolver filters on it. Existing rows backfill to `'*'`, so every
|
||||||
|
-- current app secret stays env-agnostic and resolves unchanged.
|
||||||
|
-- * PK `(app_id, name)` → two PARTIAL unique indexes, one per owner, both
|
||||||
|
-- keyed `(owner, environment_scope, name)`.
|
||||||
|
--
|
||||||
|
-- CRYPTO INVARIANT (audit 2026-06-11 H-D1): the v1 AAD is
|
||||||
|
-- `secret:{app_id}:{name}` for app secrets and `secret:group:{group_id}:{name}`
|
||||||
|
-- for group secrets — it does NOT include `environment_scope`. Adding the
|
||||||
|
-- column therefore leaves every existing ciphertext decryptable byte-for-byte;
|
||||||
|
-- the app-owner AAD is unchanged and the group namespace is disjoint.
|
||||||
|
|
||||||
|
ALTER TABLE secrets
|
||||||
|
ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
ADD COLUMN environment_scope TEXT NOT NULL DEFAULT '*';
|
||||||
|
|
||||||
|
-- Drop the old composite PK first: `app_id` cannot lose NOT NULL while it
|
||||||
|
-- is a primary-key column.
|
||||||
|
ALTER TABLE secrets
|
||||||
|
DROP CONSTRAINT secrets_pkey;
|
||||||
|
|
||||||
|
ALTER TABLE secrets
|
||||||
|
ALTER COLUMN app_id DROP NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE secrets
|
||||||
|
ADD CONSTRAINT secrets_owner_exactly_one
|
||||||
|
CHECK ((group_id IS NULL) <> (app_id IS NULL));
|
||||||
|
|
||||||
|
-- One secret per (owner, env, name). Partial so each owner column only
|
||||||
|
-- constrains its own rows; the resolver and every upsert restate the
|
||||||
|
-- predicate as the ON CONFLICT arbiter.
|
||||||
|
CREATE UNIQUE INDEX secrets_app_uidx
|
||||||
|
ON secrets (app_id, environment_scope, name) WHERE app_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX secrets_group_uidx
|
||||||
|
ON secrets (group_id, environment_scope, name) WHERE group_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Owner lookup index for the group side (the app side keeps idx_secrets_app).
|
||||||
|
CREATE INDEX idx_secrets_group ON secrets (group_id) WHERE group_id IS NOT NULL;
|
||||||
40
crates/manager-core/migrations/0050_group_scripts.sql
Normal file
40
crates/manager-core/migrations/0050_group_scripts.sql
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
-- Phase 4 (v1.2 Hierarchies): group-owned scripts.
|
||||||
|
--
|
||||||
|
-- Until now every script was owned by exactly one app (`app_id NOT NULL`,
|
||||||
|
-- ON DELETE RESTRICT). Phase 4 lets a script be owned by a GROUP instead, so
|
||||||
|
-- it is shared by every descendant app — resolved by name, nearest-owner
|
||||||
|
-- wins (CoW: an app's own script of the same name shadows the inherited one),
|
||||||
|
-- exactly like `vars`/`secrets` (0048/0049).
|
||||||
|
--
|
||||||
|
-- Phase 4-LITE scope: group-owned ENDPOINT scripts only. Group modules and
|
||||||
|
-- the origin-aware (lexical) import resolver are deferred to Phase 4b, so a
|
||||||
|
-- group-owned script must be self-contained (enforced in the service layer).
|
||||||
|
--
|
||||||
|
-- Reshape (mirrors vars/secrets, but RESTRICT not CASCADE — code is not data,
|
||||||
|
-- a group can't be deleted out from under the scripts it owns, matching the
|
||||||
|
-- existing app_id RESTRICT and the §5.6 delete=RESTRICT rule):
|
||||||
|
-- * `group_id` — nullable FK→groups, ON DELETE RESTRICT.
|
||||||
|
-- * `app_id` — made nullable (was NOT NULL); the exactly-one CHECK now
|
||||||
|
-- enforces "owned by an app XOR a group".
|
||||||
|
-- * the per-app unique name index becomes two partial indexes, one per owner.
|
||||||
|
|
||||||
|
ALTER TABLE scripts
|
||||||
|
ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE RESTRICT;
|
||||||
|
|
||||||
|
ALTER TABLE scripts
|
||||||
|
ALTER COLUMN app_id DROP NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE scripts
|
||||||
|
ADD CONSTRAINT scripts_owner_exactly_one
|
||||||
|
CHECK ((group_id IS NULL) <> (app_id IS NULL));
|
||||||
|
|
||||||
|
-- Per-owner, case-insensitive name uniqueness. Partial so each owner column
|
||||||
|
-- only constrains its own rows; existing app rows (group_id NULL) keep the
|
||||||
|
-- exact same (app_id, LOWER(name)) uniqueness they had under the old index.
|
||||||
|
DROP INDEX scripts_name_uidx;
|
||||||
|
CREATE UNIQUE INDEX scripts_app_name_uidx
|
||||||
|
ON scripts (app_id, LOWER(name)) WHERE app_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX scripts_group_name_uidx
|
||||||
|
ON scripts (group_id, LOWER(name)) WHERE group_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX scripts_group_id_idx ON scripts (group_id) WHERE group_id IS NOT NULL;
|
||||||
40
crates/manager-core/migrations/0051_extension_points.sql
Normal file
40
crates/manager-core/migrations/0051_extension_points.sql
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
-- §5.5 (v1.2 Hierarchies): extension-point markers — opt-in module polymorphism.
|
||||||
|
--
|
||||||
|
-- An extension point marks a module NAME at a node (app or group) as one
|
||||||
|
-- descendants are *expected* to provide or override. Imports of an EP name
|
||||||
|
-- resolve DYNAMICALLY against the inheriting app's view (the app can supply
|
||||||
|
-- its own module), instead of the Phase 4b lexical default (sealed to the
|
||||||
|
-- importing script's defining node). See docs §5.5.
|
||||||
|
--
|
||||||
|
-- This table holds only the MARKER (owner, name) — it is pure declaration,
|
||||||
|
-- structurally identical to a `secrets` name. The optional DEFAULT BODY is
|
||||||
|
-- just a co-located `kind = 'module'` script of the same name at this node
|
||||||
|
-- (Phase 4b already stores/resolves/caches those); there is no body column
|
||||||
|
-- here. So a marker is config, not code → ON DELETE CASCADE (unlike the
|
||||||
|
-- module body's RESTRICT in 0050).
|
||||||
|
--
|
||||||
|
-- Ownership is polymorphic (mirrors vars/secrets/scripts): exactly one of
|
||||||
|
-- (app_id, group_id) is set, with per-owner partial-unique LOWER(name)
|
||||||
|
-- indexes for case-insensitive uniqueness.
|
||||||
|
|
||||||
|
CREATE TABLE extension_points (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT extension_points_owner_exactly_one
|
||||||
|
CHECK ((group_id IS NULL) <> (app_id IS NULL)),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- One marker per (owner, name), case-insensitive. Partial because the owner
|
||||||
|
-- is split across two nullable columns.
|
||||||
|
CREATE UNIQUE INDEX extension_points_group_uidx
|
||||||
|
ON extension_points (group_id, LOWER(name)) WHERE group_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX extension_points_app_uidx
|
||||||
|
ON extension_points (app_id, LOWER(name)) WHERE app_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Lookup indexes for the resolver's chain join + list-by-owner.
|
||||||
|
CREATE INDEX extension_points_group_id_idx ON extension_points (group_id) WHERE group_id IS NOT NULL;
|
||||||
|
CREATE INDEX extension_points_app_id_idx ON extension_points (app_id) WHERE app_id IS NOT NULL;
|
||||||
47
crates/manager-core/migrations/0052_group_collections.sql
Normal file
47
crates/manager-core/migrations/0052_group_collections.sql
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
-- §11.6 (v1.2 Hierarchies): group-collection registry — shared cross-app data.
|
||||||
|
--
|
||||||
|
-- A row marks a collection NAME at a node as group-SHARED: every app in the
|
||||||
|
-- owning group's subtree may read it (and, with an authenticated editor+,
|
||||||
|
-- write it) via the explicit `kv::shared_collection("name")` SDK handle. Resolution is
|
||||||
|
-- nearest-ancestor-group-wins, walking the reading app's chain — that ancestry
|
||||||
|
-- walk is the isolation boundary (a foreign app's chain never contains the
|
||||||
|
-- owning group, so the name simply does not resolve). See docs §11.6.
|
||||||
|
--
|
||||||
|
-- This table holds only the MARKER (owner, name, kind) — the data itself lives
|
||||||
|
-- in a per-kind storage table (`group_kv_entries`, 0053, for kind='kv'). It is
|
||||||
|
-- pure declaration, structurally identical to an `extension_points` marker
|
||||||
|
-- (0051). A marker is config, not code → ON DELETE CASCADE.
|
||||||
|
--
|
||||||
|
-- Ownership is polymorphic (mirrors vars/secrets/scripts/extension_points):
|
||||||
|
-- exactly one of (app_id, group_id) is set. MVP authoring is restricted to
|
||||||
|
-- GROUP owners at the manifest layer (an app-declared shared collection is
|
||||||
|
-- degenerate — only that app would read it); the polymorphic shape keeps the
|
||||||
|
-- owner-generic reconcile path uniform with the other inheritable kinds.
|
||||||
|
--
|
||||||
|
-- `kind` is the storage discriminator. Only 'kv' ships in the MVP; the column
|
||||||
|
-- + CHECK exist now so docs/files/topics/queue slot in later without a
|
||||||
|
-- migration, and so a future `docs` collection of the same name is a distinct
|
||||||
|
-- row (the unique index covers `kind`).
|
||||||
|
|
||||||
|
CREATE TABLE group_collections (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT group_collections_owner_exactly_one
|
||||||
|
CHECK ((group_id IS NULL) <> (app_id IS NULL)),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'kv' CHECK (kind IN ('kv')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- One marker per (owner, name, kind), case-insensitive. Partial because the
|
||||||
|
-- owner is split across two nullable columns.
|
||||||
|
CREATE UNIQUE INDEX group_collections_group_uidx
|
||||||
|
ON group_collections (group_id, LOWER(name), kind) WHERE group_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX group_collections_app_uidx
|
||||||
|
ON group_collections (app_id, LOWER(name), kind) WHERE app_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Lookup indexes for the resolver's chain join + list-by-owner.
|
||||||
|
CREATE INDEX group_collections_group_id_idx ON group_collections (group_id) WHERE group_id IS NOT NULL;
|
||||||
|
CREATE INDEX group_collections_app_id_idx ON group_collections (app_id) WHERE app_id IS NOT NULL;
|
||||||
30
crates/manager-core/migrations/0053_group_kv_entries.sql
Normal file
30
crates/manager-core/migrations/0053_group_kv_entries.sql
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
-- §11.6 (v1.2 Hierarchies): group-KV storage — the shared read/write data for
|
||||||
|
-- a collection declared group-shared in `group_collections` (0052, kind='kv').
|
||||||
|
--
|
||||||
|
-- Identity tuple `(group_id, collection, key)`. Unlike per-app `kv_entries`
|
||||||
|
-- (0007) this is keyed by the OWNING GROUP, not by app — the whole point is
|
||||||
|
-- that many apps in the group's subtree share one store. There is no `app_id`
|
||||||
|
-- column: a shared row belongs to the group, not to whichever app wrote it.
|
||||||
|
--
|
||||||
|
-- `value` is JSONB, mirroring `kv_entries`. The reading/writing app is resolved
|
||||||
|
-- to its owning group at the service layer (walking the app's ancestor chain);
|
||||||
|
-- this table only ever sees a concrete `group_id`.
|
||||||
|
--
|
||||||
|
-- ON DELETE CASCADE on group_id: the shared store is DATA, so it dies with its
|
||||||
|
-- owning group — like `vars`/`secrets` config CASCADE, deliberately UNLIKE the
|
||||||
|
-- `scripts` (0050) RESTRICT (code is not data). Deleting an app does NOT touch
|
||||||
|
-- this table (the data is the group's, and survives the app's departure).
|
||||||
|
|
||||||
|
CREATE TABLE group_kv_entries (
|
||||||
|
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
collection TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
value JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (group_id, collection, key)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Supports list-by-collection (keyset pagination); the PK already covers
|
||||||
|
-- (group_id, collection) as a prefix but the explicit index makes intent clear.
|
||||||
|
CREATE INDEX idx_group_kv_entries_group_collection ON group_kv_entries (group_id, collection);
|
||||||
33
crates/manager-core/migrations/0054_group_docs.sql
Normal file
33
crates/manager-core/migrations/0054_group_docs.sql
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
-- §11.6 (cont., v1.2 Hierarchies): group-shared DOCS collections.
|
||||||
|
--
|
||||||
|
-- Extends the shared-collection machinery (0052 registry + 0053 group_kv_entries)
|
||||||
|
-- from KV to the queryable-JSON `docs` store. The registry's `kind`
|
||||||
|
-- discriminator was built for exactly this — widen its CHECK to admit 'docs',
|
||||||
|
-- and add a group-keyed storage table mirroring `docs` (0013).
|
||||||
|
|
||||||
|
-- Widen the marker kind allow-list. The constraint was an inline column CHECK,
|
||||||
|
-- so Postgres auto-named it `group_collections_kind_check`.
|
||||||
|
ALTER TABLE group_collections
|
||||||
|
DROP CONSTRAINT group_collections_kind_check,
|
||||||
|
ADD CONSTRAINT group_collections_kind_check CHECK (kind IN ('kv', 'docs'));
|
||||||
|
|
||||||
|
-- Group-keyed docs store. Identity tuple `(group_id, collection, id)` — keyed
|
||||||
|
-- by the OWNING GROUP, not an app (the shared rows belong to the group). `id`
|
||||||
|
-- is a server-generated UUID, as in `docs`. CASCADE on group delete (data dies
|
||||||
|
-- with its group; an app delete leaves it), matching group_kv_entries (0053).
|
||||||
|
CREATE TABLE group_docs (
|
||||||
|
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
collection TEXT NOT NULL,
|
||||||
|
id UUID NOT NULL,
|
||||||
|
data JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (group_id, collection, id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- "all docs in group X / collection Y" — mirrors idx_docs_app_collection (0013).
|
||||||
|
CREATE INDEX idx_group_docs_group_collection ON group_docs (group_id, collection);
|
||||||
|
|
||||||
|
-- GIN on JSONB (jsonb_path_ops) for the find DSL's equality/containment, same
|
||||||
|
-- as idx_docs_data_gin (0013).
|
||||||
|
CREATE INDEX idx_group_docs_data_gin ON group_docs USING GIN (data jsonb_path_ops);
|
||||||
37
crates/manager-core/migrations/0055_group_files.sql
Normal file
37
crates/manager-core/migrations/0055_group_files.sql
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
-- §11.6 (cont., v1.2 Hierarchies): group-shared FILES collections.
|
||||||
|
--
|
||||||
|
-- Extends the shared-collection machinery (0052 registry + the per-kind stores
|
||||||
|
-- 0053 group_kv_entries / 0054 group_docs) from KV/docs to the filesystem-backed
|
||||||
|
-- `files` blob store. The registry's `kind` discriminator was built for exactly
|
||||||
|
-- this — widen its CHECK to admit 'files', and add a group-keyed metadata table
|
||||||
|
-- mirroring `files` (0018).
|
||||||
|
|
||||||
|
-- Widen the marker kind allow-list (auto-named `group_collections_kind_check`,
|
||||||
|
-- per 0052's inline column CHECK; 0054 widened it to ('kv','docs')).
|
||||||
|
ALTER TABLE group_collections
|
||||||
|
DROP CONSTRAINT group_collections_kind_check,
|
||||||
|
ADD CONSTRAINT group_collections_kind_check CHECK (kind IN ('kv', 'docs', 'files'));
|
||||||
|
|
||||||
|
-- Group-keyed file metadata. Identity tuple `(group_id, collection, id)` — keyed
|
||||||
|
-- by the OWNING GROUP, not an app (the shared blobs belong to the group). The
|
||||||
|
-- bytes live on disk at
|
||||||
|
-- <PICLOUD_FILES_ROOT>/files/groups/<group_id>/<collection>/<id[0:2]>/<id>
|
||||||
|
-- (a `groups/` infix disjoint from the per-app `files/<app_id>/...` subtree, so
|
||||||
|
-- the orphan sweeper covers both with one walk). CASCADE on group delete (the
|
||||||
|
-- metadata dies with its group; an app delete leaves it), matching 0053/0054.
|
||||||
|
CREATE TABLE group_files (
|
||||||
|
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
collection TEXT NOT NULL,
|
||||||
|
id UUID NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
content_type TEXT NOT NULL,
|
||||||
|
size_bytes BIGINT NOT NULL,
|
||||||
|
checksum_sha256 TEXT NOT NULL, -- hex, 64 chars, lowercase
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (group_id, collection, id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- List + cursor pagination scans by (group_id, collection) — mirrors
|
||||||
|
-- idx_files_app_collection (0018).
|
||||||
|
CREATE INDEX idx_group_files_group_collection ON group_files (group_id, collection);
|
||||||
49
crates/manager-core/migrations/0056_group_triggers.sql
Normal file
49
crates/manager-core/migrations/0056_group_triggers.sql
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
-- §11 tail (v1.2 Hierarchies): group TRIGGER templates.
|
||||||
|
--
|
||||||
|
-- Until now every trigger was owned by exactly one app (`app_id NOT NULL`).
|
||||||
|
-- A group-owned trigger is a TEMPLATE: it is never dispatched directly, but
|
||||||
|
-- the dispatcher's hot-path match queries UNION it in for every descendant app
|
||||||
|
-- via the ancestor-chain CTE (live resolution, like vars/secrets/scripts and
|
||||||
|
-- §11.6 collections — no per-app materialized rows). It binds a group-owned
|
||||||
|
-- handler script and fires under each firing app's `app_id`.
|
||||||
|
--
|
||||||
|
-- Scope: EVENT kinds only (kv/docs/files/pubsub) — those are stateless at
|
||||||
|
-- dispatch. cron/queue/email carry per-instance state (last_fired_at, the
|
||||||
|
-- one-consumer advisory lock, a sealed inbound secret) and would need
|
||||||
|
-- materialization; they are rejected on a [group] at the manifest layer and
|
||||||
|
-- deferred. The CHECK here is deliberately NOT narrowed to event kinds: the
|
||||||
|
-- column allows any kind for forward-compat, the authoring gate is upstream.
|
||||||
|
--
|
||||||
|
-- Reshape mirrors 0050_group_scripts exactly (RESTRICT, not CASCADE — a
|
||||||
|
-- trigger template references a group script; a group can't be deleted out
|
||||||
|
-- from under it):
|
||||||
|
-- * `group_id` — nullable FK→groups, ON DELETE RESTRICT.
|
||||||
|
-- * `app_id` — made nullable; the exactly-one CHECK enforces app XOR group.
|
||||||
|
-- * per-app unique name index + dispatch index become per-owner partials.
|
||||||
|
|
||||||
|
ALTER TABLE triggers
|
||||||
|
ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE RESTRICT;
|
||||||
|
|
||||||
|
ALTER TABLE triggers
|
||||||
|
ALTER COLUMN app_id DROP NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE triggers
|
||||||
|
ADD CONSTRAINT triggers_owner_exactly_one
|
||||||
|
CHECK ((group_id IS NULL) <> (app_id IS NULL));
|
||||||
|
|
||||||
|
-- Per-owner name uniqueness (partial so each owner column only constrains its
|
||||||
|
-- own rows). Existing app rows keep the exact (app_id, name) uniqueness.
|
||||||
|
DROP INDEX triggers_app_name_uniq;
|
||||||
|
CREATE UNIQUE INDEX triggers_app_name_uniq
|
||||||
|
ON triggers (app_id, name) WHERE app_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX triggers_group_name_uniq
|
||||||
|
ON triggers (group_id, name) WHERE group_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Per-owner dispatch hot-path index ("all enabled triggers of kind Y for
|
||||||
|
-- owner X"). The app index keeps its name + shape (now owner-partial); a
|
||||||
|
-- parallel group index serves the chain-union's group-template lookups.
|
||||||
|
DROP INDEX idx_triggers_app_kind_enabled;
|
||||||
|
CREATE INDEX idx_triggers_app_kind_enabled
|
||||||
|
ON triggers (app_id, kind) WHERE enabled = TRUE AND app_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_triggers_group_kind_enabled
|
||||||
|
ON triggers (group_id, kind) WHERE enabled = TRUE AND group_id IS NOT NULL;
|
||||||
48
crates/manager-core/migrations/0057_group_routes.sql
Normal file
48
crates/manager-core/migrations/0057_group_routes.sql
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
-- §11 tail (v1.2 Hierarchies): group ROUTE templates.
|
||||||
|
--
|
||||||
|
-- Until now every route was owned by exactly one app (`app_id NOT NULL`).
|
||||||
|
-- A group-owned route is a TEMPLATE: it is never served directly, but the
|
||||||
|
-- in-memory RouteTable rebuild EXPANDS it into every descendant app's slice
|
||||||
|
-- via the ancestor chain (live resolution, like vars/secrets/scripts, §11.6
|
||||||
|
-- collections, and §11 trigger templates — no per-app materialized rows). It
|
||||||
|
-- binds a group-owned handler script and runs under each firing app's
|
||||||
|
-- `app_id`.
|
||||||
|
--
|
||||||
|
-- Unlike triggers (a SQL-per-event dispatch), routes are served from an
|
||||||
|
-- in-memory cache, so the inheritance is resolved at table-rebuild time, not
|
||||||
|
-- per request. The reshape, however, is identical: a polymorphic owner column.
|
||||||
|
--
|
||||||
|
-- Reshape mirrors 0056_group_triggers exactly (RESTRICT, not CASCADE — a route
|
||||||
|
-- template references a group script; a group can't be deleted out from under
|
||||||
|
-- it):
|
||||||
|
-- * `group_id` — nullable FK→groups, ON DELETE RESTRICT.
|
||||||
|
-- * `app_id` — made nullable; the exactly-one CHECK enforces app XOR group.
|
||||||
|
-- * the per-app unique binding index becomes per-owner partials.
|
||||||
|
|
||||||
|
ALTER TABLE routes
|
||||||
|
ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE RESTRICT;
|
||||||
|
|
||||||
|
ALTER TABLE routes
|
||||||
|
ALTER COLUMN app_id DROP NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE routes
|
||||||
|
ADD CONSTRAINT routes_owner_exactly_one
|
||||||
|
CHECK ((group_id IS NULL) <> (app_id IS NULL));
|
||||||
|
|
||||||
|
-- Per-owner binding uniqueness (partial so each owner column only constrains
|
||||||
|
-- its own rows). Existing app rows keep the exact binding-tuple uniqueness; a
|
||||||
|
-- group can't declare two identical templates. An app declaring a binding
|
||||||
|
-- identical to an inherited group template is a deliberate SHADOW, resolved
|
||||||
|
-- at rebuild (nearest-owner-wins) — not a DB conflict.
|
||||||
|
DROP INDEX routes_unique_binding_idx;
|
||||||
|
CREATE UNIQUE INDEX routes_unique_binding_idx
|
||||||
|
ON routes (app_id, host_kind, host, path_kind, path, COALESCE(method, ''))
|
||||||
|
WHERE app_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX routes_group_binding_idx
|
||||||
|
ON routes (group_id, host_kind, host, path_kind, path, COALESCE(method, ''))
|
||||||
|
WHERE group_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- The expansion join matches routes by owner (`r.app_id = ac.owner_app OR
|
||||||
|
-- r.group_id = ac.owner_group`). The app side already has routes_app_id_idx;
|
||||||
|
-- add the parallel group lookup index.
|
||||||
|
CREATE INDEX routes_group_id_idx ON routes (group_id) WHERE group_id IS NOT NULL;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- §11 tail (v1.2 Hierarchies): per-app opt-out of inherited group templates.
|
||||||
|
--
|
||||||
|
-- A group TRIGGER or ROUTE template inherits to every descendant app. Until now
|
||||||
|
-- a descendant could SHADOW a template (declare its own identical binding) but
|
||||||
|
-- not DECLINE one. This marker records that a specific app opts OUT of a
|
||||||
|
-- specific inherited template — the template then stops resolving for that app
|
||||||
|
-- (and only that app; a sibling subtree is unaffected).
|
||||||
|
--
|
||||||
|
-- Coarse by REFERENCE (not row id — template ids churn on re-apply): a
|
||||||
|
-- suppression names a handler SCRIPT NAME (target_kind='trigger') or a PATH
|
||||||
|
-- (target_kind='route'). A reference may decline more than one inherited
|
||||||
|
-- template (a group that bound several to the same script/path). The dispatch
|
||||||
|
-- filters gate to group-owned rows, so an app can only decline what it
|
||||||
|
-- INHERITS — never its own trigger/route, never a sibling's.
|
||||||
|
--
|
||||||
|
-- App-only: only an app suppresses (a group would just not declare the
|
||||||
|
-- template). So `app_id NOT NULL` — no polymorphic owner. It is pure app config
|
||||||
|
-- (like `extension_points`), so ON DELETE CASCADE (not the code-owner RESTRICT).
|
||||||
|
|
||||||
|
CREATE TABLE template_suppressions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
target_kind TEXT NOT NULL CHECK (target_kind IN ('trigger', 'route')),
|
||||||
|
-- A handler script name (trigger) or a path (route). Matched
|
||||||
|
-- case-insensitively for triggers (as script-name resolution is
|
||||||
|
-- elsewhere) and exactly for routes; stored as authored.
|
||||||
|
reference TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- One marker per (app, kind, reference) — re-apply is a NoOp.
|
||||||
|
CREATE UNIQUE INDEX template_suppressions_uidx
|
||||||
|
ON template_suppressions (app_id, target_kind, reference);
|
||||||
|
|
||||||
|
-- The trigger anti-join / route rebuild both look up by app_id.
|
||||||
|
CREATE INDEX template_suppressions_app_idx ON template_suppressions (app_id);
|
||||||
19
crates/manager-core/migrations/0059_sealed_templates.sql
Normal file
19
crates/manager-core/migrations/0059_sealed_templates.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
-- §11 tail (v1.2 Hierarchies): `sealed` (mandatory) group templates.
|
||||||
|
--
|
||||||
|
-- Per-app opt-out (0058_template_suppressions) made an inherited group TRIGGER
|
||||||
|
-- or ROUTE template advisory-by-default: it runs on a descendant *unless* that
|
||||||
|
-- descendant declares a `[suppress]` declining it. That is a compliance footgun
|
||||||
|
-- — a group's audit / security / rate-limit template can be silently opted out
|
||||||
|
-- of by the very tenant it is meant to police.
|
||||||
|
--
|
||||||
|
-- A `sealed` template closes the gap: the two suppression filters skip a sealed
|
||||||
|
-- row, so it fires / serves on every descendant regardless of any suppression.
|
||||||
|
-- The column is only meaningful on a group-owned template (`group_id IS NOT
|
||||||
|
-- NULL`); an app-owned row is never inherited, so sealing it is meaningless and
|
||||||
|
-- rejected at apply. Existing rows default to unsealed (the prior behaviour).
|
||||||
|
--
|
||||||
|
-- * triggers.sealed — a sealed group trigger template is never suppressible.
|
||||||
|
-- * routes.sealed — a sealed group route template is never suppressible.
|
||||||
|
|
||||||
|
ALTER TABLE triggers ADD COLUMN sealed BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
ALTER TABLE routes ADD COLUMN sealed BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
38
crates/manager-core/migrations/0060_group_suppressions.sql
Normal file
38
crates/manager-core/migrations/0060_group_suppressions.sql
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
-- §11 tail (v1.2 Hierarchies): GROUP-level template suppression.
|
||||||
|
--
|
||||||
|
-- 0058 let an APP decline an inherited group template (for that app only). A
|
||||||
|
-- group operator often wants the same opt-out for a whole subtree: "no
|
||||||
|
-- descendant of this group runs the ancestor's `audit` template." Until now
|
||||||
|
-- only an app could suppress; a group could not.
|
||||||
|
--
|
||||||
|
-- Reshape `template_suppressions` to a POLYMORPHIC owner, exactly like the
|
||||||
|
-- group triggers/routes reshape (0056/0057) and the config markers (0051/0052):
|
||||||
|
-- * `group_id` — nullable FK→groups, ON DELETE CASCADE (config, not code).
|
||||||
|
-- * `app_id` — made nullable; the exactly-one CHECK enforces app XOR group.
|
||||||
|
-- * the per-app unique index becomes per-owner partials.
|
||||||
|
--
|
||||||
|
-- A GROUP suppression declines a template the group INHERITS from a higher
|
||||||
|
-- ancestor, for the group's whole subtree. Inheritance-only still holds: the
|
||||||
|
-- dispatch filters gate to group-owned rows, and a suppression only matches on
|
||||||
|
-- the suppressing owner's ancestor chain — a group can decline what it
|
||||||
|
-- inherits, never a sibling subtree's, never its own descendants' own rows.
|
||||||
|
|
||||||
|
ALTER TABLE template_suppressions
|
||||||
|
ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE template_suppressions
|
||||||
|
ALTER COLUMN app_id DROP NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE template_suppressions
|
||||||
|
ADD CONSTRAINT template_suppressions_owner_exactly_one
|
||||||
|
CHECK ((group_id IS NULL) <> (app_id IS NULL));
|
||||||
|
|
||||||
|
DROP INDEX template_suppressions_uidx;
|
||||||
|
CREATE UNIQUE INDEX template_suppressions_app_uidx
|
||||||
|
ON template_suppressions (app_id, target_kind, reference) WHERE app_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX template_suppressions_group_uidx
|
||||||
|
ON template_suppressions (group_id, target_kind, reference) WHERE group_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- The trigger anti-join / route rebuild look up by both owners now.
|
||||||
|
CREATE INDEX template_suppressions_group_idx
|
||||||
|
ON template_suppressions (group_id) WHERE group_id IS NOT NULL;
|
||||||
21
crates/manager-core/migrations/0061_shared_triggers.sql
Normal file
21
crates/manager-core/migrations/0061_shared_triggers.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
-- §11.6 / §4.5: SHARED-collection triggers.
|
||||||
|
--
|
||||||
|
-- A §11.6 shared collection is group-owned and written by any subtree app via
|
||||||
|
-- the `kv::shared_collection(...)` handles. Until now such writes fired NO
|
||||||
|
-- trigger (the "group trigger has no app to watch" deferral): the per-app
|
||||||
|
-- trigger dispatch keys on the writer app's OWN collections, and a shared
|
||||||
|
-- collection lives in a distinct group-keyed store.
|
||||||
|
--
|
||||||
|
-- A SHARED trigger closes that gap: a group-owned trigger template marked
|
||||||
|
-- `shared = true` watches the group's shared collection (not per-app
|
||||||
|
-- collections). On a shared-collection write the emitter matches these triggers
|
||||||
|
-- by the OWNING GROUP's chain and runs the handler under the WRITER app
|
||||||
|
-- (`cx.app_id`) — the same "group template runs under the firing app" model.
|
||||||
|
--
|
||||||
|
-- The `shared` marker is the namespace boundary: a `shared = false` trigger
|
||||||
|
-- (the default, incl. every existing row) watches per-app collections exactly
|
||||||
|
-- as before; a `shared = true` trigger watches shared collections only. The two
|
||||||
|
-- never cross (the per-app match queries add `AND t.shared = FALSE`, the shared
|
||||||
|
-- match queries `AND t.shared = TRUE`).
|
||||||
|
|
||||||
|
ALTER TABLE triggers ADD COLUMN shared BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- §4.5 M5: STATEFUL group trigger templates (cron / queue / email) via
|
||||||
|
-- per-descendant MATERIALIZATION.
|
||||||
|
--
|
||||||
|
-- The event kinds (kv/docs/files/pubsub) resolve a group template LIVE at
|
||||||
|
-- dispatch via the chain CTE — no per-app rows. The stateful kinds can't:
|
||||||
|
-- * cron needs a per-app `last_fired_at` the scheduler advances;
|
||||||
|
-- * queue needs the one-consumer-per-(app_id, queue_name) advisory lock;
|
||||||
|
-- * email needs a per-app SEALED inbound secret.
|
||||||
|
-- So a group stateful template is instead MATERIALIZED into an app-owned
|
||||||
|
-- trigger row per descendant app (created/removed as the tree changes), and the
|
||||||
|
-- unchanged dispatch paths (scheduler, queue consumer, email webhook) only ever
|
||||||
|
-- see app-owned rows.
|
||||||
|
--
|
||||||
|
-- `materialized_from` links a managed app-owned row back to the group template
|
||||||
|
-- it was expanded from: NULL = a hand-authored trigger; set = a reconciler-owned
|
||||||
|
-- copy (dropped + re-created as descendants come and go). ON DELETE CASCADE so
|
||||||
|
-- deleting the group template (RESTRICT-guarded elsewhere) or the group cleans
|
||||||
|
-- up the copies; a plain app delete CASCADEs the row away via `triggers.app_id`.
|
||||||
|
|
||||||
|
ALTER TABLE triggers
|
||||||
|
ADD COLUMN materialized_from UUID REFERENCES triggers(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- The reconciler looks up existing copies by their source template.
|
||||||
|
CREATE INDEX idx_triggers_materialized_from
|
||||||
|
ON triggers (materialized_from) WHERE materialized_from IS NOT NULL;
|
||||||
12
crates/manager-core/migrations/0063_materialized_unique.sql
Normal file
12
crates/manager-core/migrations/0063_materialized_unique.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
-- §4.5 M5: exactly one materialized copy per (descendant app, source template).
|
||||||
|
--
|
||||||
|
-- The materialization reconciler (materialize::rematerialize_stateful_templates)
|
||||||
|
-- runs on every tree/apply mutation, and two of them can run concurrently (e.g.
|
||||||
|
-- two apps created in parallel each trigger a full reconcile). Both compute the
|
||||||
|
-- same "missing" (app, template) pair and both try to insert the copy — without
|
||||||
|
-- a constraint that races into a DUPLICATE. This partial unique index makes the
|
||||||
|
-- second insert a no-op (the reconciler uses `ON CONFLICT DO NOTHING`), so a
|
||||||
|
-- copy is idempotent under concurrency.
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX triggers_materialized_uidx
|
||||||
|
ON triggers (app_id, materialized_from) WHERE materialized_from IS NOT NULL;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- §11.6 (cont., v1.2 Hierarchies): admit 'topic' and 'queue' as shared-
|
||||||
|
-- collection kinds.
|
||||||
|
--
|
||||||
|
-- D2 (shared topics) + D3 (shared queues) extend the group shared-collection
|
||||||
|
-- machinery from data stores (kv/docs/files) to the two event/messaging kinds.
|
||||||
|
-- A shared TOPIC is storeless — it is a group-scoped publish NAMESPACE watched
|
||||||
|
-- by `shared = true` group pubsub triggers (no per-kind storage table). A shared
|
||||||
|
-- QUEUE gets its group-keyed message store in 0065 (D3). This migration only
|
||||||
|
-- widens the marker allow-list; both kinds route through the same owner-generic
|
||||||
|
-- reconcile + `resolve_owning_group` path as the data kinds.
|
||||||
|
|
||||||
|
-- Widen the marker kind allow-list (auto-named `group_collections_kind_check`,
|
||||||
|
-- an inline column CHECK), matching 0054/0055.
|
||||||
|
ALTER TABLE group_collections
|
||||||
|
DROP CONSTRAINT group_collections_kind_check,
|
||||||
|
ADD CONSTRAINT group_collections_kind_check
|
||||||
|
CHECK (kind IN ('kv', 'docs', 'files', 'topic', 'queue'));
|
||||||
46
crates/manager-core/migrations/0065_group_queues.sql
Normal file
46
crates/manager-core/migrations/0065_group_queues.sql
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
-- §11.6 D3: group-shared durable queues.
|
||||||
|
--
|
||||||
|
-- The queue counterpart to the group_kv/docs/files shared stores (0053-0055).
|
||||||
|
-- A group declares a `queue` shared collection (kind admitted in 0064); any
|
||||||
|
-- subtree app enqueues into ONE group-owned store via
|
||||||
|
-- `queue::shared_collection(name).enqueue(...)`. Consumption is by COMPETING
|
||||||
|
-- CONSUMERS: a group `[[triggers.queue]] shared = true` template materializes a
|
||||||
|
-- consumer copy per descendant app (like the M5 stateful templates), and all
|
||||||
|
-- copies claim from this shared store with FOR UPDATE SKIP LOCKED — each message
|
||||||
|
-- delivered at-most-once across the whole subtree, scaling horizontally.
|
||||||
|
--
|
||||||
|
-- Identity tuple: (group_id, collection). Keyed by the OWNING GROUP, not an app
|
||||||
|
-- (the shared rows belong to the group). CASCADE on group delete (data dies with
|
||||||
|
-- its group; an app delete leaves it), matching the other group stores. Mirrors
|
||||||
|
-- queue_messages (0034) column-for-column, swapping (app_id, queue_name) for
|
||||||
|
-- (group_id, collection).
|
||||||
|
|
||||||
|
CREATE TABLE group_queue_messages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
collection TEXT NOT NULL,
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
enqueued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deliver_after TIMESTAMPTZ NULL,
|
||||||
|
claim_token UUID NULL,
|
||||||
|
claimed_at TIMESTAMPTZ NULL,
|
||||||
|
attempt INT NOT NULL DEFAULT 0,
|
||||||
|
max_attempts INT NOT NULL DEFAULT 3,
|
||||||
|
enqueued_by_principal UUID NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Dispatch hot path: scan for unclaimed messages in (group_id, collection)
|
||||||
|
-- order by enqueued_at (the NOW() deliver_after filter is applied on the small
|
||||||
|
-- partial result, as in 0034).
|
||||||
|
CREATE INDEX idx_group_queue_messages_dispatch
|
||||||
|
ON group_queue_messages (group_id, collection, enqueued_at)
|
||||||
|
WHERE claim_token IS NULL;
|
||||||
|
|
||||||
|
-- depth() / depth_pending() helpers.
|
||||||
|
CREATE INDEX idx_group_queue_messages_group_collection
|
||||||
|
ON group_queue_messages (group_id, collection);
|
||||||
|
|
||||||
|
-- Reclaim-task scan: currently-leased messages past their visibility timeout.
|
||||||
|
CREATE INDEX idx_group_queue_messages_claimed
|
||||||
|
ON group_queue_messages (claimed_at)
|
||||||
|
WHERE claim_token IS NOT NULL;
|
||||||
39
crates/manager-core/migrations/0066_owner_project.sql
Normal file
39
crates/manager-core/migrations/0066_owner_project.sql
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
-- Phase 5 / M3 (v1.2 Hierarchies): multi-repo single-owner ownership (§7).
|
||||||
|
--
|
||||||
|
-- A group node is authoritatively MANAGED by at most one project-root (the
|
||||||
|
-- repo whose manifest declares it as something it applies, not merely
|
||||||
|
-- references). `groups.owner_project` has carried this seam since 0047 as an
|
||||||
|
-- inert, FK-less UUID. M3 makes it a real reference: a `projects` table keyed
|
||||||
|
-- by a stable, gitignored project key the CLI mints in `.picloud/`, and a
|
||||||
|
-- foreign key from `groups.owner_project` to it.
|
||||||
|
--
|
||||||
|
-- Ownership is recorded at GROUP granularity; apps inherit their owning project
|
||||||
|
-- from their group (apps are never claimed directly). A NULL `owner_project`
|
||||||
|
-- means the node is UI/API-owned — no manifest fights it (§7.5). First apply
|
||||||
|
-- claims; a second project's apply to an owned node is refused unless
|
||||||
|
-- `--takeover` (group-admin gated). Ownership ⟂ RBAC: owning the manifest does
|
||||||
|
-- NOT grant write — the actor still needs the usual group capabilities (§7.4).
|
||||||
|
--
|
||||||
|
-- ON DELETE SET NULL: deleting a project row (not something the platform does
|
||||||
|
-- today) reverts its nodes to UI-owned rather than cascading away real groups.
|
||||||
|
|
||||||
|
CREATE TABLE projects (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
-- Stable, opaque key minted by `pic init` and persisted (gitignored) in the
|
||||||
|
-- repo's `.picloud/`. The CLI presents it on every tree apply; the server
|
||||||
|
-- maps it to this row (upsert-by-key), so the same repo keeps the same
|
||||||
|
-- project identity across clones/CI without committing any server id.
|
||||||
|
key TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Promote the inert `groups.owner_project` (0047) to a real FK now that the
|
||||||
|
-- referent exists. Existing rows are all NULL (no projects yet), so this adds
|
||||||
|
-- no validation burden.
|
||||||
|
ALTER TABLE groups
|
||||||
|
ADD CONSTRAINT groups_owner_project_fkey
|
||||||
|
FOREIGN KEY (owner_project) REFERENCES projects(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- The ownership reconcile reads "which groups does project P own?" (to find
|
||||||
|
-- owned-but-undeclared nodes for structural prune) and "who owns group G?".
|
||||||
|
CREATE INDEX groups_owner_project_idx ON groups (owner_project);
|
||||||
@@ -45,6 +45,11 @@ pub struct AdminsState {
|
|||||||
/// Capability gate: every endpoint here requires
|
/// Capability gate: every endpoint here requires
|
||||||
/// `InstanceManageUsers` (owner / admin).
|
/// `InstanceManageUsers` (owner / admin).
|
||||||
pub authz: Arc<dyn AuthzRepo>,
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
|
/// Audit 2026-06-11 (PrincipalCache revocation-lag) — evicted on
|
||||||
|
/// password change / deactivation so a just-revoked session or
|
||||||
|
/// API key stops authenticating immediately instead of lingering
|
||||||
|
/// for the cache TTL.
|
||||||
|
pub principal_cache: Arc<crate::auth_middleware::PrincipalCache>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn admins_router(state: AdminsState) -> Router {
|
pub fn admins_router(state: AdminsState) -> Router {
|
||||||
@@ -233,11 +238,33 @@ async fn patch_admin(
|
|||||||
validate_password(new_password)?;
|
validate_password(new_password)?;
|
||||||
let hash = hash_password(new_password).map_err(|e| AdminApiError::Hash(e.to_string()))?;
|
let hash = hash_password(new_password).map_err(|e| AdminApiError::Hash(e.to_string()))?;
|
||||||
latest = Some(state.users.update_password_hash(id, &hash).await?);
|
latest = Some(state.users.update_password_hash(id, &hash).await?);
|
||||||
// Best practice: rotating your own password should still keep
|
// Audit 2026-06-11 H-E1 — a password change invalidates both
|
||||||
// your session alive, so we don't wipe sessions here. (If we
|
// credential surfaces for the target user (sessions + API keys),
|
||||||
// wanted "log everyone else out on password change", that'd be
|
// matching the CLI `reset-password` flow and the deactivation
|
||||||
// a `delete_for_user` + re-issue current session. Out of scope
|
// path below. A self-change therefore logs the caller out, and
|
||||||
// for the initial cut.)
|
// the dashboard handles the subsequent 401 by redirecting to the
|
||||||
|
// login screen. The previous behavior kept all sessions live,
|
||||||
|
// which meant a hijacked session that triggered a password
|
||||||
|
// change retained its grip after the rotation.
|
||||||
|
if let Err(err) = state.sessions.delete_for_user(id).await {
|
||||||
|
tracing::error!(?err, "failed to delete sessions on password change");
|
||||||
|
}
|
||||||
|
match state.keys.expire_all_for_user(id).await {
|
||||||
|
Ok(n) => {
|
||||||
|
if n > 0 {
|
||||||
|
tracing::info!(user_id = %id, expired = n, "expired api keys on password change");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(?err, "failed to expire api keys on password change");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Audit 2026-06-11 (PrincipalCache revocation-lag) — the DB
|
||||||
|
// writes above are best-effort by design (a blip must not undo
|
||||||
|
// the rotation), so evicting the in-process cache is what makes
|
||||||
|
// the rotation take effect on the very next request rather than
|
||||||
|
// after the cache TTL.
|
||||||
|
state.principal_cache.evict_user(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(email_patch) = input.email.as_ref() {
|
if let Some(email_patch) = input.email.as_ref() {
|
||||||
@@ -307,6 +334,11 @@ async fn patch_admin(
|
|||||||
tracing::error!(?err, "failed to expire api keys for deactivated admin");
|
tracing::error!(?err, "failed to expire api keys for deactivated admin");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Audit 2026-06-11 (PrincipalCache revocation-lag) — evict
|
||||||
|
// cached principals so a deactivated admin's session / API
|
||||||
|
// keys stop authenticating immediately instead of lingering
|
||||||
|
// for the cache TTL window.
|
||||||
|
state.principal_cache.evict_user(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user