diff --git a/crates/server/src/firehose.rs b/crates/server/src/firehose.rs index 575af5c..0c18b09 100644 --- a/crates/server/src/firehose.rs +++ b/crates/server/src/firehose.rs @@ -1,7 +1,7 @@ //! Firehose consumption via AT Protocol Jetstream. //! //! Provides WebSocket subscription to Jetstream for indexing public records. -//! Filters for `app.malfestio.*` collections and indexes them locally. +//! Filters for `org.stormlightlabs.malfestio.*` collections and indexes them locally. use crate::db::DbPool; use async_trait::async_trait; @@ -15,7 +15,11 @@ use tokio_util::sync::CancellationToken; pub const DEFAULT_JETSTREAM_URL: &str = "wss://jetstream2.us-west.bsky.network/subscribe"; /// Collections we're interested in indexing. -pub const MALFESTIO_COLLECTIONS: &[&str] = &["app.malfestio.deck", "app.malfestio.card", "app.malfestio.note"]; +pub const MALFESTIO_COLLECTIONS: &[&str] = &[ + "org.stormlightlabs.malfestio.deck", + "org.stormlightlabs.malfestio.card", + "org.stormlightlabs.malfestio.note", +]; /// Deck record structure matching the Lexicon schema. #[derive(Debug, Deserialize)] @@ -85,7 +89,7 @@ impl MalfestioEventHandler { async fn index_deck( &self, did: &str, rkey: &str, rev: &str, record: &Value, ) -> Result<(), Box> { - let at_uri = format!("at://{}/app.malfestio.deck/{}", did, rkey); + let at_uri = format!("at://{}/org.stormlightlabs.malfestio.deck/{}", did, rkey); let deck: DeckRecord = serde_json::from_value(record.clone())?; let created_at = parse_record_datetime(&deck.created_at); @@ -122,7 +126,7 @@ impl MalfestioEventHandler { async fn index_card( &self, did: &str, rkey: &str, rev: &str, record: &Value, ) -> Result<(), Box> { - let at_uri = format!("at://{}/app.malfestio.card/{}", did, rkey); + let at_uri = format!("at://{}/org.stormlightlabs.malfestio.card/{}", did, rkey); let card: CardRecord = serde_json::from_value(record.clone())?; let created_at = parse_record_datetime(&card.created_at); let card_type = card.card_type.unwrap_or_else(|| "basic".to_string()); @@ -160,7 +164,7 @@ impl MalfestioEventHandler { async fn index_note( &self, did: &str, rkey: &str, rev: &str, record: &Value, ) -> Result<(), Box> { - let at_uri = format!("at://{}/app.malfestio.note/{}", did, rkey); + let at_uri = format!("at://{}/org.stormlightlabs.malfestio.note/{}", did, rkey); let note: NoteRecord = serde_json::from_value(record.clone())?; let created_at = parse_record_datetime(¬e.created_at); let visibility = note.visibility.unwrap_or_else(|| "public".to_string()); @@ -201,9 +205,9 @@ impl MalfestioEventHandler { let client = self.pool.get().await?; let table = match collection { - "app.malfestio.deck" => "indexed_decks", - "app.malfestio.card" => "indexed_cards", - "app.malfestio.note" => "indexed_notes", + "org.stormlightlabs.malfestio.deck" => "indexed_decks", + "org.stormlightlabs.malfestio.card" => "indexed_cards", + "org.stormlightlabs.malfestio.note" => "indexed_notes", _ => return Ok(()), }; @@ -289,9 +293,15 @@ impl EventHandler for MalfestioEventHandler { match operation.as_str() { "create" | "update" => { let result = match collection.as_str() { - "app.malfestio.deck" => self.index_deck(&did, rkey, rev, &commit.record).await, - "app.malfestio.card" => self.index_card(&did, rkey, rev, &commit.record).await, - "app.malfestio.note" => self.index_note(&did, rkey, rev, &commit.record).await, + "org.stormlightlabs.malfestio.deck" => { + self.index_deck(&did, rkey, rev, &commit.record).await + } + "org.stormlightlabs.malfestio.card" => { + self.index_card(&did, rkey, rev, &commit.record).await + } + "org.stormlightlabs.malfestio.note" => { + self.index_note(&did, rkey, rev, &commit.record).await + } _ => Ok(()), }; @@ -414,9 +424,9 @@ mod tests { #[test] fn test_malfestio_collections() { - assert!(MALFESTIO_COLLECTIONS.contains(&"app.malfestio.deck")); - assert!(MALFESTIO_COLLECTIONS.contains(&"app.malfestio.card")); - assert!(MALFESTIO_COLLECTIONS.contains(&"app.malfestio.note")); + assert!(MALFESTIO_COLLECTIONS.contains(&"org.stormlightlabs.malfestio.deck")); + assert!(MALFESTIO_COLLECTIONS.contains(&"org.stormlightlabs.malfestio.card")); + assert!(MALFESTIO_COLLECTIONS.contains(&"org.stormlightlabs.malfestio.note")); } #[test] @@ -425,7 +435,7 @@ mod tests { "title": "Test Deck", "description": "A test deck", "tags": ["rust", "learning"], - "cardRefs": ["at://did:plc:abc/app.malfestio.card/123"], + "cardRefs": ["at://did:plc:abc/org.stormlightlabs.malfestio.card/123"], "sourceRefs": [], "license": "CC-BY-4.0", "createdAt": "2024-01-01T00:00:00Z" @@ -442,7 +452,7 @@ mod tests { #[test] fn test_parse_card_record() { let json = serde_json::json!({ - "deckRef": "at://did:plc:abc/app.malfestio.deck/123", + "deckRef": "at://did:plc:abc/org.stormlightlabs.malfestio.deck/123", "front": "What is Rust?", "back": "A systems programming language", "cardType": "basic", @@ -451,7 +461,7 @@ mod tests { }); let card: CardRecord = serde_json::from_value(json).unwrap(); - assert_eq!(card.deck_ref, "at://did:plc:abc/app.malfestio.deck/123"); + assert_eq!(card.deck_ref, "at://did:plc:abc/org.stormlightlabs.malfestio.deck/123"); assert_eq!(card.front, "What is Rust?"); assert_eq!(card.back, "A systems programming language"); assert_eq!(card.card_type, Some("basic".to_string())); diff --git a/crates/server/src/middleware/auth.rs b/crates/server/src/middleware/auth.rs index 3bfae41..eb1bace 100644 --- a/crates/server/src/middleware/auth.rs +++ b/crates/server/src/middleware/auth.rs @@ -250,42 +250,11 @@ pub async fn auth_middleware(State(state): State, mut req: Request, /// but continues without error if no token or invalid token. /// /// Used by endpoints that need to check permissions but don't require authentication. -pub async fn optional_auth_middleware(mut req: Request, next: Next) -> Response { - let auth_header = req.headers().get(http::header::AUTHORIZATION); - - let token = match auth_header.and_then(|h| h.to_str().ok()).and_then(parse_auth_header) { - Some(AuthScheme::Bearer(t)) | Some(AuthScheme::DPoP(t)) => t, - None => { - return next.run(req).await; - } - }; - - let client = reqwest::Client::new(); - let pds_url = std::env::var("PDS_URL").unwrap_or_else(|_| "https://bsky.social".to_string()); - - match client - .get(format!("{}/xrpc/com.atproto.server.getSession", pds_url)) - .header("Authorization", format!("Bearer {}", token)) - .send() - .await - { - Ok(response) if response.status().is_success() => { - let body: serde_json::Value = response.json().await.unwrap_or_default(); - let did = body["did"].as_str().unwrap_or("").to_string(); - let handle = body["handle"].as_str().unwrap_or("").to_string(); - - req.extensions_mut().insert(UserContext { - did, - handle, - access_token: token.to_string(), - pds_url: pds_url.clone(), - has_dpop: false, - }); - } - _ => {} +pub async fn optional_auth_middleware(State(state): State, req: Request, next: Next) -> Response { + if req.headers().get(http::header::AUTHORIZATION).is_none() { + return next.run(req).await; } - - next.run(req).await + auth_middleware(State(state), req, next).await } /// Cleanup expired nonces from the cache. diff --git a/crates/server/src/pds/client.rs b/crates/server/src/pds/client.rs index 117b0c8..623a9be 100644 --- a/crates/server/src/pds/client.rs +++ b/crates/server/src/pds/client.rs @@ -128,7 +128,7 @@ impl PdsClient { /// # Arguments /// /// * `did` - The user's DID (repository owner) - /// * `collection` - The collection NSID (e.g., "app.malfestio.deck") + /// * `collection` - The collection NSID (e.g., "org.stormlightlabs.malfestio.deck") /// * `rkey` - The record key (TID) /// * `record` - The record data as JSON pub async fn put_record( @@ -284,7 +284,7 @@ mod tests { fn test_put_record_request_serialization() { let request = PutRecordRequest { repo: "did:plc:abc123".to_string(), - collection: "app.malfestio.deck".to_string(), + collection: "org.stormlightlabs.malfestio.deck".to_string(), rkey: "3k5abc123".to_string(), record: serde_json::json!({ "title": "Test Deck", @@ -297,7 +297,7 @@ mod tests { let json = serde_json::to_string(&request).unwrap(); assert!(json.contains("\"repo\":\"did:plc:abc123\"")); - assert!(json.contains("\"collection\":\"app.malfestio.deck\"")); + assert!(json.contains("\"collection\":\"org.stormlightlabs.malfestio.deck\"")); assert!(json.contains("\"rkey\":\"3k5abc123\"")); assert!(json.contains("\"validate\":true")); } @@ -306,7 +306,7 @@ mod tests { fn test_delete_record_request_serialization() { let request = DeleteRecordRequest { repo: "did:plc:abc123".to_string(), - collection: "app.malfestio.deck".to_string(), + collection: "org.stormlightlabs.malfestio.deck".to_string(), rkey: "3k5abc123".to_string(), swap_record: None, swap_commit: None, diff --git a/crates/server/src/pds/records.rs b/crates/server/src/pds/records.rs index d45a368..b8984fe 100644 --- a/crates/server/src/pds/records.rs +++ b/crates/server/src/pds/records.rs @@ -82,7 +82,7 @@ impl DeckRecord { /// Create a DeckRecord from an internal Deck model. pub fn from_deck(deck: &Deck, card_at_uris: Vec) -> Self { Self { - record_type: "app.malfestio.deck".to_string(), + record_type: "org.stormlightlabs.malfestio.deck".to_string(), title: deck.title.clone(), description: if deck.description.is_empty() { None } else { Some(deck.description.clone()) }, tags: deck.tags.clone(), @@ -98,7 +98,7 @@ impl CardRecord { /// Create a CardRecord from an internal Card model. pub fn from_card(card: &Card, deck_at_uri: &str) -> Self { Self { - record_type: "app.malfestio.card".to_string(), + record_type: "org.stormlightlabs.malfestio.card".to_string(), deck_ref: deck_at_uri.to_string(), front: card.front.clone(), back: card.back.clone(), @@ -117,7 +117,7 @@ impl NoteRecord { /// Create a NoteRecord from an internal Note model. pub fn from_note(note: &Note) -> Self { Self { - record_type: "app.malfestio.note".to_string(), + record_type: "org.stormlightlabs.malfestio.note".to_string(), title: note.title.clone(), body: note.body.clone(), tags: note.tags.clone(), @@ -143,7 +143,7 @@ pub fn prepare_deck_record(deck: &Deck, card_at_uris: Vec) -> PreparedRe let record = DeckRecord::from_deck(deck, card_at_uris); PreparedRecord { rkey: generate_tid(), - collection: "app.malfestio.deck".to_string(), + collection: "org.stormlightlabs.malfestio.deck".to_string(), record: serde_json::to_value(record).expect("Failed to serialize deck record"), } } @@ -153,7 +153,7 @@ pub fn prepare_card_record(card: &Card, deck_at_uri: &str) -> PreparedRecord { let record = CardRecord::from_card(card, deck_at_uri); PreparedRecord { rkey: generate_tid(), - collection: "app.malfestio.card".to_string(), + collection: "org.stormlightlabs.malfestio.card".to_string(), record: serde_json::to_value(record).expect("Failed to serialize card record"), } } @@ -163,7 +163,7 @@ pub fn prepare_note_record(note: &Note) -> PreparedRecord { let record = NoteRecord::from_note(note); PreparedRecord { rkey: generate_tid(), - collection: "app.malfestio.note".to_string(), + collection: "org.stormlightlabs.malfestio.note".to_string(), record: serde_json::to_value(record).expect("Failed to serialize note record"), } } @@ -221,7 +221,7 @@ mod tests { let deck = sample_deck(); let record = DeckRecord::from_deck(&deck, vec![]); - assert_eq!(record.record_type, "app.malfestio.deck"); + assert_eq!(record.record_type, "org.stormlightlabs.malfestio.deck"); assert_eq!(record.title, "Test Deck"); assert_eq!(record.description, Some("A test deck".to_string())); assert_eq!(record.tags.len(), 2); @@ -230,10 +230,13 @@ mod tests { #[test] fn test_deck_record_serialization() { let deck = sample_deck(); - let record = DeckRecord::from_deck(&deck, vec!["at://did:plc:abc/app.malfestio.card/tid1".to_string()]); + let record = DeckRecord::from_deck( + &deck, + vec!["at://did:plc:abc/org.stormlightlabs.malfestio.card/tid1".to_string()], + ); let json = serde_json::to_string(&record).unwrap(); - assert!(json.contains("\"$type\":\"app.malfestio.deck\"")); + assert!(json.contains("\"$type\":\"org.stormlightlabs.malfestio.deck\"")); assert!(json.contains("\"title\":\"Test Deck\"")); assert!(json.contains("cardRefs")); } @@ -241,10 +244,10 @@ mod tests { #[test] fn test_card_record_from_card() { let card = sample_card(); - let deck_uri = "at://did:plc:abc123/app.malfestio.deck/tid123"; + let deck_uri = "at://did:plc:abc123/org.stormlightlabs.malfestio.deck/tid123"; let record = CardRecord::from_card(&card, deck_uri); - assert_eq!(record.record_type, "app.malfestio.card"); + assert_eq!(record.record_type, "org.stormlightlabs.malfestio.card"); assert_eq!(record.deck_ref, deck_uri); assert_eq!(record.front, "What is the capital of France?"); assert_eq!(record.back, "Paris"); @@ -255,7 +258,7 @@ mod tests { let note = sample_note(); let record = NoteRecord::from_note(¬e); - assert_eq!(record.record_type, "app.malfestio.note"); + assert_eq!(record.record_type, "org.stormlightlabs.malfestio.note"); assert_eq!(record.title, "Test Note"); assert_eq!(record.visibility, "public"); } @@ -265,7 +268,7 @@ mod tests { let deck = sample_deck(); let prepared = prepare_deck_record(&deck, vec![]); - assert_eq!(prepared.collection, "app.malfestio.deck"); + assert_eq!(prepared.collection, "org.stormlightlabs.malfestio.deck"); assert_eq!(prepared.rkey.len(), 13); // TID length assert!(prepared.record.is_object()); } @@ -273,9 +276,9 @@ mod tests { #[test] fn test_prepare_card_record() { let card = sample_card(); - let prepared = prepare_card_record(&card, "at://did:plc:abc/app.malfestio.deck/tid"); + let prepared = prepare_card_record(&card, "at://did:plc:abc/org.stormlightlabs.malfestio.deck/tid"); - assert_eq!(prepared.collection, "app.malfestio.card"); + assert_eq!(prepared.collection, "org.stormlightlabs.malfestio.card"); assert_eq!(prepared.rkey.len(), 13); } @@ -284,14 +287,17 @@ mod tests { let note = sample_note(); let prepared = prepare_note_record(¬e); - assert_eq!(prepared.collection, "app.malfestio.note"); + assert_eq!(prepared.collection, "org.stormlightlabs.malfestio.note"); assert_eq!(prepared.rkey.len(), 13); } #[test] fn test_make_at_uri() { - let uri = make_at_uri("did:plc:abc123", "app.malfestio.deck", "3k5abc123"); - assert_eq!(uri.to_string(), "at://did:plc:abc123/app.malfestio.deck/3k5abc123"); + let uri = make_at_uri("did:plc:abc123", "org.stormlightlabs.malfestio.deck", "3k5abc123"); + assert_eq!( + uri.to_string(), + "at://did:plc:abc123/org.stormlightlabs.malfestio.deck/3k5abc123" + ); } #[test] diff --git a/lexicons/README.md b/lexicons/README.md index 24f8330..cacf948 100644 --- a/lexicons/README.md +++ b/lexicons/README.md @@ -32,6 +32,75 @@ This directory contains the Lexicon definitions for the malfestio's public recor - **Private layer**: - review schedule, lapses, grades, per-card performance, streaks +## Publishing Lexicons to AT Protocol Network + +### Prerequisites + +**Goat CLI**: Install the official AT Protocol CLI tool + +```bash +# macOS +brew install goat +``` + +### Publishing Workflow + +1. **Validate schemas locally**: + + ```bash + goat lexicon lint lexicons/ + ``` + +2. **Check DNS configuration**: + + ```bash + goat lexicon check-dns org.stormlightlabs.malfestio.card + ``` + +3. **Publish to network**: + + ```bash + goat lexicon publish lexicons/org/stormlightlabs/malfestio/ + ``` + +### PDS Validation Modes + +AT Protocol PDSs support three lexicon validation modes: + +1. **Explicit validation required**: Record must validate against schema; fails if PDS doesn't know the lexicon + - This is the current mode causing `Lexicon not found` errors + - Requires publishing lexicons or using optimistic validation + +2. **Optimistic validation** (default): Validates if PDS knows the schema, allows creation if unknown + - Most flexible for custom lexicons during development + - Set via `validate: undefined` in create/update record calls + +3. **Explicit no validation**: Skips validation even if PDS knows the schema + - Set via `validate: false` in create/update record calls + +### Version Updates + +When updating lexicon schemas: + +1. **Minor Updates** (additive only): + - Add new optional fields + - Update descriptions + - Add new `knownValues` (don't remove old ones) + - Increment patch version in documentation + +2. **Breaking Changes** (avoid if possible): + - Create new lexicon with new NSID (e.g., `org.stormlightlabs.malfestio.cardV2`) + - Maintain both versions during migration period + - Update code to support both old and new schemas + - Document migration path + +3. **Republishing**: + + ```bash + goat lexicon lint lexicons/ + goat lexicon publish lexicons/org/stormlightlabs/malfestio/ + ``` + ## Evolution Rules 1. **Additive Changes Only**: You can add new optional fields to existing records. diff --git a/web/src/components/NoteCard.tsx b/web/src/components/NoteCard.tsx index 8631327..ff8645c 100644 --- a/web/src/components/NoteCard.tsx +++ b/web/src/components/NoteCard.tsx @@ -32,7 +32,7 @@ export const NoteCard: Component = (props) => { {props.note.title || "Untitled"}

