this repo has no description
1
fork

Configure Feed

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

Add SDK overview page + nested docs routing + new Tangled URL

First programmer-aimed docs page, plus the scaffolding to host the rest.

Page — `build/sdk/overview.mdx`:
- What `@opake/sdk` is, what it isn't, and how the class shape divides
between static (bootstrap before Identity exists) and instance (normal
working surface after `Opake.init`).
- Hello-cabinet example: upload + download, with the under-the-hood
breakdown written as prose rather than a patronising reveal.
- Static and instance surface shown as two call-shape code blocks
(grouped by purpose, one call per line with aligned comments) rather
than a wall of TypeScript signatures.
- Storage section ends with a contribution pointer: the `Storage`
contract is small enough that new backends are one file plus tests;
upstreaming keeps them in sync with the trait and discoverable next
to the built-ins.
- Error primer showing `OpakeError.kind` branching, with
`IdentityMissing` called out as a normal post-login state, not a
failure mode.
- Navigation grid linking to the rest of the SDK pages (to be written).

Routing — `/docs/{category}/{slug}`:
- New nested route files under `_public/docs/` and `cabinet/docs/`
(`$category/$slug`, both sync + lazy variants). Coexist with the
existing flat `$slug` routes; TanStack picks the more specific match.
- `DocMeta` gains an optional `group` field. Pages with a group live at
`/docs/{group}/{slug}`; flat docs stay on `/docs/{slug}`. New
`docPath(doc)` helper returns the correct URL.
- `DocLink` in the cabinet docs index splits on `doc.group` so each
variant gets the right typed `<Link>`.

Icons:
- Added `folder` (Phosphor `FolderIcon`) for the Files & Directories
card. Added `lightning` as a future-facing option for live-updates
content.

New Tangled URL:
- Swept every `tangled.org/sans-self.org/opake.app` →
`tangled.org/opake.app/opake` across CLI docs, FAQ, troubleshooting,
web footer + nav, README, CRYPTO.md, and the blog post attribution.
- `apps/cli/src/commands/docs/build/cli.mdx`: adjusted the follow-up
`cd opake.app` to `cd opake` now that the repo name matches.

