Feat v0.6.1-v0.6.2: backup/restore, docs surface, dynamic OpenAPI
Some checks failed
CI/CD / detect-changes (pull_request) Successful in 20s
CI/CD / test-frontend (pull_request) Successful in 6s
CI/CD / test-sqlite (pull_request) Has been cancelled
CI/CD / build-and-deploy (pull_request) Has been cancelled
CI/CD / test-go-pg (pull_request) Has been cancelled

v0.6.1 — Backup tooling (.swb ZIP export/import), admin backup section,
docs surface with markdown rendering, 5 reference docs, 6 handler tests.

v0.6.2 — Dark mode contrast fixes, topbar nav, 📖 docs icon, dynamic
OpenAPI spec with extension route merging, docs outline sidebar,
scroll/routing fixes; 7 new tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 10:53:24 +00:00
parent 1a7f41493d
commit 7bdc6df6b4
31 changed files with 3721 additions and 8 deletions

View File

@@ -2,6 +2,40 @@
All notable changes to Switchboard Core are documented here. All notable changes to Switchboard Core are documented here.
## v0.6.1 — Backup/Restore + Documentation
Operational tooling for data safety and extension authoring.
### Added
- **Backup/Restore**: Full-instance backup as `.swb` ZIP archive containing
core tables (JSONL), ext_data tables, and package assets. Stream to client
or save server-side. Restore wipes and rebuilds from archive. Dialect-neutral
(works across SQLite and Postgres).
- **Admin backup section**: New "Backup" tab under `/admin` with create,
download, delete, and restore operations. Destructive restore requires
confirmation dialog.
- **Documentation surface**: Builtin surface at `/docs` with sidebar navigation
and client-side markdown rendering. Ships with 5 documents: Getting Started,
Extension Guide, API Reference, Deployment, Package Format.
- **Docs API**: `GET /api/v1/docs` lists available documents, `GET /api/v1/docs/:name`
returns raw markdown content. Authenticated (all users).
- **Tests**: 6 backup handler tests + E2E script (`ci/e2e-backup-test.sh`).
### API
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/admin/backup` | POST | Create backup (stream or `?store=true`) |
| `/api/v1/admin/backups` | GET | List server-side backups |
| `/api/v1/admin/backups/:name` | GET | Download backup |
| `/api/v1/admin/backups/:name` | DELETE | Delete backup |
| `/api/v1/admin/restore` | POST | Restore from `.swb` upload |
| `/api/v1/docs` | GET | List documentation |
| `/api/v1/docs/:name` | GET | Get document content |
---
## v0.6.0 — Cluster Registry + HA ## v0.6.0 — Cluster Registry + HA
MVP convergence point. PG-backed cluster registry for horizontal scaling — MVP convergence point. PG-backed cluster registry for horizontal scaling —

View File

@@ -77,6 +77,9 @@ COPY --from=backend /app/database/migrations /app/database/migrations
COPY src/ /usr/share/nginx/html/ COPY src/ /usr/share/nginx/html/
COPY VERSION /VERSION COPY VERSION /VERSION
# Documentation (v0.6.1) — served by Go backend via /api/v1/docs
COPY docs/ /app/docs/
# Inject version and build hash into index.html and sw.js at build time # Inject version and build hash into index.html and sw.js at build time
RUN APP_VERSION=$(cat /VERSION | tr -d '[:space:]') && \ RUN APP_VERSION=$(cat /VERSION | tr -d '[:space:]') && \
BUILD_HASH=$(find /usr/share/nginx/html/js -name '*.js' -exec md5sum {} + | sort | md5sum | cut -c1-8) && \ BUILD_HASH=$(find /usr/share/nginx/html/js -name '*.js' -exec md5sum {} + | sort | md5sum | cut -c1-8) && \

View File

@@ -437,12 +437,29 @@ PG is the consensus layer. Zero new infrastructure. `UNLOGGED` table + `LISTEN/N
| Single-node regression | ✅ | One-node behavior identical to pre-cluster: one registry row, NOTIFY delivers back to same instance. No special-casing. SQLite returns nil store — all cluster code guarded. | | Single-node regression | ✅ | One-node behavior identical to pre-cluster: one registry row, NOTIFY delivers back to same instance. No special-casing. SQLite returns nil store — all cluster code guarded. |
| Multi-node integration test | ✅ | Docker Compose: 3 instances, shared PG. `ci/e2e-cluster-test.sh`: registration, stale sweep on stop, re-registration on restart. 3 unit tests + 2 handler tests. | | Multi-node integration test | ✅ | Docker Compose: 3 instances, shared PG. `ci/e2e-cluster-test.sh`: registration, stale sweep on stop, re-registration on restart. 3 unit tests + 2 handler tests. |
### v0.6.1 — Backup/Restore + Documentation (planned) ### v0.6.1 — Backup/Restore + Documentation
| Step | Status | Description | | Step | Status | Description |
|------|--------|-------------| |------|--------|-------------|
| Backup tooling | | ext_data export/import, admin surface for backup management. | | Backup handler | ✅ | `POST /api/v1/admin/backup` streams `.swb` ZIP (JSONL core + ext_data tables + package assets). `POST /api/v1/admin/restore` wipes DB and restores from archive. Dialect-neutral (SQLite + Postgres). |
| Documentation site | | Extension authoring guide, API reference, deployment guide. | | Server-side backups | ✅ | `GET /api/v1/admin/backups` list, `GET /download`, `DELETE`. Store backups in `{STORAGE_PATH}/backups/`. |
| Admin backup section | ✅ | New "Backup" section under `/admin/backup`. Create (download or server-side), list, download, delete, restore with destructive confirmation. |
| Documentation API | ✅ | `GET /api/v1/docs` lists, `GET /api/v1/docs/:name` returns raw markdown. Authenticated (not admin-only). |
| Docs surface | ✅ | Builtin surface at `/docs/:section`. Sidebar navigation, client-side markdown renderer. 5 new docs: Getting Started, Extension Guide, API Reference, Deployment, Package Format. |
| Tests | ✅ | 6 handler tests (basic backup, ext_data backup, round-trip restore, schema mismatch, dump/restore table, list empty). E2E script `ci/e2e-backup-test.sh`. |
### v0.6.2 — Docs Polish + Dynamic OpenAPI
| Step | Status | Description |
|------|--------|-------------|
| Dark mode fix | ✅ | Added `--bg-code` to CSS variables (dark + light). Replaced all hardcoded light-mode fallbacks in docs CSS with theme-aware variables. Table styling, code blocks, nav items all readable in dark mode. |
| Loading & error handling | ✅ | Replaced borrowed `settings-placeholder` with docs-specific pulse animation. Added error state with retry button for failed doc list fetch. |
| Topbar navigation | ✅ | Imported `Topbar` + `UserMenu` into docs surface. Users can now navigate to other surfaces via the avatar menu. |
| Docs icon + menu entry | ✅ | Added `📖 Docs` entry to UserMenu standard items. Docs accessible from any surface's user menu. |
| `api_schema` manifest field | ✅ | Optional `api_schema` array in `manifest.json`. Parsed lazily by spec builder. Malformed entries logged and skipped — never blocks extension loading. |
| OpenAPI spec builder | ✅ | `BuildOpenAPISpec()` merges static kernel spec with extension routes. Tier 1: auto-generated stubs for all `api_routes`. Tier 2: `api_schema` replaces stubs with rich path items (params, body, response). |
| Dynamic spec endpoint | ✅ | `GET /api/docs/openapi.json` serves merged spec. Swagger UI updated to use JSON endpoint. Static YAML preserved for backward compat. |
| Tests | ✅ | 7 new handler tests: zero extensions, stubs, rich schema, multi-extension, malformed schema, disabled exclusion, required fields. |
## Post-MVP ## Post-MVP

View File

@@ -1 +1 @@
0.6.0 0.6.2

139
ci/e2e-backup-test.sh Executable file
View File

@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# e2e-backup-test.sh — Backup/restore E2E test (v0.6.1)
#
# Requires: docker compose running with a single instance.
# Usage:
# docker compose up --build -d
# ./ci/e2e-backup-test.sh
# docker compose down -v
set -euo pipefail
BASE="http://localhost:8080"
PASS=0
FAIL=0
pass() { PASS=$((PASS + 1)); echo " PASS: $1"; }
fail() { FAIL=$((FAIL + 1)); echo " FAIL: $1"; }
# ── Wait for service ─────────────────────────
echo "=== Waiting for service ==="
for i in $(seq 1 30); do
if curl -sf "$BASE/health" > /dev/null 2>&1; then
echo " service ready"
break
fi
sleep 1
if [ "$i" -eq 30 ]; then
echo "FATAL: service did not become healthy"
exit 1
fi
done
# ── Login as admin ───────────────────────────
echo "=== Authenticating ==="
TOKEN=$(curl -sf "$BASE/api/v1/auth/login" \
-H "Content-Type: application/json" \
-d '{"login":"admin","password":"admin"}' | jq -r '.access_token')
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
echo "FATAL: login failed"
exit 1
fi
echo " admin token acquired"
auth() { echo "Authorization: Bearer $TOKEN"; }
# ── Seed test data ───────────────────────────
echo "=== Seeding test data ==="
# Create a test user
curl -sf "$BASE/api/v1/auth/register" \
-H "Content-Type: application/json" \
-d '{"username":"backup-test-user","email":"bktest@test.com","password":"testpass123"}' > /dev/null
USER_COUNT=$(curl -sf "$BASE/api/v1/admin/users" -H "$(auth)" | jq '.data | length')
echo " users: $USER_COUNT"
# ── Test 1: List backups (empty) ─────────────
echo "=== Test 1: List backups (empty) ==="
LIST=$(curl -sf "$BASE/api/v1/admin/backups" -H "$(auth)")
COUNT=$(echo "$LIST" | jq '.data | length')
if [ "$COUNT" -eq 0 ]; then
pass "empty backup list"
else
fail "expected 0 backups, got $COUNT"
fi
# ── Test 2: Create server-side backup ────────
echo "=== Test 2: Create server-side backup ==="
RESULT=$(curl -sf "$BASE/api/v1/admin/backup?store=true" \
-X POST \
-H "$(auth)")
FILENAME=$(echo "$RESULT" | jq -r '.data.filename')
if [ -n "$FILENAME" ] && [ "$FILENAME" != "null" ]; then
pass "created backup: $FILENAME"
else
fail "backup creation failed: $RESULT"
fi
# ── Test 3: List backups (has one) ───────────
echo "=== Test 3: List backups (has one) ==="
LIST=$(curl -sf "$BASE/api/v1/admin/backups" -H "$(auth)")
COUNT=$(echo "$LIST" | jq '.data | length')
if [ "$COUNT" -eq 1 ]; then
pass "one backup listed"
else
fail "expected 1 backup, got $COUNT"
fi
# ── Test 4: Download backup ──────────────────
echo "=== Test 4: Download backup ==="
TMPFILE=$(mktemp /tmp/backup-test-XXXXXX.swb)
HTTP_CODE=$(curl -sf -o "$TMPFILE" -w "%{http_code}" \
"$BASE/api/v1/admin/backups/$FILENAME" -H "$(auth)")
if [ "$HTTP_CODE" = "200" ] && [ -s "$TMPFILE" ]; then
pass "download OK ($(wc -c < "$TMPFILE") bytes)"
else
fail "download failed (HTTP $HTTP_CODE)"
fi
# ── Test 5: Streaming backup (direct download) ──
echo "=== Test 5: Streaming backup ==="
STREAM_FILE=$(mktemp /tmp/backup-stream-XXXXXX.swb)
HTTP_CODE=$(curl -sf -o "$STREAM_FILE" -w "%{http_code}" \
-X POST "$BASE/api/v1/admin/backup" -H "$(auth)")
if [ "$HTTP_CODE" = "200" ] && [ -s "$STREAM_FILE" ]; then
pass "streaming download OK ($(wc -c < "$STREAM_FILE") bytes)"
else
fail "streaming download failed (HTTP $HTTP_CODE)"
fi
# ── Test 6: Delete backup ────────────────────
echo "=== Test 6: Delete backup ==="
DEL=$(curl -sf -X DELETE "$BASE/api/v1/admin/backups/$FILENAME" -H "$(auth)")
DELETED=$(echo "$DEL" | jq -r '.data.deleted')
if [ "$DELETED" = "$FILENAME" ]; then
pass "deleted backup"
else
fail "delete failed: $DEL"
fi
# Verify deletion
LIST=$(curl -sf "$BASE/api/v1/admin/backups" -H "$(auth)")
COUNT=$(echo "$LIST" | jq '.data | length')
if [ "$COUNT" -eq 0 ]; then
pass "backup list empty after delete"
else
fail "expected 0 backups after delete, got $COUNT"
fi
# ── Cleanup ──────────────────────────────────
rm -f "$TMPFILE" "$STREAM_FILE"
# ── Summary ──────────────────────────────────
echo ""
echo "=== Results: $PASS passed, $FAIL failed ==="
if [ "$FAIL" -gt 0 ]; then
exit 1
fi

186
docs/API-REFERENCE.md Normal file
View File