- {new Date(props.note.updated_at).toLocaleDateString()} + {props.note.updated_at ? new Date(props.note.updated_at).toLocaleDateString() : ""}

diff --git a/web/src/components/NoteEditor.tsx b/web/src/components/NoteEditor.tsx index 5fef9ba..7403e64 100644 --- a/web/src/components/NoteEditor.tsx +++ b/web/src/components/NoteEditor.tsx @@ -5,6 +5,7 @@ import type { Note } from "$lib/model"; import { toast } from "$lib/toast"; import { Button } from "$ui/Button"; import rehypeShiki from "@shikijs/rehype"; +import { useNavigate } from "@solidjs/router"; import { Textcomplete } from "@textcomplete/core"; import { TextareaEditor } from "@textcomplete/textarea"; import rehypeExternalLinks from "rehype-external-links"; @@ -20,12 +21,32 @@ type NoteEditorProps = { noteId?: string; initialTitle?: string; initialContent? type EditorTab = "write" | "preview"; +function getFontName(font: EditorFont | (() => EditorFont)) { + switch (typeof font === "function" ? font() : font) { + case "neon": + return "Monaspace Neon"; + case "argon": + return "Monaspace Argon"; + case "krypton": + return "Monaspace Krypton"; + case "radon": + return "Monaspace Radon"; + case "xenon": + return "Monaspace Xenon"; + case "google": + return "Google Sans Code"; + default: + return "JetBrains Mono"; + } +} + const processor = unified().use(remarkParse).use(remarkRehype).use(rehypeShiki, { theme: "vitesse-dark" }).use( rehypeExternalLinks, { target: "_blank", rel: ["nofollow"] }, ).use(rehypeStringify); export function NoteEditor(props: NoteEditorProps) { + const navigate = useNavigate(); const [title, setTitle] = createSignal(props.initialTitle || ""); const [content, setContent] = createSignal(props.initialContent || ""); const [preview, setPreview] = createSignal(""); @@ -76,24 +97,7 @@ export function NoteEditor(props: NoteEditorProps) { textcomplete?.destroy(); }); - const fontValue = createMemo(() => { - switch (editorFont()) { - case "neon": - return "Monaspace Neon"; - case "argon": - return "Monaspace Argon"; - case "krypton": - return "Monaspace Krypton"; - case "radon": - return "Monaspace Radon"; - case "xenon": - return "Monaspace Xenon"; - case "google": - return "Google Sans Code"; - default: - return "JetBrains Mono"; - } - }); + const fontValue = createMemo(() => getFontName(editorFont)); const insertAtCursor = (before: string, after: string = "") => { if (!textareaRef) return; @@ -116,11 +120,7 @@ export function NoteEditor(props: NoteEditorProps) { const handleCodeBlock = () => insertAtCursor("```\n", "\n```"); const handleWikilink = () => insertAtCursor("[[", "]]"); const handleList = () => insertAtCursor("- "); - - const handleHeading = (level: 1 | 2 | 3 | 4 | 5 | 6) => { - const prefix = "#".repeat(level) + " "; - insertAtCursor(prefix); - }; + const handleHeading = (level: 1 | 2 | 3 | 4 | 5 | 6) => insertAtCursor("#".repeat(level) + " "); const handleKeyDown = (e: KeyboardEvent) => { if (e.metaKey || e.ctrlKey) { @@ -162,14 +162,19 @@ export function NoteEditor(props: NoteEditorProps) { if (res.ok) { toast.success("Note saved!"); - if (!props.noteId) { - setTitle(""); - setContent(""); - setTags(""); - setVisibilityType("Private"); - setSharedWith(""); + if (props.noteId) { + navigate(`/notes/${props.noteId}`); + } else { + try { + const newNote = await res.json(); + navigate(`/notes/${newNote.id}`); + } catch { + navigate("/notes"); + } } } else { + const errorText = await res.text(); + console.error("Failed to save note:", res.status, errorText); toast.error("Failed to save note"); } } catch (e) { diff --git a/web/src/lib/model.ts b/web/src/lib/model.ts index 96d0b85..55eb9d3 100644 --- a/web/src/lib/model.ts +++ b/web/src/lib/model.ts @@ -35,8 +35,9 @@ export type Note = { tags: string[]; visibility: Visibility; published_at?: string; - created_at: string; - updated_at: string; + created_at?: string; + updated_at?: string; + links?: string[]; }; export type CreateDeckPayload = { diff --git a/web/src/pages/Notes.tsx b/web/src/pages/Notes.tsx index f4a7dae..5876632 100644 --- a/web/src/pages/Notes.tsx +++ b/web/src/pages/Notes.tsx @@ -3,9 +3,9 @@ import { Button } from "$components/ui/Button"; import { EmptyState } from "$components/ui/EmptyState"; import { api } from "$lib/api"; import type { Note } from "$lib/model"; -import { A } from "@solidjs/router"; +import { A, useLocation } from "@solidjs/router"; import type { Component } from "solid-js"; -import { createMemo, createResource, createSignal, For, Show } from "solid-js"; +import { createEffect, createMemo, createResource, createSignal, For, Show } from "solid-js"; const fetchNotes = async (): Promise => { const res = await api.getNotes(); @@ -16,10 +16,17 @@ const fetchNotes = async (): Promise => { type ViewMode = "grid" | "list"; const Notes: Component = () => { - const [notes] = createResource(fetchNotes); + const location = useLocation(); + const [notes, { refetch }] = createResource(fetchNotes); const [viewMode, setViewMode] = createSignal("grid"); const [searchQuery, setSearchQuery] = createSignal(""); + createEffect(() => { + if (location.pathname === "/notes") { + refetch(); + } + }); + const filteredNotes = createMemo(() => { const allNotes = notes() || []; const query = searchQuery().toLowerCase().trim();