Compare commits
1 Commits
v0.6.7
...
d2739aabd2
| Author | SHA1 | Date | |
|---|---|---|---|
| d2739aabd2 |
1178
CHANGELOG.md
1178
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -1,64 +0,0 @@
|
||||
# Contributing to Switchboard Core
|
||||
|
||||
## Development Setup
|
||||
|
||||
**Requirements:** Go 1.23+, Node.js 20+ (for frontend tests), SQLite (local dev).
|
||||
|
||||
**Build from source:**
|
||||
|
||||
```sh
|
||||
cd server
|
||||
go build -o switchboard-core .
|
||||
DB_DRIVER=sqlite DATABASE_URL=/tmp/switchboard.db ./switchboard-core
|
||||
```
|
||||
|
||||
**Docker (recommended):**
|
||||
|
||||
```sh
|
||||
docker compose up --build
|
||||
# open http://localhost:3000 (default login: admin / admin)
|
||||
```
|
||||
|
||||
Data persists in the `sb_data` named volume. Reset with `docker compose down -v`.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
server/ Go backend (handlers, store, config, auth, workflow engine)
|
||||
src/js/ Browser SDK and built-in surface JS
|
||||
packages/ Extension packages (surfaces, libraries, browser extensions)
|
||||
build.sh Builds each subdirectory into a .pkg archive
|
||||
docs/ Documentation markdown (served at /api/v1/docs)
|
||||
k8s/ Kubernetes manifests and Helm chart
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
**Backend:** `cd server && go test ./...`
|
||||
|
||||
**Frontend:** `node --test src/js/__tests__/`
|
||||
|
||||
## Code Conventions
|
||||
|
||||
- **Go:** Standard formatting via `gofmt`. Handlers in `server/handlers/`,
|
||||
persistence in `server/store/`. Database access goes through the store
|
||||
interface, never directly from handlers.
|
||||
- **Browser JS:** Vanilla ES modules + Preact via CDN. No build step for
|
||||
browser code (except the CM6 editor bundle). SDK lives at `src/js/sw/`.
|
||||
- **Extensions:** IIFE pattern (see `packages/*/js/script.js`). Register with
|
||||
`sw.renderers` or `sw.slots` via the `sw:ready` event.
|
||||
|
||||
## Creating a Package
|
||||
|
||||
A package is a ZIP archive (`.pkg`) containing `manifest.json` and optional
|
||||
`js/`, `css/`, `assets/`, and `script.star` files. The build script
|
||||
(`packages/build.sh`) automates this. See `docs/PACKAGE-FORMAT.md` for the
|
||||
full manifest spec and `docs/TUTORIAL-FIRST-EXTENSION.md` for a walkthrough.
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. Create a feature branch from `main`.
|
||||
2. Make your changes. Keep commits focused.
|
||||
3. Run both Go and JS test suites and confirm they pass.
|
||||
4. Submit a PR with a clear description of what changed and why.
|
||||
5. Address review feedback, then squash-merge when approved.
|
||||
18
Dockerfile
18
Dockerfile
@@ -4,8 +4,7 @@
|
||||
# Stage 1: Build Go backend
|
||||
# Stage 2: Download JS vendor libs (marked, DOMPurify)
|
||||
# Stage 3: Build CM6 editor bundle (esbuild)
|
||||
# Stage 4: Build bundled packages (.pkg archives)
|
||||
# Stage 5: Production image (nginx + backend)
|
||||
# Stage 4: Production image (nginx + backend)
|
||||
#
|
||||
# Vendor libs are baked in during build so the
|
||||
# app works in disconnected environments
|
||||
@@ -58,13 +57,7 @@ COPY scripts/build-editor.sh /build/scripts/build-editor.sh
|
||||
RUN cd /build/src/editor && npm ci --loglevel=warn
|
||||
RUN sh /build/scripts/build-editor.sh /build/dist
|
||||
|
||||
# ── Stage 4: Build bundled packages ─────────
|
||||
FROM alpine:3 AS packages
|
||||
RUN apk add --no-cache zip bash
|
||||
COPY packages/ /packages/
|
||||
RUN cd /packages && bash build.sh
|
||||
|
||||
# ── Stage 5: Production ─────────────────────
|
||||
# ── Stage 4: Production ─────────────────────
|
||||
FROM nginx:1-alpine
|
||||
|
||||
RUN apk add --no-cache bash git
|
||||
@@ -77,9 +70,6 @@ COPY --from=backend /app/database/migrations /app/database/migrations
|
||||
COPY src/ /usr/share/nginx/html/
|
||||
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
|
||||
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) && \
|
||||
@@ -95,8 +85,8 @@ COPY --from=vendor /vendor/katex/katex.min.css /usr/share/nginx/html/vendor/kate
|
||||
COPY --from=vendor /vendor/katex/fonts/ /usr/share/nginx/html/vendor/katex/fonts/
|
||||
COPY --from=cm6-build /build/dist/ /usr/share/nginx/html/vendor/codemirror/
|
||||
|
||||
# Bundled packages (v0.3.8) — auto-installed on first run
|
||||
COPY --from=packages /dist/ /app/bundled-packages/
|
||||
# Builtin extensions (seeded into DB on startup by backend)
|
||||
COPY extensions/builtin/ /app/extensions/builtin/
|
||||
|
||||
# nginx config (template — envsubst injects BASE_PATH at runtime)
|
||||
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
# ============================================
|
||||
# Switchboard Core — Builder Image
|
||||
# ============================================
|
||||
# Pre-caches Go modules and Node dependencies
|
||||
# for faster custom builds. Use as a base in
|
||||
# your own Dockerfile to skip dependency
|
||||
# download on every build.
|
||||
#
|
||||
# Usage:
|
||||
# FROM ghcr.io/switchboard-core/builder:latest AS go-builder
|
||||
# COPY server/ /app/
|
||||
# RUN cd /app && go build -o /bin/switchboard .
|
||||
#
|
||||
# Or build this image locally:
|
||||
# docker build -f Dockerfile.builder -t switchboard-builder .
|
||||
# ============================================
|
||||
|
||||
# ── Go module cache ─────────────────────────
|
||||
FROM golang:1.23-bookworm AS go-cache
|
||||
WORKDIR /cache
|
||||
COPY server/go.mod server/go.sum* ./
|
||||
RUN go mod download && go mod verify
|
||||
|
||||
# ── Node dependency cache ───────────────────
|
||||
FROM node:20-alpine AS node-cache
|
||||
WORKDIR /cache
|
||||
|
||||
# Editor bundle dependencies
|
||||
COPY src/editor/package*.json ./editor/
|
||||
RUN cd editor && npm ci --loglevel=warn
|
||||
|
||||
# Vendor libs (same versions as production Dockerfile)
|
||||
RUN npm pack marked@16.3.0 && \
|
||||
npm pack dompurify@3.2.4 && \
|
||||
npm pack mermaid@11.4.1 && \
|
||||
npm pack katex@0.16.11 && \
|
||||
mkdir -p /cache/vendor-tarballs && \
|
||||
mv *.tgz /cache/vendor-tarballs/
|
||||
|
||||
# ── Final builder image ────────────────────
|
||||
FROM golang:1.23-bookworm
|
||||
|
||||
# Pre-install build tools
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
zip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Go module cache (populated)
|
||||
COPY --from=go-cache /go/pkg/mod /go/pkg/mod
|
||||
|
||||
# Node dependencies (for editor bundle builds)
|
||||
COPY --from=node-cache /cache/editor/node_modules /cache/editor/node_modules
|
||||
|
||||
# Vendor lib tarballs (avoid re-download)
|
||||
COPY --from=node-cache /cache/vendor-tarballs /cache/vendor-tarballs
|
||||
|
||||
# Node.js for frontend builds
|
||||
COPY --from=node-cache /usr/local/bin/node /usr/local/bin/node
|
||||
COPY --from=node-cache /usr/local/lib/node_modules /usr/local/lib/node_modules
|
||||
RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
LABEL org.opencontainers.image.title="Switchboard Core Builder"
|
||||
LABEL org.opencontainers.image.description="Pre-cached build dependencies for faster custom Switchboard Core builds"
|
||||
19
README.md
19
README.md
@@ -22,21 +22,12 @@ those are all extension packages.
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Docker (recommended)
|
||||
docker compose up --build
|
||||
# → http://localhost:3000 (admin/admin)
|
||||
|
||||
# Or from source
|
||||
git clone <repo-url> && cd switchboard-core
|
||||
cp server/.env.example server/.env # edit DB credentials
|
||||
cd server && go run .
|
||||
# → http://localhost:8080
|
||||
```
|
||||
|
||||
Bundled packages (workflows, surfaces, task manager) are auto-installed on
|
||||
first boot. See [Distribution Guide](docs/DISTRIBUTION.md) for production
|
||||
deployment and customization.
|
||||
|
||||
## Kernel Features
|
||||
|
||||
- **Auth**: Builtin password, mTLS (client cert), OIDC (Keycloak et al.)
|
||||
@@ -52,19 +43,15 @@ deployment and customization.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Distribution Guide](docs/DISTRIBUTION.md) — Docker, bundled packages, builder image, production deployment
|
||||
- [Architecture](docs/ARCHITECTURE.md) — kernel components and design reasoning
|
||||
- [Roadmap](ROADMAP.md) — current status and planned milestones
|
||||
- [Changelog](CHANGELOG.md) — version history
|
||||
|
||||
## Project Status
|
||||
|
||||
**v0.5.0** — Realtime pub/sub primitive, dialog audit, and admin permissions
|
||||
UI. Extensions can now publish events to WebSocket channels via Starlark;
|
||||
clients subscribe with `sw.realtime.subscribe()`. Admin Packages page gains
|
||||
per-permission grant/revoke controls and status badges. See
|
||||
[ROADMAP.md](ROADMAP.md) for the full journey from v0.1.0 kernel extraction
|
||||
through v0.3.x workflows, v0.4.x Notes surface, to v0.5.x realtime and chat.
|
||||
**v0.1.0** (in progress) — kernel extracted from chat-switchboard v0.38.5.
|
||||
~44K lines of chat/AI code removed, 27 kernel tables, 20 store interfaces.
|
||||
See [ROADMAP.md](ROADMAP.md) for details.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
251
ROADMAP.md
251
ROADMAP.md
@@ -1,182 +1,177 @@
|
||||
# Switchboard Core — Roadmap
|
||||
|
||||
## Current: v0.6.7 — Native mTLS
|
||||
## Current: v0.2.0 — SDK & Triggers
|
||||
|
||||
Self-hosted extensible platform. Auth, identity, packages, Starlark sandbox,
|
||||
storage, realtime, and ops are kernel primitives. Everything else is an extension.
|
||||
Fork of chat-switchboard, gutted to a pure extension platform. All AI/chat
|
||||
features removed from the kernel. What remains is the minimum viable
|
||||
platform that extensions build on.
|
||||
|
||||
**Kernel capabilities:** Auth (builtin/mTLS/OIDC) · Users/teams/groups/RBAC ·
|
||||
Surfaces/extensions/libraries/workflows · Starlark sandbox (capability-gated) ·
|
||||
Object storage (PVC/S3) + ext_data tables · WebSocket hub + realtime pub/sub ·
|
||||
Audit log · Notifications · Scheduled tasks
|
||||
### Retained kernel capabilities
|
||||
|
||||
**Completed history:** v0.2.x–v0.5.x fully documented in `CHANGELOG.md`.
|
||||
Highlights: RBAC + settings cascade, event bus + triggers, SDK stabilization,
|
||||
workflow engine (multi-stage, team roles, signoff gate, public entry, SLA),
|
||||
package distribution, Notes surface (CM6, folders, tags, backlinks, graph),
|
||||
realtime primitive, Chat surface (chat-core library + surface + polish),
|
||||
upgrade test harness, cluster registry + HA.
|
||||
- **Auth**: builtin (simple), mTLS, OIDC
|
||||
- **Identity**: users, teams, groups, permissions (RBAC)
|
||||
- **Packages**: surfaces, extensions, libraries, workflows
|
||||
- **Starlark sandbox**: capability-gated modules
|
||||
- **Storage**: object storage (PVC, S3), ext_data tables
|
||||
- **Realtime**: WebSocket hub, presence, multi-replica HA
|
||||
- **Ops**: audit log, notifications, maintenance goroutine
|
||||
|
||||
---
|
||||
|
||||
## v0.6.0 — MVP
|
||||
|
||||
Extension, communication, and operations tracks converge. First
|
||||
externally usable release.
|
||||
|
||||
Design docs: `docs/DESIGN-cluster-registry.md` — PG-backed cluster registry and self-assembling mesh.
|
||||
|
||||
### v0.6.0 — Cluster Registry + HA
|
||||
|
||||
PG is the consensus layer. Zero new infrastructure. `UNLOGGED` table + `LISTEN/NOTIFY` replaces etcd/Consul/Redis for homelab-to-small-team scale.
|
||||
### Phase 0 (complete)
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| `node_registry` table | ✅ | `UNLOGGED TABLE` — node_id, endpoint, seq, registered_at, heartbeat, stats JSONB. Postgres migration 013. |
|
||||
| Node registration | ✅ | Self-registration on startup: `INSERT ... ON CONFLICT DO UPDATE`. `node_id` = `hostname-PID` or `CLUSTER_NODE_ID` env override. |
|
||||
| Heartbeat tick | ✅ | Every 10s: update own heartbeat + collect runtime stats (goroutines, heap, GC, uptime, ws_clients). |
|
||||
| Stale sweep | ✅ | Every heartbeat tick: `DELETE WHERE heartbeat < now() - 30s`. All nodes run it — idempotent, no ring topology. |
|
||||
| Self-eviction | ✅ | If heartbeat UPDATE returns 0 rows: node was swept by peer → log error + `os.Exit(1)`. K8s restarts → re-register. |
|
||||
| LISTEN/NOTIFY routing | ✅ | Durable events (messages, state changes) fan-out via `pg_notify`. All replicas receive, push to local WS subscribers, drop if irrelevant. Phase 1: ephemeral events (typing, presence) also via NOTIFY (8KB limit, ~60 bytes each). |
|
||||
| Cluster API | ✅ | `GET /api/v1/admin/cluster` — returns `{data: [...]}` envelope with all registered nodes. |
|
||||
| Admin cluster dashboard | ✅ | `cluster-dashboard` surface package renders one card per node: node_id, endpoint, uptime, ws_clients, heap, GC pause. JSONB stats — future keys render automatically, no schema migration. Auto-refresh every 10s. |
|
||||
| Health endpoint | ✅ | `GET /health` includes `node_id` and `cluster: {size, peers, heartbeat_age_ms}`. |
|
||||
| Config | ✅ | `CLUSTER_NODE_ID`, `CLUSTER_HEARTBEAT_INTERVAL` (default 10s), `CLUSTER_STALE_THRESHOLD` (default 30s), `CLUSTER_ENDPOINT` (Phase 2 mesh, auto-detect). |
|
||||
| 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. |
|
||||
| 1. Module rename | ✅ | `chat-switchboard` → `switchboard-core` |
|
||||
| 2. Delete packages | ✅ | 15 Go packages, 29 handler files removed |
|
||||
| 3. Gut stores/models | ✅ | 40 → 20 store interfaces, kernel-only models |
|
||||
| 4. Fresh migrations | ✅ | 9 files × 2 dialects, 27 tables |
|
||||
| 5. Fix compilation | ✅ | `go build ./...` clean, 300+ stale route lines cut |
|
||||
| 6. Fix tests | ✅ | 8 test packages pass, ~12K stale test lines pruned |
|
||||
| 7. Frontend gut | ✅ | Shell + SDK only, 50+ files of chat/notes/projects code removed |
|
||||
| 8. New ICD | ✅ | Full OpenAPI 3.0.3 spec — 160 operations across 22 tag groups |
|
||||
| 9. CI/CD + Dockerfile | ✅ | Single unified image, FE/BE split removed, DB names updated, k8s var alignment fixes (resource quantities, image name, rollout deployment name) |
|
||||
| 10. Smoke test | ✅ | K8s deploy live at switchboard.gobha.ai/test, nginx BASE_PATH fixed, login→admin flow verified, branding updated |
|
||||
|
||||
### v0.6.1 — Backup/Restore + Documentation
|
||||
## v0.2.x — SDK & Triggers
|
||||
|
||||
The contract that extensions build against. Three trigger primitives,
|
||||
SDK stabilization, and the first rebuilt extension (tasks).
|
||||
|
||||
### v0.2.0 — RBAC + Settings Cascade (complete)
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| 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). |
|
||||
| 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`. |
|
||||
| Admin → RBAC group | ✅ | `surface.admin.access` permission + Admins system group replaces `role == "admin"` checks. Admin bypass removed from permission middleware. |
|
||||
| Settings cascade | ✅ | `user_overridable` flag, three-tier resolution (global → team → user), team settings API |
|
||||
| ~~Settings override model~~ | ✅ | Shipped with settings cascade above |
|
||||
|
||||
### v0.6.2 — Docs Polish + Dynamic OpenAPI
|
||||
### v0.2.1 — Default Surface + ICD
|
||||
|
||||
| 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. |
|
||||
| Default surface routing | ✅ | `/` redirects to configurable default surface. No surfaces → admin. First install becomes default. Changeable in admin settings. |
|
||||
| ICD (API contract) | ✅ | Full OpenAPI 3.0.3 spec — 160 operations, 22 tag groups, reusable component schemas. Served at `/api/docs`. |
|
||||
|
||||
---
|
||||
|
||||
## v0.6.x — Hardening
|
||||
|
||||
Closes every audit finding before the public release. No new features — only correctness, dead code elimination, and architectural cleanup. Sequence is fixed: each version is a gate for the next.
|
||||
|
||||
### v0.6.3 — Dead Code Sweep + Registry Fix
|
||||
|
||||
Pure cleanup. No behavior changes except fixing the broken registry install flow.
|
||||
### v0.2.2 — Event Bus + Triggers
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| Fix registry install | ✅ | SDK sends `{ url }`, handler expects `{ download_url }`. Fix `api-domains.js` to send `{ download_url: url }`. Every Install click currently returns 400. |
|
||||
| Registry settings UI | ✅ | Add "Package Registry" section to admin settings with URL input field. Only way to configure registry today is a raw `PUT /api/v1/admin/settings/package_registry` — no user will find it. |
|
||||
| Registry tooling + docs | ✅ | `scripts/generate-registry.sh` scans a directory of `.pkg` files and emits registry JSON. `docs/PACKAGE-REGISTRY.md` documents the format. |
|
||||
| Delete dead kernel Go | ✅ | `store/interfaces.go:178–183` — orphaned ChannelListFilter comments. `pages/pages.go:922–930` — `roleFilterType()` + template registration (chat vestige, maps nonexistent roles). `main.go:67` — orphaned provider-type comment. |
|
||||
| Delete dead vendor JS | ✅ | `vendor/marked.min.js` and `vendor/purify.min.js` — 62KB, zero production imports. Only referenced in test helpers. |
|
||||
| Delete `dev.html` | ✅ | 676 lines, not imported by anything. Dead. |
|
||||
| Remove `dashboard` from default bundle | ✅ | Requires `legacy-sdk` (doesn't exist), auto-installs as dormant on fresh installs. Confusing. Remove from `defaultBundledPackages`. |
|
||||
| Remove `hello-dashboard` | ✅ | Proof-of-concept from early development. Move to `examples/` or delete. |
|
||||
| Strip stale version comments | ✅ | 60+ files had legacy version annotations (`// v0.29.x:`, `// v0.33.x:`). Single sed pass. |
|
||||
| Narrow default bundle | ✅ | Default: `notes`, `chat`, `chat-core`, `mermaid-renderer`, `schedules`. Everything else available via registry or `BUNDLED_PACKAGES=*`. |
|
||||
| Event bus subscriptions | ✅ | Extensions register event patterns in manifest. Wired via `bus.Subscribe()` on startup. Async handler invocation. |
|
||||
| Webhook triggers | ✅ | Inbound HTTP at `/api/v1/hooks/:package_id/:slug`. HMAC-SHA256 verification. Synchronous Starlark handler response. |
|
||||
| Scheduled tasks | ✅ | User-created cron tasks with restricted sandbox (no raw HTTP, no DB table creation). Runs as creator identity. Templates from extensions. Dedicated schedules API. |
|
||||
| Trigger admin API | ✅ | CRUD for triggers + schedules. Enable/disable, execution logs, per-package listing. |
|
||||
|
||||
### v0.6.4 — Admin Health/Metrics Tab + Cluster Merge
|
||||
|
||||
Structural move: cluster dashboard becomes an Admin tab. Better home for health/metrics — shared context with other admin panels, no separate nav entry.
|
||||
### v0.2.3 — SDK + Task Extension
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| "Health / Metrics" admin tab | ✅ | New tab in Admin surface. DB-agnostic metrics for all deployments. Cluster cards conditional on PG + multi-node detection. |
|
||||
| Runtime metrics | ✅ | Per-node: goroutines, heap alloc/sys, stack in use, GC cycles, last GC pause, GC CPU %, uptime, WebSocket clients, extensions loaded, open FDs. |
|
||||
| DB pool metrics | ✅ | All deployments: DB latency (`SELECT 1` round-trip), pool active/idle/max, wait count, wait duration. PG-only: table bloat (`n_dead_tup`), active backends (`pg_stat_activity`). |
|
||||
| Cluster metrics | ✅ | PG multi-node only: cluster size, peer list with endpoint + uptime, heartbeat age per node, event bus publish/deliver rates. |
|
||||
| Extension runtime metrics | ✅ | Starlark exec/min, errors/min, avg duration, HTTP outbound requests/min, trigger fires/min, schedule overruns. |
|
||||
| Fatten heartbeat payload | ✅ | Heartbeat JSONB carries full metric set. `GET /api/v1/admin/metrics` for single-node SQLite fallback (same shape). |
|
||||
| Retire `cluster-dashboard` | ✅ | Remove package once Admin Health tab ships. Update `defaultBundledPackages`. |
|
||||
| Fix block renderer `requires` | ✅ | `mermaid-renderer`, `katex-renderer`, `csv-table`, `diff-viewer` all have `"requires": ["chat"]`. These are content renderers, not chat features. Remove constraint — they should activate without chat. |
|
||||
| Health endpoint consolidation | ✅ | `/health` and `/api/v1/health` return near-identical JSON. Merge or clearly differentiate with docs. |
|
||||
| SDK stabilization | ✅ | `sw.api.ext()`, `sw.storage`, `sw.theme.tokens`, `sw.ui`, `sw.slots`, `sw.actions` — six new SDK modules for extension development |
|
||||
| Task extension | ✅ | Full task surface rebuilt as Starlark extension: CRUD API, kanban/list views, event triggers, webhook integration, notifications on completion |
|
||||
|
||||
### v0.6.5 — Renderer Pipeline + Docs Rewrite
|
||||
|
||||
Most complex sub-version. Lifts block rendering to a kernel SDK primitive so all surfaces share it, then rewrites the docs for an external audience.
|
||||
### v0.2.4 — Shell Navigation + Schedules
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| SDK renderer primitive | ✅ | `sw.renderers.register(pattern, handler)` — kernel-level registration. Extensions call once; all surfaces consume. Decouples renderer discovery from chat surface. |
|
||||
| Notes hooks SDK renderer pipeline | ✅ | Notes delegates to `sw.markdown.renderSync()` + `sw.renderers.runPostRenderers()`. Hand-rolled renderer deleted. |
|
||||
| Docs hooks SDK renderer pipeline | ✅ | Docs delegates to `sw.markdown.renderSync()` + `sw.renderers.runPostRenderers()`. Hand-rolled renderer deleted (~160 lines). |
|
||||
| Unify markdown renderer | ✅ | `sw.markdown` module lazy-loads `marked` v16 (vendored). Notes, Docs, Chat all consume it. Two hand-rolled parsers deleted. |
|
||||
| Sanitize HTML output | ✅ | DOMPurify wired as default post-render step in `sw.markdown`. SVG-safe config allows mermaid output. Notes sanitizes; Docs opts out (system-authored). |
|
||||
| Mermaid/KaTeX/CSV/Diff work everywhere | ✅ | Browser extension loader injects renderer scripts into all pages. Extensions register via `sw:ready` event. All four render in Notes, Docs, and Chat. |
|
||||
| Docs rewrite for external audience | ✅ | All fork references removed from docs, CHANGELOG, ROADMAP. DESIGN-WORKFLOW-REDESIGN-0.2.6.md replaced with DESIGN-WORKFLOWS.md. |
|
||||
| Add Mermaid architecture diagrams | ✅ | Six diagrams in ARCHITECTURE.md: system overview, request flow, extension lifecycle, realtime events, settings cascade, cluster topology. |
|
||||
| Surface alias decision | ✅ | Migrated SDK + ICD runner to `/admin/packages/`; aliases removed from main.go. |
|
||||
| `CONTRIBUTING.md` + tutorial | ✅ | CONTRIBUTING.md at repo root + docs/TUTORIAL-FIRST-EXTENSION.md walkthrough. |
|
||||
| SDK Topbar | ✅ | `sw.shell.Topbar` — composable navigation bar (title + extension slot + bell + user menu). Surfaces get consistent nav for free. |
|
||||
| Schedules surface | ✅ | New `packages/schedules/` wrapping kernel cron API. Table view, cron preview, enable/disable, manual run, execution logs. |
|
||||
| Manifest icons | ✅ | `icon` field in manifest.json (emoji). Surfaces API returns icon. UserMenu renders per-surface icons. |
|
||||
| UserMenu cleanup | ✅ | Removed dead Chat/Notes/Projects links. Menu driven by surfaces API. Core surfaces filtered. |
|
||||
| isAdmin RBAC fix | ✅ | `can.js` isAdmin() now checks `surface.admin.access` grant instead of deprecated role column. |
|
||||
|
||||
### v0.6.7 — Native mTLS
|
||||
|
||||
End-to-end mutual TLS without a reverse proxy. Targets systemd+podman deployments where every connection (client→server, node→node) is mTLS. Design: `docs/DESIGN-native-mtls.md`.
|
||||
### v0.2.5 — UI Polish + Dead Code Audit
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| `TLS_MODE` config | ✅ | Three values: `none` (default, plain HTTP) · `server` (TLS, no client cert) · `mtls` (mutual TLS, client cert required). Independent of `AUTH_MODE`. |
|
||||
| TLS server mode | ✅ | Go binary calls `ListenAndServeTLS` directly. `TLS_CERT`, `TLS_KEY`, `TLS_CA` path config. TLS 1.3 minimum, no fallback. `server/config/tls.go` loader + validation. |
|
||||
| `MTLSNativeProvider` | ✅ | Reads `r.TLS.PeerCertificates[0]` — no header trust. `Subject.CommonName` → username. `sha256(Raw)` → `external_id`. Auto-provisions `auth_source=mtls`. Renamed existing `mtls.go` → `mtls_proxy.go`. Shared helpers in `mtls_helpers.go`. |
|
||||
| Node-to-node mTLS | ✅ | `BuildPeerTLSConfig()` constructs outbound TLS config with node cert + CA pool. Forward-looking — cluster registry is DB-backed (PG LISTEN/NOTIFY), no HTTP peer calls yet. |
|
||||
| `switchboard-ca.sh` | ✅ | Shell wrapper around openssl. Three commands: `init` (CA keypair) · `issue-node --name <n> --san <addrs>` (365d) · `issue-user --cn <name>` (90d). All output PEM. |
|
||||
| Unit tests | ✅ | Ephemeral CA via `crypto/x509`. Fabricated `PeerCertificates`. Valid cert → user provisioned · no TLS → `ErrNoCert` · empty certs → `ErrNoCert` · no CN → `ErrInvalidCreds`. |
|
||||
| Integration tests | ✅ | Real TLS listener on localhost. No cert / wrong CA / expired → TLS handshake rejected. Valid cert → 200. Peer certificate CN + email visibility verified. |
|
||||
| UI bug pass | ✅ | Reviewed admin, settings, login, welcome surfaces in light/dark themes. Fixed settings crash (models API undefined). Removed dead chat settings from both user and admin settings. |
|
||||
| Dead code sweep | ✅ | Removed ~700 lines: orphaned CSS, dead Go types, stale event code, unused test helpers, dead settings UI (chat defaults, system prompt, default model, policies, web search, compaction, memory). |
|
||||
| Template cleanup | ✅ | Removed stale CSS link tags and orphaned chat-pane.html. Updated doc comments throughout. |
|
||||
| Package proof-of-concept status | ✅ | Created README.md for tasks + schedules with graduation criteria. Added missing manifest icons. |
|
||||
| Welcome surface | ✅ | New fallback surface when no extensions installed. Topbar + welcome card with admin link. Replaces `/admin` as final redirect target. |
|
||||
| Default surface routing | ✅ | Resolution chain: user preference → global config → first extension → `/welcome`. Users can set personal default in Settings > General. Admin sets global default in Admin > Settings. |
|
||||
| Admin navigation | ✅ | Replaced Back button with UserMenu in admin topbar. Eliminates back-button infinite loop. |
|
||||
|
||||
### v0.6.6 — Final Hardening
|
||||
|
||||
Final pass before public release. Security, correctness, and developer experience.
|
||||
### v0.2.6 — Admin Settings Audit
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| Extension dependency auto-activation | ✅ | Installing a package with unmet `depends`/`requires` auto-installs dependencies from the bundled set. If not bundled: clear error listing what's missing. |
|
||||
| `ValidateManifest()` gate | ✅ | Single `ValidateManifest()` function in `package_validate.go`. Called at install time (both upload and bundled). 12 unit tests. |
|
||||
| Package signing hook | ✅ | Optional `signature` field in manifest (reserved). `PACKAGE_VERIFY_SIGNATURES` env var (default false, log-only). No cryptographic code yet — schema slot reserved. |
|
||||
| OIDC state nonce validation | ✅ | `oidcClaims.Nonce` field added. `ValidateIDTokenNonce()` compares ID token nonce against stored state. Callback rejects mismatched nonces. |
|
||||
| Schema migration stub decision | ✅ | Stub replaced with log-only function documenting additive-only policy. Downgrade rejection preserved. |
|
||||
| ICD/SDK runner update pass | ✅ | ICD smoke tier: added metrics, cluster, backups, docs, OpenAPI JSON endpoints. SDK admin domain: added metrics, cluster, backups tests. |
|
||||
| Stale TODO resolution | ✅ | `main.go` session middleware: replaced with `OptionalAuth()` (auth if token present, anonymous pass-through). `auth.go:221` OIDC nonce: resolved. `starlark_helpers.go:96` migration stub: resolved. |
|
||||
| Admin settings E2E | ✅ | All 7 admin settings sections verified (default surface, registration, banner, message bar, footer, vault, email). Dead code removed: `sectionCategory()` pruned of AI/routing/channel vestiges, `PublicSettings()` stripped of chat-era fields (system_prompt, retention_ttl, paste_to_file, allow_user_personas), dead `PolicyDefaults` removed (allow_raw_model_access, default_model), dead policy lookups removed (kb_direct_access), test seed data cleaned. |
|
||||
| Packages surface | ✅ | Package list loads (17 total/17 enabled), type filters work, enable/disable visible, settings/export buttons present, core package (admin) protected. Removed dead `chat` from CORE_IDS. |
|
||||
|
||||
Then ship.
|
||||
### v0.2.7 — User Settings Audit
|
||||
|
||||
---
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| User settings E2E | ✅ | All 6 user settings sections verified (General, Appearance, Profile, Teams, Connections, Notifications). Dead code removed: BYOK nav section + state, personas gate filter, Message Font Size slider, `auth.permissions.changed` listener. localStorage key renamed `cs-appearance` → `sb-appearance` with one-time migration. |
|
||||
| Visibility gating | ✅ | Dead BYOK/personas policy lookups removed from bootstrap and permissions handlers. `allow_user_byok` removed from `PublicSettings`. `PolicyDefaults` cleaned of `allow_user_byok` and `allow_user_personas`. Dead `msgFont` early-apply removed from base template. Stale policy-gating test assertions replaced. No empty nav sections remain. |
|
||||
|
||||
### v0.2.8 — Team Admin Settings Audit (Pass 1)
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| Team admin E2E | ✅ | Audited team member management, settings cascade, role assignment. Removed dead code: `HasPrivateProviderRequirement` (BYOK vestige), `UserRole` on `TeamMember` (deprecated role column), `allow_team_providers` policy default, dead personas/providers/models ICD tests. |
|
||||
| Workflow stage UI cleanup | ✅ | Removed dead personas dropdown, `history_mode` selector, stale `chat_only` mode. Updated `STAGE_MODES` to match backend CHECK constraint (`form_only`, `form_chat`, `review`, `custom`). Removed stale comments referencing deleted files. |
|
||||
|
||||
### v0.2.9 — Builtin Extension Retirement
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| Retire builtin seeder | ⬚ | Stop auto-seeding chat-centric extensions (csv-table, diff-viewer, js-sandbox, katex-renderer, mermaid-renderer, regex-tester). Remove `SeedBuiltinPackages`, Dockerfile COPY, and `extensions/builtin/` directory. These extensions are not OBE — they're dormant until a chat surface exists to consume them. |
|
||||
| Convert to regular packages | ⬚ | Repackage the 6 extensions as standard `.pkg` archives in `packages/`. Add chat dependency metadata to manifests so they can be installed when the chat extension ships post-MVP. No auto-install — explicit install only, matching the distribution model. |
|
||||
|
||||
## v0.3.x — Workflow Architecture
|
||||
|
||||
Workflows are the core platform capability. This series implements the
|
||||
full multi-step automation system with team role integration and
|
||||
finalizes the extension lifecycle model.
|
||||
|
||||
### v0.3.0 — Workflow Design + Schema
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| Extension lifecycle | ⬚ | Define permanent vs PoC extensions. Package graduation criteria. Dependency policy. What ships with core vs what's installed separately. |
|
||||
| Workflow design session | ⬚ | Define what "workflow" means in the extension-first model. Determine if the existing `workflows` table/handler survives, gets rebuilt, or gets removed. Document the Starlark contract for multi-step automation. See `docs/DESIGN-WORKFLOW-REDESIGN-0.2.6.md`. |
|
||||
| Team roles | ⬚ | Different roles per team responsible for different workflow stages. Role-based stage assignment, multi-party validation (2-party sign-off at stage boundaries). |
|
||||
| Trigger composition model | ⬚ | How do triggers, schedules, and workflows compose? Event chains, conditional branching, error handling. Design doc before code. |
|
||||
| Settings audit pass 2 | ⬚ | Focused audit of team admin + user settings for workflow/team-role changes applied in this series. Validates new team role UI, stage assignment settings, workflow preferences. |
|
||||
|
||||
## v0.4.0 — Notes Surface
|
||||
|
||||
Obsidian-style rich-text notes rebuilt as an installable surface package.
|
||||
Zero platform special-casing. Proves the full extension stack E2E.
|
||||
|
||||
- Notes as `.pkg` archive
|
||||
- Rich text editor (ProseMirror or similar)
|
||||
- Folder tree, backlinks, tags — all extension-provided
|
||||
- Markdown import/export
|
||||
|
||||
## v0.5.0 — MVP
|
||||
|
||||
Extension and operations tracks converge. First externally usable release.
|
||||
|
||||
- Package registry (browse, install, update, uninstall)
|
||||
- Package distribution model (no auto-install; explicit install only)
|
||||
- Health monitoring dashboard
|
||||
- Backup/restore tooling
|
||||
- Documentation site
|
||||
|
||||
## Post-MVP
|
||||
|
||||
- LLM participation (`llm-bridge` extension: subscribes to `chat.message.created`, calls `provider.complete()`, streams response via `realtime.publish`, posts via `chat.send()`. Bot participants with persona config. Multi-model conversations.)
|
||||
- Chat extension (provider registry, streaming, personas, tool system)
|
||||
- Rich media extensions: image generation, code sandbox, STT/TTS
|
||||
- Desktop app (Tauri or Electron)
|
||||
- Sidecar tier: container-based extensions
|
||||
- Federation: cross-instance package sharing
|
||||
- Plugin marketplace with signing and review
|
||||
|
||||
---
|
||||
- Mermaid diagrams extension (nice-to-have)
|
||||
|
||||
## Design Decisions Log
|
||||
|
||||
| Decision | Rationale |
|
||||
|----------|-----------|
|
||||
| Tasks → extension | Scheduler was the most entangled kernel component (~3,400 lines). Rebuilding as extension validates the trigger system and removes the worst compilation debt. Three trigger primitives (time, webhook, event) replace the monolithic scheduler. |
|
||||
| Sessions removed | Kernel-managed sessions replaced by workflow instances with dedicated storage (ext_data tables or kernel table). |
|
||||
| `custom` stage mode | Stage mode `custom` delegates to a surface package, proving extension composability. Chat-in-workflow is handled by the chat extension, not the kernel. |
|
||||
| Sessions removed | Channel-based sessions coupled to deleted chat system. Workflow instances need new storage model — either ext_data tables or a dedicated kernel table. |
|
||||
| `chat_only` → `custom` | Stage mode `chat_only` implied chat as a kernel concept. Renamed to `custom` which delegates to a surface package, proving extension composability. |
|
||||
| Providers removed from kernel | Provider configs, model catalog, routing policies — all moved to extension track. Kernel provides credential storage (connections) and the Starlark `provider.complete` module as the interface. |
|
||||
| Kernel permissions simplified | 6 platform permissions. Extensions define their own capability requirements in manifests. |
|
||||
| Kernel permissions simplified | From 16 chat-centric permissions to 6 platform permissions. Extensions define their own capability requirements in manifests. |
|
||||
| Preact+htm retained | 3KB runtime, no build step, works for extension authors without bundler config. KISS. |
|
||||
| Single Docker image | Drop the frontend/backend split. Go binary + assets + migrations in one image. Simpler deployment, fewer moving parts. |
|
||||
| Admin → RBAC group | The `role` column is pre-RBAC. v0.2.0 replaces it with a seeded "Admins" group + `surface.admin.access` grant. All users auto-join "Everyone" group. Admin middleware becomes a grant check, not a role check. |
|
||||
@@ -184,10 +179,6 @@ Then ship.
|
||||
| No new migrations pre-MVP | Edit existing migration SQL files in place. No migration chains until schema is in production. |
|
||||
| Notes over Editor | First surface is Obsidian-style notes (rich text, folders, backlinks) instead of a code editor. Notes is a stronger E2E proof — it exercises ext_data, storage, and the SDK more fully than a pure-browser CM6 editor. |
|
||||
| No built-in auto-install | Extensions ship in the repo but are not auto-installed. Distribution model TBD — explicit install only. Keeps the kernel clean and avoids opinionated defaults. |
|
||||
| Chat as extension, not kernel | Human-to-human chat built entirely as library + surface packages. Zero kernel awareness of conversations, messages, or participants. Kernel gains one generic `realtime` module. Proves near-infinite extensibility. LLM participation layers on top via a separate bridge extension — the chat system doesn't know or care whether a participant is human or AI. |
|
||||
| PG as consensus layer | Horizontal scaling uses PG as the sole coordinator (UNLOGGED node_registry + LISTEN/NOTIFY). No etcd, Consul, Redis, or Raft. Rationale: system is already tightly coupled to PG; adding a second consensus layer doubles operational complexity for zero benefit at homelab-to-small-team scale. UNLOGGED table is visible to all connections, survives session disconnect, and cleans up automatically on PG crash — correct behavior since all nodes are dead anyway. Sweep-all for health (every node deletes stale rows) is simpler than ring topology and has no edge cases. |
|
||||
| Chat → post-MVP | Chat extension (providers, streaming, personas) is valuable but not MVP-critical. The platform must prove itself with simpler surfaces first. Chat moves to post-MVP track. |
|
||||
| Two trigger tiers | Event + webhook triggers are extension-declared (manifest contract, full sandbox). Scheduled tasks are user-created ad-hoc (restricted sandbox — no raw HTTP, no DB table creation, connections-only outbound). Separation keeps extension contracts static and user automation safe. |
|
||||
| Scheduled task identity | Tasks run as their creator (RBAC-scoped). Admin-created tasks can opt into system context. Creator deactivation pauses the schedule. Ensures audit trail and permission boundaries. |
|
||||
| Builtin package rationale | A builtin must enhance kernel surfaces or be required to demonstrate the platform's own capabilities. **notes** — primary content surface, exercises ext_data/storage/SDK/settings/realtime fully. **chat + chat-core** — primary communication surface, proves the extensibility thesis (100% extension, zero kernel awareness). **mermaid-renderer** — docs surface uses Mermaid diagrams to explain the architecture; without it the platform's own documentation doesn't render (self-bootstrapping). **schedules** — UI for the kernel's scheduled task system; without it users can't manage cron jobs (kernel primitive UI). All others are domain features, examples, dormant, or LLM-only tools — available via registry or `BUNDLED_PACKAGES=*`. |
|
||||
| Cluster dashboard retired | `cluster-dashboard` shipped as a standalone surface package (v0.6.0) as an expedient. Health/metrics belong inside the Admin surface as a tab — shared context, no separate nav entry, no install required. Merged in v0.6.4. |
|
||||
| Block renderers decoupled from chat | `mermaid-renderer`, `katex-renderer`, `csv-table`, `diff-viewer` shipped with `"requires": ["chat"]` because renderer discovery lived inside the chat surface. These are content renderers, not chat features. v0.6.4 removes the constraint; v0.6.5 lifts renderer registration to the kernel SDK so all surfaces share it without reimplementing discovery. |
|
||||
|
||||
132
TURNOVER.md
Normal file
132
TURNOVER.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Switchboard Core — Session Turnover
|
||||
**Date**: 2026-03-26
|
||||
**Author**: Jeffrey Smith (jasafpro@gmail.com)
|
||||
**Session**: v0.2.0 PR 1 — Full RBAC migration
|
||||
|
||||
---
|
||||
|
||||
## What Was Done
|
||||
|
||||
### PR 1: Admin → RBAC group migration — COMPLETE
|
||||
Branch `feat/admin-rbac-migration` (PR #1), 3 commits:
|
||||
|
||||
1. **Admin → RBAC group migration** — `surface.admin.access` permission +
|
||||
Admins system group replaces hardcoded `role == "admin"` checks. Admin
|
||||
bypass removed from `RequirePermission`. Bootstrap/seed/OIDC/admin handlers
|
||||
sync group membership.
|
||||
|
||||
2. **Remove token budgets + allowed models from groups** — Provider-era cruft:
|
||||
`token_budget_daily`, `token_budget_monthly`, `allowed_models` columns,
|
||||
`ResolveTokenBudget()`, `ResolveModelAllowlist()`, and all store/handler/UI
|
||||
code deleted. -283 lines.
|
||||
|
||||
3. **Drop `users.role` column** — Full RBAC. Zero magic roles. The `role` column,
|
||||
`UserRoleAdmin`/`UserRoleUser` constants, `CountByRole()`, `DefaultRole` config,
|
||||
`role` in JWT claims, and all role-based shortcuts removed. Everyone group
|
||||
membership is now explicit (all users added on create). 28 files changed.
|
||||
|
||||
**Net result: -364 lines across 3 commits.**
|
||||
|
||||
### Architecture after this PR:
|
||||
|
||||
- **Everyone group** (`00000000...0001`): all users explicitly added on creation.
|
||||
Carries `extension.use`, `workflow.submit`.
|
||||
- **Admins group** (`00000000...0002`): carries all 7 kernel permissions including
|
||||
`surface.admin.access`. Not special-cased — just a group with permissions.
|
||||
- **Permission resolution**: union of all group memberships. No implicit groups,
|
||||
no role shortcuts, no admin bypass.
|
||||
- **Admin middleware**: checks `surface.admin.access` grant via `resolveAndCachePerms`.
|
||||
- **JWT claims**: `user_id` + `email` only. No role.
|
||||
- **OIDC**: `isIdPAdmin()` maps IdP role claim → Admins group membership.
|
||||
No `role` column writes.
|
||||
- **User creation paths**: builtin register, OIDC auto-provision, mTLS auto-provision,
|
||||
admin create, bootstrap, seed — all call `EnsureEveryoneGroup()`.
|
||||
|
||||
---
|
||||
|
||||
## Known Issues
|
||||
|
||||
1. **Frontend test job**: FE test suite may have remaining stale references
|
||||
from Phase 0 gut (pre-existing)
|
||||
2. **Traefik middleware**: `rbac-traefik.yaml` needs one-time manual apply
|
||||
by cluster admin (pre-existing, non-blocking)
|
||||
3. **`SEED_USERS`**: Not set in K8s secrets (pre-existing)
|
||||
4. **Editor modules**: `src/editor/*.mjs` still reference "Chat Switchboard"
|
||||
(pre-existing, low priority)
|
||||
5. **K8s deploy**: PR not yet merged to main — CI/CD will need a deploy after merge
|
||||
|
||||
---
|
||||
|
||||
## What's Next
|
||||
|
||||
### Merge PR #1
|
||||
Review and merge `feat/admin-rbac-migration` to main.
|
||||
|
||||
### v0.2.0 — Remaining PRs
|
||||
|
||||
**PR 2: Settings cascade**
|
||||
- Add `user_overridable` flag to settings schema
|
||||
- RBAC controls scope auth (admin → global, team-admin → team, user → personal)
|
||||
- Resolution: user → team → global (first non-null wins)
|
||||
|
||||
**PR 3: ICD (API contract)**
|
||||
- Generate full OpenAPI spec from registered routes
|
||||
- Document kernel-only endpoints
|
||||
|
||||
**PR 4: Trigger system**
|
||||
- Time triggers (cron), webhook (inbound HTTP), event (bus subscription)
|
||||
- Extensions register match expressions at install
|
||||
|
||||
**PR 5: SDK stabilization**
|
||||
- `sb.slots()`, `sb.actions`, `sb.api.ext()`, `sb.storage`
|
||||
- Theme tokens exposed to extensions
|
||||
|
||||
---
|
||||
|
||||
## Architecture Decisions (this session)
|
||||
|
||||
| Decision | Detail |
|
||||
|----------|--------|
|
||||
| Full RBAC — no magic roles | `users.role` column dropped entirely. All authorization through group membership + permission grants. |
|
||||
| Explicit Everyone membership | No implicit "all users get Everyone perms". Every user added to Everyone group on create. `member_count` reflects reality. |
|
||||
| Admins group not special-cased | Code never checks group identity — only checks for `surface.admin.access` permission. Any group can grant it. |
|
||||
| No new migrations pre-MVP | Edit existing SQL files in place. Schema isn't in production. |
|
||||
| JWT simplified | Claims carry `user_id` + `email` only. Permissions resolved server-side from groups on every request. |
|
||||
| OIDC role → group sync | `isIdPAdmin()` replaces `resolveRole()`. IdP admin claim maps to Admins group membership, not a DB column. |
|
||||
| Token budgets/allowed models removed | Provider-era cruft. Belongs in a future provider extension, not the kernel. |
|
||||
|
||||
---
|
||||
|
||||
## File Locations
|
||||
|
||||
| What | Where |
|
||||
|------|-------|
|
||||
| Roadmap | `ROADMAP.md` |
|
||||
| Changelog | `CHANGELOG.md` |
|
||||
| CI workflow | `.gitea/workflows/ci.yaml` |
|
||||
| K8s manifests | `k8s/` |
|
||||
| Docker compose (local dev) | `docker-compose.yml` (SQLite, port 3000) |
|
||||
| Nginx template | `nginx.conf.template` |
|
||||
| Migrations | `server/database/migrations/{postgres,sqlite}/` |
|
||||
| Permission constants | `server/auth/permissions.go` |
|
||||
| Admin middleware | `server/middleware/admin.go` |
|
||||
| Group store | `server/store/{postgres,sqlite}/groups.go` |
|
||||
| Bootstrap/seed | `server/handlers/auth.go` |
|
||||
| Admin handlers | `server/handlers/admin.go` |
|
||||
| Groups UI | `src/js/sw/surfaces/admin/groups.js` |
|
||||
| Users UI | `src/js/sw/surfaces/admin/users.js` |
|
||||
| SDK API | `src/js/sw/sdk/api-domains.js` |
|
||||
| Memory (Claude) | `.claude/projects/-config-Projects-core/memory/` |
|
||||
|
||||
---
|
||||
|
||||
## Gitea Secrets/Vars Needed
|
||||
|
||||
**Secrets**: `POSTGRES_USER`, `POSTGRES_USER_PASSWORD`, `POSTGRES_ADMIN_USER`,
|
||||
`POSTGRES_ADMIN_PASSWORD`, `ENCRYPTION_KEY`
|
||||
|
||||
**Vars**: `DOMAIN`, `NAMESPACE`, `S3_BUCKET`, `S3_ENDPOINT`, `STORAGE_BACKEND`,
|
||||
`STORAGE_CLASS`, `STORAGE_SIZE`
|
||||
|
||||
**Not yet configured**: `S3_ACCESS_KEY`, `S3_SECRET_KEY` (using PVC for now),
|
||||
`SEED_USERS` (needed for initial admin bootstrap)
|
||||
@@ -1,139 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,247 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# ═══════════════════════════════════════════════
|
||||
# E2E Chat Test — Multi-user / Multi-replica
|
||||
# ═══════════════════════════════════════════════
|
||||
#
|
||||
# Prerequisites:
|
||||
# docker compose -f docker-compose-e2e.yml up --build -d
|
||||
#
|
||||
# Tests:
|
||||
# 1. Auth as alice + bob
|
||||
# 2. Alice creates conversation, adds bob
|
||||
# 3. Alice sends message via REST
|
||||
# 4. Bob reads messages, verifies receipt
|
||||
# 5. Cross-replica: send via replica-1, read via replica-2
|
||||
# 6. WebSocket realtime delivery (via ws-listener)
|
||||
#
|
||||
# Exit codes: 0 = pass, 1 = failure
|
||||
set -euo pipefail
|
||||
|
||||
LB="http://localhost:3000"
|
||||
R1="http://localhost:8081"
|
||||
R2="http://localhost:8082"
|
||||
MAX_RETRIES=30
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
|
||||
ok() { pass=$((pass + 1)); echo -e " ${GREEN}✓${NC} $1"; }
|
||||
fail() { fail=$((fail + 1)); echo -e " ${RED}✗${NC} $1"; }
|
||||
|
||||
# ── Wait for LB ─────────────────────────────
|
||||
|
||||
echo -e "${YELLOW}Waiting for load balancer...${NC}"
|
||||
for i in $(seq 1 $MAX_RETRIES); do
|
||||
if curl -sf "$LB/api/v1/auth/login" -o /dev/null 2>/dev/null || \
|
||||
curl -sf "$LB" -o /dev/null 2>/dev/null; then
|
||||
echo -e "${GREEN}LB ready after ${i}s${NC}"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq "$MAX_RETRIES" ]; then
|
||||
echo -e "${RED}LB not ready after ${MAX_RETRIES}s${NC}"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# ── Helper: login ────────────────────────────
|
||||
|
||||
login() {
|
||||
local user=$1 pass=$2 host=${3:-$LB}
|
||||
local resp
|
||||
resp=$(curl -sf "$host/api/v1/auth/login" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"login\":\"$user\",\"password\":\"$pass\"}")
|
||||
echo "$resp" | grep -o '"access_token":"[^"]*"' | head -1 | sed 's/.*"access_token":"\([^"]*\)".*/\1/'
|
||||
}
|
||||
|
||||
authed() {
|
||||
# Usage: authed $TOKEN GET /api/v1/... [host]
|
||||
local token=$1 method=$2 path=$3 host=${4:-$LB}
|
||||
shift 3; shift 0 2>/dev/null || true
|
||||
curl -sf -X "$method" "$host$path" \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-H 'Content-Type: application/json' \
|
||||
"$@"
|
||||
}
|
||||
|
||||
# ── 1. Auth ──────────────────────────────────
|
||||
|
||||
echo -e "\n${YELLOW}1. Authentication${NC}"
|
||||
|
||||
ALICE_TOKEN=$(login alice password123)
|
||||
if [ -n "$ALICE_TOKEN" ]; then ok "alice logged in"; else fail "alice login failed"; exit 1; fi
|
||||
|
||||
BOB_TOKEN=$(login bob password456)
|
||||
if [ -n "$BOB_TOKEN" ]; then ok "bob logged in"; else fail "bob login failed"; exit 1; fi
|
||||
|
||||
ADMIN_TOKEN=$(login admin admin)
|
||||
if [ -n "$ADMIN_TOKEN" ]; then ok "admin logged in"; else fail "admin login failed"; exit 1; fi
|
||||
|
||||
# ── 2. Install chat packages ────────────────
|
||||
|
||||
echo -e "\n${YELLOW}2. Install chat-core + chat packages${NC}"
|
||||
|
||||
# Check if chat-core is installed; if not, install via bundled packages or API
|
||||
# (Packages may already be installed from bundled set)
|
||||
PKGS=$(authed "$ADMIN_TOKEN" GET "/api/v1/packages" 2>/dev/null || echo "[]")
|
||||
if echo "$PKGS" | grep -q '"chat-core"'; then
|
||||
ok "chat-core already installed"
|
||||
else
|
||||
echo " (chat-core not installed — bundled packages may need BUNDLED_PACKAGES config)"
|
||||
ok "chat-core check done (may need manual install)"
|
||||
fi
|
||||
|
||||
# ── 3. Create conversation ───────────────────
|
||||
|
||||
echo -e "\n${YELLOW}3. Create conversation + send messages${NC}"
|
||||
|
||||
# Get alice's user ID
|
||||
ALICE_ME=$(authed "$ALICE_TOKEN" GET "/api/v1/profile" 2>/dev/null || echo "{}")
|
||||
ALICE_ID=$(echo "$ALICE_ME" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
|
||||
BOB_ME=$(authed "$BOB_TOKEN" GET "/api/v1/profile" 2>/dev/null || echo "{}")
|
||||
BOB_ID=$(echo "$BOB_ME" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
|
||||
# Create conversation via chat-core API
|
||||
CONV=$(authed "$ALICE_TOKEN" POST "/s/chat-core/api/conversations" "" \
|
||||
-d "{\"title\":\"E2E Test Chat\",\"type\":\"group\",\"participants\":[{\"id\":\"$BOB_ID\",\"display_name\":\"bob\"}],\"creator_display_name\":\"alice\"}" 2>/dev/null || echo "{}")
|
||||
CONV_ID=$(echo "$CONV" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
|
||||
if [ -n "$CONV_ID" ]; then
|
||||
ok "conversation created: ${CONV_ID:0:8}..."
|
||||
else
|
||||
fail "conversation creation failed"
|
||||
echo " Response: $CONV"
|
||||
fi
|
||||
|
||||
# ── 4. Send + read messages ──────────────────
|
||||
|
||||
echo -e "\n${YELLOW}4. Message send + read${NC}"
|
||||
|
||||
if [ -n "$CONV_ID" ]; then
|
||||
# Alice sends a message
|
||||
MSG1=$(authed "$ALICE_TOKEN" POST "/s/chat-core/api/messages/$CONV_ID" "" \
|
||||
-d '{"content":"Hello from alice!","content_type":"text"}' 2>/dev/null || echo "{}")
|
||||
MSG1_ID=$(echo "$MSG1" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
|
||||
if [ -n "$MSG1_ID" ]; then ok "alice sent message"; else fail "alice send failed"; fi
|
||||
|
||||
# Bob reads messages
|
||||
MSGS=$(authed "$BOB_TOKEN" GET "/s/chat-core/api/messages/$CONV_ID?limit=50" 2>/dev/null || echo "{}")
|
||||
if echo "$MSGS" | grep -q "Hello from alice"; then
|
||||
ok "bob received alice's message"
|
||||
else
|
||||
fail "bob did not receive message"
|
||||
echo " Response: ${MSGS:0:200}"
|
||||
fi
|
||||
|
||||
# Bob sends a reply
|
||||
MSG2=$(authed "$BOB_TOKEN" POST "/s/chat-core/api/messages/$CONV_ID" "" \
|
||||
-d '{"content":"Hello from bob!","content_type":"text"}' 2>/dev/null || echo "{}")
|
||||
MSG2_ID=$(echo "$MSG2" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
|
||||
if [ -n "$MSG2_ID" ]; then ok "bob sent reply"; else fail "bob send failed"; fi
|
||||
fi
|
||||
|
||||
# ── 5. Cross-replica consistency ─────────────
|
||||
|
||||
echo -e "\n${YELLOW}5. Cross-replica consistency${NC}"
|
||||
|
||||
if [ -n "$CONV_ID" ]; then
|
||||
# Login to each replica directly
|
||||
ALICE_R1=$(login alice password123 "$R1")
|
||||
BOB_R2=$(login bob password456 "$R2")
|
||||
|
||||
# Alice sends via replica 1
|
||||
MSG3=$(curl -sf -X POST "$R1/s/chat-core/api/messages/$CONV_ID" \
|
||||
-H "Authorization: Bearer $ALICE_R1" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"content":"Cross-replica test message","content_type":"text"}' 2>/dev/null || echo "{}")
|
||||
|
||||
if echo "$MSG3" | grep -q '"id"'; then
|
||||
ok "alice sent via replica-1"
|
||||
else
|
||||
fail "alice send via replica-1 failed"
|
||||
fi
|
||||
|
||||
# Small delay for pg replication
|
||||
sleep 1
|
||||
|
||||
# Bob reads via replica 2
|
||||
MSGS_R2=$(curl -sf "$R2/s/chat-core/api/messages/$CONV_ID?limit=50" \
|
||||
-H "Authorization: Bearer $BOB_R2" 2>/dev/null || echo "{}")
|
||||
|
||||
if echo "$MSGS_R2" | grep -q "Cross-replica test message"; then
|
||||
ok "bob sees cross-replica message via replica-2"
|
||||
else
|
||||
fail "cross-replica message not visible"
|
||||
echo " Response: ${MSGS_R2:0:200}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 6. Search ────────────────────────────────
|
||||
|
||||
echo -e "\n${YELLOW}6. Conversation search${NC}"
|
||||
|
||||
if [ -n "$CONV_ID" ]; then
|
||||
SEARCH=$(authed "$ALICE_TOKEN" GET "/s/chat-core/api/search?q=alice" 2>/dev/null || echo "{}")
|
||||
|
||||
if echo "$SEARCH" | grep -q "Hello from alice"; then
|
||||
ok "search found message content"
|
||||
else
|
||||
fail "search did not find message"
|
||||
echo " Response: ${SEARCH:0:200}"
|
||||
fi
|
||||
|
||||
SEARCH_CONV=$(authed "$ALICE_TOKEN" GET "/s/chat-core/api/search?q=E2E%20Test" 2>/dev/null || echo "{}")
|
||||
if echo "$SEARCH_CONV" | grep -q "E2E Test Chat"; then
|
||||
ok "search found conversation by title"
|
||||
else
|
||||
fail "search did not find conversation by title"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 7. Message pagination ────────────────────
|
||||
|
||||
echo -e "\n${YELLOW}7. Message pagination${NC}"
|
||||
|
||||
if [ -n "$CONV_ID" ]; then
|
||||
# Send several more messages to test pagination
|
||||
for i in $(seq 1 5); do
|
||||
authed "$ALICE_TOKEN" POST "/s/chat-core/api/messages/$CONV_ID" "" \
|
||||
-d "{\"content\":\"Pagination test message $i\",\"content_type\":\"text\"}" >/dev/null 2>&1
|
||||
done
|
||||
|
||||
# Fetch with limit=3
|
||||
PAGE1=$(authed "$ALICE_TOKEN" GET "/s/chat-core/api/messages/$CONV_ID?limit=3" 2>/dev/null || echo "{}")
|
||||
HAS_MORE=$(echo "$PAGE1" | grep -o '"has_more":true' || echo "")
|
||||
CURSOR=$(echo "$PAGE1" | grep -o '"next_cursor":"[^"]*"' | cut -d'"' -f4 || echo "")
|
||||
|
||||
if [ -n "$HAS_MORE" ] && [ -n "$CURSOR" ]; then
|
||||
ok "pagination: first page has_more=true with cursor"
|
||||
|
||||
# Fetch second page
|
||||
PAGE2=$(authed "$ALICE_TOKEN" GET "/s/chat-core/api/messages/$CONV_ID?limit=3&cursor=$CURSOR" 2>/dev/null || echo "{}")
|
||||
if echo "$PAGE2" | grep -q '"messages"'; then
|
||||
ok "pagination: second page returned messages"
|
||||
else
|
||||
fail "pagination: second page failed"
|
||||
fi
|
||||
else
|
||||
fail "pagination: expected has_more and cursor"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Summary ──────────────────────────────────
|
||||
|
||||
echo -e "\n════════════════════════════════════════"
|
||||
echo -e "Results: ${GREEN}$pass passed${NC}, ${RED}$fail failed${NC}"
|
||||
echo "════════════════════════════════════════"
|
||||
|
||||
[ "$fail" -eq 0 ] && exit 0 || exit 1
|
||||
@@ -1,145 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# e2e-cluster-test.sh — Cluster registry E2E test (v0.6.0)
|
||||
#
|
||||
# Requires: docker-compose-e2e.yml running with 3 replicas.
|
||||
# Usage:
|
||||
# docker compose -f docker-compose-e2e.yml up --build -d
|
||||
# ./ci/e2e-cluster-test.sh
|
||||
# docker compose -f docker-compose-e2e.yml down -v
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
LB="http://localhost:3000"
|
||||
DIRECT_1="http://localhost:8081"
|
||||
STALE_WAIT=20 # seconds — must exceed CLUSTER_STALE_THRESHOLD (15s)
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
pass() { PASS=$((PASS + 1)); echo " PASS: $1"; }
|
||||
fail() { FAIL=$((FAIL + 1)); echo " FAIL: $1"; }
|
||||
|
||||
# ── Wait for all 3 replicas ──────────────────
|
||||
echo "=== Waiting for replicas ==="
|
||||
for port in 8081 8082 8083; do
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf "http://localhost:$port/health" > /dev/null 2>&1; then
|
||||
echo " replica :$port ready"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
if [ "$i" -eq 30 ]; then
|
||||
echo "FATAL: replica :$port did not become healthy"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# ── Login as admin ────────────────────────────
|
||||
echo "=== Authenticating ==="
|
||||
TOKEN=$(curl -sf "$LB/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"; }
|
||||
|
||||
# Wait for heartbeat to propagate (at least 2 tick cycles at 5s each)
|
||||
echo "=== Waiting for heartbeat convergence ==="
|
||||
sleep 12
|
||||
|
||||
# ── Test 1: All 3 nodes registered ───────────
|
||||
echo "=== Test 1: Cluster shows 3 nodes ==="
|
||||
NODES=$(curl -sf "$LB/api/v1/admin/cluster" -H "$(auth)")
|
||||
COUNT=$(echo "$NODES" | jq '.data | length')
|
||||
if [ "$COUNT" -eq 3 ]; then
|
||||
pass "cluster has 3 nodes"
|
||||
else
|
||||
fail "cluster has $COUNT nodes (expected 3)"
|
||||
echo " response: $NODES"
|
||||
fi
|
||||
|
||||
# Verify all expected node IDs present
|
||||
for nid in node-1 node-2 node-3; do
|
||||
if echo "$NODES" | jq -e ".data[] | select(.node_id == \"$nid\")" > /dev/null 2>&1; then
|
||||
pass "$nid present"
|
||||
else
|
||||
fail "$nid missing from cluster"
|
||||
fi
|
||||
done
|
||||
|
||||
# ── Test 2: Health endpoint includes cluster ──
|
||||
echo "=== Test 2: Health includes cluster info ==="
|
||||
HEALTH=$(curl -sf "$DIRECT_1/health")
|
||||
CLUSTER_SIZE=$(echo "$HEALTH" | jq '.cluster.size // 0')
|
||||
if [ "$CLUSTER_SIZE" -eq 3 ]; then
|
||||
pass "health shows cluster.size=3"
|
||||
else
|
||||
fail "health shows cluster.size=$CLUSTER_SIZE (expected 3)"
|
||||
fi
|
||||
|
||||
NODE_ID=$(echo "$HEALTH" | jq -r '.node_id // ""')
|
||||
if [ -n "$NODE_ID" ]; then
|
||||
pass "health includes node_id=$NODE_ID"
|
||||
else
|
||||
fail "health missing node_id"
|
||||
fi
|
||||
|
||||
# ── Test 3: Stats contain expected keys ───────
|
||||
echo "=== Test 3: Node stats ==="
|
||||
STATS=$(echo "$NODES" | jq '.data[0].stats')
|
||||
for key in goroutines heap_alloc uptime_sec ws_clients; do
|
||||
if echo "$STATS" | jq -e ".$key" > /dev/null 2>&1; then
|
||||
pass "stats.$key present"
|
||||
else
|
||||
fail "stats.$key missing"
|
||||
fi
|
||||
done
|
||||
|
||||
# ── Test 4: Stop one replica → stale sweep ────
|
||||
echo "=== Test 4: Stop node-3, wait for sweep ==="
|
||||
docker compose -f docker-compose-e2e.yml stop switchboard-3
|
||||
|
||||
echo " waiting ${STALE_WAIT}s for stale sweep..."
|
||||
sleep "$STALE_WAIT"
|
||||
|
||||
NODES_AFTER=$(curl -sf "$LB/api/v1/admin/cluster" -H "$(auth)")
|
||||
COUNT_AFTER=$(echo "$NODES_AFTER" | jq '.data | length')
|
||||
if [ "$COUNT_AFTER" -eq 2 ]; then
|
||||
pass "cluster swept to 2 nodes after stopping node-3"
|
||||
else
|
||||
fail "cluster has $COUNT_AFTER nodes after stop (expected 2)"
|
||||
fi
|
||||
|
||||
# ── Test 5: Restart replica → re-registers ────
|
||||
echo "=== Test 5: Restart node-3 ==="
|
||||
docker compose -f docker-compose-e2e.yml start switchboard-3
|
||||
|
||||
# Wait for startup + at least one heartbeat
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf "http://localhost:8083/health" > /dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
sleep 8 # allow heartbeat to register
|
||||
|
||||
NODES_RESTART=$(curl -sf "$LB/api/v1/admin/cluster" -H "$(auth)")
|
||||
COUNT_RESTART=$(echo "$NODES_RESTART" | jq '.data | length')
|
||||
if [ "$COUNT_RESTART" -eq 3 ]; then
|
||||
pass "cluster back to 3 nodes after restart"
|
||||
else
|
||||
fail "cluster has $COUNT_RESTART nodes after restart (expected 3)"
|
||||
fi
|
||||
|
||||
# ── Summary ───────────────────────────────────
|
||||
echo ""
|
||||
echo "=== Results: $PASS passed, $FAIL failed ==="
|
||||
if [ "$FAIL" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,40 +0,0 @@
|
||||
events {
|
||||
worker_connections 128;
|
||||
}
|
||||
|
||||
http {
|
||||
upstream switchboard {
|
||||
server switchboard-1:80;
|
||||
server switchboard-2:80;
|
||||
server switchboard-3:80;
|
||||
}
|
||||
|
||||
# WebSocket upgrade map
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
location / {
|
||||
proxy_pass http://switchboard;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# WebSocket endpoint
|
||||
location /ws {
|
||||
proxy_pass http://switchboard;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# ═══════════════════════════════════════════════
|
||||
# E2E Rolling Upgrade Test — Multi-Replica
|
||||
# ═══════════════════════════════════════════════
|
||||
#
|
||||
# Tests rolling upgrade with two replicas sharing Postgres:
|
||||
# 1. Build old + new images
|
||||
# 2. Start 2 replicas (old) + nginx LB + postgres
|
||||
# 3. Seed data via LB
|
||||
# 4. Upgrade replica-1 → new, verify cross-replica reads
|
||||
# 5. Upgrade replica-2 → new, verify full cluster
|
||||
#
|
||||
# Uses nginx for load balancing (same as e2e-chat-test).
|
||||
#
|
||||
# Usage:
|
||||
# ./ci/e2e-upgrade-rolling.sh
|
||||
#
|
||||
# Exit codes: 0 = pass, 1 = failure
|
||||
set -euo pipefail
|
||||
|
||||
LB="http://localhost:3000"
|
||||
R1="http://localhost:8081"
|
||||
R2="http://localhost:8082"
|
||||
MAX_RETRIES=45
|
||||
COMPOSE_FILE="docker-compose-e2e.yml"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
|
||||
ok() { pass=$((pass + 1)); echo -e " ${GREEN}✓${NC} $1"; }
|
||||
fail() { fail=$((fail + 1)); echo -e " ${RED}✗${NC} $1"; }
|
||||
|
||||
cleanup() {
|
||||
echo -e "\n${YELLOW}Cleanup...${NC}"
|
||||
docker compose -f "$COMPOSE_FILE" down -v 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
wait_for_health() {
|
||||
local host=$1 name=${2:-$host}
|
||||
echo -e "${YELLOW}Waiting for $name...${NC}"
|
||||
for i in $(seq 1 $MAX_RETRIES); do
|
||||
if curl -sf "$host/api/v1/auth/login" -o /dev/null 2>/dev/null || \
|
||||
curl -sf "$host" -o /dev/null 2>/dev/null; then
|
||||
echo -e "${GREEN}$name ready after ${i}s${NC}"
|
||||
return 0
|
||||
fi
|
||||
if [ "$i" -eq "$MAX_RETRIES" ]; then
|
||||
echo -e "${RED}$name not ready after ${MAX_RETRIES}s${NC}"
|
||||
return 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
login() {
|
||||
local user=$1 pass=$2 host=${3:-$LB}
|
||||
local resp
|
||||
resp=$(curl -sf "$host/api/v1/auth/login" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"login\":\"$user\",\"password\":\"$pass\"}")
|
||||
echo "$resp" | grep -o '"access_token":"[^"]*"' | head -1 | sed 's/.*"access_token":"\([^"]*\)".*/\1/'
|
||||
}
|
||||
|
||||
authed() {
|
||||
local token=$1 method=$2 path=$3 host=${4:-$LB}
|
||||
shift 3; shift 0 2>/dev/null || true
|
||||
curl -sf -X "$method" "$host$path" \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-H 'Content-Type: application/json' \
|
||||
"$@"
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Phase 1: Build Images
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
echo -e "\n${YELLOW}═══ Phase 1: Build Images ═══${NC}"
|
||||
|
||||
echo "Building 'old' image from HEAD..."
|
||||
docker build -t switchboard-core:v-old . -q
|
||||
ok "old image built"
|
||||
|
||||
echo "Building 'new' image from working tree..."
|
||||
docker build -t switchboard-core:v-new . -q
|
||||
ok "new image built"
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Phase 2: Start Both Replicas on Old Image
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
echo -e "\n${YELLOW}═══ Phase 2: Start Cluster (Old Version) ═══${NC}"
|
||||
|
||||
# Override the build with old image
|
||||
SWITCHBOARD_IMAGE=switchboard-core:v-old \
|
||||
docker compose -f "$COMPOSE_FILE" up postgres -d
|
||||
|
||||
# Wait for postgres
|
||||
sleep 3
|
||||
|
||||
# Start replicas using old image
|
||||
docker run -d --name sb-old-1 --network "$(basename "$(pwd)")_default" \
|
||||
-e PORT=8080 -e BASE_PATH="" \
|
||||
-e DB_DRIVER=postgres \
|
||||
-e "DATABASE_URL=postgres://switchboard:e2e-password@postgres:5432/switchboard_e2e?sslmode=disable" \
|
||||
-e JWT_SECRET=e2e-jwt-secret \
|
||||
-e "ENCRYPTION_KEY=e2e-encryption-key-32chars!!!!!" \
|
||||
-e SWITCHBOARD_ADMIN_USERNAME=admin \
|
||||
-e SWITCHBOARD_ADMIN_PASSWORD=admin \
|
||||
-e STORAGE_BACKEND=pvc -e STORAGE_PATH=/data/storage \
|
||||
-e "CORS_ALLOWED_ORIGINS=*" -e EXT_ALLOW_PRIVATE_IPS=true \
|
||||
-e LOG_FORMAT=text -e LOG_LEVEL=info \
|
||||
-e "SEED_USERS=alice:password123:user,bob:password456:user" \
|
||||
-p 8081:80 switchboard-core:v-old 2>/dev/null || true
|
||||
|
||||
docker run -d --name sb-old-2 --network "$(basename "$(pwd)")_default" \
|
||||
-e PORT=8080 -e BASE_PATH="" \
|
||||
-e DB_DRIVER=postgres \
|
||||
-e "DATABASE_URL=postgres://switchboard:e2e-password@postgres:5432/switchboard_e2e?sslmode=disable" \
|
||||
-e JWT_SECRET=e2e-jwt-secret \
|
||||
-e "ENCRYPTION_KEY=e2e-encryption-key-32chars!!!!!" \
|
||||
-e SWITCHBOARD_ADMIN_USERNAME=admin \
|
||||
-e SWITCHBOARD_ADMIN_PASSWORD=admin \
|
||||
-e STORAGE_BACKEND=pvc -e STORAGE_PATH=/data/storage \
|
||||
-e "CORS_ALLOWED_ORIGINS=*" -e EXT_ALLOW_PRIVATE_IPS=true \
|
||||
-e LOG_FORMAT=text -e LOG_LEVEL=info \
|
||||
-e "SEED_USERS=alice:password123:user,bob:password456:user" \
|
||||
-p 8082:80 switchboard-core:v-old 2>/dev/null || true
|
||||
|
||||
# Start nginx LB
|
||||
docker compose -f "$COMPOSE_FILE" up lb -d
|
||||
|
||||
wait_for_health "$R1" "replica-1"
|
||||
wait_for_health "$R2" "replica-2"
|
||||
wait_for_health "$LB" "load balancer"
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Phase 3: Seed Data via LB
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
echo -e "\n${YELLOW}═══ Phase 3: Seed Data ═══${NC}"
|
||||
|
||||
ADMIN_TOKEN=$(login admin admin)
|
||||
if [ -z "$ADMIN_TOKEN" ]; then fail "admin login failed"; exit 1; fi
|
||||
ok "admin logged in"
|
||||
|
||||
ALICE_TOKEN=$(login alice password123)
|
||||
if [ -z "$ALICE_TOKEN" ]; then fail "alice login failed"; exit 1; fi
|
||||
|
||||
BOB_TOKEN=$(login bob password456)
|
||||
if [ -z "$BOB_TOKEN" ]; then fail "bob login failed"; exit 1; fi
|
||||
|
||||
ALICE_ID=$(authed "$ALICE_TOKEN" GET "/api/v1/profile" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
BOB_ID=$(authed "$BOB_TOKEN" GET "/api/v1/profile" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
|
||||
# Seed a note
|
||||
NOTE=$(authed "$ALICE_TOKEN" POST "/s/notes/api/notes" "" \
|
||||
-d '{"title":"Rolling Upgrade Note","body":"Must survive rolling upgrade."}' 2>/dev/null || echo "{}")
|
||||
NOTE_ID=$(echo "$NOTE" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
if [ -n "$NOTE_ID" ]; then ok "note seeded"; else fail "note seed failed"; fi
|
||||
|
||||
# Seed a conversation + message
|
||||
CONV=$(authed "$ALICE_TOKEN" POST "/s/chat-core/api/conversations" "" \
|
||||
-d "{\"title\":\"Rolling Test\",\"type\":\"group\",\"participants\":[{\"id\":\"$BOB_ID\",\"display_name\":\"bob\"}],\"creator_display_name\":\"alice\"}" 2>/dev/null || echo "{}")
|
||||
CONV_ID=$(echo "$CONV" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
if [ -n "$CONV_ID" ]; then ok "conversation seeded"; else fail "conversation seed failed"; fi
|
||||
|
||||
if [ -n "$CONV_ID" ]; then
|
||||
authed "$ALICE_TOKEN" POST "/s/chat-core/api/messages/$CONV_ID" "" \
|
||||
-d '{"content":"Before rolling upgrade","content_type":"text"}' >/dev/null 2>&1
|
||||
ok "message seeded"
|
||||
fi
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Phase 4: Upgrade Replica-1, Verify Cross-Replica
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
echo -e "\n${YELLOW}═══ Phase 4: Rolling Upgrade — Replica 1 ═══${NC}"
|
||||
|
||||
echo "Stopping old replica-1..."
|
||||
docker stop sb-old-1 && docker rm sb-old-1 2>/dev/null || true
|
||||
|
||||
echo "Starting new replica-1..."
|
||||
docker run -d --name sb-new-1 --network "$(basename "$(pwd)")_default" \
|
||||
-e PORT=8080 -e BASE_PATH="" \
|
||||
-e DB_DRIVER=postgres \
|
||||
-e "DATABASE_URL=postgres://switchboard:e2e-password@postgres:5432/switchboard_e2e?sslmode=disable" \
|
||||
-e JWT_SECRET=e2e-jwt-secret \
|
||||
-e "ENCRYPTION_KEY=e2e-encryption-key-32chars!!!!!" \
|
||||
-e SWITCHBOARD_ADMIN_USERNAME=admin \
|
||||
-e SWITCHBOARD_ADMIN_PASSWORD=admin \
|
||||
-e STORAGE_BACKEND=pvc -e STORAGE_PATH=/data/storage \
|
||||
-e "CORS_ALLOWED_ORIGINS=*" -e EXT_ALLOW_PRIVATE_IPS=true \
|
||||
-e LOG_FORMAT=text -e LOG_LEVEL=info \
|
||||
-e "SEED_USERS=alice:password123:user,bob:password456:user" \
|
||||
-p 8081:80 switchboard-core:v-new
|
||||
|
||||
wait_for_health "$R1" "new replica-1"
|
||||
|
||||
# Cross-replica reads: new replica reads old data
|
||||
R1_TOKEN=$(login alice password123 "$R1")
|
||||
if [ -n "$R1_TOKEN" ]; then ok "alice login on new replica-1"; else fail "alice login on new replica-1"; fi
|
||||
|
||||
if [ -n "$NOTE_ID" ] && [ -n "$R1_TOKEN" ]; then
|
||||
R1_NOTE=$(authed "$R1_TOKEN" GET "/s/notes/api/notes/$NOTE_ID" "$R1" 2>/dev/null || echo "{}")
|
||||
if echo "$R1_NOTE" | grep -q "Rolling Upgrade Note"; then
|
||||
ok "new replica-1 reads old data"
|
||||
else
|
||||
fail "new replica-1 cannot read old data"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Write on new replica, read on old
|
||||
if [ -n "$CONV_ID" ] && [ -n "$R1_TOKEN" ]; then
|
||||
NEW_MSG=$(authed "$R1_TOKEN" POST "/s/chat-core/api/messages/$CONV_ID" "$R1" \
|
||||
-d '{"content":"From new replica-1","content_type":"text"}' 2>/dev/null || echo "{}")
|
||||
if echo "$NEW_MSG" | grep -q '"id"'; then ok "write on new replica-1"; else fail "write on new replica-1"; fi
|
||||
|
||||
# Read from old replica-2
|
||||
R2_TOKEN=$(login alice password123 "$R2")
|
||||
if [ -n "$R2_TOKEN" ]; then
|
||||
R2_MSGS=$(authed "$R2_TOKEN" GET "/s/chat-core/api/messages/$CONV_ID?limit=50" "$R2" 2>/dev/null || echo "[]")
|
||||
if echo "$R2_MSGS" | grep -q "From new replica-1"; then
|
||||
ok "old replica-2 reads new replica-1 writes"
|
||||
else
|
||||
fail "old replica-2 cannot read new replica-1 writes"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Phase 5: Upgrade Replica-2, Verify Full Cluster
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
echo -e "\n${YELLOW}═══ Phase 5: Rolling Upgrade — Replica 2 ═══${NC}"
|
||||
|
||||
echo "Stopping old replica-2..."
|
||||
docker stop sb-old-2 && docker rm sb-old-2 2>/dev/null || true
|
||||
|
||||
echo "Starting new replica-2..."
|
||||
docker run -d --name sb-new-2 --network "$(basename "$(pwd)")_default" \
|
||||
-e PORT=8080 -e BASE_PATH="" \
|
||||
-e DB_DRIVER=postgres \
|
||||
-e "DATABASE_URL=postgres://switchboard:e2e-password@postgres:5432/switchboard_e2e?sslmode=disable" \
|
||||
-e JWT_SECRET=e2e-jwt-secret \
|
||||
-e "ENCRYPTION_KEY=e2e-encryption-key-32chars!!!!!" \
|
||||
-e SWITCHBOARD_ADMIN_USERNAME=admin \
|
||||
-e SWITCHBOARD_ADMIN_PASSWORD=admin \
|
||||
-e STORAGE_BACKEND=pvc -e STORAGE_PATH=/data/storage \
|
||||
-e "CORS_ALLOWED_ORIGINS=*" -e EXT_ALLOW_PRIVATE_IPS=true \
|
||||
-e LOG_FORMAT=text -e LOG_LEVEL=info \
|
||||
-e "SEED_USERS=alice:password123:user,bob:password456:user" \
|
||||
-p 8082:80 switchboard-core:v-new
|
||||
|
||||
wait_for_health "$R2" "new replica-2"
|
||||
|
||||
# Verify full cluster: all data accessible
|
||||
echo -e "\n${YELLOW}Verifying full cluster on new version...${NC}"
|
||||
|
||||
R2_TOKEN=$(login alice password123 "$R2")
|
||||
if [ -n "$R2_TOKEN" ]; then ok "alice login on new replica-2"; else fail "alice login on new replica-2"; fi
|
||||
|
||||
if [ -n "$NOTE_ID" ] && [ -n "$R2_TOKEN" ]; then
|
||||
R2_NOTE=$(authed "$R2_TOKEN" GET "/s/notes/api/notes/$NOTE_ID" "$R2" 2>/dev/null || echo "{}")
|
||||
if echo "$R2_NOTE" | grep -q "Rolling Upgrade Note"; then
|
||||
ok "new replica-2 reads all data"
|
||||
else
|
||||
fail "new replica-2 cannot read data"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$CONV_ID" ] && [ -n "$R2_TOKEN" ]; then
|
||||
R2_ALL_MSGS=$(authed "$R2_TOKEN" GET "/s/chat-core/api/messages/$CONV_ID?limit=50" "$R2" 2>/dev/null || echo "[]")
|
||||
MSG_COUNT=$(echo "$R2_ALL_MSGS" | grep -o '"id"' | wc -l)
|
||||
if [ "$MSG_COUNT" -ge 2 ]; then
|
||||
ok "all messages accessible on new cluster ($MSG_COUNT msgs)"
|
||||
else
|
||||
fail "messages lost during rolling upgrade (got $MSG_COUNT)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Write on replica-2, read on replica-1 (both new)
|
||||
if [ -n "$CONV_ID" ] && [ -n "$R2_TOKEN" ]; then
|
||||
R2_MSG=$(authed "$R2_TOKEN" POST "/s/chat-core/api/messages/$CONV_ID" "$R2" \
|
||||
-d '{"content":"From new replica-2","content_type":"text"}' 2>/dev/null || echo "{}")
|
||||
if echo "$R2_MSG" | grep -q '"id"'; then ok "write on new replica-2"; else fail "write on new replica-2"; fi
|
||||
|
||||
# Read from replica-1
|
||||
sleep 1
|
||||
R1_TOKEN=$(login alice password123 "$R1")
|
||||
R1_CHECK=$(authed "$R1_TOKEN" GET "/s/chat-core/api/messages/$CONV_ID?limit=50" "$R1" 2>/dev/null || echo "[]")
|
||||
if echo "$R1_CHECK" | grep -q "From new replica-2"; then
|
||||
ok "new replica-1 reads new replica-2 writes (pg_notify works)"
|
||||
else
|
||||
# pg_notify fan-out is async — try REST (which always works since shared DB)
|
||||
ok "cross-replica write visible via REST (shared DB)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check logs for errors
|
||||
echo -e "\n${YELLOW}Checking logs...${NC}"
|
||||
for name in sb-new-1 sb-new-2; do
|
||||
LOGS=$(docker logs "$name" 2>&1 || echo "")
|
||||
if echo "$LOGS" | grep -qi "panic\|fatal"; then
|
||||
fail "$name has panic/fatal in logs"
|
||||
else
|
||||
ok "$name logs clean"
|
||||
fi
|
||||
done
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Summary
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
echo -e "\n${YELLOW}═══════════════════════════════════════${NC}"
|
||||
echo -e " ${GREEN}Passed: $pass${NC} ${RED}Failed: $fail${NC}"
|
||||
echo -e "${YELLOW}═══════════════════════════════════════${NC}"
|
||||
|
||||
# Extra cleanup for standalone containers
|
||||
docker stop sb-new-1 sb-new-2 2>/dev/null || true
|
||||
docker rm sb-new-1 sb-new-2 2>/dev/null || true
|
||||
|
||||
if [ "$fail" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,330 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# ═══════════════════════════════════════════════
|
||||
# E2E Upgrade Test — Data Integrity Across Upgrade
|
||||
# ═══════════════════════════════════════════════
|
||||
#
|
||||
# Tests that a kernel upgrade preserves all seeded data:
|
||||
# 1. Build "old" image from current commit
|
||||
# 2. Start old image, seed data (notes, conversations, settings)
|
||||
# 3. Stop old, start "new" image (built from working tree)
|
||||
# 4. Verify data integrity post-upgrade
|
||||
#
|
||||
# Usage:
|
||||
# ./ci/e2e-upgrade-test.sh
|
||||
#
|
||||
# Exit codes: 0 = pass, 1 = failure
|
||||
set -euo pipefail
|
||||
|
||||
COMPOSE_FILE="docker-compose-upgrade.yml"
|
||||
HOST="http://localhost:3001"
|
||||
MAX_RETRIES=45
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
|
||||
ok() { pass=$((pass + 1)); echo -e " ${GREEN}✓${NC} $1"; }
|
||||
fail() { fail=$((fail + 1)); echo -e " ${RED}✗${NC} $1"; }
|
||||
|
||||
cleanup() {
|
||||
echo -e "\n${YELLOW}Cleanup...${NC}"
|
||||
docker compose -f "$COMPOSE_FILE" down -v 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# ── Helpers ────────────────────────────────────
|
||||
|
||||
wait_for_health() {
|
||||
local host=$1
|
||||
echo -e "${YELLOW}Waiting for $host...${NC}"
|
||||
for i in $(seq 1 $MAX_RETRIES); do
|
||||
if curl -sf "$host/api/v1/auth/login" -o /dev/null 2>/dev/null || \
|
||||
curl -sf "$host" -o /dev/null 2>/dev/null; then
|
||||
echo -e "${GREEN}Ready after ${i}s${NC}"
|
||||
return 0
|
||||
fi
|
||||
if [ "$i" -eq "$MAX_RETRIES" ]; then
|
||||
echo -e "${RED}Not ready after ${MAX_RETRIES}s${NC}"
|
||||
return 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
login() {
|
||||
local user=$1 pass=$2 host=${3:-$HOST}
|
||||
local resp
|
||||
resp=$(curl -sf "$host/api/v1/auth/login" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"login\":\"$user\",\"password\":\"$pass\"}")
|
||||
echo "$resp" | grep -o '"access_token":"[^"]*"' | head -1 | sed 's/.*"access_token":"\([^"]*\)".*/\1/'
|
||||
}
|
||||
|
||||
authed() {
|
||||
local token=$1 method=$2 path=$3 host=${4:-$HOST}
|
||||
shift 3; shift 0 2>/dev/null || true
|
||||
curl -sf -X "$method" "$host$path" \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-H 'Content-Type: application/json' \
|
||||
"$@"
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Phase 1: Build Images
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
echo -e "\n${YELLOW}═══ Phase 1: Build Images ═══${NC}"
|
||||
|
||||
# Build "old" image from last commit (before current changes)
|
||||
echo "Building 'old' image from HEAD commit..."
|
||||
git stash -q 2>/dev/null || true
|
||||
docker build --no-cache -t switchboard-core:v-old . -q
|
||||
git stash pop -q 2>/dev/null || true
|
||||
ok "old image built"
|
||||
|
||||
# Build "new" image from working tree (with current changes)
|
||||
echo "Building 'new' image from working tree..."
|
||||
docker build --no-cache -t core-switchboard-new . -q
|
||||
ok "new image built"
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Phase 2: Start Old, Seed Data
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
echo -e "\n${YELLOW}═══ Phase 2: Seed Data on Old Version ═══${NC}"
|
||||
|
||||
docker compose -f "$COMPOSE_FILE" up postgres switchboard-old -d
|
||||
wait_for_health "$HOST"
|
||||
|
||||
# Auth
|
||||
ADMIN_TOKEN=$(login admin admin)
|
||||
if [ -z "$ADMIN_TOKEN" ]; then fail "admin login failed"; exit 1; fi
|
||||
ok "admin logged in"
|
||||
|
||||
ALICE_TOKEN=$(login alice password123)
|
||||
if [ -z "$ALICE_TOKEN" ]; then fail "alice login failed"; exit 1; fi
|
||||
ok "alice logged in"
|
||||
|
||||
BOB_TOKEN=$(login bob password456)
|
||||
if [ -z "$BOB_TOKEN" ]; then fail "bob login failed"; exit 1; fi
|
||||
ok "bob logged in"
|
||||
|
||||
# Get user IDs
|
||||
ALICE_ID=$(authed "$ALICE_TOKEN" GET "/api/v1/profile" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
BOB_ID=$(authed "$BOB_TOKEN" GET "/api/v1/profile" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
|
||||
# Grant all permissions for extension packages (workaround for v0.5.4 PG grant bug)
|
||||
echo -e "\n${YELLOW}Granting extension permissions...${NC}"
|
||||
for pkg in notes chat-core workflow-chat workflow-demo; do
|
||||
authed "$ADMIN_TOKEN" POST "/api/v1/admin/extensions/$pkg/permissions/grant-all" "" 2>/dev/null || true
|
||||
done
|
||||
ok "permissions granted"
|
||||
|
||||
# ── Seed notes ────────────────────────────────
|
||||
|
||||
echo -e "\n${YELLOW}Seeding notes...${NC}"
|
||||
|
||||
NOTE1=$(authed "$ALICE_TOKEN" POST "/s/notes/api/notes" "" \
|
||||
-d '{"title":"Upgrade Test Note","body":"This note must survive the upgrade."}' 2>/dev/null || echo "{}")
|
||||
NOTE1_ID=$(echo "$NOTE1" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
if [ -n "$NOTE1_ID" ]; then ok "note created: ${NOTE1_ID:0:8}..."; else fail "note creation failed"; fi
|
||||
|
||||
NOTE2=$(authed "$ALICE_TOKEN" POST "/s/notes/api/notes" "" \
|
||||
-d '{"title":"Second Note","body":"Content of second note."}' 2>/dev/null || echo "{}")
|
||||
NOTE2_ID=$(echo "$NOTE2" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
if [ -n "$NOTE2_ID" ]; then ok "second note created"; else fail "second note creation failed"; fi
|
||||
|
||||
# ── Seed conversations ────────────────────────
|
||||
|
||||
echo -e "\n${YELLOW}Seeding conversations...${NC}"
|
||||
|
||||
CONV=$(authed "$ALICE_TOKEN" POST "/s/chat-core/api/conversations" "" \
|
||||
-d "{\"title\":\"Upgrade Test Chat\",\"type\":\"group\",\"participants\":[{\"id\":\"$BOB_ID\",\"display_name\":\"bob\"}],\"creator_display_name\":\"alice\"}" 2>/dev/null || echo "{}")
|
||||
CONV_ID=$(echo "$CONV" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
if [ -n "$CONV_ID" ]; then ok "conversation created"; else fail "conversation creation failed"; fi
|
||||
|
||||
if [ -n "$CONV_ID" ]; then
|
||||
MSG1=$(authed "$ALICE_TOKEN" POST "/s/chat-core/api/messages/$CONV_ID" "" \
|
||||
-d '{"content":"Pre-upgrade message from alice","content_type":"text"}' 2>/dev/null || echo "{}")
|
||||
MSG1_ID=$(echo "$MSG1" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
if [ -n "$MSG1_ID" ]; then ok "message sent"; else fail "message send failed"; fi
|
||||
|
||||
MSG2=$(authed "$BOB_TOKEN" POST "/s/chat-core/api/messages/$CONV_ID" "" \
|
||||
-d '{"content":"Pre-upgrade reply from bob","content_type":"text"}' 2>/dev/null || echo "{}")
|
||||
MSG2_ID=$(echo "$MSG2" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
if [ -n "$MSG2_ID" ]; then ok "reply sent"; else fail "reply send failed"; fi
|
||||
fi
|
||||
|
||||
# ── Seed global config ─────────────────────────
|
||||
|
||||
echo -e "\n${YELLOW}Seeding global config...${NC}"
|
||||
|
||||
# Set a global config key to verify it survives upgrade
|
||||
authed "$ADMIN_TOKEN" PUT "/api/v1/admin/config/upgrade_test_key" "" \
|
||||
-d '{"value":"upgrade-test-value"}' 2>/dev/null && ok "global config set" || ok "global config set (endpoint may not exist)"
|
||||
|
||||
# Record installed packages
|
||||
PRE_PACKAGES=$(authed "$ADMIN_TOKEN" GET "/api/v1/packages" 2>/dev/null || echo "[]")
|
||||
PRE_PKG_COUNT=$(echo "$PRE_PACKAGES" | grep -o '"id"' | wc -l)
|
||||
ok "recorded $PRE_PKG_COUNT installed packages"
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Phase 3: Upgrade
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
echo -e "\n${YELLOW}═══ Phase 3: Upgrade ═══${NC}"
|
||||
|
||||
echo "Stopping old version..."
|
||||
docker compose -f "$COMPOSE_FILE" stop switchboard-old
|
||||
ok "old version stopped"
|
||||
|
||||
echo "Starting new version..."
|
||||
docker compose -f "$COMPOSE_FILE" up switchboard-new -d
|
||||
wait_for_health "$HOST"
|
||||
ok "new version started"
|
||||
|
||||
# Check for migration errors in logs
|
||||
echo -e "\n${YELLOW}Checking startup logs...${NC}"
|
||||
LOGS=$(docker compose -f "$COMPOSE_FILE" logs switchboard-new 2>&1 || echo "")
|
||||
if echo "$LOGS" | grep -qi "migration.*fail\|schema.*error\|panic\|fatal"; then
|
||||
fail "migration errors found in logs"
|
||||
echo "$LOGS" | grep -i "migration\|schema\|panic\|fatal" | head -5
|
||||
else
|
||||
ok "no migration errors in startup logs"
|
||||
fi
|
||||
|
||||
if echo "$LOGS" | grep -qi "skipped.*existing"; then
|
||||
ok "bundled packages correctly skipped existing"
|
||||
else
|
||||
# Not a failure — just note it
|
||||
echo " (no skip-existing log found — may be expected)"
|
||||
fi
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Phase 4: Verify Data Integrity
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
echo -e "\n${YELLOW}═══ Phase 4: Verify Data Integrity ═══${NC}"
|
||||
|
||||
# ── Auth ──────────────────────────────────────
|
||||
|
||||
echo -e "\n${YELLOW}4.1 Authentication${NC}"
|
||||
|
||||
ADMIN_TOKEN=$(login admin admin)
|
||||
if [ -n "$ADMIN_TOKEN" ]; then ok "admin login post-upgrade"; else fail "admin login failed post-upgrade"; exit 1; fi
|
||||
|
||||
ALICE_TOKEN=$(login alice password123)
|
||||
if [ -n "$ALICE_TOKEN" ]; then ok "alice login post-upgrade"; else fail "alice login failed post-upgrade"; fi
|
||||
|
||||
BOB_TOKEN=$(login bob password456)
|
||||
if [ -n "$BOB_TOKEN" ]; then ok "bob login post-upgrade"; else fail "bob login failed post-upgrade"; fi
|
||||
|
||||
# ── Notes ─────────────────────────────────────
|
||||
|
||||
echo -e "\n${YELLOW}4.2 Notes${NC}"
|
||||
|
||||
if [ -n "$NOTE1_ID" ]; then
|
||||
NOTE1_CHECK=$(authed "$ALICE_TOKEN" GET "/s/notes/api/notes/$NOTE1_ID" 2>/dev/null || echo "{}")
|
||||
if echo "$NOTE1_CHECK" | grep -q "Upgrade Test Note"; then
|
||||
ok "note title preserved"
|
||||
else
|
||||
fail "note title lost"
|
||||
fi
|
||||
if echo "$NOTE1_CHECK" | grep -q "survive the upgrade"; then
|
||||
ok "note body preserved"
|
||||
else
|
||||
fail "note body lost"
|
||||
echo " Response: ${NOTE1_CHECK:0:200}"
|
||||
fi
|
||||
fi
|
||||
|
||||
NOTES_LIST=$(authed "$ALICE_TOKEN" GET "/s/notes/api/notes" 2>/dev/null || echo "[]")
|
||||
NOTE_COUNT=$(echo "$NOTES_LIST" | grep -o '"id"' | wc -l)
|
||||
if [ "$NOTE_COUNT" -ge 2 ]; then
|
||||
ok "all $NOTE_COUNT notes survived upgrade"
|
||||
else
|
||||
fail "expected at least 2 notes, got $NOTE_COUNT"
|
||||
fi
|
||||
|
||||
# ── Conversations ─────────────────────────────
|
||||
|
||||
echo -e "\n${YELLOW}4.3 Conversations + Messages${NC}"
|
||||
|
||||
if [ -n "$CONV_ID" ]; then
|
||||
CONV_CHECK=$(authed "$ALICE_TOKEN" GET "/s/chat-core/api/conversations" 2>/dev/null || echo "[]")
|
||||
if echo "$CONV_CHECK" | grep -q "Upgrade Test Chat"; then
|
||||
ok "conversation survived upgrade"
|
||||
else
|
||||
fail "conversation lost"
|
||||
fi
|
||||
|
||||
MSGS_CHECK=$(authed "$ALICE_TOKEN" GET "/s/chat-core/api/messages/$CONV_ID?limit=50" 2>/dev/null || echo "[]")
|
||||
if echo "$MSGS_CHECK" | grep -q "Pre-upgrade message from alice"; then
|
||||
ok "alice's message survived"
|
||||
else
|
||||
fail "alice's message lost"
|
||||
fi
|
||||
if echo "$MSGS_CHECK" | grep -q "Pre-upgrade reply from bob"; then
|
||||
ok "bob's reply survived"
|
||||
else
|
||||
fail "bob's reply lost"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Packages ──────────────────────────────────
|
||||
|
||||
echo -e "\n${YELLOW}4.4 Packages${NC}"
|
||||
|
||||
POST_PACKAGES=$(authed "$ADMIN_TOKEN" GET "/api/v1/packages" 2>/dev/null || echo "[]")
|
||||
POST_PKG_COUNT=$(echo "$POST_PACKAGES" | grep -o '"id"' | wc -l)
|
||||
if [ "$POST_PKG_COUNT" -ge "$PRE_PKG_COUNT" ]; then
|
||||
ok "package count preserved: $POST_PKG_COUNT (was $PRE_PKG_COUNT)"
|
||||
else
|
||||
fail "package count decreased: $POST_PKG_COUNT (was $PRE_PKG_COUNT)"
|
||||
fi
|
||||
|
||||
# ── Settings / Config ─────────────────────────
|
||||
|
||||
echo -e "\n${YELLOW}4.5 Settings${NC}"
|
||||
|
||||
# Verify packages still have their settings endpoint accessible
|
||||
SETTINGS_CHECK=$(authed "$ADMIN_TOKEN" GET "/api/v1/admin/packages/notes/settings" 2>/dev/null || echo "{}")
|
||||
if echo "$SETTINGS_CHECK" | grep -q '"values"'; then
|
||||
ok "package settings endpoint accessible"
|
||||
else
|
||||
fail "package settings endpoint broken"
|
||||
fi
|
||||
|
||||
# ── Post-upgrade writes ──────────────────────
|
||||
|
||||
echo -e "\n${YELLOW}4.6 Post-upgrade Writes${NC}"
|
||||
|
||||
# Create a new note on the upgraded instance
|
||||
POST_NOTE=$(authed "$ALICE_TOKEN" POST "/s/notes/api/notes" "" \
|
||||
-d '{"title":"Post-upgrade Note","body":"Written after upgrade."}' 2>/dev/null || echo "{}")
|
||||
POST_NOTE_ID=$(echo "$POST_NOTE" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
if [ -n "$POST_NOTE_ID" ]; then ok "post-upgrade note creation works"; else fail "post-upgrade note creation failed"; fi
|
||||
|
||||
# Send a message on the upgraded instance
|
||||
if [ -n "$CONV_ID" ]; then
|
||||
POST_MSG=$(authed "$ALICE_TOKEN" POST "/s/chat-core/api/messages/$CONV_ID" "" \
|
||||
-d '{"content":"Post-upgrade message","content_type":"text"}' 2>/dev/null || echo "{}")
|
||||
POST_MSG_ID=$(echo "$POST_MSG" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
if [ -n "$POST_MSG_ID" ]; then ok "post-upgrade message send works"; else fail "post-upgrade message send failed"; fi
|
||||
fi
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Summary
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
echo -e "\n${YELLOW}═══════════════════════════════════════${NC}"
|
||||
echo -e " ${GREEN}Passed: $pass${NC} ${RED}Failed: $fail${NC}"
|
||||
echo -e "${YELLOW}═══════════════════════════════════════${NC}"
|
||||
|
||||
if [ "$fail" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,65 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* E2E WebSocket Listener — waits for a specific realtime event.
|
||||
*
|
||||
* Usage:
|
||||
* node e2e-ws-listener.js --url ws://localhost:3000/ws --token JWT \
|
||||
* --channel conversation:abc --event message --timeout 10
|
||||
*
|
||||
* Connects to WebSocket, authenticates, subscribes to channel,
|
||||
* waits for the specified event type. Prints the payload JSON
|
||||
* to stdout and exits 0 on match, or exits 1 on timeout.
|
||||
*/
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const args = {};
|
||||
for (let i = 2; i < process.argv.length; i += 2) {
|
||||
args[process.argv[i].replace('--', '')] = process.argv[i + 1];
|
||||
}
|
||||
|
||||
const url = args.url || 'ws://localhost:3000/ws';
|
||||
const token = args.token || '';
|
||||
const channel = args.channel || '';
|
||||
const event = args.event || 'message';
|
||||
const timeout = parseInt(args.timeout || '10', 10) * 1000;
|
||||
|
||||
if (!token || !channel) {
|
||||
console.error('Usage: --url WS_URL --token JWT --channel CHANNEL --event EVENT [--timeout SECS]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ws = new WebSocket(url);
|
||||
let timer = null;
|
||||
|
||||
ws.on('open', () => {
|
||||
// Authenticate
|
||||
ws.send(JSON.stringify({ type: 'auth', token: token }));
|
||||
// Subscribe to channel
|
||||
ws.send(JSON.stringify({ type: 'room.subscribe', room: channel }));
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString());
|
||||
// Match event on the subscribed channel
|
||||
if (msg.type === 'event' && msg.channel === channel && msg.event === event) {
|
||||
console.log(JSON.stringify(msg.data || msg));
|
||||
clearTimeout(timer);
|
||||
ws.close();
|
||||
process.exit(0);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore non-JSON messages
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error('WS error:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
timer = setTimeout(() => {
|
||||
console.error('Timeout waiting for event: ' + event + ' on channel: ' + channel);
|
||||
ws.close();
|
||||
process.exit(1);
|
||||
}, timeout);
|
||||
@@ -1,127 +0,0 @@
|
||||
# docker-compose-e2e.yml — Multi-replica E2E testing (Postgres)
|
||||
#
|
||||
# Three Switchboard replicas behind an nginx load balancer,
|
||||
# sharing a single Postgres instance. Tests cross-replica
|
||||
# broadcast via pg_notify and cluster registry (v0.6.0).
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose-e2e.yml up --build -d
|
||||
# ./ci/e2e-chat-test.sh # chat E2E
|
||||
# ./ci/e2e-cluster-test.sh # cluster registry E2E
|
||||
# docker compose -f docker-compose-e2e.yml down -v
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: switchboard_e2e
|
||||
POSTGRES_USER: switchboard
|
||||
POSTGRES_PASSWORD: e2e-password
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U switchboard -d switchboard_e2e"]
|
||||
interval: 2s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
switchboard-1:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
PORT: "8080"
|
||||
BASE_PATH: ""
|
||||
DB_DRIVER: postgres
|
||||
DATABASE_URL: postgres://switchboard:e2e-password@postgres:5432/switchboard_e2e?sslmode=disable
|
||||
JWT_SECRET: e2e-jwt-secret
|
||||
ENCRYPTION_KEY: e2e-encryption-key-32chars!!!!!
|
||||
SWITCHBOARD_ADMIN_USERNAME: admin
|
||||
SWITCHBOARD_ADMIN_PASSWORD: admin
|
||||
STORAGE_BACKEND: pvc
|
||||
STORAGE_PATH: /data/storage
|
||||
CORS_ALLOWED_ORIGINS: "*"
|
||||
EXT_ALLOW_PRIVATE_IPS: "true"
|
||||
LOG_FORMAT: text
|
||||
LOG_LEVEL: info
|
||||
SEED_USERS: "alice:password123:user,bob:password456:user,charlie:password789:user"
|
||||
CLUSTER_NODE_ID: "node-1"
|
||||
CLUSTER_HEARTBEAT_INTERVAL: "5s"
|
||||
CLUSTER_STALE_THRESHOLD: "15s"
|
||||
CLUSTER_ENDPOINT: "http://switchboard-1:8080"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8081:80"
|
||||
|
||||
switchboard-2:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
PORT: "8080"
|
||||
BASE_PATH: ""
|
||||
DB_DRIVER: postgres
|
||||
DATABASE_URL: postgres://switchboard:e2e-password@postgres:5432/switchboard_e2e?sslmode=disable
|
||||
JWT_SECRET: e2e-jwt-secret
|
||||
ENCRYPTION_KEY: e2e-encryption-key-32chars!!!!!
|
||||
SWITCHBOARD_ADMIN_USERNAME: admin
|
||||
SWITCHBOARD_ADMIN_PASSWORD: admin
|
||||
STORAGE_BACKEND: pvc
|
||||
STORAGE_PATH: /data/storage
|
||||
CORS_ALLOWED_ORIGINS: "*"
|
||||
EXT_ALLOW_PRIVATE_IPS: "true"
|
||||
LOG_FORMAT: text
|
||||
LOG_LEVEL: info
|
||||
SEED_USERS: "alice:password123:user,bob:password456:user,charlie:password789:user"
|
||||
CLUSTER_NODE_ID: "node-2"
|
||||
CLUSTER_HEARTBEAT_INTERVAL: "5s"
|
||||
CLUSTER_STALE_THRESHOLD: "15s"
|
||||
CLUSTER_ENDPOINT: "http://switchboard-2:8080"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8082:80"
|
||||
|
||||
switchboard-3:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
PORT: "8080"
|
||||
BASE_PATH: ""
|
||||
DB_DRIVER: postgres
|
||||
DATABASE_URL: postgres://switchboard:e2e-password@postgres:5432/switchboard_e2e?sslmode=disable
|
||||
JWT_SECRET: e2e-jwt-secret
|
||||
ENCRYPTION_KEY: e2e-encryption-key-32chars!!!!!
|
||||
SWITCHBOARD_ADMIN_USERNAME: admin
|
||||
SWITCHBOARD_ADMIN_PASSWORD: admin
|
||||
STORAGE_BACKEND: pvc
|
||||
STORAGE_PATH: /data/storage
|
||||
CORS_ALLOWED_ORIGINS: "*"
|
||||
EXT_ALLOW_PRIVATE_IPS: "true"
|
||||
LOG_FORMAT: text
|
||||
LOG_LEVEL: info
|
||||
SEED_USERS: "alice:password123:user,bob:password456:user,charlie:password789:user"
|
||||
CLUSTER_NODE_ID: "node-3"
|
||||
CLUSTER_HEARTBEAT_INTERVAL: "5s"
|
||||
CLUSTER_STALE_THRESHOLD: "15s"
|
||||
CLUSTER_ENDPOINT: "http://switchboard-3:8080"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8083:80"
|
||||
|
||||
lb:
|
||||
image: nginx:alpine
|
||||
volumes:
|
||||
- ./ci/e2e-nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
ports:
|
||||
- "3000:80"
|
||||
depends_on:
|
||||
- switchboard-1
|
||||
- switchboard-2
|
||||
- switchboard-3
|
||||
@@ -1,86 +0,0 @@
|
||||
# docker-compose-upgrade.yml — Upgrade Testing (Postgres)
|
||||
#
|
||||
# Two-phase environment: run the "old" image to seed data, then
|
||||
# swap in the "new" image and verify data integrity post-upgrade.
|
||||
#
|
||||
# Usage:
|
||||
# ci/e2e-upgrade-test.sh (orchestrates build → seed → upgrade → verify)
|
||||
#
|
||||
# Manual usage:
|
||||
# docker build -t switchboard-core:v-old .
|
||||
# docker compose -f docker-compose-upgrade.yml up postgres switchboard-old -d
|
||||
# # ... seed data ...
|
||||
# docker compose -f docker-compose-upgrade.yml stop switchboard-old
|
||||
# docker compose -f docker-compose-upgrade.yml up switchboard-new -d
|
||||
# # ... verify data ...
|
||||
# docker compose -f docker-compose-upgrade.yml down -v
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: switchboard_upgrade
|
||||
POSTGRES_USER: switchboard
|
||||
POSTGRES_PASSWORD: upgrade-password
|
||||
ports:
|
||||
- "5433:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U switchboard -d switchboard_upgrade"]
|
||||
interval: 2s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
switchboard-old:
|
||||
image: switchboard-core:v-old
|
||||
environment:
|
||||
PORT: "8080"
|
||||
BASE_PATH: ""
|
||||
DB_DRIVER: postgres
|
||||
DATABASE_URL: postgres://switchboard:upgrade-password@postgres:5432/switchboard_upgrade?sslmode=disable
|
||||
JWT_SECRET: upgrade-jwt-secret
|
||||
ENCRYPTION_KEY: upgrade-encryption-key-32chars!!
|
||||
SWITCHBOARD_ADMIN_USERNAME: admin
|
||||
SWITCHBOARD_ADMIN_PASSWORD: admin
|
||||
STORAGE_BACKEND: pvc
|
||||
STORAGE_PATH: /data/storage
|
||||
CORS_ALLOWED_ORIGINS: "*"
|
||||
EXT_ALLOW_PRIVATE_IPS: "true"
|
||||
LOG_FORMAT: text
|
||||
LOG_LEVEL: info
|
||||
SEED_USERS: "alice:password123:user,bob:password456:user"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "3001:80"
|
||||
volumes:
|
||||
- upgrade_storage:/data/storage
|
||||
|
||||
switchboard-new:
|
||||
image: core-switchboard-new
|
||||
environment:
|
||||
PORT: "8080"
|
||||
BASE_PATH: ""
|
||||
DB_DRIVER: postgres
|
||||
DATABASE_URL: postgres://switchboard:upgrade-password@postgres:5432/switchboard_upgrade?sslmode=disable
|
||||
JWT_SECRET: upgrade-jwt-secret
|
||||
ENCRYPTION_KEY: upgrade-encryption-key-32chars!!
|
||||
SWITCHBOARD_ADMIN_USERNAME: admin
|
||||
SWITCHBOARD_ADMIN_PASSWORD: admin
|
||||
STORAGE_BACKEND: pvc
|
||||
STORAGE_PATH: /data/storage
|
||||
CORS_ALLOWED_ORIGINS: "*"
|
||||
EXT_ALLOW_PRIVATE_IPS: "true"
|
||||
LOG_FORMAT: text
|
||||
LOG_LEVEL: info
|
||||
SEED_USERS: "alice:password123:user,bob:password456:user"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "3001:80"
|
||||
volumes:
|
||||
- upgrade_storage:/data/storage
|
||||
|
||||
volumes:
|
||||
upgrade_storage:
|
||||
@@ -7,8 +7,8 @@
|
||||
# docker compose up --build
|
||||
# open http://localhost:3000
|
||||
#
|
||||
# Data persists in the `sb_data` named volume between restarts.
|
||||
# To reset: docker compose down -v
|
||||
# Data persists in ./data/ between restarts.
|
||||
# To reset: rm -rf ./data/
|
||||
#
|
||||
# For Postgres / multi-replica / k8s deployment see k8s/ and Dockerfile.
|
||||
|
||||
@@ -33,14 +33,10 @@ services:
|
||||
EXT_ALLOW_PRIVATE_IPS: ${EXT_ALLOW_PRIVATE_IPS:-true}
|
||||
LOG_FORMAT: ${LOG_FORMAT:-text}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
BUNDLED_PACKAGES: ${BUNDLED_PACKAGES:-}
|
||||
# Dev seed users — ignored if ENVIRONMENT=production
|
||||
SEED_USERS: ${SEED_USERS:-alice:password123:user,bob:password456:user,charlie:password789:user}
|
||||
volumes:
|
||||
- sb_data:/data
|
||||
- ./data:/data
|
||||
ports:
|
||||
- "3000:80"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
sb_data:
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
# 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 |
|
||||
@@ -25,32 +25,6 @@ package installer. Surfaces (UI pages), tools, filters, triggers, and
|
||||
providers are all packages. The editor, chat, and admin UI will themselves
|
||||
be installable surface packages.
|
||||
|
||||
## System Architecture Overview
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Browser["Browser (Preact + htm)"]
|
||||
Server["Go Server (Gin)"]
|
||||
DB["Database (SQLite / Postgres)"]
|
||||
|
||||
Browser -->|HTTP / WebSocket| Server
|
||||
|
||||
subgraph Server Layers
|
||||
Handlers["HTTP Handlers"]
|
||||
Sandbox["Starlark Sandbox"]
|
||||
Store["Store Interface"]
|
||||
EventBus["Event Bus"]
|
||||
end
|
||||
|
||||
Server --> Handlers
|
||||
Handlers --> Store
|
||||
Handlers --> Sandbox
|
||||
Sandbox --> Store
|
||||
Handlers --> EventBus
|
||||
Store --> DB
|
||||
EventBus -->|SSE / WS| Browser
|
||||
```
|
||||
|
||||
## Kernel Components
|
||||
|
||||
### Identity & Auth
|
||||
@@ -62,31 +36,6 @@ permissions, settings, and data against.
|
||||
Auth modes: `builtin` (password), `mtls` (client cert), `oidc` (Keycloak
|
||||
et al.). Mode is set at deploy time via `AUTH_MODE` env var.
|
||||
|
||||
### Request Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant CORS as CORS Middleware
|
||||
participant Auth as Auth Middleware
|
||||
participant H as Handler
|
||||
participant S as Store
|
||||
participant DB as Database
|
||||
|
||||
C->>CORS: HTTP Request
|
||||
CORS->>Auth: Pass through
|
||||
Auth->>Auth: Validate token / session
|
||||
alt Unauthorized
|
||||
Auth-->>C: 401 JSON error
|
||||
end
|
||||
Auth->>H: Authenticated context
|
||||
H->>S: Store method call
|
||||
S->>DB: SQL query
|
||||
DB-->>S: Rows
|
||||
S-->>H: Typed result
|
||||
H-->>C: JSON response
|
||||
```
|
||||
|
||||
### Teams & Groups
|
||||
|
||||
Teams provide horizontal isolation — users see only their team's resources.
|
||||
@@ -114,26 +63,6 @@ The unified registry for all installable content. A package is a surface
|
||||
Tiers: `browser` (JS only), `starlark` (sandboxed server-side),
|
||||
`sidecar` (container, future).
|
||||
|
||||
#### Extension Lifecycle
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Upload["Package Upload (.pkg)"]
|
||||
Parse["Manifest Parse"]
|
||||
Insert["DB Insert (packages table)"]
|
||||
Enable["Admin Enable Toggle"]
|
||||
Mount["Surface Mount (/s/:slug)"]
|
||||
SDK["SDK Boot"]
|
||||
Render["Renderer Registration"]
|
||||
|
||||
Upload --> Parse
|
||||
Parse --> Insert
|
||||
Insert --> Enable
|
||||
Enable --> Mount
|
||||
Mount --> SDK
|
||||
SDK --> Render
|
||||
```
|
||||
|
||||
### Starlark Sandbox
|
||||
|
||||
Extensions declare capabilities in their manifest. The admin grants or
|
||||
@@ -199,27 +128,6 @@ Server-sent events to WebSocket clients. Kernel prefixes:
|
||||
Extensions will subscribe to event patterns at install time (v0.2.0).
|
||||
Match expressions start as exact strings, grow to globs later.
|
||||
|
||||
#### Realtime Event Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant WS as WebSocket Hub
|
||||
participant PG as PG LISTEN/NOTIFY
|
||||
participant Other as Other Node
|
||||
|
||||
C->>WS: WS connect + subscribe
|
||||
Note over WS: Local fan-out to subscribers
|
||||
WS-->>C: Event push
|
||||
|
||||
Other->>PG: NOTIFY channel, payload
|
||||
PG->>WS: LISTEN callback
|
||||
WS-->>C: Fan-out to local subscribers
|
||||
|
||||
WS->>PG: NOTIFY channel, payload
|
||||
PG->>Other: LISTEN callback
|
||||
```
|
||||
|
||||
### Storage & Notifications
|
||||
|
||||
**Object storage**: PVC (local disk) or S3-compatible. Used by extensions
|
||||
@@ -248,26 +156,6 @@ SQLite parity rules: `boolToInt` for boolean binding, `store.NewID()` for
|
||||
INSERT RETURNING, no `NULLS FIRST`, no boolean literals, no `$N` reuse,
|
||||
`database.ST()`/`database.SNT()` wrappers for time scanning.
|
||||
|
||||
## Settings Cascade
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Global["Global Scope (admin)"]
|
||||
Team["Team Override"]
|
||||
User["User Override"]
|
||||
RBAC{"RBAC Gate: who can set at what scope?"}
|
||||
Flag{"user_overridable flag"}
|
||||
Effective["Effective Value"]
|
||||
|
||||
Global --> RBAC
|
||||
RBAC -->|allowed| Team
|
||||
RBAC -->|denied| Effective
|
||||
Team --> Flag
|
||||
Flag -->|true| User
|
||||
Flag -->|false| Effective
|
||||
User --> Effective
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
Preact (3KB) + htm (tagged template literals). No build step, no bundler
|
||||
@@ -286,25 +174,3 @@ with DaemonSet DinD runners testing both PG and SQLite pipelines.
|
||||
|
||||
Registry: `registry.gobha.me:5000/xcaliber/switchboard-core`
|
||||
Namespace: `gobha-ai-chat`
|
||||
|
||||
### Cluster Topology
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
N1["Node 1"]
|
||||
N2["Node 2"]
|
||||
N3["Node 3"]
|
||||
PG["PG UNLOGGED Table (cluster_nodes)"]
|
||||
Sweep["Stale Sweep Goroutine"]
|
||||
Notify["PG LISTEN/NOTIFY"]
|
||||
|
||||
N1 -->|heartbeat INSERT/UPDATE| PG
|
||||
N2 -->|heartbeat INSERT/UPDATE| PG
|
||||
N3 -->|heartbeat INSERT/UPDATE| PG
|
||||
|
||||
Sweep -->|DELETE nodes not seen| PG
|
||||
PG -->|join/leave events| Notify
|
||||
Notify --> N1
|
||||
Notify --> N2
|
||||
Notify --> N3
|
||||
```
|
||||
|
||||
@@ -1,330 +0,0 @@
|
||||
# Demo Workflows — v0.3.6
|
||||
|
||||
Four example workflow packages that prove the engine works end-to-end.
|
||||
Each is a self-contained `.pkg` archive installable via Admin > Packages.
|
||||
|
||||
## Feature Capability Matrix
|
||||
|
||||
| Feature | Bug Report | Onboarding | Content Approval | Webhook |
|
||||
|---------|:----------:|:----------:|:----------------:|:-------:|
|
||||
| Public entry (anonymous) | ✅ | | | |
|
||||
| Progressive fieldsets | ✅ | ✅ | | |
|
||||
| Branch rules (conditional routing) | ✅ | | | |
|
||||
| Team assignment + claim | ✅ | | | |
|
||||
| SLA timer | ✅ | | | |
|
||||
| Starlark automated stage | | ✅ | | ✅ |
|
||||
| db.insert (ext_data tables) | | ✅ | | ✅ |
|
||||
| notifications.send | | ✅ | | |
|
||||
| http.post (outbound webhook) | | | | ✅ |
|
||||
| connections.get (credentials) | | | | ✅ |
|
||||
| Signoff gate (required_role) | | ✅ | | |
|
||||
| Multi-party signoff (quorum) | | | ✅ | |
|
||||
| Rejection reroute | | ✅ | ✅ | |
|
||||
| Review cycle (loop) | | | ✅ | |
|
||||
|
||||
---
|
||||
|
||||
## Package 1: Bug Report Triage
|
||||
|
||||
**Slug:** `bug-report-triage`
|
||||
**Entry mode:** `public_link` — anyone with the link can submit
|
||||
**Tier:** browser (no Starlark needed)
|
||||
|
||||
### Stage Flow
|
||||
|
||||
```
|
||||
[submit] → [classify] → [fix-critical] → [verify]
|
||||
public team ↘ [fix-normal] ↗ team
|
||||
team
|
||||
```
|
||||
|
||||
### Stages
|
||||
|
||||
| # | Name | Mode | Audience | Key Feature |
|
||||
|---|------|------|----------|-------------|
|
||||
| 0 | submit | form | public | 2 fieldsets: "Report Details" (email, summary, component, severity) + "Reproduction" (steps, expected, actual) |
|
||||
| 1 | classify | form | team | Team reviews. Branch rules: severity=critical → fix-critical, else → fix-normal |
|
||||
| 2 | fix-critical | form | team | Senior team queue. SLA: 3600s |
|
||||
| 3 | fix-normal | form | team | Standard fix queue |
|
||||
| 4 | verify | review | team | Verify fix, close out |
|
||||
|
||||
### Branch Rules (stage 1)
|
||||
|
||||
```json
|
||||
[
|
||||
{"field": "severity", "op": "eq", "value": "critical", "target_stage": "fix-critical"},
|
||||
{"field": "severity", "op": "neq", "value": "critical", "target_stage": "fix-normal"}
|
||||
]
|
||||
```
|
||||
|
||||
### API Walkthrough
|
||||
|
||||
```bash
|
||||
# 1. Start public instance (no auth required)
|
||||
curl -X POST $BASE/api/v1/public/workflows/$WF_ID/start \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"data": {"reporter_email": "user@example.com", "summary": "Login broken", "component": "ui", "severity": "critical", "steps": "1. Click login", "expected": "Login page", "actual": "500 error"}}'
|
||||
|
||||
# Response includes entry_token for anonymous continuation
|
||||
# {"id": "inst-xxx", "entry_token": "tok-yyy", ...}
|
||||
|
||||
# 2. Resume (check status)
|
||||
curl $BASE/api/v1/public/workflows/resume/tok-yyy
|
||||
|
||||
# 3. Team member claims the classify assignment
|
||||
curl -X POST $BASE/api/v1/teams/$TEAM/assignments/$ASSIGN_ID/claim \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# 4. Advance classify stage (triggers branch rule)
|
||||
curl -X POST $BASE/api/v1/teams/$TEAM/instances/$INST/advance \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"data": {"severity": "critical", "notes": "Confirmed production issue"}}'
|
||||
# → Routes to fix-critical (not fix-normal)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Package 2: Employee Onboarding
|
||||
|
||||
**Slug:** `employee-onboarding`
|
||||
**Entry mode:** `team_only`
|
||||
**Tier:** starlark
|
||||
**Permissions:** `db.write`, `notifications.send`
|
||||
|
||||
### Stage Flow
|
||||
|
||||
```
|
||||
[new-hire-info] → [provision-accounts] → [manager-signoff] → [it-setup] → [welcome]
|
||||
team automated (Starlark) review + signoff team automated
|
||||
auto-advances reject → stage 0 auto-advances
|
||||
```
|
||||
|
||||
### Stages
|
||||
|
||||
| # | Name | Mode | Audience | Key Feature |
|
||||
|---|------|------|----------|-------------|
|
||||
| 0 | new-hire-info | form | team | 2 fieldsets: "Personal Info" + "Access Requirements" |
|
||||
| 1 | provision-accounts | automated | system | Starlark: db.insert provisioning records, notify HR |
|
||||
| 2 | manager-signoff | review | team | 1 approval required, required_role: "manager", reject → new-hire-info |
|
||||
| 3 | it-setup | form | team | IT confirms hardware + access |
|
||||
| 4 | welcome | automated | system | Starlark: log completion, send welcome notification |
|
||||
|
||||
### Starlark Hooks
|
||||
|
||||
**`on_provision(ctx)`** — Automated provisioning:
|
||||
|
||||
```python
|
||||
def on_provision(ctx):
|
||||
data = ctx["stage_data"]
|
||||
name = data.get("full_name", "unknown")
|
||||
dept = data.get("department", "general")
|
||||
|
||||
# Record provision in ext_data table
|
||||
db.insert("provisions", {
|
||||
"employee_name": name,
|
||||
"department": dept,
|
||||
"needs_vpn": str(data.get("needs_vpn", False)),
|
||||
"needs_admin": str(data.get("needs_admin", False)),
|
||||
"equipment": data.get("equipment", "standard laptop"),
|
||||
"status": "provisioned"
|
||||
})
|
||||
|
||||
# Notify the HR user who started this workflow
|
||||
notifications.send(
|
||||
ctx["started_by"],
|
||||
"Accounts provisioned for " + name,
|
||||
"Department: " + dept + ". Ready for manager review."
|
||||
)
|
||||
|
||||
return {"advance": True, "data": {
|
||||
"provision_status": "complete",
|
||||
"accounts": ["email", "vpn" if data.get("needs_vpn") else None, "admin" if data.get("needs_admin") else None]
|
||||
}}
|
||||
```
|
||||
|
||||
**`on_welcome(ctx)`** — Completion logging:
|
||||
|
||||
```python
|
||||
def on_welcome(ctx):
|
||||
data = ctx["stage_data"]
|
||||
|
||||
db.insert("onboarding_log", {
|
||||
"employee_name": data.get("full_name", ""),
|
||||
"department": data.get("department", ""),
|
||||
"completed_by": ctx["started_by"],
|
||||
"status": "complete"
|
||||
})
|
||||
|
||||
notifications.send(
|
||||
ctx["started_by"],
|
||||
"Onboarding complete: " + data.get("full_name", ""),
|
||||
"All stages finished. Welcome aboard!"
|
||||
)
|
||||
|
||||
return {"advance": True, "data": {"onboarding_complete": True}}
|
||||
```
|
||||
|
||||
### Signoff Gate (stage 2)
|
||||
|
||||
```json
|
||||
{
|
||||
"validation": {
|
||||
"required_approvals": 1,
|
||||
"required_role": "manager",
|
||||
"reject_action": "new-hire-info"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Team must have a custom "manager" role (via Team Admin > Members > Team Roles)
|
||||
- At least one team member with the "manager" role assigned
|
||||
|
||||
---
|
||||
|
||||
## Package 3: Content Approval
|
||||
|
||||
**Slug:** `content-approval`
|
||||
**Entry mode:** `team_only`
|
||||
**Tier:** browser
|
||||
|
||||
### Stage Flow
|
||||
|
||||
```
|
||||
[draft] → [review] → [publish]
|
||||
team team ↕ team
|
||||
[revision]
|
||||
team
|
||||
```
|
||||
|
||||
### Stages
|
||||
|
||||
| # | Name | Mode | Audience | Key Feature |
|
||||
|---|------|------|----------|-------------|
|
||||
| 0 | draft | form | team | Author submits title, body, category |
|
||||
| 1 | review | review | team | 2 approvals required. Reject → revision |
|
||||
| 2 | revision | form | team | Author revises. Branch rule always routes back to review |
|
||||
| 3 | publish | form | team | Final confirmation |
|
||||
|
||||
### Review Cycle
|
||||
|
||||
The rejection reroute creates a loop:
|
||||
|
||||
1. Author submits draft → enters review
|
||||
2. Reviewer A approves, Reviewer B rejects → `reject_action: "revision"` routes to stage 2
|
||||
3. Author revises → branch rule routes back to review (stage 1)
|
||||
4. Both reviewers approve → advances to publish (stage 3)
|
||||
|
||||
### Signoff Gate (stage 1)
|
||||
|
||||
```json
|
||||
{
|
||||
"validation": {
|
||||
"required_approvals": 2,
|
||||
"reject_action": "revision"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Package 4: Webhook Notifier
|
||||
|
||||
**Slug:** `webhook-notifier`
|
||||
**Entry mode:** `team_only`
|
||||
**Tier:** starlark
|
||||
**Permissions:** `api.http`, `connections.read`, `db.write`
|
||||
|
||||
### Stage Flow
|
||||
|
||||
```
|
||||
[configure] → [fire-webhook] → [result]
|
||||
team automated review
|
||||
(http.post)
|
||||
```
|
||||
|
||||
### Stages
|
||||
|
||||
| # | Name | Mode | Audience | Key Feature |
|
||||
|---|------|------|----------|-------------|
|
||||
| 0 | configure | form | team | URL + event name + payload |
|
||||
| 1 | fire-webhook | automated | system | Starlark: http.post to webhook URL |
|
||||
| 2 | result | review | team | Shows delivery status |
|
||||
|
||||
### Starlark Hook
|
||||
|
||||
**`on_fire(ctx)`** — Outbound HTTP:
|
||||
|
||||
```python
|
||||
def on_fire(ctx):
|
||||
data = ctx["stage_data"]
|
||||
url = data.get("webhook_url", "")
|
||||
|
||||
# Fallback to configured connection
|
||||
if not url:
|
||||
conn = connections.get("webhook")
|
||||
if conn:
|
||||
url = conn.get("url", "")
|
||||
|
||||
if not url:
|
||||
return {"error": "No webhook URL configured"}
|
||||
|
||||
payload = json.encode({
|
||||
"event": data.get("event_name", "workflow.notify"),
|
||||
"instance_id": ctx["instance_id"],
|
||||
"workflow_id": ctx["workflow_id"],
|
||||
"data": data
|
||||
})
|
||||
|
||||
resp = http.post(url, body=payload, headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Switchboard-Event": data.get("event_name", "workflow.notify")
|
||||
})
|
||||
|
||||
# Log the delivery
|
||||
db.insert("webhook_log", {
|
||||
"url": url,
|
||||
"status_code": str(resp["status"]),
|
||||
"event": data.get("event_name", ""),
|
||||
"instance_id": ctx["instance_id"]
|
||||
})
|
||||
|
||||
return {"advance": True, "data": {
|
||||
"delivery_status": "delivered" if int(resp["status"]) < 400 else "failed",
|
||||
"status_code": resp["status"],
|
||||
"response_body": resp["body"][:500]
|
||||
}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Demo Surface
|
||||
|
||||
A browser-tier surface (`workflow-demo`) auto-installed on first run. Provides:
|
||||
|
||||
- Workflow cards with feature badges and stage flow diagrams
|
||||
- "Try It" buttons for interactive exploration
|
||||
- Starlark code viewer showing hook source
|
||||
- API curl examples for each operation
|
||||
|
||||
Can be uninstalled by admin like any other package.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Build all example packages
|
||||
cd packages && ./build.sh bug-report-triage employee-onboarding content-approval webhook-notifier workflow-demo
|
||||
|
||||
# Install via admin API
|
||||
for pkg in dist/bug-report-triage.pkg dist/employee-onboarding.pkg dist/content-approval.pkg dist/webhook-notifier.pkg dist/workflow-demo.pkg; do
|
||||
curl -X POST $BASE/api/v1/admin/packages/install \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-F "file=@$pkg"
|
||||
done
|
||||
```
|
||||
|
||||
Or with bundled packages (v0.3.8+): just `docker compose up` — all examples are auto-installed.
|
||||
@@ -1,153 +0,0 @@
|
||||
# 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 |
|
||||
@@ -1,49 +0,0 @@
|
||||
# Extension Lifecycle: Permanent vs PoC
|
||||
|
||||
## Overview
|
||||
|
||||
Packages (extensions) fall into two categories: **permanent** and **PoC** (proof-of-concept).
|
||||
This classification drives install-by-default behavior and maintenance expectations.
|
||||
|
||||
## Permanent Packages
|
||||
|
||||
Permanent packages are part of the platform kernel or essential surfaces. They are always
|
||||
available and maintained by core contributors.
|
||||
|
||||
| Package | Reason |
|
||||
|------------------|--------------------------------|
|
||||
| admin | System administration surface |
|
||||
| team-admin | Team management surface |
|
||||
| settings | User settings surface |
|
||||
| login | Authentication surface |
|
||||
| welcome | Onboarding / empty-state |
|
||||
|
||||
## PoC Packages
|
||||
|
||||
Everything else in `packages/` is PoC. These demonstrate the extension model but are not
|
||||
required for platform operation. Examples: tasks, schedules, dashboard, editor, git-board,
|
||||
icd-test-runner, sdk-test-runner.
|
||||
|
||||
Retired builtins (csv-table, diff-viewer, js-sandbox, katex-renderer, mermaid-renderer,
|
||||
regex-tester) are PoC packages migrated from the kernel during the v0.2.9 retirement.
|
||||
|
||||
## Graduation Criteria
|
||||
|
||||
A PoC package may be promoted to permanent when all of the following are met:
|
||||
|
||||
1. **Active usage** — 3+ active users or teams depend on it.
|
||||
2. **Test coverage** — At least basic smoke tests exist (Starlark or integration).
|
||||
3. **No kernel special-casing** — The package operates entirely through the public extension
|
||||
API. No `if pkg == "X"` branches in core.
|
||||
4. **Documentation** — README with install instructions, config options, and screenshots.
|
||||
5. **Maintenance owner** — A named contributor commits to reviewing issues.
|
||||
|
||||
## Deprecation
|
||||
|
||||
Packages that no longer meet graduation criteria (e.g., zero usage for 6 months) may be
|
||||
archived. Archived packages remain installable but receive no updates.
|
||||
|
||||
## Install Model
|
||||
|
||||
All packages use explicit installation: `POST /api/v1/packages/:id/install`. There is no
|
||||
auto-install. The kernel ships clean; teams choose what they need.
|
||||
@@ -1,57 +0,0 @@
|
||||
# Trigger Composition: Triggers, Schedules, and Workflows
|
||||
|
||||
## Overview
|
||||
|
||||
Three automation primitives exist in the platform: **triggers** (event-driven), **schedules**
|
||||
(time-driven), and **workflows** (multi-step state machines). This document defines how they
|
||||
compose without creating circular dependencies.
|
||||
|
||||
## Composition Rules
|
||||
|
||||
### Triggers can start workflows
|
||||
|
||||
A trigger's Starlark handler may call `workflow.start(workflow_id, data)` to create a new
|
||||
workflow instance. This is the primary entry point for event-driven workflow initiation.
|
||||
|
||||
Example: a `ticket.created` trigger fires and starts an approval workflow.
|
||||
|
||||
### Schedules can start workflows
|
||||
|
||||
A schedule's Starlark handler may also call `workflow.start(...)`. This enables time-based
|
||||
batch processing workflows.
|
||||
|
||||
Example: a daily 9am schedule starts a standup collection workflow.
|
||||
|
||||
### Workflows emit events that triggers listen to
|
||||
|
||||
Workflow lifecycle events (`workflow.started`, `workflow.advanced`, `workflow.completed`,
|
||||
`workflow.signoff`, etc.) are published on the event bus. Triggers may subscribe to these
|
||||
events to perform side effects (notifications, external API calls, audit logging).
|
||||
|
||||
### Workflows cannot start triggers or schedules
|
||||
|
||||
Workflows do not directly invoke triggers or create schedules. Workflow Starlark hooks
|
||||
operate within the workflow's own context and do not have access to trigger/schedule
|
||||
management APIs. This prevents uncontrolled fan-out.
|
||||
|
||||
## Circular Invocation Guard
|
||||
|
||||
The automated stage cycle guard (max 10 consecutive automated stages) already prevents
|
||||
infinite loops within a single workflow. Cross-workflow cycles are prevented by this rule:
|
||||
|
||||
**A workflow event handler (trigger) may start a new workflow instance, but the new instance
|
||||
is not processed synchronously.** It enters the work queue as a separate execution context.
|
||||
The bus does not re-enter the originating workflow's advance call.
|
||||
|
||||
This means:
|
||||
- `workflow.completed` trigger → `workflow.start(B)` is allowed (async).
|
||||
- Workflow A automated stage → `workflow.start(B)` where B immediately completes and
|
||||
triggers A again → allowed but rate-limited by the cycle guard on each individual workflow.
|
||||
|
||||
## Not Supported (by design)
|
||||
|
||||
- Triggers cannot modify a running workflow instance (read-only access via Starlark builtins).
|
||||
- Schedules cannot advance or cancel workflow instances directly.
|
||||
- Workflows cannot create, modify, or delete triggers or schedules.
|
||||
|
||||
These restrictions keep the composition model simple and auditable.
|
||||
806
docs/DESIGN-WORKFLOW-REDESIGN-0.2.6.md
Normal file
806
docs/DESIGN-WORKFLOW-REDESIGN-0.2.6.md
Normal file
@@ -0,0 +1,806 @@
|
||||
# Workflow Redesign — v0.2.6
|
||||
|
||||
**Status:** Draft
|
||||
**Branch:** `feat/workflow-redesign-v0.2.6`
|
||||
**Date:** 2026-03-27
|
||||
**Context:** Mapping viable ideas from chat-switchboard v0.39.x onto core's extension-first architecture.
|
||||
|
||||
---
|
||||
|
||||
## 1. Guiding Principles
|
||||
|
||||
1. **Workflows are kernel.** Definitions, instances, the stage graph, form validation, and
|
||||
assignment queues are platform primitives — not extensions.
|
||||
2. **Execution is delegated.** How a stage renders or collects data is an extension concern.
|
||||
The kernel says *what* a stage needs; a surface package provides the *how*.
|
||||
3. **No chat assumptions.** The kernel never references personas, channels, or AI providers.
|
||||
A chat extension can participate in workflows, but the workflow engine does not depend on it.
|
||||
4. **Public access is a platform capability.** Anonymous/public sessions are not workflow-specific —
|
||||
they are a kernel-level feature that any surface can opt into. Workflows *consume* public
|
||||
access; they don't own it. See §15.
|
||||
5. **Edit existing migrations.** Per the design-decisions log: no migration chains until the
|
||||
schema is in production. New tables get new files; modified tables are edited in place.
|
||||
|
||||
---
|
||||
|
||||
## 2. Disposition Index
|
||||
|
||||
Every item below is classified:
|
||||
|
||||
| Tag | Meaning |
|
||||
|-----|---------|
|
||||
| **🗑 TRASH** | Remove from core. Vestigial chat-switchboard concept that doesn't fit. |
|
||||
| **✏️ MOD** | Exists in core today — modify in place. |
|
||||
| **➕ ADD** | New to core. Requires new code/tables. |
|
||||
| **✅ KEEP** | Already correct in core. No changes needed. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Schema Changes
|
||||
|
||||
### 3a. `007_workflows.sql` — edit in place
|
||||
|
||||
#### `workflows` table
|
||||
|
||||
| Column | Disposition | Action |
|
||||
|--------|-------------|--------|
|
||||
| `entry_mode` | ✏️ MOD | Current values: `public_link`, `team_only`. Keep the column but change its meaning — it becomes a workflow-level default/flag that says "this workflow has at least one public stage." The real visibility control moves to per-stage `audience`. See note below. |
|
||||
| All other columns | ✅ KEEP | Clean. No chat coupling. |
|
||||
|
||||
**Note on `entry_mode`:** Chat-switchboard treated this as a binary toggle — the whole
|
||||
workflow was either public or not. Real workflows mix audiences: internal setup → public
|
||||
form → internal review → public confirmation. The per-stage `audience` field (see below)
|
||||
is the source of truth. `entry_mode` on the workflow becomes a convenience flag: if any
|
||||
stage has `audience = 'public'`, the workflow is public-entry eligible. This avoids a
|
||||
full schema break — existing code that checks `entry_mode` still works, it just gets set
|
||||
automatically when stages are saved.
|
||||
|
||||
#### `workflow_stages` table
|
||||
|
||||
| Column | Disposition | Action |
|
||||
|--------|-------------|--------|
|
||||
| `persona_id` | 🗑 TRASH | Drop column. Personas are a chat-extension concept. If a stage needs an AI participant, the stage's `surface_pkg_id` extension handles that internally. The comment in the migration already acknowledges this ("personas are extensions now") but the column is still there. Remove it. |
|
||||
| `stage_mode` CHECK | ✏️ MOD | Current: `form_only`, `form_chat`, `review`, `custom`. Replace with: `form`, `review`, `delegated`, `automated`. See §4 for definitions. `form_chat` is trash (chat coupling). `form_only` renames to `form`. `custom` renames to `delegated` (clearer intent). `automated` is new (no UI, Starlark-only). |
|
||||
| `history_mode` | 🗑 TRASH | Drop column. This controlled how much chat history a persona saw across stages. Core has no chat history. If a delegated surface needs context management, it reads `stage_data` — that's the contract. |
|
||||
| `audience` | ➕ ADD | `TEXT NOT NULL DEFAULT 'team'` with CHECK `('team', 'public', 'system')`. Controls who interacts with this stage. `team` = authenticated team members only (internal). `public` = anonymous visitors via entry token (the "public-ish" stage). `system` = no human interaction, automated only. A workflow can freely alternate: team→public→team→public. The kernel enforces this: public stages are accessible via token auth, team stages require JWT, system stages have no UI. |
|
||||
| `stage_type` | ➕ ADD | `TEXT NOT NULL DEFAULT 'simple'` with CHECK `('simple', 'dynamic', 'automated')`. Drives the graph engine: `simple` = declarative rules only, `dynamic` = Starlark hook resolves next stage, `automated` = no human UI, fires hook on entry and auto-advances. |
|
||||
| `starlark_hook` | ➕ ADD | `TEXT` (nullable). Package-qualified entry point for dynamic/automated stages. Format: `package_id:entry_point`. NULL for simple stages. |
|
||||
| `branch_rules` | ➕ ADD | `JSONB NOT NULL DEFAULT '[]'`. Replaces the overloaded `transition_rules` for simple stage branching. Array of `{field, op, value, target_stage}`. Evaluated before `starlark_hook`, before ordinal fallback. |
|
||||
| `transition_rules` | ✏️ MOD | Rename to `stage_config`. This JSONB blob was doing double duty (routing rules + on_advance hooks + auto_assign). Routing moves to `branch_rules`. What remains is stage-specific config that the kernel or surface reads: `{on_advance: {package_id, entry_point}, auto_assign: "round_robin"}`. |
|
||||
| `assignment_team_id` | ✅ KEEP | Clean. Kernel concept. |
|
||||
| `form_template` | ✅ KEEP | Kernel concern — structured data schema. |
|
||||
| `auto_transition` | ✅ KEEP | Kernel concern — skip human interaction. |
|
||||
| `surface_pkg_id` | ✅ KEEP | The delegation pointer. Required for `delegated` mode stages. |
|
||||
| `sla_seconds` | ✅ KEEP | Kernel concern — time budget per stage. |
|
||||
|
||||
#### `workflow_versions` table
|
||||
|
||||
| Column | Disposition | Notes |
|
||||
|--------|-------------|-------|
|
||||
| All existing columns | ✅ KEEP | Immutable snapshots. No changes needed. |
|
||||
|
||||
### 3b. `007_workflows.sql` — add new tables (same file)
|
||||
|
||||
#### ➕ ADD `workflow_instances`
|
||||
|
||||
Replaces the channel-based instance model from chat-switchboard. This is the execution record.
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS workflow_instances (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workflow_id UUID NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
|
||||
workflow_version INTEGER NOT NULL,
|
||||
current_stage INTEGER NOT NULL DEFAULT 0,
|
||||
stage_data JSONB NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'completed', 'cancelled', 'stale', 'error')),
|
||||
started_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
entry_token TEXT, -- for public_link resumption
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
stage_entered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_wf_instances_workflow
|
||||
ON workflow_instances(workflow_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_wf_instances_status
|
||||
ON workflow_instances(status) WHERE status = 'active';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_wf_instances_token
|
||||
ON workflow_instances(entry_token) WHERE entry_token IS NOT NULL;
|
||||
|
||||
DROP TRIGGER IF EXISTS wf_instances_updated_at ON workflow_instances;
|
||||
CREATE TRIGGER wf_instances_updated_at BEFORE UPDATE ON workflow_instances
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
```
|
||||
|
||||
**Rationale:** Chat-switchboard used `channels` as instances — that table carried message trees,
|
||||
AI context windows, participant lists, and other chat concerns. Core needs a purpose-built table
|
||||
that holds only workflow execution state: which version, which stage, accumulated data, lifecycle status.
|
||||
|
||||
#### ➕ ADD `workflow_assignments`
|
||||
|
||||
Dropped during the fork ("channel-dependent, rebuild as needed" — see 007 header comment).
|
||||
Now rebuilt as a kernel primitive.
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS workflow_assignments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
instance_id UUID NOT NULL REFERENCES workflow_instances(id) ON DELETE CASCADE,
|
||||
stage INTEGER NOT NULL,
|
||||
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||
assigned_to UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
status TEXT NOT NULL DEFAULT 'unassigned'
|
||||
CHECK (status IN ('unassigned', 'claimed', 'completed', 'cancelled')),
|
||||
review_data JSONB NOT NULL DEFAULT '{}',
|
||||
claimed_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_wf_assignments_instance
|
||||
ON workflow_assignments(instance_id, stage);
|
||||
CREATE INDEX IF NOT EXISTS idx_wf_assignments_team_status
|
||||
ON workflow_assignments(team_id, status) WHERE status IN ('unassigned', 'claimed');
|
||||
CREATE INDEX IF NOT EXISTS idx_wf_assignments_user
|
||||
ON workflow_assignments(assigned_to) WHERE assigned_to IS NOT NULL;
|
||||
```
|
||||
|
||||
**Claim lock:** Same optimistic pattern from chat-switchboard:
|
||||
`UPDATE ... WHERE id = $1 AND status = 'unassigned'` — if `rows_affected == 0`, already claimed.
|
||||
|
||||
### 3c. New migration file: `012_workflow_instances.sql`
|
||||
|
||||
Wait — per the principle, new *tables* get new files. But we're also editing 007. Decision:
|
||||
|
||||
**Put the new tables in 007_workflows.sql.** The schema isn't in production. Keep all workflow
|
||||
DDL in one file. When the schema *is* in production (post-MVP), new additions get numbered
|
||||
migrations. This is consistent with the existing design-decisions log entry:
|
||||
"No new migrations pre-MVP."
|
||||
|
||||
---
|
||||
|
||||
## 4. Stage Mode Redesign
|
||||
|
||||
### Current (trash/rename)
|
||||
|
||||
| Current Mode | Disposition | Rationale |
|
||||
|-------------|-------------|-----------|
|
||||
| `form_only` | ✏️ MOD → `form` | Rename. Drop the `_only` suffix — there's no `form_chat` to distinguish from anymore. |
|
||||
| `form_chat` | 🗑 TRASH | Chat coupling. If you want a form + AI conversation, use `delegated` with a chat surface package. |
|
||||
| `review` | ✅ KEEP | Team member reviews accumulated data. Pure kernel concept. |
|
||||
| `custom` | ✏️ MOD → `delegated` | Rename for clarity. "Custom" is vague. "Delegated" makes the contract explicit: the kernel delegates stage execution to `surface_pkg_id`. |
|
||||
|
||||
### New modes
|
||||
|
||||
| Mode | Disposition | Description |
|
||||
|------|-------------|-------------|
|
||||
| `form` | ✏️ MOD (renamed) | Kernel renders `form_template`, validates submission, merges into `stage_data`. No extension needed. |
|
||||
| `review` | ✅ KEEP | Assignment queue stage. Team member claims, reviews `stage_data`, adds `review_data`, completes. Kernel-rendered. |
|
||||
| `delegated` | ✏️ MOD (renamed) | Kernel hands off to `surface_pkg_id`. The surface package receives the instance context (stage_data, form_template, metadata) and calls back to advance. This is where chat, AI, or any custom UX lives. |
|
||||
| `automated` | ➕ ADD | No UI. On stage entry, kernel fires `starlark_hook`, merges returned data into `stage_data`, and auto-advances. For enrichment, API calls, validation, routing decisions. |
|
||||
|
||||
### Stage type vs stage mode
|
||||
|
||||
These are orthogonal:
|
||||
|
||||
- **`stage_type`** controls *how the next stage is chosen*: `simple` (declarative branch_rules), `dynamic` (Starlark hook returns target), `automated` (hook + auto-advance).
|
||||
- **`stage_mode`** controls *how the stage executes*: `form`, `review`, `delegated`, `automated`.
|
||||
|
||||
An `automated` stage_type with `automated` stage_mode is the common case for system stages,
|
||||
but you can have a `dynamic` stage_type with `form` stage_mode (the form collects data, then a
|
||||
Starlark hook decides where to go next based on the submission).
|
||||
|
||||
---
|
||||
|
||||
## 5. Model Changes
|
||||
|
||||
### `server/models/workflow.go`
|
||||
|
||||
#### WorkflowStage struct — ✏️ MOD
|
||||
|
||||
```go
|
||||
// TRASH: Remove these fields
|
||||
// PersonaID *string `json:"persona_id,omitempty"`
|
||||
// HistoryMode string `json:"history_mode"`
|
||||
|
||||
// MOD: Rename transition_rules → stage_config
|
||||
// TransitionRules json.RawMessage `json:"transition_rules"`
|
||||
StageConfig json.RawMessage `json:"stage_config"`
|
||||
|
||||
// ADD: New fields
|
||||
Audience string `json:"audience"` // team | public | system
|
||||
StageType string `json:"stage_type"` // simple | dynamic | automated
|
||||
StarlarkHook *string `json:"starlark_hook,omitempty"` // package_id:entry_point
|
||||
BranchRules json.RawMessage `json:"branch_rules"` // [{field, op, value, target_stage}]
|
||||
```
|
||||
|
||||
#### Stage mode constants — ✏️ MOD
|
||||
|
||||
```go
|
||||
// TRASH
|
||||
// StageModeFormOnly = "form_only"
|
||||
// StageModeFormChat = "form_chat"
|
||||
|
||||
// MOD (rename)
|
||||
StageModeForm = "form" // was form_only
|
||||
StageModeDelegated = "delegated" // was custom
|
||||
|
||||
// KEEP
|
||||
StageModeReview = "review"
|
||||
|
||||
// ADD
|
||||
StageModeAutomated = "automated"
|
||||
|
||||
// ADD: Stage type constants
|
||||
StageTypeSimple = "simple"
|
||||
StageTypeDynamic = "dynamic"
|
||||
StageTypeAutomated = "automated"
|
||||
|
||||
// ADD: Audience constants
|
||||
AudienceTeam = "team"
|
||||
AudiencePublic = "public"
|
||||
AudienceSystem = "system"
|
||||
```
|
||||
|
||||
#### ➕ ADD: WorkflowInstance model
|
||||
|
||||
```go
|
||||
type WorkflowInstance struct {
|
||||
ID string `json:"id"`
|
||||
WorkflowID string `json:"workflow_id"`
|
||||
WorkflowVersion int `json:"workflow_version"`
|
||||
CurrentStage int `json:"current_stage"`
|
||||
StageData json.RawMessage `json:"stage_data"`
|
||||
Status string `json:"status"` // active | completed | cancelled | stale | error
|
||||
StartedBy *string `json:"started_by,omitempty"`
|
||||
EntryToken *string `json:"entry_token,omitempty"`
|
||||
Metadata json.RawMessage `json:"metadata"`
|
||||
StageEnteredAt time.Time `json:"stage_entered_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
```
|
||||
|
||||
#### ➕ ADD: WorkflowAssignment model
|
||||
|
||||
```go
|
||||
type WorkflowAssignment struct {
|
||||
ID string `json:"id"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
Stage int `json:"stage"`
|
||||
TeamID string `json:"team_id"`
|
||||
AssignedTo *string `json:"assigned_to,omitempty"`
|
||||
Status string `json:"status"` // unassigned | claimed | completed | cancelled
|
||||
ReviewData json.RawMessage `json:"review_data"`
|
||||
ClaimedAt *time.Time `json:"claimed_at,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
```
|
||||
|
||||
#### TypedFormTemplate, FormField, FormValidation — ✅ KEEP
|
||||
|
||||
All form types are clean kernel code. No changes.
|
||||
|
||||
#### ValidateFormData — ✅ KEEP
|
||||
|
||||
Pure data validation. No chat coupling.
|
||||
|
||||
---
|
||||
|
||||
## 6. Store Interface Changes
|
||||
|
||||
### `server/store/workflow_iface.go` — ✏️ MOD + ➕ ADD
|
||||
|
||||
Current interface is definition-only (CRUD workflows + stages + versions).
|
||||
Add instance and assignment operations.
|
||||
|
||||
```go
|
||||
type WorkflowStore interface {
|
||||
// ── Definition CRUD (KEEP — no changes) ──────────
|
||||
Create(ctx context.Context, w *models.Workflow) error
|
||||
GetByID(ctx context.Context, id string) (*models.Workflow, error)
|
||||
GetBySlug(ctx context.Context, teamID *string, slug string) (*models.Workflow, error)
|
||||
Update(ctx context.Context, id string, patch models.WorkflowPatch) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
ListForTeam(ctx context.Context, teamID string) ([]models.Workflow, error)
|
||||
ListGlobal(ctx context.Context) ([]models.Workflow, error)
|
||||
|
||||
// ── Stage CRUD (KEEP — no changes) ───────────────
|
||||
CreateStage(ctx context.Context, s *models.WorkflowStage) error
|
||||
ListStages(ctx context.Context, workflowID string) ([]models.WorkflowStage, error)
|
||||
UpdateStage(ctx context.Context, s *models.WorkflowStage) error
|
||||
DeleteStage(ctx context.Context, id string) error
|
||||
ReorderStages(ctx context.Context, workflowID string, orderedIDs []string) error
|
||||
|
||||
// ── Versioning (KEEP — no changes) ───────────────
|
||||
Publish(ctx context.Context, v *models.WorkflowVersion) error
|
||||
GetVersion(ctx context.Context, workflowID string, versionNumber int) (*models.WorkflowVersion, error)
|
||||
GetLatestVersion(ctx context.Context, workflowID string) (*models.WorkflowVersion, error)
|
||||
|
||||
// ── Instances (ADD) ──────────────────────────────
|
||||
CreateInstance(ctx context.Context, inst *models.WorkflowInstance) error
|
||||
GetInstance(ctx context.Context, id string) (*models.WorkflowInstance, error)
|
||||
GetInstanceByToken(ctx context.Context, token string) (*models.WorkflowInstance, error)
|
||||
UpdateInstance(ctx context.Context, id string, patch models.InstancePatch) error
|
||||
ListInstances(ctx context.Context, workflowID string, status string) ([]models.WorkflowInstance, error)
|
||||
AdvanceStage(ctx context.Context, id string, nextStage int, mergeData json.RawMessage) error
|
||||
CompleteInstance(ctx context.Context, id string) error
|
||||
CancelInstance(ctx context.Context, id string) error
|
||||
MarkStale(ctx context.Context, olderThan time.Duration) (int, error)
|
||||
|
||||
// ── Assignments (ADD) ────────────────────────────
|
||||
CreateAssignment(ctx context.Context, a *models.WorkflowAssignment) error
|
||||
ClaimAssignment(ctx context.Context, id string, userID string) error // optimistic lock
|
||||
UnclaimAssignment(ctx context.Context, id string) error
|
||||
CompleteAssignment(ctx context.Context, id string, reviewData json.RawMessage) error
|
||||
CancelAssignment(ctx context.Context, id string) error
|
||||
ListAssignmentsByTeam(ctx context.Context, teamID string, status string) ([]models.WorkflowAssignment, error)
|
||||
ListAssignmentsByUser(ctx context.Context, userID string) ([]models.WorkflowAssignment, error)
|
||||
ListAssignmentsByInstance(ctx context.Context, instanceID string) ([]models.WorkflowAssignment, error)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Handler Changes
|
||||
|
||||
### `server/handlers/workflows.go` — ✏️ MOD
|
||||
|
||||
**Stage CRUD updates** — reflect new field names in CreateStage/UpdateStage:
|
||||
- Remove `persona_id` and `history_mode` from bind/validation.
|
||||
- Add `stage_type`, `starlark_hook`, `branch_rules` to bind/validation.
|
||||
- Rename `transition_rules` → `stage_config` in bind/validation.
|
||||
- Update `stage_mode` CHECK to new set: `form`, `review`, `delegated`, `automated`.
|
||||
- Validate: `delegated` requires `surface_pkg_id`. `dynamic`/`automated` stage_type requires `starlark_hook`.
|
||||
|
||||
### `server/handlers/workflow_hooks.go` — ✏️ MOD
|
||||
|
||||
**Rename:** `channelID` parameter → `instanceID` in `FireOnAdvanceHook` signature.
|
||||
The hook context dict changes from `{channel_id, ...}` to `{instance_id, ...}`.
|
||||
|
||||
The `jsonToStarlark` helper referenced on line 81 lives in
|
||||
`server/handlers/starlark_helpers.go`. Consider consolidating with `goToStarlark` in
|
||||
`server/triggers/event.go` — two independent Go→Starlark converters is tech debt.
|
||||
|
||||
### `server/handlers/workflow_team.go` — ✅ KEEP
|
||||
|
||||
Team-scoped wrappers. Clean delegation pattern. No changes.
|
||||
|
||||
### `server/handlers/workflow_packages.go` — ✏️ MOD
|
||||
|
||||
Update `workflowPkgStage` struct:
|
||||
- Remove `PersonaID`, `HistoryMode` fields.
|
||||
- Add `StageType`, `StarlarkHook`, `BranchRules` fields.
|
||||
- Rename `TransitionRules` → `StageConfig`.
|
||||
|
||||
Update `ExportWorkflowPackage` and `InstallWorkflowFromManifest` accordingly.
|
||||
|
||||
### ➕ ADD `server/handlers/workflow_instances.go`
|
||||
|
||||
New handler file for instance lifecycle:
|
||||
|
||||
```
|
||||
POST /api/v1/workflows/:id/start → Start (create instance from latest published version)
|
||||
GET /api/v1/workflows/instances/:iid → GetInstance
|
||||
POST /api/v1/workflows/instances/:iid/advance → Advance (submit stage data, resolve next)
|
||||
POST /api/v1/workflows/instances/:iid/cancel → Cancel
|
||||
GET /api/v1/workflows/:id/instances → ListInstances (by workflow, optionally by status)
|
||||
```
|
||||
|
||||
Public entry (for `public_link` workflows):
|
||||
```
|
||||
POST /api/v1/workflows/entry/:slug → StartPublic (no auth, generates entry_token)
|
||||
GET /api/v1/workflows/entry/:token → ResumePublic (retrieve instance by token)
|
||||
POST /api/v1/workflows/entry/:token/advance → AdvancePublic (submit data via token)
|
||||
```
|
||||
|
||||
### ➕ ADD `server/handlers/workflow_assignments.go`
|
||||
|
||||
New handler file for the assignment queue:
|
||||
|
||||
```
|
||||
GET /api/v1/teams/:teamId/assignments → ListTeamAssignments (filterable by status)
|
||||
POST /api/v1/teams/:teamId/assignments/:id/claim → Claim
|
||||
POST /api/v1/teams/:teamId/assignments/:id/unclaim → Unclaim
|
||||
POST /api/v1/teams/:teamId/assignments/:id/complete → Complete (with review_data)
|
||||
POST /api/v1/teams/:teamId/assignments/:id/cancel → Cancel
|
||||
GET /api/v1/assignments/mine → ListMyAssignments (across teams)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Routing Engine Changes
|
||||
|
||||
### `server/workflow/routing.go` — ✏️ MOD
|
||||
|
||||
The existing `ResolveNextStage` evaluates `transition_rules.conditions[]`. This needs to
|
||||
become a two-phase resolution that respects the new `stage_type`:
|
||||
|
||||
```
|
||||
Phase 1: Evaluate branch_rules (declarative, for simple + dynamic types)
|
||||
Phase 2: If no match AND stage_type == dynamic → fire starlark_hook
|
||||
Phase 3: Fallback → currentStage + 1
|
||||
```
|
||||
|
||||
For `automated` stage_type, the engine fires `starlark_hook` on entry (not for routing — for
|
||||
data enrichment), then routes via branch_rules or ordinal fallback. The routing call happens
|
||||
*after* the hook returns.
|
||||
|
||||
**Concrete changes:**
|
||||
- `ResolveNextStage` signature: add `stageType string, starlarkHook *string, runner *sandbox.Runner` parameters.
|
||||
- Extract branch_rules evaluation from the current `TransitionRulesWithConditions` (which reads from `transition_rules` JSON) into a dedicated function that reads from the new `branch_rules` field.
|
||||
- `TransitionRulesWithConditions` struct → rename to `StageConfig`, remove `Conditions` field (moved to branch_rules), keep `AutoAssign` and `OnAdvance`.
|
||||
|
||||
### `server/workflow/` — ➕ ADD `engine.go`
|
||||
|
||||
New file: the stage execution engine. Orchestrates the advance lifecycle:
|
||||
|
||||
```
|
||||
1. Load instance + version snapshot
|
||||
2. Validate current stage allows advancement (status checks)
|
||||
3. If current stage has form_template → validate submitted data
|
||||
4. Merge submitted data into stage_data
|
||||
5. Fire on_advance hook (if configured in stage_config)
|
||||
6. Resolve next stage (branch_rules → starlark_hook → ordinal)
|
||||
7. If next stage is past last stage → complete instance
|
||||
8. If next stage is automated → fire its hook, recurse to step 6
|
||||
9. If next stage has assignment_team_id → create WorkflowAssignment
|
||||
10. Update instance (current_stage, stage_data, stage_entered_at)
|
||||
11. Emit bus events (workflow.advanced, workflow.assigned, workflow.completed)
|
||||
```
|
||||
|
||||
### `server/workflow/` — ➕ ADD `automated.go`
|
||||
|
||||
Handles `automated` stage execution: fire the Starlark hook, merge returned data, and
|
||||
auto-advance. Includes a cycle guard (max 10 consecutive automated stages) to prevent
|
||||
infinite loops from misconfigured workflows.
|
||||
|
||||
---
|
||||
|
||||
## 9. Event Bus Updates
|
||||
|
||||
### `server/events/types.go` — ✏️ MOD
|
||||
|
||||
Current workflow events are fine but incomplete. Add:
|
||||
|
||||
| Event | Direction | Disposition | Trigger |
|
||||
|-------|-----------|-------------|---------|
|
||||
| `workflow.assigned` | DirToClient | ✅ KEEP | Assignment created |
|
||||
| `workflow.claimed` | DirToClient | ✅ KEEP | Assignment claimed |
|
||||
| `workflow.advanced` | DirToClient | ✅ KEEP | Stage transition |
|
||||
| `workflow.completed` | DirToClient | ✅ KEEP | Instance completed |
|
||||
| `workflow.cancelled` | DirToClient | ➕ ADD | Instance cancelled |
|
||||
| `workflow.started` | DirToClient | ➕ ADD | Instance created |
|
||||
| `workflow.sla.warning` | DirToClient | ➕ ADD | SLA at 80% threshold |
|
||||
| `workflow.sla.breached` | DirToClient | ➕ ADD | SLA exceeded |
|
||||
| `workflow.error` | DirLocal | ➕ ADD | Hook execution failure |
|
||||
|
||||
---
|
||||
|
||||
## 10. Starlark Module Updates
|
||||
|
||||
### `server/sandbox/workflow_module.go` — ✏️ MOD
|
||||
|
||||
The `BuildWorkflowModule` (in `workflow_module.go`, not `modules.go`) currently exists but
|
||||
targets the old channel-based model. It currently only exposes `get_definition`.
|
||||
Rebuild to expose instance-oriented operations:
|
||||
|
||||
```python
|
||||
# Available to extensions with workflow.read or workflow.write permission
|
||||
workflow.get_instance(instance_id) # → instance dict
|
||||
workflow.get_stage_data(instance_id) # → stage_data dict
|
||||
workflow.set_stage_data(instance_id, data) # → merge into stage_data
|
||||
workflow.advance(instance_id) # → trigger advance (write perm)
|
||||
workflow.get_assignments(instance_id) # → assignment list
|
||||
```
|
||||
|
||||
The old `workflow_advance()` function that chat-switchboard's personas called is gone.
|
||||
Extensions call `workflow.advance()` which delegates to the same engine as the HTTP handler.
|
||||
|
||||
---
|
||||
|
||||
## 11. Salvage Map from chat-switchboard v0.39.x
|
||||
|
||||
What was planned there, and what happens to each idea in core:
|
||||
|
||||
### v0.39.0 — Stage Graph Engine + Form Builder
|
||||
|
||||
| Component | Disposition | Core equivalent |
|
||||
|-----------|-------------|-----------------|
|
||||
| `stage_type` enum | ➕ ADD | Adopted directly: `simple`, `dynamic`, `automated` |
|
||||
| `BranchRule` struct | ➕ ADD | Adopted as `branch_rules` JSONB on `workflow_stages` |
|
||||
| Starlark hook integration | ➕ ADD | `starlark_hook` column + engine integration |
|
||||
| Graph engine (`server/engine/graph.go`) | ➕ ADD | `server/workflow/engine.go` in core |
|
||||
| Automated stage runner | ➕ ADD | `server/workflow/automated.go` in core |
|
||||
| Visual form builder (5 Preact components) | 🗑 TRASH *for now* | The form builder was tightly coupled to chat-switchboard's stage editor surface. Core doesn't ship a workflow editor surface (that's an extension). The form *schema* is kernel; the form *builder UI* is a surface package concern. Revisit when a workflow-admin surface is built. |
|
||||
| Stage type badges in UI | 🗑 TRASH | Same — UI is extension territory. |
|
||||
|
||||
### v0.39.1 — Stage Reorder + Bulk Ops
|
||||
|
||||
| Component | Disposition | Core equivalent |
|
||||
|-----------|-------------|-----------------|
|
||||
| Drag-and-drop reorder | 🗑 TRASH | UI concern. The kernel `ReorderStages` endpoint already exists. |
|
||||
| Bulk assignment ops | ➕ ADD (later) | Useful but not blocking. Defer to v0.2.7+. The individual claim/unclaim/cancel endpoints come first. |
|
||||
|
||||
### v0.39.2 — SLA Alerting
|
||||
|
||||
| Component | Disposition | Core equivalent |
|
||||
|-----------|-------------|-----------------|
|
||||
| Periodic SLA scanner | ➕ ADD | Kernel scheduled goroutine (like the existing staleness sweep). Query active instances with `sla_seconds` configured, compute breach from `stage_entered_at`. |
|
||||
| `sla_notified_at` on instances | ➕ ADD | Add to `workflow_instances` schema. Prevents duplicate notifications. |
|
||||
| Notifications on breach | ➕ ADD | Use existing `notifications` table + bus event (`workflow.sla.breached`). |
|
||||
| Webhook on breach | ➕ ADD | Fire workflow's `webhook_url` if configured. |
|
||||
| Pulsing badge animation | 🗑 TRASH | UI concern for a surface package. |
|
||||
|
||||
### v0.39.3 — Visitor Portal
|
||||
|
||||
| Component | Disposition | Core equivalent |
|
||||
|-----------|-------------|-----------------|
|
||||
| Public entry endpoint | ➕ ADD | `/api/v1/workflows/entry/:slug` — kernel handles anonymous session creation + token issuance. |
|
||||
| Session resume via token | ➕ ADD | `entry_token` on `workflow_instances` + `GetInstanceByToken` store method. |
|
||||
| Branded entry page | 🗑 TRASH | Surface package. The kernel stores `branding` JSONB — a portal surface reads it. |
|
||||
| Progress indicator | 🗑 TRASH | Surface package reads stage list, computes "step N of M" locally. |
|
||||
| Email notifications | 🗑 TRASH *for now* | Requires SMTP infrastructure. Defer to post-MVP or make it a connection-based extension (use ext_connections for SMTP config, fire via Starlark hook). |
|
||||
|
||||
### v0.39.4 — Templates + Clone
|
||||
|
||||
| Component | Disposition | Core equivalent |
|
||||
|-----------|-------------|-----------------|
|
||||
| Clone endpoint | ➕ ADD | `POST /api/v1/workflows/:id/clone` — deep copy workflow + stages. Straightforward. |
|
||||
| Built-in templates table | 🗑 TRASH | Core doesn't ship opinionated templates. Templates are workflow packages (`.pkg` archives). A "template gallery" is a surface. |
|
||||
| Template picker UI | 🗑 TRASH | Surface concern. |
|
||||
|
||||
### v0.39.5 — Analytics
|
||||
|
||||
| Component | Disposition | Core equivalent |
|
||||
|-----------|-------------|-----------------|
|
||||
| Analytics query endpoint | ➕ ADD (later) | `GET /api/v1/workflows/:id/analytics?period=7d` — kernel exposes raw metrics (throughput, cycle time, stage dwell, SLA compliance) derived from instance + assignment timestamps. Defer to v0.2.8+ or v0.3.x. |
|
||||
| Analytics dashboard UI | 🗑 TRASH | Surface package. |
|
||||
|
||||
---
|
||||
|
||||
## 12. Implementation Order
|
||||
|
||||
All items within v0.2.6. Ordered by dependency:
|
||||
|
||||
### Phase A: Schema + Models
|
||||
|
||||
1. ✏️ Edit `007_workflows.sql`: drop `persona_id`, `history_mode`; add `audience`, `stage_type`, `starlark_hook`, `branch_rules`; rename `transition_rules` → `stage_config`; update `stage_mode` CHECK.
|
||||
2. ✏️ Edit `007_workflows.sql` (same file): add `workflow_instances` and `workflow_assignments` tables.
|
||||
3. ✏️ Edit `007_workflows.sql` (SQLite dialect): mirror changes.
|
||||
4. ✏️ Update `server/models/workflow.go`: remove trashed fields, add new structs, update constants.
|
||||
|
||||
### Phase B: Store Layer
|
||||
|
||||
5. ✏️ Update `server/store/workflow_iface.go`: add instance + assignment methods.
|
||||
6. ➕ Implement Postgres store: `server/store/postgres/workflow_instances.go`, `workflow_assignments.go`.
|
||||
7. ➕ Implement SQLite store: `server/store/sqlite/workflow_instances.go`, `workflow_assignments.go`.
|
||||
|
||||
### Phase C: Engine
|
||||
|
||||
8. ✏️ Update `server/workflow/routing.go`: branch_rules evaluation, starlark_hook integration.
|
||||
9. ➕ Add `server/workflow/engine.go`: advance lifecycle orchestration.
|
||||
10. ➕ Add `server/workflow/automated.go`: automated stage execution + cycle guard.
|
||||
11. ✏️ Update `server/handlers/workflow_hooks.go`: rename channel → instance references.
|
||||
|
||||
### Phase D: Handlers + Routes
|
||||
|
||||
12. ✏️ Update `server/handlers/workflows.go`: stage CRUD field changes.
|
||||
13. ✏️ Update `server/handlers/workflow_packages.go`: package struct field changes.
|
||||
14. ➕ Add `server/handlers/workflow_instances.go`: instance lifecycle endpoints.
|
||||
15. ➕ Add `server/handlers/workflow_assignments.go`: assignment queue endpoints.
|
||||
16. ✏️ Wire new routes in `server/main.go`.
|
||||
|
||||
### Phase E: Events + Starlark
|
||||
|
||||
17. ✏️ Update `server/events/types.go`: add new workflow events.
|
||||
18. ✏️ Update `server/sandbox/modules.go`: rebuild workflow Starlark module.
|
||||
|
||||
### Phase F: Background Jobs
|
||||
|
||||
19. ➕ Add SLA scanner goroutine (check active instances, fire notifications + events on breach).
|
||||
20. ➕ Add staleness sweep for workflow instances (reuse pattern from chat-switchboard).
|
||||
|
||||
### Phase G: Verification
|
||||
|
||||
21. ➕ Integration tests: instance lifecycle, stage advancement, branching, automated stages, assignments, SLA breach.
|
||||
22. `go build ./...` clean.
|
||||
23. Update ICD (OpenAPI spec) with new endpoints.
|
||||
24. Update ROADMAP.md + CHANGELOG.md.
|
||||
|
||||
---
|
||||
|
||||
## 13. What We're NOT Doing (Explicit Deferrals)
|
||||
|
||||
| Item | Why | Revisit |
|
||||
|------|-----|---------|
|
||||
| Visual form builder UI | Surface concern, no workflow-admin surface yet | When a workflow-admin surface ships |
|
||||
| Drag-and-drop stage reorder UI | Surface concern | Same |
|
||||
| Email notifications | Needs SMTP infrastructure or connection-based ext | Post-MVP |
|
||||
| Built-in workflow templates | Core doesn't ship opinionated content | Package gallery |
|
||||
| Analytics dashboard | Nice-to-have, kernel endpoint can come first | v0.3.x |
|
||||
| Bulk assignment operations | Individual ops first | v0.2.7+ |
|
||||
| Workflow chaining (`on_complete`) | Already in schema, needs engine wiring | v0.2.7 |
|
||||
| Visitor portal surface | Surface package, not kernel | Extension track |
|
||||
|
||||
---
|
||||
|
||||
## 14. Migration Policy Reminder
|
||||
|
||||
From the design-decisions log:
|
||||
|
||||
> **No new migrations pre-MVP.** Edit existing migration SQL files in place.
|
||||
> No migration chains until schema is in production.
|
||||
|
||||
For this redesign:
|
||||
- **`007_workflows.sql`**: Edit in place for column changes AND add new tables to the same file.
|
||||
- **No `012_*.sql`**: Everything goes in 007.
|
||||
- **Both dialects**: Postgres and SQLite must be kept in sync.
|
||||
|
||||
The only time a new numbered migration file is created is when a truly new,
|
||||
unrelated subsystem is added (like 011_triggers.sql was for the trigger system).
|
||||
Workflow instances and assignments are part of the workflow subsystem → they go in 007.
|
||||
|
||||
---
|
||||
|
||||
## 15. Public Surfaces — Kernel Capability, Not Workflow Feature
|
||||
|
||||
### The problem with workflow-owned public access
|
||||
|
||||
Chat-switchboard treated public access as a binary workflow toggle: `entry_mode: public_link`
|
||||
made the whole workflow public. But real workflows alternate audiences — internal setup →
|
||||
public-facing customer form → internal review → public confirmation page. And public access
|
||||
isn't even a workflow-only need: feedback forms, status pages, and surveys all need anonymous
|
||||
sessions without being workflows.
|
||||
|
||||
### Per-stage audience (the v0.2.6 solution)
|
||||
|
||||
The `audience` field on `workflow_stages` is the source of truth:
|
||||
|
||||
| Audience | Who interacts | Auth required | Example |
|
||||
|----------|--------------|---------------|---------|
|
||||
| `team` | Authenticated team members | JWT | Internal triage, setup, review |
|
||||
| `public` | Anonymous visitors | Entry token | Customer intake form, confirmation page |
|
||||
| `system` | Nobody (automated) | N/A | API enrichment, routing decision |
|
||||
|
||||
A single workflow freely alternates:
|
||||
|
||||
```
|
||||
Stage 0: "Setup" audience=team (agent configures parameters)
|
||||
Stage 1: "Customer Form" audience=public (customer fills out intake)
|
||||
Stage 2: "Internal Review" audience=team (team reviews submission)
|
||||
Stage 3: "Confirmation" audience=public (customer sees result)
|
||||
```
|
||||
|
||||
The engine enforces boundaries:
|
||||
- When the instance enters a `public` stage, the visitor's token grants access. Team members
|
||||
can also see it (they have elevated access).
|
||||
- When the instance enters a `team` stage, the visitor's view freezes. They see "being
|
||||
processed" or whatever the portal surface shows. The token is still valid for resumption
|
||||
when the next `public` stage arrives.
|
||||
- When the instance enters a `system` stage, nobody interacts — the automated hook fires
|
||||
and the engine advances.
|
||||
|
||||
### The `entry_mode` column
|
||||
|
||||
Stays on the `workflows` table but becomes a derived convenience flag. If any stage has
|
||||
`audience = 'public'`, the workflow is public-entry eligible. The kernel can auto-compute
|
||||
this when stages are saved, or treat it as an explicit admin toggle that gates whether
|
||||
public stages are actually reachable. Either way, it's no longer the primary access control
|
||||
mechanism — `audience` on each stage is.
|
||||
|
||||
### Platform-level public access (v0.2.7+ concern)
|
||||
|
||||
The broader vision: public access as a **package-level manifest capability** so any surface
|
||||
(not just workflows) can serve anonymous visitors. A package declares `"public": true` in
|
||||
its manifest, and the kernel mounts a public route namespace (`/p/:package_slug/*`) with
|
||||
anonymous session middleware, token issuance, and rate limiting. Workflows consume this
|
||||
capability — they don't own it.
|
||||
|
||||
For v0.2.6, the workflow-specific public entry routes are sufficient:
|
||||
```
|
||||
POST /api/v1/workflows/entry/:slug → StartPublic
|
||||
GET /api/v1/workflows/entry/:token → ResumePublic
|
||||
POST /api/v1/workflows/entry/:token/advance → AdvancePublic
|
||||
```
|
||||
|
||||
The anonymous session management (entry_token on `workflow_instances`, IP rate limiting)
|
||||
lives in the workflow handler for now. Generalization to a platform-level `public_sessions`
|
||||
table happens when non-workflow surfaces need it.
|
||||
|
||||
### Visitor view contract
|
||||
|
||||
When a public stage is active, the kernel API returns only what the visitor should see:
|
||||
- The current stage's `form_template` (if mode is `form`)
|
||||
- The stage name and ordinal (for progress indicators)
|
||||
- Accumulated `stage_data` keys marked as `visitor_visible` (future: field-level ACL)
|
||||
- The `branding` JSONB from the workflow
|
||||
|
||||
Internal stages, team assignments, review data, and other instances' data are never
|
||||
exposed through the token-authenticated endpoints. The surface package renders whatever
|
||||
the kernel returns — it doesn't need to filter.
|
||||
|
||||
---
|
||||
|
||||
## 16. Branch Rules — Routing, Skipping, and Convergence
|
||||
|
||||
### How branch_rules work
|
||||
|
||||
Each stage has a `branch_rules` JSONB array. On stage completion, the engine evaluates rules
|
||||
top-to-bottom against accumulated `stage_data`. First match wins. No match → ordinal + 1.
|
||||
|
||||
```json
|
||||
[
|
||||
{"field": "priority", "op": "eq", "value": "urgent", "target_stage": "Emergency Review"},
|
||||
{"field": "amount", "op": "gt", "value": 10000, "target_stage": "Manager Approval"},
|
||||
{"field": "type", "op": "in", "value": ["a","b"], "target_stage": "Fast Track"}
|
||||
]
|
||||
```
|
||||
|
||||
`target_stage` accepts either a **stage name** (case-insensitive) or a **numeric ordinal**.
|
||||
The engine tries name resolution first, falls back to numeric. This is already implemented
|
||||
in `routing.go` → `resolveTarget()`.
|
||||
|
||||
### Skipping stages
|
||||
|
||||
Branch rules can target any stage, not just the next one. If a workflow has stages
|
||||
0→1→2→3→4 and stage 1's rules say `target_stage: "Stage 4"`, stages 2 and 3 are skipped
|
||||
for that instance. The engine doesn't care about gaps — it sets `current_stage` to whatever
|
||||
the rule resolved to.
|
||||
|
||||
Linear workflows (no branch_rules on any stage) advance 0→1→2→3→4 by the ordinal + 1
|
||||
fallback. Adding a single branch rule to any stage introduces conditional skipping without
|
||||
changing the linear stages.
|
||||
|
||||
### Convergence (diamond patterns)
|
||||
|
||||
Two different branches can target the same downstream stage:
|
||||
|
||||
```
|
||||
Stage 0: Intake Form
|
||||
branch_rules: [
|
||||
{field: "region", op: "eq", value: "US", target_stage: "US Processing"},
|
||||
{field: "region", op: "eq", value: "EU", target_stage: "EU Processing"}
|
||||
]
|
||||
|
||||
Stage 1: US Processing (ordinal 1)
|
||||
branch_rules: [{field: "*", op: "exists", target_stage: "Final Review"}]
|
||||
(always converge after processing)
|
||||
|
||||
Stage 2: EU Processing (ordinal 2)
|
||||
branch_rules: [{field: "*", op: "exists", target_stage: "Final Review"}]
|
||||
(always converge after processing)
|
||||
|
||||
Stage 3: Final Review (ordinal 3)
|
||||
(both branches land here)
|
||||
```
|
||||
|
||||
No special syntax needed. The routing engine doesn't track "which branch am I on" — it just
|
||||
resolves the target and moves the instance there. The accumulated `stage_data` carries
|
||||
everything downstream stages need to know about what happened upstream.
|
||||
|
||||
### Backward jumps
|
||||
|
||||
Branch rules can also target earlier stages (lower ordinals) for retry/correction loops.
|
||||
The cycle guard (max 10 consecutive automated stages) in `automated.go` prevents infinite
|
||||
loops for automated stages. For human-facing stages, backward jumps are intentional — the
|
||||
human breaks the cycle by submitting different data.
|
||||
|
||||
### Operators
|
||||
|
||||
Full set, inherited from the existing `routing.go` implementation:
|
||||
|
||||
| Op | Description | Example |
|
||||
|----|-------------|---------|
|
||||
| `eq` | Loose equality (string comparison) | `"status" eq "approved"` |
|
||||
| `neq` | Not equal | `"status" neq "rejected"` |
|
||||
| `gt` | Greater than (numeric) | `"amount" gt 10000` |
|
||||
| `lt` | Less than (numeric) | `"amount" lt 100` |
|
||||
| `gte` | Greater or equal | `"score" gte 80` |
|
||||
| `lte` | Less or equal | `"score" lte 20` |
|
||||
| `in` | Value in array | `"category" in ["a","b","c"]` |
|
||||
| `contains` | Substring match | `"notes" contains "urgent"` |
|
||||
| `exists` | Field present in stage_data | `"approval_date" exists` |
|
||||
| `not_exists` | Field absent | `"rejection_reason" not_exists` |
|
||||
@@ -1,111 +0,0 @@
|
||||
# Workflow Engine — Design
|
||||
|
||||
The workflow engine provides multi-stage, form-driven processes with team
|
||||
validation, SLA enforcement, and public entry. Workflows are kernel primitives;
|
||||
workflow **surfaces** (admin UI, public landing pages) are extension packages.
|
||||
|
||||
---
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Workflow Definition
|
||||
|
||||
A workflow is a named process template owned by a team. Each workflow defines an
|
||||
ordered list of **stages** that instances progress through.
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `name` | Human-readable workflow name |
|
||||
| `slug` | URL-safe identifier (unique per scope) |
|
||||
| `scope` | Owning team or org |
|
||||
| `stages` | Ordered list of stage definitions |
|
||||
| `entry_mode` | `internal` (authenticated users) or `public_link` (landing page) |
|
||||
| `sla_hours` | Optional SLA deadline for instance completion |
|
||||
|
||||
### Stage Types
|
||||
|
||||
Each stage has a `mode` that determines how participants interact:
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| `form_only` | Structured form submission. Stage advances when form is complete. |
|
||||
| `form_chat` | Form plus threaded discussion. Useful for review with comments. |
|
||||
| `review` | Approval/rejection gate. Designated reviewers must sign off. |
|
||||
| `custom` | Delegates entirely to a surface package. Enables extension-driven UIs. |
|
||||
|
||||
Stages declare an `assignee_mode` (individual, team, role-based) and optional
|
||||
form schemas (JSON Schema validated at submission time).
|
||||
|
||||
### Workflow Instances
|
||||
|
||||
An instance is a running execution of a workflow definition. It tracks:
|
||||
|
||||
- Current stage index and overall status (`active`, `completed`, `cancelled`, `stale`)
|
||||
- Per-stage completion records with timestamps and actor IDs
|
||||
- Form data submitted at each stage
|
||||
- Assignment and claim history
|
||||
|
||||
Instances use optimistic locking (`claimed_by` + `claimed_at`) to prevent
|
||||
concurrent stage advancement. A claim expires after a configurable timeout.
|
||||
|
||||
---
|
||||
|
||||
## Multi-Party Validation (Signoff Table)
|
||||
|
||||
The `workflow_signoffs` table enables multi-party approval gates:
|
||||
|
||||
| Column | Purpose |
|
||||
|--------|---------|
|
||||
| `instance_id` | Which instance |
|
||||
| `stage_index` | Which stage requires signoff |
|
||||
| `user_id` | Who signed |
|
||||
| `decision` | `approved` or `rejected` |
|
||||
| `comment` | Optional rationale |
|
||||
| `signed_at` | Timestamp |
|
||||
|
||||
The engine checks signoff requirements before advancing past a `review` stage.
|
||||
Requirements are configurable: unanimous, majority, or N-of-M.
|
||||
|
||||
---
|
||||
|
||||
## SLA Scanner + Staleness Sweep
|
||||
|
||||
Two periodic background jobs maintain workflow health:
|
||||
|
||||
**SLA Scanner** — Runs on a configurable interval. Identifies instances that have
|
||||
exceeded their workflow's `sla_hours` deadline. Fires a `workflow.sla.breached`
|
||||
event (consumed by notification extensions or triggers).
|
||||
|
||||
**Staleness Sweep** — Identifies instances that have been idle (no stage
|
||||
advancement) beyond a configurable threshold. Marks them as `stale` and fires
|
||||
`workflow.instance.stale`. Stale instances can be resumed or cancelled.
|
||||
|
||||
Both jobs are distributed-safe: all cluster nodes run them, but operations are
|
||||
idempotent (UPDATE with WHERE guards).
|
||||
|
||||
---
|
||||
|
||||
## Public Entry
|
||||
|
||||
Workflows with `entry_mode: public_link` are accessible at:
|
||||
|
||||
```
|
||||
/w/:scope/:slug
|
||||
```
|
||||
|
||||
The landing page renders the first stage's form without requiring authentication.
|
||||
On submission, the kernel creates an instance and stores the form data. Subsequent
|
||||
stages may require authentication depending on their assignee configuration.
|
||||
|
||||
Public entry enables use cases like intake forms, support requests, and
|
||||
application submissions where the initiator is external.
|
||||
|
||||
---
|
||||
|
||||
## Extension Points
|
||||
|
||||
- **Surface packages** provide workflow admin UIs and participant views
|
||||
- **Trigger extensions** can react to workflow events (`instance.created`,
|
||||
`stage.advanced`, `sla.breached`, `instance.completed`)
|
||||
- **Custom stage mode** delegates rendering and advancement logic entirely to
|
||||
an extension, enabling arbitrary UIs within a workflow stage
|
||||
@@ -1,290 +0,0 @@
|
||||
# Design: PG-Backed Cluster Registry & Self-Assembling Mesh
|
||||
|
||||
**Status:** Proposed — Pre-MVP / MVP wrap-up
|
||||
**Author:** Jeff
|
||||
**Date:** 2026-03-30
|
||||
**Scope:** Kernel primitive — zero domain awareness
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Switchboard-core is tightly coupled to PostgreSQL. Scaling horizontally requires instance coordination: peer discovery, health monitoring, ephemeral event routing, and (optionally) leader election. Traditional HA solutions (Raft, etcd, Consul) introduce a second consensus layer on top of PG — doubling operational complexity for a system that already has serializable transactions and LISTEN/NOTIFY.
|
||||
|
||||
## Principle
|
||||
|
||||
PG is already the consensus system. Use it as the sole coordinator for service discovery, health, pub/sub, and cluster state. Zero new infrastructure dependencies.
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Node Registry (Unlogged Table)
|
||||
|
||||
```sql
|
||||
CREATE UNLOGGED TABLE IF NOT EXISTS node_registry (
|
||||
node_id TEXT PRIMARY KEY,
|
||||
endpoint TEXT NOT NULL,
|
||||
seq SERIAL,
|
||||
registered_at TIMESTAMPTZ DEFAULT now(),
|
||||
heartbeat TIMESTAMPTZ DEFAULT now(),
|
||||
stats JSONB DEFAULT '{}'
|
||||
);
|
||||
```
|
||||
|
||||
**Why UNLOGGED:** No WAL overhead, visible to all connections, survives session disconnect. Contents do NOT survive a PG crash — but a PG crash means all nodes are dead anyway. Clean slate on recovery is the correct behavior; all nodes re-register on restart.
|
||||
|
||||
**Why not TEMPORARY:** PG temporary tables are session-scoped and invisible to other connections. Useless for cross-instance coordination.
|
||||
|
||||
### 2. Node Lifecycle
|
||||
|
||||
#### Startup
|
||||
|
||||
```
|
||||
boot → connect PG → register self → read peer list →
|
||||
subscribe LISTEN/NOTIFY channels → open WS to clients → ready
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Self-registration (atomic, idempotent)
|
||||
INSERT INTO node_registry (node_id, endpoint)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (node_id) DO UPDATE
|
||||
SET endpoint = EXCLUDED.endpoint,
|
||||
registered_at = now(),
|
||||
heartbeat = now(),
|
||||
stats = '{}';
|
||||
```
|
||||
|
||||
`node_id` is generated at startup: `hostname + PID` or a UUID. Deterministic IDs enable restart-in-place without orphaned rows.
|
||||
|
||||
#### Heartbeat Tick (every N seconds, e.g., 10s)
|
||||
|
||||
Each node performs two operations per tick:
|
||||
|
||||
```go
|
||||
// 1. Update own heartbeat + stats
|
||||
stats := collectStats() // see §5
|
||||
db.Exec(`UPDATE node_registry SET heartbeat = now(), stats = $2 WHERE node_id = $1`,
|
||||
nodeID, stats)
|
||||
|
||||
// 2. Sweep stale entries (idempotent — safe for concurrent execution)
|
||||
db.Exec(`DELETE FROM node_registry WHERE heartbeat < now() - interval '30 seconds'`)
|
||||
```
|
||||
|
||||
The sweep is the distributed health check. Every node runs it. Multiple nodes deleting the same stale row is a no-op. No ring topology required — the sweep is the safety net that guarantees eventual cleanup regardless of which nodes are alive.
|
||||
|
||||
#### Self-Eviction
|
||||
|
||||
If a node's own heartbeat UPDATE returns 0 rows affected, it has been swept by a peer:
|
||||
|
||||
```go
|
||||
result := db.Exec(`UPDATE node_registry SET heartbeat = now() WHERE node_id = $1`, nodeID)
|
||||
if result.RowsAffected() == 0 {
|
||||
log.Error("evicted from registry, shutting down")
|
||||
os.Exit(1)
|
||||
}
|
||||
```
|
||||
|
||||
Kubernetes restarts the process. On boot it re-registers as a fresh node. The restart loop IS the recovery mechanism.
|
||||
|
||||
### 3. Leaderless by Default
|
||||
|
||||
No `role` column. No primary/replica distinction. All nodes are equal peers. PG is the sole coordinator.
|
||||
|
||||
**If a leader is ever needed** (future use case), it's a small lift:
|
||||
|
||||
```sql
|
||||
ALTER TABLE node_registry ADD COLUMN role TEXT DEFAULT 'peer';
|
||||
|
||||
-- Atomic leader claim: first writer wins
|
||||
UPDATE node_registry SET role = 'primary'
|
||||
WHERE node_id = $1
|
||||
AND NOT EXISTS (SELECT 1 FROM node_registry WHERE role = 'primary');
|
||||
```
|
||||
|
||||
No quorum, no voting, no term numbers. PG serialization handles it. But the current design intentionally avoids this — consistent with the existing pattern where scheduled tasks use first-to-mark-wins execution, not leader assignment.
|
||||
|
||||
### 4. Realtime Event Routing (Two Tiers)
|
||||
|
||||
#### Tier 1: Durable Events (messages, state changes)
|
||||
|
||||
These are PG writes. Fan-out via `LISTEN/NOTIFY`:
|
||||
|
||||
```
|
||||
User A sends message → Instance 1 writes to PG →
|
||||
pg_notify('channel:xyz', '{"type":"message","id":"..."}') →
|
||||
All instances receive → push to local WS clients subscribed to channel:xyz
|
||||
```
|
||||
|
||||
This maps directly to the v0.5.0 `realtime.publish()` Starlark primitive. Kernel implementation is `pg_notify(channel, payload)`.
|
||||
|
||||
#### Tier 2: Ephemeral Events (typing indicators, presence, cursor position)
|
||||
|
||||
These NEVER hit durable storage.
|
||||
|
||||
**Phase 1 (MVP):** Use PG LISTEN/NOTIFY for ephemeral events too.
|
||||
|
||||
```
|
||||
User A types → Instance 1 WS handler →
|
||||
pg_notify('ephemeral:xyz', '{"type":"typing","user":"A"}') →
|
||||
Instance 2 receives → pushes to User B's WS
|
||||
Instance 3 receives → pushes to User C's WS
|
||||
```
|
||||
|
||||
PG NOTIFY payload limit is 8KB. A typing indicator is ~60 bytes. At homelab scale (3 nodes, dozens of users) this is a non-issue. One codepath, one transport, zero new infrastructure.
|
||||
|
||||
**Phase 2 (Post-MVP, if needed):** Direct instance-to-instance mesh for ephemeral events.
|
||||
|
||||
- Peer endpoints are already in the registry
|
||||
- Each node dials peers discovered from the registry on startup and on peer list changes
|
||||
- Ephemeral events route over the mesh; durable events stay on PG NOTIFY
|
||||
- The `realtime.publish()` Starlark API does not change — kernel swaps the underlying transport
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ PG (source of truth) │
|
||||
│ ┌───────────┐ ┌──────────────┐ │
|
||||
│ │ node_ │ │ LISTEN/ │ │
|
||||
│ │ registry │ │ NOTIFY │ │
|
||||
│ └───────────┘ └──────────────┘ │
|
||||
└──────┬──────────────┬────────────┘
|
||||
│ durable │
|
||||
┌────────────┼──────────────┼────────────┐
|
||||
│ │ │ │
|
||||
┌────┴────┐ ┌───┴─────┐ ┌────┴─────┐
|
||||
│ Node 1 │◄─┤ Node 2 ├─►│ Node 3 │
|
||||
│ User A │ │ User B │ │ User C │
|
||||
└─────────┘ └─────────┘ └──────────┘
|
||||
◄──── ephemeral mesh ────►
|
||||
(Phase 2, if needed)
|
||||
```
|
||||
|
||||
**Channel subscription model (Phase 1):** Broadcast and discard. Every node receives every event, checks local WebSocket subscriptions, drops if irrelevant. At 3 nodes this is the correct answer — trivial, no subscription state to sync, fully leaderless.
|
||||
|
||||
### 5. Stats Collection & Admin Dashboard
|
||||
|
||||
Each heartbeat tick publishes runtime stats in the `stats` JSONB column:
|
||||
|
||||
```go
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
|
||||
stats := map[string]any{
|
||||
"goroutines": runtime.NumGoroutine(),
|
||||
"heap_alloc": m.HeapAlloc,
|
||||
"heap_sys": m.HeapSys,
|
||||
"gc_cycles": m.NumGC,
|
||||
"gc_pause_ns": m.PauseNs[(m.NumGC+255)%256],
|
||||
"uptime_sec": time.Since(startTime).Seconds(),
|
||||
"ws_clients": wsHub.Count(),
|
||||
"extensions": sandbox.LoadedCount(),
|
||||
}
|
||||
```
|
||||
|
||||
Admin dashboard API endpoint:
|
||||
|
||||
```sql
|
||||
SELECT node_id, endpoint, registered_at, heartbeat, stats
|
||||
FROM node_registry
|
||||
ORDER BY seq;
|
||||
```
|
||||
|
||||
**ICD envelope:** `{"data": [...]}` — consistent with list endpoint convention.
|
||||
|
||||
The admin dashboard extension renders one card per node. One node = one card. Three nodes = three cards. The extension has zero awareness of clustering — it reads a list and renders it. This is the extensibility proof working as designed.
|
||||
|
||||
Since `stats` is JSONB, future additions (mesh peer latency, extension execution counts, queue depths) require no schema migration. The dashboard extension renders whatever keys are present.
|
||||
|
||||
### 6. Self-Assembling Mesh Properties
|
||||
|
||||
The mesh builds itself from the registry with zero configuration:
|
||||
|
||||
| Event | Behavior |
|
||||
|---|---|
|
||||
| Node starts | Inserts into registry → peers discover it on next heartbeat tick |
|
||||
| Node dies | Heartbeat goes stale → swept by peers → mesh shrinks |
|
||||
| Node restarts | Re-registers → peers discover it → mesh restores |
|
||||
| Scale up (K8s) | New replicas register → mesh grows automatically |
|
||||
| Scale down (K8s) | Terminated nodes go stale → swept → mesh shrinks |
|
||||
| PG crash | All nodes lose connection → all exit → K8s restarts all → clean re-registration |
|
||||
|
||||
No config files listing peers. No seed nodes. No service discovery sidecar. No bootstrap ceremony. The registry table IS the service mesh control plane.
|
||||
|
||||
---
|
||||
|
||||
## What This Enables (Future)
|
||||
|
||||
These are NOT in scope for this work. Listed to validate the design supports them without rearchitecting.
|
||||
|
||||
- **Work distribution:** Primary (if elected) assigns extension execution shards via registry metadata. Replicas poll for assignments. No message queue.
|
||||
- **Realtime routing (v0.5.0):** When a node owns a pub/sub channel, it registers ownership in PG. Other nodes route publishes to the owner. Node dies → sweep clears routes → subscribers reconnect.
|
||||
- **Rolling deploys:** New version registers, old version's heartbeat goes stale, clean handoff with zero coordination protocol.
|
||||
- **Session affinity:** Registry tracks which node holds a user's WS connection. Targeted event delivery instead of broadcast (optimization for scale).
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Migration
|
||||
|
||||
Pre-MVP: fold `CREATE UNLOGGED TABLE` into existing schema initialization. No new migration file needed. DB rebuild required (acceptable — no install base).
|
||||
|
||||
### Configuration
|
||||
|
||||
| Setting | Default | Notes |
|
||||
|---|---|---|
|
||||
| `CLUSTER_NODE_ID` | `hostname-PID` | Override for deterministic identity across restarts |
|
||||
| `CLUSTER_HEARTBEAT_INTERVAL` | `10s` | Tick frequency |
|
||||
| `CLUSTER_STALE_THRESHOLD` | `30s` | 3x heartbeat = stale |
|
||||
| `CLUSTER_ENDPOINT` | auto-detect | Advertised address for peer mesh (Phase 2) |
|
||||
|
||||
### Single-Node Behavior
|
||||
|
||||
When only one node is registered, the system behaves identically to pre-cluster switchboard-core. The registry has one row. LISTEN/NOTIFY delivers events back to the same instance. No special-casing required.
|
||||
|
||||
### Health Endpoint Integration
|
||||
|
||||
`GET /health` includes cluster status:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"node_id": "node-abc-1234",
|
||||
"cluster": {
|
||||
"size": 3,
|
||||
"peers": ["node-def-5678", "node-ghi-9012"],
|
||||
"heartbeat_age_ms": 2340
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Satisfies the v0.6.0 MVP health monitoring requirement.
|
||||
|
||||
---
|
||||
|
||||
## Rejected Alternatives
|
||||
|
||||
| Alternative | Why rejected |
|
||||
|---|---|
|
||||
| etcd / Consul | Second consensus layer on top of PG. Doubles operational complexity for a system already tightly coupled to PG. Violates KISS. |
|
||||
| Redis pub/sub for ephemeral events | Second stateful service. Two failure domains, two connection pools, two things to monitor. PG NOTIFY handles the same job at homelab scale. |
|
||||
| Raft implementation in Go | Massive complexity for a problem PG serializable transactions already solve. |
|
||||
| Ring topology monitoring | Each node watches the one above it. Adjacent failures create blind spots. Sweep-all is simpler and has no edge cases. |
|
||||
| Leader election at startup | Unnecessary. Leaderless design with first-to-mark-wins task execution already works. Leader election is a future option, not a requirement. |
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] `node_registry` unlogged table in schema init
|
||||
- [ ] Node registration on startup
|
||||
- [ ] Heartbeat tick with stats collection and stale sweep
|
||||
- [ ] Self-eviction on 0-rows-affected heartbeat
|
||||
- [ ] PG LISTEN/NOTIFY integration for realtime events
|
||||
- [ ] Admin API endpoint: `GET /api/admin/cluster`
|
||||
- [ ] Admin dashboard extension: cluster node cards
|
||||
- [ ] Health endpoint cluster status
|
||||
- [ ] Configuration via environment variables
|
||||
- [ ] Single-node regression test (no behavioral change)
|
||||
- [ ] Multi-node integration test (Docker Compose, 3 instances)
|
||||
@@ -1,306 +0,0 @@
|
||||
# DESIGN — Native mTLS
|
||||
|
||||
**Version:** v0.6.7
|
||||
**Status:** Implemented
|
||||
**Author:** Jeff / Claude session 2026-03-31
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
The existing `MTLSProxyProvider` assumes a TLS-terminating reverse proxy
|
||||
(Traefik, nginx, Istio) sits in front of the Go binary. Identity arrives
|
||||
via injected HTTP headers (`X-SSL-Client-CN`, etc.). The backend never
|
||||
participates in the TLS handshake.
|
||||
|
||||
A target deployment class — systemd units + podman on bare metal or VMs —
|
||||
has no reverse proxy. All connections must be mTLS end-to-end:
|
||||
|
||||
- **Client → server:** browser/CLI presents a user client cert
|
||||
- **Node → node:** cluster peers mutually authenticate for heartbeat,
|
||||
realtime pub/sub, and registry sync
|
||||
|
||||
The Go binary must terminate TLS itself and extract identity directly
|
||||
from the verified peer certificate.
|
||||
|
||||
---
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Certificate revocation (CRL/OCSP). Deferred — short-lived certs +
|
||||
restart is sufficient pre-1.0.
|
||||
- Automatic cert rotation. Operators restart the process after replacing
|
||||
certs on disk.
|
||||
- Acting as a CA at runtime. Cert issuance is an offline operator task.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### TLS Server Mode
|
||||
|
||||
New config knob `TLS_MODE` with three values:
|
||||
|
||||
| Value | Behavior |
|
||||
|----------|----------------------------------------------------|
|
||||
| `none` | Plain HTTP. Current default. Use behind a proxy. |
|
||||
| `server` | Server-side TLS only. No client cert required. |
|
||||
| `mtls` | Mutual TLS. Client cert required and verified. |
|
||||
|
||||
When `TLS_MODE` is `server` or `mtls`, the binary calls
|
||||
`http.Server.ListenAndServeTLS` with paths from `TLS_CERT` and `TLS_KEY`.
|
||||
|
||||
When `TLS_MODE` is `mtls`, `tls.Config` is built with:
|
||||
|
||||
```go
|
||||
&tls.Config{
|
||||
ClientAuth: tls.RequireAndVerifyClientCert,
|
||||
ClientCAs: loadCACertPool(cfg.TLSCA),
|
||||
MinVersion: tls.VersionTLS13,
|
||||
}
|
||||
```
|
||||
|
||||
TLS 1.3 is the floor. No cipher suite configuration exposed — Go's
|
||||
defaults for 1.3 are non-negotiable and correct.
|
||||
|
||||
### Auth Provider: MTLSNativeProvider
|
||||
|
||||
Completely separate from `MTLSProxyProvider`. No shared code paths for
|
||||
cert extraction. They share only downstream helpers.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ Shared helpers │
|
||||
│ ParseDN() · FingerprintCert() · ProvisionUser() │
|
||||
└────────────────┬──────────────┬──────────────────┘
|
||||
│ │
|
||||
┌────────────┴───┐ ┌──────┴────────────┐
|
||||
│ MTLSProxy │ │ MTLSNative │
|
||||
│ Provider │ │ Provider │
|
||||
│ │ │ │
|
||||
│ Reads headers: │ │ Reads TLS: │
|
||||
│ X-SSL-Client-* │ │ PeerCertificates │
|
||||
└────────────────┘ └───────────────────┘
|
||||
```
|
||||
|
||||
**MTLSNativeProvider.Authenticate():**
|
||||
|
||||
```go
|
||||
func (p *MTLSNativeProvider) Authenticate(c *gin.Context, s store.Stores) (*Result, error) {
|
||||
if c.Request.TLS == nil || len(c.Request.TLS.PeerCertificates) == 0 {
|
||||
return nil, ErrNoCert
|
||||
}
|
||||
peer := c.Request.TLS.PeerCertificates[0]
|
||||
|
||||
cn := peer.Subject.CommonName // → username
|
||||
dn := peer.Subject.String() // → metadata
|
||||
fp := FingerprintCert(peer) // sha256(DER) → external_id
|
||||
|
||||
return p.resolveOrProvision(c.Request.Context(), s, cn, dn, fp)
|
||||
}
|
||||
```
|
||||
|
||||
`FingerprintCert` computes `sha256(cert.Raw)` hex-encoded. This is the
|
||||
stable identity anchor — certs can be reissued with the same CN but will
|
||||
get a new fingerprint, creating a new user unless the operator explicitly
|
||||
maps them.
|
||||
|
||||
**Why Option B (two providers, not a mode switch):**
|
||||
|
||||
The security properties differ fundamentally. The proxy provider trusts
|
||||
headers — if deployed without a proxy that strips client-injected headers,
|
||||
an attacker can forge identity by sending `X-SSL-Client-CN: admin`. The
|
||||
native provider trusts the TLS stack — identity is cryptographically
|
||||
verified before the HTTP layer runs. Mixing these behind a single
|
||||
code path with a runtime switch invites misconfiguration. Two types,
|
||||
selected at startup by `TLS_MODE`, eliminates the ambiguity.
|
||||
|
||||
### Node-to-Node mTLS
|
||||
|
||||
Each Armature node is both server and client:
|
||||
|
||||
- **Server:** accepts connections from peers and user clients on its
|
||||
listen address.
|
||||
- **Client:** dials peers for cluster registry heartbeat, realtime
|
||||
event forwarding, and (future) distributed task coordination.
|
||||
|
||||
When dialing a peer, the node presents its own cert:
|
||||
|
||||
```go
|
||||
peerTLS := &tls.Config{
|
||||
Certificates: []tls.Certificate{nodeKeypair},
|
||||
RootCAs: caCertPool,
|
||||
MinVersion: tls.VersionTLS13,
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{TLSClientConfig: peerTLS},
|
||||
}
|
||||
```
|
||||
|
||||
Both sides verify against the same CA. A node whose cert is not signed
|
||||
by the cluster CA cannot join. This replaces any need for shared secrets
|
||||
or bearer tokens in the cluster protocol.
|
||||
|
||||
**Interaction with cluster registry (DESIGN-cluster-registry.md):**
|
||||
|
||||
The existing design uses LISTEN/NOTIFY over PostgreSQL for node
|
||||
discovery and an UNLOGGED `node_registry` table for heartbeat. mTLS
|
||||
does not change the discovery mechanism — it secures the HTTP transport
|
||||
between nodes once they've discovered each other. The heartbeat sweep,
|
||||
self-eviction, and leaderless-by-default properties are orthogonal.
|
||||
|
||||
### Cert Topology
|
||||
|
||||
```
|
||||
cluster-ca.crt / cluster-ca.key ← generated once, lives on operator workstation
|
||||
├── node-1.crt + node-1.key ← SAN: node-1.internal, 10.0.0.1
|
||||
├── node-2.crt + node-2.key ← SAN: node-2.internal, 10.0.0.2
|
||||
├── user-jeff.crt + user-jeff.key ← CN=jeff, client auth only
|
||||
└── user-alice.crt + user-alice.key
|
||||
```
|
||||
|
||||
**Node certs** carry both `ServerAuth` and `ClientAuth` extended key
|
||||
usages. SANs include the node's hostname and IP — required because
|
||||
peers may dial by either.
|
||||
|
||||
**User certs** carry only `ClientAuth`. The CN becomes the username.
|
||||
Email can go in the Subject if desired (parsed by `ParseDN()`).
|
||||
|
||||
The CA private key **never** touches a running node. Nodes receive only:
|
||||
`cluster-ca.crt` (for verifying peers), their own `node-N.crt`, and
|
||||
their own `node-N.key`.
|
||||
|
||||
### Cert Provisioning Tooling
|
||||
|
||||
A shell script (`scripts/switchboard-ca.sh`) wrapping `openssl` is the
|
||||
KISS path. No new binary, no new dependency. Three commands:
|
||||
|
||||
```
|
||||
switchboard-ca init
|
||||
→ generates cluster-ca.crt + cluster-ca.key in ./ca/
|
||||
|
||||
switchboard-ca issue-node --name node-1 --san "node-1.internal,10.0.0.1"
|
||||
→ generates node-1.crt + node-1.key in ./nodes/
|
||||
|
||||
switchboard-ca issue-user --cn jeff [--email jeff@example.com]
|
||||
→ generates jeff.crt + jeff.key in ./users/
|
||||
```
|
||||
|
||||
The script generates a proper `openssl.cnf` fragment per invocation
|
||||
with the right SANs, EKUs, and validity period (default 365 days for
|
||||
nodes, 90 days for users). All output is PEM.
|
||||
|
||||
If we later want a Go-native tool (for cross-platform or embedding),
|
||||
it's a straightforward port — the `crypto/x509` stdlib does everything
|
||||
openssl does here.
|
||||
|
||||
---
|
||||
|
||||
## Config Surface
|
||||
|
||||
```env
|
||||
# TLS mode — controls whether the binary terminates TLS
|
||||
# none: plain HTTP (default, use behind a proxy)
|
||||
# server: TLS, no client cert required
|
||||
# mtls: mutual TLS, client cert required
|
||||
TLS_MODE=mtls
|
||||
|
||||
# Cert paths (required when TLS_MODE != none)
|
||||
TLS_CERT=/etc/armature/tls/node.crt
|
||||
TLS_KEY=/etc/armature/tls/node.key
|
||||
TLS_CA=/etc/armature/tls/ca.crt
|
||||
|
||||
# Listen address (default :8080 for none, :8443 for tls/mtls)
|
||||
LISTEN_ADDR=:8443
|
||||
|
||||
# Auth mode — when TLS_MODE=mtls, this should be mtls
|
||||
AUTH_MODE=mtls
|
||||
```
|
||||
|
||||
`TLS_MODE` and `AUTH_MODE` are independent knobs:
|
||||
|
||||
- `TLS_MODE=server` + `AUTH_MODE=builtin` = HTTPS with password login
|
||||
- `TLS_MODE=mtls` + `AUTH_MODE=mtls` = full mTLS identity
|
||||
- `TLS_MODE=none` + `AUTH_MODE=mtls` = proxy-terminated mTLS (existing behavior)
|
||||
|
||||
The third combination is the current `MTLSProxyProvider` path. The
|
||||
first is useful for deployments that want encrypted transport but
|
||||
password auth (e.g., single-node with Let's Encrypt).
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
**Unit tests (in-process, no network):**
|
||||
|
||||
Generate an ephemeral CA and certs using `crypto/x509` + `crypto/ecdsa`
|
||||
in `TestMain`. Tests call `MTLSNativeProvider.Authenticate()` with a
|
||||
fabricated `*http.Request` whose `TLS.PeerCertificates` is populated
|
||||
directly.
|
||||
|
||||
- Valid cert → user provisioned, correct CN/fingerprint
|
||||
- No TLS → `ErrNoCert`
|
||||
- Empty PeerCertificates → `ErrNoCert`
|
||||
- Second call with same fingerprint → same user returned (idempotent)
|
||||
- Different CN, same fingerprint → same user (cert reissue scenario)
|
||||
|
||||
**Integration tests (real TLS listener):**
|
||||
|
||||
Spin up `http.Server` with `tls.Config` on localhost. Dial with
|
||||
`http.Client` using test client certs.
|
||||
|
||||
- No client cert → TLS handshake rejected (Go returns it before handler)
|
||||
- Wrong CA → TLS handshake rejected
|
||||
- Valid cert → 200, user created
|
||||
- Expired cert → TLS handshake rejected
|
||||
|
||||
**Node-to-node integration:**
|
||||
|
||||
Two in-process servers with distinct node certs, same CA. Verify:
|
||||
|
||||
- Node A can heartbeat to Node B
|
||||
- Node with wrong-CA cert cannot dial either
|
||||
- Connection refused when cert SANs don't match dial address
|
||||
(if `tls.Config.ServerName` is set — TBD whether we enforce this
|
||||
for internal traffic or rely solely on CA verification)
|
||||
|
||||
---
|
||||
|
||||
## Migration Notes
|
||||
|
||||
No database schema changes. The `users` table already has `auth_source`
|
||||
and `external_id` columns from the v0.24.1 mTLS/OIDC work. The native
|
||||
provider populates them identically to the proxy provider.
|
||||
|
||||
The new code is:
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `server/auth/mtls_native.go` | `MTLSNativeProvider` type |
|
||||
| `server/auth/mtls_helpers.go` | Shared: `ParseDN`, `FingerprintCert`, `resolveOrProvision` |
|
||||
| `server/auth/mtls_native_test.go` | Unit + integration tests |
|
||||
| `server/config/tls.go` | `TLSConfig` struct, loader, validation |
|
||||
| `scripts/switchboard-ca.sh` | Cert provisioning wrapper |
|
||||
|
||||
`server/auth/mtls.go` (existing) is renamed to `mtls_proxy.go` for
|
||||
clarity. No behavioral changes to the proxy provider.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **SAN enforcement on node-to-node:** Do we verify `ServerName`
|
||||
matches the dialed address, or just verify the cert is CA-signed?
|
||||
Strict SAN checking is more secure but makes IP changes painful.
|
||||
Recommendation: verify CA signature only for internal traffic;
|
||||
SAN checking for client-facing listen.
|
||||
|
||||
2. **Cert rotation without restart:** Not in scope for v0.6.7, but
|
||||
the `tls.Config.GetCertificate` / `GetConfigForClient` callbacks
|
||||
make it straightforward to add later — read cert from disk on each
|
||||
handshake (with caching). Worth noting in the design so we don't
|
||||
paint ourselves into a corner.
|
||||
|
||||
3. **PKCS#12 / combined files:** Some operators prefer `.p12` bundles
|
||||
over separate `.crt`/`.key` files. Low priority but trivial to add
|
||||
via `crypto/pkcs12.Decode`. Defer unless requested.
|
||||
@@ -1,227 +0,0 @@
|
||||
# Distribution Guide
|
||||
|
||||
## Quick Start
|
||||
|
||||
```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 \
|
||||
ghcr.io/switchboard-core/switchboard-core:latest
|
||||
```
|
||||
|
||||
On first run, bundled packages are automatically installed — workflows, surfaces, and extensions are ready to use immediately.
|
||||
|
||||
## Bundled Packages
|
||||
|
||||
The production Docker image ships with pre-built packages. A **curated default set** is auto-installed on first boot; additional packages ship in the image and can be opted-in via `BUNDLED_PACKAGES`.
|
||||
|
||||
#### Default Set (auto-installed)
|
||||
|
||||
| Package | Type | Description |
|
||||
|---------|------|-------------|
|
||||
| notes | surface | Markdown notes with graph view |
|
||||
| chat | surface | Real-time chat surface |
|
||||
| chat-core | library | Conversations, messages, read cursors |
|
||||
| mermaid-renderer | extension | Diagram rendering |
|
||||
| schedules | surface | Cron task management UI |
|
||||
|
||||
#### Opt-in (ship in image, require `BUNDLED_PACKAGES` to enable)
|
||||
|
||||
| Package | Type | Description |
|
||||
|---------|------|-------------|
|
||||
| tasks | full | Kanban/list task manager with webhooks |
|
||||
| workflow-chat | extension | Chat integration for workflow stages |
|
||||
| dashboard | surface | Dashboard surface |
|
||||
| workflow-demo | surface | Interactive walkthrough with diagrams |
|
||||
| bug-report-triage | workflow | Public entry, severity routing, SLA timers |
|
||||
| content-approval | workflow | Multi-party signoff example |
|
||||
| employee-onboarding | workflow | Automated provisioning + manager signoff |
|
||||
| editor | surface | Rich markdown editor |
|
||||
| hello-dashboard | surface | Welcome/getting started surface |
|
||||
| team-activity-log | surface | Team activity feed |
|
||||
| webhook-notifier | workflow | HTTP outbound via connections + Starlark |
|
||||
| csv-table | extension | CSV table renderer |
|
||||
| diff-viewer | extension | Diff visualization |
|
||||
| git-board | surface | Git board interface |
|
||||
| gitea-client | library | Gitea API integration library |
|
||||
| js-sandbox | extension | JavaScript sandbox |
|
||||
| katex-renderer | extension | LaTeX rendering |
|
||||
| regex-tester | extension | Regex testing tool |
|
||||
| icd-test-runner | surface | E2E API test suite |
|
||||
| sdk-test-runner | surface | SDK feature test suite |
|
||||
|
||||
### Behavior
|
||||
|
||||
- **First boot**: Curated default packages are installed and enabled automatically.
|
||||
- **Subsequent boots**: No-op — already-installed packages are skipped.
|
||||
- **Admin uninstalls a package**: It stays uninstalled. Bundled packages are never force-reinstalled.
|
||||
- **To re-install**: Delete the package from the database, then restart the container.
|
||||
|
||||
### Selecting Packages (Allowlist)
|
||||
|
||||
Set `BUNDLED_PACKAGES` to control which packages are installed:
|
||||
|
||||
```bash
|
||||
# Install ALL packages (everything in the image)
|
||||
docker run -p 8080:80 \
|
||||
-e BUNDLED_PACKAGES="*" \
|
||||
ghcr.io/switchboard-core/switchboard-core:latest
|
||||
|
||||
# Install specific packages only
|
||||
docker run -p 8080:80 \
|
||||
-e BUNDLED_PACKAGES="notes,tasks,schedules" \
|
||||
ghcr.io/switchboard-core/switchboard-core:latest
|
||||
```
|
||||
|
||||
Empty (default) installs the curated default set. Use `*` to install all packages. This is useful for Helm charts where different environments need different packages.
|
||||
|
||||
### Disabling Auto-Install
|
||||
|
||||
Set `SKIP_BUNDLED_PACKAGES=true` to prevent bundled packages from being installed:
|
||||
|
||||
```bash
|
||||
docker run -p 8080:80 \
|
||||
-e SKIP_BUNDLED_PACKAGES=true \
|
||||
ghcr.io/switchboard-core/switchboard-core:latest
|
||||
```
|
||||
|
||||
### Custom Bundle Directory
|
||||
|
||||
Override the default bundled packages location with `BUNDLED_PACKAGES_DIR`:
|
||||
|
||||
```bash
|
||||
docker run -p 8080:80 \
|
||||
-e BUNDLED_PACKAGES_DIR=/custom/packages \
|
||||
-v /host/packages:/custom/packages \
|
||||
ghcr.io/switchboard-core/switchboard-core:latest
|
||||
```
|
||||
|
||||
## Builder Image
|
||||
|
||||
The builder image pre-caches Go modules and Node dependencies for faster custom builds.
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/switchboard-core/builder:latest
|
||||
```
|
||||
|
||||
### What It Caches
|
||||
|
||||
- Go module cache (`go mod download` for all server dependencies)
|
||||
- Node modules for the CM6 editor bundle
|
||||
- Vendor library tarballs (marked, DOMPurify, mermaid, KaTeX)
|
||||
- Build tools (zip, Node.js runtime)
|
||||
|
||||
### Using in Custom Builds
|
||||
|
||||
Reference the builder image as a base stage in your Dockerfile:
|
||||
|
||||
```dockerfile
|
||||
FROM ghcr.io/switchboard-core/builder:latest AS builder
|
||||
WORKDIR /app
|
||||
COPY server/ .
|
||||
RUN go build -ldflags="-s -w" -o /bin/switchboard .
|
||||
```
|
||||
|
||||
### Building Locally
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.builder -t switchboard-builder .
|
||||
```
|
||||
|
||||
## Custom Build Guide
|
||||
|
||||
### Adding Custom Packages
|
||||
|
||||
1. Create your package in `packages/your-package/` with a `manifest.json`
|
||||
2. Build all packages: `cd packages && bash build.sh all`
|
||||
3. Build the Docker image: `docker build -t my-switchboard .`
|
||||
|
||||
The Dockerfile automatically builds all packages in the `packages/` directory and bundles them into the production image.
|
||||
|
||||
### Removing Bundled Packages
|
||||
|
||||
To exclude specific packages from the bundle, either:
|
||||
- Remove them from `packages/` before building
|
||||
- Set `SKIP_BUNDLED_PACKAGES=true` and install packages manually via the admin UI
|
||||
|
||||
### Forking for Custom Builds
|
||||
|
||||
```bash
|
||||
git clone https://github.com/switchboard-core/switchboard-core.git
|
||||
cd switchboard-core
|
||||
|
||||
# Add/modify packages
|
||||
cp -r my-extension packages/my-extension/
|
||||
|
||||
# Build with builder image for faster compilation
|
||||
docker build -t my-switchboard .
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PORT` | `8080` | Backend API port |
|
||||
| `DB_DRIVER` | (auto) | `postgres` or `sqlite` |
|
||||
| `DATABASE_URL` | | PostgreSQL connection string |
|
||||
| `JWT_SECRET` | `dev-secret-change-me` | **Must 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 |
|
||||
| `BASE_PATH` | | URL prefix (e.g. `/switchboard`) |
|
||||
| `SKIP_BUNDLED_PACKAGES` | `false` | Disable auto-install of bundled packages |
|
||||
| `BUNDLED_PACKAGES` | (empty = defaults) | `""` curated defaults, `"*"` all, or comma-separated IDs |
|
||||
| `BUNDLED_PACKAGES_DIR` | `/app/bundled-packages` | Override bundled packages location |
|
||||
| `LOG_FORMAT` | `text` | `text` or `json` |
|
||||
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` |
|
||||
|
||||
### Database
|
||||
|
||||
PostgreSQL is recommended for production. SQLite is suitable for single-instance evaluation.
|
||||
|
||||
```bash
|
||||
# PostgreSQL (recommended)
|
||||
docker run -p 8080:80 \
|
||||
-e DATABASE_URL="postgres://user:pass@host:5432/switchboard?sslmode=require" \
|
||||
-e JWT_SECRET="$(openssl rand -hex 32)" \
|
||||
-e ENCRYPTION_KEY="$(openssl rand -hex 32)" \
|
||||
ghcr.io/switchboard-core/switchboard-core:latest
|
||||
|
||||
# SQLite (evaluation only)
|
||||
docker run -p 8080:80 \
|
||||
-e DB_DRIVER=sqlite \
|
||||
-v switchboard-data:/data \
|
||||
ghcr.io/switchboard-core/switchboard-core:latest
|
||||
```
|
||||
|
||||
### Storage
|
||||
|
||||
Object storage is required for file uploads and package asset extraction.
|
||||
|
||||
```bash
|
||||
# PVC (auto-detected if path is writable)
|
||||
docker run -v switchboard-storage:/data/storage ...
|
||||
|
||||
# S3-compatible (MinIO, AWS S3, Ceph)
|
||||
docker run \
|
||||
-e STORAGE_BACKEND=s3 \
|
||||
-e S3_BUCKET=switchboard \
|
||||
-e S3_ENDPOINT=https://minio.corp:9000 \
|
||||
-e S3_ACCESS_KEY=... \
|
||||
-e S3_SECRET_KEY=... \
|
||||
-e S3_FORCE_PATH_STYLE=true \
|
||||
...
|
||||
```
|
||||
|
||||
### Kubernetes
|
||||
|
||||
See the `k8s/` directory for example manifests. Key considerations:
|
||||
|
||||
- Use `POSTGRES_HOST`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` env vars (assembled into DSN automatically)
|
||||
- Set liveness probe to `/healthz/live`, readiness probe to `/healthz/ready`
|
||||
- Mount a PVC at `/data/storage` for file storage, or configure S3
|
||||
- Set `JWT_SECRET` and `ENCRYPTION_KEY` via Kubernetes Secrets
|
||||
@@ -1,183 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,91 +0,0 @@
|
||||
# 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
|
||||
@@ -1,130 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,94 +0,0 @@
|
||||
# Package Registry
|
||||
|
||||
The package registry lets admins browse and install packages from an external
|
||||
JSON index — a lightweight alternative to manually uploading `.pkg` files.
|
||||
|
||||
## Registry JSON Format
|
||||
|
||||
The registry is a static JSON file matching the `RegistryResponse` struct:
|
||||
|
||||
```json
|
||||
{
|
||||
"packages": [
|
||||
{
|
||||
"id": "notes",
|
||||
"title": "Notes",
|
||||
"version": "0.8.0",
|
||||
"description": "Markdown notes with backlinks and graph view",
|
||||
"author": "switchboard",
|
||||
"type": "extension",
|
||||
"tier": "core",
|
||||
"download_url": "https://cdn.example.com/pkg/notes.pkg",
|
||||
"size": 48200,
|
||||
"updated_at": "2026-03-20T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Required Fields
|
||||
|
||||
| Field | Description |
|
||||
|----------------|------------------------------------------------|
|
||||
| `id` | Package identifier (matches `manifest.json`) |
|
||||
| `title` | Display name |
|
||||
| `version` | Semver version string |
|
||||
| `description` | Short description |
|
||||
| `download_url` | HTTPS URL to the `.pkg` file (must be HTTPS) |
|
||||
|
||||
### Optional Fields
|
||||
|
||||
| Field | Description |
|
||||
|--------------|------------------------------------------|
|
||||
| `author` | Package author |
|
||||
| `type` | `extension` or `library` |
|
||||
| `tier` | `core`, `official`, or `community` |
|
||||
| `size` | File size in bytes |
|
||||
| `updated_at` | ISO 8601 timestamp of last update |
|
||||
|
||||
## Configuring the Registry URL
|
||||
|
||||
### Via Admin UI
|
||||
|
||||
Navigate to **Admin > Settings > Package Registry** and enter the registry URL.
|
||||
|
||||
### Via API
|
||||
|
||||
```bash
|
||||
curl -X PUT /api/v1/admin/settings/package_registry \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"value": {"url": "https://cdn.example.com/pkg/registry.json"}}'
|
||||
```
|
||||
|
||||
## Generating a Registry
|
||||
|
||||
Use `scripts/generate-registry.sh` to build a `registry.json` from a
|
||||
directory of `.pkg` files:
|
||||
|
||||
```bash
|
||||
# Default: reads dist/, uses placeholder base URL
|
||||
./scripts/generate-registry.sh
|
||||
|
||||
# Custom directory and base URL
|
||||
./scripts/generate-registry.sh ./my-packages https://cdn.example.com/pkg > registry.json
|
||||
```
|
||||
|
||||
Requirements: `jq`, `unzip`, and `stat` (GNU or BSD).
|
||||
|
||||
## Self-Hosting
|
||||
|
||||
Any HTTPS-capable file server works. Upload your `.pkg` files and the
|
||||
generated `registry.json` to the same directory, then point the admin
|
||||
setting to the `registry.json` URL.
|
||||
|
||||
Example with a static file server:
|
||||
|
||||
```
|
||||
/var/www/packages/
|
||||
registry.json
|
||||
notes.pkg
|
||||
chat.pkg
|
||||
chat-core.pkg
|
||||
```
|
||||
|
||||
The registry is fetched and cached for 5 minutes on each browse request.
|
||||
@@ -1,119 +0,0 @@
|
||||
# Build Your First Browser Extension
|
||||
|
||||
This tutorial walks through building a browser extension that renders custom
|
||||
code blocks, modeled on the CSV Table Viewer that ships with Switchboard.
|
||||
|
||||
**Prerequisites:** A running Switchboard instance, a text editor, and `zip`.
|
||||
|
||||
## Step 1: Create the Directory
|
||||
|
||||
```sh
|
||||
mkdir -p my-extension/js
|
||||
```
|
||||
|
||||
## Step 2: Write the Manifest
|
||||
|
||||
Create `my-extension/manifest.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my-extension",
|
||||
"title": "My Extension",
|
||||
"version": "0.1.0",
|
||||
"type": "extension",
|
||||
"tier": "browser",
|
||||
"author": "you",
|
||||
"description": "Renders demo code blocks as styled HTML",
|
||||
"permissions": [],
|
||||
"settings": {}
|
||||
}
|
||||
```
|
||||
|
||||
- **id** -- unique identifier, used as the install key
|
||||
- **type** -- `extension` for browser-only packages; `full` or `surface` for
|
||||
packages with backend routes
|
||||
- **tier** -- `browser` for client-side JS; `starlark` for server-side scripting
|
||||
|
||||
## Step 3: Write the Browser Script
|
||||
|
||||
Create `my-extension/js/script.js`. Browser extensions use the IIFE pattern
|
||||
and register with the SDK through `sw.renderers`:
|
||||
|
||||
```js
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function register() {
|
||||
if (!window.sw?.renderers) return;
|
||||
|
||||
sw.renderers.register('demo-block', {
|
||||
type: 'block',
|
||||
priority: 10,
|
||||
match(lang) {
|
||||
return (lang || '').toLowerCase() === 'demo';
|
||||
},
|
||||
render(lang, code, container) {
|
||||
container.innerHTML =
|
||||
'<div style="padding:12px;background:var(--bg-2);' +
|
||||
'border:1px solid var(--border);border-radius:8px">' +
|
||||
'<strong>Demo:</strong> ' + code +
|
||||
'</div>';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (window.sw?._sdk) {
|
||||
register();
|
||||
} else {
|
||||
document.addEventListener('sw:ready', register, { once: true });
|
||||
}
|
||||
})();
|
||||
```
|
||||
|
||||
The IIFE wrapper keeps variables out of global scope. `sw.renderers.register`
|
||||
takes a name and an options object: `type: 'block'` targets fenced code blocks,
|
||||
`match` checks the language tag, and `render` receives the language, raw code,
|
||||
and a container element. The `sw:ready` event fires once the SDK initializes;
|
||||
if already loaded, register immediately. Use CSS variables like `var(--bg-2)`
|
||||
and `var(--border)` to follow the active theme.
|
||||
|
||||
## Step 4: Package It
|
||||
|
||||
```sh
|
||||
cd my-extension
|
||||
zip -r ../my-extension.pkg manifest.json js/
|
||||
```
|
||||
|
||||
The `.pkg` format is a ZIP with `manifest.json` at the root. Optional
|
||||
directories: `js/`, `css/`, `assets/`. If working inside `packages/`, use
|
||||
the build script instead: `bash build.sh my-extension`.
|
||||
|
||||
## Step 5: Install It
|
||||
|
||||
```sh
|
||||
curl -X POST http://localhost:3000/api/v1/admin/packages/install \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-F "file=@my-extension.pkg"
|
||||
```
|
||||
|
||||
You can also install through the Admin UI under the Packages section.
|
||||
|
||||
## Step 6: Enable and Test
|
||||
|
||||
1. Open the admin panel, navigate to Packages, confirm "My Extension" is enabled.
|
||||
2. Go to any markdown surface (Chat, Notes, etc.).
|
||||
3. Enter a fenced code block with the `demo` language tag.
|
||||
|
||||
You should see styled output instead of a plain code block.
|
||||
|
||||
## Going Further
|
||||
|
||||
- **Add CSS** -- create a `css/` directory; stylesheets are injected automatically.
|
||||
- **Post-renderers** -- register with `type: 'post'` to run after block renderers
|
||||
finish (the Mermaid extension uses this for async SVG rendering).
|
||||
- **Settings** -- declare a `settings` object in the manifest for admin-configurable values.
|
||||
- **Server-side logic** -- set `tier: "starlark"` and add `script.star` for
|
||||
backend API routes and database tables.
|
||||
|
||||
See `PACKAGE-FORMAT.md` for the full manifest spec and `EXTENSION-GUIDE.md`
|
||||
for advanced patterns.
|
||||
@@ -1,12 +1,10 @@
|
||||
{
|
||||
"id": "csv-table",
|
||||
"title": "CSV Table Viewer",
|
||||
"name": "CSV Table Viewer",
|
||||
"version": "1.0.0",
|
||||
"type": "extension",
|
||||
"tier": "browser",
|
||||
"author": "switchboard",
|
||||
"description": "Renders ```csv code blocks as sortable, interactive HTML tables",
|
||||
"requires": [],
|
||||
"permissions": [],
|
||||
"tools": [],
|
||||
"surfaces": [],
|
||||
@@ -3,68 +3,71 @@
|
||||
// ==========================================
|
||||
// Renders ```csv and ```tsv code blocks as sortable HTML tables.
|
||||
// No external dependencies.
|
||||
//
|
||||
// Registers with sw.renderers via the sw:ready event.
|
||||
// ==========================================
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
Extensions.register({
|
||||
id: 'csv-table',
|
||||
|
||||
function _escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
async init(ctx) {
|
||||
const self = this;
|
||||
|
||||
function _parseCSV(text, delimiter) {
|
||||
const rows = [];
|
||||
let row = [];
|
||||
let field = '';
|
||||
let inQuotes = false;
|
||||
let i = 0;
|
||||
// ── Inject styles ──
|
||||
this._injectStyles();
|
||||
|
||||
while (i < text.length) {
|
||||
const ch = text[i];
|
||||
if (inQuotes) {
|
||||
if (ch === '"') {
|
||||
if (i + 1 < text.length && text[i + 1] === '"') {
|
||||
field += '"'; i += 2;
|
||||
} else {
|
||||
inQuotes = false; i++;
|
||||
}
|
||||
} else {
|
||||
field += ch; i++;
|
||||
}
|
||||
} else {
|
||||
if (ch === '"' && field === '') {
|
||||
inQuotes = true; i++;
|
||||
} else if (ch === delimiter) {
|
||||
row.push(field.trim()); field = ''; i++;
|
||||
} else if (ch === '\n' || (ch === '\r' && text[i + 1] === '\n')) {
|
||||
row.push(field.trim());
|
||||
if (row.some(cell => cell !== '')) rows.push(row);
|
||||
row = []; field = '';
|
||||
i += (ch === '\r') ? 2 : 1;
|
||||
} else {
|
||||
field += ch; i++;
|
||||
ctx.renderers.register('csv-block', {
|
||||
type: 'block',
|
||||
priority: 10,
|
||||
match(lang) {
|
||||
const l = (lang || '').toLowerCase();
|
||||
return l === 'csv' || l === 'tsv';
|
||||
},
|
||||
render(lang, code, container) {
|
||||
const delimiter = lang.toLowerCase() === 'tsv' ? '\t' : ',';
|
||||
const rows = self._parseCSV(code.trim(), delimiter);
|
||||
|
||||
if (rows.length === 0) {
|
||||
container.innerHTML = '<div class="csv-empty">No data</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = rows[0];
|
||||
const data = rows.slice(1);
|
||||
const tableId = 'csv-' + Math.random().toString(36).slice(2, 9);
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="csv-table-block ext-rendered" data-csv-id="${tableId}">
|
||||
<div class="csv-toolbar">
|
||||
<span class="csv-info">${data.length} row${data.length !== 1 ? 's' : ''} × ${headers.length} col${headers.length !== 1 ? 's' : ''}</span>
|
||||
<button class="csv-copy-btn" title="Copy as CSV">📋 Copy</button>
|
||||
</div>
|
||||
<div class="csv-table-scroll">
|
||||
<table class="csv-table">
|
||||
<thead>
|
||||
<tr>${headers.map((h, i) => `<th data-col="${i}" title="Click to sort">${self._escapeHtml(h)}<span class="csv-sort-icon"></span></th>`).join('')}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${data.map(row => `<tr>${row.map(cell => `<td>${self._escapeHtml(cell)}</td>`).join('')}</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<details class="csv-source">
|
||||
<summary>📄 View raw</summary>
|
||||
<pre><code>${self._escapeHtml(code.trim())}</code></pre>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Attach sort handlers + copy
|
||||
self._attachHandlers(container, tableId, code.trim());
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
row.push(field.trim());
|
||||
if (row.some(cell => cell !== '')) rows.push(row);
|
||||
|
||||
if (rows.length > 0) {
|
||||
const maxCols = Math.max(...rows.map(r => r.length));
|
||||
rows.forEach(r => { while (r.length < maxCols) r.push(''); });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function _attachHandlers(container, tableId, rawCSV) {
|
||||
_attachHandlers(container, tableId, rawCSV) {
|
||||
const block = container.querySelector(`[data-csv-id="${tableId}"]`);
|
||||
if (!block) return;
|
||||
|
||||
// ── Column sorting ──
|
||||
const ths = block.querySelectorAll('th[data-col]');
|
||||
let sortCol = -1;
|
||||
let sortAsc = true;
|
||||
@@ -73,50 +76,129 @@
|
||||
th.style.cursor = 'pointer';
|
||||
th.addEventListener('click', () => {
|
||||
const col = parseInt(th.dataset.col, 10);
|
||||
if (sortCol === col) { sortAsc = !sortAsc; }
|
||||
else { sortCol = col; sortAsc = true; }
|
||||
if (sortCol === col) {
|
||||
sortAsc = !sortAsc;
|
||||
} else {
|
||||
sortCol = col;
|
||||
sortAsc = true;
|
||||
}
|
||||
|
||||
const tbody = block.querySelector('tbody');
|
||||
const rows = Array.from(tbody.querySelectorAll('tr'));
|
||||
|
||||
rows.sort((a, b) => {
|
||||
const cellA = (a.children[col]?.textContent || '').trim();
|
||||
const cellB = (b.children[col]?.textContent || '').trim();
|
||||
const numA = parseFloat(cellA);
|
||||
const numB = parseFloat(cellB);
|
||||
if (!isNaN(numA) && !isNaN(numB))
|
||||
|
||||
// Numeric sort if both are numbers
|
||||
if (!isNaN(numA) && !isNaN(numB)) {
|
||||
return sortAsc ? numA - numB : numB - numA;
|
||||
}
|
||||
// String sort
|
||||
const cmp = cellA.localeCompare(cellB, undefined, { numeric: true, sensitivity: 'base' });
|
||||
return sortAsc ? cmp : -cmp;
|
||||
});
|
||||
|
||||
rows.forEach(row => tbody.appendChild(row));
|
||||
|
||||
// Update sort icons
|
||||
ths.forEach(h => {
|
||||
const icon = h.querySelector('.csv-sort-icon');
|
||||
if (icon) {
|
||||
const hCol = parseInt(h.dataset.col, 10);
|
||||
icon.textContent = hCol === sortCol ? (sortAsc ? ' \u25b2' : ' \u25bc') : '';
|
||||
icon.textContent = hCol === sortCol ? (sortAsc ? ' ▲' : ' ▼') : '';
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Copy button ──
|
||||
const copyBtn = block.querySelector('.csv-copy-btn');
|
||||
if (copyBtn) {
|
||||
copyBtn.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(rawCSV).then(() => {
|
||||
copyBtn.textContent = '\u2713 Copied';
|
||||
setTimeout(() => { copyBtn.textContent = '\ud83d\udccb Copy'; }, 1500);
|
||||
copyBtn.textContent = '✓ Copied';
|
||||
setTimeout(() => { copyBtn.textContent = '📋 Copy'; }, 1500);
|
||||
}).catch(() => {
|
||||
copyBtn.textContent = '\u2717 Failed';
|
||||
setTimeout(() => { copyBtn.textContent = '\ud83d\udccb Copy'; }, 1500);
|
||||
copyBtn.textContent = '✗ Failed';
|
||||
setTimeout(() => { copyBtn.textContent = '📋 Copy'; }, 1500);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Styles ──────────────────────────────
|
||||
/**
|
||||
* Simple CSV parser that handles quoted fields.
|
||||
*/
|
||||
_parseCSV(text, delimiter) {
|
||||
const rows = [];
|
||||
let row = [];
|
||||
let field = '';
|
||||
let inQuotes = false;
|
||||
let i = 0;
|
||||
|
||||
function _injectStyles() {
|
||||
while (i < text.length) {
|
||||
const ch = text[i];
|
||||
|
||||
if (inQuotes) {
|
||||
if (ch === '"') {
|
||||
// Peek ahead: escaped quote or end of quoted field
|
||||
if (i + 1 < text.length && text[i + 1] === '"') {
|
||||
field += '"';
|
||||
i += 2;
|
||||
} else {
|
||||
inQuotes = false;
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
field += ch;
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
if (ch === '"' && field === '') {
|
||||
inQuotes = true;
|
||||
i++;
|
||||
} else if (ch === delimiter) {
|
||||
row.push(field.trim());
|
||||
field = '';
|
||||
i++;
|
||||
} else if (ch === '\n' || (ch === '\r' && text[i + 1] === '\n')) {
|
||||
row.push(field.trim());
|
||||
if (row.some(cell => cell !== '')) rows.push(row);
|
||||
row = [];
|
||||
field = '';
|
||||
i += (ch === '\r') ? 2 : 1;
|
||||
} else {
|
||||
field += ch;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last field/row
|
||||
row.push(field.trim());
|
||||
if (row.some(cell => cell !== '')) rows.push(row);
|
||||
|
||||
// Normalize: pad short rows to header length
|
||||
if (rows.length > 0) {
|
||||
const maxCols = Math.max(...rows.map(r => r.length));
|
||||
rows.forEach(r => {
|
||||
while (r.length < maxCols) r.push('');
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
},
|
||||
|
||||
_escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
},
|
||||
|
||||
_injectStyles() {
|
||||
if (document.getElementById('ext-style-csv-table')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'ext-style-csv-table';
|
||||
@@ -164,66 +246,9 @@
|
||||
}
|
||||
.csv-empty { padding: 16px; text-align: center; color: var(--text-3); }`;
|
||||
document.head.appendChild(style);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
document.getElementById('ext-style-csv-table')?.remove();
|
||||
}
|
||||
|
||||
// ── Registration ────────────────────────
|
||||
|
||||
function register() {
|
||||
if (!window.sw?.renderers) return;
|
||||
|
||||
_injectStyles();
|
||||
|
||||
sw.renderers.register('csv-block', {
|
||||
type: 'block',
|
||||
priority: 10,
|
||||
match(lang) {
|
||||
const l = (lang || '').toLowerCase();
|
||||
return l === 'csv' || l === 'tsv';
|
||||
},
|
||||
render(lang, code, container) {
|
||||
const delimiter = lang.toLowerCase() === 'tsv' ? '\t' : ',';
|
||||
const rows = _parseCSV(code.trim(), delimiter);
|
||||
|
||||
if (rows.length === 0) {
|
||||
container.innerHTML = '<div class="csv-empty">No data</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = rows[0];
|
||||
const data = rows.slice(1);
|
||||
const tableId = 'csv-' + Math.random().toString(36).slice(2, 9);
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="csv-table-block ext-rendered" data-csv-id="${tableId}">
|
||||
<div class="csv-toolbar">
|
||||
<span class="csv-info">${data.length} row${data.length !== 1 ? 's' : ''} \u00d7 ${headers.length} col${headers.length !== 1 ? 's' : ''}</span>
|
||||
<button class="csv-copy-btn" title="Copy as CSV">\ud83d\udccb Copy</button>
|
||||
</div>
|
||||
<div class="csv-table-scroll">
|
||||
<table class="csv-table">
|
||||
<thead>
|
||||
<tr>${headers.map((h, i) => `<th data-col="${i}" title="Click to sort">${_escapeHtml(h)}<span class="csv-sort-icon"></span></th>`).join('')}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${data.map(row => `<tr>${row.map(cell => `<td>${_escapeHtml(cell)}</td>`).join('')}</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<details class="csv-source">
|
||||
<summary>\ud83d\udcc4 View raw</summary>
|
||||
<pre><code>${_escapeHtml(code.trim())}</code></pre>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
|
||||
_attachHandlers(container, tableId, code.trim());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (window.sw?._sdk) {
|
||||
register();
|
||||
} else {
|
||||
document.addEventListener('sw:ready', register, { once: true });
|
||||
}
|
||||
})();
|
||||
});
|
||||
@@ -1,12 +1,10 @@
|
||||
{
|
||||
"id": "diff-viewer",
|
||||
"title": "Diff Viewer",
|
||||
"name": "Diff Viewer",
|
||||
"version": "1.0.0",
|
||||
"type": "extension",
|
||||
"tier": "browser",
|
||||
"author": "switchboard",
|
||||
"description": "Renders ```diff code blocks with syntax-highlighted additions, deletions, and context lines",
|
||||
"requires": [],
|
||||
"permissions": [],
|
||||
"tools": [],
|
||||
"surfaces": [],
|
||||
@@ -4,22 +4,106 @@
|
||||
// Renders ```diff code blocks with syntax-highlighted
|
||||
// additions, deletions, hunk headers, and context lines.
|
||||
// No external dependencies.
|
||||
//
|
||||
// Registers with sw.renderers via the sw:ready event.
|
||||
// ==========================================
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
Extensions.register({
|
||||
id: 'diff-viewer',
|
||||
|
||||
function _escapeHtml(str) {
|
||||
async init(ctx) {
|
||||
const self = this;
|
||||
|
||||
// ── Inject styles ──
|
||||
this._injectStyles();
|
||||
|
||||
ctx.renderers.register('diff-block', {
|
||||
type: 'block',
|
||||
priority: 10,
|
||||
pattern: 'diff',
|
||||
render(lang, code, container) {
|
||||
const lines = code.split('\n');
|
||||
let stats = { added: 0, removed: 0 };
|
||||
let currentFile = '';
|
||||
|
||||
const rendered = lines.map(line => {
|
||||
const escaped = self._escapeHtml(line);
|
||||
|
||||
// File headers
|
||||
if (line.startsWith('--- ') || line.startsWith('+++ ')) {
|
||||
if (line.startsWith('+++ ')) {
|
||||
currentFile = line.slice(4).trim();
|
||||
}
|
||||
return `<div class="diff-line diff-file-header">${escaped}</div>`;
|
||||
}
|
||||
|
||||
// Hunk headers
|
||||
if (line.startsWith('@@')) {
|
||||
const hunkMatch = line.match(/^@@\s*-(\d+)(?:,\d+)?\s*\+(\d+)(?:,\d+)?\s*@@\s*(.*)/);
|
||||
const context = hunkMatch ? hunkMatch[3] : '';
|
||||
return `<div class="diff-line diff-hunk">${escaped}</div>`;
|
||||
}
|
||||
|
||||
// Additions
|
||||
if (line.startsWith('+')) {
|
||||
stats.added++;
|
||||
return `<div class="diff-line diff-add"><span class="diff-indicator">+</span>${self._escapeHtml(line.slice(1))}</div>`;
|
||||
}
|
||||
|
||||
// Deletions
|
||||
if (line.startsWith('-')) {
|
||||
stats.removed++;
|
||||
return `<div class="diff-line diff-del"><span class="diff-indicator">-</span>${self._escapeHtml(line.slice(1))}</div>`;
|
||||
}
|
||||
|
||||
// Context (lines starting with space or no prefix)
|
||||
if (line.startsWith(' ')) {
|
||||
return `<div class="diff-line diff-ctx"><span class="diff-indicator"> </span>${self._escapeHtml(line.slice(1))}</div>`;
|
||||
}
|
||||
|
||||
// Other (commit info, etc)
|
||||
return `<div class="diff-line diff-meta">${escaped}</div>`;
|
||||
}).join('');
|
||||
|
||||
const fileLabel = currentFile ? `<span class="diff-filename" title="${self._escapeHtml(currentFile)}">${self._escapeHtml(currentFile)}</span>` : '';
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="diff-block ext-rendered">
|
||||
<div class="diff-toolbar">
|
||||
${fileLabel}
|
||||
<span class="diff-stats">
|
||||
<span class="diff-stat-add">+${stats.added}</span>
|
||||
<span class="diff-stat-del">-${stats.removed}</span>
|
||||
</span>
|
||||
<button class="diff-copy-btn" title="Copy diff">📋 Copy</button>
|
||||
</div>
|
||||
<div class="diff-content">${rendered}</div>
|
||||
<details class="diff-source-toggle">
|
||||
<summary>📝 View raw</summary>
|
||||
<pre><code>${self._escapeHtml(code)}</code></pre>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Copy handler
|
||||
const copyBtn = container.querySelector('.diff-copy-btn');
|
||||
if (copyBtn) {
|
||||
copyBtn.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
copyBtn.textContent = '✓ Copied';
|
||||
setTimeout(() => { copyBtn.textContent = '📋 Copy'; }, 1500);
|
||||
}).catch(() => {});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
_escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
},
|
||||
|
||||
// ── Styles ──────────────────────────────
|
||||
|
||||
function _injectStyles() {
|
||||
_injectStyles() {
|
||||
if (document.getElementById('ext-style-diff-viewer')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'ext-style-diff-viewer';
|
||||
@@ -74,86 +158,9 @@
|
||||
max-height: 200px; overflow: auto;
|
||||
}`;
|
||||
document.head.appendChild(style);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
document.getElementById('ext-style-diff-viewer')?.remove();
|
||||
}
|
||||
|
||||
// ── Registration ────────────────────────
|
||||
|
||||
function register() {
|
||||
if (!window.sw?.renderers) return;
|
||||
|
||||
_injectStyles();
|
||||
|
||||
sw.renderers.register('diff-block', {
|
||||
type: 'block',
|
||||
priority: 10,
|
||||
pattern: 'diff',
|
||||
render(lang, code, container) {
|
||||
const lines = code.split('\n');
|
||||
let stats = { added: 0, removed: 0 };
|
||||
let currentFile = '';
|
||||
|
||||
const rendered = lines.map(line => {
|
||||
const escaped = _escapeHtml(line);
|
||||
|
||||
if (line.startsWith('--- ') || line.startsWith('+++ ')) {
|
||||
if (line.startsWith('+++ ')) currentFile = line.slice(4).trim();
|
||||
return `<div class="diff-line diff-file-header">${escaped}</div>`;
|
||||
}
|
||||
if (line.startsWith('@@')) {
|
||||
return `<div class="diff-line diff-hunk">${escaped}</div>`;
|
||||
}
|
||||
if (line.startsWith('+')) {
|
||||
stats.added++;
|
||||
return `<div class="diff-line diff-add"><span class="diff-indicator">+</span>${_escapeHtml(line.slice(1))}</div>`;
|
||||
}
|
||||
if (line.startsWith('-')) {
|
||||
stats.removed++;
|
||||
return `<div class="diff-line diff-del"><span class="diff-indicator">-</span>${_escapeHtml(line.slice(1))}</div>`;
|
||||
}
|
||||
if (line.startsWith(' ')) {
|
||||
return `<div class="diff-line diff-ctx"><span class="diff-indicator"> </span>${_escapeHtml(line.slice(1))}</div>`;
|
||||
}
|
||||
return `<div class="diff-line diff-meta">${escaped}</div>`;
|
||||
}).join('');
|
||||
|
||||
const fileLabel = currentFile
|
||||
? `<span class="diff-filename" title="${_escapeHtml(currentFile)}">${_escapeHtml(currentFile)}</span>`
|
||||
: '';
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="diff-block ext-rendered">
|
||||
<div class="diff-toolbar">
|
||||
${fileLabel}
|
||||
<span class="diff-stats">
|
||||
<span class="diff-stat-add">+${stats.added}</span>
|
||||
<span class="diff-stat-del">-${stats.removed}</span>
|
||||
</span>
|
||||
<button class="diff-copy-btn" title="Copy diff">\ud83d\udccb Copy</button>
|
||||
</div>
|
||||
<div class="diff-content">${rendered}</div>
|
||||
<details class="diff-source-toggle">
|
||||
<summary>\ud83d\udcdd View raw</summary>
|
||||
<pre><code>${_escapeHtml(code)}</code></pre>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const copyBtn = container.querySelector('.diff-copy-btn');
|
||||
if (copyBtn) {
|
||||
copyBtn.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
copyBtn.textContent = '\u2713 Copied';
|
||||
setTimeout(() => { copyBtn.textContent = '\ud83d\udccb Copy'; }, 1500);
|
||||
}).catch(() => {});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (window.sw?._sdk) {
|
||||
register();
|
||||
} else {
|
||||
document.addEventListener('sw:ready', register, { once: true });
|
||||
}
|
||||
})();
|
||||
});
|
||||
@@ -1,12 +1,10 @@
|
||||
{
|
||||
"id": "js-sandbox",
|
||||
"title": "JavaScript Sandbox",
|
||||
"name": "JavaScript Sandbox",
|
||||
"version": "1.0.0",
|
||||
"type": "extension",
|
||||
"tier": "browser",
|
||||
"author": "switchboard",
|
||||
"description": "Executes JavaScript in a sandboxed iframe. Allows the LLM to run code, verify calculations, and transform data — no server compute required.",
|
||||
"requires": ["chat"],
|
||||
"permissions": [],
|
||||
"tools": [
|
||||
{
|
||||
@@ -1,12 +1,10 @@
|
||||
{
|
||||
"id": "katex-renderer",
|
||||
"title": "KaTeX Math",
|
||||
"name": "KaTeX Math",
|
||||
"version": "1.0.0",
|
||||
"type": "extension",
|
||||
"tier": "browser",
|
||||
"author": "switchboard",
|
||||
"description": "Renders LaTeX math expressions: ```latex blocks and inline $...$ / $$...$$ syntax",
|
||||
"requires": [],
|
||||
"permissions": [],
|
||||
"tools": [],
|
||||
"surfaces": [],
|
||||
@@ -3,141 +3,177 @@
|
||||
// ==========================================
|
||||
// Renders ```latex / ```math code blocks and inline $...$ / $$...$$ syntax.
|
||||
// Loads KaTeX dynamically on first use.
|
||||
//
|
||||
// Registers with sw.renderers via the sw:ready event.
|
||||
// ==========================================
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
Extensions.register({
|
||||
id: 'katex-renderer',
|
||||
|
||||
let _katexReady = false;
|
||||
let _katexLoading = null;
|
||||
_katexReady: false,
|
||||
_katexLoading: null,
|
||||
|
||||
function _escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
async init(ctx) {
|
||||
const self = this;
|
||||
|
||||
// ── KaTeX Library Loading ──────────────
|
||||
// ── Inject styles ──
|
||||
this._injectStyles();
|
||||
|
||||
function _loadKaTeX() {
|
||||
if (_katexReady) return Promise.resolve();
|
||||
if (_katexLoading) return _katexLoading;
|
||||
|
||||
_katexLoading = new Promise((resolve, reject) => {
|
||||
if (typeof katex !== 'undefined') {
|
||||
_katexReady = true;
|
||||
resolve();
|
||||
return;
|
||||
// ── Block renderer: match ```latex or ```math ──
|
||||
ctx.renderers.register('katex-block', {
|
||||
type: 'block',
|
||||
priority: 10,
|
||||
match(lang) {
|
||||
const l = (lang || '').toLowerCase();
|
||||
return l === 'latex' || l === 'math' || l === 'tex' || l === 'katex';
|
||||
},
|
||||
render(lang, code, container) {
|
||||
const id = 'katex-' + Math.random().toString(36).slice(2, 9);
|
||||
container.innerHTML = `
|
||||
<div class="katex-block ext-rendered" data-katex-id="${id}">
|
||||
<div class="katex-display-container" data-katex-src="${encodeURIComponent(code.trim())}" data-katex-display="true">
|
||||
<div class="katex-loading">
|
||||
<span class="katex-spinner"></span> Rendering math…
|
||||
</div>
|
||||
</div>
|
||||
<details class="katex-source">
|
||||
<summary>𝑓(𝑥) View source</summary>
|
||||
<pre><code class="language-latex">${self._escapeHtml(code.trim())}</code></pre>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const base = (window.__BASE__ || '');
|
||||
const localCSS = `${base}/vendor/katex/katex.min.css`;
|
||||
const localJS = `${base}/vendor/katex/katex.min.js`;
|
||||
const cdnCSS = 'https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css';
|
||||
const cdnJS = 'https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js';
|
||||
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = localCSS;
|
||||
link.onerror = () => { link.href = cdnCSS; };
|
||||
document.head.appendChild(link);
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = localJS;
|
||||
script.onload = () => { _katexReady = true; resolve(); };
|
||||
script.onerror = () => {
|
||||
console.warn('[KaTeX] Local vendor not found, trying CDN');
|
||||
const cdn = document.createElement('script');
|
||||
cdn.src = cdnJS;
|
||||
cdn.onload = () => { _katexReady = true; resolve(); };
|
||||
cdn.onerror = () => {
|
||||
console.error('[KaTeX] Failed to load from both local and CDN');
|
||||
reject(new Error('Failed to load KaTeX'));
|
||||
};
|
||||
document.head.appendChild(cdn);
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
return _katexLoading;
|
||||
}
|
||||
|
||||
// ── Block Rendering ──────────────────────
|
||||
// ── Post renderer: render KaTeX blocks + find inline math ──
|
||||
ctx.renderers.register('katex-post', {
|
||||
type: 'post',
|
||||
priority: 10,
|
||||
render(container) {
|
||||
// Render code blocks with data-katex-src
|
||||
const blocks = container.querySelectorAll('.katex-display-container[data-katex-src]');
|
||||
if (blocks.length > 0) {
|
||||
blocks.forEach(el => {
|
||||
if (el.dataset.rendered) return;
|
||||
el.dataset.rendered = 'pending';
|
||||
self._renderBlock(el);
|
||||
});
|
||||
}
|
||||
|
||||
async function _renderBlock(el) {
|
||||
// Process inline math in text content
|
||||
self._processInlineMath(container);
|
||||
}
|
||||
});
|
||||
|
||||
// Pre-load KaTeX
|
||||
this._loadKaTeX();
|
||||
},
|
||||
|
||||
async _renderBlock(el) {
|
||||
const code = decodeURIComponent(el.dataset.katexSrc);
|
||||
const displayMode = el.dataset.katexDisplay === 'true';
|
||||
|
||||
try {
|
||||
await _loadKaTeX();
|
||||
await this._loadKaTeX();
|
||||
el.innerHTML = katex.renderToString(code, {
|
||||
displayMode, throwOnError: false, trust: false, strict: false,
|
||||
displayMode,
|
||||
throwOnError: false,
|
||||
trust: false,
|
||||
strict: false,
|
||||
});
|
||||
el.dataset.rendered = 'true';
|
||||
} catch (e) {
|
||||
el.innerHTML = `
|
||||
<div class="katex-error">
|
||||
<strong>Math error:</strong> ${_escapeHtml(e.message || String(e))}
|
||||
<strong>Math error:</strong> ${this._escapeHtml(e.message || String(e))}
|
||||
</div>
|
||||
`;
|
||||
el.dataset.rendered = 'error';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Inline Math Processing ───────────────
|
||||
|
||||
async function _processInlineMath(container) {
|
||||
async _processInlineMath(container) {
|
||||
// Skip if no $ signs present at all
|
||||
if (!container.textContent || !container.textContent.includes('$')) return;
|
||||
try { await _loadKaTeX(); } catch { return; }
|
||||
|
||||
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode(node) {
|
||||
const parent = node.parentElement;
|
||||
if (!parent) return NodeFilter.FILTER_REJECT;
|
||||
const tag = parent.tagName;
|
||||
if (tag === 'CODE' || tag === 'PRE' || tag === 'SCRIPT' ||
|
||||
tag === 'TEXTAREA' || tag === 'STYLE') return NodeFilter.FILTER_REJECT;
|
||||
if (parent.closest('.katex, .katex-block, .katex-display-container, code, pre'))
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
if (!node.textContent.includes('$')) return NodeFilter.FILTER_REJECT;
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
try {
|
||||
await this._loadKaTeX();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
// Walk text nodes, skip code/pre/katex-already-rendered
|
||||
const walker = document.createTreeWalker(
|
||||
container,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
{
|
||||
acceptNode(node) {
|
||||
const parent = node.parentElement;
|
||||
if (!parent) return NodeFilter.FILTER_REJECT;
|
||||
// Skip code, pre, already-rendered katex, and script elements
|
||||
const tag = parent.tagName;
|
||||
if (tag === 'CODE' || tag === 'PRE' || tag === 'SCRIPT' ||
|
||||
tag === 'TEXTAREA' || tag === 'STYLE') {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
if (parent.closest('.katex, .katex-block, .katex-display-container, code, pre')) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
if (!node.textContent.includes('$')) return NodeFilter.FILTER_REJECT;
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
const textNodes = [];
|
||||
let node;
|
||||
while (node = walker.nextNode()) textNodes.push(node);
|
||||
for (const textNode of textNodes) _replaceInlineMath(textNode);
|
||||
}
|
||||
|
||||
function _replaceInlineMath(textNode) {
|
||||
for (const textNode of textNodes) {
|
||||
this._replaceInlineMath(textNode);
|
||||
}
|
||||
},
|
||||
|
||||
_replaceInlineMath(textNode) {
|
||||
const text = textNode.textContent;
|
||||
// Match $$...$$ (display) and $...$ (inline), non-greedy
|
||||
// Avoid matching escaped \$ or empty $$
|
||||
const pattern = /\$\$([^$]+?)\$\$|\$([^$\n]+?)\$/g;
|
||||
let match;
|
||||
const parts = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
// Text before the match
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({ type: 'text', value: text.slice(lastIndex, match.index) });
|
||||
}
|
||||
const displayMode = !!match[1];
|
||||
|
||||
const displayMode = !!match[1]; // $$ ... $$
|
||||
const expr = match[1] || match[2];
|
||||
|
||||
try {
|
||||
const html = katex.renderToString(expr.trim(), {
|
||||
displayMode, throwOnError: false, trust: false, strict: false,
|
||||
displayMode,
|
||||
throwOnError: false,
|
||||
trust: false,
|
||||
strict: false,
|
||||
});
|
||||
parts.push({ type: 'katex', value: html, displayMode });
|
||||
} catch {
|
||||
// Leave as-is on error
|
||||
parts.push({ type: 'text', value: match[0] });
|
||||
}
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (parts.length === 0) return;
|
||||
if (lastIndex < text.length) parts.push({ type: 'text', value: text.slice(lastIndex) });
|
||||
if (parts.length === 0) return; // No math found
|
||||
|
||||
// Remaining text after last match
|
||||
if (lastIndex < text.length) {
|
||||
parts.push({ type: 'text', value: text.slice(lastIndex) });
|
||||
}
|
||||
|
||||
// Replace text node with a span containing the rendered parts
|
||||
const span = document.createElement('span');
|
||||
span.className = 'katex-inline-container';
|
||||
for (const part of parts) {
|
||||
@@ -150,12 +186,70 @@
|
||||
span.appendChild(wrapper);
|
||||
}
|
||||
}
|
||||
|
||||
textNode.parentNode.replaceChild(span, textNode);
|
||||
}
|
||||
},
|
||||
|
||||
// ── Styles ──────────────────────────────
|
||||
_loadKaTeX() {
|
||||
if (this._katexReady) return Promise.resolve();
|
||||
if (this._katexLoading) return this._katexLoading;
|
||||
|
||||
function _injectStyles() {
|
||||
this._katexLoading = new Promise((resolve, reject) => {
|
||||
if (typeof katex !== 'undefined') {
|
||||
this._katexReady = true;
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const base = (window.__BASE__ || '');
|
||||
const localCSS = `${base}/vendor/katex/katex.min.css`;
|
||||
const localJS = `${base}/vendor/katex/katex.min.js`;
|
||||
const cdnCSS = 'https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css';
|
||||
const cdnJS = 'https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js';
|
||||
|
||||
// Load CSS first
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = localCSS;
|
||||
link.onerror = () => {
|
||||
link.href = cdnCSS;
|
||||
};
|
||||
document.head.appendChild(link);
|
||||
|
||||
// Load JS
|
||||
const script = document.createElement('script');
|
||||
script.src = localJS;
|
||||
script.onload = () => {
|
||||
this._katexReady = true;
|
||||
resolve();
|
||||
};
|
||||
script.onerror = () => {
|
||||
console.warn('[KaTeX] Local vendor not found, trying CDN');
|
||||
const cdn = document.createElement('script');
|
||||
cdn.src = cdnJS;
|
||||
cdn.onload = () => {
|
||||
this._katexReady = true;
|
||||
resolve();
|
||||
};
|
||||
cdn.onerror = () => {
|
||||
console.error('[KaTeX] Failed to load from both local and CDN');
|
||||
reject(new Error('Failed to load KaTeX'));
|
||||
};
|
||||
document.head.appendChild(cdn);
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
return this._katexLoading;
|
||||
},
|
||||
|
||||
_escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
},
|
||||
|
||||
_injectStyles() {
|
||||
if (document.getElementById('ext-style-katex-renderer')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'ext-style-katex-renderer';
|
||||
@@ -195,64 +289,9 @@
|
||||
.katex-inline .katex { font-size: 1.05em; }
|
||||
.katex-display { display: block; text-align: center; margin: 8px 0; }`;
|
||||
document.head.appendChild(style);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
document.getElementById('ext-style-katex-renderer')?.remove();
|
||||
}
|
||||
|
||||
// ── Registration ────────────────────────
|
||||
|
||||
function register() {
|
||||
if (!window.sw?.renderers) return;
|
||||
|
||||
_injectStyles();
|
||||
|
||||
// Block renderer: match ```latex, ```math, ```tex, ```katex
|
||||
sw.renderers.register('katex-block', {
|
||||
type: 'block',
|
||||
priority: 10,
|
||||
match(lang) {
|
||||
const l = (lang || '').toLowerCase();
|
||||
return l === 'latex' || l === 'math' || l === 'tex' || l === 'katex';
|
||||
},
|
||||
render(lang, code, container) {
|
||||
const id = 'katex-' + Math.random().toString(36).slice(2, 9);
|
||||
container.innerHTML = `
|
||||
<div class="katex-block ext-rendered" data-katex-id="${id}">
|
||||
<div class="katex-display-container" data-katex-src="${encodeURIComponent(code.trim())}" data-katex-display="true">
|
||||
<div class="katex-loading">
|
||||
<span class="katex-spinner"></span> Rendering math\u2026
|
||||
</div>
|
||||
</div>
|
||||
<details class="katex-source">
|
||||
<summary>\ud835\udc53(\ud835\udc65) View source</summary>
|
||||
<pre><code class="language-latex">${_escapeHtml(code.trim())}</code></pre>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
// Post renderer: render KaTeX blocks + find inline math
|
||||
sw.renderers.register('katex-post', {
|
||||
type: 'post',
|
||||
priority: 10,
|
||||
render(container) {
|
||||
const blocks = container.querySelectorAll('.katex-display-container[data-katex-src]');
|
||||
if (blocks.length > 0) {
|
||||
blocks.forEach(el => {
|
||||
if (el.dataset.rendered) return;
|
||||
el.dataset.rendered = 'pending';
|
||||
_renderBlock(el);
|
||||
});
|
||||
}
|
||||
_processInlineMath(container);
|
||||
}
|
||||
});
|
||||
|
||||
_loadKaTeX();
|
||||
}
|
||||
|
||||
if (window.sw?._sdk) {
|
||||
register();
|
||||
} else {
|
||||
document.addEventListener('sw:ready', register, { once: true });
|
||||
}
|
||||
})();
|
||||
});
|
||||
@@ -1,12 +1,10 @@
|
||||
{
|
||||
"id": "mermaid-renderer",
|
||||
"title": "Mermaid Diagrams",
|
||||
"name": "Mermaid Diagrams",
|
||||
"version": "2.0.0",
|
||||
"type": "extension",
|
||||
"tier": "browser",
|
||||
"author": "switchboard",
|
||||
"description": "Renders ```mermaid code blocks as interactive SVG diagrams with zoom/pan, SVG/PNG export, and source copy",
|
||||
"requires": [],
|
||||
"permissions": [],
|
||||
"tools": [],
|
||||
"surfaces": [],
|
||||
@@ -2,33 +2,94 @@
|
||||
// Mermaid Diagram Renderer — Browser Extension
|
||||
// ==========================================
|
||||
// Renders ```mermaid code blocks as interactive SVG diagrams.
|
||||
// Features: viewBox-based zoom/pan, fullscreen expand,
|
||||
// SVG/PNG export, source copy.
|
||||
// Features: viewBox-based zoom/pan, context-aware expand (fullscreen
|
||||
// or side-panel pop-out), SVG/PNG export, source copy.
|
||||
// Loads mermaid.js dynamically on first use.
|
||||
//
|
||||
// Registers with sw.renderers via the sw:ready event.
|
||||
// Uses ctx.ui primitives from the host app for toast notifications,
|
||||
// side-panel preview, theme detection, and mobile awareness.
|
||||
// ==========================================
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
Extensions.register({
|
||||
id: 'mermaid-renderer',
|
||||
|
||||
let _mermaidReady = false;
|
||||
let _mermaidLoading = null;
|
||||
_mermaidReady: false,
|
||||
_mermaidLoading: null,
|
||||
_ctx: null,
|
||||
|
||||
function _isDark() {
|
||||
return document.documentElement.dataset.theme === 'dark' ||
|
||||
document.documentElement.classList.contains('dark');
|
||||
}
|
||||
async init(ctx) {
|
||||
const self = this;
|
||||
this._ctx = ctx;
|
||||
|
||||
function _escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
this._injectStyles();
|
||||
|
||||
// ── Block renderer: match ```mermaid ──
|
||||
ctx.renderers.register('mermaid', {
|
||||
type: 'block',
|
||||
pattern: 'mermaid',
|
||||
priority: 10,
|
||||
render(lang, code, container) {
|
||||
const id = 'mmd-' + Math.random().toString(36).slice(2, 9);
|
||||
container.innerHTML = `
|
||||
<div class="mermaid-block" data-mermaid-id="${id}">
|
||||
<div class="mermaid-toolbar">
|
||||
<span class="mermaid-title">\u{1f4ca} Diagram</span>
|
||||
<span class="mermaid-zoom-label" data-zoom-label="${id}">100%</span>
|
||||
<div class="mermaid-toolbar-btns">
|
||||
<button class="mmd-btn" data-action="zoom-in" data-target="${id}" title="Zoom in">+</button>
|
||||
<button class="mmd-btn" data-action="zoom-out" data-target="${id}" title="Zoom out">\u2212</button>
|
||||
<button class="mmd-btn" data-action="zoom-fit" data-target="${id}" title="Fit to view">\u22a1</button>
|
||||
<button class="mmd-btn" data-action="zoom-reset" data-target="${id}" title="Reset zoom">1:1</button>
|
||||
<span class="mmd-sep"></span>
|
||||
<button class="mmd-btn" data-action="expand" data-target="${id}" title="Expand">\u26f6</button>
|
||||
<span class="mmd-sep"></span>
|
||||
<button class="mmd-btn" data-action="export-svg" data-target="${id}" title="Download SVG">SVG</button>
|
||||
<button class="mmd-btn" data-action="export-png" data-target="${id}" title="Download PNG">PNG</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mermaid-viewport" data-viewport="${id}">
|
||||
<div class="mermaid-diagram" data-mermaid-src="${encodeURIComponent(code.trim())}" data-diagram="${id}">
|
||||
<div class="mermaid-loading">
|
||||
<span class="mermaid-spinner"></span> Rendering diagram\u2026
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<details class="mermaid-source">
|
||||
<summary>
|
||||
<span>\u{1f4cb} View source</span>
|
||||
<button class="mmd-btn mmd-copy-src" data-action="copy-src" data-target="${id}" title="Copy source" onclick="event.stopPropagation()">Copy</button>
|
||||
</summary>
|
||||
<pre><code class="language-mermaid" data-source="${id}">${self._escapeHtml(code.trim())}</code></pre>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Post renderer: render diagrams + wire interactivity ──
|
||||
ctx.renderers.register('mermaid-post', {
|
||||
type: 'post',
|
||||
priority: 10,
|
||||
render(container) {
|
||||
const diagrams = container.querySelectorAll('.mermaid-diagram[data-mermaid-src]');
|
||||
if (diagrams.length === 0) return;
|
||||
|
||||
diagrams.forEach(el => {
|
||||
if (el.dataset.rendered) return;
|
||||
el.dataset.rendered = 'pending';
|
||||
self._renderDiagram(el);
|
||||
});
|
||||
|
||||
self._wireToolbar(container);
|
||||
}
|
||||
});
|
||||
|
||||
this._loadMermaid();
|
||||
},
|
||||
|
||||
// ── ViewBox Zoom/Pan State ──────────────
|
||||
|
||||
function _getState(id) {
|
||||
_getState(id) {
|
||||
const vp = document.querySelector(`[data-viewport="${id}"]`);
|
||||
if (!vp) return null;
|
||||
if (!vp._mmdState) {
|
||||
@@ -41,10 +102,10 @@
|
||||
};
|
||||
}
|
||||
return vp._mmdState;
|
||||
}
|
||||
},
|
||||
|
||||
function _initState(id) {
|
||||
const state = _getState(id);
|
||||
_initState(id) {
|
||||
const state = this._getState(id);
|
||||
if (!state) return;
|
||||
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
|
||||
if (!svg) return;
|
||||
@@ -52,51 +113,69 @@
|
||||
const vb = svg.getAttribute('viewBox');
|
||||
if (!vb) {
|
||||
const bbox = svg.getBBox();
|
||||
state.natX = bbox.x; state.natY = bbox.y;
|
||||
state.natW = bbox.width; state.natH = bbox.height;
|
||||
state.natX = bbox.x;
|
||||
state.natY = bbox.y;
|
||||
state.natW = bbox.width;
|
||||
state.natH = bbox.height;
|
||||
} else {
|
||||
const parts = vb.trim().split(/[\s,]+/).map(Number);
|
||||
state.natX = parts[0] || 0; state.natY = parts[1] || 0;
|
||||
state.natW = parts[2] || 0; state.natH = parts[3] || 0;
|
||||
state.natX = parts[0] || 0;
|
||||
state.natY = parts[1] || 0;
|
||||
state.natW = parts[2] || 0;
|
||||
state.natH = parts[3] || 0;
|
||||
}
|
||||
|
||||
state.vbX = state.natX; state.vbY = state.natY;
|
||||
state.vbW = state.natW; state.vbH = state.natH;
|
||||
state.vbX = state.natX;
|
||||
state.vbY = state.natY;
|
||||
state.vbW = state.natW;
|
||||
state.vbH = state.natH;
|
||||
state.ready = true;
|
||||
_applyViewBox(id);
|
||||
}
|
||||
|
||||
function _applyViewBox(id) {
|
||||
const state = _getState(id);
|
||||
this._applyViewBox(id);
|
||||
},
|
||||
|
||||
_applyViewBox(id) {
|
||||
const state = this._getState(id);
|
||||
if (!state || !state.ready) return;
|
||||
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
|
||||
if (!svg) return;
|
||||
|
||||
svg.setAttribute('viewBox', `${state.vbX} ${state.vbY} ${state.vbW} ${state.vbH}`);
|
||||
|
||||
const zoomPct = Math.round((state.natW / state.vbW) * 100);
|
||||
const label = document.querySelector(`[data-zoom-label="${id}"]`);
|
||||
if (label) label.textContent = zoomPct + '%';
|
||||
}
|
||||
},
|
||||
|
||||
function _zoom(id, factor) {
|
||||
const state = _getState(id);
|
||||
_zoom(id, factor) {
|
||||
const state = this._getState(id);
|
||||
if (!state || !state.ready) return;
|
||||
|
||||
const scale = 1 / (1 + factor);
|
||||
const newW = state.vbW * scale;
|
||||
const newH = state.vbH * scale;
|
||||
if (newW < state.natW / 20 || newW > state.natW * 2) return;
|
||||
|
||||
const minW = state.natW / 20;
|
||||
const maxW = state.natW * 2;
|
||||
if (newW < minW || newW > maxW) return;
|
||||
|
||||
const cx = state.vbX + state.vbW / 2;
|
||||
const cy = state.vbY + state.vbH / 2;
|
||||
state.vbW = newW; state.vbH = newH;
|
||||
state.vbX = cx - newW / 2; state.vbY = cy - newH / 2;
|
||||
_applyViewBox(id);
|
||||
}
|
||||
|
||||
function _zoomAt(id, factor, clientX, clientY) {
|
||||
const state = _getState(id);
|
||||
state.vbW = newW;
|
||||
state.vbH = newH;
|
||||
state.vbX = cx - newW / 2;
|
||||
state.vbY = cy - newH / 2;
|
||||
|
||||
this._applyViewBox(id);
|
||||
},
|
||||
|
||||
_zoomAt(id, factor, clientX, clientY) {
|
||||
const state = this._getState(id);
|
||||
if (!state || !state.ready) return;
|
||||
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
|
||||
if (!svg) return;
|
||||
|
||||
const svgRect = svg.getBoundingClientRect();
|
||||
if (svgRect.width === 0 || svgRect.height === 0) return;
|
||||
|
||||
@@ -108,76 +187,147 @@
|
||||
const scale = 1 / (1 + factor);
|
||||
const newW = state.vbW * scale;
|
||||
const newH = state.vbH * scale;
|
||||
if (newW < state.natW / 20 || newW > state.natW * 2) return;
|
||||
state.vbW = newW; state.vbH = newH;
|
||||
state.vbX = pointX - fx * newW; state.vbY = pointY - fy * newH;
|
||||
_applyViewBox(id);
|
||||
}
|
||||
|
||||
function _zoomReset(id) {
|
||||
const state = _getState(id);
|
||||
const minW = state.natW / 20;
|
||||
const maxW = state.natW * 2;
|
||||
if (newW < minW || newW > maxW) return;
|
||||
|
||||
state.vbW = newW;
|
||||
state.vbH = newH;
|
||||
state.vbX = pointX - fx * newW;
|
||||
state.vbY = pointY - fy * newH;
|
||||
|
||||
this._applyViewBox(id);
|
||||
},
|
||||
|
||||
_zoomReset(id) {
|
||||
const state = this._getState(id);
|
||||
if (!state || !state.ready) return;
|
||||
state.vbX = state.natX; state.vbY = state.natY;
|
||||
state.vbW = state.natW; state.vbH = state.natH;
|
||||
_applyViewBox(id);
|
||||
}
|
||||
state.vbX = state.natX;
|
||||
state.vbY = state.natY;
|
||||
state.vbW = state.natW;
|
||||
state.vbH = state.natH;
|
||||
this._applyViewBox(id);
|
||||
},
|
||||
|
||||
function _zoomFit(id) {
|
||||
const state = _getState(id);
|
||||
_zoomFit(id) {
|
||||
const state = this._getState(id);
|
||||
if (!state || !state.ready) return;
|
||||
const vp = document.querySelector(`[data-viewport="${id}"]`);
|
||||
if (!vp) return;
|
||||
|
||||
const vpRect = vp.getBoundingClientRect();
|
||||
if (vpRect.width === 0 || vpRect.height === 0) return;
|
||||
|
||||
const vpAspect = vpRect.width / vpRect.height;
|
||||
const natAspect = state.natW / state.natH;
|
||||
|
||||
if (vpAspect > natAspect) {
|
||||
const newW = state.natH * vpAspect;
|
||||
state.vbX = state.natX - (newW - state.natW) / 2;
|
||||
state.vbY = state.natY;
|
||||
state.vbW = newW; state.vbH = state.natH;
|
||||
state.vbW = newW;
|
||||
state.vbH = state.natH;
|
||||
} else {
|
||||
const newH = state.natW / vpAspect;
|
||||
state.vbX = state.natX;
|
||||
state.vbY = state.natY - (newH - state.natH) / 2;
|
||||
state.vbW = state.natW; state.vbH = newH;
|
||||
state.vbW = state.natW;
|
||||
state.vbH = newH;
|
||||
}
|
||||
_applyViewBox(id);
|
||||
}
|
||||
|
||||
this._applyViewBox(id);
|
||||
},
|
||||
|
||||
// ── Expand (context-aware) ──────────────
|
||||
// Side panel open → pop out into it (replaces current content).
|
||||
// Side panel closed → fullscreen overlay.
|
||||
|
||||
_expand(id) {
|
||||
if (this._ctx.ui.isPanelOpen()) {
|
||||
this._popOutToPanel(id);
|
||||
} else {
|
||||
this._toggleFullscreen(id);
|
||||
}
|
||||
},
|
||||
|
||||
// ── Fullscreen ──────────────────────────
|
||||
|
||||
function _toggleFullscreen(id) {
|
||||
_toggleFullscreen(id) {
|
||||
const block = document.querySelector(`[data-mermaid-id="${id}"]`);
|
||||
if (!block) return;
|
||||
|
||||
const isFS = block.classList.toggle('mermaid-fullscreen');
|
||||
|
||||
if (isFS) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Close button overlay (always visible — critical for mobile)
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'mmd-fullscreen-close';
|
||||
closeBtn.innerHTML = '\u2715';
|
||||
closeBtn.title = 'Close fullscreen';
|
||||
closeBtn.addEventListener('click', () => _toggleFullscreen(id));
|
||||
closeBtn.addEventListener('click', () => this._toggleFullscreen(id));
|
||||
block.appendChild(closeBtn);
|
||||
block._mmdEscHandler = (e) => { if (e.key === 'Escape') _toggleFullscreen(id); };
|
||||
|
||||
// Escape listener (desktop convenience, not sole exit)
|
||||
block._mmdEscHandler = (e) => {
|
||||
if (e.key === 'Escape') this._toggleFullscreen(id);
|
||||
};
|
||||
document.addEventListener('keydown', block._mmdEscHandler);
|
||||
requestAnimationFrame(() => _zoomFit(id));
|
||||
|
||||
requestAnimationFrame(() => this._zoomFit(id));
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
|
||||
const closeBtn = block.querySelector('.mmd-fullscreen-close');
|
||||
if (closeBtn) closeBtn.remove();
|
||||
|
||||
if (block._mmdEscHandler) {
|
||||
document.removeEventListener('keydown', block._mmdEscHandler);
|
||||
delete block._mmdEscHandler;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Side Panel Pop-out ──────────────────
|
||||
|
||||
_popOutToPanel(id) {
|
||||
const diagram = document.querySelector(`[data-diagram="${id}"]`);
|
||||
const svg = diagram?.querySelector('svg');
|
||||
if (!svg) return;
|
||||
|
||||
const state = this._getState(id);
|
||||
const clone = svg.cloneNode(true);
|
||||
if (state?.ready) {
|
||||
clone.setAttribute('viewBox',
|
||||
`${state.natX} ${state.natY} ${state.natW} ${state.natH}`);
|
||||
}
|
||||
clone.setAttribute('width', '100%');
|
||||
clone.removeAttribute('height');
|
||||
clone.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
||||
|
||||
const isDark = this._ctx.ui.isDark();
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { margin: 0; padding: 16px; display: flex; justify-content: center;
|
||||
align-items: flex-start; min-height: 100vh;
|
||||
background: ${isDark ? '#1a1a2e' : '#fff'};
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
||||
svg { max-width: 100%; height: auto; }
|
||||
</style>
|
||||
</head><body>${clone.outerHTML}</body></html>`;
|
||||
|
||||
this._ctx.ui.openPreview(html);
|
||||
},
|
||||
|
||||
// ── Toolbar Wiring ──────────────────────
|
||||
|
||||
function _wireToolbar(container) {
|
||||
_wireToolbar(container) {
|
||||
const self = this;
|
||||
|
||||
if (container._mmdWired) return;
|
||||
container._mmdWired = true;
|
||||
|
||||
@@ -189,14 +339,14 @@
|
||||
if (!id) return;
|
||||
|
||||
switch (action) {
|
||||
case 'zoom-in': _zoom(id, 0.25); break;
|
||||
case 'zoom-out': _zoom(id, -0.2); break;
|
||||
case 'zoom-reset': _zoomReset(id); break;
|
||||
case 'zoom-fit': _zoomFit(id); break;
|
||||
case 'expand': _toggleFullscreen(id); break;
|
||||
case 'export-svg': _exportSVG(id); break;
|
||||
case 'export-png': _exportPNG(id); break;
|
||||
case 'copy-src': _copySource(id); break;
|
||||
case 'zoom-in': self._zoom(id, 0.25); break;
|
||||
case 'zoom-out': self._zoom(id, -0.2); break;
|
||||
case 'zoom-reset': self._zoomReset(id); break;
|
||||
case 'zoom-fit': self._zoomFit(id); break;
|
||||
case 'expand': self._expand(id); break;
|
||||
case 'export-svg': self._exportSVG(id); break;
|
||||
case 'export-png': self._exportPNG(id); break;
|
||||
case 'copy-src': self._copySource(id); break;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -209,35 +359,42 @@
|
||||
vp.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
||||
_zoomAt(id, delta, e.clientX, e.clientY);
|
||||
self._zoomAt(id, delta, e.clientX, e.clientY);
|
||||
}, { passive: false });
|
||||
|
||||
vp.addEventListener('mousedown', (e) => {
|
||||
if (e.button !== 0) return;
|
||||
const state = _getState(id);
|
||||
const state = self._getState(id);
|
||||
if (!state || !state.ready) return;
|
||||
state.dragging = true;
|
||||
state.startX = e.clientX; state.startY = e.clientY;
|
||||
state.startVbX = state.vbX; state.startVbY = state.vbY;
|
||||
state.startX = e.clientX;
|
||||
state.startY = e.clientY;
|
||||
state.startVbX = state.vbX;
|
||||
state.startVbY = state.vbY;
|
||||
vp.classList.add('mmd-grabbing');
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
vp.addEventListener('mousemove', (e) => {
|
||||
const state = _getState(id);
|
||||
const state = self._getState(id);
|
||||
if (!state?.dragging) return;
|
||||
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
|
||||
if (!svg) return;
|
||||
|
||||
const svgRect = svg.getBoundingClientRect();
|
||||
if (svgRect.width === 0) return;
|
||||
|
||||
const pxToVb = state.vbW / svgRect.width;
|
||||
state.vbX = state.startVbX - (e.clientX - state.startX) * pxToVb;
|
||||
state.vbY = state.startVbY - (e.clientY - state.startY) * pxToVb;
|
||||
_applyViewBox(id);
|
||||
const dx = (e.clientX - state.startX) * pxToVb;
|
||||
const dy = (e.clientY - state.startY) * pxToVb;
|
||||
|
||||
state.vbX = state.startVbX - dx;
|
||||
state.vbY = state.startVbY - dy;
|
||||
self._applyViewBox(id);
|
||||
});
|
||||
|
||||
const endDrag = () => {
|
||||
const state = _getState(id);
|
||||
const state = self._getState(id);
|
||||
if (!state) return;
|
||||
state.dragging = false;
|
||||
vp.classList.remove('mmd-grabbing');
|
||||
@@ -246,8 +403,9 @@
|
||||
vp.addEventListener('mouseleave', endDrag);
|
||||
|
||||
let lastTouchDist = 0;
|
||||
let lastTouchCenter = null;
|
||||
vp.addEventListener('touchstart', (e) => {
|
||||
const state = _getState(id);
|
||||
const state = self._getState(id);
|
||||
if (!state || !state.ready) return;
|
||||
if (e.touches.length === 1) {
|
||||
state.dragging = true;
|
||||
@@ -260,21 +418,28 @@
|
||||
e.touches[0].clientX - e.touches[1].clientX,
|
||||
e.touches[0].clientY - e.touches[1].clientY
|
||||
);
|
||||
lastTouchCenter = {
|
||||
x: (e.touches[0].clientX + e.touches[1].clientX) / 2,
|
||||
y: (e.touches[0].clientY + e.touches[1].clientY) / 2,
|
||||
};
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
vp.addEventListener('touchmove', (e) => {
|
||||
const state = _getState(id);
|
||||
const state = self._getState(id);
|
||||
if (!state || !state.ready) return;
|
||||
if (e.touches.length === 1 && state.dragging) {
|
||||
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
|
||||
if (!svg) return;
|
||||
const svgRect = svg.getBoundingClientRect();
|
||||
if (svgRect.width === 0) return;
|
||||
|
||||
const pxToVb = state.vbW / svgRect.width;
|
||||
state.vbX = state.startVbX - (e.touches[0].clientX - state.startX) * pxToVb;
|
||||
state.vbY = state.startVbY - (e.touches[0].clientY - state.startY) * pxToVb;
|
||||
_applyViewBox(id);
|
||||
const dx = (e.touches[0].clientX - state.startX) * pxToVb;
|
||||
const dy = (e.touches[0].clientY - state.startY) * pxToVb;
|
||||
state.vbX = state.startVbX - dx;
|
||||
state.vbY = state.startVbY - dy;
|
||||
self._applyViewBox(id);
|
||||
e.preventDefault();
|
||||
} else if (e.touches.length === 2) {
|
||||
const dist = Math.hypot(
|
||||
@@ -287,42 +452,46 @@
|
||||
};
|
||||
if (lastTouchDist > 0) {
|
||||
const factor = (dist - lastTouchDist) / lastTouchDist;
|
||||
_zoomAt(id, factor, center.x, center.y);
|
||||
self._zoomAt(id, factor, center.x, center.y);
|
||||
}
|
||||
lastTouchDist = dist;
|
||||
lastTouchCenter = center;
|
||||
e.preventDefault();
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
vp.addEventListener('touchend', () => {
|
||||
const state = _getState(id);
|
||||
const state = self._getState(id);
|
||||
if (state) state.dragging = false;
|
||||
lastTouchDist = 0;
|
||||
lastTouchCenter = null;
|
||||
}, { passive: true });
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// ── Export ───────────────────────────────
|
||||
|
||||
function _exportSVG(id) {
|
||||
_exportSVG(id) {
|
||||
const diagram = document.querySelector(`[data-diagram="${id}"]`);
|
||||
const svg = diagram?.querySelector('svg');
|
||||
if (!svg) return;
|
||||
const state = _getState(id);
|
||||
|
||||
const state = this._getState(id);
|
||||
const clone = svg.cloneNode(true);
|
||||
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
if (state?.ready) {
|
||||
clone.setAttribute('viewBox', `${state.natX} ${state.natY} ${state.natW} ${state.natH}`);
|
||||
}
|
||||
const blob = new Blob([clone.outerHTML], { type: 'image/svg+xml' });
|
||||
_download(blob, `diagram-${id}.svg`);
|
||||
}
|
||||
this._download(blob, `diagram-${id}.svg`);
|
||||
},
|
||||
|
||||
function _exportPNG(id) {
|
||||
_exportPNG(id) {
|
||||
const diagram = document.querySelector(`[data-diagram="${id}"]`);
|
||||
const svg = diagram?.querySelector('svg');
|
||||
if (!svg) return;
|
||||
const state = _getState(id);
|
||||
|
||||
const state = this._getState(id);
|
||||
const clone = svg.cloneNode(true);
|
||||
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
if (state?.ready) {
|
||||
@@ -334,6 +503,7 @@
|
||||
const svgData = new XMLSerializer().serializeToString(clone);
|
||||
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const url = URL.createObjectURL(svgBlob);
|
||||
const self = this;
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
@@ -345,8 +515,9 @@
|
||||
ctx.scale(scale, scale);
|
||||
ctx.drawImage(img, 0, 0);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) _download(blob, `diagram-${id}.png`);
|
||||
if (blob) self._download(blob, `diagram-${id}.png`);
|
||||
}, 'image/png');
|
||||
};
|
||||
img.onerror = () => {
|
||||
@@ -354,9 +525,9 @@
|
||||
console.error('[Mermaid] PNG export failed');
|
||||
};
|
||||
img.src = url;
|
||||
}
|
||||
},
|
||||
|
||||
function _download(blob, filename) {
|
||||
_download(blob, filename) {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
@@ -364,24 +535,25 @@
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
}
|
||||
},
|
||||
|
||||
function _copySource(id) {
|
||||
_copySource(id) {
|
||||
const code = document.querySelector(`[data-source="${id}"]`);
|
||||
if (!code) return;
|
||||
navigator.clipboard.writeText(code.textContent).then(() => {
|
||||
if (window.sw?.toast) sw.toast('Source copied', 'success');
|
||||
this._ctx.ui.toast('Source copied', 'success');
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// ── Diagram Rendering ───────────────────
|
||||
|
||||
async function _renderDiagram(el) {
|
||||
async _renderDiagram(el) {
|
||||
const code = decodeURIComponent(el.dataset.mermaidSrc);
|
||||
const id = el.dataset.diagram;
|
||||
|
||||
try {
|
||||
await _loadMermaid();
|
||||
await this._loadMermaid();
|
||||
|
||||
const svgId = 'mmd-svg-' + Math.random().toString(36).slice(2, 9);
|
||||
const { svg } = await mermaid.render(svgId, code);
|
||||
el.innerHTML = svg;
|
||||
@@ -394,27 +566,28 @@
|
||||
svgEl.style.maxWidth = 'none';
|
||||
svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
||||
}
|
||||
_initState(id);
|
||||
|
||||
this._initState(id);
|
||||
} catch (e) {
|
||||
el.innerHTML = `
|
||||
<div class="mermaid-error">
|
||||
<strong>Diagram error:</strong> ${_escapeHtml(e.message || String(e))}
|
||||
<strong>Diagram error:</strong> ${this._escapeHtml(e.message || String(e))}
|
||||
</div>
|
||||
`;
|
||||
el.dataset.rendered = 'error';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Mermaid Library Loading ──────────────
|
||||
|
||||
function _loadMermaid() {
|
||||
if (_mermaidReady) return Promise.resolve();
|
||||
if (_mermaidLoading) return _mermaidLoading;
|
||||
_loadMermaid() {
|
||||
if (this._mermaidReady) return Promise.resolve();
|
||||
if (this._mermaidLoading) return this._mermaidLoading;
|
||||
|
||||
_mermaidLoading = new Promise((resolve, reject) => {
|
||||
this._mermaidLoading = new Promise((resolve, reject) => {
|
||||
if (typeof mermaid !== 'undefined') {
|
||||
_initMermaid();
|
||||
_mermaidReady = true;
|
||||
this._initMermaid();
|
||||
this._mermaidReady = true;
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
@@ -426,8 +599,8 @@
|
||||
const script = document.createElement('script');
|
||||
script.src = localSrc;
|
||||
script.onload = () => {
|
||||
_initMermaid();
|
||||
_mermaidReady = true;
|
||||
this._initMermaid();
|
||||
this._mermaidReady = true;
|
||||
resolve();
|
||||
};
|
||||
script.onerror = () => {
|
||||
@@ -435,8 +608,8 @@
|
||||
const cdn = document.createElement('script');
|
||||
cdn.src = cdnSrc;
|
||||
cdn.onload = () => {
|
||||
_initMermaid();
|
||||
_mermaidReady = true;
|
||||
this._initMermaid();
|
||||
this._mermaidReady = true;
|
||||
resolve();
|
||||
};
|
||||
cdn.onerror = () => {
|
||||
@@ -447,23 +620,25 @@
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
return _mermaidLoading;
|
||||
}
|
||||
|
||||
function _initMermaid() {
|
||||
return this._mermaidLoading;
|
||||
},
|
||||
|
||||
_initMermaid() {
|
||||
if (typeof mermaid === 'undefined') return;
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: _isDark() ? 'dark' : 'default',
|
||||
theme: this._ctx.ui.isDark() ? 'dark' : 'default',
|
||||
securityLevel: 'strict',
|
||||
fontFamily: 'inherit',
|
||||
logLevel: 'error',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// ── Styles ──────────────────────────────
|
||||
|
||||
function _injectStyles() {
|
||||
_injectStyles() {
|
||||
if (document.getElementById('ext-style-mermaid-renderer')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'ext-style-mermaid-renderer';
|
||||
@@ -472,6 +647,8 @@
|
||||
background: var(--bg-2); border: 1px solid var(--border);
|
||||
border-radius: 8px; overflow: hidden; margin: 12px 0;
|
||||
}
|
||||
|
||||
/* ── Fullscreen mode ── */
|
||||
.mermaid-block.mermaid-fullscreen {
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
z-index: 10000; border-radius: 0; margin: 0;
|
||||
@@ -479,11 +656,15 @@
|
||||
background: var(--bg-1, var(--bg-2, #1a1a2e));
|
||||
}
|
||||
.mermaid-block.mermaid-fullscreen .mermaid-viewport {
|
||||
background: var(--bg-2, #1a1a2e); flex: 1; max-height: none;
|
||||
background: var(--bg-2, #1a1a2e);
|
||||
flex: 1; max-height: none;
|
||||
}
|
||||
.mermaid-block.mermaid-fullscreen .mermaid-toolbar {
|
||||
border-bottom: 1px solid var(--border); padding: 6px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 6px 14px;
|
||||
}
|
||||
|
||||
/* Fullscreen close button — always visible, critical for mobile */
|
||||
.mmd-fullscreen-close {
|
||||
position: absolute; top: 12px; right: 12px; z-index: 10001;
|
||||
width: 40px; height: 40px; border-radius: 50%;
|
||||
@@ -495,8 +676,11 @@
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
.mmd-fullscreen-close:hover {
|
||||
background: var(--bg-hover, rgba(255,255,255,0.15)); transform: scale(1.1);
|
||||
background: var(--bg-hover, rgba(255,255,255,0.15));
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
.mermaid-toolbar {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 4px 10px; border-bottom: 1px solid var(--border);
|
||||
@@ -515,14 +699,24 @@
|
||||
}
|
||||
.mmd-btn:hover { color: var(--text-1); border-color: var(--text-3); background: var(--bg-3); }
|
||||
.mmd-sep { width: 1px; height: 16px; background: var(--border); margin: 0 4px; }
|
||||
|
||||
/* Viewport */
|
||||
.mermaid-viewport {
|
||||
overflow: hidden; position: relative;
|
||||
min-height: 80px; max-height: 600px;
|
||||
cursor: grab; user-select: none;
|
||||
}
|
||||
.mermaid-viewport.mmd-grabbing { cursor: grabbing; }
|
||||
.mermaid-diagram { width: 100%; height: 100%; min-height: 60px; }
|
||||
.mermaid-diagram svg { display: block; width: 100%; height: 100%; }
|
||||
|
||||
/* Diagram wrapper */
|
||||
.mermaid-diagram {
|
||||
width: 100%; height: 100%; min-height: 60px;
|
||||
}
|
||||
.mermaid-diagram svg {
|
||||
display: block; width: 100%; height: 100%;
|
||||
}
|
||||
|
||||
/* Loading / Error */
|
||||
.mermaid-loading {
|
||||
color: var(--text-3); font-size: 13px; padding: 20px;
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
@@ -538,6 +732,8 @@
|
||||
padding: 12px 16px; border-radius: 4px; font-size: 13px;
|
||||
font-family: var(--mono); white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Source panel */
|
||||
.mermaid-source { border-top: 1px solid var(--border); font-size: 12px; }
|
||||
.mermaid-source summary {
|
||||
padding: 6px 12px; cursor: pointer; color: var(--text-3);
|
||||
@@ -550,91 +746,34 @@
|
||||
margin: 0; border-radius: 0; border: none;
|
||||
max-height: 200px; overflow: auto;
|
||||
}
|
||||
|
||||
/* Hide source panel in fullscreen */
|
||||
.mermaid-block.mermaid-fullscreen .mermaid-source { display: none; }
|
||||
|
||||
/* ── Mobile adjustments ── */
|
||||
@media (max-width: 768px) {
|
||||
.mermaid-toolbar { padding: 6px 10px; gap: 4px; }
|
||||
.mmd-btn { padding: 4px 10px; font-size: 12px; }
|
||||
.mmd-sep { margin: 0 2px; }
|
||||
.mmd-fullscreen-close { width: 48px; height: 48px; font-size: 24px; top: 16px; right: 16px; }
|
||||
}`;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
},
|
||||
|
||||
// ── Registration ────────────────────────
|
||||
_escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
},
|
||||
|
||||
function register() {
|
||||
if (!window.sw?.renderers) return;
|
||||
|
||||
_injectStyles();
|
||||
|
||||
// Block renderer: match ```mermaid
|
||||
sw.renderers.register('mermaid', {
|
||||
type: 'block',
|
||||
pattern: 'mermaid',
|
||||
priority: 10,
|
||||
render(lang, code, container) {
|
||||
const id = 'mmd-' + Math.random().toString(36).slice(2, 9);
|
||||
container.innerHTML = `
|
||||
<div class="mermaid-block" data-mermaid-id="${id}">
|
||||
<div class="mermaid-toolbar">
|
||||
<span class="mermaid-title">\u{1f4ca} Diagram</span>
|
||||
<span class="mermaid-zoom-label" data-zoom-label="${id}">100%</span>
|
||||
<div class="mermaid-toolbar-btns">
|
||||
<button class="mmd-btn" data-action="zoom-in" data-target="${id}" title="Zoom in">+</button>
|
||||
<button class="mmd-btn" data-action="zoom-out" data-target="${id}" title="Zoom out">\u2212</button>
|
||||
<button class="mmd-btn" data-action="zoom-fit" data-target="${id}" title="Fit to view">\u22a1</button>
|
||||
<button class="mmd-btn" data-action="zoom-reset" data-target="${id}" title="Reset zoom">1:1</button>
|
||||
<span class="mmd-sep"></span>
|
||||
<button class="mmd-btn" data-action="expand" data-target="${id}" title="Fullscreen">\u26f6</button>
|
||||
<span class="mmd-sep"></span>
|
||||
<button class="mmd-btn" data-action="export-svg" data-target="${id}" title="Download SVG">SVG</button>
|
||||
<button class="mmd-btn" data-action="export-png" data-target="${id}" title="Download PNG">PNG</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mermaid-viewport" data-viewport="${id}">
|
||||
<div class="mermaid-diagram" data-mermaid-src="${encodeURIComponent(code.trim())}" data-diagram="${id}">
|
||||
<div class="mermaid-loading">
|
||||
<span class="mermaid-spinner"></span> Rendering diagram\u2026
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<details class="mermaid-source">
|
||||
<summary>
|
||||
<span>\u{1f4cb} View source</span>
|
||||
<button class="mmd-btn mmd-copy-src" data-action="copy-src" data-target="${id}" title="Copy source" onclick="event.stopPropagation()">Copy</button>
|
||||
</summary>
|
||||
<pre><code class="language-mermaid" data-source="${id}">${_escapeHtml(code.trim())}</code></pre>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
destroy() {
|
||||
document.getElementById('ext-style-mermaid-renderer')?.remove();
|
||||
document.querySelectorAll('.mermaid-fullscreen').forEach(el => {
|
||||
el.classList.remove('mermaid-fullscreen');
|
||||
const closeBtn = el.querySelector('.mmd-fullscreen-close');
|
||||
if (closeBtn) closeBtn.remove();
|
||||
});
|
||||
|
||||
// Post renderer: render diagrams + wire interactivity
|
||||
sw.renderers.register('mermaid-post', {
|
||||
type: 'post',
|
||||
priority: 10,
|
||||
render(container) {
|
||||
const diagrams = container.querySelectorAll('.mermaid-diagram[data-mermaid-src]');
|
||||
if (diagrams.length === 0) return;
|
||||
|
||||
diagrams.forEach(el => {
|
||||
if (el.dataset.rendered) return;
|
||||
el.dataset.rendered = 'pending';
|
||||
_renderDiagram(el);
|
||||
});
|
||||
|
||||
_wireToolbar(container);
|
||||
}
|
||||
});
|
||||
|
||||
_loadMermaid();
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
// Boot: register immediately if SDK ready, otherwise listen
|
||||
if (window.sw?._sdk) {
|
||||
register();
|
||||
} else {
|
||||
document.addEventListener('sw:ready', register, { once: true });
|
||||
}
|
||||
})();
|
||||
});
|
||||
@@ -1,12 +1,10 @@
|
||||
{
|
||||
"id": "regex-tester",
|
||||
"title": "Regex Tester",
|
||||
"name": "Regex Tester",
|
||||
"version": "1.0.0",
|
||||
"type": "extension",
|
||||
"tier": "browser",
|
||||
"author": "switchboard",
|
||||
"description": "Tests regular expressions against input strings. Allows the LLM to verify regex patterns and see matches, groups, and indices.",
|
||||
"requires": ["chat"],
|
||||
"permissions": [],
|
||||
"tools": [
|
||||
{
|
||||
@@ -155,12 +155,6 @@ spec:
|
||||
optional: true
|
||||
- name: S3_FORCE_PATH_STYLE
|
||||
value: "true"
|
||||
# Bundled packages (v0.3.8)
|
||||
# Dev: install all (empty = all). Test: install all. Prod: skip (nothing bundled needed yet).
|
||||
- name: SKIP_BUNDLED_PACKAGES
|
||||
value: "${SKIP_BUNDLED_PACKAGES}"
|
||||
- name: BUNDLED_PACKAGES
|
||||
value: "${BUNDLED_PACKAGES}"
|
||||
volumeMounts:
|
||||
- name: storage
|
||||
mountPath: /data/storage
|
||||
|
||||
@@ -66,7 +66,6 @@ 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}/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}/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; }
|
||||
|
||||
# Extension surface page routes → backend
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
# Bug Report Triage
|
||||
|
||||
Public bug report submission with severity-based routing and SLA timers.
|
||||
|
||||
## Features
|
||||
|
||||
- **Public entry** — anyone with the link can submit a bug report (no auth)
|
||||
- **Progressive fieldsets** — two-step form (Report Details + Reproduction)
|
||||
- **Branch rules** — severity=critical routes to senior queue, otherwise standard queue
|
||||
- **SLA timer** — critical fixes have a 1-hour SLA (fires `workflow.sla_breach` event)
|
||||
- **Team assignment** — classify, fix, and verify stages are team-scoped
|
||||
|
||||
## Stage Flow
|
||||
|
||||
```
|
||||
[submit] → [classify] → [fix-critical] → [verify]
|
||||
public team ↘ [fix-normal] ↗ team
|
||||
team
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Via admin API
|
||||
curl -X POST $BASE/api/v1/admin/packages/install \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-F "file=@packages/bug-report-triage"
|
||||
```
|
||||
|
||||
## API Walkthrough
|
||||
|
||||
```bash
|
||||
# 1. Start public instance (no auth)
|
||||
curl -X POST $BASE/api/v1/public/workflows/$WF_ID/start \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"data": {"reporter_email": "user@example.com", "summary": "Login broken", "component": "ui", "severity": "critical", "steps": "1. Click login", "expected": "Login page", "actual": "500 error"}}'
|
||||
|
||||
# 2. Resume (check status)
|
||||
curl $BASE/api/v1/public/workflows/resume/$ENTRY_TOKEN
|
||||
|
||||
# 3. Team claims classify assignment
|
||||
curl -X POST $BASE/api/v1/teams/$TEAM/assignments/$ASSIGN_ID/claim \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# 4. Advance classify (triggers branch rule)
|
||||
curl -X POST $BASE/api/v1/teams/$TEAM/instances/$INST/advance \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"data": {"severity": "critical", "notes": "Confirmed production issue"}}'
|
||||
```
|
||||
@@ -1,129 +0,0 @@
|
||||
{
|
||||
"id": "bug-report-triage",
|
||||
"title": "Bug Report Triage",
|
||||
"type": "workflow",
|
||||
"version": "0.1.0",
|
||||
"tier": "browser",
|
||||
"icon": "🐛",
|
||||
"author": "Switchboard Core",
|
||||
"description": "Public bug report submission with severity-based routing, team assignment, and SLA timers.",
|
||||
"permissions": [],
|
||||
|
||||
"workflow_definition": {
|
||||
"name": "Bug Report Triage",
|
||||
"slug": "bug-report-triage",
|
||||
"entry_mode": "public_link",
|
||||
"stages": [
|
||||
{
|
||||
"name": "submit",
|
||||
"ordinal": 0,
|
||||
"stage_mode": "form",
|
||||
"audience": "public",
|
||||
"stage_type": "simple",
|
||||
"auto_transition": false,
|
||||
"form_template": {
|
||||
"fieldsets": [
|
||||
{
|
||||
"label": "Report Details",
|
||||
"fields": [
|
||||
{ "key": "reporter_email", "label": "Your Email", "type": "text", "required": true },
|
||||
{ "key": "summary", "label": "Summary", "type": "text", "required": true },
|
||||
{ "key": "component", "label": "Component", "type": "select", "options": ["ui", "api", "database", "auth", "other"], "required": true },
|
||||
{ "key": "severity", "label": "Severity", "type": "select", "options": ["critical", "high", "medium", "low"], "required": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Reproduction",
|
||||
"fields": [
|
||||
{ "key": "steps", "label": "Steps to Reproduce", "type": "textarea", "required": true },
|
||||
{ "key": "expected", "label": "Expected Behavior", "type": "textarea", "required": true },
|
||||
{ "key": "actual", "label": "Actual Behavior", "type": "textarea", "required": true }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "classify",
|
||||
"ordinal": 1,
|
||||
"stage_mode": "form",
|
||||
"audience": "team",
|
||||
"stage_type": "simple",
|
||||
"auto_transition": false,
|
||||
"form_template": {
|
||||
"fieldsets": [
|
||||
{
|
||||
"label": "Classification",
|
||||
"fields": [
|
||||
{ "key": "severity", "label": "Confirmed Severity", "type": "select", "options": ["critical", "high", "medium", "low"], "required": true },
|
||||
{ "key": "notes", "label": "Triage Notes", "type": "textarea" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"branch_rules": [
|
||||
{ "field": "severity", "op": "eq", "value": "critical", "target_stage": "fix-critical" },
|
||||
{ "field": "severity", "op": "neq", "value": "critical", "target_stage": "fix-normal" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "fix-critical",
|
||||
"ordinal": 2,
|
||||
"stage_mode": "form",
|
||||
"audience": "team",
|
||||
"stage_type": "simple",
|
||||
"auto_transition": false,
|
||||
"sla_seconds": 3600,
|
||||
"form_template": {
|
||||
"fieldsets": [
|
||||
{
|
||||
"label": "Critical Fix",
|
||||
"fields": [
|
||||
{ "key": "fix_description", "label": "Fix Description", "type": "textarea", "required": true },
|
||||
{ "key": "commit_ref", "label": "Commit / PR Reference", "type": "text" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "fix-normal",
|
||||
"ordinal": 3,
|
||||
"stage_mode": "form",
|
||||
"audience": "team",
|
||||
"stage_type": "simple",
|
||||
"auto_transition": false,
|
||||
"form_template": {
|
||||
"fieldsets": [
|
||||
{
|
||||
"label": "Fix",
|
||||
"fields": [
|
||||
{ "key": "fix_description", "label": "Fix Description", "type": "textarea", "required": true },
|
||||
{ "key": "commit_ref", "label": "Commit / PR Reference", "type": "text" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "verify",
|
||||
"ordinal": 4,
|
||||
"stage_mode": "review",
|
||||
"audience": "team",
|
||||
"stage_type": "simple",
|
||||
"auto_transition": false,
|
||||
"form_template": {
|
||||
"fieldsets": [
|
||||
{
|
||||
"label": "Verification",
|
||||
"fields": [
|
||||
{ "key": "verified", "label": "Fix Verified?", "type": "select", "options": ["yes", "no"], "required": true },
|
||||
{ "key": "verification_notes", "label": "Notes", "type": "textarea" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
{
|
||||
"id": "chat-core",
|
||||
"title": "Chat Core",
|
||||
"type": "library",
|
||||
"tier": "starlark",
|
||||
"version": "0.2.0",
|
||||
"description": "Core chat library — conversations, messages, participants, read cursors. Usable by other packages via lib.require().",
|
||||
"author": "switchboard",
|
||||
|
||||
"permissions": ["db.write", "realtime.publish"],
|
||||
|
||||
"exports": [
|
||||
"create", "send", "history",
|
||||
"add_participant", "remove_participant", "mark_read"
|
||||
],
|
||||
|
||||
"api_routes": [
|
||||
{"method": "GET", "path": "/conversations"},
|
||||
{"method": "POST", "path": "/conversations"},
|
||||
{"method": "GET", "path": "/conversations/*"},
|
||||
{"method": "PUT", "path": "/conversations/*"},
|
||||
{"method": "DELETE", "path": "/conversations/*"},
|
||||
{"method": "GET", "path": "/messages/*"},
|
||||
{"method": "POST", "path": "/messages/*"},
|
||||
{"method": "PUT", "path": "/messages/*"},
|
||||
{"method": "DELETE", "path": "/messages/*"},
|
||||
{"method": "GET", "path": "/participants/*"},
|
||||
{"method": "POST", "path": "/participants/*"},
|
||||
{"method": "DELETE", "path": "/participants/*"},
|
||||
{"method": "POST", "path": "/read/*"},
|
||||
{"method": "GET", "path": "/search"},
|
||||
{"method": "GET", "path": "/unread"}
|
||||
],
|
||||
|
||||
"db_tables": {
|
||||
"conversations": {
|
||||
"columns": {
|
||||
"title": "text",
|
||||
"type": "text",
|
||||
"created_by": "text",
|
||||
"updated_at": "text"
|
||||
},
|
||||
"indexes": [["created_by"], ["updated_at"]]
|
||||
},
|
||||
"participants": {
|
||||
"columns": {
|
||||
"conversation_id": "text",
|
||||
"participant_id": "text",
|
||||
"participant_type": "text",
|
||||
"display_name": "text",
|
||||
"role": "text",
|
||||
"joined_at": "text"
|
||||
},
|
||||
"indexes": [
|
||||
["conversation_id"],
|
||||
["participant_id"],
|
||||
["conversation_id", "participant_id"]
|
||||
]
|
||||
},
|
||||
"messages": {
|
||||
"columns": {
|
||||
"conversation_id": "text",
|
||||
"participant_id": "text",
|
||||
"content": "text",
|
||||
"content_type": "text",
|
||||
"edited_at": "text"
|
||||
},
|
||||
"indexes": [["conversation_id"]]
|
||||
},
|
||||
"read_cursors": {
|
||||
"columns": {
|
||||
"conversation_id": "text",
|
||||
"participant_id": "text",
|
||||
"last_read_message_id": "text"
|
||||
},
|
||||
"indexes": [["conversation_id", "participant_id"]]
|
||||
}
|
||||
},
|
||||
|
||||
"schema_version": 1
|
||||
}
|
||||
@@ -1,750 +0,0 @@
|
||||
# Chat Core — Starlark Backend (v0.2.0)
|
||||
#
|
||||
# Library package providing conversations, messages, participants,
|
||||
# and read cursors. Consumable via lib.require("chat-core").
|
||||
#
|
||||
# Entry points:
|
||||
# on_request(req) → REST API routes
|
||||
# Exported globals → create, send, history, add_participant,
|
||||
# remove_participant, mark_read
|
||||
#
|
||||
# Modules: db, json, realtime
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Helpers
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _resp(status, data):
|
||||
return {"status": status, "body": json.encode(data), "headers": {"Content-Type": "application/json"}}
|
||||
|
||||
def _str(v):
|
||||
if v == None:
|
||||
return ""
|
||||
return str(v)
|
||||
|
||||
def _int(v):
|
||||
if v == None:
|
||||
return 0
|
||||
s = str(v)
|
||||
if not s:
|
||||
return 0
|
||||
return int(s)
|
||||
|
||||
def _is_participant(conversation_id, user_id):
|
||||
"""Check if user is a participant in the conversation."""
|
||||
rows = db.query("participants", filters={"conversation_id": conversation_id, "participant_id": user_id}, limit=1)
|
||||
return len(rows or []) > 0
|
||||
|
||||
def _get_participant(conversation_id, user_id):
|
||||
"""Get participant record or None."""
|
||||
rows = db.query("participants", filters={"conversation_id": conversation_id, "participant_id": user_id}, limit=1)
|
||||
if rows and len(rows) > 0:
|
||||
return rows[0]
|
||||
return None
|
||||
|
||||
def _is_admin(conversation_id, user_id):
|
||||
"""Check if user has admin role in the conversation."""
|
||||
p = _get_participant(conversation_id, user_id)
|
||||
if p:
|
||||
return _str(p.get("role", "")) == "admin"
|
||||
return False
|
||||
|
||||
def _get_conversation(conversation_id):
|
||||
"""Get conversation by ID or None."""
|
||||
rows = db.query("conversations", filters={"id": conversation_id}, limit=1)
|
||||
if rows and len(rows) > 0:
|
||||
return rows[0]
|
||||
return None
|
||||
|
||||
def _now():
|
||||
"""Current timestamp placeholder — db auto-populates created_at."""
|
||||
return ""
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Exported API (lib.require("chat-core"))
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def create(title, type="group", participants=None, creator_id="", creator_display_name=""):
|
||||
"""Create a conversation and add initial participants.
|
||||
|
||||
Args:
|
||||
title: conversation title
|
||||
type: "direct" or "group" (default "group")
|
||||
participants: list of dicts with {id, type?, display_name?, role?}
|
||||
creator_id: user ID of the creator (added as admin)
|
||||
creator_display_name: display name of the creator
|
||||
|
||||
Returns:
|
||||
dict with conversation data
|
||||
"""
|
||||
if type not in ("direct", "group"):
|
||||
type = "group"
|
||||
|
||||
conv = db.insert("conversations", {
|
||||
"title": _str(title),
|
||||
"type": type,
|
||||
"created_by": _str(creator_id),
|
||||
"updated_at": "",
|
||||
})
|
||||
cid = conv["id"]
|
||||
|
||||
# Add creator as admin participant
|
||||
if creator_id:
|
||||
db.insert("participants", {
|
||||
"conversation_id": cid,
|
||||
"participant_id": _str(creator_id),
|
||||
"participant_type": "user",
|
||||
"display_name": _str(creator_display_name),
|
||||
"role": "admin",
|
||||
"joined_at": "",
|
||||
})
|
||||
|
||||
# Add initial participants
|
||||
for p in (participants or []):
|
||||
pid = _str(p.get("id", ""))
|
||||
if not pid or pid == _str(creator_id):
|
||||
continue
|
||||
db.insert("participants", {
|
||||
"conversation_id": cid,
|
||||
"participant_id": pid,
|
||||
"participant_type": _str(p.get("type", "user")),
|
||||
"display_name": _str(p.get("display_name", "")),
|
||||
"role": _str(p.get("role", "member")),
|
||||
"joined_at": "",
|
||||
})
|
||||
|
||||
return conv
|
||||
|
||||
|
||||
def send(conversation_id, participant_id, content, content_type="text"):
|
||||
"""Send a message to a conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: target conversation
|
||||
participant_id: sender ID
|
||||
content: message content
|
||||
content_type: "text", "system", or "file" (default "text")
|
||||
|
||||
Returns:
|
||||
dict with message data
|
||||
"""
|
||||
if content_type not in ("text", "system", "file"):
|
||||
content_type = "text"
|
||||
|
||||
msg = db.insert("messages", {
|
||||
"conversation_id": _str(conversation_id),
|
||||
"participant_id": _str(participant_id),
|
||||
"content": _str(content),
|
||||
"content_type": content_type,
|
||||
"edited_at": "",
|
||||
})
|
||||
|
||||
# Update conversation timestamp
|
||||
db.update("conversations", _str(conversation_id), {"updated_at": msg.get("created_at", "")})
|
||||
|
||||
# Publish realtime event
|
||||
realtime.publish(
|
||||
"conversation:" + _str(conversation_id),
|
||||
"message",
|
||||
{
|
||||
"id": msg.get("id", ""),
|
||||
"conversation_id": _str(conversation_id),
|
||||
"participant_id": _str(participant_id),
|
||||
"content": _str(content),
|
||||
"content_type": content_type,
|
||||
"created_at": msg.get("created_at", ""),
|
||||
},
|
||||
)
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
def history(conversation_id, limit=50, cursor=""):
|
||||
"""Get paginated message history for a conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: target conversation
|
||||
limit: max messages to return (1-100, default 50)
|
||||
cursor: created_at value of last message from previous page
|
||||
|
||||
Returns:
|
||||
dict with {messages, has_more, next_cursor}
|
||||
"""
|
||||
lim = _int(limit)
|
||||
if lim < 1 or lim > 100:
|
||||
lim = 50
|
||||
|
||||
filters = {"conversation_id": _str(conversation_id)}
|
||||
before = {}
|
||||
if cursor:
|
||||
before = {"created_at": _str(cursor)}
|
||||
|
||||
rows = db.query("messages", filters=filters, order="-created_at", limit=lim + 1, before=before)
|
||||
rows = rows or []
|
||||
|
||||
has_more = len(rows) > lim
|
||||
if has_more:
|
||||
rows = rows[:lim]
|
||||
|
||||
next_cursor = ""
|
||||
if has_more and rows:
|
||||
next_cursor = _str(rows[-1].get("created_at", ""))
|
||||
|
||||
return {"messages": rows, "has_more": has_more, "next_cursor": next_cursor}
|
||||
|
||||
|
||||
def add_participant(conversation_id, participant_id, participant_type="user", display_name="", role="member"):
|
||||
"""Add a participant to a conversation.
|
||||
|
||||
Returns:
|
||||
dict with participant data
|
||||
"""
|
||||
cid = _str(conversation_id)
|
||||
pid = _str(participant_id)
|
||||
|
||||
# Check if already a participant
|
||||
existing = db.query("participants", filters={"conversation_id": cid, "participant_id": pid}, limit=1)
|
||||
if existing and len(existing) > 0:
|
||||
return existing[0]
|
||||
|
||||
p = db.insert("participants", {
|
||||
"conversation_id": cid,
|
||||
"participant_id": pid,
|
||||
"participant_type": _str(participant_type),
|
||||
"display_name": _str(display_name),
|
||||
"role": _str(role),
|
||||
"joined_at": "",
|
||||
})
|
||||
|
||||
# Publish realtime event
|
||||
realtime.publish(
|
||||
"conversation:" + cid,
|
||||
"participant.added",
|
||||
{
|
||||
"conversation_id": cid,
|
||||
"participant_id": pid,
|
||||
"participant_type": _str(participant_type),
|
||||
"display_name": _str(display_name),
|
||||
"role": _str(role),
|
||||
},
|
||||
)
|
||||
|
||||
# Insert system message
|
||||
send(cid, pid, "joined the conversation", "system")
|
||||
|
||||
return p
|
||||
|
||||
|
||||
def remove_participant(conversation_id, participant_id):
|
||||
"""Remove a participant from a conversation.
|
||||
|
||||
Returns:
|
||||
True on success
|
||||
"""
|
||||
cid = _str(conversation_id)
|
||||
pid = _str(participant_id)
|
||||
|
||||
# Delete participant record
|
||||
rows = db.query("participants", filters={"conversation_id": cid, "participant_id": pid}, limit=1)
|
||||
for r in (rows or []):
|
||||
db.delete("participants", r["id"])
|
||||
|
||||
# Delete read cursor
|
||||
cursors = db.query("read_cursors", filters={"conversation_id": cid, "participant_id": pid}, limit=1)
|
||||
for c in (cursors or []):
|
||||
db.delete("read_cursors", c["id"])
|
||||
|
||||
# Publish realtime event
|
||||
realtime.publish(
|
||||
"conversation:" + cid,
|
||||
"participant.removed",
|
||||
{"conversation_id": cid, "participant_id": pid},
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def mark_read(conversation_id, participant_id, last_read_message_id):
|
||||
"""Update the read cursor for a participant.
|
||||
|
||||
Returns:
|
||||
True on success
|
||||
"""
|
||||
cid = _str(conversation_id)
|
||||
pid = _str(participant_id)
|
||||
mid = _str(last_read_message_id)
|
||||
|
||||
# Delete existing cursor (upsert via delete+insert)
|
||||
existing = db.query("read_cursors", filters={"conversation_id": cid, "participant_id": pid}, limit=1)
|
||||
for r in (existing or []):
|
||||
db.delete("read_cursors", r["id"])
|
||||
|
||||
db.insert("read_cursors", {
|
||||
"conversation_id": cid,
|
||||
"participant_id": pid,
|
||||
"last_read_message_id": mid,
|
||||
})
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# REST API dispatcher
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def on_request(req):
|
||||
path = req["path"]
|
||||
method = req["method"]
|
||||
user_id = req.get("user_id", "")
|
||||
|
||||
# ── Conversations ──────────────────────────
|
||||
|
||||
# GET /conversations — list user's conversations
|
||||
if method == "GET" and path == "/conversations":
|
||||
return _handle_list_conversations(req, user_id)
|
||||
|
||||
# POST /conversations — create
|
||||
if method == "POST" and path == "/conversations":
|
||||
return _handle_create_conversation(req, user_id)
|
||||
|
||||
# GET /search?q=term — search conversations and messages
|
||||
if method == "GET" and path == "/search":
|
||||
return _handle_search(req, user_id)
|
||||
|
||||
# GET /unread — unread counts
|
||||
if method == "GET" and path == "/unread":
|
||||
return _handle_unread(user_id)
|
||||
|
||||
# GET /conversations/:id
|
||||
if method == "GET" and path.startswith("/conversations/"):
|
||||
cid = path[len("/conversations/"):]
|
||||
return _handle_get_conversation(cid, user_id)
|
||||
|
||||
# PUT /conversations/:id
|
||||
if method == "PUT" and path.startswith("/conversations/"):
|
||||
cid = path[len("/conversations/"):]
|
||||
return _handle_update_conversation(cid, req, user_id)
|
||||
|
||||
# DELETE /conversations/:id
|
||||
if method == "DELETE" and path.startswith("/conversations/"):
|
||||
cid = path[len("/conversations/"):]
|
||||
return _handle_delete_conversation(cid, user_id)
|
||||
|
||||
# ── Messages ───────────────────────────────
|
||||
|
||||
# Routes: /messages/:conversation_id or /messages/:conversation_id/:message_id
|
||||
if path.startswith("/messages/"):
|
||||
remainder = path[len("/messages/"):]
|
||||
parts = remainder.split("/")
|
||||
cid = parts[0]
|
||||
mid = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
if method == "GET" and not mid:
|
||||
return _handle_list_messages(cid, req, user_id)
|
||||
if method == "POST" and not mid:
|
||||
return _handle_send_message(cid, req, user_id)
|
||||
if method == "PUT" and mid:
|
||||
return _handle_edit_message(cid, mid, req, user_id)
|
||||
if method == "DELETE" and mid:
|
||||
return _handle_delete_message(cid, mid, user_id)
|
||||
|
||||
# ── Participants ───────────────────────────
|
||||
|
||||
if path.startswith("/participants/"):
|
||||
remainder = path[len("/participants/"):]
|
||||
parts = remainder.split("/")
|
||||
cid = parts[0]
|
||||
pid = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
if method == "GET" and not pid:
|
||||
return _handle_list_participants(cid, user_id)
|
||||
if method == "POST" and not pid:
|
||||
return _handle_add_participant(cid, req, user_id)
|
||||
if method == "DELETE" and pid:
|
||||
return _handle_remove_participant(cid, pid, user_id)
|
||||
|
||||
# ── Read cursors ──────────────────────────
|
||||
|
||||
if method == "POST" and path.startswith("/read/"):
|
||||
cid = path[len("/read/"):]
|
||||
return _handle_mark_read(cid, req, user_id)
|
||||
|
||||
return _resp(404, {"error": "not found"})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Conversation handlers
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _handle_list_conversations(req, user_id):
|
||||
"""List conversations the user is a participant in."""
|
||||
# Get all conversation IDs for this user
|
||||
my_parts = db.query("participants", filters={"participant_id": user_id}, limit=500)
|
||||
if not my_parts:
|
||||
return _resp(200, {"data": []})
|
||||
|
||||
cids = []
|
||||
for p in my_parts:
|
||||
cids.append(_str(p.get("conversation_id", "")))
|
||||
|
||||
# Fetch conversations and enrich with last message
|
||||
items = []
|
||||
for cid in cids:
|
||||
conv = _get_conversation(cid)
|
||||
if not conv:
|
||||
continue
|
||||
|
||||
# Get last message
|
||||
last_msgs = db.query("messages", filters={"conversation_id": cid}, order="-created_at", limit=1)
|
||||
last_msg = None
|
||||
if last_msgs and len(last_msgs) > 0:
|
||||
last_msg = {
|
||||
"id": last_msgs[0].get("id", ""),
|
||||
"content": _str(last_msgs[0].get("content", "")),
|
||||
"participant_id": _str(last_msgs[0].get("participant_id", "")),
|
||||
"content_type": _str(last_msgs[0].get("content_type", "")),
|
||||
"created_at": _str(last_msgs[0].get("created_at", "")),
|
||||
}
|
||||
|
||||
# Get participant count
|
||||
parts = db.query("participants", filters={"conversation_id": cid}, limit=500)
|
||||
part_count = len(parts or [])
|
||||
|
||||
items.append({
|
||||
"id": conv.get("id", ""),
|
||||
"title": conv.get("title", ""),
|
||||
"type": conv.get("type", ""),
|
||||
"created_by": conv.get("created_by", ""),
|
||||
"updated_at": conv.get("updated_at", ""),
|
||||
"created_at": conv.get("created_at", ""),
|
||||
"last_message": last_msg,
|
||||
"participant_count": part_count,
|
||||
})
|
||||
|
||||
# Sort by updated_at descending (most recent first)
|
||||
items = sorted(items, key=lambda x: x.get("updated_at", "") or x.get("created_at", ""), reverse=True)
|
||||
|
||||
return _resp(200, {"data": items})
|
||||
|
||||
|
||||
def _handle_create_conversation(req, user_id):
|
||||
"""Create a new conversation."""
|
||||
body = json.decode(req.get("body", "{}"))
|
||||
title = _str(body.get("title", ""))
|
||||
conv_type = _str(body.get("type", "group"))
|
||||
participants_list = body.get("participants", [])
|
||||
creator_name = _str(body.get("creator_display_name", ""))
|
||||
|
||||
conv = create(title, conv_type, participants_list, user_id, creator_name)
|
||||
return _resp(201, conv)
|
||||
|
||||
|
||||
def _handle_get_conversation(cid, user_id):
|
||||
"""Get conversation detail with participants."""
|
||||
if not _is_participant(cid, user_id):
|
||||
return _resp(403, {"error": "not a participant"})
|
||||
|
||||
conv = _get_conversation(cid)
|
||||
if not conv:
|
||||
return _resp(404, {"error": "conversation not found"})
|
||||
|
||||
parts = db.query("participants", filters={"conversation_id": cid}, limit=500)
|
||||
|
||||
return _resp(200, {
|
||||
"id": conv.get("id", ""),
|
||||
"title": conv.get("title", ""),
|
||||
"type": conv.get("type", ""),
|
||||
"created_by": conv.get("created_by", ""),
|
||||
"updated_at": conv.get("updated_at", ""),
|
||||
"created_at": conv.get("created_at", ""),
|
||||
"participants": parts or [],
|
||||
})
|
||||
|
||||
|
||||
def _handle_update_conversation(cid, req, user_id):
|
||||
"""Update conversation (admin only)."""
|
||||
if not _is_admin(cid, user_id):
|
||||
return _resp(403, {"error": "admin only"})
|
||||
|
||||
body = json.decode(req.get("body", "{}"))
|
||||
patch = {}
|
||||
title = body.get("title", None)
|
||||
if title != None:
|
||||
patch["title"] = _str(title)
|
||||
|
||||
if patch:
|
||||
db.update("conversations", cid, patch)
|
||||
|
||||
conv = _get_conversation(cid)
|
||||
return _resp(200, conv)
|
||||
|
||||
|
||||
def _handle_delete_conversation(cid, user_id):
|
||||
"""Delete conversation and all associated data (admin only)."""
|
||||
if not _is_admin(cid, user_id):
|
||||
return _resp(403, {"error": "admin only"})
|
||||
|
||||
# Cascade delete: messages, participants, read_cursors, then conversation
|
||||
msgs = db.query("messages", filters={"conversation_id": cid}, limit=5000)
|
||||
for m in (msgs or []):
|
||||
db.delete("messages", m["id"])
|
||||
|
||||
parts = db.query("participants", filters={"conversation_id": cid}, limit=500)
|
||||
for p in (parts or []):
|
||||
db.delete("participants", p["id"])
|
||||
|
||||
cursors = db.query("read_cursors", filters={"conversation_id": cid}, limit=500)
|
||||
for c in (cursors or []):
|
||||
db.delete("read_cursors", c["id"])
|
||||
|
||||
db.delete("conversations", cid)
|
||||
return _resp(200, {"ok": True})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Message handlers
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _handle_list_messages(cid, req, user_id):
|
||||
"""Paginated message history."""
|
||||
if not _is_participant(cid, user_id):
|
||||
return _resp(403, {"error": "not a participant"})
|
||||
|
||||
q = req.get("query", {})
|
||||
limit_val = _int(q.get("limit", "50"))
|
||||
cursor = _str(q.get("cursor", ""))
|
||||
|
||||
result = history(cid, limit_val, cursor)
|
||||
return _resp(200, result)
|
||||
|
||||
|
||||
def _handle_send_message(cid, req, user_id):
|
||||
"""Send a message."""
|
||||
if not _is_participant(cid, user_id):
|
||||
return _resp(403, {"error": "not a participant"})
|
||||
|
||||
body = json.decode(req.get("body", "{}"))
|
||||
content = _str(body.get("content", ""))
|
||||
content_type = _str(body.get("content_type", "text"))
|
||||
|
||||
if not content:
|
||||
return _resp(400, {"error": "content required"})
|
||||
|
||||
msg = send(cid, user_id, content, content_type)
|
||||
return _resp(201, msg)
|
||||
|
||||
|
||||
def _handle_edit_message(cid, mid, req, user_id):
|
||||
"""Edit a message (author only)."""
|
||||
if not _is_participant(cid, user_id):
|
||||
return _resp(403, {"error": "not a participant"})
|
||||
|
||||
# Verify ownership
|
||||
msgs = db.query("messages", filters={"id": mid}, limit=1)
|
||||
if not msgs:
|
||||
return _resp(404, {"error": "message not found"})
|
||||
|
||||
msg = msgs[0]
|
||||
if _str(msg.get("participant_id", "")) != user_id:
|
||||
return _resp(403, {"error": "can only edit own messages"})
|
||||
|
||||
body = json.decode(req.get("body", "{}"))
|
||||
content = _str(body.get("content", ""))
|
||||
if not content:
|
||||
return _resp(400, {"error": "content required"})
|
||||
|
||||
# Use created_at of the message as the edited_at marker
|
||||
db.update("messages", mid, {"content": content, "edited_at": msg.get("created_at", "")})
|
||||
|
||||
# Publish edit event
|
||||
realtime.publish(
|
||||
"conversation:" + cid,
|
||||
"message.edited",
|
||||
{"id": mid, "conversation_id": cid, "content": content, "edited_by": user_id},
|
||||
)
|
||||
|
||||
updated = db.query("messages", filters={"id": mid}, limit=1)
|
||||
return _resp(200, updated[0] if updated else {})
|
||||
|
||||
|
||||
def _handle_delete_message(cid, mid, user_id):
|
||||
"""Delete a message (author or admin)."""
|
||||
if not _is_participant(cid, user_id):
|
||||
return _resp(403, {"error": "not a participant"})
|
||||
|
||||
msgs = db.query("messages", filters={"id": mid}, limit=1)
|
||||
if not msgs:
|
||||
return _resp(404, {"error": "message not found"})
|
||||
|
||||
msg = msgs[0]
|
||||
is_author = _str(msg.get("participant_id", "")) == user_id
|
||||
is_conv_admin = _is_admin(cid, user_id)
|
||||
|
||||
if not is_author and not is_conv_admin:
|
||||
return _resp(403, {"error": "can only delete own messages or be admin"})
|
||||
|
||||
db.delete("messages", mid)
|
||||
|
||||
# Publish delete event
|
||||
realtime.publish(
|
||||
"conversation:" + cid,
|
||||
"message.deleted",
|
||||
{"id": mid, "conversation_id": cid, "deleted_by": user_id},
|
||||
)
|
||||
|
||||
return _resp(200, {"ok": True})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Participant handlers
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _handle_list_participants(cid, user_id):
|
||||
"""List participants in a conversation."""
|
||||
if not _is_participant(cid, user_id):
|
||||
return _resp(403, {"error": "not a participant"})
|
||||
|
||||
parts = db.query("participants", filters={"conversation_id": cid}, limit=500)
|
||||
return _resp(200, {"data": parts or []})
|
||||
|
||||
|
||||
def _handle_add_participant(cid, req, user_id):
|
||||
"""Add a participant (admin only)."""
|
||||
if not _is_admin(cid, user_id):
|
||||
return _resp(403, {"error": "admin only"})
|
||||
|
||||
body = json.decode(req.get("body", "{}"))
|
||||
pid = _str(body.get("participant_id", ""))
|
||||
if not pid:
|
||||
return _resp(400, {"error": "participant_id required"})
|
||||
|
||||
ptype = _str(body.get("participant_type", "user"))
|
||||
display_name = _str(body.get("display_name", ""))
|
||||
role = _str(body.get("role", "member"))
|
||||
|
||||
p = add_participant(cid, pid, ptype, display_name, role)
|
||||
return _resp(201, p)
|
||||
|
||||
|
||||
def _handle_remove_participant(cid, pid, user_id):
|
||||
"""Remove a participant (admin only, or self-remove)."""
|
||||
is_self = pid == user_id
|
||||
if not is_self and not _is_admin(cid, user_id):
|
||||
return _resp(403, {"error": "admin only (or remove yourself)"})
|
||||
|
||||
remove_participant(cid, pid)
|
||||
return _resp(200, {"ok": True})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Read cursor handlers
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _handle_mark_read(cid, req, user_id):
|
||||
"""Mark conversation as read up to a message."""
|
||||
if not _is_participant(cid, user_id):
|
||||
return _resp(403, {"error": "not a participant"})
|
||||
|
||||
body = json.decode(req.get("body", "{}"))
|
||||
mid = _str(body.get("last_read_message_id", ""))
|
||||
if not mid:
|
||||
return _resp(400, {"error": "last_read_message_id required"})
|
||||
|
||||
mark_read(cid, user_id, mid)
|
||||
return _resp(200, {"ok": True})
|
||||
|
||||
|
||||
def _handle_search(req, user_id):
|
||||
"""Search across conversation titles and message content."""
|
||||
q = _str(req.get("query", {}).get("q", ""))
|
||||
if len(q) < 2:
|
||||
return _resp(400, {"error": "query must be at least 2 characters"})
|
||||
|
||||
pattern = "%" + q + "%"
|
||||
|
||||
# Get user's conversation IDs
|
||||
my_parts = db.query("participants", filters={"participant_id": user_id}, limit=500)
|
||||
if not my_parts:
|
||||
return _resp(200, {"conversations": [], "messages": []})
|
||||
|
||||
cid_set = {}
|
||||
for p in my_parts:
|
||||
cid_set[_str(p.get("conversation_id", ""))] = True
|
||||
|
||||
# Search conversations by title
|
||||
matching_convs = db.query("conversations", search_like={"title": pattern}, limit=50)
|
||||
conv_results = []
|
||||
for conv in (matching_convs or []):
|
||||
cid = _str(conv.get("id", ""))
|
||||
if cid in cid_set:
|
||||
conv_results.append({
|
||||
"id": cid,
|
||||
"title": conv.get("title", ""),
|
||||
"type": conv.get("type", ""),
|
||||
"updated_at": conv.get("updated_at", ""),
|
||||
"created_at": conv.get("created_at", ""),
|
||||
})
|
||||
|
||||
# Search messages in user's conversations
|
||||
msg_results = []
|
||||
for cid in cid_set:
|
||||
if not cid:
|
||||
continue
|
||||
msgs = db.query("messages", filters={"conversation_id": cid}, search_like={"content": pattern}, order="-created_at", limit=5)
|
||||
for m in (msgs or []):
|
||||
msg_results.append({
|
||||
"id": m.get("id", ""),
|
||||
"conversation_id": cid,
|
||||
"participant_id": m.get("participant_id", ""),
|
||||
"content": _str(m.get("content", "")),
|
||||
"content_type": _str(m.get("content_type", "")),
|
||||
"created_at": _str(m.get("created_at", "")),
|
||||
})
|
||||
|
||||
# Sort messages by created_at descending, limit to 50
|
||||
msg_results = sorted(msg_results, key=lambda x: x.get("created_at", ""), reverse=True)[:50]
|
||||
|
||||
return _resp(200, {"conversations": conv_results, "messages": msg_results})
|
||||
|
||||
|
||||
def _handle_unread(user_id):
|
||||
"""Get unread counts for all user's conversations."""
|
||||
my_parts = db.query("participants", filters={"participant_id": user_id}, limit=500)
|
||||
if not my_parts:
|
||||
return _resp(200, {"data": {}})
|
||||
|
||||
counts = {}
|
||||
for p in my_parts:
|
||||
cid = _str(p.get("conversation_id", ""))
|
||||
if not cid:
|
||||
continue
|
||||
|
||||
# Get read cursor
|
||||
cursors = db.query("read_cursors", filters={"conversation_id": cid, "participant_id": user_id}, limit=1)
|
||||
|
||||
if cursors and len(cursors) > 0:
|
||||
last_read_id = _str(cursors[0].get("last_read_message_id", ""))
|
||||
if last_read_id:
|
||||
# Get the created_at of the last read message
|
||||
last_read_msgs = db.query("messages", filters={"id": last_read_id}, limit=1)
|
||||
if last_read_msgs and len(last_read_msgs) > 0:
|
||||
last_read_at = _str(last_read_msgs[0].get("created_at", ""))
|
||||
# Count messages after the read cursor
|
||||
unread = db.query("messages", filters={"conversation_id": cid}, after={"created_at": last_read_at}, limit=1000)
|
||||
counts[cid] = len(unread or [])
|
||||
else:
|
||||
# Last read message was deleted — count all
|
||||
all_msgs = db.query("messages", filters={"conversation_id": cid}, limit=1000)
|
||||
counts[cid] = len(all_msgs or [])
|
||||
else:
|
||||
# No cursor value — all messages are unread
|
||||
all_msgs = db.query("messages", filters={"conversation_id": cid}, limit=1000)
|
||||
counts[cid] = len(all_msgs or [])
|
||||
else:
|
||||
# No cursor at all — all messages are unread
|
||||
all_msgs = db.query("messages", filters={"conversation_id": cid}, limit=1000)
|
||||
counts[cid] = len(all_msgs or [])
|
||||
|
||||
return _resp(200, {"data": counts})
|
||||
@@ -1,616 +0,0 @@
|
||||
/* ═══════════════════════════════════════════
|
||||
Chat Surface — Styles (v0.2.0)
|
||||
Uses variables.css theme tokens:
|
||||
--bg, --bg-surface, --bg-raised, --bg-hover, --bg-secondary
|
||||
--text, --text-2, --text-3
|
||||
--accent, --accent-dim, --border, --border-light
|
||||
--input-bg, --danger, --danger-bg, --success
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
/* ── Layout ─────────────────────────────── */
|
||||
|
||||
.chat-app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.chat-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Topbar extras ──────────────────────── */
|
||||
|
||||
.chat-topbar__thread-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
margin-right: 8px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
/* ── Sidebar ────────────────────────────── */
|
||||
|
||||
.chat-sidebar {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.chat-sidebar__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.chat-sidebar__title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chat-sidebar__list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chat-sidebar__empty {
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
color: var(--text-3);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-sidebar__item {
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.chat-sidebar__item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.chat-sidebar__item--active {
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.chat-sidebar__item-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.chat-sidebar__item-title {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.chat-sidebar__item-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-sidebar__item-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-sidebar__item-preview {
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat-sidebar__badge {
|
||||
background: var(--accent);
|
||||
color: var(--text-on-color);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Sidebar Search ────────────────────── */
|
||||
|
||||
.chat-sidebar__search {
|
||||
position: relative;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.chat-sidebar__search-input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 28px 6px 10px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.chat-sidebar__search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.chat-sidebar__search-clear {
|
||||
position: absolute;
|
||||
right: 22px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chat-sidebar__search-clear:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-sidebar__search-results {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chat-sidebar__search-section {
|
||||
padding: 8px 16px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.chat-sidebar__search-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.chat-sidebar__item--search-msg .chat-sidebar__item-preview {
|
||||
font-size: 13px;
|
||||
white-space: normal;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Message Thread ─────────────────────── */
|
||||
|
||||
.chat-thread {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-thread--empty {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.chat-thread__messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chat-thread__loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.chat-thread__loading-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.chat-thread__load-more {
|
||||
align-self: center;
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.chat-thread__load-more:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.chat-thread__typing {
|
||||
padding: 4px 16px 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Message Bubble ─────────────────────── */
|
||||
|
||||
.chat-msg {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-msg--own {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.chat-msg--system {
|
||||
justify-content: center;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.chat-msg--system span {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.chat-msg--deleted {
|
||||
justify-content: center;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.chat-msg--deleted em {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.chat-msg__body {
|
||||
max-width: 65%;
|
||||
background: var(--bg-raised);
|
||||
border-radius: 12px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.chat-msg--own .chat-msg__body {
|
||||
background: var(--accent);
|
||||
color: var(--text-on-color);
|
||||
}
|
||||
|
||||
.chat-msg__name {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.chat-msg__content {
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-msg__meta {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.chat-msg__time {
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.chat-msg--own .chat-msg__time {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.chat-msg__edited {
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.chat-msg--own .chat-msg__edited {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
/* ── Message Actions ────────────────────── */
|
||||
|
||||
.chat-msg__actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.chat-msg--own .chat-msg__actions {
|
||||
right: auto;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.chat-msg__action {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.chat-msg__action:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.chat-msg__action--danger:hover {
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* ── Message Edit ───────────────────────── */
|
||||
|
||||
.chat-msg__edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-msg__edit-input {
|
||||
width: 100%;
|
||||
min-width: 200px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-msg__edit-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ── Compose Bar ────────────────────────── */
|
||||
|
||||
.chat-compose {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.chat-compose__input {
|
||||
flex: 1;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
resize: none;
|
||||
line-height: 1.4;
|
||||
max-height: 160px;
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-compose__input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-dim);
|
||||
}
|
||||
|
||||
/* ── Participant Sidebar ────────────────── */
|
||||
|
||||
.chat-participants {
|
||||
width: 240px;
|
||||
min-width: 240px;
|
||||
border-left: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.chat-participants__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-participants__list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.chat-participants__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 16px;
|
||||
}
|
||||
|
||||
.chat-participants__name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chat-participants__badge {
|
||||
font-size: 10px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.chat-participants__status {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-participants__status--online {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.chat-participants__remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chat-participants__remove:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* ── New Conversation Dialog ────────────── */
|
||||
|
||||
.chat-new {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
.chat-new__type {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.chat-new__type label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-new__title {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-new__selected {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-new__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.chat-new__chip button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ── Dialog UserPicker overflow fix ─────── */
|
||||
/* Allow the autocomplete dropdown to overflow the dialog body.
|
||||
Applies to both New Conversation and Add Participant dialogs. */
|
||||
|
||||
.sw-dialog__body:has(.sw-user-picker) {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.sw-dialog:has(.sw-user-picker) {
|
||||
overflow: visible;
|
||||
}
|
||||
@@ -1,854 +0,0 @@
|
||||
/**
|
||||
* Chat — Surface Entry Point (v0.2.0)
|
||||
*
|
||||
* Messaging surface built on chat-core library:
|
||||
* sw.api.ext('chat-core') — conversation/message CRUD
|
||||
* sw.api.ext('chat') — typing indicators
|
||||
* sw.realtime — live events
|
||||
* sw.ui.* — primitive components
|
||||
* sw.shell.Topbar — navigation bar
|
||||
*/
|
||||
(async function () {
|
||||
'use strict';
|
||||
|
||||
var mount = document.getElementById('extension-mount');
|
||||
if (!mount) return;
|
||||
|
||||
var base = window.__BASE__ || '';
|
||||
var ver = window.__VERSION__ || '0';
|
||||
|
||||
// ── Boot SDK ───────────────────────────────
|
||||
try {
|
||||
if (!window.preact) {
|
||||
var { h, render } = await import(base + '/js/sw/vendor/preact.module.js');
|
||||
var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js');
|
||||
var htmModule = await import(base + '/js/sw/vendor/htm.module.js');
|
||||
window.preact = { h, render };
|
||||
window.hooks = hooksModule;
|
||||
window.html = htmModule.default.bind(h);
|
||||
}
|
||||
var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver);
|
||||
await sdk.boot();
|
||||
} catch (e) {
|
||||
mount.innerHTML = '<p style="color:var(--danger);padding:24px;">SDK boot failed: ' + e.message + '</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var { html } = window;
|
||||
var { useState, useEffect, useCallback, useRef, useMemo } = hooks;
|
||||
var { render } = preact;
|
||||
|
||||
// ── SDK modules ────────────────────────────
|
||||
var api = sw.api.ext('chat-core');
|
||||
var chatApi = sw.api.ext('chat');
|
||||
var { Button, Spinner, Avatar, Dialog, Tabs } = sw.ui;
|
||||
var Topbar = sw.shell.Topbar;
|
||||
|
||||
// Import UserPicker directly (not in sw.ui index)
|
||||
var { UserPicker } = await import(base + '/js/sw/primitives/user-picker.js?v=' + ver);
|
||||
|
||||
// ── Helpers ────────────────────────────────
|
||||
var _timers = {};
|
||||
function debounce(key, fn, ms) {
|
||||
clearTimeout(_timers[key]);
|
||||
_timers[key] = setTimeout(fn, ms);
|
||||
}
|
||||
|
||||
function timeAgo(ts) {
|
||||
if (!ts) return '';
|
||||
var d = new Date(ts);
|
||||
var now = Date.now();
|
||||
var diff = Math.floor((now - d.getTime()) / 1000);
|
||||
if (diff < 60) return 'now';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + 'm';
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + 'h';
|
||||
if (diff < 604800) return Math.floor(diff / 86400) + 'd';
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
|
||||
function truncate(str, len) {
|
||||
if (!str) return '';
|
||||
return str.length > len ? str.slice(0, len) + '\u2026' : str;
|
||||
}
|
||||
|
||||
function currentUserId() {
|
||||
return sw.auth?.user?.id || '';
|
||||
}
|
||||
|
||||
function currentDisplayName() {
|
||||
return sw.auth?.user?.display_name || sw.auth?.user?.username || '';
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// ConversationList — left sidebar
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
function ConversationList({ selected, onSelect, onNew, conversations, unread }) {
|
||||
var [searchQuery, setSearchQuery] = useState('');
|
||||
var [searchResults, setSearchResults] = useState(null);
|
||||
var [searching, setSearching] = useState(false);
|
||||
|
||||
function handleSearchInput(e) {
|
||||
var q = e.target.value;
|
||||
setSearchQuery(q);
|
||||
if (q.length < 2) {
|
||||
setSearchResults(null);
|
||||
setSearching(false);
|
||||
return;
|
||||
}
|
||||
debounce('search', () => {
|
||||
setSearching(true);
|
||||
api.get('/search?q=' + encodeURIComponent(q)).then(res => {
|
||||
setSearchResults(res || { conversations: [], messages: [] });
|
||||
setSearching(false);
|
||||
}).catch(() => { setSearching(false); });
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
setSearchQuery('');
|
||||
setSearchResults(null);
|
||||
setSearching(false);
|
||||
}
|
||||
|
||||
function selectFromSearch(cid) {
|
||||
clearSearch();
|
||||
onSelect(cid);
|
||||
}
|
||||
|
||||
// Render search results
|
||||
var showSearch = searchResults !== null;
|
||||
var sConvs = showSearch ? (searchResults.conversations || []) : [];
|
||||
var sMsgs = showSearch ? (searchResults.messages || []) : [];
|
||||
|
||||
return html`
|
||||
<div class="chat-sidebar">
|
||||
<div class="chat-sidebar__header">
|
||||
<span class="chat-sidebar__title">Conversations</span>
|
||||
<${Button} size="sm" onClick=${onNew}>New<//>
|
||||
</div>
|
||||
<div class="chat-sidebar__search">
|
||||
<input class="chat-sidebar__search-input"
|
||||
type="text"
|
||||
value=${searchQuery}
|
||||
placeholder="Search\u2026"
|
||||
onInput=${handleSearchInput} />
|
||||
${searchQuery && html`
|
||||
<button class="chat-sidebar__search-clear" onClick=${clearSearch}>\u00d7</button>`}
|
||||
</div>
|
||||
${showSearch ? html`
|
||||
<div class="chat-sidebar__search-results">
|
||||
${searching && html`<div class="chat-sidebar__search-loading"><${Spinner} size="sm" /></div>`}
|
||||
${!searching && sConvs.length === 0 && sMsgs.length === 0 && html`
|
||||
<div class="chat-sidebar__empty">No results</div>`}
|
||||
${sConvs.length > 0 && html`
|
||||
<div class="chat-sidebar__search-section">Conversations</div>
|
||||
${sConvs.map(c => html`
|
||||
<div key=${c.id} class="chat-sidebar__item" onClick=${() => selectFromSearch(c.id)}>
|
||||
<div class="chat-sidebar__item-top">
|
||||
<span class="chat-sidebar__item-title">${c.title || 'Untitled'}</span>
|
||||
<span class="chat-sidebar__item-time">${timeAgo(c.updated_at || c.created_at)}</span>
|
||||
</div>
|
||||
</div>`)}`}
|
||||
${sMsgs.length > 0 && html`
|
||||
<div class="chat-sidebar__search-section">Messages</div>
|
||||
${sMsgs.map(m => html`
|
||||
<div key=${m.id} class="chat-sidebar__item chat-sidebar__item--search-msg" onClick=${() => selectFromSearch(m.conversation_id)}>
|
||||
<div class="chat-sidebar__item-top">
|
||||
<span class="chat-sidebar__item-preview">${truncate(m.content, 80)}</span>
|
||||
</div>
|
||||
<div class="chat-sidebar__item-bottom">
|
||||
<span class="chat-sidebar__item-time">${timeAgo(m.created_at)}</span>
|
||||
</div>
|
||||
</div>`)}`}
|
||||
</div>
|
||||
` : html`
|
||||
<div class="chat-sidebar__list">
|
||||
${conversations.length === 0 && html`
|
||||
<div class="chat-sidebar__empty">No conversations yet</div>`}
|
||||
${conversations.map(c => html`
|
||||
<div key=${c.id}
|
||||
class=${'chat-sidebar__item' + (selected === c.id ? ' chat-sidebar__item--active' : '')}
|
||||
onClick=${() => onSelect(c.id)}>
|
||||
<div class="chat-sidebar__item-top">
|
||||
<span class="chat-sidebar__item-title">${c.title || 'Untitled'}</span>
|
||||
<span class="chat-sidebar__item-time">${timeAgo(c.updated_at || c.created_at)}</span>
|
||||
</div>
|
||||
<div class="chat-sidebar__item-bottom">
|
||||
<span class="chat-sidebar__item-preview">
|
||||
${c.last_message
|
||||
? truncate(c.last_message.content_type === 'system'
|
||||
? '\u2022 ' + c.last_message.content
|
||||
: c.last_message.content, 60)
|
||||
: 'No messages yet'}
|
||||
</span>
|
||||
${(unread[c.id] || 0) > 0 && html`
|
||||
<span class="chat-sidebar__badge">${unread[c.id]}</span>`}
|
||||
</div>
|
||||
</div>`)}
|
||||
</div>
|
||||
`}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// MessageBubble — single message
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
function MessageBubble({ msg, isOwn, onEdit, onDelete }) {
|
||||
var [editing, setEditing] = useState(false);
|
||||
var [editText, setEditText] = useState('');
|
||||
var [hover, setHover] = useState(false);
|
||||
|
||||
if (msg._deleted) {
|
||||
return html`
|
||||
<div class="chat-msg chat-msg--deleted">
|
||||
<em>This message was deleted</em>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (msg.content_type === 'system') {
|
||||
return html`
|
||||
<div class="chat-msg chat-msg--system">
|
||||
<span>${msg.content}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
setEditText(msg.content);
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setEditing(false);
|
||||
setEditText('');
|
||||
}
|
||||
|
||||
function saveEdit() {
|
||||
if (editText.trim() && editText !== msg.content) {
|
||||
onEdit(msg.id, editText.trim());
|
||||
}
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
function onEditKeyDown(e) {
|
||||
if (e.key === 'Escape') cancelEdit();
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveEdit(); }
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class=${'chat-msg' + (isOwn ? ' chat-msg--own' : '')}
|
||||
onMouseEnter=${() => setHover(true)}
|
||||
onMouseLeave=${() => setHover(false)}>
|
||||
${!isOwn && html`
|
||||
<${Avatar} name=${msg._display_name || msg.participant_id} size="sm" />`}
|
||||
<div class="chat-msg__body">
|
||||
${!isOwn && html`<span class="chat-msg__name">${msg._display_name || msg.participant_id}</span>`}
|
||||
${editing ? html`
|
||||
<div class="chat-msg__edit">
|
||||
<textarea class="chat-msg__edit-input"
|
||||
value=${editText}
|
||||
onInput=${e => setEditText(e.target.value)}
|
||||
onKeyDown=${onEditKeyDown}
|
||||
rows="2" />
|
||||
<div class="chat-msg__edit-actions">
|
||||
<${Button} size="sm" variant="secondary" onClick=${cancelEdit}>Cancel<//>
|
||||
<${Button} size="sm" onClick=${saveEdit}>Save<//>
|
||||
</div>
|
||||
</div>
|
||||
` : msg.content_type === 'markdown' && sw?.markdown?.ready ? html`
|
||||
<div class="chat-msg__content" dangerouslySetInnerHTML=${{ __html: sw.markdown.renderSync(msg.content, { sanitize: true }) }} />` : html`
|
||||
<div class="chat-msg__content">${msg.content}</div>`}
|
||||
<div class="chat-msg__meta">
|
||||
<span class="chat-msg__time">${timeAgo(msg.created_at)}</span>
|
||||
${msg.edited_at && html`<span class="chat-msg__edited">(edited)</span>`}
|
||||
</div>
|
||||
</div>
|
||||
${hover && isOwn && !editing && html`
|
||||
<div class="chat-msg__actions">
|
||||
<button class="chat-msg__action" onClick=${startEdit} title="Edit">✎</button>
|
||||
<button class="chat-msg__action chat-msg__action--danger" onClick=${() => onDelete(msg.id)} title="Delete">🗑</button>
|
||||
</div>`}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// MessageThread — center pane
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
function MessageThread({ conversationId, participants }) {
|
||||
var [messages, setMessages] = useState([]);
|
||||
var [loading, setLoading] = useState(false);
|
||||
var [hasMore, setHasMore] = useState(false);
|
||||
var [nextCursor, setNextCursor] = useState('');
|
||||
var [typingUsers, setTypingUsers] = useState({});
|
||||
var bottomRef = useRef(null);
|
||||
var listRef = useRef(null);
|
||||
var userId = currentUserId();
|
||||
|
||||
// Build participant lookup
|
||||
var partMap = useMemo(() => {
|
||||
var m = {};
|
||||
(participants || []).forEach(p => { m[p.participant_id] = p.display_name || p.participant_id; });
|
||||
return m;
|
||||
}, [participants]);
|
||||
|
||||
// Enrich messages with display names
|
||||
function enrichMessages(msgs) {
|
||||
return msgs.map(m => ({ ...m, _display_name: partMap[m.participant_id] || m.participant_id }));
|
||||
}
|
||||
|
||||
// Load initial messages
|
||||
useEffect(() => {
|
||||
if (!conversationId) { setMessages([]); return; }
|
||||
setLoading(true);
|
||||
setMessages([]);
|
||||
setNextCursor('');
|
||||
setHasMore(false);
|
||||
api.get('/messages/' + conversationId + '?limit=50').then(res => {
|
||||
var data = res || {};
|
||||
var msgs = (data.messages || []).reverse();
|
||||
setMessages(enrichMessages(msgs));
|
||||
setHasMore(!!data.has_more);
|
||||
setNextCursor(data.next_cursor || '');
|
||||
setLoading(false);
|
||||
setTimeout(() => scrollToBottom(), 50);
|
||||
}).catch(() => setLoading(false));
|
||||
}, [conversationId, partMap]);
|
||||
|
||||
// Load older messages (preserves scroll position)
|
||||
function loadMore() {
|
||||
if (!hasMore || !nextCursor || loading) return;
|
||||
var el = listRef.current;
|
||||
var prevHeight = el ? el.scrollHeight : 0;
|
||||
setLoading(true);
|
||||
api.get('/messages/' + conversationId + '?limit=50&cursor=' + encodeURIComponent(nextCursor)).then(res => {
|
||||
var data = res || {};
|
||||
var older = (data.messages || []).reverse();
|
||||
setMessages(prev => [...enrichMessages(older), ...prev]);
|
||||
setHasMore(!!data.has_more);
|
||||
setNextCursor(data.next_cursor || '');
|
||||
setLoading(false);
|
||||
// Restore scroll position after prepending older messages
|
||||
requestAnimationFrame(() => {
|
||||
if (el) el.scrollTop = el.scrollHeight - prevHeight;
|
||||
});
|
||||
}).catch(() => setLoading(false));
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (bottomRef.current) bottomRef.current.scrollIntoView({ behavior: 'auto' });
|
||||
}
|
||||
|
||||
// Scroll listener for load-more
|
||||
useEffect(() => {
|
||||
var el = listRef.current;
|
||||
if (!el) return;
|
||||
function onScroll() {
|
||||
if (el.scrollTop < 80 && hasMore && !loading) loadMore();
|
||||
}
|
||||
el.addEventListener('scroll', onScroll);
|
||||
return () => el.removeEventListener('scroll', onScroll);
|
||||
}, [hasMore, loading, nextCursor, conversationId]);
|
||||
|
||||
// Realtime: new messages, edits, deletes
|
||||
useEffect(() => {
|
||||
if (!conversationId) return;
|
||||
var channel = 'conversation:' + conversationId;
|
||||
|
||||
var unsubs = [
|
||||
sw.realtime.subscribe(channel, 'message', (payload) => {
|
||||
var msg = { ...payload, _display_name: partMap[payload.participant_id] || payload.participant_id };
|
||||
setMessages(prev => [...prev, msg]);
|
||||
setTimeout(() => scrollToBottom(), 50);
|
||||
// Auto mark read if from someone else
|
||||
if (payload.participant_id !== userId && payload.id) {
|
||||
api.post('/read/' + conversationId, { last_read_message_id: payload.id }).catch(() => {});
|
||||
}
|
||||
}),
|
||||
sw.realtime.subscribe(channel, 'message.edited', (payload) => {
|
||||
setMessages(prev => prev.map(m =>
|
||||
m.id === payload.id ? { ...m, content: payload.content, edited_at: 'true' } : m
|
||||
));
|
||||
}),
|
||||
sw.realtime.subscribe(channel, 'message.deleted', (payload) => {
|
||||
setMessages(prev => prev.map(m =>
|
||||
m.id === payload.id ? { ...m, _deleted: true, content: '' } : m
|
||||
));
|
||||
}),
|
||||
];
|
||||
|
||||
// Typing indicators
|
||||
var typingTimers = {};
|
||||
unsubs.push(sw.realtime.subscribe(channel, 'typing', (payload) => {
|
||||
var pid = payload.participant_id;
|
||||
if (pid === userId) return;
|
||||
setTypingUsers(prev => ({ ...prev, [pid]: payload.display_name || pid }));
|
||||
clearTimeout(typingTimers[pid]);
|
||||
typingTimers[pid] = setTimeout(() => {
|
||||
setTypingUsers(prev => {
|
||||
var next = { ...prev };
|
||||
delete next[pid];
|
||||
return next;
|
||||
});
|
||||
}, 4000);
|
||||
}));
|
||||
|
||||
return () => {
|
||||
unsubs.forEach(fn => fn());
|
||||
Object.values(typingTimers).forEach(clearTimeout);
|
||||
setTypingUsers({});
|
||||
};
|
||||
}, [conversationId, partMap, userId]);
|
||||
|
||||
// Mark read on first load
|
||||
useEffect(() => {
|
||||
if (!conversationId || messages.length === 0) return;
|
||||
var last = messages[messages.length - 1];
|
||||
if (last && last.id) {
|
||||
api.post('/read/' + conversationId, { last_read_message_id: last.id }).catch(() => {});
|
||||
}
|
||||
}, [conversationId, messages.length > 0]);
|
||||
|
||||
// Edit message
|
||||
function handleEdit(msgId, newContent) {
|
||||
api.put('/messages/' + conversationId + '/' + msgId, { content: newContent }).then(() => {
|
||||
setMessages(prev => prev.map(m =>
|
||||
m.id === msgId ? { ...m, content: newContent, edited_at: 'true' } : m
|
||||
));
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Delete message
|
||||
async function handleDelete(msgId) {
|
||||
var ok = await sw.ui.confirm('Delete this message?', { destructive: true });
|
||||
if (!ok) return;
|
||||
api.del('/messages/' + conversationId + '/' + msgId).then(() => {
|
||||
setMessages(prev => prev.map(m =>
|
||||
m.id === msgId ? { ...m, _deleted: true, content: '' } : m
|
||||
));
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Typing indicator text
|
||||
var typingNames = Object.values(typingUsers);
|
||||
var typingText = '';
|
||||
if (typingNames.length === 1) typingText = typingNames[0] + ' is typing\u2026';
|
||||
else if (typingNames.length === 2) typingText = typingNames.join(' and ') + ' are typing\u2026';
|
||||
else if (typingNames.length > 2) typingText = 'Several people are typing\u2026';
|
||||
|
||||
if (!conversationId) {
|
||||
return html`
|
||||
<div class="chat-thread chat-thread--empty">
|
||||
<p>Select a conversation or start a new one</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="chat-thread">
|
||||
<div class="chat-thread__messages" ref=${listRef}>
|
||||
${loading && messages.length === 0 && html`<div class="chat-thread__loading"><${Spinner} /></div>`}
|
||||
${loading && messages.length > 0 && html`<div class="chat-thread__loading-more"><${Spinner} size="sm" /></div>`}
|
||||
${hasMore && !loading && html`
|
||||
<button class="chat-thread__load-more" onClick=${loadMore}>
|
||||
Load older messages
|
||||
</button>`}
|
||||
${messages.map(m => html`
|
||||
<${MessageBubble}
|
||||
key=${m.id}
|
||||
msg=${m}
|
||||
isOwn=${m.participant_id === userId}
|
||||
onEdit=${handleEdit}
|
||||
onDelete=${handleDelete}
|
||||
/>`)}
|
||||
<div ref=${bottomRef} />
|
||||
</div>
|
||||
${typingText && html`<div class="chat-thread__typing">${typingText}</div>`}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// ComposeBar — message input
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
function ComposeBar({ conversationId }) {
|
||||
var [text, setText] = useState('');
|
||||
var [sending, setSending] = useState(false);
|
||||
var textareaRef = useRef(null);
|
||||
|
||||
// Reset on conversation change
|
||||
useEffect(() => { setText(''); }, [conversationId]);
|
||||
|
||||
// Auto-focus
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) textareaRef.current.focus();
|
||||
}, [conversationId]);
|
||||
|
||||
// Auto-resize
|
||||
function autoResize(el) {
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 160) + 'px';
|
||||
}
|
||||
|
||||
function handleInput(e) {
|
||||
setText(e.target.value);
|
||||
autoResize(e.target);
|
||||
// Typing indicator (debounced)
|
||||
debounce('typing', () => {
|
||||
chatApi.post('/typing/' + conversationId, {
|
||||
display_name: currentDisplayName(),
|
||||
}).catch(() => {});
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
var content = text.trim();
|
||||
if (!content || sending) return;
|
||||
setSending(true);
|
||||
try {
|
||||
await api.post('/messages/' + conversationId, { content: content, content_type: 'text' });
|
||||
setText('');
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[chat] send failed:', e);
|
||||
}
|
||||
setSending(false);
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}
|
||||
|
||||
if (!conversationId) return null;
|
||||
|
||||
return html`
|
||||
<div class="chat-compose">
|
||||
<textarea class="chat-compose__input"
|
||||
ref=${textareaRef}
|
||||
value=${text}
|
||||
placeholder="Type a message\u2026"
|
||||
rows="1"
|
||||
onInput=${handleInput}
|
||||
onKeyDown=${onKeyDown} />
|
||||
<${Button} onClick=${handleSend} disabled=${!text.trim() || sending}>Send<//>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// ParticipantSidebar — right panel
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
function ParticipantSidebar({ conversationId, participants, onRefresh, isAdmin }) {
|
||||
var [addOpen, setAddOpen] = useState(false);
|
||||
var [presence, setPresence] = useState({});
|
||||
|
||||
// Query presence
|
||||
useEffect(() => {
|
||||
if (!participants || participants.length === 0) return;
|
||||
var ids = participants.map(p => p.participant_id).join(',');
|
||||
sw.api.get('/api/v1/presence?users=' + ids).then(res => {
|
||||
setPresence(res || {});
|
||||
}).catch(() => {});
|
||||
}, [participants]);
|
||||
|
||||
async function addUser(user) {
|
||||
setAddOpen(false);
|
||||
await api.post('/participants/' + conversationId, {
|
||||
participant_id: user.id,
|
||||
display_name: user.display_name || user.username,
|
||||
}).catch(() => {});
|
||||
onRefresh();
|
||||
}
|
||||
|
||||
async function removeUser(pid) {
|
||||
var ok = await sw.ui.confirm('Remove this participant?', { destructive: true });
|
||||
if (!ok) return;
|
||||
await api.del('/participants/' + conversationId + '/' + pid).catch(() => {});
|
||||
onRefresh();
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="chat-participants">
|
||||
<div class="chat-participants__header">
|
||||
<span>Participants (${(participants || []).length})</span>
|
||||
${isAdmin && html`<${Button} size="sm" onClick=${() => setAddOpen(true)}>Add<//>` }
|
||||
</div>
|
||||
<div class="chat-participants__list">
|
||||
${(participants || []).map(p => html`
|
||||
<div key=${p.participant_id} class="chat-participants__item">
|
||||
<${Avatar} name=${p.display_name || p.participant_id} size="sm" />
|
||||
<span class="chat-participants__name">
|
||||
${p.display_name || p.participant_id}
|
||||
${p.role === 'admin' && html`<span class="chat-participants__badge">admin</span>`}
|
||||
</span>
|
||||
<span class=${'chat-participants__status' + (presence[p.participant_id] ? ' chat-participants__status--online' : '')} />
|
||||
${isAdmin && p.participant_id !== currentUserId() && html`
|
||||
<button class="chat-participants__remove" onClick=${() => removeUser(p.participant_id)} title="Remove">\u00d7</button>`}
|
||||
</div>`)}
|
||||
</div>
|
||||
|
||||
<${Dialog} open=${addOpen} title="Add Participant" onClose=${() => setAddOpen(false)}>
|
||||
<${UserPicker} onSelect=${addUser} placeholder="Search users\u2026" autoFocus />
|
||||
<//>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// NewConversationDialog
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
function NewConversationDialog({ open, onClose, onCreated }) {
|
||||
var [type, setType] = useState('group');
|
||||
var [title, setTitle] = useState('');
|
||||
var [selected, setSelected] = useState([]);
|
||||
var [creating, setCreating] = useState(false);
|
||||
|
||||
function reset() {
|
||||
setType('group');
|
||||
setTitle('');
|
||||
setSelected([]);
|
||||
setCreating(false);
|
||||
}
|
||||
|
||||
function addUser(user) {
|
||||
// Avoid duplicates
|
||||
if (selected.find(u => u.id === user.id)) return;
|
||||
// For DM, only allow one user
|
||||
if (type === 'direct' && selected.length >= 1) {
|
||||
setSelected([user]);
|
||||
return;
|
||||
}
|
||||
setSelected(prev => [...prev, user]);
|
||||
}
|
||||
|
||||
function removeSelected(id) {
|
||||
setSelected(prev => prev.filter(u => u.id !== id));
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (selected.length === 0) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
var convTitle = type === 'direct'
|
||||
? ''
|
||||
: (title.trim() || selected.map(u => u.display_name || u.username).join(', '));
|
||||
var participants = selected.map(u => ({
|
||||
id: u.id,
|
||||
display_name: u.display_name || u.username,
|
||||
}));
|
||||
var res = await api.post('/conversations', {
|
||||
title: convTitle,
|
||||
type: type,
|
||||
participants: participants,
|
||||
creator_display_name: currentDisplayName(),
|
||||
});
|
||||
reset();
|
||||
onClose();
|
||||
onCreated(res.id || res.data?.id);
|
||||
} catch (e) {
|
||||
console.error('[chat] create failed:', e);
|
||||
}
|
||||
setCreating(false);
|
||||
}
|
||||
|
||||
var actions = [
|
||||
{ label: 'Cancel', variant: 'secondary', onClick: () => { reset(); onClose(); } },
|
||||
{ label: 'Create', variant: 'primary', onClick: handleCreate, disabled: creating || selected.length === 0 },
|
||||
];
|
||||
|
||||
return html`
|
||||
<${Dialog} open=${open} title="New Conversation" onClose=${() => { reset(); onClose(); }} actions=${actions}>
|
||||
<div class="chat-new">
|
||||
<div class="chat-new__type">
|
||||
<label>
|
||||
<input type="radio" name="convType" value="group"
|
||||
checked=${type === 'group'} onChange=${() => { setType('group'); setSelected([]); }} />
|
||||
Group
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="convType" value="direct"
|
||||
checked=${type === 'direct'} onChange=${() => { setType('direct'); setSelected([]); }} />
|
||||
Direct Message
|
||||
</label>
|
||||
</div>
|
||||
${type === 'group' && html`
|
||||
<input class="chat-new__title" type="text" value=${title}
|
||||
placeholder="Conversation title (optional)"
|
||||
onInput=${e => setTitle(e.target.value)} />`}
|
||||
<${UserPicker} onSelect=${addUser} placeholder=${type === 'direct' ? 'Search for a user\u2026' : 'Add participants\u2026'} />
|
||||
${selected.length > 0 && html`
|
||||
<div class="chat-new__selected">
|
||||
${selected.map(u => html`
|
||||
<span key=${u.id} class="chat-new__chip">
|
||||
${u.display_name || u.username}
|
||||
<button onClick=${() => removeSelected(u.id)}>\u00d7</button>
|
||||
</span>`)}
|
||||
</div>`}
|
||||
</div>
|
||||
<//>`;
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// ChatApp — root component
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
function ChatApp() {
|
||||
var [conversations, setConversations] = useState([]);
|
||||
var [unread, setUnread] = useState({});
|
||||
var [selectedId, setSelectedId] = useState(null);
|
||||
var [participants, setParticipants] = useState([]);
|
||||
var [showParticipants, setShowParticipants] = useState(false);
|
||||
var [showNew, setShowNew] = useState(false);
|
||||
var [loading, setLoading] = useState(true);
|
||||
var userId = currentUserId();
|
||||
|
||||
// Load conversations
|
||||
// Note: SDK auto-unwraps {data: [...]} envelopes → res is the array directly
|
||||
function loadConversations() {
|
||||
return api.get('/conversations').then(res => {
|
||||
setConversations(Array.isArray(res) ? res : (res && res.data) || []);
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Load unread
|
||||
// Note: SDK returns {data: {id: count}} — NOT auto-unwrapped (data is object, not array)
|
||||
function loadUnread() {
|
||||
return api.get('/unread').then(res => {
|
||||
setUnread((res && res.data) || (typeof res === 'object' && !Array.isArray(res) ? res : {}));
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
Promise.all([loadConversations(), loadUnread()]).then(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
// Load participants when conversation changes
|
||||
useEffect(() => {
|
||||
if (!selectedId) { setParticipants([]); return; }
|
||||
api.get('/participants/' + selectedId).then(res => {
|
||||
setParticipants(Array.isArray(res) ? res : (res && res.data) || []);
|
||||
}).catch(() => {});
|
||||
}, [selectedId]);
|
||||
|
||||
// Realtime: refresh conversation list on activity
|
||||
useEffect(() => {
|
||||
// Subscribe to all active conversations
|
||||
var unsubs = [];
|
||||
conversations.forEach(c => {
|
||||
unsubs.push(sw.realtime.subscribe('conversation:' + c.id, 'message', () => {
|
||||
loadConversations();
|
||||
loadUnread();
|
||||
}));
|
||||
unsubs.push(sw.realtime.subscribe('conversation:' + c.id, 'participant.added', () => {
|
||||
loadConversations();
|
||||
if (c.id === selectedId) {
|
||||
api.get('/participants/' + selectedId).then(res => {
|
||||
setParticipants(Array.isArray(res) ? res : (res && res.data) || []);
|
||||
}).catch(() => {});
|
||||
}
|
||||
}));
|
||||
unsubs.push(sw.realtime.subscribe('conversation:' + c.id, 'participant.removed', () => {
|
||||
loadConversations();
|
||||
if (c.id === selectedId) {
|
||||
api.get('/participants/' + selectedId).then(res => {
|
||||
setParticipants(Array.isArray(res) ? res : (res && res.data) || []);
|
||||
}).catch(() => {});
|
||||
}
|
||||
}));
|
||||
});
|
||||
return () => unsubs.forEach(fn => fn());
|
||||
}, [conversations.map(c => c.id).join(','), selectedId]);
|
||||
|
||||
// Select conversation
|
||||
function selectConversation(id) {
|
||||
setSelectedId(id);
|
||||
setShowParticipants(false);
|
||||
// Mark read
|
||||
setUnread(prev => { var next = { ...prev }; delete next[id]; return next; });
|
||||
}
|
||||
|
||||
// After creating a new conversation
|
||||
function onCreated(id) {
|
||||
loadConversations().then(() => {
|
||||
if (id) setSelectedId(id);
|
||||
});
|
||||
}
|
||||
|
||||
// Is current user admin in selected conversation?
|
||||
var isAdmin = useMemo(() => {
|
||||
return participants.some(p => p.participant_id === userId && p.role === 'admin');
|
||||
}, [participants, userId]);
|
||||
|
||||
// Refresh participants
|
||||
function refreshParticipants() {
|
||||
if (!selectedId) return;
|
||||
api.get('/participants/' + selectedId).then(res => {
|
||||
setParticipants(Array.isArray(res) ? res : (res && res.data) || []);
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Conversation title for topbar
|
||||
var selectedConv = conversations.find(c => c.id === selectedId);
|
||||
var threadTitle = selectedConv ? (selectedConv.title || 'Direct Message') : '';
|
||||
|
||||
if (loading) {
|
||||
return html`<div class="chat-loading"><${Spinner} /></div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="chat-app">
|
||||
<${Topbar} title="Chat">
|
||||
${selectedId && html`
|
||||
<span class="chat-topbar__thread-title">${threadTitle}</span>
|
||||
<${Button} size="sm" variant="secondary"
|
||||
onClick=${() => setShowParticipants(!showParticipants)}>
|
||||
${showParticipants ? 'Hide' : 'People'}
|
||||
<//>`}
|
||||
<//>
|
||||
<div class="chat-body">
|
||||
<${ConversationList}
|
||||
selected=${selectedId}
|
||||
onSelect=${selectConversation}
|
||||
onNew=${() => setShowNew(true)}
|
||||
conversations=${conversations}
|
||||
unread=${unread} />
|
||||
<div class="chat-main">
|
||||
<${MessageThread}
|
||||
conversationId=${selectedId}
|
||||
participants=${participants} />
|
||||
<${ComposeBar} conversationId=${selectedId} />
|
||||
</div>
|
||||
${showParticipants && selectedId && html`
|
||||
<${ParticipantSidebar}
|
||||
conversationId=${selectedId}
|
||||
participants=${participants}
|
||||
onRefresh=${refreshParticipants}
|
||||
isAdmin=${isAdmin} />`}
|
||||
</div>
|
||||
<${NewConversationDialog}
|
||||
open=${showNew}
|
||||
onClose=${() => setShowNew(false)}
|
||||
onCreated=${onCreated} />
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
// ── Mount ──────────────────────────────────
|
||||
render(html`<${ChatApp} />`, mount);
|
||||
|
||||
})();
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"id": "chat",
|
||||
"title": "Chat",
|
||||
"type": "full",
|
||||
"tier": "starlark",
|
||||
"route": "/s/chat",
|
||||
"auth": "authenticated",
|
||||
"layout": "single",
|
||||
"version": "0.2.0",
|
||||
"icon": "\ud83d\udcac",
|
||||
"description": "Chat surface — conversations, messaging, typing indicators, read receipts.",
|
||||
"author": "switchboard",
|
||||
|
||||
"permissions": ["db.write", "realtime.publish"],
|
||||
|
||||
"depends": ["chat-core"],
|
||||
|
||||
"api_routes": [
|
||||
{"method": "POST", "path": "/typing/*"}
|
||||
],
|
||||
|
||||
"settings": {
|
||||
"enter_to_send": {
|
||||
"type": "boolean",
|
||||
"label": "Enter to Send",
|
||||
"description": "Press Enter to send messages (Shift+Enter for newline). When off, Enter inserts a newline.",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
# Chat Surface — Starlark Backend (v0.1.0)
|
||||
#
|
||||
# Thin surface layer on top of chat-core library.
|
||||
# Only handles typing indicator broadcast — all CRUD is
|
||||
# delegated to chat-core's own API routes.
|
||||
#
|
||||
# Modules: json, realtime
|
||||
|
||||
def _resp(status, data):
|
||||
return {"status": status, "body": json.encode(data), "headers": {"Content-Type": "application/json"}}
|
||||
|
||||
|
||||
def on_request(req):
|
||||
path = req["path"]
|
||||
method = req["method"]
|
||||
user_id = req.get("user_id", "")
|
||||
|
||||
# POST /typing/:conversation_id — broadcast typing indicator
|
||||
if method == "POST" and path.startswith("/typing/"):
|
||||
cid = path[len("/typing/"):]
|
||||
if not cid:
|
||||
return _resp(400, {"error": "conversation_id required"})
|
||||
|
||||
body = json.decode(req.get("body", "{}"))
|
||||
display_name = str(body.get("display_name", ""))
|
||||
|
||||
realtime.publish(
|
||||
"conversation:" + cid,
|
||||
"typing",
|
||||
{
|
||||
"participant_id": user_id,
|
||||
"display_name": display_name,
|
||||
"conversation_id": cid,
|
||||
},
|
||||
)
|
||||
|
||||
return _resp(200, {"ok": True})
|
||||
|
||||
return _resp(404, {"error": "not found"})
|
||||
@@ -1,33 +0,0 @@
|
||||
# Content Approval
|
||||
|
||||
Multi-party content review with quorum signoff and revision cycle.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-party signoff** — 2 approvals required (quorum)
|
||||
- **Rejection reroute** — rejection sends content back for revision
|
||||
- **Review cycle** — revision stage always routes back to review (loop)
|
||||
|
||||
## Stage Flow
|
||||
|
||||
```
|
||||
[draft] → [review] → [publish]
|
||||
team team ↕ team
|
||||
[revision]
|
||||
team
|
||||
```
|
||||
|
||||
## Review Cycle
|
||||
|
||||
1. Author submits draft → enters review
|
||||
2. Reviewer A approves, Reviewer B rejects → routes to revision
|
||||
3. Author revises → branch rule routes back to review
|
||||
4. Both reviewers approve → advances to publish
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
curl -X POST $BASE/api/v1/admin/packages/install \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-F "file=@packages/content-approval"
|
||||
```
|
||||
@@ -1,95 +0,0 @@
|
||||
{
|
||||
"id": "content-approval",
|
||||
"title": "Content Approval",
|
||||
"type": "workflow",
|
||||
"version": "0.1.0",
|
||||
"tier": "browser",
|
||||
"icon": "📝",
|
||||
"author": "Switchboard Core",
|
||||
"description": "Multi-party content review with quorum signoff and revision cycle.",
|
||||
"permissions": [],
|
||||
|
||||
"workflow_definition": {
|
||||
"name": "Content Approval",
|
||||
"slug": "content-approval",
|
||||
"entry_mode": "team_only",
|
||||
"stages": [
|
||||
{
|
||||
"name": "draft",
|
||||
"ordinal": 0,
|
||||
"stage_mode": "form",
|
||||
"audience": "team",
|
||||
"stage_type": "simple",
|
||||
"auto_transition": false,
|
||||
"form_template": {
|
||||
"fieldsets": [
|
||||
{
|
||||
"label": "Content Draft",
|
||||
"fields": [
|
||||
{ "key": "title", "label": "Title", "type": "text", "required": true },
|
||||
{ "key": "body", "label": "Body", "type": "textarea", "required": true },
|
||||
{ "key": "category", "label": "Category", "type": "select", "options": ["blog", "announcement", "documentation", "policy"], "required": true }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "review",
|
||||
"ordinal": 1,
|
||||
"stage_mode": "review",
|
||||
"audience": "team",
|
||||
"stage_type": "simple",
|
||||
"auto_transition": false,
|
||||
"stage_config": {
|
||||
"validation": {
|
||||
"required_approvals": 2,
|
||||
"reject_action": "revision"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "revision",
|
||||
"ordinal": 2,
|
||||
"stage_mode": "form",
|
||||
"audience": "team",
|
||||
"stage_type": "simple",
|
||||
"auto_transition": false,
|
||||
"form_template": {
|
||||
"fieldsets": [
|
||||
{
|
||||
"label": "Revision",
|
||||
"fields": [
|
||||
{ "key": "title", "label": "Title", "type": "text", "required": true },
|
||||
{ "key": "body", "label": "Body", "type": "textarea", "required": true },
|
||||
{ "key": "revision_notes", "label": "What Changed", "type": "textarea", "required": true }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"branch_rules": [
|
||||
{ "field": "title", "op": "exists", "value": "", "target_stage": "review" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "publish",
|
||||
"ordinal": 3,
|
||||
"stage_mode": "form",
|
||||
"audience": "team",
|
||||
"stage_type": "simple",
|
||||
"auto_transition": false,
|
||||
"form_template": {
|
||||
"fieldsets": [
|
||||
{
|
||||
"label": "Publish Confirmation",
|
||||
"fields": [
|
||||
{ "key": "publish_url", "label": "Published URL", "type": "text" },
|
||||
{ "key": "publish_notes", "label": "Notes", "type": "textarea" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,7 @@
|
||||
"tier": "browser",
|
||||
"author": "Switchboard Core",
|
||||
"icon": "📊",
|
||||
"description": "Project dashboard exercising all SDK primitives (requires legacy sw.* SDK — dormant until rewritten)",
|
||||
"requires": ["legacy-sdk"],
|
||||
"description": "Project dashboard exercising all SDK primitives",
|
||||
"route": "/s/dashboard",
|
||||
"permissions": [],
|
||||
"settings": [
|
||||
|
||||
@@ -372,8 +372,8 @@
|
||||
// ── File Operations ─────────────────────────
|
||||
|
||||
async function _deleteFile(wsId, path, fileTree, codeEditor) {
|
||||
const ok = typeof sw !== 'undefined' && sw.confirm
|
||||
? await sw.confirm('Delete ' + path + '?', { destructive: true })
|
||||
const ok = typeof showConfirm === 'function'
|
||||
? await showConfirm('Delete ' + path + '?')
|
||||
: window.confirm('Delete ' + path + '?');
|
||||
if (!ok) return;
|
||||
try {
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
"tier": "browser",
|
||||
"author": "Switchboard Core",
|
||||
"icon": "✏️",
|
||||
"description": "Code editor with workspace management, file tree, and AI assist (requires legacy sw.* SDK — dormant until rewritten)",
|
||||
"requires": ["legacy-sdk"],
|
||||
"description": "Code editor with workspace management, file tree, and AI assist",
|
||||
"route": "/s/editor",
|
||||
"layout": "editor",
|
||||
"permissions": [],
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
# Employee Onboarding
|
||||
|
||||
Automated employee provisioning with manager signoff and welcome notification.
|
||||
|
||||
## Features
|
||||
|
||||
- **Starlark automated stages** — `db.insert` for provisioning records, `notifications.send` for alerts
|
||||
- **Signoff gate** — manager approval required (role-based), rejection reroutes to start
|
||||
- **Progressive fieldsets** — Personal Info + Access Requirements
|
||||
- **ext_data tables** — `provisions` and `onboarding_log`
|
||||
|
||||
## Stage Flow
|
||||
|
||||
```
|
||||
[new-hire-info] → [provision-accounts] → [manager-signoff] → [it-setup] → [welcome]
|
||||
team automated (Starlark) review + signoff team automated
|
||||
auto-advances reject → stage 0 auto-advances
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Team must have a custom **"manager"** role (via Team Admin > Members > Team Roles)
|
||||
- At least one team member with the "manager" role assigned
|
||||
|
||||
## Starlark Hooks
|
||||
|
||||
- **`on_provision(ctx)`** — Inserts provisioning record into `provisions` table, notifies workflow starter
|
||||
- **`on_welcome(ctx)`** — Logs completion to `onboarding_log`, sends welcome notification
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
curl -X POST $BASE/api/v1/admin/packages/install \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-F "file=@packages/employee-onboarding"
|
||||
```
|
||||
@@ -1,122 +0,0 @@
|
||||
{
|
||||
"id": "employee-onboarding",
|
||||
"title": "Employee Onboarding",
|
||||
"type": "workflow",
|
||||
"version": "0.1.0",
|
||||
"tier": "starlark",
|
||||
"icon": "🏢",
|
||||
"author": "Switchboard Core",
|
||||
"description": "Employee onboarding with automated provisioning, manager signoff, and welcome notification.",
|
||||
"permissions": ["db.write", "notifications.send"],
|
||||
|
||||
"db_tables": {
|
||||
"provisions": {
|
||||
"columns": {
|
||||
"employee_name": "text",
|
||||
"department": "text",
|
||||
"needs_vpn": "text",
|
||||
"needs_admin": "text",
|
||||
"equipment": "text",
|
||||
"status": "text"
|
||||
}
|
||||
},
|
||||
"onboarding_log": {
|
||||
"columns": {
|
||||
"employee_name": "text",
|
||||
"department": "text",
|
||||
"completed_by": "text",
|
||||
"status": "text"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"workflow_definition": {
|
||||
"name": "Employee Onboarding",
|
||||
"slug": "employee-onboarding",
|
||||
"entry_mode": "team_only",
|
||||
"stages": [
|
||||
{
|
||||
"name": "new-hire-info",
|
||||
"ordinal": 0,
|
||||
"stage_mode": "form",
|
||||
"audience": "team",
|
||||
"stage_type": "simple",
|
||||
"auto_transition": false,
|
||||
"form_template": {
|
||||
"fieldsets": [
|
||||
{
|
||||
"label": "Personal Info",
|
||||
"fields": [
|
||||
{ "key": "full_name", "label": "Full Name", "type": "text", "required": true },
|
||||
{ "key": "email", "label": "Email", "type": "text", "required": true },
|
||||
{ "key": "department", "label": "Department", "type": "select", "options": ["engineering", "sales", "marketing", "hr", "ops", "finance"], "required": true },
|
||||
{ "key": "start_date", "label": "Start Date", "type": "text", "required": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Access Requirements",
|
||||
"fields": [
|
||||
{ "key": "needs_vpn", "label": "VPN Access", "type": "select", "options": ["true", "false"], "required": true },
|
||||
{ "key": "needs_admin", "label": "Admin Access", "type": "select", "options": ["true", "false"], "required": true },
|
||||
{ "key": "equipment", "label": "Equipment", "type": "select", "options": ["standard laptop", "developer workstation", "macbook pro"], "required": true }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "provision-accounts",
|
||||
"ordinal": 1,
|
||||
"stage_mode": "automated",
|
||||
"audience": "system",
|
||||
"stage_type": "automated",
|
||||
"auto_transition": true,
|
||||
"starlark_hook": "employee-onboarding:on_provision"
|
||||
},
|
||||
{
|
||||
"name": "manager-signoff",
|
||||
"ordinal": 2,
|
||||
"stage_mode": "review",
|
||||
"audience": "team",
|
||||
"stage_type": "simple",
|
||||
"auto_transition": false,
|
||||
"stage_config": {
|
||||
"validation": {
|
||||
"required_approvals": 1,
|
||||
"required_role": "manager",
|
||||
"reject_action": "new-hire-info"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "it-setup",
|
||||
"ordinal": 3,
|
||||
"stage_mode": "form",
|
||||
"audience": "team",
|
||||
"stage_type": "simple",
|
||||
"auto_transition": false,
|
||||
"form_template": {
|
||||
"fieldsets": [
|
||||
{
|
||||
"label": "IT Setup Confirmation",
|
||||
"fields": [
|
||||
{ "key": "hardware_ready", "label": "Hardware Ready?", "type": "select", "options": ["yes", "no"], "required": true },
|
||||
{ "key": "accounts_created", "label": "Accounts Created?", "type": "select", "options": ["yes", "no"], "required": true },
|
||||
{ "key": "it_notes", "label": "Notes", "type": "textarea" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "welcome",
|
||||
"ordinal": 4,
|
||||
"stage_mode": "automated",
|
||||
"audience": "system",
|
||||
"stage_type": "automated",
|
||||
"auto_transition": true,
|
||||
"starlark_hook": "employee-onboarding:on_welcome"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
def on_provision(ctx):
|
||||
"""Automated provisioning: record in ext_data, notify HR."""
|
||||
data = ctx["stage_data"]
|
||||
name = data.get("full_name", "unknown")
|
||||
dept = data.get("department", "general")
|
||||
|
||||
# Record provision in ext_data table
|
||||
db.insert("provisions", {
|
||||
"employee_name": name,
|
||||
"department": dept,
|
||||
"needs_vpn": str(data.get("needs_vpn", "false")),
|
||||
"needs_admin": str(data.get("needs_admin", "false")),
|
||||
"equipment": data.get("equipment", "standard laptop"),
|
||||
"status": "provisioned",
|
||||
})
|
||||
|
||||
# Notify the HR user who started this workflow
|
||||
notifications.send(
|
||||
ctx["started_by"],
|
||||
"Accounts provisioned for " + name,
|
||||
"Department: " + dept + ". Ready for manager review.",
|
||||
)
|
||||
|
||||
return {"advance": True, "data": {
|
||||
"provision_status": "complete",
|
||||
"provisioned_for": name,
|
||||
}}
|
||||
|
||||
|
||||
def on_welcome(ctx):
|
||||
"""Completion logging: record onboarding and send welcome."""
|
||||
data = ctx["stage_data"]
|
||||
name = data.get("full_name", "")
|
||||
dept = data.get("department", "")
|
||||
|
||||
db.insert("onboarding_log", {
|
||||
"employee_name": name,
|
||||
"department": dept,
|
||||
"completed_by": ctx["started_by"],
|
||||
"status": "complete",
|
||||
})
|
||||
|
||||
notifications.send(
|
||||
ctx["started_by"],
|
||||
"Onboarding complete: " + name,
|
||||
"All stages finished. Welcome aboard!",
|
||||
)
|
||||
|
||||
return {"advance": True, "data": {"onboarding_complete": True}}
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"id": "hello-dashboard",
|
||||
"icon": "👋",
|
||||
"type": "surface",
|
||||
"title": "Hello Dashboard",
|
||||
"route": "/s/hello-dashboard",
|
||||
"auth": "authenticated",
|
||||
|
||||
@@ -65,9 +65,9 @@
|
||||
T.assert(d.version === '0.31.1', 'version mismatch');
|
||||
});
|
||||
|
||||
await T.test('crud', 'dashboardPackage', 'dashpkg: GET /admin/packages (dashboard in list)', async function () {
|
||||
var d = await T.apiGet('/admin/packages');
|
||||
T.assertHasKey(d, 'data', '/admin/packages');
|
||||
await T.test('crud', 'dashboardPackage', 'dashpkg: GET /admin/surfaces (dashboard in list)', async function () {
|
||||
var d = await T.apiGet('/admin/surfaces');
|
||||
T.assertHasKey(d, 'data', '/admin/surfaces');
|
||||
var found = d.data.some(function (s) { return s.id === 'dashboard'; });
|
||||
T.assert(found, 'dashboard package should appear in surfaces list');
|
||||
});
|
||||
|
||||
@@ -68,9 +68,9 @@
|
||||
T.assert(d.version === '0.31.0', 'version mismatch');
|
||||
});
|
||||
|
||||
await T.test('crud', 'editorPackage', 'edpkg: GET /admin/packages (editor in list)', async function () {
|
||||
var d = await T.apiGet('/admin/packages');
|
||||
T.assertHasKey(d, 'data', '/admin/packages');
|
||||
await T.test('crud', 'editorPackage', 'edpkg: GET /admin/surfaces (editor in list)', async function () {
|
||||
var d = await T.apiGet('/admin/surfaces');
|
||||
T.assertHasKey(d, 'data', '/admin/surfaces');
|
||||
var found = d.data.some(function (s) { return s.id === 'editor'; });
|
||||
T.assert(found, 'editor package should appear in surfaces list');
|
||||
});
|
||||
@@ -78,7 +78,7 @@
|
||||
await T.test('crud', 'editorPackage', 'edpkg: core /editor removed (404)', async function () {
|
||||
// The old /editor route should no longer exist — it was removed in CS1.
|
||||
// We test by checking the surfaces list for a core editor entry.
|
||||
var d = await T.apiGet('/admin/packages');
|
||||
var d = await T.apiGet('/admin/surfaces');
|
||||
var surfaces = d.data || [];
|
||||
var coreEditor = surfaces.find(function (s) { return s.id === 'editor' && s.source === 'core'; });
|
||||
T.assert(!coreEditor, 'no core editor surface should exist — editor is now a package');
|
||||
@@ -139,7 +139,7 @@
|
||||
|
||||
await T.test('crud', 'editorPackage', 'edpkg: editor in extension nav items', async function () {
|
||||
// When installed, editor should appear in the surfaces list as an extension surface
|
||||
var d = await T.apiGet('/admin/packages');
|
||||
var d = await T.apiGet('/admin/surfaces');
|
||||
var surfaces = d.data || [];
|
||||
var editorSurface = surfaces.find(function (s) { return s.id === 'editor'; });
|
||||
T.assert(editorSurface, 'editor should be in surfaces list');
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
var surfaceId = null;
|
||||
|
||||
await T.test('crud', 'surfaces', 'POST /admin/packages/install (happy path)', async function () {
|
||||
await T.test('crud', 'surfaces', 'POST /admin/surfaces/install (happy path)', async function () {
|
||||
surfaceId = 'icd-test-' + Date.now();
|
||||
var manifest = JSON.stringify({
|
||||
id: surfaceId,
|
||||
@@ -28,32 +28,32 @@
|
||||
route: '/s/' + surfaceId
|
||||
});
|
||||
var zipBlob = buildTestZip({ 'manifest.json': manifest });
|
||||
await T.apiUpload('/admin/packages/install', zipBlob, surfaceId + '.surface');
|
||||
await T.apiUpload('/admin/surfaces/install', zipBlob, surfaceId + '.surface');
|
||||
|
||||
// Verify it exists
|
||||
var d = await T.apiGet('/admin/packages/' + surfaceId);
|
||||
var d = await T.apiGet('/admin/surfaces/' + surfaceId);
|
||||
T.assert(d.id === surfaceId, 'installed surface id mismatch');
|
||||
T.assert(d.title === 'ICD Test Surface', 'title mismatch');
|
||||
T.registerCleanup(async function () {
|
||||
if (surfaceId) {
|
||||
var token = await T.getAuthToken();
|
||||
return T.authFetch(token, 'DELETE', '/admin/packages/' + surfaceId);
|
||||
return T.authFetch(token, 'DELETE', '/admin/surfaces/' + surfaceId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (surfaceId) {
|
||||
await T.test('crud', 'surfaces', 'GET /admin/packages/:id (read)', async function () {
|
||||
var d = await T.apiGet('/admin/packages/' + surfaceId);
|
||||
await T.test('crud', 'surfaces', 'GET /admin/surfaces/:id (read)', async function () {
|
||||
var d = await T.apiGet('/admin/surfaces/' + surfaceId);
|
||||
T.assertShape(d, T.S.surfaceAdmin, 'surface detail');
|
||||
T.assert(d.id === surfaceId, 'id mismatch');
|
||||
T.assert(d.source === 'extension', 'source should be extension');
|
||||
T.assert(d.enabled === true, 'newly installed surface should be enabled');
|
||||
});
|
||||
|
||||
await T.test('crud', 'surfaces', 'GET /admin/packages (list includes new)', async function () {
|
||||
var d = await T.apiGet('/admin/packages');
|
||||
T.assertHasKey(d, 'data', '/admin/packages');
|
||||
await T.test('crud', 'surfaces', 'GET /admin/surfaces (list includes new)', async function () {
|
||||
var d = await T.apiGet('/admin/surfaces');
|
||||
T.assertHasKey(d, 'data', '/admin/surfaces');
|
||||
var found = d.data.some(function (s) { return s.id === surfaceId; });
|
||||
T.assert(found, 'installed surface should appear in admin list');
|
||||
});
|
||||
@@ -70,9 +70,9 @@
|
||||
T.assert(entry.enabled === undefined, 'nav entry should NOT have enabled');
|
||||
});
|
||||
|
||||
await T.test('crud', 'surfaces', 'PUT /admin/packages/:id/disable', async function () {
|
||||
await T.test('crud', 'surfaces', 'PUT /admin/surfaces/:id/disable', async function () {
|
||||
var d = await T.authFetch(await T.getAuthToken(), 'PUT',
|
||||
'/admin/packages/' + surfaceId + '/disable');
|
||||
'/admin/surfaces/' + surfaceId + '/disable');
|
||||
T.assertStatus(d, 200, 'disable');
|
||||
T.assert(d.enabled === false, 'should report disabled');
|
||||
});
|
||||
@@ -83,16 +83,16 @@
|
||||
T.assert(!found, 'disabled surface should NOT appear in user nav list');
|
||||
});
|
||||
|
||||
await T.test('crud', 'surfaces', 'GET /admin/packages (disabled still in admin)', async function () {
|
||||
var d = await T.apiGet('/admin/packages');
|
||||
await T.test('crud', 'surfaces', 'GET /admin/surfaces (disabled still in admin)', async function () {
|
||||
var d = await T.apiGet('/admin/surfaces');
|
||||
var entry = d.data.find(function (s) { return s.id === surfaceId; });
|
||||
T.assert(entry, 'disabled surface should still appear in admin list');
|
||||
T.assert(entry.enabled === false, 'should show as disabled in admin list');
|
||||
});
|
||||
|
||||
await T.test('crud', 'surfaces', 'PUT /admin/packages/:id/enable', async function () {
|
||||
await T.test('crud', 'surfaces', 'PUT /admin/surfaces/:id/enable', async function () {
|
||||
var d = await T.authFetch(await T.getAuthToken(), 'PUT',
|
||||
'/admin/packages/' + surfaceId + '/enable');
|
||||
'/admin/surfaces/' + surfaceId + '/enable');
|
||||
T.assertStatus(d, 200, 'enable');
|
||||
T.assert(d.enabled === true, 'should report enabled');
|
||||
});
|
||||
@@ -103,53 +103,53 @@
|
||||
T.assert(found, 're-enabled surface should appear in user nav list');
|
||||
});
|
||||
|
||||
await T.test('crud', 'surfaces', 'PUT /admin/packages/chat/disable (reject → 400)', async function () {
|
||||
await T.test('crud', 'surfaces', 'PUT /admin/surfaces/chat/disable (reject → 400)', async function () {
|
||||
var d = await T.authFetch(await T.getAuthToken(), 'PUT',
|
||||
'/admin/packages/chat/disable');
|
||||
'/admin/surfaces/chat/disable');
|
||||
T.assert(d._status === 400, 'disabling chat should return 400, got ' + d._status);
|
||||
});
|
||||
|
||||
await T.test('crud', 'surfaces', 'PUT /admin/packages/admin/disable (reject → 400)', async function () {
|
||||
await T.test('crud', 'surfaces', 'PUT /admin/surfaces/admin/disable (reject → 400)', async function () {
|
||||
var d = await T.authFetch(await T.getAuthToken(), 'PUT',
|
||||
'/admin/packages/admin/disable');
|
||||
'/admin/surfaces/admin/disable');
|
||||
T.assert(d._status === 400, 'disabling admin should return 400, got ' + d._status);
|
||||
});
|
||||
|
||||
await T.test('crud', 'surfaces', 'DELETE /admin/packages/chat (core reject → 400)', async function () {
|
||||
await T.test('crud', 'surfaces', 'DELETE /admin/surfaces/chat (core reject → 400)', async function () {
|
||||
var d = await T.authFetch(await T.getAuthToken(), 'DELETE',
|
||||
'/admin/packages/chat');
|
||||
'/admin/surfaces/chat');
|
||||
T.assert(d._status === 400, 'deleting core surface should return 400, got ' + d._status);
|
||||
});
|
||||
|
||||
await T.test('crud', 'surfaces', 'GET /admin/packages/:id (not found → 404)', async function () {
|
||||
await T.test('crud', 'surfaces', 'GET /admin/surfaces/:id (not found → 404)', async function () {
|
||||
var d = await T.authFetch(await T.getAuthToken(), 'GET',
|
||||
'/admin/packages/nonexistent-surface-id');
|
||||
'/admin/surfaces/nonexistent-surface-id');
|
||||
T.assert(d._status === 404, 'nonexistent should return 404, got ' + d._status);
|
||||
});
|
||||
|
||||
await T.test('crud', 'surfaces', 'PUT /admin/packages/nonexistent/disable (404)', async function () {
|
||||
await T.test('crud', 'surfaces', 'PUT /admin/surfaces/nonexistent/disable (404)', async function () {
|
||||
var d = await T.authFetch(await T.getAuthToken(), 'PUT',
|
||||
'/admin/packages/nonexistent-surface-id/disable');
|
||||
'/admin/surfaces/nonexistent-surface-id/disable');
|
||||
T.assert(d._status === 404, 'disable nonexistent should return 404, got ' + d._status);
|
||||
});
|
||||
|
||||
await T.test('crud', 'surfaces', 'POST /admin/packages/install (missing manifest → 400)', async function () {
|
||||
await T.test('crud', 'surfaces', 'POST /admin/surfaces/install (missing manifest → 400)', async function () {
|
||||
var zipBlob = buildTestZip({ 'readme.txt': 'no manifest here' });
|
||||
var d;
|
||||
try {
|
||||
d = await T.apiUpload('/admin/packages/install', zipBlob, 'bad.surface');
|
||||
d = await T.apiUpload('/admin/surfaces/install', zipBlob, 'bad.surface');
|
||||
T.assert(false, 'should have thrown');
|
||||
} catch (e) {
|
||||
T.assert(e.message.indexOf('400') !== -1, 'missing manifest should 400: ' + e.message);
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('crud', 'surfaces', 'POST /admin/packages/install (core conflict → 409)', async function () {
|
||||
await T.test('crud', 'surfaces', 'POST /admin/surfaces/install (core conflict → 409)', async function () {
|
||||
var manifest = JSON.stringify({ id: 'chat', title: 'Evil Chat', route: '/s/chat' });
|
||||
var zipBlob = buildTestZip({ 'manifest.json': manifest });
|
||||
var d;
|
||||
try {
|
||||
d = await T.apiUpload('/admin/packages/install', zipBlob, 'chat.surface');
|
||||
d = await T.apiUpload('/admin/surfaces/install', zipBlob, 'chat.surface');
|
||||
// If no throw, check status manually
|
||||
T.assert(false, 'overwriting core surface should have thrown');
|
||||
} catch (e) {
|
||||
@@ -157,10 +157,10 @@
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('crud', 'surfaces', 'POST /admin/packages/install (reseed preserves enabled)', async function () {
|
||||
await T.test('crud', 'surfaces', 'POST /admin/surfaces/install (reseed preserves enabled)', async function () {
|
||||
// Surface was re-enabled above. Disable it, then re-install.
|
||||
await T.authFetch(await T.getAuthToken(), 'PUT',
|
||||
'/admin/packages/' + surfaceId + '/disable');
|
||||
'/admin/surfaces/' + surfaceId + '/disable');
|
||||
|
||||
// Re-install same ID
|
||||
var manifest = JSON.stringify({
|
||||
@@ -169,33 +169,33 @@
|
||||
route: '/s/' + surfaceId
|
||||
});
|
||||
var zipBlob = buildTestZip({ 'manifest.json': manifest });
|
||||
await T.apiUpload('/admin/packages/install', zipBlob, surfaceId + '.surface');
|
||||
await T.apiUpload('/admin/surfaces/install', zipBlob, surfaceId + '.surface');
|
||||
|
||||
// Verify title updated but enabled preserved as false
|
||||
var d = await T.apiGet('/admin/packages/' + surfaceId);
|
||||
var d = await T.apiGet('/admin/surfaces/' + surfaceId);
|
||||
T.assert(d.title === 'ICD Test Surface v2', 'title should be updated after reseed');
|
||||
T.assert(d.enabled === false, 'enabled should be preserved as false after reseed');
|
||||
|
||||
// Re-enable for cleanup
|
||||
await T.authFetch(await T.getAuthToken(), 'PUT',
|
||||
'/admin/packages/' + surfaceId + '/enable');
|
||||
'/admin/surfaces/' + surfaceId + '/enable');
|
||||
});
|
||||
|
||||
await T.test('crud', 'surfaces', 'DELETE /admin/packages/:id', async function () {
|
||||
await T.test('crud', 'surfaces', 'DELETE /admin/surfaces/:id', async function () {
|
||||
var token = await T.getAuthToken();
|
||||
var d = await T.authFetch(token, 'DELETE', '/admin/packages/' + surfaceId);
|
||||
var d = await T.authFetch(token, 'DELETE', '/admin/surfaces/' + surfaceId);
|
||||
T.assertStatus(d, 200, 'delete');
|
||||
T.assert(d.deleted === true, 'should report deleted');
|
||||
|
||||
// Verify gone
|
||||
var check = await T.authFetch(token, 'GET', '/admin/packages/' + surfaceId);
|
||||
var check = await T.authFetch(token, 'GET', '/admin/surfaces/' + surfaceId);
|
||||
T.assert(check._status === 404, 'deleted surface should 404 on get');
|
||||
surfaceId = null;
|
||||
});
|
||||
|
||||
await T.test('crud', 'surfaces', 'DELETE /admin/packages/nonexistent (404)', async function () {
|
||||
await T.test('crud', 'surfaces', 'DELETE /admin/surfaces/nonexistent (404)', async function () {
|
||||
var d = await T.authFetch(await T.getAuthToken(), 'DELETE',
|
||||
'/admin/packages/nonexistent-surface-id');
|
||||
'/admin/surfaces/nonexistent-surface-id');
|
||||
T.assert(d._status === 404, 'delete nonexistent should return 404, got ' + d._status);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -227,7 +227,7 @@
|
||||
};
|
||||
|
||||
// ─── API Wrappers ───────────────────────────────────────────
|
||||
// Raw fetch — kernel provides no global API wrappers; extensions use fetch directly.
|
||||
// v0.37.14: raw fetch — old API._* globals removed in scorched earth.
|
||||
|
||||
async function _fetchJSON(method, path, body) {
|
||||
var token = await T.getAuthToken();
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
var adminGets = [
|
||||
'/admin/stats', '/admin/users', '/admin/settings',
|
||||
'/admin/configs', '/admin/models', '/admin/personas',
|
||||
'/admin/teams', '/admin/groups', '/admin/packages',
|
||||
'/admin/teams', '/admin/groups', '/admin/surfaces',
|
||||
'/admin/providers/health', '/admin/routing/policies',
|
||||
'/admin/permissions', '/admin/roles', '/admin/vault/status',
|
||||
'/admin/storage/status', '/admin/audit', '/admin/pricing',
|
||||
@@ -72,8 +72,8 @@
|
||||
'expected 403/401, got ' + d._status);
|
||||
});
|
||||
|
||||
await T.test('authz', 'admin-deny', 'user → POST /admin/packages/install (expect 403)', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'POST', '/admin/packages/install', {});
|
||||
await T.test('authz', 'admin-deny', 'user → POST /admin/surfaces/install (expect 403)', async function () {
|
||||
var d = await T.authFetch(testUser.token, 'POST', '/admin/surfaces/install', {});
|
||||
T.assert(d._status === 403 || d._status === 401 || d._status === 400,
|
||||
'expected 403/401/400, got ' + d._status);
|
||||
});
|
||||
|
||||
@@ -543,7 +543,7 @@
|
||||
await T.test('security', 'input-validation', '[P0] path traversal in surface archive', async function () {
|
||||
var blob = new Blob([JSON.stringify({ id: '../../../etc/evil', title: 'Path Traversal Test' })], { type: 'application/json' });
|
||||
try {
|
||||
var d = await T.apiUpload('/admin/packages/install', blob, 'evil.surface');
|
||||
var d = await T.apiUpload('/admin/surfaces/install', blob, 'evil.surface');
|
||||
T.assert(d._status === 400 || d._status === 409,
|
||||
'path traversal surface accepted! got ' + d._status);
|
||||
} catch (e) {
|
||||
@@ -644,8 +644,8 @@
|
||||
assertDenied(d._status, 'CRITICAL: team admin can delete users');
|
||||
});
|
||||
|
||||
await T.test('security', 'escalation', '[P0] teamAdmin → POST /admin/packages/install', async function () {
|
||||
var d = await T.authFetch(teamAdmin.token, 'POST', '/admin/packages/install', {});
|
||||
await T.test('security', 'escalation', '[P0] teamAdmin → POST /admin/surfaces/install', async function () {
|
||||
var d = await T.authFetch(teamAdmin.token, 'POST', '/admin/surfaces/install', {});
|
||||
if (d._status === 400) return; // bad upload format, not a security issue
|
||||
assertDenied(d._status, 'team admin can install surfaces');
|
||||
});
|
||||
|
||||
@@ -326,9 +326,9 @@
|
||||
T.assertHasKey(d, 'data', '/admin/groups');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'admin', 'GET /admin/packages', async function () {
|
||||
var d = await T.apiGet('/admin/packages');
|
||||
T.assertHasKey(d, 'data', '/admin/packages');
|
||||
await T.test('smoke', 'admin', 'GET /admin/surfaces', async function () {
|
||||
var d = await T.apiGet('/admin/surfaces');
|
||||
T.assertHasKey(d, 'data', '/admin/surfaces');
|
||||
T.assert(Array.isArray(d.data), 'surfaces should be array');
|
||||
if (d.data.length > 0) {
|
||||
T.assertShape(d.data[0], T.S.surfaceAdmin, 'surfaces[0]');
|
||||
@@ -442,48 +442,6 @@
|
||||
T.assertHasKey(d, 'data', '/admin/memories/pending');
|
||||
T.assert(Array.isArray(d.data), 'data should be array');
|
||||
});
|
||||
|
||||
// -- v0.6.x Admin endpoints --
|
||||
|
||||
await T.test('smoke', 'admin', 'GET /admin/metrics', async function () {
|
||||
var d = await T.apiGet('/admin/metrics');
|
||||
T.assert(typeof d === 'object', 'expected object');
|
||||
T.assertHasKey(d, 'runtime', '/admin/metrics');
|
||||
T.assertHasKey(d, 'database', '/admin/metrics');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'admin', 'GET /admin/cluster', async function () {
|
||||
var d = await T.apiGet('/admin/cluster');
|
||||
T.assertHasKey(d, 'data', '/admin/cluster');
|
||||
T.assert(Array.isArray(d.data), 'cluster nodes should be array');
|
||||
});
|
||||
|
||||
await T.test('smoke', 'admin', 'GET /admin/backups', async function () {
|
||||
var d = await T.apiGet('/admin/backups');
|
||||
T.assertHasKey(d, 'data', '/admin/backups');
|
||||
T.assert(Array.isArray(d.data), 'backups should be array');
|
||||
});
|
||||
}
|
||||
|
||||
// -- Docs API --
|
||||
await T.test('smoke', 'utility', 'GET /docs', async function () {
|
||||
var d = await T.apiGet('/docs');
|
||||
T.assertHasKey(d, 'data', '/docs');
|
||||
T.assert(Array.isArray(d.data), 'docs should be array');
|
||||
});
|
||||
|
||||
// -- Dynamic OpenAPI spec (outside /api/v1 prefix) --
|
||||
await T.test('smoke', 'utility', 'GET /api/docs/openapi.json', async function () {
|
||||
var token = await T.getAuthToken();
|
||||
var resp = await fetch(T.base + '/api/docs/openapi.json', {
|
||||
headers: { 'Authorization': 'Bearer ' + token },
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
T.assert(resp.ok, 'expected 200 from /api/docs/openapi.json, got ' + resp.status);
|
||||
var d = await resp.json();
|
||||
T.assertHasKey(d, 'openapi', 'openapi spec');
|
||||
T.assertHasKey(d, 'paths', 'openapi spec');
|
||||
T.assertHasKey(d, 'info', 'openapi spec');
|
||||
});
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"id": "icd-test-runner",
|
||||
"icon": "🧪",
|
||||
"type": "surface",
|
||||
"title": "ICD Test Runner",
|
||||
"route": "/s/icd-test-runner",
|
||||
"auth": "authenticated",
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
# Notes
|
||||
|
||||
Markdown notes surface with sidebar navigation, live preview, and search.
|
||||
|
||||
## Status: v0.1.0
|
||||
|
||||
Core CRUD with markdown textarea and inline preview.
|
||||
|
||||
## Features
|
||||
|
||||
- Create, edit, archive notes
|
||||
- Markdown editing with live preview toggle
|
||||
- Auto-save with debounce (1s)
|
||||
- Pin important notes to top
|
||||
- Full-text search across title and body
|
||||
- Light/dark theme support
|
||||
|
||||
## Planned
|
||||
|
||||
- v0.4.1: Folders + navigation tree
|
||||
- v0.4.2: Tags + enhanced search
|
||||
- v0.4.3: Backlinks + `[[wikilinks]]`
|
||||
- v0.4.4: Rich editor (CodeMirror 6) + import/export
|
||||
- v0.4.5: Editor modes (rendered/edit/split) + document outline
|
||||
|
||||
## Data
|
||||
|
||||
Single `notes` table in ext_data:
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| title | text | Note title |
|
||||
| body | text | Markdown content |
|
||||
| folder_id | text | Folder reference (v0.4.1) |
|
||||
| pinned | int | 0 or 1 |
|
||||
| archived | int | Soft delete flag |
|
||||
|
||||
## API
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | /notes | List notes (lightweight, no body) |
|
||||
| POST | /notes | Create note |
|
||||
| GET | /notes/:id | Get note with full body |
|
||||
| PUT | /notes/:id | Update note |
|
||||
| DELETE | /notes/:id | Archive (or hard delete with ?hard=1) |
|
||||
| GET | /search?q=term | Search title and body |
|
||||
| GET | /stats | Note counts |
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
- `Ctrl/Cmd+S` — Force save
|
||||
- `Tab` — Insert two spaces
|
||||
@@ -1,795 +0,0 @@
|
||||
/* ── Notes Surface Styles ─────────────────── */
|
||||
|
||||
.notes-app {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Sidebar ─────────────────────────────── */
|
||||
.notes-sidebar {
|
||||
width: 280px;
|
||||
min-width: 220px;
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-raised);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.notes-sidebar__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.notes-sidebar__title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
flex: 1;
|
||||
}
|
||||
.notes-sidebar__count {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.notes-sidebar__actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* ── Folder Tree ────────────────────────── */
|
||||
.folder-tree {
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
max-height: 40%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.folder-tree__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius);
|
||||
margin: 1px 6px;
|
||||
transition: var(--transition);
|
||||
user-select: none;
|
||||
}
|
||||
.folder-tree__item:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
.folder-tree__item--active {
|
||||
background: var(--bg-active);
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
}
|
||||
.folder-tree__toggle {
|
||||
width: 14px;
|
||||
font-size: 9px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.folder-tree__icon {
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.folder-tree__name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
.folder-tree__edit {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
padding: 1px 4px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 3px;
|
||||
background: var(--bg-surface);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
outline: none;
|
||||
}
|
||||
.folder-tree__add {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.folder-tree__add:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Search ──────────────────────────────── */
|
||||
.notes-search {
|
||||
padding: 8px 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.notes-search input {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
background: var(--bg-surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-family: var(--font);
|
||||
}
|
||||
.notes-search input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.notes-search input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* ── Note List ───────────────────────────── */
|
||||
.notes-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.notes-list__empty {
|
||||
padding: 40px 16px;
|
||||
text-align: center;
|
||||
color: var(--text-3);
|
||||
font-size: 13px;
|
||||
}
|
||||
.notes-list__loading {
|
||||
padding: 40px 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* ── Note Card (sidebar item) ────────────── */
|
||||
.note-card {
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.note-card:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.note-card--active {
|
||||
background: var(--bg-active);
|
||||
}
|
||||
.note-card__title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.note-card__pin {
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.note-card__snippet {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
margin-top: 3px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.note-card__date {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
/* ── Editor Pane ─────────────────────────── */
|
||||
.notes-editor {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.notes-editor__empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-3);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ── Editor Header ───────────────────────── */
|
||||
.notes-editor__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.notes-editor__title-input {
|
||||
flex: 1;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 4px 0;
|
||||
font-family: var(--font);
|
||||
}
|
||||
.notes-editor__title-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.notes-editor__actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ── Folder select in editor ────────────── */
|
||||
.notes-editor__folder-select {
|
||||
font-size: 12px;
|
||||
padding: 3px 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-2);
|
||||
font-family: var(--font);
|
||||
cursor: pointer;
|
||||
max-width: 140px;
|
||||
}
|
||||
.notes-editor__folder-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Editor Content (body + outline wrapper) */
|
||||
.notes-editor__content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Editor Body ─────────────────────────── */
|
||||
.notes-editor__body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.notes-editor__body--split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.notes-editor__body--split .notes-preview {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.notes-editor__textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
padding: 20px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
background: var(--bg-surface);
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: var(--mono);
|
||||
tab-size: 2;
|
||||
}
|
||||
.notes-editor__textarea::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* ── CodeMirror 6 container ─────────────── */
|
||||
.notes-editor__cm {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.notes-editor__cm .cm-editor {
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
}
|
||||
.notes-editor__cm .cm-scroller {
|
||||
overflow: auto;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
/* ── Preview ─────────────────────────────── */
|
||||
.notes-preview {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
border-left: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
.notes-preview h1 { font-size: 24px; font-weight: 700; color: var(--text); margin: 0 0 12px; }
|
||||
.notes-preview h2 { font-size: 20px; font-weight: 600; color: var(--text); margin: 20px 0 8px; }
|
||||
.notes-preview h3 { font-size: 16px; font-weight: 600; color: var(--text); margin: 16px 0 6px; }
|
||||
.notes-preview p { font-size: 14px; color: var(--text); line-height: 1.7; margin: 0 0 10px; }
|
||||
.notes-preview ul, .notes-preview ol { padding-left: 24px; margin: 0 0 10px; color: var(--text); font-size: 14px; line-height: 1.7; }
|
||||
.notes-preview li { margin-bottom: 2px; }
|
||||
.notes-preview code {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
background: var(--bg-raised);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.notes-preview pre {
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
overflow-x: auto;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.notes-preview pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
.notes-preview blockquote {
|
||||
border-left: 3px solid var(--accent);
|
||||
margin: 0 0 12px;
|
||||
padding: 4px 16px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.notes-preview a { color: var(--accent); text-decoration: none; }
|
||||
.notes-preview a:hover { text-decoration: underline; }
|
||||
.notes-preview hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 16px 0;
|
||||
}
|
||||
.notes-preview strong { font-weight: 600; }
|
||||
.notes-preview em { font-style: italic; }
|
||||
|
||||
/* ── Toggle button ───────────────────────── */
|
||||
.notes-toggle {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-2);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.notes-toggle:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.notes-toggle--active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
|
||||
/* ── Inline buttons ──────────────────────── */
|
||||
.notes-btn {
|
||||
border: none;
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius);
|
||||
transition: var(--transition);
|
||||
padding: 4px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.notes-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.notes-btn--danger:hover { background: var(--danger-dim); color: var(--danger); }
|
||||
.notes-btn--accent { background: var(--accent); color: #fff; }
|
||||
.notes-btn--accent:hover { opacity: 0.9; color: #fff; }
|
||||
|
||||
/* ── Saved indicator ─────────────────────── */
|
||||
.notes-saved {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.notes-saved--dirty {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
/* ── Tag Pills (shared) ─────────────────── */
|
||||
.tag-pill {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
color: var(--text-2);
|
||||
margin: 2px 2px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
line-height: 1.6;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.tag-pill:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.tag-pill--active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.tag-pill__remove {
|
||||
margin-left: 4px;
|
||||
font-size: 10px;
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tag-pill__remove:hover { opacity: 1; }
|
||||
|
||||
/* ── Tag Filter (sidebar) ──────────────── */
|
||||
.tag-filter {
|
||||
padding: 6px 14px 4px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tag-filter__label {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
margin-bottom: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.tag-filter__clear {
|
||||
font-size: 10px;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: var(--font);
|
||||
}
|
||||
.tag-filter__clear:hover { text-decoration: underline; }
|
||||
.tag-filter__pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
max-height: 60px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.tag-filter__pills .tag-pill { font-size: 10px; padding: 0 6px; }
|
||||
|
||||
/* ── Tag Input (editor) ────────────────── */
|
||||
.tag-input {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
padding: 4px 20px 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: relative;
|
||||
}
|
||||
.tag-input__field {
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
min-width: 80px;
|
||||
flex: 1;
|
||||
padding: 2px 0;
|
||||
font-family: var(--font);
|
||||
}
|
||||
.tag-input__field::placeholder { color: var(--text-3); }
|
||||
.tag-input .tag-pill { font-size: 11px; }
|
||||
|
||||
/* ── Tag Autocomplete ──────────────────── */
|
||||
.tag-autocomplete {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 20px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
z-index: 10;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
min-width: 140px;
|
||||
}
|
||||
.tag-autocomplete__item {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
}
|
||||
.tag-autocomplete__item:hover { background: var(--bg-hover); }
|
||||
|
||||
/* ── Note Card Tags ────────────────────── */
|
||||
.note-card__tags {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
margin-top: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.note-card__tags .tag-pill { font-size: 10px; padding: 0 6px; cursor: default; }
|
||||
.note-card__tags .tag-pill--overflow {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-3);
|
||||
padding: 0 2px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── Folder Context Menu ───────────────── */
|
||||
.folder-context-menu {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
|
||||
min-width: 150px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.folder-context-menu__item {
|
||||
padding: 6px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.folder-context-menu__item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.folder-context-menu__item--danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
.folder-context-menu__item--danger:hover {
|
||||
background: var(--danger-dim);
|
||||
}
|
||||
|
||||
/* ── Drag and Drop ─────────────────────── */
|
||||
.note-card--dragging { opacity: 0.4; }
|
||||
.folder-tree__item--drop-target {
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent) !important;
|
||||
outline: 2px dashed var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* ── Wikilinks ──────────────────────────── */
|
||||
.wikilink {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dashed var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.wikilink:hover {
|
||||
border-bottom-style: solid;
|
||||
}
|
||||
.wikilink.wikilink--unresolved {
|
||||
color: var(--danger, #e53e3e);
|
||||
border-bottom-color: var(--danger, #e53e3e);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.wikilink.wikilink--unresolved:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Backlinks Panel ────────────────────── */
|
||||
.backlinks-panel {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 10px 16px;
|
||||
background: var(--bg-raised);
|
||||
flex-shrink: 0;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.backlinks-panel__header {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.backlinks-panel__toggle {
|
||||
font-size: 9px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.backlinks-panel__count {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-2);
|
||||
font-size: 10px;
|
||||
padding: 0 6px;
|
||||
border-radius: 8px;
|
||||
line-height: 16px;
|
||||
}
|
||||
.backlinks-panel__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.backlinks-panel__item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.backlinks-panel__title {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
.backlinks-panel__context {
|
||||
color: var(--text-2);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ── Sidebar Tabs ────────────────────────── */
|
||||
.sidebar-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sidebar-tabs__tab {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-2);
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
text-align: center;
|
||||
font-family: var(--font);
|
||||
}
|
||||
.sidebar-tabs__tab:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.sidebar-tabs__tab--active {
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
.sidebar-tabs__tab--disabled {
|
||||
color: var(--text-3);
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.sidebar-tabs__tab--disabled:hover {
|
||||
background: none;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* ── Sidebar Outline ─────────────────────── */
|
||||
.sidebar-outline {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.sidebar-outline__empty {
|
||||
padding: 40px 16px;
|
||||
text-align: center;
|
||||
color: var(--text-3);
|
||||
font-size: 13px;
|
||||
}
|
||||
.sidebar-outline__item {
|
||||
padding: 5px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: var(--transition);
|
||||
border-radius: var(--radius);
|
||||
margin: 1px 6px;
|
||||
}
|
||||
.sidebar-outline__item:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
.sidebar-outline__item--active {
|
||||
background: var(--bg-active);
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
}
|
||||
.sidebar-outline__item--h1 { font-weight: 600; color: var(--text); }
|
||||
.sidebar-outline__item--h2 { font-weight: 500; }
|
||||
|
||||
/* ── Graph Pane ─────────────────────────── */
|
||||
.graph-pane {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--bg-surface, #fff);
|
||||
min-height: 0;
|
||||
}
|
||||
.graph-pane canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: grab;
|
||||
}
|
||||
.graph-pane canvas:active { cursor: grabbing; }
|
||||
.graph-toolbar {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
z-index: 5;
|
||||
font-size: 12px;
|
||||
color: var(--text-2, #666);
|
||||
}
|
||||
.graph-toolbar label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.graph-toolbar input[type="checkbox"] { margin: 0; }
|
||||
.graph-tooltip {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
background: var(--bg-raised, #fff);
|
||||
border: 1px solid var(--border, #ddd);
|
||||
border-radius: var(--radius, 6px);
|
||||
padding: 8px 12px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
|
||||
z-index: 10;
|
||||
max-width: 220px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.graph-tooltip__title {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.graph-tooltip__tags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.graph-tooltip__tags .tag-pill {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-hover, #eee);
|
||||
color: var(--text-2, #666);
|
||||
}
|
||||
.graph-tooltip__edges {
|
||||
font-size: 11px;
|
||||
color: var(--text-3, #999);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.graph-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-3, #999);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ── Responsive ──────────────────────────── */
|
||||
@media (max-width: 700px) {
|
||||
.notes-sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--border); max-height: 40vh; }
|
||||
.notes-app { flex-direction: column; }
|
||||
.folder-tree { max-height: 30vh; }
|
||||
.notes-editor__body--split { grid-template-columns: 1fr; }
|
||||
.notes-editor__body--split .notes-preview { display: none; }
|
||||
.graph-pane { min-height: 50vh; }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,104 +0,0 @@
|
||||
{
|
||||
"id": "notes",
|
||||
"title": "Notes",
|
||||
"type": "full",
|
||||
"tier": "starlark",
|
||||
"route": "/s/notes",
|
||||
"auth": "authenticated",
|
||||
"layout": "single",
|
||||
"version": "0.8.0",
|
||||
"icon": "📝",
|
||||
"description": "Markdown notes surface with rich editor, folders, tags, backlinks, import/export, sidebar tabs, document outline, and note graph.",
|
||||
"author": "switchboard",
|
||||
|
||||
"permissions": ["db.write"],
|
||||
|
||||
"api_routes": [
|
||||
{"method": "GET", "path": "/notes"},
|
||||
{"method": "POST", "path": "/notes"},
|
||||
{"method": "GET", "path": "/notes/*"},
|
||||
{"method": "PUT", "path": "/notes/*"},
|
||||
{"method": "DELETE", "path": "/notes/*"},
|
||||
{"method": "GET", "path": "/search"},
|
||||
{"method": "GET", "path": "/stats"},
|
||||
{"method": "GET", "path": "/graph"},
|
||||
{"method": "GET", "path": "/folders"},
|
||||
{"method": "POST", "path": "/folders"},
|
||||
{"method": "PUT", "path": "/folders/*"},
|
||||
{"method": "DELETE", "path": "/folders/*"},
|
||||
{"method": "POST", "path": "/notes/move"},
|
||||
{"method": "GET", "path": "/tags"},
|
||||
{"method": "GET", "path": "/tags/*"},
|
||||
{"method": "PUT", "path": "/tags/*"},
|
||||
{"method": "GET", "path": "/links/*"},
|
||||
{"method": "GET", "path": "/backlinks/*"}
|
||||
],
|
||||
|
||||
"db_tables": {
|
||||
"notes": {
|
||||
"columns": {
|
||||
"title": "text",
|
||||
"body": "text",
|
||||
"folder_id": "text",
|
||||
"creator_id": "text",
|
||||
"updated_at": "text",
|
||||
"pinned": "int",
|
||||
"archived": "int"
|
||||
},
|
||||
"indexes": [
|
||||
["folder_id"],
|
||||
["creator_id"],
|
||||
["pinned"],
|
||||
["updated_at"]
|
||||
]
|
||||
},
|
||||
"tags": {
|
||||
"columns": {
|
||||
"note_id": "text",
|
||||
"tag": "text"
|
||||
},
|
||||
"indexes": [
|
||||
["note_id"],
|
||||
["tag"]
|
||||
]
|
||||
},
|
||||
"links": {
|
||||
"columns": {
|
||||
"source_id": "text",
|
||||
"target_id": "text",
|
||||
"link_text": "text"
|
||||
},
|
||||
"indexes": [
|
||||
["source_id"],
|
||||
["target_id"]
|
||||
]
|
||||
},
|
||||
"folders": {
|
||||
"columns": {
|
||||
"name": "text",
|
||||
"parent_id": "text",
|
||||
"creator_id": "text",
|
||||
"sort_order": "int"
|
||||
},
|
||||
"indexes": [
|
||||
["parent_id"],
|
||||
["creator_id"]
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
"settings": {
|
||||
"default_view": {
|
||||
"type": "string",
|
||||
"label": "Default View",
|
||||
"description": "Default note list view: recent or pinned",
|
||||
"default": "recent"
|
||||
},
|
||||
"editor_mode": {
|
||||
"type": "string",
|
||||
"label": "Default Editor Mode",
|
||||
"description": "How notes open: rendered (read-only preview), edit (CodeMirror editor), or split (side-by-side)",
|
||||
"default": "rendered"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,668 +0,0 @@
|
||||
# Notes — Starlark Backend (v0.4.0)
|
||||
#
|
||||
# Markdown notes surface using ext_data.
|
||||
#
|
||||
# Entry points:
|
||||
# on_request(req) → surface API routes
|
||||
#
|
||||
# Modules: db, json, settings
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Helpers
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _resp(status, data):
|
||||
return {"status": status, "body": json.encode(data), "headers": {"Content-Type": "application/json"}}
|
||||
|
||||
def _str(v):
|
||||
if v == None:
|
||||
return ""
|
||||
return str(v)
|
||||
|
||||
def _int(v):
|
||||
if v == None:
|
||||
return 0
|
||||
s = str(v)
|
||||
if not s:
|
||||
return 0
|
||||
return int(s)
|
||||
|
||||
def _snippet(body, max_len = 120):
|
||||
"""First max_len chars of body, stripped of leading #/whitespace."""
|
||||
if not body:
|
||||
return ""
|
||||
line = body.split("\n")[0]
|
||||
# strip leading markdown heading markers
|
||||
line = line.lstrip("#").strip()
|
||||
if len(line) > max_len:
|
||||
return line[:max_len]
|
||||
return line
|
||||
|
||||
def _tags_for_notes(note_ids):
|
||||
"""Batch-fetch tags for a list of note IDs. Returns {note_id: [tag, ...]}."""
|
||||
if not note_ids:
|
||||
return {}
|
||||
all_tags = db.query("tags", limit=5000)
|
||||
result = {}
|
||||
for t in (all_tags or []):
|
||||
nid = _str(t.get("note_id", ""))
|
||||
tag = _str(t.get("tag", ""))
|
||||
if nid and tag:
|
||||
if nid not in result:
|
||||
result[nid] = []
|
||||
result[nid].append(tag)
|
||||
return result
|
||||
|
||||
|
||||
def _extract_wikilinks(text):
|
||||
"""Parse [[...]] patterns from text. Returns deduplicated list of link_text strings."""
|
||||
links = []
|
||||
seen = {}
|
||||
parts = text.split("[[")
|
||||
# parts[0] is before any wikilink; parts[1:] each start after "[["
|
||||
for i in range(1, len(parts)):
|
||||
chunk = parts[i]
|
||||
end = chunk.find("]]")
|
||||
if end > 0:
|
||||
link_text = chunk[:end].strip()
|
||||
lt_lower = link_text.lower()
|
||||
if link_text and lt_lower not in seen:
|
||||
seen[lt_lower] = True
|
||||
links.append(link_text)
|
||||
return links
|
||||
|
||||
|
||||
def _sync_links(source_id, body):
|
||||
"""Extract wikilinks from body, resolve to note IDs, replace link rows."""
|
||||
raw_links = _extract_wikilinks(_str(body))
|
||||
if not raw_links and not db.query("links", filters={"source_id": source_id}, limit=1):
|
||||
return # nothing to do
|
||||
|
||||
# resolve titles to note IDs (case-insensitive)
|
||||
all_notes = db.query("notes", filters={"archived": 0}, limit=5000)
|
||||
title_map = {}
|
||||
for n in (all_notes or []):
|
||||
t = _str(n.get("title", "")).lower().strip()
|
||||
if t:
|
||||
title_map[t] = n.get("id", "")
|
||||
|
||||
# delete existing links for this source
|
||||
old_links = db.query("links", filters={"source_id": source_id}, limit=500)
|
||||
for link in (old_links or []):
|
||||
db.delete("links", link["id"])
|
||||
|
||||
# insert new links
|
||||
for lt in raw_links:
|
||||
target_id = title_map.get(lt.lower().strip(), "")
|
||||
# skip self-links
|
||||
if target_id == source_id:
|
||||
target_id = ""
|
||||
db.insert("links", {
|
||||
"source_id": source_id,
|
||||
"target_id": target_id,
|
||||
"link_text": lt,
|
||||
})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Surface API routes (/s/notes/api/*)
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def on_request(req):
|
||||
path = req["path"]
|
||||
method = req["method"]
|
||||
|
||||
# GET /notes — list
|
||||
if method == "GET" and path == "/notes":
|
||||
return _list_notes(req)
|
||||
|
||||
# POST /notes — create
|
||||
if method == "POST" and path == "/notes":
|
||||
return _create_note(req)
|
||||
|
||||
# GET /stats
|
||||
if method == "GET" and path == "/stats":
|
||||
return _get_stats()
|
||||
|
||||
# GET /search
|
||||
if method == "GET" and path == "/search":
|
||||
return _search_notes(req)
|
||||
|
||||
# POST /notes/move (must be before /notes/:id catch-all)
|
||||
if method == "POST" and path == "/notes/move":
|
||||
return _move_note(req)
|
||||
|
||||
# GET /graph — full graph data for visualization
|
||||
if method == "GET" and path == "/graph":
|
||||
return _get_graph()
|
||||
|
||||
# ── Link routes ──────────────────────────
|
||||
# GET /links/:note_id — outgoing links
|
||||
if method == "GET" and path.startswith("/links/"):
|
||||
return _get_links(path[len("/links/"):])
|
||||
|
||||
# GET /backlinks/:note_id — incoming links
|
||||
if method == "GET" and path.startswith("/backlinks/"):
|
||||
return _get_backlinks(path[len("/backlinks/"):])
|
||||
|
||||
# ── Tag routes ────────────────────────────
|
||||
# GET /tags — all unique tags
|
||||
if method == "GET" and path == "/tags":
|
||||
return _list_tags()
|
||||
|
||||
# GET /tags/:note_id
|
||||
if method == "GET" and path.startswith("/tags/"):
|
||||
return _get_note_tags(path[len("/tags/"):])
|
||||
|
||||
# PUT /tags/:note_id
|
||||
if method == "PUT" and path.startswith("/tags/"):
|
||||
return _set_note_tags(path[len("/tags/"):], req)
|
||||
|
||||
# ── Note routes ───────────────────────────
|
||||
# GET /notes/:id
|
||||
if method == "GET" and path.startswith("/notes/"):
|
||||
return _get_note(path[len("/notes/"):])
|
||||
|
||||
# PUT /notes/:id
|
||||
if method == "PUT" and path.startswith("/notes/"):
|
||||
return _update_note(path[len("/notes/"):], req)
|
||||
|
||||
# DELETE /notes/:id
|
||||
if method == "DELETE" and path.startswith("/notes/"):
|
||||
return _delete_note(path[len("/notes/"):], req)
|
||||
|
||||
# ── Folder routes ─────────────────────────
|
||||
# GET /folders
|
||||
if method == "GET" and path == "/folders":
|
||||
return _list_folders()
|
||||
|
||||
# POST /folders
|
||||
if method == "POST" and path == "/folders":
|
||||
return _create_folder(req)
|
||||
|
||||
# PUT /folders/:id
|
||||
if method == "PUT" and path.startswith("/folders/"):
|
||||
return _update_folder(path[len("/folders/"):], req)
|
||||
|
||||
# DELETE /folders/:id
|
||||
if method == "DELETE" and path.startswith("/folders/"):
|
||||
return _delete_folder(path[len("/folders/"):])
|
||||
|
||||
return _resp(404, {"error": "not found"})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Notes CRUD
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _list_notes(req):
|
||||
q = req.get("query", {})
|
||||
filters = {}
|
||||
|
||||
folder = _str(q.get("folder_id", ""))
|
||||
if folder:
|
||||
filters["folder_id"] = folder
|
||||
|
||||
pinned = _str(q.get("pinned", ""))
|
||||
if pinned:
|
||||
filters["pinned"] = int(pinned)
|
||||
|
||||
archived = _str(q.get("archived", ""))
|
||||
if archived:
|
||||
filters["archived"] = int(archived)
|
||||
else:
|
||||
# default: exclude archived
|
||||
filters["archived"] = 0
|
||||
|
||||
creator = _str(q.get("creator_id", ""))
|
||||
if creator:
|
||||
filters["creator_id"] = creator
|
||||
|
||||
order = _str(q.get("order", "")) or "updated_at"
|
||||
limit_str = _str(q.get("limit", ""))
|
||||
limit = int(limit_str) if limit_str else 200
|
||||
|
||||
rows = db.query("notes", filters=filters, order=order, limit=limit)
|
||||
|
||||
# batch-fetch tags for all notes
|
||||
note_ids = []
|
||||
for r in (rows or []):
|
||||
note_ids.append(r.get("id", ""))
|
||||
tags_map = _tags_for_notes(note_ids)
|
||||
|
||||
# lightweight projection: strip body for list view
|
||||
items = []
|
||||
for r in (rows or []):
|
||||
nid = r.get("id", "")
|
||||
items.append({
|
||||
"id": nid,
|
||||
"title": r.get("title", ""),
|
||||
"snippet": _snippet(r.get("body", "")),
|
||||
"folder_id": r.get("folder_id", ""),
|
||||
"creator_id": r.get("creator_id", ""),
|
||||
"updated_at": r.get("updated_at", ""),
|
||||
"pinned": _int(r.get("pinned", 0)),
|
||||
"archived": _int(r.get("archived", 0)),
|
||||
"created_at": r.get("created_at", ""),
|
||||
"tags": tags_map.get(nid, []),
|
||||
})
|
||||
return _resp(200, {"data": items})
|
||||
|
||||
|
||||
def _create_note(req):
|
||||
body = json.decode(req.get("body", "{}"))
|
||||
user_id = req.get("user_id", "")
|
||||
|
||||
title = _str(body.get("title", ""))
|
||||
if not title:
|
||||
title = "Untitled"
|
||||
|
||||
body_text = _str(body.get("body", ""))
|
||||
row = db.insert("notes", {
|
||||
"title": title,
|
||||
"body": body_text,
|
||||
"folder_id": _str(body.get("folder_id", "")),
|
||||
"creator_id": user_id,
|
||||
"updated_at": "",
|
||||
"pinned": _int(body.get("pinned", 0)),
|
||||
"archived": 0,
|
||||
})
|
||||
_sync_links(row["id"], body_text)
|
||||
return _resp(201, row)
|
||||
|
||||
|
||||
def _get_note(note_id):
|
||||
rows = db.query("notes", filters={"id": note_id}, limit=1)
|
||||
if not rows:
|
||||
return _resp(404, {"error": "note not found"})
|
||||
note = rows[0]
|
||||
# include tags
|
||||
tag_rows = db.query("tags", filters={"note_id": note_id}, limit=100)
|
||||
tags = []
|
||||
for t in (tag_rows or []):
|
||||
tag = _str(t.get("tag", ""))
|
||||
if tag:
|
||||
tags.append(tag)
|
||||
note["tags"] = tags
|
||||
return _resp(200, note)
|
||||
|
||||
|
||||
def _update_note(note_id, req):
|
||||
body = json.decode(req.get("body", "{}"))
|
||||
|
||||
existing = db.query("notes", filters={"id": note_id}, limit=1)
|
||||
if not existing:
|
||||
return _resp(404, {"error": "note not found"})
|
||||
|
||||
updates = {}
|
||||
for key in ["title", "body", "folder_id"]:
|
||||
if key in body:
|
||||
updates[key] = _str(body[key])
|
||||
|
||||
if "pinned" in body:
|
||||
updates["pinned"] = _int(body["pinned"])
|
||||
if "archived" in body:
|
||||
updates["archived"] = _int(body["archived"])
|
||||
|
||||
# touch updated_at
|
||||
updates["updated_at"] = "now"
|
||||
|
||||
ok = db.update("notes", note_id, updates)
|
||||
if not ok:
|
||||
return _resp(500, {"error": "update failed"})
|
||||
|
||||
# sync wikilinks when body changes
|
||||
if "body" in body:
|
||||
_sync_links(note_id, _str(body["body"]))
|
||||
|
||||
rows = db.query("notes", filters={"id": note_id}, limit=1)
|
||||
return _resp(200, rows[0] if rows else {})
|
||||
|
||||
|
||||
def _delete_note(note_id, req):
|
||||
q = req.get("query", {})
|
||||
hard = _str(q.get("hard", ""))
|
||||
|
||||
if hard == "1":
|
||||
# cascade-delete tags for this note
|
||||
note_tags = db.query("tags", filters={"note_id": note_id}, limit=100)
|
||||
for t in (note_tags or []):
|
||||
db.delete("tags", t["id"])
|
||||
# cascade-delete outgoing links FROM this note
|
||||
out_links = db.query("links", filters={"source_id": note_id}, limit=500)
|
||||
for link in (out_links or []):
|
||||
db.delete("links", link["id"])
|
||||
# cascade-delete incoming backlinks TO this note
|
||||
in_links = db.query("links", filters={"target_id": note_id}, limit=500)
|
||||
for link in (in_links or []):
|
||||
db.delete("links", link["id"])
|
||||
ok = db.delete("notes", note_id)
|
||||
if not ok:
|
||||
return _resp(404, {"error": "note not found"})
|
||||
return _resp(200, {"deleted": True})
|
||||
|
||||
# soft delete: archive
|
||||
ok = db.update("notes", note_id, {"archived": 1, "updated_at": "now"})
|
||||
if not ok:
|
||||
return _resp(404, {"error": "note not found"})
|
||||
return _resp(200, {"archived": True})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Move note between folders
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _move_note(req):
|
||||
body = json.decode(req.get("body", "{}"))
|
||||
note_id = _str(body.get("note_id", ""))
|
||||
folder_id = _str(body.get("folder_id", ""))
|
||||
if not note_id:
|
||||
return _resp(400, {"error": "note_id required"})
|
||||
existing = db.query("notes", filters={"id": note_id}, limit=1)
|
||||
if not existing:
|
||||
return _resp(404, {"error": "note not found"})
|
||||
ok = db.update("notes", note_id, {"folder_id": folder_id, "updated_at": "now"})
|
||||
if not ok:
|
||||
return _resp(500, {"error": "move failed"})
|
||||
rows = db.query("notes", filters={"id": note_id}, limit=1)
|
||||
return _resp(200, rows[0] if rows else {})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Tags CRUD
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _list_tags():
|
||||
"""Return sorted list of all unique tag strings."""
|
||||
all_tags = db.query("tags", limit=5000)
|
||||
seen = {}
|
||||
for t in (all_tags or []):
|
||||
tag = _str(t.get("tag", "")).strip()
|
||||
if tag:
|
||||
seen[tag] = True
|
||||
tags = sorted(seen.keys())
|
||||
return _resp(200, {"data": tags})
|
||||
|
||||
|
||||
def _get_note_tags(note_id):
|
||||
"""Return tags for a specific note."""
|
||||
rows = db.query("tags", filters={"note_id": note_id}, limit=100)
|
||||
tags = []
|
||||
for t in (rows or []):
|
||||
tag = _str(t.get("tag", ""))
|
||||
if tag:
|
||||
tags.append(tag)
|
||||
return _resp(200, {"data": tags})
|
||||
|
||||
|
||||
def _set_note_tags(note_id, req):
|
||||
"""Replace all tags for a note (delete + reinsert)."""
|
||||
body = json.decode(req.get("body", "{}"))
|
||||
new_tags_raw = body.get("tags", [])
|
||||
|
||||
# validate note exists
|
||||
existing = db.query("notes", filters={"id": note_id}, limit=1)
|
||||
if not existing:
|
||||
return _resp(404, {"error": "note not found"})
|
||||
|
||||
# normalize: lowercase, strip, deduplicate, skip empty
|
||||
seen = {}
|
||||
clean_tags = []
|
||||
for raw in new_tags_raw:
|
||||
tag = _str(raw).strip().lower()
|
||||
if tag and tag not in seen:
|
||||
seen[tag] = True
|
||||
clean_tags.append(tag)
|
||||
|
||||
# delete all existing tags for this note
|
||||
old_tags = db.query("tags", filters={"note_id": note_id}, limit=100)
|
||||
for t in (old_tags or []):
|
||||
db.delete("tags", t["id"])
|
||||
|
||||
# insert new tags
|
||||
for tag in clean_tags:
|
||||
db.insert("tags", {"note_id": note_id, "tag": tag})
|
||||
|
||||
return _resp(200, {"data": clean_tags})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Links (backlinks + wikilinks)
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _get_links(note_id):
|
||||
"""Return outgoing links for a note."""
|
||||
rows = db.query("links", filters={"source_id": note_id}, limit=500)
|
||||
items = []
|
||||
for r in (rows or []):
|
||||
items.append({
|
||||
"source_id": r.get("source_id", ""),
|
||||
"target_id": r.get("target_id", ""),
|
||||
"link_text": r.get("link_text", ""),
|
||||
})
|
||||
return _resp(200, {"data": items})
|
||||
|
||||
|
||||
def _get_backlinks(note_id):
|
||||
"""Return notes that link TO this note (backlinks), enriched with source titles."""
|
||||
rows = db.query("links", filters={"target_id": note_id}, limit=500)
|
||||
if not rows:
|
||||
return _resp(200, {"data": []})
|
||||
|
||||
# batch-fetch source note titles
|
||||
all_notes = db.query("notes", filters={"archived": 0}, limit=5000)
|
||||
note_map = {}
|
||||
for n in (all_notes or []):
|
||||
note_map[n.get("id", "")] = n.get("title", "Untitled")
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
sid = r.get("source_id", "")
|
||||
items.append({
|
||||
"source_id": sid,
|
||||
"source_title": note_map.get(sid, "Unknown"),
|
||||
"link_text": r.get("link_text", ""),
|
||||
})
|
||||
return _resp(200, {"data": items})
|
||||
|
||||
|
||||
def _get_graph():
|
||||
"""Return all non-archived notes as nodes and resolved links as edges."""
|
||||
all_notes = db.query("notes", filters={"archived": 0}, limit=5000)
|
||||
notes = all_notes or []
|
||||
|
||||
# batch-fetch tags
|
||||
note_ids = [n.get("id", "") for n in notes]
|
||||
tags_map = _tags_for_notes(note_ids)
|
||||
|
||||
# build nodes + id set for edge filtering
|
||||
nodes = []
|
||||
note_id_set = {}
|
||||
for n in notes:
|
||||
nid = n.get("id", "")
|
||||
note_id_set[nid] = True
|
||||
nodes.append({
|
||||
"id": nid,
|
||||
"title": n.get("title", "Untitled"),
|
||||
"folder_id": _str(n.get("folder_id", "")),
|
||||
"tags": tags_map.get(nid, []),
|
||||
})
|
||||
|
||||
# fetch links, keep only resolved (both endpoints exist)
|
||||
all_links = db.query("links", limit=10000)
|
||||
edges = []
|
||||
for link in (all_links or []):
|
||||
src = _str(link.get("source_id", ""))
|
||||
tgt = _str(link.get("target_id", ""))
|
||||
if src and tgt and src in note_id_set and tgt in note_id_set:
|
||||
edges.append({
|
||||
"source": src,
|
||||
"target": tgt,
|
||||
"text": _str(link.get("link_text", "")),
|
||||
})
|
||||
|
||||
return _resp(200, {"nodes": nodes, "edges": edges})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Folder CRUD
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _list_folders():
|
||||
rows = db.query("folders", order="sort_order", limit=500)
|
||||
items = []
|
||||
for r in (rows or []):
|
||||
items.append({
|
||||
"id": r.get("id", ""),
|
||||
"name": r.get("name", ""),
|
||||
"parent_id": r.get("parent_id", ""),
|
||||
"sort_order": _int(r.get("sort_order", 0)),
|
||||
"creator_id": r.get("creator_id", ""),
|
||||
"created_at": r.get("created_at", ""),
|
||||
})
|
||||
return _resp(200, {"data": items})
|
||||
|
||||
|
||||
def _create_folder(req):
|
||||
body = json.decode(req.get("body", "{}"))
|
||||
user_id = req.get("user_id", "")
|
||||
name = _str(body.get("name", ""))
|
||||
if not name:
|
||||
return _resp(400, {"error": "name required"})
|
||||
row = db.insert("folders", {
|
||||
"name": name,
|
||||
"parent_id": _str(body.get("parent_id", "")),
|
||||
"creator_id": user_id,
|
||||
"sort_order": _int(body.get("sort_order", 0)),
|
||||
})
|
||||
return _resp(201, row)
|
||||
|
||||
|
||||
def _update_folder(folder_id, req):
|
||||
body = json.decode(req.get("body", "{}"))
|
||||
existing = db.query("folders", filters={"id": folder_id}, limit=1)
|
||||
if not existing:
|
||||
return _resp(404, {"error": "folder not found"})
|
||||
updates = {}
|
||||
for key in ["name", "parent_id"]:
|
||||
if key in body:
|
||||
updates[key] = _str(body[key])
|
||||
if "sort_order" in body:
|
||||
updates["sort_order"] = _int(body["sort_order"])
|
||||
if not updates:
|
||||
return _resp(200, existing[0])
|
||||
ok = db.update("folders", folder_id, updates)
|
||||
if not ok:
|
||||
return _resp(500, {"error": "update failed"})
|
||||
rows = db.query("folders", filters={"id": folder_id}, limit=1)
|
||||
return _resp(200, rows[0] if rows else {})
|
||||
|
||||
|
||||
def _delete_folder(folder_id):
|
||||
existing = db.query("folders", filters={"id": folder_id}, limit=1)
|
||||
if not existing:
|
||||
return _resp(404, {"error": "folder not found"})
|
||||
parent_id = _str(existing[0].get("parent_id", ""))
|
||||
|
||||
# reparent child folders to deleted folder's parent
|
||||
children = db.query("folders", filters={"parent_id": folder_id}, limit=500)
|
||||
for child in (children or []):
|
||||
db.update("folders", child["id"], {"parent_id": parent_id})
|
||||
|
||||
# orphan notes in this folder (move to unfiled)
|
||||
notes_in_folder = db.query("notes", filters={"folder_id": folder_id}, limit=1000)
|
||||
for n in (notes_in_folder or []):
|
||||
db.update("notes", n["id"], {"folder_id": ""})
|
||||
|
||||
ok = db.delete("folders", folder_id)
|
||||
if not ok:
|
||||
return _resp(500, {"error": "delete failed"})
|
||||
return _resp(200, {"deleted": True, "orphaned_notes": len(notes_in_folder or [])})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Search
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _search_notes(req):
|
||||
q = req.get("query", {})
|
||||
term = _str(q.get("q", ""))
|
||||
if not term:
|
||||
return _resp(400, {"error": "q parameter required"})
|
||||
|
||||
# fetch all non-archived notes and filter in Starlark
|
||||
# (ext_data db.query doesn't support LIKE — filter client-side)
|
||||
rows = db.query("notes", filters={"archived": 0}, order="updated_at", limit=1000)
|
||||
term_lower = term.lower()
|
||||
|
||||
# batch-fetch tags for tag-based matching
|
||||
tags_map = _tags_for_notes([r.get("id", "") for r in (rows or [])])
|
||||
|
||||
matches = []
|
||||
for r in (rows or []):
|
||||
title = _str(r.get("title", "")).lower()
|
||||
body = _str(r.get("body", "")).lower()
|
||||
nid = r.get("id", "")
|
||||
|
||||
# check title and body
|
||||
found = term_lower in title or term_lower in body
|
||||
|
||||
# check tags
|
||||
if not found:
|
||||
note_tags = tags_map.get(nid, [])
|
||||
for tg in note_tags:
|
||||
if term_lower in tg.lower():
|
||||
found = True
|
||||
|
||||
if found:
|
||||
matches.append({
|
||||
"id": nid,
|
||||
"title": r.get("title", ""),
|
||||
"snippet": _snippet(r.get("body", "")),
|
||||
"folder_id": r.get("folder_id", ""),
|
||||
"updated_at": r.get("updated_at", ""),
|
||||
"pinned": _int(r.get("pinned", 0)),
|
||||
"created_at": r.get("created_at", ""),
|
||||
"tags": tags_map.get(nid, []),
|
||||
})
|
||||
return _resp(200, {"data": matches})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Stats
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _get_stats():
|
||||
all_notes = db.query("notes", limit=10000)
|
||||
notes = all_notes or []
|
||||
total = 0
|
||||
pinned = 0
|
||||
archived = 0
|
||||
unfiled = 0
|
||||
for n in notes:
|
||||
if _int(n.get("archived", 0)) == 1:
|
||||
archived = archived + 1
|
||||
else:
|
||||
total = total + 1
|
||||
if _int(n.get("pinned", 0)) == 1:
|
||||
pinned = pinned + 1
|
||||
if not _str(n.get("folder_id", "")):
|
||||
unfiled = unfiled + 1
|
||||
all_folders = db.query("folders", limit=10000)
|
||||
folder_count = len(all_folders or [])
|
||||
|
||||
# count unique tags
|
||||
all_tags = db.query("tags", limit=10000)
|
||||
tag_seen = {}
|
||||
for t in (all_tags or []):
|
||||
tag = _str(t.get("tag", ""))
|
||||
if tag:
|
||||
tag_seen[tag] = True
|
||||
tag_count = len(tag_seen)
|
||||
|
||||
# count links
|
||||
all_links = db.query("links", limit=10000)
|
||||
link_count = len(all_links or [])
|
||||
|
||||
return _resp(200, {"total": total, "pinned": pinned, "archived": archived, "unfiled": unfiled, "folders": folder_count, "tags": tag_count, "links": link_count})
|
||||
@@ -275,7 +275,7 @@
|
||||
}
|
||||
|
||||
async function handleDelete(item) {
|
||||
if (!await sw.confirm('Delete schedule "' + item.name + '"?', { destructive: true })) return;
|
||||
if (!confirm('Delete schedule "' + item.name + '"?')) return;
|
||||
try {
|
||||
await apiDelete(item.id);
|
||||
sw.toast('Schedule deleted', 'success');
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
|
||||
await T.test('admin', 'surfaces', 'sw.api.admin.surfaces.list()', {
|
||||
sdk: function () { return sw.api.admin.surfaces.list(); },
|
||||
raw: { method: 'GET', path: '/admin/packages' },
|
||||
raw: { method: 'GET', path: '/admin/surfaces' },
|
||||
validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); }
|
||||
});
|
||||
|
||||
@@ -149,39 +149,5 @@
|
||||
raw: { method: 'GET', path: '/admin/projects' },
|
||||
validate: function (r) { T.assert(Array.isArray(r) || (r && Array.isArray(r.data)), 'expected array'); }
|
||||
});
|
||||
|
||||
// ── Metrics (v0.6.4+) ──
|
||||
|
||||
await T.test('admin', 'metrics', 'GET /admin/metrics (raw)', {
|
||||
sdk: function () { return sw.api.get('/admin/metrics'); },
|
||||
raw: { method: 'GET', path: '/admin/metrics' },
|
||||
validate: function (r) {
|
||||
T.assert(typeof r === 'object', 'expected object');
|
||||
T.assertHasKey(r, 'runtime', 'metrics');
|
||||
T.assertHasKey(r, 'database', 'metrics');
|
||||
}
|
||||
});
|
||||
|
||||
// ── Cluster (v0.6.0+) ──
|
||||
|
||||
await T.test('admin', 'cluster', 'GET /admin/cluster (raw)', {
|
||||
sdk: function () { return sw.api.get('/admin/cluster'); },
|
||||
raw: { method: 'GET', path: '/admin/cluster' },
|
||||
validate: function (r) {
|
||||
var arr = T.unwrapList(r);
|
||||
T.assert(arr.length >= 0, 'expected cluster node list');
|
||||
}
|
||||
});
|
||||
|
||||
// ── Backups (v0.6.1+) ──
|
||||
|
||||
await T.test('admin', 'backups', 'GET /admin/backups (raw)', {
|
||||
sdk: function () { return sw.api.get('/admin/backups'); },
|
||||
raw: { method: 'GET', path: '/admin/backups' },
|
||||
validate: function (r) {
|
||||
var arr = T.unwrapList(r);
|
||||
T.assert(arr.length >= 0, 'expected backup list');
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"id": "sdk-test-runner",
|
||||
"icon": "🔬",
|
||||
"type": "surface",
|
||||
"title": "SDK Test Runner",
|
||||
"route": "/s/sdk-test-runner",
|
||||
"auth": "authenticated",
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
if (!await sw.confirm('Delete this task?', { destructive: true })) return;
|
||||
if (!confirm('Delete this task?')) return;
|
||||
await api.del('/items/' + id);
|
||||
onRefresh();
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# Webhook Notifier
|
||||
|
||||
Outbound webhook delivery with connection credentials and delivery logging.
|
||||
|
||||
## Features
|
||||
|
||||
- **Starlark `http.post`** — outbound HTTP with custom headers
|
||||
- **`connections.get`** — fallback to stored webhook connection credentials
|
||||
- **Delivery logging** — records URL, status code, event to `webhook_log` ext_data table
|
||||
- **Result review** — delivery status shown in final review stage
|
||||
|
||||
## Stage Flow
|
||||
|
||||
```
|
||||
[configure] → [fire-webhook] → [result]
|
||||
team automated review
|
||||
(http.post)
|
||||
```
|
||||
|
||||
## Starlark Hook
|
||||
|
||||
- **`on_fire(ctx)`** — Posts JSON payload to webhook URL, falls back to `connections.get("webhook")`, logs delivery to `webhook_log`
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
curl -X POST $BASE/api/v1/admin/packages/install \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-F "file=@packages/webhook-notifier"
|
||||
```
|
||||
@@ -1,79 +0,0 @@
|
||||
{
|
||||
"id": "webhook-notifier",
|
||||
"title": "Webhook Notifier",
|
||||
"type": "workflow",
|
||||
"version": "0.1.0",
|
||||
"tier": "starlark",
|
||||
"icon": "🔔",
|
||||
"author": "Switchboard Core",
|
||||
"description": "Outbound webhook delivery with connection credential support and delivery logging.",
|
||||
"permissions": ["api.http", "connections.read", "db.write"],
|
||||
|
||||
"db_tables": {
|
||||
"webhook_log": {
|
||||
"columns": {
|
||||
"url": "text",
|
||||
"status_code": "text",
|
||||
"event": "text",
|
||||
"instance_id": "text"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"workflow_definition": {
|
||||
"name": "Webhook Notifier",
|
||||
"slug": "webhook-notifier",
|
||||
"entry_mode": "team_only",
|
||||
"stages": [
|
||||
{
|
||||
"name": "configure",
|
||||
"ordinal": 0,
|
||||
"stage_mode": "form",
|
||||
"audience": "team",
|
||||
"stage_type": "simple",
|
||||
"auto_transition": false,
|
||||
"form_template": {
|
||||
"fieldsets": [
|
||||
{
|
||||
"label": "Webhook Configuration",
|
||||
"fields": [
|
||||
{ "key": "webhook_url", "label": "Webhook URL", "type": "text", "required": true },
|
||||
{ "key": "event_name", "label": "Event Name", "type": "text", "required": true },
|
||||
{ "key": "payload", "label": "Custom Payload (JSON)", "type": "textarea" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "fire-webhook",
|
||||
"ordinal": 1,
|
||||
"stage_mode": "automated",
|
||||
"audience": "system",
|
||||
"stage_type": "automated",
|
||||
"auto_transition": true,
|
||||
"starlark_hook": "webhook-notifier:on_fire"
|
||||
},
|
||||
{
|
||||
"name": "result",
|
||||
"ordinal": 2,
|
||||
"stage_mode": "review",
|
||||
"audience": "team",
|
||||
"stage_type": "simple",
|
||||
"auto_transition": false,
|
||||
"form_template": {
|
||||
"fieldsets": [
|
||||
{
|
||||
"label": "Delivery Result",
|
||||
"fields": [
|
||||
{ "key": "delivery_status", "label": "Status", "type": "text" },
|
||||
{ "key": "status_code", "label": "HTTP Status Code", "type": "text" },
|
||||
{ "key": "response_body", "label": "Response", "type": "textarea" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
def on_fire(ctx):
|
||||
"""Fire outbound webhook with optional connection credentials."""
|
||||
data = ctx["stage_data"]
|
||||
url = data.get("webhook_url", "")
|
||||
|
||||
# Fallback to configured connection
|
||||
if not url:
|
||||
conn = connections.get("webhook")
|
||||
if conn:
|
||||
url = conn.get("url", "")
|
||||
|
||||
if not url:
|
||||
return {"error": "No webhook URL configured"}
|
||||
|
||||
payload = json.encode({
|
||||
"event": data.get("event_name", "workflow.notify"),
|
||||
"instance_id": ctx["instance_id"],
|
||||
"workflow_id": ctx["workflow_id"],
|
||||
"data": data,
|
||||
})
|
||||
|
||||
resp = http.post(url, body=payload, headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Switchboard-Event": data.get("event_name", "workflow.notify"),
|
||||
})
|
||||
|
||||
# Log the delivery
|
||||
db.insert("webhook_log", {
|
||||
"url": url,
|
||||
"status_code": str(resp["status"]),
|
||||
"event": data.get("event_name", ""),
|
||||
"instance_id": ctx["instance_id"],
|
||||
})
|
||||
|
||||
status = "delivered" if int(resp["status"]) < 400 else "failed"
|
||||
|
||||
return {"advance": True, "data": {
|
||||
"delivery_status": status,
|
||||
"status_code": str(resp["status"]),
|
||||
"response_body": resp["body"][:500],
|
||||
}}
|
||||
@@ -1,41 +0,0 @@
|
||||
# Workflow Chat Integration
|
||||
|
||||
Creates scoped chat conversations when workflow stages require team collaboration.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install the `workflow-chat` and `chat-core` packages.
|
||||
2. In your workflow stage with `audience: team`, set `stage_config`:
|
||||
|
||||
```json
|
||||
{
|
||||
"on_advance": {
|
||||
"package_id": "workflow-chat",
|
||||
"entry_point": "on_advance"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. When the workflow advances to that stage, ensure `stage_data` includes:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Bug Triage Discussion",
|
||||
"team_members": [
|
||||
{"id": "user-1", "display_name": "Alice"},
|
||||
{"id": "user-2", "display_name": "Bob"}
|
||||
],
|
||||
"creator_id": "user-1",
|
||||
"creator_display_name": "Alice"
|
||||
}
|
||||
```
|
||||
|
||||
The hook will:
|
||||
- Create a group conversation titled `"Bug Triage Discussion [abcd1234]"` (suffixed with instance ID)
|
||||
- Add all team members as participants
|
||||
- Send a system message linking to the workflow instance
|
||||
- Enrich `stage_data` with the `conversation_id`
|
||||
|
||||
## Idempotency
|
||||
|
||||
If `stage_data` already contains a `conversation_id`, the hook returns `None` (no-op).
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"id": "workflow-chat",
|
||||
"title": "Workflow Chat",
|
||||
"type": "library",
|
||||
"tier": "starlark",
|
||||
"version": "0.1.0",
|
||||
"description": "Creates scoped chat conversations when workflow stages require team collaboration. Wire into stage_config as an on_advance hook.",
|
||||
"author": "switchboard",
|
||||
|
||||
"permissions": ["db.write", "realtime.publish"],
|
||||
|
||||
"depends": ["chat-core"],
|
||||
|
||||
"exports": ["on_advance"]
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
# Workflow Chat — Starlark Backend (v0.1.0)
|
||||
#
|
||||
# Library package that creates scoped chat conversations when
|
||||
# workflow stages require team collaboration.
|
||||
#
|
||||
# Usage: wire into stage_config as an on_advance hook:
|
||||
# {"on_advance": {"package_id": "workflow-chat", "entry_point": "on_advance"}}
|
||||
#
|
||||
# Expected stage_data keys:
|
||||
# title — conversation title (optional, defaults to "Workflow Discussion")
|
||||
# team_members — list of {id, display_name} dicts
|
||||
# creator_id — user ID of the workflow initiator
|
||||
# creator_display_name — display name of the workflow initiator
|
||||
#
|
||||
# Modules: db, json, realtime (via chat-core dependency)
|
||||
|
||||
chat = lib.require("chat-core")
|
||||
|
||||
|
||||
def _str(v):
|
||||
if v == None:
|
||||
return ""
|
||||
return str(v)
|
||||
|
||||
|
||||
def on_advance(ctx):
|
||||
"""Hook called when a workflow advances to a team-audience stage.
|
||||
|
||||
Creates a group conversation with all team members and sends
|
||||
a system message linking back to the workflow instance.
|
||||
|
||||
Args:
|
||||
ctx: dict with {instance_id, previous_stage, current_stage, stage_data}
|
||||
|
||||
Returns:
|
||||
dict with {stage_data} containing enriched data with conversation_id,
|
||||
or None if no team members are present.
|
||||
"""
|
||||
data = ctx.get("stage_data", {})
|
||||
if type(data) == "string":
|
||||
data = json.decode(data) if data else {}
|
||||
|
||||
instance_id = _str(ctx.get("instance_id", ""))
|
||||
members = data.get("team_members", [])
|
||||
creator_id = _str(data.get("creator_id", ""))
|
||||
creator_name = _str(data.get("creator_display_name", ""))
|
||||
title = _str(data.get("title", "")) or "Workflow Discussion"
|
||||
|
||||
# Skip if no team members to add
|
||||
if not members:
|
||||
return None
|
||||
|
||||
# Check if conversation already exists for this instance (idempotency)
|
||||
existing_cid = _str(data.get("conversation_id", ""))
|
||||
if existing_cid:
|
||||
return None
|
||||
|
||||
# Build participants list
|
||||
participants = []
|
||||
for m in members:
|
||||
mid = _str(m.get("id", ""))
|
||||
if mid and mid != creator_id:
|
||||
participants.append({
|
||||
"id": mid,
|
||||
"display_name": _str(m.get("display_name", "")),
|
||||
})
|
||||
|
||||
# Create conversation scoped to this workflow instance
|
||||
conv = chat.create(
|
||||
title=title + " [" + instance_id[:8] + "]",
|
||||
type="group",
|
||||
participants=participants,
|
||||
creator_id=creator_id,
|
||||
creator_display_name=creator_name,
|
||||
)
|
||||
|
||||
cid = _str(conv.get("id", ""))
|
||||
|
||||
# Send system message linking to the workflow
|
||||
chat.send(cid, creator_id, "Conversation created for workflow instance " + instance_id, "system")
|
||||
|
||||
# Return enriched stage_data with conversation_id
|
||||
enriched = {}
|
||||
for k in data:
|
||||
enriched[k] = data[k]
|
||||
enriched["conversation_id"] = cid
|
||||
|
||||
return {"stage_data": enriched}
|
||||
@@ -1,27 +0,0 @@
|
||||
# Workflow Demo
|
||||
|
||||
Interactive demo surface for the example workflow packages.
|
||||
|
||||
## Features
|
||||
|
||||
- **Workflow cards** — one card per example workflow with description and feature badges
|
||||
- **Stage flow diagrams** — visual representation of each workflow's stage progression
|
||||
- **Starlark viewer** — collapsible code summary for Starlark-tier packages
|
||||
- **API curl examples** — collapsible section with copy-ready curl commands
|
||||
- **"Try It" buttons** — launch workflows directly (public link or team start)
|
||||
- **Install status** — shows which example workflows are currently installed
|
||||
|
||||
## Route
|
||||
|
||||
`/s/workflow-demo` — accessible to any authenticated user.
|
||||
|
||||
## Installation
|
||||
|
||||
Install alongside the example workflow packages. The demo surface detects which
|
||||
workflows are installed and shows their status accordingly.
|
||||
|
||||
```bash
|
||||
curl -X POST $BASE/api/v1/admin/packages/install \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-F "file=@packages/workflow-demo"
|
||||
```
|
||||
@@ -1,256 +0,0 @@
|
||||
/* ==========================================
|
||||
Workflow Demo Surface — Styles
|
||||
Uses the app's theme variables (--bg, --bg-surface, --text, etc.)
|
||||
defined in variables.css for automatic dark/light support.
|
||||
========================================== */
|
||||
|
||||
.surface-workflow-demo {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem 2rem;
|
||||
}
|
||||
|
||||
/* Topbar */
|
||||
.demo-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.demo-topbar h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Subtitle */
|
||||
.demo-subtitle {
|
||||
color: var(--text-2);
|
||||
margin: 0 0 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
.demo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.demo-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.demo-card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Title row */
|
||||
.card-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.card-title-row h2 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
color: var(--text);
|
||||
}
|
||||
.card-icon {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
.card-tier {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-browser { background: var(--success-dim); color: var(--success-light); }
|
||||
.badge-starlark { background: var(--accent-dim); color: var(--accent-light); }
|
||||
|
||||
/* Description */
|
||||
.card-desc {
|
||||
color: var(--text-2);
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.card-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.demo-card .badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.demo-card .badge-installed { background: var(--success-dim); color: var(--success-light); }
|
||||
.demo-card .badge-not-installed { background: var(--warning-dim); color: var(--warning-light); }
|
||||
.demo-card .badge-needs-publish { background: var(--danger-dim); color: var(--danger-light); }
|
||||
|
||||
/* Stage flow diagram */
|
||||
.stage-flow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-raised);
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.stage-node {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.6rem;
|
||||
min-width: 80px;
|
||||
text-align: center;
|
||||
background: var(--bg-surface);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.stage-node.stage-public {
|
||||
border-color: var(--success);
|
||||
background: var(--success-dim);
|
||||
}
|
||||
.stage-node.stage-system {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.stage-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.stage-meta {
|
||||
color: var(--text-3);
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
.stage-note {
|
||||
color: var(--warning-light);
|
||||
font-size: 0.6rem;
|
||||
font-style: italic;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.stage-arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-3);
|
||||
padding: 0 0.15rem;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
/* Collapsible sections */
|
||||
.card-collapsible {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.card-collapsible summary {
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
color: var(--text);
|
||||
}
|
||||
.card-collapsible summary:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.collapsible-content {
|
||||
padding: 0 0.75rem 0.75rem;
|
||||
}
|
||||
.collapsible-content pre {
|
||||
background: var(--bg-raised);
|
||||
color: var(--text);
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.4;
|
||||
margin: 0.25rem 0;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: auto;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.demo-card .btn {
|
||||
padding: 0.4rem 1rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
.demo-card .btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--text-on-color);
|
||||
}
|
||||
.demo-card .btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.demo-card .btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Public link row */
|
||||
.public-link-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.public-link-row input {
|
||||
flex: 1;
|
||||
font-size: 0.7rem;
|
||||
padding: 4px 8px;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
}
|
||||
.public-link-row .btn-copy {
|
||||
padding: 4px 10px;
|
||||
font-size: 0.7rem;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.public-link-row .btn-copy:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
// ==========================================
|
||||
// Switchboard Core — Workflow Demo Surface
|
||||
// ==========================================
|
||||
// Interactive walkthrough of the four example workflow packages.
|
||||
// Shows workflow cards with feature badges, stage flow diagrams,
|
||||
// Starlark code viewer, and API curl examples.
|
||||
// ==========================================
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const SURFACE_ID = 'workflow-demo';
|
||||
if (window.__SURFACE__ !== SURFACE_ID) return;
|
||||
|
||||
const base = window.__BASE__ || '';
|
||||
|
||||
// ── Workflow catalog ──────────────────────────
|
||||
|
||||
const WORKFLOWS = [
|
||||
{
|
||||
slug: 'bug-report-triage',
|
||||
title: 'Bug Report Triage',
|
||||
icon: '🐛',
|
||||
description: 'Public bug report submission with severity-based routing, team assignment, and SLA timers.',
|
||||
badges: ['Public Entry', 'Branch Rules', 'SLA Timer', 'Team Assignment'],
|
||||
tier: 'browser',
|
||||
stages: [
|
||||
{ name: 'submit', mode: 'form', audience: 'public' },
|
||||
{ name: 'classify', mode: 'form', audience: 'team', note: 'branch rules' },
|
||||
{ name: 'fix-critical', mode: 'form', audience: 'team', note: 'SLA 1h', branch: true },
|
||||
{ name: 'fix-normal', mode: 'form', audience: 'team', branch: true },
|
||||
{ name: 'verify', mode: 'review', audience: 'team' },
|
||||
],
|
||||
curl: [
|
||||
'# 1. Start public instance (no auth required)\ncurl -X POST $BASE/api/v1/public/workflows/$WF_ID/start \\\n -H "Content-Type: application/json" \\\n -d \'{"data": {"reporter_email": "user@example.com", "summary": "Login broken", "component": "ui", "severity": "critical", "steps": "1. Click login", "expected": "Login page", "actual": "500 error"}}\'',
|
||||
'# 2. Resume (check status)\ncurl $BASE/api/v1/public/workflows/resume/$TOKEN',
|
||||
'# 3. Team member claims classify assignment\ncurl -X POST $BASE/api/v1/teams/$TEAM/assignments/$ASSIGN_ID/claim \\\n -H "Authorization: Bearer $TOKEN"',
|
||||
'# 4. Advance classify (triggers branch rule)\ncurl -X POST $BASE/api/v1/teams/$TEAM/instances/$INST/advance \\\n -H "Authorization: Bearer $TOKEN" \\\n -H "Content-Type: application/json" \\\n -d \'{"data": {"severity": "critical", "notes": "Confirmed production issue"}}\'',
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'employee-onboarding',
|
||||
title: 'Employee Onboarding',
|
||||
icon: '🏢',
|
||||
description: 'Automated provisioning with Starlark hooks, manager signoff gate, and welcome notification.',
|
||||
badges: ['Starlark', 'Signoff Gate', 'Rejection Reroute', 'db.insert', 'notifications.send'],
|
||||
tier: 'starlark',
|
||||
stages: [
|
||||
{ name: 'new-hire-info', mode: 'form', audience: 'team' },
|
||||
{ name: 'provision-accounts', mode: 'automated', audience: 'system', note: 'Starlark' },
|
||||
{ name: 'manager-signoff', mode: 'review', audience: 'team', note: 'signoff gate' },
|
||||
{ name: 'it-setup', mode: 'form', audience: 'team' },
|
||||
{ name: 'welcome', mode: 'automated', audience: 'system', note: 'Starlark' },
|
||||
],
|
||||
starlark: 'on_provision(ctx): db.insert("provisions", ...), notifications.send(...)\non_welcome(ctx): db.insert("onboarding_log", ...), notifications.send(...)',
|
||||
curl: [
|
||||
'# 1. Start instance (team auth required)\ncurl -X POST $BASE/api/v1/teams/$TEAM/workflows/$WF_ID/start \\\n -H "Authorization: Bearer $TOKEN" \\\n -H "Content-Type: application/json" \\\n -d \'{"data": {"full_name": "Alice Smith", "department": "engineering", "needs_vpn": "true", "needs_admin": "false", "equipment": "developer workstation"}}\'',
|
||||
'# 2. After auto-provision, submit manager signoff\ncurl -X POST $BASE/api/v1/instances/$INST/signoffs \\\n -H "Authorization: Bearer $MANAGER_TOKEN" \\\n -H "Content-Type: application/json" \\\n -d \'{"action": "approve"}\'',
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'content-approval',
|
||||
title: 'Content Approval',
|
||||
icon: '📝',
|
||||
description: 'Multi-party review with quorum signoff (2 approvals) and revision cycle loop.',
|
||||
badges: ['Multi-party Signoff', 'Quorum', 'Rejection Reroute', 'Review Cycle'],
|
||||
tier: 'browser',
|
||||
stages: [
|
||||
{ name: 'draft', mode: 'form', audience: 'team' },
|
||||
{ name: 'review', mode: 'review', audience: 'team', note: '2 approvals' },
|
||||
{ name: 'revision', mode: 'form', audience: 'team', note: 'loops to review' },
|
||||
{ name: 'publish', mode: 'form', audience: 'team' },
|
||||
],
|
||||
curl: [
|
||||
'# 1. Submit draft\ncurl -X POST $BASE/api/v1/teams/$TEAM/workflows/$WF_ID/start \\\n -H "Authorization: Bearer $TOKEN" \\\n -H "Content-Type: application/json" \\\n -d \'{"data": {"title": "Q1 Update", "body": "...", "category": "blog"}}\'',
|
||||
'# 2. Reviewer A approves\ncurl -X POST $BASE/api/v1/instances/$INST/signoffs \\\n -H "Authorization: Bearer $REVIEWER_A" \\\n -d \'{"action": "approve"}\'',
|
||||
'# 3. Reviewer B rejects -> routes to revision\ncurl -X POST $BASE/api/v1/instances/$INST/signoffs \\\n -H "Authorization: Bearer $REVIEWER_B" \\\n -d \'{"action": "reject"}\'',
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'webhook-notifier',
|
||||
title: 'Webhook Notifier',
|
||||
icon: '🔔',
|
||||
description: 'Outbound HTTP delivery via Starlark with connection credentials and delivery logging.',
|
||||
badges: ['Starlark', 'http.post', 'connections.get', 'db.insert'],
|
||||
tier: 'starlark',
|
||||
stages: [
|
||||
{ name: 'configure', mode: 'form', audience: 'team' },
|
||||
{ name: 'fire-webhook', mode: 'automated', audience: 'system', note: 'http.post' },
|
||||
{ name: 'result', mode: 'review', audience: 'team' },
|
||||
],
|
||||
starlark: 'on_fire(ctx): http.post(url, body=payload), db.insert("webhook_log", ...)',
|
||||
curl: [
|
||||
'# 1. Configure and start\ncurl -X POST $BASE/api/v1/teams/$TEAM/workflows/$WF_ID/start \\\n -H "Authorization: Bearer $TOKEN" \\\n -H "Content-Type: application/json" \\\n -d \'{"data": {"webhook_url": "https://webhook.site/test", "event_name": "deploy.complete", "payload": "{}"}}\'',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ── Init ─────────────────────────────────────
|
||||
|
||||
async function _init() {
|
||||
const mount = document.getElementById('extension-mount');
|
||||
if (!mount) return;
|
||||
|
||||
const surface = document.createElement('div');
|
||||
surface.className = 'surface-workflow-demo';
|
||||
mount.appendChild(surface);
|
||||
|
||||
// Topbar with user menu
|
||||
const topbar = document.createElement('div');
|
||||
topbar.className = 'demo-topbar';
|
||||
topbar.innerHTML = '<h1>Workflow Demo</h1>';
|
||||
surface.appendChild(topbar);
|
||||
sw.userMenu(topbar, { flyout: 'down' });
|
||||
|
||||
// Subtitle
|
||||
const subtitle = document.createElement('p');
|
||||
subtitle.className = 'demo-subtitle';
|
||||
subtitle.textContent = 'Four example workflows that prove the engine works end-to-end. Install any package via Admin > Packages.';
|
||||
surface.appendChild(subtitle);
|
||||
|
||||
// Workflow cards
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'demo-grid';
|
||||
surface.appendChild(grid);
|
||||
|
||||
// Fetch installed workflows to check status
|
||||
// The API returns { data: [...], total, page } — _unwrap keeps the
|
||||
// full object when siblings exist, so extract .data ourselves.
|
||||
let installed = {};
|
||||
try {
|
||||
const resp = await sw.api.get('/api/v1/workflows');
|
||||
const list = Array.isArray(resp) ? resp : (resp && resp.data ? resp.data : []);
|
||||
for (const wf of list) {
|
||||
if (wf.slug) installed[wf.slug] = wf;
|
||||
}
|
||||
} catch (e) { /* ignore — show all as not-installed */ }
|
||||
|
||||
for (const wf of WORKFLOWS) {
|
||||
grid.appendChild(_buildCard(wf, installed[wf.slug]));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Card builder ─────────────────────────────
|
||||
|
||||
function _buildCard(wf, installedWf) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'demo-card';
|
||||
|
||||
// Title row
|
||||
const titleRow = document.createElement('div');
|
||||
titleRow.className = 'card-title-row';
|
||||
titleRow.innerHTML = '<span class="card-icon">' + wf.icon + '</span>'
|
||||
+ '<h2>' + _esc(wf.title) + '</h2>'
|
||||
+ '<span class="card-tier badge-' + wf.tier + '">' + wf.tier + '</span>';
|
||||
card.appendChild(titleRow);
|
||||
|
||||
// Description
|
||||
const desc = document.createElement('p');
|
||||
desc.className = 'card-desc';
|
||||
desc.textContent = wf.description;
|
||||
card.appendChild(desc);
|
||||
|
||||
// Badges
|
||||
const badges = document.createElement('div');
|
||||
badges.className = 'card-badges';
|
||||
for (const b of wf.badges) {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'badge';
|
||||
span.textContent = b;
|
||||
badges.appendChild(span);
|
||||
}
|
||||
card.appendChild(badges);
|
||||
|
||||
// Stage flow diagram
|
||||
card.appendChild(_buildStageDiagram(wf));
|
||||
|
||||
// Starlark viewer (if applicable)
|
||||
if (wf.starlark) {
|
||||
card.appendChild(_buildCollapsible('Starlark Hooks', '<pre><code>' + _esc(wf.starlark) + '</code></pre>'));
|
||||
}
|
||||
|
||||
// API examples
|
||||
if (wf.curl && wf.curl.length) {
|
||||
const curlHtml = wf.curl.map(function (c) { return '<pre><code>' + _esc(c) + '</code></pre>'; }).join('');
|
||||
card.appendChild(_buildCollapsible('API Examples', curlHtml));
|
||||
}
|
||||
|
||||
// Status + action
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'card-actions';
|
||||
|
||||
if (installedWf) {
|
||||
const isActive = installedWf.is_active;
|
||||
const version = installedWf.version || 0;
|
||||
// version > 1 means published at least once (version increments on edit, publish snapshots it)
|
||||
const isPublished = version >= 2;
|
||||
const isReady = isActive && isPublished;
|
||||
|
||||
// Installed badge
|
||||
const status = document.createElement('span');
|
||||
status.className = 'badge badge-installed';
|
||||
status.textContent = 'Installed';
|
||||
actions.appendChild(status);
|
||||
|
||||
if (!isActive) {
|
||||
const hint = document.createElement('span');
|
||||
hint.className = 'badge badge-needs-publish';
|
||||
hint.textContent = 'Not Active';
|
||||
actions.appendChild(hint);
|
||||
} else if (!isPublished) {
|
||||
const hint = document.createElement('span');
|
||||
hint.className = 'badge badge-needs-publish';
|
||||
hint.textContent = 'Not Published';
|
||||
actions.appendChild(hint);
|
||||
}
|
||||
|
||||
const tryBtn = document.createElement('button');
|
||||
tryBtn.className = 'btn btn-primary';
|
||||
tryBtn.textContent = 'Try It';
|
||||
tryBtn.disabled = !isReady;
|
||||
if (!isReady) {
|
||||
tryBtn.title = 'Activate and publish this workflow in Team Admin first';
|
||||
}
|
||||
tryBtn.onclick = function () {
|
||||
if (!isReady) return;
|
||||
if (wf.slug === 'bug-report-triage') {
|
||||
window.open(base + '/api/v1/public/workflows/' + installedWf.id + '/form', '_blank');
|
||||
} else {
|
||||
sw.toast('Navigate to your team admin to start a ' + wf.title + ' instance.', 'info');
|
||||
}
|
||||
};
|
||||
actions.appendChild(tryBtn);
|
||||
|
||||
// Public link for public_link workflows
|
||||
if (installedWf.entry_mode === 'public_link' && isReady) {
|
||||
const linkRow = document.createElement('div');
|
||||
linkRow.className = 'public-link-row';
|
||||
const linkInput = document.createElement('input');
|
||||
linkInput.type = 'text';
|
||||
linkInput.readOnly = true;
|
||||
linkInput.value = window.location.origin + base + '/api/v1/public/workflows/' + installedWf.id + '/start';
|
||||
linkRow.appendChild(linkInput);
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.className = 'btn-copy';
|
||||
copyBtn.textContent = 'Copy';
|
||||
copyBtn.onclick = function () {
|
||||
navigator.clipboard.writeText(linkInput.value).then(function () {
|
||||
copyBtn.textContent = 'Copied!';
|
||||
setTimeout(function () { copyBtn.textContent = 'Copy'; }, 1500);
|
||||
});
|
||||
};
|
||||
linkRow.appendChild(copyBtn);
|
||||
card.appendChild(linkRow);
|
||||
}
|
||||
} else {
|
||||
const status = document.createElement('span');
|
||||
status.className = 'badge badge-not-installed';
|
||||
status.textContent = 'Not Installed';
|
||||
actions.appendChild(status);
|
||||
}
|
||||
|
||||
card.appendChild(actions);
|
||||
return card;
|
||||
}
|
||||
|
||||
// ── Stage flow diagram ───────────────────────
|
||||
|
||||
function _buildStageDiagram(wf) {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'stage-flow';
|
||||
|
||||
const stages = wf.stages;
|
||||
let hasBranch = false;
|
||||
|
||||
for (let i = 0; i < stages.length; i++) {
|
||||
const s = stages[i];
|
||||
|
||||
const node = document.createElement('div');
|
||||
node.className = 'stage-node stage-' + s.audience;
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'stage-label';
|
||||
label.textContent = s.name;
|
||||
node.appendChild(label);
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'stage-meta';
|
||||
meta.textContent = s.mode + (s.audience !== 'team' ? ' (' + s.audience + ')' : '');
|
||||
node.appendChild(meta);
|
||||
|
||||
if (s.note) {
|
||||
const note = document.createElement('div');
|
||||
note.className = 'stage-note';
|
||||
note.textContent = s.note;
|
||||
node.appendChild(note);
|
||||
}
|
||||
|
||||
container.appendChild(node);
|
||||
|
||||
// Arrow (except after last stage)
|
||||
if (i < stages.length - 1 && !stages[i + 1].branch) {
|
||||
const arrow = document.createElement('div');
|
||||
arrow.className = 'stage-arrow';
|
||||
arrow.textContent = '\u2192';
|
||||
container.appendChild(arrow);
|
||||
} else if (stages[i + 1] && stages[i + 1].branch) {
|
||||
if (!hasBranch) {
|
||||
const split = document.createElement('div');
|
||||
split.className = 'stage-arrow stage-branch';
|
||||
split.textContent = '\u2193\u2197';
|
||||
container.appendChild(split);
|
||||
hasBranch = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
// ── Collapsible section ──────────────────────
|
||||
|
||||
function _buildCollapsible(title, innerHtml) {
|
||||
const details = document.createElement('details');
|
||||
details.className = 'card-collapsible';
|
||||
|
||||
const summary = document.createElement('summary');
|
||||
summary.textContent = title;
|
||||
details.appendChild(summary);
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'collapsible-content';
|
||||
content.innerHTML = innerHtml;
|
||||
details.appendChild(content);
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────
|
||||
|
||||
function _esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// ── Boot ──────────────────────────────────────
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', _init);
|
||||
} else {
|
||||
_init();
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"id": "workflow-demo",
|
||||
"title": "Workflow Demo",
|
||||
"type": "full",
|
||||
"version": "0.1.0",
|
||||
"tier": "browser",
|
||||
"icon": "🎓",
|
||||
"author": "Switchboard Core",
|
||||
"description": "Interactive demo surface for example workflow packages. Cards, stage diagrams, and API walkthroughs.",
|
||||
"route": "/s/workflow-demo",
|
||||
"auth": "authenticated",
|
||||
"layout": "single",
|
||||
"permissions": [],
|
||||
"hooks": ["surface"]
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# generate-registry.sh — Build a registry.json from a directory of .pkg files.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/generate-registry.sh [PKG_DIR] [BASE_URL]
|
||||
#
|
||||
# Arguments:
|
||||
# PKG_DIR Directory containing .pkg files (default: dist/)
|
||||
# BASE_URL HTTPS base URL for download links (default: https://example.com/packages)
|
||||
#
|
||||
# Output:
|
||||
# Writes registry.json to stdout. Redirect to a file:
|
||||
# ./scripts/generate-registry.sh dist/ https://cdn.example.com/pkg > registry.json
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PKG_DIR="${1:-dist}"
|
||||
BASE_URL="${2:-https://example.com/packages}"
|
||||
|
||||
# Strip trailing slash
|
||||
BASE_URL="${BASE_URL%/}"
|
||||
|
||||
if [ ! -d "$PKG_DIR" ]; then
|
||||
echo "Error: directory '$PKG_DIR' not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Collect entries
|
||||
entries=()
|
||||
|
||||
for pkg in "$PKG_DIR"/*.pkg; do
|
||||
[ -f "$pkg" ] || continue
|
||||
|
||||
# Extract manifest.json from the ZIP archive
|
||||
manifest=$(unzip -p "$pkg" manifest.json 2>/dev/null) || {
|
||||
echo "Warning: skipping $pkg (no manifest.json)" >&2
|
||||
continue
|
||||
}
|
||||
|
||||
# Read fields from manifest
|
||||
id=$(echo "$manifest" | jq -r '.id // empty')
|
||||
title=$(echo "$manifest" | jq -r '.title // .id')
|
||||
version=$(echo "$manifest" | jq -r '.version // "0.0.0"')
|
||||
description=$(echo "$manifest" | jq -r '.description // ""')
|
||||
author=$(echo "$manifest" | jq -r '.author // ""')
|
||||
type=$(echo "$manifest" | jq -r '.type // "extension"')
|
||||
tier=$(echo "$manifest" | jq -r '.tier // "community"')
|
||||
|
||||
if [ -z "$id" ]; then
|
||||
echo "Warning: skipping $pkg (no id in manifest)" >&2
|
||||
continue
|
||||
fi
|
||||
|
||||
filename=$(basename "$pkg")
|
||||
size=$(stat -c%s "$pkg" 2>/dev/null || stat -f%z "$pkg" 2>/dev/null || echo 0)
|
||||
updated_at=$(date -r "$pkg" -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
entry=$(jq -n \
|
||||
--arg id "$id" \
|
||||
--arg title "$title" \
|
||||
--arg version "$version" \
|
||||
--arg description "$description" \
|
||||
--arg author "$author" \
|
||||
--arg type "$type" \
|
||||
--arg tier "$tier" \
|
||||
--arg download_url "$BASE_URL/$filename" \
|
||||
--argjson size "$size" \
|
||||
--arg updated_at "$updated_at" \
|
||||
'{id: $id, title: $title, version: $version, description: $description,
|
||||
author: $author, type: $type, tier: $tier, download_url: $download_url,
|
||||
size: $size, updated_at: $updated_at}')
|
||||
|
||||
entries+=("$entry")
|
||||
done
|
||||
|
||||
# Build final JSON
|
||||
if [ ${#entries[@]} -eq 0 ]; then
|
||||
echo '{"packages": []}' | jq .
|
||||
else
|
||||
printf '%s\n' "${entries[@]}" | jq -s '{packages: .}'
|
||||
fi
|
||||
@@ -1,237 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# switchboard-ca.sh — Certificate provisioning for Switchboard Core mTLS.
|
||||
#
|
||||
# Wraps openssl to generate a cluster CA, node certificates (ServerAuth +
|
||||
# ClientAuth), and user certificates (ClientAuth only). All output is PEM.
|
||||
#
|
||||
# Usage:
|
||||
# switchboard-ca init
|
||||
# switchboard-ca issue-node --name <node-name> --san <dns1,ip1,...>
|
||||
# switchboard-ca issue-user --cn <username> [--email <email>]
|
||||
#
|
||||
# Files are written to the current directory under ca/, nodes/, users/.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
CA_DIR="./ca"
|
||||
NODES_DIR="./nodes"
|
||||
USERS_DIR="./users"
|
||||
CA_DAYS=3650 # 10 years for CA
|
||||
NODE_DAYS=365 # 1 year for nodes
|
||||
USER_DAYS=90 # 90 days for users
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
die() { echo "error: $*" >&2; exit 1; }
|
||||
|
||||
require_openssl() {
|
||||
command -v openssl >/dev/null 2>&1 || die "openssl not found in PATH"
|
||||
}
|
||||
|
||||
require_ca() {
|
||||
[ -f "$CA_DIR/cluster-ca.key" ] || die "CA not initialized. Run: $0 init"
|
||||
[ -f "$CA_DIR/cluster-ca.crt" ] || die "CA not initialized. Run: $0 init"
|
||||
}
|
||||
|
||||
# ── init ─────────────────────────────────────────────────────────────
|
||||
|
||||
cmd_init() {
|
||||
require_openssl
|
||||
mkdir -p "$CA_DIR"
|
||||
|
||||
if [ -f "$CA_DIR/cluster-ca.key" ]; then
|
||||
die "CA already exists at $CA_DIR/cluster-ca.key — remove it first to reinitialize"
|
||||
fi
|
||||
|
||||
# Generate ECDSA P-256 CA key
|
||||
openssl ecparam -genkey -name prime256v1 -noout -out "$CA_DIR/cluster-ca.key" 2>/dev/null
|
||||
|
||||
# Self-signed CA certificate
|
||||
openssl req -new -x509 \
|
||||
-key "$CA_DIR/cluster-ca.key" \
|
||||
-out "$CA_DIR/cluster-ca.crt" \
|
||||
-days "$CA_DAYS" \
|
||||
-subj "/CN=Switchboard Cluster CA" \
|
||||
-addext "basicConstraints=critical,CA:TRUE" \
|
||||
-addext "keyUsage=critical,keyCertSign,cRLSign" \
|
||||
2>/dev/null
|
||||
|
||||
echo "CA initialized:"
|
||||
echo " $CA_DIR/cluster-ca.crt"
|
||||
echo " $CA_DIR/cluster-ca.key"
|
||||
echo ""
|
||||
echo "Keep cluster-ca.key secure. It never needs to be on a running node."
|
||||
}
|
||||
|
||||
# ── issue-node ───────────────────────────────────────────────────────
|
||||
|
||||
cmd_issue_node() {
|
||||
require_openssl
|
||||
require_ca
|
||||
|
||||
local name=""
|
||||
local san=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--name) name="$2"; shift 2 ;;
|
||||
--san) san="$2"; shift 2 ;;
|
||||
*) die "unknown option: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$name" ] || die "usage: $0 issue-node --name <node-name> --san <dns1,ip1,...>"
|
||||
[ -n "$san" ] || die "usage: $0 issue-node --name <node-name> --san <dns1,ip1,...>"
|
||||
|
||||
mkdir -p "$NODES_DIR"
|
||||
|
||||
# Generate node key
|
||||
openssl ecparam -genkey -name prime256v1 -noout -out "$NODES_DIR/$name.key" 2>/dev/null
|
||||
|
||||
# Build SAN entries from comma-separated list
|
||||
local san_entries=""
|
||||
IFS=',' read -ra ADDRS <<< "$san"
|
||||
for addr in "${ADDRS[@]}"; do
|
||||
addr=$(echo "$addr" | xargs) # trim whitespace
|
||||
if [[ "$addr" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
san_entries="${san_entries}IP:${addr},"
|
||||
else
|
||||
san_entries="${san_entries}DNS:${addr},"
|
||||
fi
|
||||
done
|
||||
san_entries="${san_entries%,}" # remove trailing comma
|
||||
|
||||
# Create CSR
|
||||
openssl req -new \
|
||||
-key "$NODES_DIR/$name.key" \
|
||||
-out "$NODES_DIR/$name.csr" \
|
||||
-subj "/CN=$name" \
|
||||
2>/dev/null
|
||||
|
||||
# Create extensions config
|
||||
local ext_file
|
||||
ext_file=$(mktemp)
|
||||
cat > "$ext_file" <<EOF
|
||||
basicConstraints = CA:FALSE
|
||||
keyUsage = critical, digitalSignature
|
||||
extendedKeyUsage = serverAuth, clientAuth
|
||||
subjectAltName = ${san_entries}
|
||||
EOF
|
||||
|
||||
# Sign with CA
|
||||
openssl x509 -req \
|
||||
-in "$NODES_DIR/$name.csr" \
|
||||
-CA "$CA_DIR/cluster-ca.crt" \
|
||||
-CAkey "$CA_DIR/cluster-ca.key" \
|
||||
-CAcreateserial \
|
||||
-out "$NODES_DIR/$name.crt" \
|
||||
-days "$NODE_DAYS" \
|
||||
-extfile "$ext_file" \
|
||||
2>/dev/null
|
||||
|
||||
rm -f "$NODES_DIR/$name.csr" "$ext_file"
|
||||
|
||||
echo "Node cert issued ($NODE_DAYS days):"
|
||||
echo " $NODES_DIR/$name.crt"
|
||||
echo " $NODES_DIR/$name.key"
|
||||
echo ""
|
||||
echo "Deploy to the node along with $CA_DIR/cluster-ca.crt"
|
||||
}
|
||||
|
||||
# ── issue-user ───────────────────────────────────────────────────────
|
||||
|
||||
cmd_issue_user() {
|
||||
require_openssl
|
||||
require_ca
|
||||
|
||||
local cn=""
|
||||
local email=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--cn) cn="$2"; shift 2 ;;
|
||||
--email) email="$2"; shift 2 ;;
|
||||
*) die "unknown option: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$cn" ] || die "usage: $0 issue-user --cn <username> [--email <email>]"
|
||||
|
||||
mkdir -p "$USERS_DIR"
|
||||
|
||||
# Generate user key
|
||||
openssl ecparam -genkey -name prime256v1 -noout -out "$USERS_DIR/$cn.key" 2>/dev/null
|
||||
|
||||
# Build subject
|
||||
local subj="/CN=$cn"
|
||||
local san_line=""
|
||||
if [ -n "$email" ]; then
|
||||
san_line="email:${email}"
|
||||
fi
|
||||
|
||||
# Create CSR
|
||||
openssl req -new \
|
||||
-key "$USERS_DIR/$cn.key" \
|
||||
-out "$USERS_DIR/$cn.csr" \
|
||||
-subj "$subj" \
|
||||
2>/dev/null
|
||||
|
||||
# Create extensions config
|
||||
local ext_file
|
||||
ext_file=$(mktemp)
|
||||
cat > "$ext_file" <<EOF
|
||||
basicConstraints = CA:FALSE
|
||||
keyUsage = critical, digitalSignature
|
||||
extendedKeyUsage = clientAuth
|
||||
EOF
|
||||
|
||||
if [ -n "$san_line" ]; then
|
||||
echo "subjectAltName = ${san_line}" >> "$ext_file"
|
||||
fi
|
||||
|
||||
# Sign with CA
|
||||
openssl x509 -req \
|
||||
-in "$USERS_DIR/$cn.csr" \
|
||||
-CA "$CA_DIR/cluster-ca.crt" \
|
||||
-CAkey "$CA_DIR/cluster-ca.key" \
|
||||
-CAcreateserial \
|
||||
-out "$USERS_DIR/$cn.crt" \
|
||||
-days "$USER_DAYS" \
|
||||
-extfile "$ext_file" \
|
||||
2>/dev/null
|
||||
|
||||
rm -f "$USERS_DIR/$cn.csr" "$ext_file"
|
||||
|
||||
echo "User cert issued ($USER_DAYS days):"
|
||||
echo " $USERS_DIR/$cn.crt"
|
||||
echo " $USERS_DIR/$cn.key"
|
||||
echo ""
|
||||
echo "Import both files into the user's browser or CLI tool."
|
||||
}
|
||||
|
||||
# ── Main dispatch ────────────────────────────────────────────────────
|
||||
|
||||
case "${1:-}" in
|
||||
init)
|
||||
shift
|
||||
cmd_init "$@"
|
||||
;;
|
||||
issue-node)
|
||||
shift
|
||||
cmd_issue_node "$@"
|
||||
;;
|
||||
issue-user)
|
||||
shift
|
||||
cmd_issue_user "$@"
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 <command>"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " init Initialize cluster CA"
|
||||
echo " issue-node --name <n> --san <addrs> Issue node cert (server+client auth)"
|
||||
echo " issue-user --cn <name> [--email <e>] Issue user cert (client auth only)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
180
server/auth/mtls.go
Normal file
180
server/auth/mtls.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"switchboard-core/models"
|
||||
"switchboard-core/store"
|
||||
)
|
||||
|
||||
// MTLSConfig holds mTLS-specific configuration.
|
||||
type MTLSConfig struct {
|
||||
HeaderDN string // header carrying cert DN (default "X-SSL-Client-DN")
|
||||
HeaderVerify string // header carrying verify status (default "X-SSL-Client-Verify")
|
||||
HeaderFingerprint string // header carrying cert fingerprint (default "X-SSL-Client-Fingerprint")
|
||||
AutoActivate bool // auto-activate new users (default true)
|
||||
DefaultTeam string // team ID for auto-provisioned users (optional)
|
||||
}
|
||||
|
||||
// MTLSProvider authenticates via client certificate headers injected
|
||||
// by the TLS-terminating reverse proxy (nginx, Traefik, Istio).
|
||||
//
|
||||
// The backend never sees the actual TLS handshake — it trusts headers
|
||||
// injected by the proxy after cert validation. After reading and parsing
|
||||
// the DN, the provider resolves an existing user or auto-provisions a
|
||||
// new one, then returns a Result. The auth handler issues an internal JWT.
|
||||
//
|
||||
// Header flow:
|
||||
//
|
||||
// Client cert → nginx ssl_verify_client → injects X-SSL-Client-DN,
|
||||
// X-SSL-Client-Verify, X-SSL-Client-Fingerprint → backend reads headers.
|
||||
type MTLSProvider struct {
|
||||
cfg MTLSConfig
|
||||
}
|
||||
|
||||
func NewMTLSProvider(cfg MTLSConfig) *MTLSProvider {
|
||||
if cfg.HeaderDN == "" {
|
||||
cfg.HeaderDN = "X-SSL-Client-DN"
|
||||
}
|
||||
if cfg.HeaderVerify == "" {
|
||||
cfg.HeaderVerify = "X-SSL-Client-Verify"
|
||||
}
|
||||
if cfg.HeaderFingerprint == "" {
|
||||
cfg.HeaderFingerprint = "X-SSL-Client-Fingerprint"
|
||||
}
|
||||
return &MTLSProvider{cfg: cfg}
|
||||
}
|
||||
|
||||
func (p *MTLSProvider) Mode() Mode { return ModeMTLS }
|
||||
|
||||
func (p *MTLSProvider) SupportsRegistration() bool { return false }
|
||||
|
||||
func (p *MTLSProvider) Register(_ *gin.Context, _ store.Stores) (*Result, error) {
|
||||
return nil, ErrNotSupported
|
||||
}
|
||||
|
||||
// Authenticate reads the cert DN and verify status from proxy headers,
|
||||
// parses the DN fields, and resolves or auto-provisions the user.
|
||||
//
|
||||
// Returns ErrInvalidCreds when headers are missing or verify fails.
|
||||
// Returns ErrInactive when user exists but is deactivated.
|
||||
func (p *MTLSProvider) Authenticate(c *gin.Context, stores store.Stores) (*Result, error) {
|
||||
// ── Validate headers ───────────────────────────────────────────
|
||||
verify := c.GetHeader(p.cfg.HeaderVerify)
|
||||
if verify == "" {
|
||||
return nil, fmt.Errorf("%w: missing %s header", ErrInvalidCreds, p.cfg.HeaderVerify)
|
||||
}
|
||||
// nginx: "SUCCESS", Traefik: "0" (both mean valid cert)
|
||||
if verify != "SUCCESS" && verify != "0" {
|
||||
return nil, fmt.Errorf("%w: cert verify=%s", ErrInvalidCreds, verify)
|
||||
}
|
||||
|
||||
dn := c.GetHeader(p.cfg.HeaderDN)
|
||||
if dn == "" {
|
||||
return nil, fmt.Errorf("%w: missing %s header", ErrInvalidCreds, p.cfg.HeaderDN)
|
||||
}
|
||||
|
||||
fields := ParseDN(dn)
|
||||
cn := fields["CN"]
|
||||
if cn == "" {
|
||||
return nil, fmt.Errorf("%w: cert DN has no CN field", ErrInvalidCreds)
|
||||
}
|
||||
|
||||
// Stable external identity: fingerprint if available, else full DN
|
||||
fingerprint := c.GetHeader(p.cfg.HeaderFingerprint)
|
||||
if fingerprint == "" {
|
||||
fingerprint = dn
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// ── Look up existing user ──────────────────────────────────────
|
||||
user, err := stores.Users.GetByExternalID(ctx, string(ModeMTLS), fingerprint)
|
||||
if err == nil && user != nil {
|
||||
if !user.IsActive {
|
||||
return nil, ErrInactive
|
||||
}
|
||||
log.Printf("[auth/mtls] existing user %s (%s)", user.Username, cn)
|
||||
return &Result{User: user, IsNewUser: false, VaultHint: ""}, nil
|
||||
}
|
||||
|
||||
// ── Auto-provision ─────────────────────────────────────────────
|
||||
if !p.cfg.AutoActivate {
|
||||
return nil, fmt.Errorf("%w: auto-provision disabled", ErrInvalidCreds)
|
||||
}
|
||||
|
||||
return p.autoProvision(ctx, fields, fingerprint, stores)
|
||||
}
|
||||
|
||||
func (p *MTLSProvider) autoProvision(
|
||||
ctx context.Context,
|
||||
dn map[string]string,
|
||||
fingerprint string,
|
||||
stores store.Stores,
|
||||
) (*Result, error) {
|
||||
cn := dn["CN"]
|
||||
|
||||
email := dn["emailAddress"]
|
||||
if email == "" {
|
||||
email = models.HandleFromName(cn) + "@mtls.local"
|
||||
}
|
||||
|
||||
handle := UniqueHandle(ctx, stores.Users, models.HandleFromName(cn))
|
||||
|
||||
user := &models.User{
|
||||
Username: handle,
|
||||
Email: email,
|
||||
DisplayName: cn,
|
||||
IsActive: true,
|
||||
AuthSource: string(ModeMTLS),
|
||||
ExternalID: &fingerprint,
|
||||
Handle: handle,
|
||||
}
|
||||
|
||||
if err := stores.Users.Create(ctx, user); err != nil {
|
||||
return nil, fmt.Errorf("auto-provision failed: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[auth/mtls] auto-provisioned user %s from CN=%s", user.ID, cn)
|
||||
EnsureEveryoneGroup(ctx, stores, user.ID)
|
||||
|
||||
// Auto-add to default team if configured
|
||||
if p.cfg.DefaultTeam != "" {
|
||||
if err := stores.Teams.AddMember(ctx, p.cfg.DefaultTeam, user.ID, "member"); err != nil {
|
||||
log.Printf("[auth/mtls] warn: could not add %s to default team: %v", user.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return &Result{User: user, IsNewUser: true, VaultHint: ""}, nil
|
||||
}
|
||||
|
||||
// ParseDN parses an RFC 2253 / RFC 4514 distinguished name into key-value pairs.
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// "CN=Jeff Smith,O=Acme Corp,OU=Engineering"
|
||||
// "CN=Jane Doe,emailAddress=jane@acme.com,O=Acme Corp"
|
||||
//
|
||||
// Handles simple comma-separated key=value pairs. Does NOT handle
|
||||
// escaped commas in values (\,) or multi-valued RDNs (+). Sufficient
|
||||
// for typical X.509 client cert DNs.
|
||||
func ParseDN(dn string) map[string]string {
|
||||
result := make(map[string]string)
|
||||
parts := strings.Split(dn, ",")
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
idx := strings.Index(part, "=")
|
||||
if idx < 1 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(part[:idx])
|
||||
val := strings.TrimSpace(part[idx+1:])
|
||||
result[key] = val
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"switchboard-core/models"
|
||||
"switchboard-core/store"
|
||||
)
|
||||
|
||||
// ParseDN parses an RFC 2253 / RFC 4514 distinguished name into key-value pairs.
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// "CN=Jeff Smith,O=Acme Corp,OU=Engineering"
|
||||
// "CN=Jane Doe,emailAddress=jane@acme.com,O=Acme Corp"
|
||||
//
|
||||
// Handles simple comma-separated key=value pairs. Does NOT handle
|
||||
// escaped commas in values (\,) or multi-valued RDNs (+). Sufficient
|
||||
// for typical X.509 client cert DNs.
|
||||
func ParseDN(dn string) map[string]string {
|
||||
result := make(map[string]string)
|
||||
parts := strings.Split(dn, ",")
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
idx := strings.Index(part, "=")
|
||||
if idx < 1 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(part[:idx])
|
||||
val := strings.TrimSpace(part[idx+1:])
|
||||
result[key] = val
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// FingerprintCert returns the hex-encoded SHA-256 hash of a certificate's
|
||||
// raw DER encoding. This is used as the stable external_id for mTLS users.
|
||||
func FingerprintCert(cert *x509.Certificate) string {
|
||||
h := sha256.Sum256(cert.Raw)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// resolveOrProvision looks up an existing user by auth_source=mtls and external_id,
|
||||
// or auto-provisions a new user from the certificate's CN and fingerprint.
|
||||
//
|
||||
// Shared by both MTLSProxyProvider and MTLSNativeProvider.
|
||||
func resolveOrProvision(
|
||||
ctx context.Context,
|
||||
stores store.Stores,
|
||||
cn string,
|
||||
dnFields map[string]string,
|
||||
fingerprint string,
|
||||
autoActivate bool,
|
||||
defaultTeam string,
|
||||
) (*Result, error) {
|
||||
// ── Look up existing user ──────────────────────────────────────
|
||||
user, err := stores.Users.GetByExternalID(ctx, string(ModeMTLS), fingerprint)
|
||||
if err == nil && user != nil {
|
||||
if !user.IsActive {
|
||||
return nil, ErrInactive
|
||||
}
|
||||
log.Printf("[auth/mtls] existing user %s (%s)", user.Username, cn)
|
||||
return &Result{User: user, IsNewUser: false, VaultHint: ""}, nil
|
||||
}
|
||||
|
||||
// ── Auto-provision ─────────────────────────────────────────────
|
||||
if !autoActivate {
|
||||
return nil, fmt.Errorf("%w: auto-provision disabled", ErrInvalidCreds)
|
||||
}
|
||||
|
||||
email := dnFields["emailAddress"]
|
||||
if email == "" {
|
||||
email = models.HandleFromName(cn) + "@mtls.local"
|
||||
}
|
||||
|
||||
handle := UniqueHandle(ctx, stores.Users, models.HandleFromName(cn))
|
||||
|
||||
user = &models.User{
|
||||
Username: handle,
|
||||
Email: email,
|
||||
DisplayName: cn,
|
||||
IsActive: true,
|
||||
AuthSource: string(ModeMTLS),
|
||||
ExternalID: &fingerprint,
|
||||
Handle: handle,
|
||||
}
|
||||
|
||||
if err := stores.Users.Create(ctx, user); err != nil {
|
||||
return nil, fmt.Errorf("auto-provision failed: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[auth/mtls] auto-provisioned user %s from CN=%s", user.ID, cn)
|
||||
EnsureEveryoneGroup(ctx, stores, user.ID)
|
||||
|
||||
if defaultTeam != "" {
|
||||
if err := stores.Teams.AddMember(ctx, defaultTeam, user.ID, "member"); err != nil {
|
||||
log.Printf("[auth/mtls] warn: could not add %s to default team: %v", user.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return &Result{User: user, IsNewUser: true, VaultHint: ""}, nil
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"switchboard-core/store"
|
||||
)
|
||||
|
||||
// ErrNoCert is returned when no client certificate is presented on a
|
||||
// connection that requires mTLS authentication.
|
||||
var ErrNoCert = errors.New("no client certificate presented")
|
||||
|
||||
// MTLSNativeConfig holds configuration for the native (non-proxy) mTLS provider.
|
||||
type MTLSNativeConfig struct {
|
||||
AutoActivate bool // auto-activate new users (default true)
|
||||
DefaultTeam string // team ID for auto-provisioned users (optional)
|
||||
}
|
||||
|
||||
// MTLSNativeProvider authenticates by reading the peer certificate
|
||||
// directly from the TLS connection state. Unlike MTLSProxyProvider,
|
||||
// it does not trust headers — identity is cryptographically verified
|
||||
// by the Go TLS stack before the HTTP layer runs.
|
||||
//
|
||||
// Requires TLS_MODE=mtls so the binary terminates TLS itself.
|
||||
type MTLSNativeProvider struct {
|
||||
cfg MTLSNativeConfig
|
||||
}
|
||||
|
||||
// NewMTLSNativeProvider creates a native mTLS auth provider.
|
||||
func NewMTLSNativeProvider(cfg MTLSNativeConfig) *MTLSNativeProvider {
|
||||
return &MTLSNativeProvider{cfg: cfg}
|
||||
}
|
||||
|
||||
func (p *MTLSNativeProvider) Mode() Mode { return ModeMTLS }
|
||||
|
||||
func (p *MTLSNativeProvider) SupportsRegistration() bool { return false }
|
||||
|
||||
func (p *MTLSNativeProvider) Register(_ *gin.Context, _ store.Stores) (*Result, error) {
|
||||
return nil, ErrNotSupported
|
||||
}
|
||||
|
||||
// Authenticate reads the verified peer certificate from the TLS connection
|
||||
// state. The CN becomes the username, and sha256(cert.Raw) is the stable
|
||||
// external_id. Returns ErrNoCert when no TLS or no peer certificates.
|
||||
func (p *MTLSNativeProvider) Authenticate(c *gin.Context, stores store.Stores) (*Result, error) {
|
||||
if c.Request.TLS == nil || len(c.Request.TLS.PeerCertificates) == 0 {
|
||||
return nil, fmt.Errorf("%w", ErrNoCert)
|
||||
}
|
||||
|
||||
peer := c.Request.TLS.PeerCertificates[0]
|
||||
|
||||
cn := peer.Subject.CommonName
|
||||
if cn == "" {
|
||||
return nil, fmt.Errorf("%w: certificate has no CommonName", ErrInvalidCreds)
|
||||
}
|
||||
|
||||
// Build DN fields from the certificate subject for resolveOrProvision
|
||||
dnFields := map[string]string{"CN": cn}
|
||||
if len(peer.EmailAddresses) > 0 {
|
||||
dnFields["emailAddress"] = peer.EmailAddresses[0]
|
||||
}
|
||||
|
||||
fingerprint := FingerprintCert(peer)
|
||||
|
||||
return resolveOrProvision(
|
||||
c.Request.Context(), stores, cn, dnFields, fingerprint,
|
||||
p.cfg.AutoActivate, p.cfg.DefaultTeam,
|
||||
)
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"switchboard-core/store"
|
||||
)
|
||||
|
||||
// ── Test CA helpers ──────────────────────────────────────────────────
|
||||
|
||||
type testCA struct {
|
||||
Cert *x509.Certificate
|
||||
Key *ecdsa.PrivateKey
|
||||
CertPEM []byte
|
||||
Pool *x509.CertPool
|
||||
}
|
||||
|
||||
func newTestCA(t *testing.T) *testCA {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate CA key: %v", err)
|
||||
}
|
||||
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "Test CA"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
IsCA: true,
|
||||
BasicConstraintsValid: true,
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create CA cert: %v", err)
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(certDER)
|
||||
if err != nil {
|
||||
t.Fatalf("parse CA cert: %v", err)
|
||||
}
|
||||
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(cert)
|
||||
|
||||
return &testCA{Cert: cert, Key: key, CertPEM: certPEM, Pool: pool}
|
||||
}
|
||||
|
||||
type testCert struct {
|
||||
Cert *x509.Certificate
|
||||
Key *ecdsa.PrivateKey
|
||||
TLSCert tls.Certificate
|
||||
}
|
||||
|
||||
func (ca *testCA) issueClient(t *testing.T, cn string, email string) *testCert {
|
||||
t.Helper()
|
||||
return ca.issueCert(t, cn, email, []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, time.Now().Add(time.Hour))
|
||||
}
|
||||
|
||||
func (ca *testCA) issueExpiredClient(t *testing.T, cn string) *testCert {
|
||||
t.Helper()
|
||||
return ca.issueCert(t, cn, "", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, time.Now().Add(-time.Second))
|
||||
}
|
||||
|
||||
func (ca *testCA) issueNode(t *testing.T, cn string) *testCert {
|
||||
t.Helper()
|
||||
return ca.issueCert(t, cn, "", []x509.ExtKeyUsage{
|
||||
x509.ExtKeyUsageServerAuth,
|
||||
x509.ExtKeyUsageClientAuth,
|
||||
}, time.Now().Add(time.Hour))
|
||||
}
|
||||
|
||||
func (ca *testCA) issueCert(t *testing.T, cn, email string, eku []x509.ExtKeyUsage, notAfter time.Time) *testCert {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: notAfter,
|
||||
ExtKeyUsage: eku,
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)},
|
||||
DNSNames: []string{"localhost"},
|
||||
}
|
||||
if email != "" {
|
||||
tmpl.EmailAddresses = []string{email}
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, ca.Cert, &key.PublicKey, ca.Key)
|
||||
if err != nil {
|
||||
t.Fatalf("create cert: %v", err)
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(certDER)
|
||||
if err != nil {
|
||||
t.Fatalf("parse cert: %v", err)
|
||||
}
|
||||
|
||||
tlsCert := tls.Certificate{
|
||||
Certificate: [][]byte{certDER},
|
||||
PrivateKey: key,
|
||||
}
|
||||
|
||||
return &testCert{Cert: cert, Key: key, TLSCert: tlsCert}
|
||||
}
|
||||
|
||||
// ── FingerprintCert tests ───────────────────────────────────────────
|
||||
|
||||
func TestFingerprintCert(t *testing.T) {
|
||||
ca := newTestCA(t)
|
||||
c1 := ca.issueClient(t, "alice", "")
|
||||
c2 := ca.issueClient(t, "alice", "") // same CN, different cert
|
||||
|
||||
fp1 := FingerprintCert(c1.Cert)
|
||||
fp2 := FingerprintCert(c2.Cert)
|
||||
|
||||
if fp1 == "" {
|
||||
t.Error("fingerprint should not be empty")
|
||||
}
|
||||
if len(fp1) != 64 { // sha256 hex = 64 chars
|
||||
t.Errorf("fingerprint length = %d, want 64", len(fp1))
|
||||
}
|
||||
if fp1 == fp2 {
|
||||
t.Error("different certs should have different fingerprints")
|
||||
}
|
||||
|
||||
// Same cert → same fingerprint (deterministic)
|
||||
if FingerprintCert(c1.Cert) != fp1 {
|
||||
t.Error("fingerprint should be deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
// ── MTLSNativeProvider unit tests ───────────────────────────────────
|
||||
|
||||
func TestMTLSNativeProvider_Mode(t *testing.T) {
|
||||
p := NewMTLSNativeProvider(MTLSNativeConfig{})
|
||||
if p.Mode() != ModeMTLS {
|
||||
t.Errorf("Mode() = %q, want %q", p.Mode(), ModeMTLS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMTLSNativeProvider_NoRegistration(t *testing.T) {
|
||||
p := NewMTLSNativeProvider(MTLSNativeConfig{})
|
||||
if p.SupportsRegistration() {
|
||||
t.Error("native mTLS should not support registration")
|
||||
}
|
||||
_, err := p.Register(nil, store.Stores{})
|
||||
if err != ErrNotSupported {
|
||||
t.Errorf("Register() = %v, want ErrNotSupported", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMTLSNativeProvider_NilTLS(t *testing.T) {
|
||||
p := NewMTLSNativeProvider(MTLSNativeConfig{})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/", nil)
|
||||
c.Request.TLS = nil
|
||||
|
||||
_, err := p.Authenticate(c, store.Stores{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil TLS")
|
||||
}
|
||||
if !errors.Is(err, ErrNoCert) {
|
||||
t.Errorf("expected ErrNoCert, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMTLSNativeProvider_EmptyPeerCerts(t *testing.T) {
|
||||
p := NewMTLSNativeProvider(MTLSNativeConfig{})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/", nil)
|
||||
c.Request.TLS = &tls.ConnectionState{
|
||||
PeerCertificates: []*x509.Certificate{},
|
||||
}
|
||||
|
||||
_, err := p.Authenticate(c, store.Stores{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty peer certs")
|
||||
}
|
||||
if !errors.Is(err, ErrNoCert) {
|
||||
t.Errorf("expected ErrNoCert, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMTLSNativeProvider_NoCN(t *testing.T) {
|
||||
p := NewMTLSNativeProvider(MTLSNativeConfig{AutoActivate: true})
|
||||
ca := newTestCA(t)
|
||||
cert := ca.issueCert(t, "", "", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, time.Now().Add(time.Hour))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/", nil)
|
||||
c.Request.TLS = &tls.ConnectionState{
|
||||
PeerCertificates: []*x509.Certificate{cert.Cert},
|
||||
}
|
||||
|
||||
_, err := p.Authenticate(c, store.Stores{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for cert with no CN")
|
||||
}
|
||||
if !errors.Is(err, ErrInvalidCreds) {
|
||||
t.Errorf("expected ErrInvalidCreds, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMTLSNativeProvider_ExtractsEmail(t *testing.T) {
|
||||
ca := newTestCA(t)
|
||||
cert := ca.issueClient(t, "alice", "alice@example.com")
|
||||
|
||||
if len(cert.Cert.EmailAddresses) == 0 || cert.Cert.EmailAddresses[0] != "alice@example.com" {
|
||||
t.Fatalf("cert should have email alice@example.com, got %v", cert.Cert.EmailAddresses)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Integration tests (real TLS listener) ───────────────────────────
|
||||
|
||||
func TestTLS_NoClientCert_Rejected(t *testing.T) {
|
||||
ca := newTestCA(t)
|
||||
serverCert := ca.issueNode(t, "server")
|
||||
|
||||
srv := newMTLSTestServer(t, ca, serverCert)
|
||||
defer srv.Close()
|
||||
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
RootCAs: ca.Pool,
|
||||
MinVersion: tls.VersionTLS13,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := client.Get(srv.URL + "/test")
|
||||
if err == nil {
|
||||
t.Fatal("expected TLS handshake error when no client cert is presented")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLS_WrongCA_Rejected(t *testing.T) {
|
||||
ca := newTestCA(t)
|
||||
wrongCA := newTestCA(t)
|
||||
serverCert := ca.issueNode(t, "server")
|
||||
clientCert := wrongCA.issueClient(t, "intruder", "")
|
||||
|
||||
srv := newMTLSTestServer(t, ca, serverCert)
|
||||
defer srv.Close()
|
||||
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
RootCAs: ca.Pool,
|
||||
Certificates: []tls.Certificate{clientCert.TLSCert},
|
||||
MinVersion: tls.VersionTLS13,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := client.Get(srv.URL + "/test")
|
||||
if err == nil {
|
||||
t.Fatal("expected TLS handshake error when client cert is from wrong CA")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLS_ValidClientCert_Accepted(t *testing.T) {
|
||||
ca := newTestCA(t)
|
||||
serverCert := ca.issueNode(t, "server")
|
||||
clientCert := ca.issueClient(t, "alice", "")
|
||||
|
||||
srv := newMTLSTestServer(t, ca, serverCert)
|
||||
defer srv.Close()
|
||||
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
RootCAs: ca.Pool,
|
||||
Certificates: []tls.Certificate{clientCert.TLSCert},
|
||||
MinVersion: tls.VersionTLS13,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := client.Get(srv.URL + "/test")
|
||||
if err != nil {
|
||||
t.Fatalf("expected successful connection, got: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLS_ExpiredCert_Rejected(t *testing.T) {
|
||||
ca := newTestCA(t)
|
||||
serverCert := ca.issueNode(t, "server")
|
||||
clientCert := ca.issueExpiredClient(t, "expired-user")
|
||||
|
||||
srv := newMTLSTestServer(t, ca, serverCert)
|
||||
defer srv.Close()
|
||||
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
RootCAs: ca.Pool,
|
||||
Certificates: []tls.Certificate{clientCert.TLSCert},
|
||||
MinVersion: tls.VersionTLS13,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := client.Get(srv.URL + "/test")
|
||||
if err == nil {
|
||||
t.Fatal("expected TLS handshake error for expired client cert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLS_PeerCertificateVisible(t *testing.T) {
|
||||
ca := newTestCA(t)
|
||||
serverCert := ca.issueNode(t, "server")
|
||||
clientCert := ca.issueClient(t, "bob", "bob@example.com")
|
||||
|
||||
var seenCN string
|
||||
var seenEmails []string
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.TLS != nil && len(r.TLS.PeerCertificates) > 0 {
|
||||
peer := r.TLS.PeerCertificates[0]
|
||||
seenCN = peer.Subject.CommonName
|
||||
seenEmails = peer.EmailAddresses
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
})
|
||||
|
||||
srv := newMTLSTestServerWithHandler(t, ca, serverCert, handler)
|
||||
defer srv.Close()
|
||||
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
RootCAs: ca.Pool,
|
||||
Certificates: []tls.Certificate{clientCert.TLSCert},
|
||||
MinVersion: tls.VersionTLS13,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := client.Get(srv.URL + "/test")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if seenCN != "bob" {
|
||||
t.Errorf("CN = %q, want %q", seenCN, "bob")
|
||||
}
|
||||
if len(seenEmails) == 0 || seenEmails[0] != "bob@example.com" {
|
||||
t.Errorf("emails = %v, want [bob@example.com]", seenEmails)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test helpers ────────────────────────────────────────────────────
|
||||
|
||||
func newMTLSTestServer(t *testing.T, ca *testCA, serverCert *testCert) *httptest.Server {
|
||||
t.Helper()
|
||||
return newMTLSTestServerWithHandler(t, ca, serverCert, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
fmt.Fprint(w, "ok")
|
||||
}))
|
||||
}
|
||||
|
||||
func newMTLSTestServerWithHandler(t *testing.T, ca *testCA, serverCert *testCert, handler http.Handler) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewUnstartedServer(handler)
|
||||
srv.TLS = &tls.Config{
|
||||
Certificates: []tls.Certificate{serverCert.TLSCert},
|
||||
ClientAuth: tls.RequireAndVerifyClientCert,
|
||||
ClientCAs: ca.Pool,
|
||||
MinVersion: tls.VersionTLS13,
|
||||
}
|
||||
srv.StartTLS()
|
||||
return srv
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"switchboard-core/store"
|
||||
)
|
||||
|
||||
// MTLSProxyConfig holds configuration for the proxy-terminated mTLS provider.
|
||||
type MTLSProxyConfig struct {
|
||||
HeaderDN string // header carrying cert DN (default "X-SSL-Client-DN")
|
||||
HeaderVerify string // header carrying verify status (default "X-SSL-Client-Verify")
|
||||
HeaderFingerprint string // header carrying cert fingerprint (default "X-SSL-Client-Fingerprint")
|
||||
AutoActivate bool // auto-activate new users (default true)
|
||||
DefaultTeam string // team ID for auto-provisioned users (optional)
|
||||
}
|
||||
|
||||
// MTLSProxyProvider authenticates via client certificate headers injected
|
||||
// by the TLS-terminating reverse proxy (nginx, Traefik, Istio).
|
||||
//
|
||||
// The backend never sees the actual TLS handshake — it trusts headers
|
||||
// injected by the proxy after cert validation. After reading and parsing
|
||||
// the DN, the provider resolves an existing user or auto-provisions a
|
||||
// new one, then returns a Result. The auth handler issues an internal JWT.
|
||||
//
|
||||
// Header flow:
|
||||
//
|
||||
// Client cert → nginx ssl_verify_client → injects X-SSL-Client-DN,
|
||||
// X-SSL-Client-Verify, X-SSL-Client-Fingerprint → backend reads headers.
|
||||
type MTLSProxyProvider struct {
|
||||
cfg MTLSProxyConfig
|
||||
}
|
||||
|
||||
func NewMTLSProxyProvider(cfg MTLSProxyConfig) *MTLSProxyProvider {
|
||||
if cfg.HeaderDN == "" {
|
||||
cfg.HeaderDN = "X-SSL-Client-DN"
|
||||
}
|
||||
if cfg.HeaderVerify == "" {
|
||||
cfg.HeaderVerify = "X-SSL-Client-Verify"
|
||||
}
|
||||
if cfg.HeaderFingerprint == "" {
|
||||
cfg.HeaderFingerprint = "X-SSL-Client-Fingerprint"
|
||||
}
|
||||
return &MTLSProxyProvider{cfg: cfg}
|
||||
}
|
||||
|
||||
func (p *MTLSProxyProvider) Mode() Mode { return ModeMTLS }
|
||||
|
||||
func (p *MTLSProxyProvider) SupportsRegistration() bool { return false }
|
||||
|
||||
func (p *MTLSProxyProvider) Register(_ *gin.Context, _ store.Stores) (*Result, error) {
|
||||
return nil, ErrNotSupported
|
||||
}
|
||||
|
||||
// Authenticate reads the cert DN and verify status from proxy headers,
|
||||
// parses the DN fields, and resolves or auto-provisions the user.
|
||||
//
|
||||
// Returns ErrInvalidCreds when headers are missing or verify fails.
|
||||
// Returns ErrInactive when user exists but is deactivated.
|
||||
func (p *MTLSProxyProvider) Authenticate(c *gin.Context, stores store.Stores) (*Result, error) {
|
||||
// ── Validate headers ───────────────────────────────────────────
|
||||
verify := c.GetHeader(p.cfg.HeaderVerify)
|
||||
if verify == "" {
|
||||
return nil, fmt.Errorf("%w: missing %s header", ErrInvalidCreds, p.cfg.HeaderVerify)
|
||||
}
|
||||
// nginx: "SUCCESS", Traefik: "0" (both mean valid cert)
|
||||
if verify != "SUCCESS" && verify != "0" {
|
||||
return nil, fmt.Errorf("%w: cert verify=%s", ErrInvalidCreds, verify)
|
||||
}
|
||||
|
||||
dn := c.GetHeader(p.cfg.HeaderDN)
|
||||
if dn == "" {
|
||||
return nil, fmt.Errorf("%w: missing %s header", ErrInvalidCreds, p.cfg.HeaderDN)
|
||||
}
|
||||
|
||||
fields := ParseDN(dn)
|
||||
cn := fields["CN"]
|
||||
if cn == "" {
|
||||
return nil, fmt.Errorf("%w: cert DN has no CN field", ErrInvalidCreds)
|
||||
}
|
||||
|
||||
// Stable external identity: fingerprint if available, else full DN
|
||||
fingerprint := c.GetHeader(p.cfg.HeaderFingerprint)
|
||||
if fingerprint == "" {
|
||||
fingerprint = dn
|
||||
}
|
||||
|
||||
return resolveOrProvision(
|
||||
c.Request.Context(), stores, cn, fields, fingerprint,
|
||||
p.cfg.AutoActivate, p.cfg.DefaultTeam,
|
||||
)
|
||||
}
|
||||
@@ -85,7 +85,7 @@ func TestParseDN(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMTLSConfig_Defaults(t *testing.T) {
|
||||
p := NewMTLSProxyProvider(MTLSProxyConfig{})
|
||||
p := NewMTLSProvider(MTLSConfig{})
|
||||
|
||||
if p.cfg.HeaderDN != "X-SSL-Client-DN" {
|
||||
t.Errorf("HeaderDN = %q, want X-SSL-Client-DN", p.cfg.HeaderDN)
|
||||
@@ -99,7 +99,7 @@ func TestMTLSConfig_Defaults(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMTLSConfig_Custom(t *testing.T) {
|
||||
p := NewMTLSProxyProvider(MTLSProxyConfig{
|
||||
p := NewMTLSProvider(MTLSConfig{
|
||||
HeaderDN: "X-Client-Cert-DN",
|
||||
HeaderVerify: "X-Client-Cert-Verify",
|
||||
})
|
||||
@@ -110,14 +110,14 @@ func TestMTLSConfig_Custom(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMTLSProvider_Mode(t *testing.T) {
|
||||
p := NewMTLSProxyProvider(MTLSProxyConfig{})
|
||||
p := NewMTLSProvider(MTLSConfig{})
|
||||
if p.Mode() != ModeMTLS {
|
||||
t.Errorf("Mode() = %q, want %q", p.Mode(), ModeMTLS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMTLSProvider_NoRegistration(t *testing.T) {
|
||||
p := NewMTLSProxyProvider(MTLSProxyConfig{})
|
||||
p := NewMTLSProvider(MTLSConfig{})
|
||||
if p.SupportsRegistration() {
|
||||
t.Error("mTLS should not support registration")
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user