@@ -0,0 +1,186 @@
# API Reference
Base path: `/api/v1` (all endpoints below are relative to this unless noted).
## Authentication
Most endpoints require a Bearer token in the `Authorization` header:
```
Authorization: Bearer <access_token>
```
Obtain tokens via the auth endpoints (no auth required):
| Method | Path | Description |
|--------|------|-------------|
| POST | `/auth/register` | Create account (if registration enabled) |
| POST | `/auth/login` | Login, returns access + refresh tokens |
| POST | `/auth/refresh` | Refresh access token |
| POST | `/auth/logout` | Invalidate refresh token |
| GET | `/auth/oidc/login` | OIDC login redirect (when `AUTH_MODE=oidc`) |
| GET | `/auth/oidc/callback` | OIDC callback handler |
## Error Format
All errors return JSON:
```json
{"error": "description of what went wrong"}
```
Standard HTTP status codes: 400 (bad request), 401 (unauthorized), 403 (forbidden), 404 (not found), 500 (server error).
## Pagination
List endpoints accept query parameters:
| Param | Default | Description |
|-------|---------|-------------|
| `limit` | 50 | Max items to return |
| `offset` | 0 | Number of items to skip |
## Endpoints by Category
### Profile & Settings
| Method | Path | Description |
|--------|------|-------------|
| GET | `/profile` | Current user profile |
| PUT | `/profile` | Update profile |
| POST | `/profile/password` | Change password |
| GET | `/settings` | User settings |
| PUT | `/settings` | Update user settings |
| GET | `/profile/permissions` | List granted permissions |
| GET | `/profile/bootstrap` | Bootstrap data for frontend |
### Surfaces & Extensions
| Method | Path | Description |
|--------|------|-------------|
| GET | `/surfaces` | List enabled surfaces |
| GET | `/extensions` | List user extensions |
| POST | `/extensions/:id/settings` | Update extension settings |
| GET | `/extensions/:id/manifest` | Get extension manifest |
| GET | `/settings/public` | Public platform settings |
### Notifications
| Method | Path | Description |
|--------|------|-------------|
| GET | `/notifications` | List notifications |
| GET | `/notifications/unread-count` | Unread count |
| PATCH | `/notifications/:id/read` | Mark as read |
| POST | `/notifications/mark-all-read` | Mark all read |
| DELETE | `/notifications/:id` | Delete notification |
| GET | `/notifications/preferences` | Notification preferences |
| PUT | `/notifications/preferences/:type` | Set preference |
### Workflows
| Method | Path | Description |
|--------|------|-------------|
| GET | `/workflows` | List workflows |
| POST | `/workflows` | Create workflow |
| GET | `/workflows/:id` | Get workflow |
| PATCH | `/workflows/:id` | Update workflow |
| DELETE | `/workflows/:id` | Delete workflow |
| POST | `/workflows/:id/publish` | Publish version |
| POST | `/workflows/:id/clone` | Clone workflow |
| POST | `/workflows/:id/instances` | Start instance |
| GET | `/workflows/:id/instances` | List instances |
| GET | `/assignments/mine` | My assignments |
### Teams
| Method | Path | Description |
|--------|------|-------------|
| GET | `/teams/mine` | List my teams |
| GET | `/teams/:id/members` | Team members |
| POST | `/teams/:id/members` | Add member |
| GET | `/teams/:id/roles` | Team roles |
### Connections
| Method | Path | Description |
|--------|------|-------------|
| GET | `/connections` | List connections |
| POST | `/connections` | Create connection |
| GET | `/connections/resolve` | Resolve connection by type |
| PUT | `/connections/:id` | Update connection |
| DELETE | `/connections/:id` | Delete connection |
### Packages (User)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/packages` | List visible packages |
| POST | `/packages/install` | Install personal package |
| DELETE | `/packages/:id` | Delete personal package |
### Admin Endpoints
All under `/api/v1/admin/` -- require `surface.admin.access` permission.
| Method | Path | Description |
|--------|------|-------------|
| GET | `/admin/users` | List users |
| POST | `/admin/users` | Create user |
| GET | `/admin/stats` | Platform statistics |
| GET | `/admin/settings` | Global settings |
| PUT | `/admin/settings/:key` | Update setting |
| GET | `/admin/packages` | List all packages |
| POST | `/admin/packages/install` | Install package (.pkg upload) |
| POST | `/admin/packages/:id/update` | Update package |
| GET | `/admin/packages/:id/export` | Export package as .pkg |
| PUT | `/admin/packages/:id/enable` | Enable package |
| PUT | `/admin/packages/:id/disable` | Disable package |
| DELETE | `/admin/packages/:id` | Delete package |
| GET | `/admin/cluster` | Cluster node list (Postgres only) |
| POST | `/admin/backup` | Create backup |
| GET | `/admin/backups` | List backups |
| POST | `/admin/restore` | Restore from backup |
### Health & Monitoring
These are at the base path (not under `/api/v1`):
| Method | Path | Description |
|--------|------|-------------|
| GET | `/health` | Basic health check |
| GET | `/healthz/live` | Liveness probe (Kubernetes) |
| GET | `/healthz/ready` | Readiness probe (Kubernetes) |
| GET | `/metrics` | Prometheus metrics |
| GET | `/api/docs` | OpenAPI documentation UI (Swagger) |
| GET | `/api/docs/openapi.json` | Dynamic OpenAPI 3.0 spec (includes extension routes) |
| GET | `/api/docs/openapi.yaml` | Static kernel-only OpenAPI spec (YAML) |
### Webhooks (Public)
| Method | Path | Description |
|--------|------|-------------|
| GET/POST | `/api/v1/hooks/:package_id/:slug` | Inbound webhook for extensions |
| POST | `/api/v1/workflows/public/:id/start` | Public workflow entry |
## WebSocket
Connect to `/ws` with a ticket-based auth flow:
1. POST `/api/v1/ws/ticket` with Bearer token to get a one-time ticket.
2. Connect to `/ws?ticket=<ticket>`.
The WebSocket carries JSON-framed events. Subscribe to channels with:
```json
{"type": "subscribe", "channel": "notifications"}
```
Kernel event prefixes: `user.*`, `team.*`, `workflow.*`, `notification.*`, `presence.*`, `extension.*`, `admin.*`, `system.*`.
### Presence
| Method | Path | Description |
|--------|------|-------------|
| POST | `/presence/heartbeat` | Update presence status |
| GET | `/presence` | Query online users |
| GET | `/users/search` | Search users |

153
docs/DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,153 @@
# Deployment Guide
## Docker Single-Instance
```bash
docker pull ghcr.io/switchboard-core/switchboard-core:latest
docker run -p 8080:80 \
-e SWITCHBOARD_ADMIN_USERNAME=admin \
-e SWITCHBOARD_ADMIN_PASSWORD=changeme \
-e JWT_SECRET="$(openssl rand -hex 32)" \
-e ENCRYPTION_KEY="$(openssl rand -hex 32)" \
-v switchboard-data:/data \
ghcr.io/switchboard-core/switchboard-core:latest
```
This runs with SQLite and PVC storage. Suitable for evaluation and small teams.
## Docker Compose with PostgreSQL
```yaml
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: switchboard
POSTGRES_USER: switchboard
POSTGRES_PASSWORD: secretpassword
volumes:
- pg_data:/var/lib/postgresql/data
switchboard:
image: ghcr.io/switchboard-core/switchboard-core:latest
ports:
- "8080:80"
environment:
DATABASE_URL: "postgres://switchboard:secretpassword@postgres:5432/switchboard?sslmode=disable"
JWT_SECRET: "change-me-in-production"
ENCRYPTION_KEY: "change-me-in-production"
SWITCHBOARD_ADMIN_USERNAME: admin
SWITCHBOARD_ADMIN_PASSWORD: changeme
STORAGE_BACKEND: pvc
STORAGE_PATH: /data/storage
volumes:
- sb_storage:/data/storage
depends_on:
- postgres
volumes:
pg_data:
sb_storage:
```
## Kubernetes
See the `k8s/` directory for example manifests. Key considerations:
- Set `POSTGRES_HOST`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` env vars (assembled into DSN automatically).
- Liveness probe: `/healthz/live`. Readiness probe: `/healthz/ready`.
- Mount a PVC at `/data/storage` or configure S3.
- Store `JWT_SECRET` and `ENCRYPTION_KEY` in Kubernetes Secrets.
- Registry: `registry.gobha.me:5000/xcaliber/switchboard-core`.
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `8080` | Backend API port |
| `DB_DRIVER` | auto | `postgres` or `sqlite` |
| `DATABASE_URL` | | PostgreSQL DSN or SQLite path |
| `JWT_SECRET` | `dev-secret-change-me` | Token signing key -- **must change** |
| `ENCRYPTION_KEY` | | AES-256 key for credential vault |
| `AUTH_MODE` | `builtin` | `builtin`, `mtls`, `oidc` |
| `STORAGE_BACKEND` | auto | `pvc` or `s3` |
| `STORAGE_PATH` | `/data/storage` | PVC mount point |
| `BASE_PATH` | | URL prefix (e.g., `/switchboard`) |
| `LOG_FORMAT` | `text` | `text` or `json` |
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` |
| `CORS_ALLOWED_ORIGINS` | | Comma-separated allowed origins |
| `BUNDLED_PACKAGES` | (empty) | `""` defaults, `"*"` all, or comma-separated |
| `SKIP_BUNDLED_PACKAGES` | `false` | Disable bundled package install |
| `BUNDLED_PACKAGES_DIR` | `/app/bundled-packages` | Custom bundle directory |
| `SWITCHBOARD_ADMIN_USERNAME` | | Bootstrap admin username |
| `SWITCHBOARD_ADMIN_PASSWORD` | | Bootstrap admin password |
| `SEED_USERS` | | Dev seed users (ignored in production) |
### S3 Storage Variables
| Variable | Description |
|----------|-------------|
| `S3_BUCKET` | Bucket name |
| `S3_ENDPOINT` | Endpoint URL (for MinIO, Ceph, etc.) |
| `S3_ACCESS_KEY` | Access key |
| `S3_SECRET_KEY` | Secret key |
| `S3_FORCE_PATH_STYLE` | `true` for MinIO/Ceph |
### Cluster Variables (Multi-Replica)
| Variable | Default | Description |
|----------|---------|-------------|
| `CLUSTER_NODE_ID` | hostname-PID | Override for deterministic node identity |
| `CLUSTER_HEARTBEAT_INTERVAL` | `10s` | Heartbeat tick frequency |
| `CLUSTER_STALE_THRESHOLD` | `30s` | 3x heartbeat = stale node |
| `CLUSTER_ENDPOINT` | auto-detect | Advertised address for peer mesh |
## Database Configuration
**PostgreSQL** (recommended for production):
```bash
DATABASE_URL="postgres://user:pass@host:5432/switchboard?sslmode=require"
```
**SQLite** (dev, test, edge deployments):
```bash
DB_DRIVER=sqlite
DATABASE_URL=/data/switchboard.db
```
Both databases are first-class -- every query compiles and passes tests on both. The driver is auto-detected from `DATABASE_URL` if `DB_DRIVER` is not set.
## Cluster Mode
Multi-replica HA requires PostgreSQL. The kernel uses PG-backed WebSocket tickets and rate limit counters to coordinate across replicas. A cluster registry with heartbeat and stale sweep tracks active nodes.
View cluster status in Admin > Cluster or via `GET /api/v1/admin/cluster`.
## Storage Backends
**PVC**: Auto-detected if the storage path is writable. Mount a volume at `/data/storage`.
**S3-compatible**: Set `STORAGE_BACKEND=s3` and provide S3 variables. Works with AWS S3, MinIO, and Ceph.
## Backup and Restore
Available via Admin UI or API:
```
POST /api/v1/admin/backup # Create backup
GET /api/v1/admin/backups # List backups
GET /api/v1/admin/backups/:name # Download backup
POST /api/v1/admin/restore # Restore from backup
DELETE /api/v1/admin/backups/:name # Delete backup
```
## Monitoring
| Endpoint | Purpose |
|----------|---------|
| `/health` | Basic health check |
| `/healthz/live` | Kubernetes liveness probe |
| `/healthz/ready` | Kubernetes readiness probe |
| `/metrics` | Prometheus metrics |

183
docs/EXTENSION-GUIDE.md Normal file
View File

@@ -0,0 +1,183 @@
# Extension Guide
## Package Types
| Type | Description |
|------|-------------|
| `surface` | A routable UI page rendered in the shell viewport |
| `extension` | Starlark hooks, tools, API routes, DB tables |
| `full` | Both surface and extension combined |
| `library` | Shared code imported by other packages via `lib.require()` |
| `workflow` | Bundled workflow definition |
## Tiers
| Tier | Runs | Capabilities |
|------|------|-------------|
| `browser` | Client-side JS only | DOM access, SDK hooks, no server-side logic |
| `starlark` | Sandboxed server-side | DB, HTTP, notifications, secrets, API routes, realtime |
| `sidecar` | Separate container | Full runtime (future) |
## manifest.json
Every package has a `manifest.json` at its root. Example for a surface:
```json
{
"id": "my-surface",
"title": "My Surface",
"type": "full",
"tier": "starlark",
"version": "1.0.0",
"description": "A custom surface with server-side logic.",
"icon": "🔧",
"author": "you",
"route": "/s/my-surface",
"auth": "authenticated",
"permissions": ["db.write"],
"api_routes": [
{"method": "GET", "path": "/items"},
{"method": "POST", "path": "/items"}
],
"db_tables": {
"items": {
"columns": {
"title": "text",
"done": "int"
},
"indexes": [["title"]]
}
},
"settings": {
"page_size": {
"type": "string",
"label": "Items Per Page",
"description": "Number of items shown per page",
"default": "25"
}
}
}
```
### Field Reference
| Field | Required | Description |
|-------|----------|-------------|
| `id` | yes | Unique kebab-case identifier |
| `title` | yes | Display name |
| `type` | yes | `surface`, `extension`, `full`, `library`, `workflow` |
| `tier` | yes | `browser`, `starlark`, `sidecar` |
| `version` | yes | Semver string |
| `description` | no | Short description |
| `icon` | no | Emoji icon for sidebar/menu |
| `route` | surfaces | URL path (e.g., `/s/my-surface`) |
| `auth` | no | `authenticated` (default) or `public` |
| `permissions` | no | Capabilities requested: `db.write`, `http`, `notifications`, `secrets`, `realtime.publish` |
| `api_routes` | no | Array of `{method, path}` for extension HTTP endpoints |
| `api_schema` | no | OpenAPI documentation for extension API routes (see below) |
| `db_tables` | no | Table definitions (see below) |
| `settings` | no | User-configurable settings schema |
| `exports` | libraries | Functions exported for other packages |
| `hooks` | no | Event bus subscriptions |
| `schema_version` | no | Integer for additive schema migrations |
## db_tables Schema
Tables are automatically namespaced as `ext_{package_id}_{table_name}`. Column types: `text`, `int`. Every table gets an auto-generated `id` primary key and `created_at` timestamp.
```json
"db_tables": {
"notes": {
"columns": {
"title": "text",
"body": "text",
"creator_id": "text",
"pinned": "int"
},
"indexes": [
["creator_id"],
["pinned"]
]
}
}
```
## api_schema (OpenAPI Documentation)
Extensions can optionally declare an `api_schema` array in their manifest to provide rich API documentation. Declared routes appear in Swagger UI at `/api/docs` with parameters, request bodies, and response schemas. Routes without `api_schema` entries still appear as auto-generated stubs.
```json
"api_schema": [
{
"path": "/items",
"method": "GET",
"summary": "List items",
"description": "Returns paginated items for the current user",
"params": {
"limit": {"type": "integer", "default": 50, "description": "Max results"},
"offset": {"type": "integer", "default": 0}
},
"response": {
"type": "object",
"example": {"data": [{"id": "string", "title": "string"}]}
}
},
{
"path": "/items",
"method": "POST",
"summary": "Create item",
"body": {
"title": {"type": "string", "required": true},
"description": {"type": "string"}
}
}
]
```
Only `path` and `method` are required. All other fields are optional. Malformed entries are logged and skipped without blocking extension loading.
## Starlark Sandbox API
Starlark scripts run server-side with a CPU budget and memory ceiling. Available modules (granted per-permission by admin):
| Module | Permission | API |
|--------|-----------|-----|
| `db` | `db.write` | `db.query(table, filters)`, `db.insert(table, row)`, `db.update(table, id, row)`, `db.delete(table, id)` |
| `http` | `http` | `http.get(url)`, `http.post(url, body)` -- SSRF-safe, no private IPs by default |
| `notifications` | `notifications` | `notifications.send(user_id, title, body)` |
| `secrets` | `secrets` | `secrets.get(connection_type)` -- reads from the credential vault |
| `api` | (implicit) | Registers HTTP routes at `/s/:slug/api/*path` |
| `realtime` | `realtime.publish` | `realtime.publish(channel, event, data)` -- push to WebSocket clients |
The sandbox cannot spawn goroutines, access the filesystem, or import arbitrary packages.
## Permissions Model
Extensions declare required permissions in `manifest.json`. The admin must grant each permission before the extension can use the corresponding module. Permission status is visible in Admin > Packages > Permissions.
Kernel permissions for users/groups: `extension.use`, `extension.install`, `workflow.create`, `workflow.submit`, `admin.view`, `token.unlimited`.
## File Structure
```
my-package/
manifest.json # required
js/ # browser-side JavaScript
index.js
css/ # stylesheets
styles.css
script.star # Starlark entry point
star/ # additional Starlark modules
assets/ # static assets
migrations/ # schema migration files
```
## Testing Extensions
1. Build the package: `cd packages && bash build.sh my-package`
2. Upload the `.pkg` file via Admin > Packages > Install.
3. Grant permissions in Admin > Packages > Permissions.
4. Enable the package.
5. If it is a surface, navigate to its route (e.g., `/s/my-package`).
Extension API routes are accessible at `/s/{slug}/api/{path}` and require an authenticated Bearer token.

