diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 37734e1..b44a952 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -415,8 +415,8 @@ jobs: echo "FE_MEMORY_LIMIT=128Mi" >> "$GITHUB_OUTPUT" echo "FE_CPU_REQUEST=25m" >> "$GITHUB_OUTPUT" echo "FE_CPU_LIMIT=100m" >> "$GITHUB_OUTPUT" - echo "BE_MEMORY_REQUEST=128Mi" >> "$GITHUB_OUTPUT" - echo "BE_MEMORY_LIMIT=256Mi" >> "$GITHUB_OUTPUT" + echo "BE_MEMORY_REQUEST=256Mi" >> "$GITHUB_OUTPUT" + echo "BE_MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT" echo "BE_CPU_REQUEST=50m" >> "$GITHUB_OUTPUT" echo "BE_CPU_LIMIT=250m" >> "$GITHUB_OUTPUT" echo "env_label=dev (PR #${{ gitea.event.pull_request.number }})" >> "$GITHUB_OUTPUT" @@ -456,8 +456,8 @@ jobs: echo "FE_MEMORY_LIMIT=128Mi" >> "$GITHUB_OUTPUT" echo "FE_CPU_REQUEST=25m" >> "$GITHUB_OUTPUT" echo "FE_CPU_LIMIT=100m" >> "$GITHUB_OUTPUT" - echo "BE_MEMORY_REQUEST=128Mi" >> "$GITHUB_OUTPUT" - echo "BE_MEMORY_LIMIT=256Mi" >> "$GITHUB_OUTPUT" + echo "BE_MEMORY_REQUEST=256Mi" >> "$GITHUB_OUTPUT" + echo "BE_MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT" echo "BE_CPU_REQUEST=50m" >> "$GITHUB_OUTPUT" echo "BE_CPU_LIMIT=250m" >> "$GITHUB_OUTPUT" echo "env_label=test (main)" >> "$GITHUB_OUTPUT" @@ -709,6 +709,7 @@ jobs: envsubst < k8s/backend.yaml > /tmp/backend.yaml envsubst < k8s/frontend.yaml > /tmp/frontend.yaml + envsubst < k8s/middleware-retry.yaml > /tmp/middleware-retry.yaml envsubst < k8s/ingress.yaml > /tmp/ingress.yaml echo "━━━ Applying manifests for ${DEPLOY_HOST} ━━━" @@ -723,8 +724,31 @@ jobs: kubectl apply -f /tmp/backend.yaml kubectl apply -f /tmp/frontend.yaml + + # Traefik retry middleware (v0.28.8) — requires RBAC for traefik.io CRDs. + # First-time setup: kubectl apply -f k8s/rbac-traefik.yaml (cluster admin) + if kubectl apply -f /tmp/middleware-retry.yaml 2>/tmp/mw-err.txt; then + echo " ✓ Traefik retry middleware" + MW_READY=true + else + echo " ⚠ Traefik middleware skipped ($(head -1 /tmp/mw-err.txt))" + echo " First-time setup: kubectl apply -f k8s/rbac-traefik.yaml" + MW_READY=false + fi + kubectl apply -f /tmp/ingress.yaml + # Annotate ingress with middleware reference ONLY if middleware exists. + # Traefik invalidates the entire router if it references a missing middleware, + # which kills all routes for this path prefix. + if [[ "${MW_READY}" == "true" ]]; then + MW_REF="${NAMESPACE}-switchboard-retry${DEPLOY_SUFFIX}@kubernetescrd" + kubectl annotate ingress "switchboard${DEPLOY_SUFFIX}" \ + "traefik.ingress.kubernetes.io/router.middlewares=${MW_REF}" \ + --namespace="${NAMESPACE}" --overwrite + echo " ✓ Ingress annotated with retry middleware" + fi + - name: Rollout and verify env: SUFFIX: ${{ steps.setup.outputs.DEPLOY_SUFFIX }} diff --git a/CHANGELOG.md b/CHANGELOG.md index db738c0..de9a8e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,104 @@ # Changelog +## [0.28.8] — 2026-03-15 + +### Summary + +ICD Green Board. Close out all pre-existing ICD test failures before +v0.29.0 feature work. Gate: 543/543 (100%) on the ICD runner. + +Three changesets: provider sync + infrastructure resilience (cs0), +CORS lockdown + WebSocket origin validation (cs1), WebSocket ticket +exchange (cs2). + +### Fixed + +- **Batched `UpsertFromSync`** — catalog sync rewritten as a single + transaction: one SELECT to identify existing models, N prepared + `INSERT ON CONFLICT DO UPDATE` statements, one COMMIT. Previously + each model was N×2 auto-committed queries (SELECT + INSERT/UPDATE), + producing ~300 round-trips and WAL flushes on a typical Venice sync. + Both PG and SQLite stores rewritten. Root cause of cascading 502s: + the write burst starved the connection pool and caused Traefik to + see stale keepalive connections on subsequent requests. +- **DB connection lifecycle** — `SetConnMaxLifetime(5m)` and + `SetConnMaxIdleTime(1m)` added to the Postgres pool. Previously + connections lived forever — stale TCP after PG restart or network + blip would fail silently until a query hit the dead connection. +- **HTTP transport pool** — `ProviderConfig.Client()` now returns a + shared `*http.Client` from a `sync.Map` keyed by `(ProxyMode, + ProxyURL)`. Previously every outbound provider call allocated a + fresh `http.Transport`, leaking idle connections until GC. +- **Traefik retry middleware** — new `Middleware` CRD (2 attempts, + 100ms initial interval) applied via ingress annotation. Retries on + 502 and connection reset. Both `k8s/` CI manifests and Helm chart + templates updated. CI pipeline renders and applies the middleware + before the ingress resource. +- **Provider sync timeout** — `syncProviderModels` now wraps the + outbound provider HTTP call with a configurable timeout (default 30s, + `PROVIDER_SYNC_TIMEOUT` env var). Previously used no timeout, causing + Gin handler timeouts and cascading 502s from the reverse proxy when + upstream providers (especially Venice) were slow. +- **504 on upstream timeout** — `POST /admin/models/fetch` and + `POST /api-configs/:id/models/fetch` now return 504 with structured + `{"error": "upstream timeout: "}` instead of letting the + reverse proxy manufacture a 502 from a broken connection. +- **ICD runner provider tier resilience** — `models/fetch` calls retry + once on 502/504 with 2-second backoff. Eliminates cascading test + failures caused by transient upstream timeouts. +- **WebSocket CheckOrigin** — `upgrader.CheckOrigin` now validates the + `Origin` header against `CORS_ALLOWED_ORIGINS` instead of + unconditionally returning `true`. Connections from unlisted origins + are rejected at upgrade time. +- **CORS startup warning** — if `CORS_ALLOWED_ORIGINS` is explicitly + set to `*`, a warning is logged at startup recommending restriction + for production deployments. + +### New + +- **WebSocket ticket exchange** — `POST /api/v1/ws/ticket` returns a + single-use opaque ticket (128-bit random hex, 30s TTL). Client + connects with `?ticket=` instead of `?token=`. Ticket + is validated and deleted atomically (single-use). Background reaper + removes expired unclaimed tickets every 15 seconds. This eliminates + JWT exposure in server logs, proxy logs, and browser history. +- **`WsAuth` middleware** — dedicated WebSocket auth middleware with + three-path priority: `?ticket=` (preferred), `?token=` (legacy, + logs deprecation warning), `Authorization` header (non-browser). +- **`TicketStore`** — in-memory ticket store with `Issue()`, + `Validate()`, `Stop()`, and automatic TTL reaping. +- **`GetAllowedOrigins()`** — exported CORS origin resolver for use + by WebSocket hub and other subsystems. +- **Traefik retry Middleware CRD** — `k8s/middleware-retry.yaml` and + `chart/templates/middleware-retry.yaml`. Configurable via + `ingress.retry.attempts` and `ingress.retry.initialInterval` in + Helm values. + +### Changed + +- **`events.NewHub(bus, allowedOrigins)`** — Hub constructor now takes + the resolved CORS origin string. The upgrader's `CheckOrigin` is + configured once at construction time. +- **`events.js` `_doConnect()`** — now async. Fetches a ticket via + `POST /api/v1/ws/ticket` before building the WebSocket URL. Falls + back to legacy `?token=` if ticket exchange fails (pre-v0.28.8 + server compatibility). +- **WebSocket route** — `GET /ws` now uses `WsAuth` middleware instead + of generic `Auth`. Legacy `?token=` still works but logs deprecation. +- **`ProviderConfig.Client()`** — returns shared client from pool + instead of allocating per call. + +### Docs + +- **`docs/ICD/websocket.md`** — ticket exchange protocol documented + (preferred auth, legacy deprecation, origin validation). +- **`docs/ARCHITECTURE.md`** — expanded CORS configuration section + (environment behavior, production guidance, WebSocket interaction). +- **`docs/ROADMAP.md`** — v0.28.x Tier 2 bucket eliminated (items + relocated to pinned versions: v0.29.1, v0.30.0, v0.30.1). Pre-1.0 + Code Quality Sweeps section deleted (relocated to v0.29.0 Phase 0 + with CI-enforced gate). Zero unscheduled items remain outside TBD. + ## [0.28.7] — 2026-03-15 ### Summary diff --git a/VERSION b/VERSION index cf86fe7..a4b9c5a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.28.7 +0.28.8 diff --git a/chart/templates/ingress.yaml b/chart/templates/ingress.yaml index 11b5a8a..93222d2 100644 --- a/chart/templates/ingress.yaml +++ b/chart/templates/ingress.yaml @@ -5,9 +5,14 @@ metadata: name: {{ .Release.Name }}-ingress labels: {{- include "switchboard.labels" . | nindent 4 }} - {{- with .Values.ingress.annotations }} + {{- if or .Values.ingress.annotations (and (eq (default "traefik" .Values.ingress.className) "traefik") .Values.ingress.retry.annotateIngress) }} annotations: + {{- with .Values.ingress.annotations }} {{- toYaml . | nindent 4 }} + {{- end }} + {{- if and (eq (default "traefik" .Values.ingress.className) "traefik") .Values.ingress.retry.annotateIngress }} + traefik.ingress.kubernetes.io/router.middlewares: {{ .Release.Namespace }}-{{ .Release.Name }}-retry@kubernetescrd + {{- end }} {{- end }} spec: {{- if .Values.ingress.className }} diff --git a/chart/templates/middleware-retry.yaml b/chart/templates/middleware-retry.yaml new file mode 100644 index 0000000..ca75884 --- /dev/null +++ b/chart/templates/middleware-retry.yaml @@ -0,0 +1,14 @@ +{{- if .Values.ingress.enabled }} +{{- if eq (default "traefik" .Values.ingress.className) "traefik" }} +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: {{ .Release.Name }}-retry + labels: + {{- include "switchboard.labels" . | nindent 4 }} +spec: + retry: + attempts: {{ .Values.ingress.retry.attempts | default 2 }} + initialInterval: {{ .Values.ingress.retry.initialInterval | default "100ms" }} +{{- end }} +{{- end }} diff --git a/chart/values.yaml b/chart/values.yaml index 43146c3..79e0522 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -89,6 +89,14 @@ ingress: tls: enabled: false secretName: "" + # Traefik retry middleware (auto-applied when className=traefik). + # The Middleware CRD is always created. Set annotateIngress: true to wire + # it to the ingress. Only enable after confirming the middleware CRD exists + # (Traefik invalidates the entire router if a referenced middleware is missing). + retry: + attempts: 2 + initialInterval: 100ms + annotateIngress: false # ── Auth ─────────────────────────────────── auth: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 450f435..7c7601b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -543,7 +543,18 @@ The appearance settings offer three modes: Light, Dark, System. System mode list - **Policies**: Boolean flags in `global_settings` table control registration, BYOK, team providers, etc. - **Audit**: All admin operations logged to `audit_log` with actor, action, resource type/ID, and diff - **Banner**: Environment classification banner configurable via admin settings (text, color, position) -- **CORS**: Configurable allowed origins via env var +- **CORS**: `CORS_ALLOWED_ORIGINS` env var controls which origins can make + cross-origin requests (HTTP and WebSocket). Comma-separated list of + allowed origins, e.g. `https://switchboard.corp,https://admin.corp`. + Behavior by environment: + - **Production** (`ENVIRONMENT=production`): if unset, defaults to + same-origin only (no `Access-Control-Allow-Origin` header). Set + explicitly to the domain(s) serving the frontend. + - **Development/test**: if unset, defaults to `*` (all origins). + - **Explicit `*`**: allowed but logs a startup warning. Not recommended + for production — restricts nothing and exposes the API to any origin. + The WebSocket upgrader's `CheckOrigin` respects the same setting. + Connections from origins not in the list are rejected at upgrade time. - **No sensitive terminology**: Banner system avoids classification-related terms for security compliance ## File Storage diff --git a/docs/ICD/websocket.md b/docs/ICD/websocket.md index 95a4fda..9f32a6d 100644 --- a/docs/ICD/websocket.md +++ b/docs/ICD/websocket.md @@ -6,10 +6,41 @@ WebSocket connection belonging to the recipient user. ### Connection +**Preferred (v0.28.8+): Ticket exchange** + +``` +POST /api/v1/ws/ticket (authenticated via JWT Bearer header) +→ 200 { "ticket": "" } +``` + +Then connect with the opaque ticket: + +``` +ws://{host}{BASE_PATH}/ws?ticket={ticket} +``` + +Tickets are single-use (deleted on validation), 30-second TTL, and +stored in server memory. This avoids exposing the JWT in server logs, +proxy logs, and browser history. + +**Legacy (deprecated): JWT query parameter** + ``` ws://{host}{BASE_PATH}/ws?token={access_token} ``` +Still accepted for backward compatibility with pre-v0.28.8 clients. +Server logs a deprecation warning on each legacy connection. Will be +removed in a future version. + +**Non-browser clients** may also use the `Authorization: Bearer ` +header on the upgrade request. + +**Origin validation:** The WebSocket upgrader validates the `Origin` +header against `CORS_ALLOWED_ORIGINS`. Connections from unlisted +origins are rejected at upgrade time. In development (no +`CORS_ALLOWED_ORIGINS` set), all origins are accepted. + **Lifecycle:** - Server sends WebSocket-level `ping` frames every 54 seconds - Client must respond with `pong` within 60 seconds or disconnection diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 97b4a26..857fb38 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -15,844 +15,376 @@ No compatibility guarantees before 1.0. ## Dependency Graph -Features have real dependencies. This ordering respects them. +Two parallel implementation tracks converge at v0.50.0 (MVP). The +extension track builds the package ecosystem and workflow capabilities. +The operations track builds production readiness for multi-team +deployment. Neither blocks the other until the MVP gate. ``` -v0.9.x–v0.27.5 Foundation → Extensions → Surfaces → Auth ✅ - → Workflows → Tasks → Team Tasks +v0.9.x–v0.28.7 Foundation through Platform Polish ✅ (see CHANGELOG.md for full history) │ - v0.28.0 Platform Polish - ├─ v0.28.1 Surfaces ICD audit ✅ - ├─ v0.28.2 ICD audit — all domains ✅ - ├─ v0.28.3 ICD close-out + FE decomp ✅ - ├─ v0.28.4 Security tier (red team) ✅ - ├─ v0.28.5 Frontend SDK + Pipes ✅ - │ (switchboard-sdk.js, pipe/filter - │ pipeline, component mounting) - ├─ v0.28.6 Infrastructure ✅ - │ (virtual scroll, Helm, system tasks, - │ broadcast, git keygen, model prefs) - └─ v0.28.7 Unified Packaging + Task RBAC ✅ - (.pkg archive, manifest.type, - task permission gate pre-Starlark) + v0.28.8 ICD Green Board ✅ │ - v0.28.8 ICD Green Board - (provider timeout resilience, CORS - lockdown, SDK base path fix, WS - ticket exchange) - │ - v0.29.0 Starlark Sandbox - (eval loop, permissions, admin UI - — adds starlark capability to .pkg) - │ - v0.29.1 API Extensions - (Starlark route handlers, - outbound HTTP, requires_provider) - │ - v0.29.2 DB Extensions - (namespaced tables, scoped db module, - declarative schema in manifest) - │ - v0.29.3 Workflow Forms - (form_template → real UI, LLM optional, - .star validation, structured data) - │ - v0.30.0 Package Lifecycle - (schema versioning, migrations, - settings extension point, - export/import, marketplace) - │ - v0.30.1 SDK Adoption - (sw.notes factory, core surface - migration, fix-once-fix-everywhere) - │ - v0.30.2 Workflow Packages - (stage surfaces, custom UI per stage, - team admin workflow builder) - │ - v0.31.0 Editor Package - (E2E proof: rebuild editor as - installable .pkg, zero - platform special-casing) + ┌───────────────┴───────────────┐ + │ │ + Extension Track Operations Track + │ │ + v0.29.0 Starlark Sandbox v0.32.0 Multi-Replica HA + v0.29.1 API Extensions v0.33.0 Observability + v0.29.2 DB Extensions v0.34.0 Data Portability + v0.29.3 Workflow Forms │ + v0.30.0 Package Lifecycle │ + v0.30.1 SDK Adoption │ + v0.30.2 Workflow Packages │ + v0.31.0 Editor Package │ + │ │ + ══════╪═══════════════════════════════╪══════ + │ MVP v0.50.0 │ + │ (v0.31.0 + v0.34.0 │ + │ + mobile + deployment docs) │ + ══════╪═══════════════════════════════╪══════ + │ + v0.50.0+ Rich Media + Beyond + (image gen, code sandbox, + STT/TTS, desktop app) ``` ---- - -## v0.28.0 — Platform Polish - -Audit arc, frontend decomposition, security, and infrastructure improvements. - -### v0.28.1 — Surfaces ICD Audit ✅ -- [x] ICD `surfaces.md` corrected (6 discrepancies: field name, archive format, response shape) -- [x] Surface ID slug validation + `extractableRelPath` install hardening -- [x] 19 E2E surface CRUD tests in ICD runner (install, enable/disable, delete, error paths) -- [x] 22 handler-level tests + 14 store-level tests (PG + SQLite) -- [x] CI timeout 8m → 12m for PG integration tests - -### v0.28.2 — ICD Audit: All Domains ✅ -Full audit of every ICD document against code. Goal: ICD becomes the single -source of truth. After this version, the workflow is ICD-first — update the -contract before changing code. - -**Methodology:** Read ICD → grep all source → trace route → handler → store -(PG + SQLite) → tests → enforce `{"data": [...]}` envelope convention → fix -ICD → fix code → fix runner → CI green. Final pass picks up straggling -failures across all domains. - -**Final score: 469/469 (100%)** - -**Completed audits:** - -*Notifications (cs0):* -- [x] Notifications ICD audit: object shape, query params, response envelopes, WS events -- [x] Notification type enum sync: remove aspirational types, add implemented types - (`kb.ready`, `kb.error`, `grant.changed`, `task.budget_exceeded`) -- [x] Implement `memory.extracted` notification (hook in memory extractor) -- [x] Implement `user.mentioned` persisted notification (was WS-only) -- [x] Implement `workflow.claimed` persisted notification (was WS-only) -- [x] Remove dead `NotifTypeProjectInvite` constant -- [x] `GET /notifications/preferences` — envelope fix (`missing key "preferences"`) - -*Knowledge (cs1–cs4):* -- [x] `knowledge.md` corrected: KB object shape (6 field mismatches), search envelope - (`data` not `results`), search result fields, status progression (`extracting` - step), file type support (text-only, not binary), auth annotations -- [x] Test harness: add `RequirePermission` on `POST /knowledge-bases` and - `POST /:id/documents`, add missing `GET /:id/documents/:docId/status` and - `DELETE /:id/documents/:docId` routes -- [x] New tests: document status polling, document delete, update-empty-body 400, - permission denial for non-privileged user -- [x] P0 fix: `SetDiscoverable` authorization — `loadAndAuthorize` + owner/admin - check, audit log, cross-user security tests (cs2) -- [x] `ListDiscoverableKBs` response normalization — use `toKBResponse()`, - shape assertion test (cs3) -- [x] Dead code removal: `ListGlobal`/`ListForTeam` on KnowledgeBaseStore — - interface + PG + SQLite (cs4) -- [x] Move `CreateKB` team role check from raw `database.DB.QueryRow` to - `stores.Teams.IsTeamAdmin`; remove `database` import (cs4) -- [x] Team-scoped KB creation test: member denied, team admin succeeds (cs4) - -*Profile (cs5):* -- [x] `profile.md` corrected: avatar API (was `multipart/form-data`, actually JSON - base64), response shapes for all 7 endpoints, auth annotations, field table -- [x] P0 fix: `GET /settings` returns bare object → wrapped in `{"settings": {...}}` -- [x] `profileResponse` shape: add `last_login_at` field + dialect-safe time scan - (`database.ST()` / `database.SNT()`), COALESCE guard on settings column -- [x] Integration test harness: register all 7 profile/settings routes (was only - `GET /profile`), remove stale `/avatar` route aliases -- [x] New `profile_test.go`: GET profile shape, PUT profile (display_name, email, - duplicate email 409), GET/PUT settings envelope, password change (success, - wrong current 401, too short 400), avatar delete - -*Workspaces (cs6):* -- [x] `workspaces.md` corrected: workspace object shape (added `indexing_enabled`, - `git_*` fields, `total_bytes` not `storage_bytes`, `owner_type` full enum), - file entry shape (`is_directory`/`size_bytes` not `is_dir`/`size`), git status - full shape, git log envelope (`{"data": [...]}`), git commit request (`paths` - not `files`), archive format query param, auth annotations, all response shapes -- [x] P0 fix: `GET /workspaces` returns bare array → `{"data": [...]}` -- [x] P0 fix: `GET /git-credentials` returns bare array → `{"data": [...]}` -- [x] Fix: `GET .../git/log` returns bare array → `{"data": [...]}` + nil slice guard -- [x] New `workspace_test.go`: List empty envelope, list with data + shape, root_path - not exposed, user isolation, GET by ID shape, not found 404, forbidden 403, - git-credentials empty envelope, auth required (11 tests) - -*Projects (cs7):* -- [x] `projects.md` full rewrite: 6 categories of drift (ghost fields, missing fields, - request shapes, association objects, `omitempty` on computed counts) -- [x] 14 new Go tests: CRUD shapes, 201 status, envelope, admin list, auth, isolation -- [x] P0 nil guard: `ListByProject` files `null` → `[]` -- [x] P0: `ListTeamProviderModels` missing `Type` field — Venice 400 fix -- [x] ICD runner projects rewritten 9 → 19 tests -- [x] ICD runner tier-providers: SSE parser, model exclusion, ID fallback fixes - -### v0.28.3 — ICD Close-out + Frontend Decomposition Prep -ICD straggler sweep (rolled in from v0.28.2 tail) and frontend -decomposition groundwork. - -**ICD close-out (cs0):** -- [x] `websocket.md` full rewrite: event envelope field (`event` not `type`), - payload shapes for 11 event types, routing table from code, room model - documented as planned-not-implemented -- [x] `auth.md` corrected: login response shape (added `token_type`/`expires_in`, - removed phantom fields), register admin-approval path -- [x] `enums.md` corrected: added missing `queued` task run status -- [x] Presence status gap documented (DB allows `away`, runtime emits only - `online`/`offline`) -- [x] VERSION, CHANGELOG, ROADMAP updated - -**WebSocket delivery fix (cs1):** -- [x] `workflow.claimed`, `workflow.advanced`, `workflow.completed` use room-scoped - `Bus.Publish()` but rooms are never joined — events never reach WebSocket - clients. Convert to `SendToUser()` per channel participant (same pattern as - `message.created`, `workflow.assigned`). - -**Frontend decomposition:** -- [x] JS dependency audit: `docs/JS-DEPENDENCY-AUDIT.md` — full map of 47 files, - 431 globals, cross-file call graph, script load order per surface -- [x] IIFE extraction: all 47 JS files wrapped, ~168 functions privatized, zero - implicit globals remain. Explicit `window.*` exports on every file. -- [x] Cross-file coupling fix: `events.js` no longer reads `_storageKey` from - `api.js` — uses `API.accessToken` instead -- [ ] ICD runner gains test tiers: `crud`, `envelope`, `security`, `sdk` -- [ ] Runner coverage target: 100% of ICD-documented endpoints have at least one test -- [x] Phase 2: onclick → addEventListener migration (143 dynamic → 5 unconvertible, - 163 data-action delegated via `_uiDispatch` on document.body) -- [x] Phase 3: Action registry (`sb.js`) — 248 `window.*` exports replaced with - `sb.register()`/`sb.ns()`. `sb.resolve()` centralized dispatch. - `sb.call()` template bridge ready for Go template migration. -- [x] Phase 3b: Go template onclick → `sb.call()` migration (72 handlers - across 10 templates, 4 unconvertible inline DOM ops, 3 standalone - pages excluded) -- [x] Phase 4: ES module conversion — IIFE wrappers removed from all 47 - files, `