From b0f7b72dd60377892156fe66d2067847f39c1ec4 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Thu, 11 Jun 2026 17:20:34 +0200 Subject: [PATCH] fix(review): close 5 follow-up gaps from Stage 6 audit re-review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - F-T-003 actually closed: clients/typescript/src/subscribe.ts now bounds the 401-refresh loop at 3 consecutive refusals, surfaces an onError describing the loop, and resets on any successful stream open. New test covers the cap. The Stage 2 dashboard fix only addressed adminRequest in dashboard/src/lib/api.ts; the originally- cited TS client file was untouched. - users/invitations subtab dropped the redundant api.apps.get fetch the other 5 subtabs already shed in Stage 6. - Queue visibility-timeout validator pulled out as validate_queue_visibility_timeout, with two thresholds: hard-reject below MIN_QUEUE_VISIBILITY_TIMEOUT_SECS (30s — catches typos), warn- log between MIN and SAFE_VISIBILITY_VS_EXEC_BUDGET_SECS (300s) so operators see when their visibility is below the dispatcher's per-message executor budget. Stage 6 only had the hard floor; the reviewer caught that a 60s handler still races a 30s visibility even after the floor. Four new unit tests cover none/above-safe/ between/below-min. - pic dead-letters count subcommand: cheap headless probe for unresolved DL totals, parallels the dashboard's badge query. - pic dead-letters replay now has a happy-path integration test (replay_against_real_dl_row_succeeds): inserts a synthetic DL row directly via the rust-postgres sync driver (added as dev-dep), drives `pic dead-letters replay`, asserts the row is resolved with reason=replayed and count drops back to 0. Plus a count smoke test. All 75 CLI integration tests + 16 TS client tests + 4 new visibility-timeout unit tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 368 +++++++++++++++--- clients/typescript/src/subscribe.ts | 23 +- clients/typescript/tests/subscribe.test.ts | 26 ++ crates/manager-core/src/triggers_api.rs | 101 ++++- crates/picloud-cli/Cargo.toml | 6 + crates/picloud-cli/src/client.rs | 17 + crates/picloud-cli/src/cmds/dead_letters.rs | 12 + crates/picloud-cli/src/main.rs | 10 + crates/picloud-cli/tests/dead_letters.rs | 130 +++++++ .../[slug]/users/invitations/+page.svelte | 12 +- 10 files changed, 628 insertions(+), 77 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c9c897b..6734c52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,7 +8,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -20,7 +20,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -139,7 +139,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures", + "cpufeatures 0.2.17", "password-hash", ] @@ -324,7 +324,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -336,6 +336,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bstr" version = "1.12.1" @@ -399,6 +408,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.44" @@ -421,7 +441,7 @@ checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb" dependencies = [ "chrono", "chrono-tz-build", - "phf", + "phf 0.11.3", ] [[package]] @@ -431,7 +451,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1" dependencies = [ "parse-zoneinfo", - "phf", + "phf 0.11.3", "phf_codegen", ] @@ -441,7 +461,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] @@ -485,6 +505,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.5" @@ -506,6 +532,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "const-random" version = "0.1.18" @@ -551,6 +583,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.4.0" @@ -609,6 +650,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ctr" version = "0.9.2" @@ -618,6 +668,15 @@ dependencies = [ "cipher", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "data-encoding" version = "2.11.0" @@ -630,7 +689,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "pem-rfc7468", "zeroize", ] @@ -662,12 +721,24 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "directories" version = "5.0.1" @@ -769,6 +840,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + [[package]] name = "fastrand" version = "2.4.1" @@ -948,7 +1025,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "wasm-bindgen", ] @@ -975,6 +1052,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -1033,7 +1111,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", ] [[package]] @@ -1042,7 +1120,16 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", ] [[package]] @@ -1110,6 +1197,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.9.0" @@ -1501,7 +1597,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", ] [[package]] @@ -1529,7 +1635,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -1628,6 +1734,24 @@ dependencies = [ "libm", ] +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1748,7 +1872,17 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "phf_shared", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared 0.13.1", + "serde", ] [[package]] @@ -1758,7 +1892,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.11.3", ] [[package]] @@ -1767,7 +1901,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "phf_shared", + "phf_shared 0.11.3", "rand 0.8.6", ] @@ -1780,6 +1914,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "picloud" version = "1.1.9" @@ -1791,14 +1934,14 @@ dependencies = [ "chrono", "figment", "hex", - "hmac", + "hmac 0.12.1", "picloud-executor-core", "picloud-manager-core", "picloud-orchestrator-core", "picloud-shared", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sqlx", "thiserror 1.0.69", "tokio", @@ -1820,6 +1963,7 @@ dependencies = [ "directories", "libc", "picloud-shared", + "postgres", "predicates", "reqwest", "rpassword", @@ -1828,6 +1972,7 @@ dependencies = [ "tempfile", "tokio", "toml", + "uuid", ] [[package]] @@ -1892,7 +2037,7 @@ dependencies = [ "data-encoding", "futures", "hex", - "hmac", + "hmac 0.12.1", "lettre", "picloud-executor-core", "picloud-orchestrator-core", @@ -1901,7 +2046,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sqlx", "thiserror 1.0.69", "tokio", @@ -1953,11 +2098,11 @@ dependencies = [ "async-trait", "base64", "chrono", - "hmac", + "hmac 0.12.1", "rand 0.8.6", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "thiserror 1.0.69", "tokio", "tracing", @@ -2010,7 +2155,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -2021,6 +2166,52 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +[[package]] +name = "postgres" +version = "0.19.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacf632d0554ff75f58183694f41dc8999c8a3a43a386994d0ec2d034f1dfbe1" +dependencies = [ + "bytes", + "fallible-iterator", + "futures-util", + "log", + "tokio", + "tokio-postgres", +] + +[[package]] +name = "postgres-protocol" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56201207dac53e2f38e848e31b4b91616a6bb6e0c7205b77718994a7f49e70fc" +dependencies = [ + "base64", + "byteorder", + "bytes", + "fallible-iterator", + "hmac 0.13.0", + "md-5 0.11.0", + "memchr", + "rand 0.10.1", + "sha2 0.11.0", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc729a129e682e8d24170cd30ae1aa01b336b096cbb56df6d534ffec133d186" +dependencies = [ + "bytes", + "fallible-iterator", + "postgres-protocol", + "serde_core", + "serde_json", + "uuid", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2220,6 +2411,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2258,6 +2460,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2426,8 +2634,8 @@ version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid", - "digest", + "const-oid 0.9.6", + "digest 0.10.7", "num-bigint-dig", "num-integer", "num-traits", @@ -2626,8 +2834,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -2637,8 +2845,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -2672,7 +2891,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] @@ -2784,7 +3003,7 @@ dependencies = [ "rustls", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "thiserror 2.0.18", "tokio", @@ -2823,7 +3042,7 @@ dependencies = [ "quote", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sqlx-core", "sqlx-mysql", "sqlx-postgres", @@ -2846,7 +3065,7 @@ dependencies = [ "bytes", "chrono", "crc", - "digest", + "digest 0.10.7", "dotenvy", "either", "futures-channel", @@ -2856,10 +3075,10 @@ dependencies = [ "generic-array", "hex", "hkdf", - "hmac", + "hmac 0.12.1", "itoa", "log", - "md-5", + "md-5 0.10.6", "memchr", "once_cell", "percent-encoding", @@ -2867,14 +3086,14 @@ dependencies = [ "rsa", "serde", "sha1", - "sha2", + "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", "thiserror 2.0.18", "tracing", "uuid", - "whoami", + "whoami 1.6.1", ] [[package]] @@ -2896,24 +3115,24 @@ dependencies = [ "futures-util", "hex", "hkdf", - "hmac", + "hmac 0.12.1", "home", "itoa", "log", - "md-5", + "md-5 0.10.6", "memchr", "once_cell", "rand 0.8.6", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", "thiserror 2.0.18", "tracing", "uuid", - "whoami", + "whoami 1.6.1", ] [[package]] @@ -3178,6 +3397,32 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-postgres" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dd8df5ef180f6364759a6f00f7aadda4fbbac86cdee37480826a6ff9f3574ce" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf 0.13.1", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.10.1", + "socket2", + "tokio", + "tokio-util", + "whoami 2.1.2", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -3436,7 +3681,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -3530,6 +3775,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" @@ -3554,6 +3808,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + [[package]] name = "wasm-bindgen" version = "0.2.122" @@ -3688,7 +3951,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" dependencies = [ "libredox", - "wasite", + "wasite 0.1.0", +] + +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite 1.0.2", + "web-sys", ] [[package]] diff --git a/clients/typescript/src/subscribe.ts b/clients/typescript/src/subscribe.ts index 129bd34..e69e5f2 100644 --- a/clients/typescript/src/subscribe.ts +++ b/clients/typescript/src/subscribe.ts @@ -29,6 +29,14 @@ export function subscribeTopic( let lastEventId: string | undefined; let controller: AbortController | null = null; let backoffTimer: ReturnType | 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 = () => { stopped = true; @@ -61,6 +69,17 @@ export function subscribeTopic( } 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. const fresh = opts.onTokenExpired ? await opts.onTokenExpired() : null; if (fresh) { @@ -79,8 +98,10 @@ export function subscribeTopic( 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; + consecutive401 = 0; try { await readStream(res.body, (frame) => { if (frame.id !== undefined) lastEventId = frame.id; diff --git a/clients/typescript/tests/subscribe.test.ts b/clients/typescript/tests/subscribe.test.ts index cf3909a..1a4b7f0 100644 --- a/clients/typescript/tests/subscribe.test.ts +++ b/clients/typescript/tests/subscribe.test.ts @@ -96,4 +96,30 @@ describe('subscribe', () => { await vi.waitFor(() => expect(onError).toHaveBeenCalled()); 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'); + }); }); diff --git a/crates/manager-core/src/triggers_api.rs b/crates/manager-core/src/triggers_api.rs index b2eaac2..f531494 100644 --- a/crates/manager-core/src/triggers_api.rs +++ b/crates/manager-core/src/triggers_api.rs @@ -34,12 +34,51 @@ use crate::trigger_repo::{ TriggerRepo, TriggerRepoError, }; -/// Minimum allowed queue visibility timeout. Anything shorter races the -/// dispatcher's per-message executor budget; reclaim would re-deliver -/// the message before the handler returned. Picked to outlast a slow -/// handler by a comfortable margin. +/// Hard floor on queue visibility timeout. Anything shorter is a typo +/// in practice (the dispatcher itself ticks every 100ms; reclaim ticks +/// every 30s; an executor needs more wall-clock than this to do useful +/// work). Reject below this with a 422 + actionable error. const MIN_QUEUE_VISIBILITY_TIMEOUT_SECS: u32 = 30; +/// Soft warning ceiling: when the configured visibility is shorter than +/// the dispatcher's per-message executor budget (default 300s, see +/// `DEFAULT_ASYNC_EXEC_TIMEOUT` in dispatcher.rs), a handler that runs +/// for the full budget will be reclaimed mid-execution and the message +/// double-delivered. Don't reject — short handlers are a valid choice +/// — but log loudly so the operator can see the mismatch. +const SAFE_VISIBILITY_VS_EXEC_BUDGET_SECS: u32 = 300; + +/// Validate the script-supplied visibility timeout. Hard-rejects below +/// [`MIN_QUEUE_VISIBILITY_TIMEOUT_SECS`]; warn-logs (does not reject) +/// when in `[MIN, SAFE)` because a handler running past `visibility_secs` +/// will get double-delivered when reclaim sweeps the stale claim. +/// +/// Extracted from the handler so it's unit-testable without spinning up +/// the full Axum harness. +fn validate_queue_visibility_timeout(secs: Option) -> Result<(), TriggersApiError> { + let Some(s) = secs else { + return Ok(()); // None → fall back to TriggerConfig's default. + }; + if s < MIN_QUEUE_VISIBILITY_TIMEOUT_SECS { + return Err(TriggersApiError::Invalid(format!( + "visibility_timeout_secs must be >= {MIN_QUEUE_VISIBILITY_TIMEOUT_SECS} \ + (shorter than the dispatcher's tick + reclaim cadence; \ + a handler can't return before reclaim fires)" + ))); + } + if s < SAFE_VISIBILITY_VS_EXEC_BUDGET_SECS { + tracing::warn!( + visibility_timeout_secs = s, + executor_budget_secs = SAFE_VISIBILITY_VS_EXEC_BUDGET_SECS, + "queue trigger visibility_timeout_secs is below the dispatcher's per-message \ + executor budget; a handler that runs longer than {s}s will be reclaimed \ + mid-execution and the message double-delivered. Either raise visibility or \ + accept the at-least-once semantics for this queue." + ); + } + Ok(()) +} + #[derive(Clone)] pub struct TriggersState { pub triggers: Arc, @@ -573,21 +612,7 @@ async fn create_queue_trigger( "queue_name must not be empty".into(), )); } - // Reject obviously-too-short visibility timeouts. The dispatcher - // budgets up to ~5 minutes of executor wall-clock per message - // (DEFAULT_ASYNC_EXEC_TIMEOUT in dispatcher.rs); a 10s visibility - // timeout would race the executor and cause the reclaim task to - // re-deliver the message before the first handler returned, so the - // queue would silently double-deliver. The audit's Low finding. - if let Some(secs) = input.visibility_timeout_secs { - if secs < MIN_QUEUE_VISIBILITY_TIMEOUT_SECS { - return Err(TriggersApiError::Invalid(format!( - "visibility_timeout_secs must be >= {MIN_QUEUE_VISIBILITY_TIMEOUT_SECS} \ - (shorter than the dispatcher's per-message executor budget; \ - reclaim would race the handler)" - ))); - } - } + validate_queue_visibility_timeout(input.visibility_timeout_secs)?; validate_trigger_target(&*s.scripts, app_id, input.script_id).await?; let req = crate::trigger_repo::CreateQueueTrigger { @@ -736,6 +761,44 @@ mod tests { use super::*; use crate::app_repo::{AppLookup, AppRepository}; + + #[test] + fn visibility_timeout_none_passes() { + assert!(validate_queue_visibility_timeout(None).is_ok()); + } + + #[test] + fn visibility_timeout_above_safe_passes() { + // ≥ 300s: safely above the executor budget, no warn. + assert!(validate_queue_visibility_timeout(Some(600)).is_ok()); + assert!(validate_queue_visibility_timeout(Some(300)).is_ok()); + } + + #[test] + fn visibility_timeout_between_min_and_safe_warns_but_passes() { + // [30, 300): valid but the operator should know. We assert the + // call succeeds; the warn-log emission is a side effect tracing + // would surface in real ops — covered visually, not asserted. + assert!(validate_queue_visibility_timeout(Some(60)).is_ok()); + assert!(validate_queue_visibility_timeout(Some(MIN_QUEUE_VISIBILITY_TIMEOUT_SECS)).is_ok()); + } + + #[test] + fn visibility_timeout_below_min_rejected() { + for too_short in [0u32, 1, 5, 10, MIN_QUEUE_VISIBILITY_TIMEOUT_SECS - 1] { + let err = validate_queue_visibility_timeout(Some(too_short)).unwrap_err(); + match err { + TriggersApiError::Invalid(msg) => { + assert!( + msg.contains(&MIN_QUEUE_VISIBILITY_TIMEOUT_SECS.to_string()), + "error should cite the minimum: {msg}" + ); + } + other => panic!("expected Invalid for {too_short}s, got {other:?}"), + } + } + } + use crate::trigger_repo::{ CreateCronTrigger, CreateEmailTrigger, CreateFilesTrigger, CreatePubsubTrigger, DeadLetterTriggerMatch, DocsTriggerMatch, EmailInboundTarget, FilesTriggerMatch, diff --git a/crates/picloud-cli/Cargo.toml b/crates/picloud-cli/Cargo.toml index 85ad27b..57dcfec 100644 --- a/crates/picloud-cli/Cargo.toml +++ b/crates/picloud-cli/Cargo.toml @@ -40,3 +40,9 @@ predicates = "3" tempfile = "3" reqwest = { workspace = true, features = ["json", "blocking"] } libc = "0.2" +# Synchronous Postgres driver used by the dead-letters integration test +# to inject a row the replay path can act on. Smaller compile cost than +# pulling sqlx into the CLI tree, and the existing tests are +# blocking-style anyway. +postgres = { version = "0.19", features = ["with-uuid-1", "with-serde_json-1"] } +uuid = { version = "1", features = ["v4"] } diff --git a/crates/picloud-cli/src/client.rs b/crates/picloud-cli/src/client.rs index 5e0920c..b2a6979 100644 --- a/crates/picloud-cli/src/client.rs +++ b/crates/picloud-cli/src/client.rs @@ -399,6 +399,18 @@ impl Client { decode(resp).await } + /// `GET /api/v1/admin/apps/{id}/dead_letters/count` + pub async fn dead_letters_count(&self, app: &str) -> Result { + let resp = self + .request( + Method::GET, + &format!("/api/v1/admin/apps/{app}/dead_letters/count"), + ) + .send() + .await?; + decode(resp).await + } + /// `GET /api/v1/admin/apps/{id}/dead_letters?unresolved={bool}&limit={n}` pub async fn dead_letters_list( &self, @@ -647,6 +659,11 @@ pub struct TriggerDto { pub details: Value, } +#[derive(Debug, Deserialize)] +pub struct DeadLetterCountDto { + pub unresolved: i64, +} + #[derive(Debug, Deserialize)] pub struct DeadLetterListDto { pub dead_letters: Vec, diff --git a/crates/picloud-cli/src/cmds/dead_letters.rs b/crates/picloud-cli/src/cmds/dead_letters.rs index 9759e91..a15a7f0 100644 --- a/crates/picloud-cli/src/cmds/dead_letters.rs +++ b/crates/picloud-cli/src/cmds/dead_letters.rs @@ -6,6 +6,18 @@ use crate::client::Client; use crate::config; use crate::output::{KvBlock, OutputMode, Table}; +/// `pic dead-letters count --app ` — print the unresolved DL +/// count for an app. Useful for headless probes (alerting / dashboards). +pub async fn count(app: &str, mode: OutputMode) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let resp = client.dead_letters_count(app).await?; + let mut table = Table::new(["unresolved"]); + table.row([resp.unresolved.to_string()]); + table.print(mode); + Ok(()) +} + pub async fn ls(app: &str, unresolved: bool, limit: u32, mode: OutputMode) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; diff --git a/crates/picloud-cli/src/main.rs b/crates/picloud-cli/src/main.rs index 6025639..a0b2186 100644 --- a/crates/picloud-cli/src/main.rs +++ b/crates/picloud-cli/src/main.rs @@ -466,6 +466,13 @@ enum TriggersCmd { #[derive(Subcommand)] enum DeadLettersCmd { + /// Print the unresolved DL count. Cheap probe for alerting / + /// dashboards — no row payloads, just the bare integer. + Count { + #[arg(long)] + app: String, + }, + /// List dead-letter rows for an app. Ls { #[arg(long)] @@ -822,6 +829,9 @@ async fn main() -> ExitCode { Cmd::Triggers { cmd: TriggersCmd::CreateFromJson { app, kind, body }, } => cmds::triggers::create_from_json(&app, &kind, &body, mode).await, + Cmd::DeadLetters { + cmd: DeadLettersCmd::Count { app }, + } => cmds::dead_letters::count(&app, mode).await, Cmd::DeadLetters { cmd: DeadLettersCmd::Ls { diff --git a/crates/picloud-cli/tests/dead_letters.rs b/crates/picloud-cli/tests/dead_letters.rs index 10780ba..43ad5b2 100644 --- a/crates/picloud-cli/tests/dead_letters.rs +++ b/crates/picloud-cli/tests/dead_letters.rs @@ -46,6 +46,136 @@ fn ls_empty_app_succeeds() { assert_eq!(stdout.lines().count(), 1, "fresh app has no DL rows"); } +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn count_returns_unresolved_total() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let slug = common::unique_slug("dl-count"); + common::pic_as(&env) + .args(["apps", "create", &slug]) + .assert() + .success(); + let _guard = AppGuard::new(&env.url, &env.token, &slug); + + let out = common::pic_as(&env) + .args(["dead-letters", "count", "--app", &slug]) + .output() + .expect("dl count"); + assert!(out.status.success(), "count failed: {out:?}"); + let stdout = String::from_utf8(out.stdout).unwrap(); + let header = stdout.lines().next().expect("header"); + assert_eq!(common::cells(header), vec!["unresolved"]); + let row = stdout.lines().nth(1).expect("data row"); + assert_eq!(common::cells(row), vec!["0"]); +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn replay_against_real_dl_row_succeeds() { + use postgres::{Client as PgClient, NoTls}; + use uuid::Uuid; + + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let (script_id_str, guard) = common::deploy_fixture(&env, "dl-replay", "hello.rhai"); + let app_slug = guard.slug().to_string(); + + // Need the app's UUID to inject the DL row. Resolve via the admin + // GET — picloud accepts either slug or id, returns both. + let app_id: String = reqwest::blocking::Client::new() + .get(format!("{}/api/v1/admin/apps/{}", env.url, app_slug)) + .bearer_auth(&env.token) + .send() + .expect("apps_get") + .json::() + .expect("apps_get json")["id"] + .as_str() + .expect("app id") + .to_string(); + + // Direct INSERT — no admin endpoint creates DL rows; the real ones + // come from the dispatcher's retry-exhaust path, which would take + // minutes to drive. The replay path doesn't care how the row got + // there, only that it exists, belongs to this app, and is + // unresolved. + let db_url = + std::env::var("DATABASE_URL").expect("DATABASE_URL required for replay happy-path test"); + let mut pg = PgClient::connect(&db_url, NoTls).expect("postgres connect"); + let dl_id = Uuid::new_v4(); + let app_uuid = Uuid::parse_str(&app_id).expect("app uuid"); + let script_uuid = Uuid::parse_str(&script_id_str).expect("script uuid"); + // Payload shape must round-trip through the dispatcher: source="kv" + // matches a KV TriggerEvent, op="set" is a KvEventOp, and the + // service_kind=`kv` outbox row gets routed by resolve_trigger. + let payload = serde_json::json!({ + "source": "kv", + "op": "set", + "kv": { + "collection": "x", + "key": "y", + "value": { "v": 1 } + } + }); + pg.execute( + "INSERT INTO dead_letters ( + id, app_id, original_event_id, source, op, trigger_id, script_id, + payload, attempt_count, first_attempt_at, last_attempt_at, last_error + ) VALUES ($1, $2, $3, 'kv', 'set', NULL, $4, $5, 1, NOW(), NOW(), 'forced')", + &[&dl_id, &app_uuid, &Uuid::new_v4(), &script_uuid, &payload], + ) + .expect("insert synthetic DL row"); + + // Verify CLI count picks the row up. + let out = common::pic_as(&env) + .args(["dead-letters", "count", "--app", &app_slug]) + .output() + .expect("dl count"); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!( + stdout.lines().nth(1) == Some("1"), + "count should be 1: {stdout}" + ); + + // Replay — pic exits 0, the server resolves the row with reason=replayed. + common::pic_as(&env) + .args([ + "dead-letters", + "replay", + "--app", + &app_slug, + &dl_id.to_string(), + ]) + .assert() + .success() + .stdout(predicate::str::contains("Replayed dead-letter")); + + // Verify the row is now resolved with reason=replayed. + let row = pg + .query_one( + "SELECT resolution FROM dead_letters WHERE id = $1", + &[&dl_id], + ) + .expect("dl row exists post-replay"); + let resolution: Option = row.get(0); + assert_eq!(resolution.as_deref(), Some("replayed")); + + // And the unresolved count drops back to 0. + let out = common::pic_as(&env) + .args(["dead-letters", "count", "--app", &app_slug]) + .output() + .expect("dl count"); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!( + stdout.lines().nth(1) == Some("0"), + "count should be 0 after replay: {stdout}" + ); +} + #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[test] fn resolve_marks_row_resolved() { diff --git a/dashboard/src/routes/apps/[slug]/users/invitations/+page.svelte b/dashboard/src/routes/apps/[slug]/users/invitations/+page.svelte index 8f846c1..d42a222 100644 --- a/dashboard/src/routes/apps/[slug]/users/invitations/+page.svelte +++ b/dashboard/src/routes/apps/[slug]/users/invitations/+page.svelte @@ -1,11 +1,10 @@