91
docs/GETTING-STARTED.md Normal file
View File

@@ -0,0 +1,91 @@
# Getting Started
## Prerequisites
- Docker and Docker Compose
## Running with Docker Compose
```bash
docker compose up --build
```
Open [http://localhost:3000](http://localhost:3000). Default credentials: `admin` / `admin`.
Data persists in the `sb_data` named volume. To reset everything:
```bash
docker compose down -v
```
## First Boot
On first start, Switchboard Core will:
1. Run database migrations (SQLite by default in compose).
2. Create the admin user from `SWITCHBOARD_ADMIN_USERNAME` / `SWITCHBOARD_ADMIN_PASSWORD` env vars.
3. Auto-install the curated default package set (notes, chat-core, dashboard, workflow demos, etc.).
No manual setup steps are required.
## Installing Additional Packages
The Docker image ships with extra packages beyond the default set. To install them:
- **Via Admin UI**: Go to `/admin` > Packages. Browse, enable, or install packages.
- **Via environment variable**: Set `BUNDLED_PACKAGES` before starting:
```bash
# Install all bundled packages
BUNDLED_PACKAGES="*" docker compose up --build
# Install specific extras
BUNDLED_PACKAGES="notes,tasks,schedules" docker compose up --build
```
You can also upload `.pkg` archives through the Admin > Packages page.
## Key URLs
| URL | Purpose |
|-----|---------|
| `/` | Redirects to your default surface |
| `/admin` | Admin panel (users, packages, settings, teams) |
| `/settings` | User settings (profile, preferences, default surface) |
| `/welcome` | Welcome page (shown when no surfaces are installed) |
| `/api/docs` | Interactive OpenAPI documentation |
## Key Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `8080` | Backend API port |
| `DB_DRIVER` | auto | `postgres` or `sqlite` |
| `DATABASE_URL` | | PostgreSQL DSN or SQLite file path |
| `JWT_SECRET` | `dev-secret-change-me` | **Change in production** |
| `ENCRYPTION_KEY` | | AES-256 key for credential encryption |
| `AUTH_MODE` | `builtin` | `builtin`, `mtls`, or `oidc` |
| `STORAGE_BACKEND` | auto | `pvc` or `s3` |
| `STORAGE_PATH` | `/data/storage` | PVC mount point |
| `SWITCHBOARD_ADMIN_USERNAME` | | Bootstrap admin username |
| `SWITCHBOARD_ADMIN_PASSWORD` | | Bootstrap admin password |
| `BUNDLED_PACKAGES` | (empty) | `""` = curated defaults, `"*"` = all, or comma-separated IDs |
| `SKIP_BUNDLED_PACKAGES` | `false` | Disable auto-install entirely |
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` |
| `LOG_FORMAT` | `text` | `text` or `json` |
## From Source
```bash
git clone <repo-url> && cd switchboard-core
cp server/.env.example server/.env # edit DB credentials
cd server && go run .
# Backend on http://localhost:8080
```
## Next Steps
- [Extension Guide](EXTENSION-GUIDE.md) -- Author your own packages
- [API Reference](API-REFERENCE.md) -- REST API overview
- [Deployment](DEPLOYMENT.md) -- Production deployment
- [Architecture](ARCHITECTURE.md) -- System design

130
docs/PACKAGE-FORMAT.md Normal file
View File

@@ -0,0 +1,130 @@
# Package Format
Switchboard packages are distributed as `.pkg` files -- ZIP archives with a standard internal structure.
## ZIP Structure
```
my-package.pkg
├── manifest.json # required -- package metadata
├── js/ # browser-side JavaScript
│ └── index.js
├── css/ # stylesheets
│ └── styles.css
├── script.star # Starlark entry point
├── star/ # additional Starlark modules
├── assets/ # static assets (images, etc.)
└── migrations/ # schema migration files
```
Only `manifest.json` is required. All other directories are optional and included only if present in the source.
## manifest.json Reference
```json
{
"id": "my-package",
"title": "My Package",
"type": "surface",
"tier": "browser",
"version": "1.0.0",
"description": "What this package does.",
"icon": "📦",
"author": "your-name",
"route": "/s/my-package",
"auth": "authenticated",
"permissions": [],
"api_routes": [],
"db_tables": {},
"settings": {},
"hooks": {},
"exports": [],
"schema_version": 1
}
```
### Required Fields
| Field | Description |
|-------|-------------|
| `id` | Unique kebab-case identifier |
| `title` | Human-readable display name |
| `type` | `surface`, `extension`, `full`, `library`, `workflow` |
| `tier` | `browser`, `starlark`, `sidecar` |
| `version` | Semver version string |
### Optional Fields
| Field | Description |
|-------|-------------|
| `description` | Short description |
| `icon` | Emoji for sidebar/menu display |
| `author` | Package author |
| `route` | URL path for surfaces (e.g., `/s/my-package`) |
| `auth` | `authenticated` or `public` |
| `layout` | Surface layout mode (e.g., `single`) |
| `permissions` | Array of required capabilities (`db.write`, `http`, `notifications`, `secrets`, `realtime.publish`) |
| `api_routes` | Array of `{"method": "GET", "path": "/items"}` |
| `db_tables` | Table definitions with columns and indexes |
| `settings` | User-configurable settings with type, label, description, default |
| `hooks` | Event bus subscription patterns |
| `exports` | Functions exported by library packages |
| `schema_version` | Integer for additive schema migrations |
## Package Lifecycle
1. **Install**: Upload a `.pkg` file via Admin > Packages or `POST /api/v1/admin/packages/install`. The kernel extracts the archive, creates database tables, and registers routes.
2. **Enable**: Activate the package so its routes, hooks, and UI become available. `PUT /api/v1/admin/packages/:id/enable`.
3. **Disable**: Deactivate without removing data. `PUT /api/v1/admin/packages/:id/disable`.
4. **Update**: Upload a new `.pkg` with a higher semver version. Schema changes must be additive (new columns/tables only). `POST /api/v1/admin/packages/:id/update`.
5. **Export**: Download the installed package as a `.pkg` archive. `GET /api/v1/admin/packages/:id/export`.
6. **Delete**: Remove the package and its data. `DELETE /api/v1/admin/packages/:id`.
## Bundled vs User-Installed
**Bundled packages** ship inside the Docker image at `/app/bundled-packages`. On first boot, a curated default set is auto-installed. Behavior:
- First boot: curated defaults are installed and enabled.
- Subsequent boots: already-installed packages are skipped.
- Admin uninstalls: the package stays uninstalled (never force-reinstalled).
- Control via `BUNDLED_PACKAGES` env var: empty = defaults, `"*"` = all, or comma-separated IDs.
**User-installed packages** are uploaded through the Admin UI or API. They follow the same lifecycle but are not tied to the Docker image.
## Building Packages
Packages are built by zipping the standard directories alongside `manifest.json`:
```bash
# Build all packages in the packages/ directory
cd packages && bash build.sh
# Build a single package
cd packages && bash build.sh my-package
```
Output goes to `dist/my-package.pkg`.
### Manual Build
```bash
cd packages/my-package
zip -r ../../dist/my-package.pkg manifest.json js/ css/ script.star
```
The build script automatically includes whichever standard directories exist: `js/`, `css/`, `assets/`, `script.star`, `star/`, `migrations/`.
### Custom Docker Image
To bundle custom packages into a Docker image:
1. Place your package in `packages/your-package/` with a `manifest.json`.
2. Run `cd packages && bash build.sh all`.
3. Build the image: `docker build -t my-switchboard .`
The Dockerfile builds all packages and copies them into the bundled packages directory.

View File

@@ -66,6 +66,7 @@ server {
location ${BASE_PATH}/team-admin { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location ${BASE_PATH}/team-admin { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
location ${BASE_PATH}/settings { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location ${BASE_PATH}/settings { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
location ${BASE_PATH}/welcome { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location ${BASE_PATH}/welcome { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
location ${BASE_PATH}/docs { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
location ${BASE_PATH}/w/ { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location ${BASE_PATH}/w/ { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
# Extension surface page routes → backend # Extension surface page routes → backend

523
server/handlers/backup.go Normal file
View File

@@ -0,0 +1,523 @@
package handlers
import (
"archive/zip"
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/gin-gonic/gin"
"switchboard-core/database"
"switchboard-core/store"
)
// BackupHandler provides backup/restore endpoints for admin users.
type BackupHandler struct {
stores store.Stores
packagesDir string
storagePath string
}
// NewBackupHandler creates a new BackupHandler.
func NewBackupHandler(stores store.Stores, packagesDir, storagePath string) *BackupHandler {
return &BackupHandler{
stores: stores,
packagesDir: packagesDir,
storagePath: storagePath,
}
}
// backupMeta is written as meta.json inside the .swb archive.
type backupMeta struct {
Version string `json:"version"`
Timestamp time.Time `json:"timestamp"`
SchemaVersion string `json:"schema_version"`
Dialect string `json:"dialect"`
CoreTables []string `json:"core_tables"`
ExtTables []string `json:"ext_tables,omitempty"`
}
func (h *BackupHandler) backupsDir() string {
if h.storagePath == "" {
return ""
}
return filepath.Join(h.storagePath, "backups")
}
// ── CreateBackup ─────────────────────────────
// POST /api/v1/admin/backup
// Streams a .swb ZIP archive containing all core + ext_data tables as JSONL,
// plus package asset files from disk.
func (h *BackupHandler) CreateBackup(c *gin.Context) {
ctx := c.Request.Context()
db := database.DB
// Determine schema version
schemaVersion := ""
row := db.QueryRowContext(ctx, "SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1")
row.Scan(&schemaVersion)
dialect := "sqlite"
if database.IsPostgres() {
dialect = "postgres"
}
// Enumerate ext_data tables
extEntries, err := listExtDataTables(ctx, db)
if err != nil {
log.Printf("[backup] warning: list ext_data tables: %v", err)
}
extTableNames := make([]string, len(extEntries))
for i, e := range extEntries {
extTableNames[i] = e.PhysicalName
}
meta := backupMeta{
Version: readVersionFile(),
Timestamp: time.Now().UTC(),
SchemaVersion: schemaVersion,
Dialect: dialect,
CoreTables: coreTableOrder,
ExtTables: extTableNames,
}
// Determine if we should store server-side or stream directly
storeSide := c.Query("store") == "true"
if storeSide {
h.createStoredBackup(c, ctx, db, meta, extEntries)
return
}
// Stream directly to client
filename := fmt.Sprintf("switchboard-%s-%s.swb", meta.Version, meta.Timestamp.Format("20060102-150405"))
c.Header("Content-Type", "application/zip")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
zw := zip.NewWriter(c.Writer)
defer zw.Close()
h.writeBackupContents(c, zw, meta, extEntries)
}
func (h *BackupHandler) createStoredBackup(c *gin.Context, ctx interface{ Deadline() (time.Time, bool) }, db interface{}, meta backupMeta, extEntries []extDataTableEntry) {
dir := h.backupsDir()
if dir == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "no storage path configured"})
return
}
os.MkdirAll(dir, 0755)
filename := fmt.Sprintf("switchboard-%s-%s.swb", meta.Version, meta.Timestamp.Format("20060102-150405"))
path := filepath.Join(dir, filename)
f, err := os.Create(path)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "create backup file: " + err.Error()})
return
}
defer f.Close()
zw := zip.NewWriter(f)
defer zw.Close()
h.writeBackupContents(c, zw, meta, extEntries)
c.JSON(http.StatusOK, gin.H{"data": gin.H{
"filename": filename,
"path": path,
}})
}
func (h *BackupHandler) writeBackupContents(c *gin.Context, zw *zip.Writer, meta backupMeta, extEntries []extDataTableEntry) {
ctx := c.Request.Context()
db := database.DB
// Write meta.json
mw, err := zw.Create("meta.json")
if err != nil {
log.Printf("[backup] create meta.json: %v", err)
return
}
enc := json.NewEncoder(mw)
enc.SetIndent("", " ")
enc.Encode(meta)
// Dump core tables
for _, table := range coreTableOrder {
if !tableExists(ctx, db, table) {
continue
}
fw, err := zw.Create(fmt.Sprintf("core/%s.jsonl", table))
if err != nil {
log.Printf("[backup] create core/%s.jsonl: %v", table, err)
continue
}
count, err := dumpTable(ctx, db, table, fw)
if err != nil {
log.Printf("[backup] dump %s: %v", table, err)
} else {
log.Printf("[backup] dumped %s: %d rows", table, count)
}
}
// Dump ext_data tables
for _, entry := range extEntries {
if !tableExists(ctx, db, entry.PhysicalName) {
continue
}
path := fmt.Sprintf("ext_data/%s/%s.jsonl", entry.PackageID, entry.TableName)
fw, err := zw.Create(path)
if err != nil {
log.Printf("[backup] create %s: %v", path, err)
continue
}
count, err := dumpTable(ctx, db, entry.PhysicalName, fw)
if err != nil {
log.Printf("[backup] dump %s: %v", entry.PhysicalName, err)
} else {
log.Printf("[backup] dumped %s: %d rows", entry.PhysicalName, count)
}
}
// Walk package assets
if h.packagesDir != "" {
filepath.Walk(h.packagesDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
relPath, err := filepath.Rel(h.packagesDir, path)
if err != nil {
return nil
}
relPath = filepath.ToSlash(relPath)
fw, err := zw.Create("packages/" + relPath)
if err != nil {
return nil
}
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
io.Copy(fw, f)
return nil
})
}
}
// ── ListBackups ──────────────────────────────
// GET /api/v1/admin/backups
func (h *BackupHandler) ListBackups(c *gin.Context) {
dir := h.backupsDir()
if dir == "" {
c.JSON(http.StatusOK, gin.H{"data": []interface{}{}})
return
}
entries, err := os.ReadDir(dir)
if err != nil {
c.JSON(http.StatusOK, gin.H{"data": []interface{}{}})
return
}
type backupEntry struct {
Name string `json:"name"`
Size int64 `json:"size"`
CreatedAt time.Time `json:"created_at"`
}
var backups []backupEntry
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".swb") {
continue
}
info, err := e.Info()
if err != nil {
continue
}
backups = append(backups, backupEntry{
Name: e.Name(),
Size: info.Size(),
CreatedAt: info.ModTime(),
})
}
if backups == nil {
backups = []backupEntry{}
}
c.JSON(http.StatusOK, gin.H{"data": backups})
}
// ── DownloadBackup ───────────────────────────
// GET /api/v1/admin/backups/:name
func (h *BackupHandler) DownloadBackup(c *gin.Context) {
name := c.Param("name")
if !isValidBackupName(name) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid backup name"})
return
}
dir := h.backupsDir()
if dir == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "no storage configured"})
return
}
path := filepath.Join(dir, name)
if _, err := os.Stat(path); os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "backup not found"})
return
}
c.Header("Content-Type", "application/zip")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name))
c.File(path)
}
// ── DeleteBackup ─────────────────────────────
// DELETE /api/v1/admin/backups/:name
func (h *BackupHandler) DeleteBackup(c *gin.Context) {
name := c.Param("name")
if !isValidBackupName(name) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid backup name"})
return
}
dir := h.backupsDir()
if dir == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "no storage configured"})
return
}
path := filepath.Join(dir, name)
if err := os.Remove(path); err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "backup not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{"deleted": name}})
}
// ── RestoreBackup ────────────────────────────
// POST /api/v1/admin/restore
// Accepts a .swb file upload, validates meta, wipes DB, restores data.
func (h *BackupHandler) RestoreBackup(c *gin.Context) {
ctx := c.Request.Context()
db := database.DB
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file required"})
return
}
defer file.Close()
// Read entire file into memory for zip.NewReader
data, err := io.ReadAll(file)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "read file: " + err.Error()})
return
}
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid archive: " + err.Error()})
return
}
// Read meta.json
meta, err := readBackupMeta(zr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid backup: " + err.Error()})
return
}
// Validate schema version — reject backups from future schema
currentSchema := ""
db.QueryRowContext(ctx, "SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1").Scan(&currentSchema)
if meta.SchemaVersion > currentSchema && currentSchema != "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("backup schema %s is newer than current %s — upgrade the application first", meta.SchemaVersion, currentSchema),
})
return
}
log.Printf("[backup] restoring from %s (schema %s, %d core tables, %d ext tables)",
header.Filename, meta.SchemaVersion, len(meta.CoreTables), len(meta.ExtTables))
// Build full table list for wipe (core + ext_data)
allTables := make([]string, 0, len(coreTableOrder)+len(meta.ExtTables))
allTables = append(allTables, coreTableOrder...)
allTables = append(allTables, meta.ExtTables...)
// Wipe all data
if err := wipeTables(ctx, db, allTables); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "wipe failed: " + err.Error()})
return
}
// Restore core tables in order
restored := 0
for _, table := range coreTableOrder {
path := fmt.Sprintf("core/%s.jsonl", table)
f := findInZip(zr, path)
if f == nil {
continue
}
rc, err := f.Open()
if err != nil {
log.Printf("[backup] open %s: %v", path, err)
continue
}
count, err := restoreTable(ctx, db, table, rc)
rc.Close()
if err != nil {
log.Printf("[backup] restore %s: %v", table, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("restore %s: %v", table, err)})
return
}
log.Printf("[backup] restored %s: %d rows", table, count)
restored += count
}
// Restore ext_data tables
for _, extTable := range meta.ExtTables {
// Parse package_id and table_name from physical name: ext_{pkg}_{table}
pkgID, tblName := parseExtTableName(extTable)
if pkgID == "" {
continue
}
path := fmt.Sprintf("ext_data/%s/%s.jsonl", pkgID, tblName)
f := findInZip(zr, path)
if f == nil {
continue
}
// Ensure physical table exists before restoring
if !tableExists(ctx, db, extTable) {
log.Printf("[backup] skip ext table %s (not in schema)", extTable)
continue
}
rc, err := f.Open()
if err != nil {
log.Printf("[backup] open %s: %v", path, err)
continue
}
count, err := restoreTable(ctx, db, extTable, rc)
rc.Close()
if err != nil {
log.Printf("[backup] restore ext %s: %v", extTable, err)
} else {
log.Printf("[backup] restored ext %s: %d rows", extTable, count)
restored += count
}
}
// Restore package files
if h.packagesDir != "" {
for _, f := range zr.File {
if !strings.HasPrefix(f.Name, "packages/") || f.FileInfo().IsDir() {
continue
}
relPath := strings.TrimPrefix(f.Name, "packages/")
destPath := filepath.Join(h.packagesDir, filepath.FromSlash(relPath))
os.MkdirAll(filepath.Dir(destPath), 0755)
rc, err := f.Open()
if err != nil {
continue
}
out, err := os.Create(destPath)
if err != nil {
rc.Close()
continue
}
io.Copy(out, rc)
out.Close()
rc.Close()
}
}
log.Printf("[backup] restore complete: %d total rows", restored)
c.JSON(http.StatusOK, gin.H{"data": gin.H{
"rows_restored": restored,
"schema": meta.SchemaVersion,
}})
}
// ── Helpers ──────────────────────────────────
func readBackupMeta(zr *zip.Reader) (*backupMeta, error) {
f := findInZip(zr, "meta.json")
if f == nil {
return nil, fmt.Errorf("meta.json not found in archive")
}
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
var meta backupMeta
if err := json.NewDecoder(rc).Decode(&meta); err != nil {
return nil, fmt.Errorf("decode meta.json: %w", err)
}
return &meta, nil
}
func findInZip(zr *zip.Reader, name string) *zip.File {
for _, f := range zr.File {
if f.Name == name {
return f
}
}
return nil
}
// parseExtTableName parses "ext_{pkg}_{table}" into (pkg, table).
func parseExtTableName(physical string) (string, string) {
if !strings.HasPrefix(physical, "ext_") {
return "", ""
}
rest := physical[4:]
idx := strings.Index(rest, "_")
if idx < 0 {
return "", ""
}
return rest[:idx], rest[idx+1:]
}
var validBackupNameRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+\.swb$`)
func isValidBackupName(name string) bool {
return validBackupNameRe.MatchString(name) && !strings.Contains(name, "..")
}
// readVersionFile reads the VERSION file from the project root.
func readVersionFile() string {
// Try common locations
for _, path := range []string{"VERSION", "/app/VERSION", "../VERSION"} {
data, err := os.ReadFile(path)
if err == nil {
return strings.TrimSpace(string(data))
}
}
return "unknown"
}

