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 1: Build Go backend
|
||||||
# Stage 2: Download JS vendor libs (marked, DOMPurify)
|
# Stage 2: Download JS vendor libs (marked, DOMPurify)
|
||||||
# Stage 3: Build CM6 editor bundle (esbuild)
|
# Stage 3: Build CM6 editor bundle (esbuild)
|
||||||
# Stage 4: Build bundled packages (.pkg archives)
|
# Stage 4: Production image (nginx + backend)
|
||||||
# Stage 5: Production image (nginx + backend)
|
|
||||||
#
|
#
|
||||||
# Vendor libs are baked in during build so the
|
# Vendor libs are baked in during build so the
|
||||||
# app works in disconnected environments
|
# 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 cd /build/src/editor && npm ci --loglevel=warn
|
||||||
RUN sh /build/scripts/build-editor.sh /build/dist
|
RUN sh /build/scripts/build-editor.sh /build/dist
|
||||||
|
|
||||||
# ── Stage 4: Build bundled packages ─────────
|
# ── Stage 4: Production ─────────────────────
|
||||||
FROM alpine:3 AS packages
|
|
||||||
RUN apk add --no-cache zip bash
|
|
||||||
COPY packages/ /packages/
|
|
||||||
RUN cd /packages && bash build.sh
|
|
||||||
|
|
||||||
# ── Stage 5: Production ─────────────────────
|
|
||||||
FROM nginx:1-alpine
|
FROM nginx:1-alpine
|
||||||
|
|
||||||
RUN apk add --no-cache bash git
|
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 src/ /usr/share/nginx/html/
|
||||||
COPY VERSION /VERSION
|
COPY VERSION /VERSION
|
||||||
|
|
||||||
# Documentation (v0.6.1) — served by Go backend via /api/v1/docs
|
|
||||||
COPY docs/ /app/docs/
|
|
||||||
|
|
||||||
# Inject version and build hash into index.html and sw.js at build time
|
# Inject version and build hash into index.html and sw.js at build time
|
||||||
RUN APP_VERSION=$(cat /VERSION | tr -d '[:space:]') && \
|
RUN APP_VERSION=$(cat /VERSION | tr -d '[:space:]') && \
|
||||||
BUILD_HASH=$(find /usr/share/nginx/html/js -name '*.js' -exec md5sum {} + | sort | md5sum | cut -c1-8) && \
|
BUILD_HASH=$(find /usr/share/nginx/html/js -name '*.js' -exec md5sum {} + | sort | md5sum | cut -c1-8) && \
|
||||||
@@ -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=vendor /vendor/katex/fonts/ /usr/share/nginx/html/vendor/katex/fonts/
|
||||||
COPY --from=cm6-build /build/dist/ /usr/share/nginx/html/vendor/codemirror/
|
COPY --from=cm6-build /build/dist/ /usr/share/nginx/html/vendor/codemirror/
|
||||||
|
|
||||||
# Bundled packages (v0.3.8) — auto-installed on first run
|
# Builtin extensions (seeded into DB on startup by backend)
|
||||||
COPY --from=packages /dist/ /app/bundled-packages/
|
COPY extensions/builtin/ /app/extensions/builtin/
|
||||||
|
|
||||||
# nginx config (template — envsubst injects BASE_PATH at runtime)
|
# nginx config (template — envsubst injects BASE_PATH at runtime)
|
||||||
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
|
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
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Docker (recommended)
|
|
||||||
docker compose up --build
|
|
||||||
# → http://localhost:3000 (admin/admin)
|
|
||||||
|
|
||||||
# Or from source
|
|
||||||
git clone <repo-url> && cd switchboard-core
|
git clone <repo-url> && cd switchboard-core
|
||||||
cp server/.env.example server/.env # edit DB credentials
|
cp server/.env.example server/.env # edit DB credentials
|
||||||
cd server && go run .
|
cd server && go run .
|
||||||
# → http://localhost:8080
|
# → 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
|
## Kernel Features
|
||||||
|
|
||||||
- **Auth**: Builtin password, mTLS (client cert), OIDC (Keycloak et al.)
|
- **Auth**: Builtin password, mTLS (client cert), OIDC (Keycloak et al.)
|
||||||
@@ -52,19 +43,15 @@ deployment and customization.
|
|||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [Distribution Guide](docs/DISTRIBUTION.md) — Docker, bundled packages, builder image, production deployment
|
|
||||||
- [Architecture](docs/ARCHITECTURE.md) — kernel components and design reasoning
|
- [Architecture](docs/ARCHITECTURE.md) — kernel components and design reasoning
|
||||||
- [Roadmap](ROADMAP.md) — current status and planned milestones
|
- [Roadmap](ROADMAP.md) — current status and planned milestones
|
||||||
- [Changelog](CHANGELOG.md) — version history
|
- [Changelog](CHANGELOG.md) — version history
|
||||||
|
|
||||||
## Project Status
|
## Project Status
|
||||||
|
|
||||||
**v0.5.0** — Realtime pub/sub primitive, dialog audit, and admin permissions
|
**v0.1.0** (in progress) — kernel extracted from chat-switchboard v0.38.5.
|
||||||
UI. Extensions can now publish events to WebSocket channels via Starlark;
|
~44K lines of chat/AI code removed, 27 kernel tables, 20 store interfaces.
|
||||||
clients subscribe with `sw.realtime.subscribe()`. Admin Packages page gains
|
See [ROADMAP.md](ROADMAP.md) for details.
|
||||||
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.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
251
ROADMAP.md
251
ROADMAP.md
@@ -1,182 +1,177 @@
|
|||||||
# Switchboard Core — Roadmap
|
# Switchboard Core — Roadmap
|
||||||
|
|
||||||
## Current: v0.6.7 — Native mTLS
|
## Current: v0.2.0 — SDK & Triggers
|
||||||
|
|
||||||
Self-hosted extensible platform. Auth, identity, packages, Starlark sandbox,
|
Fork of chat-switchboard, gutted to a pure extension platform. All AI/chat
|
||||||
storage, realtime, and ops are kernel primitives. Everything else is an extension.
|
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 ·
|
### Retained kernel capabilities
|
||||||
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
|
|
||||||
|
|
||||||
**Completed history:** v0.2.x–v0.5.x fully documented in `CHANGELOG.md`.
|
- **Auth**: builtin (simple), mTLS, OIDC
|
||||||
Highlights: RBAC + settings cascade, event bus + triggers, SDK stabilization,
|
- **Identity**: users, teams, groups, permissions (RBAC)
|
||||||
workflow engine (multi-stage, team roles, signoff gate, public entry, SLA),
|
- **Packages**: surfaces, extensions, libraries, workflows
|
||||||
package distribution, Notes surface (CM6, folders, tags, backlinks, graph),
|
- **Starlark sandbox**: capability-gated modules
|
||||||
realtime primitive, Chat surface (chat-core library + surface + polish),
|
- **Storage**: object storage (PVC, S3), ext_data tables
|
||||||
upgrade test harness, cluster registry + HA.
|
- **Realtime**: WebSocket hub, presence, multi-replica HA
|
||||||
|
- **Ops**: audit log, notifications, maintenance goroutine
|
||||||
|
|
||||||
---
|
### Phase 0 (complete)
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
| Step | Status | Description |
|
| Step | Status | Description |
|
||||||
|------|--------|-------------|
|
|------|--------|-------------|
|
||||||
| `node_registry` table | ✅ | `UNLOGGED TABLE` — node_id, endpoint, seq, registered_at, heartbeat, stats JSONB. Postgres migration 013. |
|
| 1. Module rename | ✅ | `chat-switchboard` → `switchboard-core` |
|
||||||
| Node registration | ✅ | Self-registration on startup: `INSERT ... ON CONFLICT DO UPDATE`. `node_id` = `hostname-PID` or `CLUSTER_NODE_ID` env override. |
|
| 2. Delete packages | ✅ | 15 Go packages, 29 handler files removed |
|
||||||
| Heartbeat tick | ✅ | Every 10s: update own heartbeat + collect runtime stats (goroutines, heap, GC, uptime, ws_clients). |
|
| 3. Gut stores/models | ✅ | 40 → 20 store interfaces, kernel-only models |
|
||||||
| Stale sweep | ✅ | Every heartbeat tick: `DELETE WHERE heartbeat < now() - 30s`. All nodes run it — idempotent, no ring topology. |
|
| 4. Fresh migrations | ✅ | 9 files × 2 dialects, 27 tables |
|
||||||
| Self-eviction | ✅ | If heartbeat UPDATE returns 0 rows: node was swept by peer → log error + `os.Exit(1)`. K8s restarts → re-register. |
|
| 5. Fix compilation | ✅ | `go build ./...` clean, 300+ stale route lines cut |
|
||||||
| 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). |
|
| 6. Fix tests | ✅ | 8 test packages pass, ~12K stale test lines pruned |
|
||||||
| Cluster API | ✅ | `GET /api/v1/admin/cluster` — returns `{data: [...]}` envelope with all registered nodes. |
|
| 7. Frontend gut | ✅ | Shell + SDK only, 50+ files of chat/notes/projects code removed |
|
||||||
| 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. |
|
| 8. New ICD | ✅ | Full OpenAPI 3.0.3 spec — 160 operations across 22 tag groups |
|
||||||
| Health endpoint | ✅ | `GET /health` includes `node_id` and `cluster: {size, peers, heartbeat_age_ms}`. |
|
| 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) |
|
||||||
| Config | ✅ | `CLUSTER_NODE_ID`, `CLUSTER_HEARTBEAT_INTERVAL` (default 10s), `CLUSTER_STALE_THRESHOLD` (default 30s), `CLUSTER_ENDPOINT` (Phase 2 mesh, auto-detect). |
|
| 10. Smoke test | ✅ | K8s deploy live at switchboard.gobha.ai/test, nginx BASE_PATH fixed, login→admin flow verified, branding updated |
|
||||||
| 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. |
|
|
||||||
|
|
||||||
### 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 |
|
| 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). |
|
| Admin → RBAC group | ✅ | `surface.admin.access` permission + Admins system group replaces `role == "admin"` checks. Admin bypass removed from permission middleware. |
|
||||||
| Server-side backups | ✅ | `GET /api/v1/admin/backups` list, `GET /download`, `DELETE`. Store backups in `{STORAGE_PATH}/backups/`. |
|
| Settings cascade | ✅ | `user_overridable` flag, three-tier resolution (global → team → user), team settings API |
|
||||||
| Admin backup section | ✅ | New "Backup" section under `/admin/backup`. Create (download or server-side), list, download, delete, restore with destructive confirmation. |
|
| ~~Settings override model~~ | ✅ | Shipped with settings cascade above |
|
||||||
| Documentation API | ✅ | `GET /api/v1/docs` lists, `GET /api/v1/docs/:name` returns raw markdown. Authenticated (not admin-only). |
|
|
||||||
| Docs surface | ✅ | Builtin surface at `/docs/:section`. Sidebar navigation, client-side markdown renderer. 5 new docs: Getting Started, Extension Guide, API Reference, Deployment, Package Format. |
|
|
||||||
| Tests | ✅ | 6 handler tests (basic backup, ext_data backup, round-trip restore, schema mismatch, dump/restore table, list empty). E2E script `ci/e2e-backup-test.sh`. |
|
|
||||||
|
|
||||||
### v0.6.2 — Docs Polish + Dynamic OpenAPI
|
### v0.2.1 — Default Surface + ICD
|
||||||
|
|
||||||
| Step | Status | Description |
|
| 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. |
|
| Default surface routing | ✅ | `/` redirects to configurable default surface. No surfaces → admin. First install becomes default. Changeable in admin settings. |
|
||||||
| Loading & error handling | ✅ | Replaced borrowed `settings-placeholder` with docs-specific pulse animation. Added error state with retry button for failed doc list fetch. |
|
| ICD (API contract) | ✅ | Full OpenAPI 3.0.3 spec — 160 operations, 22 tag groups, reusable component schemas. Served at `/api/docs`. |
|
||||||
| 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. |
|
|
||||||
|
|
||||||
---
|
### v0.2.2 — Event Bus + Triggers
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
| Step | Status | Description |
|
| 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. |
|
| Event bus subscriptions | ✅ | Extensions register event patterns in manifest. Wired via `bus.Subscribe()` on startup. Async handler invocation. |
|
||||||
| 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. |
|
| Webhook triggers | ✅ | Inbound HTTP at `/api/v1/hooks/:package_id/:slug`. HMAC-SHA256 verification. Synchronous Starlark handler response. |
|
||||||
| Registry tooling + docs | ✅ | `scripts/generate-registry.sh` scans a directory of `.pkg` files and emits registry JSON. `docs/PACKAGE-REGISTRY.md` documents the format. |
|
| 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. |
|
||||||
| 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. |
|
| Trigger admin API | ✅ | CRUD for triggers + schedules. Enable/disable, execution logs, per-package listing. |
|
||||||
| 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=*`. |
|
|
||||||
|
|
||||||
### v0.6.4 — Admin Health/Metrics Tab + Cluster Merge
|
### v0.2.3 — SDK + Task Extension
|
||||||
|
|
||||||
Structural move: cluster dashboard becomes an Admin tab. Better home for health/metrics — shared context with other admin panels, no separate nav entry.
|
|
||||||
|
|
||||||
| Step | Status | Description |
|
| 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. |
|
| SDK stabilization | ✅ | `sw.api.ext()`, `sw.storage`, `sw.theme.tokens`, `sw.ui`, `sw.slots`, `sw.actions` — six new SDK modules for extension development |
|
||||||
| 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. |
|
| Task extension | ✅ | Full task surface rebuilt as Starlark extension: CRUD API, kanban/list views, event triggers, webhook integration, notifications on completion |
|
||||||
| 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. |
|
|
||||||
|
|
||||||
### v0.6.5 — Renderer Pipeline + Docs Rewrite
|
### v0.2.4 — Shell Navigation + Schedules
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
| Step | Status | Description |
|
| 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. |
|
| SDK Topbar | ✅ | `sw.shell.Topbar` — composable navigation bar (title + extension slot + bell + user menu). Surfaces get consistent nav for free. |
|
||||||
| Notes hooks SDK renderer pipeline | ✅ | Notes delegates to `sw.markdown.renderSync()` + `sw.renderers.runPostRenderers()`. Hand-rolled renderer deleted. |
|
| Schedules surface | ✅ | New `packages/schedules/` wrapping kernel cron API. Table view, cron preview, enable/disable, manual run, execution logs. |
|
||||||
| Docs hooks SDK renderer pipeline | ✅ | Docs delegates to `sw.markdown.renderSync()` + `sw.renderers.runPostRenderers()`. Hand-rolled renderer deleted (~160 lines). |
|
| Manifest icons | ✅ | `icon` field in manifest.json (emoji). Surfaces API returns icon. UserMenu renders per-surface icons. |
|
||||||
| Unify markdown renderer | ✅ | `sw.markdown` module lazy-loads `marked` v16 (vendored). Notes, Docs, Chat all consume it. Two hand-rolled parsers deleted. |
|
| UserMenu cleanup | ✅ | Removed dead Chat/Notes/Projects links. Menu driven by surfaces API. Core surfaces filtered. |
|
||||||
| 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). |
|
| isAdmin RBAC fix | ✅ | `can.js` isAdmin() now checks `surface.admin.access` grant instead of deprecated role column. |
|
||||||
| 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. |
|
|
||||||
|
|
||||||
### v0.6.7 — Native mTLS
|
### v0.2.5 — UI Polish + Dead Code Audit
|
||||||
|
|
||||||
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`.
|
|
||||||
|
|
||||||
| Step | Status | Description |
|
| 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`. |
|
| 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. |
|
||||||
| 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. |
|
| 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). |
|
||||||
| `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`. |
|
| Template cleanup | ✅ | Removed stale CSS link tags and orphaned chat-pane.html. Updated doc comments throughout. |
|
||||||
| 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. |
|
| Package proof-of-concept status | ✅ | Created README.md for tasks + schedules with graduation criteria. Added missing manifest icons. |
|
||||||
| `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. |
|
| Welcome surface | ✅ | New fallback surface when no extensions installed. Topbar + welcome card with admin link. Replaces `/admin` as final redirect target. |
|
||||||
| Unit tests | ✅ | Ephemeral CA via `crypto/x509`. Fabricated `PeerCertificates`. Valid cert → user provisioned · no TLS → `ErrNoCert` · empty certs → `ErrNoCert` · no CN → `ErrInvalidCreds`. |
|
| 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. |
|
||||||
| Integration tests | ✅ | Real TLS listener on localhost. No cert / wrong CA / expired → TLS handshake rejected. Valid cert → 200. Peer certificate CN + email visibility verified. |
|
| Admin navigation | ✅ | Replaced Back button with UserMenu in admin topbar. Eliminates back-button infinite loop. |
|
||||||
|
|
||||||
### v0.6.6 — Final Hardening
|
### v0.2.6 — Admin Settings Audit
|
||||||
|
|
||||||
Final pass before public release. Security, correctness, and developer experience.
|
|
||||||
|
|
||||||
| Step | Status | Description |
|
| 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. |
|
| 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. |
|
||||||
| `ValidateManifest()` gate | ✅ | Single `ValidateManifest()` function in `package_validate.go`. Called at install time (both upload and bundled). 12 unit tests. |
|
| 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. |
|
||||||
| 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. |
|
|
||||||
|
|
||||||
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
|
## 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
|
- Rich media extensions: image generation, code sandbox, STT/TTS
|
||||||
- Desktop app (Tauri or Electron)
|
- Desktop app (Tauri or Electron)
|
||||||
- Sidecar tier: container-based extensions
|
- Sidecar tier: container-based extensions
|
||||||
- Federation: cross-instance package sharing
|
- Federation: cross-instance package sharing
|
||||||
- Plugin marketplace with signing and review
|
- Plugin marketplace with signing and review
|
||||||
|
- Mermaid diagrams extension (nice-to-have)
|
||||||
---
|
|
||||||
|
|
||||||
## Design Decisions Log
|
## Design Decisions Log
|
||||||
|
|
||||||
| Decision | Rationale |
|
| 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. |
|
| 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). |
|
| 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. |
|
||||||
| `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. |
|
| `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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
||||||
| 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. |
|
|
||||||
| 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. |
|
| 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. |
|
| 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
|
# docker compose up --build
|
||||||
# open http://localhost:3000
|
# open http://localhost:3000
|
||||||
#
|
#
|
||||||
# Data persists in the `sb_data` named volume between restarts.
|
# Data persists in ./data/ between restarts.
|
||||||
# To reset: docker compose down -v
|
# To reset: rm -rf ./data/
|
||||||
#
|
#
|
||||||
# For Postgres / multi-replica / k8s deployment see k8s/ and Dockerfile.
|
# For Postgres / multi-replica / k8s deployment see k8s/ and Dockerfile.
|
||||||
|
|
||||||
@@ -33,14 +33,10 @@ services:
|
|||||||
EXT_ALLOW_PRIVATE_IPS: ${EXT_ALLOW_PRIVATE_IPS:-true}
|
EXT_ALLOW_PRIVATE_IPS: ${EXT_ALLOW_PRIVATE_IPS:-true}
|
||||||
LOG_FORMAT: ${LOG_FORMAT:-text}
|
LOG_FORMAT: ${LOG_FORMAT:-text}
|
||||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
BUNDLED_PACKAGES: ${BUNDLED_PACKAGES:-}
|
|
||||||
# Dev seed users — ignored if ENVIRONMENT=production
|
# Dev seed users — ignored if ENVIRONMENT=production
|
||||||
SEED_USERS: ${SEED_USERS:-alice:password123:user,bob:password456:user,charlie:password789:user}
|
SEED_USERS: ${SEED_USERS:-alice:password123:user,bob:password456:user,charlie:password789:user}
|
||||||
volumes:
|
volumes:
|
||||||
- sb_data:/data
|
- ./data:/data
|
||||||
ports:
|
ports:
|
||||||
- "3000:80"
|
- "3000:80"
|
||||||
restart: unless-stopped
|
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
|
providers are all packages. The editor, chat, and admin UI will themselves
|
||||||
be installable surface packages.
|
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
|
## Kernel Components
|
||||||
|
|
||||||
### Identity & Auth
|
### Identity & Auth
|
||||||
@@ -62,31 +36,6 @@ permissions, settings, and data against.
|
|||||||
Auth modes: `builtin` (password), `mtls` (client cert), `oidc` (Keycloak
|
Auth modes: `builtin` (password), `mtls` (client cert), `oidc` (Keycloak
|
||||||
et al.). Mode is set at deploy time via `AUTH_MODE` env var.
|
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 & Groups
|
||||||
|
|
||||||
Teams provide horizontal isolation — users see only their team's resources.
|
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),
|
Tiers: `browser` (JS only), `starlark` (sandboxed server-side),
|
||||||
`sidecar` (container, future).
|
`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
|
### Starlark Sandbox
|
||||||
|
|
||||||
Extensions declare capabilities in their manifest. The admin grants or
|
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).
|
Extensions will subscribe to event patterns at install time (v0.2.0).
|
||||||
Match expressions start as exact strings, grow to globs later.
|
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
|
### Storage & Notifications
|
||||||
|
|
||||||
**Object storage**: PVC (local disk) or S3-compatible. Used by extensions
|
**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,
|
INSERT RETURNING, no `NULLS FIRST`, no boolean literals, no `$N` reuse,
|
||||||
`database.ST()`/`database.SNT()` wrappers for time scanning.
|
`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
|
## Frontend
|
||||||
|
|
||||||
Preact (3KB) + htm (tagged template literals). No build step, no bundler
|
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`
|
Registry: `registry.gobha.me:5000/xcaliber/switchboard-core`
|
||||||
Namespace: `gobha-ai-chat`
|
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",
|
"id": "csv-table",
|
||||||
"title": "CSV Table Viewer",
|
"name": "CSV Table Viewer",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "extension",
|
|
||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
"author": "switchboard",
|
"author": "switchboard",
|
||||||
"description": "Renders ```csv code blocks as sortable, interactive HTML tables",
|
"description": "Renders ```csv code blocks as sortable, interactive HTML tables",
|
||||||
"requires": [],
|
|
||||||
"permissions": [],
|
"permissions": [],
|
||||||
"tools": [],
|
"tools": [],
|
||||||
"surfaces": [],
|
"surfaces": [],
|
||||||
@@ -3,68 +3,71 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// Renders ```csv and ```tsv code blocks as sortable HTML tables.
|
// Renders ```csv and ```tsv code blocks as sortable HTML tables.
|
||||||
// No external dependencies.
|
// No external dependencies.
|
||||||
//
|
|
||||||
// Registers with sw.renderers via the sw:ready event.
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
(function () {
|
Extensions.register({
|
||||||
'use strict';
|
id: 'csv-table',
|
||||||
|
|
||||||
function _escapeHtml(str) {
|
async init(ctx) {
|
||||||
const div = document.createElement('div');
|
const self = this;
|
||||||
div.textContent = str;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _parseCSV(text, delimiter) {
|
// ── Inject styles ──
|
||||||
const rows = [];
|
this._injectStyles();
|
||||||
let row = [];
|
|
||||||
let field = '';
|
|
||||||
let inQuotes = false;
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
while (i < text.length) {
|
ctx.renderers.register('csv-block', {
|
||||||
const ch = text[i];
|
type: 'block',
|
||||||
if (inQuotes) {
|
priority: 10,
|
||||||
if (ch === '"') {
|
match(lang) {
|
||||||
if (i + 1 < text.length && text[i + 1] === '"') {
|
const l = (lang || '').toLowerCase();
|
||||||
field += '"'; i += 2;
|
return l === 'csv' || l === 'tsv';
|
||||||
} else {
|
},
|
||||||
inQuotes = false; i++;
|
render(lang, code, container) {
|
||||||
}
|
const delimiter = lang.toLowerCase() === 'tsv' ? '\t' : ',';
|
||||||
} else {
|
const rows = self._parseCSV(code.trim(), delimiter);
|
||||||
field += ch; i++;
|
|
||||||
}
|
if (rows.length === 0) {
|
||||||
} else {
|
container.innerHTML = '<div class="csv-empty">No data</div>';
|
||||||
if (ch === '"' && field === '') {
|
return;
|
||||||
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++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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());
|
_attachHandlers(container, tableId, rawCSV) {
|
||||||
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) {
|
|
||||||
const block = container.querySelector(`[data-csv-id="${tableId}"]`);
|
const block = container.querySelector(`[data-csv-id="${tableId}"]`);
|
||||||
if (!block) return;
|
if (!block) return;
|
||||||
|
|
||||||
|
// ── Column sorting ──
|
||||||
const ths = block.querySelectorAll('th[data-col]');
|
const ths = block.querySelectorAll('th[data-col]');
|
||||||
let sortCol = -1;
|
let sortCol = -1;
|
||||||
let sortAsc = true;
|
let sortAsc = true;
|
||||||
@@ -73,50 +76,129 @@
|
|||||||
th.style.cursor = 'pointer';
|
th.style.cursor = 'pointer';
|
||||||
th.addEventListener('click', () => {
|
th.addEventListener('click', () => {
|
||||||
const col = parseInt(th.dataset.col, 10);
|
const col = parseInt(th.dataset.col, 10);
|
||||||
if (sortCol === col) { sortAsc = !sortAsc; }
|
if (sortCol === col) {
|
||||||
else { sortCol = col; sortAsc = true; }
|
sortAsc = !sortAsc;
|
||||||
|
} else {
|
||||||
|
sortCol = col;
|
||||||
|
sortAsc = true;
|
||||||
|
}
|
||||||
|
|
||||||
const tbody = block.querySelector('tbody');
|
const tbody = block.querySelector('tbody');
|
||||||
const rows = Array.from(tbody.querySelectorAll('tr'));
|
const rows = Array.from(tbody.querySelectorAll('tr'));
|
||||||
|
|
||||||
rows.sort((a, b) => {
|
rows.sort((a, b) => {
|
||||||
const cellA = (a.children[col]?.textContent || '').trim();
|
const cellA = (a.children[col]?.textContent || '').trim();
|
||||||
const cellB = (b.children[col]?.textContent || '').trim();
|
const cellB = (b.children[col]?.textContent || '').trim();
|
||||||
const numA = parseFloat(cellA);
|
const numA = parseFloat(cellA);
|
||||||
const numB = parseFloat(cellB);
|
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;
|
return sortAsc ? numA - numB : numB - numA;
|
||||||
|
}
|
||||||
|
// String sort
|
||||||
const cmp = cellA.localeCompare(cellB, undefined, { numeric: true, sensitivity: 'base' });
|
const cmp = cellA.localeCompare(cellB, undefined, { numeric: true, sensitivity: 'base' });
|
||||||
return sortAsc ? cmp : -cmp;
|
return sortAsc ? cmp : -cmp;
|
||||||
});
|
});
|
||||||
|
|
||||||
rows.forEach(row => tbody.appendChild(row));
|
rows.forEach(row => tbody.appendChild(row));
|
||||||
|
|
||||||
|
// Update sort icons
|
||||||
ths.forEach(h => {
|
ths.forEach(h => {
|
||||||
const icon = h.querySelector('.csv-sort-icon');
|
const icon = h.querySelector('.csv-sort-icon');
|
||||||
if (icon) {
|
if (icon) {
|
||||||
const hCol = parseInt(h.dataset.col, 10);
|
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');
|
const copyBtn = block.querySelector('.csv-copy-btn');
|
||||||
if (copyBtn) {
|
if (copyBtn) {
|
||||||
copyBtn.addEventListener('click', () => {
|
copyBtn.addEventListener('click', () => {
|
||||||
navigator.clipboard.writeText(rawCSV).then(() => {
|
navigator.clipboard.writeText(rawCSV).then(() => {
|
||||||
copyBtn.textContent = '\u2713 Copied';
|
copyBtn.textContent = '✓ Copied';
|
||||||
setTimeout(() => { copyBtn.textContent = '\ud83d\udccb Copy'; }, 1500);
|
setTimeout(() => { copyBtn.textContent = '📋 Copy'; }, 1500);
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
copyBtn.textContent = '\u2717 Failed';
|
copyBtn.textContent = '✗ Failed';
|
||||||
setTimeout(() => { copyBtn.textContent = '\ud83d\udccb Copy'; }, 1500);
|
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;
|
if (document.getElementById('ext-style-csv-table')) return;
|
||||||
const style = document.createElement('style');
|
const style = document.createElement('style');
|
||||||
style.id = 'ext-style-csv-table';
|
style.id = 'ext-style-csv-table';
|
||||||
@@ -164,66 +246,9 @@
|
|||||||
}
|
}
|
||||||
.csv-empty { padding: 16px; text-align: center; color: var(--text-3); }`;
|
.csv-empty { padding: 16px; text-align: center; color: var(--text-3); }`;
|
||||||
document.head.appendChild(style);
|
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",
|
"id": "diff-viewer",
|
||||||
"title": "Diff Viewer",
|
"name": "Diff Viewer",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "extension",
|
|
||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
"author": "switchboard",
|
"author": "switchboard",
|
||||||
"description": "Renders ```diff code blocks with syntax-highlighted additions, deletions, and context lines",
|
"description": "Renders ```diff code blocks with syntax-highlighted additions, deletions, and context lines",
|
||||||
"requires": [],
|
|
||||||
"permissions": [],
|
"permissions": [],
|
||||||
"tools": [],
|
"tools": [],
|
||||||
"surfaces": [],
|
"surfaces": [],
|
||||||
@@ -4,22 +4,106 @@
|
|||||||
// Renders ```diff code blocks with syntax-highlighted
|
// Renders ```diff code blocks with syntax-highlighted
|
||||||
// additions, deletions, hunk headers, and context lines.
|
// additions, deletions, hunk headers, and context lines.
|
||||||
// No external dependencies.
|
// No external dependencies.
|
||||||
//
|
|
||||||
// Registers with sw.renderers via the sw:ready event.
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
(function () {
|
Extensions.register({
|
||||||
'use strict';
|
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');
|
const div = document.createElement('div');
|
||||||
div.textContent = str;
|
div.textContent = str;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
},
|
||||||
|
|
||||||
// ── Styles ──────────────────────────────
|
_injectStyles() {
|
||||||
|
|
||||||
function _injectStyles() {
|
|
||||||
if (document.getElementById('ext-style-diff-viewer')) return;
|
if (document.getElementById('ext-style-diff-viewer')) return;
|
||||||
const style = document.createElement('style');
|
const style = document.createElement('style');
|
||||||
style.id = 'ext-style-diff-viewer';
|
style.id = 'ext-style-diff-viewer';
|
||||||
@@ -74,86 +158,9 @@
|
|||||||
max-height: 200px; overflow: auto;
|
max-height: 200px; overflow: auto;
|
||||||
}`;
|
}`;
|
||||||
document.head.appendChild(style);
|
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",
|
"id": "js-sandbox",
|
||||||
"title": "JavaScript Sandbox",
|
"name": "JavaScript Sandbox",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "extension",
|
|
||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
"author": "switchboard",
|
"author": "switchboard",
|
||||||
"description": "Executes JavaScript in a sandboxed iframe. Allows the LLM to run code, verify calculations, and transform data — no server compute required.",
|
"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": [],
|
"permissions": [],
|
||||||
"tools": [
|
"tools": [
|
||||||
{
|
{
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
{
|
{
|
||||||
"id": "katex-renderer",
|
"id": "katex-renderer",
|
||||||
"title": "KaTeX Math",
|
"name": "KaTeX Math",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "extension",
|
|
||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
"author": "switchboard",
|
"author": "switchboard",
|
||||||
"description": "Renders LaTeX math expressions: ```latex blocks and inline $...$ / $$...$$ syntax",
|
"description": "Renders LaTeX math expressions: ```latex blocks and inline $...$ / $$...$$ syntax",
|
||||||
"requires": [],
|
|
||||||
"permissions": [],
|
"permissions": [],
|
||||||
"tools": [],
|
"tools": [],
|
||||||
"surfaces": [],
|
"surfaces": [],
|
||||||
@@ -3,141 +3,177 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// Renders ```latex / ```math code blocks and inline $...$ / $$...$$ syntax.
|
// Renders ```latex / ```math code blocks and inline $...$ / $$...$$ syntax.
|
||||||
// Loads KaTeX dynamically on first use.
|
// Loads KaTeX dynamically on first use.
|
||||||
//
|
|
||||||
// Registers with sw.renderers via the sw:ready event.
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
(function () {
|
Extensions.register({
|
||||||
'use strict';
|
id: 'katex-renderer',
|
||||||
|
|
||||||
let _katexReady = false;
|
_katexReady: false,
|
||||||
let _katexLoading = null;
|
_katexLoading: null,
|
||||||
|
|
||||||
function _escapeHtml(str) {
|
async init(ctx) {
|
||||||
const div = document.createElement('div');
|
const self = this;
|
||||||
div.textContent = str;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── KaTeX Library Loading ──────────────
|
// ── Inject styles ──
|
||||||
|
this._injectStyles();
|
||||||
|
|
||||||
function _loadKaTeX() {
|
// ── Block renderer: match ```latex or ```math ──
|
||||||
if (_katexReady) return Promise.resolve();
|
ctx.renderers.register('katex-block', {
|
||||||
if (_katexLoading) return _katexLoading;
|
type: 'block',
|
||||||
|
priority: 10,
|
||||||
_katexLoading = new Promise((resolve, reject) => {
|
match(lang) {
|
||||||
if (typeof katex !== 'undefined') {
|
const l = (lang || '').toLowerCase();
|
||||||
_katexReady = true;
|
return l === 'latex' || l === 'math' || l === 'tex' || l === 'katex';
|
||||||
resolve();
|
},
|
||||||
return;
|
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 code = decodeURIComponent(el.dataset.katexSrc);
|
||||||
const displayMode = el.dataset.katexDisplay === 'true';
|
const displayMode = el.dataset.katexDisplay === 'true';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _loadKaTeX();
|
await this._loadKaTeX();
|
||||||
el.innerHTML = katex.renderToString(code, {
|
el.innerHTML = katex.renderToString(code, {
|
||||||
displayMode, throwOnError: false, trust: false, strict: false,
|
displayMode,
|
||||||
|
throwOnError: false,
|
||||||
|
trust: false,
|
||||||
|
strict: false,
|
||||||
});
|
});
|
||||||
el.dataset.rendered = 'true';
|
el.dataset.rendered = 'true';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="katex-error">
|
<div class="katex-error">
|
||||||
<strong>Math error:</strong> ${_escapeHtml(e.message || String(e))}
|
<strong>Math error:</strong> ${this._escapeHtml(e.message || String(e))}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
el.dataset.rendered = 'error';
|
el.dataset.rendered = 'error';
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
// ── Inline Math Processing ───────────────
|
async _processInlineMath(container) {
|
||||||
|
// Skip if no $ signs present at all
|
||||||
async function _processInlineMath(container) {
|
|
||||||
if (!container.textContent || !container.textContent.includes('$')) return;
|
if (!container.textContent || !container.textContent.includes('$')) return;
|
||||||
try { await _loadKaTeX(); } catch { return; }
|
|
||||||
|
|
||||||
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
|
try {
|
||||||
acceptNode(node) {
|
await this._loadKaTeX();
|
||||||
const parent = node.parentElement;
|
} catch {
|
||||||
if (!parent) return NodeFilter.FILTER_REJECT;
|
return;
|
||||||
const tag = parent.tagName;
|
}
|
||||||
if (tag === 'CODE' || tag === 'PRE' || tag === 'SCRIPT' ||
|
|
||||||
tag === 'TEXTAREA' || tag === 'STYLE') return NodeFilter.FILTER_REJECT;
|
// Walk text nodes, skip code/pre/katex-already-rendered
|
||||||
if (parent.closest('.katex, .katex-block, .katex-display-container, code, pre'))
|
const walker = document.createTreeWalker(
|
||||||
return NodeFilter.FILTER_REJECT;
|
container,
|
||||||
if (!node.textContent.includes('$')) return NodeFilter.FILTER_REJECT;
|
NodeFilter.SHOW_TEXT,
|
||||||
return NodeFilter.FILTER_ACCEPT;
|
{
|
||||||
|
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 = [];
|
const textNodes = [];
|
||||||
let node;
|
let node;
|
||||||
while (node = walker.nextNode()) textNodes.push(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;
|
const text = textNode.textContent;
|
||||||
|
// Match $$...$$ (display) and $...$ (inline), non-greedy
|
||||||
|
// Avoid matching escaped \$ or empty $$
|
||||||
const pattern = /\$\$([^$]+?)\$\$|\$([^$\n]+?)\$/g;
|
const pattern = /\$\$([^$]+?)\$\$|\$([^$\n]+?)\$/g;
|
||||||
let match;
|
let match;
|
||||||
const parts = [];
|
const parts = [];
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
|
|
||||||
while ((match = pattern.exec(text)) !== null) {
|
while ((match = pattern.exec(text)) !== null) {
|
||||||
|
// Text before the match
|
||||||
if (match.index > lastIndex) {
|
if (match.index > lastIndex) {
|
||||||
parts.push({ type: 'text', value: text.slice(lastIndex, match.index) });
|
parts.push({ type: 'text', value: text.slice(lastIndex, match.index) });
|
||||||
}
|
}
|
||||||
const displayMode = !!match[1];
|
|
||||||
|
const displayMode = !!match[1]; // $$ ... $$
|
||||||
const expr = match[1] || match[2];
|
const expr = match[1] || match[2];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const html = katex.renderToString(expr.trim(), {
|
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 });
|
parts.push({ type: 'katex', value: html, displayMode });
|
||||||
} catch {
|
} catch {
|
||||||
|
// Leave as-is on error
|
||||||
parts.push({ type: 'text', value: match[0] });
|
parts.push({ type: 'text', value: match[0] });
|
||||||
}
|
}
|
||||||
|
|
||||||
lastIndex = match.index + match[0].length;
|
lastIndex = match.index + match[0].length;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parts.length === 0) return;
|
if (parts.length === 0) return; // No math found
|
||||||
if (lastIndex < text.length) parts.push({ type: 'text', value: text.slice(lastIndex) });
|
|
||||||
|
|
||||||
|
// 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');
|
const span = document.createElement('span');
|
||||||
span.className = 'katex-inline-container';
|
span.className = 'katex-inline-container';
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
@@ -150,12 +186,70 @@
|
|||||||
span.appendChild(wrapper);
|
span.appendChild(wrapper);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
textNode.parentNode.replaceChild(span, textNode);
|
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;
|
if (document.getElementById('ext-style-katex-renderer')) return;
|
||||||
const style = document.createElement('style');
|
const style = document.createElement('style');
|
||||||
style.id = 'ext-style-katex-renderer';
|
style.id = 'ext-style-katex-renderer';
|
||||||
@@ -195,64 +289,9 @@
|
|||||||
.katex-inline .katex { font-size: 1.05em; }
|
.katex-inline .katex { font-size: 1.05em; }
|
||||||
.katex-display { display: block; text-align: center; margin: 8px 0; }`;
|
.katex-display { display: block; text-align: center; margin: 8px 0; }`;
|
||||||
document.head.appendChild(style);
|
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",
|
"id": "mermaid-renderer",
|
||||||
"title": "Mermaid Diagrams",
|
"name": "Mermaid Diagrams",
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"type": "extension",
|
|
||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
"author": "switchboard",
|
"author": "switchboard",
|
||||||
"description": "Renders ```mermaid code blocks as interactive SVG diagrams with zoom/pan, SVG/PNG export, and source copy",
|
"description": "Renders ```mermaid code blocks as interactive SVG diagrams with zoom/pan, SVG/PNG export, and source copy",
|
||||||
"requires": [],
|
|
||||||
"permissions": [],
|
"permissions": [],
|
||||||
"tools": [],
|
"tools": [],
|
||||||
"surfaces": [],
|
"surfaces": [],
|
||||||
@@ -2,33 +2,94 @@
|
|||||||
// Mermaid Diagram Renderer — Browser Extension
|
// Mermaid Diagram Renderer — Browser Extension
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Renders ```mermaid code blocks as interactive SVG diagrams.
|
// Renders ```mermaid code blocks as interactive SVG diagrams.
|
||||||
// Features: viewBox-based zoom/pan, fullscreen expand,
|
// Features: viewBox-based zoom/pan, context-aware expand (fullscreen
|
||||||
// SVG/PNG export, source copy.
|
// or side-panel pop-out), SVG/PNG export, source copy.
|
||||||
// Loads mermaid.js dynamically on first use.
|
// 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 () {
|
Extensions.register({
|
||||||
'use strict';
|
id: 'mermaid-renderer',
|
||||||
|
|
||||||
let _mermaidReady = false;
|
_mermaidReady: false,
|
||||||
let _mermaidLoading = null;
|
_mermaidLoading: null,
|
||||||
|
_ctx: null,
|
||||||
|
|
||||||
function _isDark() {
|
async init(ctx) {
|
||||||
return document.documentElement.dataset.theme === 'dark' ||
|
const self = this;
|
||||||
document.documentElement.classList.contains('dark');
|
this._ctx = ctx;
|
||||||
}
|
|
||||||
|
|
||||||
function _escapeHtml(str) {
|
this._injectStyles();
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = str;
|
// ── Block renderer: match ```mermaid ──
|
||||||
return div.innerHTML;
|
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 ──────────────
|
// ── ViewBox Zoom/Pan State ──────────────
|
||||||
|
|
||||||
function _getState(id) {
|
_getState(id) {
|
||||||
const vp = document.querySelector(`[data-viewport="${id}"]`);
|
const vp = document.querySelector(`[data-viewport="${id}"]`);
|
||||||
if (!vp) return null;
|
if (!vp) return null;
|
||||||
if (!vp._mmdState) {
|
if (!vp._mmdState) {
|
||||||
@@ -41,10 +102,10 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
return vp._mmdState;
|
return vp._mmdState;
|
||||||
}
|
},
|
||||||
|
|
||||||
function _initState(id) {
|
_initState(id) {
|
||||||
const state = _getState(id);
|
const state = this._getState(id);
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
|
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
|
||||||
if (!svg) return;
|
if (!svg) return;
|
||||||
@@ -52,51 +113,69 @@
|
|||||||
const vb = svg.getAttribute('viewBox');
|
const vb = svg.getAttribute('viewBox');
|
||||||
if (!vb) {
|
if (!vb) {
|
||||||
const bbox = svg.getBBox();
|
const bbox = svg.getBBox();
|
||||||
state.natX = bbox.x; state.natY = bbox.y;
|
state.natX = bbox.x;
|
||||||
state.natW = bbox.width; state.natH = bbox.height;
|
state.natY = bbox.y;
|
||||||
|
state.natW = bbox.width;
|
||||||
|
state.natH = bbox.height;
|
||||||
} else {
|
} else {
|
||||||
const parts = vb.trim().split(/[\s,]+/).map(Number);
|
const parts = vb.trim().split(/[\s,]+/).map(Number);
|
||||||
state.natX = parts[0] || 0; state.natY = parts[1] || 0;
|
state.natX = parts[0] || 0;
|
||||||
state.natW = parts[2] || 0; state.natH = parts[3] || 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.vbX = state.natX;
|
||||||
state.vbW = state.natW; state.vbH = state.natH;
|
state.vbY = state.natY;
|
||||||
|
state.vbW = state.natW;
|
||||||
|
state.vbH = state.natH;
|
||||||
state.ready = true;
|
state.ready = true;
|
||||||
_applyViewBox(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function _applyViewBox(id) {
|
this._applyViewBox(id);
|
||||||
const state = _getState(id);
|
},
|
||||||
|
|
||||||
|
_applyViewBox(id) {
|
||||||
|
const state = this._getState(id);
|
||||||
if (!state || !state.ready) return;
|
if (!state || !state.ready) return;
|
||||||
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
|
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
|
||||||
if (!svg) return;
|
if (!svg) return;
|
||||||
|
|
||||||
svg.setAttribute('viewBox', `${state.vbX} ${state.vbY} ${state.vbW} ${state.vbH}`);
|
svg.setAttribute('viewBox', `${state.vbX} ${state.vbY} ${state.vbW} ${state.vbH}`);
|
||||||
|
|
||||||
const zoomPct = Math.round((state.natW / state.vbW) * 100);
|
const zoomPct = Math.round((state.natW / state.vbW) * 100);
|
||||||
const label = document.querySelector(`[data-zoom-label="${id}"]`);
|
const label = document.querySelector(`[data-zoom-label="${id}"]`);
|
||||||
if (label) label.textContent = zoomPct + '%';
|
if (label) label.textContent = zoomPct + '%';
|
||||||
}
|
},
|
||||||
|
|
||||||
function _zoom(id, factor) {
|
_zoom(id, factor) {
|
||||||
const state = _getState(id);
|
const state = this._getState(id);
|
||||||
if (!state || !state.ready) return;
|
if (!state || !state.ready) return;
|
||||||
|
|
||||||
const scale = 1 / (1 + factor);
|
const scale = 1 / (1 + factor);
|
||||||
const newW = state.vbW * scale;
|
const newW = state.vbW * scale;
|
||||||
const newH = state.vbH * 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 cx = state.vbX + state.vbW / 2;
|
||||||
const cy = state.vbY + state.vbH / 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) {
|
state.vbW = newW;
|
||||||
const state = _getState(id);
|
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;
|
if (!state || !state.ready) return;
|
||||||
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
|
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
|
||||||
if (!svg) return;
|
if (!svg) return;
|
||||||
|
|
||||||
const svgRect = svg.getBoundingClientRect();
|
const svgRect = svg.getBoundingClientRect();
|
||||||
if (svgRect.width === 0 || svgRect.height === 0) return;
|
if (svgRect.width === 0 || svgRect.height === 0) return;
|
||||||
|
|
||||||
@@ -108,76 +187,147 @@
|
|||||||
const scale = 1 / (1 + factor);
|
const scale = 1 / (1 + factor);
|
||||||
const newW = state.vbW * scale;
|
const newW = state.vbW * scale;
|
||||||
const newH = state.vbH * 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 minW = state.natW / 20;
|
||||||
const state = _getState(id);
|
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;
|
if (!state || !state.ready) return;
|
||||||
state.vbX = state.natX; state.vbY = state.natY;
|
state.vbX = state.natX;
|
||||||
state.vbW = state.natW; state.vbH = state.natH;
|
state.vbY = state.natY;
|
||||||
_applyViewBox(id);
|
state.vbW = state.natW;
|
||||||
}
|
state.vbH = state.natH;
|
||||||
|
this._applyViewBox(id);
|
||||||
|
},
|
||||||
|
|
||||||
function _zoomFit(id) {
|
_zoomFit(id) {
|
||||||
const state = _getState(id);
|
const state = this._getState(id);
|
||||||
if (!state || !state.ready) return;
|
if (!state || !state.ready) return;
|
||||||
const vp = document.querySelector(`[data-viewport="${id}"]`);
|
const vp = document.querySelector(`[data-viewport="${id}"]`);
|
||||||
if (!vp) return;
|
if (!vp) return;
|
||||||
|
|
||||||
const vpRect = vp.getBoundingClientRect();
|
const vpRect = vp.getBoundingClientRect();
|
||||||
if (vpRect.width === 0 || vpRect.height === 0) return;
|
if (vpRect.width === 0 || vpRect.height === 0) return;
|
||||||
|
|
||||||
const vpAspect = vpRect.width / vpRect.height;
|
const vpAspect = vpRect.width / vpRect.height;
|
||||||
const natAspect = state.natW / state.natH;
|
const natAspect = state.natW / state.natH;
|
||||||
|
|
||||||
if (vpAspect > natAspect) {
|
if (vpAspect > natAspect) {
|
||||||
const newW = state.natH * vpAspect;
|
const newW = state.natH * vpAspect;
|
||||||
state.vbX = state.natX - (newW - state.natW) / 2;
|
state.vbX = state.natX - (newW - state.natW) / 2;
|
||||||
state.vbY = state.natY;
|
state.vbY = state.natY;
|
||||||
state.vbW = newW; state.vbH = state.natH;
|
state.vbW = newW;
|
||||||
|
state.vbH = state.natH;
|
||||||
} else {
|
} else {
|
||||||
const newH = state.natW / vpAspect;
|
const newH = state.natW / vpAspect;
|
||||||
state.vbX = state.natX;
|
state.vbX = state.natX;
|
||||||
state.vbY = state.natY - (newH - state.natH) / 2;
|
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 ──────────────────────────
|
// ── Fullscreen ──────────────────────────
|
||||||
|
|
||||||
function _toggleFullscreen(id) {
|
_toggleFullscreen(id) {
|
||||||
const block = document.querySelector(`[data-mermaid-id="${id}"]`);
|
const block = document.querySelector(`[data-mermaid-id="${id}"]`);
|
||||||
if (!block) return;
|
if (!block) return;
|
||||||
|
|
||||||
const isFS = block.classList.toggle('mermaid-fullscreen');
|
const isFS = block.classList.toggle('mermaid-fullscreen');
|
||||||
|
|
||||||
if (isFS) {
|
if (isFS) {
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
// Close button overlay (always visible — critical for mobile)
|
||||||
const closeBtn = document.createElement('button');
|
const closeBtn = document.createElement('button');
|
||||||
closeBtn.className = 'mmd-fullscreen-close';
|
closeBtn.className = 'mmd-fullscreen-close';
|
||||||
closeBtn.innerHTML = '\u2715';
|
closeBtn.innerHTML = '\u2715';
|
||||||
closeBtn.title = 'Close fullscreen';
|
closeBtn.title = 'Close fullscreen';
|
||||||
closeBtn.addEventListener('click', () => _toggleFullscreen(id));
|
closeBtn.addEventListener('click', () => this._toggleFullscreen(id));
|
||||||
block.appendChild(closeBtn);
|
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);
|
document.addEventListener('keydown', block._mmdEscHandler);
|
||||||
requestAnimationFrame(() => _zoomFit(id));
|
|
||||||
|
requestAnimationFrame(() => this._zoomFit(id));
|
||||||
} else {
|
} else {
|
||||||
document.body.style.overflow = '';
|
document.body.style.overflow = '';
|
||||||
|
|
||||||
const closeBtn = block.querySelector('.mmd-fullscreen-close');
|
const closeBtn = block.querySelector('.mmd-fullscreen-close');
|
||||||
if (closeBtn) closeBtn.remove();
|
if (closeBtn) closeBtn.remove();
|
||||||
|
|
||||||
if (block._mmdEscHandler) {
|
if (block._mmdEscHandler) {
|
||||||
document.removeEventListener('keydown', block._mmdEscHandler);
|
document.removeEventListener('keydown', block._mmdEscHandler);
|
||||||
delete 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 ──────────────────────
|
// ── Toolbar Wiring ──────────────────────
|
||||||
|
|
||||||
function _wireToolbar(container) {
|
_wireToolbar(container) {
|
||||||
|
const self = this;
|
||||||
|
|
||||||
if (container._mmdWired) return;
|
if (container._mmdWired) return;
|
||||||
container._mmdWired = true;
|
container._mmdWired = true;
|
||||||
|
|
||||||
@@ -189,14 +339,14 @@
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'zoom-in': _zoom(id, 0.25); break;
|
case 'zoom-in': self._zoom(id, 0.25); break;
|
||||||
case 'zoom-out': _zoom(id, -0.2); break;
|
case 'zoom-out': self._zoom(id, -0.2); break;
|
||||||
case 'zoom-reset': _zoomReset(id); break;
|
case 'zoom-reset': self._zoomReset(id); break;
|
||||||
case 'zoom-fit': _zoomFit(id); break;
|
case 'zoom-fit': self._zoomFit(id); break;
|
||||||
case 'expand': _toggleFullscreen(id); break;
|
case 'expand': self._expand(id); break;
|
||||||
case 'export-svg': _exportSVG(id); break;
|
case 'export-svg': self._exportSVG(id); break;
|
||||||
case 'export-png': _exportPNG(id); break;
|
case 'export-png': self._exportPNG(id); break;
|
||||||
case 'copy-src': _copySource(id); break;
|
case 'copy-src': self._copySource(id); break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -209,35 +359,42 @@
|
|||||||
vp.addEventListener('wheel', (e) => {
|
vp.addEventListener('wheel', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
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 });
|
}, { passive: false });
|
||||||
|
|
||||||
vp.addEventListener('mousedown', (e) => {
|
vp.addEventListener('mousedown', (e) => {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
const state = _getState(id);
|
const state = self._getState(id);
|
||||||
if (!state || !state.ready) return;
|
if (!state || !state.ready) return;
|
||||||
state.dragging = true;
|
state.dragging = true;
|
||||||
state.startX = e.clientX; state.startY = e.clientY;
|
state.startX = e.clientX;
|
||||||
state.startVbX = state.vbX; state.startVbY = state.vbY;
|
state.startY = e.clientY;
|
||||||
|
state.startVbX = state.vbX;
|
||||||
|
state.startVbY = state.vbY;
|
||||||
vp.classList.add('mmd-grabbing');
|
vp.classList.add('mmd-grabbing');
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
});
|
});
|
||||||
|
|
||||||
vp.addEventListener('mousemove', (e) => {
|
vp.addEventListener('mousemove', (e) => {
|
||||||
const state = _getState(id);
|
const state = self._getState(id);
|
||||||
if (!state?.dragging) return;
|
if (!state?.dragging) return;
|
||||||
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
|
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
|
||||||
if (!svg) return;
|
if (!svg) return;
|
||||||
|
|
||||||
const svgRect = svg.getBoundingClientRect();
|
const svgRect = svg.getBoundingClientRect();
|
||||||
if (svgRect.width === 0) return;
|
if (svgRect.width === 0) return;
|
||||||
|
|
||||||
const pxToVb = state.vbW / svgRect.width;
|
const pxToVb = state.vbW / svgRect.width;
|
||||||
state.vbX = state.startVbX - (e.clientX - state.startX) * pxToVb;
|
const dx = (e.clientX - state.startX) * pxToVb;
|
||||||
state.vbY = state.startVbY - (e.clientY - state.startY) * pxToVb;
|
const dy = (e.clientY - state.startY) * pxToVb;
|
||||||
_applyViewBox(id);
|
|
||||||
|
state.vbX = state.startVbX - dx;
|
||||||
|
state.vbY = state.startVbY - dy;
|
||||||
|
self._applyViewBox(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
const endDrag = () => {
|
const endDrag = () => {
|
||||||
const state = _getState(id);
|
const state = self._getState(id);
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
state.dragging = false;
|
state.dragging = false;
|
||||||
vp.classList.remove('mmd-grabbing');
|
vp.classList.remove('mmd-grabbing');
|
||||||
@@ -246,8 +403,9 @@
|
|||||||
vp.addEventListener('mouseleave', endDrag);
|
vp.addEventListener('mouseleave', endDrag);
|
||||||
|
|
||||||
let lastTouchDist = 0;
|
let lastTouchDist = 0;
|
||||||
|
let lastTouchCenter = null;
|
||||||
vp.addEventListener('touchstart', (e) => {
|
vp.addEventListener('touchstart', (e) => {
|
||||||
const state = _getState(id);
|
const state = self._getState(id);
|
||||||
if (!state || !state.ready) return;
|
if (!state || !state.ready) return;
|
||||||
if (e.touches.length === 1) {
|
if (e.touches.length === 1) {
|
||||||
state.dragging = true;
|
state.dragging = true;
|
||||||
@@ -260,21 +418,28 @@
|
|||||||
e.touches[0].clientX - e.touches[1].clientX,
|
e.touches[0].clientX - e.touches[1].clientX,
|
||||||
e.touches[0].clientY - e.touches[1].clientY
|
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 });
|
}, { passive: true });
|
||||||
|
|
||||||
vp.addEventListener('touchmove', (e) => {
|
vp.addEventListener('touchmove', (e) => {
|
||||||
const state = _getState(id);
|
const state = self._getState(id);
|
||||||
if (!state || !state.ready) return;
|
if (!state || !state.ready) return;
|
||||||
if (e.touches.length === 1 && state.dragging) {
|
if (e.touches.length === 1 && state.dragging) {
|
||||||
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
|
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
|
||||||
if (!svg) return;
|
if (!svg) return;
|
||||||
const svgRect = svg.getBoundingClientRect();
|
const svgRect = svg.getBoundingClientRect();
|
||||||
if (svgRect.width === 0) return;
|
if (svgRect.width === 0) return;
|
||||||
|
|
||||||
const pxToVb = state.vbW / svgRect.width;
|
const pxToVb = state.vbW / svgRect.width;
|
||||||
state.vbX = state.startVbX - (e.touches[0].clientX - state.startX) * pxToVb;
|
const dx = (e.touches[0].clientX - state.startX) * pxToVb;
|
||||||
state.vbY = state.startVbY - (e.touches[0].clientY - state.startY) * pxToVb;
|
const dy = (e.touches[0].clientY - state.startY) * pxToVb;
|
||||||
_applyViewBox(id);
|
state.vbX = state.startVbX - dx;
|
||||||
|
state.vbY = state.startVbY - dy;
|
||||||
|
self._applyViewBox(id);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
} else if (e.touches.length === 2) {
|
} else if (e.touches.length === 2) {
|
||||||
const dist = Math.hypot(
|
const dist = Math.hypot(
|
||||||
@@ -287,42 +452,46 @@
|
|||||||
};
|
};
|
||||||
if (lastTouchDist > 0) {
|
if (lastTouchDist > 0) {
|
||||||
const factor = (dist - lastTouchDist) / lastTouchDist;
|
const factor = (dist - lastTouchDist) / lastTouchDist;
|
||||||
_zoomAt(id, factor, center.x, center.y);
|
self._zoomAt(id, factor, center.x, center.y);
|
||||||
}
|
}
|
||||||
lastTouchDist = dist;
|
lastTouchDist = dist;
|
||||||
|
lastTouchCenter = center;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
}, { passive: false });
|
}, { passive: false });
|
||||||
|
|
||||||
vp.addEventListener('touchend', () => {
|
vp.addEventListener('touchend', () => {
|
||||||
const state = _getState(id);
|
const state = self._getState(id);
|
||||||
if (state) state.dragging = false;
|
if (state) state.dragging = false;
|
||||||
lastTouchDist = 0;
|
lastTouchDist = 0;
|
||||||
|
lastTouchCenter = null;
|
||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
|
|
||||||
// ── Export ───────────────────────────────
|
// ── Export ───────────────────────────────
|
||||||
|
|
||||||
function _exportSVG(id) {
|
_exportSVG(id) {
|
||||||
const diagram = document.querySelector(`[data-diagram="${id}"]`);
|
const diagram = document.querySelector(`[data-diagram="${id}"]`);
|
||||||
const svg = diagram?.querySelector('svg');
|
const svg = diagram?.querySelector('svg');
|
||||||
if (!svg) return;
|
if (!svg) return;
|
||||||
const state = _getState(id);
|
|
||||||
|
const state = this._getState(id);
|
||||||
const clone = svg.cloneNode(true);
|
const clone = svg.cloneNode(true);
|
||||||
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||||
if (state?.ready) {
|
if (state?.ready) {
|
||||||
clone.setAttribute('viewBox', `${state.natX} ${state.natY} ${state.natW} ${state.natH}`);
|
clone.setAttribute('viewBox', `${state.natX} ${state.natY} ${state.natW} ${state.natH}`);
|
||||||
}
|
}
|
||||||
const blob = new Blob([clone.outerHTML], { type: 'image/svg+xml' });
|
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 diagram = document.querySelector(`[data-diagram="${id}"]`);
|
||||||
const svg = diagram?.querySelector('svg');
|
const svg = diagram?.querySelector('svg');
|
||||||
if (!svg) return;
|
if (!svg) return;
|
||||||
const state = _getState(id);
|
|
||||||
|
const state = this._getState(id);
|
||||||
const clone = svg.cloneNode(true);
|
const clone = svg.cloneNode(true);
|
||||||
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||||
if (state?.ready) {
|
if (state?.ready) {
|
||||||
@@ -334,6 +503,7 @@
|
|||||||
const svgData = new XMLSerializer().serializeToString(clone);
|
const svgData = new XMLSerializer().serializeToString(clone);
|
||||||
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
||||||
const url = URL.createObjectURL(svgBlob);
|
const url = URL.createObjectURL(svgBlob);
|
||||||
|
const self = this;
|
||||||
|
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
@@ -345,8 +515,9 @@
|
|||||||
ctx.scale(scale, scale);
|
ctx.scale(scale, scale);
|
||||||
ctx.drawImage(img, 0, 0);
|
ctx.drawImage(img, 0, 0);
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
canvas.toBlob((blob) => {
|
canvas.toBlob((blob) => {
|
||||||
if (blob) _download(blob, `diagram-${id}.png`);
|
if (blob) self._download(blob, `diagram-${id}.png`);
|
||||||
}, 'image/png');
|
}, 'image/png');
|
||||||
};
|
};
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
@@ -354,9 +525,9 @@
|
|||||||
console.error('[Mermaid] PNG export failed');
|
console.error('[Mermaid] PNG export failed');
|
||||||
};
|
};
|
||||||
img.src = url;
|
img.src = url;
|
||||||
}
|
},
|
||||||
|
|
||||||
function _download(blob, filename) {
|
_download(blob, filename) {
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = URL.createObjectURL(blob);
|
a.href = URL.createObjectURL(blob);
|
||||||
a.download = filename;
|
a.download = filename;
|
||||||
@@ -364,24 +535,25 @@
|
|||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||||
}
|
},
|
||||||
|
|
||||||
function _copySource(id) {
|
_copySource(id) {
|
||||||
const code = document.querySelector(`[data-source="${id}"]`);
|
const code = document.querySelector(`[data-source="${id}"]`);
|
||||||
if (!code) return;
|
if (!code) return;
|
||||||
navigator.clipboard.writeText(code.textContent).then(() => {
|
navigator.clipboard.writeText(code.textContent).then(() => {
|
||||||
if (window.sw?.toast) sw.toast('Source copied', 'success');
|
this._ctx.ui.toast('Source copied', 'success');
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
|
|
||||||
// ── Diagram Rendering ───────────────────
|
// ── Diagram Rendering ───────────────────
|
||||||
|
|
||||||
async function _renderDiagram(el) {
|
async _renderDiagram(el) {
|
||||||
const code = decodeURIComponent(el.dataset.mermaidSrc);
|
const code = decodeURIComponent(el.dataset.mermaidSrc);
|
||||||
const id = el.dataset.diagram;
|
const id = el.dataset.diagram;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _loadMermaid();
|
await this._loadMermaid();
|
||||||
|
|
||||||
const svgId = 'mmd-svg-' + Math.random().toString(36).slice(2, 9);
|
const svgId = 'mmd-svg-' + Math.random().toString(36).slice(2, 9);
|
||||||
const { svg } = await mermaid.render(svgId, code);
|
const { svg } = await mermaid.render(svgId, code);
|
||||||
el.innerHTML = svg;
|
el.innerHTML = svg;
|
||||||
@@ -394,27 +566,28 @@
|
|||||||
svgEl.style.maxWidth = 'none';
|
svgEl.style.maxWidth = 'none';
|
||||||
svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
||||||
}
|
}
|
||||||
_initState(id);
|
|
||||||
|
this._initState(id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="mermaid-error">
|
<div class="mermaid-error">
|
||||||
<strong>Diagram error:</strong> ${_escapeHtml(e.message || String(e))}
|
<strong>Diagram error:</strong> ${this._escapeHtml(e.message || String(e))}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
el.dataset.rendered = 'error';
|
el.dataset.rendered = 'error';
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
// ── Mermaid Library Loading ──────────────
|
// ── Mermaid Library Loading ──────────────
|
||||||
|
|
||||||
function _loadMermaid() {
|
_loadMermaid() {
|
||||||
if (_mermaidReady) return Promise.resolve();
|
if (this._mermaidReady) return Promise.resolve();
|
||||||
if (_mermaidLoading) return _mermaidLoading;
|
if (this._mermaidLoading) return this._mermaidLoading;
|
||||||
|
|
||||||
_mermaidLoading = new Promise((resolve, reject) => {
|
this._mermaidLoading = new Promise((resolve, reject) => {
|
||||||
if (typeof mermaid !== 'undefined') {
|
if (typeof mermaid !== 'undefined') {
|
||||||
_initMermaid();
|
this._initMermaid();
|
||||||
_mermaidReady = true;
|
this._mermaidReady = true;
|
||||||
resolve();
|
resolve();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -426,8 +599,8 @@
|
|||||||
const script = document.createElement('script');
|
const script = document.createElement('script');
|
||||||
script.src = localSrc;
|
script.src = localSrc;
|
||||||
script.onload = () => {
|
script.onload = () => {
|
||||||
_initMermaid();
|
this._initMermaid();
|
||||||
_mermaidReady = true;
|
this._mermaidReady = true;
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
script.onerror = () => {
|
script.onerror = () => {
|
||||||
@@ -435,8 +608,8 @@
|
|||||||
const cdn = document.createElement('script');
|
const cdn = document.createElement('script');
|
||||||
cdn.src = cdnSrc;
|
cdn.src = cdnSrc;
|
||||||
cdn.onload = () => {
|
cdn.onload = () => {
|
||||||
_initMermaid();
|
this._initMermaid();
|
||||||
_mermaidReady = true;
|
this._mermaidReady = true;
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
cdn.onerror = () => {
|
cdn.onerror = () => {
|
||||||
@@ -447,23 +620,25 @@
|
|||||||
};
|
};
|
||||||
document.head.appendChild(script);
|
document.head.appendChild(script);
|
||||||
});
|
});
|
||||||
return _mermaidLoading;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _initMermaid() {
|
return this._mermaidLoading;
|
||||||
|
},
|
||||||
|
|
||||||
|
_initMermaid() {
|
||||||
if (typeof mermaid === 'undefined') return;
|
if (typeof mermaid === 'undefined') return;
|
||||||
|
|
||||||
mermaid.initialize({
|
mermaid.initialize({
|
||||||
startOnLoad: false,
|
startOnLoad: false,
|
||||||
theme: _isDark() ? 'dark' : 'default',
|
theme: this._ctx.ui.isDark() ? 'dark' : 'default',
|
||||||
securityLevel: 'strict',
|
securityLevel: 'strict',
|
||||||
fontFamily: 'inherit',
|
fontFamily: 'inherit',
|
||||||
logLevel: 'error',
|
logLevel: 'error',
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
|
|
||||||
// ── Styles ──────────────────────────────
|
// ── Styles ──────────────────────────────
|
||||||
|
|
||||||
function _injectStyles() {
|
_injectStyles() {
|
||||||
if (document.getElementById('ext-style-mermaid-renderer')) return;
|
if (document.getElementById('ext-style-mermaid-renderer')) return;
|
||||||
const style = document.createElement('style');
|
const style = document.createElement('style');
|
||||||
style.id = 'ext-style-mermaid-renderer';
|
style.id = 'ext-style-mermaid-renderer';
|
||||||
@@ -472,6 +647,8 @@
|
|||||||
background: var(--bg-2); border: 1px solid var(--border);
|
background: var(--bg-2); border: 1px solid var(--border);
|
||||||
border-radius: 8px; overflow: hidden; margin: 12px 0;
|
border-radius: 8px; overflow: hidden; margin: 12px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Fullscreen mode ── */
|
||||||
.mermaid-block.mermaid-fullscreen {
|
.mermaid-block.mermaid-fullscreen {
|
||||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||||
z-index: 10000; border-radius: 0; margin: 0;
|
z-index: 10000; border-radius: 0; margin: 0;
|
||||||
@@ -479,11 +656,15 @@
|
|||||||
background: var(--bg-1, var(--bg-2, #1a1a2e));
|
background: var(--bg-1, var(--bg-2, #1a1a2e));
|
||||||
}
|
}
|
||||||
.mermaid-block.mermaid-fullscreen .mermaid-viewport {
|
.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 {
|
.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 {
|
.mmd-fullscreen-close {
|
||||||
position: absolute; top: 12px; right: 12px; z-index: 10001;
|
position: absolute; top: 12px; right: 12px; z-index: 10001;
|
||||||
width: 40px; height: 40px; border-radius: 50%;
|
width: 40px; height: 40px; border-radius: 50%;
|
||||||
@@ -495,8 +676,11 @@
|
|||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||||
}
|
}
|
||||||
.mmd-fullscreen-close:hover {
|
.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 {
|
.mermaid-toolbar {
|
||||||
display: flex; align-items: center; gap: 6px;
|
display: flex; align-items: center; gap: 6px;
|
||||||
padding: 4px 10px; border-bottom: 1px solid var(--border);
|
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-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; }
|
.mmd-sep { width: 1px; height: 16px; background: var(--border); margin: 0 4px; }
|
||||||
|
|
||||||
|
/* Viewport */
|
||||||
.mermaid-viewport {
|
.mermaid-viewport {
|
||||||
overflow: hidden; position: relative;
|
overflow: hidden; position: relative;
|
||||||
min-height: 80px; max-height: 600px;
|
min-height: 80px; max-height: 600px;
|
||||||
cursor: grab; user-select: none;
|
cursor: grab; user-select: none;
|
||||||
}
|
}
|
||||||
.mermaid-viewport.mmd-grabbing { cursor: grabbing; }
|
.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 {
|
.mermaid-loading {
|
||||||
color: var(--text-3); font-size: 13px; padding: 20px;
|
color: var(--text-3); font-size: 13px; padding: 20px;
|
||||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||||
@@ -538,6 +732,8 @@
|
|||||||
padding: 12px 16px; border-radius: 4px; font-size: 13px;
|
padding: 12px 16px; border-radius: 4px; font-size: 13px;
|
||||||
font-family: var(--mono); white-space: pre-wrap;
|
font-family: var(--mono); white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Source panel */
|
||||||
.mermaid-source { border-top: 1px solid var(--border); font-size: 12px; }
|
.mermaid-source { border-top: 1px solid var(--border); font-size: 12px; }
|
||||||
.mermaid-source summary {
|
.mermaid-source summary {
|
||||||
padding: 6px 12px; cursor: pointer; color: var(--text-3);
|
padding: 6px 12px; cursor: pointer; color: var(--text-3);
|
||||||
@@ -550,91 +746,34 @@
|
|||||||
margin: 0; border-radius: 0; border: none;
|
margin: 0; border-radius: 0; border: none;
|
||||||
max-height: 200px; overflow: auto;
|
max-height: 200px; overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hide source panel in fullscreen */
|
||||||
.mermaid-block.mermaid-fullscreen .mermaid-source { display: none; }
|
.mermaid-block.mermaid-fullscreen .mermaid-source { display: none; }
|
||||||
|
|
||||||
|
/* ── Mobile adjustments ── */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.mermaid-toolbar { padding: 6px 10px; gap: 4px; }
|
.mermaid-toolbar { padding: 6px 10px; gap: 4px; }
|
||||||
.mmd-btn { padding: 4px 10px; font-size: 12px; }
|
.mmd-btn { padding: 4px 10px; font-size: 12px; }
|
||||||
.mmd-sep { margin: 0 2px; }
|
.mmd-sep { margin: 0 2px; }
|
||||||
.mmd-fullscreen-close { width: 48px; height: 48px; font-size: 24px; top: 16px; right: 16px; }
|
.mmd-fullscreen-close { width: 48px; height: 48px; font-size: 24px; top: 16px; right: 16px; }
|
||||||
}`;
|
}
|
||||||
|
`;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
}
|
},
|
||||||
|
|
||||||
// ── Registration ────────────────────────
|
_escapeHtml(str) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
},
|
||||||
|
|
||||||
function register() {
|
destroy() {
|
||||||
if (!window.sw?.renderers) return;
|
document.getElementById('ext-style-mermaid-renderer')?.remove();
|
||||||
|
document.querySelectorAll('.mermaid-fullscreen').forEach(el => {
|
||||||
_injectStyles();
|
el.classList.remove('mermaid-fullscreen');
|
||||||
|
const closeBtn = el.querySelector('.mmd-fullscreen-close');
|
||||||
// Block renderer: match ```mermaid
|
if (closeBtn) closeBtn.remove();
|
||||||
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>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
document.body.style.overflow = '';
|
||||||
// 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();
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
// 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",
|
"id": "regex-tester",
|
||||||
"title": "Regex Tester",
|
"name": "Regex Tester",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "extension",
|
|
||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
"author": "switchboard",
|
"author": "switchboard",
|
||||||
"description": "Tests regular expressions against input strings. Allows the LLM to verify regex patterns and see matches, groups, and indices.",
|
"description": "Tests regular expressions against input strings. Allows the LLM to verify regex patterns and see matches, groups, and indices.",
|
||||||
"requires": ["chat"],
|
|
||||||
"permissions": [],
|
"permissions": [],
|
||||||
"tools": [
|
"tools": [
|
||||||
{
|
{
|
||||||
@@ -155,12 +155,6 @@ spec:
|
|||||||
optional: true
|
optional: true
|
||||||
- name: S3_FORCE_PATH_STYLE
|
- name: S3_FORCE_PATH_STYLE
|
||||||
value: "true"
|
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:
|
volumeMounts:
|
||||||
- name: storage
|
- name: storage
|
||||||
mountPath: /data/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}/team-admin { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
||||||
location ${BASE_PATH}/settings { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
location ${BASE_PATH}/settings { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
||||||
location ${BASE_PATH}/welcome { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
location ${BASE_PATH}/welcome { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
||||||
location ${BASE_PATH}/docs { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
|
||||||
location ${BASE_PATH}/w/ { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
location ${BASE_PATH}/w/ { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
||||||
|
|
||||||
# Extension surface page routes → backend
|
# Extension surface page routes → backend
|
||||||
|
|||||||
@@ -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",
|
"tier": "browser",
|
||||||
"author": "Switchboard Core",
|
"author": "Switchboard Core",
|
||||||
"icon": "📊",
|
"icon": "📊",
|
||||||
"description": "Project dashboard exercising all SDK primitives (requires legacy sw.* SDK — dormant until rewritten)",
|
"description": "Project dashboard exercising all SDK primitives",
|
||||||
"requires": ["legacy-sdk"],
|
|
||||||
"route": "/s/dashboard",
|
"route": "/s/dashboard",
|
||||||
"permissions": [],
|
"permissions": [],
|
||||||
"settings": [
|
"settings": [
|
||||||
|
|||||||
@@ -372,8 +372,8 @@
|
|||||||
// ── File Operations ─────────────────────────
|
// ── File Operations ─────────────────────────
|
||||||
|
|
||||||
async function _deleteFile(wsId, path, fileTree, codeEditor) {
|
async function _deleteFile(wsId, path, fileTree, codeEditor) {
|
||||||
const ok = typeof sw !== 'undefined' && sw.confirm
|
const ok = typeof showConfirm === 'function'
|
||||||
? await sw.confirm('Delete ' + path + '?', { destructive: true })
|
? await showConfirm('Delete ' + path + '?')
|
||||||
: window.confirm('Delete ' + path + '?');
|
: window.confirm('Delete ' + path + '?');
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,8 +6,7 @@
|
|||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
"author": "Switchboard Core",
|
"author": "Switchboard Core",
|
||||||
"icon": "✏️",
|
"icon": "✏️",
|
||||||
"description": "Code editor with workspace management, file tree, and AI assist (requires legacy sw.* SDK — dormant until rewritten)",
|
"description": "Code editor with workspace management, file tree, and AI assist",
|
||||||
"requires": ["legacy-sdk"],
|
|
||||||
"route": "/s/editor",
|
"route": "/s/editor",
|
||||||
"layout": "editor",
|
"layout": "editor",
|
||||||
"permissions": [],
|
"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",
|
"id": "hello-dashboard",
|
||||||
"icon": "👋",
|
"icon": "👋",
|
||||||
"type": "surface",
|
|
||||||
"title": "Hello Dashboard",
|
"title": "Hello Dashboard",
|
||||||
"route": "/s/hello-dashboard",
|
"route": "/s/hello-dashboard",
|
||||||
"auth": "authenticated",
|
"auth": "authenticated",
|
||||||
|
|||||||
@@ -65,9 +65,9 @@
|
|||||||
T.assert(d.version === '0.31.1', 'version mismatch');
|
T.assert(d.version === '0.31.1', 'version mismatch');
|
||||||
});
|
});
|
||||||
|
|
||||||
await T.test('crud', 'dashboardPackage', 'dashpkg: GET /admin/packages (dashboard in list)', async function () {
|
await T.test('crud', 'dashboardPackage', 'dashpkg: GET /admin/surfaces (dashboard in list)', async function () {
|
||||||
var d = await T.apiGet('/admin/packages');
|
var d = await T.apiGet('/admin/surfaces');
|
||||||
T.assertHasKey(d, 'data', '/admin/packages');
|
T.assertHasKey(d, 'data', '/admin/surfaces');
|
||||||
var found = d.data.some(function (s) { return s.id === 'dashboard'; });
|
var found = d.data.some(function (s) { return s.id === 'dashboard'; });
|
||||||
T.assert(found, 'dashboard package should appear in surfaces list');
|
T.assert(found, 'dashboard package should appear in surfaces list');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -68,9 +68,9 @@
|
|||||||
T.assert(d.version === '0.31.0', 'version mismatch');
|
T.assert(d.version === '0.31.0', 'version mismatch');
|
||||||
});
|
});
|
||||||
|
|
||||||
await T.test('crud', 'editorPackage', 'edpkg: GET /admin/packages (editor in list)', async function () {
|
await T.test('crud', 'editorPackage', 'edpkg: GET /admin/surfaces (editor in list)', async function () {
|
||||||
var d = await T.apiGet('/admin/packages');
|
var d = await T.apiGet('/admin/surfaces');
|
||||||
T.assertHasKey(d, 'data', '/admin/packages');
|
T.assertHasKey(d, 'data', '/admin/surfaces');
|
||||||
var found = d.data.some(function (s) { return s.id === 'editor'; });
|
var found = d.data.some(function (s) { return s.id === 'editor'; });
|
||||||
T.assert(found, 'editor package should appear in surfaces list');
|
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 () {
|
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.
|
// 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.
|
// 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 surfaces = d.data || [];
|
||||||
var coreEditor = surfaces.find(function (s) { return s.id === 'editor' && s.source === 'core'; });
|
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');
|
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 () {
|
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
|
// 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 surfaces = d.data || [];
|
||||||
var editorSurface = surfaces.find(function (s) { return s.id === 'editor'; });
|
var editorSurface = surfaces.find(function (s) { return s.id === 'editor'; });
|
||||||
T.assert(editorSurface, 'editor should be in surfaces list');
|
T.assert(editorSurface, 'editor should be in surfaces list');
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
var surfaceId = null;
|
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();
|
surfaceId = 'icd-test-' + Date.now();
|
||||||
var manifest = JSON.stringify({
|
var manifest = JSON.stringify({
|
||||||
id: surfaceId,
|
id: surfaceId,
|
||||||
@@ -28,32 +28,32 @@
|
|||||||
route: '/s/' + surfaceId
|
route: '/s/' + surfaceId
|
||||||
});
|
});
|
||||||
var zipBlob = buildTestZip({ 'manifest.json': manifest });
|
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
|
// 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.id === surfaceId, 'installed surface id mismatch');
|
||||||
T.assert(d.title === 'ICD Test Surface', 'title mismatch');
|
T.assert(d.title === 'ICD Test Surface', 'title mismatch');
|
||||||
T.registerCleanup(async function () {
|
T.registerCleanup(async function () {
|
||||||
if (surfaceId) {
|
if (surfaceId) {
|
||||||
var token = await T.getAuthToken();
|
var token = await T.getAuthToken();
|
||||||
return T.authFetch(token, 'DELETE', '/admin/packages/' + surfaceId);
|
return T.authFetch(token, 'DELETE', '/admin/surfaces/' + surfaceId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (surfaceId) {
|
if (surfaceId) {
|
||||||
await T.test('crud', 'surfaces', 'GET /admin/packages/:id (read)', async function () {
|
await T.test('crud', 'surfaces', 'GET /admin/surfaces/:id (read)', async function () {
|
||||||
var d = await T.apiGet('/admin/packages/' + surfaceId);
|
var d = await T.apiGet('/admin/surfaces/' + surfaceId);
|
||||||
T.assertShape(d, T.S.surfaceAdmin, 'surface detail');
|
T.assertShape(d, T.S.surfaceAdmin, 'surface detail');
|
||||||
T.assert(d.id === surfaceId, 'id mismatch');
|
T.assert(d.id === surfaceId, 'id mismatch');
|
||||||
T.assert(d.source === 'extension', 'source should be extension');
|
T.assert(d.source === 'extension', 'source should be extension');
|
||||||
T.assert(d.enabled === true, 'newly installed surface should be enabled');
|
T.assert(d.enabled === true, 'newly installed surface should be enabled');
|
||||||
});
|
});
|
||||||
|
|
||||||
await T.test('crud', 'surfaces', 'GET /admin/packages (list includes new)', async function () {
|
await T.test('crud', 'surfaces', 'GET /admin/surfaces (list includes new)', async function () {
|
||||||
var d = await T.apiGet('/admin/packages');
|
var d = await T.apiGet('/admin/surfaces');
|
||||||
T.assertHasKey(d, 'data', '/admin/packages');
|
T.assertHasKey(d, 'data', '/admin/surfaces');
|
||||||
var found = d.data.some(function (s) { return s.id === surfaceId; });
|
var found = d.data.some(function (s) { return s.id === surfaceId; });
|
||||||
T.assert(found, 'installed surface should appear in admin list');
|
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');
|
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',
|
var d = await T.authFetch(await T.getAuthToken(), 'PUT',
|
||||||
'/admin/packages/' + surfaceId + '/disable');
|
'/admin/surfaces/' + surfaceId + '/disable');
|
||||||
T.assertStatus(d, 200, 'disable');
|
T.assertStatus(d, 200, 'disable');
|
||||||
T.assert(d.enabled === false, 'should report disabled');
|
T.assert(d.enabled === false, 'should report disabled');
|
||||||
});
|
});
|
||||||
@@ -83,16 +83,16 @@
|
|||||||
T.assert(!found, 'disabled surface should NOT appear in user nav list');
|
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 () {
|
await T.test('crud', 'surfaces', 'GET /admin/surfaces (disabled still in admin)', async function () {
|
||||||
var d = await T.apiGet('/admin/packages');
|
var d = await T.apiGet('/admin/surfaces');
|
||||||
var entry = d.data.find(function (s) { return s.id === surfaceId; });
|
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, 'disabled surface should still appear in admin list');
|
||||||
T.assert(entry.enabled === false, 'should show as disabled 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',
|
var d = await T.authFetch(await T.getAuthToken(), 'PUT',
|
||||||
'/admin/packages/' + surfaceId + '/enable');
|
'/admin/surfaces/' + surfaceId + '/enable');
|
||||||
T.assertStatus(d, 200, 'enable');
|
T.assertStatus(d, 200, 'enable');
|
||||||
T.assert(d.enabled === true, 'should report enabled');
|
T.assert(d.enabled === true, 'should report enabled');
|
||||||
});
|
});
|
||||||
@@ -103,53 +103,53 @@
|
|||||||
T.assert(found, 're-enabled surface should appear in user nav list');
|
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',
|
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);
|
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',
|
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);
|
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',
|
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);
|
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',
|
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);
|
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',
|
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);
|
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 zipBlob = buildTestZip({ 'readme.txt': 'no manifest here' });
|
||||||
var d;
|
var d;
|
||||||
try {
|
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');
|
T.assert(false, 'should have thrown');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
T.assert(e.message.indexOf('400') !== -1, 'missing manifest should 400: ' + e.message);
|
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 manifest = JSON.stringify({ id: 'chat', title: 'Evil Chat', route: '/s/chat' });
|
||||||
var zipBlob = buildTestZip({ 'manifest.json': manifest });
|
var zipBlob = buildTestZip({ 'manifest.json': manifest });
|
||||||
var d;
|
var d;
|
||||||
try {
|
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
|
// If no throw, check status manually
|
||||||
T.assert(false, 'overwriting core surface should have thrown');
|
T.assert(false, 'overwriting core surface should have thrown');
|
||||||
} catch (e) {
|
} 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.
|
// Surface was re-enabled above. Disable it, then re-install.
|
||||||
await T.authFetch(await T.getAuthToken(), 'PUT',
|
await T.authFetch(await T.getAuthToken(), 'PUT',
|
||||||
'/admin/packages/' + surfaceId + '/disable');
|
'/admin/surfaces/' + surfaceId + '/disable');
|
||||||
|
|
||||||
// Re-install same ID
|
// Re-install same ID
|
||||||
var manifest = JSON.stringify({
|
var manifest = JSON.stringify({
|
||||||
@@ -169,33 +169,33 @@
|
|||||||
route: '/s/' + surfaceId
|
route: '/s/' + surfaceId
|
||||||
});
|
});
|
||||||
var zipBlob = buildTestZip({ 'manifest.json': manifest });
|
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
|
// 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.title === 'ICD Test Surface v2', 'title should be updated after reseed');
|
||||||
T.assert(d.enabled === false, 'enabled should be preserved as false after reseed');
|
T.assert(d.enabled === false, 'enabled should be preserved as false after reseed');
|
||||||
|
|
||||||
// Re-enable for cleanup
|
// Re-enable for cleanup
|
||||||
await T.authFetch(await T.getAuthToken(), 'PUT',
|
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 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.assertStatus(d, 200, 'delete');
|
||||||
T.assert(d.deleted === true, 'should report deleted');
|
T.assert(d.deleted === true, 'should report deleted');
|
||||||
|
|
||||||
// Verify gone
|
// 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');
|
T.assert(check._status === 404, 'deleted surface should 404 on get');
|
||||||
surfaceId = null;
|
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',
|
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);
|
T.assert(d._status === 404, 'delete nonexistent should return 404, got ' + d._status);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,7 +227,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ─── API Wrappers ───────────────────────────────────────────
|
// ─── 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) {
|
async function _fetchJSON(method, path, body) {
|
||||||
var token = await T.getAuthToken();
|
var token = await T.getAuthToken();
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
var adminGets = [
|
var adminGets = [
|
||||||
'/admin/stats', '/admin/users', '/admin/settings',
|
'/admin/stats', '/admin/users', '/admin/settings',
|
||||||
'/admin/configs', '/admin/models', '/admin/personas',
|
'/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/providers/health', '/admin/routing/policies',
|
||||||
'/admin/permissions', '/admin/roles', '/admin/vault/status',
|
'/admin/permissions', '/admin/roles', '/admin/vault/status',
|
||||||
'/admin/storage/status', '/admin/audit', '/admin/pricing',
|
'/admin/storage/status', '/admin/audit', '/admin/pricing',
|
||||||
@@ -72,8 +72,8 @@
|
|||||||
'expected 403/401, got ' + d._status);
|
'expected 403/401, got ' + d._status);
|
||||||
});
|
});
|
||||||
|
|
||||||
await T.test('authz', 'admin-deny', 'user → POST /admin/packages/install (expect 403)', async function () {
|
await T.test('authz', 'admin-deny', 'user → POST /admin/surfaces/install (expect 403)', async function () {
|
||||||
var d = await T.authFetch(testUser.token, 'POST', '/admin/packages/install', {});
|
var d = await T.authFetch(testUser.token, 'POST', '/admin/surfaces/install', {});
|
||||||
T.assert(d._status === 403 || d._status === 401 || d._status === 400,
|
T.assert(d._status === 403 || d._status === 401 || d._status === 400,
|
||||||
'expected 403/401/400, got ' + d._status);
|
'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 () {
|
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' });
|
var blob = new Blob([JSON.stringify({ id: '../../../etc/evil', title: 'Path Traversal Test' })], { type: 'application/json' });
|
||||||
try {
|
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,
|
T.assert(d._status === 400 || d._status === 409,
|
||||||
'path traversal surface accepted! got ' + d._status);
|
'path traversal surface accepted! got ' + d._status);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -644,8 +644,8 @@
|
|||||||
assertDenied(d._status, 'CRITICAL: team admin can delete users');
|
assertDenied(d._status, 'CRITICAL: team admin can delete users');
|
||||||
});
|
});
|
||||||
|
|
||||||
await T.test('security', 'escalation', '[P0] teamAdmin → POST /admin/packages/install', async function () {
|
await T.test('security', 'escalation', '[P0] teamAdmin → POST /admin/surfaces/install', async function () {
|
||||||
var d = await T.authFetch(teamAdmin.token, 'POST', '/admin/packages/install', {});
|
var d = await T.authFetch(teamAdmin.token, 'POST', '/admin/surfaces/install', {});
|
||||||
if (d._status === 400) return; // bad upload format, not a security issue
|
if (d._status === 400) return; // bad upload format, not a security issue
|
||||||
assertDenied(d._status, 'team admin can install surfaces');
|
assertDenied(d._status, 'team admin can install surfaces');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -326,9 +326,9 @@
|
|||||||
T.assertHasKey(d, 'data', '/admin/groups');
|
T.assertHasKey(d, 'data', '/admin/groups');
|
||||||
});
|
});
|
||||||
|
|
||||||
await T.test('smoke', 'admin', 'GET /admin/packages', async function () {
|
await T.test('smoke', 'admin', 'GET /admin/surfaces', async function () {
|
||||||
var d = await T.apiGet('/admin/packages');
|
var d = await T.apiGet('/admin/surfaces');
|
||||||
T.assertHasKey(d, 'data', '/admin/packages');
|
T.assertHasKey(d, 'data', '/admin/surfaces');
|
||||||
T.assert(Array.isArray(d.data), 'surfaces should be array');
|
T.assert(Array.isArray(d.data), 'surfaces should be array');
|
||||||
if (d.data.length > 0) {
|
if (d.data.length > 0) {
|
||||||
T.assertShape(d.data[0], T.S.surfaceAdmin, 'surfaces[0]');
|
T.assertShape(d.data[0], T.S.surfaceAdmin, 'surfaces[0]');
|
||||||
@@ -442,48 +442,6 @@
|
|||||||
T.assertHasKey(d, 'data', '/admin/memories/pending');
|
T.assertHasKey(d, 'data', '/admin/memories/pending');
|
||||||
T.assert(Array.isArray(d.data), 'data should be array');
|
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",
|
"id": "icd-test-runner",
|
||||||
"icon": "🧪",
|
"icon": "🧪",
|
||||||
"type": "surface",
|
|
||||||
"title": "ICD Test Runner",
|
"title": "ICD Test Runner",
|
||||||
"route": "/s/icd-test-runner",
|
"route": "/s/icd-test-runner",
|
||||||
"auth": "authenticated",
|
"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) {
|
async function handleDelete(item) {
|
||||||
if (!await sw.confirm('Delete schedule "' + item.name + '"?', { destructive: true })) return;
|
if (!confirm('Delete schedule "' + item.name + '"?')) return;
|
||||||
try {
|
try {
|
||||||
await apiDelete(item.id);
|
await apiDelete(item.id);
|
||||||
sw.toast('Schedule deleted', 'success');
|
sw.toast('Schedule deleted', 'success');
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
|
|
||||||
await T.test('admin', 'surfaces', 'sw.api.admin.surfaces.list()', {
|
await T.test('admin', 'surfaces', 'sw.api.admin.surfaces.list()', {
|
||||||
sdk: function () { return 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'); }
|
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' },
|
raw: { method: 'GET', path: '/admin/projects' },
|
||||||
validate: function (r) { T.assert(Array.isArray(r) || (r && Array.isArray(r.data)), 'expected array'); }
|
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",
|
"id": "sdk-test-runner",
|
||||||
"icon": "🔬",
|
"icon": "🔬",
|
||||||
"type": "surface",
|
|
||||||
"title": "SDK Test Runner",
|
"title": "SDK Test Runner",
|
||||||
"route": "/s/sdk-test-runner",
|
"route": "/s/sdk-test-runner",
|
||||||
"auth": "authenticated",
|
"auth": "authenticated",
|
||||||
|
|||||||
@@ -144,7 +144,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id) {
|
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);
|
await api.del('/items/' + id);
|
||||||
onRefresh();
|
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) {
|
func TestMTLSConfig_Defaults(t *testing.T) {
|
||||||
p := NewMTLSProxyProvider(MTLSProxyConfig{})
|
p := NewMTLSProvider(MTLSConfig{})
|
||||||
|
|
||||||
if p.cfg.HeaderDN != "X-SSL-Client-DN" {
|
if p.cfg.HeaderDN != "X-SSL-Client-DN" {
|
||||||
t.Errorf("HeaderDN = %q, want X-SSL-Client-DN", p.cfg.HeaderDN)
|
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) {
|
func TestMTLSConfig_Custom(t *testing.T) {
|
||||||
p := NewMTLSProxyProvider(MTLSProxyConfig{
|
p := NewMTLSProvider(MTLSConfig{
|
||||||
HeaderDN: "X-Client-Cert-DN",
|
HeaderDN: "X-Client-Cert-DN",
|
||||||
HeaderVerify: "X-Client-Cert-Verify",
|
HeaderVerify: "X-Client-Cert-Verify",
|
||||||
})
|
})
|
||||||
@@ -110,14 +110,14 @@ func TestMTLSConfig_Custom(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMTLSProvider_Mode(t *testing.T) {
|
func TestMTLSProvider_Mode(t *testing.T) {
|
||||||
p := NewMTLSProxyProvider(MTLSProxyConfig{})
|
p := NewMTLSProvider(MTLSConfig{})
|
||||||
if p.Mode() != ModeMTLS {
|
if p.Mode() != ModeMTLS {
|
||||||
t.Errorf("Mode() = %q, want %q", p.Mode(), ModeMTLS)
|
t.Errorf("Mode() = %q, want %q", p.Mode(), ModeMTLS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMTLSProvider_NoRegistration(t *testing.T) {
|
func TestMTLSProvider_NoRegistration(t *testing.T) {
|
||||||
p := NewMTLSProxyProvider(MTLSProxyConfig{})
|
p := NewMTLSProvider(MTLSConfig{})
|
||||||
if p.SupportsRegistration() {
|
if p.SupportsRegistration() {
|
||||||
t.Error("mTLS should not support registration")
|
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