feat(docs): add collapsible sidebar and new documentation pages

Restructure sidebar into Tauri-style collapsible sections (Quick Start, Core Concepts, Guides, Reference) with auto-expand on active page. Add four new stub pages: What is Portal, Prerequisites, Security Model, and SIWE Authentication. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Yechan Kim committed Apr 12, 2026 at 19:29 UTC 915fdf7ebb6c758d9e4e0e695137d59a5a8fa8e7
6 files changed +312 -41
docs/src/lib/components/Sidebar.svelte
+92 -29
@@ -4,40 +4,103 @@
4 import { navigation } from '$lib/nav';
5
6 const currentPath = $derived($page.url.pathname);
7 +
8 + // Track user-toggled state per section. null means "not manually toggled"
9 + let userToggles: Record<number, boolean | null> = $state({});
10 +
11 + function isItemActive(href: string): boolean {
12 + return (
13 + currentPath === `${base}${href}/` ||
14 + currentPath === `${base}${href}` ||
15 + currentPath.startsWith(`${base}${href}/`)
16 + );
17 + }
18 +
19 + function sectionHasActiveChild(sectionIndex: number): boolean {
20 + return navigation[sectionIndex].items.some((item) => isItemActive(item.href));
21 + }
22 +
23 + function isSectionOpen(index: number): boolean {
24 + const userToggle = userToggles[index];
25 + if (userToggle !== null && userToggle !== undefined) {
26 + return userToggle;
27 + }
28 + // Auto-open if section has active child or defaultOpen
29 + return sectionHasActiveChild(index) || (navigation[index].defaultOpen ?? false);
30 + }
31 +
32 + function toggleSection(index: number) {
33 + const currentlyOpen = isSectionOpen(index);
34 + userToggles[index] = !currentlyOpen;
35 + }
36 +
37 + // When the route changes to a page within a section, force that section open
38 + $effect(() => {
39 + // Access currentPath to create the dependency
40 + void currentPath;
41 + for (let i = 0; i < navigation.length; i++) {
42 + if (sectionHasActiveChild(i)) {
43 + userToggles[i] = true;
44 + }
45 + }
46 + });
47 </script>
48
9 -<nav class="space-y-6" aria-label="Documentation">
10 - {#each navigation as section}
49 +<nav class="space-y-1" aria-label="Documentation">
50 + {#each navigation as section, index (section.title)}
51 + {@const isOpen = isSectionOpen(index)}
52 <div>
12 - <h3
13 - class="flex items-center gap-2 font-display text-xs font-semibold tracking-wider text-gray-500 uppercase dark:text-gray-400"
53 + <button
54 + type="button"
55 + class="flex w-full cursor-pointer items-center justify-between rounded-md px-1 py-2 font-display text-xs font-semibold tracking-wider text-text-muted uppercase transition-colors hover:text-foreground"
56 + aria-expanded={isOpen}
57 + onclick={() => toggleSection(index)}
58 >
15 - {section.title}
16 - {#if section.badge}
17 - <span
18 - class="rounded-full border border-amber-400/25 bg-amber-400/15 px-1.5 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400"
19 - >
20 - {section.badge}
21 - </span>
22 - {/if}
23 - </h3>
24 - <ul class="mt-2 space-y-1">
25 - {#each section.items as item}
26 - {@const isActive =
27 - currentPath === `${base}${item.href}/` || currentPath === `${base}${item.href}` || currentPath.startsWith(`${base}${item.href}/`)}
28 - <li>
29 - <a
30 - href="{base}{item.href}"
31 - class="block rounded-lg px-3 py-2 text-sm transition-colors {isActive
32 - ? 'bg-primary/10 font-medium text-primary dark:bg-primary-light/15 dark:text-primary-light'
33 - : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/6'}"
34 - aria-current={isActive ? 'page' : undefined}
59 + <span class="flex items-center gap-2">
60 + {section.title}
61 + {#if section.badge}
62 + <span
63 + class="rounded-full border border-amber-400/25 bg-amber-400/15 px-1.5 py-0.5 text-[10px] font-medium tracking-normal normal-case text-amber-600 dark:text-amber-400"
64 >
36 - {item.title}
37 - </a>
38 - </li>
39 - {/each}
40 - </ul>
65 + {section.badge}
66 + </span>
67 + {/if}
68 + </span>
69 + <svg
70 + class="h-3.5 w-3.5 shrink-0 transition-transform duration-200 {isOpen
71 + ? 'rotate-90'
72 + : 'rotate-0'}"
73 + viewBox="0 0 16 16"
74 + fill="currentColor"
75 + aria-hidden="true"
76 + >
77 + <path
78 + d="M6.22 4.22a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06L8.94 8 6.22 5.28a.75.75 0 0 1 0-1.06Z"
79 + />
80 + </svg>
81 + </button>
82 + <div
83 + class="grid transition-[grid-template-rows] duration-200 ease-in-out {isOpen
84 + ? 'grid-rows-[1fr]'
85 + : 'grid-rows-[0fr]'}"
86 + >
87 + <ul class="overflow-hidden">
88 + {#each section.items as item (item.href)}
89 + {@const isActive = isItemActive(item.href)}
90 + <li>
91 + <a
92 + href="{base}{item.href}"
93 + class="block rounded-lg px-3 py-2 text-sm transition-colors {isActive
94 + ? 'bg-primary/10 font-medium text-primary dark:bg-primary-light/15 dark:text-primary-light'
95 + : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/6'}"
96 + aria-current={isActive ? 'page' : undefined}
97 + >
98 + {item.title}
99 + </a>
100 + </li>
101 + {/each}
102 + </ul>
103 + </div>
104 </div>
105 {/each}
106 </nav>
docs/src/lib/nav.ts
+20 -12
@@ -6,6 +6,7 @@ export interface NavItem {
6 export interface NavSection {
7 title: string;
8 badge?: string;
9 + defaultOpen?: boolean;
10 items: NavItem[];
11 }
12
@@ -35,30 +36,37 @@ export function getPrevNext(
36
37 export const navigation: NavSection[] = [
38 {
38 - title: 'Getting Started',
39 + title: 'Quick Start',
40 + defaultOpen: true,
41 items: [
40 - { title: 'Quick Start', href: '/getting-started' },
41 - { title: 'Concepts', href: '/concepts' },
42 - { title: 'Self-Hosting', href: '/self-hosting' }
42 + { title: 'What is Portal?', href: '/what-is-portal' },
43 + { title: 'Prerequisites', href: '/prerequisites' },
44 + { title: 'Getting Started', href: '/getting-started' }
45 ]
46 },
47 {
46 - title: 'Guides',
47 - items: [{ title: 'TCP/UDP Tunneling', href: '/tcp-udp-tunneling' }]
48 + title: 'Core Concepts',
49 + items: [
50 + { title: 'Overview', href: '/concepts' },
51 + { title: 'Architecture', href: '/architecture' },
52 + { title: 'Security Model', href: '/security-model' }
53 + ]
54 },
55 {
50 - title: 'Reference',
56 + title: 'Guides',
57 items: [
52 - { title: 'CLI Reference', href: '/cli-reference' },
53 - { title: 'API Reference', href: '/api-reference' },
58 + { title: 'Self-Hosting', href: '/self-hosting' },
59 + { title: 'TCP/UDP Tunneling', href: '/tcp-udp-tunneling' },
60 + { title: 'SIWE Authentication', href: '/siwe-authentication' },
61 + { title: 'Deployment', href: '/deployment' },
62 { title: 'Configuration', href: '/configuration' }
63 ]
64 },
65 {
58 - title: 'Advanced',
66 + title: 'Reference',
67 items: [
60 - { title: 'Architecture', href: '/architecture' },
61 - { title: 'Deployment', href: '/deployment' }
68 + { title: 'CLI Reference', href: '/cli-reference' },
69 + { title: 'API Reference', href: '/api-reference' }
70 ]
71 }
72 ];
docs/src/routes/prerequisites/+page.md new
+41
@@ -0,0 +1,41 @@
1 +---
2 +title: Prerequisites
3 +description: System requirements and prerequisites for running Portal tunnel.
4 +---
5 +
6 +# Prerequisites
7 +
8 +Before installing Portal, make sure your environment meets the following requirements.
9 +
10 +## System Requirements
11 +
12 +| Requirement | Minimum |
13 +|-------------|---------|
14 +| OS | Linux (amd64/arm64), macOS (amd64/arm64), Windows (amd64) |
15 +| Network | Outbound TCP access (no inbound ports needed) |
16 +| Disk | ~10 MB for the binary |
17 +
18 +## For Tunnel Users
19 +
20 +- A local service running on a TCP port (e.g., a web server on `localhost:3000`)
21 +- Internet connectivity to reach a relay server
22 +
23 +No accounts, API keys, or billing setup required.
24 +
25 +## For Relay Operators
26 +
27 +If you plan to run your own relay server:
28 +
29 +- A server with a public IP address
30 +- A domain name with DNS pointing to the server
31 +- TLS certificate (auto-provisioned via ACME/Let's Encrypt, or manually provided)
32 +- Ports 443 (HTTPS) and optionally 80 (HTTP redirect) open
33 +
34 +## Optional
35 +
36 +- **ENS name** — for SIWE-based identity and portable naming
37 +- **Ethereum wallet** — for signing SIWE authentication messages
38 +
39 +## Next Steps
40 +
41 +- [Getting Started](/getting-started) — install Portal and create your first tunnel
docs/src/routes/security-model/+page.md new
+60
@@ -0,0 +1,60 @@
1 +---
2 +title: Security Model
3 +description: How Portal ensures end-to-end encryption and prevents relay-level eavesdropping.
4 +---
5 +
6 +# Security Model
7 +
8 +Portal's security model is designed so that relay operators **cannot read tunnel traffic**, even though all data passes through their servers.
9 +
10 +## End-to-End TLS
11 +
12 +Tunnel traffic is encrypted with TLS between the client (browser) and your local service. The relay only sees opaque TCP bytes.
13 +
14 +```
15 +Client (browser) <-- TLS --> Your local app
16 + | ^
17 + | opaque TCP bytes |
18 + v |
19 + Relay server --- forwards ----->
20 +```
21 +
22 +The relay performs **TCP passthrough** — it connects raw TCP streams without terminating TLS.
23 +
24 +## MITM Detection
25 +
26 +Portal includes built-in MITM detection:
27 +
28 +1. The tunnel client generates a TLS certificate locally
29 +2. The certificate fingerprint is embedded in the public URL
30 +3. Connecting clients verify the fingerprint matches the server certificate
31 +4. Any relay-level interception would present a different certificate, triggering a mismatch
32 +
33 +## Relay Trust Model
34 +
35 +| What relays CAN see | What relays CANNOT see |
36 +|---------------------|----------------------|
37 +| Connection metadata (IP, timing) | Request/response content |
38 +| Tunnel name and domain | HTTP headers or body |
39 +| Traffic volume (bytes) | TLS-encrypted payload |
40 +| Connection duration | Application-layer data |
41 +
42 +## SIWE Authentication
43 +
44 +Portal supports Sign-In with Ethereum (SIWE) for identity:
45 +
46 +- Proves ownership of a tunnel name without a centralized auth server
47 +- ENS names provide portable, human-readable identity
48 +- No passwords or API keys stored anywhere
49 +
50 +## Best Practices
51 +
52 +1. **Always use HTTPS** — Portal provisions TLS certificates automatically
53 +2. **Verify certificate fingerprints** for sensitive applications
54 +3. **Run your own relay** if you need full control over the infrastructure
55 +4. **Rotate tunnel names** for temporary or throwaway use cases
56 +
57 +## Next Steps
58 +
59 +- [Architecture](/architecture) — deep dive into Portal's internal design
60 +- [Self-Hosting](/self-hosting) — run your own relay server
docs/src/routes/siwe-authentication/+page.md new
+63
@@ -0,0 +1,63 @@
1 +---
2 +title: SIWE Authentication
3 +description: Use Sign-In with Ethereum (SIWE) and ENS for portable tunnel identity.
4 +---
5 +
6 +# SIWE Authentication
7 +
8 +Portal supports **Sign-In with Ethereum (SIWE)** for proving ownership of tunnel names without centralized accounts or API keys.
9 +
10 +## Overview
11 +
12 +SIWE allows you to:
13 +
14 +- **Claim a tunnel name** by signing a message with your Ethereum wallet
15 +- **Prove ownership** without passwords, tokens, or a central auth server
16 +- **Use ENS names** for human-readable, portable identity (e.g., `alice.eth`)
17 +
18 +## How It Works
19 +
20 +1. You choose a tunnel name (or use your ENS name)
21 +2. Portal generates a SIWE message containing the tunnel name and relay domain
22 +3. You sign the message with your Ethereum wallet (e.g., MetaMask, hardware wallet)
23 +4. The signed message is sent to the relay server
24 +5. The relay verifies the signature on-chain and grants the tunnel name
25 +
26 +```
27 +Wallet --> Sign SIWE message --> Portal CLI --> Relay server
28 + |
29 + Verify signature
30 + |
31 + Grant tunnel name
32 +```
33 +
34 +## ENS Integration
35 +
36 +If you own an ENS name, you can use it directly as your tunnel name:
37 +
38 +- `alice.eth` becomes your portable identity across relays
39 +- No registration or DNS configuration needed
40 +- Works with any relay in the public registry
41 +
42 +## Configuration
43 +
44 +```bash
45 +# Use SIWE authentication with a specific tunnel name
46 +portal-tunnel --auth siwe --name my-tunnel localhost:3000
47 +
48 +# Use your ENS name
49 +portal-tunnel --auth siwe --name alice.eth localhost:3000
50 +```
51 +
52 +## Without SIWE
53 +
54 +SIWE is optional. Without it:
55 +
56 +- Tunnel names are assigned on a first-come, first-served basis
57 +- No ownership guarantee — anyone can claim an unused name
58 +- Suitable for temporary or throwaway tunnels
59 +
60 +## Next Steps
61 +
62 +- [Security Model](/security-model) — understand Portal's encryption and trust model
63 +- [Configuration](/configuration) — full configuration reference
docs/src/routes/what-is-portal/+page.md new
+36
@@ -0,0 +1,36 @@
1 +---
2 +title: What is Portal?
3 +description: An introduction to Portal — a permissionless localhost tunnel and public relay system.
4 +---
5 +
6 +# What is Portal?
7 +
8 +Portal is an open-source localhost tunnel that publishes local services to the public internet through relay servers — without login, billing, or cloud SaaS dependencies.
9 +
10 +## Key Properties
11 +
12 +- **Permissionless** — no account or API key required. Run the command and you're live.
13 +- **End-to-end TLS** — tenant TLS terminates locally with MITM detection; relays never see plaintext.
14 +- **Self-hostable** — use the public relay registry, discovered relay pools with failover, or run your own relay.
15 +- **Raw TCP** — carries HTTP, gRPC, WebSocket, and arbitrary TCP protocols without SSH or WebSocket overlays.
16 +
17 +## How It Works
18 +
19 +1. You start a local app (e.g., `localhost:3000`).
20 +2. You run `portal-tunnel` which connects to a relay server.
21 +3. The relay assigns a public HTTPS URL (e.g., `your-name.relay.example.com`).
22 +4. Incoming traffic is forwarded through the relay to your local app via an encrypted tunnel.
23 +
24 +## When to Use Portal
25 +
26 +| Use Case | Example |
27 +|----------|---------|
28 +| Share a dev server | Show a colleague your local branch |
29 +| Webhook development | Receive Stripe/GitHub webhooks locally |
30 +| Demo to clients | Temporary public URL for a staging app |
31 +| IoT / edge devices | Expose a device behind NAT |
32 +
33 +## Next Steps
34 +
35 +- [Prerequisites](/prerequisites) — what you need before installing
36 +- [Getting Started](/getting-started) — install and run your first tunnel