View File

@@ -0,0 +1,231 @@
package handlers
import (
"bufio"
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"log"
"strings"
"switchboard-core/database"
)
// coreTableOrder lists tables for backup in FK-safe import order.
// Ephemeral tables (node_registry, ws_tickets, rate_limit_counters,
// user_presence, oidc_auth_state, refresh_tokens) are excluded.
var coreTableOrder = []string{
"schema_migrations",
"users",
"teams",
"team_members",
"groups",
"group_members",
"platform_policies",
"global_settings",
"packages",
"package_user_settings",
"package_team_settings",
"extension_permissions",
"ext_data_tables",
"ext_connections",
"ext_dependencies",
"resource_grants",
"notifications",
"notification_preferences",
"audit_log",
"workflows",
"workflow_stages",
"workflow_versions",
"workflow_instances",
"workflow_assignments",
"workflow_signoffs",
"triggers",
"scheduled_tasks",
"trigger_logs",
}
// dumpTable writes all rows of a table as newline-delimited JSON to w.
// Columns are discovered dynamically via rows.ColumnTypes().
func dumpTable(ctx context.Context, db *sql.DB, table string, w io.Writer) (int, error) {
rows, err := db.QueryContext(ctx, fmt.Sprintf("SELECT * FROM %s", table))
if err != nil {
return 0, fmt.Errorf("query %s: %w", table, err)
}
defer rows.Close()
cols, err := rows.Columns()
if err != nil {
return 0, fmt.Errorf("columns %s: %w", table, err)
}
count := 0
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
for rows.Next() {
// Create scan destinations — all as interface{}
vals := make([]interface{}, len(cols))
ptrs := make([]interface{}, len(cols))
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rows.Scan(ptrs...); err != nil {
return count, fmt.Errorf("scan %s row %d: %w", table, count, err)
}
row := make(map[string]interface{}, len(cols))
for i, col := range cols {
row[col] = normalizeValue(vals[i])
}
if err := enc.Encode(row); err != nil {
return count, fmt.Errorf("encode %s row %d: %w", table, count, err)
}
count++
}
return count, rows.Err()
}
// normalizeValue converts database-specific types to JSON-friendly values.
func normalizeValue(v interface{}) interface{} {
if v == nil {
return nil
}
switch val := v.(type) {
case []byte:
// Try to parse as JSON first (for JSONB columns)
var j interface{}
if err := json.Unmarshal(val, &j); err == nil {
return j
}
return string(val)
default:
return val
}
}
// restoreTable reads JSONL from r and inserts rows into the table.
// Column names come from the first JSON object. Uses dialect-adapted SQL.
func restoreTable(ctx context.Context, db *sql.DB, table string, r io.Reader) (int, error) {
scanner := bufio.NewScanner(r)
// Allow large lines (e.g. JSONB audit_log entries)
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
count := 0
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var row map[string]interface{}
if err := json.Unmarshal(line, &row); err != nil {
return count, fmt.Errorf("unmarshal %s line %d: %w", table, count+1, err)
}
cols := make([]string, 0, len(row))
vals := make([]interface{}, 0, len(row))
for k, v := range row {
cols = append(cols, k)
vals = append(vals, marshalIfComplex(v))
}
// Build INSERT with positional placeholders
placeholders := make([]string, len(cols))
for i := range placeholders {
placeholders[i] = fmt.Sprintf("$%d", i+1)
}
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)",
table,
strings.Join(cols, ", "),
strings.Join(placeholders, ", "),
)
if _, err := database.ExecContext(ctx, query, vals...); err != nil {
return count, fmt.Errorf("insert %s row %d: %w", table, count+1, err)
}
count++
}
return count, scanner.Err()
}
// marshalIfComplex converts maps/slices back to JSON strings for DB storage.
func marshalIfComplex(v interface{}) interface{} {
if v == nil {
return nil
}
switch v.(type) {
case map[string]interface{}, []interface{}:
b, _ := json.Marshal(v)
return string(b)
default:
return v
}
}
// listExtDataTables returns all (package_id, physical_table_name) pairs
// from the ext_data_tables catalog.
func listExtDataTables(ctx context.Context, db *sql.DB) ([]extDataTableEntry, error) {
rows, err := db.QueryContext(ctx, "SELECT package_id, table_name FROM ext_data_tables ORDER BY package_id, table_name")
if err != nil {
return nil, err
}
defer rows.Close()
var entries []extDataTableEntry
for rows.Next() {
var e extDataTableEntry
if err := rows.Scan(&e.PackageID, &e.TableName); err != nil {
return nil, err
}
e.PhysicalName = fmt.Sprintf("ext_%s_%s", e.PackageID, e.TableName)
entries = append(entries, e)
}
return entries, rows.Err()
}
type extDataTableEntry struct {
PackageID string
TableName string
PhysicalName string
}
// tableExists checks if a table exists in the database.
func tableExists(ctx context.Context, db *sql.DB, table string) bool {
if database.IsPostgres() {
var exists bool
db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = $1)", table).Scan(&exists)
return exists
}
// SQLite
var name string
err := db.QueryRowContext(ctx, "SELECT name FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&name)
return err == nil
}
// wipeTables deletes all data from the given tables in reverse order (FK-safe).
func wipeTables(ctx context.Context, db *sql.DB, tables []string) error {
if database.IsSQLite() {
db.ExecContext(ctx, "PRAGMA foreign_keys = OFF")
defer db.ExecContext(ctx, "PRAGMA foreign_keys = ON")
}
// Delete in reverse order to respect FK constraints
for i := len(tables) - 1; i >= 0; i-- {
table := tables[i]
if !tableExists(ctx, db, table) {
continue
}
if database.IsPostgres() {
if _, err := db.ExecContext(ctx, fmt.Sprintf("TRUNCATE TABLE %s CASCADE", table)); err != nil {
log.Printf("[backup] warning: truncate %s: %v", table, err)
}
} else {
if _, err := db.ExecContext(ctx, fmt.Sprintf("DELETE FROM %s", table)); err != nil {
log.Printf("[backup] warning: delete %s: %v", table, err)
}
}
}
return nil
}

View File

