diff --git a/bun.lock b/bun.lock --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,7 @@ "fumadocs-mdx": "^15.0.4", "fumadocs-ui": "^16.8.10", "lucide-react": "^1.14.0", - "mermaid": "^11.6.0", + "mermaid": "^11.16.0", "next": "^16.1.6", "next-themes": "^0.4.6", "react": "^19.2.0", @@ -219,15 +219,7 @@ "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="], - "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@12.0.0", "", { "dependencies": { "@chevrotain/gast": "12.0.0", "@chevrotain/types": "12.0.0" } }, "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg=="], - - "@chevrotain/gast": ["@chevrotain/gast@12.0.0", "", { "dependencies": { "@chevrotain/types": "12.0.0" } }, "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ=="], - - "@chevrotain/regexp-to-ast": ["@chevrotain/regexp-to-ast@12.0.0", "", {}, "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA=="], - - "@chevrotain/types": ["@chevrotain/types@12.0.0", "", {}, "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA=="], - - "@chevrotain/utils": ["@chevrotain/utils@12.0.0", "", {}, "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA=="], + "@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="], "@clack/core": ["@clack/core@1.4.2", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-0Ty/1Gfm+Kb07sXcuESjyKfwEhSy4Ns1AgeEisHb/bDY5fWme0tTeTkU14T1Gmcs17YIjB/teiDe4uaCghbYqQ=="], @@ -379,7 +371,7 @@ "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], - "@mermaid-js/parser": ["@mermaid-js/parser@1.1.0", "", { "dependencies": { "langium": "^4.0.0" } }, "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw=="], + "@mermaid-js/parser": ["@mermaid-js/parser@1.2.0", "", { "dependencies": { "@chevrotain/types": "~11.1.2" } }, "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA=="], "@next/env": ["@next/env@16.2.6", "", {}, "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw=="], @@ -801,10 +793,6 @@ "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], - "chevrotain": ["chevrotain@12.0.0", "", { "dependencies": { "@chevrotain/cst-dts-gen": "12.0.0", "@chevrotain/gast": "12.0.0", "@chevrotain/regexp-to-ast": "12.0.0", "@chevrotain/types": "12.0.0", "@chevrotain/utils": "12.0.0" } }, "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ=="], - - "chevrotain-allstar": ["chevrotain-allstar@0.4.1", "", { "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { "chevrotain": "^12.0.0" } }, "sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA=="], - "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], @@ -871,7 +859,7 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - "cytoscape": ["cytoscape@3.33.2", "", {}, "sha512-sj4HXd3DokGhzZAdjDejGvTPLqlt84vNFN8m7bGsOzDY5DyVcxIb2ejIXat2Iy7HxWhdT/N1oKyheJ5YdpsGuw=="], + "cytoscape": ["cytoscape@3.34.0", "", {}, "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg=="], "cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="], @@ -996,6 +984,8 @@ "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-toolkit": ["es-toolkit@1.49.0", "", {}, "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g=="], "esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="], @@ -1245,8 +1235,6 @@ "khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="], - "langium": ["langium@4.2.2", "", { "dependencies": { "@chevrotain/regexp-to-ast": "~12.0.0", "chevrotain": "~12.0.0", "chevrotain-allstar": "~0.4.1", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.1.0" } }, "sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ=="], - "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], @@ -1351,7 +1339,7 @@ "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], - "mermaid": ["mermaid@11.14.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.1.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.19", "dompurify": "^3.3.1", "katex": "^0.16.25", "khroma": "^2.1.0", "lodash-es": "^4.17.23", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g=="], + "mermaid": ["mermaid@11.16.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.2.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.20", "dompurify": "^3.3.3", "es-toolkit": "^1.45.1", "katex": "^0.16.45", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA=="], "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], @@ -1872,18 +1860,6 @@ "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], - - "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], - - "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], - - "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], - - "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], - - "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], - - "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], diff --git a/src/lib.rs b/src/lib.rs --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,7 @@ pub mod feature_middleware; pub mod http_retry; pub mod jetstream; +pub mod jobs; pub mod labeler; pub mod lexicon; pub mod lua; diff --git a/src/main.rs b/src/main.rs --- a/src/main.rs +++ b/src/main.rs @@ -681,6 +681,15 @@ happyview::admin::backfill::resume_backfill_jobs(&state).await; + // Resume interrupted jobs and start the job worker + happyview::jobs::worker::resume_interrupted_jobs(&state).await; + { + let job_state = state.clone(); + tokio::spawn(async move { + happyview::jobs::worker::run_worker(job_state).await; + }); + } + { let state = state.clone(); tokio::spawn(async move { diff --git a/tests/e2e_jobs.rs b/tests/e2e_jobs.rs new file mode 100644 --- /dev/null +++ b/tests/e2e_jobs.rs @@ -0,0 +1,491 @@ +mod common; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use happyview::db::adapt_sql; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use serial_test::serial; +use tower::ServiceExt; +use uuid::Uuid; + +use common::app::TestApp; + +async fn json_body(resp: axum::response::Response) -> Value { + let body = resp.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&body).unwrap() +} + +fn admin_get( + uri: &str, + cookie: (axum::http::HeaderName, axum::http::HeaderValue), +) -> Request { + Request::builder() + .uri(uri) + .header(cookie.0, cookie.1) + .body(Body::empty()) + .unwrap() +} + +fn admin_post( + uri: &str, + cookie: (axum::http::HeaderName, axum::http::HeaderValue), + body: &Value, +) -> Request { + Request::builder() + .method("POST") + .uri(uri) + .header(cookie.0, cookie.1) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(body).unwrap())) + .unwrap() +} + +async fn seed_job(app: &TestApp, job_type: &str, status: &str) -> String { + let id = Uuid::new_v4().to_string(); + let now = happyview::db::now_rfc3339(); + let input = serde_json::to_string(&json!({"test": true})).unwrap(); + + let sql = adapt_sql( + "INSERT INTO happyview_jobs (id, job_type, status, input, progress, created_by, created_at) VALUES (?, ?, ?, ?, '{}', ?, ?)", + app.state.db_backend, + ); + sqlx::query(&sql) + .bind(&id) + .bind(job_type) + .bind(status) + .bind(&input) + .bind(&app.admin_did) + .bind(&now) + .execute(&app.state.db) + .await + .expect("seed_job: insert failed"); + + id +} + +async fn set_job_status(app: &TestApp, id: &str, status: &str) { + let sql = adapt_sql( + "UPDATE happyview_jobs SET status = ? WHERE id = ?", + app.state.db_backend, + ); + sqlx::query(&sql) + .bind(status) + .bind(id) + .execute(&app.state.db) + .await + .expect("set_job_status failed"); +} + +// --------------------------------------------------------------------------- +// List jobs +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn list_jobs_empty() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot(admin_get("/admin/jobs", app.admin_cookie())) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + assert_eq!(body["jobs"].as_array().unwrap().len(), 0); + assert_eq!(body["cursor"], Value::Null); +} + +#[tokio::test] +#[serial] +async fn list_jobs_returns_seeded_jobs() { + common::require_db!(); + let app = TestApp::new().await; + + seed_job(&app, "test.export", "pending").await; + seed_job(&app, "test.import", "running").await; + + let resp = app + .router + .clone() + .oneshot(admin_get("/admin/jobs", app.admin_cookie())) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + let jobs = body["jobs"].as_array().unwrap(); + assert_eq!(jobs.len(), 2); +} + +#[tokio::test] +#[serial] +async fn list_jobs_filters_by_status() { + common::require_db!(); + let app = TestApp::new().await; + + seed_job(&app, "test.export", "pending").await; + seed_job(&app, "test.import", "running").await; + seed_job(&app, "test.cleanup", "completed").await; + + let resp = app + .router + .clone() + .oneshot(admin_get("/admin/jobs?status=running", app.admin_cookie())) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + let jobs = body["jobs"].as_array().unwrap(); + assert_eq!(jobs.len(), 1); + assert_eq!(jobs[0]["job_type"], "test.import"); + assert_eq!(jobs[0]["status"], "running"); +} + +// --------------------------------------------------------------------------- +// Get job +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn get_job_returns_details() { + common::require_db!(); + let app = TestApp::new().await; + + let id = seed_job(&app, "test.export", "pending").await; + + let resp = app + .router + .clone() + .oneshot(admin_get(&format!("/admin/jobs/{id}"), app.admin_cookie())) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + assert_eq!(body["id"], id); + assert_eq!(body["job_type"], "test.export"); + assert_eq!(body["status"], "pending"); + assert_eq!(body["input"]["test"], true); +} + +#[tokio::test] +#[serial] +async fn get_job_not_found() { + common::require_db!(); + let app = TestApp::new().await; + + let fake_id = Uuid::new_v4(); + let resp = app + .router + .clone() + .oneshot(admin_get( + &format!("/admin/jobs/{fake_id}"), + app.admin_cookie(), + )) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +// --------------------------------------------------------------------------- +// Cancel job +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn cancel_pending_job_sets_cancelled() { + common::require_db!(); + let app = TestApp::new().await; + + let id = seed_job(&app, "test.export", "pending").await; + + let resp = app + .router + .clone() + .oneshot(admin_post( + &format!("/admin/jobs/{id}/cancel"), + app.admin_cookie(), + &json!({}), + )) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + assert_eq!(body["status"], "cancelled"); +} + +#[tokio::test] +#[serial] +async fn cancel_running_job_sets_cancelling() { + common::require_db!(); + let app = TestApp::new().await; + + let id = seed_job(&app, "test.export", "running").await; + + let resp = app + .router + .clone() + .oneshot(admin_post( + &format!("/admin/jobs/{id}/cancel"), + app.admin_cookie(), + &json!({}), + )) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + assert_eq!(body["status"], "cancelling"); +} + +#[tokio::test] +#[serial] +async fn cancel_paused_job_sets_cancelled() { + common::require_db!(); + let app = TestApp::new().await; + + let id = seed_job(&app, "test.export", "paused").await; + + let resp = app + .router + .clone() + .oneshot(admin_post( + &format!("/admin/jobs/{id}/cancel"), + app.admin_cookie(), + &json!({}), + )) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + assert_eq!(body["status"], "cancelled"); +} + +#[tokio::test] +#[serial] +async fn cancel_completed_job_returns_409() { + common::require_db!(); + let app = TestApp::new().await; + + let id = seed_job(&app, "test.export", "completed").await; + + let resp = app + .router + .clone() + .oneshot(admin_post( + &format!("/admin/jobs/{id}/cancel"), + app.admin_cookie(), + &json!({}), + )) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::CONFLICT); +} + +// --------------------------------------------------------------------------- +// Pause job +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn pause_running_job_sets_pausing() { + common::require_db!(); + let app = TestApp::new().await; + + let id = seed_job(&app, "test.export", "running").await; + + let resp = app + .router + .clone() + .oneshot(admin_post( + &format!("/admin/jobs/{id}/pause"), + app.admin_cookie(), + &json!({}), + )) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + assert_eq!(body["status"], "pausing"); +} + +#[tokio::test] +#[serial] +async fn pause_pending_job_returns_409() { + common::require_db!(); + let app = TestApp::new().await; + + let id = seed_job(&app, "test.export", "pending").await; + + let resp = app + .router + .clone() + .oneshot(admin_post( + &format!("/admin/jobs/{id}/pause"), + app.admin_cookie(), + &json!({}), + )) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::CONFLICT); +} + +// --------------------------------------------------------------------------- +// Resume job +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn resume_paused_job_sets_pending() { + common::require_db!(); + let app = TestApp::new().await; + + let id = seed_job(&app, "test.export", "paused").await; + + let resp = app + .router + .clone() + .oneshot(admin_post( + &format!("/admin/jobs/{id}/resume"), + app.admin_cookie(), + &json!({}), + )) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + assert_eq!(body["status"], "pending"); +} + +#[tokio::test] +#[serial] +async fn resume_running_job_returns_409() { + common::require_db!(); + let app = TestApp::new().await; + + let id = seed_job(&app, "test.export", "running").await; + + let resp = app + .router + .clone() + .oneshot(admin_post( + &format!("/admin/jobs/{id}/resume"), + app.admin_cookie(), + &json!({}), + )) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::CONFLICT); +} + +// --------------------------------------------------------------------------- +// Auth: unauthenticated requests +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn list_jobs_without_auth_returns_401() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/admin/jobs") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +// --------------------------------------------------------------------------- +// Full lifecycle: pending → running → pausing → paused → pending → cancel +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn full_job_lifecycle() { + common::require_db!(); + let app = TestApp::new().await; + + let id = seed_job(&app, "test.lifecycle", "pending").await; + + // Simulate worker claiming → running + set_job_status(&app, &id, "running").await; + + // Pause the running job + let resp = app + .router + .clone() + .oneshot(admin_post( + &format!("/admin/jobs/{id}/pause"), + app.admin_cookie(), + &json!({}), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(json_body(resp).await["status"], "pausing"); + + // Simulate worker acknowledging pause + set_job_status(&app, &id, "paused").await; + + // Resume the paused job + let resp = app + .router + .clone() + .oneshot(admin_post( + &format!("/admin/jobs/{id}/resume"), + app.admin_cookie(), + &json!({}), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(json_body(resp).await["status"], "pending"); + + // Cancel the pending job + let resp = app + .router + .clone() + .oneshot(admin_post( + &format!("/admin/jobs/{id}/cancel"), + app.admin_cookie(), + &json!({}), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(json_body(resp).await["status"], "cancelled"); + + // Verify final state + let resp = app + .router + .clone() + .oneshot(admin_get(&format!("/admin/jobs/{id}"), app.admin_cookie())) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let job = json_body(resp).await; + assert_eq!(job["status"], "cancelled"); + assert!(job["completed_at"].is_string()); +} diff --git a/tests/spaces_db.rs b/tests/spaces_db.rs --- a/tests/spaces_db.rs +++ b/tests/spaces_db.rs @@ -307,6 +307,7 @@ } else { None }, + value: None, created_at: now_rfc3339(), }; oplog::append_op(&pool, backend, &entry) @@ -356,6 +357,7 @@ rkey: format!("item-{i}"), cid: Some(format!("bafy{i}")), prev: None, + value: None, created_at: now_rfc3339(), }; oplog::append_op(&pool, backend, &entry) diff --git a/web/playwright.config.ts b/web/playwright.config.ts --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -30,9 +30,11 @@ "lexicon-services.spec.ts", "lexicon-delete.spec.ts", "script-delete.spec.ts", + "script-job.spec.ts", "record-delete.spec.ts", "proxy-config.spec.ts", "spaces.spec.ts", + "jobs.spec.ts", ], dependencies: ["setup"], use: { browserName: "chromium" }, diff --git a/migrations/postgres/20260701000000_create_jobs.sql b/migrations/postgres/20260701000000_create_jobs.sql new file mode 100644 --- /dev/null +++ b/migrations/postgres/20260701000000_create_jobs.sql @@ -0,0 +1,17 @@ +CREATE TABLE happyview_jobs ( + id TEXT PRIMARY KEY, + job_type TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + input TEXT NOT NULL DEFAULT '{}', + progress TEXT NOT NULL DEFAULT '{}', + result TEXT, + error TEXT, + created_by TEXT NOT NULL, + started_at TEXT, + completed_at TEXT, + created_at TEXT NOT NULL +); + +CREATE INDEX idx_happyview_jobs_status ON happyview_jobs (status); +CREATE INDEX idx_happyview_jobs_job_type ON happyview_jobs (job_type); +CREATE INDEX idx_happyview_jobs_created_by ON happyview_jobs (created_by); diff --git a/migrations/postgres/20260702000000_add_inherit_auth_to_jobs.sql b/migrations/postgres/20260702000000_add_inherit_auth_to_jobs.sql new file mode 100644 --- /dev/null +++ b/migrations/postgres/20260702000000_add_inherit_auth_to_jobs.sql @@ -0,0 +1,1 @@ +ALTER TABLE happyview_jobs ADD COLUMN inherit_auth BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/sqlite/20260701000000_create_jobs.sql b/migrations/sqlite/20260701000000_create_jobs.sql new file mode 100644 --- /dev/null +++ b/migrations/sqlite/20260701000000_create_jobs.sql @@ -0,0 +1,17 @@ +CREATE TABLE happyview_jobs ( + id TEXT PRIMARY KEY, + job_type TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + input TEXT NOT NULL DEFAULT '{}', + progress TEXT NOT NULL DEFAULT '{}', + result TEXT, + error TEXT, + created_by TEXT NOT NULL, + started_at TEXT, + completed_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_happyview_jobs_status ON happyview_jobs (status); +CREATE INDEX idx_happyview_jobs_job_type ON happyview_jobs (job_type); +CREATE INDEX idx_happyview_jobs_created_by ON happyview_jobs (created_by); diff --git a/migrations/sqlite/20260702000000_add_inherit_auth_to_jobs.sql b/migrations/sqlite/20260702000000_add_inherit_auth_to_jobs.sql new file mode 100644 --- /dev/null +++ b/migrations/sqlite/20260702000000_add_inherit_auth_to_jobs.sql @@ -0,0 +1,1 @@ +ALTER TABLE happyview_jobs ADD COLUMN inherit_auth BOOLEAN NOT NULL DEFAULT 0; diff --git a/packages/docs/package.json b/packages/docs/package.json --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -20,7 +20,7 @@ "fumadocs-mdx": "^15.0.4", "fumadocs-ui": "^16.8.10", "lucide-react": "^1.14.0", - "mermaid": "^11.6.0", + "mermaid": "^11.16.0", "next": "^16.1.6", "next-themes": "^0.4.6", "react": "^19.2.0", diff --git a/src/admin/jobs.rs b/src/admin/jobs.rs new file mode 100644 --- /dev/null +++ b/src/admin/jobs.rs @@ -0,0 +1,124 @@ +use axum::Json; +use axum::extract::{Path, Query, State}; +use serde::Deserialize; + +use crate::AppState; +use crate::error::AppError; +use crate::jobs; + +use super::auth::UserAuth; +use super::permissions::Permission; + +#[derive(Deserialize)] +pub struct ListJobsQuery { + pub status: Option, + pub limit: Option, + pub cursor: Option, +} + +pub async fn list_jobs( + State(state): State, + auth: UserAuth, + Query(query): Query, +) -> Result, AppError> { + auth.require(Permission::JobsRead).await?; + + let limit = query.limit.unwrap_or(50).clamp(1, 100); + let (jobs_list, cursor) = jobs::db::list_jobs( + &state, + query.status.as_deref(), + limit, + query.cursor.as_deref(), + ) + .await?; + + Ok(Json(serde_json::json!({ + "jobs": jobs_list, + "cursor": cursor, + }))) +} + +pub async fn get_job( + State(state): State, + auth: UserAuth, + Path(id): Path, +) -> Result, AppError> { + auth.require(Permission::JobsRead).await?; + + let job = jobs::db::get_job(&state, &id) + .await? + .ok_or_else(|| AppError::NotFound("job not found".into()))?; + + Ok(Json(serde_json::to_value(job).unwrap())) +} + +pub async fn cancel_job( + State(state): State, + auth: UserAuth, + Path(id): Path, +) -> Result, AppError> { + auth.require(Permission::JobsManage).await?; + + let job = jobs::db::get_job(&state, &id) + .await? + .ok_or_else(|| AppError::NotFound("job not found".into()))?; + + match job.status.as_str() { + "running" => { + jobs::db::set_status(&state, &id, "cancelling").await?; + Ok(Json(serde_json::json!({ "status": "cancelling" }))) + } + "pending" | "paused" => { + jobs::db::set_status(&state, &id, "cancelled").await?; + Ok(Json(serde_json::json!({ "status": "cancelled" }))) + } + _ => Err(AppError::Conflict(format!( + "cannot cancel job with status: {}", + job.status + ))), + } +} + +pub async fn pause_job( + State(state): State, + auth: UserAuth, + Path(id): Path, +) -> Result, AppError> { + auth.require(Permission::JobsManage).await?; + + let job = jobs::db::get_job(&state, &id) + .await? + .ok_or_else(|| AppError::NotFound("job not found".into()))?; + + if job.status != "running" { + return Err(AppError::Conflict(format!( + "cannot pause job with status: {}", + job.status + ))); + } + + jobs::db::set_status(&state, &id, "pausing").await?; + Ok(Json(serde_json::json!({ "status": "pausing" }))) +} + +pub async fn resume_job( + State(state): State, + auth: UserAuth, + Path(id): Path, +) -> Result, AppError> { + auth.require(Permission::JobsManage).await?; + + let job = jobs::db::get_job(&state, &id) + .await? + .ok_or_else(|| AppError::NotFound("job not found".into()))?; + + if job.status != "paused" { + return Err(AppError::Conflict(format!( + "cannot resume job with status: {}", + job.status + ))); + } + + jobs::db::set_status(&state, &id, "pending").await?; + Ok(Json(serde_json::json!({ "status": "pending" }))) +} diff --git a/src/admin/mod.rs b/src/admin/mod.rs --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -6,6 +6,7 @@ mod domains; mod events; mod feature_flags; +mod jobs; mod labelers; mod lexicons; mod network_lexicons; @@ -62,6 +63,11 @@ "/backfill/{id}/details", delete(backfill::flush_backfill_details), ) + .route("/jobs", get(jobs::list_jobs)) + .route("/jobs/{id}", get(jobs::get_job)) + .route("/jobs/{id}/cancel", post(jobs::cancel_job)) + .route("/jobs/{id}/pause", post(jobs::pause_job)) + .route("/jobs/{id}/resume", post(jobs::resume_job)) .route("/events", get(events::list_events)) .route("/users", post(users::create_user).get(users::list_users)) .route("/users/transfer-super", post(users::transfer_super)) diff --git a/src/admin/permissions.rs b/src/admin/permissions.rs --- a/src/admin/permissions.rs +++ b/src/admin/permissions.rs @@ -113,6 +113,13 @@ ScriptsRead, #[serde(rename = "scripts:manage")] ScriptsManage, + + #[serde(rename = "jobs:read")] + JobsRead, + #[serde(rename = "jobs:create")] + JobsCreate, + #[serde(rename = "jobs:manage")] + JobsManage, } impl Permission { @@ -162,6 +169,9 @@ Self::SpacesManageCredentials => "spaces:manage-credentials", Self::ScriptsRead => "scripts:read", Self::ScriptsManage => "scripts:manage", + Self::JobsRead => "jobs:read", + Self::JobsCreate => "jobs:create", + Self::JobsManage => "jobs:manage", } } @@ -425,6 +435,24 @@ description: "Create, update, and delete trigger-keyed scripts", category: "Scripts", }, + Self::JobsRead => PermissionInfo { + key: "jobs:read", + name: "View Jobs", + description: "View background job status and progress", + category: "Jobs", + }, + Self::JobsCreate => PermissionInfo { + key: "jobs:create", + name: "Create Jobs", + description: "Queue new background jobs", + category: "Jobs", + }, + Self::JobsManage => PermissionInfo { + key: "jobs:manage", + name: "Manage Jobs", + description: "Cancel, pause, and resume background jobs", + category: "Jobs", + }, } } @@ -474,6 +502,9 @@ Self::SpacesManageCredentials, Self::ScriptsRead, Self::ScriptsManage, + Self::JobsRead, + Self::JobsCreate, + Self::JobsManage, ]) } } @@ -525,6 +556,9 @@ SpacesManageInvites, SpacesManageRecords, SpacesManageCredentials, + JobsRead, + JobsCreate, + JobsManage, ] .iter() .map(|p| p.info()) diff --git a/src/jobs/db.rs b/src/jobs/db.rs new file mode 100644 --- /dev/null +++ b/src/jobs/db.rs @@ -0,0 +1,305 @@ +use serde_json::Value; +use uuid::Uuid; + +use crate::AppState; +use crate::db::{DatabaseBackend, adapt_sql, now_rfc3339}; +use crate::error::AppError; + +use super::Job; + +type JobRow = ( + String, + String, + String, + String, + String, + Option, + Option, + String, + Option, + Option, + String, + bool, +); + +fn row_to_job( + ( + id, + job_type, + status, + input, + progress, + result, + error, + created_by, + started_at, + completed_at, + created_at, + inherit_auth, + ): JobRow, +) -> Job { + Job { + id, + job_type, + status, + input: serde_json::from_str(&input).unwrap_or(Value::Null), + progress: serde_json::from_str(&progress).unwrap_or(Value::Null), + result: result.and_then(|r| serde_json::from_str(&r).ok()), + error, + created_by, + started_at, + completed_at, + created_at, + inherit_auth, + } +} + +pub async fn create_job( + state: &AppState, + job_type: &str, + input: &Value, + created_by: &str, + inherit_auth: bool, +) -> Result { + let id = Uuid::new_v4().to_string(); + let now = now_rfc3339(); + let input_str = serde_json::to_string(input) + .map_err(|e| AppError::Internal(format!("failed to serialize job input: {e}")))?; + + let sql = adapt_sql( + "INSERT INTO happyview_jobs (id, job_type, status, input, created_by, created_at, inherit_auth) VALUES (?, ?, 'pending', ?, ?, ?, ?)", + state.db_backend, + ); + sqlx::query(&sql) + .bind(&id) + .bind(job_type) + .bind(&input_str) + .bind(created_by) + .bind(&now) + .bind(inherit_auth) + .execute(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to create job: {e}")))?; + + Ok(id) +} + +pub async fn get_job(state: &AppState, id: &str) -> Result, AppError> { + let sql = adapt_sql( + "SELECT * FROM happyview_jobs WHERE id = ?", + state.db_backend, + ); + let row: Option = sqlx::query_as(&sql) + .bind(id) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to fetch job: {e}")))?; + + Ok(row.map(row_to_job)) +} + +pub async fn list_jobs( + state: &AppState, + status_filter: Option<&str>, + limit: i64, + cursor: Option<&str>, +) -> Result<(Vec, Option), AppError> { + let sql = if status_filter.is_some() { + let base = if cursor.is_some() { + "SELECT * FROM happyview_jobs WHERE status = ? AND created_at < ? ORDER BY created_at DESC LIMIT ?" + } else { + "SELECT * FROM happyview_jobs WHERE status = ? ORDER BY created_at DESC LIMIT ?" + }; + adapt_sql(base, state.db_backend) + } else { + let base = if cursor.is_some() { + "SELECT * FROM happyview_jobs WHERE created_at < ? ORDER BY created_at DESC LIMIT ?" + } else { + "SELECT * FROM happyview_jobs ORDER BY created_at DESC LIMIT ?" + }; + adapt_sql(base, state.db_backend) + }; + + let mut query = sqlx::query_as::<_, JobRow>(&sql); + + if let Some(status) = status_filter { + query = query.bind(status); + } + if let Some(cursor) = cursor { + query = query.bind(cursor); + } + query = query.bind(limit + 1); + + let rows = query + .fetch_all(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to list jobs: {e}")))?; + + let has_more = rows.len() as i64 > limit; + let jobs: Vec = rows + .into_iter() + .take(limit as usize) + .map(row_to_job) + .collect(); + + let next_cursor = if has_more { + jobs.last().map(|j| j.created_at.clone()) + } else { + None + }; + + Ok((jobs, next_cursor)) +} + +pub async fn set_status(state: &AppState, id: &str, status: &str) -> Result<(), AppError> { + let now = now_rfc3339(); + let sql = match status { + "running" => adapt_sql( + "UPDATE happyview_jobs SET status = ?, started_at = ? WHERE id = ?", + state.db_backend, + ), + "completed" | "failed" | "cancelled" => adapt_sql( + "UPDATE happyview_jobs SET status = ?, completed_at = ? WHERE id = ?", + state.db_backend, + ), + _ => adapt_sql( + "UPDATE happyview_jobs SET status = ? WHERE id = ? AND 1=1", + state.db_backend, + ), + }; + + match status { + "running" | "completed" | "failed" | "cancelled" => { + sqlx::query(&sql) + .bind(status) + .bind(&now) + .bind(id) + .execute(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to update job status: {e}")))?; + } + _ => { + let sql = adapt_sql( + "UPDATE happyview_jobs SET status = ? WHERE id = ?", + state.db_backend, + ); + sqlx::query(&sql) + .bind(status) + .bind(id) + .execute(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to update job status: {e}")))?; + } + } + + Ok(()) +} + +pub async fn update_progress(state: &AppState, id: &str, progress: &Value) -> Result<(), AppError> { + let progress_str = serde_json::to_string(progress) + .map_err(|e| AppError::Internal(format!("failed to serialize progress: {e}")))?; + let sql = adapt_sql( + "UPDATE happyview_jobs SET progress = ? WHERE id = ?", + state.db_backend, + ); + sqlx::query(&sql) + .bind(&progress_str) + .bind(id) + .execute(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to update job progress: {e}")))?; + Ok(()) +} + +pub async fn set_result(state: &AppState, id: &str, result: &Value) -> Result<(), AppError> { + let result_str = serde_json::to_string(result) + .map_err(|e| AppError::Internal(format!("failed to serialize result: {e}")))?; + let now = now_rfc3339(); + let sql = adapt_sql( + "UPDATE happyview_jobs SET status = 'completed', result = ?, completed_at = ? WHERE id = ?", + state.db_backend, + ); + sqlx::query(&sql) + .bind(&result_str) + .bind(&now) + .bind(id) + .execute(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to set job result: {e}")))?; + Ok(()) +} + +pub async fn set_error(state: &AppState, id: &str, error: &str) -> Result<(), AppError> { + let now = now_rfc3339(); + let sql = adapt_sql( + "UPDATE happyview_jobs SET status = 'failed', error = ?, completed_at = ? WHERE id = ?", + state.db_backend, + ); + sqlx::query(&sql) + .bind(error) + .bind(&now) + .bind(id) + .execute(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to set job error: {e}")))?; + Ok(()) +} + +/// Check if a job should stop (status changed to cancelling or pausing). +/// Same cooperative cancellation pattern as the backfill system. +pub async fn should_stop(state: &AppState, id: &str) -> Option<&'static str> { + let sql = adapt_sql( + "SELECT status FROM happyview_jobs WHERE id = ?", + state.db_backend, + ); + let status = sqlx::query_as::<_, (String,)>(&sql) + .bind(id) + .fetch_optional(&state.db) + .await + .ok() + .flatten() + .map(|(s,)| s); + match status.as_deref() { + Some("cancelling") => Some("cancelling"), + Some("pausing") => Some("pausing"), + _ => None, + } +} + +/// Find jobs that were interrupted by a server restart. +pub async fn find_interrupted_jobs(state: &AppState) -> Vec { + let sql = adapt_sql( + "SELECT * FROM happyview_jobs WHERE status IN ('running', 'cancelling', 'pausing')", + state.db_backend, + ); + let rows: Vec = sqlx::query_as(&sql) + .fetch_all(&state.db) + .await + .unwrap_or_default(); + + rows.into_iter().map(row_to_job).collect() +} + +/// Pick the next pending job and atomically set it to running. +pub async fn claim_next_job(state: &AppState) -> Result, AppError> { + let now = now_rfc3339(); + + let sql = match state.db_backend { + DatabaseBackend::Postgres => adapt_sql( + "UPDATE happyview_jobs SET status = 'running', started_at = ? WHERE id = (SELECT id FROM happyview_jobs WHERE status = 'pending' ORDER BY created_at ASC LIMIT 1 FOR UPDATE SKIP LOCKED) RETURNING *", + state.db_backend, + ), + DatabaseBackend::Sqlite => adapt_sql( + "UPDATE happyview_jobs SET status = 'running', started_at = ? WHERE id = (SELECT id FROM happyview_jobs WHERE status = 'pending' ORDER BY created_at ASC LIMIT 1) AND status = 'pending' RETURNING *", + state.db_backend, + ), + }; + + let row: Option = sqlx::query_as(&sql) + .bind(&now) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to claim job: {e}")))?; + + Ok(row.map(row_to_job)) +} diff --git a/src/jobs/mod.rs b/src/jobs/mod.rs new file mode 100644 --- /dev/null +++ b/src/jobs/mod.rs @@ -0,0 +1,20 @@ +pub(crate) mod db; +pub mod worker; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Job { + pub id: String, + pub job_type: String, + pub status: String, + pub input: serde_json::Value, + pub progress: serde_json::Value, + pub result: Option, + pub error: Option, + pub created_by: String, + pub started_at: Option, + pub completed_at: Option, + pub created_at: String, + pub inherit_auth: bool, +} diff --git a/src/jobs/worker.rs b/src/jobs/worker.rs new file mode 100644 --- /dev/null +++ b/src/jobs/worker.rs @@ -0,0 +1,340 @@ +use std::sync::Arc; +use std::time::Duration; + +use mlua::LuaSerdeExt; + +use crate::AppState; +use crate::db::adapt_sql; +use crate::event_log::{EventLog, Severity, log_event}; +use crate::lua::{sandbox, scripts}; +use crate::repo; + +use super::db; + +const POLL_INTERVAL: Duration = Duration::from_secs(5); + +/// Start the background job worker. Polls for pending jobs and +/// executes them one at a time. +pub async fn run_worker(state: AppState) { + tracing::info!("job worker started"); + + loop { + match db::claim_next_job(&state).await { + Ok(Some(job)) => { + tracing::info!(job_id = %job.id, job_type = %job.job_type, "executing job"); + execute_job(&state, &job).await; + } + Ok(None) => { + tokio::time::sleep(POLL_INTERVAL).await; + } + Err(e) => { + tracing::error!(error = %e, "job worker: failed to claim job"); + tokio::time::sleep(POLL_INTERVAL).await; + } + } + } +} + +/// Resume jobs that were interrupted by a server restart. +pub async fn resume_interrupted_jobs(state: &AppState) { + let jobs = db::find_interrupted_jobs(state).await; + + for job in jobs { + match job.status.as_str() { + "cancelling" => { + tracing::info!(job_id = %job.id, "finalising cancelled job from previous run"); + let _ = db::set_status(state, &job.id, "cancelled").await; + } + "pausing" => { + tracing::info!(job_id = %job.id, "finalising paused job from previous run"); + let _ = db::set_status(state, &job.id, "paused").await; + } + "running" => { + tracing::info!(job_id = %job.id, "re-queuing interrupted job"); + let _ = db::set_status(state, &job.id, "pending").await; + } + _ => {} + } + } +} + +async fn execute_job(state: &AppState, job: &super::Job) { + let backend = state.db_backend; + + log_event( + &state.db, + EventLog { + event_type: "job.started".to_string(), + severity: Severity::Info, + actor_did: Some(job.created_by.clone()), + subject: Some(job.job_type.clone()), + detail: serde_json::json!({ + "job_id": job.id, + "job_type": job.job_type, + }), + }, + backend, + ) + .await; + + let trigger_id = format!("job.run:{}", job.job_type); + let script = match scripts::resolve(state, &trigger_id).await { + Some(s) => s, + None => { + let error = format!("no script found for trigger: {trigger_id}"); + tracing::error!(job_id = %job.id, %error); + let _ = db::set_error(state, &job.id, &error).await; + log_event( + &state.db, + EventLog { + event_type: "job.failed".to_string(), + severity: Severity::Error, + actor_did: Some(job.created_by.clone()), + subject: Some(job.job_type.clone()), + detail: serde_json::json!({ + "job_id": job.id, + "error": error, + }), + }, + backend, + ) + .await; + return; + } + }; + + let (claims, pds_auth_arc) = if job.inherit_auth { + let pds_auth = match repo::get_oauth_session(state, &job.created_by).await { + Ok(session) => repo::PdsAuth::OAuth(Arc::new(session)), + Err(e) => { + let error = format!("failed to obtain PDS auth for {}: {e}", job.created_by); + tracing::error!(job_id = %job.id, %error); + let _ = db::set_error(state, &job.id, &error).await; + log_event( + &state.db, + EventLog { + event_type: "job.failed".to_string(), + severity: Severity::Error, + actor_did: Some(job.created_by.clone()), + subject: Some(job.job_type.clone()), + detail: serde_json::json!({ + "job_id": job.id, + "error": error, + }), + }, + backend, + ) + .await; + return; + } + }; + ( + Some(Arc::new(crate::auth::Claims::internal( + job.created_by.clone(), + ))), + Some(Arc::new(pds_auth)), + ) + } else { + (None, None) + }; + + let lua = match sandbox::create_sandbox() { + Ok(l) => l, + Err(e) => { + let error = format!("failed to create Lua VM: {e}"); + let _ = db::set_error(state, &job.id, &error).await; + return; + } + }; + + lua.remove_hook(); + + let state_arc = Arc::new(state.clone()); + + if let Err(e) = crate::lua::db_api::register_db_api(&lua, state_arc.clone()) { + let _ = db::set_error(state, &job.id, &format!("db api: {e}")).await; + return; + } + if let Err(e) = crate::lua::http_api::register_http_api(&lua, state_arc.clone()) { + let _ = db::set_error(state, &job.id, &format!("http api: {e}")).await; + return; + } + if let Err(e) = crate::lua::xrpc_api::register_xrpc_api( + &lua, + state_arc.clone(), + Some(job.created_by.clone()), + ) { + let _ = db::set_error(state, &job.id, &format!("xrpc api: {e}")).await; + return; + } + if let Err(e) = crate::lua::atproto_api::register_atproto_api( + &lua, + state_arc.clone(), + Some(&job.created_by), + ) { + let _ = db::set_error(state, &job.id, &format!("atproto api: {e}")).await; + return; + } + if let (Some(c), Some(p)) = (&claims, &pds_auth_arc) + && let Err(e) = crate::lua::atproto_api::register_atproto_blob_api( + &lua, + state_arc.clone(), + c.clone(), + p.clone(), + ) + { + let _ = db::set_error(state, &job.id, &format!("blob api: {e}")).await; + return; + } + if let Err(e) = crate::lua::jobs_api::register_jobs_api( + &lua, + state_arc.clone(), + Some(job.created_by.clone()), + ) { + let _ = db::set_error(state, &job.id, &format!("jobs api: {e}")).await; + return; + } + if let Err(e) = + crate::lua::record::register_record_api(&lua, state_arc.clone(), claims, pds_auth_arc, None) + { + let _ = db::set_error(state, &job.id, &format!("record api: {e}")).await; + return; + } + if let Err(e) = crate::lua::scripts::register_log_event_api( + &lua, + &state_arc, + &trigger_id, + Some(&job.created_by), + ) { + let _ = db::set_error(state, &job.id, &format!("log api: {e}")).await; + return; + } + if let Err(e) = crate::lua::jobs_api::register_job_context( + &lua, + state_arc.clone(), + job.id.clone(), + job.input.clone(), + ) { + let _ = db::set_error(state, &job.id, &format!("job context: {e}")).await; + return; + } + + let env_vars = load_env_vars(&state.db, backend).await; + if let Err(e) = crate::lua::context::set_env_context(&lua, &env_vars) { + let _ = db::set_error(state, &job.id, &format!("env context: {e}")).await; + return; + } + + if let Err(e) = lua.globals().set("caller_did", job.created_by.as_str()) { + let _ = db::set_error(state, &job.id, &format!("caller_did: {e}")).await; + return; + } + + if let Err(e) = lua.load(script.body.as_str()).exec() { + let error = format!("script load failed: {e}"); + let _ = db::set_error(state, &job.id, &error).await; + return; + } + + let handle: mlua::Function = match lua.globals().get("handle") { + Ok(f) => f, + Err(e) => { + let _ = db::set_error(state, &job.id, &format!("missing handle(): {e}")).await; + return; + } + }; + + match handle.call_async::(()).await { + Ok(result) => { + let json_result: serde_json::Value = + lua.from_value(result).unwrap_or(serde_json::json!(null)); + + match db::should_stop(state, &job.id).await { + Some("pausing") => { + let _ = db::set_status(state, &job.id, "paused").await; + tracing::info!(job_id = %job.id, "job paused"); + log_event( + &state.db, + EventLog { + event_type: "job.paused".to_string(), + severity: Severity::Info, + actor_did: Some(job.created_by.clone()), + subject: Some(job.job_type.clone()), + detail: serde_json::json!({ "job_id": job.id }), + }, + backend, + ) + .await; + } + Some("cancelling") => { + let _ = db::set_status(state, &job.id, "cancelled").await; + tracing::info!(job_id = %job.id, "job cancelled"); + log_event( + &state.db, + EventLog { + event_type: "job.cancelled".to_string(), + severity: Severity::Info, + actor_did: Some(job.created_by.clone()), + subject: Some(job.job_type.clone()), + detail: serde_json::json!({ "job_id": job.id }), + }, + backend, + ) + .await; + } + _ => { + let _ = db::set_result(state, &job.id, &json_result).await; + tracing::info!(job_id = %job.id, "job completed"); + log_event( + &state.db, + EventLog { + event_type: "job.completed".to_string(), + severity: Severity::Info, + actor_did: Some(job.created_by.clone()), + subject: Some(job.job_type.clone()), + detail: serde_json::json!({ + "job_id": job.id, + "result": json_result, + }), + }, + backend, + ) + .await; + } + } + } + Err(e) => { + let error = format!("{e}"); + tracing::error!(job_id = %job.id, %error, "job script failed"); + let _ = db::set_error(state, &job.id, &error).await; + log_event( + &state.db, + EventLog { + event_type: "job.failed".to_string(), + severity: Severity::Error, + actor_did: Some(job.created_by.clone()), + subject: Some(job.job_type.clone()), + detail: serde_json::json!({ + "job_id": job.id, + "error": error, + }), + }, + backend, + ) + .await; + } + } +} + +async fn load_env_vars( + db: &sqlx::AnyPool, + backend: crate::db::DatabaseBackend, +) -> std::collections::HashMap { + let sql = adapt_sql("SELECT key, value FROM happyview_script_variables", backend); + sqlx::query_as::<_, (String, String)>(&sql) + .fetch_all(db) + .await + .unwrap_or_default() + .into_iter() + .collect() +} diff --git a/src/lua/context.rs b/src/lua/context.rs --- a/src/lua/context.rs +++ b/src/lua/context.rs @@ -271,7 +271,7 @@ let lua = create_sandbox().unwrap(); let params = HashMap::new(); let space = SpaceContext { - space: "ats://did:plc:owner/com.example.forum/main".into(), + space: "at://did:plc:owner/space/com.example.forum/main".into(), space_id: "space-123".into(), did: "did:plc:owner".into(), authority_did: "did:plc:owner".into(), @@ -292,7 +292,7 @@ let space_table: mlua::Table = globals.get("space").unwrap(); assert_eq!( space_table.get::("space").unwrap(), - "ats://did:plc:owner/com.example.forum/main" + "at://did:plc:owner/space/com.example.forum/main" ); assert_eq!(space_table.get::("space_id").unwrap(), "space-123"); assert_eq!(space_table.get::("did").unwrap(), "did:plc:owner"); diff --git a/src/lua/execute.rs b/src/lua/execute.rs --- a/src/lua/execute.rs +++ b/src/lua/execute.rs @@ -268,6 +268,32 @@ return Err(AppError::Internal(error_message)); } + if let Err(e) = + super::jobs_api::register_jobs_api(&lua, state_arc.clone(), Some(claims.did().to_string())) + { + let error_message = format!("failed to register jobs API: {e}"); + log_event( + &state.db, + EventLog { + event_type: "script.error".to_string(), + severity: Severity::Error, + actor_did: Some(claims.did().to_string()), + subject: Some(method.to_string()), + detail: serde_json::json!({ + "error": error_message, + "script_source": script_source, + "input": input_json, + "caller_did": claims.did(), + "method": method, + "duration_ms": start.elapsed().as_millis() as u64, + }), + }, + backend, + ) + .await; + return Err(AppError::Internal(error_message)); + } + if let Err(e) = record::register_record_api( &lua, state_arc.clone(), diff --git a/src/lua/jobs_api.rs b/src/lua/jobs_api.rs new file mode 100644 --- /dev/null +++ b/src/lua/jobs_api.rs @@ -0,0 +1,330 @@ +use mlua::{Lua, LuaSerdeExt, Result as LuaResult}; +use regex::Regex; +use std::sync::{Arc, LazyLock}; + +use crate::AppState; +use crate::jobs; + +static JOB_TYPE_PATTERN: LazyLock = + LazyLock::new(|| Regex::new(r"^[a-z0-9][a-z0-9._-]*$").unwrap()); + +/// Register the `jobs` table for queuing jobs from scripts. +/// Available in all script contexts (procedure, query, record-event). +pub fn register_jobs_api( + lua: &Lua, + state: Arc, + caller_did: Option, +) -> LuaResult<()> { + let jobs_table = lua.create_table()?; + + // jobs.create(job_type, input[, opts]) -> job_id string + // opts.auth: boolean (default false) — inherit caller's PDS auth + { + let state = state.clone(); + let caller_did = caller_did.clone(); + let create_fn = lua.create_async_function( + move |lua, (job_type, input, opts): (String, mlua::Value, Option)| { + let state = state.clone(); + let caller_did = caller_did.clone(); + + let input_json: serde_json::Value = + lua.from_value(input).unwrap_or(serde_json::json!({})); + + let inherit_auth = opts + .and_then(|t| t.get::("auth").ok()) + .unwrap_or(false); + + async move { + if job_type.is_empty() + || job_type.len() > 128 + || !JOB_TYPE_PATTERN.is_match(&job_type) + { + return Err(mlua::Error::runtime( + "job_type must be 1-128 characters matching /^[a-z0-9][a-z0-9._-]*$/", + )); + } + + let caller = caller_did.as_deref().ok_or_else(|| { + mlua::Error::runtime("jobs.create requires an authenticated caller") + })?; + + let job_id = + jobs::db::create_job(&state, &job_type, &input_json, caller, inherit_auth) + .await + .map_err(|e| { + mlua::Error::runtime(format!("jobs.create failed: {e}")) + })?; + + Ok(job_id) + } + }, + )?; + jobs_table.set("create", create_fn)?; + } + + lua.globals().set("jobs", jobs_table)?; + Ok(()) +} + +/// Register the `job` context table for use inside job scripts. +/// Provides access to job input, progress reporting, cooperative +/// cancellation, and sleep/wait. +/// +/// Called by the job worker, not by the normal script execution path. +pub fn register_job_context( + lua: &Lua, + state: Arc, + job_id: String, + input: serde_json::Value, +) -> LuaResult<()> { + let job_table = lua.create_table()?; + + // job.input — the JSONB input passed to jobs.create() + let input_value = lua.to_value(&input)?; + job_table.set("input", input_value)?; + + // job.id — the job's UUID + job_table.set("id", job_id.clone())?; + + // job.progress(data) — persist progress to DB + { + let state = state.clone(); + let job_id = job_id.clone(); + let progress_fn = lua.create_async_function(move |lua, data: mlua::Value| { + let state = state.clone(); + let job_id = job_id.clone(); + let json_data: serde_json::Value = + lua.from_value(data).unwrap_or(serde_json::json!({})); + async move { + jobs::db::update_progress(&state, &job_id, &json_data) + .await + .map_err(|e| mlua::Error::runtime(format!("job.progress failed: {e}")))?; + Ok(()) + } + })?; + job_table.set("progress", progress_fn)?; + } + + // job.should_stop() -> boolean + { + let state = state.clone(); + let job_id = job_id.clone(); + let should_stop_fn = lua.create_async_function(move |_lua, ()| { + let state = state.clone(); + let job_id = job_id.clone(); + async move { + let result = jobs::db::should_stop(&state, &job_id).await; + Ok(result.is_some()) + } + })?; + job_table.set("should_stop", should_stop_fn)?; + } + + // job.wait(seconds) — yield execution for the given duration + { + let wait_fn = lua.create_async_function(move |_lua, seconds: f64| async move { + let duration = std::time::Duration::from_secs_f64(seconds.clamp(0.0, 3600.0)); + tokio::time::sleep(duration).await; + Ok(()) + })?; + job_table.set("wait", wait_fn)?; + } + + lua.globals().set("job", job_table)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::db::DatabaseBackend; + use crate::lexicon::LexiconRegistry; + use tokio::sync::watch; + + fn test_state() -> AppState { + let config = Config { + host: "127.0.0.1".into(), + port: 3000, + database_url: String::new(), + database_backend: crate::db::DatabaseBackend::Sqlite, + public_url: String::new(), + session_secret: "test-secret".into(), + jetstream_url: String::new(), + relay_url: String::new(), + plc_url: String::new(), + static_dir: String::new(), + base_path: None, + event_log_retention_days: 30, + app_name: None, + logo_uri: None, + tos_uri: None, + policy_uri: None, + token_encryption_key: None, + default_rate_limit_capacity: 100, + default_rate_limit_refill_rate: 2.0, + }; + let (tx, _) = watch::channel(vec![]); + let (labeler_tx, _) = watch::channel(()); + sqlx::any::install_default_drivers(); + let test_db = sqlx::AnyPool::connect_lazy("sqlite::memory:").unwrap(); + let atrium_http = std::sync::Arc::new(atrium_oauth::DefaultHttpClient::default()); + let did_resolver = atrium_identity::did::CommonDidResolver::new( + atrium_identity::did::CommonDidResolverConfig { + plc_directory_url: "https://plc.directory".into(), + http_client: std::sync::Arc::clone(&atrium_http), + }, + ); + let handle_resolver = atrium_identity::handle::AtprotoHandleResolver::new( + atrium_identity::handle::AtprotoHandleResolverConfig { + dns_txt_resolver: crate::dns::NativeDnsResolver::new(), + http_client: atrium_http, + }, + ); + let oauth = atrium_oauth::OAuthClient::new(atrium_oauth::OAuthClientConfig { + client_metadata: atrium_oauth::AtprotoLocalhostClientMetadata { + redirect_uris: Some(vec!["http://127.0.0.1:0/auth/callback".into()]), + scopes: Some(vec![atrium_oauth::Scope::Known( + atrium_oauth::KnownScope::Atproto, + )]), + }, + keys: None, + state_store: crate::auth::oauth_store::DbStateStore::new( + test_db.clone(), + crate::db::DatabaseBackend::Sqlite, + ), + session_store: crate::auth::oauth_store::DbSessionStore::new( + test_db.clone(), + crate::db::DatabaseBackend::Sqlite, + ), + resolver: atrium_oauth::OAuthResolverConfig { + did_resolver, + handle_resolver, + authorization_server_metadata: Default::default(), + protected_resource_metadata: Default::default(), + }, + }) + .expect("Failed to create test OAuth client"); + AppState { + config, + http: reqwest::Client::new(), + db: test_db.clone(), + backfill_db: test_db.clone(), + db_backend: DatabaseBackend::Sqlite, + domain_cache: crate::domain::DomainCache::new(), + lexicons: LexiconRegistry::new(), + collections_tx: tx, + labeler_subscriptions_tx: labeler_tx, + rate_limiter: crate::rate_limit::RateLimiter::new( + crate::rate_limit::RateLimitDefaults { + query_cost: 1, + procedure_cost: 1, + proxy_cost: 1, + }, + ), + oauth: std::sync::Arc::new(crate::auth::OAuthClientRegistry::new(std::sync::Arc::new( + oauth, + ))), + oauth_state_store: crate::auth::oauth_store::DbStateStore::new( + test_db.clone(), + crate::db::DatabaseBackend::Sqlite, + ), + cookie_key: axum_extra::extract::cookie::Key::derive_from( + b"test-secret-for-tests-only-not-production", + ), + plugin_registry: std::sync::Arc::new(crate::plugin::PluginRegistry::new()), + wasm_runtime: std::sync::Arc::new( + crate::plugin::WasmRuntime::new().expect("wasm runtime"), + ), + attestation_signer: None, + official_registry: std::sync::Arc::new(tokio::sync::RwLock::new( + crate::plugin::official_registry::OfficialRegistryState::default(), + )), + official_registry_config: crate::plugin::official_registry::RegistryConfig::production( + ), + proxy_config: std::sync::Arc::new(arc_swap::ArcSwap::new(std::sync::Arc::new( + crate::proxy_config::ProxyConfig::default(), + ))), + backfill_events_tx: tokio::sync::broadcast::channel(16).0, + verbose_event_logging: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + } + } + + #[tokio::test] + async fn jobs_api_is_registered() { + let lua = crate::lua::sandbox::create_sandbox().unwrap(); + let state = test_state(); + register_jobs_api(&lua, Arc::new(state), Some("did:plc:test".into())).unwrap(); + + let has_create: bool = lua + .load("return type(jobs.create) == 'function'") + .eval_async() + .await + .unwrap(); + assert!(has_create); + } + + #[tokio::test] + async fn job_context_exposes_input() { + let lua = crate::lua::sandbox::create_sandbox().unwrap(); + let state = test_state(); + let input = serde_json::json!({ "game_uri": "at://did:plc:test/game/123" }); + register_job_context(&lua, Arc::new(state), "test-job-id".into(), input).unwrap(); + + let game_uri: String = lua + .load("return job.input.game_uri") + .eval_async() + .await + .unwrap(); + assert_eq!(game_uri, "at://did:plc:test/game/123"); + + let job_id: String = lua.load("return job.id").eval_async().await.unwrap(); + assert_eq!(job_id, "test-job-id"); + } + + #[tokio::test] + async fn job_context_has_required_functions() { + let lua = crate::lua::sandbox::create_sandbox().unwrap(); + let state = test_state(); + register_job_context( + &lua, + Arc::new(state), + "test-id".into(), + serde_json::json!({}), + ) + .unwrap(); + + let result: bool = lua + .load( + r#" + return type(job.progress) == 'function' + and type(job.should_stop) == 'function' + and type(job.wait) == 'function' + "#, + ) + .eval_async() + .await + .unwrap(); + assert!(result); + } + + #[tokio::test] + async fn jobs_create_rejects_invalid_job_type() { + let lua = crate::lua::sandbox::create_sandbox().unwrap(); + let state = test_state(); + register_jobs_api(&lua, Arc::new(state), Some("did:plc:test".into())).unwrap(); + + for bad in [ + "", + "UPPER", + "has space", + "has:colon", + "-leading-dash", + ".leading-dot", + ] { + let script = format!(r#"return jobs.create("{bad}", {{}})"#); + let result: mlua::Result = lua.load(&script).eval_async().await; + assert!(result.is_err(), "expected error for job_type={bad:?}"); + } + } +} diff --git a/src/lua/mod.rs b/src/lua/mod.rs --- a/src/lua/mod.rs +++ b/src/lua/mod.rs @@ -1,13 +1,14 @@ -mod atproto_api; -mod context; +pub(crate) mod atproto_api; +pub(crate) mod context; pub mod db_api; mod execute; -mod http_api; +pub(crate) mod http_api; +pub(crate) mod jobs_api; pub mod record; pub(crate) mod sandbox; pub mod scripts; pub(crate) mod tid; -mod xrpc_api; +pub(crate) mod xrpc_api; #[allow(unused_imports)] pub(crate) use context::SpaceContext; diff --git a/src/lua/scripts.rs b/src/lua/scripts.rs --- a/src/lua/scripts.rs +++ b/src/lua/scripts.rs @@ -32,9 +32,13 @@ //! [`super::execute::execute_procedure_script`] / //! [`super::execute::execute_query_script`] directly. +use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; + +static JOB_TYPE_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^[a-z0-9][a-z0-9._-]*$").unwrap()); use crate::AppState; use crate::db::{DatabaseBackend, adapt_sql, now_rfc3339}; @@ -60,6 +64,7 @@ XrpcQuery, XrpcProcedure, LabelerApply, + JobRun, } /// A trigger id parsed into `(kind, suffix)`. The suffix is either an NSID @@ -82,6 +87,7 @@ TriggerKind::XrpcQuery => format!("xrpc.query:{}", self.suffix), TriggerKind::XrpcProcedure => format!("xrpc.procedure:{}", self.suffix), TriggerKind::LabelerApply => format!("labeler.apply:{}", self.suffix), + TriggerKind::JobRun => format!("job.run:{}", self.suffix), } } @@ -92,7 +98,8 @@ format!( "trigger id '{id}' must contain a ':' separator; \ valid prefixes: record.{{index,create,update,delete}}:, \ - xrpc.{{query,procedure}}:, labeler.apply:" + xrpc.{{query,procedure}}:, labeler.apply:, \ + job.run:" ) })?; @@ -108,18 +115,21 @@ "xrpc.query" => TriggerKind::XrpcQuery, "xrpc.procedure" => TriggerKind::XrpcProcedure, "labeler.apply" => TriggerKind::LabelerApply, + "job.run" => TriggerKind::JobRun, other => { return Err(format!( "unknown trigger prefix '{other}'; valid prefixes: \ record.{{index,create,update,delete}}, xrpc.{{query,procedure}}, \ - labeler.apply" + labeler.apply, job.run" )); } }; - // Suffix validation: NSID for everything except `labeler.apply:_actor`. - match (kind, suffix) { - (TriggerKind::LabelerApply, "_actor") => {} + // Suffix validation: NSID for most triggers, but `labeler.apply:_actor` + // and `job.run:` have their own formats. + match kind { + TriggerKind::JobRun => validate_job_type(suffix)?, + TriggerKind::LabelerApply if suffix == "_actor" => {} _ => validate_nsid(suffix)?, } @@ -128,6 +138,20 @@ suffix: suffix.to_string(), }) } +} + +fn validate_job_type(job_type: &str) -> Result<(), String> { + if job_type.is_empty() || job_type.len() > 128 { + return Err(format!( + "invalid job type '{job_type}': must be 1–128 characters" + )); + } + if !JOB_TYPE_RE.is_match(job_type) { + return Err(format!( + "invalid job type '{job_type}': must match /^[a-z0-9][a-z0-9._-]*$/" + )); + } + Ok(()) } /// Minimal NSID validation: at least two dot-separated segments, each @@ -725,6 +749,8 @@ .map_err(|e| format!("xrpc api: {e}"))?; atproto_api::register_atproto_api(lua, state.clone(), None) .map_err(|e| format!("atproto api: {e}"))?; + super::jobs_api::register_jobs_api(lua, state.clone(), caller_did.map(String::from)) + .map_err(|e| format!("jobs api: {e}"))?; record::register_record_api_no_auth(lua, state.clone()) .map_err(|e| format!("record api: {e}"))?; register_log_event_api(lua, state, trigger_id, caller_did)?; @@ -896,6 +922,21 @@ let err = ParsedTrigger::parse("record.index").unwrap_err(); assert!(err.contains("must contain a ':' separator")); assert!(err.contains("valid prefixes")); + } + + #[test] + fn parse_job_run_trigger() { + let t = ParsedTrigger::parse("job.run:test.export").unwrap(); + assert_eq!(t.kind, TriggerKind::JobRun); + assert_eq!(t.suffix, "test.export"); + assert_eq!(t.id(), "job.run:test.export"); + } + + #[test] + fn rejects_bad_job_type() { + assert!(ParsedTrigger::parse("job.run:UPPER").is_err()); + assert!(ParsedTrigger::parse("job.run:has space").is_err()); + assert!(ParsedTrigger::parse("job.run:").is_err()); } #[test] diff --git a/src/spaces/auth.rs b/src/spaces/auth.rs --- a/src/spaces/auth.rs +++ b/src/spaces/auth.rs @@ -42,7 +42,10 @@ let claims = SpaceCredentialClaims { iss: space.authority_did.clone(), - sub: format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey), + sub: format!( + "at://{}/space/{}/{}", + space.did, space.type_nsid, space.skey + ), iat: now, exp, jti: make_jti(), @@ -80,7 +83,10 @@ "space mint_policy is managing-app but managing_app_did is not set".into(), ) })?; - let space_uri = format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); + let space_uri = format!( + "at://{}/space/{}/{}", + space.did, space.type_nsid, space.skey + ); let granted = check_user_access_with_managing_app( http, managing_app, diff --git a/src/spaces/car.rs b/src/spaces/car.rs new file mode 100644 --- /dev/null +++ b/src/spaces/car.rs @@ -0,0 +1,247 @@ +use cid::Cid; +use sha2::{Digest, Sha256}; + +use crate::error::AppError; +use crate::spaces::commit::SignedCommit; +use crate::spaces::types::SpaceRecord; + +const SHA2_256: u64 = 0x12; +const DAG_CBOR: u64 = 0x71; +const RAW: u64 = 0x55; + +fn unsigned_varint(mut value: u64) -> Vec { + let mut buf = Vec::new(); + loop { + let mut byte = (value & 0x7F) as u8; + value >>= 7; + if value != 0 { + byte |= 0x80; + } + buf.push(byte); + if value == 0 { + break; + } + } + buf +} + +fn make_cid(codec: u64, block: &[u8]) -> Cid { + let digest = Sha256::digest(block); + let mut mh_bytes = Vec::with_capacity(34); + mh_bytes.push(SHA2_256 as u8); + mh_bytes.push(32u8); + mh_bytes.extend_from_slice(&digest); + let mh = cid::multihash::Multihash::<64>::from_bytes(&mh_bytes).expect("valid multihash"); + Cid::new_v1(codec, mh) +} + +// DAG-CBOR CID link: CBOR tag 42 wrapping bytes prefixed with 0x00 (multibase identity) +fn cid_link(cid: &Cid) -> ciborium::Value { + let mut bytes = vec![0x00u8]; // multibase identity prefix + bytes.extend_from_slice(&cid.to_bytes()); + ciborium::Value::Tag(42, Box::new(ciborium::Value::Bytes(bytes))) +} + +fn encode_cbor(value: &ciborium::Value) -> Result, AppError> { + let mut buf = Vec::new(); + ciborium::into_writer(value, &mut buf) + .map_err(|e| AppError::Internal(format!("CBOR encoding failed: {e}")))?; + Ok(buf) +} + +fn write_car_block(out: &mut Vec, cid: &Cid, block: &[u8]) { + let cid_bytes = cid.to_bytes(); + let section_len = cid_bytes.len() + block.len(); + out.extend(unsigned_varint(section_len as u64)); + out.extend_from_slice(&cid_bytes); + out.extend_from_slice(block); +} + +pub fn serialize_repo(commit: &SignedCommit, records: &[SpaceRecord]) -> Result, AppError> { + // Build record blocks sorted by collection/rkey + let mut indexed: Vec<(&SpaceRecord, Vec, Cid)> = records + .iter() + .map(|r| { + let block = serde_json::to_vec(&r.record) + .map_err(|e| AppError::Internal(format!("failed to serialize record: {e}")))?; + let cid = make_cid(RAW, &block); + Ok((r, block, cid)) + }) + .collect::, AppError>>()?; + indexed.sort_by(|a, b| { + let ka = format!("{}/{}", a.0.collection, a.0.rkey); + let kb = format!("{}/{}", b.0.collection, b.0.rkey); + ka.cmp(&kb) + }); + + // Build index: sorted map of "collection/rkey" -> CID link + let index_pairs: Vec<(ciborium::Value, ciborium::Value)> = indexed + .iter() + .map(|(r, _, cid)| { + let key = format!("{}/{}", r.collection, r.rkey); + (ciborium::Value::Text(key), cid_link(cid)) + }) + .collect(); + let index_cbor = ciborium::Value::Map(index_pairs); + let index_block = encode_cbor(&index_cbor)?; + let index_cid = make_cid(DAG_CBOR, &index_block); + + // Build signed commit block + let commit_cbor = ciborium::Value::Map(vec![ + ( + ciborium::Value::Text("ver".into()), + ciborium::Value::Integer(commit.ver.into()), + ), + ( + ciborium::Value::Text("hash".into()), + ciborium::Value::Bytes(commit.hash.to_vec()), + ), + ( + ciborium::Value::Text("ikm".into()), + ciborium::Value::Bytes(commit.ikm.to_vec()), + ), + ( + ciborium::Value::Text("sig".into()), + ciborium::Value::Bytes(commit.sig.clone()), + ), + ( + ciborium::Value::Text("mac".into()), + ciborium::Value::Bytes(commit.mac.to_vec()), + ), + ( + ciborium::Value::Text("rev".into()), + ciborium::Value::Text(commit.rev.clone()), + ), + ]); + let commit_block = encode_cbor(&commit_cbor)?; + let commit_cid = make_cid(DAG_CBOR, &commit_block); + + // Build CAR v1 header + let header_cbor = ciborium::Value::Map(vec![ + ( + ciborium::Value::Text("version".into()), + ciborium::Value::Integer(1.into()), + ), + ( + ciborium::Value::Text("roots".into()), + ciborium::Value::Array(vec![cid_link(&commit_cid), cid_link(&index_cid)]), + ), + ]); + let header_block = encode_cbor(&header_cbor)?; + + // Assemble CAR + let mut car = Vec::new(); + + // Header (varint-length-prefixed) + car.extend(unsigned_varint(header_block.len() as u64)); + car.extend_from_slice(&header_block); + + // Commit block + write_car_block(&mut car, &commit_cid, &commit_block); + + // Index block + write_car_block(&mut car, &index_cid, &index_block); + + // Record blocks in sorted order + for (_, block, cid) in &indexed { + write_car_block(&mut car, cid, block); + } + + Ok(car) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_commit() -> SignedCommit { + SignedCommit { + ver: 1, + hash: [0u8; 32], + ikm: [0u8; 32], + sig: vec![0u8; 64], + mac: [0u8; 32], + rev: "3k2rev1".to_string(), + } + } + + #[test] + fn serialize_empty_repo() { + let commit = test_commit(); + let car = serialize_repo(&commit, &[]).unwrap(); + assert!(!car.is_empty()); + // CAR starts with a varint-prefixed header — must be more than 10 bytes + assert!(car.len() > 10); + } + + #[test] + fn serialize_repo_with_records() { + let commit = SignedCommit { + ver: 1, + hash: [0xAA; 32], + ikm: [0xBB; 32], + sig: vec![0xCC; 64], + mac: [0xDD; 32], + rev: "3k2rev1".to_string(), + }; + + let records = + vec![ + SpaceRecord { + uri: "at://did:plc:abc/space/com.example.forum/main/did:plc:user/com.example.post/1" + .into(), + space_id: "space1".into(), + author_did: "did:plc:user".into(), + collection: "com.example.post".into(), + rkey: "1".into(), + record: serde_json::json!({"text": "hello"}), + cid: "bafyreiabc".into(), + indexed_at: "2026-01-01T00:00:00Z".into(), + }, + SpaceRecord { + uri: "at://did:plc:abc/space/com.example.forum/main/did:plc:user/com.example.post/2" + .into(), + space_id: "space1".into(), + author_did: "did:plc:user".into(), + collection: "com.example.post".into(), + rkey: "2".into(), + record: serde_json::json!({"text": "world"}), + cid: "bafyreixyz".into(), + indexed_at: "2026-01-01T00:00:01Z".into(), + }, + ]; + + let car = serialize_repo(&commit, &records).unwrap(); + assert!(car.len() > 100); + } + + #[test] + fn records_sorted_in_output() { + let commit = test_commit(); + let records = vec![ + SpaceRecord { + uri: "u1".into(), + space_id: "s".into(), + author_did: "d".into(), + collection: "com.example.b".into(), + rkey: "1".into(), + record: serde_json::json!({"n": 2}), + cid: "c2".into(), + indexed_at: "2026-01-01".into(), + }, + SpaceRecord { + uri: "u2".into(), + space_id: "s".into(), + author_did: "d".into(), + collection: "com.example.a".into(), + rkey: "1".into(), + record: serde_json::json!({"n": 1}), + cid: "c1".into(), + indexed_at: "2026-01-01".into(), + }, + ]; + // Should not panic, and produces valid output + let car = serialize_repo(&commit, &records).unwrap(); + assert!(car.len() > 100); + } +} diff --git a/src/spaces/commit.rs b/src/spaces/commit.rs --- a/src/spaces/commit.rs +++ b/src/spaces/commit.rs @@ -6,6 +6,7 @@ use crate::error::AppError; pub struct SignedCommit { + pub ver: u32, pub hash: [u8; 32], pub ikm: [u8; 32], pub sig: Vec, @@ -13,19 +14,24 @@ pub rev: String, } -pub fn build_context(space_uri: &str, rev: &str, ikm: &[u8; 32]) -> Vec { +pub fn build_context(space_uri: &str, author_did: &str, rev: &str, ikm: &[u8; 32]) -> Vec { let tag = b"atproto-space-v1"; let space_bytes = space_uri.as_bytes(); + let author_bytes = author_did.as_bytes(); let rev_bytes = rev.as_bytes(); - let mut ctx = - Vec::with_capacity(tag.len() + 2 + space_bytes.len() + 2 + rev_bytes.len() + 2 + 32); + let mut ctx = Vec::with_capacity( + tag.len() + 2 + space_bytes.len() + 2 + author_bytes.len() + 2 + rev_bytes.len() + 2 + 32, + ); ctx.extend_from_slice(tag); // TLS 1.3 variable-length encoding: big-endian uint16 length prefix ctx.extend_from_slice(&(space_bytes.len() as u16).to_be_bytes()); ctx.extend_from_slice(space_bytes); + + ctx.extend_from_slice(&(author_bytes.len() as u16).to_be_bytes()); + ctx.extend_from_slice(author_bytes); ctx.extend_from_slice(&(rev_bytes.len() as u16).to_be_bytes()); ctx.extend_from_slice(rev_bytes); @@ -39,15 +45,16 @@ pub fn sign_commit( hash: &[u8; 32], space_uri: &str, + author_did: &str, rev: &str, signing_key: &SigningKey, ) -> Result { let mut ikm = [0u8; 32]; rand::RngCore::fill_bytes(&mut rand::rng(), &mut ikm); - let ctx = build_context(space_uri, rev, &ikm); + let ctx = build_context(space_uri, author_did, rev, &ikm); - // sig covers space + rev + ikm, NOT the hash — prevents rebroadcast proof + // sig covers space + author + rev + ikm, NOT the hash — prevents rebroadcast proof let sig: Signature = signing_key.sign(&ctx); // mac = HMAC-SHA256(HKDF-SHA256(ikm, ctx), hash) @@ -62,6 +69,7 @@ let mac: [u8; 32] = mac_hasher.finalize().into_bytes().into(); Ok(SignedCommit { + ver: 1, hash: *hash, ikm, sig: sig.to_bytes().to_vec(), @@ -73,9 +81,17 @@ pub fn verify_commit( commit: &SignedCommit, space_uri: &str, + author_did: &str, verifying_key: &VerifyingKey, ) -> Result<(), AppError> { - let ctx = build_context(space_uri, &commit.rev, &commit.ikm); + if commit.ver != 1 { + return Err(AppError::BadRequest(format!( + "unsupported commit version: {}", + commit.ver + ))); + } + + let ctx = build_context(space_uri, author_did, &commit.rev, &commit.ikm); let sig = Signature::from_bytes(commit.sig.as_slice().into()) .map_err(|_| AppError::Auth("invalid commit signature format".into()))?; @@ -114,7 +130,8 @@ #[test] fn context_string_format() { let ctx = build_context( - "ats://did:plc:abc/com.example.forum/main", + "at://did:plc:abc/space/com.example.forum/main", + "did:plc:testuser", "3k2abc", &[0xAA; 32], ); @@ -124,15 +141,45 @@ #[test] fn context_includes_all_fields() { - let space = "ats://did:plc:abc/com.example.forum/main"; + let space = "at://did:plc:abc/space/com.example.forum/main"; + let author = "did:plc:testuser"; let rev = "3k2abc"; let ikm = [0xBB; 32]; - let ctx = build_context(space, rev, &ikm); + let ctx = build_context(space, author, rev, &ikm); - // Context must contain the space URI, rev, and ikm + // Context must contain the space URI, author, rev, and ikm assert!(ctx.windows(space.len()).any(|w| w == space.as_bytes())); + assert!(ctx.windows(author.len()).any(|w| w == author.as_bytes())); assert!(ctx.windows(rev.len()).any(|w| w == rev.as_bytes())); assert!(ctx.windows(32).any(|w| w == ikm)); + } + + #[test] + fn context_includes_author_did() { + let space = "at://did:plc:abc/space/com.example.forum/main"; + let author = "did:plc:user1"; + let rev = "3k2abc"; + let ikm = [0xBB; 32]; + let ctx = build_context(space, author, rev, &ikm); + + assert!(ctx.starts_with(b"atproto-space-v1")); + assert!(ctx.windows(author.len()).any(|w| w == author.as_bytes())); + + // Author must appear after space and before rev in the byte stream + let space_pos = ctx + .windows(space.len()) + .position(|w| w == space.as_bytes()) + .unwrap(); + let author_pos = ctx + .windows(author.len()) + .position(|w| w == author.as_bytes()) + .unwrap(); + let rev_pos = ctx + .windows(rev.len()) + .position(|w| w == rev.as_bytes()) + .unwrap(); + assert!(space_pos < author_pos); + assert!(author_pos < rev_pos); } #[test] @@ -140,16 +187,38 @@ let sk = test_signing_key(); let vk = *sk.verifying_key(); let hash = [0xCC; 32]; - let space = "ats://did:plc:abc/com.example.forum/main"; + let space = "at://did:plc:abc/space/com.example.forum/main"; - let commit = sign_commit(&hash, space, "3k2rev1", &sk).unwrap(); + let commit = sign_commit(&hash, space, "did:plc:testuser", "3k2rev1", &sk).unwrap(); assert_eq!(commit.hash, hash); assert_eq!(commit.rev, "3k2rev1"); assert_eq!(commit.mac.len(), 32); assert!(!commit.sig.is_empty()); + assert_eq!(commit.ver, 1); - assert!(verify_commit(&commit, space, &vk).is_ok()); + assert!(verify_commit(&commit, space, "did:plc:testuser", &vk).is_ok()); + } + + #[test] + fn commit_has_version() { + let sk = test_signing_key(); + let hash = [0xCC; 32]; + let space = "at://did:plc:abc/space/com.example.forum/main"; + let commit = sign_commit(&hash, space, "did:plc:testuser", "rev1", &sk).unwrap(); + assert_eq!(commit.ver, 1); + } + + #[test] + fn verify_rejects_wrong_author() { + let sk = test_signing_key(); + let vk = *sk.verifying_key(); + let hash = [0xAA; 32]; + let space = "at://did:plc:abc/space/com.example.forum/main"; + + let commit = sign_commit(&hash, space, "did:plc:user1", "rev1", &sk).unwrap(); + assert!(verify_commit(&commit, space, "did:plc:user1", &vk).is_ok()); + assert!(verify_commit(&commit, space, "did:plc:user2", &vk).is_err()); } #[test] @@ -161,10 +230,10 @@ let vk2 = *sk2.verifying_key(); let hash = [0xDD; 32]; - let space = "ats://did:plc:abc/com.example.forum/main"; + let space = "at://did:plc:abc/space/com.example.forum/main"; - let commit = sign_commit(&hash, space, "rev1", &sk1).unwrap(); - assert!(verify_commit(&commit, space, &vk2).is_err()); + let commit = sign_commit(&hash, space, "did:plc:testuser", "rev1", &sk1).unwrap(); + assert!(verify_commit(&commit, space, "did:plc:testuser", &vk2).is_err()); } #[test] @@ -172,11 +241,11 @@ let sk = test_signing_key(); let vk = *sk.verifying_key(); let hash = [0xEE; 32]; - let space = "ats://did:plc:abc/com.example.forum/main"; + let space = "at://did:plc:abc/space/com.example.forum/main"; - let mut commit = sign_commit(&hash, space, "rev1", &sk).unwrap(); + let mut commit = sign_commit(&hash, space, "did:plc:testuser", "rev1", &sk).unwrap(); commit.hash[0] ^= 0xFF; // tamper - assert!(verify_commit(&commit, space, &vk).is_err()); + assert!(verify_commit(&commit, space, "did:plc:testuser", &vk).is_err()); } #[test] @@ -187,29 +256,50 @@ let commit = sign_commit( &hash, - "ats://did:plc:abc/com.example.forum/main", + "at://did:plc:abc/space/com.example.forum/main", + "did:plc:user", "rev1", &sk, ) .unwrap(); - assert!(verify_commit(&commit, "ats://did:plc:xyz/com.example.forum/other", &vk).is_err()); + assert!( + verify_commit( + &commit, + "at://did:plc:xyz/space/com.example.forum/other", + "did:plc:user", + &vk + ) + .is_err() + ); } #[test] fn different_ikm_per_commit() { let sk = test_signing_key(); let hash = [0xAA; 32]; - let space = "ats://did:plc:abc/com.example.forum/main"; + let space = "at://did:plc:abc/space/com.example.forum/main"; - let c1 = sign_commit(&hash, space, "rev1", &sk).unwrap(); - let c2 = sign_commit(&hash, space, "rev1", &sk).unwrap(); + let c1 = sign_commit(&hash, space, "did:plc:testuser", "rev1", &sk).unwrap(); + let c2 = sign_commit(&hash, space, "did:plc:testuser", "rev1", &sk).unwrap(); // Each call generates fresh ikm assert_ne!(c1.ikm, c2.ikm); // But both verify let vk = *sk.verifying_key(); - assert!(verify_commit(&c1, space, &vk).is_ok()); - assert!(verify_commit(&c2, space, &vk).is_ok()); + assert!(verify_commit(&c1, space, "did:plc:testuser", &vk).is_ok()); + assert!(verify_commit(&c2, space, "did:plc:testuser", &vk).is_ok()); + } + + #[test] + fn verify_rejects_unknown_version() { + let sk = test_signing_key(); + let vk = *sk.verifying_key(); + let hash = [0xCC; 32]; + let space = "at://did:plc:abc/space/com.example.forum/main"; + + let mut commit = sign_commit(&hash, space, "did:plc:testuser", "rev1", &sk).unwrap(); + commit.ver = 2; + assert!(verify_commit(&commit, space, "did:plc:testuser", &vk).is_err()); } #[test] @@ -217,11 +307,11 @@ let sk = test_signing_key(); let vk = *sk.verifying_key(); let hash = [0xCC; 32]; - let space = "ats://did:plc:abc/com.example.forum/main"; + let space = "at://did:plc:abc/space/com.example.forum/main"; - let mut commit = sign_commit(&hash, space, "rev1", &sk).unwrap(); - assert!(verify_commit(&commit, space, &vk).is_ok()); + let mut commit = sign_commit(&hash, space, "did:plc:testuser", "rev1", &sk).unwrap(); + assert!(verify_commit(&commit, space, "did:plc:testuser", &vk).is_ok()); commit.mac[0] ^= 0xFF; - assert!(verify_commit(&commit, space, &vk).is_err()); + assert!(verify_commit(&commit, space, "did:plc:testuser", &vk).is_err()); } } diff --git a/src/spaces/credential.rs b/src/spaces/credential.rs --- a/src/spaces/credential.rs +++ b/src/spaces/credential.rs @@ -50,7 +50,7 @@ #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DelegationTokenClaims { pub iss: String, // User DID - pub sub: String, // Space URI (ats://...) + pub sub: String, // Space URI (at://...) pub aud: String, // Space host (did#atproto_space_host) pub iat: u64, pub exp: u64, @@ -156,7 +156,7 @@ #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SpaceCredentialClaims { pub iss: String, // Space authority DID - pub sub: String, // Space URI (ats://...) + pub sub: String, // Space URI (at://...) pub iat: u64, pub exp: u64, pub jti: String, // Random nonce @@ -362,7 +362,7 @@ .as_secs(); SpaceCredentialClaims { iss: "did:plc:spaceowner".into(), - sub: "ats://did:plc:spaceowner/com.example.forum/main".into(), + sub: "at://did:plc:spaceowner/space/com.example.forum/main".into(), iat: now, exp: now + DEFAULT_CREDENTIAL_TTL_SECS, jti: make_jti(), @@ -420,7 +420,7 @@ .as_secs(); let claims = SpaceCredentialClaims { iss: "did:plc:owner".into(), - sub: "ats://did:plc:owner/com.example.test/main".into(), + sub: "at://did:plc:owner/space/com.example.test/main".into(), iat: now - 7200, exp: now - 3600, jti: make_jti(), @@ -451,7 +451,7 @@ .as_secs(); DelegationTokenClaims { iss: "did:plc:member".into(), - sub: "ats://did:plc:space/com.example.forum/main".into(), + sub: "at://did:plc:space/space/com.example.forum/main".into(), aud: "did:plc:space#atproto_space_host".into(), iat: now, exp: now + DELEGATION_TOKEN_TTL_SECS, @@ -509,7 +509,7 @@ .as_secs(); let claims = DelegationTokenClaims { iss: "did:plc:member".into(), - sub: "ats://did:plc:space/com.example.forum/main".into(), + sub: "at://did:plc:space/space/com.example.forum/main".into(), aud: "did:plc:space#atproto_space_host".into(), iat: now - 120, exp: now - 60, @@ -575,7 +575,7 @@ let token = sign_credential(&claims, &keypair.private_jwk).unwrap(); assert_eq!( peek_credential_sub(&token).as_deref(), - Some("ats://did:plc:spaceowner/com.example.forum/main") + Some("at://did:plc:spaceowner/space/com.example.forum/main") ); } diff --git a/src/spaces/db.rs b/src/spaces/db.rs --- a/src/spaces/db.rs +++ b/src/spaces/db.rs @@ -122,12 +122,12 @@ let sql = if decoded_cursor.is_some() { adapt_sql( - "SELECT s.did, s.authority_did, s.type_nsid, s.skey, sm.created_at FROM happyview_space_members sm JOIN happyview_spaces s ON s.id = sm.space_id WHERE sm.member_did = ? AND (sm.created_at > ? OR (sm.created_at = ? AND ('ats://' || s.did || '/' || s.type_nsid || '/' || s.skey) > ?)) ORDER BY sm.created_at ASC, ('ats://' || s.did || '/' || s.type_nsid || '/' || s.skey) ASC LIMIT ?", + "SELECT s.did, s.authority_did, s.type_nsid, s.skey, sm.created_at FROM happyview_space_members sm JOIN happyview_spaces s ON s.id = sm.space_id WHERE sm.member_did = ? AND (sm.created_at > ? OR (sm.created_at = ? AND ('at://' || s.did || '/space/' || s.type_nsid || '/' || s.skey) > ?)) ORDER BY sm.created_at ASC, ('at://' || s.did || '/space/' || s.type_nsid || '/' || s.skey) ASC LIMIT ?", backend, ) } else { adapt_sql( - "SELECT s.did, s.authority_did, s.type_nsid, s.skey, sm.created_at FROM happyview_space_members sm JOIN happyview_spaces s ON s.id = sm.space_id WHERE sm.member_did = ? ORDER BY sm.created_at ASC, ('ats://' || s.did || '/' || s.type_nsid || '/' || s.skey) ASC LIMIT ?", + "SELECT s.did, s.authority_did, s.type_nsid, s.skey, sm.created_at FROM happyview_space_members sm JOIN happyview_spaces s ON s.id = sm.space_id WHERE sm.member_did = ? ORDER BY sm.created_at ASC, ('at://' || s.did || '/space/' || s.type_nsid || '/' || s.skey) ASC LIMIT ?", backend, ) }; @@ -147,7 +147,7 @@ .into_iter() .map( |(space_did, authority_did, type_nsid, skey, created_at)| SpaceView { - uri: format!("ats://{}/{}/{}", space_did, type_nsid, skey), + uri: format!("at://{}/space/{}/{}", space_did, type_nsid, skey), is_owner: authority_did == did, created_at, }, @@ -527,6 +527,27 @@ }; Ok((records, next_cursor)) +} + +pub async fn list_all_space_records( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + space_id: &str, + author_did: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT uri, space_id, author_did, collection, rkey, record, cid, indexed_at FROM happyview_space_records WHERE space_id = ? AND author_did = ? ORDER BY collection, rkey", + backend, + ); + + let rows: Vec = sqlx::query_as(&sql) + .bind(space_id) + .bind(author_did) + .fetch_all(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to list all space records: {e}")))?; + + rows.into_iter().map(parse_record_row).collect() } pub async fn insert_space_record( diff --git a/src/spaces/integration_tests.rs b/src/spaces/integration_tests.rs --- a/src/spaces/integration_tests.rs +++ b/src/spaces/integration_tests.rs @@ -41,13 +41,13 @@ let sk = test_signing_key(); let vk = *sk.verifying_key(); - let space_uri = "ats://did:plc:abc/com.example.forum/main"; + let space_uri = "at://did:plc:abc/space/com.example.forum/main"; let rev = "3k2rev1"; - let commit = sign_commit(&hash_after_ab, space_uri, rev, &sk).unwrap(); + let commit = sign_commit(&hash_after_ab, space_uri, "did:plc:testuser", rev, &sk).unwrap(); assert_eq!(commit.hash, hash_after_ab); assert_eq!(commit.rev, rev); - assert!(verify_commit(&commit, space_uri, &vk).is_ok()); + assert!(verify_commit(&commit, space_uri, "did:plc:testuser", &vk).is_ok()); } /// Remove a record — hash must change back toward the previous state. @@ -76,9 +76,9 @@ let sk = test_signing_key(); let vk = *sk.verifying_key(); - let space_uri = "ats://did:plc:abc/com.example.forum/main"; - let commit = sign_commit(&hash_one, space_uri, "3k2rev2", &sk).unwrap(); - assert!(verify_commit(&commit, space_uri, &vk).is_ok()); + let space_uri = "at://did:plc:abc/space/com.example.forum/main"; + let commit = sign_commit(&hash_one, space_uri, "did:plc:testuser", "3k2rev2", &sk).unwrap(); + assert!(verify_commit(&commit, space_uri, "did:plc:testuser", &vk).is_ok()); } /// Commit signed for one hash must not verify against a different hash. @@ -94,13 +94,13 @@ let sk = test_signing_key(); let vk = *sk.verifying_key(); - let space_uri = "ats://did:plc:abc/com.example.forum/main"; + let space_uri = "at://did:plc:abc/space/com.example.forum/main"; - let commit_a = sign_commit(&hash_a, space_uri, "rev1", &sk).unwrap(); + let commit_a = sign_commit(&hash_a, space_uri, "did:plc:testuser", "rev1", &sk).unwrap(); // Tamper: swap in hash_b let mut tampered = commit_a; tampered.hash = hash_b; - assert!(verify_commit(&tampered, space_uri, &vk).is_err()); + assert!(verify_commit(&tampered, space_uri, "did:plc:testuser", &vk).is_err()); } // ----------------------------------------------------------------------- @@ -138,7 +138,7 @@ // Step 1: member signs delegation token let delegation = DelegationTokenClaims { iss: "did:plc:member".into(), - sub: "ats://did:plc:space/com.example.forum/main".into(), + sub: "at://did:plc:space/space/com.example.forum/main".into(), aud: "did:plc:space#atproto_space_host".into(), iat: now, exp: now + DELEGATION_TOKEN_TTL_SECS, @@ -154,7 +154,7 @@ assert_eq!(verified_delegation.iss, "did:plc:member"); assert_eq!( verified_delegation.sub, - "ats://did:plc:space/com.example.forum/main" + "at://did:plc:space/space/com.example.forum/main" ); // Step 3: space host issues a space credential (using P-256 key) @@ -175,7 +175,7 @@ ); assert_eq!( peek_credential_sub(&credential).as_deref(), - Some("ats://did:plc:space/com.example.forum/main") + Some("at://did:plc:space/space/com.example.forum/main") ); // Step 4: verify credential @@ -183,7 +183,7 @@ assert_eq!(verified_cred.iss, "did:plc:space"); assert_eq!( verified_cred.sub, - "ats://did:plc:space/com.example.forum/main" + "at://did:plc:space/space/com.example.forum/main" ); } @@ -196,7 +196,7 @@ let delegation = DelegationTokenClaims { iss: "did:plc:member".into(), - sub: "ats://did:plc:space/com.example.forum/main".into(), + sub: "at://did:plc:space/space/com.example.forum/main".into(), aud: "did:plc:space#atproto_space_host".into(), iat: now - 120, exp: now - 60, // already expired @@ -240,6 +240,7 @@ rkey: "3k2abc".into(), cid: Some("bafyreiabc".into()), prev: None, + value: None, created_at: "2026-01-01T00:00:00Z".into(), }; @@ -266,6 +267,7 @@ rkey: "3k2abc".into(), cid: None, prev: Some("bafyreiabc".into()), + value: None, created_at: "2026-01-01T00:00:01Z".into(), }; @@ -499,11 +501,11 @@ } let qs = serde_json::json!({ - "space": "ats://did:plc:abc/com.example.forum/main", + "space": "at://did:plc:abc/space/com.example.forum/main", "cid": "bafyreiabc123" }); let q: BlobQuery = serde_json::from_value(qs).unwrap(); - assert_eq!(q.space, "ats://did:plc:abc/com.example.forum/main"); + assert_eq!(q.space, "at://did:plc:abc/space/com.example.forum/main"); assert_eq!(q.cid, "bafyreiabc123"); } @@ -540,7 +542,7 @@ let claims = SpaceCredentialClaims { iss: "did:plc:owner".into(), - sub: "ats://did:plc:owner/com.example.forum/main".into(), + sub: "at://did:plc:owner/space/com.example.forum/main".into(), iat: now, exp: now + DEFAULT_CREDENTIAL_TTL_SECS, jti: make_jti(), @@ -562,7 +564,7 @@ let claims = SpaceCredentialClaims { iss: "did:plc:owner".into(), - sub: "ats://did:plc:owner/com.example.forum/main".into(), + sub: "at://did:plc:owner/space/com.example.forum/main".into(), iat: now, exp: now + DEFAULT_CREDENTIAL_TTL_SECS, jti: make_jti(), diff --git a/src/spaces/members.rs b/src/spaces/members.rs --- a/src/spaces/members.rs +++ b/src/spaces/members.rs @@ -87,13 +87,13 @@ /// Resolve a delegation member entry to the target space ID. /// -/// Delegation entries store either an ats:// URI or a space ID directly. +/// Delegation entries store either an at:// URI or a space ID directly. async fn resolve_delegation_target( pool: &sqlx::AnyPool, backend: DatabaseBackend, member: &SpaceMember, ) -> Result, AppError> { - if member.did.starts_with("ats://") { + if member.did.starts_with("at://") || member.did.starts_with("ats://") { let uri = SpaceUri::parse(&member.did)?; let space = db::get_space_by_address(pool, backend, &uri.did, &uri.type_nsid, &uri.skey).await?; diff --git a/src/spaces/mod.rs b/src/spaces/mod.rs --- a/src/spaces/mod.rs +++ b/src/spaces/mod.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod car; pub mod client_attestation; pub mod commit; pub mod credential; @@ -18,10 +19,10 @@ use crate::error::AppError; use std::fmt; -/// A parsed `ats://` URI for addressing permissioned data. +/// A parsed `at://` URI for addressing permissioned data. /// -/// Full form: `ats:///////` -/// Space-only form: `ats:////` +/// Full form: `at:///space/////` +/// Space-only form: `at:///space//` #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct SpaceUri { pub did: String, @@ -34,39 +35,66 @@ impl SpaceUri { pub fn parse(uri: &str) -> Result { - let stripped = uri - .strip_prefix("ats://") - .ok_or_else(|| AppError::BadRequest("SpaceUri must start with ats://".into()))?; + // Rewrite legacy ats:// URIs (ats://did/type/skey) to at://did/space/type/skey + let normalized; + let stripped = if let Some(ats_rest) = uri.strip_prefix("ats://") { + let ats_parts: Vec<&str> = ats_rest.split('/').collect(); + if ats_parts.len() >= 3 { + normalized = format!("at://{}/space/{}", ats_parts[0], ats_parts[1..].join("/")); + normalized + .strip_prefix("at://") + .expect("just constructed with at:// prefix") + } else { + return Err(AppError::BadRequest( + "ats:// URI requires at least did/type/skey".into(), + )); + } + } else { + uri.strip_prefix("at://") + .ok_or_else(|| AppError::BadRequest("SpaceUri must start with at://".into()))? + }; let parts: Vec<&str> = stripped.split('/').collect(); - if parts.len() < 3 { + // Must have at least: did/space/type_nsid/skey (4 segments) + if parts.len() < 4 { return Err(AppError::BadRequest( - "SpaceUri requires at least did/type_nsid/skey".into(), + "SpaceUri requires at least did/space/type_nsid/skey".into(), )); } - if parts[0].is_empty() || parts[1].is_empty() || parts[2].is_empty() { + if parts[1] != "space" { + return Err(AppError::BadRequest( + "SpaceUri must have 'space' as the second path segment".into(), + )); + } + + if parts[0].is_empty() || parts[2].is_empty() || parts[3].is_empty() { return Err(AppError::BadRequest( "SpaceUri components must not be empty".into(), )); } let did = parts[0].to_string(); - let type_nsid = parts[1].to_string(); - let skey = parts[2].to_string(); + let type_nsid = parts[2].to_string(); + let skey = parts[3].to_string(); - let (user_did, collection, rkey) = if parts.len() >= 6 { + let (user_did, collection, rkey) = if parts.len() == 7 { + if parts[4].is_empty() || parts[5].is_empty() || parts[6].is_empty() { + return Err(AppError::BadRequest( + "SpaceUri record components must not be empty".into(), + )); + } ( - Some(parts[3].to_string()), Some(parts[4].to_string()), Some(parts[5].to_string()), + Some(parts[6].to_string()), ) - } else if parts.len() == 3 { + } else if parts.len() == 4 { (None, None, None) } else { return Err(AppError::BadRequest( - "SpaceUri must have 3 components (space) or 6 components (record)".into(), + "SpaceUri must have 4 components (space) or 7 components (record)".into(), )); }; @@ -81,7 +109,7 @@ } pub fn space_uri(&self) -> String { - format!("ats://{}/{}/{}", self.did, self.type_nsid, self.skey) + format!("at://{}/space/{}/{}", self.did, self.type_nsid, self.skey) } pub fn is_record_uri(&self) -> bool { @@ -95,7 +123,11 @@ impl fmt::Display for SpaceUri { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "ats://{}/{}/{}", self.did, self.type_nsid, self.skey)?; + write!( + f, + "at://{}/space/{}/{}", + self.did, self.type_nsid, self.skey + )?; if let (Some(user), Some(col), Some(rkey)) = (&self.user_did, &self.collection, &self.rkey) { write!(f, "/{}/{}/{}", user, col, rkey)?; @@ -110,7 +142,7 @@ #[test] fn parse_space_uri() { - let uri = SpaceUri::parse("ats://did:plc:abc123/com.example.forum/main").unwrap(); + let uri = SpaceUri::parse("at://did:plc:abc123/space/com.example.forum/main").unwrap(); assert_eq!(uri.did, "did:plc:abc123"); assert_eq!(uri.type_nsid, "com.example.forum"); assert_eq!(uri.skey, "main"); @@ -122,7 +154,7 @@ #[test] fn parse_record_uri() { let uri = SpaceUri::parse( - "ats://did:plc:abc123/com.example.forum/main/did:plc:user1/com.example.forum.post/3k2abc", + "at://did:plc:abc123/space/com.example.forum/main/did:plc:user1/com.example.forum.post/3k2abc", ) .unwrap(); assert_eq!(uri.did, "did:plc:abc123"); @@ -147,7 +179,7 @@ }; assert_eq!( uri.to_string(), - "ats://did:plc:abc123/com.example.forum/main" + "at://did:plc:abc123/space/com.example.forum/main" ); } @@ -163,53 +195,86 @@ }; assert_eq!( uri.to_string(), - "ats://did:plc:abc123/com.example.forum/main/did:plc:user1/com.example.forum.post/3k2abc" + "at://did:plc:abc123/space/com.example.forum/main/did:plc:user1/com.example.forum.post/3k2abc" ); } #[test] fn space_uri_extracts_space_part() { let uri = SpaceUri::parse( - "ats://did:plc:abc123/com.example.forum/main/did:plc:user1/com.example.forum.post/3k2abc", + "at://did:plc:abc123/space/com.example.forum/main/did:plc:user1/com.example.forum.post/3k2abc", ) .unwrap(); assert_eq!( uri.space_uri(), - "ats://did:plc:abc123/com.example.forum/main" + "at://did:plc:abc123/space/com.example.forum/main" ); } #[test] - fn reject_at_scheme() { + fn rewrite_ats_space_uri() { + let uri = SpaceUri::parse("ats://did:plc:abc123/com.example.forum/main").unwrap(); + assert_eq!(uri.did, "did:plc:abc123"); + assert_eq!(uri.type_nsid, "com.example.forum"); + assert_eq!(uri.skey, "main"); + assert!(uri.is_space_uri()); + assert_eq!( + uri.to_string(), + "at://did:plc:abc123/space/com.example.forum/main" + ); + } + + #[test] + fn rewrite_ats_record_uri() { + let uri = SpaceUri::parse( + "ats://did:plc:abc123/com.example.forum/main/did:plc:user1/com.example.forum.post/3k2abc", + ) + .unwrap(); + assert_eq!(uri.did, "did:plc:abc123"); + assert_eq!(uri.type_nsid, "com.example.forum"); + assert_eq!(uri.skey, "main"); + assert_eq!(uri.user_did.as_deref(), Some("did:plc:user1")); + assert!(uri.is_record_uri()); + } + + #[test] + fn reject_ats_too_few_segments() { + let result = SpaceUri::parse("ats://did:plc:abc123/com.example.forum"); + assert!(result.is_err()); + } + + #[test] + fn reject_missing_space_segment() { let result = SpaceUri::parse("at://did:plc:abc123/com.example.forum/main"); assert!(result.is_err()); } #[test] fn reject_too_few_components() { - let result = SpaceUri::parse("ats://did:plc:abc123/com.example.forum"); + let result = SpaceUri::parse("at://did:plc:abc123/space/com.example.forum"); assert!(result.is_err()); } #[test] fn reject_wrong_component_count() { - let result = SpaceUri::parse("ats://did:plc:abc123/com.example.forum/main/did:plc:user1"); + let result = + SpaceUri::parse("at://did:plc:abc123/space/com.example.forum/main/did:plc:user1"); assert!(result.is_err()); } #[test] fn reject_empty_components() { - let result = SpaceUri::parse("ats:///com.example.forum/main"); + let result = SpaceUri::parse("at:///space/com.example.forum/main"); assert!(result.is_err()); } #[test] fn roundtrip_parse_display() { - let original = "ats://did:plc:abc123/com.example.forum/main"; + let original = "at://did:plc:abc123/space/com.example.forum/main"; let uri = SpaceUri::parse(original).unwrap(); assert_eq!(uri.to_string(), original); - let original_record = "ats://did:plc:abc123/com.example.forum/main/did:plc:user1/com.example.forum.post/3k2abc"; + let original_record = "at://did:plc:abc123/space/com.example.forum/main/did:plc:user1/com.example.forum.post/3k2abc"; let uri = SpaceUri::parse(original_record).unwrap(); assert_eq!(uri.to_string(), original_record); } diff --git a/src/spaces/oplog.rs b/src/spaces/oplog.rs --- a/src/spaces/oplog.rs +++ b/src/spaces/oplog.rs @@ -91,6 +91,82 @@ rkey: r.7, cid: r.8, prev: r.9, + value: None, + created_at: r.10, + }) + }) + .collect() +} + +pub async fn list_ops_with_values( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + space_id: &str, + author_did: &str, + since_rev: Option<&str>, + limit: i64, +) -> Result, AppError> { + let sql = if since_rev.is_some() { + adapt_sql( + "SELECT o.id, o.space_id, o.author_did, o.rev, o.idx, o.action, o.collection, o.rkey, o.cid, o.prev, o.created_at, r.record FROM happyview_space_record_oplog o LEFT JOIN happyview_space_records r ON r.space_id = o.space_id AND r.author_did = o.author_did AND r.collection = o.collection AND r.rkey = o.rkey AND r.cid = o.cid WHERE o.space_id = ? AND o.author_did = ? AND o.rev > ? ORDER BY o.rev, o.idx LIMIT ?", + backend, + ) + } else { + adapt_sql( + "SELECT o.id, o.space_id, o.author_did, o.rev, o.idx, o.action, o.collection, o.rkey, o.cid, o.prev, o.created_at, r.record FROM happyview_space_record_oplog o LEFT JOIN happyview_space_records r ON r.space_id = o.space_id AND r.author_did = o.author_did AND r.collection = o.collection AND r.rkey = o.rkey AND r.cid = o.cid WHERE o.space_id = ? AND o.author_did = ? ORDER BY o.rev, o.idx LIMIT ?", + backend, + ) + }; + + type OplogWithValueRow = ( + String, + String, + String, + String, + i32, + String, + String, + String, + Option, + Option, + String, + Option, + ); + + let mut query = sqlx::query_as::<_, OplogWithValueRow>(&sql) + .bind(space_id) + .bind(author_did); + if let Some(rev) = since_rev { + query = query.bind(rev); + } + query = query.bind(limit); + + let rows = query.fetch_all(pool).await.map_err(|e| { + AppError::Internal(format!("failed to list oplog entries with values: {e}")) + })?; + + rows.into_iter() + .map(|r| { + let action = OplogAction::parse(&r.5) + .ok_or_else(|| AppError::Internal(format!("invalid oplog action: {}", r.5)))?; + let value = r + .11 + .as_deref() + .map(serde_json::from_str) + .transpose() + .map_err(|e| AppError::Internal(format!("failed to parse record value: {e}")))?; + Ok(OplogEntry { + id: r.0, + space_id: r.1, + author_did: r.2, + rev: r.3, + idx: r.4, + action, + collection: r.6, + rkey: r.7, + cid: r.8, + prev: r.9, + value, created_at: r.10, }) }) diff --git a/src/spaces/routes.rs b/src/spaces/routes.rs --- a/src/spaces/routes.rs +++ b/src/spaces/routes.rs @@ -24,7 +24,14 @@ #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct RepoStateQuery { +struct LatestCommitQuery { + space: String, + did: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct GetRepoQuery { space: String, did: String, } @@ -36,6 +43,7 @@ did: String, limit: Option, cursor: Option, + exclude_values: Option, } #[derive(Deserialize)] @@ -218,9 +226,14 @@ get(list_records), ) .route( - &format!("/xrpc/{PROTO_NS}.space.getRepoState"), - get(get_repo_state), + &format!("/xrpc/{PROTO_NS}.space.getLatestCommit"), + get(get_latest_commit), ) + .route( + &format!("/xrpc/{PROTO_NS}.space.getRepoState"), + get(get_latest_commit), + ) + .route(&format!("/xrpc/{PROTO_NS}.space.getRepo"), get(get_repo)) .route( &format!("/xrpc/{PROTO_NS}.space.listRepoOps"), get(list_repo_ops), @@ -409,7 +422,10 @@ space_credential: Option<&str>, ) -> Result { if let Some(token) = space_credential { - let space_uri = format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); + let space_uri = format!( + "at://{}/space/{}/{}", + space.did, space.type_nsid, space.skey + ); match crate::spaces::credential::verify_external_credential( token, &state.http, @@ -490,7 +506,10 @@ } } - let space_uri = format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); + let space_uri = format!( + "at://{}/space/{}/{}", + space.did, space.type_nsid, space.skey + ); let simplespace_config = serde_json::json!({ "$type": "com.atproto.simplespace.defs#spaceConfig", "mintPolicy": space.mint_policy, @@ -561,7 +580,7 @@ let rkey = generate_tid(); let cid = content_cid(&input.record); let record_uri = format!( - "ats://{}/{}/{}/{}/{}/{}", + "at://{}/space/{}/{}/{}/{}/{}", space.did, space.type_nsid, space.skey, did, input.collection, rkey ); @@ -609,7 +628,7 @@ let cid = content_cid(&input.record); let record_uri = format!( - "ats://{}/{}/{}/{}/{}/{}", + "at://{}/space/{}/{}/{}/{}/{}", space.did, space.type_nsid, space.skey, did, input.collection, input.rkey ); @@ -653,7 +672,7 @@ let space = resolve_space(&state, &input.space).await?; let record_uri = format!( - "ats://{}/{}/{}/{}/{}/{}", + "at://{}/space/{}/{}/{}/{}/{}", space.did, space.type_nsid, space.skey, did, input.collection, input.rkey ); @@ -722,7 +741,7 @@ let rkey = rkey.unwrap_or_else(generate_tid); let cid = content_cid(&value); let record_uri = format!( - "ats://{}/{}/{}/{}/{}/{}", + "at://{}/space/{}/{}/{}/{}/{}", space.did, space.type_nsid, space.skey, did, collection, rkey ); let record = SpaceRecord { @@ -749,7 +768,7 @@ } => { let cid = content_cid(&value); let record_uri = format!( - "ats://{}/{}/{}/{}/{}/{}", + "at://{}/space/{}/{}/{}/{}/{}", space.did, space.type_nsid, space.skey, did, collection, rkey ); let record = SpaceRecord { @@ -784,7 +803,7 @@ swap_record, } => { let record_uri = format!( - "ats://{}/{}/{}/{}/{}/{}", + "at://{}/space/{}/{}/{}/{}/{}", space.did, space.type_nsid, space.skey, did, collection, rkey ); if let Some(swap_cid) = swap_record { @@ -1007,7 +1026,7 @@ db::increment_invite_uses(&state.db, state.db_backend, &invite.id).await?; let space = db::get_space(&state.db, state.db_backend, &invite.space_id).await?; - let space_uri = space.map(|s| format!("ats://{}/{}/{}", s.did, s.type_nsid, s.skey)); + let space_uri = space.map(|s| format!("at://{}/space/{}/{}", s.did, s.type_nsid, s.skey)); let mut response = Json(serde_json::json!({ "uri": space_uri, @@ -1095,7 +1114,10 @@ .as_secs(); let exp = now + crate::spaces::credential::DELEGATION_TOKEN_TTL_SECS; - let space_uri = format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); + let space_uri = format!( + "at://{}/space/{}/{}", + space.did, space.type_nsid, space.skey + ); let space_host = format!("{}#atproto_space_host", space.did); let delegation_claims = crate::spaces::credential::DelegationTokenClaims { iss: did, @@ -1122,10 +1144,10 @@ // Protocol endpoint implementations // --------------------------------------------------------------------------- -async fn get_repo_state( +async fn get_latest_commit( State(state): State, claims: XrpcClaims, - Query(params): Query, + Query(params): Query, ) -> Result { let did = require_auth_or_credential(&state, &claims).await?; let space = resolve_space(&state, ¶ms.space).await?; @@ -1145,18 +1167,101 @@ let repo_state = db::get_or_create_repo_state(&state.db, state.db_backend, &space.id, ¶ms.did).await?; + let commit = if let Some(h) = repo_state.hash.as_ref() { + let ikm = repo_state.ikm.as_deref().ok_or_else(|| { + AppError::Internal("corrupt repo state: hash present but ikm missing".into()) + })?; + let sig = repo_state.sig.as_deref().ok_or_else(|| { + AppError::Internal("corrupt repo state: hash present but sig missing".into()) + })?; + let mac = repo_state.mac.as_deref().ok_or_else(|| { + AppError::Internal("corrupt repo state: hash present but mac missing".into()) + })?; + Some(serde_json::json!({ + "ver": 1, + "hash": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(h), + "ikm": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(ikm), + "sig": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig), + "mac": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac), + "rev": repo_state.rev, + })) + } else { + None + }; + Ok(Json(serde_json::json!({ "rev": repo_state.rev, - "commit": repo_state.hash.as_ref().map(|h| { - serde_json::json!({ - "hash": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(h), - "ikm": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(repo_state.ikm.as_deref().unwrap_or_default()), - "sig": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(repo_state.sig.as_deref().unwrap_or_default()), - "mac": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(repo_state.mac.as_deref().unwrap_or_default()), - "rev": repo_state.rev, - }) - }), + "commit": commit, }))) +} + +async fn get_repo( + State(state): State, + claims: XrpcClaims, + Query(params): Query, +) -> Result { + let did = require_auth_or_credential(&state, &claims).await?; + let space = resolve_space(&state, ¶ms.space).await?; + let has_credential = claims.space_credential.is_some(); + let membership = require_membership( + &state, + &space, + &did, + false, + claims.space_credential.as_deref(), + ) + .await?; + + let read_access = SpaceReadAccess::from_space_access(membership); + check_read_access(&did, ¶ms.did, read_access, has_credential)?; + + let repo_state = + db::get_or_create_repo_state(&state.db, state.db_backend, &space.id, ¶ms.did).await?; + let records = + db::list_all_space_records(&state.db, state.db_backend, &space.id, ¶ms.did).await?; + + let hash = repo_state + .hash + .as_deref() + .ok_or_else(|| AppError::NotFound("no commit exists for this repo".into()))?; + let hash: [u8; 32] = hash + .try_into() + .map_err(|_| AppError::Internal("corrupt repo state: hash is not 32 bytes".into()))?; + let ikm: [u8; 32] = repo_state + .ikm + .as_deref() + .ok_or_else(|| AppError::Internal("corrupt repo state: missing ikm".into()))? + .try_into() + .map_err(|_| AppError::Internal("corrupt repo state: ikm is not 32 bytes".into()))?; + let mac: [u8; 32] = repo_state + .mac + .as_deref() + .ok_or_else(|| AppError::Internal("corrupt repo state: missing mac".into()))? + .try_into() + .map_err(|_| AppError::Internal("corrupt repo state: mac is not 32 bytes".into()))?; + let sig = repo_state + .sig + .ok_or_else(|| AppError::Internal("corrupt repo state: missing sig".into()))?; + let rev = repo_state + .rev + .ok_or_else(|| AppError::Internal("corrupt repo state: missing rev".into()))?; + let commit = crate::spaces::commit::SignedCommit { + ver: 1, + hash, + ikm, + sig, + mac, + rev, + }; + + let car_bytes = crate::spaces::car::serialize_repo(&commit, &records)?; + + Ok(( + StatusCode::OK, + [(axum::http::header::CONTENT_TYPE, "application/vnd.ipld.car")], + car_bytes, + ) + .into_response()) } async fn list_repo_ops( @@ -1180,15 +1285,29 @@ check_read_access(&did, ¶ms.did, read_access, has_credential)?; let limit = params.limit.unwrap_or(100).min(1000); - let ops = oplog::list_ops( - &state.db, - state.db_backend, - &space.id, - ¶ms.did, - params.cursor.as_deref(), - limit, - ) - .await?; + let exclude_values = params.exclude_values.unwrap_or(false); + + let ops = if exclude_values { + oplog::list_ops( + &state.db, + state.db_backend, + &space.id, + ¶ms.did, + params.cursor.as_deref(), + limit, + ) + .await? + } else { + oplog::list_ops_with_values( + &state.db, + state.db_backend, + &space.id, + ¶ms.did, + params.cursor.as_deref(), + limit, + ) + .await? + }; Ok(Json(serde_json::json!({ "ops": ops }))) } @@ -1416,12 +1535,12 @@ #[test] fn deserialize_create_record_input() { let input: CreateRecordInput = serde_json::from_value(json!({ - "space": "ats://did:plc:abc/com.example.forum/main", + "space": "at://did:plc:abc/space/com.example.forum/main", "collection": "com.example.forum.post", "record": { "text": "hello" } })) .unwrap(); - assert_eq!(input.space, "ats://did:plc:abc/com.example.forum/main"); + assert_eq!(input.space, "at://did:plc:abc/space/com.example.forum/main"); assert_eq!(input.collection, "com.example.forum.post"); assert_eq!(input.record["text"], "hello"); } @@ -1429,7 +1548,7 @@ #[test] fn deserialize_put_record_with_swap() { let input: PutRecordInput = serde_json::from_value(json!({ - "space": "ats://did:plc:abc/com.example.forum/main", + "space": "at://did:plc:abc/space/com.example.forum/main", "collection": "com.example.forum.post", "rkey": "3k2abc", "record": { "text": "updated" }, @@ -1442,7 +1561,7 @@ #[test] fn deserialize_put_record_without_swap() { let input: PutRecordInput = serde_json::from_value(json!({ - "space": "ats://did:plc:abc/com.example.forum/main", + "space": "at://did:plc:abc/space/com.example.forum/main", "collection": "com.example.forum.post", "rkey": "3k2abc", "record": { "text": "hello" } @@ -1454,7 +1573,7 @@ #[test] fn deserialize_delete_record_with_swap() { let input: DeleteRecordInput = serde_json::from_value(json!({ - "space": "ats://did:plc:abc/com.example.forum/main", + "space": "at://did:plc:abc/space/com.example.forum/main", "collection": "com.example.forum.post", "rkey": "3k2abc", "swapRecord": "bafyrei456" @@ -1552,7 +1671,7 @@ #[test] fn deserialize_apply_writes_input() { let input: ApplyWritesInput = serde_json::from_value(json!({ - "space": "ats://did:plc:abc/com.example.forum/main", + "space": "at://did:plc:abc/space/com.example.forum/main", "swapCommit": "tid123", "writes": [ { @@ -1568,7 +1687,7 @@ ] })) .unwrap(); - assert_eq!(input.space, "ats://did:plc:abc/com.example.forum/main"); + assert_eq!(input.space, "at://did:plc:abc/space/com.example.forum/main"); assert_eq!(input.swap_commit.as_deref(), Some("tid123")); assert_eq!(input.writes.len(), 2); } @@ -1576,7 +1695,7 @@ #[test] fn deserialize_apply_writes_without_swap_commit() { let input: ApplyWritesInput = serde_json::from_value(json!({ - "space": "ats://did:plc:abc/com.example.forum/main", + "space": "at://did:plc:abc/space/com.example.forum/main", "writes": [ { "action": "create", diff --git a/src/spaces/simplespace.rs b/src/spaces/simplespace.rs --- a/src/spaces/simplespace.rs +++ b/src/spaces/simplespace.rs @@ -288,7 +288,10 @@ }; db::add_member(&state.db, state.db_backend, &member).await?; - let space_uri = format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); + let space_uri = format!( + "at://{}/space/{}/{}", + space.did, space.type_nsid, space.skey + ); let body = serde_json::json!({ "uri": space_uri, }); @@ -342,7 +345,10 @@ db::update_space(&state.db, state.db_backend, &space).await?; - let space_uri = format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); + let space_uri = format!( + "at://{}/space/{}/{}", + space.did, space.type_nsid, space.skey + ); Ok(Json(serde_json::json!({ "uri": space_uri, "space": space, diff --git a/src/spaces/types.rs b/src/spaces/types.rs --- a/src/spaces/types.rs +++ b/src/spaces/types.rs @@ -126,6 +126,8 @@ pub rkey: String, pub cid: Option, pub prev: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, pub created_at: String, } diff --git a/tests/common/db.rs b/tests/common/db.rs --- a/tests/common/db.rs +++ b/tests/common/db.rs @@ -48,7 +48,7 @@ match backend { DatabaseBackend::Postgres => { sqlx::query( - "TRUNCATE happyview_records, happyview_lexicons, happyview_backfill_jobs, happyview_users, happyview_user_permissions, happyview_api_keys, happyview_event_logs, happyview_script_variables, happyview_scripts, happyview_dead_letter_scripts, happyview_dead_letter_hooks, happyview_record_refs, happyview_labeler_subscriptions, happyview_labels, happyview_instance_settings, happyview_domains, happyview_dpop_sessions, happyview_dpop_keys, happyview_api_clients, happyview_delegated_accounts, happyview_account_delegates, happyview_service_identity, happyview_service_entries, happyview_service_entry_xrpcs RESTART IDENTITY CASCADE", + "TRUNCATE happyview_records, happyview_lexicons, happyview_backfill_jobs, happyview_users, happyview_user_permissions, happyview_api_keys, happyview_event_logs, happyview_script_variables, happyview_scripts, happyview_dead_letter_scripts, happyview_dead_letter_hooks, happyview_record_refs, happyview_labeler_subscriptions, happyview_labels, happyview_instance_settings, happyview_domains, happyview_dpop_sessions, happyview_dpop_keys, happyview_api_clients, happyview_delegated_accounts, happyview_account_delegates, happyview_service_identity, happyview_service_entries, happyview_service_entry_xrpcs, happyview_jobs RESTART IDENTITY CASCADE", ) .execute(pool) .await @@ -80,6 +80,7 @@ "happyview_labels", "happyview_instance_settings", "happyview_domains", + "happyview_jobs", ]; for table in tables { sqlx::query(&format!("DELETE FROM {table}")) diff --git a/web/src/components/app-sidebar.tsx b/web/src/components/app-sidebar.tsx --- a/web/src/components/app-sidebar.tsx +++ b/web/src/components/app-sidebar.tsx @@ -22,6 +22,7 @@ IconSkull, IconFlask, IconFingerprint, + IconPlayerPlay, } from "@tabler/icons-react"; import Image from "next/image"; import Link from "next/link"; @@ -59,6 +60,12 @@ { title: "Lexicons", url: "/dashboard/lexicons", icon: IconFileDescription }, { title: "Records", url: "/dashboard/records", icon: IconTable }, { title: "Backfill", url: "/dashboard/backfill", icon: IconDatabase }, + { + title: "Jobs", + url: "/dashboard/jobs", + icon: IconPlayerPlay, + requiredPermissions: ["jobs:read"], + }, { title: "Dead Letters", url: "/dashboard/dead-letters", diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -3,6 +3,7 @@ import type { LexiconSummary, LexiconDetail } from "@/types/lexicons"; import type { NetworkLexiconSummary } from "@/types/network-lexicons"; import type { BackfillJob, BackfillReposResponse, PdsSummaryResponse } from "@/types/backfill"; +import type { Job, JobsListResponse } from "@/types/jobs"; import type { UserSummary } from "@/types/users"; import type { AdminListRecordsResponse } from "@/types/records"; import type { EventsListResponse } from "@/types/events"; @@ -252,6 +253,38 @@ export function flushAllBackfillDetails() { return apiFetch(`/admin/backfill/details`, { method: "DELETE" }); +} + +// Jobs +export function getJobs(params: { status?: string; limit?: number; cursor?: string } = {}) { + const qs = new URLSearchParams(); + if (params.status) qs.set("status", params.status); + if (params.limit) qs.set("limit", String(params.limit)); + if (params.cursor) qs.set("cursor", params.cursor); + const query = qs.toString(); + return apiFetch(`/admin/jobs${query ? `?${query}` : ""}`); +} + +export function getJob(id: string) { + return apiFetch(`/admin/jobs/${id}`); +} + +export function cancelJob(id: string) { + return apiFetch<{ status: string }>(`/admin/jobs/${id}/cancel`, { + method: "POST", + }); +} + +export function pauseJob(id: string) { + return apiFetch<{ status: string }>(`/admin/jobs/${id}/pause`, { + method: "POST", + }); +} + +export function resumeJob(id: string) { + return apiFetch<{ status: string }>(`/admin/jobs/${id}/resume`, { + method: "POST", + }); } // Users diff --git a/web/src/types/jobs.ts b/web/src/types/jobs.ts new file mode 100644 --- /dev/null +++ b/web/src/types/jobs.ts @@ -0,0 +1,19 @@ +export interface Job { + id: string + job_type: string + status: string + input: unknown + progress: unknown + result: unknown | null + error: string | null + created_by: string + inherit_auth: boolean + started_at: string | null + completed_at: string | null + created_at: string +} + +export interface JobsListResponse { + jobs: Job[] + cursor: string | null +} diff --git a/web/src/types/scripts.ts b/web/src/types/scripts.ts --- a/web/src/types/scripts.ts +++ b/web/src/types/scripts.ts @@ -65,6 +65,7 @@ | "xrpc.query" | "xrpc.procedure" | "labeler.apply" + | "job.run" /** Display labels for each trigger kind. */ export const TRIGGER_KIND_LABELS: Record = { @@ -75,21 +76,24 @@ "xrpc.query": "XRPC query", "xrpc.procedure": "XRPC procedure", "labeler.apply": "Label arrival", + "job.run": "Job runner", } /** Top-level grouping for the Scripts list page. */ -export type TriggerFamily = "record" | "xrpc" | "labeler" +export type TriggerFamily = "record" | "xrpc" | "labeler" | "job" export const TRIGGER_FAMILY_LABELS: Record = { record: "Record events", xrpc: "XRPC handlers", labeler: "Label arrivals", + job: "Job runners", } /** Map a trigger kind to its top-level family. */ export function familyOf(kind: TriggerKind): TriggerFamily { if (kind.startsWith("record.")) return "record" if (kind.startsWith("xrpc.")) return "xrpc" + if (kind.startsWith("job.")) return "job" return "labeler" } @@ -113,6 +117,7 @@ "xrpc.query", "xrpc.procedure", "labeler.apply", + "job.run", ] as const).find((k) => k === prefix) if (!kind) return null return { kind, suffix } @@ -131,5 +136,30 @@ function handle() log("script fired") return event +end +` + +export const DEFAULT_JOB_SCRIPT_BODY = `-- Job runner: executes as a background job. +-- +-- Available globals: +-- job.input — the input table passed to jobs.create() +-- job.id — the job's UUID +-- job.progress() — persist progress (visible in the dashboard) +-- job.should_stop() — check for pause/cancel (cooperative) +-- job.wait(seconds) — sleep (0–3600s) +-- +-- Available APIs: db.*, http.*, xrpc.*, atproto.*, Record.*, env. +-- Return value becomes the job's result. + +function handle() + local input = job.input + + job.progress({ status = "working" }) + + if job.should_stop() then + return { partial = true } + end + + return { done = true } end ` diff --git a/web/tests/e2e/jobs.spec.ts b/web/tests/e2e/jobs.spec.ts new file mode 100644 --- /dev/null +++ b/web/tests/e2e/jobs.spec.ts @@ -0,0 +1,216 @@ +import { test, expect } from "@playwright/test" +import { randomUUID } from "crypto" +import pg from "pg" +import { loginAsTestAdmin } from "./auth-helper" + +const DB_URL = "postgres://happyview:happyview@localhost:5434/happyview_test" +const TEST_DID = "did:plc:e2e-test-admin" + +async function seedJob( + status: string, + jobType = "test.e2e.export", +): Promise { + const client = new pg.Client(DB_URL) + await client.connect() + try { + const id = randomUUID() + const now = new Date().toISOString() + await client.query( + `INSERT INTO happyview_jobs (id, job_type, status, input, progress, created_by, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + id, + jobType, + status, + JSON.stringify({ source: "e2e-test" }), + JSON.stringify({}), + TEST_DID, + now, + ], + ) + return id + } finally { + await client.end() + } +} + +async function cleanupJobs(): Promise { + const client = new pg.Client(DB_URL) + await client.connect() + try { + await client.query( + "DELETE FROM happyview_jobs WHERE created_by = $1", + [TEST_DID], + ) + } finally { + await client.end() + } +} + +test.describe("Jobs Dashboard", () => { + test.beforeEach(async ({ page }) => { + await loginAsTestAdmin(page) + }) + + test.afterEach(async () => { + await cleanupJobs() + }) + + test("shows empty state when no jobs exist", async ({ page }) => { + await page.goto("/dashboard/jobs") + + await expect( + page.getByText("No background jobs yet"), + ).toBeVisible({ timeout: 5000 }) + }) + + test("lists seeded jobs in the table", async ({ page }) => { + await seedJob("pending", "test.e2e.alpha") + await seedJob("running", "test.e2e.beta") + + await page.goto("/dashboard/jobs") + + const rows = page.locator("table tbody tr") + await expect(rows).toHaveCount(2, { timeout: 5000 }) + + await expect(page.getByText("test.e2e.alpha")).toBeVisible() + await expect(page.getByText("test.e2e.beta")).toBeVisible() + }) + + test("filters jobs by status", async ({ page }) => { + await seedJob("pending", "test.e2e.pending-job") + await seedJob("completed", "test.e2e.completed-job") + + await page.goto("/dashboard/jobs") + + const rows = page.locator("table tbody tr") + await expect(rows).toHaveCount(2, { timeout: 5000 }) + + await page.getByRole("combobox").click() + await page.getByRole("option", { name: "Completed" }).click() + + await expect(rows).toHaveCount(1, { timeout: 5000 }) + await expect(page.getByText("test.e2e.completed-job")).toBeVisible() + await expect(page.getByText("test.e2e.pending-job")).not.toBeVisible() + }) + + test("opens detail sheet when clicking a job row", async ({ page }) => { + const id = await seedJob("pending", "test.e2e.detail") + + await page.goto("/dashboard/jobs") + + const row = page.locator("table tbody tr", { + hasText: "test.e2e.detail", + }) + await expect(row).toBeVisible({ timeout: 5000 }) + await row.click() + + const sheet = page.locator("[data-state='open'][role='dialog']") + await expect(sheet).toBeVisible({ timeout: 3000 }) + + await expect(sheet.getByText("Job Details")).toBeVisible() + await expect(sheet.getByText(id)).toBeVisible() + await expect(sheet.getByText("test.e2e.detail")).toBeVisible() + await expect(sheet.getByText("pending")).toBeVisible() + }) + + test("shows cancel button for running job and cancels it", async ({ + page, + }) => { + const id = await seedJob("running", "test.e2e.cancel") + + await page.goto("/dashboard/jobs") + + const row = page.locator("table tbody tr", { + hasText: "test.e2e.cancel", + }) + await expect(row).toBeVisible({ timeout: 5000 }) + await row.click() + + const sheet = page.locator("[data-state='open'][role='dialog']") + await expect(sheet).toBeVisible({ timeout: 3000 }) + + const cancelButton = sheet.getByRole("button", { name: "Cancel Job" }) + await expect(cancelButton).toBeVisible() + await cancelButton.click() + + await expect(page.getByText("Job cancelled")).toBeVisible({ + timeout: 5000, + }) + }) + + test("shows pause button for running job", async ({ page }) => { + await seedJob("running", "test.e2e.pause") + + await page.goto("/dashboard/jobs") + + const row = page.locator("table tbody tr", { + hasText: "test.e2e.pause", + }) + await expect(row).toBeVisible({ timeout: 5000 }) + await row.click() + + const sheet = page.locator("[data-state='open'][role='dialog']") + await expect(sheet).toBeVisible({ timeout: 3000 }) + + await expect( + sheet.getByRole("button", { name: "Pause Job" }), + ).toBeVisible() + }) + + test("shows resume button for paused job", async ({ page }) => { + await seedJob("paused", "test.e2e.resume") + + await page.goto("/dashboard/jobs") + + const row = page.locator("table tbody tr", { + hasText: "test.e2e.resume", + }) + await expect(row).toBeVisible({ timeout: 5000 }) + await row.click() + + const sheet = page.locator("[data-state='open'][role='dialog']") + await expect(sheet).toBeVisible({ timeout: 3000 }) + + await expect( + sheet.getByRole("button", { name: "Resume Job" }), + ).toBeVisible() + }) + + test("shows error section for failed job", async ({ page }) => { + const client = new pg.Client(DB_URL) + await client.connect() + try { + const id = randomUUID() + const now = new Date().toISOString() + await client.query( + `INSERT INTO happyview_jobs (id, job_type, status, input, progress, error, created_by, created_at, completed_at) + VALUES ($1, $2, 'failed', $3, $4, $5, $6, $7, $7)`, + [ + id, + "test.e2e.failed", + JSON.stringify({}), + JSON.stringify({}), + "something went wrong", + TEST_DID, + now, + ], + ) + } finally { + await client.end() + } + + await page.goto("/dashboard/jobs") + + const row = page.locator("table tbody tr", { + hasText: "test.e2e.failed", + }) + await expect(row).toBeVisible({ timeout: 5000 }) + await row.click() + + const sheet = page.locator("[data-state='open'][role='dialog']") + await expect(sheet).toBeVisible({ timeout: 3000 }) + + await expect(sheet.getByText("something went wrong")).toBeVisible() + }) +}) diff --git a/web/tests/e2e/script-job.spec.ts b/web/tests/e2e/script-job.spec.ts new file mode 100644 --- /dev/null +++ b/web/tests/e2e/script-job.spec.ts @@ -0,0 +1,110 @@ +import { test, expect } from "@playwright/test" +import { loginAsTestAdmin } from "./auth-helper" + +const JOB_TYPE = "test.e2e.myjob" +const TRIGGER_ID = `job.run:${JOB_TYPE}` + +async function seedScript( + request: import("@playwright/test").APIRequestContext, +) { + const resp = await request.post("/admin/scripts", { + data: { + id: TRIGGER_ID, + body: "function handle()\n return { ok = true }\nend", + }, + }) + if (!resp.ok()) { + const text = await resp.text() + if (!text.includes("already exists")) { + throw new Error(`Failed to seed script: ${resp.status()} ${text}`) + } + } +} + +async function cleanupScript( + request: import("@playwright/test").APIRequestContext, +) { + await request.delete(`/admin/scripts/${encodeURIComponent(TRIGGER_ID)}`) +} + +test.describe("Job Script Creation", () => { + test.beforeEach(async ({ page }) => { + await loginAsTestAdmin(page) + }) + + test.afterEach(async ({ page }) => { + await cleanupScript(page.request) + }) + + test("selecting Job source shows job type input and composes trigger id", async ({ + page, + }) => { + await page.goto("/dashboard/settings/scripts/new") + + const sourceSelect = page.locator("#source-pick") + await expect(sourceSelect).toBeVisible({ timeout: 5000 }) + + await sourceSelect.click() + await page.getByRole("option", { name: /Job/ }).click() + + const jobTypeInput = page.locator("#job-type-input") + await expect(jobTypeInput).toBeVisible() + + await expect(page.locator("#action-pick")).not.toBeVisible() + + await jobTypeInput.fill(JOB_TYPE) + + await expect(page.getByText(TRIGGER_ID)).toBeVisible() + }) + + test("creating a job script navigates to detail page", async ({ page }) => { + await page.goto("/dashboard/settings/scripts/new") + + await page.locator("#source-pick").click() + await page.getByRole("option", { name: /Job/ }).click() + + await page.locator("#job-type-input").fill(JOB_TYPE) + + const createButton = page.getByRole("button", { name: "Create script" }) + await expect(createButton).toBeEnabled({ timeout: 3000 }) + await createButton.click() + + await page.waitForURL( + `**/dashboard/settings/scripts/${encodeURIComponent(TRIGGER_ID)}`, + { timeout: 10000 }, + ) + + await expect( + page.getByText("Job runner", { exact: true }), + ).toBeVisible() + await expect( + page.getByText(TRIGGER_ID, { exact: true }), + ).toBeVisible() + }) + + test("job script has job-specific template body", async ({ page }) => { + await page.goto("/dashboard/settings/scripts/new") + + await page.locator("#source-pick").click() + await page.getByRole("option", { name: /Job/ }).click() + + await expect(page.getByText("job.input").first()).toBeVisible({ + timeout: 3000, + }) + await expect(page.getByText("job.should_stop").first()).toBeVisible() + }) + + test("job script appears in scripts list with Job runners family", async ({ + page, + }) => { + await seedScript(page.request) + + await page.goto("/dashboard/settings/scripts") + + const row = page.locator("table tbody tr", { hasText: JOB_TYPE }) + await expect(row).toBeVisible({ timeout: 5000 }) + + await expect(row.getByText("Job runner", { exact: true })).toBeVisible() + await expect(row.getByText("Job runners", { exact: true })).toBeVisible() + }) +}) diff --git a/web/tests/e2e/spaces.spec.ts b/web/tests/e2e/spaces.spec.ts --- a/web/tests/e2e/spaces.spec.ts +++ b/web/tests/e2e/spaces.spec.ts @@ -57,7 +57,7 @@ } const createBody = await createResp.json() expect(createBody).toHaveProperty("uri") - expect(createBody.uri).toMatch(/^ats:\/\//) + expect(createBody.uri).toMatch(/^at:\/\/.+\/space\//) createdSpaceUri = createBody.uri const listResp = await page.request.get( diff --git a/packages/docs/src/components/post-comments.tsx b/packages/docs/src/components/post-comments.tsx --- a/packages/docs/src/components/post-comments.tsx +++ b/packages/docs/src/components/post-comments.tsx @@ -43,6 +43,7 @@ const PLATFORMS = [ { key: 'bluesky', name: 'Bluesky', domain: 'bsky.app' }, { key: 'blacksky', name: 'Blacksky', domain: 'blacksky.app' }, + { key: 'witchsky', name: 'Witchsky', domain: 'witchsky.app' }, { key: 'mu', name: 'mu.social', domain: 'mu.social' }, ] as const; @@ -289,6 +290,7 @@ const PLATFORM_FAVICONS: Record = { bluesky: 'https://bsky.app/static/favicon-32x32.png', blacksky: 'https://blacksky.app/favicon.ico', + witchsky: 'https://witchsky.app/favicon.ico', mu: 'https://mu.social/favicon.ico', }; diff --git a/packages/docs/content/docs/guides/background-jobs.md b/packages/docs/content/docs/guides/background-jobs.md new file mode 100644 --- /dev/null +++ b/packages/docs/content/docs/guides/background-jobs.md @@ -0,0 +1,232 @@ +--- +title: "Background Jobs" +--- + +Background jobs let you run long-running Lua scripts outside the request cycle. A script running in any context can queue a job with `jobs.create()`, and a dedicated worker picks it up and executes the matching job script. Jobs are useful for data migrations, batch exports, external API syncs, or any work that's too slow for a synchronous request. + +## How it works + +1. **Queue** - any Lua script calls `jobs.create("my-type", { ... })`, passing a free-form type name and an input table. This inserts a row into the `happyview_jobs` table with status `pending` and returns the job's UUID. +2. **Match** - the worker resolves the job's type to a script by looking up the trigger `job.run:my-type`. If no script exists for that type, the job fails immediately. +3. **Execute** - the worker calls the script's `handle()` function with the `job` global set. The script can report progress, check for cancellation, sleep, and return a result. + +## Creating a job script + +Job scripts are created from the [dashboard](../getting-started/dashboard.md) (Settings > Scripts > New) or via the [admin API](../api-reference/admin/scripts.md). + +In the dashboard, select **Job** as the trigger source, then type a job type name. The type name is a free-form string that must match `/^[a-z0-9][a-z0-9._-]*$/` (max 128 characters). The resulting trigger id is `job.run:`; for example, `job.run:export` or `job.run:data.migrate`. + +### Trigger grammar + +| Trigger | Fires when | +| ---------------- | ------------------------------------------------------- | +| `job.run:` | A job with the matching type is picked up by the worker | + +There is no cascade for job triggers; the type must match exactly. + +### Script structure + +Job scripts follow the same `handle()` convention as all other scripts. The return value becomes the job's `result` field. + +```lua +function handle() + local data = job.input + + for i, item in ipairs(data.items) do + -- process each item + job.progress({ processed = i, total = #data.items }) + + if job.should_stop() then + return { partial = true, processed = i } + end + end + + return { processed = #data.items } +end +``` + +## The `job` global + +Inside a job script, the `job` global provides access to the job's metadata and control functions. This global is only available in job scripts. It's `nil` in all other script contexts. + +| Field / Function | Type | Description | +| -------------------- | -------- | ----------------------------------------------------------- | +| `job.id` | string | The job's UUID | +| `job.input` | table | The input table passed to `jobs.create()` | +| `job.progress(data)` | function | Persist progress to the database (visible in the dashboard) | +| `job.should_stop()` | function | Returns `true` if the job has been paused or cancelled | +| `job.wait(seconds)` | function | Sleep for 0–3600 seconds | + +### `job.progress(data)` + +Call `job.progress()` to persist a progress snapshot. The `data` argument can be any Lua table: it's stored as JSONB and displayed in the job detail panel in the dashboard. Call it as often as you like; each call overwrites the previous progress value. + +```lua +job.progress({ status = "indexing", page = 5, total_pages = 20 }) +``` + +### `job.should_stop()` + +Check `job.should_stop()` at natural checkpoints in your script. It returns `true` when an operator has paused or cancelled the job from the dashboard. Cancellation and pausing are **cooperative**: the worker sets a flag, but it's up to your script to check it and exit gracefully. If your script never checks, pause and cancel requests will wait until the script finishes on its own. + +```lua +for i, repo in ipairs(repos) do + if job.should_stop() then + return { partial = true, last_processed = i } + end + process(repo) +end +``` + +### `job.wait(seconds)` + +Pause execution for up to 3600 seconds (1 hour). Useful for rate-limited external API calls or scheduled delays. Values below 0 are clamped to 0; values above 3600 are clamped to 3600. + +```lua +for _, batch in ipairs(batches) do + push_to_api(batch) + job.wait(2) -- respect rate limits +end +``` + +## Enqueuing jobs + +Any Lua script can enqueue a job using the `jobs` global. This is available in **all** script contexts: procedures, queries, record scripts, label scripts, and even other job scripts (a job can enqueue follow-up jobs). + +```lua +-- In a procedure script +function handle() + local job_id = jobs.create("export", { + collection = collection, + format = input.format, + }) + return { job_id = job_id, status = "queued" } +end +``` + +`jobs.create(type, input[, opts])` returns the new job's UUID as a string. The `type` argument must match a `job.run:` script trigger. If no matching script exists, the job will fail when the worker picks it up. + +See the [Jobs API reference](../api-reference/lua/jobs-api.md#jobscreatejob_type-input-opts) for the full parameter list. + +## Authentication + +By default, jobs run **without PDS auth**. This is intentional. Most jobs don't need to write records on behalf of a user, and granting auth by default would give long-running background scripts access to a user's PDS session unnecessarily. + +### What's available without auth + +Every job script — regardless of auth setting — has access to: + +- `caller_did` - the DID of the user who enqueued the job (always set) +- `db.*` - full database access (queries, raw SQL, search, backlinks) +- `http.*` - outbound HTTP requests +- `xrpc.*` - XRPC calls (local and proxied) +- `atproto.*` - DID resolution, label queries, signature verification +- `json.*` - JSON encode/decode +- `jobs.*` - enqueue follow-up jobs +- `Record.load()` - load records from the local database +- `r:save_local()` / `r:delete_local()` - write or delete records in HappyView's local database only +- `Record.delete_local()` - delete by URI from the local database +- Utility globals: `log()`, `now()`, `TID()`, `toarray()` +- `env.` - script variables + +### What requires auth + +PDS-touching operations need the creator's OAuth session. Without auth, these raise an error: + +- `r:save()` - writes a record to the user's PDS and indexes it locally +- `r:delete()` - deletes a record from the user's PDS and removes it locally +- `Record.save_all()` - batch save to PDS +- `atproto.upload_blob()` - upload a blob to the user's PDS + +### Opting into auth + +To give a job access to the creator's PDS session, pass `{ auth = true }` as the third argument to `jobs.create()`: + +```lua +-- Without auth (default) - local-only operations +jobs.create("stats.rebuild", { collection = collection }) + +-- With auth - can write to the creator's PDS +jobs.create("sync-records", { collection = collection }, { auth = true }) +``` + +When `auth = true`, the worker loads the creator's OAuth session at execution time. If the session is no longer valid (expired, revoked, or the user has no session), the job fails immediately with an error. The creating user must have a valid OAuth session when the job runs, not just when it was enqueued. + +### When to use auth + +Use `{ auth = true }` when the job needs to create, update, or delete records on the AT Protocol network on behalf of the user. For example, batch record creation, cross-collection syncs, or migrations that write back to the user's PDS. + +Leave auth off (the default) for jobs that only read data, compute aggregates, sync to external services, clean up local records, or perform any work that doesn't touch a user's PDS. + +## Job lifecycle + +Jobs move through these statuses: + +| Status | Description | +| ------------ | ------------------------------------------------- | +| `pending` | Queued, waiting for the worker to pick it up | +| `running` | Currently executing | +| `completed` | Script returned successfully | +| `failed` | Script raised an error | +| `pausing` | Pause requested, waiting for the script to check | +| `paused` | Script exited after detecting the pause flag | +| `cancelling` | Cancel requested, waiting for the script to check | +| `cancelled` | Script exited after detecting the cancel flag | + +### Pausing and cancelling + +Pause and cancel are requested via the dashboard or the [admin API](../api-reference/admin/jobs.md). Both are cooperative: + +1. The endpoint sets the job's status to `pausing` or `cancelling`. +2. The worker continues running the script. At its next `job.should_stop()` check, it returns `true`. +3. The script should exit gracefully. Whatever it returns becomes the job's result. +4. The worker sets the final status to `paused` or `cancelled`. + +If the script never calls `job.should_stop()`, the pause or cancel request waits until the script finishes naturally. + +A paused job can be resumed via `POST /admin/jobs/:id/resume` or the Resume button in the dashboard. Resuming sets the status back to `pending`, and the worker picks it up again, but the script runs from the beginning. Use `job.input` or progress data to implement resumable logic. + +### Recovery after restart + +Jobs survive server restarts. On startup, the worker checks for orphaned jobs: + +- **Running** jobs are reset to `pending` and re-queued. +- **Cancelling** jobs are finalised as `cancelled`. +- **Pausing** jobs are finalised as `paused`. + +## Worker + +The job worker runs as a background task inside the HappyView server process. It polls for pending jobs every 5 seconds and executes one job at a time. Job scripts have **no instruction count limit** (unlike XRPC and record scripts, which are capped at 1,000,000 instructions), so they can run arbitrarily long computations. + +Job scripts have access to all standard Lua APIs: `db.*`, `http.*`, `xrpc.*`, `atproto.*`, `Record.*`, `json.*`, `env.`, `log()`, `now()`, `TID()`, `toarray()`, and `jobs.*` (including `jobs.create()` to queue follow-up jobs). + +## Dashboard + +The **Jobs** page in the dashboard (`/dashboard/jobs`) shows all background jobs in a filterable table. You can filter by status using the dropdown at the top. + +Clicking a job row opens a detail sheet showing: + +- Job ID, type, and status +- Input data (the table passed to `jobs.create()`) +- Progress (the last value passed to `job.progress()`) +- Result or error +- Timestamps (created, started, completed) +- Action buttons: **Cancel**, **Pause**, or **Resume** depending on the current status + +## Permissions + +Job management requires specific permissions: + +| Permission | Grants | +| ------------- | ---------------------------------- | +| `jobs:read` | View jobs in the dashboard and API | +| `jobs:manage` | Cancel, pause, and resume jobs | + +Queuing jobs via `jobs.create()` in a script requires an authenticated caller (`caller_did` must be set). + +## Next steps + +- [Admin API - Jobs](../api-reference/admin/jobs.md): Full reference for job endpoints +- [Lua API - Jobs](../api-reference/lua/jobs-api.md): Full reference for the `jobs` and `job` Lua APIs +- [Lua Scripting](lua-scripting.md): General Lua scripting reference +- [Record & Label Scripts](record-scripts.md): Trigger grammar for all script types diff --git a/packages/docs/content/docs/guides/lua-scripting.md b/packages/docs/content/docs/guides/lua-scripting.md --- a/packages/docs/content/docs/guides/lua-scripting.md +++ b/packages/docs/content/docs/guides/lua-scripting.md @@ -69,14 +69,14 @@ When a script handles a space-scoped request, the `space` global is set to a table with the space's metadata. For non-space requests, `space` is `nil`. -| Field | Type | Description | -| ----------- | ------ | -------------------------------------------------------- | -| `space` | string | The full `ats://` space URI | -| `space_id` | string | Internal space identifier | -| `did` | string | The space's DID | -| `authority_did` | string | The space authority's DID | -| `type_nsid` | string | Space type NSID | -| `skey` | string | Space key | +| Field | Type | Description | +| --------------- | ------ | --------------------------- | +| `space` | string | The full `at://` space URI | +| `space_id` | string | Internal space identifier | +| `did` | string | The space's DID | +| `authority_did` | string | The space authority's DID | +| `type_nsid` | string | Space type NSID | +| `skey` | string | Space key | ```lua function handle() @@ -178,6 +178,21 @@ The `json` global provides JSON serialization and deserialization. See the full [JSON API reference](../api-reference/lua/json-api.md) for `json.encode` and `json.decode`. + +## Jobs API + +The `jobs` table lets any script queue background jobs for long-running work. Available in all script contexts. + +See the full [Jobs API reference](../api-reference/lua/jobs-api.md) for `jobs.create()` and the `job` global available inside job scripts. + +Quick example: + +```lua +local job_id = jobs.create("export", { collection = collection }) +return { job_id = job_id } +``` + +For the full guide on background jobs, see [Background Jobs](background-jobs.md). ## Debugging diff --git a/packages/docs/content/docs/guides/meta.json b/packages/docs/content/docs/guides/meta.json --- a/packages/docs/content/docs/guides/meta.json +++ b/packages/docs/content/docs/guides/meta.json @@ -4,6 +4,7 @@ "upgrading-to-v2", "lexicons", "backfill", + "background-jobs", "label-scripts", "lua-scripting", "api-clients", diff --git a/packages/docs/content/docs/guides/record-scripts.md b/packages/docs/content/docs/guides/record-scripts.md --- a/packages/docs/content/docs/guides/record-scripts.md +++ b/packages/docs/content/docs/guides/record-scripts.md @@ -41,6 +41,14 @@ There is no cascade for label or XRPC triggers -- each trigger string must match exactly. +### Job triggers + +| Trigger | Fires when | +| -------------------------- | --------------------------------------------- | +| `job.run:` | A background job with the matching type is picked up by the worker | + +There is no cascade for job triggers -- the type must match exactly. See [Background Jobs](./background-jobs.md) for the full job scripting guide. + ## Creating scripts You can create scripts through the [dashboard](../getting-started/dashboard.md) (Settings > Scripts > New) or via the [admin API](../api-reference/admin/scripts.md) (`POST /admin/scripts`). diff --git a/packages/docs/content/docs/reference/architecture.md b/packages/docs/content/docs/reference/architecture.md --- a/packages/docs/content/docs/reference/architecture.md +++ b/packages/docs/content/docs/reference/architecture.md @@ -325,7 +325,7 @@ | Column | Type | Description | | -------------- | ----------- | ------------------------------------------------ | -| `uri` | text (PK) | `ats://` URI of the record | +| `uri` | text (PK) | `at://` URI of the record | | `space_id` | text (FK) | References `spaces.id` | | `author_did` | text | DID of the record author | | `collection` | text | Lexicon NSID | diff --git a/packages/docs/content/docs/reference/glossary.md b/packages/docs/content/docs/reference/glossary.md --- a/packages/docs/content/docs/reference/glossary.md +++ b/packages/docs/content/docs/reference/glossary.md @@ -56,7 +56,7 @@ **Permission template** — A predefined set of permissions that can be applied when creating a user. Templates are: **Viewer** (read-only access), **Operator** (viewer + backfill and API key management), **Manager** (operator + lexicon, record, spaces, and plugin management), and **Full Access** (all 44 permissions). -**Space** — A container for permissioned data in AT Protocol. Identified by a space DID, type NSID, and space key (skey), forming an `ats://` URI. +**Space** — A container for permissioned data in AT Protocol. Identified by a space DID, type NSID, and space key (skey), forming an `at://` URI with a `space` path segment (e.g. `at://did:plc:abc/space/com.example.forum/main`). **Space Credential** — A short-lived JWT (`typ: atproto-space-credential+jwt`, ES256, 2-hour TTL) for cross-service read access to space data. Signed by the space's P-256 keypair. Obtained by exchanging a delegation token via `com.atproto.space.getSpaceCredential`. diff --git a/web/src/app/dashboard/jobs/page.tsx b/web/src/app/dashboard/jobs/page.tsx new file mode 100644 --- /dev/null +++ b/web/src/app/dashboard/jobs/page.tsx @@ -0,0 +1,520 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { + CheckCircle2, + ChevronDown, + Circle, + Loader2, + PauseCircle, + XCircle, +} from "lucide-react"; + +import { useCurrentUser } from "@/hooks/use-current-user"; +import { toastError } from "@/lib/format"; +import { + cancelJob, + getJobs, + pauseJob, + resumeJob, +} from "@/lib/api"; +import type { Job } from "@/types/jobs"; +import { SiteHeader } from "@/components/site-header"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +const STATUS_OPTIONS = [ + { value: "all", label: "All statuses" }, + { value: "pending", label: "Pending" }, + { value: "running", label: "Running" }, + { value: "paused", label: "Paused" }, + { value: "completed", label: "Completed" }, + { value: "failed", label: "Failed" }, + { value: "cancelled", label: "Cancelled" }, +] as const; + +function statusBadge(status: string) { + switch (status) { + case "completed": + return ( + + completed + + ); + case "failed": + return failed; + case "cancelled": + return ( + + cancelled + + ); + case "cancelling": + return ( + + cancelling + + ); + case "pausing": + return ( + + pausing + + ); + case "paused": + return ( + + paused + + ); + case "running": + return ( + + running + + ); + case "pending": + return pending; + default: + return {status}; + } +} + +function statusIcon(status: string) { + switch (status) { + case "completed": + return ; + case "failed": + return ; + case "cancelled": + return ; + case "cancelling": + return ; + case "pausing": + return ; + case "paused": + return ; + case "running": + return ; + default: + return ; + } +} + +function hasContent(value: unknown): boolean { + if (value == null) return false; + if (typeof value === "object") { + return Array.isArray(value) + ? value.length > 0 + : Object.keys(value as Record).length > 0; + } + return true; +} + +function relativeTime(dateStr: string): string { + const now = Date.now(); + const then = new Date(dateStr).getTime(); + const diff = now - then; + const seconds = Math.floor(diff / 1000); + if (seconds < 60) return "just now"; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +export default function JobsPage() { + const { hasPermission } = useCurrentUser(); + const [jobs, setJobs] = useState([]); + const [statusFilter, setStatusFilter] = useState("all"); + const [selectedJobId, setSelectedJobId] = useState(null); + const [loading, setLoading] = useState(true); + + const load = useCallback(() => { + const params = statusFilter !== "all" ? { status: statusFilter } : {}; + getJobs(params) + .then((resp) => { + setJobs(resp.jobs); + setLoading(false); + }) + .catch((e) => { + toastError("Failed to load jobs", e); + setLoading(false); + }); + }, [statusFilter]); + + useEffect(() => { + setLoading(true); + load(); + }, [load]); + + // Poll every 5 seconds for active jobs + const hasActiveJobs = jobs.some( + (j) => + j.status === "running" || + j.status === "pending" || + j.status === "cancelling" || + j.status === "pausing", + ); + + useEffect(() => { + const interval = setInterval(load, hasActiveJobs ? 3000 : 10000); + return () => clearInterval(interval); + }, [load, hasActiveJobs]); + + const selectedJob = jobs.find((j) => j.id === selectedJobId) ?? null; + const canManage = hasPermission("jobs:manage"); + + return ( + <> + +
+
+

