learn and share notes on atproto (wip) 🦉 malfestio.stormlightlabs.org/
readability solid axum atproto srs
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: Implement deck publishing/unpublishing API

+455 -75
+98
Cargo.lock
··· 12 12 ] 13 13 14 14 [[package]] 15 + name = "android_system_properties" 16 + version = "0.1.5" 17 + source = "registry+https://github.com/rust-lang/crates.io-index" 18 + checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" 19 + dependencies = [ 20 + "libc", 21 + ] 22 + 23 + [[package]] 15 24 name = "anstream" 16 25 version = "0.6.21" 17 26 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 66 75 version = "1.1.2" 67 76 source = "registry+https://github.com/rust-lang/crates.io-index" 68 77 checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" 78 + 79 + [[package]] 80 + name = "autocfg" 81 + version = "1.5.0" 82 + source = "registry+https://github.com/rust-lang/crates.io-index" 83 + checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 69 84 70 85 [[package]] 71 86 name = "axum" ··· 158 173 version = "1.0.4" 159 174 source = "registry+https://github.com/rust-lang/crates.io-index" 160 175 checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 176 + 177 + [[package]] 178 + name = "chrono" 179 + version = "0.4.42" 180 + source = "registry+https://github.com/rust-lang/crates.io-index" 181 + checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" 182 + dependencies = [ 183 + "iana-time-zone", 184 + "js-sys", 185 + "num-traits", 186 + "serde", 187 + "wasm-bindgen", 188 + "windows-link", 189 + ] 161 190 162 191 [[package]] 163 192 name = "clap" ··· 552 581 ] 553 582 554 583 [[package]] 584 + name = "iana-time-zone" 585 + version = "0.1.64" 586 + source = "registry+https://github.com/rust-lang/crates.io-index" 587 + checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" 588 + dependencies = [ 589 + "android_system_properties", 590 + "core-foundation-sys", 591 + "iana-time-zone-haiku", 592 + "js-sys", 593 + "log", 594 + "wasm-bindgen", 595 + "windows-core", 596 + ] 597 + 598 + [[package]] 599 + name = "iana-time-zone-haiku" 600 + version = "0.1.2" 601 + source = "registry+https://github.com/rust-lang/crates.io-index" 602 + checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 603 + dependencies = [ 604 + "cc", 605 + ] 606 + 607 + [[package]] 555 608 name = "icu_collections" 556 609 version = "2.1.1" 557 610 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 764 817 version = "0.1.0" 765 818 dependencies = [ 766 819 "axum", 820 + "chrono", 767 821 "malfestio-core", 768 822 "reqwest", 769 823 "serde", ··· 846 900 version = "0.1.0" 847 901 source = "registry+https://github.com/rust-lang/crates.io-index" 848 902 checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" 903 + 904 + [[package]] 905 + name = "num-traits" 906 + version = "0.2.19" 907 + source = "registry+https://github.com/rust-lang/crates.io-index" 908 + checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 909 + dependencies = [ 910 + "autocfg", 911 + ] 849 912 850 913 [[package]] 851 914 name = "once_cell" ··· 1817 1880 dependencies = [ 1818 1881 "js-sys", 1819 1882 "wasm-bindgen", 1883 + ] 1884 + 1885 + [[package]] 1886 + name = "windows-core" 1887 + version = "0.62.2" 1888 + source = "registry+https://github.com/rust-lang/crates.io-index" 1889 + checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" 1890 + dependencies = [ 1891 + "windows-implement", 1892 + "windows-interface", 1893 + "windows-link", 1894 + "windows-result", 1895 + "windows-strings", 1896 + ] 1897 + 1898 + [[package]] 1899 + name = "windows-implement" 1900 + version = "0.60.2" 1901 + source = "registry+https://github.com/rust-lang/crates.io-index" 1902 + checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" 1903 + dependencies = [ 1904 + "proc-macro2", 1905 + "quote", 1906 + "syn", 1907 + ] 1908 + 1909 + [[package]] 1910 + name = "windows-interface" 1911 + version = "0.59.3" 1912 + source = "registry+https://github.com/rust-lang/crates.io-index" 1913 + checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" 1914 + dependencies = [ 1915 + "proc-macro2", 1916 + "quote", 1917 + "syn", 1820 1918 ] 1821 1919 1822 1920 [[package]]
+4
crates/core/src/model.rs
··· 15 15 } 16 16 17 17 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 18 + #[serde(tag = "type", content = "content")] 18 19 pub enum Visibility { 19 20 Private, 20 21 Unlisted, 21 22 Public, 23 + SharedWith(Vec<String>), 22 24 } 23 25 24 26 #[derive(Debug, Clone, Serialize, Deserialize)] ··· 29 31 pub description: String, 30 32 pub tags: Vec<String>, 31 33 pub visibility: Visibility, 34 + pub published_at: Option<String>, 35 + pub fork_of: Option<String>, 32 36 }
+1
crates/server/Cargo.toml
··· 5 5 6 6 [dependencies] 7 7 axum = "0.8.8" 8 + chrono = { version = "0.4.42", features = ["serde"] } 8 9 malfestio-core = { version = "0.1.0", path = "../core" } 9 10 reqwest = { version = "0.12.28", features = ["json"] } 10 11 serde = "1.0.228"
+2 -2
crates/server/src/api/auth.rs
··· 17 17 handle: String, 18 18 } 19 19 20 - /// TODO: Make PDS URL configurable 20 + /// TODO: Make PDS URL configurable (bluesky users can use their own PDS) 21 21 pub async fn login(Json(payload): Json<LoginRequest>) -> impl IntoResponse { 22 22 let client = reqwest::Client::new(); 23 - let pds_url = "https://bsky.social"; 23 + let pds_url = std::env::var("PDS_URL").unwrap_or_else(|_| "https://bsky.social".to_string()); 24 24 25 25 let resp = client 26 26 .post(format!("{}/xrpc/com.atproto.server.createSession", pds_url))
+185
crates/server/src/api/deck.rs
··· 21 21 visibility: Visibility, 22 22 } 23 23 24 + #[derive(Deserialize)] 25 + pub struct PublishDeckRequest { 26 + pub published: bool, 27 + } 28 + 24 29 pub fn init_db() -> Db { 25 30 Arc::new(RwLock::new(Vec::new())) 26 31 } ··· 40 45 description: payload.description, 41 46 tags: payload.tags, 42 47 visibility: payload.visibility, 48 + published_at: None, 49 + fork_of: None, 43 50 }; 44 51 45 52 db.write().unwrap().push(new_deck.clone()); ··· 63 70 if d.visibility == Visibility::Public { 64 71 return true; 65 72 } 73 + if let Visibility::SharedWith(dids) = &d.visibility 74 + && let Some(did) = &user_did 75 + && dids.contains(did) 76 + { 77 + return true; 78 + } 66 79 false 67 80 }) 68 81 .cloned() ··· 84 97 return Json(deck).into_response(); 85 98 } 86 99 100 + if let Visibility::SharedWith(dids) = &deck.visibility 101 + && let Some(did) = &user_did 102 + && dids.contains(did) 103 + { 104 + return Json(deck).into_response(); 105 + } 106 + 87 107 if deck.visibility == Visibility::Unlisted { 88 108 return Json(deck).into_response(); 89 109 } ··· 92 112 93 113 (StatusCode::NOT_FOUND, Json(json!({"error": "Deck not found"}))).into_response() 94 114 } 115 + 116 + /// NOTE: Unpublishing sets visibility to Private and clears published_at 117 + pub async fn publish_deck( 118 + State(db): State<Db>, ctx: Option<axum::Extension<UserContext>>, Path(id): Path<String>, 119 + Json(payload): Json<PublishDeckRequest>, 120 + ) -> impl IntoResponse { 121 + let user = match ctx { 122 + Some(axum::Extension(user)) => user, 123 + None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), 124 + }; 125 + 126 + let mut decks = db.write().unwrap(); 127 + if let Some(deck) = decks.iter_mut().find(|d| d.id == id) { 128 + if deck.owner_did != user.did { 129 + return (StatusCode::FORBIDDEN, Json(json!({"error": "Only owner can publish"}))).into_response(); 130 + } 131 + 132 + if payload.published { 133 + deck.visibility = Visibility::Public; 134 + deck.published_at = Some(chrono::Utc::now().to_rfc3339()); 135 + } else { 136 + deck.visibility = Visibility::Private; 137 + deck.published_at = None; 138 + } 139 + return Json(deck.clone()).into_response(); 140 + } 141 + 142 + (StatusCode::NOT_FOUND, Json(json!({"error": "Deck not found"}))).into_response() 143 + } 144 + 145 + #[cfg(test)] 146 + mod tests { 147 + use super::*; 148 + use axum::extract::State; 149 + 150 + fn mock_db() -> Db { 151 + Arc::new(RwLock::new(vec![ 152 + Deck { 153 + id: "deck-public".to_string(), 154 + owner_did: "did:plc:owner".to_string(), 155 + title: "Public Deck".to_string(), 156 + description: "desc".to_string(), 157 + tags: vec![], 158 + visibility: Visibility::Public, 159 + published_at: None, 160 + fork_of: None, 161 + }, 162 + Deck { 163 + id: "deck-private".to_string(), 164 + owner_did: "did:plc:owner".to_string(), 165 + title: "Private Deck".to_string(), 166 + description: "desc".to_string(), 167 + tags: vec![], 168 + visibility: Visibility::Private, 169 + published_at: None, 170 + fork_of: None, 171 + }, 172 + Deck { 173 + id: "deck-shared".to_string(), 174 + owner_did: "did:plc:owner".to_string(), 175 + title: "Shared Deck".to_string(), 176 + description: "desc".to_string(), 177 + tags: vec![], 178 + visibility: Visibility::SharedWith(vec!["did:plc:friend".to_string()]), 179 + published_at: None, 180 + fork_of: None, 181 + }, 182 + ])) 183 + } 184 + 185 + #[tokio::test] 186 + async fn test_get_public_deck() { 187 + let db = mock_db(); 188 + let response = get_deck(State(db), None, Path("deck-public".to_string())) 189 + .await 190 + .into_response(); 191 + 192 + assert_eq!(response.status(), StatusCode::OK); 193 + } 194 + 195 + #[tokio::test] 196 + async fn test_get_private_deck_owner() { 197 + let db = mock_db(); 198 + let ctx = Some(Extension(UserContext { 199 + did: "did:plc:owner".to_string(), 200 + handle: "owner.bsky.social".to_string(), 201 + })); 202 + 203 + let response = get_deck(State(db), ctx, Path("deck-private".to_string())) 204 + .await 205 + .into_response(); 206 + 207 + assert_eq!(response.status(), StatusCode::OK); 208 + } 209 + 210 + #[tokio::test] 211 + async fn test_get_private_deck_stranger() { 212 + let db = mock_db(); 213 + let ctx = Some(Extension(UserContext { 214 + did: "did:plc:stranger".to_string(), 215 + handle: "stranger.bsky.social".to_string(), 216 + })); 217 + 218 + let response = get_deck(State(db), ctx, Path("deck-private".to_string())) 219 + .await 220 + .into_response(); 221 + 222 + assert_eq!(response.status(), StatusCode::FORBIDDEN); 223 + } 224 + 225 + #[tokio::test] 226 + async fn test_get_shared_deck_permitted() { 227 + let db = mock_db(); 228 + let ctx = Some(Extension(UserContext { 229 + did: "did:plc:friend".to_string(), 230 + handle: "friend.bsky.social".to_string(), 231 + })); 232 + 233 + let response = get_deck(State(db), ctx, Path("deck-shared".to_string())) 234 + .await 235 + .into_response(); 236 + 237 + assert_eq!(response.status(), StatusCode::OK); 238 + } 239 + 240 + #[tokio::test] 241 + async fn test_get_shared_deck_unpermitted() { 242 + let db = mock_db(); 243 + let ctx = Some(Extension(UserContext { 244 + did: "did:plc:stranger".to_string(), 245 + handle: "stranger.bsky.social".to_string(), 246 + })); 247 + 248 + let response = get_deck(State(db), ctx, Path("deck-shared".to_string())) 249 + .await 250 + .into_response(); 251 + 252 + assert_eq!(response.status(), StatusCode::FORBIDDEN); 253 + } 254 + 255 + #[tokio::test] 256 + async fn test_publish_deck() { 257 + let db = mock_db(); 258 + let ctx = Some(Extension(UserContext { 259 + did: "did:plc:owner".to_string(), 260 + handle: "owner.bsky.social".to_string(), 261 + })); 262 + 263 + let response = publish_deck( 264 + State(db.clone()), 265 + ctx, 266 + Path("deck-private".to_string()), 267 + Json(PublishDeckRequest { published: true }), 268 + ) 269 + .await 270 + .into_response(); 271 + 272 + assert_eq!(response.status(), StatusCode::OK); 273 + 274 + let decks = db.read().unwrap(); 275 + let deck = decks.iter().find(|d| d.id == "deck-private").unwrap(); 276 + assert_eq!(deck.visibility, Visibility::Public); 277 + assert!(deck.published_at.is_some()); 278 + } 279 + }
+2 -2
crates/server/src/middleware/auth.rs
··· 13 13 pub handle: String, 14 14 } 15 15 16 - /// TODO: Cache this or use signature verification for performance 16 + /// TODO: Cache or use signature verification for performance 17 17 pub async fn auth_middleware(mut req: Request, next: Next) -> Response { 18 18 let auth_header = req.headers().get(http::header::AUTHORIZATION); 19 19 ··· 29 29 }; 30 30 31 31 let client = reqwest::Client::new(); 32 - let pds_url = "https://bsky.social"; 32 + let pds_url = std::env::var("PDS_URL").unwrap_or_else(|_| "https://bsky.social".to_string()); 33 33 34 34 let resp = client 35 35 .get(format!("{}/xrpc/com.atproto.server.getSession", pds_url))
+5 -71
docs/todo.md
··· 45 45 - Monorepo layout, CI, Axum/Solid skeletons implemented. 46 46 - Backend running on 8080, Frontend on 3000. 47 47 48 - ### Milestone D - Identity + Permissions + Publishing Model 49 - 50 - #### Deliverables 51 - 52 - - Auth MVP: 53 - - BlueSky App Passwords 54 - - ATProto OAuth 55 - - Permission model: 56 - - private / unlisted / public / shared-with 57 - - Publishing: 58 - - draft editing, publish, update, deprecate, fork 59 - 60 - #### Acceptance 61 - 62 - - A user can publish a deck and another user can view it. 48 + - **(Done) Milestone D**: Identity + Permissions + Publishing Model. 49 + - Auth MVP, Permission model (Private/Public/SharedWith), and basic Publishing flow implemented. 50 + - Backend API and Frontend Editor updated with tests covering permissions and publishing. 63 51 64 52 ### Milestone E - Content Authoring (Notes + Cards + Deck Builder) 65 53 ··· 177 165 178 166 - You can run this as a real product with confidence. 179 167 180 - ## "First Cut" Lexicon Fields (Draft) 181 - 182 - ### Note (app.malfestio.note) 183 - 184 - - title: string 185 - - body: richtext/markdown string 186 - - tags: string[] 187 - - links: { uri, title?, type? }[] 188 - - createdAt, updatedAt 189 - - visibility: "private|unlisted|public" (consider leaving as string + documented values) 190 - 191 - ### Card (app.malfestio.card) 192 - 193 - - deckRef: at-uri / stable ref 194 - - front: string (markdown) 195 - - back: string (markdown) 196 - - cardType: "basic|cloze" (optional) 197 - - hints?: string[] 198 - - media?: { kind, uri, alt? }[] 199 - 200 - ### Deck (app.malfestio.deck) 201 - 202 - - title, description 203 - - tags 204 - - cardRefs: at-uri[] 205 - - sourceRefs: at-uri[] (articles/lectures) 206 - - license?: string (strongly recommended) 207 - 208 - ### Article (app.malfestio.source.article) 168 + ## Lexicon Definitions 209 169 210 - - url 211 - - title 212 - - author? 213 - - publishedAt? 214 - - extractedTextRef? (only if you store it) 215 - - highlights?: { quote, start?, end? }[] 216 - 217 - ### Lecture (app.malfestio.source.lecture) 218 - 219 - - url 220 - - title 221 - - creator? 222 - - timestamps?: { t, label, noteRef? }[] 223 - 224 - ### Collection/Path (app.malfestio.collection) 225 - 226 - - title, description 227 - - items: { type, ref, note? }[] 228 - - tags 229 - 230 - ### Comment (app.malfestio.thread.comment) 231 - 232 - - subjectRef (deck/card/note ref) 233 - - body 234 - - replyTo? 235 - 236 - (Keep everything extensible; avoid hard commitments early.) 170 + Authoritative Lexicon definitions are located in the [`lexicons/`](../lexicons) directory. 237 171 238 172 ## Open Questions (Parked Decisions) 239 173
+61
web/src/components/DeckEditor.test.tsx
··· 1 + import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library"; 2 + import { afterEach, describe, expect, it, vi } from "vitest"; 3 + import { api } from "../lib/api"; 4 + import { DeckEditor } from "./DeckEditor"; 5 + 6 + vi.mock("../lib/api", () => ({ api: { post: vi.fn() } })); 7 + 8 + describe("DeckEditor", () => { 9 + afterEach(cleanup); 10 + 11 + it("renders form fields", () => { 12 + render(() => <DeckEditor />); 13 + expect(screen.getByLabelText(/Title/i)).toBeInTheDocument(); 14 + expect(screen.getByLabelText(/Description/i)).toBeInTheDocument(); 15 + expect(screen.getByRole("combobox", { name: /Visibility/i })).toBeInTheDocument(); 16 + }); 17 + 18 + it("shows shared with input when SharedWith is selected", () => { 19 + render(() => <DeckEditor />); 20 + const select = screen.getByRole("combobox", { name: /Visibility/i }); 21 + 22 + fireEvent.change(select, { target: { value: "SharedWith" } }); 23 + 24 + expect(screen.getByPlaceholderText(/did:plc/i)).toBeInTheDocument(); 25 + }); 26 + 27 + it("submits correct payload for Private deck", async () => { 28 + render(() => <DeckEditor />); 29 + 30 + fireEvent.input(screen.getByLabelText(/Title/i), { target: { value: "My Deck" } }); 31 + fireEvent.change(screen.getByRole("combobox", { name: /Visibility/i }), { target: { value: "Private" } }); 32 + 33 + const submitBtn = screen.getByRole("button", { name: /Create Deck/i }); 34 + fireEvent.click(submitBtn); 35 + 36 + expect(api.post).toHaveBeenCalledWith( 37 + "/decks", 38 + expect.objectContaining({ title: "My Deck", visibility: "Private" }), 39 + ); 40 + }); 41 + 42 + it("submits correct payload for SharedWith deck", async () => { 43 + render(() => <DeckEditor />); 44 + 45 + fireEvent.input(screen.getByLabelText(/Title/i), { target: { value: "Shared Deck" } }); 46 + 47 + const select = screen.getByRole("combobox", { name: /Visibility/i }); 48 + fireEvent.change(select, { target: { value: "SharedWith" } }); 49 + 50 + const sharedInput = screen.getByPlaceholderText(/did:plc/i); 51 + fireEvent.input(sharedInput, { target: { value: "did:plc:123, did:plc:456" } }); 52 + 53 + const submitBtn = screen.getByRole("button", { name: /Create Deck/i }); 54 + fireEvent.click(submitBtn); 55 + 56 + expect(api.post).toHaveBeenCalledWith( 57 + "/decks", 58 + expect.objectContaining({ title: "Shared Deck", visibility: { SharedWith: ["did:plc:123", "did:plc:456"] } }), 59 + ); 60 + }); 61 + });
+84
web/src/components/DeckEditor.tsx
··· 1 + import { createSignal, Show } from "solid-js"; 2 + import { api } from "../lib/api"; 3 + import type { Visibility } from "../lib/store"; 4 + 5 + export function DeckEditor() { 6 + const [title, setTitle] = createSignal(""); 7 + const [description, setDescription] = createSignal(""); 8 + const [visibilityType, setVisibilityType] = createSignal<string>("Private"); 9 + const [sharedWith, setSharedWith] = createSignal(""); 10 + 11 + const handleSubmit = async (e: Event) => { 12 + e.preventDefault(); 13 + 14 + let visibility: Visibility; 15 + if (visibilityType() === "SharedWith") { 16 + visibility = { SharedWith: sharedWith().split(",").map(s => s.trim()).filter(s => s) }; 17 + } else { 18 + visibility = visibilityType() as Visibility; 19 + } 20 + 21 + const payload = { title: title(), description: description(), tags: [], visibility }; 22 + 23 + await api.post("/decks", payload); 24 + // TODO: Navigate or show success 25 + alert("Deck created!"); 26 + }; 27 + 28 + return ( 29 + <form onSubmit={handleSubmit} class="space-y-4 max-w-md mx-auto p-4 border rounded"> 30 + <div> 31 + <label for="title" class="block text-sm font-medium">Title</label> 32 + <input 33 + id="title" 34 + type="text" 35 + value={title()} 36 + onInput={(e) => setTitle(e.target.value)} 37 + class="mt-1 block w-full rounded-md border-gray-300 shadow-sm border p-2" 38 + required /> 39 + </div> 40 + 41 + <div> 42 + <label for="description" class="block text-sm font-medium">Description</label> 43 + <textarea 44 + id="description" 45 + value={description()} 46 + onInput={(e) => setDescription(e.target.value)} 47 + class="mt-1 block w-full rounded-md border-gray-300 shadow-sm border p-2" /> 48 + </div> 49 + 50 + <div> 51 + <label for="visibility" class="block text-sm font-medium">Visibility</label> 52 + <select 53 + id="visibility" 54 + value={visibilityType()} 55 + onChange={(e) => setVisibilityType(e.target.value)} 56 + class="mt-1 block w-full rounded-md border-gray-300 shadow-sm border p-2" 57 + aria-label="Visibility"> 58 + <option value="Private">Private</option> 59 + <option value="Unlisted">Unlisted</option> 60 + <option value="Public">Public</option> 61 + <option value="SharedWith">Shared With...</option> 62 + </select> 63 + </div> 64 + 65 + <Show when={visibilityType() === "SharedWith"}> 66 + <div> 67 + <label class="block text-sm font-medium">Share with DIDs (comma separated)</label> 68 + <input 69 + type="text" 70 + value={sharedWith()} 71 + onInput={(e) => setSharedWith(e.target.value)} 72 + class="mt-1 block w-full rounded-md border-gray-300 shadow-sm border p-2" 73 + placeholder="did:plc:..., did:plc:..." /> 74 + </div> 75 + </Show> 76 + 77 + <button 78 + type="submit" 79 + class="inline-flex justify-center rounded-md border border-transparent bg-indigo-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none"> 80 + Create Deck 81 + </button> 82 + </form> 83 + ); 84 + }
+13
web/src/lib/store.ts
··· 36 36 } 37 37 38 38 export const authStore = createRoot(createAuthStore); 39 + 40 + export type Visibility = "Private" | "Unlisted" | "Public" | { SharedWith: string[] }; 41 + 42 + export type Deck = { 43 + id: string; 44 + owner_did: string; 45 + title: string; 46 + description: string; 47 + tags: string[]; 48 + visibility: Visibility; 49 + published_at?: string; 50 + fork_of?: string; 51 + };