@@ -0,0 +1,372 @@
package handlers
import (
"archive/zip"
"bufio"
"bytes"
"context"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
authpkg "switchboard-core/auth"
"switchboard-core/config"
"switchboard-core/database"
"switchboard-core/middleware"
"switchboard-core/store"
"switchboard-core/store/sqlite"
)
// ── Backup Test Harness ────────────────────────
type backupHarness struct {
*testHarness
adminToken string
adminID string
stores store.Stores
backupH *BackupHandler
}
func setupBackupHarness(t *testing.T) *backupHarness {
t.Helper()
database.RequireTestDB(t)
database.TruncateAll(t)
cfg := &config.Config{
JWTSecret: testJWTSecret,
BasePath: "",
}
var stores store.Stores
if database.IsSQLite() {
stores = sqlite.NewStores(database.TestDB)
} else {
t.Skip("backup tests run on SQLite only")
}
userCache := middleware.NewUserStatusCache()
storagePath := t.TempDir()
backupH := NewBackupHandler(stores, "", storagePath)
r := gin.New()
api := r.Group("/api/v1")
// Auth
auth := NewAuthHandler(cfg, stores, nil, authpkg.NewBuiltinProvider())
api.POST("/auth/register", auth.Register)
api.POST("/auth/login", auth.Login)
// Admin group
admin := api.Group("/admin")
admin.Use(middleware.Auth(cfg, stores.Users, userCache), middleware.RequireAdmin(stores))
admin.POST("/backup", backupH.CreateBackup)
admin.GET("/backups", backupH.ListBackups)
admin.GET("/backups/:name", backupH.DownloadBackup)
admin.DELETE("/backups/:name", backupH.DeleteBackup)
admin.POST("/restore", backupH.RestoreBackup)
// Seed admin
adminID := seedInsertReturningID(t,
`INSERT INTO users (username, email, password_hash, handle, auth_source) VALUES ($1, $2, $3, $4, $5) RETURNING id`,
"bk-admin", "bk-admin@test.com", "$2a$10$test", "bk-admin", "builtin",
)
database.SeedEveryoneGroupMember(t, adminID)
database.SeedAdminsGroupMember(t, adminID)
adminToken := makeToken(adminID, "bk-admin@test.com", "admin")
return &backupHarness{
testHarness: &testHarness{router: r, t: t},
adminToken: adminToken,
adminID: adminID,
stores: stores,
backupH: backupH,
}
}
// ── Tests ──────────────────────────────────────
func TestCreateBackup_Basic(t *testing.T) {
h := setupBackupHarness(t)
w := h.request("POST", "/api/v1/admin/backup", h.adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
// Verify it's a valid ZIP
data := w.Body.Bytes()
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
t.Fatalf("invalid ZIP: %v", err)
}
// Must contain meta.json
var foundMeta bool
for _, f := range zr.File {
if f.Name == "meta.json" {
foundMeta = true
rc, _ := f.Open()
var meta backupMeta
json.NewDecoder(rc).Decode(&meta)
rc.Close()
if meta.Dialect != "sqlite" {
t.Errorf("expected dialect sqlite, got %s", meta.Dialect)
}
if len(meta.CoreTables) == 0 {
t.Error("expected core tables in meta")
}
}
}
if !foundMeta {
t.Fatal("meta.json not found in backup")
}
// Must contain core table JSONL files
var foundUsers bool
for _, f := range zr.File {
if f.Name == "core/users.jsonl" {
foundUsers = true
}
}
if !foundUsers {
t.Error("core/users.jsonl not found in backup")
}
}
func TestCreateBackup_WithExtData(t *testing.T) {
h := setupBackupHarness(t)
ctx := context.Background()
// Seed a package so FK constraint is satisfied
database.TestDB.ExecContext(ctx, dialectSQL(
`INSERT INTO packages (id, title, type, tier, version, source, enabled, manifest)
VALUES ($1, $2, $3, $4, $5, $6, 1, '{}')`),
"testpkg", "Test Pkg", "extension", "starlark", "0.1.0", "extension",
)
// Register an ext_data table and create it
database.TestDB.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS ext_testpkg_items (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
)`)
if _, err := database.TestDB.ExecContext(ctx,
dialectSQL(`INSERT INTO ext_data_tables (package_id, table_name) VALUES ($1, $2)`),
"testpkg", "items",
); err != nil {
t.Fatalf("insert ext_data_tables: %v", err)
}
database.TestDB.ExecContext(ctx,
`INSERT INTO ext_testpkg_items (id, name) VALUES ('item1', 'Test Item')`,
)
defer database.TestDB.ExecContext(ctx, "DROP TABLE IF EXISTS ext_testpkg_items")
w := h.request("POST", "/api/v1/admin/backup", h.adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
data := w.Body.Bytes()
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
t.Fatalf("invalid ZIP: %v", err)
}
// Should contain ext_data/testpkg/items.jsonl
var foundExt bool
for _, f := range zr.File {
if f.Name == "ext_data/testpkg/items.jsonl" {
foundExt = true
rc, _ := f.Open()
scanner := bufio.NewScanner(rc)
if !scanner.Scan() {
t.Error("ext_data file is empty")
} else {
var row map[string]interface{}
json.Unmarshal(scanner.Bytes(), &row)
if row["name"] != "Test Item" {
t.Errorf("expected 'Test Item', got %v", row["name"])
}
}
rc.Close()
}
}
if !foundExt {
t.Error("ext_data/testpkg/items.jsonl not found")
}
// Check meta.json has ext table listed
metaFile := findInZip(zr, "meta.json")
rc, _ := metaFile.Open()
var meta backupMeta
json.NewDecoder(rc).Decode(&meta)
rc.Close()
found := false
for _, et := range meta.ExtTables {
if et == "ext_testpkg_items" {
found = true
}
}
if !found {
t.Errorf("ext_testpkg_items not in meta.ExtTables: %v", meta.ExtTables)
}
}
func TestRestoreBackup_RoundTrip(t *testing.T) {
h := setupBackupHarness(t)
// Create a second user so we have data to verify
seedInsertReturningID(t,
`INSERT INTO users (username, email, password_hash, handle, auth_source) VALUES ($1, $2, $3, $4, $5) RETURNING id`,
"bk-user2", "bk-user2@test.com", "$2a$10$test", "bk-user2", "builtin",
)
// Create backup
w := h.request("POST", "/api/v1/admin/backup", h.adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("backup: expected 200, got %d: %s", w.Code, w.Body.String())
}
backupData := w.Body.Bytes()
// Verify we have 2 users
var countBefore int
database.TestDB.QueryRow("SELECT COUNT(*) FROM users").Scan(&countBefore)
if countBefore < 2 {
t.Fatalf("expected at least 2 users, got %d", countBefore)
}
// Restore directly (the handler wipes + restores, so data should round-trip)
// We use a separate router without admin middleware for restore,
// since the wipe inside RestoreBackup removes the admin group membership
// mid-request. In production, restore would use a session-based token
// that survives the wipe. For testing, we bypass the middleware.
restoreRouter := gin.New()
restoreRouter.POST("/restore", h.backupH.RestoreBackup)
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, _ := writer.CreateFormFile("file", "test-backup.swb")
part.Write(backupData)
writer.Close()
req := httptest.NewRequest("POST", "/restore", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
rw := httptest.NewRecorder()
restoreRouter.ServeHTTP(rw, req)
if rw.Code != http.StatusOK {
t.Fatalf("restore: expected 200, got %d: %s", rw.Code, rw.Body.String())
}
// Verify data is back
var countAfterRestore int
database.TestDB.QueryRow("SELECT COUNT(*) FROM users").Scan(&countAfterRestore)
if countAfterRestore != countBefore {
t.Errorf("expected %d users after restore, got %d", countBefore, countAfterRestore)
}
// Verify specific user exists
var username string
err := database.TestDB.QueryRow("SELECT username FROM users WHERE username = 'bk-user2'").Scan(&username)
if err != nil {
t.Errorf("user bk-user2 not found after restore: %v", err)
}
}
func TestRestoreBackup_SchemaVersionMismatch(t *testing.T) {
h := setupBackupHarness(t)
// Build a fake backup with a future schema version
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
mw, _ := zw.Create("meta.json")
json.NewEncoder(mw).Encode(backupMeta{
Version: "99.0.0",
SchemaVersion: "999_future",
Dialect: "sqlite",
CoreTables: []string{"users"},
})
zw.Close()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, _ := writer.CreateFormFile("file", "future.swb")
part.Write(buf.Bytes())
writer.Close()
req := httptest.NewRequest("POST", "/api/v1/admin/restore", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+h.adminToken)
rw := httptest.NewRecorder()
h.router.ServeHTTP(rw, req)
if rw.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for future schema, got %d: %s", rw.Code, rw.Body.String())
}
}
func TestDumpAndRestoreTable(t *testing.T) {
database.RequireTestDB(t)
ctx := context.Background()
db := database.TestDB
// Create a test table
db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS test_backup_round (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
count INTEGER DEFAULT 0
)`)
defer db.ExecContext(ctx, "DROP TABLE IF EXISTS test_backup_round")
// Insert data
db.ExecContext(ctx, `INSERT INTO test_backup_round (id, name, count) VALUES ('r1', 'alpha', 10)`)
db.ExecContext(ctx, `INSERT INTO test_backup_round (id, name, count) VALUES ('r2', 'beta', 20)`)
// Dump
var buf bytes.Buffer
count, err := dumpTable(ctx, db, "test_backup_round", &buf)
if err != nil {
t.Fatalf("dump: %v", err)
}
if count != 2 {
t.Fatalf("dump: expected 2 rows, got %d", count)
}
// Wipe
db.ExecContext(ctx, "DELETE FROM test_backup_round")
// Restore
restored, err := restoreTable(ctx, db, "test_backup_round", &buf)
if err != nil {
t.Fatalf("restore: %v", err)
}
if restored != 2 {
t.Fatalf("restore: expected 2 rows, got %d", restored)
}
// Verify
var name string
var cnt int
db.QueryRowContext(ctx, "SELECT name, count FROM test_backup_round WHERE id = 'r1'").Scan(&name, &cnt)
if name != "alpha" || cnt != 10 {
t.Errorf("expected alpha/10, got %s/%d", name, cnt)
}
}
func TestListBackups_Empty(t *testing.T) {
h := setupBackupHarness(t)
w := h.request("GET", "/api/v1/admin/backups", h.adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp struct{ Data []interface{} }
json.NewDecoder(w.Body).Decode(&resp)
if len(resp.Data) != 0 {
t.Errorf("expected empty list, got %d", len(resp.Data))
}
}

134
server/handlers/docs.go Normal file
View File

@@ -0,0 +1,134 @@
package handlers
import (
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/gin-gonic/gin"
)
// DocsHandler serves documentation files.
type DocsHandler struct {
docsDir string
}
// NewDocsHandler creates a new DocsHandler.
func NewDocsHandler(docsDir string) *DocsHandler {
return &DocsHandler{docsDir: docsDir}
}
// docEntry describes a documentation file.
type docEntry struct {
Slug string `json:"slug"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
}
// docsOrder defines the display order and metadata for documentation files.
var docsOrder = []docEntry{
{Slug: "GETTING-STARTED", Title: "Getting Started", Description: "Quick start guide"},
{Slug: "ARCHITECTURE", Title: "Architecture", Description: "Kernel design and components"},
{Slug: "EXTENSION-GUIDE", Title: "Extension Guide", Description: "How to author extensions"},
{Slug: "PACKAGE-FORMAT", Title: "Package Format", Description: "The .pkg archive format"},
{Slug: "API-REFERENCE", Title: "API Reference", Description: "REST API overview"},
{Slug: "DEPLOYMENT", Title: "Deployment", Description: "Production deployment guide"},
{Slug: "DISTRIBUTION", Title: "Distribution", Description: "Docker image and bundled packages"},
}
// ListDocs returns the list of available documentation files.
// GET /api/v1/docs
func (h *DocsHandler) ListDocs(c *gin.Context) {
if h.docsDir == "" {
c.JSON(http.StatusOK, gin.H{"data": []docEntry{}})
return
}
var available []docEntry
for _, d := range docsOrder {
path := filepath.Join(h.docsDir, d.Slug+".md")
if _, err := os.Stat(path); err == nil {
available = append(available, d)
}
}
// Also add any .md files not in the ordered list
entries, err := os.ReadDir(h.docsDir)
if err == nil {
known := make(map[string]bool)
for _, d := range docsOrder {
known[d.Slug] = true
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
continue
}
slug := strings.TrimSuffix(e.Name(), ".md")
if known[slug] {
continue
}
// Skip design docs and other non-user-facing files
if strings.HasPrefix(slug, "DESIGN-") || strings.HasPrefix(slug, "DEMO-") {
continue
}
available = append(available, docEntry{
Slug: slug,
Title: slugToTitle(slug),
})
}
}
if available == nil {
available = []docEntry{}
}
c.JSON(http.StatusOK, gin.H{"data": available})
}
// GetDoc returns the raw markdown content of a documentation file.
// GET /api/v1/docs/:name
func (h *DocsHandler) GetDoc(c *gin.Context) {
name := c.Param("name")
if !isValidDocName(name) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid document name"})
return
}
if h.docsDir == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "docs not configured"})
return
}
path := filepath.Join(h.docsDir, name+".md")
data, err := os.ReadFile(path)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "document not found"})
return
}
c.JSON(http.StatusOK, gin.H{
"data": gin.H{
"slug": name,
"title": slugToTitle(name),
"content": string(data),
},
})
}
var validDocNameRe = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
func isValidDocName(name string) bool {
return validDocNameRe.MatchString(name) && !strings.Contains(name, "..")
}
func slugToTitle(slug string) string {
// Convert "GETTING-STARTED" → "Getting Started"
parts := strings.Split(slug, "-")
for i, p := range parts {
if len(p) > 0 {
parts[i] = strings.ToUpper(p[:1]) + strings.ToLower(p[1:])
}
}
return strings.Join(parts, " ")
}

View File

@@ -332,6 +332,107 @@ func hasPermission(granted []string, perm string) bool {
return false return false
} }
// ── api_schema support (v0.6.2) ─────────────────────────────────────
// APISchemaEntry represents one route's OpenAPI-level documentation
// declared via the optional manifest "api_schema" array.
type APISchemaEntry struct {
Path string `json:"path"`
Method string `json:"method"`
Summary string `json:"summary,omitempty"`
Description string `json:"description,omitempty"`
Params map[string]any `json:"params,omitempty"`
Body map[string]any `json:"body,omitempty"`
Response map[string]any `json:"response,omitempty"`
}
// ParseAPISchema extracts the optional api_schema array from a package manifest.
// Malformed entries are logged and skipped — never blocks extension loading.
func ParseAPISchema(manifest map[string]any) []APISchemaEntry {
raw, ok := manifest["api_schema"]
if !ok {
return nil
}
arr, ok := raw.([]any)
if !ok {
log.Printf("[ext_api] api_schema is not an array, skipping")
return nil
}
var entries []APISchemaEntry
for i, item := range arr {
m, ok := item.(map[string]any)
if !ok {
log.Printf("[ext_api] api_schema[%d]: not an object, skipping", i)
continue
}
path, _ := m["path"].(string)
method, _ := m["method"].(string)
if path == "" || method == "" {
log.Printf("[ext_api] api_schema[%d]: missing path or method, skipping", i)
continue
}
entry := APISchemaEntry{
Path: path,
Method: strings.ToUpper(method),
}
if s, ok := m["summary"].(string); ok {
entry.Summary = s
}
if s, ok := m["description"].(string); ok {
entry.Description = s
}
if p, ok := m["params"].(map[string]any); ok {
entry.Params = p
}
if b, ok := m["body"].(map[string]any); ok {
entry.Body = b
}
if r, ok := m["response"].(map[string]any); ok {
entry.Response = r
}
entries = append(entries, entry)
}
return entries
}
// ExtractAPIRoutes returns all declared path+method pairs from the manifest.
// Used by the OpenAPI builder to enumerate routes for stub generation.
func ExtractAPIRoutes(manifest map[string]any) []struct{ Method, Path string } {
raw, ok := manifest["api_routes"]
if !ok {
return nil
}
// Boolean shorthand — no specific routes to enumerate
if _, ok := raw.(bool); ok {
return nil
}
routes, ok := raw.([]any)
if !ok {
return nil
}
var result []struct{ Method, Path string }
for _, entry := range routes {
route, ok := entry.(map[string]any)
if !ok {
continue
}
method, _ := route["method"].(string)
path, _ := route["path"].(string)
if method == "" || path == "" {
continue
}
result = append(result, struct{ Method, Path string }{
Method: strings.ToUpper(method),
Path: path,
})
}
return result
}
// HasAPIRoutes checks whether a package manifest declares API routes. // HasAPIRoutes checks whether a package manifest declares API routes.
// Used by startup discovery and admin UI to identify API-capable packages. // Used by startup discovery and admin UI to identify API-capable packages.
func HasAPIRoutes(manifest map[string]any) bool { func HasAPIRoutes(manifest map[string]any) bool {

276
server/handlers/openapi.go Normal file
View File

@@ -0,0 +1,276 @@
// Package handlers — openapi.go
//
// v0.6.2: Dynamic OpenAPI spec generation. Merges the static kernel spec
// with extension-declared API routes. Extensions with api_schema get rich
// path items; others get auto-generated stubs.
//
// Two tiers:
// Tier 1 — Stubs: zero extension author effort, every route gets a stub
// Tier 2 — api_schema: optional manifest field for richer docs
package handlers
import (
"context"
"fmt"
"log"
"strings"
"switchboard-core/store"
"gopkg.in/yaml.v3"
)
// BuildOpenAPISpec produces a complete OpenAPI 3.0 spec by merging the static
// kernel spec with dynamically discovered extension routes.
//
// staticSpec is the raw YAML of the kernel's openapi.yaml.
// version is substituted into the spec's info.version field.
func BuildOpenAPISpec(stores store.Stores, staticSpec []byte, version string) (map[string]any, error) {
// 1. Parse static spec
spec := make(map[string]any)
patched := strings.Replace(string(staticSpec), "${VERSION}", version, 1)
if err := yaml.Unmarshal([]byte(patched), &spec); err != nil {
return nil, fmt.Errorf("failed to parse static OpenAPI spec: %w", err)
}
// Ensure paths map exists
paths, _ := spec["paths"].(map[string]any)
if paths == nil {
paths = make(map[string]any)
spec["paths"] = paths
}
// Ensure tags array exists
tags, _ := spec["tags"].([]any)
// 2. Enumerate enabled extensions with API routes
if stores.Packages == nil {
return spec, nil
}
pkgs, err := stores.Packages.List(context.Background())
if err != nil {
log.Printf("[openapi] Failed to list packages: %v", err)
return spec, nil // degrade gracefully — return kernel-only spec
}
for _, pkg := range pkgs {
if !pkg.Enabled || pkg.Manifest == nil {
continue
}
if !HasAPIRoutes(pkg.Manifest) {
continue
}
slug := pkg.ID
tagName := "extensions/" + slug
// Add extension tag
tags = append(tags, map[string]any{
"name": tagName,
"description": fmt.Sprintf("API routes for %s extension", pkg.Title),
})
// Build schema lookup from api_schema (if present)
schemaEntries := ParseAPISchema(pkg.Manifest)
schemaMap := make(map[string]APISchemaEntry) // key: "METHOD /path"
for _, entry := range schemaEntries {
key := entry.Method + " " + entry.Path
schemaMap[key] = entry
}
// Iterate declared routes
routes := ExtractAPIRoutes(pkg.Manifest)
if len(routes) == 0 {
// api_routes: true — no specific routes to enumerate.
// If api_schema is declared, generate entries from those.
for _, entry := range schemaEntries {
oaPath := fmt.Sprintf("/s/%s/api%s", slug, entry.Path)
method := strings.ToLower(entry.Method)
pathItem := getOrCreatePathItem(paths, oaPath)
pathItem[method] = buildSchemaOperation(entry, tagName)
}
continue
}
for _, route := range routes {
// Skip wildcard paths — can't represent meaningfully in OpenAPI
if strings.HasSuffix(route.Path, "*") {
continue
}
oaPath := fmt.Sprintf("/s/%s/api%s", slug, route.Path)
method := strings.ToLower(route.Method)
// Wildcard method: generate for common methods
methods := []string{method}
if route.Method == "*" {
methods = []string{"get", "post", "put", "delete", "patch"}
}
for _, m := range methods {
pathItem := getOrCreatePathItem(paths, oaPath)
key := strings.ToUpper(m) + " " + route.Path
if schema, ok := schemaMap[key]; ok {
pathItem[m] = buildSchemaOperation(schema, tagName)
} else {
pathItem[m] = buildStubOperation(slug, m, route.Path, tagName)
}
}
}
}
spec["tags"] = tags
return spec, nil
}
// getOrCreatePathItem returns the path item map for the given OpenAPI path,
// creating it if it doesn't exist.
func getOrCreatePathItem(paths map[string]any, path string) map[string]any {
if existing, ok := paths[path].(map[string]any); ok {
return existing
}
item := make(map[string]any)
paths[path] = item
return item
}
// buildStubOperation generates an auto-generated stub operation for routes
// without api_schema declarations.
func buildStubOperation(slug, method, path, tag string) map[string]any {
return map[string]any{
"summary": fmt.Sprintf("%s: %s %s", slug, strings.ToUpper(method), path),
"tags": []any{tag},
"responses": map[string]any{
"200": map[string]any{
"description": "Extension-defined response",
},
},
}
}
// buildSchemaOperation converts an APISchemaEntry into a full OpenAPI operation.
func buildSchemaOperation(entry APISchemaEntry, tag string) map[string]any {
op := map[string]any{
"summary": entry.Summary,
"tags": []any{tag},
}
if entry.Summary == "" {
op["summary"] = fmt.Sprintf("%s %s", entry.Method, entry.Path)
}
if entry.Description != "" {
op["description"] = entry.Description
}
// Query/path parameters
if len(entry.Params) > 0 {
var params []any
for name, def := range entry.Params {
param := map[string]any{
"name": name,
"in": "query",
}
if m, ok := def.(map[string]any); ok {
if t, ok := m["type"].(string); ok {
param["schema"] = map[string]any{"type": mapJSONType(t)}
}
if desc, ok := m["description"].(string); ok {
param["description"] = desc
}
if req, ok := m["required"].(bool); ok && req {
param["required"] = true
}
} else {
// Simple type string
if t, ok := def.(string); ok {
param["schema"] = map[string]any{"type": mapJSONType(t)}
}
}
params = append(params, param)
}
op["parameters"] = params
}
// Request body
if len(entry.Body) > 0 {
properties := make(map[string]any)
var required []any
for name, def := range entry.Body {
prop := map[string]any{}
if m, ok := def.(map[string]any); ok {
if t, ok := m["type"].(string); ok {
prop["type"] = mapJSONType(t)
}
if desc, ok := m["description"].(string); ok {
prop["description"] = desc
}
if req, ok := m["required"].(bool); ok && req {
required = append(required, name)
}
} else if t, ok := def.(string); ok {
prop["type"] = mapJSONType(t)
}
properties[name] = prop
}
schema := map[string]any{
"type": "object",
"properties": properties,
}
if len(required) > 0 {
schema["required"] = required
}
op["requestBody"] = map[string]any{
"required": true,
"content": map[string]any{
"application/json": map[string]any{
"schema": schema,
},
},
}
}
// Response
resp := map[string]any{
"200": map[string]any{
"description": "Successful response",
},
}
if entry.Response != nil {
respSchema := make(map[string]any)
if t, ok := entry.Response["type"].(string); ok {
respSchema["type"] = mapJSONType(t)
}
if ex, ok := entry.Response["example"]; ok {
respSchema["example"] = ex
}
resp["200"] = map[string]any{
"description": "Successful response",
"content": map[string]any{
"application/json": map[string]any{
"schema": respSchema,
},
},
}
}
op["responses"] = resp
return op
}
// mapJSONType converts common type names to OpenAPI types.
func mapJSONType(t string) string {
switch strings.ToLower(t) {
case "int", "integer":
return "integer"
case "float", "number", "double":
return "number"
case "bool", "boolean":
return "boolean"
case "array", "list":
return "array"
case "object", "map", "dict":
return "object"
default:
return "string"
}
}

View File

@@ -0,0 +1,406 @@
package handlers
import (
"context"
"encoding/json"
"testing"
"switchboard-core/database"
"switchboard-core/store"
"switchboard-core/store/sqlite"
)
// Minimal static spec for testing — valid OpenAPI 3.0 structure.
var testStaticSpec = []byte(`
openapi: "3.0.3"
info:
title: Switchboard Core
version: "${VERSION}"
paths:
/api/v1/health:
get:
summary: Health check
responses:
"200":
description: OK
tags:
- name: system
description: System endpoints
`)
func openapiTestStores(t *testing.T) store.Stores {
t.Helper()
database.RequireTestDB(t)
database.TruncateAll(t)
return sqlite.NewStores(database.TestDB)
}
// ── Test: Zero extensions → kernel spec only ──────────────────
func TestBuildOpenAPISpec_NoExtensions(t *testing.T) {
stores := openapiTestStores(t)
spec, err := BuildOpenAPISpec(stores, testStaticSpec, "0.6.2")
if err != nil {
t.Fatalf("BuildOpenAPISpec failed: %v", err)
}
// Version should be substituted
info, _ := spec["info"].(map[string]any)
if info == nil {
t.Fatal("missing info field")
}
if v, _ := info["version"].(string); v != "0.6.2" {
t.Errorf("expected version 0.6.2, got %q", v)
}
// Paths should contain the kernel endpoint
paths, _ := spec["paths"].(map[string]any)
if paths == nil {
t.Fatal("missing paths field")
}
if _, ok := paths["/api/v1/health"]; !ok {
t.Error("expected /api/v1/health in paths")
}
// Tags should contain kernel tag
tags, _ := spec["tags"].([]any)
if len(tags) < 1 {
t.Error("expected at least 1 tag")
}
}
// ── Test: Extension with api_routes but no api_schema → stubs ────
func TestBuildOpenAPISpec_StubEntries(t *testing.T) {
stores := openapiTestStores(t)
// Install a test extension with api_routes
manifest := map[string]any{
"api_routes": []any{
map[string]any{"method": "GET", "path": "/items"},
map[string]any{"method": "POST", "path": "/items"},
},
}
manifestJSON, _ := json.Marshal(manifest)
err := stores.Packages.Create(context.Background(), &store.PackageRegistration{
ID: "test-ext",
Title: "Test Extension",
Type: "extension",
Version: "1.0.0",
Tier: "starlark",
Manifest: manifest,
Enabled: true,
Status: "active",
Source: "extension",
Scope: "global",
})
_ = manifestJSON
if err != nil {
t.Fatalf("failed to create test package: %v", err)
}
spec, err := BuildOpenAPISpec(stores, testStaticSpec, "0.6.2")
if err != nil {
t.Fatalf("BuildOpenAPISpec failed: %v", err)
}
paths, _ := spec["paths"].(map[string]any)
// Check GET stub
getPath, ok := paths["/s/test-ext/api/items"]
if !ok {
t.Fatal("expected /s/test-ext/api/items in paths")
}
pathItem, _ := getPath.(map[string]any)
getOp, _ := pathItem["get"].(map[string]any)
if getOp == nil {
t.Fatal("expected GET operation")
}
summary, _ := getOp["summary"].(string)
if summary != "test-ext: GET /items" {
t.Errorf("unexpected stub summary: %q", summary)
}
// Check POST stub
postOp, _ := pathItem["post"].(map[string]any)
if postOp == nil {
t.Fatal("expected POST operation")
}
// Check tag
tags, _ := getOp["tags"].([]any)
if len(tags) != 1 || tags[0] != "extensions/test-ext" {
t.Errorf("unexpected tags: %v", tags)
}
}
// ── Test: Extension with api_schema → rich entries ────────
func TestBuildOpenAPISpec_SchemaEntries(t *testing.T) {
stores := openapiTestStores(t)
manifest := map[string]any{
"api_routes": []any{
map[string]any{"method": "GET", "path": "/items"},
map[string]any{"method": "POST", "path": "/items"},
map[string]any{"method": "DELETE", "path": "/items/:id"},
},
"api_schema": []any{
map[string]any{
"path": "/items",
"method": "GET",
"summary": "List items",
"params": map[string]any{
"limit": map[string]any{"type": "integer", "default": 50},
},
"response": map[string]any{
"type": "object",
"example": map[string]any{"data": []any{}},
},
},
map[string]any{
"path": "/items",
"method": "POST",
"summary": "Create item",
"body": map[string]any{
"name": map[string]any{"type": "string", "required": true},
"description": map[string]any{"type": "string"},
},
},
},
}
err := stores.Packages.Create(context.Background(), &store.PackageRegistration{
ID: "rich-ext",
Title: "Rich Extension",
Type: "extension",
Version: "1.0.0",
Tier: "starlark",
Manifest: manifest,
Enabled: true,
Status: "active",
Source: "extension",
Scope: "global",
})
if err != nil {
t.Fatalf("failed to create test package: %v", err)
}
spec, err := BuildOpenAPISpec(stores, testStaticSpec, "0.6.2")
if err != nil {
t.Fatalf("BuildOpenAPISpec failed: %v", err)
}
paths, _ := spec["paths"].(map[string]any)
// GET /items should have rich schema (summary = "List items")
itemsPath, ok := paths["/s/rich-ext/api/items"]
if !ok {
t.Fatal("expected /s/rich-ext/api/items in paths")
}
pathItem, _ := itemsPath.(map[string]any)
getOp, _ := pathItem["get"].(map[string]any)
if getOp == nil {
t.Fatal("expected GET operation")
}
if s, _ := getOp["summary"].(string); s != "List items" {
t.Errorf("expected rich summary, got %q", s)
}
// Should have parameters
params, _ := getOp["parameters"].([]any)
if len(params) == 0 {
t.Error("expected parameters from api_schema")
}
// POST /items should have requestBody
postOp, _ := pathItem["post"].(map[string]any)
if postOp == nil {
t.Fatal("expected POST operation")
}
if _, ok := postOp["requestBody"]; !ok {
t.Error("expected requestBody from api_schema body")
}
// DELETE /items/:id should be a stub (no api_schema for it)
deletePath, ok := paths["/s/rich-ext/api/items/:id"]
if !ok {
t.Fatal("expected /s/rich-ext/api/items/:id in paths")
}
deleteItem, _ := deletePath.(map[string]any)
deleteOp, _ := deleteItem["delete"].(map[string]any)
if deleteOp == nil {
t.Fatal("expected DELETE operation")
}
deleteSummary, _ := deleteOp["summary"].(string)
if deleteSummary != "rich-ext: DELETE /items/:id" {
t.Errorf("expected stub summary for undeclared route, got %q", deleteSummary)
}
}
// ── Test: Multiple extensions → all appear ────────
func TestBuildOpenAPISpec_MultipleExtensions(t *testing.T) {
stores := openapiTestStores(t)
for _, ext := range []struct {
id string
title string
route string
}{
{"ext-a", "Extension A", "/status"},
{"ext-b", "Extension B", "/health"},
} {
err := stores.Packages.Create(context.Background(), &store.PackageRegistration{
ID: ext.id,
Title: ext.title,
Type: "extension",
Version: "1.0.0",
Tier: "starlark",
Manifest: map[string]any{
"api_routes": []any{
map[string]any{"method": "GET", "path": ext.route},
},
},
Enabled: true,
Status: "active",
Source: "extension",
Scope: "global",
})
if err != nil {
t.Fatalf("failed to create %s: %v", ext.id, err)
}
}
spec, err := BuildOpenAPISpec(stores, testStaticSpec, "0.6.2")
if err != nil {
t.Fatalf("BuildOpenAPISpec failed: %v", err)
}
paths, _ := spec["paths"].(map[string]any)
if _, ok := paths["/s/ext-a/api/status"]; !ok {
t.Error("expected ext-a path")
}
if _, ok := paths["/s/ext-b/api/health"]; !ok {
t.Error("expected ext-b path")
}
// Verify tags
tags, _ := spec["tags"].([]any)
tagNames := make(map[string]bool)
for _, tag := range tags {
if m, ok := tag.(map[string]any); ok {
if n, ok := m["name"].(string); ok {
tagNames[n] = true
}
}
}
if !tagNames["extensions/ext-a"] {
t.Error("expected extensions/ext-a tag")
}
if !tagNames["extensions/ext-b"] {
t.Error("expected extensions/ext-b tag")
}
}
// ── Test: Malformed api_schema → stubs, no crash ────────
func TestBuildOpenAPISpec_MalformedSchema(t *testing.T) {
stores := openapiTestStores(t)
manifest := map[string]any{
"api_routes": []any{
map[string]any{"method": "GET", "path": "/data"},
},
"api_schema": []any{
"not-an-object", // malformed
map[string]any{"path": "/data"}, // missing method
map[string]any{"method": "GET"}, // missing path
map[string]any{"path": "/data", "method": "POST"}, // valid but no matching route
},
}
err := stores.Packages.Create(context.Background(), &store.PackageRegistration{
ID: "bad-schema",
Title: "Bad Schema",
Type: "extension",
Version: "1.0.0",
Tier: "starlark",
Manifest: manifest,
Enabled: true,
Status: "active",
Source: "extension",
Scope: "global",
})
if err != nil {
t.Fatalf("failed to create package: %v", err)
}
spec, err := BuildOpenAPISpec(stores, testStaticSpec, "0.6.2")
if err != nil {
t.Fatalf("BuildOpenAPISpec should not error on malformed schema: %v", err)
}
paths, _ := spec["paths"].(map[string]any)
// GET /data should still appear as a stub
if _, ok := paths["/s/bad-schema/api/data"]; !ok {
t.Error("expected stub entry for /data despite malformed api_schema")
}
}
// ── Test: Disabled extension → excluded ────────
func TestBuildOpenAPISpec_DisabledExcluded(t *testing.T) {
stores := openapiTestStores(t)
err := stores.Packages.Create(context.Background(), &store.PackageRegistration{
ID: "disabled-ext",
Title: "Disabled",
Type: "extension",
Version: "1.0.0",
Tier: "starlark",
Manifest: map[string]any{
"api_routes": []any{
map[string]any{"method": "GET", "path": "/secret"},
},
},
Enabled: false,
Status: "active",
Source: "extension",
Scope: "global",
})
if err != nil {
t.Fatalf("failed to create package: %v", err)
}
spec, err := BuildOpenAPISpec(stores, testStaticSpec, "0.6.2")
if err != nil {
t.Fatalf("BuildOpenAPISpec failed: %v", err)
}
paths, _ := spec["paths"].(map[string]any)
if _, ok := paths["/s/disabled-ext/api/secret"]; ok {
t.Error("disabled extension should not appear in spec")
}
}
// ── Test: Required OpenAPI 3.0 fields present ────────
func TestBuildOpenAPISpec_RequiredFields(t *testing.T) {
stores := openapiTestStores(t)
spec, err := BuildOpenAPISpec(stores, testStaticSpec, "1.0.0")
if err != nil {
t.Fatalf("BuildOpenAPISpec failed: %v", err)
}
if _, ok := spec["openapi"]; !ok {
t.Error("missing required 'openapi' field")
}
if _, ok := spec["info"]; !ok {
t.Error("missing required 'info' field")
}
if _, ok := spec["paths"]; !ok {
t.Error("missing required 'paths' field")
}
}

View File

@@ -311,6 +311,15 @@ func main() {
patched := bytes.Replace(openapiSpec, []byte("${VERSION}"), []byte(Version), 1) patched := bytes.Replace(openapiSpec, []byte("${VERSION}"), []byte(Version), 1)
c.Data(http.StatusOK, "application/yaml", patched) c.Data(http.StatusOK, "application/yaml", patched)
}) })
// v0.6.2: Dynamic OpenAPI spec with extension routes merged in
base.GET("/api/docs/openapi.json", func(c *gin.Context) {
spec, err := handlers.BuildOpenAPISpec(stores, openapiSpec, Version)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to build spec"})
return
}
c.JSON(http.StatusOK, spec)
})
// WebSocket endpoint // WebSocket endpoint
base.GET("/ws", middleware.WsAuth(cfg, stores.Users, userCache, ticketAdapter), hub.HandleWebSocket) base.GET("/ws", middleware.WsAuth(cfg, stores.Users, userCache, ticketAdapter), hub.HandleWebSocket)
@@ -512,6 +521,12 @@ func main() {
protected.GET("/settings", settings.GetSettings) protected.GET("/settings", settings.GetSettings)
protected.PUT("/settings", settings.UpdateSettings) protected.PUT("/settings", settings.UpdateSettings)
// Documentation (v0.6.1)
docsDir := findDocsDir()
docsH := handlers.NewDocsHandler(docsDir)
protected.GET("/docs", docsH.ListDocs)
protected.GET("/docs/:name", docsH.GetDoc)
// Permission bootstrap (v0.37.1) — self-service resolved permissions // Permission bootstrap (v0.37.1) — self-service resolved permissions
permH := handlers.NewProfilePermissionsHandler(stores) permH := handlers.NewProfilePermissionsHandler(stores)
protected.GET("/profile/permissions", permH.GetMyPermissions) protected.GET("/profile/permissions", permH.GetMyPermissions)
@@ -793,6 +808,14 @@ func main() {
admin.GET("/cluster", clusterH.ListNodes) admin.GET("/cluster", clusterH.ListNodes)
} }
// ── Backup/Restore (v0.6.1) ─────────
backupH := handlers.NewBackupHandler(stores, packagesDir, cfg.StoragePath)
admin.POST("/backup", backupH.CreateBackup)
admin.GET("/backups", backupH.ListBackups)
admin.GET("/backups/:name", backupH.DownloadBackup)
admin.DELETE("/backups/:name", backupH.DeleteBackup)
admin.POST("/restore", backupH.RestoreBackup)
// Surface aliases (backward compat — same handlers) // Surface aliases (backward compat — same handlers)
admin.GET("/surfaces", pkgAdm.ListPackages) admin.GET("/surfaces", pkgAdm.ListPackages)
admin.GET("/surfaces/:id", pkgAdm.GetPackage) admin.GET("/surfaces/:id", pkgAdm.GetPackage)
@@ -898,6 +921,21 @@ func appendClusterHealth(info gin.H, reg *cluster.Registry, stores store.Stores)
} }
} }
// findDocsDir locates the docs/ directory. Checks common locations.
func findDocsDir() string {
candidates := []string{
"docs",
"../docs",
"/app/docs",
}
for _, dir := range candidates {
if info, err := os.Stat(dir); err == nil && info.IsDir() {
return dir
}
}
return ""
}
// ── Vault CLI Commands ────────────────────── // ── Vault CLI Commands ──────────────────────
func runVaultCommand(subcmd string) { func runVaultCommand(subcmd string) {

View File

@@ -81,7 +81,7 @@ func sectionCategory(section string) string {
return "people" return "people"
case "workflows": case "workflows":
return "workflows" return "workflows"
case "settings", "storage", "packages", "broadcast": case "settings", "storage", "packages", "broadcast", "backup":
return "system" return "system"
case "usage", "audit", "stats": case "usage", "audit", "stats":
return "monitoring" return "monitoring"

View File

@@ -249,6 +249,11 @@ func (e *Engine) registerCoreSurfaces() {
Title: "Welcome", Template: "surface-welcome", Auth: "authenticated", Title: "Welcome", Template: "surface-welcome", Auth: "authenticated",
Layout: "single", Source: "core", Layout: "single", Source: "core",
}, },
{
ID: "docs", Route: "/docs/:section", AltRoutes: []string{"/docs"},
Title: "Docs", Template: "surface-docs", Auth: "authenticated",
Layout: "single", Source: "core",
},
} }
} }
@@ -361,6 +366,9 @@ func (e *Engine) RenderSurface(surfaceID string) gin.HandlerFunc {
if section == "" && surfaceID == "team-admin" { if section == "" && surfaceID == "team-admin" {
section = "members" section = "members"
} }
if section == "" && surfaceID == "docs" {
section = "GETTING-STARTED"
}
e.Render(c, "base.html", PageData{ e.Render(c, "base.html", PageData{
Surface: surfaceID, Surface: surfaceID,

View File

@@ -89,6 +89,7 @@
{{else if eq .Surface "team-admin"}}{{template "surface-team-admin" .}} {{else if eq .Surface "team-admin"}}{{template "surface-team-admin" .}}
{{else if eq .Surface "settings"}}{{template "surface-settings" .}} {{else if eq .Surface "settings"}}{{template "surface-settings" .}}
{{else if eq .Surface "welcome"}}{{template "surface-welcome" .}} {{else if eq .Surface "welcome"}}{{template "surface-welcome" .}}
{{else if eq .Surface "docs"}}{{template "surface-docs" .}}
{{else if and .Manifest (eq .Manifest.Source "extension")}}{{template "surface-extension" .}} {{else if and .Manifest (eq .Manifest.Source "extension")}}{{template "surface-extension" .}}
{{else}}<div style="padding:20px">Unknown surface: {{.Surface}}</div> {{else}}<div style="padding:20px">Unknown surface: {{.Surface}}</div>
{{end}} {{end}}
@@ -124,6 +125,7 @@
{{if eq .Surface "team-admin"}}{{template "scripts-team-admin" .}}{{end}} {{if eq .Surface "team-admin"}}{{template "scripts-team-admin" .}}{{end}}
{{if eq .Surface "settings"}}{{template "scripts-settings" .}}{{end}} {{if eq .Surface "settings"}}{{template "scripts-settings" .}}{{end}}
{{if eq .Surface "welcome"}}{{template "scripts-welcome" .}}{{end}} {{if eq .Surface "welcome"}}{{template "scripts-welcome" .}}{{end}}
{{if eq .Surface "docs"}}{{template "scripts-docs" .}}{{end}}
{{/* v0.27.0: Extension surface JS — load Preact globals, boot SDK, then extension */}} {{/* v0.27.0: Extension surface JS — load Preact globals, boot SDK, then extension */}}
{{if and .Manifest (eq .Manifest.Source "extension")}} {{if and .Manifest (eq .Manifest.Source "extension")}}
<script type="module" nonce="{{.CSPNonce}}"> <script type="module" nonce="{{.CSPNonce}}">

View File

@@ -0,0 +1,35 @@
{{/*
Docs surface — v0.6.1
Renders markdown documentation with sidebar navigation.
*/}}
{{define "surface-docs"}}
<div id="docs-mount" class="surface-docs">
<div style="padding:40px;text-align:center;color:var(--text-3);">Loading docs&hellip;</div>
</div>
{{end}}
{{define "css-docs"}}{{end}}
{{define "scripts-docs"}}
<script nonce="{{.CSPNonce}}">
window.__SECTION__ = '{{.Section}}';
</script>
<script type="module" nonce="{{.CSPNonce}}">
const { h, render } = await import('{{.BasePath}}/js/sw/vendor/preact.module.js');
const hooksModule = await import('{{.BasePath}}/js/sw/vendor/hooks.module.js');
const { default: htm } = await import('{{.BasePath}}/js/sw/vendor/htm.module.js');
const html = htm.bind(h);
window.preact = window.preact || { h, render };
window.hooks = window.hooks || hooksModule;
window.html = window.html || html;
// Boot SDK (idempotent)
const { boot } = await import('{{.BasePath}}/js/sw/sdk/index.js?v={{.Version}}');
await boot();
// Mount docs surface
await import('{{.BasePath}}/js/sw/surfaces/docs/index.js?v={{.Version}}');
</script>
{{end}}

View File

@@ -256,7 +256,7 @@
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script> <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script> <script>
SwaggerUIBundle({ SwaggerUIBundle({
url: window.location.pathname.replace(/\/$/, '') + '/openapi.yaml', url: window.location.pathname.replace(/\/$/, '') + '/openapi.json',
dom_id: '#swagger-ui', dom_id: '#swagger-ui',
deepLinking: true, deepLinking: true,
presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset], presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],

View File

@@ -19,6 +19,12 @@
.admin-storage-value { font-size: 14px; font-weight: 500; } .admin-storage-value { font-size: 14px; font-weight: 500; }
/* ── Docs Surface ────────────────────────── */
.surface-docs {
display: flex; flex-direction: column; height: 100%; overflow: hidden;
}
/* ── Settings Surface ────────────────────── */ /* ── Settings Surface ────────────────────── */
.surface-settings { .surface-settings {

View File

@@ -54,6 +54,7 @@
--input-bg: #1a1a1f; --input-bg: #1a1a1f;
--user-bubble: rgba(108,159,255,0.08); --user-bubble: rgba(108,159,255,0.08);
--danger-bg: rgba(239,68,68,0.12); --danger-bg: rgba(239,68,68,0.12);
--bg-code: #1a1a22;
--shadow-lg: 0 8px 32px rgba(0,0,0,0.5); --shadow-lg: 0 8px 32px rgba(0,0,0,0.5);
/* ── Layout ──────────────────────────────── */ /* ── Layout ──────────────────────────────── */
@@ -115,6 +116,7 @@
--input-bg: #f2f3f6; --input-bg: #f2f3f6;
--user-bubble: rgba(74,124,219,0.06); --user-bubble: rgba(74,124,219,0.06);
--danger-bg: #fef2f2; --danger-bg: #fef2f2;
--bg-code: #f0f0f2;
--shadow-lg: 0 8px 32px rgba(0,0,0,0.1); --shadow-lg: 0 8px 32px rgba(0,0,0,0.1);
color-scheme: light; color-scheme: light;

View File

@@ -277,6 +277,15 @@ export function createDomains(restClient) {
disable: (id) => rc.put(`/api/v1/admin/surfaces/${id}/disable`, {}), disable: (id) => rc.put(`/api/v1/admin/surfaces/${id}/disable`, {}),
del: (id) => rc.del(`/api/v1/admin/surfaces/${id}`), del: (id) => rc.del(`/api/v1/admin/surfaces/${id}`),
}, },
// v0.6.1: Backup/Restore
backup: {
create: (opts) => rc.post('/api/v1/admin/backup' + _qs(opts)),
list: () => rc.get('/api/v1/admin/backups'),
download: (name) => `/api/v1/admin/backups/${name}`,
del: (name) => rc.del(`/api/v1/admin/backups/${name}`),
restore: (file) => rc.upload('/api/v1/admin/restore', file),
},
}, },
// ── Users ────────────────────────────── // ── Users ──────────────────────────────

View File

@@ -36,7 +36,7 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
// Fetch installed surfaces once on mount. // Fetch installed surfaces once on mount.
// Filter out core surfaces that have dedicated menu entries below. // Filter out core surfaces that have dedicated menu entries below.
const CORE_IDS = new Set(['admin', 'settings', 'team-admin', 'workflow', 'workflow-landing']); const CORE_IDS = new Set(['admin', 'settings', 'team-admin', 'workflow', 'workflow-landing', 'docs']);
useEffect(() => { useEffect(() => {
if (!sw?.api?.surfaces?.list) return; if (!sw?.api?.surfaces?.list) return;
@@ -68,6 +68,11 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
} }
// ── Standard items ───────────────────── // ── Standard items ─────────────────────
// Docs — skip if current surface IS docs
if (current !== 'docs') {
list.push({ label: 'Docs', action: 'docs', icon: '\ud83d\udcd6' });
}
// Settings — skip if current surface IS settings // Settings — skip if current surface IS settings
if (current !== 'settings') { if (current !== 'settings') {
list.push({ label: 'Settings', action: 'settings', icon: '\u2699\ufe0f' }); list.push({ label: 'Settings', action: 'settings', icon: '\u2699\ufe0f' });
@@ -107,6 +112,9 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
location.href = BASE + '/login'; location.href = BASE + '/login';
}); });
break; break;
case 'docs':
location.href = BASE + '/docs/GETTING-STARTED';
break;
case 'debug': case 'debug':
window.dispatchEvent(new CustomEvent('debug:toggle')); window.dispatchEvent(new CustomEvent('debug:toggle'));
break; break;

View File

@@ -0,0 +1,162 @@
/**
* Admin > System > Backup
*
* Create, download, and restore .swb backup archives.
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
export default function BackupSection() {
const [backups, setBackups] = useState([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [restoring, setRestoring] = useState(false);
const load = useCallback(async () => {
try {
const resp = await sw.api.admin.backup.list();
setBackups(Array.isArray(resp) ? resp : resp.data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, []);
async function createBackup(store) {
setCreating(true);
try {
if (store) {
await sw.api.admin.backup.create({ store: 'true' });
sw.toast('Backup created', 'success');
load();
} else {
// Stream download
const url = '/api/v1/admin/backup';
const token = sw.auth.token();
const resp = await fetch(url, {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
});
if (!resp.ok) throw new Error('Backup failed');
const blob = await resp.blob();
const disposition = resp.headers.get('Content-Disposition') || '';
const match = disposition.match(/filename="?([^"]+)"?/);
const filename = match ? match[1] : 'backup.swb';
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
sw.toast('Backup downloaded', 'success');
}
} catch (e) { sw.toast(e.message, 'error'); }
finally { setCreating(false); }
}
async function downloadBackup(name) {
const token = sw.auth.token();
const resp = await fetch(sw.api.admin.backup.download(name), {
headers: { 'Authorization': 'Bearer ' + token },
});
if (!resp.ok) { sw.toast('Download failed', 'error'); return; }
const blob = await resp.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = name;
a.click();
URL.revokeObjectURL(a.href);
}
async function deleteBackup(name) {
const ok = await sw.confirm(`Delete backup "${name}"?`);
if (!ok) return;
try {
await sw.api.admin.backup.del(name);
sw.toast('Backup deleted', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function restoreBackup() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.swb';
input.onchange = async () => {
const file = input.files[0];
if (!file) return;
const ok = await sw.confirm(
`Restore from "${file.name}"? This will replace ALL data in the database. This action cannot be undone.`
);
if (!ok) return;
setRestoring(true);
try {
const resp = await sw.api.admin.backup.restore(file);
const d = resp.data || resp;
sw.toast(`Restored ${d.rows_restored || 0} rows`, 'success');
load();
} catch (e) { sw.toast('Restore failed: ' + e.message, 'error'); }
finally { setRestoring(false); }
};
input.click();
}
function formatBytes(b) {
if (!b) return '0 B';
if (b < 1024) return b + ' B';
if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
if (b < 1073741824) return (b / 1048576).toFixed(1) + ' MB';
return (b / 1073741824).toFixed(2) + ' GB';
}
function formatDate(d) {
if (!d) return '\u2014';
return new Date(d).toLocaleString();
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
<div style="display:flex;gap:8px;margin-bottom:20px;flex-wrap:wrap;">
<button class="btn btn-primary" onclick=${() => createBackup(false)} disabled=${creating || restoring}>
${creating ? 'Creating\u2026' : 'Download Backup'}
</button>
<button class="btn" onclick=${() => createBackup(true)} disabled=${creating || restoring}>
${creating ? 'Creating\u2026' : 'Save to Server'}
</button>
<button class="btn btn-danger" onclick=${restoreBackup} disabled=${creating || restoring}>
${restoring ? 'Restoring\u2026' : 'Restore from File'}
</button>
</div>
<h3 style="margin:0 0 12px;">Server Backups</h3>
${backups.length === 0 ? html`
<p style="color:var(--text-secondary);">No server-side backups. Use "Save to Server" to create one.</p>
` : html`
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Size</th>
<th>Created</th>
<th style="width:120px;">Actions</th>
</tr>
</thead>
<tbody>
${backups.map(b => html`
<tr key=${b.name}>
<td><code>${b.name}</code></td>
<td>${formatBytes(b.size)}</td>
<td>${formatDate(b.created_at)}</td>
<td>
<button class="btn btn-sm" onclick=${() => downloadBackup(b.name)}>Download</button>
<button class="btn btn-sm btn-danger" onclick=${() => deleteBackup(b.name)} style="margin-left:4px;">Delete</button>
</td>
</tr>
`)}
</tbody>
</table>
`}
</div>
`;
}

View File

@@ -21,7 +21,7 @@ import { UserMenu } from '../../shell/user-menu.js';
const ADMIN_SECTIONS = { const ADMIN_SECTIONS = {
people: ['users', 'teams', 'groups'], people: ['users', 'teams', 'groups'],
workflows: ['workflows'], workflows: ['workflows'],
system: ['settings', 'storage', 'packages', 'connections', 'broadcast'], system: ['settings', 'storage', 'packages', 'connections', 'broadcast', 'backup'],
monitoring: ['audit'], monitoring: ['audit'],
}; };
@@ -30,6 +30,7 @@ const ADMIN_LABELS = {
workflows: 'Workflows', workflows: 'Workflows',
settings: 'Settings', storage: 'Storage', packages: 'Packages', settings: 'Settings', storage: 'Storage', packages: 'Packages',
connections: 'Connections', broadcast: 'Broadcast', connections: 'Connections', broadcast: 'Broadcast',
backup: 'Backup',
audit: 'Audit', audit: 'Audit',
}; };
@@ -67,6 +68,7 @@ const sectionModules = {
packages: () => import(`./packages.js${_v}`), packages: () => import(`./packages.js${_v}`),
connections: () => import(`./connections.js${_v}`), connections: () => import(`./connections.js${_v}`),
broadcast: () => import(`./broadcast.js${_v}`), broadcast: () => import(`./broadcast.js${_v}`),
backup: () => import(`./backup.js${_v}`),
audit: () => import(`./audit.js${_v}`), audit: () => import(`./audit.js${_v}`),
}; };