Background Jobs

+ +
+ +
+ + + + + Type + Status + Created by + Created + + + + {loading && jobs.length === 0 && ( + + + + + + )} + {!loading && jobs.length === 0 && ( + + + {statusFilter !== "all" + ? `No ${statusFilter} jobs.` + : "No background jobs yet. Jobs are created by Lua scripts via jobs.create()."} + + + )} + {jobs.map((job) => ( + setSelectedJobId(job.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setSelectedJobId(job.id); + } + }} + > + + {statusIcon(job.status)} + + + {job.job_type} + + {statusBadge(job.status)} + + {job.created_by} + + + {relativeTime(job.created_at)} + + + ))} + +
+
+ + { + if (!open) { + setSelectedJobId(null); + load(); + } + }} + > + + {selectedJob && ( + + )} + + +
+ + ); +} + +function JobDetail({ + job, + canManage, + onAction, +}: { + job: Job; + canManage: boolean; + onAction: () => void; +}) { + const [actionLoading, setActionLoading] = useState(null); + const isActive = + job.status === "running" || + job.status === "cancelling" || + job.status === "pausing"; + + async function handleCancel() { + setActionLoading("cancel"); + try { + await cancelJob(job.id); + toast.success("Job cancelled"); + onAction(); + } catch (e) { + toastError("Failed to cancel job", e); + } finally { + setActionLoading(null); + } + } + + async function handlePause() { + setActionLoading("pause"); + try { + await pauseJob(job.id); + toast.success("Job paused"); + onAction(); + } catch (e) { + toastError("Failed to pause job", e); + } finally { + setActionLoading(null); + } + } + + async function handleResume() { + setActionLoading("resume"); + try { + await resumeJob(job.id); + toast.success("Job resumed"); + onAction(); + } catch (e) { + toastError("Failed to resume job", e); + } finally { + setActionLoading(null); + } + } + + return ( + <> + + + Job Details + + +
+
+
+ Job ID +

{job.id}

+
+
+ Type +

{job.job_type}

+
+
+ Status +
{statusBadge(job.status)}
+
+
+ Created by +

{job.created_by}

+
+
+ Created +

+ {new Date(job.created_at).toLocaleString()} +

+
+ {job.started_at && ( +
+ Started +

+ {new Date(job.started_at).toLocaleString()} +

+
+ )} + {job.completed_at && ( +
+ Completed +

+ {new Date(job.completed_at).toLocaleString()} +

+
+ )} +
+ + {job.error && ( +
+ Error +
+ {job.error} +
+
+ )} + + + + {hasContent(job.result) && } +
+ + {canManage && ( + + {(job.status === "running" || job.status === "pausing") && ( + + )} + {job.status === "paused" && ( + + )} + {isActive && ( + + )} + {job.status === "paused" && ( + + )} + + )} + + ); +} + +function JsonSection({ + title, + data, + defaultOpen = false, +}: { + title: string; + data: unknown; + defaultOpen?: boolean; +}) { + const [open, setOpen] = useState(defaultOpen); + const empty = !hasContent(data); + + if (empty) return null; + + return ( + + + + + +
+          {JSON.stringify(data, null, 2)}
+        
+
+
+ ); +} diff --git a/packages/docs/content/docs/api-reference/admin/jobs.md b/packages/docs/content/docs/api-reference/admin/jobs.md new file mode 100644 --- /dev/null +++ b/packages/docs/content/docs/api-reference/admin/jobs.md @@ -0,0 +1,151 @@ +--- +title: "Jobs" +--- + +Admin API endpoints for managing background jobs. For a conceptual overview, see [Background Jobs](../../guides/background-jobs.md). + +## List jobs + +```http +GET /admin/jobs +``` + +Returns a paginated list of jobs, newest first. + +**Query parameters:** + +| Parameter | Type | Description | +| --------- | ------ | ------------------------------------------------------------------------------------- | +| `status` | string | Filter by status (`pending`, `running`, `completed`, `failed`, `paused`, `cancelled`) | +| `limit` | number | Maximum number of results (default: 50) | +| `cursor` | string | Pagination cursor from a previous response | + +**Permission:** `jobs:read` + +**Response:** + +```json +{ + "jobs": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "job_type": "export", + "status": "completed", + "input": { "collection": "xyz.statusphere.status" }, + "progress": { "processed": 1500 }, + "result": { "processed": 1500 }, + "error": null, + "created_by": "did:plc:abc123", + "inherit_auth": false, + "started_at": "2026-07-01T12:00:05Z", + "completed_at": "2026-07-01T12:02:30Z", + "created_at": "2026-07-01T12:00:00Z" + } + ], + "cursor": "next-page-cursor" +} +``` + +## Get job + +```http +GET /admin/jobs/:id +``` + +Returns a single job by ID. + +**Permission:** `jobs:read` + +**Response:** Same shape as a single item in the list response. + +## Cancel job + +```http +POST /admin/jobs/:id/cancel +``` + +Request cancellation of a job. If the job is `pending` or `paused`, it's immediately set to `cancelled`. If the job is `running`, it's set to `cancelling` — the worker will stop the job when the script next calls `job.should_stop()`. + +**Permission:** `jobs:manage` + +**Response:** + +```json +{ + "status": "cancelling" +} +``` + +**Errors:** + +| Status | Condition | +| ------ | ---------------------------------------------- | +| 404 | Job not found | +| 409 | Job is already completed, failed, or cancelled | + +## Pause job + +```http +POST /admin/jobs/:id/pause +``` + +Request pause of a running job. Sets the status to `pausing` — the worker will pause the job when the script next calls `job.should_stop()`. + +**Permission:** `jobs:manage` + +**Response:** + +```json +{ + "status": "pausing" +} +``` + +**Errors:** + +| Status | Condition | +| ------ | ------------------ | +| 404 | Job not found | +| 409 | Job is not running | + +## Resume job + +```http +POST /admin/jobs/:id/resume +``` + +Resume a paused job. Sets the status back to `pending` so the worker picks it up again. + +**Permission:** `jobs:manage` + +**Response:** + +```json +{ + "status": "pending" +} +``` + +**Errors:** + +| Status | Condition | +| ------ | ----------------- | +| 404 | Job not found | +| 409 | Job is not paused | + +## Job object + +| Field | Type | Description | +| -------------- | ------------ | ------------------------------------------------------------------------------- | +| `id` | string | UUID | +| `job_type` | string | The type name passed to `jobs.create()` | +| `status` | string | Current status (see [lifecycle](../../guides/background-jobs.md#job-lifecycle)) | +| `input` | object | Input data passed to `jobs.create()` | +| `progress` | object | Last progress update from `job.progress()` | +| `result` | object\|null | Return value of the script on completion | +| `error` | string\|null | Error message on failure | +| `created_by` | string | DID of the user who enqueued the job | +| `inherit_auth` | boolean | Whether the job inherits the creator's PDS auth | +| `started_at` | string\|null | ISO 8601 timestamp when the worker started executing | +| `completed_at` | string\|null | ISO 8601 timestamp when the job finished | +| `created_at` | string | ISO 8601 timestamp when the job was enqueued | diff --git a/packages/docs/content/docs/api-reference/admin/meta.json b/packages/docs/content/docs/api-reference/admin/meta.json --- a/packages/docs/content/docs/api-reference/admin/meta.json +++ b/packages/docs/content/docs/api-reference/admin/meta.json @@ -7,6 +7,7 @@ "records", "stats", "backfill", + "jobs", "events", "api-keys", "users", diff --git a/packages/docs/content/docs/api-reference/lua/jobs-api.md b/packages/docs/content/docs/api-reference/lua/jobs-api.md new file mode 100644 --- /dev/null +++ b/packages/docs/content/docs/api-reference/lua/jobs-api.md @@ -0,0 +1,156 @@ +--- +title: "Jobs API" +--- + +Lua API for creating and managing background jobs. For a conceptual overview, see [Background Jobs](../../guides/background-jobs.md). + +## `jobs` table + +The `jobs` table is available in **all** script contexts (procedures, queries, record scripts, label scripts, and job scripts). It provides functions for queuing new jobs. + +### `jobs.create(job_type, input[, opts])` + +Enqueue a new background job. + +**Parameters:** + +| Parameter | Type | Description | +| ---------- | ------ | ------------------------------------------------------------ | +| `job_type` | string | The job type name. Must match a `job.run:` script trigger. | +| `input` | table | Input data passed to the job script via `job.input`. | +| `opts` | table? | Optional settings (see below). | + +**Options:** + +| Key | Type | Default | Description | +| ------ | ------- | ------- | -------------------------------------------------------- | +| `auth` | boolean | `false` | Inherit the caller's PDS auth. When `true`, the job script can use `r:save()`, `r:delete()`, and blob uploads as the creating user. When `false`, only local operations (`r:save_local()`, `r:delete_local()`) are available. | + +**Returns:** `string` — the new job's UUID. + +**Requires:** An authenticated caller (`caller_did` must be set). Raises an error in unauthenticated contexts. + +```lua +-- Enqueue a job without PDS auth (default) +function handle() + local job_id = jobs.create("stats.rebuild", { + collection = collection, + }) + return { job_id = job_id } +end +``` + +```lua +-- Enqueue a job that needs to write records on behalf of the caller +function handle() + local job_id = jobs.create("export", { + collection = collection, + format = input.format, + }, { auth = true }) + return { job_id = job_id } +end +``` + +Jobs can enqueue other jobs — a job script can call `jobs.create()` to spawn follow-up work: + +```lua +-- Inside a job script: fan out to per-collection jobs +function handle() + local collections = job.input.collections + local child_ids = {} + for _, col in ipairs(collections) do + table.insert(child_ids, jobs.create("export.collection", { + collection = col, + parent_job = job.id, + })) + end + return { children = child_ids } +end +``` + +## `job` table + +The `job` table is available **only inside job scripts** (trigger `job.run:`). It is `nil` in all other script contexts. + +### `job.id` + +**Type:** `string` + +The job's UUID. + +### `job.input` + +**Type:** `table` + +The input table that was passed to `jobs.create()` when the job was queued. + +### `job.progress(data)` + +Persist a progress snapshot to the database. + +**Parameters:** + +| Parameter | Type | Description | +| --------- | ----- | -------------------------------- | +| `data` | table | Any Lua table — stored as JSONB. | + +Each call overwrites the previous progress value. The snapshot is visible in the job detail panel in the dashboard and via `GET /admin/jobs/:id`. + +```lua +job.progress({ phase = "fetching", fetched = 250, total = 1000 }) +``` + +### `job.should_stop()` + +Check whether the job has been paused or cancelled. + +**Returns:** `boolean` — `true` if the operator requested a pause or cancel. + +Pause and cancel are cooperative. The worker sets a flag when the operator requests it, but the script must call `job.should_stop()` and exit gracefully. If your script never checks, pause and cancel requests wait until the script finishes on its own. + +```lua +for i, item in ipairs(items) do + if job.should_stop() then + return { partial = true, last = i } + end + process(item) +end +``` + +### `job.wait(seconds)` + +Sleep for the specified duration. + +**Parameters:** + +| Parameter | Type | Description | +| --------- | ------ | ---------------------------------------- | +| `seconds` | number | Duration in seconds (clamped to 0–3600). | + +Values below 0 are clamped to 0. Values above 3600 are clamped to 3600. + +```lua +-- Poll an external API with a delay between requests +for _, batch in ipairs(batches) do + local resp = http.post("https://api.example.com/import", { + body = json.encode(batch), + }) + job.wait(1) -- rate limit +end +``` + +## Available APIs + +Job scripts have access to all standard Lua APIs: + +- [`db.*`](database-api.md) — database queries +- [`http.*`](http-api.md) — HTTP client +- [`xrpc.*`](xrpc-lua-api.md) — XRPC calls +- [`atproto.*`](atproto-api.md) — DID resolution, labels, signing +- [`Record.*`](record-api.md) — record operations +- [`json.*`](json-api.md) — JSON encode/decode +- [`jobs.*`](#jobs-table) — queue follow-up jobs +- [`log()`](utility-globals.md), [`now()`](utility-globals.md), [`TID()`](utility-globals.md#tid), [`toarray()`](utility-globals.md) — utility globals +- `env.` — [script variables](../admin/script-variables.md) + +Unlike XRPC and record scripts, job scripts have **no instruction count limit** — they can run arbitrarily long computations. diff --git a/packages/docs/content/docs/api-reference/lua/meta.json b/packages/docs/content/docs/api-reference/lua/meta.json --- a/packages/docs/content/docs/api-reference/lua/meta.json +++ b/packages/docs/content/docs/api-reference/lua/meta.json @@ -7,6 +7,7 @@ "xrpc-lua-api", "atproto-api", "json-api", + "jobs-api", "utility-globals", "standard-libraries" ] diff --git a/packages/docs/content/docs/experimental/spaces/changelog.md b/packages/docs/content/docs/experimental/spaces/changelog.md --- a/packages/docs/content/docs/experimental/spaces/changelog.md +++ b/packages/docs/content/docs/experimental/spaces/changelog.md @@ -2,7 +2,32 @@ title: "Changelog" --- -## Latest — Proposal 0016 Alignment +## v2.11 — Final Proposal 0016 Alignment + +Aligns with the merged [Proposal 0016](https://github.com/bluesky-social/proposals/blob/main/0016-permissioned-data/README.md) specification. + +### URI scheme change + +- **`ats://` → `at://` with `space` path segment** — space URIs now use the standard `at://` scheme with a literal `space` segment: `at:///space//`. Record URIs follow: `at:///space/////`. + +### Endpoint changes + +- **`getRepoState` → `getLatestCommit`** — renamed to match the proposal. The old name is kept as a backward-compatible alias. +- **`getRepo`** (GET) — new endpoint that exports a user's repo as a CAR v1 file (two roots: signedCommit + DRISL index) +- **`listRepoOps`** now inlines record values by default via LEFT JOIN against `space_records`. Pass `excludeValues=true` for metadata-only responses. + +### Commit changes + +- **`SignedCommit.ver`** — new version field (currently `1`) for future-proofing +- **Commit context string** now includes the author's DID: `tag || space || author || rev || ikm` (was `tag || space || rev || ikm`) + +### Breaking changes + +- All space URIs now use `at://` with a `space` segment instead of `ats://`. Clients using the old scheme must update. + +--- + +## v2.10.0 — Proposal 0016 Alignment Major restructuring to align with [AT Protocol Proposal 0016](https://github.com/bluesky-social/proposals) (Permissioned Data). @@ -56,6 +81,14 @@ - Feature flag disabled response changed from `501 Not Implemented` to `404` with `FeatureDisabled` error code - Deleting a space now cascades to all associated data (records, members, repo state, oplog, notifications, credentials) + +--- + +## v2.8.0 + +### Bug fixes + +- Spaces endpoints now use cursor-based pagination instead of offset-based --- diff --git a/packages/docs/content/docs/experimental/spaces/credentials.md b/packages/docs/content/docs/experimental/spaces/credentials.md --- a/packages/docs/content/docs/experimental/spaces/credentials.md +++ b/packages/docs/content/docs/experimental/spaces/credentials.md @@ -42,7 +42,7 @@ ```ts tab="TypeScript" tab-group="language" const params = new URLSearchParams({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", }); const response = await fetch(`https://happyview.example.com/xrpc/com.atproto.space.getDelegationToken?${params}`, { headers: { @@ -59,7 +59,7 @@ ``` ```js tab="JavaScript" tab-group="language" const params = new URLSearchParams({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", }); const response = await fetch(`https://happyview.example.com/xrpc/com.atproto.space.getDelegationToken?${params}`, { headers: { @@ -73,7 +73,7 @@ ```rust tab="Rust" tab-group="language" let response = client .get("https://happyview.example.com/xrpc/com.atproto.space.getDelegationToken") - .query(&[("space", "ats://did:plc:abc123/com.example.forum/main")]) + .query(&[("space", "at://did:plc:abc123/space/com.example.forum/main")]) .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -83,7 +83,7 @@ ``` ```go tab="Go" tab-group="language" req, _ := http.NewRequest("GET", - "https://happyview.example.com/xrpc/com.atproto.space.getDelegationToken?space=ats%3A%2F%2Fdid%3Aplc%3Aabc123%2Fcom.example.forum%2Fmain", + "https://happyview.example.com/xrpc/com.atproto.space.getDelegationToken?space=at%3A%2F%2Fdid%3Aplc%3Aabc123%2Fspace%2Fcom.example.forum%2Fmain", nil) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) @@ -91,7 +91,7 @@ resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl 'https://happyview.example.com/xrpc/com.atproto.space.getDelegationToken?space=ats%3A%2F%2Fdid%3Aplc%3Aabc123%2Fcom.example.forum%2Fmain' \ +curl 'https://happyview.example.com/xrpc/com.atproto.space.getDelegationToken?space=at%3A%2F%2Fdid%3Aplc%3Aabc123%2Fspace%2Fcom.example.forum%2Fmain' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' @@ -194,7 +194,7 @@ | Claim | Description | |---|---| | `iss` | The space authority's DID (who signed it) | -| `sub` | The full `ats://` space URI | +| `sub` | The full `at://` space URI | | `iat` | Issued at (Unix timestamp) | | `exp` | Expiry (Unix timestamp) | | `jti` | Random nonce for replay protection | diff --git a/packages/docs/content/docs/experimental/spaces/index.md b/packages/docs/content/docs/experimental/spaces/index.md --- a/packages/docs/content/docs/experimental/spaces/index.md +++ b/packages/docs/content/docs/experimental/spaces/index.md @@ -16,12 +16,12 @@ - **Type** — the space type as an NSID, describing the modality (e.g. a forum, a group chat, a photo album) - **Space key (skey)** — a short string differentiating multiple spaces of the same type -These form the space URI: `ats:////` +These form the space URI: `at:///space//` A **space record** adds three more components to the URI: the author's DID, the collection NSID, and the record key: ``` -ats://///// +at:///space///// ``` ## Feature flag @@ -100,7 +100,8 @@ | `com.atproto.simplespace.addMember` | POST | Add a member | | `com.atproto.simplespace.removeMember` | POST | Remove a member | | `com.atproto.simplespace.listMembers` | GET | List resolved members | -| `com.atproto.space.getRepoState` | GET | Get per-user repo state (LtHash + commit) | +| `com.atproto.space.getLatestCommit` | GET | Get per-user signed commit | +| `com.atproto.space.getRepo` | GET | Export a user's repo as a CAR file | | `com.atproto.space.listRepoOps` | GET | List record operation log entries | | `com.atproto.space.listRepos` | GET | List repos (authors) in a space | | `com.atproto.space.getDelegationToken` | GET | Get a delegation token (step 1 of credentials) | @@ -150,10 +151,12 @@ - **App access** — `open`, `allowList` (replaces `appAllowlist`/`appDenylist`) - **Delegation tokens** — `getDelegationToken` (GET, 60-second TTL) replaces `getMemberGrant` - **Space credentials** — `atproto-space-credential+jwt` typ, ES256, 2-hour TTL -- **Deniable commit signatures** — user signs context (space + rev + random IKM), not content hash +- **Deniable commit signatures** — user signs context (space + author + rev + random IKM), not content hash - **LtHash** — homomorphic set-hash (2048-byte state, 1024 uint16 lanes, BLAKE3 XOF) -- **Record operation log** — `listRepoOps` returns the oplog for sync -- **Repo state** — `getRepoState` returns LtHash state + signed commit +- **SignedCommit** — versioned commit struct (`ver: 1`) with hash, ikm, sig, mac, rev +- **Record operation log** — `listRepoOps` returns the oplog for sync (values inlined by default, `excludeValues` to opt out) +- **Latest commit** — `getLatestCommit` returns the signed commit for a user in a space +- **Repo export** — `getRepo` exports a user's repo as a CAR v1 file (signedCommit + DRISL index) - **Write notifications** — `registerNotify`, `notifyWrite`, `notifySpaceDeleted` - **Space-scoped blobs** — `getBlob` - **Authority DID** — spaces use `authority_did` (not `owner_did`) with a separate `creator_did` diff --git a/packages/docs/content/docs/experimental/spaces/invites.md b/packages/docs/content/docs/experimental/spaces/invites.md --- a/packages/docs/content/docs/experimental/spaces/invites.md +++ b/packages/docs/content/docs/experimental/spaces/invites.md @@ -26,7 +26,7 @@ "Content-Type": "application/json", }, body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", access: "write", maxUses: 10, expiresAt: "2026-06-01T00:00:00Z", @@ -51,7 +51,7 @@ "Content-Type": "application/json", }, body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", access: "write", maxUses: 10, expiresAt: "2026-06-01T00:00:00Z", @@ -66,7 +66,7 @@ .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) .json(&serde_json::json!({ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "access": "write", "maxUses": 10, "expiresAt": "2026-06-01T00:00:00Z" @@ -77,7 +77,7 @@ ``` ```go tab="Go" tab-group="language" body := bytes.NewBufferString(`{ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "access": "write", "maxUses": 10, "expiresAt": "2026-06-01T00:00:00Z" @@ -97,7 +97,7 @@ -H 'DPoP: ' \ -H 'Content-Type: application/json' \ -d '{ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "access": "write", "maxUses": 10, "expiresAt": "2026-06-01T00:00:00Z" @@ -205,7 +205,7 @@ ```json { - "uri": "ats://did:plc:abc123/com.example.forum/main", + "uri": "at://did:plc:abc123/space/com.example.forum/main", "access": "write" } ``` @@ -230,7 +230,7 @@ "Content-Type": "application/json", }, body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", inviteId: "uuid", }), }); @@ -245,7 +245,7 @@ "Content-Type": "application/json", }, body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", inviteId: "uuid", }), }); @@ -257,7 +257,7 @@ .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) .json(&serde_json::json!({ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "inviteId": "uuid" })) .send() @@ -265,7 +265,7 @@ ``` ```go tab="Go" tab-group="language" body := bytes.NewBufferString(`{ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "inviteId": "uuid" }`) req, _ := http.NewRequest("POST", @@ -283,7 +283,7 @@ -H 'DPoP: ' \ -H 'Content-Type: application/json' \ -d '{ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "inviteId": "uuid" }' ``` @@ -296,7 +296,7 @@ ```ts tab="TypeScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/dev.happyview.space.listInvites?space=ats://did:plc:abc123/com.example.forum/main", + "https://happyview.example.com/xrpc/dev.happyview.space.listInvites?space=at://did:plc:abc123/space/com.example.forum/main", { headers: { "X-Client-Key": CLIENT_KEY, @@ -319,7 +319,7 @@ ``` ```js tab="JavaScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/dev.happyview.space.listInvites?space=ats://did:plc:abc123/com.example.forum/main", + "https://happyview.example.com/xrpc/dev.happyview.space.listInvites?space=at://did:plc:abc123/space/com.example.forum/main", { headers: { "X-Client-Key": CLIENT_KEY, @@ -333,7 +333,7 @@ ```rust tab="Rust" tab-group="language" let response = client .get("https://happyview.example.com/xrpc/dev.happyview.space.listInvites") - .query(&[("space", "ats://did:plc:abc123/com.example.forum/main")]) + .query(&[("space", "at://did:plc:abc123/space/com.example.forum/main")]) .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -343,7 +343,7 @@ ``` ```go tab="Go" tab-group="language" req, _ := http.NewRequest("GET", - "https://happyview.example.com/xrpc/dev.happyview.space.listInvites?space=ats://did:plc:abc123/com.example.forum/main", + "https://happyview.example.com/xrpc/dev.happyview.space.listInvites?space=at://did:plc:abc123/space/com.example.forum/main", nil) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) @@ -351,7 +351,7 @@ resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl 'https://happyview.example.com/xrpc/dev.happyview.space.listInvites?space=ats://did:plc:abc123/com.example.forum/main' \ +curl 'https://happyview.example.com/xrpc/dev.happyview.space.listInvites?space=at://did:plc:abc123/space/com.example.forum/main' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' diff --git a/packages/docs/content/docs/experimental/spaces/managing-spaces.md b/packages/docs/content/docs/experimental/spaces/managing-spaces.md --- a/packages/docs/content/docs/experimental/spaces/managing-spaces.md +++ b/packages/docs/content/docs/experimental/spaces/managing-spaces.md @@ -114,7 +114,7 @@ ```json { - "uri": "ats://did:plc:abc123/com.example.forum/main" + "uri": "at://did:plc:abc123/space/com.example.forum/main" } ``` @@ -135,7 +135,7 @@ ```ts tab="TypeScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/com.atproto.space.getSpace?space=ats://did:plc:abc123/com.example.forum/main", + "https://happyview.example.com/xrpc/com.atproto.space.getSpace?space=at://did:plc:abc123/space/com.example.forum/main", { headers: { "X-Client-Key": CLIENT_KEY, @@ -153,7 +153,7 @@ ``` ```js tab="JavaScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/com.atproto.space.getSpace?space=ats://did:plc:abc123/com.example.forum/main", + "https://happyview.example.com/xrpc/com.atproto.space.getSpace?space=at://did:plc:abc123/space/com.example.forum/main", { headers: { "X-Client-Key": CLIENT_KEY, @@ -167,7 +167,7 @@ ```rust tab="Rust" tab-group="language" let response = client .get("https://happyview.example.com/xrpc/com.atproto.space.getSpace") - .query(&[("space", "ats://did:plc:abc123/com.example.forum/main")]) + .query(&[("space", "at://did:plc:abc123/space/com.example.forum/main")]) .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -177,7 +177,7 @@ ``` ```go tab="Go" tab-group="language" req, _ := http.NewRequest("GET", - "https://happyview.example.com/xrpc/com.atproto.space.getSpace?space=ats://did:plc:abc123/com.example.forum/main", + "https://happyview.example.com/xrpc/com.atproto.space.getSpace?space=at://did:plc:abc123/space/com.example.forum/main", nil) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) @@ -185,7 +185,7 @@ resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl 'https://happyview.example.com/xrpc/com.atproto.space.getSpace?space=ats://did:plc:abc123/com.example.forum/main' \ +curl 'https://happyview.example.com/xrpc/com.atproto.space.getSpace?space=at://did:plc:abc123/space/com.example.forum/main' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' @@ -272,7 +272,7 @@ { "spaces": [ { - "uri": "ats://did:plc:abc123/com.example.forum/main", + "uri": "at://did:plc:abc123/space/com.example.forum/main", "isOwner": true } ], @@ -294,7 +294,7 @@ "Content-Type": "application/json", }, body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", displayName: "Updated Forum Name", mintPolicy: "public", }), @@ -310,7 +310,7 @@ "Content-Type": "application/json", }, body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", displayName: "Updated Forum Name", mintPolicy: "public", }), @@ -323,7 +323,7 @@ .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) .json(&serde_json::json!({ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "displayName": "Updated Forum Name", "mintPolicy": "public" })) @@ -332,7 +332,7 @@ ``` ```go tab="Go" tab-group="language" body := bytes.NewBufferString(`{ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "displayName": "Updated Forum Name", "mintPolicy": "public" }`) @@ -351,7 +351,7 @@ -H 'DPoP: ' \ -H 'Content-Type: application/json' \ -d '{ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "displayName": "Updated Forum Name", "mintPolicy": "public" }' @@ -373,7 +373,7 @@ "Content-Type": "application/json", }, body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", }), }); ``` @@ -387,7 +387,7 @@ "Content-Type": "application/json", }, body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", }), }); ``` @@ -398,13 +398,13 @@ .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) .json(&serde_json::json!({ - "space": "ats://did:plc:abc123/com.example.forum/main" + "space": "at://did:plc:abc123/space/com.example.forum/main" })) .send() .await?; ``` ```go tab="Go" tab-group="language" -body := bytes.NewBufferString(`{"space": "ats://did:plc:abc123/com.example.forum/main"}`) +body := bytes.NewBufferString(`{"space": "at://did:plc:abc123/space/com.example.forum/main"}`) req, _ := http.NewRequest("POST", "https://happyview.example.com/xrpc/com.atproto.simplespace.deleteSpace", body) req.Header.Set("X-Client-Key", clientKey) @@ -419,7 +419,7 @@ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ -H 'Content-Type: application/json' \ - -d '{"space": "ats://did:plc:abc123/com.example.forum/main"}' + -d '{"space": "at://did:plc:abc123/space/com.example.forum/main"}' ``` @@ -432,7 +432,7 @@ ```ts tab="TypeScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/com.atproto.simplespace.getConfig?space=ats://did:plc:abc123/com.example.forum/main", + "https://happyview.example.com/xrpc/com.atproto.simplespace.getConfig?space=at://did:plc:abc123/space/com.example.forum/main", { headers: { "X-Client-Key": CLIENT_KEY, @@ -451,7 +451,7 @@ ``` ```js tab="JavaScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/com.atproto.simplespace.getConfig?space=ats://did:plc:abc123/com.example.forum/main", + "https://happyview.example.com/xrpc/com.atproto.simplespace.getConfig?space=at://did:plc:abc123/space/com.example.forum/main", { headers: { "X-Client-Key": CLIENT_KEY, @@ -465,7 +465,7 @@ ```rust tab="Rust" tab-group="language" let response = client .get("https://happyview.example.com/xrpc/com.atproto.simplespace.getConfig") - .query(&[("space", "ats://did:plc:abc123/com.example.forum/main")]) + .query(&[("space", "at://did:plc:abc123/space/com.example.forum/main")]) .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -475,7 +475,7 @@ ``` ```go tab="Go" tab-group="language" req, _ := http.NewRequest("GET", - "https://happyview.example.com/xrpc/com.atproto.simplespace.getConfig?space=ats://did:plc:abc123/com.example.forum/main", + "https://happyview.example.com/xrpc/com.atproto.simplespace.getConfig?space=at://did:plc:abc123/space/com.example.forum/main", nil) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) @@ -483,7 +483,7 @@ resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl 'https://happyview.example.com/xrpc/com.atproto.simplespace.getConfig?space=ats://did:plc:abc123/com.example.forum/main' \ +curl 'https://happyview.example.com/xrpc/com.atproto.simplespace.getConfig?space=at://did:plc:abc123/space/com.example.forum/main' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' @@ -520,7 +520,7 @@ "Content-Type": "application/json", }, body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", mintPolicy: "public", appAccess: { type: "allowList", allowed: ["did:web:myapp.example.com"] }, }), @@ -536,7 +536,7 @@ "Content-Type": "application/json", }, body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", mintPolicy: "public", appAccess: { type: "allowList", allowed: ["did:web:myapp.example.com"] }, }), @@ -549,7 +549,7 @@ .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) .json(&serde_json::json!({ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "mintPolicy": "public", "appAccess": { "type": "allowList", "allowed": ["did:web:myapp.example.com"] } })) @@ -559,7 +559,7 @@ ``` ```go tab="Go" tab-group="language" body := bytes.NewBufferString(`{ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "mintPolicy": "public", "appAccess": {"type": "allowList", "allowed": ["did:web:myapp.example.com"]} }`) @@ -578,7 +578,7 @@ -H 'DPoP: ' \ -H 'Content-Type: application/json' \ -d '{ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "mintPolicy": "public", "appAccess": {"type": "allowList", "allowed": ["did:web:myapp.example.com"]} }' diff --git a/packages/docs/content/docs/experimental/spaces/members.md b/packages/docs/content/docs/experimental/spaces/members.md --- a/packages/docs/content/docs/experimental/spaces/members.md +++ b/packages/docs/content/docs/experimental/spaces/members.md @@ -22,7 +22,7 @@ "Content-Type": "application/json", }, body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", did: "did:plc:newmember", access: "write", isDelegation: false, @@ -49,7 +49,7 @@ "Content-Type": "application/json", }, body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", did: "did:plc:newmember", access: "write", isDelegation: false, @@ -64,7 +64,7 @@ .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) .json(&serde_json::json!({ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "did": "did:plc:newmember", "access": "write", "isDelegation": false @@ -75,7 +75,7 @@ ``` ```go tab="Go" tab-group="language" body := bytes.NewBufferString(`{ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "did": "did:plc:newmember", "access": "write", "isDelegation": false @@ -95,7 +95,7 @@ -H 'DPoP: ' \ -H 'Content-Type: application/json' \ -d '{ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "did": "did:plc:newmember", "access": "write", "isDelegation": false @@ -139,7 +139,7 @@ "Content-Type": "application/json", }, body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", did: "did:plc:newmember", }), }); @@ -154,7 +154,7 @@ "Content-Type": "application/json", }, body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", did: "did:plc:newmember", }), }); @@ -166,7 +166,7 @@ .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) .json(&serde_json::json!({ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "did": "did:plc:newmember" })) .send() @@ -174,7 +174,7 @@ ``` ```go tab="Go" tab-group="language" body := bytes.NewBufferString(`{ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "did": "did:plc:newmember" }`) req, _ := http.NewRequest("POST", @@ -192,7 +192,7 @@ -H 'DPoP: ' \ -H 'Content-Type: application/json' \ -d '{ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "did": "did:plc:newmember" }' ``` @@ -201,7 +201,7 @@ ```ts tab="TypeScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/com.atproto.simplespace.listMembers?space=ats://did:plc:abc123/com.example.forum/main", + "https://happyview.example.com/xrpc/com.atproto.simplespace.listMembers?space=at://did:plc:abc123/space/com.example.forum/main", { headers: { "X-Client-Key": CLIENT_KEY, @@ -218,7 +218,7 @@ ``` ```js tab="JavaScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/com.atproto.simplespace.listMembers?space=ats://did:plc:abc123/com.example.forum/main", + "https://happyview.example.com/xrpc/com.atproto.simplespace.listMembers?space=at://did:plc:abc123/space/com.example.forum/main", { headers: { "X-Client-Key": CLIENT_KEY, @@ -232,7 +232,7 @@ ```rust tab="Rust" tab-group="language" let response = client .get("https://happyview.example.com/xrpc/com.atproto.simplespace.listMembers") - .query(&[("space", "ats://did:plc:abc123/com.example.forum/main")]) + .query(&[("space", "at://did:plc:abc123/space/com.example.forum/main")]) .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -242,7 +242,7 @@ ``` ```go tab="Go" tab-group="language" req, _ := http.NewRequest("GET", - "https://happyview.example.com/xrpc/com.atproto.simplespace.listMembers?space=ats://did:plc:abc123/com.example.forum/main", + "https://happyview.example.com/xrpc/com.atproto.simplespace.listMembers?space=at://did:plc:abc123/space/com.example.forum/main", nil) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) @@ -250,7 +250,7 @@ resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl 'https://happyview.example.com/xrpc/com.atproto.simplespace.listMembers?space=ats://did:plc:abc123/com.example.forum/main' \ +curl 'https://happyview.example.com/xrpc/com.atproto.simplespace.listMembers?space=at://did:plc:abc123/space/com.example.forum/main' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' @@ -284,8 +284,8 @@ "Content-Type": "application/json", }, body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", - did: "ats://did:plc:org/com.example.team/engineering", + space: "at://did:plc:abc123/space/com.example.forum/main", + did: "at://did:plc:org/space/com.example.team/engineering", access: "read", isDelegation: true, }), @@ -301,8 +301,8 @@ "Content-Type": "application/json", }, body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", - did: "ats://did:plc:org/com.example.team/engineering", + space: "at://did:plc:abc123/space/com.example.forum/main", + did: "at://did:plc:org/space/com.example.team/engineering", access: "read", isDelegation: true, }), @@ -315,8 +315,8 @@ .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) .json(&serde_json::json!({ - "space": "ats://did:plc:abc123/com.example.forum/main", - "did": "ats://did:plc:org/com.example.team/engineering", + "space": "at://did:plc:abc123/space/com.example.forum/main", + "did": "at://did:plc:org/space/com.example.team/engineering", "access": "read", "isDelegation": true })) @@ -325,8 +325,8 @@ ``` ```go tab="Go" tab-group="language" body := bytes.NewBufferString(`{ - "space": "ats://did:plc:abc123/com.example.forum/main", - "did": "ats://did:plc:org/com.example.team/engineering", + "space": "at://did:plc:abc123/space/com.example.forum/main", + "did": "at://did:plc:org/space/com.example.team/engineering", "access": "read", "isDelegation": true }`) @@ -345,8 +345,8 @@ -H 'DPoP: ' \ -H 'Content-Type: application/json' \ -d '{ - "space": "ats://did:plc:abc123/com.example.forum/main", - "did": "ats://did:plc:org/com.example.team/engineering", + "space": "at://did:plc:abc123/space/com.example.forum/main", + "did": "at://did:plc:org/space/com.example.team/engineering", "access": "read", "isDelegation": true }' diff --git a/packages/docs/content/docs/experimental/spaces/notifications.md b/packages/docs/content/docs/experimental/spaces/notifications.md --- a/packages/docs/content/docs/experimental/spaces/notifications.md +++ b/packages/docs/content/docs/experimental/spaces/notifications.md @@ -24,7 +24,7 @@ "Content-Type": "application/json", }, body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", serviceDid: "did:web:feed.example.com", endpoint: "https://feed.example.com/webhooks/space-writes", }), @@ -44,7 +44,7 @@ "Content-Type": "application/json", }, body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", serviceDid: "did:web:feed.example.com", endpoint: "https://feed.example.com/webhooks/space-writes", }), @@ -58,7 +58,7 @@ .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) .json(&serde_json::json!({ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "serviceDid": "did:web:feed.example.com", "endpoint": "https://feed.example.com/webhooks/space-writes" })) @@ -68,7 +68,7 @@ ``` ```go tab="Go" tab-group="language" body := bytes.NewBufferString(`{ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "serviceDid": "did:web:feed.example.com", "endpoint": "https://feed.example.com/webhooks/space-writes" }`) @@ -87,7 +87,7 @@ -H 'DPoP: ' \ -H 'Content-Type: application/json' \ -d '{ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "serviceDid": "did:web:feed.example.com", "endpoint": "https://feed.example.com/webhooks/space-writes" }' @@ -97,7 +97,7 @@ | Field | Type | Required | Description | | ------------ | ------ | -------- | ------------------------------------------------ | -| `space` | string | Yes | Space URI (`ats://...`) | +| `space` | string | Yes | Space URI (`at://...`) | | `serviceDid` | string | Yes | DID of the service receiving notifications | | `endpoint` | string | Yes | HTTPS endpoint to deliver notifications to | @@ -144,7 +144,7 @@ "Content-Type": "application/json", }, body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", did: "did:plc:author456", collection: "com.example.forum.post", rkey: "3jwq5dya2gy2z", @@ -161,7 +161,7 @@ "Content-Type": "application/json", }, body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", did: "did:plc:author456", collection: "com.example.forum.post", rkey: "3jwq5dya2gy2z", @@ -175,7 +175,7 @@ let response = client .post("https://happyview.example.com/xrpc/com.atproto.space.notifyWrite") .json(&serde_json::json!({ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "did": "did:plc:author456", "collection": "com.example.forum.post", "rkey": "3jwq5dya2gy2z", @@ -187,7 +187,7 @@ ``` ```go tab="Go" tab-group="language" body := bytes.NewBufferString(`{ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "did": "did:plc:author456", "collection": "com.example.forum.post", "rkey": "3jwq5dya2gy2z", @@ -202,7 +202,7 @@ curl -X POST 'https://happyview.example.com/xrpc/com.atproto.space.notifyWrite' \ -H 'Content-Type: application/json' \ -d '{ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "did": "did:plc:author456", "collection": "com.example.forum.post", "rkey": "3jwq5dya2gy2z", @@ -214,7 +214,7 @@ | Field | Type | Required | Description | | ------------ | ------------- | -------- | ------------------------------------------------ | -| `space` | string | Yes | Space URI (`ats://...`) | +| `space` | string | Yes | Space URI (`at://...`) | | `did` | string | Yes | DID of the author who made the change | | `collection` | string (NSID) | Yes | Collection the record belongs to | | `rkey` | string | Yes | Record key | @@ -239,7 +239,7 @@ "Content-Type": "application/json", }, body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", }), }); const data = await response.json(); @@ -252,7 +252,7 @@ "Content-Type": "application/json", }, body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", }), }); const data = await response.json(); @@ -262,7 +262,7 @@ let response = client .post("https://happyview.example.com/xrpc/com.atproto.space.notifySpaceDeleted") .json(&serde_json::json!({ - "space": "ats://did:plc:abc123/com.example.forum/main" + "space": "at://did:plc:abc123/space/com.example.forum/main" })) .send() .await?; @@ -270,7 +270,7 @@ ``` ```go tab="Go" tab-group="language" body := bytes.NewBufferString(`{ - "space": "ats://did:plc:abc123/com.example.forum/main" + "space": "at://did:plc:abc123/space/com.example.forum/main" }`) req, _ := http.NewRequest("POST", "https://happyview.example.com/xrpc/com.atproto.space.notifySpaceDeleted", body) @@ -281,7 +281,7 @@ curl -X POST 'https://happyview.example.com/xrpc/com.atproto.space.notifySpaceDeleted' \ -H 'Content-Type: application/json' \ -d '{ - "space": "ats://did:plc:abc123/com.example.forum/main" + "space": "at://did:plc:abc123/space/com.example.forum/main" }' ``` @@ -289,7 +289,7 @@ | Field | Type | Required | Description | | ------- | ------ | -------- | ----------------------- | -| `space` | string | Yes | Space URI (`ats://...`) | +| `space` | string | Yes | Space URI (`at://...`) | **Response (200):** diff --git a/packages/docs/content/docs/experimental/spaces/records.md b/packages/docs/content/docs/experimental/spaces/records.md --- a/packages/docs/content/docs/experimental/spaces/records.md +++ b/packages/docs/content/docs/experimental/spaces/records.md @@ -6,11 +6,11 @@ This API is experimental and will change. See the [Permissioned Spaces overview](../spaces.md) for context. -Space records are stored separately from public AT Protocol records. They follow the same URI pattern but use the `ats://` scheme and include the space identity: +Space records are stored separately from public AT Protocol records. They use the `at://` scheme with a `space` path segment to distinguish them from public records: ``` -ats:// did:plc:abcdefghijklmnop1234567890 / com.example.forum / main / did:plc:author / com.example.forum.post / abcdefghijklmnop1234567890 - └── space DID ───────────────────┘ └── space type ─┘ └── skey ─┘ └── author ──┘ └── collection ──────┘ └── rkey ────────────────┘ +at:// did:plc:abcdefghijklmnop1234567890 / space / com.example.forum / main / did:plc:author / com.example.forum.post / abcdefghijklmnop1234567890 + └── space DID ───────────────────┘ └── space type ─┘ └── skey ─┘ └── author ──┘ └── collection ──────┘ └── rkey ────────────────┘ ``` ## Creating a record @@ -27,7 +27,7 @@ "Content-Type": "application/json", }, body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", collection: "com.example.forum.post", record: { $type: "com.example.forum.post", @@ -52,7 +52,7 @@ "Content-Type": "application/json", }, body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", collection: "com.example.forum.post", record: { $type: "com.example.forum.post", @@ -70,7 +70,7 @@ .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) .json(&serde_json::json!({ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "collection": "com.example.forum.post", "record": { "$type": "com.example.forum.post", @@ -84,7 +84,7 @@ ``` ```go tab="Go" tab-group="language" body := bytes.NewBufferString(`{ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "collection": "com.example.forum.post", "record": { "$type": "com.example.forum.post", @@ -107,7 +107,7 @@ -H 'DPoP: ' \ -H 'Content-Type: application/json' \ -d '{ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "collection": "com.example.forum.post", "record": { "$type": "com.example.forum.post", @@ -129,7 +129,7 @@ ```json { - "uri": "ats://did:plc:abc123/com.example.forum/main/did:plc:author/com.example.forum.post/3l2tkbx7225co", + "uri": "at://did:plc:abc123/space/com.example.forum/main/did:plc:author/com.example.forum.post/3l2tkbx7225co", "cid": "bafyrei..." } ``` @@ -150,7 +150,7 @@ "Content-Type": "application/json", }, body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", collection: "com.example.forum.post", rkey: "3k2abc", record: { @@ -176,7 +176,7 @@ "Content-Type": "application/json", }, body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", collection: "com.example.forum.post", rkey: "3k2abc", record: { @@ -195,7 +195,7 @@ .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) .json(&serde_json::json!({ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "collection": "com.example.forum.post", "rkey": "3k2abc", "record": { @@ -210,7 +210,7 @@ ``` ```go tab="Go" tab-group="language" body := bytes.NewBufferString(`{ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "collection": "com.example.forum.post", "rkey": "3k2abc", "record": { @@ -234,7 +234,7 @@ -H 'DPoP: ' \ -H 'Content-Type: application/json' \ -d '{ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "collection": "com.example.forum.post", "rkey": "3k2abc", "record": { @@ -259,7 +259,7 @@ ```json { - "uri": "ats://did:plc:abc123/com.example.forum/main/did:plc:author/com.example.forum.post/3k2abc", + "uri": "at://did:plc:abc123/space/com.example.forum/main/did:plc:author/com.example.forum.post/3k2abc", "cid": "bafyrei..." } ``` @@ -276,7 +276,7 @@ ```ts tab="TypeScript" tab-group="language" const params = new URLSearchParams({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", collection: "com.example.forum.post", rkey: "3k2abc", }); @@ -299,7 +299,7 @@ ``` ```js tab="JavaScript" tab-group="language" const params = new URLSearchParams({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", collection: "com.example.forum.post", rkey: "3k2abc", }); @@ -319,7 +319,7 @@ let response = client .get("https://happyview.example.com/xrpc/com.atproto.space.getRecord") .query(&[ - ("space", "ats://did:plc:abc123/com.example.forum/main"), + ("space", "at://did:plc:abc123/space/com.example.forum/main"), ("collection", "com.example.forum.post"), ("rkey", "3k2abc"), ]) @@ -332,7 +332,7 @@ ``` ```go tab="Go" tab-group="language" req, _ := http.NewRequest("GET", - "https://happyview.example.com/xrpc/com.atproto.space.getRecord?space=ats://did:plc:abc123/com.example.forum/main&collection=com.example.forum.post&rkey=3k2abc", + "https://happyview.example.com/xrpc/com.atproto.space.getRecord?space=at://did:plc:abc123/space/com.example.forum/main&collection=com.example.forum.post&rkey=3k2abc", nil) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) @@ -340,7 +340,7 @@ resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl 'https://happyview.example.com/xrpc/com.atproto.space.getRecord?space=ats://did:plc:abc123/com.example.forum/main&collection=com.example.forum.post&rkey=3k2abc' \ +curl 'https://happyview.example.com/xrpc/com.atproto.space.getRecord?space=at://did:plc:abc123/space/com.example.forum/main&collection=com.example.forum.post&rkey=3k2abc' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' @@ -358,7 +358,7 @@ ```json { - "uri": "ats://did:plc:abc123/com.example.forum/main/did:plc:author/com.example.forum.post/3k2abc", + "uri": "at://did:plc:abc123/space/com.example.forum/main/did:plc:author/com.example.forum.post/3k2abc", "cid": "bafyrei...", "value": { "$type": "com.example.forum.post", @@ -372,7 +372,7 @@ ```ts tab="TypeScript" tab-group="language" const params = new URLSearchParams({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", collection: "com.example.forum.post", limit: "20", }); @@ -399,7 +399,7 @@ ``` ```js tab="JavaScript" tab-group="language" const params = new URLSearchParams({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", collection: "com.example.forum.post", limit: "20", }); @@ -419,7 +419,7 @@ let response = client .get("https://happyview.example.com/xrpc/com.atproto.space.listRecords") .query(&[ - ("space", "ats://did:plc:abc123/com.example.forum/main"), + ("space", "at://did:plc:abc123/space/com.example.forum/main"), ("collection", "com.example.forum.post"), ("limit", "20"), ]) @@ -432,7 +432,7 @@ ``` ```go tab="Go" tab-group="language" req, _ := http.NewRequest("GET", - "https://happyview.example.com/xrpc/com.atproto.space.listRecords?space=ats://did:plc:abc123/com.example.forum/main&collection=com.example.forum.post&limit=20", + "https://happyview.example.com/xrpc/com.atproto.space.listRecords?space=at://did:plc:abc123/space/com.example.forum/main&collection=com.example.forum.post&limit=20", nil) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) @@ -440,7 +440,7 @@ resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl 'https://happyview.example.com/xrpc/com.atproto.space.listRecords?space=ats://did:plc:abc123/com.example.forum/main&collection=com.example.forum.post&limit=20' \ +curl 'https://happyview.example.com/xrpc/com.atproto.space.listRecords?space=at://did:plc:abc123/space/com.example.forum/main&collection=com.example.forum.post&limit=20' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' @@ -486,7 +486,7 @@ "Content-Type": "application/json", }, body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", collection: "com.example.forum.post", rkey: "3k2abc", }), @@ -502,7 +502,7 @@ "Content-Type": "application/json", }, body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", collection: "com.example.forum.post", rkey: "3k2abc", }), @@ -515,7 +515,7 @@ .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) .json(&serde_json::json!({ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "collection": "com.example.forum.post", "rkey": "3k2abc" })) @@ -524,7 +524,7 @@ ``` ```go tab="Go" tab-group="language" body := bytes.NewBufferString(`{ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "collection": "com.example.forum.post", "rkey": "3k2abc" }`) @@ -543,7 +543,7 @@ -H 'DPoP: ' \ -H 'Content-Type: application/json' \ -d '{ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "collection": "com.example.forum.post", "rkey": "3k2abc" }' @@ -574,7 +574,7 @@ "Content-Type": "application/json", }, body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", writes: [ { action: "create", @@ -612,7 +612,7 @@ "Content-Type": "application/json", }, body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", + space: "at://did:plc:abc123/space/com.example.forum/main", writes: [ { action: "create", @@ -643,7 +643,7 @@ .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) .json(&serde_json::json!({ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "writes": [ { "action": "create", @@ -670,7 +670,7 @@ ``` ```go tab="Go" tab-group="language" body := bytes.NewBufferString(`{ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "writes": [ { "action": "create", @@ -706,7 +706,7 @@ -H 'DPoP: ' \ -H 'Content-Type: application/json' \ -d '{ - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "writes": [ { "action": "create", @@ -750,8 +750,8 @@ ```json { "results": [ - { "uri": "ats://...", "cid": "bafyrei..." }, - { "uri": "ats://...", "cid": "bafyrei..." }, + { "uri": "at://...", "cid": "bafyrei..." }, + { "uri": "at://...", "cid": "bafyrei..." }, {} ] } @@ -769,7 +769,7 @@ ```json { - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "collection": "com.example.forum.post", "rkey": "3k2abc", "record": { "text": "updated safely" }, @@ -785,19 +785,19 @@ ```json { - "space": "ats://did:plc:abc123/com.example.forum/main", + "space": "at://did:plc:abc123/space/com.example.forum/main", "swapCommit": "3l2tkbx7225co", "writes": [...] } ``` -## Repo state +## Latest commit -Returns the per-user repo state for a space, including the current revision and deniable commit data. +Returns the per-user signed commit for a space, including the current revision and deniable commit data. ```ts tab="TypeScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/com.atproto.space.getRepoState?space=ats://did:plc:abc123/com.example.forum/main&did=did:plc:author", + "https://happyview.example.com/xrpc/com.atproto.space.getLatestCommit?space=at://did:plc:abc123/space/com.example.forum/main&did=did:plc:author", { headers: { "X-Client-Key": CLIENT_KEY, @@ -806,9 +806,10 @@ }, }, ); -interface RepoStateResponse { +interface LatestCommitResponse { rev: string | null; commit: { + ver: number; hash: string; ikm: string; sig: string; @@ -816,11 +817,11 @@ rev: string; } | null; } -const data: RepoStateResponse = await response.json(); +const data: LatestCommitResponse = await response.json(); ``` ```js tab="JavaScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/com.atproto.space.getRepoState?space=ats://did:plc:abc123/com.example.forum/main&did=did:plc:author", + "https://happyview.example.com/xrpc/com.atproto.space.getLatestCommit?space=at://did:plc:abc123/space/com.example.forum/main&did=did:plc:author", { headers: { "X-Client-Key": CLIENT_KEY, @@ -833,9 +834,9 @@ ``` ```rust tab="Rust" tab-group="language" let response = client - .get("https://happyview.example.com/xrpc/com.atproto.space.getRepoState") + .get("https://happyview.example.com/xrpc/com.atproto.space.getLatestCommit") .query(&[ - ("space", "ats://did:plc:abc123/com.example.forum/main"), + ("space", "at://did:plc:abc123/space/com.example.forum/main"), ("did", "did:plc:author"), ]) .header("X-Client-Key", client_key) @@ -847,7 +848,7 @@ ``` ```go tab="Go" tab-group="language" req, _ := http.NewRequest("GET", - "https://happyview.example.com/xrpc/com.atproto.space.getRepoState?space=ats://did:plc:abc123/com.example.forum/main&did=did:plc:author", + "https://happyview.example.com/xrpc/com.atproto.space.getLatestCommit?space=at://did:plc:abc123/space/com.example.forum/main&did=did:plc:author", nil) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) @@ -855,7 +856,7 @@ resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl 'https://happyview.example.com/xrpc/com.atproto.space.getRepoState?space=ats://did:plc:abc123/com.example.forum/main&did=did:plc:author' \ +curl 'https://happyview.example.com/xrpc/com.atproto.space.getLatestCommit?space=at://did:plc:abc123/space/com.example.forum/main&did=did:plc:author' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' @@ -873,7 +874,7 @@ | Field | Type | Description | | -------- | ------------ | -------------------------------------------------------------- | | `rev` | string/null | Current revision for this user's repo in the space | -| `commit` | object/null | Deniable commit data (base64url-encoded `hash`, `ikm`, `sig`, `mac`, and `rev`) | +| `commit` | object/null | Deniable commit data (`ver`, base64url-encoded `hash`, `ikm`, `sig`, `mac`, and `rev`) | ## Record operation log @@ -881,7 +882,7 @@ ```ts tab="TypeScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/com.atproto.space.listRepoOps?space=ats://did:plc:abc123/com.example.forum/main&did=did:plc:author", + "https://happyview.example.com/xrpc/com.atproto.space.listRepoOps?space=at://did:plc:abc123/space/com.example.forum/main&did=did:plc:author", { headers: { "X-Client-Key": CLIENT_KEY, @@ -895,7 +896,7 @@ ``` ```js tab="JavaScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/com.atproto.space.listRepoOps?space=ats://did:plc:abc123/com.example.forum/main&did=did:plc:author", + "https://happyview.example.com/xrpc/com.atproto.space.listRepoOps?space=at://did:plc:abc123/space/com.example.forum/main&did=did:plc:author", { headers: { "X-Client-Key": CLIENT_KEY, @@ -910,7 +911,7 @@ let response = client .get("https://happyview.example.com/xrpc/com.atproto.space.listRepoOps") .query(&[ - ("space", "ats://did:plc:abc123/com.example.forum/main"), + ("space", "at://did:plc:abc123/space/com.example.forum/main"), ("did", "did:plc:author"), ]) .header("X-Client-Key", client_key) @@ -922,7 +923,7 @@ ``` ```go tab="Go" tab-group="language" req, _ := http.NewRequest("GET", - "https://happyview.example.com/xrpc/com.atproto.space.listRepoOps?space=ats://did:plc:abc123/com.example.forum/main&did=did:plc:author", + "https://happyview.example.com/xrpc/com.atproto.space.listRepoOps?space=at://did:plc:abc123/space/com.example.forum/main&did=did:plc:author", nil) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) @@ -930,7 +931,7 @@ resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl 'https://happyview.example.com/xrpc/com.atproto.space.listRepoOps?space=ats://did:plc:abc123/com.example.forum/main&did=did:plc:author' \ +curl 'https://happyview.example.com/xrpc/com.atproto.space.listRepoOps?space=at://did:plc:abc123/space/com.example.forum/main&did=did:plc:author' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' @@ -942,8 +943,9 @@ | -------- | ------- | -------- | ---------------------------------------------- | | `space` | string | Yes | The space URI | | `did` | string | Yes | The DID of the user whose ops to list | -| `limit` | integer | No | Max number of entries to return (default 100, max 1000) | -| `cursor` | string | No | Revision to start after (for pagination) | +| `limit` | integer | No | Max number of entries to return (default 100, max 1000) | +| `cursor` | string | No | Revision to start after (for pagination) | +| `excludeValues` | boolean | No | If `true`, omit record values from response (default `false`) | **Response:** @@ -959,13 +961,84 @@ "rkey": "3k2abc", "cid": "bafyrei...", "prev": null, + "value": { "text": "hello world" }, "createdAt": "2026-05-09T12:00:00Z" } ] } ``` -Each entry records a single write operation. The `action` is one of `create`, `update`, or `delete`. The `prev` field contains the CID of the record before the operation (for updates and deletes). +Each entry records a single write operation. The `action` is one of `create`, `update`, or `delete`. The `prev` field contains the CID of the record before the operation (for updates and deletes). The `value` field contains the record's current value (omitted for deletes or when `excludeValues=true`). + +## Repo export + +Exports a user's full repo within a space as a CAR v1 file. The file contains two roots: the signed commit and a DRISL index (flat DAG-CBOR map of `collection/rkey` to CID). Record blocks follow in lexicographic order. + +```ts tab="TypeScript" tab-group="language" +const response = await fetch( + "https://happyview.example.com/xrpc/com.atproto.space.getRepo?space=at://did:plc:abc123/space/com.example.forum/main&did=did:plc:author", + { + headers: { + "X-Client-Key": CLIENT_KEY, + "Authorization": `DPoP ${ACCESS_TOKEN}`, + "DPoP": DPOP_PROOF, + }, + }, +); +const car = await response.arrayBuffer(); +``` +```js tab="JavaScript" tab-group="language" +const response = await fetch( + "https://happyview.example.com/xrpc/com.atproto.space.getRepo?space=at://did:plc:abc123/space/com.example.forum/main&did=did:plc:author", + { + headers: { + "X-Client-Key": CLIENT_KEY, + "Authorization": `DPoP ${ACCESS_TOKEN}`, + "DPoP": DPOP_PROOF, + }, + }, +); +const car = await response.arrayBuffer(); +``` +```rust tab="Rust" tab-group="language" +let response = client + .get("https://happyview.example.com/xrpc/com.atproto.space.getRepo") + .query(&[ + ("space", "at://did:plc:abc123/space/com.example.forum/main"), + ("did", "did:plc:author"), + ]) + .header("X-Client-Key", client_key) + .header("Authorization", format!("DPoP {}", access_token)) + .header("DPoP", &dpop_proof) + .send() + .await?; +let bytes = response.bytes().await?; +``` +```go tab="Go" tab-group="language" +req, _ := http.NewRequest("GET", + "https://happyview.example.com/xrpc/com.atproto.space.getRepo?space=at://did:plc:abc123/space/com.example.forum/main&did=did:plc:author", + nil) +req.Header.Set("X-Client-Key", clientKey) +req.Header.Set("Authorization", "DPoP "+accessToken) +req.Header.Set("DPoP", dpopProof) +resp, err := http.DefaultClient.Do(req) +``` +```sh tab="cURL" tab-group="language" +curl 'https://happyview.example.com/xrpc/com.atproto.space.getRepo?space=at://did:plc:abc123/space/com.example.forum/main&did=did:plc:author' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + --output repo.car +``` + +**Parameters:** + +| Field | Type | Required | Description | +| ------- | ------ | -------- | ------------------------------------ | +| `space` | string | Yes | The space URI | +| `did` | string | Yes | The DID of the user whose repo to export | + +The response body is a CAR v1 file with content type `application/vnd.ipld.car`. ## Listing repos @@ -973,7 +1046,7 @@ ```ts tab="TypeScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/com.atproto.space.listRepos?space=ats://did:plc:abc123/com.example.forum/main", + "https://happyview.example.com/xrpc/com.atproto.space.listRepos?space=at://did:plc:abc123/space/com.example.forum/main", { headers: { "X-Client-Key": CLIENT_KEY, @@ -990,7 +1063,7 @@ ``` ```js tab="JavaScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/com.atproto.space.listRepos?space=ats://did:plc:abc123/com.example.forum/main", + "https://happyview.example.com/xrpc/com.atproto.space.listRepos?space=at://did:plc:abc123/space/com.example.forum/main", { headers: { "X-Client-Key": CLIENT_KEY, @@ -1004,7 +1077,7 @@ ```rust tab="Rust" tab-group="language" let response = client .get("https://happyview.example.com/xrpc/com.atproto.space.listRepos") - .query(&[("space", "ats://did:plc:abc123/com.example.forum/main")]) + .query(&[("space", "at://did:plc:abc123/space/com.example.forum/main")]) .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -1014,7 +1087,7 @@ ``` ```go tab="Go" tab-group="language" req, _ := http.NewRequest("GET", - "https://happyview.example.com/xrpc/com.atproto.space.listRepos?space=ats://did:plc:abc123/com.example.forum/main", + "https://happyview.example.com/xrpc/com.atproto.space.listRepos?space=at://did:plc:abc123/space/com.example.forum/main", nil) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) @@ -1022,7 +1095,7 @@ resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl 'https://happyview.example.com/xrpc/com.atproto.space.listRepos?space=ats://did:plc:abc123/com.example.forum/main' \ +curl 'https://happyview.example.com/xrpc/com.atproto.space.listRepos?space=at://did:plc:abc123/space/com.example.forum/main' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' @@ -1051,7 +1124,7 @@ ```ts tab="TypeScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/com.atproto.space.getBlob?space=ats://did:plc:abc123/com.example.forum/main&cid=bafyrei...", + "https://happyview.example.com/xrpc/com.atproto.space.getBlob?space=at://did:plc:abc123/space/com.example.forum/main&cid=bafyrei...", { headers: { "X-Client-Key": CLIENT_KEY, @@ -1064,7 +1137,7 @@ ``` ```js tab="JavaScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/com.atproto.space.getBlob?space=ats://did:plc:abc123/com.example.forum/main&cid=bafyrei...", + "https://happyview.example.com/xrpc/com.atproto.space.getBlob?space=at://did:plc:abc123/space/com.example.forum/main&cid=bafyrei...", { headers: { "X-Client-Key": CLIENT_KEY, @@ -1079,7 +1152,7 @@ let response = client .get("https://happyview.example.com/xrpc/com.atproto.space.getBlob") .query(&[ - ("space", "ats://did:plc:abc123/com.example.forum/main"), + ("space", "at://did:plc:abc123/space/com.example.forum/main"), ("cid", "bafyrei..."), ]) .header("X-Client-Key", client_key) @@ -1091,7 +1164,7 @@ ``` ```go tab="Go" tab-group="language" req, _ := http.NewRequest("GET", - "https://happyview.example.com/xrpc/com.atproto.space.getBlob?space=ats://did:plc:abc123/com.example.forum/main&cid=bafyrei...", + "https://happyview.example.com/xrpc/com.atproto.space.getBlob?space=at://did:plc:abc123/space/com.example.forum/main&cid=bafyrei...", nil) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) @@ -1099,7 +1172,7 @@ resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl 'https://happyview.example.com/xrpc/com.atproto.space.getBlob?space=ats://did:plc:abc123/com.example.forum/main&cid=bafyrei...' \ +curl 'https://happyview.example.com/xrpc/com.atproto.space.getBlob?space=at://did:plc:abc123/space/com.example.forum/main&cid=bafyrei...' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ diff --git a/web/src/app/dashboard/settings/scripts/script-form.tsx b/web/src/app/dashboard/settings/scripts/script-form.tsx --- a/web/src/app/dashboard/settings/scripts/script-form.tsx +++ b/web/src/app/dashboard/settings/scripts/script-form.tsx @@ -4,6 +4,7 @@ import { MonacoEditor } from "@/components/monaco-editor"; import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, @@ -18,7 +19,12 @@ import { Textarea } from "@/components/ui/textarea"; import type { LexiconSummary } from "@/types/lexicons"; import type { TriggerKind } from "@/types/scripts"; -import { TRIGGER_KIND_LABELS, parseTriggerId } from "@/types/scripts"; +import { + DEFAULT_JOB_SCRIPT_BODY, + DEFAULT_SCRIPT_BODY, + TRIGGER_KIND_LABELS, + parseTriggerId, +} from "@/types/scripts"; /** * Sentinel suffix used when the operator picks "Actor" in the lexicon @@ -27,15 +33,27 @@ */ export const ACTOR_SUFFIX = "_actor"; +/** + * Sentinel value for the source selector when the operator picks "Job". + * The actual suffix is typed into a free-form input (the job type name). + */ +export const JOB_SOURCE = "_job"; + export interface ScriptFormState { /** Trigger kind selector value (e.g. `record.create`). */ kind: TriggerKind; /** * Suffix portion of the trigger id — usually an NSID (= a lexicon id), * or the literal `_actor` when `kind === "labeler.apply"` for - * actor-level labels. + * actor-level labels. For jobs, this is the user-typed job type name. */ suffix: string; + /** + * Which source-selector value was chosen. Usually identical to `suffix` + * (i.e. a lexicon NSID or `_actor`). For jobs this is `_job` while + * `suffix` holds the free-form job type name. + */ + source: string; description: string; body: string; } @@ -51,9 +69,15 @@ body: string; }): ScriptFormState { const parsed = parseTriggerId(args.id); + const kind = parsed?.kind ?? "record.index"; + const suffix = parsed?.suffix ?? ""; + let source = suffix; + if (kind === "job.run") source = JOB_SOURCE; + else if (suffix === ACTOR_SUFFIX) source = ACTOR_SUFFIX; return { - kind: parsed?.kind ?? "record.index", - suffix: parsed?.suffix ?? "", + kind, + suffix, + source, description: args.description ?? "", body: args.body, }; @@ -93,9 +117,23 @@ { kind: "xrpc.procedure", label: "Procedure handler" }, ]; -function actionsFor(suffix: string, lexicons: LexiconSummary[]): ActionOption[] { - if (suffix === ACTOR_SUFFIX) return ACTOR_ACTIONS; - const lex = lexicons.find((l) => l.id === suffix); +const JOB_ACTIONS: ActionOption[] = [{ kind: "job.run", label: "Job runner" }]; + +const JOB_TYPE_PATTERN = /^[a-z0-9][a-z0-9._-]*$/; + +export function isValidJobType(value: string): boolean { + return ( + value.length > 0 && value.length <= 128 && JOB_TYPE_PATTERN.test(value) + ); +} + +function actionsFor( + source: string, + lexicons: LexiconSummary[], +): ActionOption[] { + if (source === ACTOR_SUFFIX) return ACTOR_ACTIONS; + if (source === JOB_SOURCE) return JOB_ACTIONS; + const lex = lexicons.find((l) => l.id === source); if (!lex) return []; switch (lex.lexicon_type) { case "record": @@ -129,12 +167,15 @@ onChange, idLocked, lexicons, + lexiconsLoading, }: { state: ScriptFormState; onChange: (next: ScriptFormState) => void; idLocked?: boolean; /** Required when `idLocked` is false; ignored otherwise. */ lexicons?: LexiconSummary[]; + /** True while the lexicon list is being fetched. */ + lexiconsLoading?: boolean; }) { return (
@@ -145,6 +186,7 @@ state={state} onChange={onChange} lexicons={lexicons ?? []} + lexiconsLoading={lexiconsLoading} /> )} @@ -193,19 +235,23 @@ state, onChange, lexicons, + lexiconsLoading, }: { state: ScriptFormState; onChange: (next: ScriptFormState) => void; lexicons: LexiconSummary[]; + lexiconsLoading?: boolean; }) { const sortedLexicons = useMemo( () => [...lexicons].sort((a, b) => a.id.localeCompare(b.id)), [lexicons], ); const actions = useMemo( - () => actionsFor(state.suffix, lexicons), - [state.suffix, lexicons], + () => actionsFor(state.source, lexicons), + [state.source, lexicons], ); + + const isJob = state.source === JOB_SOURCE; const stateRef = useRef(state); stateRef.current = state; @@ -218,14 +264,34 @@ } }, [actions, onChange]); - function handleSuffixChange(next: string) { - // Pre-snap kind so the resolved trigger id badge updates immediately - // rather than flickering through an invalid state. + function handleSourceChange(next: string) { + const wasJob = state.source === JOB_SOURCE; + const isNowJob = next === JOB_SOURCE; + const bodyIsDefault = + state.body === DEFAULT_SCRIPT_BODY || + state.body === DEFAULT_JOB_SCRIPT_BODY; + + if (isNowJob) { + onChange({ + ...state, + source: JOB_SOURCE, + suffix: "", + kind: "job.run", + body: bodyIsDefault ? DEFAULT_JOB_SCRIPT_BODY : state.body, + }); + return; + } const nextActions = actionsFor(next, lexicons); const nextKind = nextActions.some((a) => a.kind === state.kind) ? state.kind : (nextActions[0]?.kind ?? state.kind); - onChange({ ...state, suffix: next, kind: nextKind }); + onChange({ + ...state, + source: next, + suffix: next, + kind: nextKind, + body: wasJob && bodyIsDefault ? DEFAULT_SCRIPT_BODY : state.body, + }); } const triggerPreview = @@ -235,12 +301,12 @@ <>
-
- - + {isJob ? ( + <> + + onChange({ ...state, suffix: e.target.value })} + placeholder="e.g. export, migrate, sync" + className="h-8 text-sm font-mono" + aria-invalid={ + state.suffix.length > 0 && !isValidJobType(state.suffix) + } + /> + {state.suffix.length > 0 && !isValidJobType(state.suffix) ? ( +

+ Lowercase letters, numbers, dots, hyphens, and underscores + only. +

+ ) : ( +

+ Must match the type passed to{" "} + + jobs.create() + {" "} + in the queuing script. +

+ )} + + ) : ( + <> + + + + )}
@@ -304,7 +420,9 @@ {triggerPreview} ) : ( - Pick a lexicon to compose the trigger id. + {isJob + ? "Enter a job type to compose the trigger id." + : "Pick a source to compose the trigger id."} )}

