this repo has no description
1
fork

Configure Feed

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

Docs update second pass

+1107 -248
+3
apps/web/package.json
··· 34 34 "@tiptap/react": "^3.20.4", 35 35 "@tiptap/starter-kit": "^3.20.4", 36 36 "clsx": "^2.1.1", 37 + "cmdk": "^1.1.1", 37 38 "comlink": "^4.4.2", 38 39 "dexie": "^4.3.0", 39 40 "immer": "^11.1.4", ··· 42 43 "react-dom": "^19.2.4", 43 44 "react-markdown": "^10.1.0", 44 45 "react-syntax-highlighter": "^16.1.1", 46 + "rehype-autolink-headings": "^7.1.0", 47 + "rehype-slug": "^6.0.0", 45 48 "remark-gfm": "^4.0.1", 46 49 "tailwind-merge": "^3.5.0", 47 50 "tiptap-markdown": "^0.9.0",
+1 -1
apps/web/src/components/cabinet/MarkdownPreview.tsx
··· 7 7 import type { ComponentPropsWithoutRef } from "react"; 8 8 9 9 const MermaidBlock = lazy(() => 10 - import("./MermaidBlock").then((m) => ({ default: m.MermaidBlock })), 10 + import("@/components/content/MermaidBlock").then((m) => ({ default: m.MermaidBlock })), 11 11 ); 12 12 13 13 interface MarkdownPreviewProps {
+16 -1
apps/web/src/components/cabinet/MermaidBlock.tsx apps/web/src/components/content/MermaidBlock.tsx
··· 21 21 readonly code: string; 22 22 } 23 23 24 + /** 25 + * Render a Mermaid diagram from its source string. Mermaid is dynamically 26 + * imported so the ~500 KB library only loads on pages that use it (cabinet 27 + * markdown preview, docs sequence diagrams). 28 + */ 24 29 export function MermaidBlock({ code }: MermaidBlockProps) { 25 30 const containerRef = useRef<HTMLDivElement>(null); 26 31 const [error, setError] = useState<string | null>(null); ··· 64 69 ); 65 70 } 66 71 67 - return <div ref={containerRef} className="my-4 flex justify-center [&>svg]:max-w-full" />; 72 + // `role="img"` marks the rendered SVG as a single image for assistive tech. 73 + // The accessible name comes from the wrapping `<figure aria-label=...>` 74 + // in SequenceDiagram; if MermaidBlock is used standalone (MarkdownPreview), 75 + // the surrounding context is expected to carry that meaning. 76 + return ( 77 + <div 78 + ref={containerRef} 79 + role="img" 80 + className="my-4 flex justify-center [&>svg]:max-w-full" 81 + /> 82 + ); 68 83 }
+173
apps/web/src/components/content/DocsSearch.tsx
··· 1 + import { useEffect, useState, useMemo } from "react"; 2 + import { createPortal } from "react-dom"; 3 + import { useNavigate } from "@tanstack/react-router"; 4 + import { Command } from "cmdk"; 5 + import { MagnifyingGlassIcon } from "@phosphor-icons/react"; 6 + import { searchDocs, type SearchHit } from "@/lib/docs-search"; 7 + import { GROUP_META } from "@/lib/docs-registry"; 8 + 9 + /** 10 + * Command-palette search over the docs. Triggered by `Cmd+K` / `Ctrl+K` or 11 + * by clicking the sidebar search button. Searches doc titles, descriptions, 12 + * and `##`+ headings extracted at build time from each MDX file. 13 + * 14 + * cmdk handles keyboard nav (Up/Down/Enter) and focus trapping within the 15 + * palette. We just give it a list of candidates and a select handler. 16 + */ 17 + 18 + interface DocsSearchProps { 19 + readonly open: boolean; 20 + readonly onOpenChange: (open: boolean) => void; 21 + } 22 + 23 + export function DocsSearch({ open, onOpenChange }: DocsSearchProps) { 24 + const navigate = useNavigate(); 25 + const [query, setQuery] = useState(""); 26 + 27 + // Global Cmd+K / Ctrl+K toggle. Ignore when a modifier besides the intended 28 + // meta/ctrl is active, to avoid intercepting browser shortcuts like Ctrl+Shift+K. 29 + useEffect(() => { 30 + const onKeyDown = (event: KeyboardEvent) => { 31 + const isToggle = 32 + (event.metaKey || event.ctrlKey) && 33 + !event.shiftKey && 34 + !event.altKey && 35 + event.key.toLowerCase() === "k"; 36 + if (isToggle) { 37 + event.preventDefault(); 38 + onOpenChange(!open); 39 + } else if (event.key === "Escape" && open) { 40 + onOpenChange(false); 41 + } 42 + }; 43 + window.addEventListener("keydown", onKeyDown); 44 + return () => window.removeEventListener("keydown", onKeyDown); 45 + }, [open, onOpenChange]); 46 + 47 + // Clear the query on close so reopening starts fresh. 48 + useEffect(() => { 49 + if (!open) setQuery(""); 50 + }, [open]); 51 + 52 + const hits = useMemo(() => searchDocs(query), [query]); 53 + 54 + const onSelect = (hit: SearchHit) => { 55 + onOpenChange(false); 56 + // The href already includes the heading anchor when relevant. 57 + void navigate({ to: hit.href }); 58 + }; 59 + 60 + if (!open) return null; 61 + // SSR guard: `document` isn't defined during server rendering. The palette 62 + // is only ever opened via user interaction, so at that point we're on the 63 + // client and the body is mounted. Portaling escapes any transformed/ 64 + // filtered ancestor that'd otherwise trap `position: fixed` to itself. 65 + if (typeof document === "undefined") return null; 66 + 67 + return createPortal( 68 + <div 69 + role="presentation" 70 + onClick={() => onOpenChange(false)} 71 + className="fixed inset-0 z-50 flex items-start justify-center bg-black/30 p-4 pt-[10vh]" 72 + > 73 + <div 74 + role="dialog" 75 + aria-modal="true" 76 + aria-label="Search documentation" 77 + onClick={(event) => event.stopPropagation()} 78 + className="bg-base-100 border-border-accent/40 w-full max-w-xl overflow-hidden rounded-xl border shadow-panel-lg" 79 + > 80 + <Command 81 + label="Search documentation" 82 + shouldFilter={false} 83 + className="flex flex-col" 84 + > 85 + <div className="border-border-accent/30 flex items-center gap-2 border-b px-4 py-3"> 86 + <MagnifyingGlassIcon size={16} aria-hidden="true" className="text-text-muted" /> 87 + <Command.Input 88 + autoFocus 89 + value={query} 90 + onValueChange={setQuery} 91 + placeholder="Search docs…" 92 + className="text-base-content placeholder:text-text-muted flex-1 bg-transparent text-[0.9rem] outline-none" 93 + /> 94 + <kbd className="text-text-muted border-border-accent/40 rounded border px-1.5 py-0.5 text-[0.65rem]"> 95 + Esc 96 + </kbd> 97 + </div> 98 + <Command.List className="max-h-[60vh] overflow-y-auto p-2"> 99 + {query.trim() === "" ? ( 100 + <div className="text-text-muted p-6 text-center text-[0.85rem]"> 101 + Start typing to search chapters, sections, and descriptions. 102 + </div> 103 + ) : hits.length === 0 ? ( 104 + <Command.Empty className="text-text-muted p-6 text-center text-[0.85rem]"> 105 + No matches for "{query}". 106 + </Command.Empty> 107 + ) : ( 108 + hits.map((hit, index) => ( 109 + <Command.Item 110 + key={`${hit.docGroup ?? "flat"}-${hit.docSlug}`} 111 + value={`${hit.docTitle} ${hit.docDescription} ${index}`} 112 + onSelect={() => onSelect(hit)} 113 + className="data-[selected=true]:bg-accent/40 flex cursor-pointer flex-col gap-0.5 rounded-lg px-3 py-2 transition-colors" 114 + > 115 + <div className="flex items-baseline gap-2"> 116 + <span className="text-base-content text-[0.9rem] font-medium"> 117 + {hit.docTitle} 118 + </span> 119 + {hit.docGroup && ( 120 + <span className="text-text-muted text-[0.7rem]"> 121 + {GROUP_META[hit.docGroup] ?? hit.docGroup} 122 + </span> 123 + )} 124 + </div> 125 + <span className="text-text-muted line-clamp-1 text-[0.75rem]"> 126 + {hit.docDescription} 127 + </span> 128 + </Command.Item> 129 + )) 130 + )} 131 + </Command.List> 132 + </Command> 133 + </div> 134 + </div>, 135 + document.body, 136 + ); 137 + } 138 + 139 + /** 140 + * Button that opens the search palette. Meant for the docs sidebar header. 141 + * Shows the current platform's Cmd/Ctrl shortcut hint. 142 + */ 143 + interface DocsSearchButtonProps { 144 + readonly onClick: () => void; 145 + readonly compact?: boolean; 146 + } 147 + 148 + export function DocsSearchButton({ onClick, compact = false }: DocsSearchButtonProps) { 149 + // Detect macOS once on mount — we need to pick ⌘ vs Ctrl for the hint. 150 + const [isMac, setIsMac] = useState(false); 151 + useEffect(() => { 152 + setIsMac(/mac/i.test(navigator.platform) || /mac/i.test(navigator.userAgent)); 153 + }, []); 154 + const modifierKey = isMac ? "⌘" : "Ctrl"; 155 + 156 + return ( 157 + <button 158 + type="button" 159 + onClick={onClick} 160 + aria-label="Search documentation (keyboard shortcut available)" 161 + aria-keyshortcuts={isMac ? "Meta+K" : "Control+K"} 162 + className={`border-border-accent/40 bg-base-100 text-text-muted hover:border-primary/40 hover:text-base-content flex w-full items-center gap-2 rounded-lg border px-3 transition-colors ${ 163 + compact ? "py-1.5 text-[0.72rem]" : "py-2 text-[0.82rem]" 164 + }`} 165 + > 166 + <MagnifyingGlassIcon size={compact ? 12 : 14} aria-hidden="true" /> 167 + <span className="flex-1 text-left">Search docs</span> 168 + <kbd className="border-border-accent/40 rounded border px-1 py-0.5 text-[0.65rem]"> 169 + {modifierKey}K 170 + </kbd> 171 + </button> 172 + ); 173 + }
+131 -21
apps/web/src/components/content/chapter.tsx
··· 1 1 import { 2 2 type ReactNode, 3 3 type ReactElement, 4 + type KeyboardEvent, 5 + Suspense, 6 + lazy, 7 + useId, 8 + useRef, 4 9 useState, 5 10 Children, 6 11 isValidElement, 7 - createContext, 8 - useContext, 9 12 } from "react"; 10 13 import { InfoIcon, WarningIcon, DesktopIcon, TerminalIcon } from "@phosphor-icons/react"; 11 14 import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; 12 15 import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism"; 13 16 17 + /* Mermaid is ~500 KB and only loads when a page actually renders a diagram. */ 18 + const MermaidBlock = lazy(() => 19 + import("./MermaidBlock").then((m) => ({ default: m.MermaidBlock })), 20 + ); 21 + 14 22 /* ─── Chapter header ───────────────────────────────────────────────────────── */ 15 23 16 24 interface ChapterHeaderProps { ··· 44 52 container: "border-info/30 bg-info/5", 45 53 icon: InfoIcon, 46 54 iconClass: "text-info", 55 + /** Visually-hidden prefix so screen readers announce context ("Note:" vs "Warning:"). */ 56 + srLabel: "Note:", 47 57 }, 48 58 warning: { 49 59 container: "border-warning/30 bg-warning/5", 50 60 icon: WarningIcon, 51 61 iconClass: "text-warning", 62 + srLabel: "Warning:", 52 63 }, 53 64 } as const; 54 65 ··· 66 77 role="note" 67 78 className={`my-6 flex items-center gap-3 rounded-xl border p-4 ${style.container}`} 68 79 > 69 - <IconComponent size={18} weight="fill" className={`mt-0.5 shrink-0 ${style.iconClass}`} /> 70 - <div className="prose text-[0.88rem] leading-relaxed">{children}</div> 80 + <IconComponent 81 + size={18} 82 + weight="fill" 83 + aria-hidden="true" 84 + className={`mt-0.5 shrink-0 ${style.iconClass}`} 85 + /> 86 + <div className="prose text-[0.88rem] leading-relaxed"> 87 + <span className="sr-only">{style.srLabel} </span> 88 + {children} 89 + </div> 71 90 </aside> 72 91 ); 73 92 } 74 93 75 94 /* ─── Platform toggle (Web App / CLI tabs) ─────────────────────────────────── */ 76 - 77 - const PlatformContext = createContext("Web App"); 78 95 79 96 interface PlatformToggleProps { 80 97 readonly children: ReactNode; 81 98 } 82 99 100 + /** 101 + * WAI-ARIA tabs pattern for "how do I do this on the web app / on the CLI?" 102 + * blocks. Implements the manual-activation variant of the 103 + * [Tabs APG pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/): 104 + * left/right/home/end navigate between tabs and also activate them, since 105 + * each tab swap is cheap (just toggles a `hidden` attribute). 106 + * 107 + * PlatformTab is a prop-bag component whose props (`name`, `children`) are 108 + * read here — it doesn't render anything on its own. All panels render into 109 + * the DOM and `hidden` is used to mask inactive ones, which keeps 110 + * `aria-controls` pointing at a real tabpanel element whether or not it's 111 + * currently visible. 112 + */ 83 113 export function PlatformToggle({ children }: PlatformToggleProps) { 84 114 const tabs = Children.toArray(children).filter( 85 115 (child): child is ReactElement<PlatformTabProps> => ··· 91 121 92 122 const tabNames = tabs.map((tab) => tab.props.name); 93 123 const [active, setActive] = useState(tabNames[0] ?? "Web App"); 124 + const id = useId(); 125 + const tabRefs = useRef<(HTMLButtonElement | null)[]>([]); 126 + 127 + const moveFocus = (currentIndex: number, key: string): boolean => { 128 + // eslint-disable-next-line functional/no-let -- computed branching below 129 + let nextIndex = -1; 130 + if (key === "ArrowRight") nextIndex = (currentIndex + 1) % tabNames.length; 131 + else if (key === "ArrowLeft") 132 + nextIndex = (currentIndex - 1 + tabNames.length) % tabNames.length; 133 + else if (key === "Home") nextIndex = 0; 134 + else if (key === "End") nextIndex = tabNames.length - 1; 135 + 136 + if (nextIndex < 0) return false; 137 + 138 + const nextName = tabNames[nextIndex]; 139 + if (nextName) { 140 + setActive(nextName); 141 + tabRefs.current[nextIndex]?.focus(); 142 + } 143 + return true; 144 + }; 145 + 146 + const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => { 147 + if (moveFocus(index, event.key)) { 148 + event.preventDefault(); 149 + } 150 + }; 94 151 95 152 return ( 96 153 <div className="border-border-accent/40 my-6 overflow-hidden rounded-xl border"> 97 - <div role="tablist" className="bg-base-200/60 flex border-b border-inherit"> 98 - {tabNames.map((name) => { 154 + <div 155 + role="tablist" 156 + aria-label="Platform" 157 + className="bg-base-200/60 flex border-b border-inherit" 158 + > 159 + {tabNames.map((name, index) => { 99 160 const isActive = name === active; 100 161 const Icon = name === "CLI" ? TerminalIcon : DesktopIcon; 101 162 102 163 return ( 103 164 <button 104 165 key={name} 166 + type="button" 105 167 role="tab" 168 + id={`${id}-tab-${index}`} 169 + aria-controls={`${id}-panel-${index}`} 106 170 aria-selected={isActive} 171 + tabIndex={isActive ? 0 : -1} 172 + ref={(el) => { 173 + // eslint-disable-next-line functional/immutable-data -- ref array mutation is the React pattern 174 + tabRefs.current[index] = el; 175 + }} 107 176 onClick={() => setActive(name)} 177 + onKeyDown={(event) => handleKeyDown(event, index)} 108 178 className={`text-ui flex items-center gap-1.5 px-4 py-2.5 font-medium transition-colors ${ 109 179 isActive 110 180 ? "border-primary text-base-content border-b-2" 111 181 : "text-text-muted hover:text-secondary" 112 182 }`} 113 183 > 114 - <Icon size={14} /> 184 + <Icon size={14} aria-hidden="true" /> 115 185 {name} 116 186 </button> 117 187 ); 118 188 })} 119 189 </div> 120 - <div className="bg-base-100 p-4"> 121 - <PlatformContext.Provider value={active}>{children}</PlatformContext.Provider> 122 - </div> 190 + {tabs.map((tab, index) => { 191 + const isActive = tab.props.name === active; 192 + return ( 193 + <div 194 + key={tab.props.name} 195 + role="tabpanel" 196 + id={`${id}-panel-${index}`} 197 + aria-labelledby={`${id}-tab-${index}`} 198 + hidden={!isActive} 199 + tabIndex={0} 200 + className="bg-base-100 p-4" 201 + > 202 + <div className="prose text-[0.88rem] leading-relaxed">{tab.props.children}</div> 203 + </div> 204 + ); 205 + })} 123 206 </div> 124 207 ); 125 208 } ··· 129 212 readonly children: ReactNode; 130 213 } 131 214 132 - export function PlatformTab({ name, children }: PlatformTabProps) { 133 - const active = useContext(PlatformContext); 134 - if (name !== active) return null; 135 - 215 + /** 216 + * Declarative marker for a single panel inside a {@link PlatformToggle}. 217 + * The parent `PlatformToggle` reads this element's `name` and `children` 218 + * props directly; this function body is only used as a fallback when the 219 + * component is rendered outside a toggle (e.g. a misnested page). 220 + */ 221 + export function PlatformTab({ children }: PlatformTabProps) { 136 222 return <div className="prose text-[0.88rem] leading-relaxed">{children}</div>; 137 223 } 138 224 ··· 199 285 /* ─── Sequence diagram (placeholder) ───────────────────────────────────────── */ 200 286 201 287 interface SequenceDiagramProps { 202 - readonly id: string; 288 + /** Mermaid source. Typically imported from the page's local `_diagrams.ts`. */ 289 + readonly code: string; 290 + /** Short caption rendered below the diagram for context and accessibility. */ 291 + readonly caption?: string; 203 292 } 204 293 205 - export function SequenceDiagram({ id }: SequenceDiagramProps) { 294 + /** 295 + * Render a Mermaid sequence diagram (or any Mermaid-supported flowchart) in a 296 + * docs page. Use alongside short prose explaining what the diagram shows — 297 + * the caption is the primary accessible label, since the generated SVG isn't 298 + * structured for screen readers. 299 + */ 300 + export function SequenceDiagram({ code, caption }: SequenceDiagramProps) { 206 301 return ( 207 - <div className="border-border-accent/30 bg-base-200/30 my-6 flex min-h-32 items-center justify-center rounded-xl border border-dashed"> 208 - <span className="text-text-muted text-ui italic">Diagram: {id}</span> 209 - </div> 302 + <figure className="not-prose my-8" role="group" aria-label={caption}> 303 + <div className="border-border-accent/30 bg-base-200/30 overflow-x-auto rounded-xl border p-4"> 304 + <Suspense 305 + fallback={ 306 + <div className="text-text-muted text-ui flex min-h-32 items-center justify-center italic"> 307 + Loading diagram… 308 + </div> 309 + } 310 + > 311 + <MermaidBlock code={code} /> 312 + </Suspense> 313 + </div> 314 + {caption && ( 315 + <figcaption className="text-text-muted text-ui mt-3 text-center italic"> 316 + {caption} 317 + </figcaption> 318 + )} 319 + </figure> 210 320 ); 211 321 }
+8
apps/web/src/components/content/docs-sidebar.tsx
··· 1 + import { useState } from "react"; 1 2 import { Link } from "@tanstack/react-router"; 2 3 import { CATEGORY_META, findDoc, partitionCategoryForSidebar, type DocMeta } from "@/lib/docs-registry"; 4 + import { DocsSearch, DocsSearchButton } from "./DocsSearch"; 3 5 4 6 interface DocsSidebarProps { 5 7 /** ··· 42 44 variant = "public", 43 45 }: DocsSidebarProps) { 44 46 const faq = findDoc("faq"); 47 + const [searchOpen, setSearchOpen] = useState(false); 45 48 46 49 const baseSectionGap = variant === "cabinet" ? "space-y-4" : "space-y-5"; 47 50 const headingSize = variant === "cabinet" ? "text-caption" : "text-ui"; ··· 49 52 50 53 return ( 51 54 <nav aria-label="Documentation" className={`${baseSectionGap} text-sm`}> 55 + <DocsSearchButton onClick={() => setSearchOpen(true)} /> 56 + <DocsSearch open={searchOpen} onOpenChange={setSearchOpen} /> 52 57 <Link 53 58 to="/docs" 54 59 className={`text-text-muted hover:text-base-content ${linkSize} block font-medium`} ··· 165 170 readonly currentGroup?: string; 166 171 }) { 167 172 const faq = findDoc("faq"); 173 + const [searchOpen, setSearchOpen] = useState(false); 168 174 169 175 return ( 170 176 <nav aria-label="Documentation" className="text-caption space-y-4"> 177 + <DocsSearchButton onClick={() => setSearchOpen(true)} compact /> 178 + <DocsSearch open={searchOpen} onOpenChange={setSearchOpen} /> 171 179 <Link 172 180 to="/cabinet/docs" 173 181 className="text-text-muted hover:text-base-content block text-xs font-medium"
+23 -4
apps/web/src/components/content/docs.tsx
··· 166 166 // card is its own visual affordance and doesn't need an underline on top. 167 167 const className = 168 168 "not-prose border-border-accent/40 bg-base-100 group hover:border-primary/60 hover:shadow-panel-sm mt-12 flex items-center justify-between rounded-xl border p-4 no-underline transition-all"; 169 + // Accessible name for screen readers. The visible "Next" + title works for 170 + // sighted users, but the role-less link needs an explicit aria-label so 171 + // SR users hear a single clear "Next chapter: <title>" announcement. 172 + const accessibleLabel = `Next chapter: ${next.title}`; 169 173 const body = ( 170 174 <> 171 175 <div className="flex flex-col"> 172 - <span className="text-text-muted text-ui">Next</span> 173 - <span className="text-base-content text-[1.05rem] font-medium">{next.title}</span> 176 + <span aria-hidden="true" className="text-text-muted text-ui"> 177 + Next 178 + </span> 179 + <span aria-hidden="true" className="text-base-content text-[1.05rem] font-medium"> 180 + {next.title} 181 + </span> 174 182 </div> 175 183 <ArrowRightIcon 176 184 size={18} 185 + aria-hidden="true" 177 186 className="text-primary transition-transform group-hover:translate-x-1" 178 187 /> 179 188 </> 180 189 ); 181 190 182 191 return next.group ? ( 183 - <Link to="/docs/$category/$slug" params={{ category: next.group, slug: next.slug }} className={className}> 192 + <Link 193 + to="/docs/$category/$slug" 194 + params={{ category: next.group, slug: next.slug }} 195 + className={className} 196 + aria-label={accessibleLabel} 197 + > 184 198 {body} 185 199 </Link> 186 200 ) : ( 187 - <Link to="/docs/$slug" params={{ slug: next.slug }} className={className}> 201 + <Link 202 + to="/docs/$slug" 203 + params={{ slug: next.slug }} 204 + className={className} 205 + aria-label={accessibleLabel} 206 + > 188 207 {body} 189 208 </Link> 190 209 );
+4 -1
apps/web/src/components/content/icons.ts
··· 12 12 PlantIcon, 13 13 FolderIcon, 14 14 LightningIcon, 15 + AtomIcon, 15 16 } from "@phosphor-icons/react"; 16 17 import { TerminalIcon } from "@phosphor-icons/react/dist/ssr"; 17 18 ··· 28 29 | "seedling" 29 30 | "terminal" 30 31 | "folder" 31 - | "lightning"; 32 + | "lightning" 33 + | "react"; 32 34 33 35 const ICON_MAP: Readonly<Record<IconName, Icon>> = { 34 36 lock: LockIcon, ··· 44 46 terminal: TerminalIcon, 45 47 folder: FolderIcon, 46 48 lightning: LightningIcon, 49 + react: AtomIcon, 47 50 }; 48 51 49 52 export function resolveIcon(name: string): Icon {
+26
apps/web/src/content/docs/build/_diagrams.ts
··· 1 + // Mermaid sources for the /build/ docs pages. 2 + // 3 + // Kept in a `.ts` file rather than inline in the `.mdx` files because MDX 3 4 + // dedents template literals inside `.mdx`, which mangles mermaid whitespace. 5 + 6 + /** Device pairing via the CLI. Shows which command runs on which device. */ 7 + export const pairingCliFlow = `sequenceDiagram 8 + participant New as New device 9 + participant PDS as Your PDS 10 + participant Old as Existing device 11 + 12 + Note over New: opake pair request 13 + New->>New: generate one-time keypair 14 + New->>PDS: publish pair request<br/>(new device's public half) 15 + 16 + Note over Old: opake pair approve 17 + Old->>PDS: poll for pair requests 18 + PDS-->>Old: request record 19 + Old->>Old: wrap identity keys<br/>with new device's public half 20 + Old->>PDS: publish pair response 21 + 22 + New->>PDS: poll for pair response 23 + PDS-->>New: response record 24 + New->>New: unwrap with one-time key<br/>→ identity installed 25 + New->>PDS: delete both records 26 + `;
+199 -51
apps/web/src/content/docs/build/cli.mdx
··· 1 + import { pairingCliFlow } from "./_diagrams"; 2 + 1 3 <ChapterHeader title="The CLI Manual" /> 2 4 3 5 <Lead> 4 - The Opake CLI is the reference implementation of the protocol. Everything the web app and SDK 5 - can do, the CLI can do first — identity, files, sharing, workspaces, device pairing, daemon 6 - maintenance. 6 + `opake` is the reference client. Everything the web app does maps back to a command here, and 7 + a few things only the CLI does today: shell completions, the background maintenance daemon, 8 + and the scripting hooks you'd want for pipelines and cron jobs. 7 9 </Lead> 8 10 9 - ## Installation 11 + ## Installing 10 12 11 - The CLI is built in Rust and requires building from source. You'll need Rust 1.75+ and Git. 13 + The CLI is written in Rust. For now it only ships as source; pre-built binaries and a 14 + crates.io release are on the roadmap. Needs Rust 1.75 or later and Git. 12 15 13 16 <CodeBlock language="sh">git clone https://tangled.org/opake.app/opake</CodeBlock> 14 17 15 18 <CodeBlock language="sh">cd opake && cargo install --path apps/cli</CodeBlock> 16 19 17 - This puts the `opake` binary in your `~/.cargo/bin/` directory. Pre-built binaries and crates.io publishing are planned. 20 + `cargo install` drops the binary at `~/.cargo/bin/opake`. Confirm with `opake --version`. 21 + 22 + ### Shell completions 23 + 24 + Every modern shell gets tab-completion for commands, flags, and subcommands. Pick yours: 25 + 26 + <CodeBlock language="sh">opake completions bash > ~/.local/share/bash-completion/completions/opake</CodeBlock> 27 + 28 + <CodeBlock language="sh">opake completions zsh > ~/.zfunc/_opake</CodeBlock> 29 + 30 + <CodeBlock language="sh">opake completions fish > ~/.config/fish/completions/opake.fish</CodeBlock> 31 + 32 + --- 33 + 34 + ## How the CLI thinks 35 + 36 + Three concepts orient the rest of this page. 37 + 38 + **Paths** look like file paths: `report.pdf`, `projects/Q4/notes.md`, `/`. Internally Opake 39 + identifies records by AT-URI (`at://did:plc:xxx/app.opake.document/yyy`), and most commands 40 + accept either — the friendly path when you're at a prompt, the URI when you're piping URIs 41 + around between commands. 42 + 43 + **Workspaces** are a separate namespace from your personal cabinet. A bare `opake ls` lists 44 + the cabinet; `opake ls --workspace family-photos` lists that workspace. Every file-touching 45 + command takes the same `--workspace` flag. 46 + 47 + **Accounts** stack. `opake account login` adds one; `opake account list` shows the stack; 48 + `--as alice.bsky.social` runs a single command against a specific account without flipping 49 + the default. Useful when you keep a personal account and a work account side by side and 50 + don't want to remember which is currently primary. 51 + 52 + Two more globals worth knowing up front: `-v` increases log verbosity (`-vv` debug, `-vvv` 53 + trace), and `--help` works on every subcommand. 18 54 19 55 --- 20 56 21 - ## 1. Identity & Session Management 57 + ## 1. Signing in 58 + 59 + OAuth through your PDS is the default. The first `opake account login` on a fresh machine also 60 + generates your identity keys and prints a **24-word seed phrase** — write it down before you 61 + close the window. It's the only thing that can bring your identity back if you lose every 62 + device you're signed in on. 63 + 64 + <CodeBlock language="sh">opake account login alice.bsky.social</CodeBlock> 65 + 66 + If your PDS is somewhere other than the Bluesky cluster, point at it: 67 + 68 + <CodeBlock language="sh">opake account login alice.example.com --pds https://pds.example.com</CodeBlock> 69 + 70 + For PDSes that don't speak OAuth, fall back to app-password auth: 71 + 72 + <CodeBlock language="sh">opake account login alice.example.com --legacy</CodeBlock> 22 73 23 - Everything starts with a session. Opake supports multiple accounts and uses AT Protocol OAuth by default. 74 + ### Coming back to an existing identity 24 75 25 - ### Login 76 + Two ways to sign in on a new machine without generating a fresh identity: pair off an existing 77 + device, or restore from the seed phrase. 26 78 27 - <CodeBlock language="sh">opake account login you.bsky.social</CodeBlock> 79 + <CodeBlock language="sh">opake pair request # on the new device</CodeBlock> 80 + 81 + <CodeBlock language="sh">opake pair approve # on the existing device</CodeBlock> 82 + 83 + Pairing hands the new device a wrapped copy of your identity keys through your PDS. Both sides 84 + delete their half of the handshake as soon as the transfer is complete. 85 + 86 + <SequenceDiagram 87 + code={pairingCliFlow} 88 + caption="Device pairing. Both devices coordinate through your PDS; neither side ever transmits your identity keys in the clear." 89 + /> 28 90 29 - Override the PDS or fall back to legacy app-password auth: 91 + The other way back in is the seed phrase, in case no paired device is available: 30 92 31 - <CodeBlock language="sh">opake account login you.bsky.social --pds https://pds.example.com</CodeBlock> 93 + <CodeBlock language="sh">opake recover # interactive; prompts for all 24 words</CodeBlock> 32 94 33 - <CodeBlock language="sh">opake account login you.bsky.social --legacy</CodeBlock> 95 + <CodeBlock language="sh">opake recover -f seed-backup.txt</CodeBlock> 34 96 35 - ### Managing Accounts 97 + ### Managing the account stack 36 98 37 99 <CodeBlock language="sh">opake account list</CodeBlock> 38 100 39 101 <CodeBlock language="sh">opake account set-default bob.other.com</CodeBlock> 40 102 41 - Use a specific account for a single command with `--as`: 103 + <CodeBlock language="sh">opake ls --as alice.bsky.social</CodeBlock> 42 104 43 - <CodeBlock language="sh">opake ls --as alice.example.com</CodeBlock> 105 + `--as` accepts a handle or a DID. Handles get resolved fresh on each invocation, so if someone 106 + moved providers recently the CLI still finds them. 44 107 45 108 --- 46 109 47 - ## 2. The Social Filesystem 110 + ## 2. Files 48 111 49 - Managing your encrypted vault from the terminal. 112 + `opake upload` puts a file in your cabinet. Destination paths are optional; omit them and the 113 + file lands at the root. 50 114 51 - ### Upload & Download 115 + <CodeBlock language="sh">opake upload report.pdf</CodeBlock> 52 116 53 - <CodeBlock language="sh">opake upload photo.jpg --description "Beach vacation"</CodeBlock> 117 + <CodeBlock language="sh">opake upload report.pdf --dir projects/Q4</CodeBlock> 54 118 55 - <CodeBlock language="sh">opake download photo.jpg -o ~/Downloads/copy.jpg</CodeBlock> 119 + <CodeBlock language="sh">opake upload report.pdf --description "Quarterly financial summary"</CodeBlock> 56 120 57 - Decrypt and stream to stdout without saving locally: 121 + <CodeBlock language="sh">opake upload report.pdf --workspace team</CodeBlock> 58 122 59 - <CodeBlock language="sh">opake cat notes.txt</CodeBlock> 123 + ### Reading them back 60 124 61 - ### Organization 125 + <CodeBlock language="sh">opake ls</CodeBlock> 62 126 63 - <CodeBlock language="sh">opake ls --long</CodeBlock> 127 + <CodeBlock language="sh">opake ls projects/Q4 -l # long format, sizes + mime types</CodeBlock> 64 128 65 - <CodeBlock language="sh">opake mkdir Photos</CodeBlock> 129 + <CodeBlock language="sh">opake ls --tag finance</CodeBlock> 66 130 67 131 <CodeBlock language="sh">opake tree</CodeBlock> 68 132 69 - ### Metadata 133 + <CodeBlock language="sh">opake download report.pdf -o ~/Downloads/</CodeBlock> 70 134 71 - View or modify a document's metadata after upload: 135 + <CodeBlock language="sh">opake cat notes.md # decrypts to stdout, pipe-friendly</CodeBlock> 72 136 73 - <CodeBlock language="sh">opake metadata photo.jpg</CodeBlock> 137 + `opake cat` is `opake download --stdout` with a shorter name, and it composes with the rest of 138 + your shell: 74 139 75 - <CodeBlock language="sh">opake metadata photo.jpg --rename "sunset.jpg"</CodeBlock> 140 + <CodeBlock language="sh">opake cat grocery-list.txt | grep -i bread</CodeBlock> 76 141 77 - <CodeBlock language="sh">opake metadata photo.jpg --add-tag vacation --add-tag beach</CodeBlock> 142 + ### Moving, renaming, deleting 78 143 79 - <CodeBlock language="sh"> 80 - opake metadata photo.jpg --description "Golden hour at Scheveningen" 81 - </CodeBlock> 144 + Rename is a metadata change. Move is structural. 145 + 146 + <CodeBlock language="sh">opake metadata rename report.pdf quarterly-report.pdf</CodeBlock> 147 + 148 + <CodeBlock language="sh">opake move report.pdf projects/Q4/</CodeBlock> 149 + 150 + <CodeBlock language="sh">opake mkdir archive</CodeBlock> 151 + 152 + <CodeBlock language="sh">opake rm old-draft.md</CodeBlock> 153 + 154 + <CodeBlock language="sh">opake rm -r archive/</CodeBlock> 155 + 156 + ### Editing metadata 157 + 158 + Document metadata is the part Opake still lets you see in the clear — locally. Names, tags, 159 + and descriptions are encrypted on the wire but rendered in your terminal. 160 + 161 + <CodeBlock language="sh">opake metadata show report.pdf</CodeBlock> 162 + 163 + <CodeBlock language="sh">opake metadata describe report.pdf "Q4 financial summary"</CodeBlock> 164 + 165 + <CodeBlock language="sh">opake metadata tag add report.pdf finance</CodeBlock> 166 + 167 + <CodeBlock language="sh">opake metadata tag remove report.pdf draft</CodeBlock> 82 168 83 169 --- 84 170 85 - ## 3. Sharing & Collaboration 171 + ## 3. Sharing 86 172 87 - ### Direct Sharing (Grants) 173 + ### One file, one person 88 174 89 175 <CodeBlock language="sh">opake share new secret.pdf bob.bsky.social</CodeBlock> 90 176 91 - <CodeBlock language="sh">opake share list</CodeBlock> 177 + Opake resolves the handle, fetches Bob's public key from his PDS, wraps the file's content key 178 + against it, and publishes a grant record. Bob sees the file appear in his inbox the next time 179 + his client syncs. 92 180 93 - <CodeBlock language="sh">opake share inbox</CodeBlock> 181 + <CodeBlock language="sh">opake share list # grants you've issued</CodeBlock> 182 + 183 + <CodeBlock language="sh">opake share inbox # grants issued to you</CodeBlock> 94 184 95 185 <CodeBlock language="sh">opake share revoke at://did:plc:123/app.opake.grant/tid456</CodeBlock> 96 186 97 - ### Group Sharing (Workspaces) 187 + Revoking deletes the grant record. It doesn't un-decrypt any copy Bob has already downloaded 188 + — that ship sailed the moment he opened the file. What revocation does buy you is: Bob can't 189 + re-fetch, can't get future updates, and anyone else browsing his repo won't see the grant. 190 + 191 + Grab the URI of a specific grant from `opake share list`. 192 + 193 + ### A group of people 194 + 195 + Workspaces are shared folders with a shared encryption key. Adding someone to the workspace 196 + gives them access to every file in it; removing them doesn't re-encrypt history, but it does 197 + cut them off from future changes. 198 + 199 + <CodeBlock language="sh">opake workspace create family-photos</CodeBlock> 200 + 201 + <CodeBlock language="sh">opake workspace add-member family-photos alice.bsky.social</CodeBlock> 202 + 203 + <CodeBlock language="sh">opake workspace ls -l</CodeBlock> 204 + 205 + <CodeBlock language="sh">opake upload vacation.jpg --workspace family-photos</CodeBlock> 206 + 207 + <CodeBlock language="sh">opake workspace remove-member family-photos alice.bsky.social</CodeBlock> 208 + 209 + <CodeBlock language="sh">opake workspace leave family-photos</CodeBlock> 98 210 99 - <CodeBlock language="sh">opake workspace create "The Collective"</CodeBlock> 211 + --- 100 212 101 - <CodeBlock language="sh">opake workspace add-member "The Collective" alice.bsky.social</CodeBlock> 213 + ## 4. Looking someone up 102 214 103 - <CodeBlock language="sh">opake upload internal-docs.zip --workspace "The Collective"</CodeBlock> 215 + Before you ship a grant to a handle you got over email or Signal, it's sometimes worth 216 + verifying who's on the other end: 217 + 218 + <CodeBlock language="sh">opake resolve bob.bsky.social</CodeBlock> 219 + 220 + Prints Bob's DID, his PDS, and his X25519 encryption public key. Ask Bob to read his key 221 + fingerprint to you out-of-band; if it matches what `opake resolve` shows, you've confirmed the 222 + identity the grant will actually target. 104 223 105 224 --- 106 225 107 - ## 4. Maintenance & Security 226 + ## 5. Background sync 108 227 109 - ### Device Pairing 228 + The daemon runs maintenance tasks that don't need to be interactive: the SSE subscription for 229 + live updates, proactive token refresh, and re-encryption passes after a key rotation. You 230 + don't strictly need it for day-to-day use — every interactive command triggers the minimum 231 + sync it needs before running. The daemon just means you don't eat that latency each time. 110 232 111 - <CodeBlock language="sh">opake pair request</CodeBlock> 233 + <CodeBlock language="sh">opake daemon run # foreground, for systemd / launchd units</CodeBlock> 112 234 113 - Run the above on the **new** device, then approve on the **existing** device: 235 + <CodeBlock language="sh">opake daemon list-tasks # what's queued and what's running</CodeBlock> 114 236 115 - <CodeBlock language="sh">opake pair approve</CodeBlock> 237 + <CodeBlock language="sh">opake daemon install # generate + install a launchd or systemd unit</CodeBlock> 116 238 117 - ### The Nuclear Option 239 + <CodeBlock language="sh">opake daemon uninstall</CodeBlock> 240 + 241 + `opake daemon install` picks the right service format for your platform (launchd on macOS, 242 + systemd on Linux) and writes the unit file where the service manager expects it. The service 243 + starts automatically on login. 244 + 245 + --- 246 + 247 + ## 6. Starting over 248 + 249 + Three escalating levels of clean slate. 118 250 119 251 <CodeBlock language="sh">opake account logout alice.bsky.social</CodeBlock> 252 + 253 + Drops local session credentials for one account. Your encrypted files stay on your PDS 254 + untouched; signing back in picks them right up. 120 255 121 256 <CodeBlock language="sh">opake rm -r /</CodeBlock> 257 + 258 + Empties your cabinet of every document and directory but keeps your identity, keys, and 259 + workspace memberships intact. Good for when you want to repopulate from scratch without 260 + re-pairing or regenerating anything. 122 261 123 262 <CodeBlock language="sh">opake purge --dry-run</CodeBlock> 124 263 125 264 <CodeBlock language="sh">opake purge --force</CodeBlock> 126 265 266 + The real nuke. Deletes every `app.opake.*` record from your PDS — files, grants, workspaces, 267 + published encryption key, the lot. Your atproto identity itself survives (that belongs to the 268 + PDS, not Opake), but every trace of Opake having been there is gone. `--dry-run` lists what 269 + would go without touching anything. 270 + 127 271 <Callout type="warning"> 128 - **The Mini Nuke:** Running `opake rm -r /` clears your entire vault while keeping your 129 - cryptographic identity intact. Use it when you want a fresh start without re-pairing devices. 272 + `opake purge --force` is irreversible and doesn't prompt. Grants you've issued to other 273 + people still point at records that no longer exist after a purge — the links break silently 274 + on their end. 130 275 </Callout> 131 276 132 277 <Callout type="info"> 133 - **Pro-Tip:** Every command supports `--help` for detailed usage and sub-command options. 278 + Every subcommand takes `--help` for full flags and arguments. Pair it with `-v` when 279 + something's misbehaving and you want to see the underlying XRPC calls. 134 280 </Callout> 281 + 282 + <DocsNext slug="cli" />
+2
apps/web/src/content/docs/build/lexicons.mdx
··· 205 205 re-exports the field-level types via `@opake/sdk` — check 206 206 [Files & directories](/docs/sdk/files) and [Sharing](/docs/sdk/sharing) for how they 207 207 surface in the high-level API. 208 + 209 + <DocsNext slug="lexicons" />
+3 -3
apps/web/src/content/docs/faq.mdx
··· 26 26 </FaqItem> 27 27 28 28 <FaqItem question="Can I share files with people who don't use Opake?"> 29 - The recipient needs an AT Protocol identity (a DID) to receive a [Grant](/docs/sharing) — the 30 - grant wraps the file's content key to their published public key, and there's no public key to 31 - wrap to if they've never set up an account. If you want to share with someone outside the 29 + The recipient needs an AT Protocol identity (a DID) to receive a [Grant](/docs/sharing). The 30 + grant is the file's key, encrypted just for them — and if they've never set up an account, 31 + there's no public key to encrypt it against. If you want to share with someone outside the 32 32 network, help them sign up first. 33 33 </FaqItem> 34 34
+27 -22
apps/web/src/content/docs/index.mdx
··· 29 29 30 30 <DocsIndexCard href="/docs/sharing" icon="share"> 31 31 <DocsIndexTitle>Sharing</DocsIndexTitle> 32 - <DocsIndexBody>Share a file with one other person — no accounts, no invites, just their handle.</DocsIndexBody> 32 + <DocsIndexBody>Share a file with someone else. No account setup on their end, no invite link, just their handle.</DocsIndexBody> 33 33 </DocsIndexCard> 34 34 35 35 <DocsIndexCard href="/docs/workspaces" icon="group"> 36 36 <DocsIndexTitle>Workspaces</DocsIndexTitle> 37 - <DocsIndexBody>Share folders with teams, families, or research groups without re-encrypting each file.</DocsIndexBody> 37 + <DocsIndexBody>Share folders with teams, families, or research groups. Add and remove people without re-uploading files.</DocsIndexBody> 38 38 </DocsIndexCard> 39 39 40 40 <DocsIndexCard href="/docs/pairing" icon="pairing"> 41 41 <DocsIndexTitle>Multi-Device</DocsIndexTitle> 42 - <DocsIndexBody>Move your identity to a new phone or laptop without exposing it to the network.</DocsIndexBody> 42 + <DocsIndexBody>Move your identity onto a new phone or laptop without putting it on the network in plaintext.</DocsIndexBody> 43 43 </DocsIndexCard> 44 44 45 45 <DocsIndexCard href="/docs/seed-phrase" icon="seedling"> 46 - <DocsIndexTitle>Your Seed Phrase</DocsIndexTitle> 47 - <DocsIndexBody>Back up your identity with a 24-word phrase — the fallback when all else fails.</DocsIndexBody> 46 + <DocsIndexTitle>What your key actually looks like</DocsIndexTitle> 47 + <DocsIndexBody>Twenty-four words that can bring your identity back on any device — the fallback when nothing else is left.</DocsIndexBody> 48 48 </DocsIndexCard> 49 49 50 50 <DocsIndexCard href="/docs/troubleshooting" icon="question"> ··· 55 55 </DocsIndexSection> 56 56 57 57 <DocsIndexSection 58 + label="For developers" 59 + description="Program against Opake. The CLI is the reference implementation; the SDK and React bindings wrap the same primitives for building your own clients." 60 + > 61 + <DocsIndexGrid> 62 + <DocsIndexCard href="/docs/cli" icon="terminal"> 63 + <DocsIndexTitle>The CLI Manual</DocsIndexTitle> 64 + <DocsIndexBody>Complete command reference for the Opake CLI — identity, files, sharing, and more.</DocsIndexBody> 65 + </DocsIndexCard> 66 + 67 + <DocsIndexCard href="/docs/sdk/overview" icon="book"> 68 + <DocsIndexTitle>@opake/sdk</DocsIndexTitle> 69 + <DocsIndexBody>Install, initialise, and ship your first encrypted upload with the TypeScript SDK.</DocsIndexBody> 70 + </DocsIndexCard> 71 + 72 + <DocsIndexCard href="/docs/react/overview" icon="react"> 73 + <DocsIndexTitle>@opake/react</DocsIndexTitle> 74 + <DocsIndexBody>Quickly ship front-end applications using our React wrappers.</DocsIndexBody> 75 + </DocsIndexCard> 76 + </DocsIndexGrid> 77 + </DocsIndexSection> 78 + 79 + <DocsIndexSection 58 80 label="Under the hood" 59 81 description="How Opake protects your data — the crypto, the records, the protocol. Written for anyone curious enough to look, not just developers." 60 82 > ··· 76 98 </DocsIndexGrid> 77 99 </DocsIndexSection> 78 100 79 - <DocsIndexSection 80 - label="For developers" 81 - description="Program against Opake. The CLI is the reference implementation; the SDK and React bindings wrap the same primitives for building your own clients." 82 - > 83 - <DocsIndexGrid> 84 - <DocsIndexCard href="/docs/cli" icon="terminal"> 85 - <DocsIndexTitle>The CLI Manual</DocsIndexTitle> 86 - <DocsIndexBody>Complete command reference for the Opake CLI — identity, files, sharing, and more.</DocsIndexBody> 87 - </DocsIndexCard> 88 - 89 - <DocsIndexCard href="/docs/sdk/overview" icon="book"> 90 - <DocsIndexTitle>@opake/sdk</DocsIndexTitle> 91 - <DocsIndexBody>Install, initialise, and ship your first encrypted upload with the TypeScript SDK.</DocsIndexBody> 92 - </DocsIndexCard> 93 - </DocsIndexGrid> 94 - </DocsIndexSection> 95 -
+98
apps/web/src/content/docs/understand/_diagrams.ts
··· 1 + // Mermaid sources for the /understand/ docs pages. 2 + // 3 + // Kept in a `.ts` file rather than inline in the `.mdx` files because MDX 3 4 + // dedents template literals inside `.mdx`, which mangles mermaid whitespace 5 + // (mermaid is sensitive to leading indentation on lines inside 6 + // sequenceDiagram / flowchart blocks). Imports bypass that. 7 + // 8 + // These are simplified versions of the internal flows in `docs/flows/*.md` — 9 + // implementation-detail names like `FileManager`, `#[signoff]`, and 10 + // `com.atproto.repo.createRecord` are collapsed to the underlying operation 11 + // a reader actually needs to picture. 12 + 13 + /** Encrypt-and-upload, end-to-end. */ 14 + export const uploadFlow = `sequenceDiagram 15 + participant App as Opake App 16 + participant Crypto as Client-side crypto 17 + participant PDS as Your PDS 18 + 19 + App->>Crypto: generate random content key K (256-bit) 20 + App->>Crypto: encrypt file with K 21 + Crypto-->>App: ciphertext blob 22 + 23 + App->>PDS: upload ciphertext as a blob 24 + PDS-->>App: blob reference (CID) 25 + 26 + App->>Crypto: wrap K with your public key 27 + Crypto-->>App: wrappedKey 28 + 29 + App->>Crypto: encrypt metadata with K 30 + Crypto-->>App: encrypted metadata 31 + 32 + App->>PDS: publish document record<br/>(blob ref + wrappedKey + encrypted metadata) 33 + PDS-->>App: record URI 34 + 35 + Note over PDS: PDS never sees<br/>the plaintext or K 36 + `; 37 + 38 + /** Granting access to another user and the recipient's download. */ 39 + export const sharingFlow = `sequenceDiagram 40 + participant You 41 + participant YourPDS as Your PDS 42 + participant Indexer 43 + participant Friend 44 + 45 + You->>YourPDS: resolve friend's handle → DID 46 + You->>YourPDS: fetch friend's public encryption key 47 + 48 + You->>You: wrap file's content key<br/>with friend's public key 49 + You->>YourPDS: publish Grant record<br/>(document URI + wrappedKey) 50 + 51 + YourPDS-->>Indexer: firehose event: new grant 52 + Indexer-->>Friend: inbox: "new share from you" 53 + 54 + Friend->>YourPDS: fetch the grant 55 + YourPDS-->>Friend: grant record 56 + Friend->>Friend: unwrap content key<br/>with their private key 57 + 58 + Friend->>YourPDS: fetch ciphertext blob 59 + YourPDS-->>Friend: ciphertext 60 + Friend->>Friend: decrypt with content key 61 + 62 + Note over YourPDS: File never leaves your PDS —<br/>friend streams it directly 63 + `; 64 + 65 + /** Device pairing — how identity keys reach a new device. */ 66 + export const pairingFlow = `sequenceDiagram 67 + participant NewDevice as New Device 68 + participant PDS as Your PDS 69 + participant OldDevice as Existing Device 70 + 71 + NewDevice->>NewDevice: generate one-time keypair 72 + NewDevice->>PDS: publish pair request<br/>(contains new device's public half) 73 + 74 + OldDevice->>PDS: poll for pair requests 75 + PDS-->>OldDevice: pair request record 76 + 77 + OldDevice->>OldDevice: wrap your identity<br/>with new device's public half 78 + 79 + OldDevice->>PDS: publish pair response<br/>(wrapped identity) 80 + 81 + NewDevice->>PDS: poll for pair response 82 + PDS-->>NewDevice: pair response record 83 + 84 + NewDevice->>NewDevice: unwrap with<br/>one-time private half<br/>→ identity ready 85 + 86 + NewDevice->>PDS: delete both records 87 + `; 88 + 89 + /** Turning 24 words into the two identity keypairs. */ 90 + export const derivationFlow = `flowchart LR 91 + A["24 words<br/><small>BIP-39 wordlist</small>"] -->|"PBKDF2-HMAC-SHA512<br/><small>2048 rounds, salt 'mnemonic'</small>"| B["512-bit<br/>master seed"] 92 + B -->|"HKDF-SHA256<br/><small>info: opake-v1-x25519-identity</small>"| C["X25519 keypair<br/><small>encryption + key wrapping</small>"] 93 + B -->|"HKDF-SHA256<br/><small>info: opake-v1-ed25519-signing</small>"| D["Ed25519 keypair<br/><small>indexer auth + record signing</small>"] 94 + 95 + style A fill:#F5E9D0,stroke:#9A7840 96 + style C fill:#EEF2E8,stroke:#5C7A54 97 + style D fill:#EEF2E8,stroke:#5C7A54 98 + `;
+8 -1
apps/web/src/content/docs/understand/at-protocol.mdx
··· 1 + import { sharingFlow } from "./_diagrams"; 2 + 1 3 <ChapterHeader title="The Open Network: Your Data, Anywhere" /> 2 4 3 5 <Lead> ··· 71 73 different host and they stream it straight from your cabinet. The PDS on each side only ever 72 74 sees ciphertext; the encryption is the access control. 73 75 76 + <SequenceDiagram 77 + code={sharingFlow} 78 + caption="Sharing across the Atmosphere: the grant travels through the indexer, the ciphertext stays on the original PDS, and the recipient fetches directly." 79 + /> 80 + 74 81 --- 75 82 76 83 ## Resources & Further Reading ··· 90 97 - **[A Social Filesystem](https://overreacted.io/a-social-filesystem/):** Dan Abramov's vision of the AT Protocol as a decentralized filesystem—the very vision Opake is building. 91 98 92 99 93 - Ready to learn about how we keep this open network private? Read about [Encryption & Keys](/docs/encryption). 100 + <DocsNext slug="at-protocol" />
+63 -3
apps/web/src/content/docs/understand/encryption.mdx
··· 1 1 import { encryptBlob } from "./_snippets"; 2 + import { uploadFlow, derivationFlow } from "./_diagrams"; 2 3 3 4 <ChapterHeader title="Encryption & Keys" /> 4 5 ··· 32 33 33 34 The result is a `WrappedKey`. This lockbox is safe to store publicly while being streamed through the AT Protocol firehose because it can only be opened by your private X25519 key. 34 35 36 + <SequenceDiagram 37 + code={uploadFlow} 38 + caption="Full upload flow: generate a content key, encrypt the file, wrap the key to you, publish both." 39 + /> 40 + 35 41 <Callout type="info"> 36 42 **A Note on JWE:** We intentionally avoided the JSON Web Encryption (JWE) standard. Our specific 37 43 HKDF info string (`opake-v1-x25519-hkdf-a256kw-{did}`) provides strict domain separation, ··· 54 60 55 61 ## Where Do Keys Come From? 56 62 57 - Your keys are derived from a **24-word secret phrase** generated when you first set up Opake. The same phrase always produces the same keys — this is how you can recover your identity on a new device without needing your old one. 63 + Your keys are derived from a **24-word secret phrase** generated when you first set up Opake. 64 + The same phrase always produces the same keys — this is how you can recover your identity on 65 + a new device without needing your old one. For the user-facing walkthrough (what to do with 66 + the words, how to back them up), see 67 + [What your key actually looks like](/docs/seed-phrase). 68 + 69 + ### The derivation pipeline 70 + 71 + Turning 24 words into a working identity is a three-stage process; each stage uses a standard, 72 + audited primitive. 73 + 74 + 1. **BIP-39 → 256 bits of entropy.** The 24 words are drawn from the BIP-39 English wordlist: 75 + 2048 entries, 11 bits each, so 24 × 11 = 264 bits total. The last 8 bits are a checksum, 76 + leaving 256 bits of actual randomness — the same entropy budget as the AES-256 keys that 77 + get derived from it. 58 78 59 - For nerds: the derivation uses standard cryptographic primitives: PBKDF2 to stretch the phrase into a master seed, then HKDF to derive separate X25519 (encryption) and Ed25519 (signing) keys. For the full technical details, see [Your Seed Phrase](/docs/seed-phrase). 79 + 2. **PBKDF2-HMAC-SHA512 → 512-bit master seed.** The words (as UTF-8 bytes) are stretched 80 + through PBKDF2 with 2048 rounds and salt `"mnemonic"` — the BIP-39 standard. The rounds are 81 + intentional cost: unnoticeable on a legitimate login, meaningful when an attacker is trying 82 + to run billions of guesses. 83 + 84 + 3. **HKDF-SHA256 → two keys.** The 512-bit master seed is fed through HKDF-SHA256 twice, with 85 + different `info` strings: 86 + 87 + - `opake-v1-x25519-identity` → X25519 private key (encryption + key wrapping) 88 + - `opake-v1-ed25519-signing` → Ed25519 signing key (indexer authentication + record 89 + signing) 90 + 91 + The `info` strings provide **domain separation**: even with the same master seed, you can't 92 + substitute one key for the other, and a future schema version (`opake-v2-...`) produces 93 + cleanly independent keys from the same words. 94 + 95 + The pipeline is **deterministic**: same 24 words, same master seed, same X25519 and Ed25519 96 + keys, every time, on every device. That's what makes seed-phrase recovery possible. 97 + 98 + <SequenceDiagram 99 + code={derivationFlow} 100 + caption="The derivation pipeline: 24 words turn into two independent keypairs via a slow KDF and two domain-separated HKDF applications." 101 + /> 102 + 103 + The PBKDF2 salt is the fixed string `"mnemonic"` per the BIP-39 specification. Security rests 104 + on the 256-bit entropy of the words themselves, which is already enough randomness to make 105 + per-user salting redundant. 106 + 107 + ### The two keys, and what they do 108 + 109 + | Key | Purpose | 110 + |-----|---------| 111 + | **X25519 keypair** | Encryption. The private half unwraps content keys sent to you. The public half is published on your PDS so others can wrap content keys to you. | 112 + | **Ed25519 keypair** | Signing. The private half signs indexer auth tokens and (eventually) record envelopes. The public half is published alongside the X25519 key so services can verify signatures. | 113 + 114 + Both public halves live in a single `app.opake.publicKey/self` record on your PDS — a 115 + singleton, republished on every login so it stays current. 60 116 61 117 ## Where Are My Keys Stored? 62 118 ··· 65 121 - **On the Web:** They are stored in your browser's `IndexedDB`, accessible only to the Opake web app domain. 66 122 - **On the CLI:** They are stored in `~/.config/opake/accounts/`, guarded by strict `0600` UNIX file permissions. 67 123 68 - Your seed phrase is shown once at setup and never stored. To learn how to back it up and recover, read [Your Seed Phrase](/docs/seed-phrase). To transfer keys between devices without re-entering the phrase, read about [Device Pairing](/docs/pairing). 124 + Your seed phrase is shown once at setup and never stored. To learn how to back it up and 125 + recover, see [What your key actually looks like](/docs/seed-phrase). To transfer keys 126 + between devices without re-entering the phrase, read about [Device Pairing](/docs/pairing). 127 + 128 + <DocsNext slug="encryption" />
+40
apps/web/src/content/docs/understand/glossary.mdx
··· 6 6 7 7 The decentralized social networking protocol that Opake is built on. It handles identity, data storage, and the communication between different servers. 8 8 9 + ### Atmosphere 10 + 11 + The broader ecosystem of apps and services built on the AT Protocol — Bluesky, Opake, and 12 + anything else speaking the same identity and storage layer. "The Atmosphere" is the 13 + shorthand for that family of products as a whole. 14 + 9 15 ### AES-256-GCM 10 16 11 17 The symmetric encryption algorithm used for file content. Authenticated (detects tampering), 12 18 fast on modern hardware, and well-studied. 13 19 20 + ### BIP-39 21 + 22 + A standardized scheme for encoding random bytes as a 24-word phrase drawn from a fixed 23 + wordlist of 2048 common English words. Opake uses it to generate your seed phrase — the words 24 + *are* your key material, expressed in a form you can actually write down. 25 + 26 + ### Blob 27 + 28 + A binary blob of data uploaded to a PDS. For Opake, that means the encrypted ciphertext of a 29 + file. The PDS stores blobs separately from records and hands them back on request by content 30 + hash. 31 + 14 32 ### Indexer 15 33 16 34 A specialized service that indexes the [Firehose](#firehose) and provides a fast way to discover which files have been shared with you. It never sees your plaintext data. Fills the atproto "appview" protocol role, but we call it the indexer because all payloads are ciphertext and it serves no rendered views. ··· 28 46 29 47 Your permanent, cryptographic ID on the AT Protocol (e.g., `did:plc:123...`). Unlike a handle, a DID never changes. 30 48 49 + ### Ed25519 50 + 51 + The elliptic curve Opake uses for *signing*. Separate from [X25519](#x25519), which handles 52 + *encryption*. Your Ed25519 private key signs indexer auth tokens and (eventually) record 53 + envelopes; the public half is published alongside your encryption key. 54 + 31 55 ### Firehose 32 56 33 57 The real-time stream of all public records being created on the AT Protocol. Our indexer "listens" to the firehose to find new Sharing Grants. ··· 50 74 51 75 The part of your cryptographic identity that you share with the world. Others use your public key to "wrap" files specifically for you. 52 76 77 + ### Publish 78 + 79 + Writing a record to your PDS. In Opake, uploading a file, creating a workspace, sharing with 80 + someone, and pairing a device all involve publishing records under the `app.opake.*` 81 + namespace. The PDS holds them and makes them available to anyone your lexicons say can read 82 + them. 83 + 84 + ### Seed Phrase 85 + 86 + The 24 words Opake shows you at first setup. Same words in, same keys out — the phrase is a 87 + portable, human-writable form of your private key. Your only way back into your cabinet if 88 + you lose access to every signed-in device. See 89 + [What your key actually looks like](/docs/seed-phrase). 90 + 53 91 ### Wrapped Key 54 92 55 93 A [Content Key](#content-key) that has been encrypted for a specific recipient using their [Public Key](#public-key). It's like a small lockbox that only one person can open. ··· 62 100 --- 63 101 64 102 Still curious? Head back to the [Handbook Index](/docs). 103 + 104 + <DocsNext slug="glossary" />
+9 -7
apps/web/src/content/docs/use/getting-started.mdx
··· 14 14 15 15 When you first set up Opake, you'll receive a **24-word secret phrase** — a backup that can 16 16 reconstruct your keys on any device. If you lose both the phrase and every device you're signed 17 - in on, your encrypted files stay permanently unreadable. That's how end-to-end encryption works; 18 - there's no recovery channel to exploit. 17 + in on, your encrypted files stay permanently unreadable. That's how end-to-end encryption 18 + works: no hidden override, no emergency hatch, no support team with a backup copy. Any of 19 + those would also be a way in for everyone else. 19 20 20 21 <Callout type="warning"> 21 - Write down your 24 words and store it somewhere safe. It is the only way to recover your files 22 - if you lose access to all your devices. Read more in [Your Seed Phrase](/docs/seed-phrase). 22 + Write down your 24 words and store them somewhere safe. They're the only way to recover your 23 + files if you lose access to all your devices. Read more in 24 + [What your key actually looks like](/docs/seed-phrase). 23 25 </Callout> 24 26 25 27 --- ··· 59 61 the network, decrypts the metadata locally, and reconstructs the folder hierarchy — instantly, 60 62 and only for you. 61 63 62 - Even if your server is compromised, an admin only sees opaque blobs and encrypted records. They 63 - can't tell which file is a PDF or an image, or what folder it sits in. 64 + Even if your server is compromised, an admin only sees scrambled, unreadable data. They can't 65 + tell which file is a PDF or an image, or what folder it sits in. 64 66 65 - Ready to go deeper into how the math actually works? Read about [Encryption & Keys](/docs/encryption). 67 + <DocsNext slug="getting-started" />
+11 -11
apps/web/src/content/docs/use/pairing.mdx
··· 11 11 Traditional apps sync your data by copying everything to a central server. If that server is 12 12 compromised, so is your privacy. 13 13 14 - Opake uses **Device Pairing** instead: a direct exchange between two of your own devices. They 15 - perform a cryptographic handshake that transfers your encryption identity through the PDS as 16 - ciphertext. The PDS relays the messages without understanding what's inside them. 14 + Opake uses **Device Pairing** instead: a direct, encrypted exchange between two of your own 15 + devices. Your identity moves from the old device to the new one through the PDS, but it's 16 + scrambled on the way. The PDS relays the bytes without seeing what's in them. 17 17 18 18 --- 19 19 20 20 ## The Pairing Process 21 21 22 22 Your private keys only leave one device to arrive on another. At no point are they readable by 23 - anyone but the two devices in the handshake. 23 + anyone but the two devices involved. 24 24 25 25 ### 1. Requesting access (New Device) 26 26 ··· 37 37 </PlatformTab> 38 38 </PlatformToggle> 39 39 40 - ### 2. Approvaling access (Existing Device) 40 + ### 2. Approving access (Existing Device) 41 41 42 42 Your current device will see a notification that a new guest is asking to join your cabinet. 43 43 ··· 59 59 60 60 Even if someone is actively watching your PDS during the handshake, they can't recover the keys. 61 61 62 - The "temporary lock" your new device made in step 1 is unique to that device — only it holds 63 - the matching key. When your existing device wraps your identity inside that lock, the only 64 - party who can unlock the package is the new device itself. The PDS shuttles the locked package 65 - between the two devices; it never holds the key. 62 + The "temporary lock" your new device made in step 1 belongs only to that device; nobody else 63 + holds the matching key. When your existing device wraps your identity inside that lock, only 64 + the new device can unlock the package. The PDS shuttles the locked package between the two 65 + devices; it never holds the key. 66 66 67 - For the specific algorithms Opake uses underneath, see [Encryption & Keys](/docs/encryption). 67 + For the specific algorithms Opake uses, see [Encryption & Keys](/docs/encryption). 68 68 69 69 <Callout type="warning"> 70 70 Verify that the pairing request you're approving is actually from your own device. Approving a ··· 72 72 and trust. 73 73 </Callout> 74 74 75 - Ready to learn about the foundation of all this? Read about the [AT Protocol](/docs/at-protocol). 75 + <DocsNext slug="pairing" />
+46 -30
apps/web/src/content/docs/use/seed-phrase.mdx
··· 1 - <ChapterHeader title="Your Seed Phrase" /> 1 + <ChapterHeader title="What your key actually looks like" /> 2 2 3 3 <Lead> 4 - Your seed phrase is the master key to your entire Opake identity. Twenty-four words that can 5 - reconstruct your encryption keys on any device, at any time. 4 + When Opake sets up your identity, it shows you twenty-four ordinary English words. Those 5 + words *are* your key — write them down and you can bring your identity back on any device, 6 + at any time. Lose them and nothing in the world can help you. 6 7 </Lead> 7 8 8 9 ## What Is It? 9 10 10 - When you first set up Opake, the app generates 24 random words from a standardised wordlist (BIP-39). These words encode 256 bits of entropy — the mathematical seed from which your encryption and signing keys are derived. 11 + When you first set up Opake, the app picks 24 words at random from a fixed list of around 12 + 2000 common English words. Those 24 words carry enough randomness that no other person on 13 + earth will ever land on the same sequence. Your identity is unique by construction. 11 14 12 - The same 24 words will always produce the same keys. This means you can recover your entire identity on a new device by entering the same phrase, without needing access to your old device. 15 + Type the same 24 words back in on a different device (a new phone, a fresh laptop, a rebuild 16 + after you reinstalled the OS) and Opake rebuilds the exact same keys from them. Same words 17 + in, same identity out. 13 18 14 19 <Callout type="warning"> 15 - **Write it down. Store it safely. Never share it.** Your seed phrase is the only way to recover 16 - your identity if you lose access to all your devices. Opake cannot recover it for you. 20 + **Write it down. Store it safely. Never share it.** Your 24 words are the only way back 21 + into your cabinet if you lose access to every device you're signed in on. Opake can't 22 + recover them for you; there's nobody behind the scenes holding a backup. 17 23 </Callout> 18 24 19 25 --- 20 26 21 27 ## How It Works 22 28 23 - When you enter your seed phrase, Opake runs it through a deterministic derivation pipeline: 29 + Same words in, same keys out; every time, on every device. 24 30 25 - 1. **PBKDF2** stretches the phrase into a 512-bit master seed (this is slow on purpose — it makes brute-force attacks expensive). 26 - 2. **HKDF** derives two separate keys from that seed: 27 - - An **X25519 key** for encrypting and decrypting files 28 - - An **Ed25519 key** for signing and authenticating 31 + Opake runs your 24 words through a fixed, slow-by-design process. "Fixed" means you get 32 + identical keys every time you type the phrase, which is how old files still decrypt on a new 33 + phone. "Slow-by-design" means it takes a computer more effort to turn the words into a key — fine 34 + for you (a second or two of wait on first setup) but very difficult for someone trying to 35 + guess your words. 29 36 30 - The important part: this process is entirely deterministic. Same words in, same keys out. Every time, on every device. 37 + <Callout type="info"> 38 + For the specific algorithms behind this derivation, see [Encryption & Keys](/docs/encryption). 39 + </Callout> 31 40 32 41 --- 33 42 ··· 38 47 After signing in, Opake will display your 24 words in a numbered grid. You can: 39 48 40 49 - **Copy** them to your clipboard 41 - - **Download** them as a `.txt` file (saved with restricted permissions) 50 + - **Download** them as a `.txt` file (saved so only your user account can read it) 42 51 43 52 You'll then be asked to confirm 3 randomly chosen words to prove you've saved them. 44 53 45 54 </PlatformTab> 46 55 <PlatformTab name="CLI"> 47 - On your first login, the CLI displays your seed phrase in a numbered grid: 56 + On your first login, the CLI displays your 24 words in a numbered grid: 48 57 49 58 ``` 50 59 1. mimic 7. action 13. zebra 19. crawl ··· 55 64 6. awful 12. palm 18. space 24. focus 56 65 ``` 57 66 58 - You can save this to a file when prompted. You'll then confirm 3 words to verify you've recorded them. 67 + You can save this to a file when prompted. You'll then confirm 3 words to verify you've 68 + recorded them. 59 69 60 70 </PlatformTab> 61 71 </PlatformToggle> 62 72 63 - After confirmation, Opake derives your keys and publishes the public key to your PDS. The seed phrase is never stored or shown again. 73 + After confirmation, Opake derives your keys and publishes the public half to your PDS. The 74 + words themselves are never stored and never shown again. 64 75 65 76 --- 66 77 67 78 ## Recovering on a New Device 68 79 69 - If you get a new device (or reinstall), you can recover your identity without needing your old device. 80 + If you get a new device (or reinstall), you can bring your identity back without needing the 81 + old device at all. 70 82 71 83 <PlatformToggle> 72 84 <PlatformTab name="Web App"> 73 85 After signing in, choose **"Use your recovery phrase"** and enter your 24 words in the grid. 74 - You can also paste all 24 words at once — the app will distribute them across the fields automatically. 86 + You can also paste all 24 words at once — the app distributes them across the fields 87 + automatically. 75 88 </PlatformTab> 76 89 <PlatformTab name="CLI"> 77 90 <CodeBlock language="sh">opake recover</CodeBlock> ··· 83 96 </PlatformTab> 84 97 </PlatformToggle> 85 98 86 - If the derived key doesn't match the one published on your PDS, Opake will warn you. This usually means either the phrase is wrong, or the account was set up before seed phrases were the default. 99 + If the key Opake rebuilds doesn't match the public half that's already published to your 100 + PDS, Opake will warn you. That usually means one of two things: a typo in the phrase, or an 101 + older account that was set up before 24-word phrases were the default. 87 102 88 103 --- 89 104 90 - ## Seed Phrase vs. Device Pairing 105 + ## 24 Words vs. Device Pairing 91 106 92 - Both accomplish the same goal — getting your keys onto a new device. The difference: 107 + Both get your identity onto a new device. The difference: 93 108 94 - | | Seed Phrase | Device Pairing | 109 + | | 24 Words | Device Pairing | 95 110 | ------------------------ | ----------------------------------- | -------------------------------------- | 96 111 | **Requires old device?** | No | Yes | 97 112 | **Works offline?** | Partially (need PDS for publishing) | No (PDS relay required) | 98 113 | **Input method** | Type or paste 24 words | Approve on existing device | 99 114 | **Best for** | Disaster recovery, first-time setup | Quick setup when you have both devices | 100 115 101 - Device pairing is faster when you have both devices handy. The seed phrase is your safety net when you don't. 116 + Device pairing is faster when you have both devices handy. The 24 words are your safety net 117 + when you don't. 102 118 103 119 --- 104 120 105 - ## Storing Your Seed Phrase 121 + ## Storing Your Words 106 122 107 123 Some options, in rough order of paranoia: 108 124 ··· 113 129 What you should _not_ do: 114 130 115 131 - Store it in a cloud notes app (defeats the purpose) 116 - - Take a screenshot (phone backups are not encrypted end-to-end by default) 132 + - Take a screenshot (phone backups aren't end-to-end encrypted by default) 117 133 - Email it to yourself 118 134 119 135 <Callout type="info"> 120 - **The `.txt` file** saved during setup uses restrictive file permissions (0600 on Unix). It's a 121 - reasonable short-term backup, but you should transfer the words to a more permanent medium and 122 - delete the file. 136 + **The `.txt` file** saved during CLI setup is set up so only your user account can read it 137 + — a reasonable short-term backup. Still, move the words somewhere more permanent and delete 138 + the file when you can. 123 139 </Callout> 124 140 125 - Ready to learn how device pairing works as an alternative? Read about [Multi-Device Magic](/docs/pairing). 141 + <DocsNext slug="seed-phrase" />
+20 -19
apps/web/src/content/docs/use/sharing.mdx
··· 1 - <ChapterHeader title="Sharing & DIDs" /> 1 + <ChapterHeader title="Sharing a file" /> 2 2 3 3 <Lead> 4 4 Sharing on a traditional cloud means granting a server permission to show your data to someone. ··· 6 6 profile. 7 7 </Lead> 8 8 9 - ## The Grant model 9 + ## How sharing works 10 10 11 - When you share a file, you create a **Grant** — a small record containing the file's content 12 - key, encrypted so that only the recipient's private key can open it. You publish the grant 13 - record to your own PDS. The recipient finds it via the indexer, unwraps the key with their 14 - private key, and then fetches the encrypted file directly from your PDS. 11 + Sharing a file doesn't move the file. It stays right in your cabinet. 15 12 16 - Neither PDS sees the key or the file content. They're just relaying ciphertext between two 17 - clients. 13 + What you actually send your recipient is a small, one-of-a-kind key for that one file. The 14 + key is locked so only that specific recipient can open it. Opake calls this little key-package 15 + a **Grant**. 16 + 17 + Your recipient uses their own identity to unlock the Grant, then reads the file straight from 18 + your storage. Your storage provider can't read any of it — not the file, not its name, not 19 + the key inside the Grant. They just hold scrambled bytes. 18 20 19 21 --- 20 22 ··· 28 30 1. Select a file in your cabinet. 29 31 2. Click the **Share** icon. 30 32 3. Enter the recipient's handle. 31 - 4. Opake resolves their handle to a DID, fetches their public encryption key, wraps the file key, and publishes the Grant record. 33 + 4. Opake looks up their identity, encrypts the file's key just for them, and publishes the Grant. 32 34 33 35 </PlatformTab> 34 36 <PlatformTab name="CLI"> ··· 36 38 </PlatformTab> 37 39 </PlatformToggle> 38 40 39 - ## 2. Why DIDs matter 41 + ## 2. What if my friend's handle changes? 40 42 41 - You might know your friend as `@bob.bsky.social`, but Opake stores them internally as 42 - `did:plc:z724xy...`. 43 - 44 - A handle is a nickname that can change. A **DID (Decentralized Identifier)** is a permanent, 45 - cryptographic ID. Grants are keyed to DIDs, so access stays valid if your friend moves to a 46 - different PDS or swaps their handle. 43 + Handles can change. Someone might move to a different PDS provider, or just pick a new 44 + handle for a rebrand. Every account also has a permanent **DID** (like `did:plc:z724xy...`) 45 + that stays put for life. When you share a file by handle, Opake quietly resolves the 46 + handle to the DID and writes the grant against that. Even if the handle changes later, the 47 + grant still points at the right person. Nothing you need to do. 47 48 48 49 <Callout type="info"> 49 50 Opake publishes your public encryption key to your PDS when you first log in. That's how 50 - someone else's client can wrap a file to you without you having to exchange an address 51 - out-of-band. 51 + someone else's Opake can encrypt a file just for you, without the two of you needing to swap 52 + keys over a separate channel beforehand. 52 53 </Callout> 53 54 54 55 --- ··· 62 63 they lose their copy, but it can't reach out across the network to delete what they already 63 64 have on disk. 64 65 65 - Ready to see how to manage your identity across multiple devices? Read about [Multi-Device Magic](/docs/pairing). 66 + <DocsNext slug="sharing" />
+18 -11
apps/web/src/content/docs/use/troubleshooting.mdx
··· 20 20 If the browser window opens for login but never redirects you back to Opake: 21 21 22 22 - **Check for ad-blockers:** Some aggressive browser extensions might block the redirect URL. 23 - - **CLI specific:** The CLI uses a temporary server on `127.0.0.1`. Ensure your firewall is not blocking local loopback connections. 23 + - **CLI specific:** The CLI spins up a small server on your own machine (`127.0.0.1`) to 24 + receive the redirect. Make sure your firewall isn't blocking connections to your own 25 + machine. 24 26 25 27 --- 26 28 ··· 28 30 29 31 ### Upload fails halfway (storage limits) 30 32 31 - Opake uploads files as single blobs to the AT Protocol, so large files stress both your 32 - connection and the PDS's blob limits. 33 + Opake uploads each file to the AT Protocol in one piece, so large files lean on both your 34 + connection and whatever size limit your PDS enforces. 33 35 34 36 - **Default size cap:** Opake caps upload size to stay within what most PDS providers accept 35 37 without prior coordination. 36 - - **Increasing the cap:** You (or your PDS administrator) can raise it by publishing 37 - configuration records to your repository. See the [Lexicon reference](/docs/lexicons) for the 38 - schema. 38 + - **Increasing the cap:** You (or your PDS administrator) can raise it by publishing a 39 + configuration record. See the [Lexicon reference](/docs/lexicons) for the exact format. 39 40 - **Network stability:** If your connection drops mid-upload, the upload fails and you have to 40 41 re-send. Resume support is on the roadmap, not there yet. 41 42 42 43 ### "Unable to Decrypt File" 43 44 44 - The most serious error. Opake can't unwrap the key for this file under the identity you're 45 + The most serious error. Opake can't unlock the key for this file under the identity you're 45 46 signed in with. Two common causes: 46 47 47 48 - **Wrong account.** Check you're logged into the account the file was shared with. ··· 58 59 If you can't share a file with someone: 59 60 60 61 - **Handle vs. DID:** Ensure the handle is correct. 61 - - **Public Key Missing:** The recipient must have logged into Opake at least once to publish their [Public Encryption Key](/docs/encryption). If they haven't done this, you cannot "wrap" a file to them. 62 + - **Public Key Missing:** The recipient has to have logged into Opake at least once, so their 63 + [Public Encryption Key](/docs/encryption) is published on their PDS. If it isn't, Opake has 64 + no key to encrypt the file against. 62 65 63 66 ### "I don't see shared files in my inbox" 64 67 65 - Opake uses an **indexer** to index sharing grants. 68 + Opake relies on an **indexer** — a helper service that watches for new grants across the 69 + network and routes each one to the right inbox. 66 70 67 - - **Index Lag:** Sometimes it takes a few moments for the firehose to catch up. 68 - - **Indexer Status:** Check if the indexer service is healthy. If the indexer is down, your inbox will appear empty even if the files exist. 71 + - **Index Lag:** Sometimes it takes a few moments for newly published grants to be picked up. 72 + - **Indexer Status:** If the indexer service is down or unhealthy, your inbox will appear 73 + empty even if the files exist. 69 74 70 75 --- 71 76 ··· 74 79 Tangled](https://tangled.org/opake.app/opake/issues). Include any error messages and 75 80 details about your environment (Web or CLI). 76 81 </Callout> 82 + 83 + <DocsNext slug="troubleshooting" />
+25 -22
apps/web/src/content/docs/use/workspaces.mdx
··· 1 1 <ChapterHeader title="Workspaces: group sharing" /> 2 2 3 3 <Lead> 4 - Direct sharing works for one-off files. For a folder shared with a family, a team, or a 5 - research group, you want something that scales past pairwise encryption. 4 + Direct sharing works fine for one-off files. For a folder that a family, a team, or a 5 + research group all need to read, you want something that scales without re-encrypting 6 + everything each time someone joins or leaves. 6 7 </Lead> 7 8 8 9 ## The group-key model 9 10 10 - A naive encrypted file-sharing app wraps every file to every recipient's public key. Ten members 11 - means ten copies of each content key. An eleventh person joining means re-encrypting everything. 11 + The most direct way to share an encrypted file with several people is to encrypt it separately 12 + for each of them. Ten members means ten copies of each file's key. An eleventh person joining 13 + means touching every file. 12 14 13 - Opake uses **workspaces** instead — a named group that owns a single shared symmetric key (the 14 - "group key"). Files in the workspace have their content keys wrapped under the group key, not 15 - directly under each member's key. 15 + Opake uses **workspaces** instead — a named group that shares a single **group key**. Every 16 + file in the workspace is encrypted with that one key. Members then each hold a personal copy of 17 + the group key itself, encrypted with their own public key. 16 18 17 - 1. The file's content key is wrapped once under the group key. 18 - 2. The group key is wrapped once per member, under each member's X25519 public key. 19 + 1. The file's content key is encrypted once for the whole group. 20 + 2. The group key is encrypted once per member, using their personal public key. 19 21 20 - Adding a member means wrapping the group key for them once. They immediately gain access to 21 - every file ever uploaded under that workspace — no re-encryption of the files themselves. 22 + Adding a member means encrypting the group key for them once. They immediately gain access to 23 + every file ever uploaded under that workspace; the files themselves stay exactly as they were. 22 24 23 25 --- 24 26 25 27 ## 1. Creating a workspace 26 28 27 - A workspace is a single record on the AT Protocol (`app.opake.keyring`). 29 + A workspace is a single record on your PDS that describes the group and carries the group key 30 + (encrypted, of course). 28 31 29 32 <PlatformToggle> 30 33 <PlatformTab name="Web App"> 31 34 32 35 1. Click the **(+)** icon in the Sidebar and select **New Workspace**. 33 36 2. Give it a name (e.g., "Family Photos"). 34 - 3. Opake generates a group key, wraps it to your public key, and publishes the record. 37 + 3. Opake generates a group key, encrypts it just for you, and publishes the record. 35 38 36 39 </PlatformTab> 37 40 <PlatformTab name="CLI"> ··· 43 46 44 47 ### Adding a member 45 48 46 - Adding someone means wrapping the current group key to their public key and updating the 47 - workspace record. They can then decrypt anything stored under that workspace. 49 + Adding someone means encrypting the current group key for them and updating the workspace 50 + record. They can then decrypt anything stored under that workspace. 48 51 49 52 ### Removing a member (key rotation) 50 53 51 54 When you remove a member, Opake rotates the group key so the removed member can't decrypt 52 55 files uploaded after their removal: 53 56 54 - 1. Generate a new group key. 55 - 2. Wrap it to all remaining members. 56 - 3. Archive the previous group key into the workspace's key history so remaining members can 57 - still decrypt files uploaded under older rotations. 57 + 1. A new group key is generated. 58 + 2. It's encrypted for each of the remaining members. 59 + 3. The previous group key is archived in the workspace's history, so remaining members can 60 + still decrypt files that were uploaded under older rotations. 58 61 59 62 <Callout type="warning"> 60 63 Key rotation prevents the removed member from decrypting _future_ files. Anything they already 61 - downloaded and decrypted locally is theirs — rotation can't reach across the network to 62 - delete copies. 64 + downloaded and decrypted locally is theirs. Rotation can't reach across the network to delete 65 + copies. 63 66 </Callout> 64 67 65 68 --- ··· 71 74 Every member of that workspace sees the file in their directory tree and can decrypt it 72 75 without extra steps. 73 76 74 - Ready for a quick reference on the terminology? Check the [Glossary](/docs/glossary). 77 + <DocsNext slug="workspaces" />
+20
apps/web/src/index.css
··· 317 317 margin-bottom: 0.5em; 318 318 color: var(--color-base-content); 319 319 } 320 + /* rehype-autolink-headings appends a link with class `.heading-anchor` to 321 + every h2/h3/h4 whose target is the heading's own slug. Hidden until the 322 + heading is hovered or keyboard-focused so it doesn't clutter the layout, 323 + but still keyboard-reachable via Tab for assistive tech. */ 324 + .prose h2 .heading-anchor, 325 + .prose h3 .heading-anchor, 326 + .prose h4 .heading-anchor { 327 + color: var(--color-text-faint); 328 + opacity: 0; 329 + margin-left: 0.35em; 330 + font-weight: 400; 331 + text-decoration: none; 332 + transition: opacity 0.15s ease-out; 333 + } 334 + .prose h2:hover .heading-anchor, 335 + .prose h3:hover .heading-anchor, 336 + .prose h4:hover .heading-anchor, 337 + .prose .heading-anchor:focus-visible { 338 + opacity: 1; 339 + } 320 340 .prose ul { 321 341 list-style-type: disc; 322 342 margin-block: 0.75em;
+35 -33
apps/web/src/lib/docs-registry.ts
··· 50 50 description: "Store, share, and recover files with Opake.", 51 51 }, 52 52 { 53 - key: "understand", 54 - label: "Under the hood", 55 - description: "How Opake protects your data — the crypto, the records, the protocol.", 56 - }, 57 - { 58 53 key: "build", 59 54 label: "For developers", 60 55 description: "Program against Opake: CLI, SDK, React hooks, lexicons.", 56 + }, 57 + { 58 + key: "understand", 59 + label: "Under the hood", 60 + description: "How Opake protects your data — the crypto, the records, the protocol.", 61 61 }, 62 62 ]; 63 63 ··· 81 81 title: "Multi-Device Magic", 82 82 icon: "pairing", 83 83 description: 84 - "Securely transfer your identity keypair to new devices using your PDS as a relay.", 84 + "Move your identity onto a new phone or laptop without putting it on the network in plaintext.", 85 85 }, 86 86 { 87 87 slug: "seed-phrase", 88 88 category: "use", 89 - title: "Your Seed Phrase", 89 + title: "What your key actually looks like", 90 90 icon: "seedling", 91 - description: "Back up and recover your identity with a 24-word recovery phrase.", 91 + description: 92 + "Twenty-four words that can bring your identity back on any device — your fallback when nothing else is left.", 92 93 }, 93 94 { 94 95 slug: "sharing", 95 96 category: "use", 96 97 title: "Sharing", 97 98 icon: "share", 98 - description: "Share files with another person — one-to-one grants and recipient discovery.", 99 + description: "Share a file with someone else. All you need is their handle.", 99 100 }, 100 101 { 101 102 slug: "workspaces", 102 103 category: "use", 103 104 title: "Workspaces", 104 105 icon: "group", 105 - description: "Share folders with teams, families, and research groups — no re-encryption.", 106 + description: 107 + "Share folders with teams, families, and research groups. Add and remove people without re-uploading files.", 106 108 }, 107 109 { 108 110 slug: "troubleshooting", ··· 110 112 title: "Troubleshooting", 111 113 icon: "question", 112 114 description: "Common problems and how to fix them.", 113 - }, 114 - 115 - // -- Under the hood -------------------------------------------------------- 116 - { 117 - slug: "encryption", 118 - category: "understand", 119 - title: "Encryption & Keys", 120 - icon: "lock", 121 - description: "How end-to-end encryption works in Opake and how your keys are managed.", 122 - }, 123 - { 124 - slug: "at-protocol", 125 - category: "understand", 126 - title: "AT Protocol", 127 - icon: "network", 128 - description: "The open standard powering Opake — identity, data portability, and federation.", 129 - }, 130 - { 131 - slug: "glossary", 132 - category: "understand", 133 - title: "Glossary", 134 - icon: "book", 135 - description: "A quick-hit reference for the terminology and acronyms we use in Opake.", 136 115 }, 137 116 138 117 // -- For developers -------------------------------------------------------- ··· 259 238 icon: "network", 260 239 description: 261 240 "The atproto collections, record schemas, and encryption envelope Opake publishes to a PDS.", 241 + }, 242 + 243 + // -- Under the hood -------------------------------------------------------- 244 + { 245 + slug: "encryption", 246 + category: "understand", 247 + title: "Encryption & Keys", 248 + icon: "lock", 249 + description: "How end-to-end encryption works in Opake and how your keys are managed.", 250 + }, 251 + { 252 + slug: "at-protocol", 253 + category: "understand", 254 + title: "AT Protocol", 255 + icon: "network", 256 + description: "The open standard powering Opake — identity, data portability, and federation.", 257 + }, 258 + { 259 + slug: "glossary", 260 + category: "understand", 261 + title: "Glossary", 262 + icon: "book", 263 + description: "A quick-hit reference for the terminology and acronyms we use in Opake.", 262 264 }, 263 265 264 266 // -- Cross-cutting ---------------------------------------------------------
+63
apps/web/src/lib/docs-search.ts
··· 1 + import { DOCS_REGISTRY, docPath } from "./docs-registry"; 2 + 3 + /** 4 + * A single searchable hit. Today this is one per registered doc; future work 5 + * could extend it with per-heading hits once there's a build-time pre-pass 6 + * that extracts `##`+ headings from MDX source (the natural `import.meta.glob 7 + * + ?raw` approach collides with the MDX plugin's `enforce: "pre"` transform, 8 + * so section-anchor search needs its own small build step to land cleanly). 9 + */ 10 + export interface SearchHit { 11 + readonly docSlug: string; 12 + readonly docTitle: string; 13 + readonly docGroup: string | undefined; 14 + readonly docDescription: string; 15 + readonly href: string; 16 + } 17 + 18 + /** 19 + * All searchable hits. Memoised in module scope — the registry is a 20 + * build-time constant, so this array is stable for the lifetime of the app. 21 + */ 22 + const ALL_HITS: readonly SearchHit[] = DOCS_REGISTRY.map((doc) => ({ 23 + docSlug: doc.slug, 24 + docTitle: doc.title, 25 + docGroup: doc.group, 26 + docDescription: doc.description, 27 + href: docPath(doc), 28 + })); 29 + 30 + /** 31 + * Simple substring match, case-insensitive, across title and description. 32 + * Results are ordered by match strength: 33 + * 1. title prefix match 34 + * 2. title contains 35 + * 3. description contains 36 + * Within each bucket, registry order is preserved. 37 + */ 38 + export function searchDocs(query: string): readonly SearchHit[] { 39 + const q = query.trim().toLowerCase(); 40 + if (!q) return []; 41 + 42 + const prefixMatches: SearchHit[] = []; 43 + const titleContainsMatches: SearchHit[] = []; 44 + const descriptionMatches: SearchHit[] = []; 45 + 46 + for (const hit of ALL_HITS) { 47 + const title = hit.docTitle.toLowerCase(); 48 + const description = hit.docDescription.toLowerCase(); 49 + 50 + if (title.startsWith(q)) { 51 + // eslint-disable-next-line functional/immutable-data -- builder array 52 + prefixMatches.push(hit); 53 + } else if (title.includes(q)) { 54 + // eslint-disable-next-line functional/immutable-data -- builder array 55 + titleContainsMatches.push(hit); 56 + } else if (description.includes(q)) { 57 + // eslint-disable-next-line functional/immutable-data -- builder array 58 + descriptionMatches.push(hit); 59 + } 60 + } 61 + 62 + return [...prefixMatches, ...titleContainsMatches, ...descriptionMatches]; 63 + }
+1 -1
apps/web/src/routes/_public/docs/$category/$slug.tsx
··· 56 56 return ( 57 57 <div className="mx-auto flex w-full max-w-6xl gap-10 px-6 pt-28 pb-20 sm:px-10"> 58 58 <aside className="hidden shrink-0 lg:block lg:w-60"> 59 - <div className="sticky top-24"> 59 + <div className="sticky top-24 max-h-[calc(100vh-7rem)] overflow-y-auto pr-1"> 60 60 <DocsSidebar currentSlug={slug} currentGroup={category} /> 61 61 </div> 62 62 </aside>
+1 -1
apps/web/src/routes/_public/docs/$slug.tsx
··· 50 50 return ( 51 51 <div className="mx-auto flex w-full max-w-6xl gap-10 px-6 pt-28 pb-20 sm:px-10"> 52 52 <aside className="hidden shrink-0 lg:block lg:w-60"> 53 - <div className="sticky top-24"> 53 + <div className="sticky top-24 max-h-[calc(100vh-7rem)] overflow-y-auto pr-1"> 54 54 <DocsSidebar currentSlug={slug} /> 55 55 </div> 56 56 </aside>
+1 -1
apps/web/src/routes/_public/docs/index.tsx
··· 8 8 return ( 9 9 <div className="mx-auto flex w-full max-w-6xl gap-10 px-6 pt-28 pb-20 sm:px-10"> 10 10 <aside className="hidden shrink-0 lg:block lg:w-60"> 11 - <div className="sticky top-24"> 11 + <div className="sticky top-24 max-h-[calc(100vh-7rem)] overflow-y-auto pr-1"> 12 12 <DocsSidebar /> 13 13 </div> 14 14 </aside>
+1 -1
apps/web/src/routes/cabinet/docs/$category/$slug.lazy.tsx
··· 80 80 <PanelShell depth={1} breadcrumbs={breadcrumbs} footer={`${meta.title} · Opake`}> 81 81 <div className="flex gap-6 p-6"> 82 82 <aside className="hidden w-44 shrink-0 md:block"> 83 - <div className="sticky top-0"> 83 + <div className="sticky top-0 max-h-[calc(100vh-2rem)] overflow-y-auto pr-1"> 84 84 <DocsSidebarCabinet currentSlug={meta.slug} currentGroup={category} /> 85 85 </div> 86 86 </aside>
+1 -1
apps/web/src/routes/cabinet/docs/$slug.lazy.tsx
··· 75 75 <PanelShell depth={1} breadcrumbs={breadcrumbs} footer={`${meta.title} · Opake`}> 76 76 <div className="flex gap-6 p-6"> 77 77 <aside className="hidden w-44 shrink-0 md:block"> 78 - <div className="sticky top-0"> 78 + <div className="sticky top-0 max-h-[calc(100vh-2rem)] overflow-y-auto pr-1"> 79 79 <DocsSidebarCabinet currentSlug={meta.slug} /> 80 80 </div> 81 81 </aside>
+30 -2
apps/web/vite.config.ts
··· 5 5 import wasm from "vite-plugin-wasm"; 6 6 import mdx from "@mdx-js/rollup"; 7 7 import remarkGfm from "remark-gfm"; 8 + import rehypeSlug from "rehype-slug"; 9 + import rehypeAutolinkHeadings from "rehype-autolink-headings"; 8 10 9 11 export default defineConfig({ 10 12 plugins: [ 11 - // MDX must run before React transform 12 - { enforce: "pre" as const, ...mdx({ remarkPlugins: [remarkGfm] }) }, 13 + // MDX must run before React transform. 14 + // rehype-slug gives every heading a stable id slug (used for deep links 15 + // like /docs/troubleshooting#unable-to-decrypt-file from in-app error 16 + // toasts). rehype-autolink-headings wraps the heading text so the 17 + // anchor is clickable to copy the link. 18 + { 19 + enforce: "pre" as const, 20 + ...mdx({ 21 + remarkPlugins: [remarkGfm], 22 + rehypePlugins: [ 23 + rehypeSlug, 24 + [ 25 + rehypeAutolinkHeadings, 26 + { 27 + behavior: "append", 28 + properties: { 29 + className: ["heading-anchor"], 30 + ariaLabel: "Link to this section", 31 + }, 32 + content: { 33 + type: "text", 34 + value: " #", 35 + }, 36 + }, 37 + ], 38 + ], 39 + }), 40 + }, 13 41 tailwindcss(), 14 42 wasm(), 15 43 comlink(),