+458 -18
+1 -1
README.md
··· 14 14 15 15 Your data is opaque to everyone without the key. That's the point. 16 16 17 - [The Handbook](https://opake.app/docs) · [Issue Tracker](https://tangled.org/sans-self.org/opake.app/issues) · [Architecture](docs/ARCHITECTURE.md) 17 + [The Handbook](https://opake.app/docs) · [Issue Tracker](https://tangled.org/opake.app/opake/issues) · [Architecture](docs/ARCHITECTURE.md) 18 18 19 19 ## Quick Start 20 20
+1 -1
apps/web/public/blog/e2e-testing-atproto.md
··· 131 131 132 132 --- 133 133 134 - _[Opake](https://opake.app) — encrypted collaboration on the AT Protocol. [Source](https://tangled.org/sans-self.org/opake)._ 134 + _[Opake](https://opake.app) — encrypted collaboration on the AT Protocol. [Source](https://tangled.org/opake.app/opake)._
+7 -1
apps/web/src/components/content/icons.ts
··· 10 10 UsersThreeIcon, 11 11 ArrowsLeftRightIcon, 12 12 PlantIcon, 13 + FolderIcon, 14 + LightningIcon, 13 15 } from "@phosphor-icons/react"; 14 16 import { TerminalIcon } from "@phosphor-icons/react/dist/ssr"; 15 17 ··· 24 26 | "group" 25 27 | "pairing" 26 28 | "seedling" 27 - | "terminal"; 29 + | "terminal" 30 + | "folder" 31 + | "lightning"; 28 32 29 33 const ICON_MAP: Readonly<Record<IconName, Icon>> = { 30 34 lock: LockIcon, ··· 38 42 pairing: ArrowsLeftRightIcon, 39 43 seedling: PlantIcon, 40 44 terminal: TerminalIcon, 45 + folder: FolderIcon, 46 + lightning: LightningIcon, 41 47 }; 42 48 43 49 export function resolveIcon(name: string): Icon {
+2 -2
apps/web/src/content/docs/build/cli.mdx
··· 9 9 10 10 The CLI is built in Rust and requires building from source. You'll need Rust 1.75+ and Git. 11 11 12 - <CodeBlock language="sh">git clone https://tangled.org/sans-self.org/opake.app</CodeBlock> 12 + <CodeBlock language="sh">git clone https://tangled.org/opake.app/opake</CodeBlock> 13 13 14 - <CodeBlock language="sh">cd opake.app && cargo install --path apps/cli</CodeBlock> 14 + <CodeBlock language="sh">cd opake && cargo install --path apps/cli</CodeBlock> 15 15 16 16 This puts the `opake` binary in your `~/.cargo/bin/` directory. Pre-built binaries and crates.io publishing are planned. 17 17
+218
apps/web/src/content/docs/build/sdk/overview.mdx
··· 1 + <ChapterHeader title="@opake/sdk — Overview" /> 2 + 3 + <Lead> 4 + `@opake/sdk` is the TypeScript layer you use when you want to build an app on top of Opake. It 5 + wraps the Rust WASM core, so every cryptographic operation runs inside a module JS can't reach. 6 + Your code sees type-safe method calls and error objects; that's it. 7 + </Lead> 8 + 9 + You interact with the SDK through a small set of classes. `Opake` is the root. You get one from 10 + `Opake.init({ storage })`, and it owns the session, the identity keypair, and the authenticated 11 + client to your PDS. `FileManager` handles uploads, downloads, directory operations, and 12 + metadata; `opake.cabinet()` returns one for your personal files, and `opake.workspaceByUri(uri)` 13 + returns one for a shared workspace. The `Storage` interface is yours to implement, though for 14 + browsers you almost certainly want the built-in `IndexedDbStorage`. 15 + 16 + None of the HTTP or crypto is exposed on this side. Tokens, DPoP keys, and identity private 17 + material live inside the WASM module where JS can't read them. The TypeScript layer is types 18 + and transport between your code and that module. 19 + 20 + ## Install 21 + 22 + <CodeBlock language="sh">bun add @opake/sdk</CodeBlock> 23 + 24 + Or if you're on npm / pnpm / yarn: 25 + 26 + <CodeBlock language="sh">npm install @opake/sdk</CodeBlock> 27 + 28 + AGPL-3.0. If you bundle it into a web app, that app becomes a derivative work. See 29 + [Licensing](/docs/faq#licensing). 30 + 31 + ## Hello, cabinet 32 + 33 + The minimum it takes to upload and read back one encrypted file: 34 + 35 + <CodeBlock language="typescript" title="example.ts"> 36 + {`import { Opake } from "@opake/sdk"; 37 + import { IndexedDbStorage } from "@opake/sdk/storage/indexeddb"; 38 + 39 + const storage = new IndexedDbStorage(); 40 + 41 + // Assumes the user has already logged in. See authentication.mdx. 42 + const opake = await Opake.init({ storage }); 43 + const fm = await opake.cabinet(); 44 + 45 + // Upload 46 + const bytes = new TextEncoder().encode("hello from an encrypted cabinet"); 47 + await fm.uploadAt(bytes, "greetings.txt", "text/plain"); 48 + 49 + // Read back 50 + const { plaintext, filename } = await fm.downloadAt("/greetings.txt"); 51 + console.log(filename, new TextDecoder().decode(plaintext)); 52 + // → "greetings.txt hello from an encrypted cabinet"`} 53 + </CodeBlock> 54 + 55 + Under those calls: `IndexedDbStorage` persisted the session and the identity keypair, so the 56 + next page load starts from `Opake.init` without redoing OAuth. `FileManager.uploadAt` generated 57 + a random AES-256-GCM content key, encrypted the bytes, wrapped the key to your X25519 public 58 + key, and wrote three records to the PDS (document metadata, blob, and the wrapped-key envelope). 59 + And if the access token was within 30 seconds of expiring, the SDK refreshed it first, 60 + single-flight so concurrent calls don't each fire their own refresh. 61 + 62 + ## The `Opake` class 63 + 64 + The surface splits in two. Static methods run before an identity exists in Storage: login, 65 + seed-phrase recovery, device pairing. Instance methods come out of `Opake.init({ storage })` 66 + once an identity is loaded. 67 + 68 + ### Static surface 69 + 70 + <CodeBlock language="typescript" title="before init"> 71 + {`// OAuth two-step 72 + Opake.startLogin(handle, options); 73 + Opake.completeLogin(code, state, pending, options); 74 + Opake.loginWithAppPassword(options); 75 + 76 + // Seed-phrase recovery 77 + Opake.generateMnemonic(); 78 + Opake.validateSeedPhrase(phrase); 79 + Opake.createIdentity(phrase, did); 80 + 81 + // Device pairing 82 + Opake.createPairRequest(storage, did); 83 + Opake.awaitPairCompletion(storage, did, rkey); 84 + Opake.cancelPairRequest(storage, did, rkey); 85 + 86 + // The one that returns an Opake instance 87 + Opake.init({ storage, did? });`} 88 + </CodeBlock> 89 + 90 + So much of the API is static because those three flows run before any identity exists on a 91 + device. They can't live on an instance because you haven't got one. Once any of them finishes, 92 + `Opake.init` succeeds. 93 + 94 + ### Instance surface 95 + 96 + <CodeBlock language="typescript" title="after init"> 97 + {`opake.did; // invariant for the instance's lifetime 98 + 99 + // FileManager access 100 + opake.cabinet(); 101 + opake.workspaceByUri(uri); 102 + 103 + // Workspace lifecycle 104 + opake.createWorkspace(name, description?); 105 + opake.listWorkspaces(); 106 + opake.watchWorkspaces(handler); 107 + 108 + // Incoming shares 109 + opake.listInbox(); 110 + opake.watchInbox(handler); 111 + 112 + // Live updates 113 + opake.startSseConsumer(indexerUrl?); 114 + opake.stopSseConsumer(); 115 + 116 + // ...plus pairing (approving side) and maintenance ops`} 117 + </CodeBlock> 118 + 119 + The `did` field is the caller's DID, set during `init` and constant for the life of the 120 + `Opake` instance. Safe as a React `useMemo` dependency or a cache key. 121 + 122 + ## Storage 123 + 124 + Any operation that persists something goes through the `Storage` interface: session tokens, 125 + identity keys, ephemeral pair state, the local record cache. Two implementations ship with the 126 + SDK: 127 + 128 + <CodeBlock language="typescript" title="storage options"> 129 + {`// Browsers. The default choice. 130 + import { IndexedDbStorage } from "@opake/sdk/storage/indexeddb"; 131 + const storage = new IndexedDbStorage(); // persists via Dexie 132 + 133 + // Tests and scripts. In-memory, lost on process exit. 134 + import { MemoryStorage } from "@opake/sdk"; 135 + const storage = new MemoryStorage();`} 136 + </CodeBlock> 137 + 138 + If you need a different backend (Tauri filesystem, React Native AsyncStorage, something 139 + server-backed), implement the interface yourself. See [Storage 140 + contract](/docs/sdk/storage) for what each method persists and when it's called. 141 + 142 + If you build one that's reusable, consider sending it upstream. The `Storage` contract is 143 + small; most backends are one file plus tests. Keeping your implementation in the main repo 144 + saves you from re-syncing it every time the trait evolves, and means other people on the same 145 + platform find it next to the built-ins instead of re-solving the problem. Issues and PRs at 146 + [tangled.org/opake.app/opake](https://tangled.org/opake.app/opake). 147 + 148 + ## Errors 149 + 150 + Every SDK method throws `OpakeError` on failure. Branch on `.kind`: 151 + 152 + <CodeBlock language="typescript" title="error handling"> 153 + {`import { OpakeError } from "@opake/sdk"; 154 + 155 + try { 156 + const opake = await Opake.init({ storage }); 157 + } catch (err) { 158 + if (err instanceof OpakeError) { 159 + switch (err.kind) { 160 + case "IdentityMissing": 161 + // Normal: the user is signed in but hasn't bootstrapped an 162 + // identity on this device yet. Route them to recovery or pair. 163 + break; 164 + case "NotFound": 165 + // No session at all. Send them to login. 166 + break; 167 + case "Auth": 168 + // Token refresh failed. The session is dead. 169 + break; 170 + default: 171 + throw err; 172 + } 173 + } 174 + }`} 175 + </CodeBlock> 176 + 177 + `kind` maps 1-to-1 to the `opake_core::Error` variants in Rust, so anything the core library can 178 + surface is reachable from TS without `any` casts. 179 + 180 + One `kind` worth special-casing: `IdentityMissing`. It fires when the caller has a live session 181 + but no encryption identity on this device yet. That's a normal state. It means the user just 182 + signed in on a fresh device and needs to either recover from a seed phrase or pair with an 183 + existing device to bootstrap an `Identity` into Storage. Route them to that flow, don't surface 184 + it as a generic failure. 185 + 186 + ## Where to go next 187 + 188 + <DocsIndexGrid> 189 + <DocsIndexCard href="/docs/sdk/authentication" icon="lock"> 190 + <DocsIndexTitle>Authentication</DocsIndexTitle> 191 + <DocsIndexBody>How `startLogin` and `completeLogin` compose, the app-password fallback, and how token refresh actually happens.</DocsIndexBody> 192 + </DocsIndexCard> 193 + 194 + <DocsIndexCard href="/docs/sdk/identity" icon="seedling"> 195 + <DocsIndexTitle>Identity & pairing</DocsIndexTitle> 196 + <DocsIndexBody>Seed phrases and device pairing, with private key material never touching JS memory.</DocsIndexBody> 197 + </DocsIndexCard> 198 + 199 + <DocsIndexCard href="/docs/sdk/files" icon="folder"> 200 + <DocsIndexTitle>Files & directories</DocsIndexTitle> 201 + <DocsIndexBody>The `FileManager` contract and how to subscribe to tree changes as they arrive.</DocsIndexBody> 202 + </DocsIndexCard> 203 + 204 + <DocsIndexCard href="/docs/sdk/sharing" icon="share"> 205 + <DocsIndexTitle>Sharing & workspaces</DocsIndexTitle> 206 + <DocsIndexBody>One-to-one grants, group workspaces, and the proposal model that lets members edit on other people's PDSes.</DocsIndexBody> 207 + </DocsIndexCard> 208 + 209 + <DocsIndexCard href="/docs/sdk/events" icon="network"> 210 + <DocsIndexTitle>Live updates</DocsIndexTitle> 211 + <DocsIndexBody>Starting the SSE consumer and subscribing to workspace, inbox, and directory changes.</DocsIndexBody> 212 + </DocsIndexCard> 213 + 214 + <DocsIndexCard href="/docs/sdk/storage" icon="book"> 215 + <DocsIndexTitle>Storage contract</DocsIndexTitle> 216 + <DocsIndexBody>Every method on `Storage`, what it persists, and when the SDK reaches for it.</DocsIndexBody> 217 + </DocsIndexCard> 218 + </DocsIndexGrid>
+1 -1
apps/web/src/content/docs/faq.mdx
··· 47 47 </FaqItem> 48 48 49 49 <FaqItem question="How do I contribute?"> 50 - You can [View the Sourcecode on Tangled](https://tangled.org/sans-self.org/opake.app) or [report issues](https://tangled.org/sans-self.org/opake.app/issues) there. We are built for the community, by the community. 50 + You can [View the Sourcecode on Tangled](https://tangled.org/opake.app/opake) or [report issues](https://tangled.org/opake.app/opake/issues) there. We are built for the community, by the community. 51 51 </FaqItem> 52 52 </FaqSection> 53 53
+5
apps/web/src/content/docs/index.mdx
··· 85 85 <DocsIndexTitle>The CLI Manual</DocsIndexTitle> 86 86 <DocsIndexBody>Complete command reference for the Opake CLI — identity, files, sharing, and more.</DocsIndexBody> 87 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> 88 93 </DocsIndexGrid> 89 94 </DocsIndexSection> 90 95
+1 -1
apps/web/src/content/docs/use/troubleshooting.mdx
··· 67 67 68 68 <Callout type="info"> 69 69 **Still Stuck?** If you've found a genuine bug, please [report it on 70 - Tangled](https://tangled.org/sans-self.org/opake.app/issues). Include any error messages and 70 + Tangled](https://tangled.org/opake.app/opake/issues). Include any error messages and 71 71 details about your environment (Web or CLI). 72 72 </Callout>
+21
apps/web/src/lib/docs-registry.ts
··· 10 10 export interface DocMeta { 11 11 readonly slug: string; 12 12 readonly category: DocCategory; 13 + /** 14 + * Sub-section inside a category. Pages with a group live at 15 + * `/docs/{group}/{slug}`; pages without a group live at `/docs/{slug}`. 16 + * Used to book-ify the `build/sdk/*` and `build/react/*` references 17 + * without turning every flat doc into a nested URL. 18 + */ 19 + readonly group?: string; 13 20 readonly title: string; 14 21 readonly description: string; 15 22 readonly icon: IconName; ··· 128 135 description: 129 136 "Complete command reference for the Opake CLI — identity, files, sharing, and more.", 130 137 }, 138 + { 139 + slug: "overview", 140 + group: "sdk", 141 + category: "build", 142 + title: "@opake/sdk — Overview", 143 + icon: "book", 144 + description: 145 + "Install, initialise, and ship your first encrypted upload with the TypeScript SDK.", 146 + }, 131 147 132 148 // -- Cross-cutting --------------------------------------------------------- 133 149 { ··· 150 166 export function docsByCategory(category: DocCategory): readonly DocMeta[] { 151 167 return DOCS_REGISTRY.filter((d) => d.category === category && d.slug !== "faq"); 152 168 } 169 + 170 + /** URL path for a doc — nested under `group` if set, flat otherwise. */ 171 + export function docPath(doc: DocMeta): string { 172 + return doc.group ? `/docs/${doc.group}/${doc.slug}` : `/docs/${doc.slug}`; 173 + }
+44
apps/web/src/routeTree.gen.ts
··· 44 44 import { Route as CabinetWorkspaceRkeySplatRouteImport } from './routes/cabinet/workspace/$rkey/$' 45 45 import { Route as CabinetWorkspaceEditorRkeyNewRouteImport } from './routes/cabinet/workspace-editor/$rkey/new' 46 46 import { Route as CabinetWorkspaceEditorRkeyDocRkeyRouteImport } from './routes/cabinet/workspace-editor/$rkey/$docRkey' 47 + import { Route as CabinetDocsCategorySlugRouteImport } from './routes/cabinet/docs/$category/$slug' 48 + import { Route as PublicDocsCategorySlugRouteImport } from './routes/_public/docs/$category/$slug' 47 49 48 50 const DevicesCliCallbackLazyRouteImport = createFileRoute( 49 51 '/devices/cli-callback', ··· 272 274 (d) => d.Route, 273 275 ), 274 276 ) 277 + const CabinetDocsCategorySlugRoute = CabinetDocsCategorySlugRouteImport.update({ 278 + id: '/$category/$slug', 279 + path: '/$category/$slug', 280 + getParentRoute: () => CabinetDocsRouteRoute, 281 + } as any).lazy(() => 282 + import('./routes/cabinet/docs/$category/$slug.lazy').then((d) => d.Route), 283 + ) 284 + const PublicDocsCategorySlugRoute = PublicDocsCategorySlugRouteImport.update({ 285 + id: '/docs/$category/$slug', 286 + path: '/docs/$category/$slug', 287 + getParentRoute: () => PublicRoute, 288 + } as any) 275 289 276 290 export interface FileRoutesByFullPath { 277 291 '/cabinet': typeof CabinetRouteRouteWithChildren ··· 303 317 '/docs/': typeof PublicDocsIndexRoute 304 318 '/cabinet/docs/': typeof CabinetDocsIndexRoute 305 319 '/cabinet/files/': typeof CabinetFilesIndexRoute 320 + '/docs/$category/$slug': typeof PublicDocsCategorySlugRoute 321 + '/cabinet/docs/$category/$slug': typeof CabinetDocsCategorySlugRoute 306 322 '/cabinet/workspace-editor/$rkey/$docRkey': typeof CabinetWorkspaceEditorRkeyDocRkeyRoute 307 323 '/cabinet/workspace-editor/$rkey/new': typeof CabinetWorkspaceEditorRkeyNewRoute 308 324 '/cabinet/workspace/$rkey/$': typeof CabinetWorkspaceRkeySplatRoute ··· 333 349 '/docs': typeof PublicDocsIndexRoute 334 350 '/cabinet/docs': typeof CabinetDocsIndexRoute 335 351 '/cabinet/files': typeof CabinetFilesIndexRoute 352 + '/docs/$category/$slug': typeof PublicDocsCategorySlugRoute 353 + '/cabinet/docs/$category/$slug': typeof CabinetDocsCategorySlugRoute 336 354 '/cabinet/workspace-editor/$rkey/$docRkey': typeof CabinetWorkspaceEditorRkeyDocRkeyRoute 337 355 '/cabinet/workspace-editor/$rkey/new': typeof CabinetWorkspaceEditorRkeyNewRoute 338 356 '/cabinet/workspace/$rkey/$': typeof CabinetWorkspaceRkeySplatRoute ··· 370 388 '/_public/docs/': typeof PublicDocsIndexRoute 371 389 '/cabinet/docs/': typeof CabinetDocsIndexRoute 372 390 '/cabinet/files/': typeof CabinetFilesIndexRoute 391 + '/_public/docs/$category/$slug': typeof PublicDocsCategorySlugRoute 392 + '/cabinet/docs/$category/$slug': typeof CabinetDocsCategorySlugRoute 373 393 '/cabinet/workspace-editor/$rkey/$docRkey': typeof CabinetWorkspaceEditorRkeyDocRkeyRoute 374 394 '/cabinet/workspace-editor/$rkey/new': typeof CabinetWorkspaceEditorRkeyNewRoute 375 395 '/cabinet/workspace/$rkey/$': typeof CabinetWorkspaceRkeySplatRoute ··· 407 427 | '/docs/' 408 428 | '/cabinet/docs/' 409 429 | '/cabinet/files/' 430 + | '/docs/$category/$slug' 431 + | '/cabinet/docs/$category/$slug' 410 432 | '/cabinet/workspace-editor/$rkey/$docRkey' 411 433 | '/cabinet/workspace-editor/$rkey/new' 412 434 | '/cabinet/workspace/$rkey/$' ··· 437 459 | '/docs' 438 460 | '/cabinet/docs' 439 461 | '/cabinet/files' 462 + | '/docs/$category/$slug' 463 + | '/cabinet/docs/$category/$slug' 440 464 | '/cabinet/workspace-editor/$rkey/$docRkey' 441 465 | '/cabinet/workspace-editor/$rkey/new' 442 466 | '/cabinet/workspace/$rkey/$' ··· 473 497 | '/_public/docs/' 474 498 | '/cabinet/docs/' 475 499 | '/cabinet/files/' 500 + | '/_public/docs/$category/$slug' 501 + | '/cabinet/docs/$category/$slug' 476 502 | '/cabinet/workspace-editor/$rkey/$docRkey' 477 503 | '/cabinet/workspace-editor/$rkey/new' 478 504 | '/cabinet/workspace/$rkey/$' ··· 725 751 preLoaderRoute: typeof CabinetWorkspaceEditorRkeyDocRkeyRouteImport 726 752 parentRoute: typeof CabinetRouteRoute 727 753 } 754 + '/cabinet/docs/$category/$slug': { 755 + id: '/cabinet/docs/$category/$slug' 756 + path: '/$category/$slug' 757 + fullPath: '/cabinet/docs/$category/$slug' 758 + preLoaderRoute: typeof CabinetDocsCategorySlugRouteImport 759 + parentRoute: typeof CabinetDocsRouteRoute 760 + } 761 + '/_public/docs/$category/$slug': { 762 + id: '/_public/docs/$category/$slug' 763 + path: '/docs/$category/$slug' 764 + fullPath: '/docs/$category/$slug' 765 + preLoaderRoute: typeof PublicDocsCategorySlugRouteImport 766 + parentRoute: typeof PublicRoute 767 + } 728 768 } 729 769 } 730 770 731 771 interface CabinetDocsRouteRouteChildren { 732 772 CabinetDocsSlugRoute: typeof CabinetDocsSlugRoute 733 773 CabinetDocsIndexRoute: typeof CabinetDocsIndexRoute 774 + CabinetDocsCategorySlugRoute: typeof CabinetDocsCategorySlugRoute 734 775 } 735 776 736 777 const CabinetDocsRouteRouteChildren: CabinetDocsRouteRouteChildren = { 737 778 CabinetDocsSlugRoute: CabinetDocsSlugRoute, 738 779 CabinetDocsIndexRoute: CabinetDocsIndexRoute, 780 + CabinetDocsCategorySlugRoute: CabinetDocsCategorySlugRoute, 739 781 } 740 782 741 783 const CabinetDocsRouteRouteWithChildren = ··· 837 879 PublicIndexRoute: typeof PublicIndexRoute 838 880 PublicDocsSlugRoute: typeof PublicDocsSlugRoute 839 881 PublicDocsIndexRoute: typeof PublicDocsIndexRoute 882 + PublicDocsCategorySlugRoute: typeof PublicDocsCategorySlugRoute 840 883 } 841 884 842 885 const PublicRouteChildren: PublicRouteChildren = { ··· 845 888 PublicIndexRoute: PublicIndexRoute, 846 889 PublicDocsSlugRoute: PublicDocsSlugRoute, 847 890 PublicDocsIndexRoute: PublicDocsIndexRoute, 891 + PublicDocsCategorySlugRoute: PublicDocsCategorySlugRoute, 848 892 } 849 893 850 894 const PublicRouteWithChildren =
+4 -4
apps/web/src/routes/_public.tsx
··· 13 13 { label: "About", href: "/#what-is-opake", internal: true }, 14 14 { label: "How it works", href: "/#how-it-works", internal: true }, 15 15 { label: "Handbook", href: "/docs/", internal: true }, 16 - { label: "Source code", href: "https://tangled.org/sans-self.org/opake.app" }, 16 + { label: "Source code", href: "https://tangled.org/opake.app/opake" }, 17 17 ]; 18 18 19 19 interface FooterLink { ··· 43 43 { 44 44 heading: "Resources", 45 45 links: [ 46 - { label: "Source Code", href: "https://tangled.org/sans-self.org/opake.app" }, 47 - { label: "Report Issues", href: "https://tangled.org/sans-self.org/opake.app/issues" }, 46 + { label: "Source Code", href: "https://tangled.org/opake.app/opake" }, 47 + { label: "Report Issues", href: "https://tangled.org/opake.app/opake/issues" }, 48 48 { 49 49 label: "Contributing", 50 - href: "https://tangled.org/sans-self.org/opake.app/tree/main/CONTRIBUTING.md", 50 + href: "https://tangled.org/opake.app/opake/tree/main/CONTRIBUTING.md", 51 51 }, 52 52 ], 53 53 },
+52
apps/web/src/routes/_public/docs/$category/$slug.tsx
··· 1 + import type { ComponentType } from "react"; 2 + import { createFileRoute } from "@tanstack/react-router"; 3 + import { MdxContent } from "@/components/content/MdxProvider"; 4 + import { findDoc } from "@/lib/docs-registry"; 5 + import { ogMeta } from "@/lib/og-meta"; 6 + 7 + import SdkOverview from "@/content/docs/build/sdk/overview.mdx"; 8 + 9 + /** 10 + * Nested docs route: /docs/{group}/{slug} — used by pages inside a 11 + * subsection (sdk, react). The flat `/docs/{slug}` route handles 12 + * everything else; the two routes coexist because TanStack picks the 13 + * more specific matcher first. 14 + */ 15 + const CONTENT_BY_PATH: Partial< 16 + Record<string, ComponentType<{ readonly components?: Record<string, ComponentType<never>> }>> 17 + > = { 18 + "sdk/overview": SdkOverview, 19 + }; 20 + 21 + function NestedDocChapterPage() { 22 + const { category, slug } = Route.useParams(); 23 + const Content = CONTENT_BY_PATH[`${category}/${slug}`]; 24 + 25 + if (!Content) { 26 + return ( 27 + <div className="flex flex-col items-center gap-4 px-6 pt-28 pb-20"> 28 + <p className="text-text-muted">Page not found.</p> 29 + </div> 30 + ); 31 + } 32 + 33 + return ( 34 + <div className="mx-auto max-w-3xl px-6 pt-28 pb-20 sm:px-10"> 35 + <MdxContent Content={Content} className="prose" /> 36 + </div> 37 + ); 38 + } 39 + 40 + export const Route = createFileRoute("/_public/docs/$category/$slug")({ 41 + head: ({ params }) => { 42 + const doc = findDoc(params.slug); 43 + return { 44 + meta: ogMeta({ 45 + title: doc ? `${doc.title} — Opake` : "Docs — Opake", 46 + description: doc?.description ?? "", 47 + image: doc ? `/og/${params.slug}.png` : undefined, 48 + }), 49 + }; 50 + }, 51 + component: NestedDocChapterPage, 52 + });
+76
apps/web/src/routes/cabinet/docs/$category/$slug.lazy.tsx
··· 1 + import type { ComponentType } from "react"; 2 + import { createLazyFileRoute, Link } from "@tanstack/react-router"; 3 + import { ArrowLeftIcon } from "@phosphor-icons/react"; 4 + import { PanelShell } from "@/components/cabinet/PanelShell"; 5 + import { MdxContent } from "@/components/content/MdxProvider"; 6 + import { findDoc } from "@/lib/docs-registry"; 7 + 8 + import SdkOverview from "@/content/docs/build/sdk/overview.mdx"; 9 + 10 + /** 11 + * Nested cabinet docs route: /cabinet/docs/{group}/{slug}. 12 + * Import list is kept small and deliberate — only pages that live inside 13 + * a group (sdk, react) register here. Flat docs stay on $slug.lazy.tsx. 14 + */ 15 + type MdxComponent = ComponentType<{ 16 + readonly components?: Record<string, ComponentType<never>>; 17 + }>; 18 + 19 + const CONTENT_BY_PATH: Partial<Record<string, MdxComponent>> = { 20 + "sdk/overview": SdkOverview, 21 + }; 22 + 23 + function NestedDocChapterPage() { 24 + const { category, slug } = Route.useParams(); 25 + const meta = findDoc(slug); 26 + const Content = CONTENT_BY_PATH[`${category}/${slug}`]; 27 + 28 + if (!meta || !Content) { 29 + return ( 30 + <PanelShell depth={1} breadcrumbs={<span />} footer="Documentation · Opake"> 31 + <div className="flex flex-col items-center gap-4 p-10"> 32 + <p className="text-text-muted">Page not found.</p> 33 + <Link to="/cabinet/docs" className="btn btn-neutral btn-sm"> 34 + Back to docs 35 + </Link> 36 + </div> 37 + </PanelShell> 38 + ); 39 + } 40 + 41 + const breadcrumbs = ( 42 + <div className="breadcrumbs text-ui min-w-0 flex-1 overflow-hidden"> 43 + <ul> 44 + <li> 45 + <Link to="/cabinet/docs" className="text-text-muted hover:text-base-content"> 46 + Docs & Help 47 + </Link> 48 + </li> 49 + <li> 50 + <span className="text-base-content font-medium">{meta.title}</span> 51 + </li> 52 + </ul> 53 + </div> 54 + ); 55 + 56 + return ( 57 + <PanelShell depth={1} breadcrumbs={breadcrumbs} footer={`${meta.title} · Opake`}> 58 + <div className="overflow-y-auto p-6"> 59 + <MdxContent Content={Content} className="prose max-w-none text-sm leading-relaxed" /> 60 + <div className="border-border-accent/30 mt-10 border-t pt-4"> 61 + <Link 62 + to="/cabinet/docs" 63 + className="text-text-muted hover:text-primary text-ui inline-flex items-center gap-1.5 transition-colors" 64 + > 65 + <ArrowLeftIcon size={12} /> 66 + Back to docs 67 + </Link> 68 + </div> 69 + </div> 70 + </PanelShell> 71 + ); 72 + } 73 + 74 + export const Route = createLazyFileRoute("/cabinet/docs/$category/$slug")({ 75 + component: NestedDocChapterPage, 76 + });
+3
apps/web/src/routes/cabinet/docs/$category/$slug.tsx
··· 1 + import { createFileRoute } from "@tanstack/react-router"; 2 + 3 + export const Route = createFileRoute("/cabinet/docs/$category/$slug")({});
+21 -6
apps/web/src/routes/cabinet/docs/index.lazy.tsx
··· 6 6 import { CATEGORY_META, docsByCategory, findDoc, type DocMeta } from "@/lib/docs-registry"; 7 7 8 8 function DocLink({ doc }: { readonly doc: DocMeta }) { 9 - return ( 10 - <Link 11 - to="/cabinet/docs/$slug" 12 - params={{ slug: doc.slug }} 13 - className="card card-bordered border-base-300/50 bg-base-100 hover:shadow-panel-sm flex cursor-pointer flex-row items-start gap-3 p-3.5 transition-shadow" 14 - > 9 + const className = 10 + "card card-bordered border-base-300/50 bg-base-100 hover:shadow-panel-sm flex cursor-pointer flex-row items-start gap-3 p-3.5 transition-shadow"; 11 + const content = ( 12 + <> 15 13 <div className="bg-accent flex size-8 shrink-0 items-center justify-center rounded-lg"> 16 14 {createElement(resolveIcon(doc.icon), { size: 14, className: "text-primary" })} 17 15 </div> ··· 20 18 <div className="text-caption text-text-muted leading-relaxed">{doc.description}</div> 21 19 </div> 22 20 <ArrowSquareOutIcon size={12} className="text-text-faint mt-0.5 shrink-0" /> 21 + </> 22 + ); 23 + 24 + // TanStack's typed Link can't take a runtime-conditional `to`, so we split 25 + // the two route families explicitly. Docs with a `group` live at the 26 + // nested `/cabinet/docs/$category/$slug` route; flat docs stay on `$slug`. 27 + return doc.group ? ( 28 + <Link 29 + to="/cabinet/docs/$category/$slug" 30 + params={{ category: doc.group, slug: doc.slug }} 31 + className={className} 32 + > 33 + {content} 34 + </Link> 35 + ) : ( 36 + <Link to="/cabinet/docs/$slug" params={{ slug: doc.slug }} className={className}> 37 + {content} 23 38 </Link> 24 39 ); 25 40 }
+1 -1
docs/CRYPTO.md
··· 329 329 3. AES-GCM has universal hardware acceleration and library support 330 330 4. Streaming encryption without a pre-read pass matters for large uploads 331 331 332 - AES-GCM-SIV is planned for `SCHEMA_VERSION` v2 as a cipher swap ([#331](https://tangled.org/sans-self.org/opake.app/issues/331)). The migration path: version-gated decrypt (v1 = AES-GCM, v2 = AES-GCM-SIV), SIV-only encrypt going forward. Existing v1 records remain readable. No key derivation changes, no identity migration — just a cipher swap with a proactive re-encryption command for users who want to upgrade old records. 332 + AES-GCM-SIV is planned for `SCHEMA_VERSION` v2 as a cipher swap ([#331](https://tangled.org/opake.app/opake/issues/331)). The migration path: version-gated decrypt (v1 = AES-GCM, v2 = AES-GCM-SIV), SIV-only encrypt going forward. Existing v1 records remain readable. No key derivation changes, no identity migration — just a cipher swap with a proactive re-encryption command for users who want to upgrade old records. 333 333 334 334 ## Post-Quantum 335 335