View File

@@ -0,0 +1,461 @@
/**
* DocsSurface — builtin documentation viewer (v0.6.2)
*
* Reads globals:
* __SECTION__ — active doc slug (e.g. "GETTING-STARTED")
* __BASE__ — base path
*
* Fetches markdown from GET /api/v1/docs/:name, renders with
* a simple markdown-to-HTML converter. Sidebar lists all docs.
*
* v0.6.2: dark mode fix, topbar navigation, error handling.
*/
const { html } = window;
const { useState, useEffect, useCallback, useMemo } = hooks;
const { render } = preact;
import { Topbar } from '../../shell/topbar.js';
function DocsSurface() {
const [docs, setDocs] = useState([]);
const [active, setActive] = useState(window.__SECTION__ || 'GETTING-STARTED');
const [content, setContent] = useState('');
const [title, setTitle] = useState('');
const [loading, setLoading] = useState(true);
const [listError, setListError] = useState(false);
// Load doc list
useEffect(() => {
if (!sw?.api?.get) return;
sw.api.get('/api/v1/docs').then(r => {
setDocs(Array.isArray(r) ? r : r.data || []);
setListError(false);
}).catch(() => { setListError(true); });
}, []);
// Load active doc
const loadDoc = useCallback(async (slug) => {
setLoading(true);
setActive(slug);
try {
const resp = await sw.api.get(`/api/v1/docs/${slug}`);
const d = resp.data || resp;
setTitle(d.title || slug);
setContent(d.content || 'Document not found.');
} catch (e) {
setContent('Failed to load document.');
}
setLoading(false);
// Update URL without reload
const base = window.__BASE__ || '';
history.replaceState(null, '', `${base}/docs/${slug}`);
}, []);
useEffect(() => { loadDoc(active); }, []);
function navigate(slug) {
if (slug === active) return;
loadDoc(slug);
// Scroll to top
const el = document.querySelector('.docs-content');
if (el) el.scrollTop = 0;
}
// Extract document outline from markdown content
const outline = useMemo(() => {
if (!content) return [];
const headings = [];
let inCode = false;
for (const line of content.split('\n')) {
if (line.startsWith('```')) { inCode = !inCode; continue; }
if (inCode) continue;
const m = line.match(/^(#{1,4})\s+(.+)/);
if (m) {
const text = m[2].replace(/[*_`\[\]]/g, '');
const id = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
headings.push({ level: m[1].length, text, id });
}
}
return headings;
}, [content]);
function scrollToHeading(id) {
const el = document.getElementById(id);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function retryList() {
setListError(false);
sw.api.get('/api/v1/docs').then(r => {
setDocs(Array.isArray(r) ? r : r.data || []);
}).catch(() => { setListError(true); });
}
return html`
<${Topbar} title="Documentation" />
<div class="docs-layout">
<nav class="docs-sidebar">
<h3 class="docs-sidebar__heading">Documentation</h3>
${listError ? html`
<div class="docs-error-inline">
<p>Failed to load docs list.</p>
<button onclick=${retryList} class="docs-retry-btn">Retry</button>
</div>
` : docs.map(d => html`
<a key=${d.slug}
class="docs-nav-item ${d.slug === active ? 'active' : ''}"
onclick=${() => navigate(d.slug)}
href="javascript:void(0)">
${d.title}
</a>
`)}
</nav>
<main class="docs-content">
${loading ? html`
<div class="docs-loading">Loading\u2026</div>
` : html`
<article class="docs-article"
dangerouslySetInnerHTML=${{ __html: renderMarkdown(content) }}>
</article>
`}
</main>
${!loading && outline.length > 2 ? html`
<aside class="docs-outline">
<h4 class="docs-outline__heading">On this page</h4>
${outline.map(h => html`
<a key=${h.id}
class="docs-outline__item docs-outline__item--h${h.level}"
onclick=${(e) => { e.preventDefault(); scrollToHeading(h.id); }}
href="#${h.id}">
${h.text}
</a>
`)}
</aside>
` : null}
</div>
`;
}
// ── Simple markdown renderer ─────────────────
// Handles: headings, code blocks, inline code, bold, italic, links, lists, paragraphs, tables, hr.
// Not a full CommonMark parser — good enough for documentation.
function renderMarkdown(md) {
if (!md) return '';
let html = '';
const lines = md.split('\n');
let i = 0;
let inCode = false;
let codeLang = '';
let codeLines = [];
let inList = false;
let listType = '';
let inTable = false;
let tableRows = [];
function esc(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function inline(s) {
s = esc(s);
// Links [text](url)
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
// Bold **text** or __text__
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
s = s.replace(/__(.+?)__/g, '<strong>$1</strong>');
// Italic *text* or _text_
s = s.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>');
// Inline code `code`
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
return s;
}
function flushTable() {
if (!inTable || tableRows.length === 0) return;
inTable = false;
html += '<table class="data-table"><thead><tr>';
const headers = tableRows[0];
for (const h of headers) html += `<th>${inline(h.trim())}</th>`;
html += '</tr></thead><tbody>';
for (let r = 2; r < tableRows.length; r++) {
html += '<tr>';
for (const cell of tableRows[r]) html += `<td>${inline(cell.trim())}</td>`;
html += '</tr>';
}
html += '</tbody></table>';
tableRows = [];
}
function flushList() {
if (!inList) return;
inList = false;
html += listType === 'ol' ? '</ol>' : '</ul>';
}
while (i < lines.length) {
const line = lines[i];
// Fenced code blocks
if (line.startsWith('```')) {
if (inCode) {
html += `<pre><code class="language-${esc(codeLang)}">${esc(codeLines.join('\n'))}</code></pre>`;
inCode = false;
codeLines = [];
codeLang = '';
} else {
flushList();
flushTable();
inCode = true;
codeLang = line.slice(3).trim();
}
i++;
continue;
}
if (inCode) {
codeLines.push(line);
i++;
continue;
}
// Table rows
if (line.includes('|') && line.trim().startsWith('|')) {
flushList();
const cells = line.split('|').slice(1, -1);
if (!inTable) inTable = true;
// Skip separator row (---|---)
if (cells.every(c => /^[\s:-]+$/.test(c))) {
tableRows.push(cells); // keep for row counting
} else {
tableRows.push(cells);
}
i++;
continue;
} else {
flushTable();
}
// Headings
const headingMatch = line.match(/^(#{1,6})\s+(.+)/);
if (headingMatch) {
flushList();
const level = headingMatch[1].length;
const text = headingMatch[2];
const id = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
html += `<h${level} id="${id}">${inline(text)}</h${level}>`;
i++;
continue;
}
// Horizontal rule
if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) {
flushList();
html += '<hr>';
i++;
continue;
}
// Unordered list
if (/^\s*[-*+]\s+/.test(line)) {
if (!inList || listType !== 'ul') {
flushList();
inList = true;
listType = 'ul';
html += '<ul>';
}
html += `<li>${inline(line.replace(/^\s*[-*+]\s+/, ''))}</li>`;
i++;
continue;
}
// Ordered list
if (/^\s*\d+\.\s+/.test(line)) {
if (!inList || listType !== 'ol') {
flushList();
inList = true;
listType = 'ol';
html += '<ol>';
}
html += `<li>${inline(line.replace(/^\s*\d+\.\s+/, ''))}</li>`;
i++;
continue;
}
flushList();
// Empty line
if (line.trim() === '') {
i++;
continue;
}
// Paragraph
html += `<p>${inline(line)}</p>`;
i++;
}
flushList();
flushTable();
return html;
}
// ── Styles ──────────────────────────────────
const style = document.createElement('style');
style.textContent = `
.docs-layout {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
gap: 0;
}
.docs-sidebar {
width: 220px;
min-width: 220px;
height: 100%;
padding: 20px 16px;
border-right: 1px solid var(--border);
background: var(--bg-secondary);
overflow-y: auto;
box-sizing: border-box;
}
.docs-sidebar__heading {
margin: 0 0 12px;
font-size: 14px;
color: var(--text-3);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.docs-nav-item {
display: block;
padding: 6px 10px;
margin: 2px 0;
border-radius: 6px;
color: var(--text);
text-decoration: none;
font-size: 13px;
cursor: pointer;
transition: background 0.15s;
}
.docs-nav-item:hover {
background: var(--bg-hover);
}
.docs-nav-item.active {
background: var(--accent-dim);
color: var(--accent);
font-weight: 600;
}
.docs-content {
flex: 1;
height: 100%;
padding: 24px 40px;
max-width: 800px;
overflow-y: auto;
box-sizing: border-box;
}
.docs-loading {
padding: 40px;
text-align: center;
color: var(--text-3);
animation: docs-pulse 1.5s ease-in-out infinite;
}
@keyframes docs-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.docs-error-inline {
padding: 12px;
color: var(--text-3);
font-size: 13px;
text-align: center;
}
.docs-retry-btn {
margin-top: 8px;
padding: 4px 12px;
background: var(--bg-hover);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 12px;
cursor: pointer;
}
.docs-retry-btn:hover { background: var(--bg-elevated); }
.docs-outline {
width: 200px;
min-width: 200px;
height: 100%;
padding: 20px 12px;
border-left: 1px solid var(--border);
overflow-y: auto;
box-sizing: border-box;
}
.docs-outline__heading {
margin: 0 0 10px;
font-size: 11px;
color: var(--text-3);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
.docs-outline__item {
display: block;
padding: 3px 8px;
margin: 1px 0;
border-radius: 4px;
color: var(--text-2);
text-decoration: none;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: color 0.15s, background 0.15s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.docs-outline__item:hover { color: var(--text); background: var(--bg-hover); }
.docs-outline__item--h1 { padding-left: 8px; font-weight: 600; color: var(--text); }
.docs-outline__item--h2 { padding-left: 8px; }
.docs-outline__item--h3 { padding-left: 20px; }
.docs-outline__item--h4 { padding-left: 32px; font-size: 11px; }
.docs-article h1, .docs-article h2, .docs-article h3, .docs-article h4 { scroll-margin-top: 16px; }
.docs-article h1 { font-size: 28px; margin: 0 0 16px; border-bottom: 1px solid var(--border); padding-bottom: 8px; color: var(--text); }
.docs-article h2 { font-size: 22px; margin: 24px 0 12px; color: var(--text); }
.docs-article h3 { font-size: 18px; margin: 20px 0 8px; color: var(--text); }
.docs-article p { margin: 8px 0; line-height: 1.6; color: var(--text); }
.docs-article pre {
background: var(--bg-code);
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px 16px;
overflow-x: auto;
font-size: 13px;
line-height: 1.5;
margin: 12px 0;
}
.docs-article code {
background: var(--bg-code);
padding: 2px 5px;
border-radius: 3px;
font-size: 0.9em;
color: var(--text);
}
.docs-article pre code { background: none; padding: 0; }
.docs-article ul, .docs-article ol { margin: 8px 0; padding-left: 24px; }
.docs-article li { margin: 4px 0; line-height: 1.5; color: var(--text); }
.docs-article hr { border: none; border-top: 1px solid var(--border); margin: 24px 0; }
.docs-article a { color: var(--accent); }
.docs-article .data-table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 13px; }
.docs-article .data-table th { text-align: left; padding: 8px 12px; border-bottom: 2px solid var(--border); color: var(--text); font-weight: 600; }
.docs-article .data-table td { padding: 6px 12px; border-bottom: 1px solid var(--border); color: var(--text-2); }
.docs-article .data-table tr:hover td { background: var(--bg-hover); }
.docs-article strong { color: var(--text); }
`;
document.head.appendChild(style);
// ── Mount ───────────────────────────────────
const mount = document.getElementById('docs-mount');
if (mount) {
mount.innerHTML = '';
render(preact.h(DocsSurface, null), mount);
}