diff --git a/.sqlx/query-0c73a5469315d589306c17a8f9f8291e50b4e4f10d90b195728862de03c7bd1d.json b/.sqlx/query-0c73a5469315d589306c17a8f9f8291e50b4e4f10d90b195728862de03c7bd1d.json new file mode 100644 index 0000000..515f7a9 --- /dev/null +++ b/.sqlx/query-0c73a5469315d589306c17a8f9f8291e50b4e4f10d90b195728862de03c7bd1d.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE ldraw_verification SET state = 'rejected', diagnostic = 'integrity-mismatch-v1', attempt_token = NULL, lease_expires_at = NULL, updated_at = ? WHERE resource_uri = ? AND source_identity = ? AND state = 'verifying' AND attempt_token = ? AND lease_expires_at > ?", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "0c73a5469315d589306c17a8f9f8291e50b4e4f10d90b195728862de03c7bd1d" +} diff --git a/.sqlx/query-12a25657d903219f5be9f92b4ed1fc596f3a4aee26020f9e67906762b4357d83.json b/.sqlx/query-12a25657d903219f5be9f92b4ed1fc596f3a4aee26020f9e67906762b4357d83.json new file mode 100644 index 0000000..b348d34 --- /dev/null +++ b/.sqlx/query-12a25657d903219f5be9f92b4ed1fc596f3a4aee26020f9e67906762b4357d83.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO ldraw_resources (resource_uri, owner_did, project_uri, record_cid, rkey, root, canonical_path, mime_type, byte_length, sha256, ordered_blob_cids, provenance, licence, notices_json, snapshot_id, legacy, revoked_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?) ON CONFLICT(resource_uri) DO UPDATE SET owner_did = excluded.owner_did, project_uri = excluded.project_uri, record_cid = excluded.record_cid, rkey = excluded.rkey, root = excluded.root, canonical_path = excluded.canonical_path, mime_type = excluded.mime_type, byte_length = excluded.byte_length, sha256 = excluded.sha256, ordered_blob_cids = excluded.ordered_blob_cids, provenance = excluded.provenance, licence = excluded.licence, notices_json = excluded.notices_json, snapshot_id = excluded.snapshot_id, legacy = 0, revoked_at = NULL, updated_at = excluded.updated_at", + "describe": { + "columns": [], + "parameters": { + "Right": 17 + }, + "nullable": [] + }, + "hash": "12a25657d903219f5be9f92b4ed1fc596f3a4aee26020f9e67906762b4357d83" +} diff --git a/.sqlx/query-1678332bd6e2b3bc3d15a5f0a45b3b8b4cc15149b143b96509d3af4b02291115.json b/.sqlx/query-1678332bd6e2b3bc3d15a5f0a45b3b8b4cc15149b143b96509d3af4b02291115.json new file mode 100644 index 0000000..1f93dcc --- /dev/null +++ b/.sqlx/query-1678332bd6e2b3bc3d15a5f0a45b3b8b4cc15149b143b96509d3af4b02291115.json @@ -0,0 +1,98 @@ +{ + "db_name": "SQLite", + "query": "SELECT principal_did, project_uri, resource_uri, source_identity, root, canonical_path, target_identity FROM ldraw_routes WHERE token_digest = ? AND revoked_at IS NULL AND expires_at > ?", + "describe": { + "columns": [ + { + "name": "principal_did", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_routes", + "name": "principal_did" + } + } + }, + { + "name": "project_uri", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_routes", + "name": "project_uri" + } + } + }, + { + "name": "resource_uri", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_routes", + "name": "resource_uri" + } + } + }, + { + "name": "source_identity", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_routes", + "name": "source_identity" + } + } + }, + { + "name": "root", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_routes", + "name": "root" + } + } + }, + { + "name": "canonical_path", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_routes", + "name": "canonical_path" + } + } + }, + { + "name": "target_identity", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_routes", + "name": "target_identity" + } + } + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + true, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "1678332bd6e2b3bc3d15a5f0a45b3b8b4cc15149b143b96509d3af4b02291115" +} diff --git a/.sqlx/query-1770125c2a78c4541fc0285cc6ad8961c210daa215e7daecc96ec0ed099303c9.json b/.sqlx/query-1770125c2a78c4541fc0285cc6ad8961c210daa215e7daecc96ec0ed099303c9.json new file mode 100644 index 0000000..e11fbe7 --- /dev/null +++ b/.sqlx/query-1770125c2a78c4541fc0285cc6ad8961c210daa215e7daecc96ec0ed099303c9.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM ldraw_verification WHERE resource_uri = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "1770125c2a78c4541fc0285cc6ad8961c210daa215e7daecc96ec0ed099303c9" +} diff --git a/.sqlx/query-272bebe0a71065b578e43eb6ea762c786dc120d94affe5a89c9348b6c8088d7e.json b/.sqlx/query-272bebe0a71065b578e43eb6ea762c786dc120d94affe5a89c9348b6c8088d7e.json new file mode 100644 index 0000000..e1d0050 --- /dev/null +++ b/.sqlx/query-272bebe0a71065b578e43eb6ea762c786dc120d94affe5a89c9348b6c8088d7e.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT source_identity FROM ldraw_verification WHERE resource_uri = ?", + "describe": { + "columns": [ + { + "name": "source_identity", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_verification", + "name": "source_identity" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "272bebe0a71065b578e43eb6ea762c786dc120d94affe5a89c9348b6c8088d7e" +} diff --git a/.sqlx/query-3d262dca6464811f937abcdec25c40a422c78da75ffd632c6f4466277fde1326.json b/.sqlx/query-3d262dca6464811f937abcdec25c40a422c78da75ffd632c6f4466277fde1326.json new file mode 100644 index 0000000..1b57cfb --- /dev/null +++ b/.sqlx/query-3d262dca6464811f937abcdec25c40a422c78da75ffd632c6f4466277fde1326.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE ldraw_verification SET state = 'verifying', attempt_token = ?, lease_expires_at = ?, expired_attempts = expired_attempts + CASE WHEN state = 'verifying' AND (lease_expires_at IS NULL OR lease_expires_at <= ?) THEN 1 ELSE 0 END, updated_at = ? WHERE resource_uri = ? AND source_identity = ? AND expired_attempts < ? AND (state = 'unverified' OR (state = 'verifying' AND (lease_expires_at IS NULL OR lease_expires_at <= ?)))", + "describe": { + "columns": [], + "parameters": { + "Right": 8 + }, + "nullable": [] + }, + "hash": "3d262dca6464811f937abcdec25c40a422c78da75ffd632c6f4466277fde1326" +} diff --git a/.sqlx/query-41f9fc023947387af3cfbf1fc38d8bbe9c31409a93f67fe9505885fb2906b306.json b/.sqlx/query-41f9fc023947387af3cfbf1fc38d8bbe9c31409a93f67fe9505885fb2906b306.json new file mode 100644 index 0000000..410870a --- /dev/null +++ b/.sqlx/query-41f9fc023947387af3cfbf1fc38d8bbe9c31409a93f67fe9505885fb2906b306.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM ldraw_manifest_files WHERE resource_uri = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "41f9fc023947387af3cfbf1fc38d8bbe9c31409a93f67fe9505885fb2906b306" +} diff --git a/.sqlx/query-44dcf63108ea15a5eb476ea93af8d8ea027f90cccfec9831f4365fb788bcf572.json b/.sqlx/query-44dcf63108ea15a5eb476ea93af8d8ea027f90cccfec9831f4365fb788bcf572.json new file mode 100644 index 0000000..775009a --- /dev/null +++ b/.sqlx/query-44dcf63108ea15a5eb476ea93af8d8ea027f90cccfec9831f4365fb788bcf572.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE ldraw_verification SET state = 'rejected', diagnostic = ?, attempt_token = NULL, lease_expires_at = NULL, updated_at = ? WHERE resource_uri = ? AND source_identity = ? AND state = 'verifying' AND attempt_token = ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "44dcf63108ea15a5eb476ea93af8d8ea027f90cccfec9831f4365fb788bcf572" +} diff --git a/.sqlx/query-5844b01e7fcf3c20487bc56e87f67253c8f2fae989e207eb90deaeac29d3aba7.json b/.sqlx/query-5844b01e7fcf3c20487bc56e87f67253c8f2fae989e207eb90deaeac29d3aba7.json new file mode 100644 index 0000000..ae1999a --- /dev/null +++ b/.sqlx/query-5844b01e7fcf3c20487bc56e87f67253c8f2fae989e207eb90deaeac29d3aba7.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "SELECT state, attempt_token, lease_expires_at, expired_attempts, revoked_at FROM ldraw_verification WHERE resource_uri = ?", + "describe": { + "columns": [ + { + "name": "state", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_verification", + "name": "state" + } + } + }, + { + "name": "attempt_token", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_verification", + "name": "attempt_token" + } + } + }, + { + "name": "lease_expires_at", + "ordinal": 2, + "type_info": "Integer", + "origin": { + "Table": { + "table": "ldraw_verification", + "name": "lease_expires_at" + } + } + }, + { + "name": "expired_attempts", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "ldraw_verification", + "name": "expired_attempts" + } + } + }, + { + "name": "revoked_at", + "ordinal": 4, + "type_info": "Integer", + "origin": { + "Table": { + "table": "ldraw_verification", + "name": "revoked_at" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + true, + false, + true + ] + }, + "hash": "5844b01e7fcf3c20487bc56e87f67253c8f2fae989e207eb90deaeac29d3aba7" +} diff --git a/.sqlx/query-5d544dae914a0c4b37c629d6e21c2a5a075703f9c7c20f7c6b01503eddb7ad3d.json b/.sqlx/query-5d544dae914a0c4b37c629d6e21c2a5a075703f9c7c20f7c6b01503eddb7ad3d.json new file mode 100644 index 0000000..ae8d051 --- /dev/null +++ b/.sqlx/query-5d544dae914a0c4b37c629d6e21c2a5a075703f9c7c20f7c6b01503eddb7ad3d.json @@ -0,0 +1,110 @@ +{ + "db_name": "SQLite", + "query": "SELECT resource_uri, owner_did, sha256, ordered_blob_cids, root, canonical_path, mime_type, byte_length\n FROM ldraw_resources\n WHERE project_uri = ? AND revoked_at IS NULL\n AND (owner_did = ? OR owner_did = 'public')", + "describe": { + "columns": [ + { + "name": "resource_uri", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_resources", + "name": "resource_uri" + } + } + }, + { + "name": "owner_did", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_resources", + "name": "owner_did" + } + } + }, + { + "name": "sha256", + "ordinal": 2, + "type_info": "Blob", + "origin": { + "Table": { + "table": "ldraw_resources", + "name": "sha256" + } + } + }, + { + "name": "ordered_blob_cids", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_resources", + "name": "ordered_blob_cids" + } + } + }, + { + "name": "root", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_resources", + "name": "root" + } + } + }, + { + "name": "canonical_path", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_resources", + "name": "canonical_path" + } + } + }, + { + "name": "mime_type", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_resources", + "name": "mime_type" + } + } + }, + { + "name": "byte_length", + "ordinal": 7, + "type_info": "Integer", + "origin": { + "Table": { + "table": "ldraw_resources", + "name": "byte_length" + } + } + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + true, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "5d544dae914a0c4b37c629d6e21c2a5a075703f9c7c20f7c6b01503eddb7ad3d" +} diff --git a/.sqlx/query-6060ba495ee11bc99e7413a63d3b84bb1a01d4d3c2cd0255dff055cfc96a2988.json b/.sqlx/query-6060ba495ee11bc99e7413a63d3b84bb1a01d4d3c2cd0255dff055cfc96a2988.json new file mode 100644 index 0000000..740ab26 --- /dev/null +++ b/.sqlx/query-6060ba495ee11bc99e7413a63d3b84bb1a01d4d3c2cd0255dff055cfc96a2988.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO ldraw_manifest_files (resource_uri, root, canonical_path, target_resource_uri, target_cid, byte_length, sha256, mime_type, ordinal) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 9 + }, + "nullable": [] + }, + "hash": "6060ba495ee11bc99e7413a63d3b84bb1a01d4d3c2cd0255dff055cfc96a2988" +} diff --git a/.sqlx/query-6c4e932e52b026c5b2ec8e4eab38baa9ea3e461c6efd74e4eaad7c9c7f30ab58.json b/.sqlx/query-6c4e932e52b026c5b2ec8e4eab38baa9ea3e461c6efd74e4eaad7c9c7f30ab58.json new file mode 100644 index 0000000..33352e6 --- /dev/null +++ b/.sqlx/query-6c4e932e52b026c5b2ec8e4eab38baa9ea3e461c6efd74e4eaad7c9c7f30ab58.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM ldraw_routes WHERE resource_uri = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "6c4e932e52b026c5b2ec8e4eab38baa9ea3e461c6efd74e4eaad7c9c7f30ab58" +} diff --git a/.sqlx/query-7403588ae3949498232f7481cbca588775a2b241ceb79a297bdc6fab569a9632.json b/.sqlx/query-7403588ae3949498232f7481cbca588775a2b241ceb79a297bdc6fab569a9632.json new file mode 100644 index 0000000..09fcc8c --- /dev/null +++ b/.sqlx/query-7403588ae3949498232f7481cbca588775a2b241ceb79a297bdc6fab569a9632.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO ldraw_routes (token_digest, principal_did, project_uri, resource_uri, source_identity, root, canonical_path, target_identity, expires_at, revoked_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 10 + }, + "nullable": [] + }, + "hash": "7403588ae3949498232f7481cbca588775a2b241ceb79a297bdc6fab569a9632" +} diff --git a/.sqlx/query-814fc55f4acca61fd8a2d38aaf0a6bc5b5e293e199b4803a86f970f74a8f0e87.json b/.sqlx/query-814fc55f4acca61fd8a2d38aaf0a6bc5b5e293e199b4803a86f970f74a8f0e87.json new file mode 100644 index 0000000..e0c11d3 --- /dev/null +++ b/.sqlx/query-814fc55f4acca61fd8a2d38aaf0a6bc5b5e293e199b4803a86f970f74a8f0e87.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE ldraw_verification SET state = 'verified', attempt_token = NULL, lease_expires_at = NULL, diagnostic = NULL, updated_at = ? WHERE resource_uri = ? AND source_identity = ? AND state = 'verifying' AND attempt_token = ? AND lease_expires_at > ?", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "814fc55f4acca61fd8a2d38aaf0a6bc5b5e293e199b4803a86f970f74a8f0e87" +} diff --git a/.sqlx/query-99c8471612e0ce58357a8e6b1ac562f14478cab8f7ef551cd78dd46707c97975.json b/.sqlx/query-99c8471612e0ce58357a8e6b1ac562f14478cab8f7ef551cd78dd46707c97975.json new file mode 100644 index 0000000..f6e12b2 --- /dev/null +++ b/.sqlx/query-99c8471612e0ce58357a8e6b1ac562f14478cab8f7ef551cd78dd46707c97975.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM ldraw_resources WHERE resource_uri = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "99c8471612e0ce58357a8e6b1ac562f14478cab8f7ef551cd78dd46707c97975" +} diff --git a/.sqlx/query-b242c6c0aea579b653e91c6ef026097c717024ca657ece51c39f63beb547858d.json b/.sqlx/query-b242c6c0aea579b653e91c6ef026097c717024ca657ece51c39f63beb547858d.json new file mode 100644 index 0000000..df3c65d --- /dev/null +++ b/.sqlx/query-b242c6c0aea579b653e91c6ef026097c717024ca657ece51c39f63beb547858d.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "SELECT state, revoked_at FROM ldraw_verification WHERE resource_uri = ?", + "describe": { + "columns": [ + { + "name": "state", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "ldraw_verification", + "name": "state" + } + } + }, + { + "name": "revoked_at", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "ldraw_verification", + "name": "revoked_at" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true + ] + }, + "hash": "b242c6c0aea579b653e91c6ef026097c717024ca657ece51c39f63beb547858d" +} diff --git a/.sqlx/query-d1c2c011883690c87716026ff79e30478016d095b01a369a93efe539c1236df8.json b/.sqlx/query-d1c2c011883690c87716026ff79e30478016d095b01a369a93efe539c1236df8.json new file mode 100644 index 0000000..2dcee4a --- /dev/null +++ b/.sqlx/query-d1c2c011883690c87716026ff79e30478016d095b01a369a93efe539c1236df8.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO ldraw_verification (resource_uri, source_identity, state, attempt_token, lease_expires_at, expired_attempts, diagnostic, revoked_at, updated_at) VALUES (?, ?, 'unverified', NULL, NULL, 0, NULL, NULL, ?) ON CONFLICT(resource_uri) DO UPDATE SET source_identity = excluded.source_identity, state = CASE WHEN ldraw_verification.source_identity = excluded.source_identity THEN ldraw_verification.state ELSE 'unverified' END, attempt_token = CASE WHEN ldraw_verification.source_identity = excluded.source_identity THEN ldraw_verification.attempt_token ELSE NULL END, lease_expires_at = CASE WHEN ldraw_verification.source_identity = excluded.source_identity THEN ldraw_verification.lease_expires_at ELSE NULL END, expired_attempts = CASE WHEN ldraw_verification.source_identity = excluded.source_identity THEN ldraw_verification.expired_attempts ELSE 0 END, diagnostic = CASE WHEN ldraw_verification.source_identity = excluded.source_identity THEN ldraw_verification.diagnostic ELSE NULL END, revoked_at = NULL, updated_at = excluded.updated_at", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "d1c2c011883690c87716026ff79e30478016d095b01a369a93efe539c1236df8" +} diff --git a/Cargo.lock b/Cargo.lock index 6cd4c4f..874d156 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6333,6 +6333,7 @@ name = "polymodel" version = "0.1.0" dependencies = [ "anyhow", + "async-trait", "axum", "axum-extra", "base64 0.22.1", @@ -6372,6 +6373,7 @@ dependencies = [ "tracing-subscriber", "tracing-wasm", "ulid", + "unicode-normalization", "urlencoding", "wasm-bindgen", "wasm-bindgen-futures", diff --git a/Cargo.toml b/Cargo.toml index a07ef9c..410fbd3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,6 +79,8 @@ polymodel-renderer-protocol = { path = "crates/polymodel-renderer-protocol" } serde = { workspace = true } serde_json = "1.0" thiserror = { workspace = true } +async-trait = "0.1" +unicode-normalization = "0.1" urlencoding = "2" tracing = { version = "0.1", default-features = false, features = ["std"] } diff --git a/crates/polymodel-api/lexicons/app_bsky_actor_defs.json b/crates/polymodel-api/lexicons/app_bsky_actor_defs.json index 8f3bf46..e6c8d91 100644 --- a/crates/polymodel-api/lexicons/app_bsky_actor_defs.json +++ b/crates/polymodel-api/lexicons/app_bsky_actor_defs.json @@ -972,11 +972,20 @@ "ref": "#knownFollowers" }, "muted": { - "type": "boolean" + "type": "boolean", + "description": "Whether the account is fully muted, directly or via a mutelist. False when the mute is scoped to specific kinds; see mutedOnlyReposts and mutedOnlyQuoteposts." }, "mutedByList": { "type": "ref", "ref": "app.bsky.graph.defs#listViewBasic" + }, + "mutedOnlyQuoteposts": { + "type": "boolean", + "description": "Whether the account's quote posts are muted. Scoped mutes are exclusive with muted: this can be true while muted is false. If muted is true, this will be false." + }, + "mutedOnlyReposts": { + "type": "boolean", + "description": "Whether the account's reposts are muted. Scoped mutes are exclusive with muted: this can be true while muted is false. If muted is true, this will be false." } } } diff --git a/crates/polymodel-api/lexicons/com_atproto_server_describeServer.json b/crates/polymodel-api/lexicons/com_atproto_server_describeServer.json index 36343a9..0f11009 100644 --- a/crates/polymodel-api/lexicons/com_atproto_server_describeServer.json +++ b/crates/polymodel-api/lexicons/com_atproto_server_describeServer.json @@ -42,6 +42,10 @@ "type": "string" } }, + "blobUploadLimit": { + "type": "integer", + "description": "Maximum size of a blob that can be uploaded via com.atproto.repo.uploadBlob, in bytes." + }, "contact": { "type": "ref", "description": "Contact information", diff --git a/crates/polymodel-api/lexicons/space_polymodel_library_ldrawManifest.json b/crates/polymodel-api/lexicons/space_polymodel_library_ldrawManifest.json new file mode 100644 index 0000000..b57e89c --- /dev/null +++ b/crates/polymodel-api/lexicons/space_polymodel_library_ldrawManifest.json @@ -0,0 +1,93 @@ +{ + "lexicon": 1, + "id": "space.polymodel.library.ldrawManifest", + "defs": { + "file": { + "type": "object", + "required": [ + "path", + "root", + "size", + "sha256", + "mimeType", + "chunks" + ], + "properties": { + "chunks": { + "type": "array", + "items": { + "type": "blob" + }, + "maxLength": 4096 + }, + "mimeType": { + "type": "string", + "maxLength": 256 + }, + "path": { + "type": "string", + "maxLength": 4096, + "maxGraphemes": 1024 + }, + "root": { + "type": "string", + "maxLength": 64 + }, + "sha256": { + "type": "bytes", + "maxLength": 32, + "minLength": 32 + }, + "size": { + "type": "integer", + "minimum": 0 + } + } + }, + "main": { + "type": "record", + "description": "A manifest describing canonical LDraw virtual files and their dependency roots.", + "key": "tid", + "record": { + "type": "object", + "required": [ + "project", + "files", + "license", + "notices", + "createdAt" + ], + "properties": { + "createdAt": { + "type": "string", + "format": "datetime" + }, + "files": { + "type": "array", + "items": { + "type": "ref", + "ref": "#file" + }, + "maxLength": 16384 + }, + "license": { + "type": "string", + "maxLength": 512 + }, + "notices": { + "type": "array", + "items": { + "type": "string", + "maxLength": 4096 + }, + "maxLength": 64 + }, + "project": { + "type": "string", + "format": "at-uri" + } + } + } + } + } +} \ No newline at end of file diff --git a/crates/polymodel-api/lexicons/space_polymodel_library_ldrawResource.json b/crates/polymodel-api/lexicons/space_polymodel_library_ldrawResource.json new file mode 100644 index 0000000..d62f09d --- /dev/null +++ b/crates/polymodel-api/lexicons/space_polymodel_library_ldrawResource.json @@ -0,0 +1,89 @@ +{ + "lexicon": 1, + "id": "space.polymodel.library.ldrawResource", + "defs": { + "main": { + "type": "record", + "description": "A verified, server-indexed LDraw virtual resource. Paths are filenames, never network URLs.", + "key": "tid", + "record": { + "type": "object", + "required": [ + "project", + "root", + "path", + "mimeType", + "size", + "sha256", + "chunks", + "license", + "notices", + "createdAt" + ], + "properties": { + "chunks": { + "type": "array", + "items": { + "type": "blob" + }, + "maxLength": 4096 + }, + "createdAt": { + "type": "string", + "format": "datetime" + }, + "license": { + "type": "string", + "maxLength": 512 + }, + "mimeType": { + "type": "string", + "maxLength": 256 + }, + "notices": { + "type": "array", + "items": { + "type": "string", + "maxLength": 4096 + }, + "maxLength": 64 + }, + "path": { + "type": "string", + "maxLength": 4096, + "maxGraphemes": 1024 + }, + "project": { + "type": "string", + "format": "at-uri" + }, + "root": { + "type": "string", + "knownValues": [ + "mpd", + "manifest", + "models", + "parts", + "p", + "official-parts", + "official-p" + ] + }, + "sha256": { + "type": "bytes", + "maxLength": 32, + "minLength": 32 + }, + "size": { + "type": "integer", + "minimum": 0 + }, + "snapshot": { + "type": "string", + "maxLength": 256 + } + } + } + } + } +} \ No newline at end of file diff --git a/crates/polymodel-api/lexicons/space_polymodel_library_mintLdrawResource.json b/crates/polymodel-api/lexicons/space_polymodel_library_mintLdrawResource.json new file mode 100644 index 0000000..717d58a --- /dev/null +++ b/crates/polymodel-api/lexicons/space_polymodel_library_mintLdrawResource.json @@ -0,0 +1,50 @@ +{ + "lexicon": 1, + "id": "space.polymodel.library.mintLdrawResource", + "defs": { + "main": { + "type": "procedure", + "description": "Mint a stateful, authorization-bound opaque route for one canonical LDraw filename.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "project", + "path" + ], + "properties": { + "path": { + "type": "string", + "maxLength": 4096 + }, + "project": { + "type": "string", + "format": "at-uri" + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "token", + "expiresAt" + ], + "properties": { + "expiresAt": { + "type": "string", + "format": "datetime" + }, + "token": { + "type": "string", + "maxLength": 256 + } + } + } + } + } + } +} \ No newline at end of file diff --git a/crates/polymodel-api/src/app_bsky/actor.rs b/crates/polymodel-api/src/app_bsky/actor.rs index ad63630..d592567 100644 --- a/crates/polymodel-api/src/app_bsky/actor.rs +++ b/crates/polymodel-api/src/app_bsky/actor.rs @@ -1680,10 +1680,17 @@ pub struct ViewerState { ///This property is present only in selected cases, as an optimization. #[serde(skip_serializing_if = "Option::is_none")] pub known_followers: Option>, + ///Whether the account is fully muted, directly or via a mutelist. False when the mute is scoped to specific kinds; see mutedOnlyReposts and mutedOnlyQuoteposts. #[serde(skip_serializing_if = "Option::is_none")] pub muted: Option, #[serde(skip_serializing_if = "Option::is_none")] pub muted_by_list: Option>, + ///Whether the account's quote posts are muted. Scoped mutes are exclusive with muted: this can be true while muted is false. If muted is true, this will be false. + #[serde(skip_serializing_if = "Option::is_none")] + pub muted_only_quoteposts: Option, + ///Whether the account's reposts are muted. Scoped mutes are exclusive with muted: this can be true while muted is false. If muted is true, this will be false. + #[serde(skip_serializing_if = "Option::is_none")] + pub muted_only_reposts: Option, #[serde(flatten, default, skip_serializing_if = "Option::is_none")] pub extra_data: Option>>, } @@ -4248,6 +4255,18 @@ fn lexicon_doc_app_bsky_actor_defs() -> LexiconDoc<'static> { ..Default::default() }), ); + map.insert( + SmolStr::new_static("mutedOnlyQuoteposts"), + LexObjectProperty::Boolean(LexBoolean { + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("mutedOnlyReposts"), + LexObjectProperty::Boolean(LexBoolean { + ..Default::default() + }), + ); map }, ..Default::default() diff --git a/crates/polymodel-api/src/com_atproto/server/describe_server.rs b/crates/polymodel-api/src/com_atproto/server/describe_server.rs index 4d11a5a..313ab87 100644 --- a/crates/polymodel-api/src/com_atproto/server/describe_server.rs +++ b/crates/polymodel-api/src/com_atproto/server/describe_server.rs @@ -53,6 +53,9 @@ pub struct Links { pub struct DescribeServerOutput { ///List of domain suffixes that can be used in account handles. pub available_user_domains: Vec, + ///Maximum size of a blob that can be uploaded via com.atproto.repo.uploadBlob, in bytes. + #[serde(skip_serializing_if = "Option::is_none")] + pub blob_upload_limit: Option, ///Contact information #[serde(skip_serializing_if = "Option::is_none")] pub contact: Option>, diff --git a/crates/polymodel-api/src/space_polymodel/library.rs b/crates/polymodel-api/src/space_polymodel/library.rs index 7727f44..8f601f1 100644 --- a/crates/polymodel-api/src/space_polymodel/library.rs +++ b/crates/polymodel-api/src/space_polymodel/library.rs @@ -12,6 +12,9 @@ pub mod get_feed; pub mod get_model; pub mod get_part_file; pub mod get_thing; +pub mod ldraw_manifest; +pub mod ldraw_resource; +pub mod mint_ldraw_resource; pub mod model; pub mod part; pub mod publish_thing; diff --git a/crates/polymodel-api/src/space_polymodel/library/ldraw_manifest.rs b/crates/polymodel-api/src/space_polymodel/library/ldraw_manifest.rs new file mode 100644 index 0000000..d6db138 --- /dev/null +++ b/crates/polymodel-api/src/space_polymodel/library/ldraw_manifest.rs @@ -0,0 +1,988 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: space.polymodel.library.ldrawManifest +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[allow(unused_imports)] +use alloc::collections::BTreeMap; + +#[allow(unused_imports)] +use core::marker::PhantomData; +use jacquard_common::{CowStr, BosStr, DefaultStr, FromStaticStr}; +use jacquard_common::deps::bytes::Bytes; + +#[allow(unused_imports)] +use jacquard_common::deps::codegen::unicode_segmentation::UnicodeSegmentation; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::blob::BlobRef; +use jacquard_common::types::collection::{Collection, RecordError}; +use jacquard_common::types::string::{AtUri, Cid, Datetime}; +use jacquard_common::types::uri::{RecordUri, UriError}; +use jacquard_common::types::value::Data; +use jacquard_common::xrpc::XrpcResp; +use jacquard_derive::{IntoStatic, lexicon}; +use jacquard_lexicon::lexicon::LexiconDoc; +use jacquard_lexicon::schema::LexiconSchema; + +#[allow(unused_imports)] +use jacquard_lexicon::validation::{ConstraintError, ValidationPath}; +use serde::{Serialize, Deserialize}; +use crate::space_polymodel::library::ldraw_manifest; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct File { + pub chunks: Vec>, + pub mime_type: S, + pub path: S, + pub root: S, + #[serde(with = "jacquard_common::serde_bytes_helper")] + pub sha256: Bytes, + pub size: i64, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + +/// A manifest describing canonical LDraw virtual files and their dependency roots. + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde( + rename_all = "camelCase", + rename = "space.polymodel.library.ldrawManifest", + tag = "$type", + bound(deserialize = "S: Deserialize<'de> + BosStr") +)] +pub struct LdrawManifest { + pub created_at: Datetime, + pub files: Vec>, + pub license: S, + pub notices: Vec, + pub project: AtUri, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + +/// Typed wrapper for GetRecord response with this collection's record type. + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase")] +pub struct LdrawManifestGetRecordOutput { + #[serde(skip_serializing_if = "Option::is_none")] + pub cid: Option>, + pub uri: AtUri, + pub value: LdrawManifest, +} + +impl LdrawManifest { + pub fn uri(uri: S) -> Result, UriError> { + RecordUri::try_from_uri(AtUri::new(uri)?) + } +} + +impl LexiconSchema for File { + fn nsid() -> &'static str { + "space.polymodel.library.ldrawManifest" + } + fn def_name() -> &'static str { + "file" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_space_polymodel_library_ldrawManifest() + } + fn validate(&self) -> Result<(), ConstraintError> { + { + let value = &self.chunks; + #[allow(unused_comparisons)] + if value.len() > 4096usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("chunks"), + max: 4096usize, + actual: value.len(), + }); + } + } + { + let value = &self.mime_type; + #[allow(unused_comparisons)] + if ::len(value.as_ref()) > 256usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("mime_type"), + max: 256usize, + actual: ::len(value.as_ref()), + }); + } + } + { + let value = &self.path; + #[allow(unused_comparisons)] + if ::len(value.as_ref()) > 4096usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("path"), + max: 4096usize, + actual: ::len(value.as_ref()), + }); + } + } + { + let value = &self.path; + { + let count = UnicodeSegmentation::graphemes(value.as_ref(), true).count(); + if count > 1024usize { + return Err(ConstraintError::MaxGraphemes { + path: ValidationPath::from_field("path"), + max: 1024usize, + actual: count, + }); + } + } + } + { + let value = &self.root; + #[allow(unused_comparisons)] + if ::len(value.as_ref()) > 64usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("root"), + max: 64usize, + actual: ::len(value.as_ref()), + }); + } + } + { + let value = &self.size; + if *value < 0i64 { + return Err(ConstraintError::Minimum { + path: ValidationPath::from_field("size"), + min: 0i64, + actual: *value, + }); + } + } + Ok(()) + } +} + +/// Marker type for deserializing records from this collection. + +#[derive(Debug, Serialize, Deserialize)] +pub struct LdrawManifestRecord; +impl XrpcResp for LdrawManifestRecord { + const NSID: &'static str = "space.polymodel.library.ldrawManifest"; + const ENCODING: &'static str = "application/json"; + type Output = LdrawManifestGetRecordOutput; + type Err = RecordError; +} + +impl From> for LdrawManifest { + fn from(output: LdrawManifestGetRecordOutput) -> Self { + output.value + } +} + +impl Collection for LdrawManifest { + const NSID: &'static str = "space.polymodel.library.ldrawManifest"; + type Record = LdrawManifestRecord; +} + +impl Collection for LdrawManifestRecord { + const NSID: &'static str = "space.polymodel.library.ldrawManifest"; + type Record = LdrawManifestRecord; +} + +impl LexiconSchema for LdrawManifest { + fn nsid() -> &'static str { + "space.polymodel.library.ldrawManifest" + } + fn def_name() -> &'static str { + "main" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_space_polymodel_library_ldrawManifest() + } + fn validate(&self) -> Result<(), ConstraintError> { + { + let value = &self.files; + #[allow(unused_comparisons)] + if value.len() > 16384usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("files"), + max: 16384usize, + actual: value.len(), + }); + } + } + { + let value = &self.license; + #[allow(unused_comparisons)] + if ::len(value.as_ref()) > 512usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("license"), + max: 512usize, + actual: ::len(value.as_ref()), + }); + } + } + { + let value = &self.notices; + #[allow(unused_comparisons)] + if value.len() > 64usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("notices"), + max: 64usize, + actual: value.len(), + }); + } + } + for value in &self.notices { + #[allow(unused_comparisons)] + if ::len(value.as_ref()) > 4096usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("notices"), + max: 4096usize, + actual: ::len(value.as_ref()), + }); + } + } + Ok(()) + } +} + +pub mod file_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type Chunks; + type MimeType; + type Path; + type Root; + type Sha256; + type Size; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type Chunks = Unset; + type MimeType = Unset; + type Path = Unset; + type Root = Unset; + type Sha256 = Unset; + type Size = Unset; + } + ///State transition - sets the `chunks` field to Set + pub struct SetChunks(PhantomData St>); + impl sealed::Sealed for SetChunks {} + impl State for SetChunks { + type Chunks = Set; + type MimeType = St::MimeType; + type Path = St::Path; + type Root = St::Root; + type Sha256 = St::Sha256; + type Size = St::Size; + } + ///State transition - sets the `mime_type` field to Set + pub struct SetMimeType(PhantomData St>); + impl sealed::Sealed for SetMimeType {} + impl State for SetMimeType { + type Chunks = St::Chunks; + type MimeType = Set; + type Path = St::Path; + type Root = St::Root; + type Sha256 = St::Sha256; + type Size = St::Size; + } + ///State transition - sets the `path` field to Set + pub struct SetPath(PhantomData St>); + impl sealed::Sealed for SetPath {} + impl State for SetPath { + type Chunks = St::Chunks; + type MimeType = St::MimeType; + type Path = Set; + type Root = St::Root; + type Sha256 = St::Sha256; + type Size = St::Size; + } + ///State transition - sets the `root` field to Set + pub struct SetRoot(PhantomData St>); + impl sealed::Sealed for SetRoot {} + impl State for SetRoot { + type Chunks = St::Chunks; + type MimeType = St::MimeType; + type Path = St::Path; + type Root = Set; + type Sha256 = St::Sha256; + type Size = St::Size; + } + ///State transition - sets the `sha256` field to Set + pub struct SetSha256(PhantomData St>); + impl sealed::Sealed for SetSha256 {} + impl State for SetSha256 { + type Chunks = St::Chunks; + type MimeType = St::MimeType; + type Path = St::Path; + type Root = St::Root; + type Sha256 = Set; + type Size = St::Size; + } + ///State transition - sets the `size` field to Set + pub struct SetSize(PhantomData St>); + impl sealed::Sealed for SetSize {} + impl State for SetSize { + type Chunks = St::Chunks; + type MimeType = St::MimeType; + type Path = St::Path; + type Root = St::Root; + type Sha256 = St::Sha256; + type Size = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `chunks` field + pub struct chunks(()); + ///Marker type for the `mime_type` field + pub struct mime_type(()); + ///Marker type for the `path` field + pub struct path(()); + ///Marker type for the `root` field + pub struct root(()); + ///Marker type for the `sha256` field + pub struct sha256(()); + ///Marker type for the `size` field + pub struct size(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct FileBuilder { + _state: PhantomData St>, + _fields: ( + Option>>, + Option, + Option, + Option, + Option, + Option, + ), + _type: PhantomData S>, +} + +impl File { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> FileBuilder { + FileBuilder::new() + } +} + +impl File { + /// Create a new builder for this type + pub fn builder() -> FileBuilder { + FileBuilder::builder() + } +} + +impl FileBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + FileBuilder { + _state: PhantomData, + _fields: (None, None, None, None, None, None), + _type: PhantomData, + } + } +} + +impl FileBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + FileBuilder { + _state: PhantomData, + _fields: (None, None, None, None, None, None), + _type: PhantomData, + } + } +} + +impl FileBuilder +where + St: file_state::State, + St::Chunks: file_state::IsUnset, +{ + /// Set the `chunks` field (required) + pub fn chunks( + mut self, + value: impl Into>>, + ) -> FileBuilder, S> { + self._fields.0 = Option::Some(value.into()); + FileBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl FileBuilder +where + St: file_state::State, + St::MimeType: file_state::IsUnset, +{ + /// Set the `mimeType` field (required) + pub fn mime_type( + mut self, + value: impl Into, + ) -> FileBuilder, S> { + self._fields.1 = Option::Some(value.into()); + FileBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl FileBuilder +where + St: file_state::State, + St::Path: file_state::IsUnset, +{ + /// Set the `path` field (required) + pub fn path( + mut self, + value: impl Into, + ) -> FileBuilder, S> { + self._fields.2 = Option::Some(value.into()); + FileBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl FileBuilder +where + St: file_state::State, + St::Root: file_state::IsUnset, +{ + /// Set the `root` field (required) + pub fn root( + mut self, + value: impl Into, + ) -> FileBuilder, S> { + self._fields.3 = Option::Some(value.into()); + FileBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl FileBuilder +where + St: file_state::State, + St::Sha256: file_state::IsUnset, +{ + /// Set the `sha256` field (required) + pub fn sha256( + mut self, + value: impl Into, + ) -> FileBuilder, S> { + self._fields.4 = Option::Some(value.into()); + FileBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl FileBuilder +where + St: file_state::State, + St::Size: file_state::IsUnset, +{ + /// Set the `size` field (required) + pub fn size( + mut self, + value: impl Into, + ) -> FileBuilder, S> { + self._fields.5 = Option::Some(value.into()); + FileBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl FileBuilder +where + St: file_state::State, + St::Chunks: file_state::IsSet, + St::MimeType: file_state::IsSet, + St::Path: file_state::IsSet, + St::Root: file_state::IsSet, + St::Sha256: file_state::IsSet, + St::Size: file_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> File { + File { + chunks: self._fields.0.unwrap(), + mime_type: self._fields.1.unwrap(), + path: self._fields.2.unwrap(), + root: self._fields.3.unwrap(), + sha256: self._fields.4.unwrap(), + size: self._fields.5.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data(self, extra_data: BTreeMap>) -> File { + File { + chunks: self._fields.0.unwrap(), + mime_type: self._fields.1.unwrap(), + path: self._fields.2.unwrap(), + root: self._fields.3.unwrap(), + sha256: self._fields.4.unwrap(), + size: self._fields.5.unwrap(), + extra_data: Some(extra_data), + } + } +} + +fn lexicon_doc_space_polymodel_library_ldrawManifest() -> LexiconDoc<'static> { + #[allow(unused_imports)] + use jacquard_common::{CowStr, deps::smol_str::SmolStr, types::blob::MimeType}; + use jacquard_lexicon::lexicon::*; + use alloc::collections::BTreeMap; + LexiconDoc { + lexicon: Lexicon::Lexicon1, + id: CowStr::new_static("space.polymodel.library.ldrawManifest"), + defs: { + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("file"), + LexUserType::Object(LexObject { + required: Some( + vec![ + SmolStr::new_static("path"), SmolStr::new_static("root"), + SmolStr::new_static("size"), SmolStr::new_static("sha256"), + SmolStr::new_static("mimeType"), + SmolStr::new_static("chunks") + ], + ), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("chunks"), + LexObjectProperty::Array(LexArray { + items: LexArrayItem::Blob(LexBlob { ..Default::default() }), + max_length: Some(4096usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("mimeType"), + LexObjectProperty::String(LexString { + max_length: Some(256usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("path"), + LexObjectProperty::String(LexString { + max_length: Some(4096usize), + max_graphemes: Some(1024usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("root"), + LexObjectProperty::String(LexString { + max_length: Some(64usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("sha256"), + LexObjectProperty::Bytes(LexBytes { + max_length: Some(32usize), + min_length: Some(32usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("size"), + LexObjectProperty::Integer(LexInteger { + minimum: Some(0i64), + ..Default::default() + }), + ); + map + }, + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("main"), + LexUserType::Record(LexRecord { + description: Some( + CowStr::new_static( + "A manifest describing canonical LDraw virtual files and their dependency roots.", + ), + ), + key: Some(CowStr::new_static("tid")), + record: LexRecordRecord::Object(LexObject { + required: Some( + vec![ + SmolStr::new_static("project"), + SmolStr::new_static("files"), + SmolStr::new_static("license"), + SmolStr::new_static("notices"), + SmolStr::new_static("createdAt") + ], + ), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("createdAt"), + LexObjectProperty::String(LexString { + format: Some(LexStringFormat::Datetime), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("files"), + LexObjectProperty::Array(LexArray { + items: LexArrayItem::Ref(LexRef { + r#ref: CowStr::new_static("#file"), + ..Default::default() + }), + max_length: Some(16384usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("license"), + LexObjectProperty::String(LexString { + max_length: Some(512usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("notices"), + LexObjectProperty::Array(LexArray { + items: LexArrayItem::String(LexString { + max_length: Some(4096usize), + ..Default::default() + }), + max_length: Some(64usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("project"), + LexObjectProperty::String(LexString { + format: Some(LexStringFormat::AtUri), + ..Default::default() + }), + ); + map + }, + ..Default::default() + }), + ..Default::default() + }), + ); + map + }, + ..Default::default() + } +} + +pub mod ldraw_manifest_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type CreatedAt; + type Files; + type License; + type Notices; + type Project; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type CreatedAt = Unset; + type Files = Unset; + type License = Unset; + type Notices = Unset; + type Project = Unset; + } + ///State transition - sets the `created_at` field to Set + pub struct SetCreatedAt(PhantomData St>); + impl sealed::Sealed for SetCreatedAt {} + impl State for SetCreatedAt { + type CreatedAt = Set; + type Files = St::Files; + type License = St::License; + type Notices = St::Notices; + type Project = St::Project; + } + ///State transition - sets the `files` field to Set + pub struct SetFiles(PhantomData St>); + impl sealed::Sealed for SetFiles {} + impl State for SetFiles { + type CreatedAt = St::CreatedAt; + type Files = Set; + type License = St::License; + type Notices = St::Notices; + type Project = St::Project; + } + ///State transition - sets the `license` field to Set + pub struct SetLicense(PhantomData St>); + impl sealed::Sealed for SetLicense {} + impl State for SetLicense { + type CreatedAt = St::CreatedAt; + type Files = St::Files; + type License = Set; + type Notices = St::Notices; + type Project = St::Project; + } + ///State transition - sets the `notices` field to Set + pub struct SetNotices(PhantomData St>); + impl sealed::Sealed for SetNotices {} + impl State for SetNotices { + type CreatedAt = St::CreatedAt; + type Files = St::Files; + type License = St::License; + type Notices = Set; + type Project = St::Project; + } + ///State transition - sets the `project` field to Set + pub struct SetProject(PhantomData St>); + impl sealed::Sealed for SetProject {} + impl State for SetProject { + type CreatedAt = St::CreatedAt; + type Files = St::Files; + type License = St::License; + type Notices = St::Notices; + type Project = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `created_at` field + pub struct created_at(()); + ///Marker type for the `files` field + pub struct files(()); + ///Marker type for the `license` field + pub struct license(()); + ///Marker type for the `notices` field + pub struct notices(()); + ///Marker type for the `project` field + pub struct project(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct LdrawManifestBuilder< + St: ldraw_manifest_state::State, + S: BosStr = DefaultStr, +> { + _state: PhantomData St>, + _fields: ( + Option, + Option>>, + Option, + Option>, + Option>, + ), + _type: PhantomData S>, +} + +impl LdrawManifest { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> LdrawManifestBuilder { + LdrawManifestBuilder::new() + } +} + +impl LdrawManifest { + /// Create a new builder for this type + pub fn builder() -> LdrawManifestBuilder { + LdrawManifestBuilder::builder() + } +} + +impl LdrawManifestBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + LdrawManifestBuilder { + _state: PhantomData, + _fields: (None, None, None, None, None), + _type: PhantomData, + } + } +} + +impl LdrawManifestBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + LdrawManifestBuilder { + _state: PhantomData, + _fields: (None, None, None, None, None), + _type: PhantomData, + } + } +} + +impl LdrawManifestBuilder +where + St: ldraw_manifest_state::State, + St::CreatedAt: ldraw_manifest_state::IsUnset, +{ + /// Set the `createdAt` field (required) + pub fn created_at( + mut self, + value: impl Into, + ) -> LdrawManifestBuilder, S> { + self._fields.0 = Option::Some(value.into()); + LdrawManifestBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl LdrawManifestBuilder +where + St: ldraw_manifest_state::State, + St::Files: ldraw_manifest_state::IsUnset, +{ + /// Set the `files` field (required) + pub fn files( + mut self, + value: impl Into>>, + ) -> LdrawManifestBuilder, S> { + self._fields.1 = Option::Some(value.into()); + LdrawManifestBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl LdrawManifestBuilder +where + St: ldraw_manifest_state::State, + St::License: ldraw_manifest_state::IsUnset, +{ + /// Set the `license` field (required) + pub fn license( + mut self, + value: impl Into, + ) -> LdrawManifestBuilder, S> { + self._fields.2 = Option::Some(value.into()); + LdrawManifestBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl LdrawManifestBuilder +where + St: ldraw_manifest_state::State, + St::Notices: ldraw_manifest_state::IsUnset, +{ + /// Set the `notices` field (required) + pub fn notices( + mut self, + value: impl Into>, + ) -> LdrawManifestBuilder, S> { + self._fields.3 = Option::Some(value.into()); + LdrawManifestBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl LdrawManifestBuilder +where + St: ldraw_manifest_state::State, + St::Project: ldraw_manifest_state::IsUnset, +{ + /// Set the `project` field (required) + pub fn project( + mut self, + value: impl Into>, + ) -> LdrawManifestBuilder, S> { + self._fields.4 = Option::Some(value.into()); + LdrawManifestBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl LdrawManifestBuilder +where + St: ldraw_manifest_state::State, + St::CreatedAt: ldraw_manifest_state::IsSet, + St::Files: ldraw_manifest_state::IsSet, + St::License: ldraw_manifest_state::IsSet, + St::Notices: ldraw_manifest_state::IsSet, + St::Project: ldraw_manifest_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> LdrawManifest { + LdrawManifest { + created_at: self._fields.0.unwrap(), + files: self._fields.1.unwrap(), + license: self._fields.2.unwrap(), + notices: self._fields.3.unwrap(), + project: self._fields.4.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data( + self, + extra_data: BTreeMap>, + ) -> LdrawManifest { + LdrawManifest { + created_at: self._fields.0.unwrap(), + files: self._fields.1.unwrap(), + license: self._fields.2.unwrap(), + notices: self._fields.3.unwrap(), + project: self._fields.4.unwrap(), + extra_data: Some(extra_data), + } + } +} \ No newline at end of file diff --git a/crates/polymodel-api/src/space_polymodel/library/ldraw_resource.rs b/crates/polymodel-api/src/space_polymodel/library/ldraw_resource.rs new file mode 100644 index 0000000..0e600ef --- /dev/null +++ b/crates/polymodel-api/src/space_polymodel/library/ldraw_resource.rs @@ -0,0 +1,969 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: space.polymodel.library.ldrawResource +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[allow(unused_imports)] +use alloc::collections::BTreeMap; + +#[allow(unused_imports)] +use core::marker::PhantomData; +use jacquard_common::{CowStr, BosStr, DefaultStr, FromStaticStr}; +use jacquard_common::deps::bytes::Bytes; + +#[allow(unused_imports)] +use jacquard_common::deps::codegen::unicode_segmentation::UnicodeSegmentation; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::blob::BlobRef; +use jacquard_common::types::collection::{Collection, RecordError}; +use jacquard_common::types::string::{AtUri, Cid, Datetime}; +use jacquard_common::types::uri::{RecordUri, UriError}; +use jacquard_common::types::value::Data; +use jacquard_common::xrpc::XrpcResp; +use jacquard_derive::{IntoStatic, lexicon}; +use jacquard_lexicon::lexicon::LexiconDoc; +use jacquard_lexicon::schema::LexiconSchema; + +#[allow(unused_imports)] +use jacquard_lexicon::validation::{ConstraintError, ValidationPath}; +use serde::{Serialize, Deserialize}; +/// A verified, server-indexed LDraw virtual resource. Paths are filenames, never network URLs. + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde( + rename_all = "camelCase", + rename = "space.polymodel.library.ldrawResource", + tag = "$type", + bound(deserialize = "S: Deserialize<'de> + BosStr") +)] +pub struct LdrawResource { + pub chunks: Vec>, + pub created_at: Datetime, + pub license: S, + pub mime_type: S, + pub notices: Vec, + pub path: S, + pub project: AtUri, + pub root: LdrawResourceRoot, + #[serde(with = "jacquard_common::serde_bytes_helper")] + pub sha256: Bytes, + pub size: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub snapshot: Option, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum LdrawResourceRoot { + Mpd, + Manifest, + Models, + Parts, + P, + OfficialParts, + OfficialP, + Other(S), +} + +impl LdrawResourceRoot { + pub fn as_str(&self) -> &str { + match self { + Self::Mpd => "mpd", + Self::Manifest => "manifest", + Self::Models => "models", + Self::Parts => "parts", + Self::P => "p", + Self::OfficialParts => "official-parts", + Self::OfficialP => "official-p", + Self::Other(s) => s.as_ref(), + } + } + /// Construct from a string-like value, matching known values. + pub fn from_value(s: S) -> Self { + match s.as_ref() { + "mpd" => Self::Mpd, + "manifest" => Self::Manifest, + "models" => Self::Models, + "parts" => Self::Parts, + "p" => Self::P, + "official-parts" => Self::OfficialParts, + "official-p" => Self::OfficialP, + _ => Self::Other(s), + } + } +} + +impl core::fmt::Display for LdrawResourceRoot { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl AsRef for LdrawResourceRoot { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Serialize for LdrawResourceRoot { + fn serialize(&self, serializer: Ser) -> Result + where + Ser: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de, S: Deserialize<'de> + BosStr> Deserialize<'de> for LdrawResourceRoot { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = S::deserialize(deserializer)?; + Ok(Self::from_value(s)) + } +} + +impl Default for LdrawResourceRoot { + fn default() -> Self { + Self::Other(Default::default()) + } +} + +impl jacquard_common::IntoStatic for LdrawResourceRoot +where + S: BosStr + jacquard_common::IntoStatic, + S::Output: BosStr, +{ + type Output = LdrawResourceRoot; + fn into_static(self) -> Self::Output { + match self { + LdrawResourceRoot::Mpd => LdrawResourceRoot::Mpd, + LdrawResourceRoot::Manifest => LdrawResourceRoot::Manifest, + LdrawResourceRoot::Models => LdrawResourceRoot::Models, + LdrawResourceRoot::Parts => LdrawResourceRoot::Parts, + LdrawResourceRoot::P => LdrawResourceRoot::P, + LdrawResourceRoot::OfficialParts => LdrawResourceRoot::OfficialParts, + LdrawResourceRoot::OfficialP => LdrawResourceRoot::OfficialP, + LdrawResourceRoot::Other(v) => LdrawResourceRoot::Other(v.into_static()), + } + } +} + +/// Typed wrapper for GetRecord response with this collection's record type. + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase")] +pub struct LdrawResourceGetRecordOutput { + #[serde(skip_serializing_if = "Option::is_none")] + pub cid: Option>, + pub uri: AtUri, + pub value: LdrawResource, +} + +impl LdrawResource { + pub fn uri(uri: S) -> Result, UriError> { + RecordUri::try_from_uri(AtUri::new(uri)?) + } +} + +/// Marker type for deserializing records from this collection. + +#[derive(Debug, Serialize, Deserialize)] +pub struct LdrawResourceRecord; +impl XrpcResp for LdrawResourceRecord { + const NSID: &'static str = "space.polymodel.library.ldrawResource"; + const ENCODING: &'static str = "application/json"; + type Output = LdrawResourceGetRecordOutput; + type Err = RecordError; +} + +impl From> for LdrawResource { + fn from(output: LdrawResourceGetRecordOutput) -> Self { + output.value + } +} + +impl Collection for LdrawResource { + const NSID: &'static str = "space.polymodel.library.ldrawResource"; + type Record = LdrawResourceRecord; +} + +impl Collection for LdrawResourceRecord { + const NSID: &'static str = "space.polymodel.library.ldrawResource"; + type Record = LdrawResourceRecord; +} + +impl LexiconSchema for LdrawResource { + fn nsid() -> &'static str { + "space.polymodel.library.ldrawResource" + } + fn def_name() -> &'static str { + "main" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_space_polymodel_library_ldrawResource() + } + fn validate(&self) -> Result<(), ConstraintError> { + { + let value = &self.chunks; + #[allow(unused_comparisons)] + if value.len() > 4096usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("chunks"), + max: 4096usize, + actual: value.len(), + }); + } + } + { + let value = &self.license; + #[allow(unused_comparisons)] + if ::len(value.as_ref()) > 512usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("license"), + max: 512usize, + actual: ::len(value.as_ref()), + }); + } + } + { + let value = &self.mime_type; + #[allow(unused_comparisons)] + if ::len(value.as_ref()) > 256usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("mime_type"), + max: 256usize, + actual: ::len(value.as_ref()), + }); + } + } + { + let value = &self.notices; + #[allow(unused_comparisons)] + if value.len() > 64usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("notices"), + max: 64usize, + actual: value.len(), + }); + } + } + for value in &self.notices { + #[allow(unused_comparisons)] + if ::len(value.as_ref()) > 4096usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("notices"), + max: 4096usize, + actual: ::len(value.as_ref()), + }); + } + } + { + let value = &self.path; + #[allow(unused_comparisons)] + if ::len(value.as_ref()) > 4096usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("path"), + max: 4096usize, + actual: ::len(value.as_ref()), + }); + } + } + { + let value = &self.path; + { + let count = UnicodeSegmentation::graphemes(value.as_ref(), true).count(); + if count > 1024usize { + return Err(ConstraintError::MaxGraphemes { + path: ValidationPath::from_field("path"), + max: 1024usize, + actual: count, + }); + } + } + } + { + let value = &self.size; + if *value < 0i64 { + return Err(ConstraintError::Minimum { + path: ValidationPath::from_field("size"), + min: 0i64, + actual: *value, + }); + } + } + if let Some(ref value) = self.snapshot { + #[allow(unused_comparisons)] + if ::len(value.as_ref()) > 256usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("snapshot"), + max: 256usize, + actual: ::len(value.as_ref()), + }); + } + } + Ok(()) + } +} + +pub mod ldraw_resource_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type Chunks; + type CreatedAt; + type License; + type MimeType; + type Notices; + type Path; + type Project; + type Root; + type Sha256; + type Size; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type Chunks = Unset; + type CreatedAt = Unset; + type License = Unset; + type MimeType = Unset; + type Notices = Unset; + type Path = Unset; + type Project = Unset; + type Root = Unset; + type Sha256 = Unset; + type Size = Unset; + } + ///State transition - sets the `chunks` field to Set + pub struct SetChunks(PhantomData St>); + impl sealed::Sealed for SetChunks {} + impl State for SetChunks { + type Chunks = Set; + type CreatedAt = St::CreatedAt; + type License = St::License; + type MimeType = St::MimeType; + type Notices = St::Notices; + type Path = St::Path; + type Project = St::Project; + type Root = St::Root; + type Sha256 = St::Sha256; + type Size = St::Size; + } + ///State transition - sets the `created_at` field to Set + pub struct SetCreatedAt(PhantomData St>); + impl sealed::Sealed for SetCreatedAt {} + impl State for SetCreatedAt { + type Chunks = St::Chunks; + type CreatedAt = Set; + type License = St::License; + type MimeType = St::MimeType; + type Notices = St::Notices; + type Path = St::Path; + type Project = St::Project; + type Root = St::Root; + type Sha256 = St::Sha256; + type Size = St::Size; + } + ///State transition - sets the `license` field to Set + pub struct SetLicense(PhantomData St>); + impl sealed::Sealed for SetLicense {} + impl State for SetLicense { + type Chunks = St::Chunks; + type CreatedAt = St::CreatedAt; + type License = Set; + type MimeType = St::MimeType; + type Notices = St::Notices; + type Path = St::Path; + type Project = St::Project; + type Root = St::Root; + type Sha256 = St::Sha256; + type Size = St::Size; + } + ///State transition - sets the `mime_type` field to Set + pub struct SetMimeType(PhantomData St>); + impl sealed::Sealed for SetMimeType {} + impl State for SetMimeType { + type Chunks = St::Chunks; + type CreatedAt = St::CreatedAt; + type License = St::License; + type MimeType = Set; + type Notices = St::Notices; + type Path = St::Path; + type Project = St::Project; + type Root = St::Root; + type Sha256 = St::Sha256; + type Size = St::Size; + } + ///State transition - sets the `notices` field to Set + pub struct SetNotices(PhantomData St>); + impl sealed::Sealed for SetNotices {} + impl State for SetNotices { + type Chunks = St::Chunks; + type CreatedAt = St::CreatedAt; + type License = St::License; + type MimeType = St::MimeType; + type Notices = Set; + type Path = St::Path; + type Project = St::Project; + type Root = St::Root; + type Sha256 = St::Sha256; + type Size = St::Size; + } + ///State transition - sets the `path` field to Set + pub struct SetPath(PhantomData St>); + impl sealed::Sealed for SetPath {} + impl State for SetPath { + type Chunks = St::Chunks; + type CreatedAt = St::CreatedAt; + type License = St::License; + type MimeType = St::MimeType; + type Notices = St::Notices; + type Path = Set; + type Project = St::Project; + type Root = St::Root; + type Sha256 = St::Sha256; + type Size = St::Size; + } + ///State transition - sets the `project` field to Set + pub struct SetProject(PhantomData St>); + impl sealed::Sealed for SetProject {} + impl State for SetProject { + type Chunks = St::Chunks; + type CreatedAt = St::CreatedAt; + type License = St::License; + type MimeType = St::MimeType; + type Notices = St::Notices; + type Path = St::Path; + type Project = Set; + type Root = St::Root; + type Sha256 = St::Sha256; + type Size = St::Size; + } + ///State transition - sets the `root` field to Set + pub struct SetRoot(PhantomData St>); + impl sealed::Sealed for SetRoot {} + impl State for SetRoot { + type Chunks = St::Chunks; + type CreatedAt = St::CreatedAt; + type License = St::License; + type MimeType = St::MimeType; + type Notices = St::Notices; + type Path = St::Path; + type Project = St::Project; + type Root = Set; + type Sha256 = St::Sha256; + type Size = St::Size; + } + ///State transition - sets the `sha256` field to Set + pub struct SetSha256(PhantomData St>); + impl sealed::Sealed for SetSha256 {} + impl State for SetSha256 { + type Chunks = St::Chunks; + type CreatedAt = St::CreatedAt; + type License = St::License; + type MimeType = St::MimeType; + type Notices = St::Notices; + type Path = St::Path; + type Project = St::Project; + type Root = St::Root; + type Sha256 = Set; + type Size = St::Size; + } + ///State transition - sets the `size` field to Set + pub struct SetSize(PhantomData St>); + impl sealed::Sealed for SetSize {} + impl State for SetSize { + type Chunks = St::Chunks; + type CreatedAt = St::CreatedAt; + type License = St::License; + type MimeType = St::MimeType; + type Notices = St::Notices; + type Path = St::Path; + type Project = St::Project; + type Root = St::Root; + type Sha256 = St::Sha256; + type Size = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `chunks` field + pub struct chunks(()); + ///Marker type for the `created_at` field + pub struct created_at(()); + ///Marker type for the `license` field + pub struct license(()); + ///Marker type for the `mime_type` field + pub struct mime_type(()); + ///Marker type for the `notices` field + pub struct notices(()); + ///Marker type for the `path` field + pub struct path(()); + ///Marker type for the `project` field + pub struct project(()); + ///Marker type for the `root` field + pub struct root(()); + ///Marker type for the `sha256` field + pub struct sha256(()); + ///Marker type for the `size` field + pub struct size(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct LdrawResourceBuilder< + St: ldraw_resource_state::State, + S: BosStr = DefaultStr, +> { + _state: PhantomData St>, + _fields: ( + Option>>, + Option, + Option, + Option, + Option>, + Option, + Option>, + Option>, + Option, + Option, + Option, + ), + _type: PhantomData S>, +} + +impl LdrawResource { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> LdrawResourceBuilder { + LdrawResourceBuilder::new() + } +} + +impl LdrawResource { + /// Create a new builder for this type + pub fn builder() -> LdrawResourceBuilder { + LdrawResourceBuilder::builder() + } +} + +impl LdrawResourceBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + LdrawResourceBuilder { + _state: PhantomData, + _fields: (None, None, None, None, None, None, None, None, None, None, None), + _type: PhantomData, + } + } +} + +impl LdrawResourceBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + LdrawResourceBuilder { + _state: PhantomData, + _fields: (None, None, None, None, None, None, None, None, None, None, None), + _type: PhantomData, + } + } +} + +impl LdrawResourceBuilder +where + St: ldraw_resource_state::State, + St::Chunks: ldraw_resource_state::IsUnset, +{ + /// Set the `chunks` field (required) + pub fn chunks( + mut self, + value: impl Into>>, + ) -> LdrawResourceBuilder, S> { + self._fields.0 = Option::Some(value.into()); + LdrawResourceBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl LdrawResourceBuilder +where + St: ldraw_resource_state::State, + St::CreatedAt: ldraw_resource_state::IsUnset, +{ + /// Set the `createdAt` field (required) + pub fn created_at( + mut self, + value: impl Into, + ) -> LdrawResourceBuilder, S> { + self._fields.1 = Option::Some(value.into()); + LdrawResourceBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl LdrawResourceBuilder +where + St: ldraw_resource_state::State, + St::License: ldraw_resource_state::IsUnset, +{ + /// Set the `license` field (required) + pub fn license( + mut self, + value: impl Into, + ) -> LdrawResourceBuilder, S> { + self._fields.2 = Option::Some(value.into()); + LdrawResourceBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl LdrawResourceBuilder +where + St: ldraw_resource_state::State, + St::MimeType: ldraw_resource_state::IsUnset, +{ + /// Set the `mimeType` field (required) + pub fn mime_type( + mut self, + value: impl Into, + ) -> LdrawResourceBuilder, S> { + self._fields.3 = Option::Some(value.into()); + LdrawResourceBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl LdrawResourceBuilder +where + St: ldraw_resource_state::State, + St::Notices: ldraw_resource_state::IsUnset, +{ + /// Set the `notices` field (required) + pub fn notices( + mut self, + value: impl Into>, + ) -> LdrawResourceBuilder, S> { + self._fields.4 = Option::Some(value.into()); + LdrawResourceBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl LdrawResourceBuilder +where + St: ldraw_resource_state::State, + St::Path: ldraw_resource_state::IsUnset, +{ + /// Set the `path` field (required) + pub fn path( + mut self, + value: impl Into, + ) -> LdrawResourceBuilder, S> { + self._fields.5 = Option::Some(value.into()); + LdrawResourceBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl LdrawResourceBuilder +where + St: ldraw_resource_state::State, + St::Project: ldraw_resource_state::IsUnset, +{ + /// Set the `project` field (required) + pub fn project( + mut self, + value: impl Into>, + ) -> LdrawResourceBuilder, S> { + self._fields.6 = Option::Some(value.into()); + LdrawResourceBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl LdrawResourceBuilder +where + St: ldraw_resource_state::State, + St::Root: ldraw_resource_state::IsUnset, +{ + /// Set the `root` field (required) + pub fn root( + mut self, + value: impl Into>, + ) -> LdrawResourceBuilder, S> { + self._fields.7 = Option::Some(value.into()); + LdrawResourceBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl LdrawResourceBuilder +where + St: ldraw_resource_state::State, + St::Sha256: ldraw_resource_state::IsUnset, +{ + /// Set the `sha256` field (required) + pub fn sha256( + mut self, + value: impl Into, + ) -> LdrawResourceBuilder, S> { + self._fields.8 = Option::Some(value.into()); + LdrawResourceBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl LdrawResourceBuilder +where + St: ldraw_resource_state::State, + St::Size: ldraw_resource_state::IsUnset, +{ + /// Set the `size` field (required) + pub fn size( + mut self, + value: impl Into, + ) -> LdrawResourceBuilder, S> { + self._fields.9 = Option::Some(value.into()); + LdrawResourceBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl LdrawResourceBuilder { + /// Set the `snapshot` field (optional) + pub fn snapshot(mut self, value: impl Into>) -> Self { + self._fields.10 = value.into(); + self + } + /// Set the `snapshot` field to an Option value (optional) + pub fn maybe_snapshot(mut self, value: Option) -> Self { + self._fields.10 = value; + self + } +} + +impl LdrawResourceBuilder +where + St: ldraw_resource_state::State, + St::Chunks: ldraw_resource_state::IsSet, + St::CreatedAt: ldraw_resource_state::IsSet, + St::License: ldraw_resource_state::IsSet, + St::MimeType: ldraw_resource_state::IsSet, + St::Notices: ldraw_resource_state::IsSet, + St::Path: ldraw_resource_state::IsSet, + St::Project: ldraw_resource_state::IsSet, + St::Root: ldraw_resource_state::IsSet, + St::Sha256: ldraw_resource_state::IsSet, + St::Size: ldraw_resource_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> LdrawResource { + LdrawResource { + chunks: self._fields.0.unwrap(), + created_at: self._fields.1.unwrap(), + license: self._fields.2.unwrap(), + mime_type: self._fields.3.unwrap(), + notices: self._fields.4.unwrap(), + path: self._fields.5.unwrap(), + project: self._fields.6.unwrap(), + root: self._fields.7.unwrap(), + sha256: self._fields.8.unwrap(), + size: self._fields.9.unwrap(), + snapshot: self._fields.10, + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data( + self, + extra_data: BTreeMap>, + ) -> LdrawResource { + LdrawResource { + chunks: self._fields.0.unwrap(), + created_at: self._fields.1.unwrap(), + license: self._fields.2.unwrap(), + mime_type: self._fields.3.unwrap(), + notices: self._fields.4.unwrap(), + path: self._fields.5.unwrap(), + project: self._fields.6.unwrap(), + root: self._fields.7.unwrap(), + sha256: self._fields.8.unwrap(), + size: self._fields.9.unwrap(), + snapshot: self._fields.10, + extra_data: Some(extra_data), + } + } +} + +fn lexicon_doc_space_polymodel_library_ldrawResource() -> LexiconDoc<'static> { + #[allow(unused_imports)] + use jacquard_common::{CowStr, deps::smol_str::SmolStr, types::blob::MimeType}; + use jacquard_lexicon::lexicon::*; + use alloc::collections::BTreeMap; + LexiconDoc { + lexicon: Lexicon::Lexicon1, + id: CowStr::new_static("space.polymodel.library.ldrawResource"), + defs: { + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("main"), + LexUserType::Record(LexRecord { + description: Some( + CowStr::new_static( + "A verified, server-indexed LDraw virtual resource. Paths are filenames, never network URLs.", + ), + ), + key: Some(CowStr::new_static("tid")), + record: LexRecordRecord::Object(LexObject { + required: Some( + vec![ + SmolStr::new_static("project"), SmolStr::new_static("root"), + SmolStr::new_static("path"), + SmolStr::new_static("mimeType"), + SmolStr::new_static("size"), SmolStr::new_static("sha256"), + SmolStr::new_static("chunks"), + SmolStr::new_static("license"), + SmolStr::new_static("notices"), + SmolStr::new_static("createdAt") + ], + ), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("chunks"), + LexObjectProperty::Array(LexArray { + items: LexArrayItem::Blob(LexBlob { ..Default::default() }), + max_length: Some(4096usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("createdAt"), + LexObjectProperty::String(LexString { + format: Some(LexStringFormat::Datetime), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("license"), + LexObjectProperty::String(LexString { + max_length: Some(512usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("mimeType"), + LexObjectProperty::String(LexString { + max_length: Some(256usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("notices"), + LexObjectProperty::Array(LexArray { + items: LexArrayItem::String(LexString { + max_length: Some(4096usize), + ..Default::default() + }), + max_length: Some(64usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("path"), + LexObjectProperty::String(LexString { + max_length: Some(4096usize), + max_graphemes: Some(1024usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("project"), + LexObjectProperty::String(LexString { + format: Some(LexStringFormat::AtUri), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("root"), + LexObjectProperty::String(LexString { + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("sha256"), + LexObjectProperty::Bytes(LexBytes { + max_length: Some(32usize), + min_length: Some(32usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("size"), + LexObjectProperty::Integer(LexInteger { + minimum: Some(0i64), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("snapshot"), + LexObjectProperty::String(LexString { + max_length: Some(256usize), + ..Default::default() + }), + ); + map + }, + ..Default::default() + }), + ..Default::default() + }), + ); + map + }, + ..Default::default() + } +} \ No newline at end of file diff --git a/crates/polymodel-api/src/space_polymodel/library/mint_ldraw_resource.rs b/crates/polymodel-api/src/space_polymodel/library/mint_ldraw_resource.rs new file mode 100644 index 0000000..8c76ab1 --- /dev/null +++ b/crates/polymodel-api/src/space_polymodel/library/mint_ldraw_resource.rs @@ -0,0 +1,227 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: space.polymodel.library.mintLdrawResource +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[allow(unused_imports)] +use alloc::collections::BTreeMap; + +#[allow(unused_imports)] +use core::marker::PhantomData; +use jacquard_common::{CowStr, BosStr, DefaultStr, FromStaticStr}; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::string::{AtUri, Datetime}; +use jacquard_common::types::value::Data; +use jacquard_derive::IntoStatic; +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct MintLdrawResource { + pub path: S, + pub project: AtUri, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct MintLdrawResourceOutput { + pub expires_at: Datetime, + pub token: S, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + +/** Response marker for the `space.polymodel.library.mintLdrawResource` procedure. + +Implements `jacquard_common::xrpc::XrpcResp`; successful bodies decode as `Self::Output`, which is `MintLdrawResourceOutput` for this endpoint.*/ +pub struct MintLdrawResourceResponse; +impl jacquard_common::xrpc::XrpcResp for MintLdrawResourceResponse { + const NSID: &'static str = "space.polymodel.library.mintLdrawResource"; + const ENCODING: &'static str = "application/json"; + type Output = MintLdrawResourceOutput; + type Err = jacquard_common::xrpc::GenericError; +} + +impl jacquard_common::xrpc::XrpcRequest for MintLdrawResource { + const NSID: &'static str = "space.polymodel.library.mintLdrawResource"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Procedure( + "application/json", + ); + type Response = MintLdrawResourceResponse; +} + +/** Endpoint marker for the `space.polymodel.library.mintLdrawResource` procedure. + +Path: `/xrpc/space.polymodel.library.mintLdrawResource`. The request payload type is `MintLdrawResource`; send that request with `jacquard::Client` or use this marker through lower-level `XrpcEndpoint` APIs.*/ +pub struct MintLdrawResourceRequest; +impl jacquard_common::xrpc::XrpcEndpoint for MintLdrawResourceRequest { + const PATH: &'static str = "/xrpc/space.polymodel.library.mintLdrawResource"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Procedure( + "application/json", + ); + type Request = MintLdrawResource; + type Response = MintLdrawResourceResponse; +} + +pub mod mint_ldraw_resource_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type Path; + type Project; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type Path = Unset; + type Project = Unset; + } + ///State transition - sets the `path` field to Set + pub struct SetPath(PhantomData St>); + impl sealed::Sealed for SetPath {} + impl State for SetPath { + type Path = Set; + type Project = St::Project; + } + ///State transition - sets the `project` field to Set + pub struct SetProject(PhantomData St>); + impl sealed::Sealed for SetProject {} + impl State for SetProject { + type Path = St::Path; + type Project = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `path` field + pub struct path(()); + ///Marker type for the `project` field + pub struct project(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct MintLdrawResourceBuilder< + St: mint_ldraw_resource_state::State, + S: BosStr = DefaultStr, +> { + _state: PhantomData St>, + _fields: (Option, Option>), + _type: PhantomData S>, +} + +impl MintLdrawResource { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> MintLdrawResourceBuilder< + mint_ldraw_resource_state::Empty, + DefaultStr, + > { + MintLdrawResourceBuilder::new() + } +} + +impl MintLdrawResource { + /// Create a new builder for this type + pub fn builder() -> MintLdrawResourceBuilder { + MintLdrawResourceBuilder::builder() + } +} + +impl MintLdrawResourceBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + MintLdrawResourceBuilder { + _state: PhantomData, + _fields: (None, None), + _type: PhantomData, + } + } +} + +impl MintLdrawResourceBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + MintLdrawResourceBuilder { + _state: PhantomData, + _fields: (None, None), + _type: PhantomData, + } + } +} + +impl MintLdrawResourceBuilder +where + St: mint_ldraw_resource_state::State, + St::Path: mint_ldraw_resource_state::IsUnset, +{ + /// Set the `path` field (required) + pub fn path( + mut self, + value: impl Into, + ) -> MintLdrawResourceBuilder, S> { + self._fields.0 = Option::Some(value.into()); + MintLdrawResourceBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl MintLdrawResourceBuilder +where + St: mint_ldraw_resource_state::State, + St::Project: mint_ldraw_resource_state::IsUnset, +{ + /// Set the `project` field (required) + pub fn project( + mut self, + value: impl Into>, + ) -> MintLdrawResourceBuilder, S> { + self._fields.1 = Option::Some(value.into()); + MintLdrawResourceBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl MintLdrawResourceBuilder +where + St: mint_ldraw_resource_state::State, + St::Path: mint_ldraw_resource_state::IsSet, + St::Project: mint_ldraw_resource_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> MintLdrawResource { + MintLdrawResource { + path: self._fields.0.unwrap(), + project: self._fields.1.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data( + self, + extra_data: BTreeMap>, + ) -> MintLdrawResource { + MintLdrawResource { + path: self._fields.0.unwrap(), + project: self._fields.1.unwrap(), + extra_data: Some(extra_data), + } + } +} \ No newline at end of file diff --git a/lexicons/library/getLdrawResource.json b/lexicons/library/getLdrawResource.json new file mode 100644 index 0000000..28a3aa2 --- /dev/null +++ b/lexicons/library/getLdrawResource.json @@ -0,0 +1,14 @@ +{ + "lexicon": 1, + "id": "space.polymodel.library.getLdrawResource", + "defs": { + "main": { + "type": "query", + "description": "Fetch verified bytes through a server-derived opaque route token.", + "parameters": { + "token": { "type": "string", "maxLength": 256 } + }, + "output": { "encoding": "*/*" } + } + } +} diff --git a/lexicons/library/ldrawManifest.json b/lexicons/library/ldrawManifest.json new file mode 100644 index 0000000..acc1515 --- /dev/null +++ b/lexicons/library/ldrawManifest.json @@ -0,0 +1,34 @@ +{ + "lexicon": 1, + "id": "space.polymodel.library.ldrawManifest", + "defs": { + "main": { + "type": "record", + "key": "tid", + "description": "A manifest describing canonical LDraw virtual files and their dependency roots.", + "record": { + "type": "object", + "required": ["project", "files", "license", "notices", "createdAt"], + "properties": { + "project": { "type": "string", "format": "at-uri" }, + "files": { "type": "array", "items": { "type": "ref", "ref": "#file" }, "maxLength": 16384 }, + "license": { "type": "string", "maxLength": 512 }, + "notices": { "type": "array", "items": { "type": "string", "maxLength": 4096 }, "maxLength": 64 }, + "createdAt": { "type": "string", "format": "datetime" } + } + } + }, + "file": { + "type": "object", + "required": ["path", "root", "size", "sha256", "mimeType", "chunks"], + "properties": { + "path": { "type": "string", "maxGraphemes": 1024, "maxLength": 4096 }, + "root": { "type": "string", "maxLength": 64 }, + "size": { "type": "integer", "minimum": 0 }, + "sha256": { "type": "bytes", "minLength": 32, "maxLength": 32 }, + "mimeType": { "type": "string", "maxLength": 256 }, + "chunks": { "type": "array", "items": { "type": "blob" }, "maxLength": 4096 } + } + } + } +} diff --git a/lexicons/library/ldrawResource.json b/lexicons/library/ldrawResource.json new file mode 100644 index 0000000..e6a9fd8 --- /dev/null +++ b/lexicons/library/ldrawResource.json @@ -0,0 +1,28 @@ +{ + "lexicon": 1, + "id": "space.polymodel.library.ldrawResource", + "defs": { + "main": { + "type": "record", + "key": "tid", + "description": "A verified, server-indexed LDraw virtual resource. Paths are filenames, never network URLs.", + "record": { + "type": "object", + "required": ["project", "root", "path", "mimeType", "size", "sha256", "chunks", "license", "notices", "createdAt"], + "properties": { + "project": { "type": "string", "format": "at-uri" }, + "root": { "type": "string", "knownValues": ["mpd", "manifest", "models", "parts", "p", "official-parts", "official-p"] }, + "path": { "type": "string", "maxGraphemes": 1024, "maxLength": 4096 }, + "mimeType": { "type": "string", "maxLength": 256 }, + "size": { "type": "integer", "minimum": 0 }, + "sha256": { "type": "bytes", "minLength": 32, "maxLength": 32 }, + "chunks": { "type": "array", "items": { "type": "blob" }, "maxLength": 4096 }, + "license": { "type": "string", "maxLength": 512 }, + "notices": { "type": "array", "items": { "type": "string", "maxLength": 4096 }, "maxLength": 64 }, + "snapshot": { "type": "string", "maxLength": 256 }, + "createdAt": { "type": "string", "format": "datetime" } + } + } + } + } +} diff --git a/lexicons/library/mintLdrawResource.json b/lexicons/library/mintLdrawResource.json new file mode 100644 index 0000000..0a0864a --- /dev/null +++ b/lexicons/library/mintLdrawResource.json @@ -0,0 +1,32 @@ +{ + "lexicon": 1, + "id": "space.polymodel.library.mintLdrawResource", + "defs": { + "main": { + "type": "procedure", + "description": "Mint a stateful, authorization-bound opaque route for one canonical LDraw filename.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["project", "path"], + "properties": { + "project": { "type": "string", "format": "at-uri" }, + "path": { "type": "string", "maxLength": 4096 } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["token", "expiresAt"], + "properties": { + "token": { "type": "string", "maxLength": 256 }, + "expiresAt": { "type": "string", "format": "datetime" } + } + } + } + } + } +} diff --git a/migrations/009_ldraw_resources.sql b/migrations/009_ldraw_resources.sql new file mode 100644 index 0000000..7b4c4cd --- /dev/null +++ b/migrations/009_ldraw_resources.sql @@ -0,0 +1,95 @@ +-- PM-76: durable LDraw resource projection, verification, and opaque routes. +-- All dependent cleanup is performed by application transactions; SQLite foreign-key +-- enforcement is intentionally not required by this schema. + +CREATE TABLE ldraw_resources ( + resource_uri TEXT PRIMARY KEY, + owner_did TEXT NOT NULL, + project_uri TEXT NOT NULL, + record_cid TEXT NOT NULL, + rkey TEXT NOT NULL, + root TEXT NOT NULL, + canonical_path TEXT NOT NULL, + mime_type TEXT NOT NULL, + byte_length INTEGER NOT NULL CHECK (byte_length >= 0), + sha256 BLOB NOT NULL CHECK (length(sha256) = 32), + ordered_blob_cids TEXT NOT NULL, + provenance TEXT NOT NULL, + licence TEXT NOT NULL, + notices_json TEXT NOT NULL, + snapshot_id TEXT, + legacy INTEGER NOT NULL DEFAULT 0 CHECK (legacy IN (0, 1)), + revoked_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE (owner_did, project_uri, root, canonical_path), + UNIQUE (resource_uri, sha256, byte_length, ordered_blob_cids) +); +CREATE INDEX idx_ldraw_resources_owner ON ldraw_resources(owner_did); +CREATE INDEX idx_ldraw_resources_project_path ON ldraw_resources(project_uri, root, canonical_path); +CREATE INDEX idx_ldraw_resources_source ON ldraw_resources(resource_uri, sha256, byte_length); + +CREATE TABLE ldraw_manifest_files ( + resource_uri TEXT NOT NULL, + root TEXT NOT NULL, + canonical_path TEXT NOT NULL, + target_resource_uri TEXT NOT NULL, + target_cid TEXT NOT NULL, + byte_length INTEGER NOT NULL CHECK (byte_length >= 0), + sha256 BLOB NOT NULL CHECK (length(sha256) = 32), + mime_type TEXT NOT NULL, + ordinal INTEGER NOT NULL CHECK (ordinal >= 0), + PRIMARY KEY (resource_uri, root, canonical_path), + UNIQUE (resource_uri, ordinal) +); +CREATE INDEX idx_ldraw_manifest_target ON ldraw_manifest_files(target_resource_uri, target_cid); + +CREATE TABLE ldraw_verification ( + resource_uri TEXT PRIMARY KEY, + source_identity TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('unverified', 'verifying', 'verified', 'rejected')), + attempt_token TEXT UNIQUE, + lease_expires_at INTEGER, + expired_attempts INTEGER NOT NULL DEFAULT 0 CHECK (expired_attempts >= 0 AND expired_attempts <= 3), + diagnostic TEXT, + revoked_at INTEGER, + updated_at INTEGER NOT NULL +); +CREATE INDEX idx_ldraw_verification_state ON ldraw_verification(state, lease_expires_at); +CREATE INDEX idx_ldraw_verification_source ON ldraw_verification(source_identity); + +CREATE TABLE ldraw_routes ( + token_digest BLOB PRIMARY KEY CHECK (length(token_digest) = 32), + principal_did TEXT, + project_uri TEXT NOT NULL, + resource_uri TEXT NOT NULL, + source_identity TEXT NOT NULL, + root TEXT NOT NULL, + canonical_path TEXT NOT NULL, + target_identity TEXT NOT NULL, + expires_at INTEGER NOT NULL, + revoked_at INTEGER, + created_at INTEGER NOT NULL, + UNIQUE (principal_did, project_uri, resource_uri, source_identity, root, canonical_path, target_identity) +); +CREATE INDEX idx_ldraw_routes_auth ON ldraw_routes(principal_did, project_uri, expires_at, revoked_at); +CREATE INDEX idx_ldraw_routes_resource ON ldraw_routes(resource_uri, source_identity); + +CREATE TABLE ldraw_official_snapshots ( + snapshot_id TEXT PRIMARY KEY, + digest BLOB NOT NULL CHECK (length(digest) = 32), + licence TEXT NOT NULL, + notices_json TEXT NOT NULL, + loaded_at INTEGER NOT NULL, + UNIQUE (snapshot_id, digest) +); + +-- Legacy rows are represented explicitly and remain unreadable until verified. +CREATE TABLE ldraw_legacy_resources ( + resource_uri TEXT PRIMARY KEY, + owner_did TEXT NOT NULL, + source_record_cid TEXT, + reason TEXT NOT NULL, + classified_at INTEGER NOT NULL +); +CREATE INDEX idx_ldraw_legacy_owner ON ldraw_legacy_resources(owner_did); diff --git a/src/appview/download_tests.rs b/src/appview/download_tests.rs index 8217e68..e86c07c 100644 --- a/src/appview/download_tests.rs +++ b/src/appview/download_tests.rs @@ -37,6 +37,22 @@ async fn get_part_file_malformed_uri_is_bad_request() { assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } +#[tokio::test] +async fn get_part_file_ldraw_resource_is_hard_blocked() { + let state = state().await; + let app = crate::appview::router().with_state(state); + let resp = app + .oneshot( + Request::builder() + .uri("/xrpc/space.polymodel.library.getPartFile?uri=at%3A%2F%2Fdid%3Aplc%3Ax%2Fspace.polymodel.library.ldrawResource%2Fr1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + #[tokio::test] async fn get_part_file_wrong_collection_is_bad_request() { let state = state().await; diff --git a/src/appview/downloads.rs b/src/appview/downloads.rs index d4828c2..79d009b 100644 --- a/src/appview/downloads.rs +++ b/src/appview/downloads.rs @@ -47,8 +47,14 @@ pub(super) async fn get_part_file( .uri .path() .ok_or_else(|| invalid_request("missing at-uri path"))?; - if path.collection.as_str() != "space.polymodel.library.part" { - return Err(invalid_request("at-uri is not a part record")); + match path.collection.as_str() { + "space.polymodel.library.ldrawResource" => { + // PM-76: LDraw resources have no legacy integrity-free path. They + // must use the stateful opaque resolver route instead. + return Err(not_found()); + } + "space.polymodel.library.part" => {} + _ => return Err(invalid_request("at-uri is not a part record")), } let authority = req.uri.authority(); let rkey = path diff --git a/src/appview/ldraw.rs b/src/appview/ldraw.rs new file mode 100644 index 0000000..ffffa0f --- /dev/null +++ b/src/appview/ldraw.rs @@ -0,0 +1,354 @@ +use axum::Json; +use axum::body::Body; +use axum::extract::{Query, State}; +use axum::http::{StatusCode, header}; +use axum::response::Response; +use chrono::Utc; +use futures::TryStreamExt; +use futures::stream::{self}; +use jacquard::client::Agent; +use jacquard::identity::resolver::IdentityResolver; +use jacquard_axum::oauth::ExtractOAuthSession; +use jacquard_common::types::string::AtUri; +use jacquard_common::xrpc::XrpcExt; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use super::error::{AppResult, db, internal, invalid_request, not_found, unauthorized}; +use super::state::AppState; +use super::writes::{ExtractSession, authenticated_did}; +use crate::ldraw::resolve::{SqliteResolver, serialize_source_identity}; +use crate::ldraw::verification::{VerificationRecord, VerificationState, claim, complete}; +use crate::ldraw::{CanonicalPath, LdrawResourceResolver}; + +#[derive(Debug, Deserialize)] +pub(super) struct MintRequest { + pub project: AtUri, + pub path: String, +} + +#[derive(Debug, Serialize)] +pub(super) struct MintResponse { + pub token: String, + #[serde(rename = "expiresAt")] + pub expires_at: String, +} + +#[derive(Debug, Deserialize)] +pub(super) struct FetchQuery { + pub token: String, +} + +pub(super) async fn mint( + State(state): State, + ExtractOAuthSession(session): ExtractSession, + Json(request): Json, +) -> AppResult> { + let agent = Agent::from(session); + let principal = authenticated_did(&agent).await?; + let path = CanonicalPath::parse(&request.path) + .map_err(|diagnostic| invalid_request(format!("invalid LDraw path: {diagnostic:?}")))?; + let resolver = SqliteResolver::new(state.pool.clone()); + let resource = ensure_verified(&state, &resolver, Some(&principal), &request.project, &path) + .await + .map_err(resolve_error)?; + + let mut token_bytes = [0_u8; 32]; + rand::rng().fill_bytes(&mut token_bytes); + let digest = Sha256::digest(token_bytes); + let digest_bytes: &[u8] = digest.as_ref(); + let now = Utc::now().timestamp_nanos_opt().unwrap_or_default(); + let expires_at = now + 300_000_000_000_i64; + db(sqlx::query!( + "INSERT INTO ldraw_routes (token_digest, principal_did, project_uri, resource_uri, source_identity, root, canonical_path, target_identity, expires_at, revoked_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)", + digest_bytes, + principal.as_ref(), + request.project.as_ref(), + resource.resource.uri.as_ref(), + serialize_source_identity(&resource.cache_key.source), + resource.root.as_str(), + resource.path.as_str(), + serde_json::to_string(&resource.target.blob_cids) + .map_err(|e| internal(format!("LDraw target identity serialization failed: {e}")))?, + expires_at, + now, + ).execute(&state.pool).await)?; + + Ok(Json(MintResponse { + token: encode_token(&token_bytes), + expires_at: chrono::DateTime::from_timestamp_nanos(expires_at).to_rfc3339(), + })) +} + +pub(super) async fn fetch( + State(state): State, + Query(query): Query, +) -> AppResult { + let token = + decode_token(&query.token).ok_or_else(|| invalid_request("invalid LDraw route token"))?; + let digest = Sha256::digest(token); + let digest_bytes: &[u8] = digest.as_ref(); + let now = Utc::now().timestamp_nanos_opt().unwrap_or_default(); + let row = db(sqlx::query!( + "SELECT principal_did, project_uri, resource_uri, source_identity, root, canonical_path, target_identity FROM ldraw_routes WHERE token_digest = ? AND revoked_at IS NULL AND expires_at > ?", + digest_bytes, + now, + ).fetch_optional(&state.pool).await)?.ok_or_else(not_found)?; + let path = CanonicalPath::parse(&row.canonical_path).map_err(|_| not_found())?; + let project = AtUri::new_owned(&row.project_uri).map_err(|_| not_found())?; + let principal = row + .principal_did + .as_deref() + .and_then(|value| jacquard_common::types::string::Did::new_owned(value).ok()); + let resolver = SqliteResolver::new(state.pool.clone()); + let resource = resolver + .resolve(principal.as_ref(), &project, &path) + .await + .map_err(resolve_error)?; + if resource.resource.uri.as_ref() != row.resource_uri + || serialize_source_identity(&resource.cache_key.source) != row.source_identity + || resource.root.as_str() != row.root + || serde_json::to_string(&resource.target.blob_cids).unwrap_or_default() + != row.target_identity + { + return Err(not_found()); + } + let bytes = fetch_verified_bytes(&state, &resource).await?; + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, resource.mime_type.as_str()) + .header(header::CONTENT_LENGTH, bytes.len()) + .body(Body::from_stream(stream::once(async move { + Ok::<_, std::convert::Infallible>(bytes::Bytes::from(bytes)) + }))) + .map_err(|error| internal(format!("LDraw response build failed: {error}"))) +} + +async fn ensure_verified( + state: &AppState, + resolver: &SqliteResolver, + principal: Option<&jacquard_common::types::string::Did>, + project: &AtUri, + path: &CanonicalPath, +) -> Result { + let resource = resolver.candidate(principal, project, path).await?; + let source = serialize_source_identity(&resource.cache_key.source); + let row = sqlx::query!( + "SELECT state, attempt_token, lease_expires_at, expired_attempts, revoked_at FROM ldraw_verification WHERE resource_uri = ?", + resource.resource.uri.as_ref(), + ).fetch_optional(&state.pool).await.map_err(|_| crate::ldraw::ResolveError::Unavailable { diagnostic: crate::ldraw::resolve::AvailabilityDiagnostic::SourceUnavailable })?.ok_or(crate::ldraw::ResolveError::Unverified)?; + if row.state == "verified" { + return Ok(resource); + } + if row.state == "rejected" { + return Err(crate::ldraw::ResolveError::Rejected { + diagnostic: crate::ldraw::resolve::DiagnosticCode::InvalidManifest, + }); + } + let now = now_nanos(); + let mut token = [0_u8; 32]; + rand::rng().fill_bytes(&mut token); + let attempt = encode_token(&token); + let record = VerificationRecord { + state: match row.state.as_str() { + "unverified" => VerificationState::Unverified, + "verifying" => VerificationState::Verifying, + _ => return Err(crate::ldraw::ResolveError::SourceChanged), + }, + source_identity: source.clone(), + attempt_token: row.attempt_token.clone(), + lease_expires_at: row.lease_expires_at, + expired_attempts: row.expired_attempts.try_into().unwrap_or(u32::MAX), + diagnostic: None, + revoked_at: row.revoked_at, + }; + let lease = now + crate::ldraw::verification::LEASE_NANOS; + let claim = claim(&record, now, attempt.clone()); + match &claim { + crate::ldraw::verification::Transition::AlreadyVerifying => { + return Err(crate::ldraw::ResolveError::Verifying); + } + crate::ldraw::verification::Transition::Rejected(_reason) + if record.revoked_at.is_some() => + { + return Err(crate::ldraw::ResolveError::Rejected { + diagnostic: crate::ldraw::resolve::DiagnosticCode::InvalidManifest, + }); + } + crate::ldraw::verification::Transition::Rejected(reason) => { + let rejected = sqlx::query!( + "UPDATE ldraw_verification SET state = 'rejected', diagnostic = ?, attempt_token = NULL, lease_expires_at = NULL, updated_at = ? WHERE resource_uri = ? AND source_identity = ? AND state = 'verifying' AND attempt_token = ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?)", + reason, now, resource.resource.uri.as_ref(), source, record.attempt_token, now, + ).execute(&state.pool).await.map_err(|_| crate::ldraw::ResolveError::Unavailable { diagnostic: crate::ldraw::resolve::AvailabilityDiagnostic::SourceUnavailable })?; + return if rejected.rows_affected() == 1 { + Err(crate::ldraw::ResolveError::Rejected { + diagnostic: crate::ldraw::resolve::DiagnosticCode::InvalidManifest, + }) + } else { + Err(crate::ldraw::ResolveError::SourceChanged) + }; + } + crate::ldraw::verification::Transition::Claim { .. } => {} + crate::ldraw::verification::Transition::Verified => { + return Ok(resource); + } + crate::ldraw::verification::Transition::Reset => { + return Err(crate::ldraw::ResolveError::SourceChanged); + } + } + let claimed = sqlx::query!( + "UPDATE ldraw_verification SET state = 'verifying', attempt_token = ?, lease_expires_at = ?, expired_attempts = expired_attempts + CASE WHEN state = 'verifying' AND (lease_expires_at IS NULL OR lease_expires_at <= ?) THEN 1 ELSE 0 END, updated_at = ? WHERE resource_uri = ? AND source_identity = ? AND expired_attempts < ? AND (state = 'unverified' OR (state = 'verifying' AND (lease_expires_at IS NULL OR lease_expires_at <= ?)))", + attempt, lease, now, now, resource.resource.uri.as_ref(), source, crate::ldraw::verification::MAX_EXPIRED_ATTEMPTS as i64, now, + ).execute(&state.pool).await.map_err(|_| crate::ldraw::ResolveError::Unavailable { diagnostic: crate::ldraw::resolve::AvailabilityDiagnostic::SourceUnavailable })?; + if claimed.rows_affected() != 1 { + return Err(crate::ldraw::ResolveError::Verifying); + } + let active_record = VerificationRecord { + state: VerificationState::Verifying, + source_identity: source.clone(), + attempt_token: Some(attempt.clone()), + lease_expires_at: Some(lease), + expired_attempts: record.expired_attempts + + u32::from(record.state == VerificationState::Verifying), + diagnostic: None, + revoked_at: record.revoked_at, + }; + let verification = fetch_verified_bytes(state, &resource).await; + match verification { + Ok(_) => { + complete(&active_record, &attempt, &source, now_nanos()) + .map_err(|_| crate::ldraw::ResolveError::SourceChanged)?; + let completed = sqlx::query!( + "UPDATE ldraw_verification SET state = 'verified', attempt_token = NULL, lease_expires_at = NULL, diagnostic = NULL, updated_at = ? WHERE resource_uri = ? AND source_identity = ? AND state = 'verifying' AND attempt_token = ? AND lease_expires_at > ?", + now_nanos(), resource.resource.uri.as_ref(), source, attempt, now_nanos(), + ).execute(&state.pool).await.map_err(|_| crate::ldraw::ResolveError::Unavailable { diagnostic: crate::ldraw::resolve::AvailabilityDiagnostic::SourceUnavailable })?; + if completed.rows_affected() == 1 { + Ok(resource) + } else { + Err(crate::ldraw::ResolveError::SourceChanged) + } + } + Err(_) => { + let rejection_now = now_nanos(); + let rejected = sqlx::query!( + "UPDATE ldraw_verification SET state = 'rejected', diagnostic = 'integrity-mismatch-v1', attempt_token = NULL, lease_expires_at = NULL, updated_at = ? WHERE resource_uri = ? AND source_identity = ? AND state = 'verifying' AND attempt_token = ? AND lease_expires_at > ?", + rejection_now, resource.resource.uri.as_ref(), source, attempt, rejection_now, + ).execute(&state.pool).await.map_err(|_| crate::ldraw::ResolveError::Unavailable { diagnostic: crate::ldraw::resolve::AvailabilityDiagnostic::SourceUnavailable })?; + if rejected.rows_affected() == 1 { + Err(crate::ldraw::ResolveError::Rejected { + diagnostic: crate::ldraw::resolve::DiagnosticCode::InvalidManifest, + }) + } else { + Err(crate::ldraw::ResolveError::SourceChanged) + } + } + } +} + +async fn fetch_verified_bytes( + state: &AppState, + resource: &crate::ldraw::resolve::ResolvedResource, +) -> AppResult> { + let did = + super::actor::resolve_actor(&state, &resource.source.resource_uri.authority().convert()) + .await?; + let did_doc_response = state + .resolver + .resolve_did_doc(&did) + .await + .map_err(|e| internal(format!("DID document resolution failed: {e}")))?; + let did_doc = did_doc_response + .parse() + .map_err(|e| internal(format!("DID document parse failed: {e}")))?; + let endpoint = did_doc + .pds_endpoint() + .ok_or_else(|| internal("DID document does not advertise a PDS endpoint"))? + .to_owned(); + let mut bytes = Vec::new(); + for value in &resource.source.blob_cids { + let cid = jacquard_common::types::string::Cid::new_owned(value.as_bytes()) + .map_err(|e| internal(format!("invalid LDraw blob CID: {e}")))?; + let response = state + .resolver + .xrpc(endpoint.borrow()) + .download(&polymodel_api::com_atproto::sync::get_blob::GetBlob { + did: did.clone(), + cid, + }) + .await + .map_err(|e| internal(format!("blob fetch failed: {e}")))?; + if !response.status().is_success() { + return Err(internal(format!( + "blob fetch failed with HTTP {}", + response.status() + ))); + } + let (_, body) = response.into_parts(); + let chunked = body + .into_inner() + .try_fold(Vec::new(), |mut bytes, chunk| async move { + bytes.extend_from_slice(&chunk); + Ok::<_, _>(bytes) + }) + .await + .map_err(|e| internal(format!("blob stream failed: {e}")))?; + bytes.extend_from_slice(&chunked); + } + if bytes.len() as i64 != resource.byte_length { + return Err(internal("LDraw byte length verification failed")); + } + if sha2::Sha256::digest(&bytes).as_slice() != resource.sha256.as_slice() { + return Err(internal("LDraw SHA-256 verification failed")); + } + Ok(bytes) +} + +fn now_nanos() -> i64 { + Utc::now().timestamp_nanos_opt().unwrap_or_default() +} + +fn resolve_error(error: crate::ldraw::ResolveError) -> super::error::AppError { + match error { + crate::ldraw::ResolveError::Unauthorized => { + unauthorized("LDraw resource is not authorized") + } + crate::ldraw::ResolveError::NotFound + | crate::ldraw::ResolveError::Revoked + | crate::ldraw::ResolveError::Rejected { .. } + | crate::ldraw::ResolveError::Unverified + | crate::ldraw::ResolveError::Verifying + | crate::ldraw::ResolveError::SourceChanged + | crate::ldraw::ResolveError::InvalidPath { .. } + | crate::ldraw::ResolveError::Collision { .. } + | crate::ldraw::ResolveError::Integrity { .. } => not_found(), + crate::ldraw::ResolveError::Unavailable { .. } => internal("LDraw resolver unavailable"), + } +} + +fn encode_token(token: &[u8; 32]) -> String { + token.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn decode_token(value: &str) -> Option> { + if value.len() != 64 || !value.is_ascii() { + return None; + } + (0..value.len()) + .step_by(2) + .map(|index| u8::from_str_radix(&value[index..index + 2], 16).ok()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::{decode_token, encode_token}; + + #[test] + fn route_tokens_are_fixed_width_hex() { + let token = [7_u8; 32]; + let encoded = encode_token(&token); + assert_eq!(decode_token(&encoded), Some(token.to_vec())); + assert!(decode_token("short").is_none()); + } +} diff --git a/src/appview/mod.rs b/src/appview/mod.rs index 4a95f40..ce20c7c 100644 --- a/src/appview/mod.rs +++ b/src/appview/mod.rs @@ -24,6 +24,7 @@ mod content_type; mod downloads; mod drafts; mod graph; +mod ldraw; mod library; mod proxy; mod writes; @@ -123,6 +124,14 @@ pub fn router() -> Router { axum::routing::get(actor::get_session), ) .merge(GetPartFileRequest::into_router(downloads::get_part_file)) + .route( + "/xrpc/space.polymodel.library.mintLdrawResource", + axum::routing::post(ldraw::mint), + ) + .route( + "/xrpc/space.polymodel.library.getLdrawResource", + axum::routing::get(ldraw::fetch), + ) // PM-43 app-internal draft store + image upload. Not federated lexicons, // so these use a plain `/app/*` namespace (cookie-authenticated, // same-origin) rather than `/xrpc/space.polymodel.*`. diff --git a/src/indexing/projection.rs b/src/indexing/projection.rs index deaf5c0..fc72b30 100644 --- a/src/indexing/projection.rs +++ b/src/indexing/projection.rs @@ -235,6 +235,18 @@ async fn project_record( let action = event.action.as_str(); let collection = event.collection.as_str(); match (action, collection) { + ("create" | "update", "space.polymodel.library.ldrawResource") => { + project_ldraw_resource_upsert(tx, event).await?; + } + ("delete", "space.polymodel.library.ldrawResource") => { + project_ldraw_resource_delete(tx, event).await?; + } + ("create" | "update", "space.polymodel.library.ldrawManifest") => { + project_ldraw_manifest_upsert(tx, event).await?; + } + ("delete", "space.polymodel.library.ldrawManifest") => { + project_ldraw_manifest_delete(tx, event).await?; + } ("create" | "update", "space.polymodel.library.thing") => { project_thing_upsert(tx, event).await?; } @@ -644,6 +656,265 @@ async fn project_part_delete( Ok(()) } +// --------------------------------------------------------------------------- +// PM-76 LDraw resource and manifest projections +// --------------------------------------------------------------------------- + +async fn project_ldraw_resource_upsert( + tx: &mut Transaction<'_, Sqlite>, + e: &ProjectionEvent, +) -> anyhow::Result<()> { + let Some(data) = &e.record else { + return Ok(()); + }; + let value = serde_json::to_value(data)?; + let Some(object) = value.as_object() else { + return Ok(()); + }; + let uri = at_uri(&e.did, &e.collection, &e.rkey); + let project_uri = required_string(object, "project")?; + let root = required_string(object, "root")?; + let canonical_path = required_string(object, "path")?; + crate::ldraw::paths::CanonicalPath::parse(&canonical_path).map_err(|diagnostic| { + anyhow::anyhow!("invalid LDraw path {canonical_path:?}: {diagnostic:?}") + })?; + let mime_type = required_string(object, "mimeType")?; + let byte_length = object + .get("size") + .and_then(serde_json::Value::as_i64) + .ok_or_else(|| anyhow::anyhow!("LDraw resource size is missing or invalid"))?; + let sha256 = digest_bytes(object.get("sha256"))?; + let ordered_blob_cids = blob_cids(object.get("chunks"))?; + let licence = required_string(object, "license")?; + let notices = object + .get("notices") + .and_then(serde_json::Value::as_array) + .cloned() + .unwrap_or_default(); + let notices_json = serde_json::to_string(¬ices)?; + let snapshot_id = object.get("snapshot").and_then(serde_json::Value::as_str); + let record_cid = e.cid.clone().unwrap_or_default(); + let source_identity = source_identity(&uri, &ordered_blob_cids, &sha256, byte_length); + let now = now_nanos(); + + let previous = sqlx::query!( + "SELECT source_identity FROM ldraw_verification WHERE resource_uri = ?", + uri, + ) + .fetch_optional(&mut **tx) + .await?; + let changed = previous + .as_ref() + .is_some_and(|row| row.source_identity != source_identity); + + sqlx::query!( + "INSERT INTO ldraw_resources (resource_uri, owner_did, project_uri, record_cid, rkey, root, canonical_path, mime_type, byte_length, sha256, ordered_blob_cids, provenance, licence, notices_json, snapshot_id, legacy, revoked_at, created_at, updated_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?) \ + ON CONFLICT(resource_uri) DO UPDATE SET owner_did = excluded.owner_did, project_uri = excluded.project_uri, record_cid = excluded.record_cid, rkey = excluded.rkey, root = excluded.root, canonical_path = excluded.canonical_path, mime_type = excluded.mime_type, byte_length = excluded.byte_length, sha256 = excluded.sha256, ordered_blob_cids = excluded.ordered_blob_cids, provenance = excluded.provenance, licence = excluded.licence, notices_json = excluded.notices_json, snapshot_id = excluded.snapshot_id, legacy = 0, revoked_at = NULL, updated_at = excluded.updated_at", + uri, + e.did, + project_uri, + record_cid, + e.rkey, + root, + canonical_path, + mime_type, + byte_length, + sha256, + serde_json::to_string(&ordered_blob_cids)?, + "atproto", + licence, + notices_json, + snapshot_id, + now, + now, + ) + .execute(&mut **tx) + .await?; + + sqlx::query!( + "INSERT INTO ldraw_verification (resource_uri, source_identity, state, attempt_token, lease_expires_at, expired_attempts, diagnostic, revoked_at, updated_at) \ + VALUES (?, ?, 'unverified', NULL, NULL, 0, NULL, NULL, ?) \ + ON CONFLICT(resource_uri) DO UPDATE SET source_identity = excluded.source_identity, state = CASE WHEN ldraw_verification.source_identity = excluded.source_identity THEN ldraw_verification.state ELSE 'unverified' END, attempt_token = CASE WHEN ldraw_verification.source_identity = excluded.source_identity THEN ldraw_verification.attempt_token ELSE NULL END, lease_expires_at = CASE WHEN ldraw_verification.source_identity = excluded.source_identity THEN ldraw_verification.lease_expires_at ELSE NULL END, expired_attempts = CASE WHEN ldraw_verification.source_identity = excluded.source_identity THEN ldraw_verification.expired_attempts ELSE 0 END, diagnostic = CASE WHEN ldraw_verification.source_identity = excluded.source_identity THEN ldraw_verification.diagnostic ELSE NULL END, revoked_at = NULL, updated_at = excluded.updated_at", + uri, + source_identity, + now, + ) + .execute(&mut **tx) + .await?; + if changed { + sqlx::query!("DELETE FROM ldraw_routes WHERE resource_uri = ?", uri) + .execute(&mut **tx) + .await?; + } + Ok(()) +} + +async fn project_ldraw_resource_delete( + tx: &mut Transaction<'_, Sqlite>, + e: &ProjectionEvent, +) -> anyhow::Result<()> { + let uri = at_uri(&e.did, &e.collection, &e.rkey); + sqlx::query!( + "DELETE FROM ldraw_manifest_files WHERE resource_uri = ?", + uri + ) + .execute(&mut **tx) + .await?; + sqlx::query!("DELETE FROM ldraw_routes WHERE resource_uri = ?", uri) + .execute(&mut **tx) + .await?; + sqlx::query!("DELETE FROM ldraw_verification WHERE resource_uri = ?", uri) + .execute(&mut **tx) + .await?; + sqlx::query!("DELETE FROM ldraw_resources WHERE resource_uri = ?", uri) + .execute(&mut **tx) + .await?; + Ok(()) +} + +async fn project_ldraw_manifest_upsert( + tx: &mut Transaction<'_, Sqlite>, + e: &ProjectionEvent, +) -> anyhow::Result<()> { + let Some(data) = &e.record else { + return Ok(()); + }; + let value = serde_json::to_value(data)?; + let Some(object) = value.as_object() else { + return Ok(()); + }; + let manifest_uri = at_uri(&e.did, &e.collection, &e.rkey); + sqlx::query!( + "DELETE FROM ldraw_manifest_files WHERE resource_uri = ?", + manifest_uri + ) + .execute(&mut **tx) + .await?; + let Some(files) = object.get("files").and_then(serde_json::Value::as_array) else { + return Ok(()); + }; + for (ordinal, file) in files.iter().enumerate() { + let Some(file) = file.as_object() else { + anyhow::bail!("LDraw manifest file is not an object"); + }; + let path = required_string(file, "path")?; + crate::ldraw::paths::CanonicalPath::parse(&path).map_err(|diagnostic| { + anyhow::anyhow!("invalid manifest path {path:?}: {diagnostic:?}") + })?; + let root = required_string(file, "root")?; + let byte_length = file + .get("size") + .and_then(serde_json::Value::as_i64) + .ok_or_else(|| anyhow::anyhow!("manifest file size is invalid"))?; + let sha256 = digest_bytes(file.get("sha256"))?; + let mime_type = required_string(file, "mimeType")?; + let cids = blob_cids(file.get("chunks"))?; + let target_cid = cids.first().cloned().unwrap_or_default(); + sqlx::query!( + "INSERT INTO ldraw_manifest_files (resource_uri, root, canonical_path, target_resource_uri, target_cid, byte_length, sha256, mime_type, ordinal) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + manifest_uri, + root, + path, + manifest_uri, + target_cid, + byte_length, + sha256, + mime_type, + ordinal as i64, + ) + .execute(&mut **tx) + .await?; + } + Ok(()) +} + +async fn project_ldraw_manifest_delete( + tx: &mut Transaction<'_, Sqlite>, + e: &ProjectionEvent, +) -> anyhow::Result<()> { + let uri = at_uri(&e.did, &e.collection, &e.rkey); + sqlx::query!( + "DELETE FROM ldraw_manifest_files WHERE resource_uri = ?", + uri + ) + .execute(&mut **tx) + .await?; + Ok(()) +} + +fn required_string( + object: &serde_json::Map, + name: &str, +) -> anyhow::Result { + object + .get(name) + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned) + .ok_or_else(|| anyhow::anyhow!("LDraw field {name} is missing or invalid")) +} + +fn digest_bytes(value: Option<&serde_json::Value>) -> anyhow::Result> { + let Some(value) = value else { + anyhow::bail!("LDraw SHA-256 is missing"); + }; + let bytes = value + .as_array() + .map(|array| { + array + .iter() + .filter_map(serde_json::Value::as_u64) + .map(|byte| byte as u8) + .collect::>() + }) + .or_else(|| value.as_str().and_then(decode_hex)) + .ok_or_else(|| anyhow::anyhow!("LDraw SHA-256 is invalid"))?; + if bytes.len() != 32 { + anyhow::bail!("LDraw SHA-256 must contain 32 bytes"); + } + Ok(bytes) +} + +fn blob_cids(value: Option<&serde_json::Value>) -> anyhow::Result> { + let Some(chunks) = value.and_then(serde_json::Value::as_array) else { + anyhow::bail!("LDraw chunks are missing"); + }; + let mut cids = Vec::with_capacity(chunks.len()); + for chunk in chunks { + let cid = chunk + .get("ref") + .and_then(|reference| reference.get("$link")) + .and_then(serde_json::Value::as_str) + .or_else(|| chunk.get("$link").and_then(serde_json::Value::as_str)) + .ok_or_else(|| anyhow::anyhow!("LDraw chunk does not contain a CID"))?; + cids.push(cid.to_owned()); + } + Ok(cids) +} + +fn decode_hex(value: &str) -> Option> { + if value.len() % 2 != 0 { + return None; + } + (0..value.len()) + .step_by(2) + .map(|index| u8::from_str_radix(&value[index..index + 2], 16).ok()) + .collect() +} + +fn source_identity(uri: &str, cids: &[String], sha256: &[u8], byte_length: i64) -> String { + let mut content = serde_json::to_vec(cids).unwrap_or_default(); + content.extend_from_slice(sha256); + content.extend_from_slice(&byte_length.to_be_bytes()); + format!( + "{uri}|{}", + content + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ) +} + // --------------------------------------------------------------------------- // Actor profile + identity projections // --------------------------------------------------------------------------- diff --git a/src/ldraw/mod.rs b/src/ldraw/mod.rs new file mode 100644 index 0000000..cc9a429 --- /dev/null +++ b/src/ldraw/mod.rs @@ -0,0 +1,8 @@ +//! PM-76 LDraw resource identity, canonical paths, verification, and resolver API. + +pub mod paths; +pub mod resolve; +pub mod verification; + +pub use paths::{CanonicalPath, PathDiagnostic, RootId}; +pub use resolve::{CacheKey, LdrawResourceResolver, ResolveError, ResolvedResource}; diff --git a/src/ldraw/paths.rs b/src/ldraw/paths.rs new file mode 100644 index 0000000..f051b10 --- /dev/null +++ b/src/ldraw/paths.rs @@ -0,0 +1,181 @@ +use std::fmt; + +use unicode_normalization::UnicodeNormalization; + +/// A validated, NFC-preserving virtual filename. It is never interpreted as a URL. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct CanonicalPath(String); + +impl CanonicalPath { + pub fn parse(input: &str) -> Result { + if input.is_empty() || input.starts_with('/') || input.ends_with('/') { + return Err(PathDiagnostic::Slash); + } + if input.contains('%') { + return Err(PathDiagnostic::Percent); + } + if input.contains('\\') { + return Err(PathDiagnostic::Backslash); + } + if input.contains('?') || input.contains('#') { + return Err(PathDiagnostic::QueryOrFragment); + } + if input.chars().any(|c| c.is_control() || c == ':') { + return Err(PathDiagnostic::ControlOrColon); + } + if input.nfc().collect::() != input { + return Err(PathDiagnostic::NotNfc); + } + for segment in input.split('/') { + if segment.is_empty() || segment == "." || segment == ".." { + return Err(PathDiagnostic::Segment); + } + } + Ok(Self(input.to_owned())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn segments(&self) -> impl Iterator { + self.0.split('/') + } +} + +impl fmt::Display for CanonicalPath { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum RootId { + Mpd, + Manifest, + Models, + Parts, + P, + OfficialParts, + OfficialP, +} + +impl RootId { + pub fn as_str(self) -> &'static str { + match self { + Self::Mpd => "mpd", + Self::Manifest => "manifest", + Self::Models => "models", + Self::Parts => "parts", + Self::P => "p", + Self::OfficialParts => "official-parts", + Self::OfficialP => "official-p", + } + } + + pub fn case_insensitive(self) -> bool { + !matches!(self, Self::Mpd | Self::Manifest) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PathDiagnostic { + Slash, + Percent, + Backslash, + QueryOrFragment, + ControlOrColon, + NotNfc, + Segment, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct LookupEntry { + pub root: RootId, + pub path: CanonicalPath, + pub target: String, +} + +/// Resolve according to PM-75's fixed source precedence. +/// The maps contain already validated entries and only perform filename lookup. +pub fn resolve_precedence( + path: &CanonicalPath, + mpd: &[LookupEntry], + manifest: &[LookupEntry], + uploaded: &[LookupEntry], + official: &[LookupEntry], +) -> Option { + exact(path, mpd) + .or_else(|| exact(path, manifest)) + .or_else(|| roots(path, uploaded, &[RootId::Models, RootId::Parts, RootId::P])) + .or_else(|| roots(path, official, &[RootId::OfficialParts, RootId::OfficialP])) +} + +fn exact(path: &CanonicalPath, entries: &[LookupEntry]) -> Option { + entries.iter().find(|e| e.path == *path).cloned() +} + +fn roots(path: &CanonicalPath, entries: &[LookupEntry], roots: &[RootId]) -> Option { + roots.iter().find_map(|root| { + entries + .iter() + .filter(|e| e.root == *root) + .find(|e| { + e.path == *path + || (root.case_insensitive() + && e.path.as_str().eq_ignore_ascii_case(path.as_str())) + }) + .cloned() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_unsafe_and_non_nfc_paths() { + for value in [ + "", + "/a", + "a/", + "a//b", + "a/../b", + "a/./b", + "a%2Fb", + "a\\b", + "a:b", + "a?b", + "a#b", + "a\u{0000}b", + ] { + assert!(CanonicalPath::parse(value).is_err(), "{value:?}"); + } + assert!(CanonicalPath::parse("cafe\u{301}").is_err()); + assert_eq!(CanonicalPath::parse("café").unwrap().as_str(), "café"); + } + + #[test] + fn precedence_and_ascii_case_policy_are_stable() { + let path = CanonicalPath::parse("parts/3001.dat").unwrap(); + let entry = |root, target: &str| LookupEntry { + root, + path: path.clone(), + target: target.into(), + }; + let found = + resolve_precedence(&path, &[], &[], &[entry(RootId::Models, "model")], &[]).unwrap(); + assert_eq!(found.target, "model"); + let upper = CanonicalPath::parse("PARTS/3001.DAT").unwrap(); + assert!( + resolve_precedence(&upper, &[], &[], &[entry(RootId::Models, "model")], &[]).is_some() + ); + let mpd = entry(RootId::Mpd, "mpd"); + assert_eq!( + resolve_precedence(&path, &[mpd], &[], &[], &[]) + .unwrap() + .target, + "mpd" + ); + } +} diff --git a/src/ldraw/resolve.rs b/src/ldraw/resolve.rs new file mode 100644 index 0000000..c698e2e --- /dev/null +++ b/src/ldraw/resolve.rs @@ -0,0 +1,320 @@ +use async_trait::async_trait; +use jacquard_common::types::string::{AtUri, Did}; +use sqlx::SqlitePool; +use std::collections::HashSet; + +use super::paths::{CanonicalPath, LookupEntry, PathDiagnostic, RootId, resolve_precedence}; + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct SourceIdentity { + pub resource: AtUri, + pub content_hash: Vec, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct TargetIdentity { + pub blob_cids: Vec, +} + +/// The sole cache and route binding key shared across PM-76 and PM-80. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct CacheKey { + pub principal: Option, + pub project: AtUri, + pub source: SourceIdentity, + pub root: RootId, + pub path: CanonicalPath, + pub target: TargetIdentity, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ResourceIdentity { + pub uri: AtUri, + pub content_hash: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ByteSource { + pub resource_uri: AtUri, + pub blob_cids: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ResolvedResource { + pub cache_key: CacheKey, + pub resource: ResourceIdentity, + pub root: RootId, + pub path: CanonicalPath, + pub target: TargetIdentity, + pub mime_type: String, + pub byte_length: i64, + pub sha256: Vec, + pub source: ByteSource, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DiagnosticCode { + InvalidManifest, + ExpiredAttempts, + Revoked, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CollisionDiagnostic { + DuplicatePath, + FoldedPath, + OutOfRoot, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum IntegrityDiagnostic { + LengthMismatch, + DigestMismatch, + BlobCidMismatch, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AvailabilityDiagnostic { + SourceUnavailable, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ResolveError { + NotFound, + Unauthorized, + Unverified, + Verifying, + Rejected { diagnostic: DiagnosticCode }, + Revoked, + SourceChanged, + InvalidPath { diagnostic: PathDiagnostic }, + Collision { diagnostic: CollisionDiagnostic }, + Integrity { diagnostic: IntegrityDiagnostic }, + Unavailable { diagnostic: AvailabilityDiagnostic }, +} + +#[async_trait] +pub trait LdrawResourceResolver: Send + Sync { + async fn resolve( + &self, + principal: Option<&Did>, + project: &AtUri, + path: &CanonicalPath, + ) -> Result; +} + +#[derive(Clone)] +pub struct SqliteResolver { + pool: SqlitePool, +} + +impl SqliteResolver { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } + + /// Resolve the canonical candidate without requiring verification. This is + /// the only lookup path used by the lazy verifier and the verified resolver. + pub async fn candidate( + &self, + principal: Option<&Did>, + project: &AtUri, + path: &CanonicalPath, + ) -> Result { + let rows = sqlx::query!( + r#"SELECT resource_uri, owner_did, sha256, ordered_blob_cids, root, canonical_path, mime_type, byte_length + FROM ldraw_resources + WHERE project_uri = ? AND revoked_at IS NULL + AND (owner_did = ? OR owner_did = 'public')"#, + project.as_ref(), principal.map(Did::as_ref), + ).fetch_all(&self.pool).await.map_err(|_| ResolveError::Unavailable { diagnostic: AvailabilityDiagnostic::SourceUnavailable })?; + let mut entries = Vec::with_capacity(rows.len()); + let mut by_uri = std::collections::HashMap::new(); + for row in rows { + let Some(root) = root_id(&row.root) else { + continue; + }; + let Ok(candidate_path) = CanonicalPath::parse(&row.canonical_path) else { + continue; + }; + let Some(uri) = row.resource_uri.clone() else { + continue; + }; + entries.push(LookupEntry { + root, + path: candidate_path, + target: uri.clone(), + }); + by_uri.insert(uri, row); + } + let folded = entries + .iter() + .filter(|entry| entry.root.case_insensitive()) + .fold(HashSet::new(), |mut set, entry| { + set.insert((entry.root, entry.path.as_str().to_ascii_lowercase())); + set + }); + if folded.len() + != entries + .iter() + .filter(|entry| entry.root.case_insensitive()) + .count() + { + return Err(ResolveError::Collision { + diagnostic: CollisionDiagnostic::FoldedPath, + }); + } + let mut mpd = Vec::new(); + let mut manifest = Vec::new(); + let mut uploaded = Vec::new(); + let mut official = Vec::new(); + for entry in entries { + match entry.root { + RootId::Mpd => mpd.push(entry), + RootId::Manifest => manifest.push(entry), + RootId::Models | RootId::Parts | RootId::P => uploaded.push(entry), + RootId::OfficialParts | RootId::OfficialP => official.push(entry), + } + } + let chosen = resolve_precedence(path, &mpd, &manifest, &uploaded, &official) + .ok_or(ResolveError::NotFound)?; + let row = by_uri + .get(&chosen.target) + .ok_or(ResolveError::SourceChanged)?; + let resource = AtUri::new_owned(&chosen.target).map_err(|_| ResolveError::SourceChanged)?; + let blobs = parse_blob_cids(&row.ordered_blob_cids); + let target = TargetIdentity { + blob_cids: blobs.clone(), + }; + let content_hash = source_hash(&blobs, &row.sha256, row.byte_length); + let cache_key = CacheKey { + principal: principal.cloned(), + project: project.clone(), + source: SourceIdentity { + resource: resource.clone(), + content_hash: content_hash.clone(), + }, + root: chosen.root, + path: chosen.path.clone(), + target: target.clone(), + }; + Ok(ResolvedResource { + cache_key, + resource: ResourceIdentity { + uri: resource.clone(), + content_hash, + }, + root: chosen.root, + path: chosen.path, + target, + mime_type: row.mime_type.clone(), + byte_length: row.byte_length, + sha256: row.sha256.clone(), + source: ByteSource { + resource_uri: resource, + blob_cids: blobs, + }, + }) + } +} + +#[async_trait] +impl LdrawResourceResolver for SqliteResolver { + async fn resolve( + &self, + principal: Option<&Did>, + project: &AtUri, + path: &CanonicalPath, + ) -> Result { + let resource = self.candidate(principal, project, path).await?; + let verification = sqlx::query!( + "SELECT state, revoked_at FROM ldraw_verification WHERE resource_uri = ?", + resource.resource.uri.as_ref() + ) + .fetch_optional(&self.pool) + .await + .map_err(|_| ResolveError::Unavailable { + diagnostic: AvailabilityDiagnostic::SourceUnavailable, + })? + .ok_or(ResolveError::Unverified)?; + if verification.revoked_at.is_some() { + return Err(ResolveError::Revoked); + } + match verification.state.as_str() { + "verified" => Ok(resource), + "verifying" => Err(ResolveError::Verifying), + "rejected" => Err(ResolveError::Rejected { + diagnostic: DiagnosticCode::InvalidManifest, + }), + _ => Err(ResolveError::Unverified), + } + } +} + +/// Canonical serialization shared by projection, route bindings, and resolver. +pub fn serialize_source_identity(source: &SourceIdentity) -> String { + format!( + "{}|{}", + source.resource.as_ref(), + hex_bytes(&source.content_hash) + ) +} + +fn hex_bytes(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn parse_blob_cids(value: &str) -> Vec { + serde_json::from_str(value).unwrap_or_default() +} + +fn source_hash(cids: &[String], sha256: &[u8], size: i64) -> Vec { + let mut value = serde_json::to_vec(cids).unwrap_or_default(); + value.extend_from_slice(sha256); + value.extend_from_slice(&size.to_be_bytes()); + value +} + +fn root_id(value: &str) -> Option { + Some(match value { + "mpd" => RootId::Mpd, + "manifest" => RootId::Manifest, + "models" => RootId::Models, + "parts" => RootId::Parts, + "p" => RootId::P, + "official-parts" => RootId::OfficialParts, + "official-p" => RootId::OfficialP, + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_cache_dimension_changes_identity() { + let project = AtUri::new_owned("at://did:plc:a/space.polymodel.library.thing/p").unwrap(); + let path = CanonicalPath::parse("parts/3001.dat").unwrap(); + let did = Did::new_owned("did:plc:a").unwrap(); + let base = CacheKey { + principal: Some(did.clone()), + project: project.clone(), + source: SourceIdentity { + resource: project.clone(), + content_hash: vec![1], + }, + root: RootId::Parts, + path: path.clone(), + target: TargetIdentity { + blob_cids: vec!["bafk".into()], + }, + }; + let mut changed = base.clone(); + changed.principal = None; + assert_ne!(base, changed); + changed = base.clone(); + changed.path = CanonicalPath::parse("parts/3002.dat").unwrap(); + assert_ne!(base, changed); + } +} diff --git a/src/ldraw/verification.rs b/src/ldraw/verification.rs new file mode 100644 index 0000000..ff92b67 --- /dev/null +++ b/src/ldraw/verification.rs @@ -0,0 +1,167 @@ +use std::fmt; + +pub const LEASE_NANOS: i64 = 60_000_000_000; +pub const MAX_EXPIRED_ATTEMPTS: u32 = 3; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum VerificationState { + Unverified, + Verifying, + Verified, + Rejected, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VerificationRecord { + pub state: VerificationState, + pub source_identity: String, + pub attempt_token: Option, + pub lease_expires_at: Option, + pub expired_attempts: u32, + pub diagnostic: Option, + pub revoked_at: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Transition { + Claim { + token: String, + lease_expires_at: i64, + }, + AlreadyVerifying, + Verified, + Rejected(String), + Reset, +} + +#[derive(Debug, thiserror::Error, Eq, PartialEq)] +pub enum VerificationError { + #[error("verification attempt is not active")] + InvalidAttempt, + #[error("verification source changed")] + SourceChanged, + #[error("verification lease expired")] + Expired, + #[error("verification is revoked")] + Revoked, +} + +pub fn claim(record: &VerificationRecord, now: i64, token: impl Into) -> Transition { + if record.revoked_at.is_some() { + return Transition::Rejected("revoked".into()); + } + if record.state == VerificationState::Verifying { + if record.lease_expires_at.is_some_and(|expiry| expiry > now) { + return Transition::AlreadyVerifying; + } + if record.expired_attempts + 1 >= MAX_EXPIRED_ATTEMPTS { + return Transition::Rejected("verification-expired-attempt-limit-v1".into()); + } + } + Transition::Claim { + token: token.into(), + lease_expires_at: now + LEASE_NANOS, + } +} + +pub fn complete( + record: &VerificationRecord, + token: &str, + source_identity: &str, + now: i64, +) -> Result { + if record.revoked_at.is_some() { + return Err(VerificationError::Revoked); + } + if record.state != VerificationState::Verifying + || record.attempt_token.as_deref() != Some(token) + { + return Err(VerificationError::InvalidAttempt); + } + if record.source_identity != source_identity { + return Err(VerificationError::SourceChanged); + } + if record.lease_expires_at.is_none_or(|expiry| expiry <= now) { + return Err(VerificationError::Expired); + } + Ok(Transition::Verified) +} + +pub fn source_changed( + old: &VerificationRecord, + source_identity: impl Into, +) -> VerificationRecord { + VerificationRecord { + state: VerificationState::Unverified, + source_identity: source_identity.into(), + attempt_token: None, + lease_expires_at: None, + expired_attempts: 0, + diagnostic: None, + revoked_at: old.revoked_at, + } +} + +impl fmt::Display for VerificationState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Unverified => "unverified", + Self::Verifying => "verifying", + Self::Verified => "verified", + Self::Rejected => "rejected", + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record() -> VerificationRecord { + VerificationRecord { + state: VerificationState::Unverified, + source_identity: "source-a".into(), + attempt_token: None, + lease_expires_at: None, + expired_attempts: 0, + diagnostic: None, + revoked_at: None, + } + } + + #[test] + fn claims_coalesce_and_completion_is_cas_guarded() { + let mut current = record(); + let Transition::Claim { + token, + lease_expires_at, + } = claim(¤t, 10, "one") + else { + panic!() + }; + current.state = VerificationState::Verifying; + current.attempt_token = Some(token.clone()); + current.lease_expires_at = Some(lease_expires_at); + assert_eq!(claim(¤t, 11, "two"), Transition::AlreadyVerifying); + assert_eq!( + complete(¤t, "wrong", "source-a", 11), + Err(VerificationError::InvalidAttempt) + ); + assert_eq!( + complete(¤t, &token, "source-a", 11), + Ok(Transition::Verified) + ); + } + + #[test] + fn source_change_resets_attempt_counter_and_lease() { + let mut current = record(); + current.state = VerificationState::Verified; + current.expired_attempts = 2; + current.attempt_token = Some("old".into()); + let reset = source_changed(¤t, "source-b"); + assert_eq!(reset.state, VerificationState::Unverified); + assert_eq!(reset.expired_attempts, 0); + assert!(reset.attempt_token.is_none()); + } +} diff --git a/src/main.rs b/src/main.rs index 527b2cd..68eb2dc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,9 @@ mod viewer_route; // Server-only indexing infrastructure (Hydrant firehose + SQLite projection). #[cfg(any(feature = "server", test))] mod indexing; +// Server-only LDraw resource identity, resolver, and verification contract. +#[cfg(any(feature = "server", test))] +mod ldraw; // Server-only XRPC read endpoints (appview query layer). #[cfg(any(feature = "server", test))] mod appview;