diff --git a/web/src/app/dashboard/settings/scripts/[id]/script-detail.tsx b/web/src/app/dashboard/settings/scripts/[id]/script-detail.tsx --- a/web/src/app/dashboard/settings/scripts/[id]/script-detail.tsx +++ b/web/src/app/dashboard/settings/scripts/[id]/script-detail.tsx @@ -66,13 +66,21 @@ ); }, [state, original]); - async function handleSave() { - if (!state || !script) return; + useEffect(() => { + if (!isDirty) return; + function onBeforeUnload(e: BeforeUnloadEvent) { + e.preventDefault(); + e.returnValue = ""; + } + window.addEventListener("beforeunload", onBeforeUnload); + return () => window.removeEventListener("beforeunload", onBeforeUnload); + }, [isDirty]); + + const handleSave = useCallback(async () => { + if (!state || !script || !isDirty || saving) return; setSaving(true); setError(null); try { - // PATCH only the editable fields. Trigger id is the PK — to - // rename, delete and recreate. await patchScript(script.id, { body: state.body, description: state.description.trim() || null, @@ -83,7 +91,18 @@ } finally { setSaving(false); } - } + }, [state, script, isDirty, saving, load]); + + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + e.preventDefault(); + handleSave(); + } + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [handleSave]); async function handleDelete() { if (!script) return; @@ -126,6 +145,7 @@ record: "Record event", xrpc: "XRPC handler", labeler: "Label arrival", + job: "Job runner", }; return ( @@ -178,6 +198,9 @@ {canManage && ( )}
diff --git a/web/src/app/dashboard/settings/scripts/new/page.tsx b/web/src/app/dashboard/settings/scripts/new/page.tsx --- a/web/src/app/dashboard/settings/scripts/new/page.tsx +++ b/web/src/app/dashboard/settings/scripts/new/page.tsx @@ -1,20 +1,37 @@ "use client"; -import { Suspense, useEffect, useState } from "react"; +import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useCurrentUser } from "@/hooks/use-current-user"; import { getLexicons, upsertScript } from "@/lib/api"; import type { LexiconSummary } from "@/types/lexicons"; import type { TriggerKind } from "@/types/scripts"; -import { DEFAULT_SCRIPT_BODY, parseTriggerId } from "@/types/scripts"; +import { + DEFAULT_JOB_SCRIPT_BODY, + DEFAULT_SCRIPT_BODY, + parseTriggerId, +} from "@/types/scripts"; import { SiteHeader } from "@/components/site-header"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { + JOB_SOURCE, ScriptForm, type ScriptFormState, composeTriggerId, + isValidJobType, } from "../script-form"; function NewScriptInner() { @@ -28,6 +45,7 @@ // form even if the call fails (the operator can still pick "Actor" // and create a labeler.apply:_actor script). const [lexicons, setLexicons] = useState([]); + const [lexiconsLoading, setLexiconsLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); @@ -39,23 +57,37 @@ useEffect(() => { getLexicons() .then(setLexicons) - .catch(() => setLexicons([])); + .catch(() => setLexicons([])) + .finally(() => setLexiconsLoading(false)); }, []); - if (!hasPermission("scripts:manage")) { + const isDirty = useMemo(() => { + const defaultBody = + state.source === JOB_SOURCE ? DEFAULT_JOB_SCRIPT_BODY : DEFAULT_SCRIPT_BODY; return ( - <> - -
-

- You don't have permission to create scripts. -

-
- + state.suffix !== "" || + state.description !== "" || + state.body !== defaultBody ); - } + }, [state]); - async function handleSave() { + useEffect(() => { + if (!isDirty) return; + function onBeforeUnload(e: BeforeUnloadEvent) { + e.preventDefault(); + e.returnValue = ""; + } + window.addEventListener("beforeunload", onBeforeUnload); + return () => window.removeEventListener("beforeunload", onBeforeUnload); + }, [isDirty]); + + const canSave = + !saving && + !!state.suffix && + !(state.source === JOB_SOURCE && !isValidJobType(state.suffix)); + + const handleSave = useCallback(async () => { + if (!canSave) return; setSaving(true); setError(null); try { @@ -70,6 +102,30 @@ setError(e instanceof Error ? e.message : String(e)); setSaving(false); } + }, [canSave, state, router]); + + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + e.preventDefault(); + handleSave(); + } + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [handleSave]); + + if (!hasPermission("scripts:manage")) { + return ( + <> + +
+

+ You don't have permission to create scripts. +

+
+ + ); } return ( @@ -78,11 +134,48 @@
{error &&

{error}

} - +
-
- + + + + Discard changes? + + You have unsaved changes that will be lost. + + + + Keep editing + router.push("/dashboard/settings/scripts")} + > + Discard + + + + + ) : ( + + )} +
@@ -92,7 +185,11 @@ export default function NewScriptPage() { return ( - + + } + > ); @@ -105,21 +202,26 @@ if (presetId) { const parsed = parseTriggerId(presetId); if (parsed) { + const isJob = parsed.kind === "job.run"; return { kind: parsed.kind, suffix: parsed.suffix, + source: isJob ? JOB_SOURCE : parsed.suffix, description: "", - body: DEFAULT_SCRIPT_BODY, + body: isJob ? DEFAULT_JOB_SCRIPT_BODY : DEFAULT_SCRIPT_BODY, }; } } // Fallbacks to a sensible default. Suffix starts empty so the form - // surfaces the "Pick a lexicon to compose the trigger id" hint. + // surfaces the "Pick a source to compose the trigger id" hint. const kind = (searchParams.get("kind") as TriggerKind | null) ?? "record.index"; + const source = searchParams.get("source") ?? searchParams.get("suffix") ?? ""; + const isJob = kind === "job.run" || source === JOB_SOURCE; return { - kind, - suffix: searchParams.get("suffix") ?? "", + kind: isJob ? "job.run" : kind, + suffix: isJob ? "" : (searchParams.get("suffix") ?? ""), + source: isJob ? JOB_SOURCE : source, description: "", - body: DEFAULT_SCRIPT_BODY, + body: isJob ? DEFAULT_JOB_SCRIPT_BODY : DEFAULT_SCRIPT_BODY, }; }