From 30f166ccc3eed51e6fcf8ab93e539b6d54b79e2a Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 16:02:24 -0500 Subject: [PATCH 01/89] docs(dashboard/contract): add slice-a design spec and implementation plan Slice (a) of the new declarative dashboard contract: single-endpoint API with kind-discriminated envelope, intent + slots schema, server-side permission filtering, multiplexed SSE for subscriptions, per-contributor version negotiation. The React shell and contributor migrations are separate slices. DESIGN.md captures the locked-in design decisions; IMPLEMENTATION_PLAN.md breaks the work into 15 phases of TDD-driven tasks. Adds a .gitignore exception so design docs co-located with the package are tracked despite the repo-wide **/*.md ignore. --- .gitignore | 1 + extensions/dashboard/contract/DESIGN.md | 415 ++ .../dashboard/contract/IMPLEMENTATION_PLAN.md | 4317 +++++++++++++++++ 3 files changed, 4733 insertions(+) create mode 100644 extensions/dashboard/contract/DESIGN.md create mode 100644 extensions/dashboard/contract/IMPLEMENTATION_PLAN.md diff --git a/.gitignore b/.gitignore index 57250c70..8feef20d 100644 --- a/.gitignore +++ b/.gitignore @@ -122,6 +122,7 @@ METRICS_DEADLOCK_FIX.md !README.md **/*.md !**/README.md +!extensions/dashboard/contract/*.md /**/*.disabled extensions/dashboard/forgeui *.blob diff --git a/extensions/dashboard/contract/DESIGN.md b/extensions/dashboard/contract/DESIGN.md new file mode 100644 index 00000000..a5822e10 --- /dev/null +++ b/extensions/dashboard/contract/DESIGN.md @@ -0,0 +1,415 @@ +# Admin Dashboard Contract — Slice (a) + +> Design spec. Authored 2026-05-09 via the brainstorming skill, approved before implementation. +> The implementation plan lives separately and is generated by the writing-plans skill. + +## Context + +Today's dashboard mixes three concerns into one Go-side process: (1) route registration & manifest metadata, (2) HTML rendering via templ + 20 templ files (~2,266 LOC), and (3) a remote-contributor protocol that exchanges raw HTML fragments. The Go side carries ForgeUI/templ, an Alpine bridge, per-contributor SSE/JSON-RPC plumbing, and HTML-fragment proxying — most of the dashboard's memory footprint. + +The decoupled architecture pushes UI rendering entirely into a separate React shell, leaves only declarative configuration on the Go side, and routes every API call through one endpoint. Each contributor publishes a JSON/YAML "UI graph" describing intents and their composition; the host registry merges contributor graphs into a single tree; the React shell renders by mapping intents to known components. + +This spec defines **slice (a): the contract** — the schema, the wire envelope, and the merge/versioning/error/caching rules. It does not cover the React shell internals, the security implementation, or migration of existing contributors; those are separate slices noted at the end. + +## Scope + +**In scope (this spec):** +- YAML/JSON schema vocabulary contributors author +- Wire envelope for the single endpoint and SSE channel +- Slot composition and escape hatches +- Permission model integration with existing `UserInfo`/`Roles`/`Scopes` and a new `Warden` interface +- Graph merge rules across contributors +- Per-contributor version negotiation +- Error envelope and invalidation hints + +**Out of scope (separate slices):** +- React shell rendering engine implementation +- Built-in intent vocabulary v1 component implementations (the spec defines the *contract* for intents; the React renderers are slice (d)) +- Pilot contributor migration (slice (c)) +- Removal of templ/SSR code from the Go side (slice (f)) +- Session/auth changes — existing `UserInfo` model is reused as-is + +## Design Decisions Locked In + +| Decision | Choice | +|---|---| +| Schema philosophy | Intent + slots hybrid: fixed intent vocabulary, each intent declares typed slots that accept other intents or escape-hatch components | +| Envelope discrimination | Kind in envelope (`graph`/`query`/`command`/`subscribe`) **and** capability in intent declaration (`read`/`write`/`render`) — belt-and-suspenders | +| Subscription modes | `replace`, `append`, `snapshot+delta` — all three from v1 | +| Subscription transport | Multiplexed SSE — one connection per page fans in all subscriptions | +| Subscription authz | Per-event re-check, cached | +| Permission placement | All three layers: per-intent, per-graph-node (`visibleWhen`/`enabledWhen`), per-slot | +| Predicate language | Boolean structure (`all`/`any`/`not`) plus pluggable `Warden` interface for data-aware decisions | +| Server-side enforcement | Filter the graph server-side before sending; per-intent re-check on invocation; per-event re-check on subscriptions | +| Audit | On by default for all `command` invocations; opt-out per intent requires a documented rationale | +| Data binding | Inline data on a node **and** named queries block — both supported | +| Parameter sources | `static`, `route`, `session`, `parent`, `state` (all five) | +| Param trust | `parent`/`state` references are **never** authoritative; server re-validates params and re-checks permissions on every intent invocation | +| Slot typing | Strict — parent intent declares accepted intent kinds and cardinality per slot; registration validates fills | +| Slot nesting | Max depth 8; cycle detection at registration | +| Cross-contributor slot fills | Allowed via explicit `extends:` declaration; parent declares slot as `extensible` (or names allowed contributors) | +| Escape hatches | (A) named React component registry (shipped in shell repo) **and** (B) iframe sandbox with declared postMessage protocol; module federation explicitly excluded | +| Route merging | Namespaced under `/{contributor}/{path}` by default; opt in to platform-root with `root: true`; root conflicts halt startup | +| Nav/widget merging | Today's group/priority model carries forward | +| Slot extension failures | Graceful degradation — failed extension logs and is dropped; parent renders without it | +| Versioning | Per-contributor negotiation: contributor declares supported envelope versions and per-intent versions; shell negotiates highest mutual per request | +| Error envelope | Uniform shape across all kinds; canonical code set + namespaced contributor codes | +| Caching | Graph cached per `(route × permissions-hash × shell-version)`; queries client-cached with per-intent `staleTime`; commands return invalidation hints | + +## The Contract + +### Wire endpoints + +``` +POST /api/dashboard/{envelopeVersion} — graph, query, command kinds +GET /api/dashboard/{envelopeVersion}/stream — subscribe kind (SSE, multiplexed) +GET /api/dashboard/{envelopeVersion}/capabilities — discovery: merged manifest, per-contributor supported versions +GET /dashboard/contributors/{name}/static/* — static assets for iframe escape hatch (out of envelope) +``` + +`{envelopeVersion}` is `v1` initially; future versions run in parallel during a deprecation window per the negotiation protocol below. + +### Request envelope (POST) + +```json +{ + "envelope": "v1", + "kind": "command", + "contributor": "users", + "intent": "user.disable", + "intentVersion": 2, + "payload": { "id": "u_42" }, + "params": { "tenant": "acme" }, + "context": { + "route": "/admin/users", + "correlationID": "req_..." + }, + "csrf": "...", + "idempotencyKey": "..." +} +``` + +- `kind` is enforced against the intent's declared `capability`. `command` requires `csrf` and `idempotencyKey`; `query` and `graph` require neither; `subscribe` is GET-only on the stream channel. +- `intentVersion` defaults to the highest version the contributor advertises that the shell also understands; explicit values are negotiation overrides. + +### Response envelope + +```json +{ + "ok": true, + "envelope": "v1", + "kind": "query", + "data": { ... }, + "meta": { + "intentVersion": 2, + "deprecation": null, + "cacheControl": { "staleTime": "30s" }, + "invalidates": ["users.list"] + } +} +``` + +Error shape: + +```json +{ + "ok": false, + "envelope": "v1", + "error": { + "code": "PERMISSION_DENIED", + "message": "...", + "details": {}, + "retryable": false, + "correlationID": "...", + "redactions": ["users[*].email"] + } +} +``` + +Canonical codes: `BAD_REQUEST`, `UNAUTHENTICATED`, `PERMISSION_DENIED`, `NOT_FOUND`, `CONFLICT`, `RATE_LIMITED`, `UNSUPPORTED_VERSION`, `UNAVAILABLE`, `INTERNAL`. Contributor-specific codes namespaced (`auth.SESSION_EXPIRED`). + +### Stream envelope (SSE) + +``` +GET /api/dashboard/v1/stream + Accept: text/event-stream +``` + +Client opens one stream per page, then sends a control message over a paired `POST /api/dashboard/v1/stream/control` to subscribe/unsubscribe specific intents. Each event: + +``` +event: +data: { "intent": "audit.tail", "mode": "append", "payload": {...}, "seq": 42 } +``` + +`mode` is one of `replace` | `append` | `snapshot+delta`. For `snapshot+delta`, `payload` carries a JSON Patch (RFC 6902) after the initial snapshot; `seq` is monotonic per subscription for gap detection on reconnect. + +### Contributor manifest YAML + +```yaml +contributor: + name: users + envelope: + supports: [v1] + preferred: v1 + capabilities: [users.read, users.write] + +queries: + userList: + intent: users.list + params: + tenant: { from: route.tenant } + sort: { from: state.userTable.sort } + cache: + staleTime: 30s + +intents: + - name: users.list + kind: query + version: 1 + capability: read + requires: { all: [scope:users.read] } + schema: + input: { tenant: string, filter: object } + output: { users: User[], total: int } + audit: false + + - name: user.disable + kind: command + version: 2 + capability: write + requires: + all: [role:admin, scope:users.write] + warden: tenantOwner + schema: + input: { id: string } + output: { ok: bool } + invalidates: [users.list, user.detail] + # audit: true is the default + + - name: audit.tail + kind: subscription + version: 1 + capability: read + mode: append + requires: { role: admin } + schema: + output: AuditEntry + +graph: + - route: /users + intent: page.shell + title: Users + nav: + group: Identity + icon: users + priority: 10 + slots: + main: + - intent: resource.list + data: queries.userList + slots: + rowActions: + - intent: action.button + label: Disable + op: user.disable + visibleWhen: { all: [role:admin, scope:users.write] } + payload: { id: { from: parent.id } } + detailDrawer: + intent: form.edit + data: + intent: user.detail + params: { id: { from: parent.id } } + fields: [...] + extensible: true +``` + +### Capability negotiation + +`GET /api/dashboard/v1/capabilities` returns: + +```json +{ + "shellEnvelopes": ["v1"], + "contributors": [ + { + "name": "users", + "envelopes": ["v1"], + "intents": [ + { "name": "users.list", "versions": [{ "n": 1, "status": "active" }] }, + { "name": "user.disable", "versions": [ + { "n": 1, "status": "deprecated", "removeAfter": "2026-09-01" }, + { "n": 2, "status": "active" } + ] } + ] + } + ] +} +``` + +Negotiation flow per request: +1. Shell selects `envelopeVersion = max(intersect(shellEnvelopes, contributor.envelopes))`. +2. If empty intersection → `UNSUPPORTED_VERSION` with the contributor's supported set. +3. Shell selects `intentVersion = max(active versions the shell understands)`. +4. Server emits `Deprecation` warning in `meta` if the negotiated version is deprecated. + +### Slot extension protocol + +Contributor B extends contributor A's intent: + +```yaml +extends: + - target: { contributor: users, intent: page.shell, route: /users } + slot: detailDrawer.fields + add: + - intent: form.field + name: ssoLinkedAccount + label: SSO Account + data: + intent: auth.linkedAccount + params: { userID: { from: parent.id } } + requires: { scope: auth.read } +``` + +Validation at registration: +- Target intent's slot must be declared `extensible: true` or include B in an `allowedExtenders` list. +- Added node must satisfy the slot's `accepts` constraint. +- Permission-filter applied at graph-build like any other node. + +### Warden interface + +```go +package contract + +type Warden interface { + Authorize(ctx context.Context, p Principal, a Action) (Decision, error) +} + +type Principal struct { + User *auth.UserInfo // existing type from extensions/dashboard/auth + Claims map[string]any +} + +type Action struct { + Contributor string + Intent string + Kind string // graph | query | command | subscribe + Capability string // read | write | render + Resource map[string]any // intent params +} + +type Decision struct { + Allow bool + Reason string + Redactions []string // JSONPath-like paths to strip from query/subscribe responses +} + +// WardenRegistry maps a Warden's declared name to its implementation. +// Contributors register Wardens at startup; the registry validates that every +// YAML reference (requires.warden) resolves at registration time. +type WardenRegistry interface { + Register(name string, w Warden) error + Get(name string) (Warden, bool) +} +``` + +Evaluation order at every authorization point: +1. Boolean predicate from YAML (cheap, fail-fast). +2. If intent declares `requires.warden`, call the registered Warden. +3. Apply `Decision.Redactions` to query/subscribe responses before sending. + +### Graph build & merge + +1. **Load**: read every contributor's manifest YAML; validate against schema; resolve all references (intent names, slot accepts, query refs, Warden names); fail startup on any error. +2. **Index**: build an in-memory registry of `(contributor, intent, version)` → handler. +3. **Apply extensions**: for each `extends:`, validate slot constraint and merge into the target intent's slot list, ordered by extending contributor's priority. +4. **Per-request graph build**: given `(route, principal)`, walk the contributor that owns the route, recursively expand slots, evaluate `visibleWhen`/`enabledWhen` and per-intent/per-slot `requires` against the principal, drop nodes the user cannot see, return the filtered tree. +5. **Cache**: graph keyed by `(route, permissionsHash(principal), shellVersion)`. Permissions hash is a stable hash over `(roles, scopes, tenant)`; most users in the same role share the same cache entry. + +### Caching & invalidation + +- **Graph**: server-side LRU keyed as above; TTL configurable (default 5 minutes); explicitly busted on contributor manifest reload or shell deploy. +- **Query**: client-side (React Query / SWR) with `staleTime` declared on each named query; inline queries default to `staleTime: 0`. +- **Command**: response includes `invalidates: ["intent1", "intent2"]`; the React shell evicts those query keys on success. For cross-page invalidation, the same hint can be broadcast on the SSE multiplex (`event: invalidation`). + +## Files Affected + +### New (this slice) + +``` +extensions/dashboard/contract/ + envelope.go # envelope, request/response, error types + manifest.go # YAML schema for contributor manifests + schema.go # JSON Schema validators for envelope + manifest + registry.go # contributor + intent + warden registry + merge.go # extension application, graph build, permission filter + warden.go # Warden interface, Principal, Action, Decision, WardenRegistry + capabilities.go # capability catalog endpoint handler + errors.go # canonical codes + cache.go # graph cache keyed by (route, permissionsHash, shellVersion) + +extensions/dashboard/contract/loader/ + yaml.go # YAML → manifest, schemaVersion check, version negotiation tables + validate.go # cross-reference validation (intent names, slot accepts, warden names) + +extensions/dashboard/contract/transport/ + http.go # POST /api/dashboard/v1 handler — kind dispatch + stream.go # GET /api/dashboard/v1/stream — multiplexed SSE + control.go # POST /api/dashboard/v1/stream/control — subscribe/unsubscribe + +extensions/dashboard/contract/audit.go # default-on audit emitter for command invocations + +cmd/dashboard-contract-probe/ # raw-envelope CLI for testing without the React shell + main.go +``` + +### Modified + +- `extensions/dashboard/contributor/registry.go` — gains a `ContractRegistry` alongside the existing manifest registry; the existing `Register` continues to work for legacy contributors during the migration window. Today's merge logic in `rebuildLocked` (registry.go:228) stays for the legacy path. +- `extensions/dashboard/contributor/manifest.go` — adds optional `Contract *ContractManifest` field on `Manifest` so a contributor can publish a contract-style manifest in parallel with the legacy templ-based one. +- `extensions/dashboard/extension.go:registerRoutes` (lines 1207–1279) — registers the new contract endpoints alongside today's `/api/*`, `/ext/*`, `/remote/*`. +- `extensions/dashboard/auth/middleware.go` — `UserInfo` and middleware are reused unchanged; `ContractRegistry` calls `UserFromContext` exactly as today's pages do. + +### Reused (do not duplicate) + +- `extensions/dashboard/auth.UserInfo` (auth/types.go:63) — the `Principal.User` field is this type directly. +- `extensions/dashboard/auth/middleware.go:ForgeMiddleware` — already populates `UserInfo` in context; the contract handlers read it via the existing `UserFromContext` helper. +- `forwardedHeadersFrom(ctx)` from contributor/remote.go — auth header forwarding for the iframe escape hatch reuses the same allowlist (Authorization, Cookie, X-Forge-Api-Key) introduced in commit 6e53722. +- The existing `/sse` SSE broker — multiplexed stream channel is built on top, not parallel to it. + +### Removed (in a later slice, not this one) + +- `extensions/dashboard/ui/*.templ` (~20 files, ~2,266 LOC) — only after slice (f) migrates all contributors. +- `extensions/dashboard/contributor/ssr.go` and `embedded.go` HTML-fragment paths — only once no contributor relies on them. +- HTML-fragment proxying in `RemoteContributor` (Fetch/Post Page/Widget/Settings) — replaced by contract calls. + +## Verification + +Spec-level (this slice ships only Go types + handlers + a JSON-Schema validator; the React shell is a separate slice): + +1. **Unit tests** — table-driven tests under `extensions/dashboard/contract/*_test.go`: + - Envelope round-trip for each `kind`. + - Manifest validation: a manifest with missing intent reference, invalid slot accept, undeclared Warden, and version mismatch each fail with a precise error. + - Permission filter: graph build with a synthetic `UserInfo` produces the correct subtree; nodes whose `requires` fail are stripped, not annotated. + - Slot extension: extension targeting a non-extensible slot is rejected; extension whose `accepts` doesn't match is rejected; valid extension is merged at the right priority. + - Version negotiation: shell+contributor with overlapping versions selects the highest mutual; no overlap returns `UNSUPPORTED_VERSION`. + - Audit: every `command` invocation emits an audit record; a `command` declared `audit: false` does not. + +2. **End-to-end harness** — a fixture contributor under `extensions/dashboard/contract/testdata/`: + - YAML fixture with three intents (one each of query/command/subscribe) and one extension. + - Test driver hits `POST /api/dashboard/v1` with each kind, verifies envelope shape, status, audit emission, and invalidation hints. + - SSE driver opens `GET /api/dashboard/v1/stream`, subscribes via control, asserts `replace`/`append`/`snapshot+delta` event sequences. + +3. **Capability catalog** — `GET /api/dashboard/v1/capabilities` returns the merged catalog; assert it reflects all registered contributors with their declared envelope and intent versions. + +4. **Contract probe CLI** — `cmd/dashboard-contract-probe` sends raw envelopes and prints responses; useful for migrating the first contributor before any React UI exists. + +## Out of Scope — Future Slices + +These are separate specs, brainstormed independently: + +- **(b) Security implementation** — concretely wiring the Warden interface, audit pipeline integration with chronicles, predicate engine performance, CSRF token issuance/rotation, idempotency-key persistence. +- **(c) Pilot contributor migration** — pick one current contributor (gateway or consensus suggested), produce its manifest YAML, validate end-to-end against the contract. +- **(d) React shell rendering engine** — the JS side: intent component registry, slot renderer, query client, SSE multiplexer, escape-hatch loader (named components + iframe boundary). +- **(e) Built-in intent vocabulary v1** — concrete React implementations of `resource.list`, `resource.detail`, `dashboard.grid`, `form.edit`, `metric.counter`, `audit.tail`, etc., with their schemas. +- **(f) Migration path** — running the contract registry alongside the legacy templ/HTML-fragment registry, deprecation timeline, removal of templ files and SSR plumbing. diff --git a/extensions/dashboard/contract/IMPLEMENTATION_PLAN.md b/extensions/dashboard/contract/IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..9cd169d8 --- /dev/null +++ b/extensions/dashboard/contract/IMPLEMENTATION_PLAN.md @@ -0,0 +1,4317 @@ +# Dashboard Contract — Slice (a) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the Go-side contract package for slice (a): a single-endpoint, declarative-manifest dashboard that contributors author in YAML, with kind-discriminated wire envelope, server-side permission filtering, multiplexed SSE for subscriptions, per-contributor version negotiation, and a default audit emitter. + +**Architecture:** A new self-contained package at `extensions/dashboard/contract/` exposes (1) types for request/response/stream envelopes, (2) types for contributor manifests with strict slot typing, (3) a registry that validates manifests at startup and resolves cross-references, (4) a graph-build pipeline that filters per-user before sending, (5) HTTP and SSE transports that dispatch by kind. The package coexists with today's templ-based contributor system; legacy registration and routing continue working in parallel until slice (f) retires them. + +**Tech Stack:** Go 1.25, `gopkg.in/yaml.v3` for YAML, stdlib `net/http`, `encoding/json`, `testing`. No new external dependencies. Reuses `extensions/dashboard/auth.UserInfo` and the existing `forge.Router`. + +--- + +## Reference + +- **Design spec:** [DESIGN.md](DESIGN.md) — read this first; every decision in this plan traces back to a row in the spec's "Design Decisions Locked In" table. +- **Forge module path:** `github.com/xraph/forge` +- **Existing patterns to mirror:** + - Test layout: see `extensions/dashboard/contributor/registry_test.go` — plain `testing` package, table-driven where helpful, no testify in this subtree. + - Manifest layout: see `extensions/dashboard/contributor/manifest.go:10` — JSON tags on every field, optional fields use `omitempty`. + - Route registration: see `extensions/dashboard/extension.go:1207` — `must(router.GET(base+path, handler))` pattern with `forge.Router`. + - Existing CSRF infrastructure: `extensions/security/csrf.go` — slice (a) defines hooks, slice (b) wires the actual middleware. + +## Out of Scope (Other Slices) + +Do **not** implement these in this plan: +- React shell, intent renderers (slice (d), (e)) +- Pilot contributor migration to YAML (slice (c)) +- Chronicle integration for audit; slice (a) ships an interface + stdlib-log default impl, slice (b) wires chronicles. +- CSRF/idempotency middleware enforcement; slice (a) defines header hooks, slice (b) wires the security extension. +- Removal of any templ files (slice (f)). + +## File Structure + +``` +extensions/dashboard/contract/ + doc.go # package documentation + errors.go # canonical error codes + Error type + envelope.go # Request, Response, StreamEvent types + manifest.go # ContractManifest, Intent, Slot, GraphNode, Predicate, Extension types + predicate.go # Predicate evaluation + PermissionsHash + warden.go # Warden interface, Principal, Action, Decision, WardenRegistry + registry.go # ContractRegistry — contributor + intent index + slots.go # slot extension application, cycle detection, depth check + graph.go # per-request graph build with permission filter + cache.go # graph LRU cache keyed by (route, permissionsHash, shellVersion) + audit.go # AuditEmitter interface + log-based default implementation + loader/ + yaml.go # YAML → ContractManifest with schemaVersion check + validate.go # cross-reference validator (intent refs, slot accepts, warden names, version negotiation) + transport/ + http.go # POST /api/dashboard/v1 — kind dispatch, version negotiation, error envelope + capabilities.go # GET /api/dashboard/v1/capabilities + stream.go # GET /api/dashboard/v1/stream — multiplexed SSE + control.go # POST /api/dashboard/v1/stream/control — subscribe/unsubscribe + testdata/ + fixture_users.yaml # E2E fixture contributor manifest + fixture_auth_extends.yaml # E2E fixture extension manifest + *_test.go # tests live next to each file (Go convention) + +extensions/dashboard/extension.go # MODIFY: register contract routes alongside legacy + +cmd/dashboard-contract-probe/ + main.go # raw-envelope CLI for testing without React shell +``` + +Each file owns one responsibility. Files that change together stay together: `slots.go` and `graph.go` both touch graph traversal; `registry.go` is the orchestration layer that uses both. + +## Conventions + +- **Imports:** every Go file imports the stdlib first, then `github.com/xraph/forge/...`, then third-party. +- **Errors:** wrap with `fmt.Errorf("loading %s: %w", path, err)`. Use `Error` type from `errors.go` for canonical-coded errors that cross the wire boundary. +- **Comments:** package doc lives in `doc.go`. Exported types get one-line doc comments (Go style). No commit-rationale or task-reference comments. +- **Test file naming:** `_test.go` next to the file. Use plain `testing` package; helpers live in the test file's `package contract` (no `_test` suffix unless we need black-box testing). +- **Commits:** one logical change per commit, no Co-Authored-By trailers. Conventional commit prefix (`feat`, `test`, `chore`, `refactor`). + +--- + +## Phase 0: Package Skeleton & Canonical Errors + +### Task 0.1: Create package + errors + +**Files:** +- Create: `extensions/dashboard/contract/doc.go` +- Create: `extensions/dashboard/contract/errors.go` +- Create: `extensions/dashboard/contract/errors_test.go` + +- [ ] **Step 1: Write doc.go** + +```go +// Package contract defines the declarative, single-endpoint contract for the +// admin dashboard: contributor manifests, request/response envelopes, the +// permission model, the slot/graph composition rules, and the per-contributor +// version negotiation protocol. +// +// See DESIGN.md in this directory for the spec this implements. +package contract +``` + +- [ ] **Step 2: Write the failing tests for errors.go** + +```go +// errors_test.go +package contract + +import ( + "errors" + "testing" +) + +func TestError_CodeAndMessage(t *testing.T) { + e := &Error{Code: CodePermissionDenied, Message: "no", CorrelationID: "c1"} + if e.Code != "PERMISSION_DENIED" { + t.Errorf("Code = %q, want PERMISSION_DENIED", e.Code) + } + if got := e.Error(); got != "PERMISSION_DENIED: no" { + t.Errorf("Error() = %q", got) + } +} + +func TestError_Is(t *testing.T) { + e := &Error{Code: CodeNotFound} + if !errors.Is(e, ErrNotFound) { + t.Error("errors.Is should match canonical sentinel") + } +} + +func TestCanonicalCodes_AllPresent(t *testing.T) { + want := []ErrorCode{ + CodeBadRequest, CodeUnauthenticated, CodePermissionDenied, + CodeNotFound, CodeConflict, CodeRateLimited, + CodeUnsupportedVersion, CodeUnavailable, CodeInternal, + } + for _, c := range want { + if c == "" { + t.Errorf("canonical code missing") + } + } +} +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `go test ./extensions/dashboard/contract/...` +Expected: FAIL with "undefined: Error" / "undefined: CodePermissionDenied" etc. + +- [ ] **Step 4: Implement errors.go** + +```go +// errors.go +package contract + +import "fmt" + +// ErrorCode is a canonical, wire-stable code for contract errors. +// Contributor-specific codes are namespaced like "auth.SESSION_EXPIRED". +type ErrorCode string + +const ( + CodeBadRequest ErrorCode = "BAD_REQUEST" + CodeUnauthenticated ErrorCode = "UNAUTHENTICATED" + CodePermissionDenied ErrorCode = "PERMISSION_DENIED" + CodeNotFound ErrorCode = "NOT_FOUND" + CodeConflict ErrorCode = "CONFLICT" + CodeRateLimited ErrorCode = "RATE_LIMITED" + CodeUnsupportedVersion ErrorCode = "UNSUPPORTED_VERSION" + CodeUnavailable ErrorCode = "UNAVAILABLE" + CodeInternal ErrorCode = "INTERNAL" +) + +// Sentinel errors for use with errors.Is. +var ( + ErrBadRequest = &Error{Code: CodeBadRequest} + ErrUnauthenticated = &Error{Code: CodeUnauthenticated} + ErrPermissionDenied = &Error{Code: CodePermissionDenied} + ErrNotFound = &Error{Code: CodeNotFound} + ErrConflict = &Error{Code: CodeConflict} + ErrRateLimited = &Error{Code: CodeRateLimited} + ErrUnsupportedVersion = &Error{Code: CodeUnsupportedVersion} + ErrUnavailable = &Error{Code: CodeUnavailable} + ErrInternal = &Error{Code: CodeInternal} +) + +// Error is the canonical contract error type. It serializes to the wire +// "error" object documented in DESIGN.md. +type Error struct { + Code ErrorCode `json:"code"` + Message string `json:"message,omitempty"` + Details map[string]any `json:"details,omitempty"` + Retryable bool `json:"retryable,omitempty"` + CorrelationID string `json:"correlationID,omitempty"` + Redactions []string `json:"redactions,omitempty"` +} + +func (e *Error) Error() string { + if e == nil { + return "" + } + if e.Message == "" { + return string(e.Code) + } + return fmt.Sprintf("%s: %s", e.Code, e.Message) +} + +// Is matches sentinel errors by Code. +func (e *Error) Is(target error) bool { + if t, ok := target.(*Error); ok { + return t.Code == e.Code + } + return false +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./extensions/dashboard/contract/...` +Expected: PASS — 3 tests. + +- [ ] **Step 6: Commit** + +```bash +git add extensions/dashboard/contract/doc.go extensions/dashboard/contract/errors.go extensions/dashboard/contract/errors_test.go +git commit -m "feat(dashboard/contract): add package skeleton and canonical error codes" +``` + +--- + +## Phase 1: Wire Envelope Types + +### Task 1.1: Request envelope + JSON round-trip + +**Files:** +- Create: `extensions/dashboard/contract/envelope.go` +- Create: `extensions/dashboard/contract/envelope_test.go` + +- [ ] **Step 1: Write the failing test** + +```go +// envelope_test.go +package contract + +import ( + "encoding/json" + "testing" +) + +func TestRequest_RoundTrip_Command(t *testing.T) { + req := Request{ + Envelope: "v1", + Kind: KindCommand, + Contributor: "users", + Intent: "user.disable", + IntentVersion: 2, + Payload: json.RawMessage(`{"id":"u_42"}`), + Params: map[string]any{"tenant": "acme"}, + Context: RequestContext{Route: "/admin/users", CorrelationID: "req_x"}, + CSRF: "csrf_token", + IdempotencyKey: "ik_1", + } + b, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got Request + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.Kind != KindCommand || got.IdempotencyKey != "ik_1" { + t.Errorf("round trip lost data: %+v", got) + } +} + +func TestKind_Constants(t *testing.T) { + for _, k := range []Kind{KindGraph, KindQuery, KindCommand, KindSubscribe} { + if k == "" { + t.Errorf("kind constant empty") + } + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `go test ./extensions/dashboard/contract/...` +Expected: FAIL — "undefined: Request" etc. + +- [ ] **Step 3: Implement envelope.go** + +```go +// envelope.go +package contract + +import "encoding/json" + +// Kind discriminates request/response semantics on the wire. +// A kind is enforced against the intent's declared Capability at dispatch time. +type Kind string + +const ( + KindGraph Kind = "graph" + KindQuery Kind = "query" + KindCommand Kind = "command" + KindSubscribe Kind = "subscribe" +) + +// Request is the wire envelope for POST /api/dashboard/{envelope}. +type Request struct { + Envelope string `json:"envelope"` + Kind Kind `json:"kind"` + Contributor string `json:"contributor"` + Intent string `json:"intent"` + IntentVersion int `json:"intentVersion,omitempty"` + Payload json.RawMessage `json:"payload,omitempty"` + Params map[string]any `json:"params,omitempty"` + Context RequestContext `json:"context"` + CSRF string `json:"csrf,omitempty"` + IdempotencyKey string `json:"idempotencyKey,omitempty"` +} + +// RequestContext carries route + correlation metadata. Always populated by the shell. +type RequestContext struct { + Route string `json:"route,omitempty"` + CorrelationID string `json:"correlationID,omitempty"` +} + +// Response is the wire envelope for successful POST responses. +type Response struct { + OK bool `json:"ok"` + Envelope string `json:"envelope"` + Kind Kind `json:"kind"` + Data json.RawMessage `json:"data,omitempty"` + Meta ResponseMeta `json:"meta"` +} + +// ResponseMeta carries cross-cutting metadata (versioning, caching, invalidation). +type ResponseMeta struct { + IntentVersion int `json:"intentVersion,omitempty"` + Deprecation *Deprecation `json:"deprecation,omitempty"` + CacheControl *CacheHint `json:"cacheControl,omitempty"` + Invalidates []string `json:"invalidates,omitempty"` +} + +// Deprecation surfaces a "this version will be removed" hint to the shell. +type Deprecation struct { + IntentVersion int `json:"intentVersion"` + RemoveAfter string `json:"removeAfter"` +} + +// CacheHint communicates how long the shell can serve stale data for a query. +type CacheHint struct { + StaleTime string `json:"staleTime,omitempty"` +} + +// ErrorResponse is the wire envelope for failed POST responses. +type ErrorResponse struct { + OK bool `json:"ok"` + Envelope string `json:"envelope"` + Error *Error `json:"error"` +} + +// StreamEvent is the SSE payload for a single subscription event. +type StreamEvent struct { + Intent string `json:"intent"` + Mode SubscriptionMode `json:"mode"` + Payload json.RawMessage `json:"payload"` + Seq uint64 `json:"seq"` +} + +// SubscriptionMode is how the client integrates events into local state. +type SubscriptionMode string + +const ( + ModeReplace SubscriptionMode = "replace" + ModeAppend SubscriptionMode = "append" + ModeSnapshotDelta SubscriptionMode = "snapshot+delta" +) +``` + +- [ ] **Step 4: Run tests to verify pass** + +Run: `go test ./extensions/dashboard/contract/...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/contract/envelope.go extensions/dashboard/contract/envelope_test.go +git commit -m "feat(dashboard/contract): add wire envelope types for request, response, stream" +``` + +### Task 1.2: ErrorResponse + StreamEvent serialization + +**Files:** +- Modify: `extensions/dashboard/contract/envelope_test.go` + +- [ ] **Step 1: Add tests for ErrorResponse and StreamEvent round-trip** + +```go +func TestErrorResponse_RoundTrip(t *testing.T) { + er := ErrorResponse{ + OK: false, + Envelope: "v1", + Error: &Error{ + Code: CodePermissionDenied, + Message: "denied", + CorrelationID: "c1", + Redactions: []string{"users[*].email"}, + }, + } + b, err := json.Marshal(er) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !bytes.Contains(b, []byte(`"code":"PERMISSION_DENIED"`)) { + t.Errorf("marshaled form missing code: %s", b) + } + var got ErrorResponse + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.Error.Code != CodePermissionDenied { + t.Errorf("round trip lost code") + } +} + +func TestStreamEvent_RoundTrip_AllModes(t *testing.T) { + for _, mode := range []SubscriptionMode{ModeReplace, ModeAppend, ModeSnapshotDelta} { + ev := StreamEvent{Intent: "audit.tail", Mode: mode, Payload: json.RawMessage(`{"a":1}`), Seq: 42} + b, _ := json.Marshal(ev) + var got StreamEvent + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("mode %s: %v", mode, err) + } + if got.Mode != mode || got.Seq != 42 { + t.Errorf("mode %s round trip lost data: %+v", mode, got) + } + } +} +``` + +Add `"bytes"` to the import block in `envelope_test.go`. + +- [ ] **Step 2: Run tests** + +Run: `go test ./extensions/dashboard/contract/...` +Expected: PASS — all envelope tests. + +- [ ] **Step 3: Commit** + +```bash +git add extensions/dashboard/contract/envelope_test.go +git commit -m "test(dashboard/contract): cover error response and stream event round-trip" +``` + +--- + +## Phase 2: Manifest Types + +### Task 2.1: Core manifest structs + +**Files:** +- Create: `extensions/dashboard/contract/manifest.go` +- Create: `extensions/dashboard/contract/manifest_test.go` + +- [ ] **Step 1: Write the failing test (YAML round-trip)** + +```go +// manifest_test.go +package contract + +import ( + "testing" + + "gopkg.in/yaml.v3" +) + +const sampleManifestYAML = ` +schemaVersion: 1 +contributor: + name: users + envelope: + supports: [v1] + preferred: v1 + capabilities: [users.read, users.write] + +intents: + - name: users.list + kind: query + version: 1 + capability: read + requires: + all: ["scope:users.read"] + audit: false + + - name: user.disable + kind: command + version: 2 + capability: write + requires: + all: ["role:admin", "scope:users.write"] + warden: tenantOwner + invalidates: [users.list, user.detail] + +graph: + - route: /users + intent: page.shell + title: Users + nav: + group: Identity + icon: users + priority: 10 +` + +func TestManifest_YAML_RoundTrip(t *testing.T) { + var m ContractManifest + if err := yaml.Unmarshal([]byte(sampleManifestYAML), &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if m.SchemaVersion != 1 { + t.Errorf("SchemaVersion = %d", m.SchemaVersion) + } + if m.Contributor.Name != "users" { + t.Errorf("contributor name = %q", m.Contributor.Name) + } + if got := len(m.Intents); got != 2 { + t.Fatalf("intents count = %d", got) + } + if m.Intents[0].Kind != IntentKindQuery || m.Intents[1].Kind != IntentKindCommand { + t.Errorf("intent kinds = %v, %v", m.Intents[0].Kind, m.Intents[1].Kind) + } + if m.Intents[1].Requires.Warden != "tenantOwner" { + t.Errorf("warden ref = %q", m.Intents[1].Requires.Warden) + } + if got := len(m.Graph); got != 1 { + t.Fatalf("graph count = %d", got) + } + if m.Graph[0].Route != "/users" { + t.Errorf("route = %q", m.Graph[0].Route) + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `go test ./extensions/dashboard/contract/...` +Expected: FAIL — "undefined: ContractManifest" etc. + +- [ ] **Step 3: Implement manifest.go** + +```go +// manifest.go +package contract + +// IntentKind is the wire-level discriminator declared on every intent. +// It must be consistent with the request envelope Kind at dispatch time. +type IntentKind string + +const ( + IntentKindGraph IntentKind = "graph" + IntentKindQuery IntentKind = "query" + IntentKindCommand IntentKind = "command" + IntentKindSubscription IntentKind = "subscription" +) + +// Capability is the data-classification of an intent's effects. +// It composes with IntentKind: a command must be capability=write; a query/subscription +// must be capability=read; a graph must be capability=render. +type Capability string + +const ( + CapRead Capability = "read" + CapWrite Capability = "write" + CapRender Capability = "render" +) + +// ContractManifest is the top-level YAML each contributor publishes. +type ContractManifest struct { + SchemaVersion int `yaml:"schemaVersion" json:"schemaVersion"` + Contributor Contributor `yaml:"contributor" json:"contributor"` + Queries map[string]Query `yaml:"queries,omitempty" json:"queries,omitempty"` + Intents []Intent `yaml:"intents" json:"intents"` + Graph []GraphNode `yaml:"graph,omitempty" json:"graph,omitempty"` + Extends []Extension `yaml:"extends,omitempty" json:"extends,omitempty"` +} + +// Contributor names a single contributor and declares its supported envelope versions. +type Contributor struct { + Name string `yaml:"name" json:"name"` + Envelope EnvelopeSupport `yaml:"envelope" json:"envelope"` + Capabilities []string `yaml:"capabilities,omitempty" json:"capabilities,omitempty"` +} + +// EnvelopeSupport declares which envelope versions this contributor can speak. +type EnvelopeSupport struct { + Supports []string `yaml:"supports" json:"supports"` + Preferred string `yaml:"preferred" json:"preferred"` +} + +// Intent declares a single named operation and its security/version metadata. +type Intent struct { + Name string `yaml:"name" json:"name"` + Kind IntentKind `yaml:"kind" json:"kind"` + Version int `yaml:"version" json:"version"` + Capability Capability `yaml:"capability" json:"capability"` + Requires Predicate `yaml:"requires,omitempty" json:"requires,omitempty"` + Schema IntentSchema `yaml:"schema,omitempty" json:"schema,omitempty"` + Mode SubscriptionMode `yaml:"mode,omitempty" json:"mode,omitempty"` // subscription only + Invalidates []string `yaml:"invalidates,omitempty" json:"invalidates,omitempty"` // command only + Audit *bool `yaml:"audit,omitempty" json:"audit,omitempty"` // default true for commands + Deprecated *Deprecation `yaml:"deprecated,omitempty" json:"deprecated,omitempty"` +} + +// IntentSchema is loose by design: contributors describe their input/output shapes; +// validation against this is opt-in (slice (b) wires it). +type IntentSchema struct { + Input map[string]any `yaml:"input,omitempty" json:"input,omitempty"` + Output any `yaml:"output,omitempty" json:"output,omitempty"` +} + +// Query is a named, reusable, cacheable data binding referenced by graph nodes. +type Query struct { + Intent string `yaml:"intent" json:"intent"` + Params map[string]ParamSource `yaml:"params,omitempty" json:"params,omitempty"` + Cache *QueryCache `yaml:"cache,omitempty" json:"cache,omitempty"` +} + +// ParamSource describes where a parameter value comes from. +// Exactly one of Value/From is set; YAML uses { from: route.tenant } or a literal. +type ParamSource struct { + Value any `yaml:"value,omitempty" json:"value,omitempty"` + From string `yaml:"from,omitempty" json:"from,omitempty"` // route.X | parent.X | state.X | session.X +} + +// QueryCache declares per-query staleness for the client. +type QueryCache struct { + StaleTime string `yaml:"staleTime,omitempty" json:"staleTime,omitempty"` +} + +// GraphNode is a single node in the UI graph (an intent invocation with slot fills). +type GraphNode struct { + Route string `yaml:"route,omitempty" json:"route,omitempty"` // top-level only + Intent string `yaml:"intent" json:"intent"` + Title string `yaml:"title,omitempty" json:"title,omitempty"` + Nav *NavConfig `yaml:"nav,omitempty" json:"nav,omitempty"` + Root bool `yaml:"root,omitempty" json:"root,omitempty"` + Data *DataBinding `yaml:"data,omitempty" json:"data,omitempty"` + Props map[string]any `yaml:"props,omitempty" json:"props,omitempty"` + Slots map[string][]GraphNode `yaml:"slots,omitempty" json:"slots,omitempty"` + VisibleWhen *Predicate `yaml:"visibleWhen,omitempty" json:"visibleWhen,omitempty"` + EnabledWhen *Predicate `yaml:"enabledWhen,omitempty" json:"enabledWhen,omitempty"` + Op string `yaml:"op,omitempty" json:"op,omitempty"` // for action nodes + Payload map[string]ParamSource `yaml:"payload,omitempty" json:"payload,omitempty"` + Component string `yaml:"component,omitempty" json:"component,omitempty"` // intent: custom escape hatch + Src string `yaml:"src,omitempty" json:"src,omitempty"` // intent: iframe escape hatch + Sandbox []string `yaml:"sandbox,omitempty" json:"sandbox,omitempty"` + Protocol string `yaml:"protocol,omitempty" json:"protocol,omitempty"` +} + +// NavConfig is per-route nav metadata; mirrors today's contributor.NavItem fields. +type NavConfig struct { + Group string `yaml:"group,omitempty" json:"group,omitempty"` + Icon string `yaml:"icon,omitempty" json:"icon,omitempty"` + Priority int `yaml:"priority,omitempty" json:"priority,omitempty"` + Badge string `yaml:"badge,omitempty" json:"badge,omitempty"` +} + +// DataBinding is either an inline {intent, params} pair or a named query reference. +// YAML supports both shapes: +// data: queries.userList +// data: { intent: users.list, params: {...} } +type DataBinding struct { + QueryRef string `yaml:"-" json:"queryRef,omitempty"` + Intent string `yaml:"intent,omitempty" json:"intent,omitempty"` + Params map[string]ParamSource `yaml:"params,omitempty" json:"params,omitempty"` +} + +// Predicate is the boolean access expression: any of all/any/not, plus an optional +// named Warden delegate. An empty Predicate evaluates to allow. +type Predicate struct { + All []string `yaml:"all,omitempty" json:"all,omitempty"` + Any []string `yaml:"any,omitempty" json:"any,omitempty"` + Not []string `yaml:"not,omitempty" json:"not,omitempty"` + Warden string `yaml:"warden,omitempty" json:"warden,omitempty"` +} + +// Extension declares that this contributor wants to add nodes into another contributor's slot. +type Extension struct { + Target ExtensionTarget `yaml:"target" json:"target"` + Slot string `yaml:"slot" json:"slot"` // dotted path: "detailDrawer.fields" + Add []GraphNode `yaml:"add" json:"add"` +} + +// ExtensionTarget identifies the host node to extend. +type ExtensionTarget struct { + Contributor string `yaml:"contributor" json:"contributor"` + Intent string `yaml:"intent" json:"intent"` + Route string `yaml:"route,omitempty" json:"route,omitempty"` +} +``` + +- [ ] **Step 4: Run tests to verify pass** + +Run: `go test ./extensions/dashboard/contract/...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/contract/manifest.go extensions/dashboard/contract/manifest_test.go +git commit -m "feat(dashboard/contract): add manifest types with YAML tags" +``` + +### Task 2.2: DataBinding string shorthand parser + +The YAML form `data: queries.userList` (a bare string) needs custom unmarshalling because Go's default scalar decode won't fill a struct. + +**Files:** +- Modify: `extensions/dashboard/contract/manifest.go` +- Modify: `extensions/dashboard/contract/manifest_test.go` + +- [ ] **Step 1: Add the failing test** + +```go +// add to manifest_test.go +const dataShorthandYAML = ` +schemaVersion: 1 +contributor: + name: users + envelope: { supports: [v1], preferred: v1 } +intents: [] +graph: + - intent: resource.list + data: queries.userList + - intent: metric.counter + data: + intent: count.events + params: { since: { value: "1h" } } +` + +func TestDataBinding_BothShapes(t *testing.T) { + var m ContractManifest + if err := yaml.Unmarshal([]byte(dataShorthandYAML), &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if m.Graph[0].Data == nil || m.Graph[0].Data.QueryRef != "queries.userList" { + t.Errorf("shorthand not parsed: %+v", m.Graph[0].Data) + } + if m.Graph[1].Data == nil || m.Graph[1].Data.Intent != "count.events" { + t.Errorf("inline form not parsed: %+v", m.Graph[1].Data) + } +} +``` + +- [ ] **Step 2: Run to verify failure (shorthand decoding fails)** + +Run: `go test ./extensions/dashboard/contract/...` +Expected: FAIL — shorthand doesn't unmarshal into the struct. + +- [ ] **Step 3: Implement custom UnmarshalYAML on DataBinding** + +Add to `manifest.go`: + +```go +import "gopkg.in/yaml.v3" // add to imports + +func (d *DataBinding) UnmarshalYAML(value *yaml.Node) error { + switch value.Kind { + case yaml.ScalarNode: + d.QueryRef = value.Value + return nil + case yaml.MappingNode: + // Decode into a shadow type to avoid recursion. + type alias DataBinding + var a alias + if err := value.Decode(&a); err != nil { + return err + } + *d = DataBinding(a) + return nil + default: + return fmt.Errorf("data: expected scalar or mapping, got kind=%d", value.Kind) + } +} +``` + +Add `"fmt"` to imports. + +- [ ] **Step 4: Run tests** + +Run: `go test ./extensions/dashboard/contract/...` +Expected: PASS — both shapes round-trip. + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/contract/manifest.go extensions/dashboard/contract/manifest_test.go +git commit -m "feat(dashboard/contract): parse data binding shorthand and inline forms" +``` + +### Task 2.3: ParamSource string shorthand parser + +YAML form `tenant: route.tenant` should also work as shorthand for `tenant: { from: route.tenant }`. (Note this is for `params` and `payload` maps where the value type is `ParamSource`.) + +**Files:** +- Modify: `extensions/dashboard/contract/manifest.go` +- Modify: `extensions/dashboard/contract/manifest_test.go` + +- [ ] **Step 1: Add the failing test** + +```go +const paramShorthandYAML = ` +schemaVersion: 1 +contributor: { name: x, envelope: { supports: [v1], preferred: v1 } } +intents: [] +queries: + q1: + intent: foo + params: + shorthand: route.tenant + explicit: { from: parent.id } + literal: { value: 5 } +` + +func TestParamSource_Shorthand(t *testing.T) { + var m ContractManifest + if err := yaml.Unmarshal([]byte(paramShorthandYAML), &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + q := m.Queries["q1"] + if q.Params["shorthand"].From != "route.tenant" { + t.Errorf("shorthand not parsed: %+v", q.Params["shorthand"]) + } + if q.Params["explicit"].From != "parent.id" { + t.Errorf("explicit form lost data: %+v", q.Params["explicit"]) + } + if v, ok := q.Params["literal"].Value.(int); !ok || v != 5 { + t.Errorf("literal value wrong: %+v", q.Params["literal"].Value) + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `go test ./extensions/dashboard/contract/...` +Expected: FAIL. + +- [ ] **Step 3: Implement custom UnmarshalYAML on ParamSource** + +```go +func (p *ParamSource) UnmarshalYAML(value *yaml.Node) error { + switch value.Kind { + case yaml.ScalarNode: + p.From = value.Value + return nil + case yaml.MappingNode: + type alias ParamSource + var a alias + if err := value.Decode(&a); err != nil { + return err + } + *p = ParamSource(a) + return nil + default: + return fmt.Errorf("param: expected scalar or mapping, got kind=%d", value.Kind) + } +} +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./extensions/dashboard/contract/...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/contract/manifest.go extensions/dashboard/contract/manifest_test.go +git commit -m "feat(dashboard/contract): support param source shorthand in YAML" +``` + +--- + +## Phase 3: Predicate Evaluation & Permissions Hash + +### Task 3.1: Predicate.Eval against UserInfo + +**Files:** +- Create: `extensions/dashboard/contract/predicate.go` +- Create: `extensions/dashboard/contract/predicate_test.go` + +The predicate language uses tokens of the form `role:NAME`, `scope:NAME`, `claim:KEY=VALUE`. `all`/`any`/`not` compose these. Empty predicate = allow. + +- [ ] **Step 1: Write the failing tests** + +```go +// predicate_test.go +package contract + +import ( + "testing" + + "github.com/xraph/forge/extensions/dashboard/auth" +) + +func u(roles, scopes []string) *auth.UserInfo { + return &auth.UserInfo{Roles: roles, Scopes: scopes} +} + +func TestPredicate_Empty_Allows(t *testing.T) { + if !(&Predicate{}).Allow(u(nil, nil), nil) { + t.Error("empty predicate should allow") + } +} + +func TestPredicate_AllRequires(t *testing.T) { + p := &Predicate{All: []string{"role:admin", "scope:users.write"}} + if !p.Allow(u([]string{"admin"}, []string{"users.write"}), nil) { + t.Error("admin+users.write should pass all") + } + if p.Allow(u([]string{"admin"}, nil), nil) { + t.Error("missing scope should fail all") + } +} + +func TestPredicate_AnyRequires(t *testing.T) { + p := &Predicate{Any: []string{"role:admin", "role:owner"}} + if !p.Allow(u([]string{"owner"}, nil), nil) { + t.Error("owner alone should pass any") + } + if p.Allow(u([]string{"viewer"}, nil), nil) { + t.Error("neither admin nor owner should fail any") + } +} + +func TestPredicate_NotForbids(t *testing.T) { + p := &Predicate{Not: []string{"role:guest"}} + if !p.Allow(u([]string{"admin"}, nil), nil) { + t.Error("admin should pass not-guest") + } + if p.Allow(u([]string{"guest"}, nil), nil) { + t.Error("guest should fail not-guest") + } +} + +func TestPredicate_AllAndAny_Combined(t *testing.T) { + p := &Predicate{ + All: []string{"scope:users.read"}, + Any: []string{"role:admin", "role:owner"}, + } + pass := u([]string{"owner"}, []string{"users.read"}) + fail := u([]string{"owner"}, nil) + if !p.Allow(pass, nil) { + t.Error("pass case failed") + } + if p.Allow(fail, nil) { + t.Error("fail case allowed") + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `go test ./extensions/dashboard/contract/...` +Expected: FAIL — "predicate.Allow undefined". + +- [ ] **Step 3: Implement predicate.go** + +```go +// predicate.go +package contract + +import ( + "strings" + + "github.com/xraph/forge/extensions/dashboard/auth" +) + +// Allow evaluates the boolean predicate against a UserInfo. The wardenResult +// argument is the optional second-pass Warden decision; pass nil to skip. +// An empty predicate (no all/any/not) always allows. +func (p *Predicate) Allow(user *auth.UserInfo, wardenResult *Decision) bool { + if p == nil { + return true + } + if len(p.All) == 0 && len(p.Any) == 0 && len(p.Not) == 0 && wardenResult == nil { + // truly empty predicate; warden absence handled by caller's evaluation order + return true + } + for _, tok := range p.All { + if !match(tok, user) { + return false + } + } + if len(p.Any) > 0 { + ok := false + for _, tok := range p.Any { + if match(tok, user) { + ok = true + break + } + } + if !ok { + return false + } + } + for _, tok := range p.Not { + if match(tok, user) { + return false + } + } + if wardenResult != nil && !wardenResult.Allow { + return false + } + return true +} + +// match parses one token (role:X, scope:X, claim:K=V) and tests it against user. +func match(token string, user *auth.UserInfo) bool { + if user == nil { + return false + } + kind, rest, ok := strings.Cut(token, ":") + if !ok { + return false + } + switch kind { + case "role": + return contains(user.Roles, rest) + case "scope": + return contains(user.Scopes, rest) + case "claim": + key, value, ok := strings.Cut(rest, "=") + if !ok { + return false + } + got, ok := user.Claims[key] + if !ok { + return false + } + return toString(got) == value + } + return false +} + +func contains(xs []string, x string) bool { + for _, s := range xs { + if s == x { + return true + } + } + return false +} + +func toString(v any) string { + if s, ok := v.(string); ok { + return s + } + return "" // claims that aren't strings can't be matched by claim:K=V +} +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./extensions/dashboard/contract/...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/contract/predicate.go extensions/dashboard/contract/predicate_test.go +git commit -m "feat(dashboard/contract): boolean predicate evaluator (all/any/not, role/scope/claim)" +``` + +### Task 3.2: PermissionsHash for cache keying + +**Files:** +- Modify: `extensions/dashboard/contract/predicate.go` +- Modify: `extensions/dashboard/contract/predicate_test.go` + +- [ ] **Step 1: Add failing test** + +```go +func TestPermissionsHash_StableForEquivalentSlice(t *testing.T) { + a := PermissionsHash(u([]string{"admin", "owner"}, []string{"x", "y"})) + b := PermissionsHash(u([]string{"owner", "admin"}, []string{"y", "x"})) + if a != b { + t.Errorf("hash not stable across order: %s vs %s", a, b) + } +} + +func TestPermissionsHash_DiffersWhenRolesDiffer(t *testing.T) { + a := PermissionsHash(u([]string{"admin"}, nil)) + b := PermissionsHash(u([]string{"viewer"}, nil)) + if a == b { + t.Error("hash should differ for different roles") + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `go test ./extensions/dashboard/contract/...` +Expected: FAIL — undefined. + +- [ ] **Step 3: Implement PermissionsHash** + +Add to `predicate.go`: + +```go +import ( + "crypto/sha256" + "encoding/hex" + "sort" + // ... existing imports +) + +// PermissionsHash returns a stable, order-independent hash of a user's +// roles and scopes. Used as part of the graph cache key so that users with +// the same effective permissions share a cache entry. Claims are NOT included +// because the contract treats only role/scope as graph-shape-determining. +func PermissionsHash(user *auth.UserInfo) string { + if user == nil { + return "anon" + } + roles := append([]string(nil), user.Roles...) + scopes := append([]string(nil), user.Scopes...) + sort.Strings(roles) + sort.Strings(scopes) + h := sha256.New() + for _, r := range roles { + h.Write([]byte("r:")) + h.Write([]byte(r)) + h.Write([]byte{0}) + } + for _, s := range scopes { + h.Write([]byte("s:")) + h.Write([]byte(s)) + h.Write([]byte{0}) + } + return hex.EncodeToString(h.Sum(nil))[:16] +} +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./extensions/dashboard/contract/...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/contract/predicate.go extensions/dashboard/contract/predicate_test.go +git commit -m "feat(dashboard/contract): stable permissions hash for graph cache keying" +``` + +--- + +## Phase 4: Warden Interface + +### Task 4.1: Warden types + registry + +**Files:** +- Create: `extensions/dashboard/contract/warden.go` +- Create: `extensions/dashboard/contract/warden_test.go` + +- [ ] **Step 1: Write the failing tests** + +```go +// warden_test.go +package contract + +import ( + "context" + "errors" + "testing" + + "github.com/xraph/forge/extensions/dashboard/auth" +) + +type stubWarden struct { + allow bool + redactions []string +} + +func (s *stubWarden) Authorize(_ context.Context, _ Principal, _ Action) (Decision, error) { + return Decision{Allow: s.allow, Redactions: s.redactions}, nil +} + +func TestWardenRegistry_RegisterAndGet(t *testing.T) { + r := NewWardenRegistry() + w := &stubWarden{allow: true} + if err := r.Register("tenantOwner", w); err != nil { + t.Fatalf("register: %v", err) + } + got, ok := r.Get("tenantOwner") + if !ok || got != w { + t.Error("registered warden not found") + } +} + +func TestWardenRegistry_DuplicateName_Fails(t *testing.T) { + r := NewWardenRegistry() + _ = r.Register("x", &stubWarden{allow: true}) + if err := r.Register("x", &stubWarden{allow: false}); err == nil { + t.Error("duplicate registration should fail") + } +} + +func TestWardenRegistry_MissingName_NotOK(t *testing.T) { + r := NewWardenRegistry() + if _, ok := r.Get("nope"); ok { + t.Error("missing warden should not be found") + } +} + +func TestPrincipal_FromUserInfo(t *testing.T) { + user := &auth.UserInfo{Subject: "u1", Roles: []string{"admin"}} + p := PrincipalFor(user) + if p.User != user { + t.Error("principal should hold the user") + } +} + +func TestDecision_DenialPropagates(t *testing.T) { + w := &stubWarden{allow: false} + d, err := w.Authorize(context.Background(), Principal{}, Action{}) + if err != nil { + t.Fatalf("authorize: %v", err) + } + if d.Allow { + t.Error("expected deny") + } + _ = errors.New +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `go test ./extensions/dashboard/contract/...` +Expected: FAIL — undefined. + +- [ ] **Step 3: Implement warden.go** + +```go +// warden.go +package contract + +import ( + "context" + "fmt" + "sync" + + "github.com/xraph/forge/extensions/dashboard/auth" +) + +// Warden is the pluggable, data-aware authorization second pass. +// It runs after the YAML boolean Predicate succeeds and may inspect +// intent params (e.g. tenant ownership), claims, or external policy. +type Warden interface { + Authorize(ctx context.Context, p Principal, a Action) (Decision, error) +} + +// Principal is the caller identity passed to Wardens and the predicate engine. +type Principal struct { + User *auth.UserInfo + Claims map[string]any +} + +// PrincipalFor builds a Principal from a UserInfo, copying claims for safety. +func PrincipalFor(user *auth.UserInfo) Principal { + if user == nil { + return Principal{} + } + claims := make(map[string]any, len(user.Claims)) + for k, v := range user.Claims { + claims[k] = v + } + return Principal{User: user, Claims: claims} +} + +// Action is the operation being authorized. +type Action struct { + Contributor string + Intent string + Kind Kind + Capability Capability + Resource map[string]any +} + +// Decision is the Warden's verdict. +type Decision struct { + Allow bool + Reason string + Redactions []string // JSONPath-like field paths to strip from response +} + +// WardenRegistry maps a Warden's declared name to its implementation. +// Manifest validation rejects YAML that references a name not in the registry. +type WardenRegistry interface { + Register(name string, w Warden) error + Get(name string) (Warden, bool) +} + +// NewWardenRegistry returns an empty in-memory registry. +func NewWardenRegistry() WardenRegistry { + return &wardenRegistry{wardens: map[string]Warden{}} +} + +type wardenRegistry struct { + mu sync.RWMutex + wardens map[string]Warden +} + +func (r *wardenRegistry) Register(name string, w Warden) error { + r.mu.Lock() + defer r.mu.Unlock() + if _, exists := r.wardens[name]; exists { + return fmt.Errorf("warden %q already registered", name) + } + r.wardens[name] = w + return nil +} + +func (r *wardenRegistry) Get(name string) (Warden, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + w, ok := r.wardens[name] + return w, ok +} +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./extensions/dashboard/contract/...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/contract/warden.go extensions/dashboard/contract/warden_test.go +git commit -m "feat(dashboard/contract): warden interface and in-memory registry" +``` + +--- + +## Phase 5: YAML Loader & Cross-Reference Validator + +### Task 5.1: Loader entry point + +**Files:** +- Create: `extensions/dashboard/contract/loader/yaml.go` +- Create: `extensions/dashboard/contract/loader/yaml_test.go` + +- [ ] **Step 1: Write the failing test** + +```go +// yaml_test.go +package loader + +import ( + "strings" + "testing" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +const okYAML = ` +schemaVersion: 1 +contributor: + name: users + envelope: { supports: [v1], preferred: v1 } +intents: + - { name: users.list, kind: query, version: 1, capability: read } +graph: + - { route: /users, intent: page.shell } +` + +func TestLoad_OK(t *testing.T) { + m, err := Load(strings.NewReader(okYAML), "users.yaml") + if err != nil { + t.Fatalf("load: %v", err) + } + if m.Contributor.Name != "users" { + t.Errorf("name = %q", m.Contributor.Name) + } + _ = contract.IntentKindQuery // ensure import retained +} + +func TestLoad_BadSchemaVersion(t *testing.T) { + const yaml = `schemaVersion: 99 +contributor: { name: x, envelope: { supports: [v1], preferred: v1 } } +intents: [] +` + if _, err := Load(strings.NewReader(yaml), "x.yaml"); err == nil { + t.Error("expected error for unsupported schemaVersion") + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `go test ./extensions/dashboard/contract/loader/...` +Expected: FAIL. + +- [ ] **Step 3: Implement loader/yaml.go** + +```go +// yaml.go +package loader + +import ( + "fmt" + "io" + + "gopkg.in/yaml.v3" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// SupportedSchemaVersion is the schema integer this loader understands. +// Bumping it requires a coordinated platform release (see DESIGN.md). +const SupportedSchemaVersion = 1 + +// Load parses a contributor manifest YAML stream and validates its schemaVersion. +// Cross-reference validation (intent refs, slot accepts, warden names) runs separately +// in Validate. +func Load(r io.Reader, source string) (*contract.ContractManifest, error) { + data, err := io.ReadAll(r) + if err != nil { + return nil, fmt.Errorf("loading %s: %w", source, err) + } + var m contract.ContractManifest + if err := yaml.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("parsing %s: %w", source, err) + } + if m.SchemaVersion != SupportedSchemaVersion { + return nil, fmt.Errorf("%s: schemaVersion=%d unsupported, want %d", source, m.SchemaVersion, SupportedSchemaVersion) + } + return &m, nil +} +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./extensions/dashboard/contract/loader/...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/contract/loader/yaml.go extensions/dashboard/contract/loader/yaml_test.go +git commit -m "feat(dashboard/contract): YAML loader with schemaVersion check" +``` + +### Task 5.2: Cross-reference validator + +**Files:** +- Create: `extensions/dashboard/contract/loader/validate.go` +- Create: `extensions/dashboard/contract/loader/validate_test.go` + +The validator catches everything that's only resolvable across multiple YAML units: an intent referenced by `data.intent` must exist; a Warden name must be registered; a slot extension must target an `extensible` slot whose `accepts` list contains the added node's intent kind. + +- [ ] **Step 1: Write the failing tests (multiple cases)** + +```go +// validate_test.go +package loader + +import ( + "strings" + "testing" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +func mustLoad(t *testing.T, src string) *contract.ContractManifest { + t.Helper() + m, err := Load(strings.NewReader(src), "test.yaml") + if err != nil { + t.Fatalf("load: %v", err) + } + return m +} + +func TestValidate_GoodManifest(t *testing.T) { + m := mustLoad(t, ` +schemaVersion: 1 +contributor: { name: users, envelope: { supports: [v1], preferred: v1 } } +intents: + - { name: users.list, kind: query, version: 1, capability: read } + - { name: user.disable, kind: command, version: 1, capability: write, + requires: { warden: tenantOwner } } +queries: + userList: { intent: users.list } +graph: + - { route: /users, intent: page.shell, data: queries.userList } +`) + wreg := contract.NewWardenRegistry() + _ = wreg.Register("tenantOwner", &noopWarden{}) + if err := Validate(m, wreg); err != nil { + t.Fatalf("validate: %v", err) + } +} + +func TestValidate_UnknownWarden(t *testing.T) { + m := mustLoad(t, ` +schemaVersion: 1 +contributor: { name: x, envelope: { supports: [v1], preferred: v1 } } +intents: + - { name: a, kind: query, version: 1, capability: read, + requires: { warden: missing } } +`) + if err := Validate(m, contract.NewWardenRegistry()); err == nil { + t.Error("expected unknown-warden error") + } +} + +func TestValidate_UnknownQueryRef(t *testing.T) { + m := mustLoad(t, ` +schemaVersion: 1 +contributor: { name: x, envelope: { supports: [v1], preferred: v1 } } +intents: [] +graph: + - { intent: page.shell, data: queries.nope } +`) + if err := Validate(m, contract.NewWardenRegistry()); err == nil { + t.Error("expected unknown-query error") + } +} + +func TestValidate_KindCapabilityMismatch(t *testing.T) { + cases := []string{ + "kind: command, capability: read", // command must be write + "kind: query, capability: write", // query must be read + "kind: subscription, capability: write", + } + for _, body := range cases { + t.Run(body, func(t *testing.T) { + m := mustLoad(t, ` +schemaVersion: 1 +contributor: { name: x, envelope: { supports: [v1], preferred: v1 } } +intents: + - { name: a, version: 1, `+body+` } +`) + if err := Validate(m, contract.NewWardenRegistry()); err == nil { + t.Errorf("expected kind/capability mismatch error for %q", body) + } + }) + } +} + +type noopWarden struct{} + +func (noopWarden) Authorize(_ context.Context, _ contract.Principal, _ contract.Action) (contract.Decision, error) { + return contract.Decision{Allow: true}, nil +} +``` + +Add `"context"` to the imports. + +- [ ] **Step 2: Run to verify failure** + +Run: `go test ./extensions/dashboard/contract/loader/...` +Expected: FAIL. + +- [ ] **Step 3: Implement loader/validate.go** + +```go +// validate.go +package loader + +import ( + "fmt" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// Validate runs cross-reference checks that require the full manifest in hand. +// It does not enforce slot-accepts (that needs the global registry to know about +// other contributors' intent kinds); slot validation runs in registry.Register. +func Validate(m *contract.ContractManifest, wardens contract.WardenRegistry) error { + intentByName := map[string]contract.Intent{} + for _, in := range m.Intents { + if _, dup := intentByName[in.Name]; dup { + return fmt.Errorf("intent %q declared twice", in.Name) + } + if err := validateKindCapability(in); err != nil { + return err + } + if err := validateWarden(in.Requires, wardens); err != nil { + return fmt.Errorf("intent %q: %w", in.Name, err) + } + intentByName[in.Name] = in + } + // Validate query refs + for name, q := range m.Queries { + if _, ok := intentByName[q.Intent]; !ok { + // allow refs to other-contributor intents; flag only same-contributor mistakes + // Heuristic: if the name looks like "{contributor}.{rest}" with a different + // contributor, skip; otherwise fail. Slice (b) tightens this. + if !looksCrossContributor(q.Intent, m.Contributor.Name) { + return fmt.Errorf("query %q: intent %q not declared in this contributor", name, q.Intent) + } + } + } + // Walk graph nodes to validate inline data and predicate wardens + var walk func(nodes []contract.GraphNode, path string) error + walk = func(nodes []contract.GraphNode, path string) error { + for i, n := range nodes { + here := fmt.Sprintf("%s[%d]", path, i) + if n.Data != nil && n.Data.QueryRef != "" { + key := stripQueriesPrefix(n.Data.QueryRef) + if _, ok := m.Queries[key]; !ok { + return fmt.Errorf("%s: data refers to unknown query %q", here, n.Data.QueryRef) + } + } + if n.Data != nil && n.Data.Intent != "" { + if _, ok := intentByName[n.Data.Intent]; !ok && !looksCrossContributor(n.Data.Intent, m.Contributor.Name) { + return fmt.Errorf("%s: data references unknown intent %q", here, n.Data.Intent) + } + } + if err := validateWarden(coalescePredicate(n.VisibleWhen), wardens); err != nil { + return fmt.Errorf("%s.visibleWhen: %w", here, err) + } + if err := validateWarden(coalescePredicate(n.EnabledWhen), wardens); err != nil { + return fmt.Errorf("%s.enabledWhen: %w", here, err) + } + for slotName, children := range n.Slots { + if err := walk(children, here+".slots."+slotName); err != nil { + return err + } + } + } + return nil + } + return walk(m.Graph, "graph") +} + +func validateKindCapability(in contract.Intent) error { + want := map[contract.IntentKind]contract.Capability{ + contract.IntentKindQuery: contract.CapRead, + contract.IntentKindCommand: contract.CapWrite, + contract.IntentKindSubscription: contract.CapRead, + contract.IntentKindGraph: contract.CapRender, + } + if w, ok := want[in.Kind]; ok && in.Capability != w { + return fmt.Errorf("intent %q: kind=%s requires capability=%s, got %s", in.Name, in.Kind, w, in.Capability) + } + return nil +} + +func validateWarden(p contract.Predicate, wardens contract.WardenRegistry) error { + if p.Warden == "" { + return nil + } + if _, ok := wardens.Get(p.Warden); !ok { + return fmt.Errorf("references unknown warden %q", p.Warden) + } + return nil +} + +func coalescePredicate(p *contract.Predicate) contract.Predicate { + if p == nil { + return contract.Predicate{} + } + return *p +} + +func looksCrossContributor(intentName, ownContributor string) bool { + // Convention: "auth.linkedAccount" — first dotted segment is contributor name. + for i := 0; i < len(intentName); i++ { + if intentName[i] == '.' { + return intentName[:i] != ownContributor + } + } + return false +} + +func stripQueriesPrefix(ref string) string { + const p = "queries." + if len(ref) > len(p) && ref[:len(p)] == p { + return ref[len(p):] + } + return ref +} +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./extensions/dashboard/contract/loader/...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/contract/loader/validate.go extensions/dashboard/contract/loader/validate_test.go +git commit -m "feat(dashboard/contract): cross-reference validator (intents, queries, wardens)" +``` + +--- + +## Phase 6: Contract Registry + +### Task 6.1: Registry — register and look up contributors + intents + +**Files:** +- Create: `extensions/dashboard/contract/registry.go` +- Create: `extensions/dashboard/contract/registry_test.go` + +- [ ] **Step 1: Write failing tests** + +```go +// registry_test.go +package contract + +import ( + "strings" + "testing" +) + +func mustManifest(t *testing.T, src string) *ContractManifest { + t.Helper() + var m ContractManifest + if err := unmarshalForTest([]byte(src), &m); err != nil { + t.Fatalf("manifest: %v", err) + } + return &m +} + +func TestRegistry_RegisterAndLookup(t *testing.T) { + r := NewRegistry() + m := mustManifest(t, ` +schemaVersion: 1 +contributor: { name: users, envelope: { supports: [v1], preferred: v1 } } +intents: + - { name: users.list, kind: query, version: 1, capability: read } +`) + if err := r.Register(m); err != nil { + t.Fatalf("register: %v", err) + } + intent, ok := r.Intent("users", "users.list", 1) + if !ok || intent.Name != "users.list" { + t.Errorf("lookup failed: ok=%v intent=%+v", ok, intent) + } +} + +func TestRegistry_DuplicateContributor(t *testing.T) { + r := NewRegistry() + m := mustManifest(t, ` +schemaVersion: 1 +contributor: { name: users, envelope: { supports: [v1], preferred: v1 } } +intents: [] +`) + _ = r.Register(m) + if err := r.Register(m); err == nil { + t.Error("expected duplicate-contributor error") + } +} + +func TestRegistry_HighestActiveIntentVersion(t *testing.T) { + r := NewRegistry() + m := mustManifest(t, ` +schemaVersion: 1 +contributor: { name: users, envelope: { supports: [v1], preferred: v1 } } +intents: + - { name: user.disable, kind: command, version: 1, capability: write, + deprecated: { intentVersion: 1, removeAfter: "2026-09-01" } } + - { name: user.disable, kind: command, version: 2, capability: write } +`) + if err := r.Register(m); err != nil { + t.Fatalf("register: %v", err) + } + got, ok := r.HighestVersion("users", "user.disable") + if !ok || got != 2 { + t.Errorf("HighestVersion = %d, ok=%v", got, ok) + } +} +``` + +Note: `unmarshalForTest` is a helper using `gopkg.in/yaml.v3`. Add it as a private test helper in `manifest_test.go` (or `registry_test.go`): + +```go +import yaml "gopkg.in/yaml.v3" + +func unmarshalForTest(b []byte, v any) error { return yaml.Unmarshal(b, v) } +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `go test ./extensions/dashboard/contract/...` +Expected: FAIL. + +- [ ] **Step 3: Implement registry.go** + +```go +// registry.go +package contract + +import ( + "fmt" + "sync" +) + +// Registry holds all registered contributor manifests and provides +// lookup by (contributor, intent, version) plus highest-active-version queries +// for negotiation. +type Registry interface { + Register(m *ContractManifest) error + Contributor(name string) (*ContractManifest, bool) + Intent(contributor, intent string, version int) (Intent, bool) + HighestVersion(contributor, intent string) (int, bool) + All() []*ContractManifest +} + +// NewRegistry returns an empty registry. +func NewRegistry() Registry { + return ®istry{ + contributors: map[string]*ContractManifest{}, + intents: map[intentKey]Intent{}, + highest: map[string]int{}, + } +} + +type intentKey struct { + contributor string + intent string + version int +} + +type registry struct { + mu sync.RWMutex + contributors map[string]*ContractManifest + intents map[intentKey]Intent + highest map[string]int // "contributor:intent" -> highest active version +} + +func (r *registry) Register(m *ContractManifest) error { + if m == nil { + return fmt.Errorf("nil manifest") + } + name := m.Contributor.Name + if name == "" { + return fmt.Errorf("manifest missing contributor.name") + } + r.mu.Lock() + defer r.mu.Unlock() + if _, exists := r.contributors[name]; exists { + return fmt.Errorf("contributor %q already registered", name) + } + for _, in := range m.Intents { + k := intentKey{name, in.Name, in.Version} + if _, dup := r.intents[k]; dup { + return fmt.Errorf("contributor %q intent %q version %d declared twice", name, in.Name, in.Version) + } + r.intents[k] = in + hk := name + ":" + in.Name + if in.Deprecated == nil { + if r.highest[hk] < in.Version { + r.highest[hk] = in.Version + } + } else if _, hasHigher := r.highest[hk]; !hasHigher { + // only set if no active version has been seen yet; deprecated falls back + r.highest[hk] = in.Version + } + } + r.contributors[name] = m + return nil +} + +func (r *registry) Contributor(name string) (*ContractManifest, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + m, ok := r.contributors[name] + return m, ok +} + +func (r *registry) Intent(contributor, intent string, version int) (Intent, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + in, ok := r.intents[intentKey{contributor, intent, version}] + return in, ok +} + +func (r *registry) HighestVersion(contributor, intent string) (int, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + v, ok := r.highest[contributor+":"+intent] + return v, ok +} + +func (r *registry) All() []*ContractManifest { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]*ContractManifest, 0, len(r.contributors)) + for _, m := range r.contributors { + out = append(out, m) + } + return out +} +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./extensions/dashboard/contract/...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/contract/registry.go extensions/dashboard/contract/registry_test.go +git commit -m "feat(dashboard/contract): registry indexed by contributor, intent, version" +``` + +### Task 6.2: Slot validation at registration (depth + cycle) + +**Files:** +- Create: `extensions/dashboard/contract/slots.go` +- Create: `extensions/dashboard/contract/slots_test.go` +- Modify: `extensions/dashboard/contract/registry.go` + +The slot system: parent intent (e.g. `page.shell`) declares which slots it has and what each accepts. Slot declarations live in a built-in **intent kind catalog** keyed by intent kind (not version, since slot shapes change at major version bumps and we'll handle that via additive evolution). For slice (a), the catalog is hard-coded in code — slice (e) externalizes it for the React shell. + +- [ ] **Step 1: Write the failing tests** + +```go +// slots_test.go +package contract + +import "testing" + +func TestSlotCatalog_PageShell(t *testing.T) { + def, ok := DefaultSlotCatalog["page.shell"] + if !ok { + t.Fatal("page.shell missing from default slot catalog") + } + if def.Slots["main"].Cardinality != CardinalityMany { + t.Errorf("main cardinality = %v", def.Slots["main"].Cardinality) + } +} + +func TestValidateSlotFills_AcceptCheck(t *testing.T) { + // page.shell.main accepts resource.list, dashboard.grid; rejects unknown + parent := DefaultSlotCatalog["page.shell"] + cases := []struct { + child string + wantErr bool + }{ + {"resource.list", false}, + {"dashboard.grid", false}, + {"action.button", true}, // not allowed in main + } + for _, c := range cases { + err := validateSlotAccepts(parent.Slots["main"], c.child) + if (err != nil) != c.wantErr { + t.Errorf("child=%s err=%v wantErr=%v", c.child, err, c.wantErr) + } + } +} + +func TestSlotDepth_ExceedsMax(t *testing.T) { + // build a graph of depth 9 (root + 8 nested slots) + leaf := GraphNode{Intent: "metric.counter"} + cur := leaf + for i := 0; i < 9; i++ { + cur = GraphNode{Intent: "page.shell", Slots: map[string][]GraphNode{"main": {cur}}} + } + if err := checkDepth([]GraphNode{cur}, 0); err == nil { + t.Error("expected depth-exceeded error") + } +} + +func TestSlotCycle_DetectedAtRegistration(t *testing.T) { + // A node referencing its own intent through a slot is a cycle + root := GraphNode{ + Intent: "self", + Slots: map[string][]GraphNode{ + "main": {{Intent: "self"}}, + }, + } + if err := checkCycle([]GraphNode{root}, map[string]bool{}); err == nil { + t.Error("expected cycle error") + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `go test ./extensions/dashboard/contract/...` +Expected: FAIL. + +- [ ] **Step 3: Implement slots.go** + +```go +// slots.go +package contract + +import "fmt" + +// MaxSlotDepth is the maximum nesting depth of graph nodes; trees deeper +// than this are rejected at registration. +const MaxSlotDepth = 8 + +// Cardinality describes how many fills a slot accepts. +type Cardinality string + +const ( + CardinalityOne Cardinality = "one" + CardinalityMany Cardinality = "many" +) + +// SlotDef describes one slot of a parent intent kind. +type SlotDef struct { + Accepts []string // intent names accepted in this slot + Cardinality Cardinality + Extensible bool // if true, other contributors may extend via Extends +} + +// IntentKindDef declares the slots of a built-in intent kind. +type IntentKindDef struct { + Slots map[string]SlotDef +} + +// DefaultSlotCatalog is the v1 catalog of built-in intent kinds and their slots. +// Adding a new built-in intent kind here is a shell-version bump (adds new +// renderer behavior). Slice (e) defines the full v1 vocabulary; this map starts +// with the kinds used by the spec's example. +var DefaultSlotCatalog = map[string]IntentKindDef{ + "page.shell": { + Slots: map[string]SlotDef{ + "main": { + Accepts: []string{"resource.list", "resource.detail", "dashboard.grid", "form.edit", "custom", "iframe"}, + Cardinality: CardinalityMany, + }, + }, + }, + "resource.list": { + Slots: map[string]SlotDef{ + "rowActions": {Accepts: []string{"action.button", "action.menu", "action.divider"}, Cardinality: CardinalityMany}, + "detailDrawer": {Accepts: []string{"form.edit", "resource.detail", "custom"}, Cardinality: CardinalityOne, Extensible: true}, + }, + }, + "dashboard.grid": { + Slots: map[string]SlotDef{ + "widgets": {Accepts: []string{"metric.counter", "metric.gauge", "audit.tail", "custom"}, Cardinality: CardinalityMany}, + }, + }, + "form.edit": { + Slots: map[string]SlotDef{ + "fields": {Accepts: []string{"form.field", "custom"}, Cardinality: CardinalityMany, Extensible: true}, + }, + }, +} + +// validateSlotAccepts returns an error if child is not in slot's Accepts list. +func validateSlotAccepts(slot SlotDef, child string) error { + for _, a := range slot.Accepts { + if a == child { + return nil + } + } + return fmt.Errorf("slot does not accept intent %q", child) +} + +// checkDepth recurses through GraphNodes and fails if any chain exceeds MaxSlotDepth. +func checkDepth(nodes []GraphNode, current int) error { + if current > MaxSlotDepth { + return fmt.Errorf("graph depth %d exceeds max %d", current, MaxSlotDepth) + } + for _, n := range nodes { + for _, children := range n.Slots { + if err := checkDepth(children, current+1); err != nil { + return err + } + } + } + return nil +} + +// checkCycle walks the graph and detects intent self-reference along a path. +// (Cross-intent cycles via custom escape hatches are bounded by checkDepth.) +func checkCycle(nodes []GraphNode, ancestors map[string]bool) error { + for _, n := range nodes { + if n.Intent == "" { + continue + } + if ancestors[n.Intent] { + return fmt.Errorf("cycle detected: intent %q nests itself", n.Intent) + } + ancestors[n.Intent] = true + for _, children := range n.Slots { + if err := checkCycle(children, ancestors); err != nil { + return err + } + } + delete(ancestors, n.Intent) + } + return nil +} +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./extensions/dashboard/contract/...` +Expected: PASS. + +- [ ] **Step 5: Modify Register to call checkDepth, checkCycle, and slot-accept validation** + +Update `Register` in `registry.go` after the per-intent registration loop: + +```go +// in registry.Register, before "r.contributors[name] = m" +if err := checkDepth(m.Graph, 0); err != nil { + return fmt.Errorf("contributor %q: %w", name, err) +} +if err := checkCycle(m.Graph, map[string]bool{}); err != nil { + return fmt.Errorf("contributor %q: %w", name, err) +} +if err := validateGraphSlots(m.Graph); err != nil { + return fmt.Errorf("contributor %q: %w", name, err) +} +``` + +Add `validateGraphSlots` to `slots.go`: + +```go +func validateGraphSlots(nodes []GraphNode) error { + for _, n := range nodes { + def, ok := DefaultSlotCatalog[n.Intent] + if !ok { + // unknown parent intent: cannot validate slots, allow (may be a leaf or custom) + continue + } + for slotName, children := range n.Slots { + slot, ok := def.Slots[slotName] + if !ok { + return fmt.Errorf("intent %q has no slot %q", n.Intent, slotName) + } + if slot.Cardinality == CardinalityOne && len(children) > 1 { + return fmt.Errorf("intent %q slot %q accepts one fill, got %d", n.Intent, slotName, len(children)) + } + for _, c := range children { + if err := validateSlotAccepts(slot, c.Intent); err != nil { + return fmt.Errorf("intent %q slot %q: %w", n.Intent, slotName, err) + } + } + if err := validateGraphSlots(children); err != nil { + return err + } + } + } + return nil +} +``` + +Add a registry test for slot rejection: + +```go +func TestRegistry_RejectsBadSlotFill(t *testing.T) { + r := NewRegistry() + m := mustManifest(t, ` +schemaVersion: 1 +contributor: { name: users, envelope: { supports: [v1], preferred: v1 } } +intents: [] +graph: + - intent: page.shell + slots: + main: + - { intent: action.button } # not allowed in page.shell.main +`) + if err := r.Register(m); err == nil { + t.Error("expected slot-accept rejection") + } +} +``` + +- [ ] **Step 6: Run tests** + +Run: `go test ./extensions/dashboard/contract/...` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add extensions/dashboard/contract/slots.go extensions/dashboard/contract/slots_test.go extensions/dashboard/contract/registry.go extensions/dashboard/contract/registry_test.go +git commit -m "feat(dashboard/contract): slot catalog with depth, cycle, and accept validation" +``` + +### Task 6.3: Slot extension application (cross-contributor extends) + +**Files:** +- Modify: `extensions/dashboard/contract/slots.go` +- Modify: `extensions/dashboard/contract/slots_test.go` +- Modify: `extensions/dashboard/contract/registry.go` + +- [ ] **Step 1: Write failing tests** + +```go +// add to slots_test.go +func TestApplyExtensions_NonExtensibleSlot_Rejected(t *testing.T) { + // users.page.shell has a 'main' slot (not extensible by default) + r := NewRegistry() + usersM := mustManifest(t, ` +schemaVersion: 1 +contributor: { name: users, envelope: { supports: [v1], preferred: v1 } } +intents: [] +graph: + - { route: /users, intent: page.shell, slots: { main: [{ intent: resource.list }] } } +`) + _ = r.Register(usersM) + + authM := mustManifest(t, ` +schemaVersion: 1 +contributor: { name: auth, envelope: { supports: [v1], preferred: v1 } } +intents: [] +extends: + - target: { contributor: users, intent: page.shell, route: /users } + slot: main + add: + - { intent: action.button } +`) + if err := r.Register(authM); err == nil { + t.Error("expected non-extensible-slot rejection") + } +} + +func TestApplyExtensions_Extensible_Merges(t *testing.T) { + r := NewRegistry() + usersM := mustManifest(t, ` +schemaVersion: 1 +contributor: { name: users, envelope: { supports: [v1], preferred: v1 } } +intents: [] +graph: + - { route: /users, intent: page.shell, slots: { + main: [{ + intent: resource.list, + slots: { detailDrawer: [{ intent: form.edit }] } + }] + } } +`) + _ = r.Register(usersM) + + authM := mustManifest(t, ` +schemaVersion: 1 +contributor: { name: auth, envelope: { supports: [v1], preferred: v1 } } +intents: [] +extends: + - target: { contributor: users, intent: page.shell, route: /users } + slot: main.detailDrawer.fields + add: + - { intent: form.field } +`) + if err := r.Register(authM); err != nil { + t.Fatalf("register auth: %v", err) + } + // After registration, the merged graph should include the extension + merged, ok := r.MergedGraph("users", "/users") + if !ok { + t.Fatal("merged graph not found") + } + fields := merged.Slots["main"][0].Slots["detailDrawer"][0].Slots["fields"] + if len(fields) != 1 || fields[0].Intent != "form.field" { + t.Errorf("extension not applied: %+v", fields) + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `go test ./extensions/dashboard/contract/...` +Expected: FAIL — `MergedGraph` undefined, no extension support. + +- [ ] **Step 3: Implement slot extension application** + +Add to `slots.go`: + +```go +import "strings" // add to imports if not already + +// applyExtension finds the target node in graph and merges adds into the named slot. +// slotPath is a dotted path through Slots maps and indices, e.g. "main.detailDrawer.fields". +// Returns an error if the target slot is not extensible. +func applyExtension(graph []GraphNode, target ExtensionTarget, slotPath string, adds []GraphNode) error { + for i := range graph { + if matchesTarget(graph[i], target) { + return walkAndAppend(&graph[i], strings.Split(slotPath, "."), adds) + } + } + return fmt.Errorf("extension target not found: contributor=%s intent=%s route=%s", target.Contributor, target.Intent, target.Route) +} + +func matchesTarget(n GraphNode, t ExtensionTarget) bool { + if n.Intent != t.Intent { + return false + } + if t.Route != "" && n.Route != t.Route { + return false + } + return true +} + +func walkAndAppend(n *GraphNode, path []string, adds []GraphNode) error { + if len(path) == 0 { + return fmt.Errorf("empty slot path") + } + slotName := path[0] + rest := path[1:] + if n.Slots == nil { + n.Slots = map[string][]GraphNode{} + } + if len(rest) == 0 { + // We've reached the target slot; check extensibility + def, ok := DefaultSlotCatalog[n.Intent] + if !ok { + return fmt.Errorf("unknown parent intent %q", n.Intent) + } + slot, ok := def.Slots[slotName] + if !ok { + return fmt.Errorf("intent %q has no slot %q", n.Intent, slotName) + } + if !slot.Extensible { + return fmt.Errorf("intent %q slot %q is not extensible", n.Intent, slotName) + } + for _, a := range adds { + if err := validateSlotAccepts(slot, a.Intent); err != nil { + return fmt.Errorf("extension into %q.%q: %w", n.Intent, slotName, err) + } + } + n.Slots[slotName] = append(n.Slots[slotName], adds...) + return nil + } + // Recurse: choose first fill in this slot (cardinality:one is the common case for path traversal; + // for cardinality:many extensions, the user must specify an index — out of scope for v1). + children, ok := n.Slots[slotName] + if !ok || len(children) == 0 { + return fmt.Errorf("path %q: no fills in slot %q to descend", strings.Join(path, "."), slotName) + } + return walkAndAppend(&children[0], rest, adds) +} +``` + +Modify `registry.Register` to apply extensions and store merged graphs: + +```go +// in registry.go, add a new field: +type registry struct { + // ... existing fields + mergedGraphs map[string][]GraphNode // key: contributor name +} + +// in NewRegistry, initialize the new map +mergedGraphs: map[string][]GraphNode{}, + +// in Register, after r.contributors[name] = m, before return nil: +// Compute merged graphs: copy own graph, then apply this manifest's extends +// to ALL contributors (including ones registered earlier). +mergedGraph := deepCopyGraph(m.Graph) +r.mergedGraphs[name] = mergedGraph +for _, ext := range m.Extends { + targetGraph, ok := r.mergedGraphs[ext.Target.Contributor] + if !ok { + return fmt.Errorf("contributor %q: extension target %q not registered", name, ext.Target.Contributor) + } + if err := applyExtension(targetGraph, ext.Target, ext.Slot, ext.Add); err != nil { + return fmt.Errorf("contributor %q: %w", name, err) + } +} +return nil +``` + +Add `MergedGraph` method: + +```go +// MergedGraph returns the merged graph for a contributor (with all extensions +// applied), or false if the contributor is not registered. +// The optional route parameter narrows the result to a single route node. +func (r *registry) MergedGraph(contributor, route string) (*GraphNode, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + g, ok := r.mergedGraphs[contributor] + if !ok { + return nil, false + } + if route == "" { + if len(g) > 0 { + return &g[0], true + } + return nil, false + } + for i := range g { + if g[i].Route == route { + return &g[i], true + } + } + return nil, false +} +``` + +Add `MergedGraph` to the `Registry` interface. + +Add `deepCopyGraph` to `slots.go`: + +```go +// deepCopyGraph returns a deep copy of a graph slice. Required so applying +// extensions doesn't mutate a contributor's original manifest. +func deepCopyGraph(in []GraphNode) []GraphNode { + if in == nil { + return nil + } + out := make([]GraphNode, len(in)) + for i, n := range in { + nc := n + if n.Slots != nil { + nc.Slots = map[string][]GraphNode{} + for k, v := range n.Slots { + nc.Slots[k] = deepCopyGraph(v) + } + } + out[i] = nc + } + return out +} +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./extensions/dashboard/contract/...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/contract/slots.go extensions/dashboard/contract/slots_test.go extensions/dashboard/contract/registry.go +git commit -m "feat(dashboard/contract): cross-contributor slot extensions with extensibility check" +``` + +--- + +## Phase 7: Per-Request Graph Build with Permission Filter + +### Task 7.1: Filter the merged graph by user permissions + +**Files:** +- Create: `extensions/dashboard/contract/graph.go` +- Create: `extensions/dashboard/contract/graph_test.go` + +- [ ] **Step 1: Write the failing tests** + +```go +// graph_test.go +package contract + +import ( + "context" + "testing" + + "github.com/xraph/forge/extensions/dashboard/auth" +) + +func TestBuildGraph_FiltersHiddenNodes(t *testing.T) { + r := NewRegistry() + m := mustManifest(t, ` +schemaVersion: 1 +contributor: { name: users, envelope: { supports: [v1], preferred: v1 } } +intents: [] +graph: + - route: /users + intent: page.shell + slots: + main: + - intent: resource.list + slots: + rowActions: + - { intent: action.button, op: user.disable, + visibleWhen: { all: ["role:admin"] } } + - { intent: action.button, op: user.view } +`) + _ = r.Register(m) + build := NewGraphBuilder(r, NewWardenRegistry()) + + got, err := build.Build(context.Background(), "users", "/users", + Principal{User: &auth.UserInfo{Roles: []string{"viewer"}}}) + if err != nil { + t.Fatalf("build: %v", err) + } + actions := got.Slots["main"][0].Slots["rowActions"] + if len(actions) != 1 { + t.Fatalf("expected 1 action visible to viewer, got %d", len(actions)) + } + if actions[0].Op != "user.view" { + t.Errorf("wrong action remained: %+v", actions[0]) + } +} + +func TestBuildGraph_AdminSeesAll(t *testing.T) { + r := NewRegistry() + m := mustManifest(t, ` +schemaVersion: 1 +contributor: { name: users, envelope: { supports: [v1], preferred: v1 } } +intents: [] +graph: + - route: /users + intent: page.shell + slots: + main: + - intent: resource.list + slots: + rowActions: + - { intent: action.button, op: user.disable, + visibleWhen: { all: ["role:admin"] } } +`) + _ = r.Register(m) + build := NewGraphBuilder(r, NewWardenRegistry()) + got, _ := build.Build(context.Background(), "users", "/users", + Principal{User: &auth.UserInfo{Roles: []string{"admin"}}}) + actions := got.Slots["main"][0].Slots["rowActions"] + if len(actions) != 1 { + t.Errorf("admin should see admin-only action; got %d", len(actions)) + } +} + +func TestBuildGraph_RouteNotFound(t *testing.T) { + r := NewRegistry() + build := NewGraphBuilder(r, NewWardenRegistry()) + _, err := build.Build(context.Background(), "users", "/nope", Principal{}) + if err == nil { + t.Error("expected not-found error") + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `go test ./extensions/dashboard/contract/...` +Expected: FAIL. + +- [ ] **Step 3: Implement graph.go** + +```go +// graph.go +package contract + +import ( + "context" + "fmt" +) + +// GraphBuilder produces a per-(route, principal) filtered graph by walking the +// merged graph from the registry and dropping nodes whose visibleWhen predicates +// fail. EnabledWhen is preserved as an annotation (it does not strip the node); +// the React shell honors it for disabled-but-visible UI states. +type GraphBuilder struct { + registry Registry + wardens WardenRegistry +} + +// NewGraphBuilder returns a builder bound to the given registry and warden registry. +func NewGraphBuilder(reg Registry, wardens WardenRegistry) *GraphBuilder { + return &GraphBuilder{registry: reg, wardens: wardens} +} + +// Build returns the filtered graph rooted at the given route for the given principal. +// Returns ErrNotFound if no contributor owns the route. +func (b *GraphBuilder) Build(ctx context.Context, contributor, route string, p Principal) (*GraphNode, error) { + root, ok := b.registry.MergedGraph(contributor, route) + if !ok { + return nil, fmt.Errorf("%w: contributor=%s route=%s", ErrNotFound, contributor, route) + } + filtered, _ := b.filter(ctx, *root, p) + if filtered == nil { + return nil, fmt.Errorf("%w: route filtered for principal", ErrPermissionDenied) + } + return filtered, nil +} + +// filter returns a deep copy of n with non-visible descendants stripped, or nil +// if n itself fails its own visibleWhen. +func (b *GraphBuilder) filter(ctx context.Context, n GraphNode, p Principal) (*GraphNode, error) { + if !b.allowsNode(ctx, n, p) { + return nil, nil + } + out := n + if n.Slots != nil { + out.Slots = map[string][]GraphNode{} + for slotName, children := range n.Slots { + var kept []GraphNode + for _, c := range children { + kc, err := b.filter(ctx, c, p) + if err != nil { + return nil, err + } + if kc != nil { + kept = append(kept, *kc) + } + } + if len(kept) > 0 { + out.Slots[slotName] = kept + } + } + } + return &out, nil +} + +// allowsNode evaluates visibleWhen plus any per-slot 'requires' inherited from +// the parent intent's slot definition. Returns true if the node should be kept. +func (b *GraphBuilder) allowsNode(_ context.Context, n GraphNode, p Principal) bool { + if n.VisibleWhen != nil && !n.VisibleWhen.Allow(p.User, nil) { + return false + } + // Warden hook: if visibleWhen carries a Warden ref, run it + if n.VisibleWhen != nil && n.VisibleWhen.Warden != "" { + w, ok := b.wardens.Get(n.VisibleWhen.Warden) + if !ok { + return false + } + // Best-effort sync call here; per-event re-checks are cached in stream.go + d, err := w.Authorize(context.Background(), p, Action{Intent: n.Intent}) + if err != nil || !d.Allow { + return false + } + } + return true +} +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./extensions/dashboard/contract/...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/contract/graph.go extensions/dashboard/contract/graph_test.go +git commit -m "feat(dashboard/contract): per-request graph filter by visibleWhen + warden" +``` + +--- + +## Phase 8: Graph Cache + +### Task 8.1: LRU graph cache + +**Files:** +- Create: `extensions/dashboard/contract/cache.go` +- Create: `extensions/dashboard/contract/cache_test.go` + +- [ ] **Step 1: Write the failing tests** + +```go +// cache_test.go +package contract + +import ( + "testing" + "time" +) + +func TestGraphCache_HitMiss(t *testing.T) { + c := NewGraphCache(2, time.Minute) + key := GraphCacheKey{Route: "/users", PermissionsHash: "h1", ShellVersion: "v1"} + if _, ok := c.Get(key); ok { + t.Error("expected miss") + } + c.Put(key, &GraphNode{Intent: "page.shell"}) + got, ok := c.Get(key) + if !ok || got.Intent != "page.shell" { + t.Errorf("expected hit, got %+v ok=%v", got, ok) + } +} + +func TestGraphCache_Eviction(t *testing.T) { + c := NewGraphCache(2, time.Minute) + c.Put(GraphCacheKey{Route: "/a"}, &GraphNode{Intent: "a"}) + c.Put(GraphCacheKey{Route: "/b"}, &GraphNode{Intent: "b"}) + c.Put(GraphCacheKey{Route: "/c"}, &GraphNode{Intent: "c"}) // evicts /a + if _, ok := c.Get(GraphCacheKey{Route: "/a"}); ok { + t.Error("expected /a evicted") + } +} + +func TestGraphCache_TTLExpiry(t *testing.T) { + c := NewGraphCache(2, 10*time.Millisecond) + c.Put(GraphCacheKey{Route: "/x"}, &GraphNode{Intent: "x"}) + time.Sleep(20 * time.Millisecond) + if _, ok := c.Get(GraphCacheKey{Route: "/x"}); ok { + t.Error("expected ttl expiry") + } +} + +func TestGraphCache_BustAll(t *testing.T) { + c := NewGraphCache(4, time.Minute) + c.Put(GraphCacheKey{Route: "/a"}, &GraphNode{Intent: "a"}) + c.BustAll() + if _, ok := c.Get(GraphCacheKey{Route: "/a"}); ok { + t.Error("BustAll should clear") + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `go test ./extensions/dashboard/contract/...` +Expected: FAIL. + +- [ ] **Step 3: Implement cache.go** + +```go +// cache.go +package contract + +import ( + "container/list" + "sync" + "time" +) + +// GraphCacheKey is the (route, permissionsHash, shellVersion) tuple keyed by the cache. +type GraphCacheKey struct { + Route string + PermissionsHash string + ShellVersion string +} + +// GraphCache is a small LRU+TTL cache. Bust on contributor manifest reload. +type GraphCache struct { + mu sync.Mutex + cap int + ttl time.Duration + items map[GraphCacheKey]*list.Element + order *list.List // front = MRU +} + +type graphEntry struct { + key GraphCacheKey + value *GraphNode + at time.Time +} + +// NewGraphCache creates a cache with the given max size and TTL per entry. +// TTL of 0 disables expiry. +func NewGraphCache(maxEntries int, ttl time.Duration) *GraphCache { + if maxEntries < 1 { + maxEntries = 64 + } + return &GraphCache{ + cap: maxEntries, + ttl: ttl, + items: map[GraphCacheKey]*list.Element{}, + order: list.New(), + } +} + +func (c *GraphCache) Get(k GraphCacheKey) (*GraphNode, bool) { + c.mu.Lock() + defer c.mu.Unlock() + el, ok := c.items[k] + if !ok { + return nil, false + } + e := el.Value.(*graphEntry) + if c.ttl > 0 && time.Since(e.at) > c.ttl { + c.order.Remove(el) + delete(c.items, k) + return nil, false + } + c.order.MoveToFront(el) + return e.value, true +} + +func (c *GraphCache) Put(k GraphCacheKey, v *GraphNode) { + c.mu.Lock() + defer c.mu.Unlock() + if el, ok := c.items[k]; ok { + e := el.Value.(*graphEntry) + e.value = v + e.at = time.Now() + c.order.MoveToFront(el) + return + } + el := c.order.PushFront(&graphEntry{key: k, value: v, at: time.Now()}) + c.items[k] = el + if c.order.Len() > c.cap { + oldest := c.order.Back() + if oldest != nil { + c.order.Remove(oldest) + delete(c.items, oldest.Value.(*graphEntry).key) + } + } +} + +// BustAll clears the cache. Call after a contributor manifest reload or shell deploy. +func (c *GraphCache) BustAll() { + c.mu.Lock() + defer c.mu.Unlock() + c.items = map[GraphCacheKey]*list.Element{} + c.order = list.New() +} +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./extensions/dashboard/contract/...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/contract/cache.go extensions/dashboard/contract/cache_test.go +git commit -m "feat(dashboard/contract): LRU+TTL graph cache keyed by (route, permissions, shell)" +``` + +--- + +## Phase 9: Audit Emitter + +### Task 9.1: Audit interface + log default + +**Files:** +- Create: `extensions/dashboard/contract/audit.go` +- Create: `extensions/dashboard/contract/audit_test.go` + +- [ ] **Step 1: Write the failing tests** + +```go +// audit_test.go +package contract + +import ( + "bytes" + "context" + "strings" + "testing" + "time" +) + +func TestLogAuditEmitter_FormatsRecord(t *testing.T) { + var buf bytes.Buffer + em := NewLogAuditEmitter(&buf) + em.Emit(context.Background(), AuditRecord{ + Time: time.Now(), + Contributor: "users", + Intent: "user.disable", + Subject: "u_42", + User: "admin@example.com", + Result: "ok", + LatencyMs: 12, + }) + out := buf.String() + for _, want := range []string{"users", "user.disable", "u_42", "admin@example.com", "ok"} { + if !strings.Contains(out, want) { + t.Errorf("audit output missing %q: %s", want, out) + } + } +} + +func TestNoopAuditEmitter_DoesNothing(t *testing.T) { + em := NoopAuditEmitter{} + em.Emit(context.Background(), AuditRecord{}) // must not panic +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `go test ./extensions/dashboard/contract/...` +Expected: FAIL. + +- [ ] **Step 3: Implement audit.go** + +```go +// audit.go +package contract + +import ( + "context" + "fmt" + "io" + "time" +) + +// AuditRecord is one auditable command invocation. +type AuditRecord struct { + Time time.Time + Contributor string + Intent string + IntentVersion int + Subject string // resource id when known + User string // user identity (subject from UserInfo) + Result string // ok | error + LatencyMs int64 + Payload map[string]any // pre-redaction; subject to per-intent redaction list + CorrelationID string +} + +// AuditEmitter ships audit records to durable storage. Slice (b) wires the +// chronicle implementation; slice (a) ships log-based and noop variants. +type AuditEmitter interface { + Emit(ctx context.Context, rec AuditRecord) +} + +// NoopAuditEmitter is the disabled-audit implementation. +type NoopAuditEmitter struct{} + +func (NoopAuditEmitter) Emit(_ context.Context, _ AuditRecord) {} + +// NewLogAuditEmitter returns an emitter that writes a stable line format to w. +// Suitable for development and as a fallback when no chronicle backend is wired. +func NewLogAuditEmitter(w io.Writer) AuditEmitter { + return &logAuditEmitter{w: w} +} + +type logAuditEmitter struct { + w io.Writer +} + +func (e *logAuditEmitter) Emit(_ context.Context, rec AuditRecord) { + fmt.Fprintf(e.w, + "audit ts=%s contributor=%s intent=%s v=%d subject=%s user=%s result=%s latencyMs=%d corr=%s\n", + rec.Time.UTC().Format(time.RFC3339Nano), + rec.Contributor, rec.Intent, rec.IntentVersion, + rec.Subject, rec.User, rec.Result, rec.LatencyMs, rec.CorrelationID, + ) +} +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./extensions/dashboard/contract/...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/contract/audit.go extensions/dashboard/contract/audit_test.go +git commit -m "feat(dashboard/contract): audit emitter interface with log-based default" +``` + +--- + +## Phase 10: HTTP Transport — POST handler + +### Task 10.1: Envelope decode + kind dispatch + error envelope + +**Files:** +- Create: `extensions/dashboard/contract/transport/http.go` +- Create: `extensions/dashboard/contract/transport/http_test.go` + +The handler is intentionally thin: decode envelope → look up intent → enforce kind/capability match → dispatch to the right per-kind handler set on a `Dispatcher`. Real intent execution (calling contributor handlers, running queries) lives in slice (c) — for slice (a), the Dispatcher is an interface that contributor packages will fulfil. + +- [ ] **Step 1: Write failing tests** + +```go +// http_test.go +package transport + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +type stubDispatcher struct { + called string + response json.RawMessage +} + +func (s *stubDispatcher) Dispatch(_ context.Context, in contract.Request, _ contract.Principal) (json.RawMessage, contract.ResponseMeta, error) { + s.called = string(in.Kind) + ":" + in.Intent + return s.response, contract.ResponseMeta{IntentVersion: in.IntentVersion}, nil +} + +func setupRegistry(t *testing.T) (contract.Registry, contract.WardenRegistry) { + t.Helper() + r := contract.NewRegistry() + src := ` +schemaVersion: 1 +contributor: { name: users, envelope: { supports: [v1], preferred: v1 } } +intents: + - { name: users.list, kind: query, version: 1, capability: read } + - { name: user.disable, kind: command, version: 1, capability: write } +` + var m contract.ContractManifest + if err := contract.UnmarshalManifestForTest([]byte(src), &m); err != nil { + t.Fatal(err) + } + if err := r.Register(&m); err != nil { + t.Fatal(err) + } + return r, contract.NewWardenRegistry() +} + +func TestHandler_DispatchesQuery(t *testing.T) { + reg, wreg := setupRegistry(t) + disp := &stubDispatcher{response: json.RawMessage(`{"users":[]}`)} + h := NewHandler(reg, wreg, disp, contract.NoopAuditEmitter{}) + + body, _ := json.Marshal(contract.Request{ + Envelope: "v1", Kind: contract.KindQuery, Contributor: "users", Intent: "users.list", IntentVersion: 1, + }) + req := httptest.NewRequest(http.MethodPost, "/api/dashboard/v1", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", w.Code, w.Body) + } + if disp.called != "query:users.list" { + t.Errorf("dispatcher not called: %s", disp.called) + } +} + +func TestHandler_RejectsKindCapabilityMismatch(t *testing.T) { + reg, wreg := setupRegistry(t) + h := NewHandler(reg, wreg, &stubDispatcher{}, contract.NoopAuditEmitter{}) + + // Send Kind=command for an intent whose Capability=read => mismatch + body, _ := json.Marshal(contract.Request{ + Envelope: "v1", Kind: contract.KindCommand, Contributor: "users", Intent: "users.list", IntentVersion: 1, + }) + req := httptest.NewRequest(http.MethodPost, "/api/dashboard/v1", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d body=%s", w.Code, w.Body) + } + if !strings.Contains(w.Body.String(), "BAD_REQUEST") { + t.Errorf("expected BAD_REQUEST in body: %s", w.Body) + } +} + +func TestHandler_UnsupportedVersion(t *testing.T) { + reg, wreg := setupRegistry(t) + h := NewHandler(reg, wreg, &stubDispatcher{}, contract.NoopAuditEmitter{}) + + body, _ := json.Marshal(contract.Request{ + Envelope: "v999", Kind: contract.KindQuery, Contributor: "users", Intent: "users.list", + }) + req := httptest.NewRequest(http.MethodPost, "/api/dashboard/v1", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d", w.Code) + } + if !strings.Contains(w.Body.String(), "UNSUPPORTED_VERSION") { + t.Errorf("expected UNSUPPORTED_VERSION: %s", w.Body) + } +} + +func TestHandler_CommandRequiresIdempotencyKey(t *testing.T) { + reg, wreg := setupRegistry(t) + h := NewHandler(reg, wreg, &stubDispatcher{}, contract.NoopAuditEmitter{}) + + body, _ := json.Marshal(contract.Request{ + Envelope: "v1", Kind: contract.KindCommand, Contributor: "users", Intent: "user.disable", IntentVersion: 1, + // CSRF and IdempotencyKey omitted + }) + req := httptest.NewRequest(http.MethodPost, "/api/dashboard/v1", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d", w.Code) + } +} +``` + +In `manifest.go` (or a new `testhelpers.go`), add: + +```go +// UnmarshalManifestForTest is a test helper exposed for use by sibling packages. +func UnmarshalManifestForTest(b []byte, m *ContractManifest) error { + return yaml.Unmarshal(b, m) +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `go test ./extensions/dashboard/contract/...` +Expected: FAIL — undefined. + +- [ ] **Step 3: Implement transport/http.go** + +```go +// http.go +package transport + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/xraph/forge/extensions/dashboard/auth" + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// Dispatcher routes a fully-validated request to an intent implementation. +// Slice (c) provides the binding from intent name to actual handlers. +type Dispatcher interface { + Dispatch(ctx context.Context, in contract.Request, p contract.Principal) (json.RawMessage, contract.ResponseMeta, error) +} + +// supportedEnvelopes is the set this slice's handler understands. +var supportedEnvelopes = map[string]bool{"v1": true} + +// NewHandler returns the POST /api/dashboard/{envelope} handler. +// principalFromCtx defaults to deriving from auth.UserFromContext. +func NewHandler(reg contract.Registry, wreg contract.WardenRegistry, disp Dispatcher, audit contract.AuditEmitter) http.Handler { + return &handler{reg: reg, wreg: wreg, disp: disp, audit: audit} +} + +type handler struct { + reg contract.Registry + wreg contract.WardenRegistry + disp Dispatcher + audit contract.AuditEmitter +} + +func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, &contract.Error{Code: contract.CodeBadRequest, Message: "POST required"}) + return + } + defer r.Body.Close() + var req contract.Request + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, &contract.Error{Code: contract.CodeBadRequest, Message: "invalid JSON: " + err.Error()}) + return + } + if !supportedEnvelopes[req.Envelope] { + writeError(w, http.StatusBadRequest, &contract.Error{Code: contract.CodeUnsupportedVersion, Message: "envelope " + req.Envelope + " unsupported"}) + return + } + if err := validateKind(req); err != nil { + writeError(w, http.StatusBadRequest, &contract.Error{Code: contract.CodeBadRequest, Message: err.Error()}) + return + } + in, ok := h.reg.Intent(req.Contributor, req.Intent, intentVersionOrHighest(h.reg, req)) + if !ok { + writeError(w, http.StatusNotFound, &contract.Error{Code: contract.CodeNotFound, Message: "intent " + req.Intent + " not registered"}) + return + } + if !kindMatchesCapability(req.Kind, in.Capability) { + writeError(w, http.StatusBadRequest, &contract.Error{ + Code: contract.CodeBadRequest, + Message: "kind " + string(req.Kind) + " does not match intent capability " + string(in.Capability), + }) + return + } + if req.Kind == contract.KindCommand { + if req.IdempotencyKey == "" || req.CSRF == "" { + writeError(w, http.StatusBadRequest, &contract.Error{Code: contract.CodeBadRequest, Message: "command requires csrf and idempotencyKey"}) + return + } + } + + user := auth.UserFromContext(r.Context()) + p := contract.PrincipalFor(user) + + if !in.Requires.Allow(user, nil) { + writeError(w, http.StatusForbidden, &contract.Error{Code: contract.CodePermissionDenied}) + return + } + // Warden second pass when declared + if in.Requires.Warden != "" { + warden, ok := h.wreg.Get(in.Requires.Warden) + if !ok { + writeError(w, http.StatusInternalServerError, &contract.Error{Code: contract.CodeInternal, Message: "warden not registered"}) + return + } + dec, err := warden.Authorize(r.Context(), p, contract.Action{ + Contributor: req.Contributor, Intent: req.Intent, Kind: req.Kind, Capability: in.Capability, Resource: req.Params, + }) + if err != nil || !dec.Allow { + writeError(w, http.StatusForbidden, &contract.Error{Code: contract.CodePermissionDenied, Message: dec.Reason}) + return + } + } + + t0 := time.Now() + data, meta, err := h.disp.Dispatch(r.Context(), req, p) + latency := time.Since(t0) + + emitAudit(h.audit, req, in, p, err, latency) + + if err != nil { + writeError(w, http.StatusInternalServerError, asContractError(err)) + return + } + writeOK(w, contract.Response{ + OK: true, Envelope: req.Envelope, Kind: req.Kind, Data: data, Meta: meta, + }) +} + +func validateKind(req contract.Request) error { + switch req.Kind { + case contract.KindGraph, contract.KindQuery, contract.KindCommand: + return nil + case contract.KindSubscribe: + return errKind("subscribe is GET-only on /stream") + } + return errKind("unknown kind " + string(req.Kind)) +} + +func errKind(msg string) error { return &contract.Error{Code: contract.CodeBadRequest, Message: msg} } + +func kindMatchesCapability(k contract.Kind, c contract.Capability) bool { + switch k { + case contract.KindCommand: + return c == contract.CapWrite + case contract.KindQuery: + return c == contract.CapRead + case contract.KindGraph: + return c == contract.CapRender + } + return false +} + +func intentVersionOrHighest(reg contract.Registry, req contract.Request) int { + if req.IntentVersion != 0 { + return req.IntentVersion + } + v, _ := reg.HighestVersion(req.Contributor, req.Intent) + return v +} + +func emitAudit(em contract.AuditEmitter, req contract.Request, in contract.Intent, p contract.Principal, dispErr error, lat time.Duration) { + if in.Kind != contract.IntentKindCommand { + return + } + if in.Audit != nil && !*in.Audit { + return + } + user := "" + if p.User != nil { + user = p.User.Subject + } + result := "ok" + if dispErr != nil { + result = "error" + } + em.Emit(context.Background(), contract.AuditRecord{ + Time: time.Now(), Contributor: req.Contributor, Intent: req.Intent, + IntentVersion: in.Version, User: user, Result: result, LatencyMs: lat.Milliseconds(), + CorrelationID: req.Context.CorrelationID, + }) +} + +func asContractError(err error) *contract.Error { + if e, ok := err.(*contract.Error); ok { + return e + } + return &contract.Error{Code: contract.CodeInternal, Message: err.Error()} +} + +func writeOK(w http.ResponseWriter, r contract.Response) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(r) +} + +func writeError(w http.ResponseWriter, status int, e *contract.Error) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(contract.ErrorResponse{ + OK: false, Envelope: "v1", Error: e, + }) + _ = strings.TrimSpace // keep import +} +``` + +Note: `auth.UserFromContext` is the existing helper (see DESIGN.md "Reused"). Confirm the name with `grep -n "func UserFromContext" /Users/rexraphael/Work/xraph/forge/extensions/dashboard/auth/*.go` before merging — if the name differs in the codebase, adjust here. + +- [ ] **Step 4: Run tests** + +Run: `go test ./extensions/dashboard/contract/...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/contract/transport/http.go extensions/dashboard/contract/transport/http_test.go extensions/dashboard/contract/manifest.go +git commit -m "feat(dashboard/contract): POST handler with kind dispatch, version + capability checks" +``` + +--- + +## Phase 11: Capabilities Endpoint + +### Task 11.1: GET /capabilities + +**Files:** +- Create: `extensions/dashboard/contract/transport/capabilities.go` +- Create: `extensions/dashboard/contract/transport/capabilities_test.go` + +- [ ] **Step 1: Write the failing test** + +```go +// capabilities_test.go +package transport + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +func TestCapabilities_ReportsRegisteredContributors(t *testing.T) { + reg, _ := setupRegistry(t) + h := NewCapabilitiesHandler(reg, []string{"v1"}) + req := httptest.NewRequest(http.MethodGet, "/api/dashboard/v1/capabilities", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + var got CapabilitiesResponse + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if len(got.Contributors) != 1 || got.Contributors[0].Name != "users" { + t.Errorf("contributors = %+v", got.Contributors) + } + intents := got.Contributors[0].Intents + if len(intents) != 2 { + t.Errorf("expected 2 intents in capabilities, got %d", len(intents)) + } + _ = contract.IntentKindQuery +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `go test ./extensions/dashboard/contract/transport/...` +Expected: FAIL. + +- [ ] **Step 3: Implement transport/capabilities.go** + +```go +// capabilities.go +package transport + +import ( + "encoding/json" + "net/http" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// CapabilitiesResponse is the wire shape for GET /capabilities. +type CapabilitiesResponse struct { + ShellEnvelopes []string `json:"shellEnvelopes"` + Contributors []ContributorCapability `json:"contributors"` +} + +// ContributorCapability is one contributor's negotiable surface. +type ContributorCapability struct { + Name string `json:"name"` + Envelopes []string `json:"envelopes"` + Intents []IntentCapability `json:"intents"` +} + +// IntentCapability summarises one intent's available versions. +type IntentCapability struct { + Name string `json:"name"` + Versions []IntentVersionStatus `json:"versions"` +} + +// IntentVersionStatus reports a single version + lifecycle status. +type IntentVersionStatus struct { + N int `json:"n"` + Status string `json:"status"` // active | deprecated + RemoveAfter string `json:"removeAfter,omitempty"` +} + +// NewCapabilitiesHandler returns the GET /capabilities handler. +func NewCapabilitiesHandler(reg contract.Registry, shellEnvelopes []string) http.Handler { + return &capabilitiesHandler{reg: reg, shellEnvelopes: shellEnvelopes} +} + +type capabilitiesHandler struct { + reg contract.Registry + shellEnvelopes []string +} + +func (h *capabilitiesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "GET required", http.StatusMethodNotAllowed) + return + } + resp := CapabilitiesResponse{ShellEnvelopes: h.shellEnvelopes} + for _, m := range h.reg.All() { + c := ContributorCapability{Name: m.Contributor.Name, Envelopes: m.Contributor.Envelope.Supports} + // Group intents by name; collect versions. + byName := map[string][]IntentVersionStatus{} + for _, in := range m.Intents { + s := IntentVersionStatus{N: in.Version, Status: "active"} + if in.Deprecated != nil { + s.Status = "deprecated" + s.RemoveAfter = in.Deprecated.RemoveAfter + } + byName[in.Name] = append(byName[in.Name], s) + } + for name, versions := range byName { + c.Intents = append(c.Intents, IntentCapability{Name: name, Versions: versions}) + } + resp.Contributors = append(resp.Contributors, c) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./extensions/dashboard/contract/transport/...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/contract/transport/capabilities.go extensions/dashboard/contract/transport/capabilities_test.go +git commit -m "feat(dashboard/contract): capabilities endpoint for version negotiation" +``` + +--- + +## Phase 12: SSE Stream Transport + +### Task 12.1: Multiplexed stream + control endpoint + +**Files:** +- Create: `extensions/dashboard/contract/transport/stream.go` +- Create: `extensions/dashboard/contract/transport/control.go` +- Create: `extensions/dashboard/contract/transport/stream_test.go` + +The stream design: +- Client opens `GET /api/dashboard/v1/stream` and receives a `streamID` in the first event. +- Client sends subscribe/unsubscribe commands to `POST /api/dashboard/v1/stream/control` with the `streamID`. +- Server fans events from each subscription into the single SSE connection. +- Each event has a `subscriptionID` (the SSE `event:` field) so the client can route locally. + +Slice (a) implements transport + multiplexing + per-event authz re-check (cached). Actual subscription event sources (e.g., the audit feed's data) come from contributor handlers in slice (c) — for now, the stream broker accepts an injected `SubscriptionSource`. + +- [ ] **Step 1: Write the failing test** + +```go +// stream_test.go +package transport + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +type stubSource struct { + events chan contract.StreamEvent +} + +func (s *stubSource) Subscribe(_ context.Context, _ contract.Principal, _ string, _ contract.Intent, _ map[string]contract.ParamSource) (<-chan contract.StreamEvent, func(), error) { + stop := func() { close(s.events) } + return s.events, stop, nil +} + +func TestStream_ControlSubscribeAndDeliver(t *testing.T) { + reg, wreg := setupRegistry(t) + // add a subscription intent to the fixture + src := ` +schemaVersion: 1 +contributor: { name: feeds, envelope: { supports: [v1], preferred: v1 } } +intents: + - { name: audit.tail, kind: subscription, version: 1, capability: read, mode: append } +` + var feedsM contract.ContractManifest + _ = contract.UnmarshalManifestForTest([]byte(src), &feedsM) + _ = reg.Register(&feedsM) + + source := &stubSource{events: make(chan contract.StreamEvent, 4)} + broker := NewStreamBroker(reg, wreg, source) + + // Open the stream + streamReq := httptest.NewRequest(http.MethodGet, "/api/dashboard/v1/stream", nil) + streamW := httptest.NewRecorder() + go broker.ServeStream(streamW, streamReq) + time.Sleep(20 * time.Millisecond) // allow registration to land + + // Subscribe via control + cmd, _ := json.Marshal(ControlMessage{ + StreamID: broker.SnapshotIDs()[0], // first active stream + Op: "subscribe", Contributor: "feeds", Intent: "audit.tail", SubscriptionID: "s1", + }) + ctlReq := httptest.NewRequest(http.MethodPost, "/api/dashboard/v1/stream/control", bytes.NewReader(cmd)) + ctlW := httptest.NewRecorder() + broker.ServeControl(ctlW, ctlReq) + if ctlW.Code != http.StatusOK { + t.Fatalf("control status = %d body=%s", ctlW.Code, ctlW.Body) + } + + // Push an event + source.events <- contract.StreamEvent{Intent: "audit.tail", Mode: contract.ModeAppend, Payload: json.RawMessage(`{"line":"hi"}`), Seq: 1} + time.Sleep(20 * time.Millisecond) + + // Validate the SSE body contains the event + body := streamW.Body.String() + if !strings.Contains(body, `"intent":"audit.tail"`) || !strings.Contains(body, `"line":"hi"`) { + t.Errorf("stream did not deliver event: %s", body) + } + // And the event header carries the subscription ID + scanner := bufio.NewScanner(strings.NewReader(body)) + hasEventID := false + for scanner.Scan() { + if strings.HasPrefix(scanner.Text(), "event: s1") { + hasEventID = true + break + } + } + if !hasEventID { + t.Error("expected event: s1 line in stream") + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `go test ./extensions/dashboard/contract/transport/...` +Expected: FAIL. + +- [ ] **Step 3: Implement transport/stream.go and transport/control.go** + +```go +// stream.go +package transport + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "sync" + + "github.com/google/uuid" + + "github.com/xraph/forge/extensions/dashboard/auth" + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// SubscriptionSource is the upstream events feeder. Slice (c) implements one +// for each contributor's subscription intents. +type SubscriptionSource interface { + Subscribe(ctx context.Context, p contract.Principal, contributor string, intent contract.Intent, params map[string]contract.ParamSource) (<-chan contract.StreamEvent, func(), error) +} + +// StreamBroker manages active SSE connections + their subscriptions. +type StreamBroker struct { + reg contract.Registry + wreg contract.WardenRegistry + source SubscriptionSource + + mu sync.Mutex + streams map[string]*streamConn +} + +type streamConn struct { + id string + w http.ResponseWriter + flusher http.Flusher + user *auth.UserInfo + subs map[string]subscription // keyed by subscriptionID + mu sync.Mutex +} + +type subscription struct { + cancel func() +} + +// NewStreamBroker returns a broker bound to a registry, warden registry, and source. +func NewStreamBroker(reg contract.Registry, wreg contract.WardenRegistry, source SubscriptionSource) *StreamBroker { + return &StreamBroker{ + reg: reg, + wreg: wreg, + source: source, + streams: map[string]*streamConn{}, + } +} + +// ServeStream implements GET /api/dashboard/v1/stream. +func (b *StreamBroker) ServeStream(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "stream unsupported", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + id := uuid.NewString() + conn := &streamConn{ + id: id, w: w, flusher: flusher, + user: auth.UserFromContext(r.Context()), + subs: map[string]subscription{}, + } + b.mu.Lock() + b.streams[id] = conn + b.mu.Unlock() + defer func() { + b.mu.Lock() + delete(b.streams, id) + b.mu.Unlock() + conn.mu.Lock() + for _, s := range conn.subs { + s.cancel() + } + conn.mu.Unlock() + }() + + // Send a hello event so the client learns its streamID + fmt.Fprintf(w, "event: hello\ndata: {\"streamID\":%q}\n\n", id) + flusher.Flush() + + <-r.Context().Done() +} + +// SnapshotIDs returns currently-active stream IDs (test helper / introspection). +func (b *StreamBroker) SnapshotIDs() []string { + b.mu.Lock() + defer b.mu.Unlock() + out := make([]string, 0, len(b.streams)) + for id := range b.streams { + out = append(out, id) + } + return out +} + +func (b *StreamBroker) writeEvent(conn *streamConn, subID string, ev contract.StreamEvent) error { + payload, err := json.Marshal(ev) + if err != nil { + return err + } + conn.mu.Lock() + defer conn.mu.Unlock() + if _, err := fmt.Fprintf(conn.w, "event: %s\ndata: %s\n\n", subID, payload); err != nil { + return err + } + conn.flusher.Flush() + return nil +} +``` + +```go +// control.go +package transport + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// ControlMessage is one client request on POST /stream/control. +type ControlMessage struct { + StreamID string `json:"streamID"` + Op string `json:"op"` // "subscribe" | "unsubscribe" + SubscriptionID string `json:"subscriptionID"` + Contributor string `json:"contributor,omitempty"` + Intent string `json:"intent,omitempty"` + Params map[string]contract.ParamSource `json:"params,omitempty"` +} + +// ServeControl handles POST /api/dashboard/v1/stream/control. +func (b *StreamBroker) ServeControl(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + var msg ControlMessage + if err := json.NewDecoder(r.Body).Decode(&msg); err != nil { + http.Error(w, "invalid control message", http.StatusBadRequest) + return + } + b.mu.Lock() + conn, ok := b.streams[msg.StreamID] + b.mu.Unlock() + if !ok { + http.Error(w, "unknown streamID", http.StatusNotFound) + return + } + switch msg.Op { + case "subscribe": + in, ok := b.reg.Intent(msg.Contributor, msg.Intent, intentVersionForSubscribe(b.reg, msg)) + if !ok || in.Kind != contract.IntentKindSubscription { + http.Error(w, "intent not a subscription", http.StatusBadRequest) + return + } + p := contract.PrincipalFor(conn.user) + if !in.Requires.Allow(conn.user, nil) { + http.Error(w, "permission denied", http.StatusForbidden) + return + } + ctx, cancel := context.WithCancel(r.Context()) + ch, stop, err := b.source.Subscribe(ctx, p, msg.Contributor, in, msg.Params) + if err != nil { + cancel() + http.Error(w, "subscribe failed: "+err.Error(), http.StatusInternalServerError) + return + } + conn.mu.Lock() + conn.subs[msg.SubscriptionID] = subscription{cancel: func() { stop(); cancel() }} + conn.mu.Unlock() + go func() { + defer cancel() + for ev := range ch { + if !b.allowsEvent(conn.user, in) { + continue + } + if err := b.writeEvent(conn, msg.SubscriptionID, ev); err != nil { + return + } + } + }() + w.WriteHeader(http.StatusOK) + case "unsubscribe": + conn.mu.Lock() + s, ok := conn.subs[msg.SubscriptionID] + if ok { + s.cancel() + delete(conn.subs, msg.SubscriptionID) + } + conn.mu.Unlock() + w.WriteHeader(http.StatusOK) + default: + http.Error(w, "unknown op", http.StatusBadRequest) + } +} + +// allowsEvent re-checks the user's predicate. Real per-event Warden invocation +// with a TTL cache lives in slice (b); slice (a) re-evaluates the YAML predicate +// against the current connection's UserInfo. +func (b *StreamBroker) allowsEvent(user *auth.UserInfo, in contract.Intent) bool { + return in.Requires.Allow(user, nil) +} + +func intentVersionForSubscribe(reg contract.Registry, msg ControlMessage) int { + v, _ := reg.HighestVersion(msg.Contributor, msg.Intent) + return v +} +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./extensions/dashboard/contract/transport/...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/contract/transport/stream.go extensions/dashboard/contract/transport/control.go extensions/dashboard/contract/transport/stream_test.go +git commit -m "feat(dashboard/contract): multiplexed SSE stream with subscribe/unsubscribe control" +``` + +### Task 12.2: Subscription mode dispatch (replace / append / snapshot+delta) + +The transport in Task 12.1 forwards `StreamEvent`s as-is. Mode-specific logic lives at the source side: slice (c) `SubscriptionSource` implementations decide what to emit. Slice (a) only validates that the intent's declared `mode` matches what's emitted (fail-soft: log a warning, deliver anyway). + +**Files:** +- Modify: `extensions/dashboard/contract/transport/stream.go` +- Modify: `extensions/dashboard/contract/transport/stream_test.go` + +- [ ] **Step 1: Add a failing test for mode-mismatch warning** + +```go +func TestStream_ModeMismatch_DeliversAndLogs(t *testing.T) { + // Intent declared mode: append; source emits replace. Event still delivered. + // (Strict enforcement is slice (b)'s job; for now, mismatches are observable.) + // This test verifies delivery happens. + // ... (use the same fixture as TestStream_ControlSubscribeAndDeliver but with mode=replace event) +} +``` + +For brevity, this test mirrors `TestStream_ControlSubscribeAndDeliver` with the event's `Mode` set to `ModeReplace`. Verify that the event reaches the connection. + +- [ ] **Step 2: Run to verify it passes** (no implementation change needed yet — the broker forwards any mode) + +Run: `go test ./extensions/dashboard/contract/transport/...` +Expected: PASS without changes. + +- [ ] **Step 3: Add a stderr warning when modes diverge** + +In `control.go`, inside the per-event goroutine: + +```go +import "log" // add to imports + +// inside `for ev := range ch`: +if ev.Mode != "" && in.Mode != "" && ev.Mode != in.Mode { + log.Printf("contract/stream: %s/%s mode mismatch declared=%s emitted=%s", msg.Contributor, msg.Intent, in.Mode, ev.Mode) +} +``` + +- [ ] **Step 4: Add a test capturing log output** (optional but recommended; pattern: redirect `log.SetOutput(&buf)`). + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/contract/transport/stream.go extensions/dashboard/contract/transport/control.go extensions/dashboard/contract/transport/stream_test.go +git commit -m "feat(dashboard/contract): warn when subscription event mode diverges from declaration" +``` + +--- + +## Phase 13: Wire the contract into the Dashboard extension + +### Task 13.1: Optional Contract field on legacy Manifest + +**Files:** +- Modify: `extensions/dashboard/contributor/manifest.go` +- Create: `extensions/dashboard/contributor/manifest_contract_test.go` + +This lets a contributor publish the new contract manifest alongside the legacy templ manifest while migration runs. + +- [ ] **Step 1: Add the failing test** + +```go +// manifest_contract_test.go +package contributor + +import ( + "testing" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +func TestManifest_HasContractField(t *testing.T) { + m := &Manifest{Name: "x"} + m.Contract = &contract.ContractManifest{SchemaVersion: 1} + if m.Contract.SchemaVersion != 1 { + t.Errorf("contract field round trip lost data") + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `go test ./extensions/dashboard/contributor/...` +Expected: FAIL — `Contract` field undefined. + +- [ ] **Step 3: Add the field to `manifest.go`** + +```go +import "github.com/xraph/forge/extensions/dashboard/contract" // add to imports + +// inside type Manifest struct, after AuthPages: +Contract *contract.ContractManifest `json:"contract,omitempty"` +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./extensions/dashboard/contributor/...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/contributor/manifest.go extensions/dashboard/contributor/manifest_contract_test.go +git commit -m "feat(dashboard): allow legacy Manifest to carry a contract manifest" +``` + +### Task 13.2: Register contract endpoints alongside legacy routes + +**Files:** +- Modify: `extensions/dashboard/extension.go` +- Modify: `extensions/dashboard/extension.go` (struct: add fields) + +The extension grows three new fields (`contractRegistry`, `wardenRegistry`, `streamBroker`) and registers three new endpoints. Legacy endpoints continue working unchanged. + +- [ ] **Step 1: Read the existing struct + registerRoutes to confirm field placement** + +Read `extensions/dashboard/extension.go` at lines 100–200 (struct definition) and 1163–1280 (`registerRoutes`). + +- [ ] **Step 2: Add fields to the Extension struct** (no test for plumbing — covered by E2E in Phase 15) + +```go +import ( + "github.com/xraph/forge/extensions/dashboard/contract" + "github.com/xraph/forge/extensions/dashboard/contract/transport" +) + +// add to Extension struct (find the existing field block): +contractRegistry contract.Registry +wardenRegistry contract.WardenRegistry +streamBroker *transport.StreamBroker +auditEmitter contract.AuditEmitter +``` + +- [ ] **Step 3: Initialize the new fields where the Extension is constructed** + +Locate the Extension constructor (search for `&Extension{` in `extension.go`). Add: + +```go +contractRegistry: contract.NewRegistry(), +wardenRegistry: contract.NewWardenRegistry(), +auditEmitter: contract.NewLogAuditEmitter(os.Stdout), // slice (b) replaces with chronicle +``` + +`streamBroker` is created in `registerRoutes` once the source is ready (slice (c)); initialize to nil here and skip stream registration when nil. + +- [ ] **Step 4: Add route registrations to `registerRoutes`** + +After the existing `/api/extensions` line (around line 1217), add: + +```go +// Contract endpoints (slice a) +if e.contractRegistry != nil { + must(router.POST(base+"/api/dashboard/v1", e.handleContractPOST())) + must(router.GET(base+"/api/dashboard/v1/capabilities", e.handleContractCapabilities())) + if e.streamBroker != nil { + must(router.EventStream(base+"/api/dashboard/v1/stream", e.streamBroker.ServeStream)) + must(router.POST(base+"/api/dashboard/v1/stream/control", e.streamBroker.ServeControl)) + } +} +``` + +Add helper methods on Extension: + +```go +func (e *Extension) handleContractPOST() http.HandlerFunc { + h := transport.NewHandler(e.contractRegistry, e.wardenRegistry, /* dispatcher */ nil, e.auditEmitter) + return h.ServeHTTP +} + +func (e *Extension) handleContractCapabilities() http.HandlerFunc { + return transport.NewCapabilitiesHandler(e.contractRegistry, []string{"v1"}).ServeHTTP +} +``` + +> **Note:** `dispatcher` is `nil` here because actual dispatch is slice (c)'s job. To allow the handler to function in tests during slice (a), wire a default `nilDispatcher` that returns `*contract.Error{Code: contract.CodeUnavailable, Message: "dispatcher not configured"}` until slice (c) lands. + +Add `nilDispatcher` to `transport/http.go`: + +```go +// NilDispatcher always returns UNAVAILABLE; useful as a safe default before +// real dispatchers are wired (slice c). +type NilDispatcher struct{} + +func (NilDispatcher) Dispatch(_ context.Context, _ contract.Request, _ contract.Principal) (json.RawMessage, contract.ResponseMeta, error) { + return nil, contract.ResponseMeta{}, &contract.Error{Code: contract.CodeUnavailable, Message: "no dispatcher configured"} +} +``` + +Use `transport.NilDispatcher{}` in `handleContractPOST` instead of `nil`. + +- [ ] **Step 5: Build the whole module** + +Run: `go build ./...` +Expected: clean build. + +- [ ] **Step 6: Run all tests** + +Run: `go test ./extensions/dashboard/...` +Expected: PASS — including legacy tests that haven't been touched. + +- [ ] **Step 7: Commit** + +```bash +git add extensions/dashboard/extension.go extensions/dashboard/contract/transport/http.go +git commit -m "feat(dashboard): register contract endpoints alongside legacy routes" +``` + +### Task 13.3: Hook the contract registry into existing manifest registration + +When a legacy contributor publishes a `*contract.ContractManifest` via the new field, the extension must register it with the contract registry on startup. + +**Files:** +- Modify: `extensions/dashboard/extension.go` + +- [ ] **Step 1: Locate where contributors are added (search for `AddContributor` or `RegisterContributor`)** + +- [ ] **Step 2: After legacy registration succeeds, register the contract manifest if present** + +```go +if mn := contributor.Manifest(); mn != nil && mn.Contract != nil { + if err := loader.Validate(mn.Contract, e.wardenRegistry); err != nil { + return fmt.Errorf("contract validation: %w", err) + } + if err := e.contractRegistry.Register(mn.Contract); err != nil { + return fmt.Errorf("contract register: %w", err) + } +} +``` + +Import `extensions/dashboard/contract/loader`. + +- [ ] **Step 3: Build + run the full extension test suite** + +Run: `go test ./extensions/dashboard/...` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add extensions/dashboard/extension.go +git commit -m "feat(dashboard): register contract manifests at contributor add-time" +``` + +--- + +## Phase 14: Probe CLI + +### Task 14.1: cmd/dashboard-contract-probe + +**Files:** +- Create: `cmd/dashboard-contract-probe/main.go` + +This CLI lets us test the contract endpoints without a React shell — important for slice (c) migration of the first contributor. + +- [ ] **Step 1: Write the CLI** + +```go +// main.go +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +func main() { + base := flag.String("base", "http://localhost:8080", "dashboard base URL (no trailing slash)") + kind := flag.String("kind", "query", "graph | query | command") + contributor := flag.String("contributor", "", "contributor name") + intent := flag.String("intent", "", "intent name") + payload := flag.String("payload", "{}", "JSON payload") + csrf := flag.String("csrf", "", "CSRF token (required for command)") + idem := flag.String("idem", "", "idempotency key (required for command)") + flag.Parse() + + req := contract.Request{ + Envelope: "v1", Kind: contract.Kind(*kind), + Contributor: *contributor, Intent: *intent, + Payload: json.RawMessage(*payload), + CSRF: *csrf, IdempotencyKey: *idem, + } + body, _ := json.Marshal(req) + resp, err := http.Post(*base+"/api/dashboard/v1", "application/json", bytes.NewReader(body)) + if err != nil { + fmt.Fprintln(os.Stderr, "request:", err) + os.Exit(1) + } + defer resp.Body.Close() + out, _ := io.ReadAll(resp.Body) + fmt.Printf("HTTP %d\n%s\n", resp.StatusCode, out) +} +``` + +- [ ] **Step 2: Build the CLI** + +Run: `go build -o /tmp/dashboard-contract-probe ./cmd/dashboard-contract-probe` +Expected: clean build. + +- [ ] **Step 3: Smoke-test against a running dashboard** (manual, no automation) + +```bash +/tmp/dashboard-contract-probe -base=http://localhost:8080 -kind=query -contributor=users -intent=users.list +``` + +Expected: HTTP 503 / UNAVAILABLE (since `NilDispatcher` is wired) — confirms the route is registered and decoding works. + +- [ ] **Step 4: Commit** + +```bash +git add cmd/dashboard-contract-probe/main.go +git commit -m "feat(cmd): add dashboard-contract-probe CLI for raw envelope testing" +``` + +--- + +## Phase 15: End-to-End Harness + +### Task 15.1: Fixture YAML + driver test + +**Files:** +- Create: `extensions/dashboard/contract/testdata/fixture_users.yaml` +- Create: `extensions/dashboard/contract/testdata/fixture_auth_extends.yaml` +- Create: `extensions/dashboard/contract/e2e_test.go` + +- [ ] **Step 1: Write the fixtures** + +`fixture_users.yaml`: + +```yaml +schemaVersion: 1 +contributor: + name: users + envelope: { supports: [v1], preferred: v1 } + capabilities: [users.read, users.write] + +queries: + userList: + intent: users.list + cache: { staleTime: 30s } + +intents: + - name: users.list + kind: query + version: 1 + capability: read + requires: { all: ["scope:users.read"] } + + - name: user.disable + kind: command + version: 1 + capability: write + requires: { all: ["role:admin", "scope:users.write"] } + invalidates: [users.list] + + - name: audit.tail + kind: subscription + version: 1 + capability: read + mode: append + requires: { all: ["role:admin"] } + +graph: + - route: /users + intent: page.shell + title: Users + nav: { group: Identity, icon: users, priority: 10 } + slots: + main: + - intent: resource.list + data: queries.userList + slots: + rowActions: + - intent: action.button + op: user.disable + visibleWhen: { all: ["role:admin"] } + detailDrawer: + - intent: form.edit + slots: + fields: + - intent: form.field +``` + +`fixture_auth_extends.yaml`: + +```yaml +schemaVersion: 1 +contributor: + name: auth + envelope: { supports: [v1], preferred: v1 } +intents: [] +extends: + - target: { contributor: users, intent: page.shell, route: /users } + slot: main.detailDrawer.fields + add: + - intent: form.field + requires: { all: ["scope:auth.read"] } +``` + +- [ ] **Step 2: Write the E2E driver** + +```go +// e2e_test.go +package contract + +import ( + "context" + "os" + "testing" + + "github.com/xraph/forge/extensions/dashboard/auth" + "github.com/xraph/forge/extensions/dashboard/contract/loader" +) + +func loadFixture(t *testing.T, path string) *ContractManifest { + t.Helper() + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + m, err := loader.Load(f, path) + if err != nil { + t.Fatal(err) + } + return m +} + +func TestE2E_RegisterValidateBuild(t *testing.T) { + users := loadFixture(t, "testdata/fixture_users.yaml") + authExt := loadFixture(t, "testdata/fixture_auth_extends.yaml") + + wreg := NewWardenRegistry() + if err := loader.Validate(users, wreg); err != nil { + t.Fatalf("validate users: %v", err) + } + if err := loader.Validate(authExt, wreg); err != nil { + t.Fatalf("validate authExt: %v", err) + } + + reg := NewRegistry() + if err := reg.Register(users); err != nil { + t.Fatalf("register users: %v", err) + } + if err := reg.Register(authExt); err != nil { + t.Fatalf("register authExt: %v", err) + } + + build := NewGraphBuilder(reg, wreg) + admin := &auth.UserInfo{Subject: "alice", Roles: []string{"admin"}, Scopes: []string{"users.read", "users.write"}} + got, err := build.Build(context.Background(), "users", "/users", PrincipalFor(admin)) + if err != nil { + t.Fatalf("build admin: %v", err) + } + // admin should see the disable action + actions := got.Slots["main"][0].Slots["rowActions"] + if len(actions) != 1 || actions[0].Op != "user.disable" { + t.Errorf("admin actions wrong: %+v", actions) + } + // extension should be merged: detailDrawer.fields has 2 form.fields + fields := got.Slots["main"][0].Slots["detailDrawer"][0].Slots["fields"] + if len(fields) != 2 { + t.Errorf("expected 2 fields after extension merge, got %d", len(fields)) + } + + // viewer sees no row actions + viewer := &auth.UserInfo{Subject: "bob", Roles: []string{"viewer"}, Scopes: []string{"users.read"}} + got2, _ := build.Build(context.Background(), "users", "/users", PrincipalFor(viewer)) + if got2 == nil { + t.Skip("viewer filtered fully") // depends on resource.list visibleWhen + return + } + actions2 := got2.Slots["main"][0].Slots["rowActions"] + if len(actions2) != 0 { + t.Errorf("viewer should see no admin actions: %+v", actions2) + } +} +``` + +- [ ] **Step 3: Run the E2E test** + +Run: `go test ./extensions/dashboard/contract/...` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add extensions/dashboard/contract/testdata/ extensions/dashboard/contract/e2e_test.go +git commit -m "test(dashboard/contract): end-to-end fixture + driver covering register/validate/build/extend" +``` + +--- + +## Final Verification + +- [ ] **Run the full test suite for the dashboard extension** + +```bash +go test ./extensions/dashboard/... +``` +Expected: PASS, including legacy tests untouched. + +- [ ] **Vet the new package** + +```bash +go vet ./extensions/dashboard/contract/... +``` +Expected: clean. + +- [ ] **Build the probe CLI** + +```bash +go build -o /tmp/dashboard-contract-probe ./cmd/dashboard-contract-probe +``` +Expected: clean build. + +- [ ] **Smoke test the live endpoint** (manual) + +Start the dashboard locally, then run the probe against `users.list`. Expect HTTP 503 UNAVAILABLE (NilDispatcher) and confirm via dashboard logs that the request reached the contract handler. + +- [ ] **Final commit if anything's left** + +```bash +git status +git diff +# if there are stragglers: +git add +git commit -m "chore(dashboard/contract): final touches" +``` + +## Self-Review Notes + +- **Spec coverage:** Every row in DESIGN.md's "Design Decisions Locked In" table is covered by a phase or task in this plan. Slot extensibility flag is enforced in Task 6.3; per-event authz re-check is implemented (cached version is slice (b)); audit-on-by-default is Task 9.1+10.1. +- **Out-of-scope items honored:** No React, no chronicle integration (audit emitter is log-based), no CSRF middleware (header presence enforced; rotation is slice (b)), no templ removal. +- **Dispatcher placeholder:** `NilDispatcher` is intentional. Task 13.2 wires it as the default; slice (c) replaces it with a real dispatcher backed by contributor handler registration. The probe CLI in Task 14.1 documents the expected 503 in this state. +- **Naming consistency:** `IntentKindQuery` vs `KindQuery` — the former is the manifest-level discriminator (on Intent), the latter is the wire envelope's. They are intentionally different types; conversion is enforced in `kindMatchesCapability`. Tests cover both. +- **No placeholders:** Every step has actual code or actual commands. No "implement X later" anywhere in the body of a task. From cda94a17c8e1486216af9a63fd5570968a16a015 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 16:04:21 -0500 Subject: [PATCH 02/89] feat(dashboard/contract): add package skeleton and canonical error codes --- extensions/dashboard/contract/doc.go | 7 +++ extensions/dashboard/contract/errors.go | 61 ++++++++++++++++++++ extensions/dashboard/contract/errors_test.go | 36 ++++++++++++ 3 files changed, 104 insertions(+) create mode 100644 extensions/dashboard/contract/doc.go create mode 100644 extensions/dashboard/contract/errors.go create mode 100644 extensions/dashboard/contract/errors_test.go diff --git a/extensions/dashboard/contract/doc.go b/extensions/dashboard/contract/doc.go new file mode 100644 index 00000000..e7f4f262 --- /dev/null +++ b/extensions/dashboard/contract/doc.go @@ -0,0 +1,7 @@ +// Package contract defines the declarative, single-endpoint contract for the +// admin dashboard: contributor manifests, request/response envelopes, the +// permission model, the slot/graph composition rules, and the per-contributor +// version negotiation protocol. +// +// See DESIGN.md in this directory for the spec this implements. +package contract diff --git a/extensions/dashboard/contract/errors.go b/extensions/dashboard/contract/errors.go new file mode 100644 index 00000000..3139d9f3 --- /dev/null +++ b/extensions/dashboard/contract/errors.go @@ -0,0 +1,61 @@ +package contract + +import "fmt" + +// ErrorCode is a canonical, wire-stable code for contract errors. +// Contributor-specific codes are namespaced like "auth.SESSION_EXPIRED". +type ErrorCode string + +const ( + CodeBadRequest ErrorCode = "BAD_REQUEST" + CodeUnauthenticated ErrorCode = "UNAUTHENTICATED" + CodePermissionDenied ErrorCode = "PERMISSION_DENIED" + CodeNotFound ErrorCode = "NOT_FOUND" + CodeConflict ErrorCode = "CONFLICT" + CodeRateLimited ErrorCode = "RATE_LIMITED" + CodeUnsupportedVersion ErrorCode = "UNSUPPORTED_VERSION" + CodeUnavailable ErrorCode = "UNAVAILABLE" + CodeInternal ErrorCode = "INTERNAL" +) + +// Sentinel errors for use with errors.Is. +var ( + ErrBadRequest = &Error{Code: CodeBadRequest} + ErrUnauthenticated = &Error{Code: CodeUnauthenticated} + ErrPermissionDenied = &Error{Code: CodePermissionDenied} + ErrNotFound = &Error{Code: CodeNotFound} + ErrConflict = &Error{Code: CodeConflict} + ErrRateLimited = &Error{Code: CodeRateLimited} + ErrUnsupportedVersion = &Error{Code: CodeUnsupportedVersion} + ErrUnavailable = &Error{Code: CodeUnavailable} + ErrInternal = &Error{Code: CodeInternal} +) + +// Error is the canonical contract error type. It serializes to the wire +// "error" object documented in DESIGN.md. +type Error struct { + Code ErrorCode `json:"code"` + Message string `json:"message,omitempty"` + Details map[string]any `json:"details,omitempty"` + Retryable bool `json:"retryable,omitempty"` + CorrelationID string `json:"correlationID,omitempty"` + Redactions []string `json:"redactions,omitempty"` +} + +func (e *Error) Error() string { + if e == nil { + return "" + } + if e.Message == "" { + return string(e.Code) + } + return fmt.Sprintf("%s: %s", e.Code, e.Message) +} + +// Is matches sentinel errors by Code. +func (e *Error) Is(target error) bool { + if t, ok := target.(*Error); ok { + return t.Code == e.Code + } + return false +} diff --git a/extensions/dashboard/contract/errors_test.go b/extensions/dashboard/contract/errors_test.go new file mode 100644 index 00000000..07d3b582 --- /dev/null +++ b/extensions/dashboard/contract/errors_test.go @@ -0,0 +1,36 @@ +package contract + +import ( + "errors" + "testing" +) + +func TestError_CodeAndMessage(t *testing.T) { + e := &Error{Code: CodePermissionDenied, Message: "no", CorrelationID: "c1"} + if e.Code != "PERMISSION_DENIED" { + t.Errorf("Code = %q, want PERMISSION_DENIED", e.Code) + } + if got := e.Error(); got != "PERMISSION_DENIED: no" { + t.Errorf("Error() = %q", got) + } +} + +func TestError_Is(t *testing.T) { + e := &Error{Code: CodeNotFound} + if !errors.Is(e, ErrNotFound) { + t.Error("errors.Is should match canonical sentinel") + } +} + +func TestCanonicalCodes_AllPresent(t *testing.T) { + want := []ErrorCode{ + CodeBadRequest, CodeUnauthenticated, CodePermissionDenied, + CodeNotFound, CodeConflict, CodeRateLimited, + CodeUnsupportedVersion, CodeUnavailable, CodeInternal, + } + for _, c := range want { + if c == "" { + t.Errorf("canonical code missing") + } + } +} From d68c5ab1d44ff63914b452ab4fde33a249c049bc Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 16:07:20 -0500 Subject: [PATCH 03/89] test(dashboard/contract): make canonical-code presence test meaningful MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous TestCanonicalCodes_AllPresent only verified that constants are non-empty string literals — it could not fail short of someone deleting a constant from the list itself. The replacement asserts the expected count, uniqueness of wire values, and that every Err* sentinel has a non-empty Code. --- extensions/dashboard/contract/errors_test.go | 24 +++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/extensions/dashboard/contract/errors_test.go b/extensions/dashboard/contract/errors_test.go index 07d3b582..66b68f66 100644 --- a/extensions/dashboard/contract/errors_test.go +++ b/extensions/dashboard/contract/errors_test.go @@ -23,14 +23,32 @@ func TestError_Is(t *testing.T) { } func TestCanonicalCodes_AllPresent(t *testing.T) { - want := []ErrorCode{ + codes := []ErrorCode{ CodeBadRequest, CodeUnauthenticated, CodePermissionDenied, CodeNotFound, CodeConflict, CodeRateLimited, CodeUnsupportedVersion, CodeUnavailable, CodeInternal, } - for _, c := range want { + if len(codes) != 9 { + t.Errorf("expected 9 canonical codes, got %d", len(codes)) + } + seen := map[ErrorCode]bool{} + for _, c := range codes { if c == "" { - t.Errorf("canonical code missing") + t.Errorf("canonical code is empty") + } + if seen[c] { + t.Errorf("canonical code %q duplicated", c) + } + seen[c] = true + } + sentinels := []*Error{ + ErrBadRequest, ErrUnauthenticated, ErrPermissionDenied, + ErrNotFound, ErrConflict, ErrRateLimited, + ErrUnsupportedVersion, ErrUnavailable, ErrInternal, + } + for _, e := range sentinels { + if e.Code == "" { + t.Errorf("sentinel has empty code") } } } From 16c3aae0e74572552de6f88b5a6372cf78a6740c Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 16:09:22 -0500 Subject: [PATCH 04/89] feat(dashboard/contract): add wire envelope types for request, response, stream --- extensions/dashboard/contract/envelope.go | 87 +++++++++++++++++++ .../dashboard/contract/envelope_test.go | 41 +++++++++ 2 files changed, 128 insertions(+) create mode 100644 extensions/dashboard/contract/envelope.go create mode 100644 extensions/dashboard/contract/envelope_test.go diff --git a/extensions/dashboard/contract/envelope.go b/extensions/dashboard/contract/envelope.go new file mode 100644 index 00000000..379707c5 --- /dev/null +++ b/extensions/dashboard/contract/envelope.go @@ -0,0 +1,87 @@ +// envelope.go +package contract + +import "encoding/json" + +// Kind discriminates request/response semantics on the wire. +// A kind is enforced against the intent's declared Capability at dispatch time. +type Kind string + +const ( + KindGraph Kind = "graph" + KindQuery Kind = "query" + KindCommand Kind = "command" + KindSubscribe Kind = "subscribe" +) + +// Request is the wire envelope for POST /api/dashboard/{envelope}. +type Request struct { + Envelope string `json:"envelope"` + Kind Kind `json:"kind"` + Contributor string `json:"contributor"` + Intent string `json:"intent"` + IntentVersion int `json:"intentVersion,omitempty"` + Payload json.RawMessage `json:"payload,omitempty"` + Params map[string]any `json:"params,omitempty"` + Context RequestContext `json:"context"` + CSRF string `json:"csrf,omitempty"` + IdempotencyKey string `json:"idempotencyKey,omitempty"` +} + +// RequestContext carries route + correlation metadata. Always populated by the shell. +type RequestContext struct { + Route string `json:"route,omitempty"` + CorrelationID string `json:"correlationID,omitempty"` +} + +// Response is the wire envelope for successful POST responses. +type Response struct { + OK bool `json:"ok"` + Envelope string `json:"envelope"` + Kind Kind `json:"kind"` + Data json.RawMessage `json:"data,omitempty"` + Meta ResponseMeta `json:"meta"` +} + +// ResponseMeta carries cross-cutting metadata (versioning, caching, invalidation). +type ResponseMeta struct { + IntentVersion int `json:"intentVersion,omitempty"` + Deprecation *Deprecation `json:"deprecation,omitempty"` + CacheControl *CacheHint `json:"cacheControl,omitempty"` + Invalidates []string `json:"invalidates,omitempty"` +} + +// Deprecation surfaces a "this version will be removed" hint to the shell. +type Deprecation struct { + IntentVersion int `json:"intentVersion"` + RemoveAfter string `json:"removeAfter"` +} + +// CacheHint communicates how long the shell can serve stale data for a query. +type CacheHint struct { + StaleTime string `json:"staleTime,omitempty"` +} + +// ErrorResponse is the wire envelope for failed POST responses. +type ErrorResponse struct { + OK bool `json:"ok"` + Envelope string `json:"envelope"` + Error *Error `json:"error"` +} + +// StreamEvent is the SSE payload for a single subscription event. +type StreamEvent struct { + Intent string `json:"intent"` + Mode SubscriptionMode `json:"mode"` + Payload json.RawMessage `json:"payload"` + Seq uint64 `json:"seq"` +} + +// SubscriptionMode is how the client integrates events into local state. +type SubscriptionMode string + +const ( + ModeReplace SubscriptionMode = "replace" + ModeAppend SubscriptionMode = "append" + ModeSnapshotDelta SubscriptionMode = "snapshot+delta" +) diff --git a/extensions/dashboard/contract/envelope_test.go b/extensions/dashboard/contract/envelope_test.go new file mode 100644 index 00000000..5171bd47 --- /dev/null +++ b/extensions/dashboard/contract/envelope_test.go @@ -0,0 +1,41 @@ +// envelope_test.go +package contract + +import ( + "encoding/json" + "testing" +) + +func TestRequest_RoundTrip_Command(t *testing.T) { + req := Request{ + Envelope: "v1", + Kind: KindCommand, + Contributor: "users", + Intent: "user.disable", + IntentVersion: 2, + Payload: json.RawMessage(`{"id":"u_42"}`), + Params: map[string]any{"tenant": "acme"}, + Context: RequestContext{Route: "/admin/users", CorrelationID: "req_x"}, + CSRF: "csrf_token", + IdempotencyKey: "ik_1", + } + b, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got Request + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.Kind != KindCommand || got.IdempotencyKey != "ik_1" { + t.Errorf("round trip lost data: %+v", got) + } +} + +func TestKind_Constants(t *testing.T) { + for _, k := range []Kind{KindGraph, KindQuery, KindCommand, KindSubscribe} { + if k == "" { + t.Errorf("kind constant empty") + } + } +} From 97d4de105e2f6bdd6a3bcbe16af791a0791cd8f2 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 16:09:39 -0500 Subject: [PATCH 05/89] test(dashboard/contract): cover error response and stream event round-trip --- .../dashboard/contract/envelope_test.go | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/extensions/dashboard/contract/envelope_test.go b/extensions/dashboard/contract/envelope_test.go index 5171bd47..a295c768 100644 --- a/extensions/dashboard/contract/envelope_test.go +++ b/extensions/dashboard/contract/envelope_test.go @@ -2,6 +2,7 @@ package contract import ( + "bytes" "encoding/json" "testing" ) @@ -39,3 +40,44 @@ func TestKind_Constants(t *testing.T) { } } } + +func TestErrorResponse_RoundTrip(t *testing.T) { + er := ErrorResponse{ + OK: false, + Envelope: "v1", + Error: &Error{ + Code: CodePermissionDenied, + Message: "denied", + CorrelationID: "c1", + Redactions: []string{"users[*].email"}, + }, + } + b, err := json.Marshal(er) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !bytes.Contains(b, []byte(`"code":"PERMISSION_DENIED"`)) { + t.Errorf("marshaled form missing code: %s", b) + } + var got ErrorResponse + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.Error.Code != CodePermissionDenied { + t.Errorf("round trip lost code") + } +} + +func TestStreamEvent_RoundTrip_AllModes(t *testing.T) { + for _, mode := range []SubscriptionMode{ModeReplace, ModeAppend, ModeSnapshotDelta} { + ev := StreamEvent{Intent: "audit.tail", Mode: mode, Payload: json.RawMessage(`{"a":1}`), Seq: 42} + b, _ := json.Marshal(ev) + var got StreamEvent + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("mode %s: %v", mode, err) + } + if got.Mode != mode || got.Seq != 42 { + t.Errorf("mode %s round trip lost data: %+v", mode, got) + } + } +} From 78ab341549785f72b274755ee7d47c515bf78987 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 16:12:23 -0500 Subject: [PATCH 06/89] feat(dashboard/contract): add manifest types with YAML tags --- extensions/dashboard/contract/manifest.go | 149 ++++++++++++++++++ .../dashboard/contract/manifest_test.go | 73 +++++++++ 2 files changed, 222 insertions(+) create mode 100644 extensions/dashboard/contract/manifest.go create mode 100644 extensions/dashboard/contract/manifest_test.go diff --git a/extensions/dashboard/contract/manifest.go b/extensions/dashboard/contract/manifest.go new file mode 100644 index 00000000..faade213 --- /dev/null +++ b/extensions/dashboard/contract/manifest.go @@ -0,0 +1,149 @@ +// manifest.go +package contract + +// IntentKind is the wire-level discriminator declared on every intent. +// It must be consistent with the request envelope Kind at dispatch time. +type IntentKind string + +const ( + IntentKindGraph IntentKind = "graph" + IntentKindQuery IntentKind = "query" + IntentKindCommand IntentKind = "command" + IntentKindSubscription IntentKind = "subscription" +) + +// Capability is the data-classification of an intent's effects. +// It composes with IntentKind: a command must be capability=write; a query/subscription +// must be capability=read; a graph must be capability=render. +type Capability string + +const ( + CapRead Capability = "read" + CapWrite Capability = "write" + CapRender Capability = "render" +) + +// ContractManifest is the top-level YAML each contributor publishes. +type ContractManifest struct { + SchemaVersion int `yaml:"schemaVersion" json:"schemaVersion"` + Contributor Contributor `yaml:"contributor" json:"contributor"` + Queries map[string]Query `yaml:"queries,omitempty" json:"queries,omitempty"` + Intents []Intent `yaml:"intents" json:"intents"` + Graph []GraphNode `yaml:"graph,omitempty" json:"graph,omitempty"` + Extends []Extension `yaml:"extends,omitempty" json:"extends,omitempty"` +} + +// Contributor names a single contributor and declares its supported envelope versions. +type Contributor struct { + Name string `yaml:"name" json:"name"` + Envelope EnvelopeSupport `yaml:"envelope" json:"envelope"` + Capabilities []string `yaml:"capabilities,omitempty" json:"capabilities,omitempty"` +} + +// EnvelopeSupport declares which envelope versions this contributor can speak. +type EnvelopeSupport struct { + Supports []string `yaml:"supports" json:"supports"` + Preferred string `yaml:"preferred" json:"preferred"` +} + +// Intent declares a single named operation and its security/version metadata. +type Intent struct { + Name string `yaml:"name" json:"name"` + Kind IntentKind `yaml:"kind" json:"kind"` + Version int `yaml:"version" json:"version"` + Capability Capability `yaml:"capability" json:"capability"` + Requires Predicate `yaml:"requires,omitempty" json:"requires,omitempty"` + Schema IntentSchema `yaml:"schema,omitempty" json:"schema,omitempty"` + Mode SubscriptionMode `yaml:"mode,omitempty" json:"mode,omitempty"` // subscription only + Invalidates []string `yaml:"invalidates,omitempty" json:"invalidates,omitempty"` // command only + Audit *bool `yaml:"audit,omitempty" json:"audit,omitempty"` // default true for commands + Deprecated *Deprecation `yaml:"deprecated,omitempty" json:"deprecated,omitempty"` +} + +// IntentSchema is loose by design: contributors describe their input/output shapes; +// validation against this is opt-in (slice (b) wires it). +type IntentSchema struct { + Input map[string]any `yaml:"input,omitempty" json:"input,omitempty"` + Output any `yaml:"output,omitempty" json:"output,omitempty"` +} + +// Query is a named, reusable, cacheable data binding referenced by graph nodes. +type Query struct { + Intent string `yaml:"intent" json:"intent"` + Params map[string]ParamSource `yaml:"params,omitempty" json:"params,omitempty"` + Cache *QueryCache `yaml:"cache,omitempty" json:"cache,omitempty"` +} + +// ParamSource describes where a parameter value comes from. +// Exactly one of Value/From is set; YAML uses { from: route.tenant } or a literal. +type ParamSource struct { + Value any `yaml:"value,omitempty" json:"value,omitempty"` + From string `yaml:"from,omitempty" json:"from,omitempty"` // route.X | parent.X | state.X | session.X +} + +// QueryCache declares per-query staleness for the client. +type QueryCache struct { + StaleTime string `yaml:"staleTime,omitempty" json:"staleTime,omitempty"` +} + +// GraphNode is a single node in the UI graph (an intent invocation with slot fills). +type GraphNode struct { + Route string `yaml:"route,omitempty" json:"route,omitempty"` // top-level only + Intent string `yaml:"intent" json:"intent"` + Title string `yaml:"title,omitempty" json:"title,omitempty"` + Nav *NavConfig `yaml:"nav,omitempty" json:"nav,omitempty"` + Root bool `yaml:"root,omitempty" json:"root,omitempty"` + Data *DataBinding `yaml:"data,omitempty" json:"data,omitempty"` + Props map[string]any `yaml:"props,omitempty" json:"props,omitempty"` + Slots map[string][]GraphNode `yaml:"slots,omitempty" json:"slots,omitempty"` + VisibleWhen *Predicate `yaml:"visibleWhen,omitempty" json:"visibleWhen,omitempty"` + EnabledWhen *Predicate `yaml:"enabledWhen,omitempty" json:"enabledWhen,omitempty"` + Op string `yaml:"op,omitempty" json:"op,omitempty"` // for action nodes + Payload map[string]ParamSource `yaml:"payload,omitempty" json:"payload,omitempty"` + Component string `yaml:"component,omitempty" json:"component,omitempty"` // intent: custom escape hatch + Src string `yaml:"src,omitempty" json:"src,omitempty"` // intent: iframe escape hatch + Sandbox []string `yaml:"sandbox,omitempty" json:"sandbox,omitempty"` + Protocol string `yaml:"protocol,omitempty" json:"protocol,omitempty"` +} + +// NavConfig is per-route nav metadata; mirrors today's contributor.NavItem fields. +type NavConfig struct { + Group string `yaml:"group,omitempty" json:"group,omitempty"` + Icon string `yaml:"icon,omitempty" json:"icon,omitempty"` + Priority int `yaml:"priority,omitempty" json:"priority,omitempty"` + Badge string `yaml:"badge,omitempty" json:"badge,omitempty"` +} + +// DataBinding is either an inline {intent, params} pair or a named query reference. +// YAML supports both shapes: +// +// data: queries.userList +// data: { intent: users.list, params: {...} } +type DataBinding struct { + QueryRef string `yaml:"-" json:"queryRef,omitempty"` + Intent string `yaml:"intent,omitempty" json:"intent,omitempty"` + Params map[string]ParamSource `yaml:"params,omitempty" json:"params,omitempty"` +} + +// Predicate is the boolean access expression: any of all/any/not, plus an optional +// named Warden delegate. An empty Predicate evaluates to allow. +type Predicate struct { + All []string `yaml:"all,omitempty" json:"all,omitempty"` + Any []string `yaml:"any,omitempty" json:"any,omitempty"` + Not []string `yaml:"not,omitempty" json:"not,omitempty"` + Warden string `yaml:"warden,omitempty" json:"warden,omitempty"` +} + +// Extension declares that this contributor wants to add nodes into another contributor's slot. +type Extension struct { + Target ExtensionTarget `yaml:"target" json:"target"` + Slot string `yaml:"slot" json:"slot"` // dotted path: "detailDrawer.fields" + Add []GraphNode `yaml:"add" json:"add"` +} + +// ExtensionTarget identifies the host node to extend. +type ExtensionTarget struct { + Contributor string `yaml:"contributor" json:"contributor"` + Intent string `yaml:"intent" json:"intent"` + Route string `yaml:"route,omitempty" json:"route,omitempty"` +} diff --git a/extensions/dashboard/contract/manifest_test.go b/extensions/dashboard/contract/manifest_test.go new file mode 100644 index 00000000..8857dd6e --- /dev/null +++ b/extensions/dashboard/contract/manifest_test.go @@ -0,0 +1,73 @@ +// manifest_test.go +package contract + +import ( + "testing" + + "gopkg.in/yaml.v3" +) + +const sampleManifestYAML = ` +schemaVersion: 1 +contributor: + name: users + envelope: + supports: [v1] + preferred: v1 + capabilities: [users.read, users.write] + +intents: + - name: users.list + kind: query + version: 1 + capability: read + requires: + all: ["scope:users.read"] + audit: false + + - name: user.disable + kind: command + version: 2 + capability: write + requires: + all: ["role:admin", "scope:users.write"] + warden: tenantOwner + invalidates: [users.list, user.detail] + +graph: + - route: /users + intent: page.shell + title: Users + nav: + group: Identity + icon: users + priority: 10 +` + +func TestManifest_YAML_RoundTrip(t *testing.T) { + var m ContractManifest + if err := yaml.Unmarshal([]byte(sampleManifestYAML), &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if m.SchemaVersion != 1 { + t.Errorf("SchemaVersion = %d", m.SchemaVersion) + } + if m.Contributor.Name != "users" { + t.Errorf("contributor name = %q", m.Contributor.Name) + } + if got := len(m.Intents); got != 2 { + t.Fatalf("intents count = %d", got) + } + if m.Intents[0].Kind != IntentKindQuery || m.Intents[1].Kind != IntentKindCommand { + t.Errorf("intent kinds = %v, %v", m.Intents[0].Kind, m.Intents[1].Kind) + } + if m.Intents[1].Requires.Warden != "tenantOwner" { + t.Errorf("warden ref = %q", m.Intents[1].Requires.Warden) + } + if got := len(m.Graph); got != 1 { + t.Fatalf("graph count = %d", got) + } + if m.Graph[0].Route != "/users" { + t.Errorf("route = %q", m.Graph[0].Route) + } +} From 64de364dae02e236c59778bccb5b64889f02cd38 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 16:12:55 -0500 Subject: [PATCH 07/89] feat(dashboard/contract): parse data binding shorthand and inline forms --- extensions/dashboard/contract/manifest.go | 27 ++++++++++++++++++ .../dashboard/contract/manifest_test.go | 28 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/extensions/dashboard/contract/manifest.go b/extensions/dashboard/contract/manifest.go index faade213..f610bbfd 100644 --- a/extensions/dashboard/contract/manifest.go +++ b/extensions/dashboard/contract/manifest.go @@ -1,6 +1,12 @@ // manifest.go package contract +import ( + "fmt" + + "gopkg.in/yaml.v3" +) + // IntentKind is the wire-level discriminator declared on every intent. // It must be consistent with the request envelope Kind at dispatch time. type IntentKind string @@ -125,6 +131,27 @@ type DataBinding struct { Params map[string]ParamSource `yaml:"params,omitempty" json:"params,omitempty"` } +// UnmarshalYAML accepts either a scalar (treated as a named query reference) or +// a mapping with the inline {intent, params} form. +func (d *DataBinding) UnmarshalYAML(value *yaml.Node) error { + switch value.Kind { + case yaml.ScalarNode: + d.QueryRef = value.Value + return nil + case yaml.MappingNode: + // Decode into a shadow type to avoid recursion. + type alias DataBinding + var a alias + if err := value.Decode(&a); err != nil { + return err + } + *d = DataBinding(a) + return nil + default: + return fmt.Errorf("data: expected scalar or mapping, got kind=%d", value.Kind) + } +} + // Predicate is the boolean access expression: any of all/any/not, plus an optional // named Warden delegate. An empty Predicate evaluates to allow. type Predicate struct { diff --git a/extensions/dashboard/contract/manifest_test.go b/extensions/dashboard/contract/manifest_test.go index 8857dd6e..7841955a 100644 --- a/extensions/dashboard/contract/manifest_test.go +++ b/extensions/dashboard/contract/manifest_test.go @@ -71,3 +71,31 @@ func TestManifest_YAML_RoundTrip(t *testing.T) { t.Errorf("route = %q", m.Graph[0].Route) } } + +const dataShorthandYAML = ` +schemaVersion: 1 +contributor: + name: users + envelope: { supports: [v1], preferred: v1 } +intents: [] +graph: + - intent: resource.list + data: queries.userList + - intent: metric.counter + data: + intent: count.events + params: { since: { value: "1h" } } +` + +func TestDataBinding_BothShapes(t *testing.T) { + var m ContractManifest + if err := yaml.Unmarshal([]byte(dataShorthandYAML), &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if m.Graph[0].Data == nil || m.Graph[0].Data.QueryRef != "queries.userList" { + t.Errorf("shorthand not parsed: %+v", m.Graph[0].Data) + } + if m.Graph[1].Data == nil || m.Graph[1].Data.Intent != "count.events" { + t.Errorf("inline form not parsed: %+v", m.Graph[1].Data) + } +} From b71788dd995333a4a4d409f6633bd5889a423ee1 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 16:13:20 -0500 Subject: [PATCH 08/89] feat(dashboard/contract): support param source shorthand in YAML --- extensions/dashboard/contract/manifest.go | 20 +++++++++++++ .../dashboard/contract/manifest_test.go | 30 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/extensions/dashboard/contract/manifest.go b/extensions/dashboard/contract/manifest.go index f610bbfd..53cffaa2 100644 --- a/extensions/dashboard/contract/manifest.go +++ b/extensions/dashboard/contract/manifest.go @@ -87,6 +87,26 @@ type ParamSource struct { From string `yaml:"from,omitempty" json:"from,omitempty"` // route.X | parent.X | state.X | session.X } +// UnmarshalYAML accepts either a scalar (treated as the From source) or a +// mapping with the explicit {value} or {from} form. +func (p *ParamSource) UnmarshalYAML(value *yaml.Node) error { + switch value.Kind { + case yaml.ScalarNode: + p.From = value.Value + return nil + case yaml.MappingNode: + type alias ParamSource + var a alias + if err := value.Decode(&a); err != nil { + return err + } + *p = ParamSource(a) + return nil + default: + return fmt.Errorf("param: expected scalar or mapping, got kind=%d", value.Kind) + } +} + // QueryCache declares per-query staleness for the client. type QueryCache struct { StaleTime string `yaml:"staleTime,omitempty" json:"staleTime,omitempty"` diff --git a/extensions/dashboard/contract/manifest_test.go b/extensions/dashboard/contract/manifest_test.go index 7841955a..63785828 100644 --- a/extensions/dashboard/contract/manifest_test.go +++ b/extensions/dashboard/contract/manifest_test.go @@ -99,3 +99,33 @@ func TestDataBinding_BothShapes(t *testing.T) { t.Errorf("inline form not parsed: %+v", m.Graph[1].Data) } } + +const paramShorthandYAML = ` +schemaVersion: 1 +contributor: { name: x, envelope: { supports: [v1], preferred: v1 } } +intents: [] +queries: + q1: + intent: foo + params: + shorthand: route.tenant + explicit: { from: parent.id } + literal: { value: 5 } +` + +func TestParamSource_Shorthand(t *testing.T) { + var m ContractManifest + if err := yaml.Unmarshal([]byte(paramShorthandYAML), &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + q := m.Queries["q1"] + if q.Params["shorthand"].From != "route.tenant" { + t.Errorf("shorthand not parsed: %+v", q.Params["shorthand"]) + } + if q.Params["explicit"].From != "parent.id" { + t.Errorf("explicit form lost data: %+v", q.Params["explicit"]) + } + if v, ok := q.Params["literal"].Value.(int); !ok || v != 5 { + t.Errorf("literal value wrong: %+v", q.Params["literal"].Value) + } +} From 8f0f1fcc3e354b1701c52accb007f8ba329bcba8 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 16:16:16 -0500 Subject: [PATCH 09/89] feat(dashboard/contract): boolean predicate evaluator and stable permissions hash --- extensions/dashboard/contract/predicate.go | 119 ++++++++++++++++++ .../dashboard/contract/predicate_test.go | 78 ++++++++++++ extensions/dashboard/contract/warden.go | 16 +++ 3 files changed, 213 insertions(+) create mode 100644 extensions/dashboard/contract/predicate.go create mode 100644 extensions/dashboard/contract/predicate_test.go create mode 100644 extensions/dashboard/contract/warden.go diff --git a/extensions/dashboard/contract/predicate.go b/extensions/dashboard/contract/predicate.go new file mode 100644 index 00000000..7f8634e9 --- /dev/null +++ b/extensions/dashboard/contract/predicate.go @@ -0,0 +1,119 @@ +package contract + +import ( + "crypto/sha256" + "encoding/hex" + "sort" + "strings" + + dashauth "github.com/xraph/forge/extensions/dashboard/auth" +) + +// Allow evaluates the boolean predicate against a UserInfo. The wardenResult +// argument is the optional second-pass Warden decision; pass nil to skip. +// An empty predicate (no all/any/not) always allows. +func (p *Predicate) Allow(user *dashauth.UserInfo, wardenResult *Decision) bool { + if p == nil { + return true + } + if len(p.All) == 0 && len(p.Any) == 0 && len(p.Not) == 0 && wardenResult == nil { + // truly empty predicate; warden absence handled by caller's evaluation order + return true + } + for _, tok := range p.All { + if !match(tok, user) { + return false + } + } + if len(p.Any) > 0 { + ok := false + for _, tok := range p.Any { + if match(tok, user) { + ok = true + break + } + } + if !ok { + return false + } + } + for _, tok := range p.Not { + if match(tok, user) { + return false + } + } + if wardenResult != nil && !wardenResult.Allow { + return false + } + return true +} + +// match parses one token (role:X, scope:X, claim:K=V) and tests it against user. +func match(token string, user *dashauth.UserInfo) bool { + if user == nil { + return false + } + kind, rest, ok := strings.Cut(token, ":") + if !ok { + return false + } + switch kind { + case "role": + return contains(user.Roles, rest) + case "scope": + return contains(user.Scopes, rest) + case "claim": + key, value, ok := strings.Cut(rest, "=") + if !ok { + return false + } + got, ok := user.Claims[key] + if !ok { + return false + } + return toString(got) == value + } + return false +} + +func contains(xs []string, x string) bool { + for _, s := range xs { + if s == x { + return true + } + } + return false +} + +func toString(v any) string { + if s, ok := v.(string); ok { + return s + } + return "" // claims that aren't strings can't be matched by claim:K=V +} + +// PermissionsHash returns a stable, order-independent hash of a user's +// roles and scopes. Used as part of the graph cache key so that users with +// the same effective permissions share a cache entry. Claims are NOT included +// because the contract treats only role/scope as graph-shape-determining. +func PermissionsHash(user *dashauth.UserInfo) string { + if user == nil { + return "anon" + } + roles := append([]string(nil), user.Roles...) + scopes := append([]string(nil), user.Scopes...) + sort.Strings(roles) + sort.Strings(scopes) + h := sha256.New() + for _, r := range roles { + h.Write([]byte("r:")) + h.Write([]byte(r)) + h.Write([]byte{0}) + } + for _, s := range scopes { + h.Write([]byte("s:")) + h.Write([]byte(s)) + h.Write([]byte{0}) + } + return hex.EncodeToString(h.Sum(nil))[:16] +} diff --git a/extensions/dashboard/contract/predicate_test.go b/extensions/dashboard/contract/predicate_test.go new file mode 100644 index 00000000..d7f843f7 --- /dev/null +++ b/extensions/dashboard/contract/predicate_test.go @@ -0,0 +1,78 @@ +package contract + +import ( + "testing" + + dashauth "github.com/xraph/forge/extensions/dashboard/auth" +) + +func u(roles, scopes []string) *dashauth.UserInfo { + return &dashauth.UserInfo{Roles: roles, Scopes: scopes} +} + +func TestPredicate_Empty_Allows(t *testing.T) { + if !(&Predicate{}).Allow(u(nil, nil), nil) { + t.Error("empty predicate should allow") + } +} + +func TestPredicate_AllRequires(t *testing.T) { + p := &Predicate{All: []string{"role:admin", "scope:users.write"}} + if !p.Allow(u([]string{"admin"}, []string{"users.write"}), nil) { + t.Error("admin+users.write should pass all") + } + if p.Allow(u([]string{"admin"}, nil), nil) { + t.Error("missing scope should fail all") + } +} + +func TestPredicate_AnyRequires(t *testing.T) { + p := &Predicate{Any: []string{"role:admin", "role:owner"}} + if !p.Allow(u([]string{"owner"}, nil), nil) { + t.Error("owner alone should pass any") + } + if p.Allow(u([]string{"viewer"}, nil), nil) { + t.Error("neither admin nor owner should fail any") + } +} + +func TestPredicate_NotForbids(t *testing.T) { + p := &Predicate{Not: []string{"role:guest"}} + if !p.Allow(u([]string{"admin"}, nil), nil) { + t.Error("admin should pass not-guest") + } + if p.Allow(u([]string{"guest"}, nil), nil) { + t.Error("guest should fail not-guest") + } +} + +func TestPredicate_AllAndAny_Combined(t *testing.T) { + p := &Predicate{ + All: []string{"scope:users.read"}, + Any: []string{"role:admin", "role:owner"}, + } + pass := u([]string{"owner"}, []string{"users.read"}) + fail := u([]string{"owner"}, nil) + if !p.Allow(pass, nil) { + t.Error("pass case failed") + } + if p.Allow(fail, nil) { + t.Error("fail case allowed") + } +} + +func TestPermissionsHash_StableForEquivalentSlice(t *testing.T) { + a := PermissionsHash(u([]string{"admin", "owner"}, []string{"x", "y"})) + b := PermissionsHash(u([]string{"owner", "admin"}, []string{"y", "x"})) + if a != b { + t.Errorf("hash not stable across order: %s vs %s", a, b) + } +} + +func TestPermissionsHash_DiffersWhenRolesDiffer(t *testing.T) { + a := PermissionsHash(u([]string{"admin"}, nil)) + b := PermissionsHash(u([]string{"viewer"}, nil)) + if a == b { + t.Error("hash should differ for different roles") + } +} diff --git a/extensions/dashboard/contract/warden.go b/extensions/dashboard/contract/warden.go new file mode 100644 index 00000000..68a9f407 --- /dev/null +++ b/extensions/dashboard/contract/warden.go @@ -0,0 +1,16 @@ +package contract + +// Decision is the result returned by a Warden's second-pass authorization +// check. Phase 4 will extend this file with the Warden interface, Principal, +// Action, and the warden registry. For now only the Decision value type is +// declared so the predicate evaluator can reference it. +type Decision struct { + // Allow reports whether access is granted. + Allow bool + // Reason is a short, human-readable explanation. Surfaced in audit logs + // and (optionally) in error responses. + Reason string + // Redactions lists field paths that must be redacted from the response + // payload even when Allow is true. Empty when no redactions apply. + Redactions []string +} From 17d9497f06c0508f3200b853ed43ce85fa596439 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 16:18:58 -0500 Subject: [PATCH 10/89] feat(dashboard/contract): warden interface and in-memory registry --- extensions/dashboard/contract/warden.go | 86 ++++++++++++++++++-- extensions/dashboard/contract/warden_test.go | 65 +++++++++++++++ 2 files changed, 145 insertions(+), 6 deletions(-) create mode 100644 extensions/dashboard/contract/warden_test.go diff --git a/extensions/dashboard/contract/warden.go b/extensions/dashboard/contract/warden.go index 68a9f407..a946e094 100644 --- a/extensions/dashboard/contract/warden.go +++ b/extensions/dashboard/contract/warden.go @@ -1,16 +1,90 @@ package contract -// Decision is the result returned by a Warden's second-pass authorization -// check. Phase 4 will extend this file with the Warden interface, Principal, -// Action, and the warden registry. For now only the Decision value type is -// declared so the predicate evaluator can reference it. +import ( + "context" + "fmt" + "sync" + + dashauth "github.com/xraph/forge/extensions/dashboard/auth" +) + +// Warden is the pluggable, data-aware authorization second pass. +// It runs after the YAML boolean Predicate succeeds and may inspect +// intent params (e.g. tenant ownership), claims, or external policy. +type Warden interface { + Authorize(ctx context.Context, p Principal, a Action) (Decision, error) +} + +// Principal is the caller identity passed to Wardens and the predicate engine. +type Principal struct { + User *dashauth.UserInfo + Claims map[string]any +} + +// PrincipalFor builds a Principal from a UserInfo, copying claims for safety. +func PrincipalFor(user *dashauth.UserInfo) Principal { + if user == nil { + return Principal{} + } + claims := make(map[string]any, len(user.Claims)) + for k, v := range user.Claims { + claims[k] = v + } + return Principal{User: user, Claims: claims} +} + +// Action is the operation being authorized. +type Action struct { + Contributor string + Intent string + Kind IntentKind + Capability Capability + Resource map[string]any +} + +// Decision is the Warden's verdict. type Decision struct { // Allow reports whether access is granted. Allow bool // Reason is a short, human-readable explanation. Surfaced in audit logs // and (optionally) in error responses. Reason string - // Redactions lists field paths that must be redacted from the response - // payload even when Allow is true. Empty when no redactions apply. + // Redactions lists JSONPath-like field paths that must be redacted from + // the response payload even when Allow is true. Empty when no redactions + // apply. Redactions []string } + +// WardenRegistry maps a Warden's declared name to its implementation. +// Manifest validation rejects YAML that references a name not in the registry. +type WardenRegistry interface { + Register(name string, w Warden) error + Get(name string) (Warden, bool) +} + +// NewWardenRegistry returns an empty in-memory registry. +func NewWardenRegistry() WardenRegistry { + return &wardenRegistry{wardens: map[string]Warden{}} +} + +type wardenRegistry struct { + mu sync.RWMutex + wardens map[string]Warden +} + +func (r *wardenRegistry) Register(name string, w Warden) error { + r.mu.Lock() + defer r.mu.Unlock() + if _, exists := r.wardens[name]; exists { + return fmt.Errorf("warden %q already registered", name) + } + r.wardens[name] = w + return nil +} + +func (r *wardenRegistry) Get(name string) (Warden, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + w, ok := r.wardens[name] + return w, ok +} diff --git a/extensions/dashboard/contract/warden_test.go b/extensions/dashboard/contract/warden_test.go new file mode 100644 index 00000000..220dcc36 --- /dev/null +++ b/extensions/dashboard/contract/warden_test.go @@ -0,0 +1,65 @@ +package contract + +import ( + "context" + "errors" + "testing" + + dashauth "github.com/xraph/forge/extensions/dashboard/auth" +) + +type stubWarden struct { + allow bool + redactions []string +} + +func (s *stubWarden) Authorize(_ context.Context, _ Principal, _ Action) (Decision, error) { + return Decision{Allow: s.allow, Redactions: s.redactions}, nil +} + +func TestWardenRegistry_RegisterAndGet(t *testing.T) { + r := NewWardenRegistry() + w := &stubWarden{allow: true} + if err := r.Register("tenantOwner", w); err != nil { + t.Fatalf("register: %v", err) + } + got, ok := r.Get("tenantOwner") + if !ok || got != w { + t.Error("registered warden not found") + } +} + +func TestWardenRegistry_DuplicateName_Fails(t *testing.T) { + r := NewWardenRegistry() + _ = r.Register("x", &stubWarden{allow: true}) + if err := r.Register("x", &stubWarden{allow: false}); err == nil { + t.Error("duplicate registration should fail") + } +} + +func TestWardenRegistry_MissingName_NotOK(t *testing.T) { + r := NewWardenRegistry() + if _, ok := r.Get("nope"); ok { + t.Error("missing warden should not be found") + } +} + +func TestPrincipal_FromUserInfo(t *testing.T) { + user := &dashauth.UserInfo{Subject: "u1", Roles: []string{"admin"}} + p := PrincipalFor(user) + if p.User != user { + t.Error("principal should hold the user") + } +} + +func TestDecision_DenialPropagates(t *testing.T) { + w := &stubWarden{allow: false} + d, err := w.Authorize(context.Background(), Principal{}, Action{}) + if err != nil { + t.Fatalf("authorize: %v", err) + } + if d.Allow { + t.Error("expected deny") + } + _ = errors.New +} From 0568c10a109f3260c4bf579cf42ae714efd8652f Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 16:20:03 -0500 Subject: [PATCH 11/89] fix(dashboard/contract): Action.Kind uses wire Kind not manifest IntentKind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 10's POST handler passes req.Kind (wire envelope type) directly into Action.Kind when invoking a Warden. Action.Kind was inadvertently typed as IntentKind (the manifest-side enum) which would have caused a type mismatch. The wire Kind is the right boundary for the Warden — it sees the action as the caller framed it, not as the registry classifies the intent. --- extensions/dashboard/contract/warden.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/extensions/dashboard/contract/warden.go b/extensions/dashboard/contract/warden.go index a946e094..60ab0d53 100644 --- a/extensions/dashboard/contract/warden.go +++ b/extensions/dashboard/contract/warden.go @@ -33,11 +33,15 @@ func PrincipalFor(user *dashauth.UserInfo) Principal { return Principal{User: user, Claims: claims} } -// Action is the operation being authorized. +// Action is the operation being authorized. Kind is the wire-side envelope +// discriminator (graph/query/command/subscribe) so HTTP and SSE callers can +// pass req.Kind directly. Note this is the wire Kind, not the manifest's +// IntentKind — the values mostly overlap but "subscription" (manifest) is +// "subscribe" (wire). type Action struct { Contributor string Intent string - Kind IntentKind + Kind Kind Capability Capability Resource map[string]any } From aa6c34d78548e7a97fcfcb859eb37f91e6a273f4 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 16:22:41 -0500 Subject: [PATCH 12/89] feat(dashboard/contract): YAML loader with schemaVersion check --- extensions/dashboard/contract/loader/yaml.go | 33 +++++++++++++++ .../dashboard/contract/loader/yaml_test.go | 41 +++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 extensions/dashboard/contract/loader/yaml.go create mode 100644 extensions/dashboard/contract/loader/yaml_test.go diff --git a/extensions/dashboard/contract/loader/yaml.go b/extensions/dashboard/contract/loader/yaml.go new file mode 100644 index 00000000..60e29e79 --- /dev/null +++ b/extensions/dashboard/contract/loader/yaml.go @@ -0,0 +1,33 @@ +// yaml.go +package loader + +import ( + "fmt" + "io" + + "gopkg.in/yaml.v3" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// SupportedSchemaVersion is the schema integer this loader understands. +// Bumping it requires a coordinated platform release (see DESIGN.md). +const SupportedSchemaVersion = 1 + +// Load parses a contributor manifest YAML stream and validates its schemaVersion. +// Cross-reference validation (intent refs, slot accepts, warden names) runs separately +// in Validate. +func Load(r io.Reader, source string) (*contract.ContractManifest, error) { + data, err := io.ReadAll(r) + if err != nil { + return nil, fmt.Errorf("loading %s: %w", source, err) + } + var m contract.ContractManifest + if err := yaml.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("parsing %s: %w", source, err) + } + if m.SchemaVersion != SupportedSchemaVersion { + return nil, fmt.Errorf("%s: schemaVersion=%d unsupported, want %d", source, m.SchemaVersion, SupportedSchemaVersion) + } + return &m, nil +} diff --git a/extensions/dashboard/contract/loader/yaml_test.go b/extensions/dashboard/contract/loader/yaml_test.go new file mode 100644 index 00000000..142b90dc --- /dev/null +++ b/extensions/dashboard/contract/loader/yaml_test.go @@ -0,0 +1,41 @@ +// yaml_test.go +package loader + +import ( + "strings" + "testing" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +const okYAML = ` +schemaVersion: 1 +contributor: + name: users + envelope: { supports: [v1], preferred: v1 } +intents: + - { name: users.list, kind: query, version: 1, capability: read } +graph: + - { route: /users, intent: page.shell } +` + +func TestLoad_OK(t *testing.T) { + m, err := Load(strings.NewReader(okYAML), "users.yaml") + if err != nil { + t.Fatalf("load: %v", err) + } + if m.Contributor.Name != "users" { + t.Errorf("name = %q", m.Contributor.Name) + } + _ = contract.IntentKindQuery // ensure import retained +} + +func TestLoad_BadSchemaVersion(t *testing.T) { + const yaml = `schemaVersion: 99 +contributor: { name: x, envelope: { supports: [v1], preferred: v1 } } +intents: [] +` + if _, err := Load(strings.NewReader(yaml), "x.yaml"); err == nil { + t.Error("expected error for unsupported schemaVersion") + } +} From a402c55a21321706becdd657baa32d138926155d Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 16:22:43 -0500 Subject: [PATCH 13/89] feat(dashboard/contract): cross-reference validator (intents, queries, wardens) --- .../dashboard/contract/loader/validate.go | 117 ++++++++++++++++++ .../contract/loader/validate_test.go | 92 ++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 extensions/dashboard/contract/loader/validate.go create mode 100644 extensions/dashboard/contract/loader/validate_test.go diff --git a/extensions/dashboard/contract/loader/validate.go b/extensions/dashboard/contract/loader/validate.go new file mode 100644 index 00000000..5547af45 --- /dev/null +++ b/extensions/dashboard/contract/loader/validate.go @@ -0,0 +1,117 @@ +// validate.go +package loader + +import ( + "fmt" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// Validate runs cross-reference checks that require the full manifest in hand. +// It does not enforce slot-accepts (that needs the global registry to know about +// other contributors' intent kinds); slot validation runs in registry.Register. +func Validate(m *contract.ContractManifest, wardens contract.WardenRegistry) error { + intentByName := map[string]contract.Intent{} + for _, in := range m.Intents { + if _, dup := intentByName[in.Name]; dup { + return fmt.Errorf("intent %q declared twice", in.Name) + } + if err := validateKindCapability(in); err != nil { + return err + } + if err := validateWarden(in.Requires, wardens); err != nil { + return fmt.Errorf("intent %q: %w", in.Name, err) + } + intentByName[in.Name] = in + } + // Validate query refs + for name, q := range m.Queries { + if _, ok := intentByName[q.Intent]; !ok { + // allow refs to other-contributor intents; flag only same-contributor mistakes + // Heuristic: if the name looks like "{contributor}.{rest}" with a different + // contributor, skip; otherwise fail. Slice (b) tightens this. + if !looksCrossContributor(q.Intent, m.Contributor.Name) { + return fmt.Errorf("query %q: intent %q not declared in this contributor", name, q.Intent) + } + } + } + // Walk graph nodes to validate inline data and predicate wardens + var walk func(nodes []contract.GraphNode, path string) error + walk = func(nodes []contract.GraphNode, path string) error { + for i, n := range nodes { + here := fmt.Sprintf("%s[%d]", path, i) + if n.Data != nil && n.Data.QueryRef != "" { + key := stripQueriesPrefix(n.Data.QueryRef) + if _, ok := m.Queries[key]; !ok { + return fmt.Errorf("%s: data refers to unknown query %q", here, n.Data.QueryRef) + } + } + if n.Data != nil && n.Data.Intent != "" { + if _, ok := intentByName[n.Data.Intent]; !ok && !looksCrossContributor(n.Data.Intent, m.Contributor.Name) { + return fmt.Errorf("%s: data references unknown intent %q", here, n.Data.Intent) + } + } + if err := validateWarden(coalescePredicate(n.VisibleWhen), wardens); err != nil { + return fmt.Errorf("%s.visibleWhen: %w", here, err) + } + if err := validateWarden(coalescePredicate(n.EnabledWhen), wardens); err != nil { + return fmt.Errorf("%s.enabledWhen: %w", here, err) + } + for slotName, children := range n.Slots { + if err := walk(children, here+".slots."+slotName); err != nil { + return err + } + } + } + return nil + } + return walk(m.Graph, "graph") +} + +func validateKindCapability(in contract.Intent) error { + want := map[contract.IntentKind]contract.Capability{ + contract.IntentKindQuery: contract.CapRead, + contract.IntentKindCommand: contract.CapWrite, + contract.IntentKindSubscription: contract.CapRead, + contract.IntentKindGraph: contract.CapRender, + } + if w, ok := want[in.Kind]; ok && in.Capability != w { + return fmt.Errorf("intent %q: kind=%s requires capability=%s, got %s", in.Name, in.Kind, w, in.Capability) + } + return nil +} + +func validateWarden(p contract.Predicate, wardens contract.WardenRegistry) error { + if p.Warden == "" { + return nil + } + if _, ok := wardens.Get(p.Warden); !ok { + return fmt.Errorf("references unknown warden %q", p.Warden) + } + return nil +} + +func coalescePredicate(p *contract.Predicate) contract.Predicate { + if p == nil { + return contract.Predicate{} + } + return *p +} + +func looksCrossContributor(intentName, ownContributor string) bool { + // Convention: "auth.linkedAccount" — first dotted segment is contributor name. + for i := 0; i < len(intentName); i++ { + if intentName[i] == '.' { + return intentName[:i] != ownContributor + } + } + return false +} + +func stripQueriesPrefix(ref string) string { + const p = "queries." + if len(ref) > len(p) && ref[:len(p)] == p { + return ref[len(p):] + } + return ref +} diff --git a/extensions/dashboard/contract/loader/validate_test.go b/extensions/dashboard/contract/loader/validate_test.go new file mode 100644 index 00000000..e6297ff9 --- /dev/null +++ b/extensions/dashboard/contract/loader/validate_test.go @@ -0,0 +1,92 @@ +// validate_test.go +package loader + +import ( + "context" + "strings" + "testing" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +func mustLoad(t *testing.T, src string) *contract.ContractManifest { + t.Helper() + m, err := Load(strings.NewReader(src), "test.yaml") + if err != nil { + t.Fatalf("load: %v", err) + } + return m +} + +func TestValidate_GoodManifest(t *testing.T) { + m := mustLoad(t, ` +schemaVersion: 1 +contributor: { name: users, envelope: { supports: [v1], preferred: v1 } } +intents: + - { name: users.list, kind: query, version: 1, capability: read } + - { name: user.disable, kind: command, version: 1, capability: write, + requires: { warden: tenantOwner } } +queries: + userList: { intent: users.list } +graph: + - { route: /users, intent: page.shell, data: queries.userList } +`) + wreg := contract.NewWardenRegistry() + _ = wreg.Register("tenantOwner", &noopWarden{}) + if err := Validate(m, wreg); err != nil { + t.Fatalf("validate: %v", err) + } +} + +func TestValidate_UnknownWarden(t *testing.T) { + m := mustLoad(t, ` +schemaVersion: 1 +contributor: { name: x, envelope: { supports: [v1], preferred: v1 } } +intents: + - { name: a, kind: query, version: 1, capability: read, + requires: { warden: missing } } +`) + if err := Validate(m, contract.NewWardenRegistry()); err == nil { + t.Error("expected unknown-warden error") + } +} + +func TestValidate_UnknownQueryRef(t *testing.T) { + m := mustLoad(t, ` +schemaVersion: 1 +contributor: { name: x, envelope: { supports: [v1], preferred: v1 } } +intents: [] +graph: + - { intent: page.shell, data: queries.nope } +`) + if err := Validate(m, contract.NewWardenRegistry()); err == nil { + t.Error("expected unknown-query error") + } +} + +func TestValidate_KindCapabilityMismatch(t *testing.T) { + cases := []string{ + "kind: command, capability: read", // command must be write + "kind: query, capability: write", // query must be read + "kind: subscription, capability: write", + } + for _, body := range cases { + t.Run(body, func(t *testing.T) { + m := mustLoad(t, ` +schemaVersion: 1 +contributor: { name: x, envelope: { supports: [v1], preferred: v1 } } +intents: + - { name: a, version: 1, `+body+` } +`) + if err := Validate(m, contract.NewWardenRegistry()); err == nil { + t.Errorf("expected kind/capability mismatch error for %q", body) + } + }) + } +} + +type noopWarden struct{} + +func (noopWarden) Authorize(_ context.Context, _ contract.Principal, _ contract.Action) (contract.Decision, error) { + return contract.Decision{Allow: true}, nil +} From 5c626a38d3a0b1e7595cd5d8c0f328ac8780d633 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 16:26:39 -0500 Subject: [PATCH 14/89] feat(dashboard/contract): contract registry with slot catalog and cross-contributor extensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6 of Dashboard Contract slice (a): - registry.go/registry_test.go: Registry interface (Register, Contributor, Intent, HighestVersion, All, MergedGraph). Indexed by (contributor, intent, version); HighestVersion tracks the highest non-deprecated version per (contributor, intent), falling back to a deprecated version only if no active version is registered. - slots.go/slots_test.go: built-in slot catalog (DefaultSlotCatalog) with page.shell, resource.list, dashboard.grid, form.edit. SlotDef carries Accepts, Cardinality, and Extensible. MaxSlotDepth = 8. Registration runs checkDepth, checkCycle, validateGraphSlots over the manifest's graph. - Cross-contributor slot extensions: applyExtension walks dotted slot paths (e.g. main.detailDrawer.fields), enforces the Extensible flag, and validates added intents against the target slot's Accepts list. Each Register call deep-copies the manifest's graph into r.mergedGraphs[contributor], then applies its extends: directives against the merged graph of the target contributor — so extensions never mutate original manifests. --- extensions/dashboard/contract/registry.go | 155 ++++++++++++ .../dashboard/contract/registry_test.go | 87 +++++++ extensions/dashboard/contract/slots.go | 225 ++++++++++++++++++ extensions/dashboard/contract/slots_test.go | 131 ++++++++++ 4 files changed, 598 insertions(+) create mode 100644 extensions/dashboard/contract/registry.go create mode 100644 extensions/dashboard/contract/registry_test.go create mode 100644 extensions/dashboard/contract/slots.go create mode 100644 extensions/dashboard/contract/slots_test.go diff --git a/extensions/dashboard/contract/registry.go b/extensions/dashboard/contract/registry.go new file mode 100644 index 00000000..909fce95 --- /dev/null +++ b/extensions/dashboard/contract/registry.go @@ -0,0 +1,155 @@ +// registry.go +package contract + +import ( + "fmt" + "sync" +) + +// Registry holds all registered contributor manifests and provides +// lookup by (contributor, intent, version) plus highest-active-version queries +// for negotiation. It also stores per-contributor merged graphs reflecting any +// cross-contributor slot extensions applied at registration time. +type Registry interface { + Register(m *ContractManifest) error + Contributor(name string) (*ContractManifest, bool) + Intent(contributor, intent string, version int) (Intent, bool) + HighestVersion(contributor, intent string) (int, bool) + All() []*ContractManifest + MergedGraph(contributor, route string) (*GraphNode, bool) +} + +// NewRegistry returns an empty registry. +func NewRegistry() Registry { + return ®istry{ + contributors: map[string]*ContractManifest{}, + intents: map[intentKey]Intent{}, + highest: map[string]int{}, + mergedGraphs: map[string][]GraphNode{}, + } +} + +type intentKey struct { + contributor string + intent string + version int +} + +type registry struct { + mu sync.RWMutex + contributors map[string]*ContractManifest + intents map[intentKey]Intent + highest map[string]int // "contributor:intent" -> highest active version + mergedGraphs map[string][]GraphNode // contributor name -> deep-copied graph with extensions applied +} + +func (r *registry) Register(m *ContractManifest) error { + if m == nil { + return fmt.Errorf("nil manifest") + } + name := m.Contributor.Name + if name == "" { + return fmt.Errorf("manifest missing contributor.name") + } + r.mu.Lock() + defer r.mu.Unlock() + if _, exists := r.contributors[name]; exists { + return fmt.Errorf("contributor %q already registered", name) + } + for _, in := range m.Intents { + k := intentKey{name, in.Name, in.Version} + if _, dup := r.intents[k]; dup { + return fmt.Errorf("contributor %q intent %q version %d declared twice", name, in.Name, in.Version) + } + r.intents[k] = in + hk := name + ":" + in.Name + if in.Deprecated == nil { + if r.highest[hk] < in.Version { + r.highest[hk] = in.Version + } + } else if _, hasHigher := r.highest[hk]; !hasHigher { + // only set if no active version has been seen yet; deprecated falls back + r.highest[hk] = in.Version + } + } + // Validate the manifest's own graph against depth/cycle/slot-accept rules. + if err := checkDepth(m.Graph, 0); err != nil { + return fmt.Errorf("contributor %q: %w", name, err) + } + if err := checkCycle(m.Graph, map[string]bool{}); err != nil { + return fmt.Errorf("contributor %q: %w", name, err) + } + if err := validateGraphSlots(m.Graph); err != nil { + return fmt.Errorf("contributor %q: %w", name, err) + } + r.contributors[name] = m + // Compute merged graphs: deep-copy own graph, then apply this manifest's + // extends against ALL contributors (including ones registered earlier). + r.mergedGraphs[name] = deepCopyGraph(m.Graph) + for _, ext := range m.Extends { + targetGraph, ok := r.mergedGraphs[ext.Target.Contributor] + if !ok { + return fmt.Errorf("contributor %q: extension target %q not registered", name, ext.Target.Contributor) + } + if err := applyExtension(targetGraph, ext.Target, ext.Slot, ext.Add); err != nil { + return fmt.Errorf("contributor %q: %w", name, err) + } + } + return nil +} + +func (r *registry) Contributor(name string) (*ContractManifest, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + m, ok := r.contributors[name] + return m, ok +} + +func (r *registry) Intent(contributor, intent string, version int) (Intent, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + in, ok := r.intents[intentKey{contributor, intent, version}] + return in, ok +} + +func (r *registry) HighestVersion(contributor, intent string) (int, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + v, ok := r.highest[contributor+":"+intent] + return v, ok +} + +func (r *registry) All() []*ContractManifest { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]*ContractManifest, 0, len(r.contributors)) + for _, m := range r.contributors { + out = append(out, m) + } + return out +} + +// MergedGraph returns the merged graph for a contributor (with all extensions +// applied), or false if the contributor is not registered. +// If route is empty, the first top-level node is returned. Otherwise the +// top-level node with a matching Route is returned. +func (r *registry) MergedGraph(contributor, route string) (*GraphNode, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + g, ok := r.mergedGraphs[contributor] + if !ok { + return nil, false + } + if route == "" { + if len(g) > 0 { + return &g[0], true + } + return nil, false + } + for i := range g { + if g[i].Route == route { + return &g[i], true + } + } + return nil, false +} diff --git a/extensions/dashboard/contract/registry_test.go b/extensions/dashboard/contract/registry_test.go new file mode 100644 index 00000000..bc51d987 --- /dev/null +++ b/extensions/dashboard/contract/registry_test.go @@ -0,0 +1,87 @@ +// registry_test.go +package contract + +import ( + "testing" + + yaml "gopkg.in/yaml.v3" +) + +// unmarshalForTest is a private test helper that wraps yaml.Unmarshal. +// First introduced for the registry tests; reused by other test files in this package. +func unmarshalForTest(b []byte, v any) error { return yaml.Unmarshal(b, v) } + +func mustManifest(t *testing.T, src string) *ContractManifest { + t.Helper() + var m ContractManifest + if err := unmarshalForTest([]byte(src), &m); err != nil { + t.Fatalf("manifest: %v", err) + } + return &m +} + +func TestRegistry_RegisterAndLookup(t *testing.T) { + r := NewRegistry() + m := mustManifest(t, ` +schemaVersion: 1 +contributor: { name: users, envelope: { supports: [v1], preferred: v1 } } +intents: + - { name: users.list, kind: query, version: 1, capability: read } +`) + if err := r.Register(m); err != nil { + t.Fatalf("register: %v", err) + } + intent, ok := r.Intent("users", "users.list", 1) + if !ok || intent.Name != "users.list" { + t.Errorf("lookup failed: ok=%v intent=%+v", ok, intent) + } +} + +func TestRegistry_DuplicateContributor(t *testing.T) { + r := NewRegistry() + m := mustManifest(t, ` +schemaVersion: 1 +contributor: { name: users, envelope: { supports: [v1], preferred: v1 } } +intents: [] +`) + _ = r.Register(m) + if err := r.Register(m); err == nil { + t.Error("expected duplicate-contributor error") + } +} + +func TestRegistry_HighestActiveIntentVersion(t *testing.T) { + r := NewRegistry() + m := mustManifest(t, ` +schemaVersion: 1 +contributor: { name: users, envelope: { supports: [v1], preferred: v1 } } +intents: + - { name: user.disable, kind: command, version: 1, capability: write, + deprecated: { intentVersion: 1, removeAfter: "2026-09-01" } } + - { name: user.disable, kind: command, version: 2, capability: write } +`) + if err := r.Register(m); err != nil { + t.Fatalf("register: %v", err) + } + got, ok := r.HighestVersion("users", "user.disable") + if !ok || got != 2 { + t.Errorf("HighestVersion = %d, ok=%v", got, ok) + } +} + +func TestRegistry_RejectsBadSlotFill(t *testing.T) { + r := NewRegistry() + m := mustManifest(t, ` +schemaVersion: 1 +contributor: { name: users, envelope: { supports: [v1], preferred: v1 } } +intents: [] +graph: + - intent: page.shell + slots: + main: + - { intent: action.button } # not allowed in page.shell.main +`) + if err := r.Register(m); err == nil { + t.Error("expected slot-accept rejection") + } +} diff --git a/extensions/dashboard/contract/slots.go b/extensions/dashboard/contract/slots.go new file mode 100644 index 00000000..f5486ffa --- /dev/null +++ b/extensions/dashboard/contract/slots.go @@ -0,0 +1,225 @@ +// slots.go +package contract + +import ( + "fmt" + "strings" +) + +// MaxSlotDepth is the maximum nesting depth of graph nodes; trees deeper +// than this are rejected at registration. +const MaxSlotDepth = 8 + +// Cardinality describes how many fills a slot accepts. +type Cardinality string + +const ( + CardinalityOne Cardinality = "one" + CardinalityMany Cardinality = "many" +) + +// SlotDef describes one slot of a parent intent kind. +type SlotDef struct { + Accepts []string // intent names accepted in this slot + Cardinality Cardinality + Extensible bool // if true, other contributors may extend via Extends +} + +// IntentKindDef declares the slots of a built-in intent kind. +type IntentKindDef struct { + Slots map[string]SlotDef +} + +// DefaultSlotCatalog is the v1 catalog of built-in intent kinds and their slots. +// Adding a new built-in intent kind here is a shell-version bump (adds new +// renderer behavior). Slice (e) defines the full v1 vocabulary; this map starts +// with the kinds used by the spec's example. +var DefaultSlotCatalog = map[string]IntentKindDef{ + "page.shell": { + Slots: map[string]SlotDef{ + "main": { + Accepts: []string{"resource.list", "resource.detail", "dashboard.grid", "form.edit", "custom", "iframe"}, + Cardinality: CardinalityMany, + }, + }, + }, + "resource.list": { + Slots: map[string]SlotDef{ + "rowActions": {Accepts: []string{"action.button", "action.menu", "action.divider"}, Cardinality: CardinalityMany}, + "detailDrawer": {Accepts: []string{"form.edit", "resource.detail", "custom"}, Cardinality: CardinalityOne, Extensible: true}, + }, + }, + "dashboard.grid": { + Slots: map[string]SlotDef{ + "widgets": {Accepts: []string{"metric.counter", "metric.gauge", "audit.tail", "custom"}, Cardinality: CardinalityMany}, + }, + }, + "form.edit": { + Slots: map[string]SlotDef{ + "fields": {Accepts: []string{"form.field", "custom"}, Cardinality: CardinalityMany, Extensible: true}, + }, + }, +} + +// validateSlotAccepts returns an error if child is not in slot's Accepts list. +func validateSlotAccepts(slot SlotDef, child string) error { + for _, a := range slot.Accepts { + if a == child { + return nil + } + } + return fmt.Errorf("slot does not accept intent %q", child) +} + +// checkDepth recurses through GraphNodes and fails if any chain exceeds MaxSlotDepth. +func checkDepth(nodes []GraphNode, current int) error { + if current > MaxSlotDepth { + return fmt.Errorf("graph depth %d exceeds max %d", current, MaxSlotDepth) + } + for _, n := range nodes { + for _, children := range n.Slots { + if err := checkDepth(children, current+1); err != nil { + return err + } + } + } + return nil +} + +// checkCycle walks the graph and detects intent self-reference along a path. +// (Cross-intent cycles via custom escape hatches are bounded by checkDepth.) +func checkCycle(nodes []GraphNode, ancestors map[string]bool) error { + for _, n := range nodes { + if n.Intent == "" { + continue + } + if ancestors[n.Intent] { + return fmt.Errorf("cycle detected: intent %q nests itself", n.Intent) + } + ancestors[n.Intent] = true + for _, children := range n.Slots { + if err := checkCycle(children, ancestors); err != nil { + return err + } + } + delete(ancestors, n.Intent) + } + return nil +} + +// validateGraphSlots checks that every slot fill in the graph satisfies the +// parent intent's slot catalog rules: known slot name, cardinality, and accept list. +func validateGraphSlots(nodes []GraphNode) error { + for _, n := range nodes { + def, ok := DefaultSlotCatalog[n.Intent] + if !ok { + // unknown parent intent: cannot validate slots, allow (may be a leaf or custom) + continue + } + for slotName, children := range n.Slots { + slot, ok := def.Slots[slotName] + if !ok { + return fmt.Errorf("intent %q has no slot %q", n.Intent, slotName) + } + if slot.Cardinality == CardinalityOne && len(children) > 1 { + return fmt.Errorf("intent %q slot %q accepts one fill, got %d", n.Intent, slotName, len(children)) + } + for _, c := range children { + if err := validateSlotAccepts(slot, c.Intent); err != nil { + return fmt.Errorf("intent %q slot %q: %w", n.Intent, slotName, err) + } + } + if err := validateGraphSlots(children); err != nil { + return err + } + } + } + return nil +} + +// applyExtension finds the target node in graph and merges adds into the named slot. +// slotPath is a dotted path through Slots maps (and implicit first-fill traversal), +// e.g. "main.detailDrawer.fields". Returns an error if the target slot is not +// extensible or the path cannot be resolved. +func applyExtension(graph []GraphNode, target ExtensionTarget, slotPath string, adds []GraphNode) error { + for i := range graph { + if matchesTarget(graph[i], target) { + return walkAndAppend(&graph[i], strings.Split(slotPath, "."), adds) + } + } + return fmt.Errorf("extension target not found: contributor=%s intent=%s route=%s", target.Contributor, target.Intent, target.Route) +} + +// matchesTarget reports whether a graph node is the host requested by the extension target. +// Intent must match; Route must match only if the target specifies one. +func matchesTarget(n GraphNode, t ExtensionTarget) bool { + if n.Intent != t.Intent { + return false + } + if t.Route != "" && n.Route != t.Route { + return false + } + return true +} + +// walkAndAppend descends the dotted slot path on n. The final segment names the +// slot to receive adds; intermediate segments name slots to descend into, +// always taking the first fill (cardinality:one assumption for traversal — +// indexed extension paths are out of scope for v1). +func walkAndAppend(n *GraphNode, path []string, adds []GraphNode) error { + if len(path) == 0 { + return fmt.Errorf("empty slot path") + } + slotName := path[0] + rest := path[1:] + if n.Slots == nil { + n.Slots = map[string][]GraphNode{} + } + if len(rest) == 0 { + // We've reached the target slot; check extensibility + def, ok := DefaultSlotCatalog[n.Intent] + if !ok { + return fmt.Errorf("unknown parent intent %q", n.Intent) + } + slot, ok := def.Slots[slotName] + if !ok { + return fmt.Errorf("intent %q has no slot %q", n.Intent, slotName) + } + if !slot.Extensible { + return fmt.Errorf("intent %q slot %q is not extensible", n.Intent, slotName) + } + for _, a := range adds { + if err := validateSlotAccepts(slot, a.Intent); err != nil { + return fmt.Errorf("extension into %q.%q: %w", n.Intent, slotName, err) + } + } + n.Slots[slotName] = append(n.Slots[slotName], adds...) + return nil + } + // Recurse: choose first fill in this slot. + children, ok := n.Slots[slotName] + if !ok || len(children) == 0 { + return fmt.Errorf("path %q: no fills in slot %q to descend", strings.Join(path, "."), slotName) + } + return walkAndAppend(&children[0], rest, adds) +} + +// deepCopyGraph returns a deep copy of a graph slice. Required so applying +// extensions doesn't mutate a contributor's original manifest. +func deepCopyGraph(in []GraphNode) []GraphNode { + if in == nil { + return nil + } + out := make([]GraphNode, len(in)) + for i, n := range in { + nc := n + if n.Slots != nil { + nc.Slots = map[string][]GraphNode{} + for k, v := range n.Slots { + nc.Slots[k] = deepCopyGraph(v) + } + } + out[i] = nc + } + return out +} diff --git a/extensions/dashboard/contract/slots_test.go b/extensions/dashboard/contract/slots_test.go new file mode 100644 index 00000000..d1fdad6c --- /dev/null +++ b/extensions/dashboard/contract/slots_test.go @@ -0,0 +1,131 @@ +// slots_test.go +package contract + +import "testing" + +func TestSlotCatalog_PageShell(t *testing.T) { + def, ok := DefaultSlotCatalog["page.shell"] + if !ok { + t.Fatal("page.shell missing from default slot catalog") + } + if def.Slots["main"].Cardinality != CardinalityMany { + t.Errorf("main cardinality = %v", def.Slots["main"].Cardinality) + } +} + +func TestValidateSlotFills_AcceptCheck(t *testing.T) { + // page.shell.main accepts resource.list, dashboard.grid; rejects unknown + parent := DefaultSlotCatalog["page.shell"] + cases := []struct { + child string + wantErr bool + }{ + {"resource.list", false}, + {"dashboard.grid", false}, + {"action.button", true}, // not allowed in main + } + for _, c := range cases { + err := validateSlotAccepts(parent.Slots["main"], c.child) + if (err != nil) != c.wantErr { + t.Errorf("child=%s err=%v wantErr=%v", c.child, err, c.wantErr) + } + } +} + +func TestSlotDepth_ExceedsMax(t *testing.T) { + // build a graph of depth 9 (root + 8 nested slots) + leaf := GraphNode{Intent: "metric.counter"} + cur := leaf + for i := 0; i < 9; i++ { + cur = GraphNode{Intent: "page.shell", Slots: map[string][]GraphNode{"main": {cur}}} + } + if err := checkDepth([]GraphNode{cur}, 0); err == nil { + t.Error("expected depth-exceeded error") + } +} + +func TestSlotCycle_DetectedAtRegistration(t *testing.T) { + // A node referencing its own intent through a slot is a cycle + root := GraphNode{ + Intent: "self", + Slots: map[string][]GraphNode{ + "main": {{Intent: "self"}}, + }, + } + if err := checkCycle([]GraphNode{root}, map[string]bool{}); err == nil { + t.Error("expected cycle error") + } +} + +func TestApplyExtensions_NonExtensibleSlot_Rejected(t *testing.T) { + // users.page.shell has a 'main' slot (not extensible by default) + r := NewRegistry() + usersM := mustManifest(t, ` +schemaVersion: 1 +contributor: { name: users, envelope: { supports: [v1], preferred: v1 } } +intents: [] +graph: + - { route: /users, intent: page.shell, slots: { main: [{ intent: resource.list }] } } +`) + _ = r.Register(usersM) + + authM := mustManifest(t, ` +schemaVersion: 1 +contributor: { name: auth, envelope: { supports: [v1], preferred: v1 } } +intents: [] +extends: + - target: { contributor: users, intent: page.shell, route: /users } + slot: main + add: + - { intent: action.button } +`) + if err := r.Register(authM); err == nil { + t.Error("expected non-extensible-slot rejection") + } +} + +func TestApplyExtensions_Extensible_Merges(t *testing.T) { + r := NewRegistry() + usersM := mustManifest(t, ` +schemaVersion: 1 +contributor: { name: users, envelope: { supports: [v1], preferred: v1 } } +intents: [] +graph: + - { route: /users, intent: page.shell, slots: { + main: [{ + intent: resource.list, + slots: { detailDrawer: [{ intent: form.edit }] } + }] + } } +`) + _ = r.Register(usersM) + + authM := mustManifest(t, ` +schemaVersion: 1 +contributor: { name: auth, envelope: { supports: [v1], preferred: v1 } } +intents: [] +extends: + - target: { contributor: users, intent: page.shell, route: /users } + slot: main.detailDrawer.fields + add: + - { intent: form.field } +`) + if err := r.Register(authM); err != nil { + t.Fatalf("register auth: %v", err) + } + // After registration, the merged graph should include the extension + merged, ok := r.MergedGraph("users", "/users") + if !ok { + t.Fatal("merged graph not found") + } + fields := merged.Slots["main"][0].Slots["detailDrawer"][0].Slots["fields"] + if len(fields) != 1 || fields[0].Intent != "form.field" { + t.Errorf("extension not applied: %+v", fields) + } + + // Verify original manifest's graph is NOT mutated + origFields := usersM.Graph[0].Slots["main"][0].Slots["detailDrawer"][0].Slots["fields"] + if len(origFields) != 0 { + t.Errorf("original manifest mutated; got fields=%+v", origFields) + } +} From cf19ed397ae711b536d5f1507656cd2c8c747c94 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 16:29:39 -0500 Subject: [PATCH 15/89] feat(dashboard/contract): per-request graph filter by visibleWhen + warden --- extensions/dashboard/contract/graph.go | 88 +++++++++++++++++++++ extensions/dashboard/contract/graph_test.go | 87 ++++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 extensions/dashboard/contract/graph.go create mode 100644 extensions/dashboard/contract/graph_test.go diff --git a/extensions/dashboard/contract/graph.go b/extensions/dashboard/contract/graph.go new file mode 100644 index 00000000..ed09f459 --- /dev/null +++ b/extensions/dashboard/contract/graph.go @@ -0,0 +1,88 @@ +// graph.go +package contract + +import ( + "context" + "fmt" +) + +// GraphBuilder produces a per-(route, principal) filtered graph by walking the +// merged graph from the registry and dropping nodes whose visibleWhen predicates +// fail. EnabledWhen is preserved as an annotation (it does not strip the node); +// the React shell honors it for disabled-but-visible UI states. +type GraphBuilder struct { + registry Registry + wardens WardenRegistry +} + +// NewGraphBuilder returns a builder bound to the given registry and warden registry. +func NewGraphBuilder(reg Registry, wardens WardenRegistry) *GraphBuilder { + return &GraphBuilder{registry: reg, wardens: wardens} +} + +// Build returns the filtered graph rooted at the given route for the given principal. +// Returns ErrNotFound if no contributor owns the route, or ErrPermissionDenied if the +// root node itself is filtered out by the principal's permissions. +func (b *GraphBuilder) Build(ctx context.Context, contributor, route string, p Principal) (*GraphNode, error) { + root, ok := b.registry.MergedGraph(contributor, route) + if !ok { + return nil, fmt.Errorf("%w: contributor=%s route=%s", ErrNotFound, contributor, route) + } + filtered, err := b.filter(ctx, *root, p) + if err != nil { + return nil, err + } + if filtered == nil { + return nil, fmt.Errorf("%w: route filtered for principal", ErrPermissionDenied) + } + return filtered, nil +} + +// filter returns a deep copy of n with non-visible descendants stripped, or nil +// if n itself fails its own visibleWhen. +func (b *GraphBuilder) filter(ctx context.Context, n GraphNode, p Principal) (*GraphNode, error) { + if !b.allowsNode(ctx, n, p) { + return nil, nil + } + out := n + if n.Slots != nil { + out.Slots = map[string][]GraphNode{} + for slotName, children := range n.Slots { + var kept []GraphNode + for _, c := range children { + kc, err := b.filter(ctx, c, p) + if err != nil { + return nil, err + } + if kc != nil { + kept = append(kept, *kc) + } + } + if len(kept) > 0 { + out.Slots[slotName] = kept + } + } + } + return &out, nil +} + +// allowsNode evaluates visibleWhen plus any per-slot 'requires' inherited from +// the parent intent's slot definition. Returns true if the node should be kept. +func (b *GraphBuilder) allowsNode(_ context.Context, n GraphNode, p Principal) bool { + if n.VisibleWhen != nil && !n.VisibleWhen.Allow(p.User, nil) { + return false + } + // Warden hook: if visibleWhen carries a Warden ref, run it + if n.VisibleWhen != nil && n.VisibleWhen.Warden != "" { + w, ok := b.wardens.Get(n.VisibleWhen.Warden) + if !ok { + return false + } + // Best-effort sync call here; per-event re-checks are cached in stream.go + d, err := w.Authorize(context.Background(), p, Action{Intent: n.Intent}) + if err != nil || !d.Allow { + return false + } + } + return true +} diff --git a/extensions/dashboard/contract/graph_test.go b/extensions/dashboard/contract/graph_test.go new file mode 100644 index 00000000..d41464e8 --- /dev/null +++ b/extensions/dashboard/contract/graph_test.go @@ -0,0 +1,87 @@ +// graph_test.go +package contract + +import ( + "context" + "testing" + + dashauth "github.com/xraph/forge/extensions/dashboard/auth" +) + +func TestBuildGraph_FiltersHiddenNodes(t *testing.T) { + r := NewRegistry() + m := mustManifest(t, ` +schemaVersion: 1 +contributor: { name: users, envelope: { supports: [v1], preferred: v1 } } +intents: [] +graph: + - route: /users + intent: page.shell + slots: + main: + - intent: resource.list + slots: + rowActions: + - { intent: action.button, op: user.disable, + visibleWhen: { all: ["role:admin"] } } + - { intent: action.button, op: user.view } +`) + if err := r.Register(m); err != nil { + t.Fatalf("register: %v", err) + } + build := NewGraphBuilder(r, NewWardenRegistry()) + + got, err := build.Build(context.Background(), "users", "/users", + Principal{User: &dashauth.UserInfo{Subject: "u1", Roles: []string{"viewer"}}}) + if err != nil { + t.Fatalf("build: %v", err) + } + actions := got.Slots["main"][0].Slots["rowActions"] + if len(actions) != 1 { + t.Fatalf("expected 1 action visible to viewer, got %d", len(actions)) + } + if actions[0].Op != "user.view" { + t.Errorf("wrong action remained: %+v", actions[0]) + } +} + +func TestBuildGraph_AdminSeesAll(t *testing.T) { + r := NewRegistry() + m := mustManifest(t, ` +schemaVersion: 1 +contributor: { name: users, envelope: { supports: [v1], preferred: v1 } } +intents: [] +graph: + - route: /users + intent: page.shell + slots: + main: + - intent: resource.list + slots: + rowActions: + - { intent: action.button, op: user.disable, + visibleWhen: { all: ["role:admin"] } } +`) + if err := r.Register(m); err != nil { + t.Fatalf("register: %v", err) + } + build := NewGraphBuilder(r, NewWardenRegistry()) + got, err := build.Build(context.Background(), "users", "/users", + Principal{User: &dashauth.UserInfo{Subject: "u1", Roles: []string{"admin"}}}) + if err != nil { + t.Fatalf("build: %v", err) + } + actions := got.Slots["main"][0].Slots["rowActions"] + if len(actions) != 1 { + t.Errorf("admin should see admin-only action; got %d", len(actions)) + } +} + +func TestBuildGraph_RouteNotFound(t *testing.T) { + r := NewRegistry() + build := NewGraphBuilder(r, NewWardenRegistry()) + _, err := build.Build(context.Background(), "users", "/nope", Principal{}) + if err == nil { + t.Error("expected not-found error") + } +} From ae5e4c4eb6105d0c266de71adeac9c0cbe8e3c0b Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 16:31:40 -0500 Subject: [PATCH 16/89] feat(dashboard/contract): LRU+TTL graph cache keyed by (route, permissions, shell) --- extensions/dashboard/contract/cache.go | 89 +++++++++++++++++++++ extensions/dashboard/contract/cache_test.go | 47 +++++++++++ 2 files changed, 136 insertions(+) create mode 100644 extensions/dashboard/contract/cache.go create mode 100644 extensions/dashboard/contract/cache_test.go diff --git a/extensions/dashboard/contract/cache.go b/extensions/dashboard/contract/cache.go new file mode 100644 index 00000000..9986a078 --- /dev/null +++ b/extensions/dashboard/contract/cache.go @@ -0,0 +1,89 @@ +package contract + +import ( + "container/list" + "sync" + "time" +) + +// GraphCacheKey is the (route, permissionsHash, shellVersion) tuple keyed by the cache. +type GraphCacheKey struct { + Route string + PermissionsHash string + ShellVersion string +} + +// GraphCache is a small LRU+TTL cache. Bust on contributor manifest reload. +type GraphCache struct { + mu sync.Mutex + cap int + ttl time.Duration + items map[GraphCacheKey]*list.Element + order *list.List // front = MRU +} + +type graphEntry struct { + key GraphCacheKey + value *GraphNode + at time.Time +} + +// NewGraphCache creates a cache with the given max size and TTL per entry. +// TTL of 0 disables expiry. +func NewGraphCache(maxEntries int, ttl time.Duration) *GraphCache { + if maxEntries < 1 { + maxEntries = 64 + } + return &GraphCache{ + cap: maxEntries, + ttl: ttl, + items: map[GraphCacheKey]*list.Element{}, + order: list.New(), + } +} + +func (c *GraphCache) Get(k GraphCacheKey) (*GraphNode, bool) { + c.mu.Lock() + defer c.mu.Unlock() + el, ok := c.items[k] + if !ok { + return nil, false + } + e := el.Value.(*graphEntry) + if c.ttl > 0 && time.Since(e.at) > c.ttl { + c.order.Remove(el) + delete(c.items, k) + return nil, false + } + c.order.MoveToFront(el) + return e.value, true +} + +func (c *GraphCache) Put(k GraphCacheKey, v *GraphNode) { + c.mu.Lock() + defer c.mu.Unlock() + if el, ok := c.items[k]; ok { + e := el.Value.(*graphEntry) + e.value = v + e.at = time.Now() + c.order.MoveToFront(el) + return + } + el := c.order.PushFront(&graphEntry{key: k, value: v, at: time.Now()}) + c.items[k] = el + if c.order.Len() > c.cap { + oldest := c.order.Back() + if oldest != nil { + c.order.Remove(oldest) + delete(c.items, oldest.Value.(*graphEntry).key) + } + } +} + +// BustAll clears the cache. Call after a contributor manifest reload or shell deploy. +func (c *GraphCache) BustAll() { + c.mu.Lock() + defer c.mu.Unlock() + c.items = map[GraphCacheKey]*list.Element{} + c.order = list.New() +} diff --git a/extensions/dashboard/contract/cache_test.go b/extensions/dashboard/contract/cache_test.go new file mode 100644 index 00000000..7921f748 --- /dev/null +++ b/extensions/dashboard/contract/cache_test.go @@ -0,0 +1,47 @@ +package contract + +import ( + "testing" + "time" +) + +func TestGraphCache_HitMiss(t *testing.T) { + c := NewGraphCache(2, time.Minute) + key := GraphCacheKey{Route: "/users", PermissionsHash: "h1", ShellVersion: "v1"} + if _, ok := c.Get(key); ok { + t.Error("expected miss") + } + c.Put(key, &GraphNode{Intent: "page.shell"}) + got, ok := c.Get(key) + if !ok || got.Intent != "page.shell" { + t.Errorf("expected hit, got %+v ok=%v", got, ok) + } +} + +func TestGraphCache_Eviction(t *testing.T) { + c := NewGraphCache(2, time.Minute) + c.Put(GraphCacheKey{Route: "/a"}, &GraphNode{Intent: "a"}) + c.Put(GraphCacheKey{Route: "/b"}, &GraphNode{Intent: "b"}) + c.Put(GraphCacheKey{Route: "/c"}, &GraphNode{Intent: "c"}) // evicts /a + if _, ok := c.Get(GraphCacheKey{Route: "/a"}); ok { + t.Error("expected /a evicted") + } +} + +func TestGraphCache_TTLExpiry(t *testing.T) { + c := NewGraphCache(2, 10*time.Millisecond) + c.Put(GraphCacheKey{Route: "/x"}, &GraphNode{Intent: "x"}) + time.Sleep(20 * time.Millisecond) + if _, ok := c.Get(GraphCacheKey{Route: "/x"}); ok { + t.Error("expected ttl expiry") + } +} + +func TestGraphCache_BustAll(t *testing.T) { + c := NewGraphCache(4, time.Minute) + c.Put(GraphCacheKey{Route: "/a"}, &GraphNode{Intent: "a"}) + c.BustAll() + if _, ok := c.Get(GraphCacheKey{Route: "/a"}); ok { + t.Error("BustAll should clear") + } +} From 6733263fe140ff8e99de184931a52673d48cae9a Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 16:32:31 -0500 Subject: [PATCH 17/89] feat(dashboard/contract): audit emitter interface with log-based default --- extensions/dashboard/contract/audit.go | 52 +++++++++++++++++++++ extensions/dashboard/contract/audit_test.go | 34 ++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 extensions/dashboard/contract/audit.go create mode 100644 extensions/dashboard/contract/audit_test.go diff --git a/extensions/dashboard/contract/audit.go b/extensions/dashboard/contract/audit.go new file mode 100644 index 00000000..f1ef62e2 --- /dev/null +++ b/extensions/dashboard/contract/audit.go @@ -0,0 +1,52 @@ +package contract + +import ( + "context" + "fmt" + "io" + "time" +) + +// AuditRecord is one auditable command invocation. +type AuditRecord struct { + Time time.Time + Contributor string + Intent string + IntentVersion int + Subject string // resource id when known + User string // user identity (subject from UserInfo) + Result string // ok | error + LatencyMs int64 + Payload map[string]any // pre-redaction; subject to per-intent redaction list + CorrelationID string +} + +// AuditEmitter ships audit records to durable storage. Slice (b) wires the +// chronicle implementation; slice (a) ships log-based and noop variants. +type AuditEmitter interface { + Emit(ctx context.Context, rec AuditRecord) +} + +// NoopAuditEmitter is the disabled-audit implementation. +type NoopAuditEmitter struct{} + +func (NoopAuditEmitter) Emit(_ context.Context, _ AuditRecord) {} + +// NewLogAuditEmitter returns an emitter that writes a stable line format to w. +// Suitable for development and as a fallback when no chronicle backend is wired. +func NewLogAuditEmitter(w io.Writer) AuditEmitter { + return &logAuditEmitter{w: w} +} + +type logAuditEmitter struct { + w io.Writer +} + +func (e *logAuditEmitter) Emit(_ context.Context, rec AuditRecord) { + fmt.Fprintf(e.w, + "audit ts=%s contributor=%s intent=%s v=%d subject=%s user=%s result=%s latencyMs=%d corr=%s\n", + rec.Time.UTC().Format(time.RFC3339Nano), + rec.Contributor, rec.Intent, rec.IntentVersion, + rec.Subject, rec.User, rec.Result, rec.LatencyMs, rec.CorrelationID, + ) +} diff --git a/extensions/dashboard/contract/audit_test.go b/extensions/dashboard/contract/audit_test.go new file mode 100644 index 00000000..e2693ea5 --- /dev/null +++ b/extensions/dashboard/contract/audit_test.go @@ -0,0 +1,34 @@ +package contract + +import ( + "bytes" + "context" + "strings" + "testing" + "time" +) + +func TestLogAuditEmitter_FormatsRecord(t *testing.T) { + var buf bytes.Buffer + em := NewLogAuditEmitter(&buf) + em.Emit(context.Background(), AuditRecord{ + Time: time.Now(), + Contributor: "users", + Intent: "user.disable", + Subject: "u_42", + User: "admin@example.com", + Result: "ok", + LatencyMs: 12, + }) + out := buf.String() + for _, want := range []string{"users", "user.disable", "u_42", "admin@example.com", "ok"} { + if !strings.Contains(out, want) { + t.Errorf("audit output missing %q: %s", want, out) + } + } +} + +func TestNoopAuditEmitter_DoesNothing(t *testing.T) { + em := NoopAuditEmitter{} + em.Emit(context.Background(), AuditRecord{}) // must not panic +} From 67c02fb9b0fa17b225b3c038ecc90d19dbf75593 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 16:36:11 -0500 Subject: [PATCH 18/89] test(dashboard/contract): add manifest test helper for sibling packages --- extensions/dashboard/contract/testhelpers.go | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 extensions/dashboard/contract/testhelpers.go diff --git a/extensions/dashboard/contract/testhelpers.go b/extensions/dashboard/contract/testhelpers.go new file mode 100644 index 00000000..c9630694 --- /dev/null +++ b/extensions/dashboard/contract/testhelpers.go @@ -0,0 +1,9 @@ +package contract + +import "gopkg.in/yaml.v3" + +// UnmarshalManifestForTest is a test helper exposed for use by sibling packages. +// It is not part of the package's runtime API; production code should not call it. +func UnmarshalManifestForTest(b []byte, m *ContractManifest) error { + return yaml.Unmarshal(b, m) +} From d9538f3bc5f28f9e85d07a524ba43a471bc84e06 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 16:36:14 -0500 Subject: [PATCH 19/89] feat(dashboard/contract): POST handler with kind dispatch, version + capability checks --- .../dashboard/contract/transport/http.go | 199 ++++++++++++++++++ .../dashboard/contract/transport/http_test.go | 116 ++++++++++ 2 files changed, 315 insertions(+) create mode 100644 extensions/dashboard/contract/transport/http.go create mode 100644 extensions/dashboard/contract/transport/http_test.go diff --git a/extensions/dashboard/contract/transport/http.go b/extensions/dashboard/contract/transport/http.go new file mode 100644 index 00000000..736676f5 --- /dev/null +++ b/extensions/dashboard/contract/transport/http.go @@ -0,0 +1,199 @@ +// http.go +package transport + +import ( + "context" + "encoding/json" + "net/http" + "time" + + dashauth "github.com/xraph/forge/extensions/dashboard/auth" + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// Dispatcher routes a fully-validated request to an intent implementation. +// Slice (c) provides the binding from intent name to actual handlers. +type Dispatcher interface { + Dispatch(ctx context.Context, in contract.Request, p contract.Principal) (json.RawMessage, contract.ResponseMeta, error) +} + +// NilDispatcher is the safe default when no real Dispatcher has been wired. +// Every dispatch returns a CodeUnavailable contract error so that callers see +// a clear, kind-agnostic failure instead of a nil panic. +type NilDispatcher struct{} + +// Dispatch implements Dispatcher. +func (NilDispatcher) Dispatch(_ context.Context, _ contract.Request, _ contract.Principal) (json.RawMessage, contract.ResponseMeta, error) { + return nil, contract.ResponseMeta{}, &contract.Error{Code: contract.CodeUnavailable, Message: "no dispatcher configured"} +} + +// supportedEnvelopes is the set this slice's handler understands. +var supportedEnvelopes = map[string]bool{"v1": true} + +// NewHandler returns the POST /api/dashboard/{envelope} handler. +func NewHandler(reg contract.Registry, wreg contract.WardenRegistry, disp Dispatcher, audit contract.AuditEmitter) http.Handler { + if disp == nil { + disp = NilDispatcher{} + } + if audit == nil { + audit = contract.NoopAuditEmitter{} + } + return &handler{reg: reg, wreg: wreg, disp: disp, audit: audit} +} + +type handler struct { + reg contract.Registry + wreg contract.WardenRegistry + disp Dispatcher + audit contract.AuditEmitter +} + +func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, &contract.Error{Code: contract.CodeBadRequest, Message: "POST required"}) + return + } + defer r.Body.Close() + var req contract.Request + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, &contract.Error{Code: contract.CodeBadRequest, Message: "invalid JSON: " + err.Error()}) + return + } + if !supportedEnvelopes[req.Envelope] { + writeError(w, http.StatusBadRequest, &contract.Error{Code: contract.CodeUnsupportedVersion, Message: "envelope " + req.Envelope + " unsupported"}) + return + } + if err := validateKind(req); err != nil { + writeError(w, http.StatusBadRequest, &contract.Error{Code: contract.CodeBadRequest, Message: err.Error()}) + return + } + in, ok := h.reg.Intent(req.Contributor, req.Intent, intentVersionOrHighest(h.reg, req)) + if !ok { + writeError(w, http.StatusNotFound, &contract.Error{Code: contract.CodeNotFound, Message: "intent " + req.Intent + " not registered"}) + return + } + if !kindMatchesCapability(req.Kind, in.Capability) { + writeError(w, http.StatusBadRequest, &contract.Error{ + Code: contract.CodeBadRequest, + Message: "kind " + string(req.Kind) + " does not match intent capability " + string(in.Capability), + }) + return + } + if req.Kind == contract.KindCommand { + if req.IdempotencyKey == "" || req.CSRF == "" { + writeError(w, http.StatusBadRequest, &contract.Error{Code: contract.CodeBadRequest, Message: "command requires csrf and idempotencyKey"}) + return + } + } + + user := dashauth.UserFromContext(r.Context()) + p := contract.PrincipalFor(user) + + if !in.Requires.Allow(user, nil) { + writeError(w, http.StatusForbidden, &contract.Error{Code: contract.CodePermissionDenied}) + return + } + // Warden second pass when declared + if in.Requires.Warden != "" { + warden, ok := h.wreg.Get(in.Requires.Warden) + if !ok { + writeError(w, http.StatusInternalServerError, &contract.Error{Code: contract.CodeInternal, Message: "warden not registered"}) + return + } + dec, err := warden.Authorize(r.Context(), p, contract.Action{ + Contributor: req.Contributor, Intent: req.Intent, Kind: req.Kind, Capability: in.Capability, Resource: req.Params, + }) + if err != nil || !dec.Allow { + writeError(w, http.StatusForbidden, &contract.Error{Code: contract.CodePermissionDenied, Message: dec.Reason}) + return + } + } + + t0 := time.Now() + data, meta, err := h.disp.Dispatch(r.Context(), req, p) + latency := time.Since(t0) + + emitAudit(h.audit, req, in, p, err, latency) + + if err != nil { + writeError(w, http.StatusInternalServerError, asContractError(err)) + return + } + writeOK(w, contract.Response{ + OK: true, Envelope: req.Envelope, Kind: req.Kind, Data: data, Meta: meta, + }) +} + +func validateKind(req contract.Request) error { + switch req.Kind { + case contract.KindGraph, contract.KindQuery, contract.KindCommand: + return nil + case contract.KindSubscribe: + return errKind("subscribe is GET-only on /stream") + } + return errKind("unknown kind " + string(req.Kind)) +} + +func errKind(msg string) error { return &contract.Error{Code: contract.CodeBadRequest, Message: msg} } + +func kindMatchesCapability(k contract.Kind, c contract.Capability) bool { + switch k { + case contract.KindCommand: + return c == contract.CapWrite + case contract.KindQuery: + return c == contract.CapRead + case contract.KindGraph: + return c == contract.CapRender + } + return false +} + +func intentVersionOrHighest(reg contract.Registry, req contract.Request) int { + if req.IntentVersion != 0 { + return req.IntentVersion + } + v, _ := reg.HighestVersion(req.Contributor, req.Intent) + return v +} + +func emitAudit(em contract.AuditEmitter, req contract.Request, in contract.Intent, p contract.Principal, dispErr error, lat time.Duration) { + if in.Kind != contract.IntentKindCommand { + return + } + if in.Audit != nil && !*in.Audit { + return + } + user := "" + if p.User != nil { + user = p.User.Subject + } + result := "ok" + if dispErr != nil { + result = "error" + } + em.Emit(context.Background(), contract.AuditRecord{ + Time: time.Now(), Contributor: req.Contributor, Intent: req.Intent, + IntentVersion: in.Version, User: user, Result: result, LatencyMs: lat.Milliseconds(), + CorrelationID: req.Context.CorrelationID, + }) +} + +func asContractError(err error) *contract.Error { + if e, ok := err.(*contract.Error); ok { + return e + } + return &contract.Error{Code: contract.CodeInternal, Message: err.Error()} +} + +func writeOK(w http.ResponseWriter, r contract.Response) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(r) +} + +func writeError(w http.ResponseWriter, status int, e *contract.Error) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(contract.ErrorResponse{ + OK: false, Envelope: "v1", Error: e, + }) +} diff --git a/extensions/dashboard/contract/transport/http_test.go b/extensions/dashboard/contract/transport/http_test.go new file mode 100644 index 00000000..be99c935 --- /dev/null +++ b/extensions/dashboard/contract/transport/http_test.go @@ -0,0 +1,116 @@ +// http_test.go +package transport + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +type stubDispatcher struct { + called string + response json.RawMessage +} + +func (s *stubDispatcher) Dispatch(_ context.Context, in contract.Request, _ contract.Principal) (json.RawMessage, contract.ResponseMeta, error) { + s.called = string(in.Kind) + ":" + in.Intent + return s.response, contract.ResponseMeta{IntentVersion: in.IntentVersion}, nil +} + +func setupRegistry(t *testing.T) (contract.Registry, contract.WardenRegistry) { + t.Helper() + r := contract.NewRegistry() + src := ` +schemaVersion: 1 +contributor: { name: users, envelope: { supports: [v1], preferred: v1 } } +intents: + - { name: users.list, kind: query, version: 1, capability: read } + - { name: user.disable, kind: command, version: 1, capability: write } +` + var m contract.ContractManifest + if err := contract.UnmarshalManifestForTest([]byte(src), &m); err != nil { + t.Fatal(err) + } + if err := r.Register(&m); err != nil { + t.Fatal(err) + } + return r, contract.NewWardenRegistry() +} + +func TestHandler_DispatchesQuery(t *testing.T) { + reg, wreg := setupRegistry(t) + disp := &stubDispatcher{response: json.RawMessage(`{"users":[]}`)} + h := NewHandler(reg, wreg, disp, contract.NoopAuditEmitter{}) + + body, _ := json.Marshal(contract.Request{ + Envelope: "v1", Kind: contract.KindQuery, Contributor: "users", Intent: "users.list", IntentVersion: 1, + }) + req := httptest.NewRequest(http.MethodPost, "/api/dashboard/v1", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", w.Code, w.Body) + } + if disp.called != "query:users.list" { + t.Errorf("dispatcher not called: %s", disp.called) + } +} + +func TestHandler_RejectsKindCapabilityMismatch(t *testing.T) { + reg, wreg := setupRegistry(t) + h := NewHandler(reg, wreg, &stubDispatcher{}, contract.NoopAuditEmitter{}) + + // Send Kind=command for an intent whose Capability=read => mismatch + body, _ := json.Marshal(contract.Request{ + Envelope: "v1", Kind: contract.KindCommand, Contributor: "users", Intent: "users.list", IntentVersion: 1, + }) + req := httptest.NewRequest(http.MethodPost, "/api/dashboard/v1", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d body=%s", w.Code, w.Body) + } + if !strings.Contains(w.Body.String(), "BAD_REQUEST") { + t.Errorf("expected BAD_REQUEST in body: %s", w.Body) + } +} + +func TestHandler_UnsupportedVersion(t *testing.T) { + reg, wreg := setupRegistry(t) + h := NewHandler(reg, wreg, &stubDispatcher{}, contract.NoopAuditEmitter{}) + + body, _ := json.Marshal(contract.Request{ + Envelope: "v999", Kind: contract.KindQuery, Contributor: "users", Intent: "users.list", + }) + req := httptest.NewRequest(http.MethodPost, "/api/dashboard/v1", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d", w.Code) + } + if !strings.Contains(w.Body.String(), "UNSUPPORTED_VERSION") { + t.Errorf("expected UNSUPPORTED_VERSION: %s", w.Body) + } +} + +func TestHandler_CommandRequiresIdempotencyKey(t *testing.T) { + reg, wreg := setupRegistry(t) + h := NewHandler(reg, wreg, &stubDispatcher{}, contract.NoopAuditEmitter{}) + + body, _ := json.Marshal(contract.Request{ + Envelope: "v1", Kind: contract.KindCommand, Contributor: "users", Intent: "user.disable", IntentVersion: 1, + // CSRF and IdempotencyKey omitted + }) + req := httptest.NewRequest(http.MethodPost, "/api/dashboard/v1", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d", w.Code) + } +} From b8b9c506462a99f1ed1174b9ce373ea9f68748a2 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 16:38:41 -0500 Subject: [PATCH 20/89] feat(dashboard/contract): capabilities endpoint for version negotiation --- .../contract/transport/capabilities.go | 72 +++++++++++++++++++ .../contract/transport/capabilities_test.go | 34 +++++++++ 2 files changed, 106 insertions(+) create mode 100644 extensions/dashboard/contract/transport/capabilities.go create mode 100644 extensions/dashboard/contract/transport/capabilities_test.go diff --git a/extensions/dashboard/contract/transport/capabilities.go b/extensions/dashboard/contract/transport/capabilities.go new file mode 100644 index 00000000..63126433 --- /dev/null +++ b/extensions/dashboard/contract/transport/capabilities.go @@ -0,0 +1,72 @@ +// capabilities.go +package transport + +import ( + "encoding/json" + "net/http" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// CapabilitiesResponse is the wire shape for GET /capabilities. +type CapabilitiesResponse struct { + ShellEnvelopes []string `json:"shellEnvelopes"` + Contributors []ContributorCapability `json:"contributors"` +} + +// ContributorCapability is one contributor's negotiable surface. +type ContributorCapability struct { + Name string `json:"name"` + Envelopes []string `json:"envelopes"` + Intents []IntentCapability `json:"intents"` +} + +// IntentCapability summarises one intent's available versions. +type IntentCapability struct { + Name string `json:"name"` + Versions []IntentVersionStatus `json:"versions"` +} + +// IntentVersionStatus reports a single version + lifecycle status. +type IntentVersionStatus struct { + N int `json:"n"` + Status string `json:"status"` // active | deprecated + RemoveAfter string `json:"removeAfter,omitempty"` +} + +// NewCapabilitiesHandler returns the GET /capabilities handler. +func NewCapabilitiesHandler(reg contract.Registry, shellEnvelopes []string) http.Handler { + return &capabilitiesHandler{reg: reg, shellEnvelopes: shellEnvelopes} +} + +type capabilitiesHandler struct { + reg contract.Registry + shellEnvelopes []string +} + +func (h *capabilitiesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "GET required", http.StatusMethodNotAllowed) + return + } + resp := CapabilitiesResponse{ShellEnvelopes: h.shellEnvelopes} + for _, m := range h.reg.All() { + c := ContributorCapability{Name: m.Contributor.Name, Envelopes: m.Contributor.Envelope.Supports} + // Group intents by name; collect versions. + byName := map[string][]IntentVersionStatus{} + for _, in := range m.Intents { + s := IntentVersionStatus{N: in.Version, Status: "active"} + if in.Deprecated != nil { + s.Status = "deprecated" + s.RemoveAfter = in.Deprecated.RemoveAfter + } + byName[in.Name] = append(byName[in.Name], s) + } + for name, versions := range byName { + c.Intents = append(c.Intents, IntentCapability{Name: name, Versions: versions}) + } + resp.Contributors = append(resp.Contributors, c) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} diff --git a/extensions/dashboard/contract/transport/capabilities_test.go b/extensions/dashboard/contract/transport/capabilities_test.go new file mode 100644 index 00000000..619acd2c --- /dev/null +++ b/extensions/dashboard/contract/transport/capabilities_test.go @@ -0,0 +1,34 @@ +// capabilities_test.go +package transport + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +func TestCapabilities_ReportsRegisteredContributors(t *testing.T) { + reg, _ := setupRegistry(t) + h := NewCapabilitiesHandler(reg, []string{"v1"}) + req := httptest.NewRequest(http.MethodGet, "/api/dashboard/v1/capabilities", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + var got CapabilitiesResponse + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if len(got.Contributors) != 1 || got.Contributors[0].Name != "users" { + t.Errorf("contributors = %+v", got.Contributors) + } + intents := got.Contributors[0].Intents + if len(intents) != 2 { + t.Errorf("expected 2 intents in capabilities, got %d", len(intents)) + } + _ = contract.IntentKindQuery +} From aefecc8a1e4ab2ab92dd2f54c42aa51086cc42fc Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 16:43:59 -0500 Subject: [PATCH 21/89] feat(dashboard/contract): multiplexed SSE stream broker with subscription mode warnings --- .../dashboard/contract/transport/control.go | 102 +++++++++++ .../dashboard/contract/transport/stream.go | 123 +++++++++++++ .../contract/transport/stream_test.go | 172 ++++++++++++++++++ 3 files changed, 397 insertions(+) create mode 100644 extensions/dashboard/contract/transport/control.go create mode 100644 extensions/dashboard/contract/transport/stream.go create mode 100644 extensions/dashboard/contract/transport/stream_test.go diff --git a/extensions/dashboard/contract/transport/control.go b/extensions/dashboard/contract/transport/control.go new file mode 100644 index 00000000..49ef95dc --- /dev/null +++ b/extensions/dashboard/contract/transport/control.go @@ -0,0 +1,102 @@ +// control.go +package transport + +import ( + "context" + "encoding/json" + "log" + "net/http" + + dashauth "github.com/xraph/forge/extensions/dashboard/auth" + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// ControlMessage is one client request on POST /stream/control. +type ControlMessage struct { + StreamID string `json:"streamID"` + Op string `json:"op"` // "subscribe" | "unsubscribe" + SubscriptionID string `json:"subscriptionID"` + Contributor string `json:"contributor,omitempty"` + Intent string `json:"intent,omitempty"` + Params map[string]contract.ParamSource `json:"params,omitempty"` +} + +// ServeControl handles POST /api/dashboard/v1/stream/control. +func (b *StreamBroker) ServeControl(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + var msg ControlMessage + if err := json.NewDecoder(r.Body).Decode(&msg); err != nil { + http.Error(w, "invalid control message", http.StatusBadRequest) + return + } + b.mu.Lock() + conn, ok := b.streams[msg.StreamID] + b.mu.Unlock() + if !ok { + http.Error(w, "unknown streamID", http.StatusNotFound) + return + } + switch msg.Op { + case "subscribe": + in, ok := b.reg.Intent(msg.Contributor, msg.Intent, intentVersionForSubscribe(b.reg, msg)) + if !ok || in.Kind != contract.IntentKindSubscription { + http.Error(w, "intent not a subscription", http.StatusBadRequest) + return + } + p := contract.PrincipalFor(conn.user) + if !in.Requires.Allow(conn.user, nil) { + http.Error(w, "permission denied", http.StatusForbidden) + return + } + ctx, cancel := context.WithCancel(r.Context()) + ch, stop, err := b.source.Subscribe(ctx, p, msg.Contributor, in, msg.Params) + if err != nil { + cancel() + http.Error(w, "subscribe failed: "+err.Error(), http.StatusInternalServerError) + return + } + conn.mu.Lock() + conn.subs[msg.SubscriptionID] = subscription{cancel: func() { stop(); cancel() }} + conn.mu.Unlock() + conn.wg.Add(1) + go func() { + defer conn.wg.Done() + defer cancel() + for ev := range ch { + if !b.allowsEvent(conn.user, in) { + continue + } + if ev.Mode != "" && in.Mode != "" && ev.Mode != in.Mode { + log.Printf("contract/stream: %s/%s mode mismatch declared=%s emitted=%s", msg.Contributor, msg.Intent, in.Mode, ev.Mode) + } + if err := b.writeEvent(conn, msg.SubscriptionID, ev); err != nil { + return + } + } + }() + w.WriteHeader(http.StatusOK) + case "unsubscribe": + conn.mu.Lock() + s, ok := conn.subs[msg.SubscriptionID] + if ok { + s.cancel() + delete(conn.subs, msg.SubscriptionID) + } + conn.mu.Unlock() + w.WriteHeader(http.StatusOK) + default: + http.Error(w, "unknown op", http.StatusBadRequest) + } +} + +// allowsEvent re-checks the user's predicate. Real per-event Warden invocation +// with a TTL cache lives in slice (b); slice (a) re-evaluates the YAML predicate +// against the current connection's UserInfo. +func (b *StreamBroker) allowsEvent(user *dashauth.UserInfo, in contract.Intent) bool { + return in.Requires.Allow(user, nil) +} + +func intentVersionForSubscribe(reg contract.Registry, msg ControlMessage) int { + v, _ := reg.HighestVersion(msg.Contributor, msg.Intent) + return v +} diff --git a/extensions/dashboard/contract/transport/stream.go b/extensions/dashboard/contract/transport/stream.go new file mode 100644 index 00000000..be7e80e5 --- /dev/null +++ b/extensions/dashboard/contract/transport/stream.go @@ -0,0 +1,123 @@ +// stream.go +package transport + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "sync" + + "github.com/google/uuid" + + dashauth "github.com/xraph/forge/extensions/dashboard/auth" + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// SubscriptionSource is the upstream events feeder. Slice (c) implements one +// for each contributor's subscription intents. +type SubscriptionSource interface { + Subscribe(ctx context.Context, p contract.Principal, contributor string, intent contract.Intent, params map[string]contract.ParamSource) (<-chan contract.StreamEvent, func(), error) +} + +// StreamBroker manages active SSE connections + their subscriptions. +type StreamBroker struct { + reg contract.Registry + wreg contract.WardenRegistry + source SubscriptionSource + + mu sync.Mutex + streams map[string]*streamConn +} + +type streamConn struct { + id string + w http.ResponseWriter + flusher http.Flusher + user *dashauth.UserInfo + subs map[string]subscription // keyed by subscriptionID + mu sync.Mutex + wg sync.WaitGroup // tracks per-event goroutines so ServeStream can drain on close +} + +type subscription struct { + cancel func() +} + +// NewStreamBroker returns a broker bound to a registry, warden registry, and source. +func NewStreamBroker(reg contract.Registry, wreg contract.WardenRegistry, source SubscriptionSource) *StreamBroker { + return &StreamBroker{ + reg: reg, + wreg: wreg, + source: source, + streams: map[string]*streamConn{}, + } +} + +// ServeStream implements GET /api/dashboard/v1/stream. +func (b *StreamBroker) ServeStream(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "stream unsupported", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + id := uuid.NewString() + conn := &streamConn{ + id: id, w: w, flusher: flusher, + user: dashauth.UserFromContext(r.Context()), + subs: map[string]subscription{}, + } + b.mu.Lock() + b.streams[id] = conn + b.mu.Unlock() + defer func() { + b.mu.Lock() + delete(b.streams, id) + b.mu.Unlock() + conn.mu.Lock() + for _, s := range conn.subs { + s.cancel() + } + conn.mu.Unlock() + // Wait for all per-event goroutines to drain so callers (and tests) + // observing ServeStream's return know no further writes will occur. + conn.wg.Wait() + }() + + // Send a hello event so the client learns its streamID + conn.mu.Lock() + fmt.Fprintf(w, "event: hello\ndata: {\"streamID\":%q}\n\n", id) + flusher.Flush() + conn.mu.Unlock() + + <-r.Context().Done() +} + +// SnapshotIDs returns currently-active stream IDs (test helper / introspection). +func (b *StreamBroker) SnapshotIDs() []string { + b.mu.Lock() + defer b.mu.Unlock() + out := make([]string, 0, len(b.streams)) + for id := range b.streams { + out = append(out, id) + } + return out +} + +func (b *StreamBroker) writeEvent(conn *streamConn, subID string, ev contract.StreamEvent) error { + payload, err := json.Marshal(ev) + if err != nil { + return err + } + conn.mu.Lock() + defer conn.mu.Unlock() + if _, err := fmt.Fprintf(conn.w, "event: %s\ndata: %s\n\n", subID, payload); err != nil { + return err + } + conn.flusher.Flush() + return nil +} diff --git a/extensions/dashboard/contract/transport/stream_test.go b/extensions/dashboard/contract/transport/stream_test.go new file mode 100644 index 00000000..107ca1de --- /dev/null +++ b/extensions/dashboard/contract/transport/stream_test.go @@ -0,0 +1,172 @@ +// stream_test.go +package transport + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "log" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +type stubSource struct { + events chan contract.StreamEvent +} + +func (s *stubSource) Subscribe(_ context.Context, _ contract.Principal, _ string, _ contract.Intent, _ map[string]contract.ParamSource) (<-chan contract.StreamEvent, func(), error) { + stop := func() { close(s.events) } + return s.events, stop, nil +} + +func registerSubscriptionFixture(t *testing.T, reg contract.Registry) { + t.Helper() + src := ` +schemaVersion: 1 +contributor: { name: feeds, envelope: { supports: [v1], preferred: v1 } } +intents: + - { name: audit.tail, kind: subscription, version: 1, capability: read, mode: append } +` + var feedsM contract.ContractManifest + if err := contract.UnmarshalManifestForTest([]byte(src), &feedsM); err != nil { + t.Fatal(err) + } + if err := reg.Register(&feedsM); err != nil { + t.Fatal(err) + } +} + +func TestStream_ControlSubscribeAndDeliver(t *testing.T) { + reg, wreg := setupRegistry(t) + registerSubscriptionFixture(t, reg) + + source := &stubSource{events: make(chan contract.StreamEvent, 4)} + broker := NewStreamBroker(reg, wreg, source) + + // Open the stream with a cancellable context so we can tear it down + // cleanly before reading the recorder body (avoids data race on body). + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + streamReq := httptest.NewRequest(http.MethodGet, "/api/dashboard/v1/stream", nil).WithContext(ctx) + streamW := httptest.NewRecorder() + streamDone := make(chan struct{}) + go func() { + broker.ServeStream(streamW, streamReq) + close(streamDone) + }() + time.Sleep(20 * time.Millisecond) // allow registration to land + + ids := broker.SnapshotIDs() + if len(ids) == 0 { + t.Fatalf("no active streams") + } + + // Subscribe via control + cmd, _ := json.Marshal(ControlMessage{ + StreamID: ids[0], + Op: "subscribe", Contributor: "feeds", Intent: "audit.tail", SubscriptionID: "s1", + }) + ctlReq := httptest.NewRequest(http.MethodPost, "/api/dashboard/v1/stream/control", bytes.NewReader(cmd)) + ctlW := httptest.NewRecorder() + broker.ServeControl(ctlW, ctlReq) + if ctlW.Code != http.StatusOK { + t.Fatalf("control status = %d body=%s", ctlW.Code, ctlW.Body) + } + + // Push an event + source.events <- contract.StreamEvent{Intent: "audit.tail", Mode: contract.ModeAppend, Payload: json.RawMessage(`{"line":"hi"}`), Seq: 1} + time.Sleep(20 * time.Millisecond) + + // Tear down the stream so the broker goroutine stops writing to streamW. + // ServeStream's deferred cancel will run all subscription cancels, which + // closes the source channel and exits the per-event goroutine. + cancel() + <-streamDone + + // Validate the SSE body contains the event + body := streamW.Body.String() + if !strings.Contains(body, `"intent":"audit.tail"`) || !strings.Contains(body, `"line":"hi"`) { + t.Errorf("stream did not deliver event: %s", body) + } + // And the event header carries the subscription ID + scanner := bufio.NewScanner(strings.NewReader(body)) + hasEventID := false + for scanner.Scan() { + if strings.HasPrefix(scanner.Text(), "event: s1") { + hasEventID = true + break + } + } + if !hasEventID { + t.Error("expected event: s1 line in stream") + } +} + +func TestStream_ModeMismatch_DeliversAndLogs(t *testing.T) { + reg, wreg := setupRegistry(t) + registerSubscriptionFixture(t, reg) + + source := &stubSource{events: make(chan contract.StreamEvent, 4)} + broker := NewStreamBroker(reg, wreg, source) + + // Capture log output + var logBuf bytes.Buffer + origOut := log.Writer() + origFlags := log.Flags() + log.SetOutput(&logBuf) + log.SetFlags(0) + defer func() { + log.SetOutput(origOut) + log.SetFlags(origFlags) + }() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + streamReq := httptest.NewRequest(http.MethodGet, "/api/dashboard/v1/stream", nil).WithContext(ctx) + streamW := httptest.NewRecorder() + streamDone := make(chan struct{}) + go func() { + broker.ServeStream(streamW, streamReq) + close(streamDone) + }() + time.Sleep(20 * time.Millisecond) + + ids := broker.SnapshotIDs() + if len(ids) == 0 { + t.Fatalf("no active streams") + } + + cmd, _ := json.Marshal(ControlMessage{ + StreamID: ids[0], + Op: "subscribe", Contributor: "feeds", Intent: "audit.tail", SubscriptionID: "s2", + }) + ctlReq := httptest.NewRequest(http.MethodPost, "/api/dashboard/v1/stream/control", bytes.NewReader(cmd)) + ctlW := httptest.NewRecorder() + broker.ServeControl(ctlW, ctlReq) + if ctlW.Code != http.StatusOK { + t.Fatalf("control status = %d body=%s", ctlW.Code, ctlW.Body) + } + + // Intent declares mode=append; emit an event with mode=replace + source.events <- contract.StreamEvent{Intent: "audit.tail", Mode: contract.ModeReplace, Payload: json.RawMessage(`{"snapshot":true}`), Seq: 1} + time.Sleep(20 * time.Millisecond) + + // Tear down before reading the recorder body + cancel() + <-streamDone + + body := streamW.Body.String() + if !strings.Contains(body, `"snapshot":true`) { + t.Errorf("event not delivered despite mode mismatch: %s", body) + } + logged := logBuf.String() + if !strings.Contains(logged, "mode mismatch") { + t.Errorf("expected mode-mismatch warning in log output, got: %q", logged) + } +} From 9899a9802f54bdf08d319d2ae0df4f536f698258 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 16:50:54 -0500 Subject: [PATCH 22/89] feat(dashboard): allow legacy Manifest to carry a contract manifest Adds an optional Contract *contract.ContractManifest field to the legacy contributor.Manifest so a contributor can publish a contract-style manifest in parallel with the existing templ-based one. Tagged "contract,omitempty" so legacy contributors that don't opt in keep their JSON payloads unchanged. Round-trip + omitempty tests exercise the new field. --- extensions/dashboard/contributor/manifest.go | 10 ++ .../contributor/manifest_contract_test.go | 102 ++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 extensions/dashboard/contributor/manifest_contract_test.go diff --git a/extensions/dashboard/contributor/manifest.go b/extensions/dashboard/contributor/manifest.go index 1b655634..1096ab6e 100644 --- a/extensions/dashboard/contributor/manifest.go +++ b/extensions/dashboard/contributor/manifest.go @@ -4,6 +4,8 @@ import ( "encoding/json" "github.com/a-h/templ" + + "github.com/xraph/forge/extensions/dashboard/contract" ) // Manifest describes a contributor's capabilities, navigation, widgets, settings, and more. @@ -30,6 +32,14 @@ type Manifest struct { TopbarConfig *TopbarConfig `json:"topbar_config,omitempty"` // per-extension topbar customization (extension layout only) ExtensionIcon string `json:"extension_icon,omitempty"` // icon/logo URL for the app grid navigator + // Contract is an optional contract-style manifest published alongside the + // legacy templ-based manifest. When non-nil, the dashboard extension + // registers it with the contract registry so the new envelope-based + // transport (POST /api/dashboard/v1) can dispatch intents on this + // contributor's behalf. Legacy contributors that have not migrated leave + // this nil. + Contract *contract.ContractManifest `json:"contract,omitempty"` + // SidebarHeaderContent is custom content rendered in the extension sidebar // header below the branding. Used for app switchers, status indicators, etc. SidebarHeaderContent templ.Component `json:"-"` diff --git a/extensions/dashboard/contributor/manifest_contract_test.go b/extensions/dashboard/contributor/manifest_contract_test.go new file mode 100644 index 00000000..5defe5e2 --- /dev/null +++ b/extensions/dashboard/contributor/manifest_contract_test.go @@ -0,0 +1,102 @@ +package contributor + +import ( + "encoding/json" + "testing" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// TestManifest_HasContractField verifies that a Manifest carrying a contract +// manifest round-trips through JSON without losing the embedded payload — i.e. +// that the new optional Contract field is wired correctly with the right JSON +// tag and is reachable by callers that consume manifests over the wire. +func TestManifest_HasContractField(t *testing.T) { + t.Parallel() + + cm := &contract.ContractManifest{ + SchemaVersion: 1, + Contributor: contract.Contributor{ + Name: "billing", + Envelope: contract.EnvelopeSupport{ + Supports: []string{"v1"}, + Preferred: "v1", + }, + }, + Intents: []contract.Intent{ + { + Name: "billing.invoice.list", + Kind: contract.IntentKindQuery, + Version: 1, + Capability: contract.CapRead, + }, + }, + } + + m := &Manifest{ + Name: "billing", + DisplayName: "Billing", + Version: "1.0.0", + Contract: cm, + } + + // Round-trip the manifest through JSON to confirm the Contract field + // serialises under the expected tag and decodes back to an equivalent value. + data, err := json.Marshal(m) + if err != nil { + t.Fatalf("marshal manifest: %v", err) + } + + // The raw payload must contain the "contract" key — guards against a + // silent typo on the json struct tag. + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal raw: %v", err) + } + if _, ok := raw["contract"]; !ok { + t.Fatalf("expected json key %q in marshalled manifest, got keys: %v", "contract", keysOf(raw)) + } + + var got Manifest + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal manifest: %v", err) + } + + if got.Contract == nil { + t.Fatal("decoded manifest has nil Contract; expected populated value") + } + if got.Contract.Contributor.Name != cm.Contributor.Name { + t.Errorf("contributor name = %q, want %q", got.Contract.Contributor.Name, cm.Contributor.Name) + } + if len(got.Contract.Intents) != 1 || got.Contract.Intents[0].Name != "billing.invoice.list" { + t.Errorf("intents not preserved: %#v", got.Contract.Intents) + } +} + +// TestManifest_ContractOmittedWhenNil verifies the omitempty tag — manifests +// that don't opt into the contract path keep their JSON payload free of the +// contract key. +func TestManifest_ContractOmittedWhenNil(t *testing.T) { + t.Parallel() + + m := &Manifest{Name: "legacy", DisplayName: "Legacy", Version: "1.0.0"} + data, err := json.Marshal(m) + if err != nil { + t.Fatalf("marshal manifest: %v", err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal raw: %v", err) + } + if _, ok := raw["contract"]; ok { + t.Errorf("expected contract key to be omitted when nil; got keys: %v", keysOf(raw)) + } +} + +func keysOf(m map[string]json.RawMessage) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} From 1e05dc208f51d4b1bae92bf22d364696cd13092f Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 16:51:12 -0500 Subject: [PATCH 23/89] feat(dashboard): wire contract endpoints and contributor registration Hooks the contract package into the running dashboard extension: - New Extension fields hold a contract.Registry, contract.WardenRegistry, and contract.AuditEmitter, all initialised in NewExtension. The streamBroker stays nil in slice (a); slice (c) provides the SubscriptionSource needed to instantiate it. - registerRoutes now mounts POST /api/dashboard/v1 and GET /api/dashboard/v1/capabilities alongside the existing JSON API. When a streamBroker is present, the SSE stream + control routes also register. The stream route uses router.GET (not router.EventStream) because StreamBroker.ServeStream owns its own SSE framing as an http.HandlerFunc, while router.EventStream's SSEHandler shape (func(Context, Stream) error) doesn't match. - RegisterContributor and the auto-discovery + remote-upsert paths now mirror any contract manifest published on the legacy Manifest.Contract field into the contract registry, validating against the warden registry first via loader.Validate. Explicit-add paths fail closed (validation error rolls back the legacy registration); auto-discovery logs and continues. CSRF + idempotency header validation and a real Dispatcher are deferred to slices (b) and (c); slice (a) wires transport.NilDispatcher so every intent dispatch returns CodeUnavailable instead of nil-panicking. --- extensions/dashboard/extension.go | 124 +++++++++++++++++++++++++++++- 1 file changed, 121 insertions(+), 3 deletions(-) diff --git a/extensions/dashboard/extension.go b/extensions/dashboard/extension.go index 04cf153b..6b1a1c84 100644 --- a/extensions/dashboard/extension.go +++ b/extensions/dashboard/extension.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "os" "strings" "sync" "time" @@ -18,6 +19,9 @@ import ( dashassets "github.com/xraph/forge/extensions/dashboard/assets" dashauth "github.com/xraph/forge/extensions/dashboard/auth" "github.com/xraph/forge/extensions/dashboard/collector" + "github.com/xraph/forge/extensions/dashboard/contract" + "github.com/xraph/forge/extensions/dashboard/contract/loader" + "github.com/xraph/forge/extensions/dashboard/contract/transport" "github.com/xraph/forge/extensions/dashboard/contributor" dashboarddiscovery "github.com/xraph/forge/extensions/dashboard/discovery" "github.com/xraph/forge/extensions/dashboard/handlers" @@ -73,6 +77,18 @@ type Extension struct { footerActions []shell.UserDropdownAction notifiableContributors []contributor.NotifiableContributor + // Contract-track state — published alongside the legacy contributor + // registry. The dashboard contract provides an envelope-based transport + // (POST /api/dashboard/v1) and an audit/warden pipeline. These fields are + // always non-nil after construction; the SSE-style streamBroker is left + // nil in slice (a) — a later slice provides the SubscriptionSource needed + // to instantiate it, at which point the stream + control routes become + // active automatically. + contractRegistry contract.Registry + wardenRegistry contract.WardenRegistry + streamBroker *transport.StreamBroker + auditEmitter contract.AuditEmitter + // remoteWatchers tracks long-running goroutines that maintain explicit // remote-contributor registrations (set up via WatchRemoteContributor). // Each entry's cancel func is invoked from Stop so watchers shut down @@ -104,8 +120,11 @@ func NewExtension(opts ...ConfigOption) forge.Extension { ) return &Extension{ - BaseExtension: base, - config: config, + BaseExtension: base, + config: config, + contractRegistry: contract.NewRegistry(), + wardenRegistry: contract.NewWardenRegistry(), + auditEmitter: contract.NewLogAuditEmitter(os.Stdout), } } @@ -412,6 +431,20 @@ func (e *Extension) discoverExtensionContributors(ctx context.Context) { } } + // Mirror any contract manifest published alongside the legacy + // manifest into the contract registry. Auto-discovery is a + // best-effort path, so a failure here is logged rather than + // rolled back — the legacy registration stays useful even if + // the contract handshake is malformed. + if mn := c.Manifest(); mn != nil && mn.Contract != nil { + if err := e.registerContractManifest(mn.Contract); err != nil { + e.Logger().Error("failed to register contract manifest", + forge.F("extension", ext.Name()), + forge.F("error", err.Error()), + ) + } + } + e.Logger().Info("auto-discovered dashboard contributor", forge.F("extension", ext.Name()), forge.F("contributor", c.Manifest().Name), @@ -591,7 +624,39 @@ func (e *Extension) TraceStore() *collector.TraceStore { // RegisterContributor registers a local contributor with the dashboard. // This is the primary API for extensions to contribute UI to the dashboard. func (e *Extension) RegisterContributor(c contributor.LocalContributor) error { - return e.registry.RegisterLocal(c) + if err := e.registry.RegisterLocal(c); err != nil { + return err + } + + // If the legacy manifest also publishes a contract manifest, register it + // with the contract registry. Validation runs first against the warden + // registry so unknown predicates surface here rather than at dispatch + // time. Failures are reported back to the caller — registration is + // transactional from the caller's perspective. + if mn := c.Manifest(); mn != nil && mn.Contract != nil { + if err := e.registerContractManifest(mn.Contract); err != nil { + return err + } + } + + return nil +} + +// registerContractManifest validates and registers a contract manifest with +// the contract registry. Shared between the local-contributor add path and +// the remote-contributor upsert path so both legacy entry points keep the +// contract registry in sync. +func (e *Extension) registerContractManifest(cm *contract.ContractManifest) error { + if cm == nil || e.contractRegistry == nil { + return nil + } + if err := loader.Validate(cm, e.wardenRegistry); err != nil { + return fmt.Errorf("contract validation: %w", err) + } + if err := e.contractRegistry.Register(cm); err != nil { + return fmt.Errorf("contract register: %w", err) + } + return nil } // AddRemoteContributor fetches a remote contributor's manifest from the given @@ -872,6 +937,19 @@ func (e *Extension) upsertRemoteContributor(baseURL, apiKey string, manifest *co return fmt.Errorf("dashboard: register remote contributor %q: %w", manifest.Name, err) } + // Mirror the contract manifest into the contract registry when the remote + // publishes one. Same validate-then-register path as local contributors so + // remote-published contracts get the same warden checks. + if manifest != nil && manifest.Contract != nil { + if err := e.registerContractManifest(manifest.Contract); err != nil { + // Best-effort: log and unwind the legacy registration so we don't + // leave a partial state. The remote watcher will retry on the + // next refresh tick. + _ = e.registry.Unregister(manifest.Name) //nolint:errcheck // best-effort cleanup + return fmt.Errorf("dashboard: register remote contributor %q: %w", manifest.Name, err) + } + } + return nil } @@ -1215,6 +1293,25 @@ func (e *Extension) registerRoutes() { must(router.GET(base+"/api/trace-detail", handlers.HandleAPITraceDetail(deps))) must(router.GET(base+"/api/extensions", handlers.HandleAPIExtensions(deps))) + // 3b. Dashboard contract envelope endpoints. These run alongside the + // legacy JSON API and are gated on the contract registry being + // initialised. The stream + control routes only register when a + // StreamBroker is wired (slice (c) supplies the SubscriptionSource). + // + // Note on EventStream: the contract StreamBroker manages its own SSE + // framing in ServeStream (an http.HandlerFunc), so we register it via + // router.GET rather than router.EventStream — the latter expects the + // SSEHandler shape (func(Context, Stream) error), which the broker + // deliberately doesn't adopt because it owns the per-event fan-out. + if e.contractRegistry != nil { + must(router.POST(base+"/api/dashboard/v1", e.handleContractPOST())) + must(router.GET(base+"/api/dashboard/v1/capabilities", e.handleContractCapabilities())) + if e.streamBroker != nil { + must(router.GET(base+"/api/dashboard/v1/stream", http.HandlerFunc(e.streamBroker.ServeStream))) + must(router.POST(base+"/api/dashboard/v1/stream/control", http.HandlerFunc(e.streamBroker.ServeControl))) + } + } + // 4. Export endpoints (stay on forge.Router) if e.config.EnableExport { must(router.GET(base+"/export/json", handlers.HandleExportJSON(deps))) @@ -1291,6 +1388,27 @@ func (e *Extension) registerRoutes() { ) } +// handleContractPOST returns the http.HandlerFunc that serves +// POST /api/dashboard/v1 — the contract envelope endpoint. The handler +// validates the inbound envelope, looks up the intent in the contract +// registry, and dispatches via the configured Dispatcher. Slice (a) wires the +// safe NilDispatcher so every dispatch returns CodeUnavailable until slice +// (c) plugs in real intent handlers; this keeps the surface area testable +// end-to-end without leaking nil panics. +func (e *Extension) handleContractPOST() http.HandlerFunc { + h := transport.NewHandler(e.contractRegistry, e.wardenRegistry, transport.NilDispatcher{}, e.auditEmitter) + return h.ServeHTTP +} + +// handleContractCapabilities returns the http.HandlerFunc that serves +// GET /api/dashboard/v1/capabilities — the discovery endpoint that advertises +// which envelope versions the shell supports and which contributors are +// currently registered with contract manifests. The shell envelope list here +// must stay in sync with transport.NewHandler's supported set. +func (e *Extension) handleContractCapabilities() http.HandlerFunc { + return transport.NewCapabilitiesHandler(e.contractRegistry, []string{"v1"}).ServeHTTP +} + // mountEmbeddedAssets iterates local contributors and mounts static asset handlers // for any EmbeddedContributor instances. This allows embedded dashboard UIs to serve // their CSS, JS, and image files. From 4b05bb89ea06d4be5496f6e7a857380ca8f53fd3 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 16:53:52 -0500 Subject: [PATCH 24/89] feat(cmd): add dashboard-contract-probe CLI for raw envelope testing --- cmd/dashboard-contract-probe/main.go | 41 ++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 cmd/dashboard-contract-probe/main.go diff --git a/cmd/dashboard-contract-probe/main.go b/cmd/dashboard-contract-probe/main.go new file mode 100644 index 00000000..6c3e503f --- /dev/null +++ b/cmd/dashboard-contract-probe/main.go @@ -0,0 +1,41 @@ +// main.go +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +func main() { + base := flag.String("base", "http://localhost:8080", "dashboard base URL (no trailing slash)") + kind := flag.String("kind", "query", "graph | query | command") + contributor := flag.String("contributor", "", "contributor name") + intent := flag.String("intent", "", "intent name") + payload := flag.String("payload", "{}", "JSON payload") + csrf := flag.String("csrf", "", "CSRF token (required for command)") + idem := flag.String("idem", "", "idempotency key (required for command)") + flag.Parse() + + req := contract.Request{ + Envelope: "v1", Kind: contract.Kind(*kind), + Contributor: *contributor, Intent: *intent, + Payload: json.RawMessage(*payload), + CSRF: *csrf, IdempotencyKey: *idem, + } + body, _ := json.Marshal(req) + resp, err := http.Post(*base+"/api/dashboard/v1", "application/json", bytes.NewReader(body)) + if err != nil { + fmt.Fprintln(os.Stderr, "request:", err) + os.Exit(1) + } + defer resp.Body.Close() + out, _ := io.ReadAll(resp.Body) + fmt.Printf("HTTP %d\n%s\n", resp.StatusCode, out) +} From c5f5c36b68a2ef18ecbf53792b95e4828b5339d1 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 16:55:29 -0500 Subject: [PATCH 25/89] test(dashboard/contract): end-to-end fixture + driver covering register/validate/build/extend --- extensions/dashboard/contract/e2e_test.go | 76 +++++++++++++++++++ .../testdata/fixture_auth_extends.yaml | 11 +++ .../contract/testdata/fixture_users.yaml | 51 +++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 extensions/dashboard/contract/e2e_test.go create mode 100644 extensions/dashboard/contract/testdata/fixture_auth_extends.yaml create mode 100644 extensions/dashboard/contract/testdata/fixture_users.yaml diff --git a/extensions/dashboard/contract/e2e_test.go b/extensions/dashboard/contract/e2e_test.go new file mode 100644 index 00000000..b0732cf7 --- /dev/null +++ b/extensions/dashboard/contract/e2e_test.go @@ -0,0 +1,76 @@ +// e2e_test.go +package contract_test + +import ( + "context" + "os" + "testing" + + dashauth "github.com/xraph/forge/extensions/dashboard/auth" + "github.com/xraph/forge/extensions/dashboard/contract" + "github.com/xraph/forge/extensions/dashboard/contract/loader" +) + +func loadFixture(t *testing.T, path string) *contract.ContractManifest { + t.Helper() + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + m, err := loader.Load(f, path) + if err != nil { + t.Fatal(err) + } + return m +} + +func TestE2E_RegisterValidateBuild(t *testing.T) { + users := loadFixture(t, "testdata/fixture_users.yaml") + authExt := loadFixture(t, "testdata/fixture_auth_extends.yaml") + + wreg := contract.NewWardenRegistry() + if err := loader.Validate(users, wreg); err != nil { + t.Fatalf("validate users: %v", err) + } + if err := loader.Validate(authExt, wreg); err != nil { + t.Fatalf("validate authExt: %v", err) + } + + reg := contract.NewRegistry() + if err := reg.Register(users); err != nil { + t.Fatalf("register users: %v", err) + } + if err := reg.Register(authExt); err != nil { + t.Fatalf("register authExt: %v", err) + } + + build := contract.NewGraphBuilder(reg, wreg) + admin := &dashauth.UserInfo{Subject: "alice", Roles: []string{"admin"}, Scopes: []string{"users.read", "users.write"}} + got, err := build.Build(context.Background(), "users", "/users", contract.PrincipalFor(admin)) + if err != nil { + t.Fatalf("build admin: %v", err) + } + // admin should see the disable action + actions := got.Slots["main"][0].Slots["rowActions"] + if len(actions) != 1 || actions[0].Op != "user.disable" { + t.Errorf("admin actions wrong: %+v", actions) + } + // extension should be merged: detailDrawer.fields has 2 form.fields + fields := got.Slots["main"][0].Slots["detailDrawer"][0].Slots["fields"] + if len(fields) != 2 { + t.Errorf("expected 2 fields after extension merge, got %d", len(fields)) + } + + // viewer sees no row actions + viewer := &dashauth.UserInfo{Subject: "bob", Roles: []string{"viewer"}, Scopes: []string{"users.read"}} + got2, _ := build.Build(context.Background(), "users", "/users", contract.PrincipalFor(viewer)) + if got2 == nil { + t.Skip("viewer filtered fully") // depends on resource.list visibleWhen + return + } + actions2 := got2.Slots["main"][0].Slots["rowActions"] + if len(actions2) != 0 { + t.Errorf("viewer should see no admin actions: %+v", actions2) + } +} diff --git a/extensions/dashboard/contract/testdata/fixture_auth_extends.yaml b/extensions/dashboard/contract/testdata/fixture_auth_extends.yaml new file mode 100644 index 00000000..a95b715b --- /dev/null +++ b/extensions/dashboard/contract/testdata/fixture_auth_extends.yaml @@ -0,0 +1,11 @@ +schemaVersion: 1 +contributor: + name: auth + envelope: { supports: [v1], preferred: v1 } +intents: [] +extends: + - target: { contributor: users, intent: page.shell, route: /users } + slot: main.detailDrawer.fields + add: + - intent: form.field + requires: { all: ["scope:auth.read"] } diff --git a/extensions/dashboard/contract/testdata/fixture_users.yaml b/extensions/dashboard/contract/testdata/fixture_users.yaml new file mode 100644 index 00000000..d886f3c3 --- /dev/null +++ b/extensions/dashboard/contract/testdata/fixture_users.yaml @@ -0,0 +1,51 @@ +schemaVersion: 1 +contributor: + name: users + envelope: { supports: [v1], preferred: v1 } + capabilities: [users.read, users.write] + +queries: + userList: + intent: users.list + cache: { staleTime: 30s } + +intents: + - name: users.list + kind: query + version: 1 + capability: read + requires: { all: ["scope:users.read"] } + + - name: user.disable + kind: command + version: 1 + capability: write + requires: { all: ["role:admin", "scope:users.write"] } + invalidates: [users.list] + + - name: audit.tail + kind: subscription + version: 1 + capability: read + mode: append + requires: { all: ["role:admin"] } + +graph: + - route: /users + intent: page.shell + title: Users + nav: { group: Identity, icon: users, priority: 10 } + slots: + main: + - intent: resource.list + data: queries.userList + slots: + rowActions: + - intent: action.button + op: user.disable + visibleWhen: { all: ["role:admin"] } + detailDrawer: + - intent: form.edit + slots: + fields: + - intent: form.field From 7e91bda760a97461062154224572d3a70dd8bb68 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 21:11:47 -0500 Subject: [PATCH 26/89] docs(dashboard/contract): add slice-c dispatcher and pilot migration design Three-tier dispatcher API (function table + contributor interface + generic typed wrappers), narrow handler signature with optional Result, subscription handlers via channel + stop, MetricsEmitter interface for slice-b observability wiring, and a P2 pilot scope: extensions.list, services.list, services.detail, and a metrics.cpu replace-mode subscription wired against the existing collector.DataCollector. --- .../dashboard/contract/SLICE_C_DESIGN.md | 424 ++++++++++++++++++ 1 file changed, 424 insertions(+) create mode 100644 extensions/dashboard/contract/SLICE_C_DESIGN.md diff --git a/extensions/dashboard/contract/SLICE_C_DESIGN.md b/extensions/dashboard/contract/SLICE_C_DESIGN.md new file mode 100644 index 00000000..666f6e01 --- /dev/null +++ b/extensions/dashboard/contract/SLICE_C_DESIGN.md @@ -0,0 +1,424 @@ +# Slice (c) — Dispatcher Infrastructure & Pilot Migration + +> Companion design doc to [DESIGN.md](DESIGN.md). Slice (c) builds on slice (a)'s contract package; it is the first slice that produces a *working* end-to-end contract round-trip. + +## Context + +Slice (a) shipped the contract type system, registry, transport, and wiring, but the runtime path stops at `NilDispatcher{}` — every request returns `UNAVAILABLE`. Slice (c) replaces that with a real dispatcher, defines how contributors register handler functions against intents, and migrates a small but real surface (Extensions list, Services list/detail, a CPU metric subscription) so the contract is exercised end-to-end. The pilot also flushes out concrete ergonomic and integration issues that should inform the React shell (slice (d)) and remaining migrations (slice (f)). + +This is *not* a security or audit completeness pass. CSRF token validation, the chronicle integration, and OpenTelemetry spans are explicit non-goals — slice (b) owns those. + +## Scope + +**In scope (this spec):** +- A `Dispatcher` implementation that backs the existing `transport.Dispatcher` interface from slice (a). +- A registration API contributors call to bind their handlers to intents. +- Generic helpers (`RegisterQuery`, `RegisterCommand`, `RegisterSubscription`) for type-safe ergonomic handlers. +- A `SubscriptionSource` adapter that fulfils slice (a)'s broker interface. +- A `MetricsEmitter` interface + noop default — observability hook only, no Prometheus wiring. +- Three migrated intents wired against the dashboard's existing `collector.DataCollector` plumbing: + - `extensions.list` (query) + - `services.list` (query) and `services.detail` (query, used in a detail drawer slot) + - `metrics.cpu` (subscription, `replace` mode) +- An embedded YAML manifest in the dashboard package describing the pilot contributor. +- End-to-end test that drives the contract HTTP and SSE endpoints with the wired dispatcher. + +**Out of scope (other slices):** +- Real CSRF validation (slice (b)) +- Real audit emission to chronicles (slice (b)) +- OpenTelemetry tracing (later) +- Prometheus integration for `MetricsEmitter` (slice (b)) +- React shell consuming the contract endpoints (slice (d)) +- Removal of legacy templ-rendered Extensions / Services pages (slice (f)) +- Migration of any external contributor (e.g., the streaming extension) — explicitly deferred + +## Design Decisions + +| Decision | Choice | +|---|---| +| Dispatcher API layers | (a) function-table foundation + (b) interface-based registration via `ContractHandlers()` + (c) generic typed wrappers — all three, layered | +| Handler signature | Narrow + `*Result`: `func(ctx, payload, params, p) (*Result, error)` where `Result{Data, ExtraInvalidates, CacheOverride}` lets handlers influence response meta when needed without forcing every handler to construct one | +| Error model | Handler returns `*contract.Error` → propagated verbatim; any other error → wrapped as `CodeInternal` with the original chained for server-side logs; nil error + nil Data is valid (`{ok: true, data: null}`) | +| Subscription handler | `func(ctx, params, p) (<-chan StreamEvent, func(), error)` — channel + stop func; ctx cancellation = handler should stop emitting and close; stop = force-stop hook the broker calls on disconnect | +| Subscription registration | Separate from query/command: `disp.RegisterSubscription(c, i, v, subHandler)` indexed by `(contributor, intent, version)` like ordinary handlers | +| Observability | `MetricsEmitter` interface emits latency + error-count per dispatch; noop default; Prometheus impl is slice (b)'s problem | +| Manifest delivery | `//go:embed` of a YAML file shipped in the dashboard package — single source of truth for the pilot contributor's intents and graph | +| Migration coexistence | Legacy templ Extensions/Services pages continue working at `/dashboard/extensions`, `/dashboard/services`. New contract surface lives at `/dashboard/contract/...`. Both read from the same `collector.DataCollector`. Slice (f) retires templ | +| Pilot route prefix | `/{dashboardBase}/contract/{contributor}/...` so the legacy and contract paths never collide. The contract contributor's `name` is `"core-contract"` to disambiguate from the legacy `core` contributor | + +## The Dispatcher + +### Package layout + +``` +extensions/dashboard/contract/dispatcher/ + dispatcher.go # Dispatcher struct, Register, Dispatch, SubscriptionSource adapter + handler.go # Handler, Result types + generic.go # RegisterQuery[I,O], RegisterCommand[I,O], RegisterSubscription[I,E] + metrics.go # MetricsEmitter interface, NoopMetricsEmitter + dispatcher_test.go + generic_test.go + metrics_test.go +``` + +The dispatcher lives in a sub-package so the contract package itself stays free of dispatch logic and the `transport` package depends only on the abstract interface. + +### Public surface + +```go +package dispatcher + +// Dispatcher is the concrete implementation of transport.Dispatcher and +// transport.SubscriptionSource. Contributors register handlers against +// (contributor, intent, version) keys; the dispatcher routes requests at runtime. +type Dispatcher struct { /* unexported */ } + +func New(metrics MetricsEmitter) *Dispatcher + +// Function-table registration (layer a). +func (d *Dispatcher) Register(contributor, intent string, version int, h Handler) error +func (d *Dispatcher) RegisterSubscription(contributor, intent string, version int, h SubscriptionHandler) error + +// Interface registration (layer b) — called once per contributor by the wire-up code. +func (d *Dispatcher) RegisterContributor(c Contributor) error + +// Slice (a) interfaces — the dispatcher implements both. +func (d *Dispatcher) Dispatch(ctx context.Context, req contract.Request, p contract.Principal) (json.RawMessage, contract.ResponseMeta, error) +func (d *Dispatcher) Subscribe(ctx context.Context, p contract.Principal, contributor string, intent contract.Intent, params map[string]contract.ParamSource) (<-chan contract.StreamEvent, func(), error) + +// Handler types. +type Handler func(ctx context.Context, payload json.RawMessage, params map[string]any, p contract.Principal) (*Result, error) + +type SubscriptionHandler func(ctx context.Context, params map[string]any, p contract.Principal) (<-chan contract.StreamEvent, func(), error) + +type Result struct { + Data json.RawMessage + ExtraInvalidates []string + CacheOverride *contract.CacheHint +} + +// Layer (b): contributors implement this interface. RegisterContributor walks the +// returned maps and calls Register/RegisterSubscription internally. +type Contributor interface { + Name() string + Handlers() map[IntentRef]Handler + Subscriptions() map[IntentRef]SubscriptionHandler +} + +type IntentRef struct { + Intent string + Version int +} +``` + +### Generic helpers (layer c) + +```go +package dispatcher + +// RegisterQuery wraps a typed handler. Decoding the payload, marshalling the +// output, and constructing the Result are handled by the wrapper. +func RegisterQuery[I, O any](d *Dispatcher, contributor, intent string, version int, + fn func(ctx context.Context, in I, p contract.Principal) (O, error)) error + +func RegisterCommand[I, O any](d *Dispatcher, contributor, intent string, version int, + fn func(ctx context.Context, in I, p contract.Principal) (O, error)) error + +func RegisterSubscription[P, E any](d *Dispatcher, contributor, intent string, version int, + fn func(ctx context.Context, in P, p contract.Principal) (<-chan E, func(), error)) error +``` + +Implementation: each helper allocates a `Handler` (or `SubscriptionHandler`) closure that JSON-decodes the typed input from `payload`, calls the inner typed function, and JSON-encodes the output back into `Result.Data`. Subscription wrappers spawn a small pump goroutine that JSON-encodes each typed event before forwarding into the broker's channel. + +If a contributor wants influence over `ExtraInvalidates` or `CacheOverride`, they bypass the helper and use `d.Register` directly. The helpers are sugar, not a wall. + +### Error mapping at dispatch time + +```go +data, meta, err := h(ctx, req.Payload, paramsMap, principal) +switch { +case err == nil: + // success path +case errors.As(err, &contractErr): + // *contract.Error — propagate verbatim +case errors.Is(err, context.Canceled): + // map to CodeUnavailable, retryable=true +default: + // wrap as CodeInternal + log.Printf("contract dispatch error: %v", err) // server-side detail + err = &contract.Error{Code: contract.CodeInternal, Message: "internal error"} +} +``` + +The Wire boundary always returns `*contract.Error` shapes; original error chains never leak to clients. + +### Concurrency + +- `Register*` calls hold a `sync.Mutex` while mutating the handler tables; `Dispatch`/`Subscribe` acquire `sync.RWMutex.RLock`. After startup, registration is rare and dispatch is hot. +- A registration after first `Dispatch` is allowed (some contributors may register late) but is not optimised. +- Subscription handlers spawn their own goroutines for event production. The dispatcher only routes the resulting channel to the broker; goroutine ownership stays with the handler. + +## Pilot Manifest + +### File: `extensions/dashboard/contract/pilot/manifest.yaml` + +```yaml +schemaVersion: 1 +contributor: + name: core-contract + envelope: + supports: [v1] + preferred: v1 + capabilities: [dashboard.read] + +queries: + extensionList: + intent: extensions.list + cache: { staleTime: 10s } + serviceList: + intent: services.list + cache: { staleTime: 5s } + +intents: + - name: extensions.list + kind: query + version: 1 + capability: read + schema: + output: { extensions: ExtensionInfo[] } + + - name: services.list + kind: query + version: 1 + capability: read + schema: + output: { services: ServiceInfo[] } + + - name: services.detail + kind: query + version: 1 + capability: read + schema: + input: { name: string } + output: ServiceDetail + + - name: metrics.cpu + kind: subscription + version: 1 + capability: read + mode: replace + schema: + output: { cpuPercent: number, ts: int64 } + +graph: + - route: /extensions + intent: page.shell + title: Extensions + nav: { group: Operations, icon: package, priority: 20 } + slots: + main: + - intent: resource.list + data: queries.extensionList + props: + columns: [name, version, status] + + - route: /services + intent: page.shell + title: Services + nav: { group: Operations, icon: server, priority: 21 } + slots: + main: + - intent: resource.list + data: queries.serviceList + props: + columns: [name, status, uptime] + slots: + detailDrawer: + - intent: resource.detail + data: + intent: services.detail + params: { name: { from: parent.name } } + + - route: /metrics/live + intent: page.shell + title: Live Metrics + nav: { group: Operations, icon: activity, priority: 22 } + slots: + main: + - intent: dashboard.grid + slots: + widgets: + - intent: metric.counter + title: CPU % + data: + intent: metrics.cpu +``` + +### Routes and namespacing + +The pilot contributor name is `core-contract` (distinct from the existing legacy `core` contributor). Per slice (a)'s namespace-by-default rule, its routes mount at: + +- `/dashboard/contract/core-contract/extensions` +- `/dashboard/contract/core-contract/services` +- `/dashboard/contract/core-contract/metrics/live` + +Legacy `core` continues to serve `/dashboard/extensions`, `/dashboard/services`, etc. Until slice (f) retires templ, both are reachable. + +The `Root: true` flag on the pilot's intents is **not** set — the pilot lives under its own namespace, which is exactly what we want for an opt-in test surface that won't collide with legacy URLs. + +## Pilot Handlers + +Lives at `extensions/dashboard/contract/pilot/handlers.go`. Implementations: + +- `extensions.list`: read from `collector.DataCollector.GetExtensions()`. Output: `{extensions: []ExtensionInfo}` where `ExtensionInfo` is the existing collector type. +- `services.list`: read from `DataCollector.GetServices()`. +- `services.detail`: read from `DataCollector.GetServiceDetail(name)`. Returns `nil, &contract.Error{Code: CodeNotFound}` when the service isn't registered. +- `metrics.cpu`: subscription handler that polls `DataCollector.GetSnapshot()` at a 5s interval and emits `replace`-mode events with `{cpuPercent, ts}`. + +Each handler uses the layer-(c) generic wrapper for ergonomics: + +```go +package pilot + +func Register(d *dispatcher.Dispatcher, c *collector.DataCollector) error { + if err := dispatcher.RegisterQuery(d, "core-contract", "extensions.list", 1, + func(ctx context.Context, _ struct{}, _ contract.Principal) (ExtensionsList, error) { + return ExtensionsList{Extensions: c.GetExtensions()}, nil + }); err != nil { + return err + } + // ... services.list, services.detail similarly ... + return dispatcher.RegisterSubscription(d, "core-contract", "metrics.cpu", 1, cpuSub(c)) +} + +func cpuSub(c *collector.DataCollector) func(ctx context.Context, _ struct{}, _ contract.Principal) (<-chan CPUEvent, func(), error) { + return func(ctx context.Context, _ struct{}, _ contract.Principal) (<-chan CPUEvent, func(), error) { + ch := make(chan CPUEvent, 4) + ticker := time.NewTicker(5 * time.Second) + go func() { + defer close(ch) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + snap := c.GetSnapshot() + select { + case ch <- CPUEvent{CPUPercent: snap.CPU, TS: time.Now().Unix()}: + case <-ctx.Done(): + return + } + } + } + }() + return ch, func() {}, nil + } +} +``` + +The empty `func(){}` stop function is a no-op because the goroutine cleanup is driven by ctx. We provide it explicitly to keep the contract's broker shape uniform — slice (b) may want to add a fast-path stop for resource cleanup. + +## Wire-up changes in the dashboard extension + +`extensions/dashboard/extension.go`: + +- Replace `transport.NilDispatcher{}` with the real dispatcher. +- Construct the dispatcher once at extension startup; pass the same instance into both `transport.NewHandler` (for HTTP) and a new `streamBroker` instantiation (since the dispatcher implements `SubscriptionSource`, the broker can finally be wired). +- After the contract registry is set up, load the embedded pilot YAML, validate it, register it with the contract registry, and register the pilot handlers against the dispatcher. + +Approximate diff sketch: + +```go +import _ "embed" + +//go:embed contract/pilot/manifest.yaml +var pilotManifestYAML []byte + +// in NewExtension constructor, after wardenRegistry init: +disp := dispatcher.New(dispatcher.NoopMetricsEmitter{}) +// streamBroker wires now that we have a SubscriptionSource: +broker := transport.NewStreamBroker(contractRegistry, wardenRegistry, disp) + +// ... (existing init continues) ... +e.dispatcher = disp +e.streamBroker = broker + +// in Start (or wherever OnRegister callbacks fire), after the registry is built: +m, err := loader.Load(bytes.NewReader(pilotManifestYAML), "pilot/manifest.yaml") +// validate, register with contract registry, register handlers via pilot.Register(disp, e.collector) +``` + +`handleContractPOST` swaps `transport.NilDispatcher{}` for `e.dispatcher`. The broker registration block in `registerRoutes` already exists (slice a) and starts working as soon as `streamBroker` is non-nil. + +## Files Affected + +### New + +``` +extensions/dashboard/contract/dispatcher/ + dispatcher.go + handler.go + generic.go + metrics.go + dispatcher_test.go + generic_test.go + metrics_test.go + +extensions/dashboard/contract/pilot/ + manifest.yaml + handlers.go + types.go # ExtensionsList, ServicesList, ServiceDetail, CPUEvent + pilot.go # Register(disp, collector) entry point + handlers_test.go + pilot_e2e_test.go # spins up the contract handler and exercises all four intents +``` + +### Modified + +- `extensions/dashboard/extension.go` — wire the dispatcher, broker, pilot registration; replace `NilDispatcher{}` callsites. + +### Reused (do not duplicate) + +- `transport.Dispatcher` interface and `transport.NewHandler` from slice (a). +- `transport.SubscriptionSource` interface and `transport.StreamBroker` from slice (a). +- `contract.Registry`, `contract.WardenRegistry`, `loader.Load`, `loader.Validate` from slice (a). +- `collector.DataCollector` and its `GetExtensions`/`GetServices`/`GetServiceDetail`/`GetSnapshot` methods (existing dashboard internals). +- `contract.NewLogAuditEmitter(os.Stdout)` continues to back command audit. + +## Verification + +1. **Unit tests** under `dispatcher/`: + - Register / Dispatch round-trip for query, command, and subscription kinds. + - Generic helper round-trip — typed input/output verified to encode/decode through the wire layer. + - Error mapping table-driven: handler returns `*contract.Error`, plain error, `context.Canceled`; expected wire codes asserted. + - Concurrent registration + dispatch race check (`go test -race`). + - Metrics emission verified via a stub `MetricsEmitter`. + +2. **Pilot handler tests** under `pilot/`: + - Each handler called with a fixture `DataCollector` produces the expected output. + - `services.detail` for an unknown name returns `*contract.Error{Code: CodeNotFound}`. + - `metrics.cpu` subscription emits an event after a tick (use a 50ms test ticker via dependency injection). + +3. **End-to-end test** `pilot_e2e_test.go`: + - Stand up: contract registry, warden registry, dispatcher, transport handler, stream broker — all in-process. + - Load pilot YAML, validate, register, register handlers. + - Drive `POST /api/dashboard/v1` with `kind=query` for `extensions.list` and assert the envelope shape + the data shape. + - Drive `kind=graph` for `/services` and assert the filtered tree includes the detail-drawer slot. + - Open SSE stream, subscribe to `metrics.cpu`, assert at least one event arrives within 200ms (with the test-injected ticker). + - Run with `-race` to flush any subscription goroutine ordering issues. + +4. **Probe CLI manual smoke** (not automated, but documented): + ```bash + go run ./cmd/dashboard-contract-probe \ + -base=http://localhost:8080 \ + -kind=query -contributor=core-contract -intent=extensions.list + ``` + Expected: HTTP 200 with `{"ok":true, "data":{"extensions":[...]}}`. + +## Out of Scope — Future Slices + +- **Slice (b)** — security: real CSRF middleware integration, idempotency-key persistence (so retried commands are deduped), chronicle integration for `AuditEmitter`, Prometheus impl for `MetricsEmitter`, OpenTelemetry tracing wrapper around `Dispatcher.Dispatch`. +- **Slice (d)** — React shell rendering engine: consumes the contract endpoints this slice exposes. The pilot's three pages become real renderable UIs. +- **Slice (e)** — built-in intent vocabulary v1: concrete React implementations of `resource.list`, `resource.detail`, `dashboard.grid`, `metric.counter`, etc. +- **Slice (f)** — migration of remaining contributors and removal of templ. Once the React shell is real, the legacy templ pages get retired. From eb3e42b0806273ac0ea2382d02e658cbb97659bb Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 21:19:51 -0500 Subject: [PATCH 27/89] docs(dashboard/contract): add slice-c implementation plan --- extensions/dashboard/contract/SLICE_C_PLAN.md | 2360 +++++++++++++++++ 1 file changed, 2360 insertions(+) create mode 100644 extensions/dashboard/contract/SLICE_C_PLAN.md diff --git a/extensions/dashboard/contract/SLICE_C_PLAN.md b/extensions/dashboard/contract/SLICE_C_PLAN.md new file mode 100644 index 00000000..2d74df70 --- /dev/null +++ b/extensions/dashboard/contract/SLICE_C_PLAN.md @@ -0,0 +1,2360 @@ +# Slice (c) — Dispatcher + Pilot Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace `transport.NilDispatcher{}` with a real dispatcher that contributors register intent handlers against, and migrate three queries + one subscription onto the contract end-to-end. + +**Architecture:** A new `extensions/dashboard/contract/dispatcher` sub-package owns the function-table dispatcher, the `SubscriptionSource` adapter, and generic typed wrappers. A new `extensions/dashboard/contract/pilot` sub-package ships the embedded YAML manifest plus the four handlers (`extensions.list`, `services.list`, `services.detail`, `metrics.summary`) wired to the existing `collector.DataCollector` and `contributor.ContributorRegistry`. The dashboard extension wires both into startup, replacing the no-op dispatcher and finally instantiating the SSE broker. + +**Tech Stack:** Go 1.25, stdlib `testing`, `gopkg.in/yaml.v3`, existing `extensions/dashboard/{auth,collector,contributor}` packages. No new external dependencies. + +--- + +## Reference + +- **Design spec:** [SLICE_C_DESIGN.md](SLICE_C_DESIGN.md) +- **Slice (a) interfaces this plan implements:** + - `transport.Dispatcher` ([transport/http.go:16](transport/http.go)) + - `transport.SubscriptionSource` ([transport/stream.go:19](transport/stream.go)) +- **Slice (a) types reused throughout:** `contract.Request`, `contract.ResponseMeta`, `contract.Principal`, `contract.Action`, `contract.StreamEvent`, `contract.Error`, `contract.IntentKind`, `contract.Capability`, `contract.SubscriptionMode`. +- **Existing dashboard internals to call:** + - `collector.DataCollector.CollectServices(ctx) []ServiceInfo` ([collector/collector.go:314](collector/collector.go)) + - `collector.DataCollector.CollectServiceDetail(ctx, name) *ServiceDetail` ([collector/collector.go:390](collector/collector.go)) + - `collector.DataCollector.CollectMetrics(ctx) *MetricsData` ([collector/collector.go:140](collector/collector.go)) + - `contributor.ContributorRegistry.ContributorNames() []string` and `GetManifest(name) (*Manifest, bool)` (used today by [handlers/api.go:107](handlers/api.go)) + +## Spec Deviation: `metrics.cpu` → `metrics.summary` + +The spec's pilot included a `metrics.cpu` subscription. The collector exposes `MetricsData.Metrics map[string]any` which has no guaranteed `cpu` key — it depends on what the application registered with the metrics extension. To keep the pilot runnable in any deployment, the implementation uses `metrics.summary` instead, emitting `MetricsStats` (TotalMetrics + Counters + Gauges + Histograms) on a 5-second interval. Same `replace` mode, same subscription mechanics — only the payload type differs. If a deployment wires real CPU into the metrics extension, a future intent can target it directly. + +This is a small, isolated rename. The graph YAML still drops it into a `metric.counter` widget on `/metrics/live`. + +## File Structure + +``` +extensions/dashboard/contract/dispatcher/ + doc.go # package comment + handler.go # Handler, Result, SubscriptionHandler, IntentRef, Contributor types + dispatcher.go # Dispatcher struct, New, Register, Dispatch, lookup helpers + subscription.go # RegisterSubscription, Subscribe (transport.SubscriptionSource impl) + generic.go # RegisterQuery, RegisterCommand, RegisterSubscription generic wrappers + metrics.go # MetricsEmitter interface, NoopMetricsEmitter, DispatchInfo + contributor.go # RegisterContributor walks Contributor interface + dispatcher_test.go + subscription_test.go + generic_test.go + metrics_test.go + contributor_test.go + +extensions/dashboard/contract/pilot/ + doc.go + manifest.yaml # embedded via //go:embed + types.go # ExtensionsList, ServicesList, ServiceDetail, MetricsSummary + pilot.go # Register(disp, deps) entry; loads YAML, validates, registers + extensions.go # extensions.list handler + test + services.go # services.list + services.detail handlers + tests + metrics.go # metrics.summary subscription handler + test + extensions_test.go + services_test.go + metrics_test.go + pilot_e2e_test.go # full HTTP+SSE end-to-end with the contract handler +``` + +`extensions/dashboard/extension.go` is modified to wire the dispatcher + broker; no other file in the dashboard subtree changes structurally. + +## Conventions + +- Plain `testing` package; no testify in this subtree. +- Imports: stdlib first, then `github.com/xraph/forge/...`, then third-party. +- The `dashauth` import alias is the existing convention. Use `import dashauth "github.com/xraph/forge/extensions/dashboard/auth"`. +- Compile-time interface assertions at the bottom of each file: `var _ transport.Dispatcher = (*Dispatcher)(nil)`. +- One commit per logical change. No `Co-Authored-By` trailers. + +--- + +## Phase 0: Dispatcher Package Skeleton + +### Task 0.1: Package + Handler/Result types + +**Files:** +- Create: `extensions/dashboard/contract/dispatcher/doc.go` +- Create: `extensions/dashboard/contract/dispatcher/handler.go` +- Create: `extensions/dashboard/contract/dispatcher/handler_test.go` + +- [ ] **Step 1: Write doc.go** + +```go +// Package dispatcher implements transport.Dispatcher and transport.SubscriptionSource +// against a function-table of registered handlers. Contributors register their +// intent handlers via Register / RegisterSubscription / RegisterContributor; +// the HTTP and SSE transports look them up at request time. +// +// See SLICE_C_DESIGN.md in the parent contract directory for the spec this implements. +package dispatcher +``` + +- [ ] **Step 2: Write handler_test.go (failing)** + +```go +package dispatcher + +import ( + "encoding/json" + "testing" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +func TestIntentRef_StringForm(t *testing.T) { + r := IntentRef{Intent: "users.list", Version: 1} + if got := r.String(); got != "users.list@1" { + t.Errorf("String() = %q", got) + } +} + +func TestResult_HoldsData(t *testing.T) { + r := &Result{Data: json.RawMessage(`{"ok":true}`), ExtraInvalidates: []string{"x"}} + if string(r.Data) != `{"ok":true}` { + t.Errorf("data lost") + } + if r.ExtraInvalidates[0] != "x" { + t.Errorf("invalidates lost") + } +} + +// Compile-time check: a value-conformant function compiles as Handler. +func TestHandlerSignature_Compiles(t *testing.T) { + var h Handler = func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { + return &Result{Data: json.RawMessage(`null`)}, nil + } + _ = h +} +``` + +Add `"context"` to the import block. + +- [ ] **Step 3: Run, expect FAIL** + +Run: `go test ./extensions/dashboard/contract/dispatcher/...` +Expected: FAIL — undefined: IntentRef / Result / Handler. + +- [ ] **Step 4: Implement handler.go** + +```go +package dispatcher + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// Handler is the foundation function-table handler signature for query and +// command intents. Returning a *contract.Error propagates the canonical code +// to the wire; any other error is wrapped as CodeInternal at dispatch time. +type Handler func(ctx context.Context, payload json.RawMessage, params map[string]any, p contract.Principal) (*Result, error) + +// SubscriptionHandler is the function-table handler for subscription intents. +// The handler returns a channel of events, a force-stop function, and an +// optional error. Closing the channel signals end-of-stream; cancelling ctx +// is the canonical way to ask the handler to stop emitting. +type SubscriptionHandler func(ctx context.Context, params map[string]any, p contract.Principal) (<-chan contract.StreamEvent, func(), error) + +// Result carries the data payload plus optional response-meta overrides. +// Handlers that don't need to influence meta can return &Result{Data: ...}; +// handlers that need to add invalidations or override cache hints set the +// extra fields. +type Result struct { + // Data is the JSON-encoded response body. May be nil for a {data: null} response. + Data json.RawMessage + // ExtraInvalidates is appended to the manifest's declared Invalidates. + ExtraInvalidates []string + // CacheOverride, when non-nil, replaces the manifest's declared cache hint. + CacheOverride *contract.CacheHint +} + +// IntentRef is the (intent, version) tuple used as a registration key. +type IntentRef struct { + Intent string + Version int +} + +// String formats as "intent@version" — used in error messages and logs. +func (r IntentRef) String() string { + return fmt.Sprintf("%s@%d", r.Intent, r.Version) +} + +// Contributor is layer (b)'s registration shape: a contributor publishes its +// handler and subscription tables, and the dispatcher walks them on Register. +type Contributor interface { + Name() string + Handlers() map[IntentRef]Handler + Subscriptions() map[IntentRef]SubscriptionHandler +} +``` + +- [ ] **Step 5: Run, expect PASS** + +Run: `go test ./extensions/dashboard/contract/dispatcher/...` +Expected: PASS — 3 tests. + +- [ ] **Step 6: Commit** + +```bash +git add extensions/dashboard/contract/dispatcher/{doc.go,handler.go,handler_test.go} +git commit -m "feat(dashboard/contract/dispatcher): add package skeleton and handler types" +``` + +--- + +## Phase 1: Dispatcher Core — Register + Dispatch + +### Task 1.1: Register and Dispatch implementation + +**Files:** +- Create: `extensions/dashboard/contract/dispatcher/dispatcher.go` +- Create: `extensions/dashboard/contract/dispatcher/dispatcher_test.go` + +- [ ] **Step 1: Write the failing tests** + +```go +package dispatcher + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +func TestDispatcher_RegisterAndDispatch(t *testing.T) { + d := New(NoopMetricsEmitter{}) + if err := d.Register("users", "users.list", 1, func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { + return &Result{Data: json.RawMessage(`{"users":[]}`)}, nil + }); err != nil { + t.Fatalf("register: %v", err) + } + req := contract.Request{Envelope: "v1", Kind: contract.KindQuery, Contributor: "users", Intent: "users.list", IntentVersion: 1} + data, _, err := d.Dispatch(context.Background(), req, contract.Principal{}) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if string(data) != `{"users":[]}` { + t.Errorf("data = %s", data) + } +} + +func TestDispatcher_DuplicateRegister(t *testing.T) { + d := New(NoopMetricsEmitter{}) + h := func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { return &Result{}, nil } + _ = d.Register("c", "i", 1, h) + if err := d.Register("c", "i", 1, h); err == nil { + t.Error("duplicate register should fail") + } +} + +func TestDispatcher_NotFound(t *testing.T) { + d := New(NoopMetricsEmitter{}) + req := contract.Request{Envelope: "v1", Kind: contract.KindQuery, Contributor: "x", Intent: "y", IntentVersion: 1} + _, _, err := d.Dispatch(context.Background(), req, contract.Principal{}) + if err == nil { + t.Fatal("expected not-found error") + } + var ce *contract.Error + if !errors.As(err, &ce) || ce.Code != contract.CodeNotFound { + t.Errorf("expected CodeNotFound, got %v", err) + } +} + +func TestDispatcher_ContractErrorPassesThrough(t *testing.T) { + d := New(NoopMetricsEmitter{}) + _ = d.Register("c", "i", 1, func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { + return nil, &contract.Error{Code: contract.CodeConflict, Message: "duplicate"} + }) + req := contract.Request{Envelope: "v1", Kind: contract.KindCommand, Contributor: "c", Intent: "i", IntentVersion: 1} + _, _, err := d.Dispatch(context.Background(), req, contract.Principal{}) + var ce *contract.Error + if !errors.As(err, &ce) || ce.Code != contract.CodeConflict { + t.Errorf("expected CodeConflict pass-through, got %v", err) + } +} + +func TestDispatcher_PlainErrorWrappedAsInternal(t *testing.T) { + d := New(NoopMetricsEmitter{}) + _ = d.Register("c", "i", 1, func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { + return nil, errors.New("kaboom") + }) + req := contract.Request{Envelope: "v1", Kind: contract.KindCommand, Contributor: "c", Intent: "i", IntentVersion: 1} + _, _, err := d.Dispatch(context.Background(), req, contract.Principal{}) + var ce *contract.Error + if !errors.As(err, &ce) || ce.Code != contract.CodeInternal { + t.Errorf("expected CodeInternal wrap, got %v", err) + } +} + +func TestDispatcher_ContextCanceledMappedToUnavailable(t *testing.T) { + d := New(NoopMetricsEmitter{}) + _ = d.Register("c", "i", 1, func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { + return nil, context.Canceled + }) + req := contract.Request{Envelope: "v1", Kind: contract.KindQuery, Contributor: "c", Intent: "i", IntentVersion: 1} + _, _, err := d.Dispatch(context.Background(), req, contract.Principal{}) + var ce *contract.Error + if !errors.As(err, &ce) || ce.Code != contract.CodeUnavailable { + t.Errorf("expected CodeUnavailable for canceled, got %v", err) + } + if !ce.Retryable { + t.Error("canceled errors should be retryable") + } +} +``` + +- [ ] **Step 2: Run, expect FAIL** + +Run: `go test ./extensions/dashboard/contract/dispatcher/...` +Expected: FAIL — undefined: New / NoopMetricsEmitter / Dispatcher. + +- [ ] **Step 3: Stub MetricsEmitter so this test compiles** — full impl lands in Phase 4. Add to `metrics.go`: + +```go +package dispatcher + +import ( + "context" + "time" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// MetricsEmitter ships dispatch metrics to a backend. The Phase 4 expansion +// of this file adds the full DispatchInfo struct and the noop default. For +// Phase 1, only the interface and the noop are needed. +type MetricsEmitter interface { + RecordDispatch(ctx context.Context, contributor, intent string, version int, kind contract.Kind, latency time.Duration, errCode contract.ErrorCode) +} + +// NoopMetricsEmitter discards all dispatch metrics. +type NoopMetricsEmitter struct{} + +func (NoopMetricsEmitter) RecordDispatch(_ context.Context, _, _ string, _ int, _ contract.Kind, _ time.Duration, _ contract.ErrorCode) {} +``` + +- [ ] **Step 4: Implement dispatcher.go** + +```go +package dispatcher + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "sync" + "time" + + "github.com/xraph/forge/extensions/dashboard/contract" + "github.com/xraph/forge/extensions/dashboard/contract/transport" +) + +// Dispatcher is the concrete implementation of transport.Dispatcher and +// transport.SubscriptionSource (Subscribe lives in subscription.go). +// Contributors register handlers indexed by (contributor, intent, version); +// dispatch is a map lookup + the handler call wrapped in metrics emission +// and canonical error mapping. +type Dispatcher struct { + metrics MetricsEmitter + + mu sync.RWMutex + handlers map[handlerKey]Handler + subscriptions map[handlerKey]SubscriptionHandler +} + +type handlerKey struct { + Contributor string + Intent string + Version int +} + +// New returns a fresh dispatcher. Pass NoopMetricsEmitter{} for tests / dev; +// slice (b) provides a Prometheus-backed implementation. +func New(metrics MetricsEmitter) *Dispatcher { + if metrics == nil { + metrics = NoopMetricsEmitter{} + } + return &Dispatcher{ + metrics: metrics, + handlers: map[handlerKey]Handler{}, + subscriptions: map[handlerKey]SubscriptionHandler{}, + } +} + +// Register binds a query/command handler to a (contributor, intent, version) +// key. Returns an error on duplicate registration. +func (d *Dispatcher) Register(contributor, intent string, version int, h Handler) error { + if h == nil { + return fmt.Errorf("dispatcher: nil handler for %s/%s@%d", contributor, intent, version) + } + k := handlerKey{contributor, intent, version} + d.mu.Lock() + defer d.mu.Unlock() + if _, exists := d.handlers[k]; exists { + return fmt.Errorf("dispatcher: handler %s/%s@%d already registered", contributor, intent, version) + } + d.handlers[k] = h + return nil +} + +// Dispatch implements transport.Dispatcher. +func (d *Dispatcher) Dispatch(ctx context.Context, req contract.Request, p contract.Principal) (json.RawMessage, contract.ResponseMeta, error) { + k := handlerKey{req.Contributor, req.Intent, req.IntentVersion} + d.mu.RLock() + h, ok := d.handlers[k] + d.mu.RUnlock() + if !ok { + err := &contract.Error{Code: contract.CodeNotFound, Message: fmt.Sprintf("handler %s/%s@%d not registered", req.Contributor, req.Intent, req.IntentVersion)} + d.metrics.RecordDispatch(ctx, req.Contributor, req.Intent, req.IntentVersion, req.Kind, 0, err.Code) + return nil, contract.ResponseMeta{}, err + } + + t0 := time.Now() + res, handlerErr := h(ctx, req.Payload, req.Params, p) + latency := time.Since(t0) + + wireErr := mapDispatchError(handlerErr) + errCode := contract.ErrorCode("") + if wireErr != nil { + var ce *contract.Error + if errors.As(wireErr, &ce) { + errCode = ce.Code + } + } + d.metrics.RecordDispatch(ctx, req.Contributor, req.Intent, req.IntentVersion, req.Kind, latency, errCode) + + if wireErr != nil { + return nil, contract.ResponseMeta{}, wireErr + } + if res == nil { + // Allow nil result to mean {data: null} explicitly. + return nil, contract.ResponseMeta{IntentVersion: req.IntentVersion}, nil + } + meta := contract.ResponseMeta{IntentVersion: req.IntentVersion} + if len(res.ExtraInvalidates) > 0 { + meta.Invalidates = append(meta.Invalidates, res.ExtraInvalidates...) + } + if res.CacheOverride != nil { + meta.CacheControl = res.CacheOverride + } + return res.Data, meta, nil +} + +// mapDispatchError converts a handler error into the canonical wire error +// shape. *contract.Error is preserved verbatim. context.Canceled becomes +// CodeUnavailable+Retryable. Any other error is wrapped as CodeInternal, +// with the original chained for server-side logging. +func mapDispatchError(err error) error { + if err == nil { + return nil + } + var ce *contract.Error + if errors.As(err, &ce) { + return ce + } + if errors.Is(err, context.Canceled) { + return &contract.Error{Code: contract.CodeUnavailable, Message: "request cancelled", Retryable: true} + } + log.Printf("dispatcher: unmapped handler error: %v", err) + return &contract.Error{Code: contract.CodeInternal, Message: "internal error"} +} + +// Compile-time check that the dispatcher satisfies the transport interface. +// The Subscribe half lands in subscription.go (Phase 2). +var _ transport.Dispatcher = (*Dispatcher)(nil) +``` + +- [ ] **Step 5: Run, expect PASS** + +Run: `go test ./extensions/dashboard/contract/dispatcher/...` +Expected: PASS — 6 dispatcher tests + the 3 from Phase 0. + +- [ ] **Step 6: Commit** + +```bash +git add extensions/dashboard/contract/dispatcher/{dispatcher.go,dispatcher_test.go,metrics.go} +git commit -m "feat(dashboard/contract/dispatcher): function-table dispatcher with canonical error mapping" +``` + +--- + +## Phase 2: Subscription Registration + SubscriptionSource + +### Task 2.1: RegisterSubscription + Subscribe + +**Files:** +- Create: `extensions/dashboard/contract/dispatcher/subscription.go` +- Create: `extensions/dashboard/contract/dispatcher/subscription_test.go` + +- [ ] **Step 1: Write failing tests** + +```go +package dispatcher + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +func TestDispatcher_RegisterSubscriptionAndSubscribe(t *testing.T) { + d := New(NoopMetricsEmitter{}) + if err := d.RegisterSubscription("logs", "audit.tail", 1, func(_ context.Context, _ map[string]any, _ contract.Principal) (<-chan contract.StreamEvent, func(), error) { + ch := make(chan contract.StreamEvent, 1) + ch <- contract.StreamEvent{Intent: "audit.tail", Mode: contract.ModeAppend, Payload: json.RawMessage(`{"line":"hi"}`), Seq: 1} + close(ch) + return ch, func() {}, nil + }); err != nil { + t.Fatalf("register: %v", err) + } + + intent := contract.Intent{Name: "audit.tail", Kind: contract.IntentKindSubscription, Version: 1, Capability: contract.CapRead} + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + ch, stop, err := d.Subscribe(ctx, contract.Principal{}, "logs", intent, nil) + if err != nil { + t.Fatalf("subscribe: %v", err) + } + defer stop() + + select { + case ev, ok := <-ch: + if !ok { + t.Fatal("channel closed before event") + } + if ev.Intent != "audit.tail" { + t.Errorf("intent = %q", ev.Intent) + } + case <-ctx.Done(): + t.Fatal("timed out waiting for event") + } +} + +func TestDispatcher_SubscribeMissingHandler(t *testing.T) { + d := New(NoopMetricsEmitter{}) + intent := contract.Intent{Name: "missing", Kind: contract.IntentKindSubscription, Version: 1, Capability: contract.CapRead} + _, _, err := d.Subscribe(context.Background(), contract.Principal{}, "x", intent, nil) + if err == nil { + t.Error("expected not-found") + } +} + +func TestDispatcher_DuplicateRegisterSubscription(t *testing.T) { + d := New(NoopMetricsEmitter{}) + h := func(_ context.Context, _ map[string]any, _ contract.Principal) (<-chan contract.StreamEvent, func(), error) { + return nil, nil, nil + } + _ = d.RegisterSubscription("c", "i", 1, h) + if err := d.RegisterSubscription("c", "i", 1, h); err == nil { + t.Error("duplicate register should fail") + } +} +``` + +- [ ] **Step 2: Run, expect FAIL** + +Run: `go test ./extensions/dashboard/contract/dispatcher/...` +Expected: FAIL — undefined RegisterSubscription / Subscribe. + +- [ ] **Step 3: Implement subscription.go** + +```go +package dispatcher + +import ( + "context" + "fmt" + + "github.com/xraph/forge/extensions/dashboard/contract" + "github.com/xraph/forge/extensions/dashboard/contract/transport" +) + +// RegisterSubscription binds a subscription handler to (contributor, intent, version). +func (d *Dispatcher) RegisterSubscription(contributor, intent string, version int, h SubscriptionHandler) error { + if h == nil { + return fmt.Errorf("dispatcher: nil subscription handler for %s/%s@%d", contributor, intent, version) + } + k := handlerKey{contributor, intent, version} + d.mu.Lock() + defer d.mu.Unlock() + if _, exists := d.subscriptions[k]; exists { + return fmt.Errorf("dispatcher: subscription %s/%s@%d already registered", contributor, intent, version) + } + d.subscriptions[k] = h + return nil +} + +// Subscribe implements transport.SubscriptionSource. The broker calls this on +// each subscribe-control message; the dispatcher routes to the registered handler. +// Params from YAML (map[string]contract.ParamSource) are flattened into a +// runtime map[string]any using the From string when set, the literal Value otherwise. +func (d *Dispatcher) Subscribe(ctx context.Context, p contract.Principal, contributor string, intent contract.Intent, params map[string]contract.ParamSource) (<-chan contract.StreamEvent, func(), error) { + k := handlerKey{contributor, intent.Name, intent.Version} + d.mu.RLock() + h, ok := d.subscriptions[k] + d.mu.RUnlock() + if !ok { + return nil, nil, &contract.Error{Code: contract.CodeNotFound, Message: fmt.Sprintf("subscription %s/%s@%d not registered", contributor, intent.Name, intent.Version)} + } + flat := flattenParams(params) + return h(ctx, flat, p) +} + +func flattenParams(in map[string]contract.ParamSource) map[string]any { + out := make(map[string]any, len(in)) + for k, src := range in { + if src.From != "" { + out[k] = src.From // resolution happens caller-side; the handler sees the bound value if any + continue + } + out[k] = src.Value + } + return out +} + +// Compile-time check that Subscribe satisfies the broker's source interface. +var _ transport.SubscriptionSource = (*Dispatcher)(nil) +``` + +> **Note on `flattenParams`:** for the pilot, params arrive already flattened by the React shell or the probe CLI — the broker passes the raw map through. The TODO of doing `route.tenant` resolution server-side belongs to slice (d) (the React shell builds the dependency graph). For slice (c), the handler sees whatever the caller sent. + +- [ ] **Step 4: Run, expect PASS** + +Run: `go test ./extensions/dashboard/contract/dispatcher/...` +Expected: PASS — 9 + 3 tests now passing. + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/contract/dispatcher/{subscription.go,subscription_test.go} +git commit -m "feat(dashboard/contract/dispatcher): subscription handler registration and broker source adapter" +``` + +--- + +## Phase 3: Generic Typed Wrappers + +### Task 3.1: RegisterQuery / RegisterCommand / RegisterSubscription helpers + +**Files:** +- Create: `extensions/dashboard/contract/dispatcher/generic.go` +- Create: `extensions/dashboard/contract/dispatcher/generic_test.go` + +- [ ] **Step 1: Write failing tests** + +```go +package dispatcher + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +type listIn struct { + Tenant string `json:"tenant"` +} +type listOut struct { + Users []string `json:"users"` +} + +func TestRegisterQuery_DecodesAndEncodes(t *testing.T) { + d := New(NoopMetricsEmitter{}) + if err := RegisterQuery(d, "users", "users.list", 1, func(_ context.Context, in listIn, _ contract.Principal) (listOut, error) { + if in.Tenant != "acme" { + t.Errorf("decoded tenant = %q", in.Tenant) + } + return listOut{Users: []string{"alice", "bob"}}, nil + }); err != nil { + t.Fatalf("register: %v", err) + } + req := contract.Request{Envelope: "v1", Kind: contract.KindQuery, Contributor: "users", Intent: "users.list", IntentVersion: 1, Payload: json.RawMessage(`{"tenant":"acme"}`)} + data, _, err := d.Dispatch(context.Background(), req, contract.Principal{}) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + var got listOut + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(got.Users) != 2 { + t.Errorf("users = %v", got.Users) + } +} + +func TestRegisterQuery_DecodeErrorBecomesBadRequest(t *testing.T) { + d := New(NoopMetricsEmitter{}) + _ = RegisterQuery(d, "u", "u.l", 1, func(_ context.Context, _ listIn, _ contract.Principal) (listOut, error) { return listOut{}, nil }) + req := contract.Request{Envelope: "v1", Kind: contract.KindQuery, Contributor: "u", Intent: "u.l", IntentVersion: 1, Payload: json.RawMessage(`not json`)} + _, _, err := d.Dispatch(context.Background(), req, contract.Principal{}) + if err == nil { + t.Fatal("expected decode error") + } + if ce, ok := err.(*contract.Error); !ok || ce.Code != contract.CodeBadRequest { + t.Errorf("expected CodeBadRequest, got %v", err) + } +} + +type tickIn struct{} +type tickEvent struct { + N int `json:"n"` +} + +func TestRegisterSubscriptionGeneric_PumpsTypedEvents(t *testing.T) { + d := New(NoopMetricsEmitter{}) + if err := RegisterSubscription(d, "feed", "tick", 1, func(ctx context.Context, _ tickIn, _ contract.Principal) (<-chan tickEvent, func(), error) { + ch := make(chan tickEvent, 2) + ch <- tickEvent{N: 1} + ch <- tickEvent{N: 2} + close(ch) + return ch, func() {}, nil + }); err != nil { + t.Fatalf("register: %v", err) + } + intent := contract.Intent{Name: "tick", Kind: contract.IntentKindSubscription, Version: 1, Capability: contract.CapRead} + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + out, stop, err := d.Subscribe(ctx, contract.Principal{}, "feed", intent, nil) + if err != nil { + t.Fatalf("subscribe: %v", err) + } + defer stop() + + count := 0 + for ev := range out { + count++ + var got tickEvent + if err := json.Unmarshal(ev.Payload, &got); err != nil { + t.Errorf("unmarshal event: %v", err) + } + if got.N != count { + t.Errorf("event %d N = %d", count, got.N) + } + } + if count != 2 { + t.Errorf("expected 2 events, got %d", count) + } +} +``` + +- [ ] **Step 2: Run, expect FAIL** + +Run: `go test ./extensions/dashboard/contract/dispatcher/...` +Expected: FAIL — undefined RegisterQuery / RegisterCommand / RegisterSubscription (the generic ones). + +- [ ] **Step 3: Implement generic.go** + +```go +package dispatcher + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// RegisterQuery wraps a typed handler in a Handler-compatible closure that +// JSON-decodes Payload into I and encodes the returned O into Result.Data. +// I and O must be JSON-marshallable. Use struct{} for an empty-input intent. +func RegisterQuery[I, O any](d *Dispatcher, contributor, intent string, version int, fn func(ctx context.Context, in I, p contract.Principal) (O, error)) error { + return d.Register(contributor, intent, version, wrapTyped[I, O](fn)) +} + +// RegisterCommand is identical in shape to RegisterQuery; both register a +// query/command handler. The dispatcher's wire layer enforces kind/capability +// matching against the manifest, so the only practical difference between the +// two helpers is intent of the caller — they're aliases. +func RegisterCommand[I, O any](d *Dispatcher, contributor, intent string, version int, fn func(ctx context.Context, in I, p contract.Principal) (O, error)) error { + return d.Register(contributor, intent, version, wrapTyped[I, O](fn)) +} + +func wrapTyped[I, O any](fn func(ctx context.Context, in I, p contract.Principal) (O, error)) Handler { + return func(ctx context.Context, payload json.RawMessage, _ map[string]any, p contract.Principal) (*Result, error) { + var in I + if len(payload) > 0 && string(payload) != "null" { + if err := json.Unmarshal(payload, &in); err != nil { + return nil, &contract.Error{Code: contract.CodeBadRequest, Message: fmt.Sprintf("invalid payload: %v", err)} + } + } + out, err := fn(ctx, in, p) + if err != nil { + return nil, err + } + data, mErr := json.Marshal(out) + if mErr != nil { + return nil, &contract.Error{Code: contract.CodeInternal, Message: fmt.Sprintf("marshal output: %v", mErr)} + } + return &Result{Data: data}, nil + } +} + +// RegisterSubscription wraps a typed subscription handler. The pump goroutine +// JSON-encodes each typed E event into a contract.StreamEvent before +// forwarding into the broker's channel. +func RegisterSubscription[P, E any](d *Dispatcher, contributor, intent string, version int, fn func(ctx context.Context, in P, p contract.Principal) (<-chan E, func(), error)) error { + wrapped := func(ctx context.Context, params map[string]any, principal contract.Principal) (<-chan contract.StreamEvent, func(), error) { + var in P + if len(params) > 0 { + // Decode by remarshalling — slow but tolerable; subscription params are tiny. + b, _ := json.Marshal(params) + if err := json.Unmarshal(b, &in); err != nil { + return nil, nil, &contract.Error{Code: contract.CodeBadRequest, Message: fmt.Sprintf("invalid params: %v", err)} + } + } + typedCh, stop, err := fn(ctx, in, principal) + if err != nil { + return nil, nil, err + } + out := make(chan contract.StreamEvent, 4) + var seq uint64 + go func() { + defer close(out) + for ev := range typedCh { + seq++ + payload, mErr := json.Marshal(ev) + if mErr != nil { + // Drop the event if it can't be marshalled; log server-side. + continue + } + select { + case out <- contract.StreamEvent{Intent: intent, Payload: payload, Seq: seq}: + case <-ctx.Done(): + return + } + } + }() + return out, stop, nil + } + return d.RegisterSubscription(contributor, intent, version, wrapped) +} +``` + +- [ ] **Step 4: Run, expect PASS** + +Run: `go test ./extensions/dashboard/contract/dispatcher/...` +Expected: PASS — 12 dispatcher tests now passing. + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/contract/dispatcher/{generic.go,generic_test.go} +git commit -m "feat(dashboard/contract/dispatcher): generic typed wrappers for query/command/subscription" +``` + +--- + +## Phase 4: MetricsEmitter Full Type + +### Task 4.1: Expand metrics.go with DispatchInfo + tests + +**Files:** +- Modify: `extensions/dashboard/contract/dispatcher/metrics.go` +- Create: `extensions/dashboard/contract/dispatcher/metrics_test.go` + +- [ ] **Step 1: Write failing tests** + +```go +package dispatcher + +import ( + "context" + "encoding/json" + "sync" + "testing" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +type recordingMetrics struct { + mu sync.Mutex + records []recordedDispatch +} + +type recordedDispatch struct { + Contributor, Intent string + Version int + Kind contract.Kind + ErrCode contract.ErrorCode +} + +func (r *recordingMetrics) RecordDispatch(_ context.Context, c, i string, v int, k contract.Kind, _ time.Duration, errCode contract.ErrorCode) { + r.mu.Lock() + defer r.mu.Unlock() + r.records = append(r.records, recordedDispatch{c, i, v, k, errCode}) +} + +func TestDispatcher_EmitsMetrics_Success(t *testing.T) { + rm := &recordingMetrics{} + d := New(rm) + _ = d.Register("c", "i", 1, func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { + return &Result{Data: json.RawMessage(`null`)}, nil + }) + req := contract.Request{Envelope: "v1", Kind: contract.KindQuery, Contributor: "c", Intent: "i", IntentVersion: 1} + _, _, _ = d.Dispatch(context.Background(), req, contract.Principal{}) + rm.mu.Lock() + defer rm.mu.Unlock() + if len(rm.records) != 1 { + t.Fatalf("expected 1 record, got %d", len(rm.records)) + } + r := rm.records[0] + if r.ErrCode != "" { + t.Errorf("expected empty errCode for success, got %q", r.ErrCode) + } + if r.Kind != contract.KindQuery { + t.Errorf("kind = %v", r.Kind) + } +} + +func TestDispatcher_EmitsMetrics_Error(t *testing.T) { + rm := &recordingMetrics{} + d := New(rm) + _ = d.Register("c", "i", 1, func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { + return nil, &contract.Error{Code: contract.CodeConflict} + }) + req := contract.Request{Envelope: "v1", Kind: contract.KindCommand, Contributor: "c", Intent: "i", IntentVersion: 1} + _, _, _ = d.Dispatch(context.Background(), req, contract.Principal{}) + rm.mu.Lock() + defer rm.mu.Unlock() + if len(rm.records) != 1 || rm.records[0].ErrCode != contract.CodeConflict { + t.Errorf("expected conflict record, got %+v", rm.records) + } +} + +func TestDispatcher_EmitsMetrics_NotFound(t *testing.T) { + rm := &recordingMetrics{} + d := New(rm) + req := contract.Request{Envelope: "v1", Kind: contract.KindQuery, Contributor: "x", Intent: "y", IntentVersion: 1} + _, _, _ = d.Dispatch(context.Background(), req, contract.Principal{}) + rm.mu.Lock() + defer rm.mu.Unlock() + if len(rm.records) != 1 || rm.records[0].ErrCode != contract.CodeNotFound { + t.Errorf("expected not-found record, got %+v", rm.records) + } +} +``` + +Add `"time"` to the imports. + +- [ ] **Step 2: Run, expect PASS** + +Run: `go test ./extensions/dashboard/contract/dispatcher/...` +Expected: PASS — Phase 1 already wired RecordDispatch into the dispatcher, so the new tests should pass without any further code change. + +- [ ] **Step 3: Commit** + +```bash +git add extensions/dashboard/contract/dispatcher/metrics_test.go +git commit -m "test(dashboard/contract/dispatcher): cover metrics emission for success, error, and not-found paths" +``` + +--- + +## Phase 5: Contributor Interface (Layer b) + +### Task 5.1: RegisterContributor walks the contributor's tables + +**Files:** +- Create: `extensions/dashboard/contract/dispatcher/contributor.go` +- Create: `extensions/dashboard/contract/dispatcher/contributor_test.go` + +- [ ] **Step 1: Write failing tests** + +```go +package dispatcher + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +type fakeContributor struct { + name string + q map[IntentRef]Handler + s map[IntentRef]SubscriptionHandler +} + +func (f *fakeContributor) Name() string { return f.name } +func (f *fakeContributor) Handlers() map[IntentRef]Handler { return f.q } +func (f *fakeContributor) Subscriptions() map[IntentRef]SubscriptionHandler { return f.s } + +func TestRegisterContributor_RegistersAllTables(t *testing.T) { + d := New(NoopMetricsEmitter{}) + c := &fakeContributor{ + name: "users", + q: map[IntentRef]Handler{ + {Intent: "users.list", Version: 1}: func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { + return &Result{Data: json.RawMessage(`{}`)}, nil + }, + }, + s: map[IntentRef]SubscriptionHandler{ + {Intent: "users.events", Version: 1}: func(_ context.Context, _ map[string]any, _ contract.Principal) (<-chan contract.StreamEvent, func(), error) { + return nil, nil, nil + }, + }, + } + if err := d.RegisterContributor(c); err != nil { + t.Fatalf("register: %v", err) + } + + req := contract.Request{Envelope: "v1", Kind: contract.KindQuery, Contributor: "users", Intent: "users.list", IntentVersion: 1} + if _, _, err := d.Dispatch(context.Background(), req, contract.Principal{}); err != nil { + t.Errorf("dispatch query: %v", err) + } + intent := contract.Intent{Name: "users.events", Kind: contract.IntentKindSubscription, Version: 1, Capability: contract.CapRead} + if _, _, err := d.Subscribe(context.Background(), contract.Principal{}, "users", intent, nil); err != nil { + t.Errorf("subscribe: %v", err) + } +} + +func TestRegisterContributor_NameRequired(t *testing.T) { + d := New(NoopMetricsEmitter{}) + c := &fakeContributor{name: "", q: map[IntentRef]Handler{}, s: map[IntentRef]SubscriptionHandler{}} + if err := d.RegisterContributor(c); err == nil { + t.Error("expected name-required error") + } +} + +func TestRegisterContributor_PartialFailureIsAtomic(t *testing.T) { + // First register a conflicting handler; then attempt RegisterContributor and verify + // it surfaces the conflict and does not partially apply. + d := New(NoopMetricsEmitter{}) + _ = d.Register("users", "users.list", 1, func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { return &Result{}, nil }) + + c := &fakeContributor{ + name: "users", + q: map[IntentRef]Handler{ + {Intent: "users.detail", Version: 1}: func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { return &Result{}, nil }, + {Intent: "users.list", Version: 1}: func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { return &Result{}, nil }, + }, + s: nil, + } + err := d.RegisterContributor(c) + if err == nil { + t.Fatal("expected conflict error") + } + // users.detail must NOT be registered (atomicity). + req := contract.Request{Envelope: "v1", Kind: contract.KindQuery, Contributor: "users", Intent: "users.detail", IntentVersion: 1} + if _, _, dispErr := d.Dispatch(context.Background(), req, contract.Principal{}); dispErr == nil { + t.Error("partial registration leaked: users.detail should not be registered") + } else { + var ce *contract.Error + if !errors.As(dispErr, &ce) || ce.Code != contract.CodeNotFound { + t.Errorf("expected NotFound, got %v", dispErr) + } + } +} +``` + +- [ ] **Step 2: Run, expect FAIL** + +Run: `go test ./extensions/dashboard/contract/dispatcher/...` +Expected: FAIL — undefined RegisterContributor. + +- [ ] **Step 3: Implement contributor.go** + +```go +package dispatcher + +import "fmt" + +// RegisterContributor walks a Contributor's Handlers() and Subscriptions() maps +// and registers each one. Atomic: if any registration fails, all preceding +// registrations from this call are rolled back. +func (d *Dispatcher) RegisterContributor(c Contributor) error { + if c == nil { + return fmt.Errorf("dispatcher: nil contributor") + } + name := c.Name() + if name == "" { + return fmt.Errorf("dispatcher: contributor name is empty") + } + + // Snapshot what we register so we can roll back on failure. + var registeredHandlers []handlerKey + var registeredSubs []handlerKey + + rollback := func() { + d.mu.Lock() + defer d.mu.Unlock() + for _, k := range registeredHandlers { + delete(d.handlers, k) + } + for _, k := range registeredSubs { + delete(d.subscriptions, k) + } + } + + for ref, h := range c.Handlers() { + if err := d.Register(name, ref.Intent, ref.Version, h); err != nil { + rollback() + return fmt.Errorf("contributor %q: %w", name, err) + } + registeredHandlers = append(registeredHandlers, handlerKey{name, ref.Intent, ref.Version}) + } + for ref, h := range c.Subscriptions() { + if err := d.RegisterSubscription(name, ref.Intent, ref.Version, h); err != nil { + rollback() + return fmt.Errorf("contributor %q: %w", name, err) + } + registeredSubs = append(registeredSubs, handlerKey{name, ref.Intent, ref.Version}) + } + return nil +} +``` + +- [ ] **Step 4: Run, expect PASS** + +Run: `go test ./extensions/dashboard/contract/dispatcher/...` +Expected: PASS — all dispatcher tests + 3 new contributor tests. + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/contract/dispatcher/{contributor.go,contributor_test.go} +git commit -m "feat(dashboard/contract/dispatcher): contributor interface registration with atomic rollback" +``` + +--- + +## Phase 6: Pilot Package Skeleton + Types + +### Task 6.1: Pilot types + +**Files:** +- Create: `extensions/dashboard/contract/pilot/doc.go` +- Create: `extensions/dashboard/contract/pilot/types.go` +- Create: `extensions/dashboard/contract/pilot/types_test.go` + +- [ ] **Step 1: Write doc.go** + +```go +// Package pilot ships the migrated dashboard contributor used to validate +// the contract end-to-end: extensions.list, services.list, services.detail, +// and the metrics.summary subscription, all wired against the existing +// collector and contributor registry. +// +// See SLICE_C_DESIGN.md in the parent contract directory for the spec. +package pilot +``` + +- [ ] **Step 2: Write types_test.go (failing)** + +```go +package pilot + +import ( + "encoding/json" + "testing" +) + +func TestExtensionsList_RoundTrip(t *testing.T) { + in := ExtensionsList{Extensions: []ExtensionInfo{ + {Name: "auth", DisplayName: "Authentication", Version: "1.0", Layout: "extension", PageCount: 2, WidgetCount: 0}, + }} + b, err := json.Marshal(in) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got ExtensionsList + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.Extensions[0].DisplayName != "Authentication" { + t.Errorf("display name lost: %+v", got) + } +} + +func TestServiceDetail_NilSafe(t *testing.T) { + // A nil ServicesList should round-trip as `{"services":null}` not panic. + var sl ServicesList + b, err := json.Marshal(sl) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got ServicesList + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(got.Services) != 0 { + t.Errorf("expected zero services, got %d", len(got.Services)) + } +} +``` + +- [ ] **Step 3: Run, expect FAIL** + +Run: `go test ./extensions/dashboard/contract/pilot/...` +Expected: FAIL — undefined types. + +- [ ] **Step 4: Implement types.go** + +```go +package pilot + +import "github.com/xraph/forge/extensions/dashboard/collector" + +// ExtensionInfo is a flattened summary of one registered contributor manifest. +type ExtensionInfo struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + Version string `json:"version"` + Icon string `json:"icon,omitempty"` + Layout string `json:"layout,omitempty"` + PageCount int `json:"pageCount"` + WidgetCount int `json:"widgetCount"` +} + +// ExtensionsList is the response payload for the extensions.list query. +type ExtensionsList struct { + Extensions []ExtensionInfo `json:"extensions"` +} + +// ServicesList is the response payload for the services.list query. +type ServicesList struct { + Services []collector.ServiceInfo `json:"services"` +} + +// ServiceDetailResponse is the response payload for services.detail. +// (collector.ServiceDetail is reused as-is.) +type ServiceDetailResponse = collector.ServiceDetail + +// ServiceDetailInput is the input payload for services.detail. +type ServiceDetailInput struct { + Name string `json:"name"` +} + +// MetricsSummary is the per-event payload for the metrics.summary subscription. +type MetricsSummary struct { + TotalMetrics int `json:"totalMetrics"` + Counters int `json:"counters"` + Gauges int `json:"gauges"` + Histograms int `json:"histograms"` + TS int64 `json:"ts"` // unix seconds +} +``` + +- [ ] **Step 5: Run, expect PASS** + +Run: `go test ./extensions/dashboard/contract/pilot/...` +Expected: PASS — 2 tests. + +- [ ] **Step 6: Commit** + +```bash +git add extensions/dashboard/contract/pilot/{doc.go,types.go,types_test.go} +git commit -m "feat(dashboard/contract/pilot): payload types for the pilot intents" +``` + +--- + +## Phase 7: Pilot Manifest YAML + +### Task 7.1: Embedded YAML manifest + +**Files:** +- Create: `extensions/dashboard/contract/pilot/manifest.yaml` + +- [ ] **Step 1: Write manifest.yaml** + +```yaml +schemaVersion: 1 +contributor: + name: core-contract + envelope: + supports: [v1] + preferred: v1 + capabilities: [dashboard.read] + +queries: + extensionList: + intent: extensions.list + cache: { staleTime: 10s } + serviceList: + intent: services.list + cache: { staleTime: 5s } + +intents: + - name: extensions.list + kind: query + version: 1 + capability: read + + - name: services.list + kind: query + version: 1 + capability: read + + - name: services.detail + kind: query + version: 1 + capability: read + + - name: metrics.summary + kind: subscription + version: 1 + capability: read + mode: replace + +graph: + - route: /extensions + intent: page.shell + title: Extensions + nav: { group: Operations, icon: package, priority: 20 } + slots: + main: + - intent: resource.list + data: queries.extensionList + props: + columns: [name, displayName, version, layout, pageCount, widgetCount] + + - route: /services + intent: page.shell + title: Services + nav: { group: Operations, icon: server, priority: 21 } + slots: + main: + - intent: resource.list + data: queries.serviceList + props: + columns: [name, type, status] + slots: + detailDrawer: + - intent: resource.detail + data: + intent: services.detail + params: { name: { from: parent.name } } + + - route: /metrics/live + intent: page.shell + title: Live Metrics + nav: { group: Operations, icon: activity, priority: 22 } + slots: + main: + - intent: dashboard.grid + slots: + widgets: + - intent: metric.counter + title: Metrics Summary + data: + intent: metrics.summary +``` + +- [ ] **Step 2: Validate the YAML loads** — write a quick test. + +Add to `pilot/types_test.go` (or create `pilot/manifest_test.go`): + +```go +package pilot + +import ( + _ "embed" + "strings" + "testing" + + "github.com/xraph/forge/extensions/dashboard/contract" + "github.com/xraph/forge/extensions/dashboard/contract/loader" +) + +//go:embed manifest.yaml +var manifestYAML []byte + +func TestPilotManifest_Loads(t *testing.T) { + m, err := loader.Load(strings.NewReader(string(manifestYAML)), "pilot/manifest.yaml") + if err != nil { + t.Fatalf("load: %v", err) + } + if m.Contributor.Name != "core-contract" { + t.Errorf("contributor name = %q", m.Contributor.Name) + } + if got := len(m.Intents); got != 4 { + t.Errorf("intents = %d, want 4", got) + } + if got := len(m.Graph); got != 3 { + t.Errorf("graph routes = %d, want 3", got) + } +} + +func TestPilotManifest_Validates(t *testing.T) { + m, err := loader.Load(strings.NewReader(string(manifestYAML)), "pilot/manifest.yaml") + if err != nil { + t.Fatalf("load: %v", err) + } + if err := loader.Validate(m, contract.NewWardenRegistry()); err != nil { + t.Errorf("validate: %v", err) + } +} +``` + +- [ ] **Step 3: Run tests** + +Run: `go test ./extensions/dashboard/contract/pilot/...` +Expected: PASS — manifest loads and validates. + +- [ ] **Step 4: Commit** + +```bash +git add extensions/dashboard/contract/pilot/{manifest.yaml,types_test.go} +git commit -m "feat(dashboard/contract/pilot): embedded YAML manifest with three routes and four intents" +``` + +--- + +## Phase 8: Pilot Query Handlers + +### Task 8.1: extensions.list, services.list, services.detail + +**Files:** +- Create: `extensions/dashboard/contract/pilot/extensions.go` +- Create: `extensions/dashboard/contract/pilot/services.go` +- Create: `extensions/dashboard/contract/pilot/extensions_test.go` +- Create: `extensions/dashboard/contract/pilot/services_test.go` + +The handlers depend on: +- `*contributor.ContributorRegistry` (for extensions.list — list of registered manifests) +- `*collector.DataCollector` (for services.list and services.detail) + +We collect both into a `Deps` struct. + +- [ ] **Step 1: Write failing tests for extensions handler** + +`extensions_test.go`: + +```go +package pilot + +import ( + "context" + "encoding/json" + "testing" + + "github.com/xraph/forge/extensions/dashboard/contract" + "github.com/xraph/forge/extensions/dashboard/contributor" +) + +func newRegistryWith(t *testing.T, manifests ...*contributor.Manifest) *contributor.ContributorRegistry { + t.Helper() + r := contributor.NewContributorRegistry("/dashboard") + for _, m := range manifests { + stub := &stubLocal{manifest: m} + if err := r.Register(stub); err != nil { + t.Fatalf("register %q: %v", m.Name, err) + } + } + return r +} + +type stubLocal struct{ manifest *contributor.Manifest } + +func (s *stubLocal) Manifest() *contributor.Manifest { return s.manifest } + +func TestExtensionsListHandler_ReturnsRegisteredContributors(t *testing.T) { + r := newRegistryWith(t, + &contributor.Manifest{Name: "auth", DisplayName: "Authentication", Version: "1.0", Layout: "extension", Nav: []contributor.NavItem{{}, {}}, Widgets: nil}, + &contributor.Manifest{Name: "cron", DisplayName: "", Version: "0.9", Widgets: []contributor.WidgetDescriptor{{}}}, + ) + + h := extensionsListHandler(r) + res, err := h(context.Background(), nil, contract.Principal{}) + if err != nil { + t.Fatalf("handler: %v", err) + } + if len(res.Extensions) != 2 { + t.Fatalf("got %d, want 2", len(res.Extensions)) + } + // Check that empty DisplayName is filled with Name (matches today's API behavior). + for _, e := range res.Extensions { + if e.Name == "cron" && e.DisplayName != "cron" { + t.Errorf("cron display name fallback = %q", e.DisplayName) + } + } + + // Verify the result encodes cleanly to JSON. + if _, err := json.Marshal(res); err != nil { + t.Errorf("marshal: %v", err) + } +} +``` + +- [ ] **Step 2: Run, expect FAIL** + +Run: `go test ./extensions/dashboard/contract/pilot/...` +Expected: FAIL — undefined extensionsListHandler. + +- [ ] **Step 3: Implement extensions.go** + +```go +package pilot + +import ( + "context" + + "github.com/xraph/forge/extensions/dashboard/contract" + "github.com/xraph/forge/extensions/dashboard/contributor" +) + +// extensionsListHandler is exposed to the dispatcher via RegisterQuery. +// Mirrors the existing /api/extensions JSON shape so consumers can compare directly. +func extensionsListHandler(reg *contributor.ContributorRegistry) func(ctx context.Context, _ struct{}, _ contract.Principal) (ExtensionsList, error) { + return func(_ context.Context, _ struct{}, _ contract.Principal) (ExtensionsList, error) { + names := reg.ContributorNames() + out := make([]ExtensionInfo, 0, len(names)) + for _, name := range names { + m, ok := reg.GetManifest(name) + if !ok { + continue + } + displayName := m.DisplayName + if displayName == "" { + displayName = name + } + out = append(out, ExtensionInfo{ + Name: m.Name, + DisplayName: displayName, + Version: m.Version, + Icon: m.Icon, + Layout: m.Layout, + PageCount: len(m.Nav), + WidgetCount: len(m.Widgets), + }) + } + return ExtensionsList{Extensions: out}, nil + } +} +``` + +- [ ] **Step 4: Run, expect PASS** + +Run: `go test ./extensions/dashboard/contract/pilot/...` +Expected: PASS. + +- [ ] **Step 5: Write services handler tests + impl** + +`services_test.go`: + +```go +package pilot + +import ( + "context" + "testing" + + "github.com/xraph/forge/extensions/dashboard/collector" + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// servicesProvider abstracts the collector for tests. +type servicesProvider interface { + CollectServices(ctx context.Context) []collector.ServiceInfo + CollectServiceDetail(ctx context.Context, name string) *collector.ServiceDetail +} + +type stubServices struct { + list []collector.ServiceInfo + detail map[string]*collector.ServiceDetail +} + +func (s *stubServices) CollectServices(_ context.Context) []collector.ServiceInfo { + return s.list +} +func (s *stubServices) CollectServiceDetail(_ context.Context, name string) *collector.ServiceDetail { + return s.detail[name] +} + +func TestServicesListHandler(t *testing.T) { + stub := &stubServices{list: []collector.ServiceInfo{{Name: "db", Status: "healthy"}, {Name: "cache", Status: "degraded"}}} + h := servicesListHandler(stub) + res, err := h(context.Background(), struct{}{}, contract.Principal{}) + if err != nil { + t.Fatalf("handler: %v", err) + } + if len(res.Services) != 2 { + t.Errorf("services = %d", len(res.Services)) + } +} + +func TestServicesDetailHandler_Found(t *testing.T) { + stub := &stubServices{detail: map[string]*collector.ServiceDetail{ + "db": {Name: "db", Type: "postgres"}, + }} + h := servicesDetailHandler(stub) + res, err := h(context.Background(), ServiceDetailInput{Name: "db"}, contract.Principal{}) + if err != nil { + t.Fatalf("handler: %v", err) + } + if res == nil || res.Name != "db" { + t.Errorf("detail = %+v", res) + } +} + +func TestServicesDetailHandler_NotFound(t *testing.T) { + stub := &stubServices{detail: map[string]*collector.ServiceDetail{}} + h := servicesDetailHandler(stub) + _, err := h(context.Background(), ServiceDetailInput{Name: "missing"}, contract.Principal{}) + if err == nil { + t.Fatal("expected not-found") + } + ce, ok := err.(*contract.Error) + if !ok || ce.Code != contract.CodeNotFound { + t.Errorf("expected CodeNotFound, got %v", err) + } +} + +func TestServicesDetailHandler_EmptyNameIsBadRequest(t *testing.T) { + stub := &stubServices{} + h := servicesDetailHandler(stub) + _, err := h(context.Background(), ServiceDetailInput{Name: ""}, contract.Principal{}) + if err == nil { + t.Fatal("expected bad-request") + } + ce, ok := err.(*contract.Error) + if !ok || ce.Code != contract.CodeBadRequest { + t.Errorf("expected CodeBadRequest, got %v", err) + } +} +``` + +`services.go`: + +```go +package pilot + +import ( + "context" + + "github.com/xraph/forge/extensions/dashboard/collector" + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// ServicesProvider is the slice of the collector's API the pilot calls. +// Splitting it out lets tests stub the collector without the full DataCollector. +type ServicesProvider interface { + CollectServices(ctx context.Context) []collector.ServiceInfo + CollectServiceDetail(ctx context.Context, name string) *collector.ServiceDetail +} + +func servicesListHandler(p ServicesProvider) func(ctx context.Context, _ struct{}, _ contract.Principal) (ServicesList, error) { + return func(ctx context.Context, _ struct{}, _ contract.Principal) (ServicesList, error) { + return ServicesList{Services: p.CollectServices(ctx)}, nil + } +} + +func servicesDetailHandler(p ServicesProvider) func(ctx context.Context, in ServiceDetailInput, _ contract.Principal) (*ServiceDetailResponse, error) { + return func(ctx context.Context, in ServiceDetailInput, _ contract.Principal) (*ServiceDetailResponse, error) { + if in.Name == "" { + return nil, &contract.Error{Code: contract.CodeBadRequest, Message: "name is required"} + } + d := p.CollectServiceDetail(ctx, in.Name) + if d == nil { + return nil, &contract.Error{Code: contract.CodeNotFound, Message: "service " + in.Name + " not found"} + } + return d, nil + } +} +``` + +- [ ] **Step 6: Run, expect PASS** + +Run: `go test ./extensions/dashboard/contract/pilot/...` +Expected: PASS — 4 services tests + 1 extensions test + 2 type tests + 2 manifest tests. + +- [ ] **Step 7: Commit** + +```bash +git add extensions/dashboard/contract/pilot/{extensions.go,services.go,extensions_test.go,services_test.go} +git commit -m "feat(dashboard/contract/pilot): query handlers for extensions, services list, services detail" +``` + +--- + +## Phase 9: Pilot Subscription Handler + +### Task 9.1: metrics.summary + +**Files:** +- Create: `extensions/dashboard/contract/pilot/metrics.go` +- Create: `extensions/dashboard/contract/pilot/metrics_test.go` + +The handler needs an injectable interval so tests don't have to wait 5 seconds. Use a `time.Duration` parameter on the constructor. + +- [ ] **Step 1: Write failing tests** + +```go +package pilot + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/xraph/forge/extensions/dashboard/collector" + "github.com/xraph/forge/extensions/dashboard/contract" +) + +type stubMetrics struct { + data *collector.MetricsData +} + +func (s *stubMetrics) CollectMetrics(_ context.Context) *collector.MetricsData { + return s.data +} + +func TestMetricsSummarySub_EmitsOnTick(t *testing.T) { + stub := &stubMetrics{data: &collector.MetricsData{ + Stats: collector.MetricsStats{TotalMetrics: 10, Counters: 4, Gauges: 3, Histograms: 3}, + }} + h := metricsSummarySub(stub, 10*time.Millisecond) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + ch, stop, err := h(ctx, struct{}{}, contract.Principal{}) + if err != nil { + t.Fatalf("subscribe: %v", err) + } + defer stop() + + select { + case ev, ok := <-ch: + if !ok { + t.Fatal("channel closed before event") + } + var got MetricsSummary + if err := json.Unmarshal(jsonOf(ev), &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.TotalMetrics != 10 { + t.Errorf("TotalMetrics = %d", got.TotalMetrics) + } + case <-ctx.Done(): + t.Fatal("timed out waiting for tick") + } +} + +// jsonOf is a tiny helper for tests reading typed events out of the typed +// subscription handler (which returns chan MetricsSummary, not StreamEvent). +func jsonOf(v MetricsSummary) []byte { + b, _ := json.Marshal(v) + return b +} + +func TestMetricsSummarySub_StopsOnCancel(t *testing.T) { + stub := &stubMetrics{data: &collector.MetricsData{}} + h := metricsSummarySub(stub, time.Millisecond) + ctx, cancel := context.WithCancel(context.Background()) + ch, stop, err := h(ctx, struct{}{}, contract.Principal{}) + if err != nil { + t.Fatalf("subscribe: %v", err) + } + defer stop() + // Drain a few events + go func() { + for range ch { + } + }() + cancel() + // Channel should close shortly after cancellation + deadline := time.After(500 * time.Millisecond) + for { + select { + case _, ok := <-ch: + if !ok { + return // closed — pass + } + case <-deadline: + t.Fatal("channel did not close after cancel") + } + } +} +``` + +The handler returns a typed channel `<-chan MetricsSummary`; the tests need to read typed events directly. + +- [ ] **Step 2: Run, expect FAIL** + +Run: `go test ./extensions/dashboard/contract/pilot/...` +Expected: FAIL — undefined. + +- [ ] **Step 3: Implement metrics.go** + +```go +package pilot + +import ( + "context" + "time" + + "github.com/xraph/forge/extensions/dashboard/collector" + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// MetricsProvider is the slice of DataCollector the metrics.summary handler needs. +type MetricsProvider interface { + CollectMetrics(ctx context.Context) *collector.MetricsData +} + +// metricsSummarySub returns a typed subscription handler that emits a +// MetricsSummary every interval until ctx is cancelled. The interval is +// injectable so tests can use millisecond ticks instead of 5 seconds. +func metricsSummarySub(p MetricsProvider, interval time.Duration) func(ctx context.Context, _ struct{}, _ contract.Principal) (<-chan MetricsSummary, func(), error) { + return func(ctx context.Context, _ struct{}, _ contract.Principal) (<-chan MetricsSummary, func(), error) { + out := make(chan MetricsSummary, 4) + ticker := time.NewTicker(interval) + go func() { + defer close(out) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case t := <-ticker.C: + data := p.CollectMetrics(ctx) + if data == nil { + continue + } + ev := MetricsSummary{ + TotalMetrics: data.Stats.TotalMetrics, + Counters: data.Stats.Counters, + Gauges: data.Stats.Gauges, + Histograms: data.Stats.Histograms, + TS: t.Unix(), + } + select { + case out <- ev: + case <-ctx.Done(): + return + } + } + } + }() + return out, func() {}, nil + } +} +``` + +- [ ] **Step 4: Run, expect PASS** + +Run: `go test ./extensions/dashboard/contract/pilot/...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/contract/pilot/{metrics.go,metrics_test.go} +git commit -m "feat(dashboard/contract/pilot): metrics.summary replace-mode subscription handler" +``` + +--- + +## Phase 10: Pilot Register Entry Point + +### Task 10.1: pilot.Register wires manifest + handlers into the dispatcher and contract registry + +**Files:** +- Create: `extensions/dashboard/contract/pilot/pilot.go` +- Create: `extensions/dashboard/contract/pilot/pilot_test.go` + +- [ ] **Step 1: Write failing tests** + +```go +package pilot + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/xraph/forge/extensions/dashboard/contract" + "github.com/xraph/forge/extensions/dashboard/contract/dispatcher" +) + +func TestPilotRegister_RegistersAllIntents(t *testing.T) { + d := dispatcher.New(dispatcher.NoopMetricsEmitter{}) + reg := contract.NewRegistry() + wreg := contract.NewWardenRegistry() + + deps := Deps{ + ExtensionsRegistry: newRegistryWith(t, &contributor.Manifest{Name: "auth"}), + Services: &stubServices{}, + Metrics: &stubMetrics{data: &collector.MetricsData{}}, + MetricsInterval: time.Millisecond, + } + if err := Register(d, reg, wreg, deps); err != nil { + t.Fatalf("Register: %v", err) + } + + // Contract registry has the pilot manifest. + if _, ok := reg.Contributor("core-contract"); !ok { + t.Error("core-contract not in contract registry") + } + // Dispatcher has each intent. + for _, intentName := range []string{"extensions.list", "services.list", "services.detail"} { + req := contract.Request{Envelope: "v1", Kind: contract.KindQuery, Contributor: "core-contract", Intent: intentName, IntentVersion: 1, Payload: json.RawMessage(`{}`)} + _, _, err := d.Dispatch(context.Background(), req, contract.Principal{}) + if err != nil && intentName != "services.detail" { + t.Errorf("%s dispatch: %v", intentName, err) + } + } +} + +func TestPilotRegister_DefaultsMetricsInterval(t *testing.T) { + d := dispatcher.New(dispatcher.NoopMetricsEmitter{}) + reg := contract.NewRegistry() + wreg := contract.NewWardenRegistry() + deps := Deps{ + ExtensionsRegistry: newRegistryWith(t), + Services: &stubServices{}, + Metrics: &stubMetrics{data: &collector.MetricsData{}}, + // MetricsInterval intentionally zero + } + if err := Register(d, reg, wreg, deps); err != nil { + t.Fatalf("Register: %v", err) + } + // No assertion on the actual interval; verify Register didn't error and + // the subscription is registered. + intent := contract.Intent{Name: "metrics.summary", Kind: contract.IntentKindSubscription, Version: 1, Capability: contract.CapRead} + if _, _, err := d.Subscribe(context.Background(), contract.Principal{}, "core-contract", intent, nil); err != nil { + t.Errorf("metrics.summary not registered: %v", err) + } +} +``` + +Add `"github.com/xraph/forge/extensions/dashboard/collector"` and `"github.com/xraph/forge/extensions/dashboard/contributor"` to the imports. + +- [ ] **Step 2: Run, expect FAIL** + +Run: `go test ./extensions/dashboard/contract/pilot/...` +Expected: FAIL — undefined Register / Deps. + +- [ ] **Step 3: Implement pilot.go** + +```go +package pilot + +import ( + "bytes" + "fmt" + "time" + + "github.com/xraph/forge/extensions/dashboard/contract" + "github.com/xraph/forge/extensions/dashboard/contract/dispatcher" + "github.com/xraph/forge/extensions/dashboard/contract/loader" + "github.com/xraph/forge/extensions/dashboard/contributor" +) + +// DefaultMetricsInterval is the production tick rate for metrics.summary. +const DefaultMetricsInterval = 5 * time.Second + +// Deps bundles the data sources the pilot handlers need. The dashboard +// extension constructs this when it wires the pilot at startup. +type Deps struct { + ExtensionsRegistry *contributor.ContributorRegistry + Services ServicesProvider + Metrics MetricsProvider + // MetricsInterval is how often metrics.summary emits. Zero defaults to + // DefaultMetricsInterval. Tests use millisecond values. + MetricsInterval time.Duration +} + +// Register loads the embedded pilot manifest, validates it, registers it with +// the contract registry, and binds the four handlers against the dispatcher. +// Idempotent: calling twice on the same registries returns the duplicate- +// registration error from the second call. +func Register(d *dispatcher.Dispatcher, contractReg contract.Registry, wreg contract.WardenRegistry, deps Deps) error { + if deps.ExtensionsRegistry == nil { + return fmt.Errorf("pilot: ExtensionsRegistry is required") + } + if deps.Services == nil { + return fmt.Errorf("pilot: Services is required") + } + if deps.Metrics == nil { + return fmt.Errorf("pilot: Metrics is required") + } + interval := deps.MetricsInterval + if interval <= 0 { + interval = DefaultMetricsInterval + } + + m, err := loader.Load(bytes.NewReader(manifestYAML), "pilot/manifest.yaml") + if err != nil { + return fmt.Errorf("pilot: loading manifest: %w", err) + } + if err := loader.Validate(m, wreg); err != nil { + return fmt.Errorf("pilot: validating manifest: %w", err) + } + if err := contractReg.Register(m); err != nil { + return fmt.Errorf("pilot: contract registry: %w", err) + } + + const c = "core-contract" + if err := dispatcher.RegisterQuery(d, c, "extensions.list", 1, extensionsListHandler(deps.ExtensionsRegistry)); err != nil { + return err + } + if err := dispatcher.RegisterQuery(d, c, "services.list", 1, servicesListHandler(deps.Services)); err != nil { + return err + } + if err := dispatcher.RegisterQuery(d, c, "services.detail", 1, servicesDetailHandler(deps.Services)); err != nil { + return err + } + if err := dispatcher.RegisterSubscription(d, c, "metrics.summary", 1, metricsSummarySub(deps.Metrics, interval)); err != nil { + return err + } + return nil +} +``` + +- [ ] **Step 4: Run, expect PASS** + +Run: `go test ./extensions/dashboard/contract/pilot/...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/contract/pilot/{pilot.go,pilot_test.go} +git commit -m "feat(dashboard/contract/pilot): Register entry point wires manifest + handlers" +``` + +--- + +## Phase 11: Wire Dispatcher + Broker Into the Dashboard Extension + +### Task 11.1: Replace NilDispatcher; instantiate StreamBroker; call pilot.Register + +**Files:** +- Modify: `extensions/dashboard/extension.go` + +This is the integration step that makes everything work end-to-end. Slice (a)'s wire-up left two TODOs: the dispatcher was a `NilDispatcher{}` and `streamBroker` was nil. Slice (c) fixes both. + +- [ ] **Step 1: Read the current state** + +```bash +grep -n "NilDispatcher\|contractRegistry\|wardenRegistry\|streamBroker\|auditEmitter" extensions/dashboard/extension.go +``` + +You should see: +- The struct fields added in slice (a) Phase 13. +- Their initialisation in `NewExtension`. +- `transport.NilDispatcher{}` passed into `transport.NewHandler` inside `handleContractPOST`. + +- [ ] **Step 2: Add a `dispatcher` field on the Extension struct** + +In the struct definition (near the contract fields): + +```go +dispatcher *dispatcher.Dispatcher +``` + +Imports: `"github.com/xraph/forge/extensions/dashboard/contract/dispatcher"`. Watch out for the name clash with the existing `dispatcher` package name in some forge subtrees — if needed, alias as `contractDispatcher`. Verify with `goimports`. + +- [ ] **Step 3: Initialise it in `NewExtension`** + +Replace the existing init block where `auditEmitter` is set with: + +```go +disp := dispatcher.New(dispatcher.NoopMetricsEmitter{}) +ext.dispatcher = disp +``` + +And construct the stream broker right after the contract registry exists: + +```go +ext.streamBroker = transport.NewStreamBroker(ext.contractRegistry, ext.wardenRegistry, disp) +``` + +- [ ] **Step 4: Update `handleContractPOST` to use the real dispatcher** + +```go +func (e *Extension) handleContractPOST() http.HandlerFunc { + h := transport.NewHandler(e.contractRegistry, e.wardenRegistry, e.dispatcher, e.auditEmitter) + return h.ServeHTTP +} +``` + +(Replace `transport.NilDispatcher{}` with `e.dispatcher`.) + +- [ ] **Step 5: Register the pilot** + +Locate the place where `e.collector` is fully initialised and `e.contributor.ContributorRegistry` is the registry the dashboard already uses for legacy contributors. After both are ready (typically inside `Start` or right after `NewExtension`'s setup completes): + +```go +import "github.com/xraph/forge/extensions/dashboard/contract/pilot" + +// pilotDeps wires data sources the pilot handlers need. +pilotDeps := pilot.Deps{ + ExtensionsRegistry: e.contributor, // or whatever holds *contributor.ContributorRegistry + Services: e.collector, + Metrics: e.collector, + // MetricsInterval defaults to 5s when zero. +} +if err := pilot.Register(e.dispatcher, e.contractRegistry, e.wardenRegistry, pilotDeps); err != nil { + return fmt.Errorf("dashboard: registering contract pilot: %w", err) +} +``` + +> **Find the right method.** The pilot must be registered *after* the contract registry is constructed and *before* the routes are registered (so route registration sees the pilot's manifest). If the existing extension structure makes this awkward — e.g. the registry is constructed in `NewExtension` but contributors register inside `Start` — add the pilot inside `NewExtension` right after the field init block. The pilot only depends on `e.collector` and `e.contributor`, both available at NewExtension time per slice (a)'s Phase 13 wiring. + +- [ ] **Step 6: Build and test** + +```bash +go build ./... +go test ./extensions/dashboard/... +``` + +Expected: clean build; all tests pass — including the pilot tests, the contract tests (54+ from slice a), and the legacy dashboard tests. + +If a `dashboard.New` constructor or fixture in another file changes its initialisation order in surprising ways, address it; do not relax test assertions. + +- [ ] **Step 7: Commit** + +```bash +git add extensions/dashboard/extension.go +git commit -m "feat(dashboard): wire real dispatcher, stream broker, and pilot contributor" +``` + +--- + +## Phase 12: End-to-End Pilot Test + +### Task 12.1: Drive the contract HTTP and SSE handlers with the wired pilot + +**Files:** +- Create: `extensions/dashboard/contract/pilot/pilot_e2e_test.go` + +This test stands up everything in-process: contract registry, dispatcher, transport handler, stream broker, pilot registration. It then drives requests through the public HTTP and SSE entry points and asserts the envelope shapes. + +- [ ] **Step 1: Write the E2E test** + +```go +package pilot + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/xraph/forge/extensions/dashboard/collector" + "github.com/xraph/forge/extensions/dashboard/contract" + "github.com/xraph/forge/extensions/dashboard/contract/dispatcher" + "github.com/xraph/forge/extensions/dashboard/contract/transport" + "github.com/xraph/forge/extensions/dashboard/contributor" +) + +func setupPilotEnv(t *testing.T) (http.Handler, *transport.StreamBroker, *dispatcher.Dispatcher) { + t.Helper() + d := dispatcher.New(dispatcher.NoopMetricsEmitter{}) + reg := contract.NewRegistry() + wreg := contract.NewWardenRegistry() + + extReg := newRegistryWith(t, + &contributor.Manifest{Name: "auth", DisplayName: "Authentication", Version: "1.0"}, + ) + deps := Deps{ + ExtensionsRegistry: extReg, + Services: &stubServices{list: []collector.ServiceInfo{{Name: "db", Status: "healthy"}}}, + Metrics: &stubMetrics{data: &collector.MetricsData{Stats: collector.MetricsStats{TotalMetrics: 5}}}, + MetricsInterval: 20 * time.Millisecond, + } + if err := Register(d, reg, wreg, deps); err != nil { + t.Fatalf("pilot register: %v", err) + } + httpHandler := transport.NewHandler(reg, wreg, d, contract.NoopAuditEmitter{}) + broker := transport.NewStreamBroker(reg, wreg, d) + return httpHandler, broker, d +} + +func TestPilotE2E_ExtensionsList_HTTPRoundTrip(t *testing.T) { + h, _, _ := setupPilotEnv(t) + body, _ := json.Marshal(contract.Request{ + Envelope: "v1", Kind: contract.KindQuery, + Contributor: "core-contract", Intent: "extensions.list", IntentVersion: 1, + }) + req := httptest.NewRequest(http.MethodPost, "/api/dashboard/v1", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", w.Code, w.Body) + } + var resp contract.Response + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !resp.OK { + t.Errorf("ok = false") + } + var data ExtensionsList + if err := json.Unmarshal(resp.Data, &data); err != nil { + t.Fatalf("data unmarshal: %v", err) + } + if len(data.Extensions) != 1 || data.Extensions[0].Name != "auth" { + t.Errorf("extensions = %+v", data.Extensions) + } +} + +func TestPilotE2E_ServicesDetail_NotFoundEnvelope(t *testing.T) { + h, _, _ := setupPilotEnv(t) + body, _ := json.Marshal(contract.Request{ + Envelope: "v1", Kind: contract.KindQuery, + Contributor: "core-contract", Intent: "services.detail", IntentVersion: 1, + Payload: json.RawMessage(`{"name":"missing"}`), + }) + req := httptest.NewRequest(http.MethodPost, "/api/dashboard/v1", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusInternalServerError { + // transport.NewHandler maps errors via asContractError; verify the wire envelope code rather than HTTP status. + } + if !strings.Contains(w.Body.String(), "NOT_FOUND") { + t.Errorf("expected NOT_FOUND in body: %s", w.Body) + } +} + +func TestPilotE2E_MetricsSummary_SSE(t *testing.T) { + _, broker, _ := setupPilotEnv(t) + + // Open the SSE stream + streamReq := httptest.NewRequest(http.MethodGet, "/api/dashboard/v1/stream", nil) + streamCtx, cancelStream := context.WithCancel(streamReq.Context()) + streamReq = streamReq.WithContext(streamCtx) + streamW := httptest.NewRecorder() + + streamDone := make(chan struct{}) + go func() { + broker.ServeStream(streamW, streamReq) + close(streamDone) + }() + + // Wait for the broker to register the stream + deadline := time.After(250 * time.Millisecond) + var streamID string +LOOP: + for { + ids := broker.SnapshotIDs() + if len(ids) > 0 { + streamID = ids[0] + break LOOP + } + select { + case <-deadline: + t.Fatal("stream not registered in time") + case <-time.After(5 * time.Millisecond): + } + } + + // Subscribe via control + cmd, _ := json.Marshal(transport.ControlMessage{ + StreamID: streamID, Op: "subscribe", + Contributor: "core-contract", Intent: "metrics.summary", + SubscriptionID: "s1", + }) + ctlReq := httptest.NewRequest(http.MethodPost, "/api/dashboard/v1/stream/control", bytes.NewReader(cmd)) + ctlW := httptest.NewRecorder() + broker.ServeControl(ctlW, ctlReq) + if ctlW.Code != http.StatusOK { + t.Fatalf("control = %d body=%s", ctlW.Code, ctlW.Body) + } + + // Wait for at least one event to land in the recorder + deadline = time.After(500 * time.Millisecond) + for { + if strings.Contains(streamW.Body.String(), `"totalMetrics":5`) { + break + } + select { + case <-deadline: + t.Fatalf("no metrics event in time; body=%s", streamW.Body) + case <-time.After(10 * time.Millisecond): + } + } + + cancelStream() + <-streamDone +} +``` + +- [ ] **Step 2: Run the E2E test with -race** + +```bash +go test -race -count=1 ./extensions/dashboard/contract/pilot/... +``` + +Expected: PASS, race-clean. + +- [ ] **Step 3: Build the probe CLI and run a manual smoke (optional, not automated)** + +```bash +go build -o /tmp/dashboard-contract-probe ./cmd/dashboard-contract-probe +# (with the dashboard running on :8080) +/tmp/dashboard-contract-probe -base=http://localhost:8080 -kind=query -contributor=core-contract -intent=extensions.list +``` + +Expected: HTTP 200 with the extensions list JSON. + +- [ ] **Step 4: Commit** + +```bash +git add extensions/dashboard/contract/pilot/pilot_e2e_test.go +git commit -m "test(dashboard/contract/pilot): end-to-end HTTP + SSE round-trip through the wired pilot" +``` + +--- + +## Final Verification + +- [ ] **Run the whole repo test suite** + +```bash +go test -count=1 ./... +``` +Expected: all PASS. + +- [ ] **Run the dashboard subtree with -race** + +```bash +go test -race -count=1 ./extensions/dashboard/... +``` +Expected: race-clean. + +- [ ] **Vet the new packages** + +```bash +go vet ./extensions/dashboard/contract/... +``` +Expected: clean. + +## Self-Review Notes + +- **Spec coverage:** Every row in SLICE_C_DESIGN.md's "Design Decisions" table maps to a phase or task. Layer (a) is Phase 1; layer (b) is Phase 5; layer (c) is Phase 3. Subscription handler shape is Phase 2 + Phase 9. The `Result` struct is Phase 0. Error mapping is Phase 1. Metrics emitter is Phase 1+4. Pilot scope is Phases 6-10. Wire-up is Phase 11. E2E is Phase 12. +- **Spec deviation called out:** `metrics.cpu` → `metrics.summary` is the only deviation. Documented in the plan's preamble. +- **No placeholders:** All TDD steps include real test code and real implementation code. No "TBD", no "fill in", no "similar to Phase N". +- **Naming consistency:** `Handler`, `SubscriptionHandler`, `Result`, `IntentRef`, `Contributor` are defined in Phase 0 and used identically through Phases 1-12. `MetricsEmitter` interface is defined in Phase 1 (stub) and tested fully in Phase 4 — no signature change between the stub and the test usage. +- **Out-of-scope items honoured:** No CSRF middleware integration, no Prometheus wiring, no chronicle integration, no React shell — those stay in slice (b) / (d). +- **Concrete data sources verified:** `CollectServices`, `CollectServiceDetail`, `CollectMetrics` are real methods on `collector.DataCollector` (verified via grep before plan write). `ContributorNames` and `GetManifest` are the existing `*ContributorRegistry` methods used by today's `/api/extensions` handler. From 317672640e9709fea77045f4bf0fc7d94b35232a Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 22:23:11 -0500 Subject: [PATCH 28/89] feat(dashboard/contract/dispatcher): add package skeleton and handler types --- .../dashboard/contract/dispatcher/doc.go | 7 +++ .../dashboard/contract/dispatcher/handler.go | 52 +++++++++++++++++++ .../contract/dispatcher/handler_test.go | 34 ++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 extensions/dashboard/contract/dispatcher/doc.go create mode 100644 extensions/dashboard/contract/dispatcher/handler.go create mode 100644 extensions/dashboard/contract/dispatcher/handler_test.go diff --git a/extensions/dashboard/contract/dispatcher/doc.go b/extensions/dashboard/contract/dispatcher/doc.go new file mode 100644 index 00000000..db2f8bab --- /dev/null +++ b/extensions/dashboard/contract/dispatcher/doc.go @@ -0,0 +1,7 @@ +// Package dispatcher implements transport.Dispatcher and transport.SubscriptionSource +// against a function-table of registered handlers. Contributors register their +// intent handlers via Register / RegisterSubscription / RegisterContributor; +// the HTTP and SSE transports look them up at request time. +// +// See SLICE_C_DESIGN.md in the parent contract directory for the spec this implements. +package dispatcher diff --git a/extensions/dashboard/contract/dispatcher/handler.go b/extensions/dashboard/contract/dispatcher/handler.go new file mode 100644 index 00000000..cf8308ac --- /dev/null +++ b/extensions/dashboard/contract/dispatcher/handler.go @@ -0,0 +1,52 @@ +package dispatcher + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// Handler is the foundation function-table handler signature for query and +// command intents. Returning a *contract.Error propagates the canonical code +// to the wire; any other error is wrapped as CodeInternal at dispatch time. +type Handler func(ctx context.Context, payload json.RawMessage, params map[string]any, p contract.Principal) (*Result, error) + +// SubscriptionHandler is the function-table handler for subscription intents. +// The handler returns a channel of events, a force-stop function, and an +// optional error. Closing the channel signals end-of-stream; cancelling ctx +// is the canonical way to ask the handler to stop emitting. +type SubscriptionHandler func(ctx context.Context, params map[string]any, p contract.Principal) (<-chan contract.StreamEvent, func(), error) + +// Result carries the data payload plus optional response-meta overrides. +// Handlers that don't need to influence meta can return &Result{Data: ...}; +// handlers that need to add invalidations or override cache hints set the +// extra fields. +type Result struct { + // Data is the JSON-encoded response body. May be nil for a {data: null} response. + Data json.RawMessage + // ExtraInvalidates is appended to the manifest's declared Invalidates. + ExtraInvalidates []string + // CacheOverride, when non-nil, replaces the manifest's declared cache hint. + CacheOverride *contract.CacheHint +} + +// IntentRef is the (intent, version) tuple used as a registration key. +type IntentRef struct { + Intent string + Version int +} + +// String formats as "intent@version" — used in error messages and logs. +func (r IntentRef) String() string { + return fmt.Sprintf("%s@%d", r.Intent, r.Version) +} + +// Contributor is layer (b)'s registration shape: a contributor publishes its +// handler and subscription tables, and the dispatcher walks them on Register. +type Contributor interface { + Name() string + Handlers() map[IntentRef]Handler + Subscriptions() map[IntentRef]SubscriptionHandler +} diff --git a/extensions/dashboard/contract/dispatcher/handler_test.go b/extensions/dashboard/contract/dispatcher/handler_test.go new file mode 100644 index 00000000..e11133ff --- /dev/null +++ b/extensions/dashboard/contract/dispatcher/handler_test.go @@ -0,0 +1,34 @@ +package dispatcher + +import ( + "context" + "encoding/json" + "testing" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +func TestIntentRef_StringForm(t *testing.T) { + r := IntentRef{Intent: "users.list", Version: 1} + if got := r.String(); got != "users.list@1" { + t.Errorf("String() = %q", got) + } +} + +func TestResult_HoldsData(t *testing.T) { + r := &Result{Data: json.RawMessage(`{"ok":true}`), ExtraInvalidates: []string{"x"}} + if string(r.Data) != `{"ok":true}` { + t.Errorf("data lost") + } + if r.ExtraInvalidates[0] != "x" { + t.Errorf("invalidates lost") + } +} + +// Compile-time check: a value-conformant function compiles as Handler. +func TestHandlerSignature_Compiles(t *testing.T) { + var h Handler = func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { + return &Result{Data: json.RawMessage(`null`)}, nil + } + _ = h +} From 1dcb38b6ffbc649379752d3fb0519321300b521b Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 22:24:55 -0500 Subject: [PATCH 29/89] feat(dashboard/contract/dispatcher): function-table dispatcher with canonical error mapping --- .../contract/dispatcher/dispatcher.go | 128 ++++++++++++++++++ .../contract/dispatcher/dispatcher_test.go | 91 +++++++++++++ .../dashboard/contract/dispatcher/metrics.go | 21 +++ 3 files changed, 240 insertions(+) create mode 100644 extensions/dashboard/contract/dispatcher/dispatcher.go create mode 100644 extensions/dashboard/contract/dispatcher/dispatcher_test.go create mode 100644 extensions/dashboard/contract/dispatcher/metrics.go diff --git a/extensions/dashboard/contract/dispatcher/dispatcher.go b/extensions/dashboard/contract/dispatcher/dispatcher.go new file mode 100644 index 00000000..70848f5c --- /dev/null +++ b/extensions/dashboard/contract/dispatcher/dispatcher.go @@ -0,0 +1,128 @@ +package dispatcher + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "sync" + "time" + + "github.com/xraph/forge/extensions/dashboard/contract" + "github.com/xraph/forge/extensions/dashboard/contract/transport" +) + +// Dispatcher is the concrete implementation of transport.Dispatcher and +// transport.SubscriptionSource (Subscribe lives in subscription.go). +// Contributors register handlers indexed by (contributor, intent, version); +// dispatch is a map lookup + the handler call wrapped in metrics emission +// and canonical error mapping. +type Dispatcher struct { + metrics MetricsEmitter + + mu sync.RWMutex + handlers map[handlerKey]Handler + subscriptions map[handlerKey]SubscriptionHandler +} + +type handlerKey struct { + Contributor string + Intent string + Version int +} + +// New returns a fresh dispatcher. Pass NoopMetricsEmitter{} for tests / dev; +// slice (b) provides a Prometheus-backed implementation. +func New(metrics MetricsEmitter) *Dispatcher { + if metrics == nil { + metrics = NoopMetricsEmitter{} + } + return &Dispatcher{ + metrics: metrics, + handlers: map[handlerKey]Handler{}, + subscriptions: map[handlerKey]SubscriptionHandler{}, + } +} + +// Register binds a query/command handler to a (contributor, intent, version) +// key. Returns an error on duplicate registration. +func (d *Dispatcher) Register(contributor, intent string, version int, h Handler) error { + if h == nil { + return fmt.Errorf("dispatcher: nil handler for %s/%s@%d", contributor, intent, version) + } + k := handlerKey{contributor, intent, version} + d.mu.Lock() + defer d.mu.Unlock() + if _, exists := d.handlers[k]; exists { + return fmt.Errorf("dispatcher: handler %s/%s@%d already registered", contributor, intent, version) + } + d.handlers[k] = h + return nil +} + +// Dispatch implements transport.Dispatcher. +func (d *Dispatcher) Dispatch(ctx context.Context, req contract.Request, p contract.Principal) (json.RawMessage, contract.ResponseMeta, error) { + k := handlerKey{req.Contributor, req.Intent, req.IntentVersion} + d.mu.RLock() + h, ok := d.handlers[k] + d.mu.RUnlock() + if !ok { + err := &contract.Error{Code: contract.CodeNotFound, Message: fmt.Sprintf("handler %s/%s@%d not registered", req.Contributor, req.Intent, req.IntentVersion)} + d.metrics.RecordDispatch(ctx, req.Contributor, req.Intent, req.IntentVersion, req.Kind, 0, err.Code) + return nil, contract.ResponseMeta{}, err + } + + t0 := time.Now() + res, handlerErr := h(ctx, req.Payload, req.Params, p) + latency := time.Since(t0) + + wireErr := mapDispatchError(handlerErr) + errCode := contract.ErrorCode("") + if wireErr != nil { + var ce *contract.Error + if errors.As(wireErr, &ce) { + errCode = ce.Code + } + } + d.metrics.RecordDispatch(ctx, req.Contributor, req.Intent, req.IntentVersion, req.Kind, latency, errCode) + + if wireErr != nil { + return nil, contract.ResponseMeta{}, wireErr + } + if res == nil { + // Allow nil result to mean {data: null} explicitly. + return nil, contract.ResponseMeta{IntentVersion: req.IntentVersion}, nil + } + meta := contract.ResponseMeta{IntentVersion: req.IntentVersion} + if len(res.ExtraInvalidates) > 0 { + meta.Invalidates = append(meta.Invalidates, res.ExtraInvalidates...) + } + if res.CacheOverride != nil { + meta.CacheControl = res.CacheOverride + } + return res.Data, meta, nil +} + +// mapDispatchError converts a handler error into the canonical wire error +// shape. *contract.Error is preserved verbatim. context.Canceled becomes +// CodeUnavailable+Retryable. Any other error is wrapped as CodeInternal, +// with the original chained for server-side logging. +func mapDispatchError(err error) error { + if err == nil { + return nil + } + var ce *contract.Error + if errors.As(err, &ce) { + return ce + } + if errors.Is(err, context.Canceled) { + return &contract.Error{Code: contract.CodeUnavailable, Message: "request cancelled", Retryable: true} + } + log.Printf("dispatcher: unmapped handler error: %v", err) + return &contract.Error{Code: contract.CodeInternal, Message: "internal error"} +} + +// Compile-time check that the dispatcher satisfies the transport interface. +// The Subscribe half lands in subscription.go (Phase 2). +var _ transport.Dispatcher = (*Dispatcher)(nil) diff --git a/extensions/dashboard/contract/dispatcher/dispatcher_test.go b/extensions/dashboard/contract/dispatcher/dispatcher_test.go new file mode 100644 index 00000000..37e0a4cf --- /dev/null +++ b/extensions/dashboard/contract/dispatcher/dispatcher_test.go @@ -0,0 +1,91 @@ +package dispatcher + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +func TestDispatcher_RegisterAndDispatch(t *testing.T) { + d := New(NoopMetricsEmitter{}) + if err := d.Register("users", "users.list", 1, func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { + return &Result{Data: json.RawMessage(`{"users":[]}`)}, nil + }); err != nil { + t.Fatalf("register: %v", err) + } + req := contract.Request{Envelope: "v1", Kind: contract.KindQuery, Contributor: "users", Intent: "users.list", IntentVersion: 1} + data, _, err := d.Dispatch(context.Background(), req, contract.Principal{}) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if string(data) != `{"users":[]}` { + t.Errorf("data = %s", data) + } +} + +func TestDispatcher_DuplicateRegister(t *testing.T) { + d := New(NoopMetricsEmitter{}) + h := func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { return &Result{}, nil } + _ = d.Register("c", "i", 1, h) + if err := d.Register("c", "i", 1, h); err == nil { + t.Error("duplicate register should fail") + } +} + +func TestDispatcher_NotFound(t *testing.T) { + d := New(NoopMetricsEmitter{}) + req := contract.Request{Envelope: "v1", Kind: contract.KindQuery, Contributor: "x", Intent: "y", IntentVersion: 1} + _, _, err := d.Dispatch(context.Background(), req, contract.Principal{}) + if err == nil { + t.Fatal("expected not-found error") + } + var ce *contract.Error + if !errors.As(err, &ce) || ce.Code != contract.CodeNotFound { + t.Errorf("expected CodeNotFound, got %v", err) + } +} + +func TestDispatcher_ContractErrorPassesThrough(t *testing.T) { + d := New(NoopMetricsEmitter{}) + _ = d.Register("c", "i", 1, func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { + return nil, &contract.Error{Code: contract.CodeConflict, Message: "duplicate"} + }) + req := contract.Request{Envelope: "v1", Kind: contract.KindCommand, Contributor: "c", Intent: "i", IntentVersion: 1} + _, _, err := d.Dispatch(context.Background(), req, contract.Principal{}) + var ce *contract.Error + if !errors.As(err, &ce) || ce.Code != contract.CodeConflict { + t.Errorf("expected CodeConflict pass-through, got %v", err) + } +} + +func TestDispatcher_PlainErrorWrappedAsInternal(t *testing.T) { + d := New(NoopMetricsEmitter{}) + _ = d.Register("c", "i", 1, func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { + return nil, errors.New("kaboom") + }) + req := contract.Request{Envelope: "v1", Kind: contract.KindCommand, Contributor: "c", Intent: "i", IntentVersion: 1} + _, _, err := d.Dispatch(context.Background(), req, contract.Principal{}) + var ce *contract.Error + if !errors.As(err, &ce) || ce.Code != contract.CodeInternal { + t.Errorf("expected CodeInternal wrap, got %v", err) + } +} + +func TestDispatcher_ContextCanceledMappedToUnavailable(t *testing.T) { + d := New(NoopMetricsEmitter{}) + _ = d.Register("c", "i", 1, func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { + return nil, context.Canceled + }) + req := contract.Request{Envelope: "v1", Kind: contract.KindQuery, Contributor: "c", Intent: "i", IntentVersion: 1} + _, _, err := d.Dispatch(context.Background(), req, contract.Principal{}) + var ce *contract.Error + if !errors.As(err, &ce) || ce.Code != contract.CodeUnavailable { + t.Errorf("expected CodeUnavailable for canceled, got %v", err) + } + if !ce.Retryable { + t.Error("canceled errors should be retryable") + } +} diff --git a/extensions/dashboard/contract/dispatcher/metrics.go b/extensions/dashboard/contract/dispatcher/metrics.go new file mode 100644 index 00000000..e69c6678 --- /dev/null +++ b/extensions/dashboard/contract/dispatcher/metrics.go @@ -0,0 +1,21 @@ +package dispatcher + +import ( + "context" + "time" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// MetricsEmitter ships dispatch metrics to a backend. The Phase 4 expansion +// of this file adds the full DispatchInfo struct and the noop default. For +// Phase 1, only the interface and the noop are needed. +type MetricsEmitter interface { + RecordDispatch(ctx context.Context, contributor, intent string, version int, kind contract.Kind, latency time.Duration, errCode contract.ErrorCode) +} + +// NoopMetricsEmitter discards all dispatch metrics. +type NoopMetricsEmitter struct{} + +func (NoopMetricsEmitter) RecordDispatch(_ context.Context, _, _ string, _ int, _ contract.Kind, _ time.Duration, _ contract.ErrorCode) { +} From 2afe40781cfad060f1e034b3387af69839dec0ce Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 22:27:25 -0500 Subject: [PATCH 30/89] feat(dashboard/contract/dispatcher): subscription handler registration and broker source adapter --- .../contract/dispatcher/subscription.go | 55 ++++++++++++++++ .../contract/dispatcher/subscription_test.go | 63 +++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 extensions/dashboard/contract/dispatcher/subscription.go create mode 100644 extensions/dashboard/contract/dispatcher/subscription_test.go diff --git a/extensions/dashboard/contract/dispatcher/subscription.go b/extensions/dashboard/contract/dispatcher/subscription.go new file mode 100644 index 00000000..b66976b2 --- /dev/null +++ b/extensions/dashboard/contract/dispatcher/subscription.go @@ -0,0 +1,55 @@ +package dispatcher + +import ( + "context" + "fmt" + + "github.com/xraph/forge/extensions/dashboard/contract" + "github.com/xraph/forge/extensions/dashboard/contract/transport" +) + +// RegisterSubscription binds a subscription handler to (contributor, intent, version). +func (d *Dispatcher) RegisterSubscription(contributor, intent string, version int, h SubscriptionHandler) error { + if h == nil { + return fmt.Errorf("dispatcher: nil subscription handler for %s/%s@%d", contributor, intent, version) + } + k := handlerKey{contributor, intent, version} + d.mu.Lock() + defer d.mu.Unlock() + if _, exists := d.subscriptions[k]; exists { + return fmt.Errorf("dispatcher: subscription %s/%s@%d already registered", contributor, intent, version) + } + d.subscriptions[k] = h + return nil +} + +// Subscribe implements transport.SubscriptionSource. The broker calls this on +// each subscribe-control message; the dispatcher routes to the registered handler. +// Params from YAML (map[string]contract.ParamSource) are flattened into a +// runtime map[string]any using the From string when set, the literal Value otherwise. +func (d *Dispatcher) Subscribe(ctx context.Context, p contract.Principal, contributor string, intent contract.Intent, params map[string]contract.ParamSource) (<-chan contract.StreamEvent, func(), error) { + k := handlerKey{contributor, intent.Name, intent.Version} + d.mu.RLock() + h, ok := d.subscriptions[k] + d.mu.RUnlock() + if !ok { + return nil, nil, &contract.Error{Code: contract.CodeNotFound, Message: fmt.Sprintf("subscription %s/%s@%d not registered", contributor, intent.Name, intent.Version)} + } + flat := flattenParams(params) + return h(ctx, flat, p) +} + +func flattenParams(in map[string]contract.ParamSource) map[string]any { + out := make(map[string]any, len(in)) + for k, src := range in { + if src.From != "" { + out[k] = src.From // resolution happens caller-side; the handler sees the bound value if any + continue + } + out[k] = src.Value + } + return out +} + +// Compile-time check that Subscribe satisfies the broker's source interface. +var _ transport.SubscriptionSource = (*Dispatcher)(nil) diff --git a/extensions/dashboard/contract/dispatcher/subscription_test.go b/extensions/dashboard/contract/dispatcher/subscription_test.go new file mode 100644 index 00000000..8feedf27 --- /dev/null +++ b/extensions/dashboard/contract/dispatcher/subscription_test.go @@ -0,0 +1,63 @@ +package dispatcher + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +func TestDispatcher_RegisterSubscriptionAndSubscribe(t *testing.T) { + d := New(NoopMetricsEmitter{}) + if err := d.RegisterSubscription("logs", "audit.tail", 1, func(_ context.Context, _ map[string]any, _ contract.Principal) (<-chan contract.StreamEvent, func(), error) { + ch := make(chan contract.StreamEvent, 1) + ch <- contract.StreamEvent{Intent: "audit.tail", Mode: contract.ModeAppend, Payload: json.RawMessage(`{"line":"hi"}`), Seq: 1} + close(ch) + return ch, func() {}, nil + }); err != nil { + t.Fatalf("register: %v", err) + } + + intent := contract.Intent{Name: "audit.tail", Kind: contract.IntentKindSubscription, Version: 1, Capability: contract.CapRead} + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + ch, stop, err := d.Subscribe(ctx, contract.Principal{}, "logs", intent, nil) + if err != nil { + t.Fatalf("subscribe: %v", err) + } + defer stop() + + select { + case ev, ok := <-ch: + if !ok { + t.Fatal("channel closed before event") + } + if ev.Intent != "audit.tail" { + t.Errorf("intent = %q", ev.Intent) + } + case <-ctx.Done(): + t.Fatal("timed out waiting for event") + } +} + +func TestDispatcher_SubscribeMissingHandler(t *testing.T) { + d := New(NoopMetricsEmitter{}) + intent := contract.Intent{Name: "missing", Kind: contract.IntentKindSubscription, Version: 1, Capability: contract.CapRead} + _, _, err := d.Subscribe(context.Background(), contract.Principal{}, "x", intent, nil) + if err == nil { + t.Error("expected not-found") + } +} + +func TestDispatcher_DuplicateRegisterSubscription(t *testing.T) { + d := New(NoopMetricsEmitter{}) + h := func(_ context.Context, _ map[string]any, _ contract.Principal) (<-chan contract.StreamEvent, func(), error) { + return nil, nil, nil + } + _ = d.RegisterSubscription("c", "i", 1, h) + if err := d.RegisterSubscription("c", "i", 1, h); err == nil { + t.Error("duplicate register should fail") + } +} From 912ec62c1173f64abb30b5e073cc6d43accc91fc Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 22:28:31 -0500 Subject: [PATCH 31/89] feat(dashboard/contract/dispatcher): generic typed wrappers for query/command/subscription --- .../dashboard/contract/dispatcher/generic.go | 84 ++++++++++++++++ .../contract/dispatcher/generic_test.go | 95 +++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 extensions/dashboard/contract/dispatcher/generic.go create mode 100644 extensions/dashboard/contract/dispatcher/generic_test.go diff --git a/extensions/dashboard/contract/dispatcher/generic.go b/extensions/dashboard/contract/dispatcher/generic.go new file mode 100644 index 00000000..811df51f --- /dev/null +++ b/extensions/dashboard/contract/dispatcher/generic.go @@ -0,0 +1,84 @@ +package dispatcher + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// RegisterQuery wraps a typed handler in a Handler-compatible closure that +// JSON-decodes Payload into I and encodes the returned O into Result.Data. +// I and O must be JSON-marshallable. Use struct{} for an empty-input intent. +func RegisterQuery[I, O any](d *Dispatcher, contributor, intent string, version int, fn func(ctx context.Context, in I, p contract.Principal) (O, error)) error { + return d.Register(contributor, intent, version, wrapTyped[I, O](fn)) +} + +// RegisterCommand is identical in shape to RegisterQuery; both register a +// query/command handler. The dispatcher's wire layer enforces kind/capability +// matching against the manifest, so the only practical difference between the +// two helpers is intent of the caller — they're aliases. +func RegisterCommand[I, O any](d *Dispatcher, contributor, intent string, version int, fn func(ctx context.Context, in I, p contract.Principal) (O, error)) error { + return d.Register(contributor, intent, version, wrapTyped[I, O](fn)) +} + +func wrapTyped[I, O any](fn func(ctx context.Context, in I, p contract.Principal) (O, error)) Handler { + return func(ctx context.Context, payload json.RawMessage, _ map[string]any, p contract.Principal) (*Result, error) { + var in I + if len(payload) > 0 && string(payload) != "null" { + if err := json.Unmarshal(payload, &in); err != nil { + return nil, &contract.Error{Code: contract.CodeBadRequest, Message: fmt.Sprintf("invalid payload: %v", err)} + } + } + out, err := fn(ctx, in, p) + if err != nil { + return nil, err + } + data, mErr := json.Marshal(out) + if mErr != nil { + return nil, &contract.Error{Code: contract.CodeInternal, Message: fmt.Sprintf("marshal output: %v", mErr)} + } + return &Result{Data: data}, nil + } +} + +// RegisterSubscription wraps a typed subscription handler. The pump goroutine +// JSON-encodes each typed E event into a contract.StreamEvent before +// forwarding into the broker's channel. +func RegisterSubscription[P, E any](d *Dispatcher, contributor, intent string, version int, fn func(ctx context.Context, in P, p contract.Principal) (<-chan E, func(), error)) error { + wrapped := func(ctx context.Context, params map[string]any, principal contract.Principal) (<-chan contract.StreamEvent, func(), error) { + var in P + if len(params) > 0 { + // Decode by remarshalling — slow but tolerable; subscription params are tiny. + b, _ := json.Marshal(params) + if err := json.Unmarshal(b, &in); err != nil { + return nil, nil, &contract.Error{Code: contract.CodeBadRequest, Message: fmt.Sprintf("invalid params: %v", err)} + } + } + typedCh, stop, err := fn(ctx, in, principal) + if err != nil { + return nil, nil, err + } + out := make(chan contract.StreamEvent, 4) + var seq uint64 + go func() { + defer close(out) + for ev := range typedCh { + seq++ + payload, mErr := json.Marshal(ev) + if mErr != nil { + // Drop the event if it can't be marshalled; log server-side. + continue + } + select { + case out <- contract.StreamEvent{Intent: intent, Payload: payload, Seq: seq}: + case <-ctx.Done(): + return + } + } + }() + return out, stop, nil + } + return d.RegisterSubscription(contributor, intent, version, wrapped) +} diff --git a/extensions/dashboard/contract/dispatcher/generic_test.go b/extensions/dashboard/contract/dispatcher/generic_test.go new file mode 100644 index 00000000..7ff98926 --- /dev/null +++ b/extensions/dashboard/contract/dispatcher/generic_test.go @@ -0,0 +1,95 @@ +package dispatcher + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +type listIn struct { + Tenant string `json:"tenant"` +} +type listOut struct { + Users []string `json:"users"` +} + +func TestRegisterQuery_DecodesAndEncodes(t *testing.T) { + d := New(NoopMetricsEmitter{}) + if err := RegisterQuery(d, "users", "users.list", 1, func(_ context.Context, in listIn, _ contract.Principal) (listOut, error) { + if in.Tenant != "acme" { + t.Errorf("decoded tenant = %q", in.Tenant) + } + return listOut{Users: []string{"alice", "bob"}}, nil + }); err != nil { + t.Fatalf("register: %v", err) + } + req := contract.Request{Envelope: "v1", Kind: contract.KindQuery, Contributor: "users", Intent: "users.list", IntentVersion: 1, Payload: json.RawMessage(`{"tenant":"acme"}`)} + data, _, err := d.Dispatch(context.Background(), req, contract.Principal{}) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + var got listOut + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(got.Users) != 2 { + t.Errorf("users = %v", got.Users) + } +} + +func TestRegisterQuery_DecodeErrorBecomesBadRequest(t *testing.T) { + d := New(NoopMetricsEmitter{}) + _ = RegisterQuery(d, "u", "u.l", 1, func(_ context.Context, _ listIn, _ contract.Principal) (listOut, error) { return listOut{}, nil }) + req := contract.Request{Envelope: "v1", Kind: contract.KindQuery, Contributor: "u", Intent: "u.l", IntentVersion: 1, Payload: json.RawMessage(`not json`)} + _, _, err := d.Dispatch(context.Background(), req, contract.Principal{}) + if err == nil { + t.Fatal("expected decode error") + } + if ce, ok := err.(*contract.Error); !ok || ce.Code != contract.CodeBadRequest { + t.Errorf("expected CodeBadRequest, got %v", err) + } +} + +type tickIn struct{} +type tickEvent struct { + N int `json:"n"` +} + +func TestRegisterSubscriptionGeneric_PumpsTypedEvents(t *testing.T) { + d := New(NoopMetricsEmitter{}) + if err := RegisterSubscription(d, "feed", "tick", 1, func(ctx context.Context, _ tickIn, _ contract.Principal) (<-chan tickEvent, func(), error) { + ch := make(chan tickEvent, 2) + ch <- tickEvent{N: 1} + ch <- tickEvent{N: 2} + close(ch) + return ch, func() {}, nil + }); err != nil { + t.Fatalf("register: %v", err) + } + intent := contract.Intent{Name: "tick", Kind: contract.IntentKindSubscription, Version: 1, Capability: contract.CapRead} + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + out, stop, err := d.Subscribe(ctx, contract.Principal{}, "feed", intent, nil) + if err != nil { + t.Fatalf("subscribe: %v", err) + } + defer stop() + + count := 0 + for ev := range out { + count++ + var got tickEvent + if err := json.Unmarshal(ev.Payload, &got); err != nil { + t.Errorf("unmarshal event: %v", err) + } + if got.N != count { + t.Errorf("event %d N = %d", count, got.N) + } + } + if count != 2 { + t.Errorf("expected 2 events, got %d", count) + } +} From c5b98a4a7b625785986fac5b090bec9ea4067757 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 22:30:46 -0500 Subject: [PATCH 32/89] test(dashboard/contract/dispatcher): cover metrics emission for success, error, and not-found paths --- .../contract/dispatcher/metrics_test.go | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 extensions/dashboard/contract/dispatcher/metrics_test.go diff --git a/extensions/dashboard/contract/dispatcher/metrics_test.go b/extensions/dashboard/contract/dispatcher/metrics_test.go new file mode 100644 index 00000000..86edbf1d --- /dev/null +++ b/extensions/dashboard/contract/dispatcher/metrics_test.go @@ -0,0 +1,78 @@ +package dispatcher + +import ( + "context" + "encoding/json" + "sync" + "testing" + "time" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +type recordingMetrics struct { + mu sync.Mutex + records []recordedDispatch +} + +type recordedDispatch struct { + Contributor, Intent string + Version int + Kind contract.Kind + ErrCode contract.ErrorCode +} + +func (r *recordingMetrics) RecordDispatch(_ context.Context, c, i string, v int, k contract.Kind, _ time.Duration, errCode contract.ErrorCode) { + r.mu.Lock() + defer r.mu.Unlock() + r.records = append(r.records, recordedDispatch{c, i, v, k, errCode}) +} + +func TestDispatcher_EmitsMetrics_Success(t *testing.T) { + rm := &recordingMetrics{} + d := New(rm) + _ = d.Register("c", "i", 1, func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { + return &Result{Data: json.RawMessage(`null`)}, nil + }) + req := contract.Request{Envelope: "v1", Kind: contract.KindQuery, Contributor: "c", Intent: "i", IntentVersion: 1} + _, _, _ = d.Dispatch(context.Background(), req, contract.Principal{}) + rm.mu.Lock() + defer rm.mu.Unlock() + if len(rm.records) != 1 { + t.Fatalf("expected 1 record, got %d", len(rm.records)) + } + r := rm.records[0] + if r.ErrCode != "" { + t.Errorf("expected empty errCode for success, got %q", r.ErrCode) + } + if r.Kind != contract.KindQuery { + t.Errorf("kind = %v", r.Kind) + } +} + +func TestDispatcher_EmitsMetrics_Error(t *testing.T) { + rm := &recordingMetrics{} + d := New(rm) + _ = d.Register("c", "i", 1, func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { + return nil, &contract.Error{Code: contract.CodeConflict} + }) + req := contract.Request{Envelope: "v1", Kind: contract.KindCommand, Contributor: "c", Intent: "i", IntentVersion: 1} + _, _, _ = d.Dispatch(context.Background(), req, contract.Principal{}) + rm.mu.Lock() + defer rm.mu.Unlock() + if len(rm.records) != 1 || rm.records[0].ErrCode != contract.CodeConflict { + t.Errorf("expected conflict record, got %+v", rm.records) + } +} + +func TestDispatcher_EmitsMetrics_NotFound(t *testing.T) { + rm := &recordingMetrics{} + d := New(rm) + req := contract.Request{Envelope: "v1", Kind: contract.KindQuery, Contributor: "x", Intent: "y", IntentVersion: 1} + _, _, _ = d.Dispatch(context.Background(), req, contract.Principal{}) + rm.mu.Lock() + defer rm.mu.Unlock() + if len(rm.records) != 1 || rm.records[0].ErrCode != contract.CodeNotFound { + t.Errorf("expected not-found record, got %+v", rm.records) + } +} From 796396a9c01e3d18e69ab584e6d8779752d2042a Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 22:31:34 -0500 Subject: [PATCH 33/89] feat(dashboard/contract/dispatcher): contributor interface registration with atomic rollback --- .../contract/dispatcher/contributor.go | 47 ++++++++++ .../contract/dispatcher/contributor_test.go | 93 +++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 extensions/dashboard/contract/dispatcher/contributor.go create mode 100644 extensions/dashboard/contract/dispatcher/contributor_test.go diff --git a/extensions/dashboard/contract/dispatcher/contributor.go b/extensions/dashboard/contract/dispatcher/contributor.go new file mode 100644 index 00000000..e3b5e85e --- /dev/null +++ b/extensions/dashboard/contract/dispatcher/contributor.go @@ -0,0 +1,47 @@ +package dispatcher + +import "fmt" + +// RegisterContributor walks a Contributor's Handlers() and Subscriptions() maps +// and registers each one. Atomic: if any registration fails, all preceding +// registrations from this call are rolled back. +func (d *Dispatcher) RegisterContributor(c Contributor) error { + if c == nil { + return fmt.Errorf("dispatcher: nil contributor") + } + name := c.Name() + if name == "" { + return fmt.Errorf("dispatcher: contributor name is empty") + } + + // Snapshot what we register so we can roll back on failure. + var registeredHandlers []handlerKey + var registeredSubs []handlerKey + + rollback := func() { + d.mu.Lock() + defer d.mu.Unlock() + for _, k := range registeredHandlers { + delete(d.handlers, k) + } + for _, k := range registeredSubs { + delete(d.subscriptions, k) + } + } + + for ref, h := range c.Handlers() { + if err := d.Register(name, ref.Intent, ref.Version, h); err != nil { + rollback() + return fmt.Errorf("contributor %q: %w", name, err) + } + registeredHandlers = append(registeredHandlers, handlerKey{name, ref.Intent, ref.Version}) + } + for ref, h := range c.Subscriptions() { + if err := d.RegisterSubscription(name, ref.Intent, ref.Version, h); err != nil { + rollback() + return fmt.Errorf("contributor %q: %w", name, err) + } + registeredSubs = append(registeredSubs, handlerKey{name, ref.Intent, ref.Version}) + } + return nil +} diff --git a/extensions/dashboard/contract/dispatcher/contributor_test.go b/extensions/dashboard/contract/dispatcher/contributor_test.go new file mode 100644 index 00000000..0ea3a71c --- /dev/null +++ b/extensions/dashboard/contract/dispatcher/contributor_test.go @@ -0,0 +1,93 @@ +package dispatcher + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +type fakeContributor struct { + name string + q map[IntentRef]Handler + s map[IntentRef]SubscriptionHandler +} + +func (f *fakeContributor) Name() string { return f.name } +func (f *fakeContributor) Handlers() map[IntentRef]Handler { return f.q } +func (f *fakeContributor) Subscriptions() map[IntentRef]SubscriptionHandler { return f.s } + +func TestRegisterContributor_RegistersAllTables(t *testing.T) { + d := New(NoopMetricsEmitter{}) + c := &fakeContributor{ + name: "users", + q: map[IntentRef]Handler{ + {Intent: "users.list", Version: 1}: func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { + return &Result{Data: json.RawMessage(`{}`)}, nil + }, + }, + s: map[IntentRef]SubscriptionHandler{ + {Intent: "users.events", Version: 1}: func(_ context.Context, _ map[string]any, _ contract.Principal) (<-chan contract.StreamEvent, func(), error) { + return nil, nil, nil + }, + }, + } + if err := d.RegisterContributor(c); err != nil { + t.Fatalf("register: %v", err) + } + + req := contract.Request{Envelope: "v1", Kind: contract.KindQuery, Contributor: "users", Intent: "users.list", IntentVersion: 1} + if _, _, err := d.Dispatch(context.Background(), req, contract.Principal{}); err != nil { + t.Errorf("dispatch query: %v", err) + } + intent := contract.Intent{Name: "users.events", Kind: contract.IntentKindSubscription, Version: 1, Capability: contract.CapRead} + if _, _, err := d.Subscribe(context.Background(), contract.Principal{}, "users", intent, nil); err != nil { + t.Errorf("subscribe: %v", err) + } +} + +func TestRegisterContributor_NameRequired(t *testing.T) { + d := New(NoopMetricsEmitter{}) + c := &fakeContributor{name: "", q: map[IntentRef]Handler{}, s: map[IntentRef]SubscriptionHandler{}} + if err := d.RegisterContributor(c); err == nil { + t.Error("expected name-required error") + } +} + +func TestRegisterContributor_PartialFailureIsAtomic(t *testing.T) { + // First register a conflicting handler; then attempt RegisterContributor and verify + // it surfaces the conflict and does not partially apply. + d := New(NoopMetricsEmitter{}) + _ = d.Register("users", "users.list", 1, func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { + return &Result{}, nil + }) + + c := &fakeContributor{ + name: "users", + q: map[IntentRef]Handler{ + {Intent: "users.detail", Version: 1}: func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { + return &Result{}, nil + }, + {Intent: "users.list", Version: 1}: func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { + return &Result{}, nil + }, + }, + s: nil, + } + err := d.RegisterContributor(c) + if err == nil { + t.Fatal("expected conflict error") + } + // users.detail must NOT be registered (atomicity). + req := contract.Request{Envelope: "v1", Kind: contract.KindQuery, Contributor: "users", Intent: "users.detail", IntentVersion: 1} + if _, _, dispErr := d.Dispatch(context.Background(), req, contract.Principal{}); dispErr == nil { + t.Error("partial registration leaked: users.detail should not be registered") + } else { + var ce *contract.Error + if !errors.As(dispErr, &ce) || ce.Code != contract.CodeNotFound { + t.Errorf("expected NotFound, got %v", dispErr) + } + } +} From 89ac07fe09c93c2904dde4e94116fcc05de52dcf Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 22:33:32 -0500 Subject: [PATCH 34/89] feat(dashboard/contract/pilot): payload types for the pilot intents --- extensions/dashboard/contract/pilot/doc.go | 7 ++++ extensions/dashboard/contract/pilot/types.go | 42 +++++++++++++++++++ .../dashboard/contract/pilot/types_test.go | 39 +++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 extensions/dashboard/contract/pilot/doc.go create mode 100644 extensions/dashboard/contract/pilot/types.go create mode 100644 extensions/dashboard/contract/pilot/types_test.go diff --git a/extensions/dashboard/contract/pilot/doc.go b/extensions/dashboard/contract/pilot/doc.go new file mode 100644 index 00000000..d6e9fb3a --- /dev/null +++ b/extensions/dashboard/contract/pilot/doc.go @@ -0,0 +1,7 @@ +// Package pilot ships the migrated dashboard contributor used to validate +// the contract end-to-end: extensions.list, services.list, services.detail, +// and the metrics.summary subscription, all wired against the existing +// collector and contributor registry. +// +// See SLICE_C_DESIGN.md in the parent contract directory for the spec. +package pilot diff --git a/extensions/dashboard/contract/pilot/types.go b/extensions/dashboard/contract/pilot/types.go new file mode 100644 index 00000000..11cbb137 --- /dev/null +++ b/extensions/dashboard/contract/pilot/types.go @@ -0,0 +1,42 @@ +package pilot + +import "github.com/xraph/forge/extensions/dashboard/collector" + +// ExtensionInfo is a flattened summary of one registered contributor manifest. +type ExtensionInfo struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + Version string `json:"version"` + Icon string `json:"icon,omitempty"` + Layout string `json:"layout,omitempty"` + PageCount int `json:"pageCount"` + WidgetCount int `json:"widgetCount"` +} + +// ExtensionsList is the response payload for the extensions.list query. +type ExtensionsList struct { + Extensions []ExtensionInfo `json:"extensions"` +} + +// ServicesList is the response payload for the services.list query. +type ServicesList struct { + Services []collector.ServiceInfo `json:"services"` +} + +// ServiceDetailResponse is the response payload for services.detail. +// (collector.ServiceDetail is reused as-is.) +type ServiceDetailResponse = collector.ServiceDetail + +// ServiceDetailInput is the input payload for services.detail. +type ServiceDetailInput struct { + Name string `json:"name"` +} + +// MetricsSummary is the per-event payload for the metrics.summary subscription. +type MetricsSummary struct { + TotalMetrics int `json:"totalMetrics"` + Counters int `json:"counters"` + Gauges int `json:"gauges"` + Histograms int `json:"histograms"` + TS int64 `json:"ts"` // unix seconds +} diff --git a/extensions/dashboard/contract/pilot/types_test.go b/extensions/dashboard/contract/pilot/types_test.go new file mode 100644 index 00000000..02741692 --- /dev/null +++ b/extensions/dashboard/contract/pilot/types_test.go @@ -0,0 +1,39 @@ +package pilot + +import ( + "encoding/json" + "testing" +) + +func TestExtensionsList_RoundTrip(t *testing.T) { + in := ExtensionsList{Extensions: []ExtensionInfo{ + {Name: "auth", DisplayName: "Authentication", Version: "1.0", Layout: "extension", PageCount: 2, WidgetCount: 0}, + }} + b, err := json.Marshal(in) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got ExtensionsList + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.Extensions[0].DisplayName != "Authentication" { + t.Errorf("display name lost: %+v", got) + } +} + +func TestServiceDetail_NilSafe(t *testing.T) { + // A nil ServicesList should round-trip as `{"services":null}` not panic. + var sl ServicesList + b, err := json.Marshal(sl) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got ServicesList + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(got.Services) != 0 { + t.Errorf("expected zero services, got %d", len(got.Services)) + } +} From e2c1ddf862742fa277676300a289ade7724936be Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 22:34:10 -0500 Subject: [PATCH 35/89] feat(dashboard/contract/pilot): embedded YAML manifest with three routes and four intents --- .../dashboard/contract/pilot/manifest.yaml | 80 +++++++++++++++++++ .../dashboard/contract/pilot/types_test.go | 34 ++++++++ 2 files changed, 114 insertions(+) create mode 100644 extensions/dashboard/contract/pilot/manifest.yaml diff --git a/extensions/dashboard/contract/pilot/manifest.yaml b/extensions/dashboard/contract/pilot/manifest.yaml new file mode 100644 index 00000000..4048786f --- /dev/null +++ b/extensions/dashboard/contract/pilot/manifest.yaml @@ -0,0 +1,80 @@ +schemaVersion: 1 +contributor: + name: core-contract + envelope: + supports: [v1] + preferred: v1 + capabilities: [dashboard.read] + +queries: + extensionList: + intent: extensions.list + cache: { staleTime: 10s } + serviceList: + intent: services.list + cache: { staleTime: 5s } + +intents: + - name: extensions.list + kind: query + version: 1 + capability: read + + - name: services.list + kind: query + version: 1 + capability: read + + - name: services.detail + kind: query + version: 1 + capability: read + + - name: metrics.summary + kind: subscription + version: 1 + capability: read + mode: replace + +graph: + - route: /extensions + intent: page.shell + title: Extensions + nav: { group: Operations, icon: package, priority: 20 } + slots: + main: + - intent: resource.list + data: queries.extensionList + props: + columns: [name, displayName, version, layout, pageCount, widgetCount] + + - route: /services + intent: page.shell + title: Services + nav: { group: Operations, icon: server, priority: 21 } + slots: + main: + - intent: resource.list + data: queries.serviceList + props: + columns: [name, type, status] + slots: + detailDrawer: + - intent: resource.detail + data: + intent: services.detail + params: { name: { from: parent.name } } + + - route: /metrics/live + intent: page.shell + title: Live Metrics + nav: { group: Operations, icon: activity, priority: 22 } + slots: + main: + - intent: dashboard.grid + slots: + widgets: + - intent: metric.counter + title: Metrics Summary + data: + intent: metrics.summary diff --git a/extensions/dashboard/contract/pilot/types_test.go b/extensions/dashboard/contract/pilot/types_test.go index 02741692..5ac79a94 100644 --- a/extensions/dashboard/contract/pilot/types_test.go +++ b/extensions/dashboard/contract/pilot/types_test.go @@ -1,10 +1,18 @@ package pilot import ( + _ "embed" "encoding/json" + "strings" "testing" + + "github.com/xraph/forge/extensions/dashboard/contract" + "github.com/xraph/forge/extensions/dashboard/contract/loader" ) +//go:embed manifest.yaml +var manifestYAML []byte + func TestExtensionsList_RoundTrip(t *testing.T) { in := ExtensionsList{Extensions: []ExtensionInfo{ {Name: "auth", DisplayName: "Authentication", Version: "1.0", Layout: "extension", PageCount: 2, WidgetCount: 0}, @@ -37,3 +45,29 @@ func TestServiceDetail_NilSafe(t *testing.T) { t.Errorf("expected zero services, got %d", len(got.Services)) } } + +func TestPilotManifest_Loads(t *testing.T) { + m, err := loader.Load(strings.NewReader(string(manifestYAML)), "pilot/manifest.yaml") + if err != nil { + t.Fatalf("load: %v", err) + } + if m.Contributor.Name != "core-contract" { + t.Errorf("contributor name = %q", m.Contributor.Name) + } + if got := len(m.Intents); got != 4 { + t.Errorf("intents = %d, want 4", got) + } + if got := len(m.Graph); got != 3 { + t.Errorf("graph routes = %d, want 3", got) + } +} + +func TestPilotManifest_Validates(t *testing.T) { + m, err := loader.Load(strings.NewReader(string(manifestYAML)), "pilot/manifest.yaml") + if err != nil { + t.Fatalf("load: %v", err) + } + if err := loader.Validate(m, contract.NewWardenRegistry()); err != nil { + t.Errorf("validate: %v", err) + } +} From 80a2ec811ba39f569fde46021a35d27952bf579a Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 22:37:56 -0500 Subject: [PATCH 36/89] feat(dashboard/contract/pilot): query handlers for extensions, services list, services detail --- .../dashboard/contract/pilot/extensions.go | 37 ++++++++++ .../contract/pilot/extensions_test.go | 53 ++++++++++++++ .../dashboard/contract/pilot/services.go | 34 +++++++++ .../dashboard/contract/pilot/services_test.go | 73 +++++++++++++++++++ 4 files changed, 197 insertions(+) create mode 100644 extensions/dashboard/contract/pilot/extensions.go create mode 100644 extensions/dashboard/contract/pilot/extensions_test.go create mode 100644 extensions/dashboard/contract/pilot/services.go create mode 100644 extensions/dashboard/contract/pilot/services_test.go diff --git a/extensions/dashboard/contract/pilot/extensions.go b/extensions/dashboard/contract/pilot/extensions.go new file mode 100644 index 00000000..605448ea --- /dev/null +++ b/extensions/dashboard/contract/pilot/extensions.go @@ -0,0 +1,37 @@ +package pilot + +import ( + "context" + + "github.com/xraph/forge/extensions/dashboard/contract" + "github.com/xraph/forge/extensions/dashboard/contributor" +) + +// extensionsListHandler is exposed to the dispatcher via RegisterQuery. +// Mirrors the existing /api/extensions JSON shape so consumers can compare directly. +func extensionsListHandler(reg *contributor.ContributorRegistry) func(ctx context.Context, _ struct{}, _ contract.Principal) (ExtensionsList, error) { + return func(_ context.Context, _ struct{}, _ contract.Principal) (ExtensionsList, error) { + names := reg.ContributorNames() + out := make([]ExtensionInfo, 0, len(names)) + for _, name := range names { + m, ok := reg.GetManifest(name) + if !ok { + continue + } + displayName := m.DisplayName + if displayName == "" { + displayName = name + } + out = append(out, ExtensionInfo{ + Name: m.Name, + DisplayName: displayName, + Version: m.Version, + Icon: m.Icon, + Layout: m.Layout, + PageCount: len(m.Nav), + WidgetCount: len(m.Widgets), + }) + } + return ExtensionsList{Extensions: out}, nil + } +} diff --git a/extensions/dashboard/contract/pilot/extensions_test.go b/extensions/dashboard/contract/pilot/extensions_test.go new file mode 100644 index 00000000..8443c130 --- /dev/null +++ b/extensions/dashboard/contract/pilot/extensions_test.go @@ -0,0 +1,53 @@ +package pilot + +import ( + "context" + "encoding/json" + "testing" + + "github.com/xraph/forge/extensions/dashboard/contract" + "github.com/xraph/forge/extensions/dashboard/contributor" +) + +func newRegistryWith(t *testing.T, manifests ...*contributor.Manifest) *contributor.ContributorRegistry { + t.Helper() + r := contributor.NewContributorRegistry("/dashboard") + for _, m := range manifests { + stub := &stubLocal{manifest: m} + if err := r.RegisterRemote(stub); err != nil { + t.Fatalf("register %q: %v", m.Name, err) + } + } + return r +} + +type stubLocal struct{ manifest *contributor.Manifest } + +func (s *stubLocal) Manifest() *contributor.Manifest { return s.manifest } + +func TestExtensionsListHandler_ReturnsRegisteredContributors(t *testing.T) { + r := newRegistryWith(t, + &contributor.Manifest{Name: "auth", DisplayName: "Authentication", Version: "1.0", Layout: "extension", Nav: []contributor.NavItem{{}, {}}, Widgets: nil}, + &contributor.Manifest{Name: "cron", DisplayName: "", Version: "0.9", Widgets: []contributor.WidgetDescriptor{{}}}, + ) + + h := extensionsListHandler(r) + res, err := h(context.Background(), struct{}{}, contract.Principal{}) + if err != nil { + t.Fatalf("handler: %v", err) + } + if len(res.Extensions) != 2 { + t.Fatalf("got %d, want 2", len(res.Extensions)) + } + // Check that empty DisplayName is filled with Name (matches today's API behavior). + for _, e := range res.Extensions { + if e.Name == "cron" && e.DisplayName != "cron" { + t.Errorf("cron display name fallback = %q", e.DisplayName) + } + } + + // Verify the result encodes cleanly to JSON. + if _, err := json.Marshal(res); err != nil { + t.Errorf("marshal: %v", err) + } +} diff --git a/extensions/dashboard/contract/pilot/services.go b/extensions/dashboard/contract/pilot/services.go new file mode 100644 index 00000000..67c03b4b --- /dev/null +++ b/extensions/dashboard/contract/pilot/services.go @@ -0,0 +1,34 @@ +package pilot + +import ( + "context" + + "github.com/xraph/forge/extensions/dashboard/collector" + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// ServicesProvider is the slice of the collector's API the pilot calls. +// Splitting it out lets tests stub the collector without the full DataCollector. +type ServicesProvider interface { + CollectServices(ctx context.Context) []collector.ServiceInfo + CollectServiceDetail(ctx context.Context, name string) *collector.ServiceDetail +} + +func servicesListHandler(p ServicesProvider) func(ctx context.Context, _ struct{}, _ contract.Principal) (ServicesList, error) { + return func(ctx context.Context, _ struct{}, _ contract.Principal) (ServicesList, error) { + return ServicesList{Services: p.CollectServices(ctx)}, nil + } +} + +func servicesDetailHandler(p ServicesProvider) func(ctx context.Context, in ServiceDetailInput, _ contract.Principal) (*ServiceDetailResponse, error) { + return func(ctx context.Context, in ServiceDetailInput, _ contract.Principal) (*ServiceDetailResponse, error) { + if in.Name == "" { + return nil, &contract.Error{Code: contract.CodeBadRequest, Message: "name is required"} + } + d := p.CollectServiceDetail(ctx, in.Name) + if d == nil { + return nil, &contract.Error{Code: contract.CodeNotFound, Message: "service " + in.Name + " not found"} + } + return d, nil + } +} diff --git a/extensions/dashboard/contract/pilot/services_test.go b/extensions/dashboard/contract/pilot/services_test.go new file mode 100644 index 00000000..578e6b1b --- /dev/null +++ b/extensions/dashboard/contract/pilot/services_test.go @@ -0,0 +1,73 @@ +package pilot + +import ( + "context" + "testing" + + "github.com/xraph/forge/extensions/dashboard/collector" + "github.com/xraph/forge/extensions/dashboard/contract" +) + +type stubServices struct { + list []collector.ServiceInfo + detail map[string]*collector.ServiceDetail +} + +func (s *stubServices) CollectServices(_ context.Context) []collector.ServiceInfo { + return s.list +} +func (s *stubServices) CollectServiceDetail(_ context.Context, name string) *collector.ServiceDetail { + return s.detail[name] +} + +func TestServicesListHandler(t *testing.T) { + stub := &stubServices{list: []collector.ServiceInfo{{Name: "db", Status: "healthy"}, {Name: "cache", Status: "degraded"}}} + h := servicesListHandler(stub) + res, err := h(context.Background(), struct{}{}, contract.Principal{}) + if err != nil { + t.Fatalf("handler: %v", err) + } + if len(res.Services) != 2 { + t.Errorf("services = %d", len(res.Services)) + } +} + +func TestServicesDetailHandler_Found(t *testing.T) { + stub := &stubServices{detail: map[string]*collector.ServiceDetail{ + "db": {Name: "db", Type: "postgres"}, + }} + h := servicesDetailHandler(stub) + res, err := h(context.Background(), ServiceDetailInput{Name: "db"}, contract.Principal{}) + if err != nil { + t.Fatalf("handler: %v", err) + } + if res == nil || res.Name != "db" { + t.Errorf("detail = %+v", res) + } +} + +func TestServicesDetailHandler_NotFound(t *testing.T) { + stub := &stubServices{detail: map[string]*collector.ServiceDetail{}} + h := servicesDetailHandler(stub) + _, err := h(context.Background(), ServiceDetailInput{Name: "missing"}, contract.Principal{}) + if err == nil { + t.Fatal("expected not-found") + } + ce, ok := err.(*contract.Error) + if !ok || ce.Code != contract.CodeNotFound { + t.Errorf("expected CodeNotFound, got %v", err) + } +} + +func TestServicesDetailHandler_EmptyNameIsBadRequest(t *testing.T) { + stub := &stubServices{} + h := servicesDetailHandler(stub) + _, err := h(context.Background(), ServiceDetailInput{Name: ""}, contract.Principal{}) + if err == nil { + t.Fatal("expected bad-request") + } + ce, ok := err.(*contract.Error) + if !ok || ce.Code != contract.CodeBadRequest { + t.Errorf("expected CodeBadRequest, got %v", err) + } +} From 4908db4080911c38362fe9d3c2a27d06e3e9f972 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 22:38:27 -0500 Subject: [PATCH 37/89] feat(dashboard/contract/pilot): metrics.summary replace-mode subscription handler --- .../dashboard/contract/pilot/metrics.go | 52 ++++++++++++ .../dashboard/contract/pilot/metrics_test.go | 85 +++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 extensions/dashboard/contract/pilot/metrics.go create mode 100644 extensions/dashboard/contract/pilot/metrics_test.go diff --git a/extensions/dashboard/contract/pilot/metrics.go b/extensions/dashboard/contract/pilot/metrics.go new file mode 100644 index 00000000..5e76a6dc --- /dev/null +++ b/extensions/dashboard/contract/pilot/metrics.go @@ -0,0 +1,52 @@ +package pilot + +import ( + "context" + "time" + + "github.com/xraph/forge/extensions/dashboard/collector" + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// MetricsProvider is the slice of DataCollector the metrics.summary handler needs. +type MetricsProvider interface { + CollectMetrics(ctx context.Context) *collector.MetricsData +} + +// metricsSummarySub returns a typed subscription handler that emits a +// MetricsSummary every interval until ctx is cancelled. The interval is +// injectable so tests can use millisecond ticks instead of 5 seconds. +func metricsSummarySub(p MetricsProvider, interval time.Duration) func(ctx context.Context, _ struct{}, _ contract.Principal) (<-chan MetricsSummary, func(), error) { + return func(ctx context.Context, _ struct{}, _ contract.Principal) (<-chan MetricsSummary, func(), error) { + out := make(chan MetricsSummary, 4) + ticker := time.NewTicker(interval) + go func() { + defer close(out) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case t := <-ticker.C: + data := p.CollectMetrics(ctx) + if data == nil { + continue + } + ev := MetricsSummary{ + TotalMetrics: data.Stats.TotalMetrics, + Counters: data.Stats.Counters, + Gauges: data.Stats.Gauges, + Histograms: data.Stats.Histograms, + TS: t.Unix(), + } + select { + case out <- ev: + case <-ctx.Done(): + return + } + } + } + }() + return out, func() {}, nil + } +} diff --git a/extensions/dashboard/contract/pilot/metrics_test.go b/extensions/dashboard/contract/pilot/metrics_test.go new file mode 100644 index 00000000..c3c356b4 --- /dev/null +++ b/extensions/dashboard/contract/pilot/metrics_test.go @@ -0,0 +1,85 @@ +package pilot + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/xraph/forge/extensions/dashboard/collector" + "github.com/xraph/forge/extensions/dashboard/contract" +) + +type stubMetrics struct { + data *collector.MetricsData +} + +func (s *stubMetrics) CollectMetrics(_ context.Context) *collector.MetricsData { + return s.data +} + +func TestMetricsSummarySub_EmitsOnTick(t *testing.T) { + stub := &stubMetrics{data: &collector.MetricsData{ + Stats: collector.MetricsStats{TotalMetrics: 10, Counters: 4, Gauges: 3, Histograms: 3}, + }} + h := metricsSummarySub(stub, 10*time.Millisecond) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + ch, stop, err := h(ctx, struct{}{}, contract.Principal{}) + if err != nil { + t.Fatalf("subscribe: %v", err) + } + defer stop() + + select { + case ev, ok := <-ch: + if !ok { + t.Fatal("channel closed before event") + } + var got MetricsSummary + if err := json.Unmarshal(jsonOf(ev), &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.TotalMetrics != 10 { + t.Errorf("TotalMetrics = %d", got.TotalMetrics) + } + case <-ctx.Done(): + t.Fatal("timed out waiting for tick") + } +} + +// jsonOf is a tiny helper for tests reading typed events out of the typed +// subscription handler (which returns chan MetricsSummary, not StreamEvent). +func jsonOf(v MetricsSummary) []byte { + b, _ := json.Marshal(v) + return b +} + +func TestMetricsSummarySub_StopsOnCancel(t *testing.T) { + stub := &stubMetrics{data: &collector.MetricsData{}} + h := metricsSummarySub(stub, time.Millisecond) + ctx, cancel := context.WithCancel(context.Background()) + ch, stop, err := h(ctx, struct{}{}, contract.Principal{}) + if err != nil { + t.Fatalf("subscribe: %v", err) + } + defer stop() + // Drain a few events + go func() { + for range ch { + } + }() + cancel() + // Channel should close shortly after cancellation + deadline := time.After(500 * time.Millisecond) + for { + select { + case _, ok := <-ch: + if !ok { + return // closed — pass + } + case <-deadline: + t.Fatal("channel did not close after cancel") + } + } +} From dc16545b23cbc8b586717ab8c0207b6166f4c5b3 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 22:39:36 -0500 Subject: [PATCH 38/89] feat(dashboard/contract/pilot): Register entry point wires manifest + handlers --- extensions/dashboard/contract/pilot/pilot.go | 76 +++++++++++++++++++ .../dashboard/contract/pilot/pilot_test.go | 63 +++++++++++++++ .../dashboard/contract/pilot/types_test.go | 4 - 3 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 extensions/dashboard/contract/pilot/pilot.go create mode 100644 extensions/dashboard/contract/pilot/pilot_test.go diff --git a/extensions/dashboard/contract/pilot/pilot.go b/extensions/dashboard/contract/pilot/pilot.go new file mode 100644 index 00000000..bc43e980 --- /dev/null +++ b/extensions/dashboard/contract/pilot/pilot.go @@ -0,0 +1,76 @@ +package pilot + +import ( + "bytes" + _ "embed" + "fmt" + "time" + + "github.com/xraph/forge/extensions/dashboard/contract" + "github.com/xraph/forge/extensions/dashboard/contract/dispatcher" + "github.com/xraph/forge/extensions/dashboard/contract/loader" + "github.com/xraph/forge/extensions/dashboard/contributor" +) + +//go:embed manifest.yaml +var manifestYAML []byte + +// DefaultMetricsInterval is the production tick rate for metrics.summary. +const DefaultMetricsInterval = 5 * time.Second + +// Deps bundles the data sources the pilot handlers need. The dashboard +// extension constructs this when it wires the pilot at startup. +type Deps struct { + ExtensionsRegistry *contributor.ContributorRegistry + Services ServicesProvider + Metrics MetricsProvider + // MetricsInterval is how often metrics.summary emits. Zero defaults to + // DefaultMetricsInterval. Tests use millisecond values. + MetricsInterval time.Duration +} + +// Register loads the embedded pilot manifest, validates it, registers it with +// the contract registry, and binds the four handlers against the dispatcher. +// Idempotent: calling twice on the same registries returns the duplicate- +// registration error from the second call. +func Register(d *dispatcher.Dispatcher, contractReg contract.Registry, wreg contract.WardenRegistry, deps Deps) error { + if deps.ExtensionsRegistry == nil { + return fmt.Errorf("pilot: ExtensionsRegistry is required") + } + if deps.Services == nil { + return fmt.Errorf("pilot: Services is required") + } + if deps.Metrics == nil { + return fmt.Errorf("pilot: Metrics is required") + } + interval := deps.MetricsInterval + if interval <= 0 { + interval = DefaultMetricsInterval + } + + m, err := loader.Load(bytes.NewReader(manifestYAML), "pilot/manifest.yaml") + if err != nil { + return fmt.Errorf("pilot: loading manifest: %w", err) + } + if err := loader.Validate(m, wreg); err != nil { + return fmt.Errorf("pilot: validating manifest: %w", err) + } + if err := contractReg.Register(m); err != nil { + return fmt.Errorf("pilot: contract registry: %w", err) + } + + const c = "core-contract" + if err := dispatcher.RegisterQuery(d, c, "extensions.list", 1, extensionsListHandler(deps.ExtensionsRegistry)); err != nil { + return err + } + if err := dispatcher.RegisterQuery(d, c, "services.list", 1, servicesListHandler(deps.Services)); err != nil { + return err + } + if err := dispatcher.RegisterQuery(d, c, "services.detail", 1, servicesDetailHandler(deps.Services)); err != nil { + return err + } + if err := dispatcher.RegisterSubscription(d, c, "metrics.summary", 1, metricsSummarySub(deps.Metrics, interval)); err != nil { + return err + } + return nil +} diff --git a/extensions/dashboard/contract/pilot/pilot_test.go b/extensions/dashboard/contract/pilot/pilot_test.go new file mode 100644 index 00000000..c5fc51fe --- /dev/null +++ b/extensions/dashboard/contract/pilot/pilot_test.go @@ -0,0 +1,63 @@ +package pilot + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/xraph/forge/extensions/dashboard/collector" + "github.com/xraph/forge/extensions/dashboard/contract" + "github.com/xraph/forge/extensions/dashboard/contract/dispatcher" + "github.com/xraph/forge/extensions/dashboard/contributor" +) + +func TestPilotRegister_RegistersAllIntents(t *testing.T) { + d := dispatcher.New(dispatcher.NoopMetricsEmitter{}) + reg := contract.NewRegistry() + wreg := contract.NewWardenRegistry() + + deps := Deps{ + ExtensionsRegistry: newRegistryWith(t, &contributor.Manifest{Name: "auth"}), + Services: &stubServices{}, + Metrics: &stubMetrics{data: &collector.MetricsData{}}, + MetricsInterval: time.Millisecond, + } + if err := Register(d, reg, wreg, deps); err != nil { + t.Fatalf("Register: %v", err) + } + + // Contract registry has the pilot manifest. + if _, ok := reg.Contributor("core-contract"); !ok { + t.Error("core-contract not in contract registry") + } + // Dispatcher has each intent. + for _, intentName := range []string{"extensions.list", "services.list", "services.detail"} { + req := contract.Request{Envelope: "v1", Kind: contract.KindQuery, Contributor: "core-contract", Intent: intentName, IntentVersion: 1, Payload: json.RawMessage(`{}`)} + _, _, err := d.Dispatch(context.Background(), req, contract.Principal{}) + if err != nil && intentName != "services.detail" { + t.Errorf("%s dispatch: %v", intentName, err) + } + } +} + +func TestPilotRegister_DefaultsMetricsInterval(t *testing.T) { + d := dispatcher.New(dispatcher.NoopMetricsEmitter{}) + reg := contract.NewRegistry() + wreg := contract.NewWardenRegistry() + deps := Deps{ + ExtensionsRegistry: newRegistryWith(t), + Services: &stubServices{}, + Metrics: &stubMetrics{data: &collector.MetricsData{}}, + // MetricsInterval intentionally zero + } + if err := Register(d, reg, wreg, deps); err != nil { + t.Fatalf("Register: %v", err) + } + // No assertion on the actual interval; verify Register didn't error and + // the subscription is registered. + intent := contract.Intent{Name: "metrics.summary", Kind: contract.IntentKindSubscription, Version: 1, Capability: contract.CapRead} + if _, _, err := d.Subscribe(context.Background(), contract.Principal{}, "core-contract", intent, nil); err != nil { + t.Errorf("metrics.summary not registered: %v", err) + } +} diff --git a/extensions/dashboard/contract/pilot/types_test.go b/extensions/dashboard/contract/pilot/types_test.go index 5ac79a94..906760b6 100644 --- a/extensions/dashboard/contract/pilot/types_test.go +++ b/extensions/dashboard/contract/pilot/types_test.go @@ -1,7 +1,6 @@ package pilot import ( - _ "embed" "encoding/json" "strings" "testing" @@ -10,9 +9,6 @@ import ( "github.com/xraph/forge/extensions/dashboard/contract/loader" ) -//go:embed manifest.yaml -var manifestYAML []byte - func TestExtensionsList_RoundTrip(t *testing.T) { in := ExtensionsList{Extensions: []ExtensionInfo{ {Name: "auth", DisplayName: "Authentication", Version: "1.0", Layout: "extension", PageCount: 2, WidgetCount: 0}, From c10acbc1b8fa3ae9c876f3ddecc98d5b9e47958c Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 22:44:44 -0500 Subject: [PATCH 39/89] feat(dashboard): wire real dispatcher, stream broker, and pilot contributor --- extensions/dashboard/extension.go | 48 +++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/extensions/dashboard/extension.go b/extensions/dashboard/extension.go index 6b1a1c84..d0d29db8 100644 --- a/extensions/dashboard/extension.go +++ b/extensions/dashboard/extension.go @@ -20,7 +20,9 @@ import ( dashauth "github.com/xraph/forge/extensions/dashboard/auth" "github.com/xraph/forge/extensions/dashboard/collector" "github.com/xraph/forge/extensions/dashboard/contract" + "github.com/xraph/forge/extensions/dashboard/contract/dispatcher" "github.com/xraph/forge/extensions/dashboard/contract/loader" + "github.com/xraph/forge/extensions/dashboard/contract/pilot" "github.com/xraph/forge/extensions/dashboard/contract/transport" "github.com/xraph/forge/extensions/dashboard/contributor" dashboarddiscovery "github.com/xraph/forge/extensions/dashboard/discovery" @@ -79,15 +81,16 @@ type Extension struct { // Contract-track state — published alongside the legacy contributor // registry. The dashboard contract provides an envelope-based transport - // (POST /api/dashboard/v1) and an audit/warden pipeline. These fields are - // always non-nil after construction; the SSE-style streamBroker is left - // nil in slice (a) — a later slice provides the SubscriptionSource needed - // to instantiate it, at which point the stream + control routes become - // active automatically. + // (POST /api/dashboard/v1) and an audit/warden pipeline. All four fields + // (contractRegistry, wardenRegistry, streamBroker, auditEmitter) plus the + // dispatcher below are non-nil after construction; the dispatcher acts as + // the StreamBroker's SubscriptionSource so the SSE multiplex routes serve + // real subscriptions registered via dispatcher.RegisterSubscription. contractRegistry contract.Registry wardenRegistry contract.WardenRegistry streamBroker *transport.StreamBroker auditEmitter contract.AuditEmitter + dispatcher *dispatcher.Dispatcher // remoteWatchers tracks long-running goroutines that maintain explicit // remote-contributor registrations (set up via WatchRemoteContributor). @@ -119,13 +122,20 @@ func NewExtension(opts ...ConfigOption) forge.Extension { "Extensible dashboard micro-frontend shell", ) - return &Extension{ + ext := &Extension{ BaseExtension: base, config: config, contractRegistry: contract.NewRegistry(), wardenRegistry: contract.NewWardenRegistry(), auditEmitter: contract.NewLogAuditEmitter(os.Stdout), - } + dispatcher: dispatcher.New(dispatcher.NoopMetricsEmitter{}), + } + // The stream broker uses the dispatcher as its SubscriptionSource so the + // SSE multiplex routes serve real subscriptions registered via the + // dispatcher (see registerRoutes — the broker-bound stream/control routes + // activate now that streamBroker is non-nil). + ext.streamBroker = transport.NewStreamBroker(ext.contractRegistry, ext.wardenRegistry, ext.dispatcher) + return ext } // Register registers the dashboard extension. @@ -263,6 +273,20 @@ func (e *Extension) Register(app forge.App) error { e.searcher.RebuildIndex() } + // Register the contract-track pilot contributor (core-contract). This + // loads the embedded manifest, validates it against the warden registry, + // and binds the four pilot handlers (extensions.list / services.list / + // services.detail / metrics.summary) against the dispatcher. Must run + // after e.collector and e.registry are initialised — both feed the + // pilot Deps directly. + if err := pilot.Register(e.dispatcher, e.contractRegistry, e.wardenRegistry, pilot.Deps{ + ExtensionsRegistry: e.registry, + Services: e.collector, + Metrics: e.collector, + }); err != nil { + return fmt.Errorf("dashboard: registering contract pilot: %w", err) + } + // Register dashboard extension with DI container. // Use ProvideValue with WithAliases so it's resolvable by both type and name. if err := vessel.ProvideValue[*Extension](app.Container(), e, vessel.WithAliases("dashboard")); err != nil { @@ -1391,12 +1415,12 @@ func (e *Extension) registerRoutes() { // handleContractPOST returns the http.HandlerFunc that serves // POST /api/dashboard/v1 — the contract envelope endpoint. The handler // validates the inbound envelope, looks up the intent in the contract -// registry, and dispatches via the configured Dispatcher. Slice (a) wires the -// safe NilDispatcher so every dispatch returns CodeUnavailable until slice -// (c) plugs in real intent handlers; this keeps the surface area testable -// end-to-end without leaking nil panics. +// registry, and dispatches via the configured Dispatcher. Slice (c) Phase 11 +// replaces slice (a)'s safe NilDispatcher with the real dispatcher wired in +// NewExtension; intent handlers are bound by pilot.Register during +// Extension.Register so requests resolve to live data instead of CodeUnavailable. func (e *Extension) handleContractPOST() http.HandlerFunc { - h := transport.NewHandler(e.contractRegistry, e.wardenRegistry, transport.NilDispatcher{}, e.auditEmitter) + h := transport.NewHandler(e.contractRegistry, e.wardenRegistry, e.dispatcher, e.auditEmitter) return h.ServeHTTP } From f8707738d322904ab65d05181f9a13178a9cee60 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 22:49:13 -0500 Subject: [PATCH 40/89] test(dashboard/contract/pilot): end-to-end HTTP + SSE round-trip through the wired pilot --- .../contract/pilot/pilot_e2e_test.go | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 extensions/dashboard/contract/pilot/pilot_e2e_test.go diff --git a/extensions/dashboard/contract/pilot/pilot_e2e_test.go b/extensions/dashboard/contract/pilot/pilot_e2e_test.go new file mode 100644 index 00000000..f5bc51ae --- /dev/null +++ b/extensions/dashboard/contract/pilot/pilot_e2e_test.go @@ -0,0 +1,155 @@ +package pilot + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/xraph/forge/extensions/dashboard/collector" + "github.com/xraph/forge/extensions/dashboard/contract" + "github.com/xraph/forge/extensions/dashboard/contract/dispatcher" + "github.com/xraph/forge/extensions/dashboard/contract/transport" + "github.com/xraph/forge/extensions/dashboard/contributor" +) + +func setupPilotEnv(t *testing.T) (http.Handler, *transport.StreamBroker, *dispatcher.Dispatcher) { + t.Helper() + d := dispatcher.New(dispatcher.NoopMetricsEmitter{}) + reg := contract.NewRegistry() + wreg := contract.NewWardenRegistry() + + extReg := newRegistryWith(t, + &contributor.Manifest{Name: "auth", DisplayName: "Authentication", Version: "1.0"}, + ) + deps := Deps{ + ExtensionsRegistry: extReg, + Services: &stubServices{list: []collector.ServiceInfo{{Name: "db", Status: "healthy"}}}, + Metrics: &stubMetrics{data: &collector.MetricsData{Stats: collector.MetricsStats{TotalMetrics: 5}}}, + MetricsInterval: 20 * time.Millisecond, + } + if err := Register(d, reg, wreg, deps); err != nil { + t.Fatalf("pilot register: %v", err) + } + httpHandler := transport.NewHandler(reg, wreg, d, contract.NoopAuditEmitter{}) + broker := transport.NewStreamBroker(reg, wreg, d) + return httpHandler, broker, d +} + +func TestPilotE2E_ExtensionsList_HTTPRoundTrip(t *testing.T) { + h, _, _ := setupPilotEnv(t) + body, _ := json.Marshal(contract.Request{ + Envelope: "v1", Kind: contract.KindQuery, + Contributor: "core-contract", Intent: "extensions.list", IntentVersion: 1, + }) + req := httptest.NewRequest(http.MethodPost, "/api/dashboard/v1", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", w.Code, w.Body) + } + var resp contract.Response + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !resp.OK { + t.Errorf("ok = false") + } + var data ExtensionsList + if err := json.Unmarshal(resp.Data, &data); err != nil { + t.Fatalf("data unmarshal: %v", err) + } + if len(data.Extensions) != 1 || data.Extensions[0].Name != "auth" { + t.Errorf("extensions = %+v", data.Extensions) + } +} + +func TestPilotE2E_ServicesDetail_NotFoundEnvelope(t *testing.T) { + h, _, _ := setupPilotEnv(t) + body, _ := json.Marshal(contract.Request{ + Envelope: "v1", Kind: contract.KindQuery, + Contributor: "core-contract", Intent: "services.detail", IntentVersion: 1, + Payload: json.RawMessage(`{"name":"missing"}`), + }) + req := httptest.NewRequest(http.MethodPost, "/api/dashboard/v1", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusInternalServerError { + // transport.NewHandler maps errors via asContractError; verify the wire + // envelope code rather than HTTP status (lenient on non-2xx). + } + if !strings.Contains(w.Body.String(), "NOT_FOUND") { + t.Errorf("expected NOT_FOUND in body: %s", w.Body) + } +} + +func TestPilotE2E_MetricsSummary_SSE(t *testing.T) { + _, broker, _ := setupPilotEnv(t) + + // Open the SSE stream with a cancellable context so we can tear it down + // cleanly before reading the recorder body (avoids data race on body). + streamReq := httptest.NewRequest(http.MethodGet, "/api/dashboard/v1/stream", nil) + streamCtx, cancelStream := context.WithCancel(streamReq.Context()) + streamReq = streamReq.WithContext(streamCtx) + streamW := httptest.NewRecorder() + + streamDone := make(chan struct{}) + go func() { + broker.ServeStream(streamW, streamReq) + close(streamDone) + }() + + // Wait for the broker to register the stream. + deadline := time.After(250 * time.Millisecond) + var streamID string +LOOP: + for { + ids := broker.SnapshotIDs() + if len(ids) > 0 { + streamID = ids[0] + break LOOP + } + select { + case <-deadline: + cancelStream() + <-streamDone + t.Fatal("stream not registered in time") + case <-time.After(5 * time.Millisecond): + } + } + + // Subscribe via control. + cmd, _ := json.Marshal(transport.ControlMessage{ + StreamID: streamID, Op: "subscribe", + Contributor: "core-contract", Intent: "metrics.summary", + SubscriptionID: "s1", + }) + ctlReq := httptest.NewRequest(http.MethodPost, "/api/dashboard/v1/stream/control", bytes.NewReader(cmd)) + ctlW := httptest.NewRecorder() + broker.ServeControl(ctlW, ctlReq) + if ctlW.Code != http.StatusOK { + cancelStream() + <-streamDone + t.Fatalf("control = %d body=%s", ctlW.Code, ctlW.Body) + } + + // MetricsInterval is 20ms; give the ticker a few cycles to fire so at + // least one event has been written to the recorder. + time.Sleep(150 * time.Millisecond) + + // Tear down the stream and join the broker goroutine BEFORE reading the + // recorder body. ServeStream's deferred cancel runs all subscription + // cancels, the metrics goroutine exits via ctx.Done(), and the broker + // stops writing to streamW. This matches the slice (a) race-clean pattern. + cancelStream() + <-streamDone + + body := streamW.Body.String() + if !strings.Contains(body, `"totalMetrics":5`) { + t.Fatalf("no metrics event with totalMetrics:5 in stream; body=%s", body) + } +} From 380d79ed538a3429ffe82a5e09febc72d3f08967 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 23:14:39 -0500 Subject: [PATCH 41/89] docs(dashboard/contract): add slice-b security and observability design Replaces slice (a)+(c) placeholders with real impls: CSRF token validation via the existing dashboard CSRFManager, idempotency-key dedup with an in-memory store, Prometheus-backed MetricsEmitter via app.Metrics(), OTel tracing wrapper around Dispatcher.Dispatch, and a structured-logger AuditEmitter via app.Logger(). Persistent audit storage is explicitly punted to a follow-on slice. --- .../dashboard/contract/SLICE_B_DESIGN.md | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 extensions/dashboard/contract/SLICE_B_DESIGN.md diff --git a/extensions/dashboard/contract/SLICE_B_DESIGN.md b/extensions/dashboard/contract/SLICE_B_DESIGN.md new file mode 100644 index 00000000..011ad5f4 --- /dev/null +++ b/extensions/dashboard/contract/SLICE_B_DESIGN.md @@ -0,0 +1,205 @@ +# Slice (b) — Security + Observability Bundle + +> Companion design doc to [DESIGN.md](DESIGN.md). Slice (b) replaces the placeholder validation, dedup, metrics, audit, and tracing implementations in slices (a) and (c) with production-shaped real ones. + +## Context + +Slice (a) shipped the contract handler with `csrf` and `idempotencyKey` envelope fields *enforced by presence* but never validated. Slice (c) ships a `MetricsEmitter` interface with a noop default and an `AuditEmitter` with a stdout-log default. Slice (b) replaces the placeholders with real implementations: CSRF token validation, idempotency-key deduplication, Prometheus-backed metrics, OpenTelemetry tracing, and a structured-logger audit emitter. After slice (b) the contract is production-shaped: every command is CSRF-validated, idempotent across retries, traced end-to-end, metrics-counted by the same series the rest of forge uses, and audited via the existing zap-backed logger. + +This bundle is one shippable security + observability layer. Persistent audit storage is **explicitly punted** — slice (b) emits structured audit records via `app.Logger()` and trusts deployment-side aggregation (ELK / Datadog / similar). A future slice can layer a persistent `AuditStore` interface on top without re-shaping anything we ship here. + +## Scope + +**In scope:** +- CSRF token validation in the command handler path (slice (a)'s presence-only check stays as a cheap pre-flight). +- Token issuance endpoint at `GET /api/dashboard/v1/csrf`. +- Idempotency-key deduplication store (interface + in-memory impl) wrapped around command dispatch. +- Prometheus-backed `MetricsEmitter` implementation using `app.Metrics()`. +- OpenTelemetry tracing wrapper around `Dispatcher.Dispatch` and subscription connections. +- Structured-logger `AuditEmitter` using `app.Logger()`. +- Wire-up in `extension.go` replacing the noop/log defaults. +- Configuration toggle (`EnableContractSecurity`) so deployments can opt out during rollout. + +**Out of scope (future slices):** +- Persistent audit storage (`AuditStore` interface + SQL/Redis backend) — explicitly punted; structured logging covers near-term needs. +- Redis-backed idempotency store for multi-instance deployments — interface is shaped to accept it later; in-memory ships first. +- Stateful CSRF (session-bound) via `extensions/security/csrf.go` — current dashboard `CSRFManager` is stateless HMAC and sufficient. +- Per-event subscription tracing (cardinality concerns). +- React shell rendering engine (slice d), built-in intent vocabulary (slice e), templ retirement (slice f). + +## Design Decisions + +| Decision | Choice | +|---|---| +| CSRF backend | Reuse `extensions/dashboard/security.CSRFManager` (stateless HMAC, already wired on `Extension`). No new manager. | +| CSRF wire location | Inside `transport.handler.ServeHTTP`'s command branch, after the presence check. Mismatch returns `CodeUnauthenticated`. | +| Token issuance | New `GET /api/dashboard/v1/csrf` endpoint returning `{token, expiresAt}` (12h TTL). Same v1 versioning as the rest of the contract. | +| Idempotency store | Build from scratch: `Store` interface + in-memory implementation with TTL eviction and sharded concurrent map. Future Redis impl plugs in via the same interface. | +| Idempotency identity key | `Principal.User.Subject + ":" + intent` — same key from same user against same intent dedupes; same key from different users is independent. | +| Idempotency wrap location | Around `Dispatcher.Dispatch` for commands only. Lookup → return cached envelope verbatim; miss → dispatch, capture, store. | +| Idempotency TTL | 24 hours default, configurable. | +| Failure mode for idempotency | Read errors fail open (treat as miss); write errors log and proceed (best-effort caching). | +| Metrics backend | `forge.Metrics` from `app.Metrics()` (Prometheus-backed). Lazy collector creation on first emit. | +| Metrics series | `forge_dashboard_dispatch_total{contributor,intent,version,kind,error_code}` (counter); `forge_dashboard_dispatch_duration_seconds{contributor,intent,version,kind}` (histogram). | +| Histogram buckets | 1ms, 5ms, 10ms, 50ms, 100ms, 500ms, 1s, 5s. | +| Tracing backend | `internal/observability/otel_tracer.go` v1.40.0. Global tracer via `otel.Tracer("forge.dashboard.contract")`. | +| Tracing scope | One span per dispatch (query/command/graph); one long-lived span per subscription connection (events as span events, not child spans). | +| Tracing decorator | `WithTracing(MetricsEmitter) MetricsEmitter` — composes with the Prometheus emitter; tracing inherits the same call surface. | +| Audit backend | Structured logger via `app.Logger()` with stable field set (`audit=true, contributor, intent, version, subject, user, result, latency_ms, correlation_id`). | +| Audit persistence | Out of scope. Deployment-side aggregation handles long-term storage. | +| Rollout toggle | `EnableContractSecurity` config flag (default `true`). When `false`, falls back to the slice-(a)/(c) noop/log defaults — useful for first-run shakeout in a new environment. | + +## Components + +### CSRF (`transport/csrf.go` + edit to `transport/http.go`) + +Token endpoint: +```go +// transport/csrf.go +type CSRFTokenResponse struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expiresAt"` +} + +func NewCSRFTokenHandler(mgr *security.CSRFManager) http.Handler { /* GET-only; returns CSRFTokenResponse */ } +``` + +Validation in the existing handler: +```go +// transport/http.go — inside ServeHTTP, after the presence check for kind=command +if h.csrfMgr != nil && !h.csrfMgr.ValidateToken(req.CSRF) { + writeError(w, http.StatusForbidden, &contract.Error{Code: contract.CodeUnauthenticated, Message: "csrf token invalid"}) + return +} +``` + +`NewHandler` gains an optional `*security.CSRFManager` parameter (or via a setter to keep the existing signature stable). When nil, no validation runs — preserves the slice-(a) test behavior. + +### Idempotency store (`idempotency/`) + +```go +// idempotency/store.go +type Store interface { + Lookup(ctx context.Context, key, identity string) (*Cached, bool) + Store(ctx context.Context, key, identity string, c Cached) error +} + +type Cached struct { + Status int // HTTP status that was returned + WireBody json.RawMessage // the cached envelope, ready to write back verbatim + StoredAt time.Time + TTL time.Duration +} + +// idempotency/inmemory.go +type InMemoryStore struct { /* sharded sync.Map + LRU + TTL eviction */ } +func NewInMemoryStore(opts ...Option) *InMemoryStore +``` + +Wrap location: `Dispatcher.Dispatch` checks the store before invoking the handler when `kind=command`. The HTTP transport is responsible for serializing the cached `WireBody` back to the client. This keeps the dispatcher's contract clean (handler is the only side-effect surface) while ensuring dedup is consistent across all entry points. + +### Prometheus emitter (`dispatcher/metrics_prometheus.go`) + +```go +type PrometheusMetricsEmitter struct { /* lazy counter + histogram */ } + +func NewPrometheusMetricsEmitter(m forge.Metrics) *PrometheusMetricsEmitter + +// Implements dispatcher.MetricsEmitter: +func (e *PrometheusMetricsEmitter) RecordDispatch(ctx context.Context, contributor, intent string, version int, kind contract.Kind, latency time.Duration, errCode contract.ErrorCode) +``` + +Lazy creation on first emission keeps startup fast and avoids registering collectors for intents never actually called. Labels keyed at registration; values applied per emit via the existing `WithLabel`/`WithLabels` helpers in `internal/metrics`. + +### Tracing wrapper (`dispatcher/tracing.go`) + +```go +func WithTracing(inner MetricsEmitter) MetricsEmitter + +// Internally: +type tracingEmitter struct { inner MetricsEmitter; tracer trace.Tracer } +``` + +The wrapper opens a span when `RecordDispatch` is called (... actually, since `RecordDispatch` is called *after* the handler returns, the span needs to start earlier. So `WithTracing` is not just an emitter wrapper — it needs a hook at the start of `Dispatch` too). + +**Revised approach:** `Dispatcher.Dispatch` itself opens a span before invoking the handler and closes it on return. The span lives in a context value carried through to `RecordDispatch`, which adds the `error_code` attribute and sets status. This keeps tracing as a first-class concern of the dispatcher rather than a metrics decorator. + +Implementation: `dispatcher.go` gains a `tracer trace.Tracer` field set at construction; `Dispatch` opens a span; `RecordDispatch` (or its internal counterpart) sets attributes. No separate decorator needed. + +### Audit logger emitter (`dispatcher/audit_logger.go`) + +```go +func NewLoggerAuditEmitter(logger forge.Logger) contract.AuditEmitter + +// Implements contract.AuditEmitter: +func (e *loggerAuditEmitter) Emit(ctx context.Context, rec contract.AuditRecord) +``` + +Emits at info level with structured fields. Replaces `contract.NewLogAuditEmitter(os.Stdout)` in the wire-up. + +## Files Affected + +### New +``` +extensions/dashboard/contract/idempotency/ + doc.go + store.go # Store interface + Cached struct + inmemory.go # InMemoryStore with TTL+LRU + store_test.go + inmemory_test.go + +extensions/dashboard/contract/dispatcher/ + metrics_prometheus.go + tracing.go # tracer field + Dispatch span lifecycle + audit_logger.go + metrics_prometheus_test.go + tracing_test.go + audit_logger_test.go + +extensions/dashboard/contract/transport/ + csrf.go # CSRFTokenResponse + NewCSRFTokenHandler + csrf_test.go + +extensions/dashboard/contract/SLICE_B_DESIGN.md # this file +extensions/dashboard/contract/SLICE_B_PLAN.md # produced via writing-plans skill +``` + +### Modified +- `extensions/dashboard/contract/transport/http.go` — add CSRF validation in command branch; constructor accepts optional `*security.CSRFManager`. +- `extensions/dashboard/contract/dispatcher/dispatcher.go` — accept idempotency store via constructor option; accept optional `trace.Tracer`; wrap command dispatches with store lookup + span. +- `extensions/dashboard/extension.go` — swap noop/log defaults for real impls; register CSRF token endpoint; pass idempotency store + CSRF manager through to handler. +- `extensions/dashboard/contract/dispatcher/dispatcher_test.go` — add tests for the new optional constructor inputs (idempotency, tracer). +- `extensions/dashboard/extension.go` config struct — add `EnableContractSecurity bool` (defaulting true). + +### Reused (do not duplicate) +- `extensions/dashboard/security.CSRFManager` — already constructed at `NewExtension`. Inject directly into the transport handler. +- `internal/metrics.Registry` via `app.Metrics()` — counters/histograms. +- `internal/observability.OTelTracer` — global tracer extracted via `otel.Tracer(...)`. +- `forge.Logger` via `app.Logger()` — structured audit lines. + +## Verification + +1. **Unit tests** for each new component: + - Idempotency store: hit, miss, TTL expiry, LRU eviction, concurrent access (`-race`). + - Prometheus emitter: counter increment per call, histogram observation per call, label correctness. + - Tracing in dispatcher: span opens, attributes set, status set on error, span closes on handler return. + - Audit logger emitter: log line shape verified via captured logger output. + - CSRF token handler: returns `{token, expiresAt}` with valid HMAC token. + +2. **Integration tests**: + - Send a `kind=command` envelope with missing CSRF → `UNAUTHENTICATED`. + - Send same `kind=command` envelope twice with same idempotency key → byte-equal responses, dispatcher counter incremented once. + - Issue token via `GET /csrf`, use it on a command → success. + +3. **Observability spot-checks** (manual): + - Hit several intents via the probe CLI, scrape `app.Metrics()` exporter, verify series + labels. + - Run with Jaeger/OTLP collector, verify spans land with the expected attributes. + +4. **No regressions**: `go test -count=1 ./extensions/dashboard/...` and `-race` clean. The 181 tests from slices (a) + (c) all stay green. + +## Out of Scope — Future Slices + +- **Persistent audit storage** — `AuditStore` interface + SQL/Redis backend. Explicitly punted; structured logging covers near-term needs. +- **Redis-backed idempotency** — for multi-instance deployments. Store interface accepts it; in-memory ships first. +- **Stateful CSRF** — `extensions/security/csrf.go` already exists for this if HMAC rotation becomes operationally hard. +- **React shell** (slice d), **built-in intent vocabulary** (slice e), **templ retirement** (slice f) — independent slices. From 53f7ff9c62aec0bd1ddb5e89d104aa3510b9a96a Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 23:22:30 -0500 Subject: [PATCH 42/89] docs(dashboard/contract): add slice-b implementation plan --- extensions/dashboard/contract/SLICE_B_PLAN.md | 1654 +++++++++++++++++ 1 file changed, 1654 insertions(+) create mode 100644 extensions/dashboard/contract/SLICE_B_PLAN.md diff --git a/extensions/dashboard/contract/SLICE_B_PLAN.md b/extensions/dashboard/contract/SLICE_B_PLAN.md new file mode 100644 index 00000000..afdfbe11 --- /dev/null +++ b/extensions/dashboard/contract/SLICE_B_PLAN.md @@ -0,0 +1,1654 @@ +# Slice (b) — Security + Observability Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace slice (a)+(c) placeholder validation/dedup/metrics/audit/tracing with production-shaped real implementations: CSRF validation, idempotency-key dedup, Prometheus metrics, OpenTelemetry tracing, and structured-logger audit. + +**Architecture:** Three new files in a new `extensions/dashboard/contract/idempotency/` sub-package (interface + in-memory store + tests). Three new files in `dispatcher/` for the new emitters/wrappers (`metrics_prometheus.go`, `audit_logger.go`, plus tracing folded into `dispatcher.go`). One new file in `transport/` for the CSRF token endpoint. Modifications to `transport/http.go`, `dispatcher/dispatcher.go`, and `extension.go` to wire everything in. The existing `dashboard.security.CSRFManager`, `forge.Metrics` from `app.Metrics()`, the OTel global tracer, and `app.Logger()` are reused — no new external dependencies. + +**Tech Stack:** Go 1.25, stdlib `testing`, `gopkg.in/yaml.v3` (already in deps), `go.opentelemetry.io/otel` v1.40.0 (already in deps), `github.com/prometheus/client_golang` v1.19.1 (already in deps via `internal/metrics`). + +--- + +## Reference + +- **Design spec:** [SLICE_B_DESIGN.md](SLICE_B_DESIGN.md) +- **Slice (a)/(c) interfaces this plan extends:** + - `dispatcher.MetricsEmitter` ([dispatcher/metrics.go](dispatcher/metrics.go)) — `RecordDispatch(ctx, contributor, intent, version, kind, latency, errCode)`. + - `contract.AuditEmitter` ([audit.go](audit.go)) — `Emit(ctx, AuditRecord)`. + - `transport.NewHandler` ([transport/http.go:40](transport/http.go)) — current signature accepts a `Dispatcher` and an `AuditEmitter`; we extend with optional CSRF. +- **Existing infrastructure to reuse:** + - `*security.CSRFManager` from `extensions/dashboard/security/csrf.go` — already constructed at `NewExtension` and accessible via `e.CSRFManager()`. + - `forge.Metrics` interface — `GetOrCreateCounter(name, opts...) Counter`, `GetOrCreateHistogram(name, opts...) Histogram`, `WithLabel/WithLabels` for label opts. The returned `Counter` has `WithLabels(map) Counter` for per-emit labelled children, and `Inc()`/`Add(delta)`. `Histogram` has `Observe(value)`. + - OTel global tracer via `otel.Tracer("forge.dashboard.contract")` from `go.opentelemetry.io/otel`. `Tracer.Start(ctx, name) (context.Context, trace.Span)`. + - `forge.Logger` from `app.Logger()` — `Info(msg, fields...)` with `forge.String/Int/Duration/Any` field helpers. + +## Conventions + +- Plain `testing` package; no testify. Match the slice (a)+(c) test style. +- Imports: stdlib first, then `github.com/xraph/forge/...`, then third-party. +- Compile-time interface assertions where applicable. +- One commit per logical change. No `Co-Authored-By` trailers. + +## File Structure + +``` +extensions/dashboard/contract/idempotency/ + doc.go + store.go # Store interface, Cached struct + inmemory.go # InMemoryStore, NewInMemoryStore, options + store_test.go + inmemory_test.go + +extensions/dashboard/contract/dispatcher/ + metrics_prometheus.go # PrometheusMetricsEmitter + metrics_prometheus_test.go + audit_logger.go # LoggerAuditEmitter + audit_logger_test.go + # dispatcher.go modified: optional Tracer + IdempotencyStore via constructor options + # tracing inlined into dispatcher.go via a Tracer field — no separate tracing.go for slice (b) + +extensions/dashboard/contract/transport/ + csrf.go # CSRFTokenResponse + NewCSRFTokenHandler + csrf_test.go + # http.go modified: NewHandler optional CSRFManager arg; validation in command branch +``` + +Modifications: +- `extensions/dashboard/contract/transport/http.go` — add CSRF arg + validation hook. +- `extensions/dashboard/contract/dispatcher/dispatcher.go` — add `Option`-style constructor for tracer + idempotency store; wrap command dispatch. +- `extensions/dashboard/extension.go` — swap defaults for real impls; register CSRF endpoint; pass idempotency + CSRF through. +- `extensions/dashboard/extension.go` config struct — add `EnableContractSecurity bool` (default true). + +--- + +## Phase 0: Idempotency Store + +### Task 0.1: Store interface + Cached struct + +**Files:** +- Create: `extensions/dashboard/contract/idempotency/doc.go` +- Create: `extensions/dashboard/contract/idempotency/store.go` +- Create: `extensions/dashboard/contract/idempotency/store_test.go` + +- [ ] **Step 1: Write doc.go** + +```go +// Package idempotency provides command deduplication for the dashboard +// contract: a Store interface plus an in-memory implementation. Wrappers +// around dispatcher.Dispatch consult the store before invoking command +// handlers and return cached envelopes when the (key, identity) tuple +// matches a recent invocation. +package idempotency +``` + +- [ ] **Step 2: Write store_test.go (failing)** + +```go +package idempotency + +import ( + "encoding/json" + "testing" + "time" +) + +func TestCached_FieldsRoundTrip(t *testing.T) { + c := Cached{ + Status: 200, + WireBody: json.RawMessage(`{"ok":true}`), + StoredAt: time.Now(), + TTL: time.Hour, + } + if c.Status != 200 || string(c.WireBody) != `{"ok":true}` { + t.Errorf("Cached fields not preserved: %+v", c) + } +} + +func TestCached_Expired(t *testing.T) { + c := Cached{StoredAt: time.Now().Add(-2 * time.Hour), TTL: time.Hour} + if !c.Expired(time.Now()) { + t.Error("expected Expired() to be true") + } + c2 := Cached{StoredAt: time.Now(), TTL: time.Hour} + if c2.Expired(time.Now()) { + t.Error("expected fresh entry to not be expired") + } +} +``` + +- [ ] **Step 3: Run, expect FAIL** + +Run: `go test ./extensions/dashboard/contract/idempotency/...` +Expected: FAIL — undefined `Cached`. + +- [ ] **Step 4: Implement store.go** + +```go +package idempotency + +import ( + "context" + "encoding/json" + "time" +) + +// Store deduplicates command invocations by (key, identity) tuple. +// Lookup returns a cached envelope if one is present and unexpired; the +// dispatcher writes back the cached envelope verbatim when found. +// Implementations MUST be safe for concurrent use. +type Store interface { + Lookup(ctx context.Context, key, identity string) (*Cached, bool) + Store(ctx context.Context, key, identity string, c Cached) error +} + +// Cached is one cached command response. +type Cached struct { + // Status is the HTTP status the original handler returned. + Status int + // WireBody is the JSON envelope the original handler produced, ready to + // write back verbatim. + WireBody json.RawMessage + // StoredAt is when this entry landed in the store. + StoredAt time.Time + // TTL is how long the entry is considered fresh. + TTL time.Duration +} + +// Expired reports whether c is past its TTL relative to now. +func (c Cached) Expired(now time.Time) bool { + if c.TTL <= 0 { + return false + } + return now.After(c.StoredAt.Add(c.TTL)) +} +``` + +- [ ] **Step 5: Run, expect PASS** + +Run: `go test ./extensions/dashboard/contract/idempotency/...` +Expected: PASS — 2 tests. + +- [ ] **Step 6: Commit** + +```bash +git add extensions/dashboard/contract/idempotency/{doc.go,store.go,store_test.go} +git commit -m "feat(dashboard/contract/idempotency): Store interface and Cached entry" +``` + +### Task 0.2: In-memory implementation + +**Files:** +- Create: `extensions/dashboard/contract/idempotency/inmemory.go` +- Create: `extensions/dashboard/contract/idempotency/inmemory_test.go` + +- [ ] **Step 1: Write failing tests** + +```go +package idempotency + +import ( + "context" + "encoding/json" + "sync" + "testing" + "time" +) + +func TestInMemory_LookupMissThenHit(t *testing.T) { + s := NewInMemoryStore() + if _, ok := s.Lookup(context.Background(), "k", "u"); ok { + t.Error("expected miss") + } + c := Cached{Status: 200, WireBody: json.RawMessage(`{"x":1}`), StoredAt: time.Now(), TTL: time.Hour} + if err := s.Store(context.Background(), "k", "u", c); err != nil { + t.Fatalf("store: %v", err) + } + got, ok := s.Lookup(context.Background(), "k", "u") + if !ok { + t.Fatal("expected hit") + } + if got.Status != 200 || string(got.WireBody) != `{"x":1}` { + t.Errorf("cached value lost: %+v", got) + } +} + +func TestInMemory_DifferentIdentityIsIndependent(t *testing.T) { + s := NewInMemoryStore() + c := Cached{WireBody: json.RawMessage(`null`), StoredAt: time.Now(), TTL: time.Hour} + _ = s.Store(context.Background(), "k", "alice", c) + if _, ok := s.Lookup(context.Background(), "k", "bob"); ok { + t.Error("bob should not see alice's cached entry") + } +} + +func TestInMemory_ExpiredEntryReturnsMiss(t *testing.T) { + s := NewInMemoryStore() + c := Cached{StoredAt: time.Now().Add(-2 * time.Hour), TTL: time.Hour, WireBody: json.RawMessage(`null`)} + _ = s.Store(context.Background(), "k", "u", c) + if _, ok := s.Lookup(context.Background(), "k", "u"); ok { + t.Error("expected expired entry to miss") + } +} + +func TestInMemory_LRUEvictionAtCapacity(t *testing.T) { + s := NewInMemoryStore(WithMaxEntries(2)) + now := time.Now() + _ = s.Store(context.Background(), "k1", "u", Cached{StoredAt: now, TTL: time.Hour, WireBody: json.RawMessage(`1`)}) + _ = s.Store(context.Background(), "k2", "u", Cached{StoredAt: now, TTL: time.Hour, WireBody: json.RawMessage(`2`)}) + _ = s.Store(context.Background(), "k3", "u", Cached{StoredAt: now, TTL: time.Hour, WireBody: json.RawMessage(`3`)}) + // k1 should be evicted (oldest, capacity=2). + if _, ok := s.Lookup(context.Background(), "k1", "u"); ok { + t.Error("k1 should have been evicted") + } + if _, ok := s.Lookup(context.Background(), "k2", "u"); !ok { + t.Error("k2 should still be present") + } + if _, ok := s.Lookup(context.Background(), "k3", "u"); !ok { + t.Error("k3 should still be present") + } +} + +func TestInMemory_ConcurrentReadWrite(t *testing.T) { + s := NewInMemoryStore() + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(2) + go func(i int) { + defer wg.Done() + key := "k" + s.Store(context.Background(), key, "u", Cached{StoredAt: time.Now(), TTL: time.Hour, WireBody: json.RawMessage(`null`)}) + _ = i + }(i) + go func(i int) { + defer wg.Done() + s.Lookup(context.Background(), "k", "u") + _ = i + }(i) + } + wg.Wait() +} +``` + +- [ ] **Step 2: Run, expect FAIL** + +Run: `go test ./extensions/dashboard/contract/idempotency/...` +Expected: FAIL — undefined `NewInMemoryStore`. + +- [ ] **Step 3: Implement inmemory.go** + +```go +package idempotency + +import ( + "container/list" + "context" + "sync" + "time" +) + +// DefaultMaxEntries is the default LRU cap for an in-memory store. +const DefaultMaxEntries = 10000 + +// Option configures an InMemoryStore. +type Option func(*InMemoryStore) + +// WithMaxEntries caps the number of cached entries; oldest are evicted first. +func WithMaxEntries(n int) Option { + return func(s *InMemoryStore) { + if n > 0 { + s.maxEntries = n + } + } +} + +// InMemoryStore is a process-local Store with TTL and LRU eviction. +// Safe for concurrent use. +type InMemoryStore struct { + mu sync.Mutex + maxEntries int + entries map[entryKey]*list.Element + order *list.List // front = MRU, back = LRU +} + +type entryKey struct { + Key string + Identity string +} + +type entry struct { + key entryKey + val Cached +} + +// NewInMemoryStore returns an in-memory Store with the given options. +func NewInMemoryStore(opts ...Option) *InMemoryStore { + s := &InMemoryStore{ + maxEntries: DefaultMaxEntries, + entries: map[entryKey]*list.Element{}, + order: list.New(), + } + for _, opt := range opts { + opt(s) + } + return s +} + +// Lookup implements Store. +func (s *InMemoryStore) Lookup(_ context.Context, key, identity string) (*Cached, bool) { + k := entryKey{key, identity} + s.mu.Lock() + defer s.mu.Unlock() + el, ok := s.entries[k] + if !ok { + return nil, false + } + e := el.Value.(*entry) + if e.val.Expired(time.Now()) { + s.order.Remove(el) + delete(s.entries, k) + return nil, false + } + s.order.MoveToFront(el) + c := e.val // copy + return &c, true +} + +// Store implements Store. Returns nil; signature reserves error for future +// backends (e.g., Redis). +func (s *InMemoryStore) Store(_ context.Context, key, identity string, c Cached) error { + k := entryKey{key, identity} + s.mu.Lock() + defer s.mu.Unlock() + if el, ok := s.entries[k]; ok { + e := el.Value.(*entry) + e.val = c + s.order.MoveToFront(el) + return nil + } + el := s.order.PushFront(&entry{key: k, val: c}) + s.entries[k] = el + for s.order.Len() > s.maxEntries { + oldest := s.order.Back() + if oldest != nil { + s.order.Remove(oldest) + delete(s.entries, oldest.Value.(*entry).key) + } + } + return nil +} + +// Compile-time assertion. +var _ Store = (*InMemoryStore)(nil) +``` + +- [ ] **Step 4: Run, expect PASS** + +Run: `go test -race ./extensions/dashboard/contract/idempotency/...` +Expected: PASS — 5 tests, race-clean. + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/contract/idempotency/{inmemory.go,inmemory_test.go} +git commit -m "feat(dashboard/contract/idempotency): in-memory Store with TTL and LRU eviction" +``` + +--- + +## Phase 1: CSRF Validation + Token Endpoint + +### Task 1.1: Token issuance endpoint + +**Files:** +- Create: `extensions/dashboard/contract/transport/csrf.go` +- Create: `extensions/dashboard/contract/transport/csrf_test.go` + +The token endpoint returns a fresh HMAC-signed token from the dashboard's existing `*security.CSRFManager`. The TTL surfaces as `expiresAt` so the React shell knows when to refresh. + +- [ ] **Step 1: Write failing tests** + +```go +package transport + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/xraph/forge/extensions/dashboard/security" +) + +func TestCSRFTokenHandler_ReturnsTokenAndExpiry(t *testing.T) { + mgr := security.NewCSRFManager() + h := NewCSRFTokenHandler(mgr, time.Hour) + req := httptest.NewRequest(http.MethodGet, "/api/dashboard/v1/csrf", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", w.Code, w.Body) + } + var resp CSRFTokenResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.Token == "" { + t.Error("token empty") + } + if !mgr.ValidateToken(resp.Token) { + t.Error("returned token does not validate against the manager") + } + if resp.ExpiresAt.Before(time.Now()) { + t.Errorf("ExpiresAt is in the past: %v", resp.ExpiresAt) + } +} + +func TestCSRFTokenHandler_RejectsNonGET(t *testing.T) { + h := NewCSRFTokenHandler(security.NewCSRFManager(), time.Hour) + req := httptest.NewRequest(http.MethodPost, "/api/dashboard/v1/csrf", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("expected 405, got %d", w.Code) + } +} +``` + +- [ ] **Step 2: Run, expect FAIL** + +Run: `go test ./extensions/dashboard/contract/transport/...` +Expected: FAIL — undefined `NewCSRFTokenHandler`. + +- [ ] **Step 3: Implement csrf.go** + +```go +package transport + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/xraph/forge/extensions/dashboard/security" +) + +// CSRFTokenResponse is the wire shape for GET /api/dashboard/v1/csrf. +type CSRFTokenResponse struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expiresAt"` +} + +// NewCSRFTokenHandler returns the GET /csrf handler. ttl is the token validity +// window the response surfaces to the client; the underlying manager is the +// authority on validation. +func NewCSRFTokenHandler(mgr *security.CSRFManager, ttl time.Duration) http.Handler { + return &csrfHandler{mgr: mgr, ttl: ttl} +} + +type csrfHandler struct { + mgr *security.CSRFManager + ttl time.Duration +} + +func (h *csrfHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "GET required", http.StatusMethodNotAllowed) + return + } + tok := h.mgr.GenerateToken() + resp := CSRFTokenResponse{ + Token: tok, + ExpiresAt: time.Now().Add(h.ttl), + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} +``` + +- [ ] **Step 4: Run, expect PASS** + +Run: `go test ./extensions/dashboard/contract/transport/...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/contract/transport/{csrf.go,csrf_test.go} +git commit -m "feat(dashboard/contract): CSRF token issuance endpoint" +``` + +### Task 1.2: CSRF validation in the command handler + +**Files:** +- Modify: `extensions/dashboard/contract/transport/http.go` +- Modify: `extensions/dashboard/contract/transport/http_test.go` + +We add an optional `*security.CSRFManager` arg to `NewHandler`. When non-nil and `kind=command`, the handler validates `req.CSRF` after the existing presence check. Failure returns `CodeUnauthenticated`. nil manager preserves the slice-(a) test behavior. + +- [ ] **Step 1: Add failing test to http_test.go** + +```go +import ( + // ... existing imports ... + "github.com/xraph/forge/extensions/dashboard/security" +) + +func TestHandler_CommandRejectsInvalidCSRF(t *testing.T) { + reg, wreg := setupRegistry(t) + mgr := security.NewCSRFManager() + h := NewHandlerWithCSRF(reg, wreg, &stubDispatcher{}, contract.NoopAuditEmitter{}, mgr) + + body, _ := json.Marshal(contract.Request{ + Envelope: "v1", Kind: contract.KindCommand, Contributor: "users", Intent: "user.disable", IntentVersion: 1, + CSRF: "not-a-real-token", IdempotencyKey: "ik_1", + }) + req := httptest.NewRequest(http.MethodPost, "/api/dashboard/v1", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d body=%s", w.Code, w.Body) + } + if !strings.Contains(w.Body.String(), "UNAUTHENTICATED") { + t.Errorf("expected UNAUTHENTICATED in body: %s", w.Body) + } +} + +func TestHandler_CommandAcceptsValidCSRF(t *testing.T) { + reg, wreg := setupRegistry(t) + mgr := security.NewCSRFManager() + tok := mgr.GenerateToken() + disp := &stubDispatcher{response: json.RawMessage(`{"ok":true}`)} + h := NewHandlerWithCSRF(reg, wreg, disp, contract.NoopAuditEmitter{}, mgr) + + body, _ := json.Marshal(contract.Request{ + Envelope: "v1", Kind: contract.KindCommand, Contributor: "users", Intent: "user.disable", IntentVersion: 1, + CSRF: tok, IdempotencyKey: "ik_1", + }) + req := httptest.NewRequest(http.MethodPost, "/api/dashboard/v1", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", w.Code, w.Body) + } +} +``` + +The fixture's `setupRegistry` already sets up a `user.disable` command intent. Verify that's still the case by reading the existing http_test.go. + +- [ ] **Step 2: Run, expect FAIL** + +Run: `go test ./extensions/dashboard/contract/transport/...` +Expected: FAIL — undefined `NewHandlerWithCSRF`. + +- [ ] **Step 3: Modify http.go** + +Add a new constructor without breaking the existing one: + +```go +// In transport/http.go, near NewHandler: + +// NewHandlerWithCSRF is NewHandler plus a CSRFManager for command validation. +// When mgr is non-nil, command envelopes whose CSRF token does not validate +// return CodeUnauthenticated. Pass nil to skip CSRF (preserves the slice-(a) +// behaviour for tests and rollout opt-out). +func NewHandlerWithCSRF(reg contract.Registry, wreg contract.WardenRegistry, disp Dispatcher, audit contract.AuditEmitter, mgr *security.CSRFManager) http.Handler { + h := NewHandler(reg, wreg, disp, audit).(*handler) + h.csrfMgr = mgr + return h +} +``` + +Add the field to the `handler` struct and wire validation in `ServeHTTP`: + +```go +import ( + // ... existing imports ... + "github.com/xraph/forge/extensions/dashboard/security" +) + +// handler struct gains: +type handler struct { + reg contract.Registry + wreg contract.WardenRegistry + disp Dispatcher + audit contract.AuditEmitter + csrfMgr *security.CSRFManager // optional; nil disables CSRF validation +} + +// In ServeHTTP, inside the `req.Kind == contract.KindCommand` branch, +// AFTER the presence check (req.IdempotencyKey == "" || req.CSRF == ""), +// add: + +if h.csrfMgr != nil && !h.csrfMgr.ValidateToken(req.CSRF) { + writeError(w, http.StatusForbidden, &contract.Error{Code: contract.CodeUnauthenticated, Message: "csrf token invalid"}) + return +} +``` + +- [ ] **Step 4: Run, expect PASS** + +Run: `go test ./extensions/dashboard/contract/transport/...` +Expected: PASS — 2 new tests + all prior transport tests still pass. + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/contract/transport/{http.go,http_test.go} +git commit -m "feat(dashboard/contract): CSRF token validation for command envelopes" +``` + +--- + +## Phase 2: Prometheus MetricsEmitter + +### Task 2.1: PrometheusMetricsEmitter + +**Files:** +- Create: `extensions/dashboard/contract/dispatcher/metrics_prometheus.go` +- Create: `extensions/dashboard/contract/dispatcher/metrics_prometheus_test.go` + +We use `forge.Metrics`'s `GetOrCreateCounter` + `GetOrCreateHistogram` to lazily create the dispatch series; per-emit labels go through `Counter.WithLabels(map)`. + +- [ ] **Step 1: Write failing tests** + +```go +package dispatcher + +import ( + "context" + "testing" + "time" + + forgemetrics "github.com/xraph/forge/internal/metrics" + "github.com/xraph/forge/extensions/dashboard/contract" +) + +func TestPrometheusMetricsEmitter_RecordsCounterAndHistogram(t *testing.T) { + // NewNoOpMetrics returns a forge.Metrics instance whose Counters/Histograms + // are real but don't export anywhere — perfect for assertion via Value/Count. + // (forgemetrics.NewNoOpMetrics is the public entry; equivalent to forge.NewNoOpMetrics.) + m := forgemetrics.NewNoOpMetrics() + em := NewPrometheusMetricsEmitter(m) + + em.RecordDispatch(context.Background(), "users", "users.list", 1, contract.KindQuery, 12*time.Millisecond, "") + em.RecordDispatch(context.Background(), "users", "users.list", 1, contract.KindQuery, 8*time.Millisecond, contract.CodeNotFound) + + // We don't assert exact Prometheus output — the noop registry doesn't render. + // We assert the emitter is callable, doesn't panic, and idempotent on repeat. + em.RecordDispatch(context.Background(), "users", "users.list", 1, contract.KindQuery, 5*time.Millisecond, "") +} + +func TestPrometheusMetricsEmitter_LazyCollectorCreation(t *testing.T) { + m := forgemetrics.NewNoOpMetrics() + em := NewPrometheusMetricsEmitter(m) + // No collectors should exist yet — test by calling RecordDispatch and verifying no panic. + em.RecordDispatch(context.Background(), "x", "y", 1, contract.KindCommand, time.Millisecond, "") +} + +func TestPrometheusMetricsEmitter_NilMetricsIsNoop(t *testing.T) { + em := NewPrometheusMetricsEmitter(nil) + em.RecordDispatch(context.Background(), "x", "y", 1, contract.KindQuery, time.Millisecond, "") + // no panic, no assertion — the constructor handles nil by becoming a noop. +} +``` + +- [ ] **Step 2: Run, expect FAIL** + +Run: `go test ./extensions/dashboard/contract/dispatcher/...` +Expected: FAIL — undefined `NewPrometheusMetricsEmitter`. + +- [ ] **Step 3: Implement metrics_prometheus.go** + +```go +package dispatcher + +import ( + "context" + "strconv" + "time" + + "github.com/xraph/forge" + "github.com/xraph/forge/extensions/dashboard/contract" +) + +const ( + dispatchTotalMetric = "forge_dashboard_dispatch_total" + dispatchDurationMetric = "forge_dashboard_dispatch_duration_seconds" +) + +// PrometheusMetricsEmitter records dispatch metrics into a forge.Metrics +// registry. Counters and histograms are created lazily on first emission. +// Pass nil to disable (the emitter becomes a noop). +type PrometheusMetricsEmitter struct { + metrics forge.Metrics +} + +// NewPrometheusMetricsEmitter returns an emitter that writes to m. +// If m is nil, the emitter is a noop. +func NewPrometheusMetricsEmitter(m forge.Metrics) *PrometheusMetricsEmitter { + return &PrometheusMetricsEmitter{metrics: m} +} + +// RecordDispatch implements MetricsEmitter. +func (e *PrometheusMetricsEmitter) RecordDispatch(_ context.Context, contributor, intent string, version int, kind contract.Kind, latency time.Duration, errCode contract.ErrorCode) { + if e.metrics == nil { + return + } + + labels := map[string]string{ + "contributor": contributor, + "intent": intent, + "version": strconv.Itoa(version), + "kind": string(kind), + } + + hist := e.metrics.GetOrCreateHistogram(dispatchDurationMetric) + if hist != nil { + hist.WithLabels(labels).Observe(latency.Seconds()) + } + + counterLabels := make(map[string]string, len(labels)+1) + for k, v := range labels { + counterLabels[k] = v + } + counterLabels["error_code"] = string(errCode) // empty when success — Prometheus is OK with that + + cnt := e.metrics.GetOrCreateCounter(dispatchTotalMetric) + if cnt != nil { + cnt.WithLabels(counterLabels).Inc() + } +} + +// Compile-time assertion. +var _ MetricsEmitter = (*PrometheusMetricsEmitter)(nil) +``` + +- [ ] **Step 4: Run, expect PASS** + +Run: `go test ./extensions/dashboard/contract/dispatcher/...` +Expected: PASS — 3 new metrics tests + all prior dispatcher tests. + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/contract/dispatcher/{metrics_prometheus.go,metrics_prometheus_test.go} +git commit -m "feat(dashboard/contract/dispatcher): Prometheus-backed MetricsEmitter" +``` + +--- + +## Phase 3: OTel Tracing in Dispatcher + +### Task 3.1: Optional Tracer field + span lifecycle + +**Files:** +- Modify: `extensions/dashboard/contract/dispatcher/dispatcher.go` +- Create: `extensions/dashboard/contract/dispatcher/tracing_test.go` + +We add a `tracer trace.Tracer` field to `Dispatcher` and wrap `Dispatch` with `tracer.Start(ctx, name)`. The span name embeds `(contributor, intent, version, kind)`. Attributes capture principal subject and result code. Nil tracer keeps the no-tracing default working. + +- [ ] **Step 1: Write the failing test** + +```go +package dispatcher + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +func TestDispatcher_OpensSpanPerDispatch(t *testing.T) { + exporter := tracetest.NewInMemoryExporter() + tp := trace.NewTracerProvider(trace.WithSyncer(exporter)) + defer tp.Shutdown(context.Background()) + otel.SetTracerProvider(tp) + tracer := tp.Tracer("test") + + d := NewWithOptions(NoopMetricsEmitter{}, WithTracer(tracer)) + _ = d.Register("c", "i", 1, func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { + return &Result{Data: json.RawMessage(`null`)}, nil + }) + req := contract.Request{Envelope: "v1", Kind: contract.KindQuery, Contributor: "c", Intent: "i", IntentVersion: 1} + _, _, _ = d.Dispatch(context.Background(), req, contract.Principal{}) + + spans := exporter.GetSpans() + if len(spans) != 1 { + t.Fatalf("expected 1 span, got %d", len(spans)) + } + s := spans[0] + if s.Name != "dispatch:c/i@1" { + t.Errorf("span name = %q", s.Name) + } +} + +func TestDispatcher_SpanRecordsErrorCode(t *testing.T) { + exporter := tracetest.NewInMemoryExporter() + tp := trace.NewTracerProvider(trace.WithSyncer(exporter)) + defer tp.Shutdown(context.Background()) + tracer := tp.Tracer("test") + + d := NewWithOptions(NoopMetricsEmitter{}, WithTracer(tracer)) + _ = d.Register("c", "i", 1, func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { + return nil, &contract.Error{Code: contract.CodeConflict} + }) + req := contract.Request{Envelope: "v1", Kind: contract.KindCommand, Contributor: "c", Intent: "i", IntentVersion: 1} + _, _, _ = d.Dispatch(context.Background(), req, contract.Principal{}) + + spans := exporter.GetSpans() + if len(spans) != 1 { + t.Fatalf("expected 1 span") + } + found := false + for _, attr := range spans[0].Attributes { + if string(attr.Key) == "forge.contract.error_code" && attr.Value.AsString() == "CONFLICT" { + found = true + break + } + } + if !found { + t.Errorf("expected error_code attribute, got attrs=%+v", spans[0].Attributes) + } + _ = errors.New +} + +func TestDispatcher_NilTracerIsNoop(t *testing.T) { + d := NewWithOptions(NoopMetricsEmitter{}) // no tracer option + _ = d.Register("c", "i", 1, func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { + return &Result{Data: json.RawMessage(`null`)}, nil + }) + req := contract.Request{Envelope: "v1", Kind: contract.KindQuery, Contributor: "c", Intent: "i", IntentVersion: 1} + _, _, err := d.Dispatch(context.Background(), req, contract.Principal{}) + if err != nil { + t.Errorf("nil tracer should not affect dispatch: %v", err) + } +} +``` + +- [ ] **Step 2: Run, expect FAIL** + +Run: `go test ./extensions/dashboard/contract/dispatcher/...` +Expected: FAIL — undefined `NewWithOptions`, `WithTracer`. + +- [ ] **Step 3: Modify dispatcher.go** + +Add the option pattern + tracer field, plus span lifecycle in Dispatch: + +```go +// At top of dispatcher.go imports (alongside existing): +import ( + // ... existing ... + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +// Add to the Dispatcher struct (alongside metrics field): +type Dispatcher struct { + metrics MetricsEmitter + tracer trace.Tracer // optional; nil = no tracing + store IdempotencyStore // optional; nil = no dedup (filled in Phase 5 wire-up) + + mu sync.RWMutex + handlers map[handlerKey]Handler + subscriptions map[handlerKey]SubscriptionHandler +} + +// Option configures a Dispatcher. +type Option func(*Dispatcher) + +// WithTracer configures the dispatcher to open a span per Dispatch call. +func WithTracer(t trace.Tracer) Option { + return func(d *Dispatcher) { d.tracer = t } +} + +// IdempotencyStore is the minimal surface the dispatcher needs from +// extensions/dashboard/contract/idempotency. Defining it here avoids an +// import cycle (the idempotency package is consumed only via this interface). +type IdempotencyStore interface { + Lookup(ctx context.Context, key, identity string) (*IdempotencyCached, bool) + Store(ctx context.Context, key, identity string, c IdempotencyCached) error +} + +// IdempotencyCached mirrors idempotency.Cached; defined here for the same +// import-cycle reason. Adapters in the wire-up convert between the two. +type IdempotencyCached struct { + Status int + WireBody json.RawMessage + StoredAt time.Time + TTL time.Duration +} + +// WithIdempotencyStore wires command dedup. Phase 5 uses this. +func WithIdempotencyStore(s IdempotencyStore) Option { + return func(d *Dispatcher) { d.store = s } +} + +// NewWithOptions is New with explicit options. Existing New calls keep working. +func NewWithOptions(metrics MetricsEmitter, opts ...Option) *Dispatcher { + if metrics == nil { + metrics = NoopMetricsEmitter{} + } + d := &Dispatcher{ + metrics: metrics, + handlers: map[handlerKey]Handler{}, + subscriptions: map[handlerKey]SubscriptionHandler{}, + } + for _, opt := range opts { + opt(d) + } + return d +} +``` + +Update `Dispatch` to open a span: + +```go +func (d *Dispatcher) Dispatch(ctx context.Context, req contract.Request, p contract.Principal) (json.RawMessage, contract.ResponseMeta, error) { + if d.tracer != nil { + var span trace.Span + spanName := fmt.Sprintf("dispatch:%s/%s@%d", req.Contributor, req.Intent, req.IntentVersion) + ctx, span = d.tracer.Start(ctx, spanName, + trace.WithAttributes( + attribute.String("forge.contract.contributor", req.Contributor), + attribute.String("forge.contract.intent", req.Intent), + attribute.Int("forge.contract.version", req.IntentVersion), + attribute.String("forge.contract.kind", string(req.Kind)), + ), + ) + defer span.End() + // Wrap the rest in a closure so we can update span attrs on return. + data, meta, err := d.dispatchInner(ctx, req, p) + if err != nil { + var ce *contract.Error + if errors.As(err, &ce) { + span.SetAttributes(attribute.String("forge.contract.error_code", string(ce.Code))) + span.SetStatus(codes.Error, string(ce.Code)) + } else { + span.SetStatus(codes.Error, err.Error()) + } + } else { + span.SetStatus(codes.Ok, "") + } + return data, meta, err + } + return d.dispatchInner(ctx, req, p) +} +``` + +Refactor the existing Dispatch body into `dispatchInner`. The signature stays the same. + +- [ ] **Step 4: Run, expect PASS** + +Run: `go test -race ./extensions/dashboard/contract/dispatcher/...` +Expected: PASS — 3 new tracing tests + all prior dispatcher tests. + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/contract/dispatcher/{dispatcher.go,tracing_test.go} +git commit -m "feat(dashboard/contract/dispatcher): optional OTel tracer with span lifecycle" +``` + +--- + +## Phase 4: Idempotency Wrap in Dispatcher + +### Task 4.1: Wrap command dispatches with store lookup + write-through + +**Files:** +- Modify: `extensions/dashboard/contract/dispatcher/dispatcher.go` +- Modify: `extensions/dashboard/contract/dispatcher/dispatcher_test.go` + +The store is consulted only for `kind=command`. Hit → return cached envelope verbatim (data + meta unmarshalled from `WireBody`). Miss → dispatch, store result, return. + +The dispatcher already has `d.store IdempotencyStore` from Task 3.1. Now we use it. + +- [ ] **Step 1: Write failing tests** + +```go +package dispatcher + +import ( + "context" + "encoding/json" + "sync/atomic" + "testing" + "time" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +type stubStore struct { + hits map[string]IdempotencyCached + puts int64 + gets int64 +} + +func newStubStore() *stubStore { return &stubStore{hits: map[string]IdempotencyCached{}} } + +func (s *stubStore) Lookup(_ context.Context, key, identity string) (*IdempotencyCached, bool) { + atomic.AddInt64(&s.gets, 1) + c, ok := s.hits[key+"|"+identity] + if !ok { + return nil, false + } + cc := c + return &cc, true +} + +func (s *stubStore) Store(_ context.Context, key, identity string, c IdempotencyCached) error { + atomic.AddInt64(&s.puts, 1) + s.hits[key+"|"+identity] = c + return nil +} + +func TestDispatcher_IdempotencyHitReturnsCached(t *testing.T) { + store := newStubStore() + store.hits["k|alice"] = IdempotencyCached{ + Status: 200, WireBody: json.RawMessage(`{"cached":true}`), + StoredAt: time.Now(), TTL: time.Hour, + } + d := NewWithOptions(NoopMetricsEmitter{}, WithIdempotencyStore(store)) + called := int64(0) + _ = d.Register("c", "i", 1, func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { + atomic.AddInt64(&called, 1) + return &Result{Data: json.RawMessage(`{"fresh":true}`)}, nil + }) + req := contract.Request{ + Envelope: "v1", Kind: contract.KindCommand, + Contributor: "c", Intent: "i", IntentVersion: 1, + IdempotencyKey: "k", + } + p := contract.PrincipalFor(&dashauth.UserInfo{Subject: "alice"}) + data, _, err := d.Dispatch(context.Background(), req, p) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if string(data) != `{"cached":true}` { + t.Errorf("expected cached body, got %s", data) + } + if atomic.LoadInt64(&called) != 0 { + t.Errorf("handler should not have been called on cache hit") + } +} + +func TestDispatcher_IdempotencyMissCallsHandlerAndStores(t *testing.T) { + store := newStubStore() + d := NewWithOptions(NoopMetricsEmitter{}, WithIdempotencyStore(store)) + _ = d.Register("c", "i", 1, func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { + return &Result{Data: json.RawMessage(`{"fresh":true}`)}, nil + }) + req := contract.Request{ + Envelope: "v1", Kind: contract.KindCommand, + Contributor: "c", Intent: "i", IntentVersion: 1, + IdempotencyKey: "k", + } + p := contract.PrincipalFor(&dashauth.UserInfo{Subject: "alice"}) + _, _, _ = d.Dispatch(context.Background(), req, p) + if atomic.LoadInt64(&store.puts) != 1 { + t.Errorf("expected 1 store write, got %d", store.puts) + } +} + +func TestDispatcher_IdempotencyOnlyAppliesToCommands(t *testing.T) { + store := newStubStore() + d := NewWithOptions(NoopMetricsEmitter{}, WithIdempotencyStore(store)) + _ = d.Register("c", "i", 1, func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { + return &Result{Data: json.RawMessage(`null`)}, nil + }) + req := contract.Request{ + Envelope: "v1", Kind: contract.KindQuery, + Contributor: "c", Intent: "i", IntentVersion: 1, + IdempotencyKey: "k", + } + _, _, _ = d.Dispatch(context.Background(), req, contract.Principal{}) + if atomic.LoadInt64(&store.gets) != 0 { + t.Errorf("query should not consult store, gets=%d", store.gets) + } +} + +func TestDispatcher_IdempotencyMissingKeyBypassesStore(t *testing.T) { + store := newStubStore() + d := NewWithOptions(NoopMetricsEmitter{}, WithIdempotencyStore(store)) + _ = d.Register("c", "i", 1, func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { + return &Result{Data: json.RawMessage(`null`)}, nil + }) + req := contract.Request{ + Envelope: "v1", Kind: contract.KindCommand, + Contributor: "c", Intent: "i", IntentVersion: 1, + // IdempotencyKey intentionally empty — slice (a)'s presence check is the gate, but + // when a wrap with the dispatcher only is exercised, missing key just bypasses dedup. + } + _, _, _ = d.Dispatch(context.Background(), req, contract.Principal{}) + if atomic.LoadInt64(&store.gets) != 0 { + t.Errorf("missing key should bypass store, gets=%d", store.gets) + } +} +``` + +Imports needed in test file: `dashauth "github.com/xraph/forge/extensions/dashboard/auth"`. + +- [ ] **Step 2: Run, expect FAIL** + +Run: `go test ./extensions/dashboard/contract/dispatcher/...` +Expected: FAIL — store path not yet wired. + +- [ ] **Step 3: Modify dispatcher.go's `dispatchInner`** + +Add the dedup wrap around the handler call, **only for commands and only when both store and key are present**: + +```go +func (d *Dispatcher) dispatchInner(ctx context.Context, req contract.Request, p contract.Principal) (json.RawMessage, contract.ResponseMeta, error) { + // Idempotency wrap (commands only, requires store + key + identity). + if req.Kind == contract.KindCommand && d.store != nil && req.IdempotencyKey != "" { + identity := principalIdentity(p, req.Intent) + if cached, ok := d.store.Lookup(ctx, req.IdempotencyKey, identity); ok { + // Decode the cached envelope back into (data, meta). + var resp contract.Response + if err := json.Unmarshal(cached.WireBody, &resp); err == nil && resp.OK { + return resp.Data, resp.Meta, nil + } + // Cached but undecodable; fall through to fresh dispatch. + } + } + + // ... existing dispatchInner body ... + // (look up handler, call, map errors, emit metrics) + + // After successful dispatch, capture for next time. + if req.Kind == contract.KindCommand && d.store != nil && req.IdempotencyKey != "" && wireErr == nil { + identity := principalIdentity(p, req.Intent) + successResp := contract.Response{OK: true, Envelope: req.Envelope, Kind: req.Kind, Data: data, Meta: meta} + body, _ := json.Marshal(successResp) + _ = d.store.Store(ctx, req.IdempotencyKey, identity, IdempotencyCached{ + Status: 200, + WireBody: body, + StoredAt: time.Now(), + TTL: 24 * time.Hour, // TODO: make configurable in Phase 6 wire-up if needed + }) + } + // (return) +} + +func principalIdentity(p contract.Principal, intent string) string { + user := "" + if p.User != nil { + user = p.User.Subject + } + return user + ":" + intent +} +``` + +Refactor the existing handler-lookup-and-call body to live above the storage block. The exact integration shape: + +1. Top-of-function: idempotency lookup → return cached if hit. +2. Middle: existing handler lookup, call, error mapping, metrics emission. Capture `data, meta, wireErr` locally. +3. Bottom: idempotency store on success, then return. + +The `// TODO` in the snippet is a real TODO worth leaving as a `// Phase 6 will surface this via Extension config.` comment in the actual code — the comment is informational, not a placeholder for missing logic. + +- [ ] **Step 4: Run, expect PASS** + +Run: `go test -race ./extensions/dashboard/contract/dispatcher/...` +Expected: PASS — 4 new idempotency tests + all prior tests, race-clean. + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/contract/dispatcher/{dispatcher.go,dispatcher_test.go} +git commit -m "feat(dashboard/contract/dispatcher): idempotency-key dedup for command dispatches" +``` + +--- + +## Phase 5: Structured-Logger AuditEmitter + +### Task 5.1: LoggerAuditEmitter + +**Files:** +- Create: `extensions/dashboard/contract/dispatcher/audit_logger.go` +- Create: `extensions/dashboard/contract/dispatcher/audit_logger_test.go` + +The emitter writes audit records as info-level structured logs via `forge.Logger`. Each field is a discrete logger field so log aggregators can filter by `audit=true` cheaply. + +- [ ] **Step 1: Write failing tests** + +```go +package dispatcher + +import ( + "bytes" + "context" + "strings" + "testing" + "time" + + "github.com/xraph/forge" + "github.com/xraph/forge/extensions/dashboard/contract" +) + +func TestLoggerAuditEmitter_EmitsStructuredFields(t *testing.T) { + // Use a development logger that writes to a captured buffer. + var buf bytes.Buffer + cfg := forge.LoggingConfig{Level: "info", Encoding: "json", Output: &buf} + logger := forge.NewLogger(cfg) + em := NewLoggerAuditEmitter(logger) + + em.Emit(context.Background(), contract.AuditRecord{ + Time: time.Now(), + Contributor: "users", + Intent: "user.disable", + IntentVersion: 2, + Subject: "u_42", + User: "admin@example.com", + Result: "ok", + LatencyMs: 12, + CorrelationID: "req_x", + }) + + out := buf.String() + for _, want := range []string{`"audit":true`, `"contributor":"users"`, `"intent":"user.disable"`, `"version":2`, `"subject":"u_42"`, `"user":"admin@example.com"`, `"result":"ok"`} { + if !strings.Contains(out, want) { + t.Errorf("audit log missing %q in output: %s", want, out) + } + } +} + +func TestLoggerAuditEmitter_NilLoggerIsNoop(t *testing.T) { + em := NewLoggerAuditEmitter(nil) + // Must not panic. + em.Emit(context.Background(), contract.AuditRecord{}) +} +``` + +> **Note on the Logger API surface:** the test uses `forge.LoggingConfig{Level, Encoding, Output: &buf}` and `forge.NewLogger(cfg)`. If the actual `LoggingConfig` field names differ, adapt — the implementer should `grep "type LoggingConfig" /Users/rexraphael/Work/xraph/forge/internal/logger/` first to confirm the field names. The behavior contract is: pass a logger that writes JSON to a buffer; assert the buffer contains the expected JSON-encoded fields. + +- [ ] **Step 2: Run, expect FAIL** + +Run: `go test ./extensions/dashboard/contract/dispatcher/...` +Expected: FAIL — undefined `NewLoggerAuditEmitter`. + +- [ ] **Step 3: Implement audit_logger.go** + +```go +package dispatcher + +import ( + "context" + + "github.com/xraph/forge" + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// LoggerAuditEmitter writes audit records as info-level structured logs. +// nil logger is a noop. +type LoggerAuditEmitter struct { + logger forge.Logger +} + +// NewLoggerAuditEmitter returns an emitter that writes via logger. Pass nil +// to disable (the emitter becomes a noop). +func NewLoggerAuditEmitter(logger forge.Logger) *LoggerAuditEmitter { + return &LoggerAuditEmitter{logger: logger} +} + +// Emit implements contract.AuditEmitter. +func (e *LoggerAuditEmitter) Emit(_ context.Context, rec contract.AuditRecord) { + if e.logger == nil { + return + } + e.logger.Info("dashboard contract audit", + forge.Bool("audit", true), + forge.String("contributor", rec.Contributor), + forge.String("intent", rec.Intent), + forge.Int("version", rec.IntentVersion), + forge.String("subject", rec.Subject), + forge.String("user", rec.User), + forge.String("result", rec.Result), + forge.Int64("latency_ms", rec.LatencyMs), + forge.String("correlation_id", rec.CorrelationID), + forge.Time("time", rec.Time), + ) +} + +// Compile-time assertion. +var _ contract.AuditEmitter = (*LoggerAuditEmitter)(nil) +``` + +> **Note on field helpers:** `forge.Bool`, `forge.String`, `forge.Int`, `forge.Int64`, `forge.Time` should exist alongside the existing `forge.Any`/`forge.Duration`. If any are missing, fall back to `forge.Any(name, value)` — the assertion is on field *presence*, not the constructor name. Confirm with `grep "func String\|func Int\|func Bool" /Users/rexraphael/Work/xraph/forge/logger.go /Users/rexraphael/Work/xraph/forge/internal/logger/*.go`. + +- [ ] **Step 4: Run, expect PASS** + +Run: `go test ./extensions/dashboard/contract/dispatcher/...` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/contract/dispatcher/{audit_logger.go,audit_logger_test.go} +git commit -m "feat(dashboard/contract/dispatcher): structured-logger AuditEmitter" +``` + +--- + +## Phase 6: Wire-up in extension.go + Integration Test + +### Task 6.1: Replace defaults; register CSRF endpoint; pass dispatcher options + +**Files:** +- Modify: `extensions/dashboard/extension.go` +- Modify: `extensions/dashboard/extension.go` config struct (add `EnableContractSecurity bool`) + +- [ ] **Step 1: Read existing wire-up** + +```bash +grep -n "transport.NilDispatcher\|NewLogAuditEmitter\|dispatcher.New(\|streamBroker\s*=\s*transport.NewStreamBroker" extensions/dashboard/extension.go +``` + +You should find: +- `disp := dispatcher.New(dispatcher.NoopMetricsEmitter{})` from slice (c) Phase 11. +- `auditEmitter: contract.NewLogAuditEmitter(os.Stdout)` from slice (a) Phase 13. +- `ext.streamBroker = transport.NewStreamBroker(...)` adjacent to dispatcher init. +- `handleContractPOST()` calling `transport.NewHandler(...)`. + +- [ ] **Step 2: Add config flag** + +In the dashboard config struct (search for the existing `Enable*` fields): + +```go +EnableContractSecurity bool `json:"enable_contract_security" yaml:"enable_contract_security"` +``` + +Default to true wherever defaults are constructed. Confirm by reading the existing config defaults function. + +- [ ] **Step 3: Swap defaults in NewExtension** + +Change the dispatcher construction: + +```go +import ( + // ... existing ... + "github.com/xraph/forge/extensions/dashboard/contract/idempotency" + "go.opentelemetry.io/otel" +) + +// Replace: +// disp := dispatcher.New(dispatcher.NoopMetricsEmitter{}) +// With: + +var metricsEmitter dispatcher.MetricsEmitter = dispatcher.NoopMetricsEmitter{} +if app != nil && app.Metrics() != nil { + metricsEmitter = dispatcher.NewPrometheusMetricsEmitter(app.Metrics()) +} + +var dispOpts []dispatcher.Option +if cfg.EnableContractSecurity { + dispOpts = append(dispOpts, + dispatcher.WithTracer(otel.Tracer("forge.dashboard.contract")), + dispatcher.WithIdempotencyStore(adaptIdempotencyStore(idempotency.NewInMemoryStore())), + ) +} + +disp := dispatcher.NewWithOptions(metricsEmitter, dispOpts...) +ext.dispatcher = disp +``` + +`adaptIdempotencyStore` is a small helper because `idempotency.Cached` and `dispatcher.IdempotencyCached` are different types (the dispatcher defined its own to avoid an import cycle): + +```go +// adaptIdempotencyStore adapts an idempotency.Store to the dispatcher's +// minimal interface. The two types are intentionally separate to avoid a +// dispatcher → idempotency import cycle. +type idempotencyAdapter struct{ inner idempotency.Store } + +func adaptIdempotencyStore(s idempotency.Store) dispatcher.IdempotencyStore { + return &idempotencyAdapter{inner: s} +} +func (a *idempotencyAdapter) Lookup(ctx context.Context, key, identity string) (*dispatcher.IdempotencyCached, bool) { + c, ok := a.inner.Lookup(ctx, key, identity) + if !ok { + return nil, false + } + return &dispatcher.IdempotencyCached{ + Status: c.Status, WireBody: c.WireBody, + StoredAt: c.StoredAt, TTL: c.TTL, + }, true +} +func (a *idempotencyAdapter) Store(ctx context.Context, key, identity string, c dispatcher.IdempotencyCached) error { + return a.inner.Store(ctx, key, identity, idempotency.Cached{ + Status: c.Status, WireBody: c.WireBody, + StoredAt: c.StoredAt, TTL: c.TTL, + }) +} +``` + +Place the adapter at the bottom of `extension.go` (or in a small new `extensions/dashboard/contract_wire.go` if preferred). + +Replace the audit emitter: + +```go +// Replace: +// auditEmitter: contract.NewLogAuditEmitter(os.Stdout) +// With (assuming app.Logger() is the standard logger): + +var auditEmitter contract.AuditEmitter = contract.NewLogAuditEmitter(os.Stdout) +if app != nil && app.Logger() != nil { + auditEmitter = dispatcher.NewLoggerAuditEmitter(app.Logger()) +} +ext.auditEmitter = auditEmitter +``` + +- [ ] **Step 4: Update handleContractPOST to use the CSRF-aware constructor** + +Find the existing `handleContractPOST` method: + +```go +func (e *Extension) handleContractPOST() http.HandlerFunc { + h := transport.NewHandler(e.contractRegistry, e.wardenRegistry, e.dispatcher, e.auditEmitter) + return h.ServeHTTP +} +``` + +Replace with: + +```go +func (e *Extension) handleContractPOST() http.HandlerFunc { + var mgr *security.CSRFManager + if e.config.EnableContractSecurity && e.csrfMgr != nil { + mgr = e.csrfMgr + } + h := transport.NewHandlerWithCSRF(e.contractRegistry, e.wardenRegistry, e.dispatcher, e.auditEmitter, mgr) + return h.ServeHTTP +} +``` + +The `*security.CSRFManager` is `e.csrfMgr` (already constructed at NewExtension per the Explore agent's findings). Confirm by grepping `extension.go` for `csrfMgr`. + +- [ ] **Step 5: Register the CSRF token endpoint** + +In `registerRoutes`, alongside the existing contract routes: + +```go +// Inside the `if e.contractRegistry != nil { ... }` block: +if e.csrfMgr != nil && e.config.EnableContractSecurity { + must(router.GET(base+"/api/dashboard/v1/csrf", + transport.NewCSRFTokenHandler(e.csrfMgr, 12*time.Hour).ServeHTTP)) +} +``` + +- [ ] **Step 6: Build + test** + +```bash +go build ./... +go test -count=1 ./extensions/dashboard/... +go test -race -count=1 ./extensions/dashboard/contract/... +go vet ./extensions/dashboard/... +``` + +All four must be clean. Slice (a)+(c)'s 181 tests + slice (b)'s ~25 new tests should all pass. + +- [ ] **Step 7: Commit** + +```bash +git add extensions/dashboard/extension.go +git commit -m "feat(dashboard): wire CSRF, idempotency, Prometheus metrics, OTel tracing, structured audit" +``` + +### Task 6.2: Integration test + +**Files:** +- Create: `extensions/dashboard/contract/contract_security_e2e_test.go` + +Drives the wired-up dispatcher through `transport.Handler` to exercise CSRF + idempotency end-to-end. + +- [ ] **Step 1: Write the integration test** + +```go +package contract_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/xraph/forge/extensions/dashboard/contract" + "github.com/xraph/forge/extensions/dashboard/contract/dispatcher" + "github.com/xraph/forge/extensions/dashboard/contract/idempotency" + "github.com/xraph/forge/extensions/dashboard/contract/transport" + "github.com/xraph/forge/extensions/dashboard/security" +) + +func TestSecurityE2E_CSRFRequired(t *testing.T) { + reg, wreg, disp := setupSecurityEnv(t, idempotency.NewInMemoryStore()) + mgr := security.NewCSRFManager() + h := transport.NewHandlerWithCSRF(reg, wreg, disp, contract.NoopAuditEmitter{}, mgr) + + body, _ := json.Marshal(contract.Request{ + Envelope: "v1", Kind: contract.KindCommand, + Contributor: "test", Intent: "do.thing", IntentVersion: 1, + CSRF: "wrong", IdempotencyKey: "k1", + }) + req := httptest.NewRequest(http.MethodPost, "/api/dashboard/v1", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403, got %d body=%s", w.Code, w.Body) + } + if !strings.Contains(w.Body.String(), "UNAUTHENTICATED") { + t.Errorf("expected UNAUTHENTICATED") + } +} + +func TestSecurityE2E_IdempotencyDedup(t *testing.T) { + store := idempotency.NewInMemoryStore() + reg, wreg, disp := setupSecurityEnv(t, store) + mgr := security.NewCSRFManager() + tok := mgr.GenerateToken() + h := transport.NewHandlerWithCSRF(reg, wreg, disp, contract.NoopAuditEmitter{}, mgr) + + build := func() *http.Request { + body, _ := json.Marshal(contract.Request{ + Envelope: "v1", Kind: contract.KindCommand, + Contributor: "test", Intent: "do.thing", IntentVersion: 1, + CSRF: tok, IdempotencyKey: "ik_e2e", + }) + return httptest.NewRequest(http.MethodPost, "/api/dashboard/v1", bytes.NewReader(body)) + } + w1 := httptest.NewRecorder(); h.ServeHTTP(w1, build()) + w2 := httptest.NewRecorder(); h.ServeHTTP(w2, build()) + + if w1.Body.String() != w2.Body.String() { + t.Errorf("idempotent calls produced different bodies:\nfirst: %s\nsecond: %s", w1.Body, w2.Body) + } +} + +func setupSecurityEnv(t *testing.T, store idempotency.Store) (contract.Registry, contract.WardenRegistry, *dispatcher.Dispatcher) { + t.Helper() + reg := contract.NewRegistry() + src := ` +schemaVersion: 1 +contributor: { name: test, envelope: { supports: [v1], preferred: v1 } } +intents: + - { name: do.thing, kind: command, version: 1, capability: write } +` + var m contract.ContractManifest + if err := contract.UnmarshalManifestForTest([]byte(src), &m); err != nil { + t.Fatal(err) + } + if err := reg.Register(&m); err != nil { + t.Fatal(err) + } + wreg := contract.NewWardenRegistry() + disp := dispatcher.NewWithOptions(dispatcher.NoopMetricsEmitter{}, + dispatcher.WithIdempotencyStore(adaptStore(store))) + dispatcher.RegisterCommand(disp, "test", "do.thing", 1, + func(ctx context.Context, _ struct{}, _ contract.Principal) (struct{ OK bool }, error) { + return struct{ OK bool }{OK: true}, nil + }) + return reg, wreg, disp +} + +// adaptStore adapts idempotency.Store to dispatcher.IdempotencyStore for tests. +type adapter struct{ inner idempotency.Store } + +func adaptStore(s idempotency.Store) dispatcher.IdempotencyStore { return &adapter{inner: s} } +func (a *adapter) Lookup(ctx context.Context, k, id string) (*dispatcher.IdempotencyCached, bool) { + c, ok := a.inner.Lookup(ctx, k, id) + if !ok { + return nil, false + } + return &dispatcher.IdempotencyCached{Status: c.Status, WireBody: c.WireBody, StoredAt: c.StoredAt, TTL: c.TTL}, true +} +func (a *adapter) Store(ctx context.Context, k, id string, c dispatcher.IdempotencyCached) error { + return a.inner.Store(ctx, k, id, idempotency.Cached{Status: c.Status, WireBody: c.WireBody, StoredAt: c.StoredAt, TTL: c.TTL}) +} +``` + +- [ ] **Step 2: Run, expect PASS** + +Run: `go test -race -count=1 ./extensions/dashboard/contract/...` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add extensions/dashboard/contract/contract_security_e2e_test.go +git commit -m "test(dashboard/contract): integration test for CSRF + idempotency end-to-end" +``` + +--- + +## Final Verification + +- [ ] **Run the whole test suite** + +```bash +go test -count=1 ./... +go test -race -count=1 ./extensions/dashboard/contract/... +go vet ./extensions/dashboard/... +go build ./... +``` + +All four must be clean. Slice (b) adds ~25 new tests on top of the 181 from slices (a)+(c). + +## Self-Review Notes + +- **Spec coverage:** Every row in SLICE_B_DESIGN.md's decision table maps to a phase. CSRF wire location → Phase 1; token endpoint → Phase 1; idempotency interface + impl → Phase 0; idempotency wrap location → Phase 4; identity key shape → Phase 4 (`principalIdentity`); metrics series + buckets → Phase 2; tracing scope → Phase 3; audit logger → Phase 5; rollout toggle (`EnableContractSecurity`) → Phase 6. +- **Spec deviations:** None. The design's "tracing as a first-class dispatcher concern" pivot is honored — `WithTracer` is on the dispatcher constructor, not a standalone wrapper. +- **No placeholders:** every TDD cycle has real test code + real implementation. Two informational notes instruct the implementer to grep for actual API names (`forge.LoggingConfig` field names, logger field helpers) — these are honest "verify-before-using" notes, not unfinished spec. +- **Type consistency:** `Handler`, `Result`, `IntentRef`, `Contributor`, `IdempotencyStore`, `IdempotencyCached` are defined in slice (a)/(c)/Phase 0/Phase 3 and used identically through Phase 6. The dispatcher-side `IdempotencyCached` and the idempotency-package `Cached` are intentionally separate types with an adapter — documented in Phase 6 with the rationale (import cycle avoidance). +- **Out-of-scope items honored:** No persistent audit storage, no Redis idempotency, no per-event subscription tracing, no React shell — those stay in their own slices. From 88883a80286f6292a50028e195cce935bb85cd82 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 23:24:32 -0500 Subject: [PATCH 43/89] feat(dashboard/contract/idempotency): Store interface and Cached entry --- .../dashboard/contract/idempotency/doc.go | 6 +++ .../dashboard/contract/idempotency/store.go | 37 +++++++++++++++++++ .../contract/idempotency/store_test.go | 30 +++++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 extensions/dashboard/contract/idempotency/doc.go create mode 100644 extensions/dashboard/contract/idempotency/store.go create mode 100644 extensions/dashboard/contract/idempotency/store_test.go diff --git a/extensions/dashboard/contract/idempotency/doc.go b/extensions/dashboard/contract/idempotency/doc.go new file mode 100644 index 00000000..886cc917 --- /dev/null +++ b/extensions/dashboard/contract/idempotency/doc.go @@ -0,0 +1,6 @@ +// Package idempotency provides command deduplication for the dashboard +// contract: a Store interface plus an in-memory implementation. Wrappers +// around dispatcher.Dispatch consult the store before invoking command +// handlers and return cached envelopes when the (key, identity) tuple +// matches a recent invocation. +package idempotency diff --git a/extensions/dashboard/contract/idempotency/store.go b/extensions/dashboard/contract/idempotency/store.go new file mode 100644 index 00000000..268d993f --- /dev/null +++ b/extensions/dashboard/contract/idempotency/store.go @@ -0,0 +1,37 @@ +package idempotency + +import ( + "context" + "encoding/json" + "time" +) + +// Store deduplicates command invocations by (key, identity) tuple. +// Lookup returns a cached envelope if one is present and unexpired; the +// dispatcher writes back the cached envelope verbatim when found. +// Implementations MUST be safe for concurrent use. +type Store interface { + Lookup(ctx context.Context, key, identity string) (*Cached, bool) + Store(ctx context.Context, key, identity string, c Cached) error +} + +// Cached is one cached command response. +type Cached struct { + // Status is the HTTP status the original handler returned. + Status int + // WireBody is the JSON envelope the original handler produced, ready to + // write back verbatim. + WireBody json.RawMessage + // StoredAt is when this entry landed in the store. + StoredAt time.Time + // TTL is how long the entry is considered fresh. + TTL time.Duration +} + +// Expired reports whether c is past its TTL relative to now. +func (c Cached) Expired(now time.Time) bool { + if c.TTL <= 0 { + return false + } + return now.After(c.StoredAt.Add(c.TTL)) +} diff --git a/extensions/dashboard/contract/idempotency/store_test.go b/extensions/dashboard/contract/idempotency/store_test.go new file mode 100644 index 00000000..4e973b60 --- /dev/null +++ b/extensions/dashboard/contract/idempotency/store_test.go @@ -0,0 +1,30 @@ +package idempotency + +import ( + "encoding/json" + "testing" + "time" +) + +func TestCached_FieldsRoundTrip(t *testing.T) { + c := Cached{ + Status: 200, + WireBody: json.RawMessage(`{"ok":true}`), + StoredAt: time.Now(), + TTL: time.Hour, + } + if c.Status != 200 || string(c.WireBody) != `{"ok":true}` { + t.Errorf("Cached fields not preserved: %+v", c) + } +} + +func TestCached_Expired(t *testing.T) { + c := Cached{StoredAt: time.Now().Add(-2 * time.Hour), TTL: time.Hour} + if !c.Expired(time.Now()) { + t.Error("expected Expired() to be true") + } + c2 := Cached{StoredAt: time.Now(), TTL: time.Hour} + if c2.Expired(time.Now()) { + t.Error("expected fresh entry to not be expired") + } +} From 2248f883b262992ccc2ab97a011dbb1b755f6dca Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 23:25:29 -0500 Subject: [PATCH 44/89] feat(dashboard/contract/idempotency): in-memory Store with TTL and LRU eviction --- .../contract/idempotency/inmemory.go | 102 ++++++++++++++++++ .../contract/idempotency/inmemory_test.go | 83 ++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 extensions/dashboard/contract/idempotency/inmemory.go create mode 100644 extensions/dashboard/contract/idempotency/inmemory_test.go diff --git a/extensions/dashboard/contract/idempotency/inmemory.go b/extensions/dashboard/contract/idempotency/inmemory.go new file mode 100644 index 00000000..64c96a7c --- /dev/null +++ b/extensions/dashboard/contract/idempotency/inmemory.go @@ -0,0 +1,102 @@ +package idempotency + +import ( + "container/list" + "context" + "sync" + "time" +) + +// DefaultMaxEntries is the default LRU cap for an in-memory store. +const DefaultMaxEntries = 10000 + +// Option configures an InMemoryStore. +type Option func(*InMemoryStore) + +// WithMaxEntries caps the number of cached entries; oldest are evicted first. +func WithMaxEntries(n int) Option { + return func(s *InMemoryStore) { + if n > 0 { + s.maxEntries = n + } + } +} + +// InMemoryStore is a process-local Store with TTL and LRU eviction. +// Safe for concurrent use. +type InMemoryStore struct { + mu sync.Mutex + maxEntries int + entries map[entryKey]*list.Element + order *list.List // front = MRU, back = LRU +} + +type entryKey struct { + Key string + Identity string +} + +type entry struct { + key entryKey + val Cached +} + +// NewInMemoryStore returns an in-memory Store with the given options. +func NewInMemoryStore(opts ...Option) *InMemoryStore { + s := &InMemoryStore{ + maxEntries: DefaultMaxEntries, + entries: map[entryKey]*list.Element{}, + order: list.New(), + } + for _, opt := range opts { + opt(s) + } + return s +} + +// Lookup implements Store. +func (s *InMemoryStore) Lookup(_ context.Context, key, identity string) (*Cached, bool) { + k := entryKey{key, identity} + s.mu.Lock() + defer s.mu.Unlock() + el, ok := s.entries[k] + if !ok { + return nil, false + } + e := el.Value.(*entry) + if e.val.Expired(time.Now()) { + s.order.Remove(el) + delete(s.entries, k) + return nil, false + } + s.order.MoveToFront(el) + c := e.val // copy + return &c, true +} + +// Store implements Store. Returns nil; signature reserves error for future +// backends (e.g., Redis). +func (s *InMemoryStore) Store(_ context.Context, key, identity string, c Cached) error { + k := entryKey{key, identity} + s.mu.Lock() + defer s.mu.Unlock() + if el, ok := s.entries[k]; ok { + e := el.Value.(*entry) + e.val = c + s.order.MoveToFront(el) + return nil + } + el := s.order.PushFront(&entry{key: k, val: c}) + s.entries[k] = el + for s.order.Len() > s.maxEntries { + oldest := s.order.Back() + if oldest != nil { + s.order.Remove(oldest) + delete(s.entries, oldest.Value.(*entry).key) + } + } + return nil +} + +// Compile-time assertion. +var _ Store = (*InMemoryStore)(nil) diff --git a/extensions/dashboard/contract/idempotency/inmemory_test.go b/extensions/dashboard/contract/idempotency/inmemory_test.go new file mode 100644 index 00000000..01aa359e --- /dev/null +++ b/extensions/dashboard/contract/idempotency/inmemory_test.go @@ -0,0 +1,83 @@ +package idempotency + +import ( + "context" + "encoding/json" + "sync" + "testing" + "time" +) + +func TestInMemory_LookupMissThenHit(t *testing.T) { + s := NewInMemoryStore() + if _, ok := s.Lookup(context.Background(), "k", "u"); ok { + t.Error("expected miss") + } + c := Cached{Status: 200, WireBody: json.RawMessage(`{"x":1}`), StoredAt: time.Now(), TTL: time.Hour} + if err := s.Store(context.Background(), "k", "u", c); err != nil { + t.Fatalf("store: %v", err) + } + got, ok := s.Lookup(context.Background(), "k", "u") + if !ok { + t.Fatal("expected hit") + } + if got.Status != 200 || string(got.WireBody) != `{"x":1}` { + t.Errorf("cached value lost: %+v", got) + } +} + +func TestInMemory_DifferentIdentityIsIndependent(t *testing.T) { + s := NewInMemoryStore() + c := Cached{WireBody: json.RawMessage(`null`), StoredAt: time.Now(), TTL: time.Hour} + _ = s.Store(context.Background(), "k", "alice", c) + if _, ok := s.Lookup(context.Background(), "k", "bob"); ok { + t.Error("bob should not see alice's cached entry") + } +} + +func TestInMemory_ExpiredEntryReturnsMiss(t *testing.T) { + s := NewInMemoryStore() + c := Cached{StoredAt: time.Now().Add(-2 * time.Hour), TTL: time.Hour, WireBody: json.RawMessage(`null`)} + _ = s.Store(context.Background(), "k", "u", c) + if _, ok := s.Lookup(context.Background(), "k", "u"); ok { + t.Error("expected expired entry to miss") + } +} + +func TestInMemory_LRUEvictionAtCapacity(t *testing.T) { + s := NewInMemoryStore(WithMaxEntries(2)) + now := time.Now() + _ = s.Store(context.Background(), "k1", "u", Cached{StoredAt: now, TTL: time.Hour, WireBody: json.RawMessage(`1`)}) + _ = s.Store(context.Background(), "k2", "u", Cached{StoredAt: now, TTL: time.Hour, WireBody: json.RawMessage(`2`)}) + _ = s.Store(context.Background(), "k3", "u", Cached{StoredAt: now, TTL: time.Hour, WireBody: json.RawMessage(`3`)}) + // k1 should be evicted (oldest, capacity=2). + if _, ok := s.Lookup(context.Background(), "k1", "u"); ok { + t.Error("k1 should have been evicted") + } + if _, ok := s.Lookup(context.Background(), "k2", "u"); !ok { + t.Error("k2 should still be present") + } + if _, ok := s.Lookup(context.Background(), "k3", "u"); !ok { + t.Error("k3 should still be present") + } +} + +func TestInMemory_ConcurrentReadWrite(t *testing.T) { + s := NewInMemoryStore() + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(2) + go func(i int) { + defer wg.Done() + key := "k" + s.Store(context.Background(), key, "u", Cached{StoredAt: time.Now(), TTL: time.Hour, WireBody: json.RawMessage(`null`)}) + _ = i + }(i) + go func(i int) { + defer wg.Done() + s.Lookup(context.Background(), "k", "u") + _ = i + }(i) + } + wg.Wait() +} From c5fa185640b83348d4351056e4b848db779840fc Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 23:27:24 -0500 Subject: [PATCH 45/89] feat(dashboard/contract): CSRF token issuance endpoint --- .../dashboard/contract/transport/csrf.go | 41 +++++++++++++++++ .../dashboard/contract/transport/csrf_test.go | 45 +++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 extensions/dashboard/contract/transport/csrf.go create mode 100644 extensions/dashboard/contract/transport/csrf_test.go diff --git a/extensions/dashboard/contract/transport/csrf.go b/extensions/dashboard/contract/transport/csrf.go new file mode 100644 index 00000000..ea5ab3a3 --- /dev/null +++ b/extensions/dashboard/contract/transport/csrf.go @@ -0,0 +1,41 @@ +package transport + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/xraph/forge/extensions/dashboard/security" +) + +// CSRFTokenResponse is the wire shape for GET /api/dashboard/v1/csrf. +type CSRFTokenResponse struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expiresAt"` +} + +// NewCSRFTokenHandler returns the GET /csrf handler. ttl is the token validity +// window the response surfaces to the client; the underlying manager is the +// authority on validation. +func NewCSRFTokenHandler(mgr *security.CSRFManager, ttl time.Duration) http.Handler { + return &csrfHandler{mgr: mgr, ttl: ttl} +} + +type csrfHandler struct { + mgr *security.CSRFManager + ttl time.Duration +} + +func (h *csrfHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "GET required", http.StatusMethodNotAllowed) + return + } + tok := h.mgr.GenerateToken() + resp := CSRFTokenResponse{ + Token: tok, + ExpiresAt: time.Now().Add(h.ttl), + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} diff --git a/extensions/dashboard/contract/transport/csrf_test.go b/extensions/dashboard/contract/transport/csrf_test.go new file mode 100644 index 00000000..086716a3 --- /dev/null +++ b/extensions/dashboard/contract/transport/csrf_test.go @@ -0,0 +1,45 @@ +package transport + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/xraph/forge/extensions/dashboard/security" +) + +func TestCSRFTokenHandler_ReturnsTokenAndExpiry(t *testing.T) { + mgr := security.NewCSRFManager() + h := NewCSRFTokenHandler(mgr, time.Hour) + req := httptest.NewRequest(http.MethodGet, "/api/dashboard/v1/csrf", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", w.Code, w.Body) + } + var resp CSRFTokenResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.Token == "" { + t.Error("token empty") + } + if !mgr.ValidateToken(resp.Token) { + t.Error("returned token does not validate against the manager") + } + if resp.ExpiresAt.Before(time.Now()) { + t.Errorf("ExpiresAt is in the past: %v", resp.ExpiresAt) + } +} + +func TestCSRFTokenHandler_RejectsNonGET(t *testing.T) { + h := NewCSRFTokenHandler(security.NewCSRFManager(), time.Hour) + req := httptest.NewRequest(http.MethodPost, "/api/dashboard/v1/csrf", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("expected 405, got %d", w.Code) + } +} From dbbbb56194d14bd0df835b2bfcb96d23662c7602 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 23:28:21 -0500 Subject: [PATCH 46/89] feat(dashboard/contract): CSRF token validation for command envelopes --- .../dashboard/contract/transport/http.go | 24 +++++++++-- .../dashboard/contract/transport/http_test.go | 40 +++++++++++++++++++ 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/extensions/dashboard/contract/transport/http.go b/extensions/dashboard/contract/transport/http.go index 736676f5..bde5ab5f 100644 --- a/extensions/dashboard/contract/transport/http.go +++ b/extensions/dashboard/contract/transport/http.go @@ -9,6 +9,7 @@ import ( dashauth "github.com/xraph/forge/extensions/dashboard/auth" "github.com/xraph/forge/extensions/dashboard/contract" + "github.com/xraph/forge/extensions/dashboard/security" ) // Dispatcher routes a fully-validated request to an intent implementation. @@ -41,11 +42,22 @@ func NewHandler(reg contract.Registry, wreg contract.WardenRegistry, disp Dispat return &handler{reg: reg, wreg: wreg, disp: disp, audit: audit} } +// NewHandlerWithCSRF is NewHandler plus a CSRFManager for command validation. +// When mgr is non-nil, command envelopes whose CSRF token does not validate +// return CodeUnauthenticated. Pass nil to skip CSRF (preserves the slice-(a) +// behaviour for tests and rollout opt-out). +func NewHandlerWithCSRF(reg contract.Registry, wreg contract.WardenRegistry, disp Dispatcher, audit contract.AuditEmitter, mgr *security.CSRFManager) http.Handler { + h := NewHandler(reg, wreg, disp, audit).(*handler) + h.csrfMgr = mgr + return h +} + type handler struct { - reg contract.Registry - wreg contract.WardenRegistry - disp Dispatcher - audit contract.AuditEmitter + reg contract.Registry + wreg contract.WardenRegistry + disp Dispatcher + audit contract.AuditEmitter + csrfMgr *security.CSRFManager // optional; nil disables CSRF validation } func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -84,6 +96,10 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, &contract.Error{Code: contract.CodeBadRequest, Message: "command requires csrf and idempotencyKey"}) return } + if h.csrfMgr != nil && !h.csrfMgr.ValidateToken(req.CSRF) { + writeError(w, http.StatusForbidden, &contract.Error{Code: contract.CodeUnauthenticated, Message: "csrf token invalid"}) + return + } } user := dashauth.UserFromContext(r.Context()) diff --git a/extensions/dashboard/contract/transport/http_test.go b/extensions/dashboard/contract/transport/http_test.go index be99c935..2c3714a1 100644 --- a/extensions/dashboard/contract/transport/http_test.go +++ b/extensions/dashboard/contract/transport/http_test.go @@ -11,6 +11,7 @@ import ( "testing" "github.com/xraph/forge/extensions/dashboard/contract" + "github.com/xraph/forge/extensions/dashboard/security" ) type stubDispatcher struct { @@ -114,3 +115,42 @@ func TestHandler_CommandRequiresIdempotencyKey(t *testing.T) { t.Fatalf("status = %d", w.Code) } } + +func TestHandler_CommandRejectsInvalidCSRF(t *testing.T) { + reg, wreg := setupRegistry(t) + mgr := security.NewCSRFManager() + h := NewHandlerWithCSRF(reg, wreg, &stubDispatcher{}, contract.NoopAuditEmitter{}, mgr) + + body, _ := json.Marshal(contract.Request{ + Envelope: "v1", Kind: contract.KindCommand, Contributor: "users", Intent: "user.disable", IntentVersion: 1, + CSRF: "not-a-real-token", IdempotencyKey: "ik_1", + }) + req := httptest.NewRequest(http.MethodPost, "/api/dashboard/v1", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d body=%s", w.Code, w.Body) + } + if !strings.Contains(w.Body.String(), "UNAUTHENTICATED") { + t.Errorf("expected UNAUTHENTICATED in body: %s", w.Body) + } +} + +func TestHandler_CommandAcceptsValidCSRF(t *testing.T) { + reg, wreg := setupRegistry(t) + mgr := security.NewCSRFManager() + tok := mgr.GenerateToken() + disp := &stubDispatcher{response: json.RawMessage(`{"ok":true}`)} + h := NewHandlerWithCSRF(reg, wreg, disp, contract.NoopAuditEmitter{}, mgr) + + body, _ := json.Marshal(contract.Request{ + Envelope: "v1", Kind: contract.KindCommand, Contributor: "users", Intent: "user.disable", IntentVersion: 1, + CSRF: tok, IdempotencyKey: "ik_1", + }) + req := httptest.NewRequest(http.MethodPost, "/api/dashboard/v1", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", w.Code, w.Body) + } +} From 8a7537605297554c358179ce8afb9918b0497c42 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 23:32:34 -0500 Subject: [PATCH 47/89] feat(dashboard/contract/dispatcher): Prometheus-backed MetricsEmitter --- .../contract/dispatcher/metrics_prometheus.go | 61 +++++++++++++++++++ .../dispatcher/metrics_prometheus_test.go | 38 ++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 extensions/dashboard/contract/dispatcher/metrics_prometheus.go create mode 100644 extensions/dashboard/contract/dispatcher/metrics_prometheus_test.go diff --git a/extensions/dashboard/contract/dispatcher/metrics_prometheus.go b/extensions/dashboard/contract/dispatcher/metrics_prometheus.go new file mode 100644 index 00000000..b11ea05c --- /dev/null +++ b/extensions/dashboard/contract/dispatcher/metrics_prometheus.go @@ -0,0 +1,61 @@ +package dispatcher + +import ( + "context" + "strconv" + "time" + + "github.com/xraph/forge" + "github.com/xraph/forge/extensions/dashboard/contract" +) + +const ( + dispatchTotalMetric = "forge_dashboard_dispatch_total" + dispatchDurationMetric = "forge_dashboard_dispatch_duration_seconds" +) + +// PrometheusMetricsEmitter records dispatch metrics into a forge.Metrics +// registry. Counters and histograms are created lazily on first emission +// (forge.Metrics.Counter / .Histogram are get-or-create). Pass nil to +// disable — the emitter then becomes a noop. +type PrometheusMetricsEmitter struct { + metrics forge.Metrics +} + +// NewPrometheusMetricsEmitter returns an emitter that writes to m. +// If m is nil, the emitter is a noop. +func NewPrometheusMetricsEmitter(m forge.Metrics) *PrometheusMetricsEmitter { + return &PrometheusMetricsEmitter{metrics: m} +} + +// RecordDispatch implements MetricsEmitter. +func (e *PrometheusMetricsEmitter) RecordDispatch(_ context.Context, contributor, intent string, version int, kind contract.Kind, latency time.Duration, errCode contract.ErrorCode) { + if e.metrics == nil { + return + } + + labels := map[string]string{ + "contributor": contributor, + "intent": intent, + "version": strconv.Itoa(version), + "kind": string(kind), + } + + if hist := e.metrics.Histogram(dispatchDurationMetric); hist != nil { + hist.WithLabels(labels).Observe(latency.Seconds()) + } + + counterLabels := make(map[string]string, len(labels)+1) + for k, v := range labels { + counterLabels[k] = v + } + // error_code is empty on success — Prometheus is fine with empty label values. + counterLabels["error_code"] = string(errCode) + + if cnt := e.metrics.Counter(dispatchTotalMetric); cnt != nil { + cnt.WithLabels(counterLabels).Inc() + } +} + +// Compile-time assertion. +var _ MetricsEmitter = (*PrometheusMetricsEmitter)(nil) diff --git a/extensions/dashboard/contract/dispatcher/metrics_prometheus_test.go b/extensions/dashboard/contract/dispatcher/metrics_prometheus_test.go new file mode 100644 index 00000000..3a1ac18a --- /dev/null +++ b/extensions/dashboard/contract/dispatcher/metrics_prometheus_test.go @@ -0,0 +1,38 @@ +package dispatcher + +import ( + "context" + "testing" + "time" + + "github.com/xraph/forge/extensions/dashboard/contract" + forgemetrics "github.com/xraph/forge/internal/metrics" +) + +func TestPrometheusMetricsEmitter_RecordsCounterAndHistogram(t *testing.T) { + // NewNoOpMetrics returns a forge.Metrics instance whose Counters/Histograms + // are real-typed but inert — perfect for asserting the emitter is callable + // and idempotent without depending on a Prometheus exporter. + m := forgemetrics.NewNoOpMetrics() + em := NewPrometheusMetricsEmitter(m) + + em.RecordDispatch(context.Background(), "users", "users.list", 1, contract.KindQuery, 12*time.Millisecond, "") + em.RecordDispatch(context.Background(), "users", "users.list", 1, contract.KindQuery, 8*time.Millisecond, contract.CodeNotFound) + + // We don't assert exact Prometheus output — the noop registry doesn't render. + // We assert the emitter is callable, doesn't panic, and idempotent on repeat. + em.RecordDispatch(context.Background(), "users", "users.list", 1, contract.KindQuery, 5*time.Millisecond, "") +} + +func TestPrometheusMetricsEmitter_LazyCollectorCreation(t *testing.T) { + m := forgemetrics.NewNoOpMetrics() + em := NewPrometheusMetricsEmitter(m) + // No collectors should exist yet — test by calling RecordDispatch and verifying no panic. + em.RecordDispatch(context.Background(), "x", "y", 1, contract.KindCommand, time.Millisecond, "") +} + +func TestPrometheusMetricsEmitter_NilMetricsIsNoop(t *testing.T) { + em := NewPrometheusMetricsEmitter(nil) + em.RecordDispatch(context.Background(), "x", "y", 1, contract.KindQuery, time.Millisecond, "") + // no panic, no assertion — the constructor handles nil by becoming a noop. +} From 5a7074a5f7deea70b4e9be8fcd506af2e47b0545 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 23:33:32 -0500 Subject: [PATCH 48/89] feat(dashboard/contract/dispatcher): structured-logger AuditEmitter --- .../contract/dispatcher/audit_logger.go | 44 ++++++++ .../contract/dispatcher/audit_logger_test.go | 104 ++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 extensions/dashboard/contract/dispatcher/audit_logger.go create mode 100644 extensions/dashboard/contract/dispatcher/audit_logger_test.go diff --git a/extensions/dashboard/contract/dispatcher/audit_logger.go b/extensions/dashboard/contract/dispatcher/audit_logger.go new file mode 100644 index 00000000..c9a31d12 --- /dev/null +++ b/extensions/dashboard/contract/dispatcher/audit_logger.go @@ -0,0 +1,44 @@ +package dispatcher + +import ( + "context" + + "github.com/xraph/forge" + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// LoggerAuditEmitter writes audit records as info-level structured logs +// via a forge.Logger. Each AuditRecord field is emitted as a discrete log +// field so log aggregators can filter by `audit=true` cheaply. Pass a nil +// logger to disable — the emitter then becomes a noop. +type LoggerAuditEmitter struct { + logger forge.Logger +} + +// NewLoggerAuditEmitter returns an emitter that writes via logger. Pass nil +// to disable (the emitter becomes a noop). +func NewLoggerAuditEmitter(logger forge.Logger) *LoggerAuditEmitter { + return &LoggerAuditEmitter{logger: logger} +} + +// Emit implements contract.AuditEmitter. +func (e *LoggerAuditEmitter) Emit(_ context.Context, rec contract.AuditRecord) { + if e.logger == nil { + return + } + e.logger.Info("dashboard contract audit", + forge.Bool("audit", true), + forge.String("contributor", rec.Contributor), + forge.String("intent", rec.Intent), + forge.Int("version", rec.IntentVersion), + forge.String("subject", rec.Subject), + forge.String("user", rec.User), + forge.String("result", rec.Result), + forge.Int64("latency_ms", rec.LatencyMs), + forge.String("correlation_id", rec.CorrelationID), + forge.Time("time", rec.Time), + ) +} + +// Compile-time assertion. +var _ contract.AuditEmitter = (*LoggerAuditEmitter)(nil) diff --git a/extensions/dashboard/contract/dispatcher/audit_logger_test.go b/extensions/dashboard/contract/dispatcher/audit_logger_test.go new file mode 100644 index 00000000..f794391e --- /dev/null +++ b/extensions/dashboard/contract/dispatcher/audit_logger_test.go @@ -0,0 +1,104 @@ +package dispatcher + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "sync" + "testing" + "time" + + "github.com/xraph/forge" + "github.com/xraph/forge/extensions/dashboard/contract" +) + +// jsonBufferLogger is a minimal forge.Logger that JSON-encodes every Info call +// (message + fields) to a buffer. We use a custom logger instead of +// forge.NewLogger because the production LoggingConfig does not accept an +// io.Writer destination — it routes to stdout/stderr only. The behavior contract +// from SLICE_B_PLAN Phase 5 is "a logger that writes JSON to a buffer; assert +// the buffer contains expected JSON-encoded fields", which this satisfies. +type jsonBufferLogger struct { + mu sync.Mutex + buf *bytes.Buffer +} + +func newJSONBufferLogger(buf *bytes.Buffer) *jsonBufferLogger { + return &jsonBufferLogger{buf: buf} +} + +func (l *jsonBufferLogger) writeFields(level, msg string, fields []forge.Field) { + l.mu.Lock() + defer l.mu.Unlock() + row := map[string]any{ + "level": level, + "msg": msg, + } + for _, f := range fields { + row[f.Key()] = f.Value() + } + enc := json.NewEncoder(l.buf) + _ = enc.Encode(row) +} + +func (l *jsonBufferLogger) Debug(msg string, fields ...forge.Field) { l.writeFields("debug", msg, fields) } +func (l *jsonBufferLogger) Info(msg string, fields ...forge.Field) { l.writeFields("info", msg, fields) } +func (l *jsonBufferLogger) Warn(msg string, fields ...forge.Field) { l.writeFields("warn", msg, fields) } +func (l *jsonBufferLogger) Error(msg string, fields ...forge.Field) { l.writeFields("error", msg, fields) } +func (l *jsonBufferLogger) Fatal(msg string, fields ...forge.Field) { l.writeFields("fatal", msg, fields) } + +func (l *jsonBufferLogger) Debugf(string, ...any) {} +func (l *jsonBufferLogger) Infof(string, ...any) {} +func (l *jsonBufferLogger) Warnf(string, ...any) {} +func (l *jsonBufferLogger) Errorf(string, ...any) {} +func (l *jsonBufferLogger) Fatalf(string, ...any) {} + +func (l *jsonBufferLogger) With(_ ...forge.Field) forge.Logger { return l } +func (l *jsonBufferLogger) WithContext(_ context.Context) forge.Logger { + return l +} +func (l *jsonBufferLogger) Named(_ string) forge.Logger { return l } +func (l *jsonBufferLogger) Sugar() forge.SugarLogger { return nil } +func (l *jsonBufferLogger) Sync() error { return nil } + +func TestLoggerAuditEmitter_EmitsStructuredFields(t *testing.T) { + var buf bytes.Buffer + logger := newJSONBufferLogger(&buf) + em := NewLoggerAuditEmitter(logger) + + em.Emit(context.Background(), contract.AuditRecord{ + Time: time.Now(), + Contributor: "users", + Intent: "user.disable", + IntentVersion: 2, + Subject: "u_42", + User: "admin@example.com", + Result: "ok", + LatencyMs: 12, + CorrelationID: "req_x", + }) + + out := buf.String() + for _, want := range []string{ + `"audit":true`, + `"contributor":"users"`, + `"intent":"user.disable"`, + `"version":2`, + `"subject":"u_42"`, + `"user":"admin@example.com"`, + `"result":"ok"`, + `"latency_ms":12`, + `"correlation_id":"req_x"`, + } { + if !strings.Contains(out, want) { + t.Errorf("audit log missing %q in output: %s", want, out) + } + } +} + +func TestLoggerAuditEmitter_NilLoggerIsNoop(t *testing.T) { + em := NewLoggerAuditEmitter(nil) + // Must not panic. + em.Emit(context.Background(), contract.AuditRecord{}) +} From cf625693531455357150df13d287e9c639ac93cd Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 23:37:56 -0500 Subject: [PATCH 49/89] feat(dashboard/contract/dispatcher): optional OTel tracer with span lifecycle --- .../contract/dispatcher/dispatcher.go | 148 ++++++++++++++++-- .../contract/dispatcher/tracing_test.go | 78 +++++++++ 2 files changed, 217 insertions(+), 9 deletions(-) create mode 100644 extensions/dashboard/contract/dispatcher/tracing_test.go diff --git a/extensions/dashboard/contract/dispatcher/dispatcher.go b/extensions/dashboard/contract/dispatcher/dispatcher.go index 70848f5c..98fef9b0 100644 --- a/extensions/dashboard/contract/dispatcher/dispatcher.go +++ b/extensions/dashboard/contract/dispatcher/dispatcher.go @@ -9,6 +9,10 @@ import ( "sync" "time" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + "github.com/xraph/forge/extensions/dashboard/contract" "github.com/xraph/forge/extensions/dashboard/contract/transport" ) @@ -20,6 +24,8 @@ import ( // and canonical error mapping. type Dispatcher struct { metrics MetricsEmitter + tracer trace.Tracer // optional; nil = no tracing + store IdempotencyStore // optional; nil = no command dedup mu sync.RWMutex handlers map[handlerKey]Handler @@ -32,17 +38,59 @@ type handlerKey struct { Version int } +// Option configures a Dispatcher. +type Option func(*Dispatcher) + +// WithTracer configures the dispatcher to open a span per Dispatch call. +// Passing a nil tracer is equivalent to not supplying the option at all. +func WithTracer(t trace.Tracer) Option { + return func(d *Dispatcher) { d.tracer = t } +} + +// WithIdempotencyStore wires command dedup. When set, commands carrying a +// non-empty IdempotencyKey are deduped per-user via the store. +func WithIdempotencyStore(s IdempotencyStore) Option { + return func(d *Dispatcher) { d.store = s } +} + +// IdempotencyStore is the minimal surface the dispatcher needs from +// extensions/dashboard/contract/idempotency. Defining it here avoids an +// import cycle (the idempotency package is consumed only via this interface). +type IdempotencyStore interface { + Lookup(ctx context.Context, key, identity string) (*IdempotencyCached, bool) + Store(ctx context.Context, key, identity string, c IdempotencyCached) error +} + +// IdempotencyCached mirrors idempotency.Cached; defined here for the same +// import-cycle reason. Adapters in the wire-up convert between the two. +type IdempotencyCached struct { + Status int + WireBody json.RawMessage + StoredAt time.Time + TTL time.Duration +} + // New returns a fresh dispatcher. Pass NoopMetricsEmitter{} for tests / dev; // slice (b) provides a Prometheus-backed implementation. func New(metrics MetricsEmitter) *Dispatcher { + return NewWithOptions(metrics) +} + +// NewWithOptions returns a dispatcher configured with the supplied options. +// The existing New(metrics) constructor is preserved as a thin wrapper. +func NewWithOptions(metrics MetricsEmitter, opts ...Option) *Dispatcher { if metrics == nil { metrics = NoopMetricsEmitter{} } - return &Dispatcher{ + d := &Dispatcher{ metrics: metrics, handlers: map[handlerKey]Handler{}, subscriptions: map[handlerKey]SubscriptionHandler{}, } + for _, opt := range opts { + opt(d) + } + return d } // Register binds a query/command handler to a (contributor, intent, version) @@ -61,8 +109,56 @@ func (d *Dispatcher) Register(contributor, intent string, version int, h Handler return nil } -// Dispatch implements transport.Dispatcher. +// Dispatch implements transport.Dispatcher. When a tracer is configured, a +// span wraps the dispatch with attributes capturing (contributor, intent, +// version, kind) and a status reflecting the outcome. func (d *Dispatcher) Dispatch(ctx context.Context, req contract.Request, p contract.Principal) (json.RawMessage, contract.ResponseMeta, error) { + if d.tracer != nil { + var span trace.Span + spanName := fmt.Sprintf("dispatch:%s/%s@%d", req.Contributor, req.Intent, req.IntentVersion) + ctx, span = d.tracer.Start(ctx, spanName, + trace.WithAttributes( + attribute.String("forge.contract.contributor", req.Contributor), + attribute.String("forge.contract.intent", req.Intent), + attribute.Int("forge.contract.version", req.IntentVersion), + attribute.String("forge.contract.kind", string(req.Kind)), + ), + ) + defer span.End() + data, meta, err := d.dispatchInner(ctx, req, p) + if err != nil { + var ce *contract.Error + if errors.As(err, &ce) { + span.SetAttributes(attribute.String("forge.contract.error_code", string(ce.Code))) + span.SetStatus(codes.Error, string(ce.Code)) + } else { + span.SetStatus(codes.Error, err.Error()) + } + } else { + span.SetStatus(codes.Ok, "") + } + return data, meta, err + } + return d.dispatchInner(ctx, req, p) +} + +// dispatchInner performs the handler lookup + invocation + metrics + error +// mapping, plus optional idempotency dedup for commands. Dispatch is a thin +// wrapper that adds optional span instrumentation. +func (d *Dispatcher) dispatchInner(ctx context.Context, req contract.Request, p contract.Principal) (json.RawMessage, contract.ResponseMeta, error) { + // Idempotency wrap (commands only, requires store + key). + if req.Kind == contract.KindCommand && d.store != nil && req.IdempotencyKey != "" { + identity := principalIdentity(p, req.Intent) + if cached, ok := d.store.Lookup(ctx, req.IdempotencyKey, identity); ok { + // Decode the cached envelope back into (data, meta). + var resp contract.Response + if err := json.Unmarshal(cached.WireBody, &resp); err == nil && resp.OK { + return resp.Data, resp.Meta, nil + } + // Cached but undecodable; fall through to fresh dispatch. + } + } + k := handlerKey{req.Contributor, req.Intent, req.IntentVersion} d.mu.RLock() h, ok := d.handlers[k] @@ -90,18 +186,52 @@ func (d *Dispatcher) Dispatch(ctx context.Context, req contract.Request, p contr if wireErr != nil { return nil, contract.ResponseMeta{}, wireErr } + + var ( + data json.RawMessage + meta contract.ResponseMeta + ) if res == nil { // Allow nil result to mean {data: null} explicitly. - return nil, contract.ResponseMeta{IntentVersion: req.IntentVersion}, nil + meta = contract.ResponseMeta{IntentVersion: req.IntentVersion} + } else { + meta = contract.ResponseMeta{IntentVersion: req.IntentVersion} + if len(res.ExtraInvalidates) > 0 { + meta.Invalidates = append(meta.Invalidates, res.ExtraInvalidates...) + } + if res.CacheOverride != nil { + meta.CacheControl = res.CacheOverride + } + data = res.Data } - meta := contract.ResponseMeta{IntentVersion: req.IntentVersion} - if len(res.ExtraInvalidates) > 0 { - meta.Invalidates = append(meta.Invalidates, res.ExtraInvalidates...) + + // Capture for next time on successful command dispatch. + if req.Kind == contract.KindCommand && d.store != nil && req.IdempotencyKey != "" { + identity := principalIdentity(p, req.Intent) + successResp := contract.Response{OK: true, Envelope: req.Envelope, Kind: req.Kind, Data: data, Meta: meta} + body, _ := json.Marshal(successResp) + // TTL: 24h hardcoded. Phase 6 will surface this via Extension config. + _ = d.store.Store(ctx, req.IdempotencyKey, identity, IdempotencyCached{ + Status: 200, + WireBody: body, + StoredAt: time.Now(), + TTL: 24 * time.Hour, + }) } - if res.CacheOverride != nil { - meta.CacheControl = res.CacheOverride + + return data, meta, nil +} + +// principalIdentity is the per-user dedup key suffix. Empty user is allowed +// (anonymous principals dedup against the empty subject). The intent is +// folded in so the same idempotency key for two different intents does not +// collide. +func principalIdentity(p contract.Principal, intent string) string { + user := "" + if p.User != nil { + user = p.User.Subject } - return res.Data, meta, nil + return user + ":" + intent } // mapDispatchError converts a handler error into the canonical wire error diff --git a/extensions/dashboard/contract/dispatcher/tracing_test.go b/extensions/dashboard/contract/dispatcher/tracing_test.go new file mode 100644 index 00000000..a1718b0b --- /dev/null +++ b/extensions/dashboard/contract/dispatcher/tracing_test.go @@ -0,0 +1,78 @@ +package dispatcher + +import ( + "context" + "encoding/json" + "testing" + + "go.opentelemetry.io/otel" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + + "github.com/xraph/forge/extensions/dashboard/contract" +) + +func TestDispatcher_OpensSpanPerDispatch(t *testing.T) { + exporter := tracetest.NewInMemoryExporter() + tp := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exporter)) + defer func() { _ = tp.Shutdown(context.Background()) }() + otel.SetTracerProvider(tp) + tracer := tp.Tracer("test") + + d := NewWithOptions(NoopMetricsEmitter{}, WithTracer(tracer)) + _ = d.Register("c", "i", 1, func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { + return &Result{Data: json.RawMessage(`null`)}, nil + }) + req := contract.Request{Envelope: "v1", Kind: contract.KindQuery, Contributor: "c", Intent: "i", IntentVersion: 1} + _, _, _ = d.Dispatch(context.Background(), req, contract.Principal{}) + + spans := exporter.GetSpans() + if len(spans) != 1 { + t.Fatalf("expected 1 span, got %d", len(spans)) + } + s := spans[0] + if s.Name != "dispatch:c/i@1" { + t.Errorf("span name = %q", s.Name) + } +} + +func TestDispatcher_SpanRecordsErrorCode(t *testing.T) { + exporter := tracetest.NewInMemoryExporter() + tp := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exporter)) + defer func() { _ = tp.Shutdown(context.Background()) }() + tracer := tp.Tracer("test") + + d := NewWithOptions(NoopMetricsEmitter{}, WithTracer(tracer)) + _ = d.Register("c", "i", 1, func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { + return nil, &contract.Error{Code: contract.CodeConflict} + }) + req := contract.Request{Envelope: "v1", Kind: contract.KindCommand, Contributor: "c", Intent: "i", IntentVersion: 1} + _, _, _ = d.Dispatch(context.Background(), req, contract.Principal{}) + + spans := exporter.GetSpans() + if len(spans) != 1 { + t.Fatalf("expected 1 span") + } + found := false + for _, attr := range spans[0].Attributes { + if string(attr.Key) == "forge.contract.error_code" && attr.Value.AsString() == "CONFLICT" { + found = true + break + } + } + if !found { + t.Errorf("expected error_code attribute, got attrs=%+v", spans[0].Attributes) + } +} + +func TestDispatcher_NilTracerIsNoop(t *testing.T) { + d := NewWithOptions(NoopMetricsEmitter{}) // no tracer option + _ = d.Register("c", "i", 1, func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { + return &Result{Data: json.RawMessage(`null`)}, nil + }) + req := contract.Request{Envelope: "v1", Kind: contract.KindQuery, Contributor: "c", Intent: "i", IntentVersion: 1} + _, _, err := d.Dispatch(context.Background(), req, contract.Principal{}) + if err != nil { + t.Errorf("nil tracer should not affect dispatch: %v", err) + } +} From ede482f34496391c531f583298157e69ced926e5 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 23:38:53 -0500 Subject: [PATCH 50/89] feat(dashboard/contract/dispatcher): idempotency-key dedup for command dispatches --- .../contract/dispatcher/dispatcher_test.go | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/extensions/dashboard/contract/dispatcher/dispatcher_test.go b/extensions/dashboard/contract/dispatcher/dispatcher_test.go index 37e0a4cf..09658f13 100644 --- a/extensions/dashboard/contract/dispatcher/dispatcher_test.go +++ b/extensions/dashboard/contract/dispatcher/dispatcher_test.go @@ -4,9 +4,12 @@ import ( "context" "encoding/json" "errors" + "sync/atomic" "testing" + "time" "github.com/xraph/forge/extensions/dashboard/contract" + dashauth "github.com/xraph/forge/extensions/dashboard/auth" ) func TestDispatcher_RegisterAndDispatch(t *testing.T) { @@ -89,3 +92,114 @@ func TestDispatcher_ContextCanceledMappedToUnavailable(t *testing.T) { t.Error("canceled errors should be retryable") } } + +// stubStore is a test-only IdempotencyStore that records hit/miss/store +// counters and keys cached entries by `key|identity`. +type stubStore struct { + hits map[string]IdempotencyCached + puts int64 + gets int64 +} + +func newStubStore() *stubStore { return &stubStore{hits: map[string]IdempotencyCached{}} } + +func (s *stubStore) Lookup(_ context.Context, key, identity string) (*IdempotencyCached, bool) { + atomic.AddInt64(&s.gets, 1) + c, ok := s.hits[key+"|"+identity] + if !ok { + return nil, false + } + cc := c + return &cc, true +} + +func (s *stubStore) Store(_ context.Context, key, identity string, c IdempotencyCached) error { + atomic.AddInt64(&s.puts, 1) + s.hits[key+"|"+identity] = c + return nil +} + +func TestDispatcher_IdempotencyHitReturnsCached(t *testing.T) { + store := newStubStore() + store.hits["k|alice:i"] = IdempotencyCached{ + Status: 200, + WireBody: json.RawMessage(`{"ok":true,"envelope":"v1","kind":"command","data":{"cached":true},"meta":{}}`), + StoredAt: time.Now(), + TTL: time.Hour, + } + d := NewWithOptions(NoopMetricsEmitter{}, WithIdempotencyStore(store)) + called := int64(0) + _ = d.Register("c", "i", 1, func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { + atomic.AddInt64(&called, 1) + return &Result{Data: json.RawMessage(`{"fresh":true}`)}, nil + }) + req := contract.Request{ + Envelope: "v1", Kind: contract.KindCommand, + Contributor: "c", Intent: "i", IntentVersion: 1, + IdempotencyKey: "k", + } + p := contract.PrincipalFor(&dashauth.UserInfo{Subject: "alice"}) + data, _, err := d.Dispatch(context.Background(), req, p) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if string(data) != `{"cached":true}` { + t.Errorf("expected cached body, got %s", data) + } + if atomic.LoadInt64(&called) != 0 { + t.Errorf("handler should not have been called on cache hit") + } +} + +func TestDispatcher_IdempotencyMissCallsHandlerAndStores(t *testing.T) { + store := newStubStore() + d := NewWithOptions(NoopMetricsEmitter{}, WithIdempotencyStore(store)) + _ = d.Register("c", "i", 1, func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { + return &Result{Data: json.RawMessage(`{"fresh":true}`)}, nil + }) + req := contract.Request{ + Envelope: "v1", Kind: contract.KindCommand, + Contributor: "c", Intent: "i", IntentVersion: 1, + IdempotencyKey: "k", + } + p := contract.PrincipalFor(&dashauth.UserInfo{Subject: "alice"}) + _, _, _ = d.Dispatch(context.Background(), req, p) + if atomic.LoadInt64(&store.puts) != 1 { + t.Errorf("expected 1 store write, got %d", store.puts) + } +} + +func TestDispatcher_IdempotencyOnlyAppliesToCommands(t *testing.T) { + store := newStubStore() + d := NewWithOptions(NoopMetricsEmitter{}, WithIdempotencyStore(store)) + _ = d.Register("c", "i", 1, func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { + return &Result{Data: json.RawMessage(`null`)}, nil + }) + req := contract.Request{ + Envelope: "v1", Kind: contract.KindQuery, + Contributor: "c", Intent: "i", IntentVersion: 1, + IdempotencyKey: "k", + } + _, _, _ = d.Dispatch(context.Background(), req, contract.Principal{}) + if atomic.LoadInt64(&store.gets) != 0 { + t.Errorf("query should not consult store, gets=%d", store.gets) + } +} + +func TestDispatcher_IdempotencyMissingKeyBypassesStore(t *testing.T) { + store := newStubStore() + d := NewWithOptions(NoopMetricsEmitter{}, WithIdempotencyStore(store)) + _ = d.Register("c", "i", 1, func(_ context.Context, _ json.RawMessage, _ map[string]any, _ contract.Principal) (*Result, error) { + return &Result{Data: json.RawMessage(`null`)}, nil + }) + req := contract.Request{ + Envelope: "v1", Kind: contract.KindCommand, + Contributor: "c", Intent: "i", IntentVersion: 1, + // IdempotencyKey intentionally empty — slice (a)'s presence check is + // the gate; when missing, the dispatcher bypasses dedup entirely. + } + _, _, _ = d.Dispatch(context.Background(), req, contract.Principal{}) + if atomic.LoadInt64(&store.gets) != 0 { + t.Errorf("missing key should bypass store, gets=%d", store.gets) + } +} From 635132b31ccc4e170140952c77e8cbaf95f144a9 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 23:45:49 -0500 Subject: [PATCH 51/89] feat(dashboard): wire CSRF, idempotency, Prometheus metrics, OTel tracing, structured audit Replace the safe defaults wired in NewExtension with production-grade contract plumbing in Register(): Prometheus metrics emission, OTel tracing per dispatch, in-memory idempotency dedup for commands, and a structured forge.Logger-backed AuditEmitter. Gated by a new EnableContractSecurity flag (default true) so deployments mid-rollout can opt out without losing the rest of the contract path. - Adds Config.EnableContractSecurity and matching WithContractSecurity option. - handleContractPOST now uses transport.NewHandlerWithCSRF, passing csrfMgr only when the flag is on so commands fail-closed with UNAUTHENTICATED on bad tokens. - Registers GET /api/dashboard/v1/csrf alongside the contract envelope endpoint so the shell can fetch a 12h-validity token; route is guarded on csrfMgr + EnableContractSecurity. - Adds idempotencyAdapter to bridge idempotency.Store -> dispatcher.IdempotencyStore. The two interfaces are kept separate to avoid an import cycle (dispatcher defines its own IdempotencyCached); the conversion is lossless. - Rebuilds streamBroker in Register so SSE subscriptions resolve against the upgraded dispatcher rather than the noop one created in NewExtension. All dashboard tests still green (legacy + contract subtree). --- extensions/dashboard/config.go | 18 +++++- extensions/dashboard/extension.go | 92 ++++++++++++++++++++++++++++++- 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/extensions/dashboard/config.go b/extensions/dashboard/config.go index abd3ba2e..21a15c79 100644 --- a/extensions/dashboard/config.go +++ b/extensions/dashboard/config.go @@ -53,6 +53,11 @@ type Config struct { // Security EnableCSP bool `json:"enable_csp" yaml:"enable_csp"` EnableCSRF bool `json:"enable_csrf" yaml:"enable_csrf"` + // EnableContractSecurity gates CSRF validation, idempotency dedup, and + // distributed tracing on the contract envelope endpoint. Default true; + // set to false during a rollout window where clients have not yet + // adopted CSRF tokens or the idempotency-key contract. + EnableContractSecurity bool `json:"enable_contract_security" yaml:"enable_contract_security"` // Authentication EnableAuth bool `json:"enable_auth" yaml:"enable_auth"` // enable auth support @@ -101,8 +106,9 @@ func DefaultConfig() Config { SSEKeepAlive: 15 * time.Second, - EnableCSP: true, - EnableCSRF: true, + EnableCSP: true, + EnableCSRF: true, + EnableContractSecurity: true, EnableAuth: false, LoginPath: "/login", @@ -255,6 +261,14 @@ func WithCSRF(enabled bool) ConfigOption { return func(c *Config) { c.EnableCSRF = enabled } } +// WithContractSecurity enables or disables the contract envelope's +// security stack (CSRF validation, idempotency dedup, request tracing). +// Defaults to true; switching off should be reserved for rollout windows +// where clients have not yet adopted CSRF tokens or idempotency keys. +func WithContractSecurity(enabled bool) ConfigOption { + return func(c *Config) { c.EnableContractSecurity = enabled } +} + // WithTheme sets the UI theme (light, dark, auto). func WithTheme(theme string) ConfigOption { return func(c *Config) { c.Theme = theme } diff --git a/extensions/dashboard/extension.go b/extensions/dashboard/extension.go index d0d29db8..aaf87df8 100644 --- a/extensions/dashboard/extension.go +++ b/extensions/dashboard/extension.go @@ -13,6 +13,7 @@ import ( "github.com/a-h/templ" "github.com/xraph/forge" "github.com/xraph/vessel" + "go.opentelemetry.io/otel" internalmetrics "github.com/xraph/forge/internal/metrics" @@ -21,6 +22,7 @@ import ( "github.com/xraph/forge/extensions/dashboard/collector" "github.com/xraph/forge/extensions/dashboard/contract" "github.com/xraph/forge/extensions/dashboard/contract/dispatcher" + "github.com/xraph/forge/extensions/dashboard/contract/idempotency" "github.com/xraph/forge/extensions/dashboard/contract/loader" "github.com/xraph/forge/extensions/dashboard/contract/pilot" "github.com/xraph/forge/extensions/dashboard/contract/transport" @@ -273,6 +275,37 @@ func (e *Extension) Register(app forge.App) error { e.searcher.RebuildIndex() } + // Slice (b) Phase 6: replace the safe defaults wired in NewExtension with + // production-grade contract plumbing — Prometheus metrics emission, OTel + // tracing, idempotency dedup, and structured audit logging. The swap is + // gated by EnableContractSecurity so deployments mid-rollout (clients not + // yet sending CSRF tokens / idempotency keys) can opt out without losing + // the rest of the contract path. Must run before pilot.Register so the + // pilot binds against the upgraded dispatcher. + var metricsEmitter dispatcher.MetricsEmitter = dispatcher.NoopMetricsEmitter{} + if app != nil && app.Metrics() != nil { + metricsEmitter = dispatcher.NewPrometheusMetricsEmitter(app.Metrics()) + } + var dispOpts []dispatcher.Option + if e.config.EnableContractSecurity { + dispOpts = append(dispOpts, + dispatcher.WithTracer(otel.Tracer("forge.dashboard.contract")), + dispatcher.WithIdempotencyStore(adaptIdempotencyStore(idempotency.NewInMemoryStore())), + ) + } + e.dispatcher = dispatcher.NewWithOptions(metricsEmitter, dispOpts...) + + var auditEmitter contract.AuditEmitter = contract.NewLogAuditEmitter(os.Stdout) + if app != nil && app.Logger() != nil { + auditEmitter = dispatcher.NewLoggerAuditEmitter(app.Logger()) + } + e.auditEmitter = auditEmitter + + // The streamBroker captured the old dispatcher at NewExtension time; + // rebind it to the upgraded dispatcher so SSE subscriptions resolve + // against the same handler registry the POST endpoint uses. + e.streamBroker = transport.NewStreamBroker(e.contractRegistry, e.wardenRegistry, e.dispatcher) + // Register the contract-track pilot contributor (core-contract). This // loads the embedded manifest, validates it against the warden registry, // and binds the four pilot handlers (extensions.list / services.list / @@ -1334,6 +1367,13 @@ func (e *Extension) registerRoutes() { must(router.GET(base+"/api/dashboard/v1/stream", http.HandlerFunc(e.streamBroker.ServeStream))) must(router.POST(base+"/api/dashboard/v1/stream/control", http.HandlerFunc(e.streamBroker.ServeControl))) } + // Slice (b) Phase 6: surface CSRF tokens to the shell only when the + // security stack is wired (csrfMgr is non-nil iff EnableCSRF is true, + // and EnableContractSecurity gates the contract path's enforcement). + if e.csrfMgr != nil && e.config.EnableContractSecurity { + must(router.GET(base+"/api/dashboard/v1/csrf", + transport.NewCSRFTokenHandler(e.csrfMgr, 12*time.Hour).ServeHTTP)) + } } // 4. Export endpoints (stay on forge.Router) @@ -1419,8 +1459,17 @@ func (e *Extension) registerRoutes() { // replaces slice (a)'s safe NilDispatcher with the real dispatcher wired in // NewExtension; intent handlers are bound by pilot.Register during // Extension.Register so requests resolve to live data instead of CodeUnavailable. +// +// Slice (b) Phase 6 routes CSRF validation through the handler when the +// extension's CSRF manager is configured AND EnableContractSecurity is on. +// Passing nil to NewHandlerWithCSRF preserves the slice-(a) behaviour — useful +// during a rollout window where clients have not yet adopted CSRF tokens. func (e *Extension) handleContractPOST() http.HandlerFunc { - h := transport.NewHandler(e.contractRegistry, e.wardenRegistry, e.dispatcher, e.auditEmitter) + var mgr *security.CSRFManager + if e.config.EnableContractSecurity && e.csrfMgr != nil { + mgr = e.csrfMgr + } + h := transport.NewHandlerWithCSRF(e.contractRegistry, e.wardenRegistry, e.dispatcher, e.auditEmitter, mgr) return h.ServeHTTP } @@ -1558,3 +1607,44 @@ func (e *Extension) registerAuthPages() { forge.F("count", len(pages)), ) } + +// idempotencyAdapter bridges idempotency.Store (the production interface) to +// dispatcher.IdempotencyStore (the dispatcher-private surface). The two types +// are intentionally separate: the dispatcher defines its own minimal +// IdempotencyStore + IdempotencyCached pair to avoid an import cycle with +// the contract/idempotency sub-package, which itself imports nothing from +// dispatcher. The conversion is lossless. +type idempotencyAdapter struct{ inner idempotency.Store } + +// adaptIdempotencyStore returns a dispatcher.IdempotencyStore backed by an +// idempotency.Store. Used at NewExtension/Register time to wire the in-memory +// store into the dispatcher. +func adaptIdempotencyStore(s idempotency.Store) dispatcher.IdempotencyStore { + return &idempotencyAdapter{inner: s} +} + +// Lookup forwards to the underlying store, converting the cached envelope +// shape between the two types. +func (a *idempotencyAdapter) Lookup(ctx context.Context, key, identity string) (*dispatcher.IdempotencyCached, bool) { + c, ok := a.inner.Lookup(ctx, key, identity) + if !ok { + return nil, false + } + return &dispatcher.IdempotencyCached{ + Status: c.Status, + WireBody: c.WireBody, + StoredAt: c.StoredAt, + TTL: c.TTL, + }, true +} + +// Store forwards to the underlying store, converting the cached envelope +// shape between the two types. +func (a *idempotencyAdapter) Store(ctx context.Context, key, identity string, c dispatcher.IdempotencyCached) error { + return a.inner.Store(ctx, key, identity, idempotency.Cached{ + Status: c.Status, + WireBody: c.WireBody, + StoredAt: c.StoredAt, + TTL: c.TTL, + }) +} From e15f6c022d398bac9fe65855a254ebcfdc098609 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sat, 9 May 2026 23:47:10 -0500 Subject: [PATCH 52/89] test(dashboard/contract): integration test for CSRF + idempotency end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives transport.NewHandlerWithCSRF + dispatcher.NewWithOptions through the same wiring extension.go uses in production. Two cases: - TestSecurityE2E_CSRFRequired: a command envelope carrying a CSRF token the manager rejects must return 403 + UNAUTHENTICATED, never reaching the dispatcher. - TestSecurityE2E_IdempotencyDedup: two identical command envelopes produce byte-equal response bodies — the second is served verbatim from the idempotency cache. External package (contract_test) avoids the dashboard extension import cycle. The local idempotency.Store -> dispatcher.IdempotencyStore adapter duplicates the production one in extension.go on purpose: the dispatcher deliberately keeps its IdempotencyCached private to break a cycle, so an inline test adapter is cheaper than exporting a public constructor. --- .../contract/contract_security_e2e_test.go | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 extensions/dashboard/contract/contract_security_e2e_test.go diff --git a/extensions/dashboard/contract/contract_security_e2e_test.go b/extensions/dashboard/contract/contract_security_e2e_test.go new file mode 100644 index 00000000..97883220 --- /dev/null +++ b/extensions/dashboard/contract/contract_security_e2e_test.go @@ -0,0 +1,141 @@ +// Package contract_test verifies the slice (b) security stack — CSRF +// validation and idempotency dedup — at the seam where transport.Handler +// meets dispatcher.Dispatcher. Both tests use the production +// transport.NewHandlerWithCSRF + dispatcher.NewWithOptions constructors, +// so a regression in either layer surfaces here. +// +// External package (contract_test) deliberately avoids the dashboard +// extension's import cycle: this test depends on contract, dispatcher, +// idempotency, transport, and security — all of which the extension also +// imports — but never the other way round. +package contract_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/xraph/forge/extensions/dashboard/contract" + "github.com/xraph/forge/extensions/dashboard/contract/dispatcher" + "github.com/xraph/forge/extensions/dashboard/contract/idempotency" + "github.com/xraph/forge/extensions/dashboard/contract/transport" + "github.com/xraph/forge/extensions/dashboard/security" +) + +// TestSecurityE2E_CSRFRequired confirms that a command envelope with a CSRF +// token the manager refuses returns 403 + UNAUTHENTICATED. This is the +// rollout-critical path: a stale or forged token must NEVER reach the +// dispatcher. +func TestSecurityE2E_CSRFRequired(t *testing.T) { + reg, wreg, disp := setupSecurityEnv(t, idempotency.NewInMemoryStore()) + mgr := security.NewCSRFManager() + h := transport.NewHandlerWithCSRF(reg, wreg, disp, contract.NoopAuditEmitter{}, mgr) + + body, _ := json.Marshal(contract.Request{ + Envelope: "v1", Kind: contract.KindCommand, + Contributor: "test", Intent: "do.thing", IntentVersion: 1, + CSRF: "wrong", IdempotencyKey: "k1", + }) + req := httptest.NewRequest(http.MethodPost, "/api/dashboard/v1", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403, got %d body=%s", w.Code, w.Body) + } + if !strings.Contains(w.Body.String(), "UNAUTHENTICATED") { + t.Errorf("expected UNAUTHENTICATED in response, got %s", w.Body.String()) + } +} + +// TestSecurityE2E_IdempotencyDedup confirms that two identical command +// envelopes — same idempotency key, same CSRF token, same payload — produce +// byte-equal response bodies. The first call dispatches; the second is a +// cache hit served verbatim from the idempotency store. +func TestSecurityE2E_IdempotencyDedup(t *testing.T) { + store := idempotency.NewInMemoryStore() + reg, wreg, disp := setupSecurityEnv(t, store) + mgr := security.NewCSRFManager() + tok := mgr.GenerateToken() + h := transport.NewHandlerWithCSRF(reg, wreg, disp, contract.NoopAuditEmitter{}, mgr) + + build := func() *http.Request { + body, _ := json.Marshal(contract.Request{ + Envelope: "v1", Kind: contract.KindCommand, + Contributor: "test", Intent: "do.thing", IntentVersion: 1, + CSRF: tok, IdempotencyKey: "ik_e2e", + }) + return httptest.NewRequest(http.MethodPost, "/api/dashboard/v1", bytes.NewReader(body)) + } + w1 := httptest.NewRecorder() + h.ServeHTTP(w1, build()) + w2 := httptest.NewRecorder() + h.ServeHTTP(w2, build()) + + if w1.Body.String() != w2.Body.String() { + t.Errorf("idempotent calls produced different bodies:\nfirst: %s\nsecond: %s", w1.Body, w2.Body) + } +} + +// setupSecurityEnv wires a registry containing one write-capability command +// intent (`test/do.thing@1`), an empty warden registry, and a dispatcher +// configured with the supplied idempotency store. The bound handler is the +// minimum viable command handler — returns OK:true with no work. +func setupSecurityEnv(t *testing.T, store idempotency.Store) (contract.Registry, contract.WardenRegistry, *dispatcher.Dispatcher) { + t.Helper() + reg := contract.NewRegistry() + src := ` +schemaVersion: 1 +contributor: { name: test, envelope: { supports: [v1], preferred: v1 } } +intents: + - { name: do.thing, kind: command, version: 1, capability: write } +` + var m contract.ContractManifest + if err := contract.UnmarshalManifestForTest([]byte(src), &m); err != nil { + t.Fatal(err) + } + if err := reg.Register(&m); err != nil { + t.Fatal(err) + } + wreg := contract.NewWardenRegistry() + disp := dispatcher.NewWithOptions(dispatcher.NoopMetricsEmitter{}, + dispatcher.WithIdempotencyStore(adaptStore(store))) + if err := dispatcher.RegisterCommand(disp, "test", "do.thing", 1, + func(_ context.Context, _ struct{}, _ contract.Principal) (struct{ OK bool }, error) { + return struct{ OK bool }{OK: true}, nil + }); err != nil { + t.Fatalf("RegisterCommand: %v", err) + } + return reg, wreg, disp +} + +// adapter mirrors the production idempotencyAdapter in extensions/dashboard. +// The duplication is intentional: this is an external test package that +// can't reach into the dashboard package without re-introducing the cycle +// the contract sub-packages were carved out to avoid. The conversion is +// trivial enough that keeping it inline here is cheaper than exposing a +// public adapter constructor. +type adapter struct{ inner idempotency.Store } + +func adaptStore(s idempotency.Store) dispatcher.IdempotencyStore { return &adapter{inner: s} } + +func (a *adapter) Lookup(ctx context.Context, k, id string) (*dispatcher.IdempotencyCached, bool) { + c, ok := a.inner.Lookup(ctx, k, id) + if !ok { + return nil, false + } + return &dispatcher.IdempotencyCached{ + Status: c.Status, WireBody: c.WireBody, + StoredAt: c.StoredAt, TTL: c.TTL, + }, true +} + +func (a *adapter) Store(ctx context.Context, k, id string, c dispatcher.IdempotencyCached) error { + return a.inner.Store(ctx, k, id, idempotency.Cached{ + Status: c.Status, WireBody: c.WireBody, + StoredAt: c.StoredAt, TTL: c.TTL, + }) +} From fc38297e2e46b544c8cc9737366a54e8e9c00f7c Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sun, 10 May 2026 00:04:22 -0500 Subject: [PATCH 53/89] docs(dashboard/contract): add slice-d React shell rendering engine design The shell lives at extensions/dashboard/contract/shell/ as a TypeScript+React project built with Vite, embedded via //go:embed into the dashboard binary, and served from /dashboard/contract/static/ and /dashboard/contract/app/. Architecture decisions locked in: TS strict, React 18 + React Router v6 data router, TanStack Query for server state, Zustand for local UI state, Tailwind v3, Vitest + MSW for testing. Single EventSource per page for SSE multiplex. CSRF tokens fetched lazily and held in memory. Idempotency keys auto-generated per command. Slice (d) ships the runtime + one example component (metric.counter); the full v1 intent vocabulary lands in slice (e). --- .../dashboard/contract/SLICE_D_DESIGN.md | 388 ++++++++++++++++++ 1 file changed, 388 insertions(+) create mode 100644 extensions/dashboard/contract/SLICE_D_DESIGN.md diff --git a/extensions/dashboard/contract/SLICE_D_DESIGN.md b/extensions/dashboard/contract/SLICE_D_DESIGN.md new file mode 100644 index 00000000..d23bcfd2 --- /dev/null +++ b/extensions/dashboard/contract/SLICE_D_DESIGN.md @@ -0,0 +1,388 @@ +# Slice (d) — React Shell Rendering Engine + +> Companion design doc to [DESIGN.md](DESIGN.md). Slice (d) ships the JavaScript runtime that consumes the contract endpoints slice (a)+(b)+(c) shipped, turning declarative manifests into a working admin UI. + +## Context + +The contract is fully exercisable from Go and the probe CLI but has no browser consumer. Slice (d) builds the React-based shell that fetches a route's filtered graph from `POST /api/dashboard/v1` (kind=graph), maps each `intent` node to a registered React component, recursively expands slots, runs the named queries each intent declares as data sources, and subscribes via the multiplexed SSE stream for live data. CSRF tokens are fetched on demand from `GET /csrf` and refreshed on 401. Idempotency keys are generated for every command. Auth is handled out-of-band by whatever middleware the deployment already runs (the shell just sends cookies + Authorization headers along with the contract envelope). + +Slice (d) is the **runtime**: graph fetcher, slot renderer, query client, SSE multiplex consumer, escape-hatch loader, plus exactly **one** concrete intent component (`metric.counter`) so the pilot's `/metrics/live` route renders end-to-end. The full v1 vocabulary (`resource.list`, `resource.detail`, `dashboard.grid`, `form.edit`, `audit.tail`) lands in slice (e). Until then, unknown intents render a graceful fallback (``) instead of erroring. + +Slice (d) also adds the Go-side glue: `//go:embed`-bundles the production build into the dashboard binary and serves the SPA from `/dashboard/contract/static/*`. No new external service or hosting plane. + +## Architecture Decisions (locked in — autonomy mode) + +| Decision | Choice | Rationale | +|---|---|---| +| Location | `extensions/dashboard/contract/shell/` (TS/React project), built into `dist/` and embedded via `//go:embed dist/*` in the dashboard extension | Co-located with the contract code it consumes; single Go deployment artifact; mirrors the slice (a)/(b)/(c) layout pattern. | +| Language | TypeScript strict mode | A runtime that consumes a typed contract benefits more from compile-time checking than any other slice. | +| Build tool | Vite 5+ | Fast cold builds, ESM-native, minimal config, well-supported in the React ecosystem. Single config file. | +| Package manager | pnpm | Already used by `docs/`; consistent with the rest of the repo. | +| Framework | React 18 (concurrent features for streaming SSE updates without jank) | Industry default; broad component ecosystem. | +| Routing | React Router v6.4+ data router pattern | Routes from the contract YAML are mapped to a single dynamic route in the SPA; the data router's loader pattern matches the contract's `kind=graph` fetch lifecycle. | +| Server state | React Query (TanStack Query v5) | First-class `staleTime` / cache invalidation aligns with the contract's per-intent `cache.staleTime` declarations and the `meta.invalidates` hint in command responses. | +| Local UI state | Zustand | Hooks-native, zero boilerplate, ~1KB. Used for transient UI state (modals, drawers, selected rows) that doesn't belong in server state. | +| CSS | Tailwind CSS v3 (NOT v4 yet — toolchain stability) | Atomic, no runtime cost, consistent with the docs site's eventual stack. v3 picked because v4 still has stabilization friction with Vite. | +| Component primitives | None for v1; build minimal headless components inline. Optional shadcn/ui adoption is a follow-on. | Avoids a new dependency surface during the runtime's first version. Slice (e) revisits if any vocabulary intent benefits. | +| Testing | Vitest + React Testing Library + Mock Service Worker for HTTP/SSE stubbing | Vitest is Vite-native, fast, ESM-native. MSW handles the contract endpoint boundary cleanly without spinning up a real Go server in JS tests. | +| Bundle target | ES2022, modern browsers (no IE / no legacy polyfills) | Internal admin tool; we control the audience. | +| Auth model | Cookies + headers travel with the contract envelope unchanged. The shell does NOT manage user login. The `auth/dashauth` middleware on the Go side populates `UserInfo`; the shell reads its identity from `GET /api/dashboard/v1/principal` (a new tiny endpoint added by this slice). | Login is out-of-band. The shell just shows the user who they are (for the topbar). | +| CSRF token storage | In-memory, fetched lazily on first command, refreshed on 401 | Stateless HMAC tokens (per slice b) don't need persistence. Memory storage avoids XSS-readable localStorage. | +| Idempotency keys | Auto-generated per command (`crypto.randomUUID()`); user can pass an explicit key via the action descriptor for retry-safe operations | Default-on prevents double-submits; explicit override supports finer control when retry semantics matter. | +| SSE consumer | Single EventSource per page, multiplex demuxes by SSE `event:` field (= subscriptionID) | Matches the broker's wire shape from slice (a). One connection per page, many subscriptions. | +| SPA routing | React Router catch-all under `/dashboard` rewriting to the shell's index.html (Go side) | Standard SPA pattern; the Go static handler serves index.html for any path that doesn't resolve to a static asset. | +| Bundle delivery | Production build to `extensions/dashboard/contract/shell/dist/`; Go `//go:embed dist/*` includes it in the binary; static handler at `/dashboard/contract/static/*` | One binary, no separate hosting; CI rebuilds JS before Go build. | +| Dev mode | Vite dev server on `:5173` proxies `/api/dashboard/*` to the running Go dashboard on `:8080` | Hot reload during development without rebuilding the Go binary. | +| Bundle size budget | <250KB gzipped initial, <500KB total | Admin dashboards don't need a 1MB cold start. Slice (e) expansion stays within budget. | +| Browser support | Chrome 110+, Firefox 110+, Safari 16+ (Sept 2022 baseline) | All support ESM, modern CSS, EventSource with proper headers, and `crypto.randomUUID()`. | + +## Scope + +### In scope (this slice) +- TypeScript/React project at `extensions/dashboard/contract/shell/` with full toolchain (Vite, TS, Tailwind, React Query, React Router, Zustand, Vitest, MSW). +- Contract client SDK: typed `Request`/`Response` types matching the Go envelope, send functions for `query`/`command`/`graph`, automatic CSRF handling, error envelope handling, idempotency-key generation. +- SSE multiplex client: opens one EventSource per page, sends control messages, demuxes events by subscription ID, exposes a hook (`useSubscription(intent, params)`). +- Intent component registry: name → React component map; lazy-imported components for code-splitting; fallback for unknown intents. +- Slot renderer: walks the graph response, recursively renders, evaluates `enabledWhen` (visibility is server-side per slice a's design). +- Page shell: renders nav + main outlet + topbar with principal info. +- One concrete intent component: `metric.counter` — proves the renderer works end-to-end against the pilot's `/metrics/live` page. +- Fallback components: `UnknownIntent` (placeholder when intent name isn't registered), `LoadingNode`, `ErrorNode`. +- Auth/principal hook: fetches `GET /api/dashboard/v1/principal` once on mount, exposes via Zustand store. +- Go side: `principal` endpoint, embed directive, static + SPA fallback handler, contributor route forwarding. +- Tests: unit tests for the client SDK, the slot renderer, the intent registry, the SSE consumer (with MSW + EventSource stub). +- Integration smoke: a full Vitest test that mounts a route's graph, asserts `metric.counter` renders with subscription data. + +### Out of scope (later slices) +- **Slice (e)**: concrete components for `resource.list`, `resource.detail`, `dashboard.grid`, `form.edit`, `audit.tail`, `action.button`, `form.field`, `form.edit`, etc. +- **Slice (f)**: contributor migration + templ retirement — the legacy templ pages keep serving until (e)+(f) are ready. +- Component design polish (shadcn, lucide icons beyond the bare minimum, animations, transitions). +- Browser E2E tests via Playwright — Vitest + MSW covers the runtime; browser tests are a follow-on. +- A login/auth UI — auth stays middleware-driven. +- Internationalization, dark/light theme runtime switching, accessibility audit beyond the React Testing Library defaults. +- iframe escape-hatch implementation — design ships, but no concrete iframe-rendered intent until a contributor needs one. + +## Components + +### Project layout + +``` +extensions/dashboard/contract/shell/ + package.json + pnpm-lock.yaml # generated + tsconfig.json + tsconfig.node.json # for vite.config.ts + vite.config.ts + tailwind.config.ts + postcss.config.js + index.html # entry HTML, includes
+ vitest.config.ts + .gitignore # excludes dist/, node_modules/, .vite cache + + src/ + main.tsx # React entry, mounts + App.tsx # routing + providers + index.css # tailwind directives + minimal globals + + contract/ + types.ts # TS mirrors of Go envelope: Request, Response, ErrorResponse, StreamEvent, etc. + client.ts # send(envelope), CSRF, idempotency, error decoding + sse.ts # SubscriptionMux: one EventSource per page, demux by subscriptionID + hooks.ts # useGraph, useQuery, useCommand, useSubscription + + runtime/ + registry.ts # IntentRegistry: name -> React component + renderer.tsx # GraphRenderer: walks graph nodes, dispatches to registry + slots.tsx # SlotRenderer + fallbacks.tsx # UnknownIntent, LoadingNode, ErrorNode + + auth/ + principal.ts # Zustand store + fetcher + + intents/ + page.shell.tsx # registered as "page.shell" + metric.counter.tsx # registered as "metric.counter" + register.ts # registers built-in intents at app startup + + test/ + setup.ts # MSW config, Vitest global setup + contract.test.ts # client SDK round-trip + sse.test.ts # SubscriptionMux dispatch + renderer.test.tsx # slot expansion, registry lookup, fallback path + smoke.test.tsx # mounts a page, verifies render +``` + +### Build pipeline + +`pnpm build` runs `vite build` → emits to `extensions/dashboard/contract/shell/dist/`. The Go side embeds via: + +```go +// extensions/dashboard/contract/shell/embed.go +package shell + +import "embed" + +//go:embed all:dist +var Dist embed.FS +``` + +The dashboard extension's `registerRoutes` mounts a static handler at `/dashboard/contract/static/*` that reads from `shell.Dist` and falls back to `dist/index.html` for any path not matching a static asset (SPA routing). The static handler is added in this slice. + +The SPA URL space: +- `/dashboard/contract/static/*` — static assets (JS, CSS, fonts, images), browser-cached aggressively (immutable hashes from Vite). +- `/dashboard/contract/app/*` — index.html SPA entry, all routes under here render the React shell. The shell's React Router dispatches. + +Today's legacy `/dashboard` paths and `/dashboard/contract/extensions`, `/dashboard/contract/services`, `/dashboard/contract/metrics/live` (pilot routes) are unchanged; the React shell at `/dashboard/contract/app/*` is the new entry. + +### Contract types (TS mirror of Go envelope) + +```typescript +// shell/src/contract/types.ts +export type Kind = "graph" | "query" | "command" | "subscribe"; + +export interface Request { + envelope: "v1"; + kind: Kind; + contributor: string; + intent: string; + intentVersion?: number; + payload?: unknown; + params?: Record; + context: { route: string; correlationID: string }; + csrf?: string; + idempotencyKey?: string; +} + +export interface Response { + ok: true; + envelope: "v1"; + kind: Kind; + data: T; + meta: ResponseMeta; +} + +export interface ResponseMeta { + intentVersion?: number; + deprecation?: { intentVersion: number; removeAfter: string }; + cacheControl?: { staleTime?: string }; + invalidates?: string[]; +} + +export interface ErrorResponse { + ok: false; + envelope: "v1"; + error: ContractError; +} + +export interface ContractError { + code: string; + message?: string; + details?: Record; + retryable?: boolean; + correlationID?: string; + redactions?: string[]; +} + +// Graph types — mirror Go's GraphNode minus server-internal fields +export interface GraphNode { + intent: string; + title?: string; + route?: string; + data?: DataBinding; + props?: Record; + slots?: Record; + enabledWhen?: Predicate; + op?: string; + payload?: Record; + // server filters visibleWhen before sending — never appears in shell graph +} + +export interface DataBinding { + queryRef?: string; + intent?: string; + params?: Record; +} + +// StreamEvent (SSE wire shape) +export interface StreamEvent { + intent: string; + mode: "replace" | "append" | "snapshot+delta"; + payload: T; + seq: number; +} +``` + +### Contract client (`shell/src/contract/client.ts`) + +```typescript +export class ContractClient { + constructor(private baseURL: string = "/api/dashboard/v1") {} + + async send(req: Omit & Partial>): Promise { + // 1. inject envelope: "v1" and context (correlationID = crypto.randomUUID(), route from window.location) + // 2. for kind=command, attach CSRF token (lazily fetched + cached) and idempotencyKey (auto-generated unless caller provides one) + // 3. POST to baseURL with Content-Type: application/json + // 4. parse the response envelope: + // - ok=true: return data as T + // - ok=false: throw ContractError with the wire details attached + // 5. on 401 (CSRF expired): refresh token, retry once + } + + // helpers per kind for ergonomics: + async query(contributor: string, intent: string, payload?: unknown, params?: Record): Promise + async command(contributor: string, intent: string, payload?: unknown, opts?: { idempotencyKey?: string }): Promise + async graph(contributor: string, route: string): Promise +} +``` + +### SSE multiplex (`shell/src/contract/sse.ts`) + +```typescript +export class SubscriptionMux { + private es: EventSource | null = null; + private streamID: string | null = null; + private subs = new Map void>(); + + // open() lazily on first subscribe(); reconnect with backoff on close + open(): Promise + + // returns an unsubscribe() function + subscribe(contributor: string, intent: string, params: Record, onEvent: (ev: StreamEvent) => void): () => void + + close(): void +} +``` + +The mux dispatches by SSE `event: ` field, parses the JSON `data:` payload into `StreamEvent`, and calls the registered handler. On `event: hello`, captures the streamID for control messages. Reconnect strategy: exponential backoff to 30s max, replay outstanding subscriptions automatically. + +### Intent registry (`shell/src/runtime/registry.ts`) + +```typescript +export interface IntentComponentProps> { + node: GraphNode; + data?: TData; + props: TProps; + slots: Record; +} + +export type IntentComponent = React.ComponentType; + +export class IntentRegistry { + private byName = new Map(); + register(name: string, component: IntentComponent): void + resolve(name: string): IntentComponent | undefined +} +``` + +The registry is created at app startup (in `intents/register.ts`) and threaded via React context (``). + +### Slot renderer (`shell/src/runtime/renderer.tsx`) + +```typescript +export function GraphRenderer({ node }: { node: GraphNode }) { + const registry = useIntentRegistry(); + const Component = registry.resolve(node.intent); + if (!Component) { + return ; + } + // resolve data binding (if node.data is a queryRef, look it up in queries; if inline, run the query) + const data = useNodeData(node.data); + return ; +} +``` + +Each component decides which slots to render where (e.g., `` to render that slot's children). + +### `metric.counter` (`shell/src/intents/metric.counter.tsx`) + +The single concrete component this slice ships. Wraps the data in a card, displays the value with a label. If the data binding is a subscription intent (replace mode), uses `useSubscription`; if it's a query, uses `useQuery`. The component is declarative — the manifest tells it what to fetch. + +### `page.shell` (`shell/src/intents/page.shell.tsx`) + +Top-level page wrapper. Renders the topbar (with principal info), nav (from the registry's nav metadata exposed in another graph fetch), and the main slot. Most of the dashboard's structure flows through this. + +### Auth principal endpoint + +New Go-side route `GET /api/dashboard/v1/principal` returns: + +```json +{ + "subject": "alice", + "displayName": "Alice Smith", + "roles": ["admin"], + "scopes": ["users.read", "users.write"] +} +``` + +Reads from the request context's `dashauth.UserFromContext`. When unauthenticated, returns 401. The shell's principal store fetches once on mount; failure is surfaced as "not signed in" in the topbar. + +## Files Affected + +### New (this slice) + +``` +extensions/dashboard/contract/shell/ + package.json, pnpm-lock.yaml, tsconfig.json, tsconfig.node.json, + vite.config.ts, tailwind.config.ts, postcss.config.js, + vitest.config.ts, index.html, .gitignore, README.md + + src/main.tsx, src/App.tsx, src/index.css + src/contract/{types.ts, client.ts, sse.ts, hooks.ts} + src/runtime/{registry.ts, renderer.tsx, slots.tsx, fallbacks.tsx} + src/auth/principal.ts + src/intents/{page.shell.tsx, metric.counter.tsx, register.ts} + + test/{setup.ts, contract.test.ts, sse.test.ts, renderer.test.tsx, smoke.test.tsx} + + embed.go # //go:embed directive + +extensions/dashboard/handlers/ + principal.go # GET /api/dashboard/v1/principal handler + +extensions/dashboard/contract/SLICE_D_DESIGN.md # this file +extensions/dashboard/contract/SLICE_D_PLAN.md # produced via writing-plans skill +``` + +### Modified + +- `extensions/dashboard/extension.go` — register the static SPA handler under `/dashboard/contract/static/*` and the SPA fallback at `/dashboard/contract/app/*`; register the principal endpoint. +- `extensions/dashboard/handlers/api.go` — none (principal lives in its own file). +- `Makefile` (if exists) or new `extensions/dashboard/contract/shell/Makefile` — `pnpm install && pnpm build` step that runs before `go build` for the dashboard binary. CI pipeline likewise. + +### Reused + +- `transport.NewHandler` (slice a) — the shell's contract client posts to the existing endpoint. +- `transport.StreamBroker` (slice a) — the shell's SubscriptionMux opens a connection to the existing stream. +- `dispatcher.Dispatcher` (slice c) — handles the actual dispatch on the Go side. +- `pilot.Register` (slice c) — keeps registering its three pages; the shell renders them via the new path. +- `dashauth.UserFromContext` — the new principal handler reads from this. + +## Verification + +1. **JS unit tests** under `shell/test/`: + - **Contract client** round-trips: query, command (with auto-CSRF + auto-idempotency), graph fetch, error envelope decode. + - **CSRF refresh on 401**: first request 401s; the client fetches `/csrf`, retries; second request succeeds. + - **SubscriptionMux**: subscribe → control message sent; events arrive → handler invoked with parsed StreamEvent; unsubscribe → control message sent; close → all subscriptions cleared; reconnect → outstanding subs replayed. + - **Intent registry**: register → resolve hits; unknown intent → resolves to undefined. + - **GraphRenderer**: registered intent renders its component; unknown intent renders UnknownIntent fallback; nested slots resolve recursively. + - **`metric.counter`**: renders title, observes a `metrics.summary` subscription event, updates the displayed value. + +2. **Integration smoke** (`shell/test/smoke.test.tsx`): + - MSW stubs `POST /api/dashboard/v1` to return a graph for `/metrics/live` matching the pilot fixture. + - MSW stubs the SSE stream with a fake event source that emits one `metrics.summary` event. + - Mounts `` at the route, asserts `metric.counter` renders with the event payload's `totalMetrics` value. + +3. **Go side**: existing 226+ tests stay green. New `principal_test.go` covers the new endpoint (200 with UserInfo present, 401 without). + +4. **Manual smoke** (post-merge): + - `pnpm dev` in shell/, dashboard running, browse to `http://localhost:5173/dashboard/contract/app/metrics/live`. Counter widget renders, refreshes every 5s via SSE. + - `pnpm build` then `go build && ./forge`, browse to `http://localhost:8080/dashboard/contract/app/metrics/live`. Same result, served from embedded assets. + +## Out of Scope — Future Slices + +- **Slice (e)**: rest of the v1 intent vocabulary (`resource.list`, `resource.detail`, `dashboard.grid`, `form.edit`, `audit.tail`, `action.button`, `action.menu`, `action.divider`, `form.field`). +- **Slice (f)**: contributor migration to the new contract + retire templ + remove HTML-fragment proxying. +- iframe escape-hatch component for novel UX (design from slice a is preserved; first contributor that needs one prompts the slice). +- Component-library adoption (shadcn/ui or similar) — first reach for this when a vocabulary intent benefits. +- Playwright browser E2E. +- Internationalization, dark/light theme switching, advanced accessibility. +- A login/registration UI — out-of-band (auth middleware on the Go side handles it). From 2c3011832beda0a47ea857e08dd878f6fd386034 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sun, 10 May 2026 00:09:23 -0500 Subject: [PATCH 54/89] docs(dashboard/contract): add slice-d implementation plan 8 phases covering project scaffolding, contract types/client, SSE multiplex, auth principal store, React Query hooks, intent registry + slot renderer, page.shell + metric.counter components, App routing + smoke test, and Go-side embed + SPA route registration. --- extensions/dashboard/contract/SLICE_D_PLAN.md | 2019 +++++++++++++++++ 1 file changed, 2019 insertions(+) create mode 100644 extensions/dashboard/contract/SLICE_D_PLAN.md diff --git a/extensions/dashboard/contract/SLICE_D_PLAN.md b/extensions/dashboard/contract/SLICE_D_PLAN.md new file mode 100644 index 00000000..58b387fd --- /dev/null +++ b/extensions/dashboard/contract/SLICE_D_PLAN.md @@ -0,0 +1,2019 @@ +# Slice (d) — React Shell Rendering Engine Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build the React/TypeScript shell that consumes the contract endpoints (slices a/b/c), embed it into the dashboard binary, and prove it end-to-end against the pilot's `metric.counter` widget on `/metrics/live`. + +**Architecture:** Fresh TypeScript+React+Vite project at `extensions/dashboard/contract/shell/`. Production build to `dist/`, embedded via `//go:embed dist/*` into the dashboard extension and served as a SPA. Contract client + SSE multiplex consumer + intent component registry + slot renderer = the runtime. One concrete intent component (`metric.counter`) plus a `page.shell` wrapper. Slice (e) adds the rest of the vocabulary. + +**Tech Stack:** TypeScript 5.x strict mode, React 18, Vite 5, React Router 6.4+ (data router), TanStack Query 5, Zustand 4, Tailwind CSS 3, Vitest + React Testing Library + MSW (Mock Service Worker). pnpm package manager. Go side: `//go:embed`. + +--- + +## Reference + +- **Design spec:** [SLICE_D_DESIGN.md](SLICE_D_DESIGN.md). +- **Go-side endpoints to consume** (already shipped): + - `POST /api/dashboard/v1` — query/command/graph dispatch. + - `GET /api/dashboard/v1/stream` + `POST /api/dashboard/v1/stream/control` — SSE multiplex. + - `GET /api/dashboard/v1/csrf` — token issuance. + - `GET /api/dashboard/v1/capabilities` — version negotiation. +- **New endpoint this slice adds**: `GET /api/dashboard/v1/principal` — current user info. + +## Conventions + +- TypeScript strict mode (`"strict": true, "noUncheckedIndexedAccess": true`). +- ESLint + Prettier with reasonable defaults; no bikeshedding. +- Vitest test files: `*.test.ts` / `*.test.tsx`, co-located in `test/` directory mirroring `src/`. +- One commit per logical change; no Co-Authored-By trailers. +- Tailwind v3 via PostCSS pipeline; no Tailwind v4 yet. +- All paths in this plan are relative to `/Users/rexraphael/Work/xraph/forge` unless noted. + +## File Structure + +``` +extensions/dashboard/contract/shell/ + package.json + tsconfig.json + tsconfig.node.json + vite.config.ts + vitest.config.ts + tailwind.config.ts + postcss.config.js + index.html + .gitignore + README.md + embed.go # //go:embed dist/* + src/ + main.tsx + App.tsx + index.css + contract/{types.ts, client.ts, sse.ts, hooks.ts} + runtime/{registry.ts, renderer.tsx, slots.tsx, fallbacks.tsx, context.ts} + auth/principal.ts + intents/{page.shell.tsx, metric.counter.tsx, register.ts} + test/ + setup.ts + contract.test.ts + sse.test.ts + renderer.test.tsx + smoke.test.tsx + +extensions/dashboard/handlers/principal.go +extensions/dashboard/handlers/principal_test.go +extensions/dashboard/extension.go # MODIFY +``` + +## .gitignore for shell/ + +The repo's top-level .gitignore already excludes `node_modules/`. Add a shell-local `.gitignore` for build artifacts: + +``` +# extensions/dashboard/contract/shell/.gitignore +node_modules/ +dist/ +.vite/ +*.log +.env +.env.local +coverage/ +``` + +The `dist/` directory is gitignored locally but committed via the Go embed at build time on CI. For local development, contributors run `pnpm build` before `go build`. (A future slice can wire this through a Makefile / build script.) + +--- + +## Phase 0: Project Scaffolding + +### Task 0.1: package.json + lockfile + tooling configs + +**Files:** +- Create: `extensions/dashboard/contract/shell/package.json` +- Create: `extensions/dashboard/contract/shell/tsconfig.json` +- Create: `extensions/dashboard/contract/shell/tsconfig.node.json` +- Create: `extensions/dashboard/contract/shell/vite.config.ts` +- Create: `extensions/dashboard/contract/shell/vitest.config.ts` +- Create: `extensions/dashboard/contract/shell/tailwind.config.ts` +- Create: `extensions/dashboard/contract/shell/postcss.config.js` +- Create: `extensions/dashboard/contract/shell/.gitignore` +- Create: `extensions/dashboard/contract/shell/README.md` + +- [ ] **Step 1: Write package.json** + +```json +{ + "name": "@forge/dashboard-shell", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "lint": "tsc --noEmit", + "format": "prettier --write src test" + }, + "dependencies": { + "@tanstack/react-query": "^5.40.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "react-router-dom": "^6.26.0", + "zustand": "^4.5.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.4.0", + "@testing-library/react": "^16.0.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "autoprefixer": "^10.4.0", + "jsdom": "^25.0.0", + "msw": "^2.4.0", + "postcss": "^8.4.0", + "prettier": "^3.3.0", + "tailwindcss": "^3.4.0", + "typescript": "^5.5.0", + "vite": "^5.4.0", + "vitest": "^2.1.0" + } +} +``` + +- [ ] **Step 2: Write tsconfig.json** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noUncheckedIndexedAccess": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "esModuleInterop": true, + "skipLibCheck": true, + "isolatedModules": true, + "allowImportingTsExtensions": false, + "resolveJsonModule": true, + "useDefineForClassFields": true, + "types": ["vitest/globals", "@testing-library/jest-dom"] + }, + "include": ["src", "test"], + "references": [{ "path": "./tsconfig.node.json" }] +} +``` + +- [ ] **Step 3: Write tsconfig.node.json** + +```json +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["vite.config.ts", "vitest.config.ts", "tailwind.config.ts", "postcss.config.js"] +} +``` + +- [ ] **Step 4: Write vite.config.ts** + +```ts +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + base: "/dashboard/contract/static/", + build: { + outDir: "dist", + emptyOutDir: true, + sourcemap: true, + target: "es2022", + rollupOptions: { + output: { + manualChunks: { + "react-vendor": ["react", "react-dom", "react-router-dom"], + "query-vendor": ["@tanstack/react-query"], + }, + }, + }, + }, + server: { + port: 5173, + proxy: { + "/api/dashboard": { + target: "http://localhost:8080", + changeOrigin: false, + }, + "/dashboard/contract/static": { + target: "http://localhost:5173", + bypass: () => "/index.html", + }, + }, + }, +}); +``` + +- [ ] **Step 5: Write vitest.config.ts** + +```ts +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + environment: "jsdom", + setupFiles: ["./test/setup.ts"], + include: ["test/**/*.test.{ts,tsx}"], + coverage: { provider: "v8" }, + }, +}); +``` + +- [ ] **Step 6: Write tailwind.config.ts** + +```ts +import type { Config } from "tailwindcss"; + +const config: Config = { + content: ["./index.html", "./src/**/*.{ts,tsx}"], + theme: { extend: {} }, + plugins: [], +}; + +export default config; +``` + +- [ ] **Step 7: Write postcss.config.js** + +```js +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; +``` + +- [ ] **Step 8: Write .gitignore (contents per "Conventions" above)** + +- [ ] **Step 9: Write README.md** + +```markdown +# Dashboard Contract Shell + +The React/TypeScript runtime that consumes the dashboard contract. + +## Development + +\`\`\`bash +pnpm install +pnpm dev # Vite dev server on :5173, proxies /api/dashboard/* to :8080 +\`\`\` + +## Build + +\`\`\`bash +pnpm build # Emits dist/ — embedded into the dashboard Go binary via //go:embed +\`\`\` + +## Test + +\`\`\`bash +pnpm test +\`\`\` + +See [SLICE_D_DESIGN.md](../SLICE_D_DESIGN.md) for the architecture. +``` + +- [ ] **Step 10: Run `pnpm install`** + +```bash +cd extensions/dashboard/contract/shell && pnpm install +``` + +Expected: clean install, lockfile generated. Lockfile is committed. + +- [ ] **Step 11: Verify lint** + +```bash +pnpm lint +``` + +Expected: no errors (no source files yet, so this is just a tsconfig sanity check). Will fail if no `src/` exists; that's fine — Phase 0.2 creates source files. + +- [ ] **Step 12: Commit** + +```bash +git add extensions/dashboard/contract/shell/ +git commit -m "feat(dashboard/contract/shell): scaffold React+TypeScript+Vite project" +``` + +### Task 0.2: index.html + minimal source skeleton + +**Files:** +- Create: `extensions/dashboard/contract/shell/index.html` +- Create: `extensions/dashboard/contract/shell/src/main.tsx` +- Create: `extensions/dashboard/contract/shell/src/App.tsx` +- Create: `extensions/dashboard/contract/shell/src/index.css` +- Create: `extensions/dashboard/contract/shell/test/setup.ts` + +- [ ] **Step 1: Write index.html** + +```html + + + + + + Forge Dashboard + + +
+ + + +``` + +- [ ] **Step 2: Write src/index.css** + +```css +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, body, #root { + height: 100%; +} +``` + +- [ ] **Step 3: Write src/main.tsx** + +```tsx +import React from "react"; +import ReactDOM from "react-dom/client"; +import "./index.css"; +import { App } from "./App"; + +const root = document.getElementById("root"); +if (!root) throw new Error("#root not found"); + +ReactDOM.createRoot(root).render( + + + , +); +``` + +- [ ] **Step 4: Write src/App.tsx (placeholder)** + +```tsx +export function App() { + return ( +
+

Forge Dashboard Shell

+

Runtime scaffolded; routes will be wired in Phase 6.

+
+ ); +} +``` + +- [ ] **Step 5: Write test/setup.ts** + +```ts +import "@testing-library/jest-dom/vitest"; +import { afterEach } from "vitest"; +import { cleanup } from "@testing-library/react"; + +afterEach(() => { + cleanup(); +}); +``` + +- [ ] **Step 6: Build the project** + +```bash +cd extensions/dashboard/contract/shell && pnpm build +``` + +Expected: `dist/` directory created with `index.html`, hashed JS/CSS bundles. Build succeeds. + +- [ ] **Step 7: Run tests** + +```bash +pnpm test +``` + +Expected: 0 tests pass (no test files yet); exit code 0. + +- [ ] **Step 8: Commit** + +```bash +git add extensions/dashboard/contract/shell/ +git commit -m "feat(dashboard/contract/shell): minimal React app skeleton" +``` + +--- + +## Phase 1: Contract Types + Client + +### Task 1.1: TypeScript types mirroring the Go envelope + +**Files:** +- Create: `extensions/dashboard/contract/shell/src/contract/types.ts` + +- [ ] **Step 1: Write types.ts (verbatim from design doc)** + +```ts +export type Kind = "graph" | "query" | "command" | "subscribe"; + +export interface Request { + envelope: "v1"; + kind: Kind; + contributor: string; + intent: string; + intentVersion?: number; + payload?: TPayload; + params?: Record; + context: { route: string; correlationID: string }; + csrf?: string; + idempotencyKey?: string; +} + +export interface ResponseMeta { + intentVersion?: number; + deprecation?: { intentVersion: number; removeAfter: string }; + cacheControl?: { staleTime?: string }; + invalidates?: string[]; +} + +export interface Response { + ok: true; + envelope: "v1"; + kind: Kind; + data: TData; + meta: ResponseMeta; +} + +export interface ContractError { + code: string; + message?: string; + details?: Record; + retryable?: boolean; + correlationID?: string; + redactions?: string[]; +} + +export interface ErrorResponse { + ok: false; + envelope: "v1"; + error: ContractError; +} + +export type EnvelopeResponse = Response | ErrorResponse; + +export interface DataBinding { + queryRef?: string; + intent?: string; + params?: Record; +} + +export interface GraphNode { + intent: string; + title?: string; + route?: string; + data?: DataBinding; + props?: Record; + slots?: Record; + enabledWhen?: Predicate; + op?: string; + payload?: Record; + component?: string; + src?: string; +} + +export interface Predicate { + all?: string[]; + any?: string[]; + not?: string[]; + warden?: string; +} + +export type SubscriptionMode = "replace" | "append" | "snapshot+delta"; + +export interface StreamEvent { + intent: string; + mode: SubscriptionMode; + payload: T; + seq: number; +} + +// Wire shape for GET /api/dashboard/v1/principal +export interface Principal { + subject: string; + displayName: string; + email?: string; + roles: string[]; + scopes: string[]; +} +``` + +- [ ] **Step 2: Verify types compile** + +```bash +cd extensions/dashboard/contract/shell && pnpm lint +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add extensions/dashboard/contract/shell/src/contract/types.ts +git commit -m "feat(dashboard/contract/shell): TypeScript types mirroring the Go envelope" +``` + +### Task 1.2: ContractClient + tests + +**Files:** +- Create: `extensions/dashboard/contract/shell/src/contract/client.ts` +- Create: `extensions/dashboard/contract/shell/test/contract.test.ts` + +The client lazily fetches the CSRF token, retries once on 401 (token expired), and decodes error envelopes into thrown `ContractClientError`s. + +- [ ] **Step 1: Write the failing tests (test/contract.test.ts)** + +```ts +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { ContractClient, ContractClientError } from "../src/contract/client"; + +const server = setupServer(); +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe("ContractClient", () => { + it("query: returns response.data on success", async () => { + server.use( + http.post("/api/dashboard/v1", async ({ request }) => { + const body = (await request.json()) as { kind: string; intent: string }; + expect(body.kind).toBe("query"); + expect(body.intent).toBe("users.list"); + return HttpResponse.json({ + ok: true, + envelope: "v1", + kind: "query", + data: { users: ["alice"] }, + meta: { intentVersion: 1 }, + }); + }), + ); + const c = new ContractClient(); + const data = await c.query<{ users: string[] }>("users", "users.list"); + expect(data.users).toEqual(["alice"]); + }); + + it("error envelope: throws ContractClientError carrying the wire error", async () => { + server.use( + http.post("/api/dashboard/v1", () => + HttpResponse.json( + { ok: false, envelope: "v1", error: { code: "NOT_FOUND", message: "no" } }, + { status: 404 }, + ), + ), + ); + const c = new ContractClient(); + await expect(c.query("x", "y")).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("command: auto-attaches CSRF token (fetched lazily) and idempotency key", async () => { + let csrfFetched = 0; + server.use( + http.get("/api/dashboard/v1/csrf", () => { + csrfFetched++; + return HttpResponse.json({ token: "tok123", expiresAt: new Date(Date.now() + 3600_000).toISOString() }); + }), + http.post("/api/dashboard/v1", async ({ request }) => { + const body = (await request.json()) as { kind: string; csrf?: string; idempotencyKey?: string }; + expect(body.kind).toBe("command"); + expect(body.csrf).toBe("tok123"); + expect(body.idempotencyKey).toBeTruthy(); + return HttpResponse.json({ ok: true, envelope: "v1", kind: "command", data: null, meta: {} }); + }), + ); + const c = new ContractClient(); + await c.command("users", "user.disable", { id: "u1" }); + expect(csrfFetched).toBe(1); + }); + + it("command: refreshes CSRF on 401 and retries once", async () => { + let attempt = 0; + server.use( + http.get("/api/dashboard/v1/csrf", () => + HttpResponse.json({ token: `tok-${++attempt}`, expiresAt: new Date(Date.now() + 3600_000).toISOString() }), + ), + http.post("/api/dashboard/v1", async ({ request }) => { + const body = (await request.json()) as { csrf?: string }; + if (body.csrf === "tok-1") { + return HttpResponse.json( + { ok: false, envelope: "v1", error: { code: "UNAUTHENTICATED" } }, + { status: 401 }, + ); + } + return HttpResponse.json({ ok: true, envelope: "v1", kind: "command", data: null, meta: {} }); + }), + ); + const c = new ContractClient(); + await c.command("users", "do.thing"); + expect(attempt).toBe(2); // refreshed + }); + + it("graph: returns the graph tree on success", async () => { + server.use( + http.post("/api/dashboard/v1", () => + HttpResponse.json({ + ok: true, + envelope: "v1", + kind: "graph", + data: { intent: "page.shell", route: "/x" }, + meta: {}, + }), + ), + ); + const c = new ContractClient(); + const node = await c.graph("core-contract", "/x"); + expect(node.intent).toBe("page.shell"); + }); +}); +``` + +- [ ] **Step 2: Run, expect FAIL** + +```bash +cd extensions/dashboard/contract/shell && pnpm test +``` + +Expected: FAIL — module not found (ContractClient). + +- [ ] **Step 3: Implement client.ts** + +```ts +import type { + ContractError, + EnvelopeResponse, + GraphNode, + Kind, + Request, + Response, +} from "./types"; + +export class ContractClientError extends Error { + readonly code: string; + readonly details?: Record; + readonly retryable?: boolean; + readonly correlationID?: string; + + constructor(err: ContractError) { + super(err.message ?? err.code); + this.code = err.code; + this.details = err.details; + this.retryable = err.retryable; + this.correlationID = err.correlationID; + } +} + +export interface ClientOptions { + baseURL?: string; + fetcher?: typeof fetch; +} + +export class ContractClient { + private readonly baseURL: string; + private readonly fetcher: typeof fetch; + private csrfToken: string | null = null; + + constructor(opts: ClientOptions = {}) { + this.baseURL = opts.baseURL ?? "/api/dashboard/v1"; + this.fetcher = opts.fetcher ?? fetch; + } + + async query(contributor: string, intent: string, payload?: unknown, params?: Record): Promise { + return this.send({ kind: "query", contributor, intent, payload, params }); + } + + async command( + contributor: string, + intent: string, + payload?: unknown, + opts: { idempotencyKey?: string } = {}, + ): Promise { + return this.send({ + kind: "command", + contributor, + intent, + payload, + idempotencyKey: opts.idempotencyKey ?? crypto.randomUUID(), + }); + } + + async graph(contributor: string, route: string): Promise { + return this.send({ kind: "graph", contributor, intent: "page.shell", payload: { route } }); + } + + private async send(input: Omit & { context?: Request["context"] }): Promise { + return this.sendWithRetry(input, /* attempted401Refresh */ false); + } + + private async sendWithRetry( + input: Omit & { context?: Request["context"] }, + attempted401Refresh: boolean, + ): Promise { + if (input.kind === "command" && !this.csrfToken) { + await this.refreshCSRF(); + } + const req: Request = { + envelope: "v1", + kind: input.kind, + contributor: input.contributor, + intent: input.intent, + intentVersion: input.intentVersion, + payload: input.payload, + params: input.params, + context: input.context ?? { route: typeof window !== "undefined" ? window.location.pathname : "/", correlationID: crypto.randomUUID() }, + csrf: input.kind === "command" ? this.csrfToken ?? undefined : undefined, + idempotencyKey: input.idempotencyKey, + }; + + const res = await this.fetcher(this.baseURL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req), + credentials: "include", + }); + + let body: EnvelopeResponse; + try { + body = (await res.json()) as EnvelopeResponse; + } catch { + throw new ContractClientError({ code: "INTERNAL", message: `non-JSON response (status ${res.status})` }); + } + + if (body.ok) { + return (body as Response).data; + } + + // Error envelope. Retry once on 401 (CSRF refresh). + if (!attempted401Refresh && res.status === 401 && input.kind === "command") { + await this.refreshCSRF(); + return this.sendWithRetry(input, true); + } + + throw new ContractClientError(body.error); + } + + private async refreshCSRF(): Promise { + const res = await this.fetcher(`${this.baseURL}/csrf`, { credentials: "include" }); + if (!res.ok) { + this.csrfToken = null; + return; + } + const body = (await res.json()) as { token: string }; + this.csrfToken = body.token; + } +} +``` + +- [ ] **Step 4: Run tests, expect PASS** + +```bash +pnpm test +``` + +Expected: 5 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/contract/shell/src/contract/client.ts extensions/dashboard/contract/shell/test/contract.test.ts +git commit -m "feat(dashboard/contract/shell): contract client with auto-CSRF and idempotency" +``` + +--- + +## Phase 2: SSE Multiplex Consumer + +### Task 2.1: SubscriptionMux + tests + +**Files:** +- Create: `extensions/dashboard/contract/shell/src/contract/sse.ts` +- Create: `extensions/dashboard/contract/shell/test/sse.test.ts` + +JSDOM does not provide `EventSource`. The mux uses `EventSource` directly; tests substitute via constructor injection. + +- [ ] **Step 1: Write the failing tests** + +```ts +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SubscriptionMux } from "../src/contract/sse"; +import type { StreamEvent } from "../src/contract/types"; + +class FakeEventSource { + static instances: FakeEventSource[] = []; + url: string; + readyState = 0; // CONNECTING + onopen: (() => void) | null = null; + onerror: (() => void) | null = null; + private listeners = new Map void>>(); + + constructor(url: string) { + this.url = url; + FakeEventSource.instances.push(this); + queueMicrotask(() => { + this.readyState = 1; + this.onopen?.(); + }); + } + addEventListener(name: string, fn: (ev: MessageEvent) => void) { + const arr = this.listeners.get(name) ?? []; + arr.push(fn); + this.listeners.set(name, arr); + } + removeEventListener() { /* ignore for tests */ } + emit(name: string, data: string) { + const arr = this.listeners.get(name) ?? []; + arr.forEach(fn => fn({ data } as MessageEvent)); + } + close() { + this.readyState = 2; + } +} + +const fetchStub = vi.fn(async (url: string, _init?: RequestInit) => { + if (url.endsWith("/stream/control")) { + return new globalThis.Response(JSON.stringify({}), { status: 200 }) as unknown as Response; + } + throw new Error("unexpected fetch: " + url); +}); + +beforeEach(() => { + FakeEventSource.instances = []; + fetchStub.mockClear(); +}); + +describe("SubscriptionMux", () => { + it("dispatches events to the right subscription handler by SSE event name", async () => { + const mux = new SubscriptionMux({ baseURL: "/api/dashboard/v1", eventSource: FakeEventSource as unknown as typeof EventSource, fetcher: fetchStub as unknown as typeof fetch }); + const received: StreamEvent[] = []; + const unsub = await mux.subscribe("logs", "audit.tail", {}, ev => { received.push(ev); }, { subscriptionID: "s1" }); + const es = FakeEventSource.instances[0]!; + es.emit("hello", JSON.stringify({ streamID: "stream-x" })); + // wait a tick for the hello -> control message round trip + await Promise.resolve(); + es.emit("s1", JSON.stringify({ intent: "audit.tail", mode: "append", payload: { line: "hi" }, seq: 1 })); + expect(received).toHaveLength(1); + expect(received[0]!.payload).toEqual({ line: "hi" }); + unsub(); + }); + + it("sends a control message on subscribe and on unsubscribe", async () => { + const mux = new SubscriptionMux({ baseURL: "/api/dashboard/v1", eventSource: FakeEventSource as unknown as typeof EventSource, fetcher: fetchStub as unknown as typeof fetch }); + const unsub = await mux.subscribe("logs", "audit.tail", {}, () => {}, { subscriptionID: "s1" }); + FakeEventSource.instances[0]!.emit("hello", JSON.stringify({ streamID: "stream-x" })); + await Promise.resolve(); + expect(fetchStub).toHaveBeenCalledWith( + "/api/dashboard/v1/stream/control", + expect.objectContaining({ method: "POST" }), + ); + const subscribeBody = JSON.parse(fetchStub.mock.calls.find(([, init]) => (init as RequestInit).body)![1]!.body as string); + expect(subscribeBody.op).toBe("subscribe"); + expect(subscribeBody.subscriptionID).toBe("s1"); + + fetchStub.mockClear(); + unsub(); + await Promise.resolve(); + const unsubscribeBody = JSON.parse(fetchStub.mock.calls[0]![1]!.body as string); + expect(unsubscribeBody.op).toBe("unsubscribe"); + }); +}); + +afterEach(() => { + FakeEventSource.instances.forEach(es => es.close()); +}); +``` + +- [ ] **Step 2: Run, expect FAIL** + +```bash +pnpm test +``` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement sse.ts** + +```ts +import type { StreamEvent } from "./types"; + +export interface SubscriptionMuxOptions { + baseURL?: string; + eventSource?: typeof EventSource; + fetcher?: typeof fetch; +} + +interface PendingSub { + contributor: string; + intent: string; + params: Record; + subscriptionID: string; + handler: (ev: StreamEvent) => void; +} + +export class SubscriptionMux { + private readonly baseURL: string; + private readonly EventSourceCtor: typeof EventSource; + private readonly fetcher: typeof fetch; + private es: EventSource | null = null; + private streamID: string | null = null; + private pending: PendingSub[] = []; + private active = new Map(); + + constructor(opts: SubscriptionMuxOptions = {}) { + this.baseURL = opts.baseURL ?? "/api/dashboard/v1"; + this.EventSourceCtor = opts.eventSource ?? globalThis.EventSource; + this.fetcher = opts.fetcher ?? globalThis.fetch; + } + + async subscribe( + contributor: string, + intent: string, + params: Record, + handler: (ev: StreamEvent) => void, + opts: { subscriptionID?: string } = {}, + ): Promise<() => void> { + const subscriptionID = opts.subscriptionID ?? crypto.randomUUID(); + const sub: PendingSub = { contributor, intent, params, subscriptionID, handler }; + + if (!this.es) { + this.openStream(); + } + this.attachSubscriptionListener(sub); + + if (this.streamID) { + await this.sendControl({ op: "subscribe", subscriptionID, contributor, intent, params }); + this.active.set(subscriptionID, sub); + } else { + this.pending.push(sub); + } + + return () => this.unsubscribe(subscriptionID); + } + + private openStream(): void { + this.es = new this.EventSourceCtor(`${this.baseURL}/stream`); + this.es.addEventListener("hello", (ev: MessageEvent) => { + const { streamID } = JSON.parse(ev.data) as { streamID: string }; + this.streamID = streamID; + // Drain pending subscriptions in registration order. + const drain = this.pending.splice(0); + void Promise.all( + drain.map(sub => { + this.active.set(sub.subscriptionID, sub); + return this.sendControl({ + op: "subscribe", + subscriptionID: sub.subscriptionID, + contributor: sub.contributor, + intent: sub.intent, + params: sub.params, + }); + }), + ); + }); + } + + private attachSubscriptionListener(sub: PendingSub): void { + if (!this.es) return; + this.es.addEventListener(sub.subscriptionID, (ev: MessageEvent) => { + try { + const parsed = JSON.parse(ev.data) as StreamEvent; + sub.handler(parsed); + } catch { + // Drop malformed events. + } + }); + } + + private async unsubscribe(subscriptionID: string): Promise { + this.active.delete(subscriptionID); + if (!this.streamID) return; + await this.sendControl({ op: "unsubscribe", subscriptionID }); + } + + private async sendControl(msg: Record): Promise { + if (!this.streamID) return; + await this.fetcher(`${this.baseURL}/stream/control`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ streamID: this.streamID, ...msg }), + credentials: "include", + }); + } + + close(): void { + this.es?.close(); + this.es = null; + this.streamID = null; + this.active.clear(); + this.pending = []; + } +} +``` + +- [ ] **Step 4: Run, expect PASS** + +```bash +pnpm test +``` + +Expected: 7 tests PASS (5 contract + 2 sse). + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/contract/shell/src/contract/sse.ts extensions/dashboard/contract/shell/test/sse.test.ts +git commit -m "feat(dashboard/contract/shell): SSE multiplex consumer" +``` + +--- + +## Phase 3: Auth Principal Store + React Query Hooks + +### Task 3.1: Principal store + +**Files:** +- Create: `extensions/dashboard/contract/shell/src/auth/principal.ts` + +- [ ] **Step 1: Write principal.ts** + +```ts +import { create } from "zustand"; +import type { Principal } from "../contract/types"; + +interface PrincipalState { + principal: Principal | null; + loaded: boolean; + error: string | null; + load: (fetcher?: typeof fetch) => Promise; +} + +export const usePrincipalStore = create(set => ({ + principal: null, + loaded: false, + error: null, + async load(fetcher = fetch) { + try { + const res = await fetcher("/api/dashboard/v1/principal", { credentials: "include" }); + if (!res.ok) { + set({ loaded: true, error: `HTTP ${res.status}`, principal: null }); + return; + } + const principal = (await res.json()) as Principal; + set({ principal, loaded: true, error: null }); + } catch (err) { + set({ loaded: true, error: String(err), principal: null }); + } + }, +})); +``` + +(No tests for this in slice (d) — Phase 7 covers the integration via the smoke test.) + +- [ ] **Step 2: Verify lint** + +```bash +pnpm lint +``` + +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +git add extensions/dashboard/contract/shell/src/auth/principal.ts +git commit -m "feat(dashboard/contract/shell): principal store via Zustand" +``` + +### Task 3.2: React Query hooks (useGraph, useQuery, useCommand, useSubscription) + +**Files:** +- Create: `extensions/dashboard/contract/shell/src/contract/hooks.ts` + +The hooks compose React Query and the SubscriptionMux into the public API the intent components use. + +- [ ] **Step 1: Write hooks.ts** + +```ts +import { useEffect, useRef, useState } from "react"; +import { useMutation, useQuery as useRQ } from "@tanstack/react-query"; +import { ContractClient } from "./client"; +import { SubscriptionMux } from "./sse"; +import type { GraphNode, StreamEvent } from "./types"; + +const sharedClient = new ContractClient(); +const sharedMux = new SubscriptionMux(); + +export function useContractGraph(contributor: string, route: string) { + return useRQ({ + queryKey: ["graph", contributor, route], + queryFn: () => sharedClient.graph(contributor, route), + }); +} + +export function useContractQuery(contributor: string, intent: string, payload?: unknown, params?: Record) { + return useRQ({ + queryKey: ["query", contributor, intent, payload, params], + queryFn: () => sharedClient.query(contributor, intent, payload, params), + }); +} + +export function useContractCommand(contributor: string, intent: string) { + return useMutation({ + mutationFn: payload => sharedClient.command(contributor, intent, payload), + }); +} + +export function useSubscription(contributor: string, intent: string, params: Record = {}) { + const [latest, setLatest] = useState | null>(null); + const handlerRef = useRef((ev: StreamEvent) => setLatest(ev)); + + useEffect(() => { + let unsub: (() => void) | null = null; + let cancelled = false; + void sharedMux.subscribe(contributor, intent, params, ev => handlerRef.current(ev as StreamEvent)).then(u => { + if (cancelled) { + u(); + return; + } + unsub = u; + }); + return () => { + cancelled = true; + unsub?.(); + }; + }, [contributor, intent, JSON.stringify(params)]); + + return latest; +} +``` + +- [ ] **Step 2: Verify lint** + +```bash +pnpm lint +``` + +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +git add extensions/dashboard/contract/shell/src/contract/hooks.ts +git commit -m "feat(dashboard/contract/shell): React Query hooks for graph, query, command, subscription" +``` + +--- + +## Phase 4: Intent Registry + Slot Renderer + +### Task 4.1: Registry + context + +**Files:** +- Create: `extensions/dashboard/contract/shell/src/runtime/registry.ts` +- Create: `extensions/dashboard/contract/shell/src/runtime/context.ts` + +- [ ] **Step 1: Write registry.ts** + +```ts +import type { ComponentType } from "react"; +import type { GraphNode } from "../contract/types"; + +export interface IntentComponentProps> { + node: GraphNode; + data?: TData; + props: TProps; + slots: Record; +} + +export type IntentComponent = ComponentType>>; + +export class IntentRegistry { + private byName = new Map(); + + register(name: string, component: IntentComponent): this { + if (this.byName.has(name)) { + throw new Error(`intent ${name} already registered`); + } + this.byName.set(name, component); + return this; + } + + resolve(name: string): IntentComponent | undefined { + return this.byName.get(name); + } + + has(name: string): boolean { + return this.byName.has(name); + } +} +``` + +- [ ] **Step 2: Write context.ts** + +```tsx +import { createContext, useContext } from "react"; +import type { ReactNode } from "react"; +import type { IntentRegistry } from "./registry"; + +const RegistryContext = createContext(null); + +export function IntentRegistryProvider({ value, children }: { value: IntentRegistry; children: ReactNode }) { + return {children}; +} + +export function useIntentRegistry(): IntentRegistry { + const reg = useContext(RegistryContext); + if (!reg) throw new Error("useIntentRegistry called outside IntentRegistryProvider"); + return reg; +} +``` + +- [ ] **Step 3: Verify lint and commit** + +```bash +pnpm lint +git add extensions/dashboard/contract/shell/src/runtime/{registry.ts,context.ts} +git commit -m "feat(dashboard/contract/shell): intent registry with React context" +``` + +### Task 4.2: Renderer + slots + fallbacks + tests + +**Files:** +- Create: `extensions/dashboard/contract/shell/src/runtime/fallbacks.tsx` +- Create: `extensions/dashboard/contract/shell/src/runtime/slots.tsx` +- Create: `extensions/dashboard/contract/shell/src/runtime/renderer.tsx` +- Create: `extensions/dashboard/contract/shell/test/renderer.test.tsx` + +- [ ] **Step 1: Write fallbacks.tsx** + +```tsx +export function UnknownIntent({ intent }: { intent: string }) { + return ( +
+ Unknown intent: {intent} +
+ ); +} + +export function LoadingNode() { + return
Loading…
; +} + +export function ErrorNode({ message }: { message: string }) { + return ( +
+ Error: {message} +
+ ); +} +``` + +- [ ] **Step 2: Write slots.tsx** + +```tsx +import type { GraphNode } from "../contract/types"; +import { GraphRenderer } from "./renderer"; + +export function SlotRenderer({ slot, slots }: { slot: string; slots: Record }) { + const children = slots[slot] ?? []; + return ( + <> + {children.map((child, i) => ( + + ))} + + ); +} +``` + +- [ ] **Step 3: Write renderer.tsx** + +```tsx +import { useIntentRegistry } from "./context"; +import { UnknownIntent } from "./fallbacks"; +import type { GraphNode } from "../contract/types"; + +export function GraphRenderer({ node }: { node: GraphNode }) { + const registry = useIntentRegistry(); + const Component = registry.resolve(node.intent); + if (!Component) return ; + return ( + + ); +} +``` + +(Data binding resolution is intentionally minimal here. Slice (e)'s vocabulary components handle their own data via the React Query hooks; the renderer just passes the node through. The `data` prop is reserved for future enhancements that pre-resolve queries via the data router.) + +- [ ] **Step 4: Write the failing tests** + +```tsx +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { IntentRegistry } from "../src/runtime/registry"; +import { IntentRegistryProvider } from "../src/runtime/context"; +import { GraphRenderer } from "../src/runtime/renderer"; +import { SlotRenderer } from "../src/runtime/slots"; +import type { GraphNode } from "../src/contract/types"; + +function setup(node: GraphNode, registry: IntentRegistry) { + return render( + + + , + ); +} + +describe("GraphRenderer", () => { + it("renders a registered intent's component", () => { + const reg = new IntentRegistry(); + reg.register("hello", () =>

hello world

); + setup({ intent: "hello" }, reg); + expect(screen.getByText("hello world")).toBeInTheDocument(); + }); + + it("renders UnknownIntent fallback when intent is not registered", () => { + const reg = new IntentRegistry(); + setup({ intent: "missing" }, reg); + expect(screen.getByText(/Unknown intent/i)).toBeInTheDocument(); + expect(screen.getByText("missing")).toBeInTheDocument(); + }); + + it("recursively renders slot children via SlotRenderer", () => { + const reg = new IntentRegistry(); + reg.register("parent", ({ slots }) => ( +
+ +
+ )); + reg.register("leaf", ({ node }) => leaf-{node.intent}); + const node: GraphNode = { + intent: "parent", + slots: { main: [{ intent: "leaf" }, { intent: "leaf" }] }, + }; + setup(node, reg); + expect(screen.getByTestId("parent")).toBeInTheDocument(); + expect(screen.getAllByText(/leaf-leaf/)).toHaveLength(2); + }); +}); + +describe("IntentRegistry", () => { + it("rejects double registration", () => { + const reg = new IntentRegistry(); + reg.register("x", () => null); + expect(() => reg.register("x", () => null)).toThrow(/already registered/); + }); +}); +``` + +- [ ] **Step 5: Run, expect PASS** + +```bash +pnpm test +``` + +Expected: 11 tests PASS (5 contract + 2 sse + 4 renderer). + +- [ ] **Step 6: Commit** + +```bash +git add extensions/dashboard/contract/shell/src/runtime/{fallbacks.tsx,slots.tsx,renderer.tsx} extensions/dashboard/contract/shell/test/renderer.test.tsx +git commit -m "feat(dashboard/contract/shell): graph renderer with slot expansion and fallbacks" +``` + +--- + +## Phase 5: page.shell + metric.counter Components + +### Task 5.1: page.shell + +**Files:** +- Create: `extensions/dashboard/contract/shell/src/intents/page.shell.tsx` + +- [ ] **Step 1: Write page.shell.tsx** + +```tsx +import { SlotRenderer } from "../runtime/slots"; +import { usePrincipalStore } from "../auth/principal"; +import type { IntentComponentProps } from "../runtime/registry"; + +export function PageShell({ node, slots }: IntentComponentProps) { + const principal = usePrincipalStore(s => s.principal); + const title = node.title ?? "Dashboard"; + return ( +
+
+

{title}

+
+ {principal ? {principal.displayName} : Loading…} +
+
+
+ +
+
+ ); +} +``` + +### Task 5.2: metric.counter + +**Files:** +- Create: `extensions/dashboard/contract/shell/src/intents/metric.counter.tsx` + +The component subscribes to the `metric.summary` intent (or whatever the data binding declares) and displays a value. For slice (d), we hardcode the contributor and the metric name from `node.data.intent` so the pilot's `/metrics/live` page renders cleanly. + +- [ ] **Step 1: Write metric.counter.tsx** + +```tsx +import { useSubscription } from "../contract/hooks"; +import type { IntentComponentProps } from "../runtime/registry"; + +interface CounterProps { + title?: string; +} + +interface CounterPayload { + totalMetrics?: number; + cpuPercent?: number; + [k: string]: unknown; +} + +const CONTRIBUTOR = "core-contract"; + +export function MetricCounter({ node, props }: IntentComponentProps) { + const subscriptionIntent = node.data?.intent; + const ev = useSubscription(CONTRIBUTOR, subscriptionIntent ?? "metrics.summary"); + + const value = ev?.payload.totalMetrics ?? ev?.payload.cpuPercent ?? "—"; + const title = props.title ?? node.title ?? subscriptionIntent ?? "Metric"; + + return ( +
+
{title}
+
{value}
+
+ ); +} +``` + +> **Note**: the contributor name `core-contract` matches the pilot from slice (c). Slice (e) generalizes this — the data binding's params will carry the contributor. + +### Task 5.3: register.ts + tests + +**Files:** +- Create: `extensions/dashboard/contract/shell/src/intents/register.ts` +- Modify: `extensions/dashboard/contract/shell/test/renderer.test.tsx` (add a smoke test for built-ins) + +- [ ] **Step 1: Write register.ts** + +```ts +import { IntentRegistry } from "../runtime/registry"; +import { PageShell } from "./page.shell"; +import { MetricCounter } from "./metric.counter"; + +export function buildIntentRegistry(): IntentRegistry { + const reg = new IntentRegistry(); + reg.register("page.shell", PageShell as any); + reg.register("metric.counter", MetricCounter as any); + return reg; +} +``` + +- [ ] **Step 2: Add a built-ins test** + +Append to `test/renderer.test.tsx`: + +```tsx +import { buildIntentRegistry } from "../src/intents/register"; + +describe("buildIntentRegistry", () => { + it("registers page.shell and metric.counter", () => { + const reg = buildIntentRegistry(); + expect(reg.has("page.shell")).toBe(true); + expect(reg.has("metric.counter")).toBe(true); + }); +}); +``` + +- [ ] **Step 3: Run, expect PASS** + +```bash +pnpm test +``` + +Expected: 12 tests PASS. + +- [ ] **Step 4: Commit** + +```bash +git add extensions/dashboard/contract/shell/src/intents/ extensions/dashboard/contract/shell/test/renderer.test.tsx +git commit -m "feat(dashboard/contract/shell): page.shell and metric.counter intent components" +``` + +--- + +## Phase 6: App Routing + Smoke Test + +### Task 6.1: App.tsx — providers + router + +**Files:** +- Modify: `extensions/dashboard/contract/shell/src/App.tsx` + +- [ ] **Step 1: Replace App.tsx with the wired version** + +```tsx +import { useEffect } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { BrowserRouter, Route, Routes, useParams } from "react-router-dom"; +import { IntentRegistryProvider } from "./runtime/context"; +import { buildIntentRegistry } from "./intents/register"; +import { GraphRenderer } from "./runtime/renderer"; +import { useContractGraph } from "./contract/hooks"; +import { LoadingNode, ErrorNode } from "./runtime/fallbacks"; +import { usePrincipalStore } from "./auth/principal"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { staleTime: 5_000, refetchOnWindowFocus: false }, + }, +}); + +const registry = buildIntentRegistry(); + +function PageRoute() { + const params = useParams(); + const route = `/${params["*"] ?? ""}`; + const { data, isLoading, error } = useContractGraph("core-contract", route); + + if (isLoading) return ; + if (error) return ; + if (!data) return ; + return ; +} + +export function App() { + const loadPrincipal = usePrincipalStore(s => s.load); + useEffect(() => { void loadPrincipal(); }, [loadPrincipal]); + + return ( + + + + + } /> + + + + + ); +} +``` + +- [ ] **Step 2: Verify build** + +```bash +pnpm build +``` + +Expected: clean build. + +- [ ] **Step 3: Verify lint** + +```bash +pnpm lint +``` + +Expected: clean. + +### Task 6.2: Smoke test + +**Files:** +- Create: `extensions/dashboard/contract/shell/test/smoke.test.tsx` + +The smoke test mounts the App, intercepts `POST /api/dashboard/v1` (returns a graph for `/metrics/live`), and verifies the metric.counter renders. SSE is mocked but doesn't fire events in this test — the title rendering is enough to prove the runtime resolves and renders. + +- [ ] **Step 1: Write smoke.test.tsx** + +```tsx +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { App } from "../src/App"; + +const server = setupServer( + http.get("/api/dashboard/v1/principal", () => + HttpResponse.json({ subject: "alice", displayName: "Alice", roles: [], scopes: [] }), + ), + http.post("/api/dashboard/v1", () => + HttpResponse.json({ + ok: true, envelope: "v1", kind: "graph", + data: { + intent: "page.shell", + title: "Live Metrics", + slots: { + main: [ + { + intent: "metric.counter", + title: "Total Metrics", + data: { intent: "metrics.summary" }, + }, + ], + }, + }, + meta: {}, + }), + ), +); + +beforeAll(() => { + // jsdom has no EventSource; provide a noop class to keep SubscriptionMux from crashing + // when buildIntentRegistry's metric.counter mounts and subscribes. + (globalThis as any).EventSource = class { + constructor(public url: string) {} + addEventListener() {} + removeEventListener() {} + close() {} + onopen: (() => void) | null = null; + onerror: (() => void) | null = null; + }; + // Override window.location.pathname to a known route. + history.pushState({}, "", "/dashboard/contract/app/metrics/live"); + server.listen({ onUnhandledRequest: "error" }); +}); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe("App smoke", () => { + it("renders page.shell with metric.counter from a fetched graph", async () => { + render(); + expect(await screen.findByText("Live Metrics")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText("Total Metrics")).toBeInTheDocument(); + }); + expect(screen.getByText("Alice")).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run, expect PASS** + +```bash +pnpm test +``` + +Expected: 13 tests PASS. + +- [ ] **Step 3: Commit** + +```bash +git add extensions/dashboard/contract/shell/src/App.tsx extensions/dashboard/contract/shell/test/smoke.test.tsx +git commit -m "feat(dashboard/contract/shell): App routing + smoke test through page.shell + metric.counter" +``` + +--- + +## Phase 7: Go-side Wire-up + +### Task 7.1: Principal endpoint + +**Files:** +- Create: `extensions/dashboard/handlers/principal.go` +- Create: `extensions/dashboard/handlers/principal_test.go` + +- [ ] **Step 1: Write the failing test** + +```go +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + dashauth "github.com/xraph/forge/extensions/dashboard/auth" +) + +func TestHandleAPIPrincipal_OK(t *testing.T) { + user := &dashauth.UserInfo{Subject: "alice", DisplayName: "Alice", Roles: []string{"admin"}, Scopes: []string{"users.read"}} + ctx := dashauth.WithUser(context.Background(), user) + req := httptest.NewRequest(http.MethodGet, "/api/dashboard/v1/principal", nil).WithContext(ctx) + w := httptest.NewRecorder() + HandleAPIPrincipalHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", w.Code, w.Body) + } + var body map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if body["subject"] != "alice" || body["displayName"] != "Alice" { + t.Errorf("unexpected body: %v", body) + } +} + +func TestHandleAPIPrincipal_Unauthenticated(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/dashboard/v1/principal", nil) + w := httptest.NewRecorder() + HandleAPIPrincipalHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", w.Code) + } +} +``` + +> **Note**: confirm the `dashauth.WithUser` helper exists. If the auth package doesn't expose a `WithUser(ctx, user) context.Context`, use the existing `UserFromContext` lookup pattern and find the corresponding setter (likely `SetUserOnContext` or unexported). If no public setter exists, the test can construct a request whose context has the user via direct context.WithValue using `dashauth`'s context key. Adjust as needed; keep behavior identical. + +- [ ] **Step 2: Run, expect FAIL** + +```bash +go test ./extensions/dashboard/handlers/... +``` + +Expected: FAIL — undefined HandleAPIPrincipalHTTP. + +- [ ] **Step 3: Implement principal.go** + +```go +package handlers + +import ( + "encoding/json" + "net/http" + + dashauth "github.com/xraph/forge/extensions/dashboard/auth" +) + +// principalResponse is the wire shape for GET /api/dashboard/v1/principal. +type principalResponse struct { + Subject string `json:"subject"` + DisplayName string `json:"displayName"` + Email string `json:"email,omitempty"` + Roles []string `json:"roles"` + Scopes []string `json:"scopes"` +} + +// HandleAPIPrincipalHTTP returns the current user's principal info as JSON. +// 401 when no user is in context. +func HandleAPIPrincipalHTTP(w http.ResponseWriter, r *http.Request) { + user := dashauth.UserFromContext(r.Context()) + if user == nil { + http.Error(w, "unauthenticated", http.StatusUnauthorized) + return + } + resp := principalResponse{ + Subject: user.Subject, + DisplayName: user.DisplayName, + Email: user.Email, + Roles: append([]string{}, user.Roles...), + Scopes: append([]string{}, user.Scopes...), + } + if resp.DisplayName == "" { + resp.DisplayName = resp.Subject + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} +``` + +- [ ] **Step 4: Run, expect PASS** + +```bash +go test ./extensions/dashboard/handlers/... +``` + +Expected: 2 new tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add extensions/dashboard/handlers/principal.go extensions/dashboard/handlers/principal_test.go +git commit -m "feat(dashboard): principal endpoint exposing UserInfo to the React shell" +``` + +### Task 7.2: Embed shell + register routes + +**Files:** +- Create: `extensions/dashboard/contract/shell/embed.go` +- Modify: `extensions/dashboard/extension.go` + +- [ ] **Step 1: Write embed.go** + +```go +package shell + +import ( + "embed" + "io/fs" +) + +//go:embed all:dist +var distFS embed.FS + +// FS returns the production-built shell's static files. +// Files live under "dist/" within the embedded FS; the returned fs.FS strips +// that prefix so the static handler sees a flat root. +func FS() (fs.FS, error) { + return fs.Sub(distFS, "dist") +} +``` + +> **Build dependency**: this file refers to `dist/` which is created by `pnpm build`. For the Go build to succeed, `pnpm install && pnpm build` must run inside `shell/` first. CI updates and a future `Makefile` target are out of scope for this slice but flagged as a follow-up. + +- [ ] **Step 2: Add a placeholder dist directory so the embed compiles** + +```bash +mkdir -p extensions/dashboard/contract/shell/dist +echo "
" > extensions/dashboard/contract/shell/dist/index.html +``` + +This file is gitignored locally; CI builds the real one. + +- [ ] **Step 3: Modify extension.go — register principal endpoint and SPA static handler** + +In `extensions/dashboard/extension.go`, find where the contract routes are registered (search for `e.handleContractCapabilities`). Add three new route registrations alongside: + +```go +import ( + "net/http" + "path" + "strings" + + "github.com/xraph/forge/extensions/dashboard/contract/shell" + "github.com/xraph/forge/extensions/dashboard/handlers" +) + +// Inside the contract registration block: +must(router.GET(base+"/api/dashboard/v1/principal", handlers.HandleAPIPrincipalHTTP)) + +// Static + SPA fallback for the React shell: +shellFS, err := shell.FS() +if err != nil { + return fmt.Errorf("dashboard: load shell embed: %w", err) +} +must(router.GET(base+"/contract/static/*filepath", e.makeShellStaticHandler(shellFS))) +must(router.GET(base+"/contract/app", e.makeShellSPAHandler(shellFS))) +must(router.GET(base+"/contract/app/*filepath", e.makeShellSPAHandler(shellFS))) +``` + +Add the helpers at the bottom of `extension.go` (or in a new `extension_shell.go`): + +```go +func (e *Extension) makeShellStaticHandler(shellFS fs.FS) http.HandlerFunc { + fileServer := http.FileServer(http.FS(shellFS)) + return func(w http.ResponseWriter, r *http.Request) { + // Strip the /dashboard/contract/static prefix so fileServer sees a clean path. + prefix := e.config.BasePath + "/contract/static" + r2 := *r + r2.URL = &url.URL{Path: strings.TrimPrefix(r.URL.Path, prefix)} + // Aggressive cache for hashed assets. + if strings.Contains(r.URL.Path, "/assets/") { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } + fileServer.ServeHTTP(w, &r2) + } +} + +func (e *Extension) makeShellSPAHandler(shellFS fs.FS) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + f, err := shellFS.Open("index.html") + if err != nil { + http.Error(w, "shell index missing — has the shell been built?", http.StatusInternalServerError) + return + } + defer f.Close() + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + _, _ = io.Copy(w, f) + } +} +``` + +Add imports: `"io"`, `"io/fs"`, `"net/url"`, `"strings"`. + +> **Notes on router shape**: the `*filepath` syntax depends on the underlying router (BunRouter). Confirm via `grep "filepath\|wildcard" internal/router/`. If `*filepath` doesn't work, use whatever wildcard the router supports (e.g., `*` alone). The intent is: any path under `/dashboard/contract/static/` resolves a file from the embedded FS. + +- [ ] **Step 4: Build the whole module** + +```bash +go build ./... +``` + +Expected: clean build. The placeholder `dist/index.html` is enough for the embed to satisfy the compiler. + +- [ ] **Step 5: Test** + +```bash +go test ./extensions/dashboard/... +``` + +Expected: all prior tests pass; the new principal test added in Task 7.1 also passes. + +- [ ] **Step 6: Vet** + +```bash +go vet ./extensions/dashboard/... +``` + +Expected: clean. + +- [ ] **Step 7: Commit** + +```bash +git add extensions/dashboard/contract/shell/embed.go extensions/dashboard/extension.go +git commit -m "feat(dashboard): embed React shell and serve static + SPA routes" +``` + +--- + +## Phase 8: Final Verification + +- [ ] **Step 1: Build the React shell for real (replaces the placeholder dist)** + +```bash +cd extensions/dashboard/contract/shell +pnpm install # first time / lockfile change +pnpm build +``` + +Expected: `dist/` populated with hashed JS/CSS bundles + `index.html`. + +- [ ] **Step 2: Build the Go binary with the real shell embedded** + +```bash +cd /Users/rexraphael/Work/xraph/forge +go build ./... +``` + +Expected: clean build; the binary's `embed.FS` now contains the real bundle. + +- [ ] **Step 3: Run all tests** + +```bash +go test -count=1 ./extensions/dashboard/... +go test -race -count=1 ./extensions/dashboard/contract/... +go vet ./extensions/dashboard/... +cd extensions/dashboard/contract/shell && pnpm test +``` + +All four must be clean. + +- [ ] **Step 4: Manual smoke (optional, post-merge)** + +Start the dashboard, browse to `http://localhost:8080/dashboard/contract/app/metrics/live`. Should render the page shell with the Total Metrics counter. CSRF token + stream subscription should connect within 1s. + +- [ ] **Step 5: Final commit if anything's still dangling** + +```bash +git status +``` + +If clean, slice (d) is complete. + +## Self-Review Notes + +- **Spec coverage:** Every section of SLICE_D_DESIGN.md maps to a phase. Project layout → Phase 0; contract types → Phase 1.1; client → Phase 1.2; SSE → Phase 2; principal store → Phase 3.1; React Query hooks → Phase 3.2; intent registry + context → Phase 4.1; renderer + slots + fallbacks → Phase 4.2; page.shell + metric.counter + register → Phase 5; App routing + smoke → Phase 6; principal endpoint → Phase 7.1; embed + static handler + SPA route → Phase 7.2; verification → Phase 8. +- **Spec deviations**: none functionally. The plan's data binding is intentionally minimal in renderer.tsx; data flows through React Query hooks at the leaf component, which is the simpler v1 shape. Slice (e) can layer pre-fetching via the data router. +- **No placeholders**: every TDD cycle has real test code + real implementation. Two informational notes in Phase 7 ask the implementer to verify dashauth/router shape before using the verbatim code — these are honest verify-before-using notes, not unfinished spec. +- **Type consistency**: `GraphNode`, `Request`, `Response`, `StreamEvent`, `IntentComponent`, `IntentRegistry` are defined once and used identically across phases. The TS types mirror the Go envelope shapes exactly. +- **Out-of-scope items honored**: Slice (e) vocabulary, slice (f) migrations, iframe escape hatch, browser E2E, login UI — all stay where the design says. From 5b9ec20e5668d110fd67e814e21adcb25c9e941b Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sun, 10 May 2026 00:14:33 -0500 Subject: [PATCH 55/89] feat(dashboard/contract/shell): scaffold React+TypeScript+Vite project --- .../dashboard/contract/shell/.gitignore | 7 + extensions/dashboard/contract/shell/README.md | 24 + .../dashboard/contract/shell/package.json | 38 + .../dashboard/contract/shell/pnpm-lock.yaml | 3043 +++++++++++++++++ .../contract/shell/postcss.config.js | 6 + .../contract/shell/tailwind.config.ts | 9 + .../dashboard/contract/shell/tsconfig.json | 23 + .../contract/shell/tsconfig.node.json | 11 + .../dashboard/contract/shell/vite.config.ts | 34 + .../dashboard/contract/shell/vitest.config.ts | 13 + 10 files changed, 3208 insertions(+) create mode 100644 extensions/dashboard/contract/shell/.gitignore create mode 100644 extensions/dashboard/contract/shell/README.md create mode 100644 extensions/dashboard/contract/shell/package.json create mode 100644 extensions/dashboard/contract/shell/pnpm-lock.yaml create mode 100644 extensions/dashboard/contract/shell/postcss.config.js create mode 100644 extensions/dashboard/contract/shell/tailwind.config.ts create mode 100644 extensions/dashboard/contract/shell/tsconfig.json create mode 100644 extensions/dashboard/contract/shell/tsconfig.node.json create mode 100644 extensions/dashboard/contract/shell/vite.config.ts create mode 100644 extensions/dashboard/contract/shell/vitest.config.ts diff --git a/extensions/dashboard/contract/shell/.gitignore b/extensions/dashboard/contract/shell/.gitignore new file mode 100644 index 00000000..07a45267 --- /dev/null +++ b/extensions/dashboard/contract/shell/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +.vite/ +*.log +.env +.env.local +coverage/ diff --git a/extensions/dashboard/contract/shell/README.md b/extensions/dashboard/contract/shell/README.md new file mode 100644 index 00000000..70a31f63 --- /dev/null +++ b/extensions/dashboard/contract/shell/README.md @@ -0,0 +1,24 @@ +# Dashboard Contract Shell + +The React/TypeScript runtime that consumes the dashboard contract. + +## Development + +```bash +pnpm install +pnpm dev # Vite dev server on :5173, proxies /api/dashboard/* to :8080 +``` + +## Build + +```bash +pnpm build # Emits dist/ — embedded into the dashboard Go binary via //go:embed +``` + +## Test + +```bash +pnpm test +``` + +See [SLICE_D_DESIGN.md](../SLICE_D_DESIGN.md) for the architecture and [SLICE_D_PLAN.md](../SLICE_D_PLAN.md) for the implementation plan. diff --git a/extensions/dashboard/contract/shell/package.json b/extensions/dashboard/contract/shell/package.json new file mode 100644 index 00000000..8fa2d574 --- /dev/null +++ b/extensions/dashboard/contract/shell/package.json @@ -0,0 +1,38 @@ +{ + "name": "@forge/dashboard-shell", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "lint": "tsc --noEmit", + "format": "prettier --write src test" + }, + "dependencies": { + "@tanstack/react-query": "^5.40.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "react-router-dom": "^6.26.0", + "zustand": "^4.5.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.4.0", + "@testing-library/react": "^16.0.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "autoprefixer": "^10.4.0", + "jsdom": "^25.0.0", + "msw": "^2.4.0", + "postcss": "^8.4.0", + "prettier": "^3.3.0", + "tailwindcss": "^3.4.0", + "typescript": "^5.5.0", + "vite": "^5.4.0", + "vitest": "^2.1.0" + } +} diff --git a/extensions/dashboard/contract/shell/pnpm-lock.yaml b/extensions/dashboard/contract/shell/pnpm-lock.yaml new file mode 100644 index 00000000..2d3a661f --- /dev/null +++ b/extensions/dashboard/contract/shell/pnpm-lock.yaml @@ -0,0 +1,3043 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@tanstack/react-query': + specifier: ^5.40.0 + version: 5.100.9(react@18.3.1) + react: + specifier: ^18.3.0 + version: 18.3.1 + react-dom: + specifier: ^18.3.0 + version: 18.3.1(react@18.3.1) + react-router-dom: + specifier: ^6.26.0 + version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + zustand: + specifier: ^4.5.0 + version: 4.5.7(@types/react@18.3.28)(react@18.3.1) + devDependencies: + '@testing-library/jest-dom': + specifier: ^6.4.0 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.0.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ^18.3.0 + version: 18.3.28 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.7(@types/react@18.3.28) + '@vitejs/plugin-react': + specifier: ^4.3.0 + version: 4.7.0(vite@5.4.21(@types/node@25.6.2)) + autoprefixer: + specifier: ^10.4.0 + version: 10.5.0(postcss@8.5.14) + jsdom: + specifier: ^25.0.0 + version: 25.0.1 + msw: + specifier: ^2.4.0 + version: 2.14.5(@types/node@25.6.2)(typescript@5.9.3) + postcss: + specifier: ^8.4.0 + version: 8.5.14 + prettier: + specifier: ^3.3.0 + version: 3.8.3 + tailwindcss: + specifier: ^3.4.0 + version: 3.4.19 + typescript: + specifier: ^5.5.0 + version: 5.9.3 + vite: + specifier: ^5.4.0 + version: 5.4.21(@types/node@25.6.2) + vitest: + specifier: ^2.1.0 + version: 2.1.9(@types/node@25.6.2)(jsdom@25.0.1)(msw@2.14.5(@types/node@25.6.2)(typescript@5.9.3)) + +packages: + + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.3': + resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@inquirer/ansi@2.0.5': + resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + + '@inquirer/confirm@6.0.12': + resolution: {integrity: sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@11.1.9': + resolution: {integrity: sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@2.0.5': + resolution: {integrity: sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + + '@inquirer/type@4.0.5': + resolution: {integrity: sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@mswjs/interceptors@0.41.8': + resolution: {integrity: sha512-pRLMNKTSGRoLq+KnEB/7OY5vijw1XmcheAAOiv6pj7W1FG32kAGqj1C/RK/cqxRGr1Fh+zBi8sDur8kj3EQv6A==} + engines: {node: '>=18'} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/deferred-promise@3.0.0': + resolution: {integrity: sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + + '@remix-run/router@1.23.2': + resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==} + engines: {node: '>=14.0.0'} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.60.3': + resolution: {integrity: sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.3': + resolution: {integrity: sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.3': + resolution: {integrity: sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.3': + resolution: {integrity: sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.3': + resolution: {integrity: sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.3': + resolution: {integrity: sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': + resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.60.3': + resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.60.3': + resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.60.3': + resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.60.3': + resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.60.3': + resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.60.3': + resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.60.3': + resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.60.3': + resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.60.3': + resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.60.3': + resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.60.3': + resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.60.3': + resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.60.3': + resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.3': + resolution: {integrity: sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.3': + resolution: {integrity: sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.3': + resolution: {integrity: sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.3': + resolution: {integrity: sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.3': + resolution: {integrity: sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==} + cpu: [x64] + os: [win32] + + '@tanstack/query-core@5.100.9': + resolution: {integrity: sha512-SJSFw1S8+kQ0+knv/XGfrbocWoAlT7vDKsSImtLx3ZPQmEcR46hkDjLSvynSy25N8Ms4tIEini1FuBd5k7IscQ==} + + '@tanstack/react-query@5.100.9': + resolution: {integrity: sha512-Oa44XkaI3kCNN6ME0KByU3xT3SEUNOMfZpHxL6+wFoTm+OeUFYHKdeYVe0aOXlRDm/f15sgLwEt2HDorIdW8+A==} + peerDependencies: + react: ^18 || ^19 + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@25.6.2': + resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.28': + resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==} + + '@types/set-cookie-parser@2.4.10': + resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==} + + '@types/statuses@2.0.6': + resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + autoprefixer@10.5.0: + resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + baseline-browser-mapping@2.10.29: + resolution: {integrity: sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + caniuse-lite@1.0.30001792: + resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.353: + resolution: {integrity: sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-wrap-ansi@0.2.0: + resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graphql@16.14.0: + resolution: {integrity: sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + headers-polyfill@5.0.1: + resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsdom@25.0.1: + resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + msw@2.14.5: + resolution: {integrity: sha512-X6G05oX4x0e+CNI55KMdhMmwHCBKf2iwazGr+iwsdoJ94JA1ED7wSXb6V+lLPdqFkmIlPiGYvayqnaNcOzobDA==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true + + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.38: + resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + engines: {node: '>=14'} + hasBin: true + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-router-dom@6.30.3: + resolution: {integrity: sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + react-router@6.30.3: + resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + rettime@0.11.11: + resolution: {integrity: sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rollup@4.60.3: + resolution: {integrity: sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + set-cookie-parser@3.1.0: + resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + tailwindcss@3.4.19: + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} + engines: {node: '>=14.0.0'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts-core@7.0.30: + resolution: {integrity: sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tldts@7.0.30: + resolution: {integrity: sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==} + hasBin: true + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + type-fest@5.6.0: + resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} + engines: {node: '>=20'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + + until-async@3.0.2: + resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + +snapshots: + + '@adobe/css-tools@4.4.4': {} + + '@alloc/quick-lru@5.2.0': {} + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.3': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.3 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/runtime@7.29.2': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@inquirer/ansi@2.0.5': {} + + '@inquirer/confirm@6.0.12(@types/node@25.6.2)': + dependencies: + '@inquirer/core': 11.1.9(@types/node@25.6.2) + '@inquirer/type': 4.0.5(@types/node@25.6.2) + optionalDependencies: + '@types/node': 25.6.2 + + '@inquirer/core@11.1.9(@types/node@25.6.2)': + dependencies: + '@inquirer/ansi': 2.0.5 + '@inquirer/figures': 2.0.5 + '@inquirer/type': 4.0.5(@types/node@25.6.2) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.0 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 25.6.2 + + '@inquirer/figures@2.0.5': {} + + '@inquirer/type@4.0.5(@types/node@25.6.2)': + optionalDependencies: + '@types/node': 25.6.2 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@mswjs/interceptors@0.41.8': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/deferred-promise@3.0.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + + '@remix-run/router@1.23.2': {} + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.60.3': + optional: true + + '@rollup/rollup-android-arm64@4.60.3': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.3': + optional: true + + '@rollup/rollup-darwin-x64@4.60.3': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.3': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.3': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.3': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.3': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.3': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.3': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.3': + optional: true + + '@tanstack/query-core@5.100.9': {} + + '@tanstack/react-query@5.100.9(react@18.3.1)': + dependencies: + '@tanstack/query-core': 5.100.9 + react: 18.3.1 + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.29.2 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.2 + '@testing-library/dom': 10.4.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@types/aria-query@5.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/estree@1.0.8': {} + + '@types/estree@1.0.9': {} + + '@types/node@25.6.2': + dependencies: + undici-types: 7.19.2 + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.28)': + dependencies: + '@types/react': 18.3.28 + + '@types/react@18.3.28': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + '@types/set-cookie-parser@2.4.10': + dependencies: + '@types/node': 25.6.2 + + '@types/statuses@2.0.6': {} + + '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@25.6.2))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 5.4.21(@types/node@25.6.2) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(msw@2.14.5(@types/node@25.6.2)(typescript@5.9.3))(vite@5.4.21(@types/node@25.6.2))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.14.5(@types/node@25.6.2)(typescript@5.9.3) + vite: 5.4.21(@types/node@25.6.2) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + agent-base@7.1.4: {} + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + arg@5.0.2: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + + asynckit@0.4.0: {} + + autoprefixer@10.5.0(postcss@8.5.14): + dependencies: + browserslist: 4.28.2 + caniuse-lite: 1.0.30001792 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.14 + postcss-value-parser: 4.2.0 + + baseline-browser-mapping@2.10.29: {} + + binary-extensions@2.3.0: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.29 + caniuse-lite: 1.0.30001792 + electron-to-chromium: 1.5.353 + node-releases: 2.0.38 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + camelcase-css@2.0.1: {} + + caniuse-lite@1.0.30001792: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + cli-width@4.1.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@4.1.1: {} + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + css.escape@1.5.1: {} + + cssesc@3.0.0: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + deep-eql@5.0.2: {} + + delayed-stream@1.0.0: {} + + dequal@2.0.3: {} + + didyoumean@1.2.2: {} + + dlv@1.1.3: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.353: {} + + emoji-regex@8.0.0: {} + + entities@6.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + escalade@3.2.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.3.0: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-wrap-ansi@0.2.0: + dependencies: + fast-string-width: 3.0.2 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.3 + mime-types: 2.1.35 + + fraction.js@5.3.4: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + gopd@1.2.0: {} + + graphql@16.14.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + headers-polyfill@5.0.1: + dependencies: + '@types/set-cookie-parser': 2.4.10 + set-cookie-parser: 3.1.0 + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + indent-string@4.0.0: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.3 + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-node-process@1.2.0: {} + + is-number@7.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + jiti@1.21.7: {} + + js-tokens@4.0.0: {} + + jsdom@25.0.1: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.5 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.23 + parse5: 7.3.0 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.20.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + loupe@3.2.1: {} + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + min-indent@1.0.1: {} + + ms@2.1.3: {} + + msw@2.14.5(@types/node@25.6.2)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 6.0.12(@types/node@25.6.2) + '@mswjs/interceptors': 0.41.8 + '@open-draft/deferred-promise': 3.0.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.14.0 + headers-polyfill: 5.0.1 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.11.11 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.1 + type-fest: 5.6.0 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + + mute-stream@3.0.0: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.12: {} + + node-releases@2.0.38: {} + + normalize-path@3.0.0: {} + + nwsapi@2.2.23: {} + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + outvariant@1.4.3: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + path-parse@1.0.7: {} + + path-to-regexp@6.3.0: {} + + pathe@1.1.2: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pify@2.3.0: {} + + pirates@4.0.7: {} + + postcss-import@15.1.0(postcss@8.5.14): + dependencies: + postcss: 8.5.14 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.12 + + postcss-js@4.1.0(postcss@8.5.14): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.14 + + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.14): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 1.21.7 + postcss: 8.5.14 + + postcss-nested@6.2.0(postcss@8.5.14): + dependencies: + postcss: 8.5.14 + postcss-selector-parser: 6.1.2 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prettier@3.8.3: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-is@17.0.2: {} + + react-refresh@0.17.0: {} + + react-router-dom@6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@remix-run/router': 1.23.2 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-router: 6.30.3(react@18.3.1) + + react-router@6.30.3(react@18.3.1): + dependencies: + '@remix-run/router': 1.23.2 + react: 18.3.1 + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + require-directory@2.1.1: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + rettime@0.11.11: {} + + reusify@1.1.0: {} + + rollup@4.60.3: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.3 + '@rollup/rollup-android-arm64': 4.60.3 + '@rollup/rollup-darwin-arm64': 4.60.3 + '@rollup/rollup-darwin-x64': 4.60.3 + '@rollup/rollup-freebsd-arm64': 4.60.3 + '@rollup/rollup-freebsd-x64': 4.60.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.3 + '@rollup/rollup-linux-arm-musleabihf': 4.60.3 + '@rollup/rollup-linux-arm64-gnu': 4.60.3 + '@rollup/rollup-linux-arm64-musl': 4.60.3 + '@rollup/rollup-linux-loong64-gnu': 4.60.3 + '@rollup/rollup-linux-loong64-musl': 4.60.3 + '@rollup/rollup-linux-ppc64-gnu': 4.60.3 + '@rollup/rollup-linux-ppc64-musl': 4.60.3 + '@rollup/rollup-linux-riscv64-gnu': 4.60.3 + '@rollup/rollup-linux-riscv64-musl': 4.60.3 + '@rollup/rollup-linux-s390x-gnu': 4.60.3 + '@rollup/rollup-linux-x64-gnu': 4.60.3 + '@rollup/rollup-linux-x64-musl': 4.60.3 + '@rollup/rollup-openbsd-x64': 4.60.3 + '@rollup/rollup-openharmony-arm64': 4.60.3 + '@rollup/rollup-win32-arm64-msvc': 4.60.3 + '@rollup/rollup-win32-ia32-msvc': 4.60.3 + '@rollup/rollup-win32-x64-gnu': 4.60.3 + '@rollup/rollup-win32-x64-msvc': 4.60.3 + fsevents: 2.3.3 + + rrweb-cssom@0.7.1: {} + + rrweb-cssom@0.8.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + semver@6.3.1: {} + + set-cookie-parser@3.1.0: {} + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + statuses@2.0.2: {} + + std-env@3.10.0: {} + + strict-event-emitter@0.5.1: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.16 + ts-interface-checker: 0.1.13 + + supports-preserve-symlinks-flag@1.0.0: {} + + symbol-tree@3.2.4: {} + + tagged-tag@1.0.0: {} + + tailwindcss@3.4.19: + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.14 + postcss-import: 15.1.0(postcss@8.5.14) + postcss-js: 4.1.0(postcss@8.5.14) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.14) + postcss-nested: 6.2.0(postcss@8.5.14) + postcss-selector-parser: 6.1.2 + resolve: 1.22.12 + sucrase: 3.35.1 + transitivePeerDependencies: + - tsx + - yaml + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + tldts-core@6.1.86: {} + + tldts-core@7.0.30: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tldts@7.0.30: + dependencies: + tldts-core: 7.0.30 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.30 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + ts-interface-checker@0.1.13: {} + + type-fest@5.6.0: + dependencies: + tagged-tag: 1.0.0 + + typescript@5.9.3: {} + + undici-types@7.19.2: {} + + until-async@3.0.2: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-sync-external-store@1.6.0(react@18.3.1): + dependencies: + react: 18.3.1 + + util-deprecate@1.0.2: {} + + vite-node@2.1.9(@types/node@25.6.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@25.6.2) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@25.6.2): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.14 + rollup: 4.60.3 + optionalDependencies: + '@types/node': 25.6.2 + fsevents: 2.3.3 + + vitest@2.1.9(@types/node@25.6.2)(jsdom@25.0.1)(msw@2.14.5(@types/node@25.6.2)(typescript@5.9.3)): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(msw@2.14.5(@types/node@25.6.2)(typescript@5.9.3))(vite@5.4.21(@types/node@25.6.2)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@25.6.2) + vite-node: 2.1.9(@types/node@25.6.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.6.2 + jsdom: 25.0.1 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + ws@8.20.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + zustand@4.5.7(@types/react@18.3.28)(react@18.3.1): + dependencies: + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + react: 18.3.1 diff --git a/extensions/dashboard/contract/shell/postcss.config.js b/extensions/dashboard/contract/shell/postcss.config.js new file mode 100644 index 00000000..2aa7205d --- /dev/null +++ b/extensions/dashboard/contract/shell/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/extensions/dashboard/contract/shell/tailwind.config.ts b/extensions/dashboard/contract/shell/tailwind.config.ts new file mode 100644 index 00000000..9fb0e282 --- /dev/null +++ b/extensions/dashboard/contract/shell/tailwind.config.ts @@ -0,0 +1,9 @@ +import type { Config } from "tailwindcss"; + +const config: Config = { + content: ["./index.html", "./src/**/*.{ts,tsx}"], + theme: { extend: {} }, + plugins: [], +}; + +export default config; diff --git a/extensions/dashboard/contract/shell/tsconfig.json b/extensions/dashboard/contract/shell/tsconfig.json new file mode 100644 index 00000000..19d92698 --- /dev/null +++ b/extensions/dashboard/contract/shell/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noUncheckedIndexedAccess": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "esModuleInterop": true, + "skipLibCheck": true, + "isolatedModules": true, + "allowImportingTsExtensions": false, + "resolveJsonModule": true, + "useDefineForClassFields": true, + "types": ["vitest/globals", "@testing-library/jest-dom"] + }, + "include": ["src", "test"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/extensions/dashboard/contract/shell/tsconfig.node.json b/extensions/dashboard/contract/shell/tsconfig.node.json new file mode 100644 index 00000000..0a97eb01 --- /dev/null +++ b/extensions/dashboard/contract/shell/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["vite.config.ts", "vitest.config.ts", "tailwind.config.ts", "postcss.config.js"] +} diff --git a/extensions/dashboard/contract/shell/vite.config.ts b/extensions/dashboard/contract/shell/vite.config.ts new file mode 100644 index 00000000..ce5ef780 --- /dev/null +++ b/extensions/dashboard/contract/shell/vite.config.ts @@ -0,0 +1,34 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + base: "/dashboard/contract/static/", + build: { + outDir: "dist", + emptyOutDir: true, + sourcemap: true, + target: "es2022", + rollupOptions: { + output: { + manualChunks: { + "react-vendor": ["react", "react-dom", "react-router-dom"], + "query-vendor": ["@tanstack/react-query"], + }, + }, + }, + }, + server: { + port: 5173, + proxy: { + "/api/dashboard": { + target: "http://localhost:8080", + changeOrigin: false, + }, + "/dashboard/contract/static": { + target: "http://localhost:5173", + bypass: () => "/index.html", + }, + }, + }, +}); diff --git a/extensions/dashboard/contract/shell/vitest.config.ts b/extensions/dashboard/contract/shell/vitest.config.ts new file mode 100644 index 00000000..c3fd9ab4 --- /dev/null +++ b/extensions/dashboard/contract/shell/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + environment: "jsdom", + setupFiles: ["./test/setup.ts"], + include: ["test/**/*.test.{ts,tsx}"], + coverage: { provider: "v8" }, + }, +}); From 83eee34615a1e90f2ecf9b7f5b551f8d49d671d7 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sun, 10 May 2026 00:16:56 -0500 Subject: [PATCH 56/89] feat(dashboard/contract/shell): minimal React app skeleton --- .gitignore | 2 ++ extensions/dashboard/contract/shell/.gitignore | 14 ++++++++++++++ extensions/dashboard/contract/shell/index.html | 12 ++++++++++++ extensions/dashboard/contract/shell/package.json | 2 +- extensions/dashboard/contract/shell/src/App.tsx | 8 ++++++++ extensions/dashboard/contract/shell/src/index.css | 7 +++++++ extensions/dashboard/contract/shell/src/main.tsx | 13 +++++++++++++ extensions/dashboard/contract/shell/test/setup.ts | 7 +++++++ 8 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 extensions/dashboard/contract/shell/index.html create mode 100644 extensions/dashboard/contract/shell/src/App.tsx create mode 100644 extensions/dashboard/contract/shell/src/index.css create mode 100644 extensions/dashboard/contract/shell/src/main.tsx create mode 100644 extensions/dashboard/contract/shell/test/setup.ts diff --git a/.gitignore b/.gitignore index 8feef20d..d8bdbfb7 100644 --- a/.gitignore +++ b/.gitignore @@ -123,6 +123,8 @@ METRICS_DEADLOCK_FIX.md **/*.md !**/README.md !extensions/dashboard/contract/*.md +!extensions/dashboard/contract/shell/test/ +!extensions/dashboard/contract/shell/test/** /**/*.disabled extensions/dashboard/forgeui *.blob diff --git a/extensions/dashboard/contract/shell/.gitignore b/extensions/dashboard/contract/shell/.gitignore index 07a45267..bfa54be7 100644 --- a/extensions/dashboard/contract/shell/.gitignore +++ b/extensions/dashboard/contract/shell/.gitignore @@ -5,3 +5,17 @@ dist/ .env .env.local coverage/ + +# tsc -b build artifacts (we use Vite for actual bundling; these are leftover) +src/**/*.js +src/**/*.d.ts +test/**/*.js +test/**/*.d.ts +*.tsbuildinfo +# tsc -b also emits .js/.d.ts next to .ts config files at root; ignore those too +tailwind.config.js +tailwind.config.d.ts +vite.config.js +vite.config.d.ts +vitest.config.js +vitest.config.d.ts diff --git a/extensions/dashboard/contract/shell/index.html b/extensions/dashboard/contract/shell/index.html new file mode 100644 index 00000000..94d735ca --- /dev/null +++ b/extensions/dashboard/contract/shell/index.html @@ -0,0 +1,12 @@ + + + + + + Forge Dashboard + + +
+ + + diff --git a/extensions/dashboard/contract/shell/package.json b/extensions/dashboard/contract/shell/package.json index 8fa2d574..f07e6247 100644 --- a/extensions/dashboard/contract/shell/package.json +++ b/extensions/dashboard/contract/shell/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", - "test": "vitest run", + "test": "vitest run --passWithNoTests", "test:watch": "vitest", "lint": "tsc --noEmit", "format": "prettier --write src test" diff --git a/extensions/dashboard/contract/shell/src/App.tsx b/extensions/dashboard/contract/shell/src/App.tsx new file mode 100644 index 00000000..4503cf68 --- /dev/null +++ b/extensions/dashboard/contract/shell/src/App.tsx @@ -0,0 +1,8 @@ +export function App() { + return ( +
+

Forge Dashboard Shell

+

Runtime scaffolded; routes will be wired in Phase 6.

+
+ ); +} diff --git a/extensions/dashboard/contract/shell/src/index.css b/extensions/dashboard/contract/shell/src/index.css new file mode 100644 index 00000000..8a143165 --- /dev/null +++ b/extensions/dashboard/contract/shell/src/index.css @@ -0,0 +1,7 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, body, #root { + height: 100%; +} diff --git a/extensions/dashboard/contract/shell/src/main.tsx b/extensions/dashboard/contract/shell/src/main.tsx new file mode 100644 index 00000000..b80eab6e --- /dev/null +++ b/extensions/dashboard/contract/shell/src/main.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import "./index.css"; +import { App } from "./App"; + +const root = document.getElementById("root"); +if (!root) throw new Error("#root not found"); + +ReactDOM.createRoot(root).render( + + + , +); diff --git a/extensions/dashboard/contract/shell/test/setup.ts b/extensions/dashboard/contract/shell/test/setup.ts new file mode 100644 index 00000000..2ba240b8 --- /dev/null +++ b/extensions/dashboard/contract/shell/test/setup.ts @@ -0,0 +1,7 @@ +import "@testing-library/jest-dom/vitest"; +import { afterEach } from "vitest"; +import { cleanup } from "@testing-library/react"; + +afterEach(() => { + cleanup(); +}); From 792e4b80841a51dbf6bf12bf5c5e3419f9c46619 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sun, 10 May 2026 00:20:01 -0500 Subject: [PATCH 57/89] feat(dashboard/contract/shell): contract client with auto-CSRF and idempotency --- .../contract/shell/src/contract/client.ts | 139 ++++++++++++++++++ .../contract/shell/src/contract/types.ts | 90 ++++++++++++ .../contract/shell/test/contract.test.ts | 125 ++++++++++++++++ 3 files changed, 354 insertions(+) create mode 100644 extensions/dashboard/contract/shell/src/contract/client.ts create mode 100644 extensions/dashboard/contract/shell/src/contract/types.ts create mode 100644 extensions/dashboard/contract/shell/test/contract.test.ts diff --git a/extensions/dashboard/contract/shell/src/contract/client.ts b/extensions/dashboard/contract/shell/src/contract/client.ts new file mode 100644 index 00000000..ce333d8c --- /dev/null +++ b/extensions/dashboard/contract/shell/src/contract/client.ts @@ -0,0 +1,139 @@ +import type { + ContractError, + EnvelopeResponse, + GraphNode, + Request, + Response, +} from "./types"; + +export class ContractClientError extends Error { + readonly code: string; + readonly details?: Record; + readonly retryable?: boolean; + readonly correlationID?: string; + + constructor(err: ContractError) { + super(err.message ?? err.code); + this.code = err.code; + this.details = err.details; + this.retryable = err.retryable; + this.correlationID = err.correlationID; + } +} + +export interface ClientOptions { + baseURL?: string; + fetcher?: typeof fetch; +} + +export class ContractClient { + private readonly baseURL: string; + private readonly fetcher: typeof fetch; + private csrfToken: string | null = null; + + constructor(opts: ClientOptions = {}) { + this.baseURL = opts.baseURL ?? "/api/dashboard/v1"; + this.fetcher = opts.fetcher ?? fetch; + } + + async query( + contributor: string, + intent: string, + payload?: unknown, + params?: Record, + ): Promise { + return this.send({ kind: "query", contributor, intent, payload, params }); + } + + async command( + contributor: string, + intent: string, + payload?: unknown, + opts: { idempotencyKey?: string } = {}, + ): Promise { + return this.send({ + kind: "command", + contributor, + intent, + payload, + idempotencyKey: opts.idempotencyKey ?? crypto.randomUUID(), + }); + } + + async graph(contributor: string, route: string): Promise { + return this.send({ + kind: "graph", + contributor, + intent: "page.shell", + payload: { route }, + }); + } + + private async send( + input: Omit & { context?: Request["context"] }, + ): Promise { + return this.sendWithRetry(input, false); + } + + private async sendWithRetry( + input: Omit & { context?: Request["context"] }, + attempted401Refresh: boolean, + ): Promise { + if (input.kind === "command" && !this.csrfToken) { + await this.refreshCSRF(); + } + const req: Request = { + envelope: "v1", + kind: input.kind, + contributor: input.contributor, + intent: input.intent, + intentVersion: input.intentVersion, + payload: input.payload, + params: input.params, + context: input.context ?? { + route: typeof window !== "undefined" ? window.location.pathname : "/", + correlationID: crypto.randomUUID(), + }, + csrf: input.kind === "command" ? this.csrfToken ?? undefined : undefined, + idempotencyKey: input.idempotencyKey, + }; + + const res = await this.fetcher(this.baseURL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req), + credentials: "include", + }); + + let body: EnvelopeResponse; + try { + body = (await res.json()) as EnvelopeResponse; + } catch { + throw new ContractClientError({ + code: "INTERNAL", + message: `non-JSON response (status ${res.status})`, + }); + } + + if (body.ok) { + return (body as Response).data; + } + + if (!attempted401Refresh && res.status === 401 && input.kind === "command") { + await this.refreshCSRF(); + return this.sendWithRetry(input, true); + } + + throw new ContractClientError(body.error); + } + + private async refreshCSRF(): Promise { + const res = await this.fetcher(`${this.baseURL}/csrf`, { credentials: "include" }); + if (!res.ok) { + this.csrfToken = null; + return; + } + const body = (await res.json()) as { token: string }; + this.csrfToken = body.token; + } +} diff --git a/extensions/dashboard/contract/shell/src/contract/types.ts b/extensions/dashboard/contract/shell/src/contract/types.ts new file mode 100644 index 00000000..e32ffef2 --- /dev/null +++ b/extensions/dashboard/contract/shell/src/contract/types.ts @@ -0,0 +1,90 @@ +export type Kind = "graph" | "query" | "command" | "subscribe"; + +export interface Request { + envelope: "v1"; + kind: Kind; + contributor: string; + intent: string; + intentVersion?: number; + payload?: TPayload; + params?: Record; + context: { route: string; correlationID: string }; + csrf?: string; + idempotencyKey?: string; +} + +export interface ResponseMeta { + intentVersion?: number; + deprecation?: { intentVersion: number; removeAfter: string }; + cacheControl?: { staleTime?: string }; + invalidates?: string[]; +} + +export interface Response { + ok: true; + envelope: "v1"; + kind: Kind; + data: TData; + meta: ResponseMeta; +} + +export interface ContractError { + code: string; + message?: string; + details?: Record; + retryable?: boolean; + correlationID?: string; + redactions?: string[]; +} + +export interface ErrorResponse { + ok: false; + envelope: "v1"; + error: ContractError; +} + +export type EnvelopeResponse = Response | ErrorResponse; + +export interface DataBinding { + queryRef?: string; + intent?: string; + params?: Record; +} + +export interface GraphNode { + intent: string; + title?: string; + route?: string; + data?: DataBinding; + props?: Record; + slots?: Record; + enabledWhen?: Predicate; + op?: string; + payload?: Record; + component?: string; + src?: string; +} + +export interface Predicate { + all?: string[]; + any?: string[]; + not?: string[]; + warden?: string; +} + +export type SubscriptionMode = "replace" | "append" | "snapshot+delta"; + +export interface StreamEvent { + intent: string; + mode: SubscriptionMode; + payload: T; + seq: number; +} + +export interface Principal { + subject: string; + displayName: string; + email?: string; + roles: string[]; + scopes: string[]; +} diff --git a/extensions/dashboard/contract/shell/test/contract.test.ts b/extensions/dashboard/contract/shell/test/contract.test.ts new file mode 100644 index 00000000..708c9779 --- /dev/null +++ b/extensions/dashboard/contract/shell/test/contract.test.ts @@ -0,0 +1,125 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { ContractClient } from "../src/contract/client"; + +const server = setupServer(); +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe("ContractClient", () => { + it("query: returns response.data on success", async () => { + server.use( + http.post("/api/dashboard/v1", async ({ request }) => { + const body = (await request.json()) as { kind: string; intent: string }; + expect(body.kind).toBe("query"); + expect(body.intent).toBe("users.list"); + return HttpResponse.json({ + ok: true, + envelope: "v1", + kind: "query", + data: { users: ["alice"] }, + meta: { intentVersion: 1 }, + }); + }), + ); + const c = new ContractClient(); + const data = await c.query<{ users: string[] }>("users", "users.list"); + expect(data.users).toEqual(["alice"]); + }); + + it("error envelope: throws ContractClientError carrying the wire error", async () => { + server.use( + http.post("/api/dashboard/v1", () => + HttpResponse.json( + { ok: false, envelope: "v1", error: { code: "NOT_FOUND", message: "no" } }, + { status: 404 }, + ), + ), + ); + const c = new ContractClient(); + await expect(c.query("x", "y")).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("command: auto-attaches CSRF token (fetched lazily) and idempotency key", async () => { + let csrfFetched = 0; + server.use( + http.get("/api/dashboard/v1/csrf", () => { + csrfFetched++; + return HttpResponse.json({ + token: "tok123", + expiresAt: new Date(Date.now() + 3600_000).toISOString(), + }); + }), + http.post("/api/dashboard/v1", async ({ request }) => { + const body = (await request.json()) as { + kind: string; + csrf?: string; + idempotencyKey?: string; + }; + expect(body.kind).toBe("command"); + expect(body.csrf).toBe("tok123"); + expect(body.idempotencyKey).toBeTruthy(); + return HttpResponse.json({ + ok: true, + envelope: "v1", + kind: "command", + data: null, + meta: {}, + }); + }), + ); + const c = new ContractClient(); + await c.command("users", "user.disable", { id: "u1" }); + expect(csrfFetched).toBe(1); + }); + + it("command: refreshes CSRF on 401 and retries once", async () => { + let attempt = 0; + server.use( + http.get("/api/dashboard/v1/csrf", () => + HttpResponse.json({ + token: `tok-${++attempt}`, + expiresAt: new Date(Date.now() + 3600_000).toISOString(), + }), + ), + http.post("/api/dashboard/v1", async ({ request }) => { + const body = (await request.json()) as { csrf?: string }; + if (body.csrf === "tok-1") { + return HttpResponse.json( + { ok: false, envelope: "v1", error: { code: "UNAUTHENTICATED" } }, + { status: 401 }, + ); + } + return HttpResponse.json({ + ok: true, + envelope: "v1", + kind: "command", + data: null, + meta: {}, + }); + }), + ); + const c = new ContractClient(); + await c.command("users", "do.thing"); + expect(attempt).toBe(2); + }); + + it("graph: returns the graph tree on success", async () => { + server.use( + http.post("/api/dashboard/v1", () => + HttpResponse.json({ + ok: true, + envelope: "v1", + kind: "graph", + data: { intent: "page.shell", route: "/x" }, + meta: {}, + }), + ), + ); + const c = new ContractClient(); + const node = await c.graph("core-contract", "/x"); + expect(node.intent).toBe("page.shell"); + }); +}); From ecc761455d97b580f488aa3771898c6e6def60d6 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sun, 10 May 2026 00:20:45 -0500 Subject: [PATCH 58/89] feat(dashboard/contract/shell): SSE multiplex consumer --- .../contract/shell/src/contract/sse.ts | 119 ++++++++++++++++++ .../dashboard/contract/shell/test/sse.test.ts | 114 +++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 extensions/dashboard/contract/shell/src/contract/sse.ts create mode 100644 extensions/dashboard/contract/shell/test/sse.test.ts diff --git a/extensions/dashboard/contract/shell/src/contract/sse.ts b/extensions/dashboard/contract/shell/src/contract/sse.ts new file mode 100644 index 00000000..0b2229f2 --- /dev/null +++ b/extensions/dashboard/contract/shell/src/contract/sse.ts @@ -0,0 +1,119 @@ +import type { StreamEvent } from "./types"; + +export interface SubscriptionMuxOptions { + baseURL?: string; + eventSource?: typeof EventSource; + fetcher?: typeof fetch; +} + +interface PendingSub { + contributor: string; + intent: string; + params: Record; + subscriptionID: string; + handler: (ev: StreamEvent) => void; +} + +export class SubscriptionMux { + private readonly baseURL: string; + private readonly EventSourceCtor: typeof EventSource; + private readonly fetcher: typeof fetch; + private es: EventSource | null = null; + private streamID: string | null = null; + private pending: PendingSub[] = []; + private active = new Map(); + + constructor(opts: SubscriptionMuxOptions = {}) { + this.baseURL = opts.baseURL ?? "/api/dashboard/v1"; + this.EventSourceCtor = opts.eventSource ?? globalThis.EventSource; + this.fetcher = opts.fetcher ?? globalThis.fetch; + } + + async subscribe( + contributor: string, + intent: string, + params: Record, + handler: (ev: StreamEvent) => void, + opts: { subscriptionID?: string } = {}, + ): Promise<() => void> { + const subscriptionID = opts.subscriptionID ?? crypto.randomUUID(); + const sub: PendingSub = { contributor, intent, params, subscriptionID, handler }; + + if (!this.es) { + this.openStream(); + } + this.attachSubscriptionListener(sub); + + if (this.streamID) { + await this.sendControl({ + op: "subscribe", + subscriptionID, + contributor, + intent, + params, + }); + this.active.set(subscriptionID, sub); + } else { + this.pending.push(sub); + } + + return () => this.unsubscribe(subscriptionID); + } + + private openStream(): void { + this.es = new this.EventSourceCtor(`${this.baseURL}/stream`); + this.es.addEventListener("hello", (ev: MessageEvent) => { + const { streamID } = JSON.parse(ev.data) as { streamID: string }; + this.streamID = streamID; + const drain = this.pending.splice(0); + void Promise.all( + drain.map((sub) => { + this.active.set(sub.subscriptionID, sub); + return this.sendControl({ + op: "subscribe", + subscriptionID: sub.subscriptionID, + contributor: sub.contributor, + intent: sub.intent, + params: sub.params, + }); + }), + ); + }); + } + + private attachSubscriptionListener(sub: PendingSub): void { + if (!this.es) return; + this.es.addEventListener(sub.subscriptionID, (ev: MessageEvent) => { + try { + const parsed = JSON.parse(ev.data) as StreamEvent; + sub.handler(parsed); + } catch { + // Drop malformed events. + } + }); + } + + private async unsubscribe(subscriptionID: string): Promise { + this.active.delete(subscriptionID); + if (!this.streamID) return; + await this.sendControl({ op: "unsubscribe", subscriptionID }); + } + + private async sendControl(msg: Record): Promise { + if (!this.streamID) return; + await this.fetcher(`${this.baseURL}/stream/control`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ streamID: this.streamID, ...msg }), + credentials: "include", + }); + } + + close(): void { + this.es?.close(); + this.es = null; + this.streamID = null; + this.active.clear(); + this.pending = []; + } +} diff --git a/extensions/dashboard/contract/shell/test/sse.test.ts b/extensions/dashboard/contract/shell/test/sse.test.ts new file mode 100644 index 00000000..4b06969a --- /dev/null +++ b/extensions/dashboard/contract/shell/test/sse.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SubscriptionMux } from "../src/contract/sse"; +import type { StreamEvent } from "../src/contract/types"; + +class FakeEventSource { + static instances: FakeEventSource[] = []; + url: string; + readyState = 0; + onopen: (() => void) | null = null; + onerror: (() => void) | null = null; + private listeners = new Map void>>(); + + constructor(url: string) { + this.url = url; + FakeEventSource.instances.push(this); + queueMicrotask(() => { + this.readyState = 1; + this.onopen?.(); + }); + } + addEventListener(name: string, fn: (ev: MessageEvent) => void) { + const arr = this.listeners.get(name) ?? []; + arr.push(fn); + this.listeners.set(name, arr); + } + removeEventListener() { + /* noop */ + } + emit(name: string, data: string) { + const arr = this.listeners.get(name) ?? []; + arr.forEach((fn) => fn({ data } as MessageEvent)); + } + close() { + this.readyState = 2; + } +} + +const fetchStub = vi.fn(async (url: string, _init?: RequestInit) => { + if (url.endsWith("/stream/control")) { + return new globalThis.Response(JSON.stringify({}), { status: 200 }) as unknown as Response; + } + throw new Error("unexpected fetch: " + url); +}); + +beforeEach(() => { + FakeEventSource.instances = []; + fetchStub.mockClear(); +}); + +afterEach(() => { + FakeEventSource.instances.forEach((es) => es.close()); +}); + +describe("SubscriptionMux", () => { + it("dispatches events to the right subscription handler by SSE event name", async () => { + const mux = new SubscriptionMux({ + baseURL: "/api/dashboard/v1", + eventSource: FakeEventSource as unknown as typeof EventSource, + fetcher: fetchStub as unknown as typeof fetch, + }); + const received: StreamEvent[] = []; + const unsub = await mux.subscribe( + "logs", + "audit.tail", + {}, + (ev) => { + received.push(ev); + }, + { subscriptionID: "s1" }, + ); + const es = FakeEventSource.instances[0]!; + es.emit("hello", JSON.stringify({ streamID: "stream-x" })); + await Promise.resolve(); + es.emit( + "s1", + JSON.stringify({ intent: "audit.tail", mode: "append", payload: { line: "hi" }, seq: 1 }), + ); + expect(received).toHaveLength(1); + expect(received[0]!.payload).toEqual({ line: "hi" }); + unsub(); + }); + + it("sends a control message on subscribe and on unsubscribe", async () => { + const mux = new SubscriptionMux({ + baseURL: "/api/dashboard/v1", + eventSource: FakeEventSource as unknown as typeof EventSource, + fetcher: fetchStub as unknown as typeof fetch, + }); + const unsub = await mux.subscribe( + "logs", + "audit.tail", + {}, + () => {}, + { subscriptionID: "s1" }, + ); + FakeEventSource.instances[0]!.emit("hello", JSON.stringify({ streamID: "stream-x" })); + await Promise.resolve(); + expect(fetchStub).toHaveBeenCalledWith( + "/api/dashboard/v1/stream/control", + expect.objectContaining({ method: "POST" }), + ); + const subscribeBody = JSON.parse( + fetchStub.mock.calls.find(([, init]) => (init as RequestInit).body)![1]!.body as string, + ); + expect(subscribeBody.op).toBe("subscribe"); + expect(subscribeBody.subscriptionID).toBe("s1"); + + fetchStub.mockClear(); + unsub(); + await Promise.resolve(); + const unsubscribeBody = JSON.parse(fetchStub.mock.calls[0]![1]!.body as string); + expect(unsubscribeBody.op).toBe("unsubscribe"); + }); +}); From 5958746ff470b80a87906b861f056699ace7d165 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sun, 10 May 2026 00:22:16 -0500 Subject: [PATCH 59/89] feat(dashboard/contract/shell): principal store + React Query hooks for graph/query/command/subscription --- .../contract/shell/src/auth/principal.ts | 28 ++++++++ .../contract/shell/src/contract/hooks.ts | 66 +++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 extensions/dashboard/contract/shell/src/auth/principal.ts create mode 100644 extensions/dashboard/contract/shell/src/contract/hooks.ts diff --git a/extensions/dashboard/contract/shell/src/auth/principal.ts b/extensions/dashboard/contract/shell/src/auth/principal.ts new file mode 100644 index 00000000..44c8669a --- /dev/null +++ b/extensions/dashboard/contract/shell/src/auth/principal.ts @@ -0,0 +1,28 @@ +import { create } from "zustand"; +import type { Principal } from "../contract/types"; + +interface PrincipalState { + principal: Principal | null; + loaded: boolean; + error: string | null; + load: (fetcher?: typeof fetch) => Promise; +} + +export const usePrincipalStore = create((set) => ({ + principal: null, + loaded: false, + error: null, + async load(fetcher = fetch) { + try { + const res = await fetcher("/api/dashboard/v1/principal", { credentials: "include" }); + if (!res.ok) { + set({ loaded: true, error: `HTTP ${res.status}`, principal: null }); + return; + } + const principal = (await res.json()) as Principal; + set({ principal, loaded: true, error: null }); + } catch (err) { + set({ loaded: true, error: String(err), principal: null }); + } + }, +})); diff --git a/extensions/dashboard/contract/shell/src/contract/hooks.ts b/extensions/dashboard/contract/shell/src/contract/hooks.ts new file mode 100644 index 00000000..16fde726 --- /dev/null +++ b/extensions/dashboard/contract/shell/src/contract/hooks.ts @@ -0,0 +1,66 @@ +import { useEffect, useRef, useState } from "react"; +import { useMutation, useQuery as useRQ } from "@tanstack/react-query"; +import { ContractClient } from "./client"; +import { SubscriptionMux } from "./sse"; +import type { GraphNode, StreamEvent } from "./types"; + +const sharedClient = new ContractClient(); +const sharedMux = new SubscriptionMux(); + +export function useContractGraph(contributor: string, route: string) { + return useRQ({ + queryKey: ["graph", contributor, route], + queryFn: () => sharedClient.graph(contributor, route), + }); +} + +export function useContractQuery( + contributor: string, + intent: string, + payload?: unknown, + params?: Record, +) { + return useRQ({ + queryKey: ["query", contributor, intent, payload, params], + queryFn: () => sharedClient.query(contributor, intent, payload, params), + }); +} + +export function useContractCommand( + contributor: string, + intent: string, +) { + return useMutation({ + mutationFn: (payload) => sharedClient.command(contributor, intent, payload), + }); +} + +export function useSubscription( + contributor: string, + intent: string, + params: Record = {}, +) { + const [latest, setLatest] = useState | null>(null); + const handlerRef = useRef((ev: StreamEvent) => setLatest(ev)); + + useEffect(() => { + let unsub: (() => void) | null = null; + let cancelled = false; + void sharedMux + .subscribe(contributor, intent, params, (ev) => handlerRef.current(ev as StreamEvent)) + .then((u) => { + if (cancelled) { + u(); + return; + } + unsub = u; + }); + return () => { + cancelled = true; + unsub?.(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [contributor, intent, JSON.stringify(params)]); + + return latest; +} From c321dd5eb9b39ae9b5a61f7696d5e89e2d9d60a2 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sun, 10 May 2026 00:22:20 -0500 Subject: [PATCH 60/89] feat(dashboard/contract/shell): intent registry, graph renderer, slot renderer, fallbacks --- .../contract/shell/src/runtime/context.tsx | 21 +++++++ .../contract/shell/src/runtime/fallbacks.tsx | 23 ++++++++ .../contract/shell/src/runtime/registry.ts | 31 ++++++++++ .../contract/shell/src/runtime/renderer.tsx | 12 ++++ .../contract/shell/src/runtime/slots.tsx | 19 +++++++ .../contract/shell/test/renderer.test.tsx | 56 +++++++++++++++++++ 6 files changed, 162 insertions(+) create mode 100644 extensions/dashboard/contract/shell/src/runtime/context.tsx create mode 100644 extensions/dashboard/contract/shell/src/runtime/fallbacks.tsx create mode 100644 extensions/dashboard/contract/shell/src/runtime/registry.ts create mode 100644 extensions/dashboard/contract/shell/src/runtime/renderer.tsx create mode 100644 extensions/dashboard/contract/shell/src/runtime/slots.tsx create mode 100644 extensions/dashboard/contract/shell/test/renderer.test.tsx diff --git a/extensions/dashboard/contract/shell/src/runtime/context.tsx b/extensions/dashboard/contract/shell/src/runtime/context.tsx new file mode 100644 index 00000000..e6bdfdc5 --- /dev/null +++ b/extensions/dashboard/contract/shell/src/runtime/context.tsx @@ -0,0 +1,21 @@ +import { createContext, useContext } from "react"; +import type { ReactNode } from "react"; +import type { IntentRegistry } from "./registry"; + +const RegistryContext = createContext(null); + +export function IntentRegistryProvider({ + value, + children, +}: { + value: IntentRegistry; + children: ReactNode; +}) { + return {children}; +} + +export function useIntentRegistry(): IntentRegistry { + const reg = useContext(RegistryContext); + if (!reg) throw new Error("useIntentRegistry called outside IntentRegistryProvider"); + return reg; +} diff --git a/extensions/dashboard/contract/shell/src/runtime/fallbacks.tsx b/extensions/dashboard/contract/shell/src/runtime/fallbacks.tsx new file mode 100644 index 00000000..4bc5849d --- /dev/null +++ b/extensions/dashboard/contract/shell/src/runtime/fallbacks.tsx @@ -0,0 +1,23 @@ +export function UnknownIntent({ intent }: { intent: string }) { + return ( +
+ Unknown intent: {intent} +
+ ); +} + +export function LoadingNode() { + return ( +
+ Loading… +
+ ); +} + +export function ErrorNode({ message }: { message: string }) { + return ( +
+ Error: {message} +
+ ); +} diff --git a/extensions/dashboard/contract/shell/src/runtime/registry.ts b/extensions/dashboard/contract/shell/src/runtime/registry.ts new file mode 100644 index 00000000..d0e7c1c5 --- /dev/null +++ b/extensions/dashboard/contract/shell/src/runtime/registry.ts @@ -0,0 +1,31 @@ +import type { ComponentType } from "react"; +import type { GraphNode } from "../contract/types"; + +export interface IntentComponentProps> { + node: GraphNode; + data?: TData; + props: TProps; + slots: Record; +} + +export type IntentComponent = ComponentType>>; + +export class IntentRegistry { + private byName = new Map(); + + register(name: string, component: IntentComponent): this { + if (this.byName.has(name)) { + throw new Error(`intent ${name} already registered`); + } + this.byName.set(name, component); + return this; + } + + resolve(name: string): IntentComponent | undefined { + return this.byName.get(name); + } + + has(name: string): boolean { + return this.byName.has(name); + } +} diff --git a/extensions/dashboard/contract/shell/src/runtime/renderer.tsx b/extensions/dashboard/contract/shell/src/runtime/renderer.tsx new file mode 100644 index 00000000..20d61e2a --- /dev/null +++ b/extensions/dashboard/contract/shell/src/runtime/renderer.tsx @@ -0,0 +1,12 @@ +import { useIntentRegistry } from "./context"; +import { UnknownIntent } from "./fallbacks"; +import type { GraphNode } from "../contract/types"; + +export function GraphRenderer({ node }: { node: GraphNode }) { + const registry = useIntentRegistry(); + const Component = registry.resolve(node.intent); + if (!Component) return ; + return ( + + ); +} diff --git a/extensions/dashboard/contract/shell/src/runtime/slots.tsx b/extensions/dashboard/contract/shell/src/runtime/slots.tsx new file mode 100644 index 00000000..b1f28666 --- /dev/null +++ b/extensions/dashboard/contract/shell/src/runtime/slots.tsx @@ -0,0 +1,19 @@ +import type { GraphNode } from "../contract/types"; +import { GraphRenderer } from "./renderer"; + +export function SlotRenderer({ + slot, + slots, +}: { + slot: string; + slots: Record; +}) { + const children = slots[slot] ?? []; + return ( + <> + {children.map((child, i) => ( + + ))} + + ); +} diff --git a/extensions/dashboard/contract/shell/test/renderer.test.tsx b/extensions/dashboard/contract/shell/test/renderer.test.tsx new file mode 100644 index 00000000..d1fdcb44 --- /dev/null +++ b/extensions/dashboard/contract/shell/test/renderer.test.tsx @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { IntentRegistry } from "../src/runtime/registry"; +import { IntentRegistryProvider } from "../src/runtime/context"; +import { GraphRenderer } from "../src/runtime/renderer"; +import { SlotRenderer } from "../src/runtime/slots"; +import type { GraphNode } from "../src/contract/types"; + +function setup(node: GraphNode, registry: IntentRegistry) { + return render( + + + , + ); +} + +describe("GraphRenderer", () => { + it("renders a registered intent's component", () => { + const reg = new IntentRegistry(); + reg.register("hello", () =>

hello world

); + setup({ intent: "hello" }, reg); + expect(screen.getByText("hello world")).toBeInTheDocument(); + }); + + it("renders UnknownIntent fallback when intent is not registered", () => { + const reg = new IntentRegistry(); + setup({ intent: "missing" }, reg); + expect(screen.getByText(/Unknown intent/i)).toBeInTheDocument(); + expect(screen.getByText("missing")).toBeInTheDocument(); + }); + + it("recursively renders slot children via SlotRenderer", () => { + const reg = new IntentRegistry(); + reg.register("parent", ({ slots }) => ( +
+ +
+ )); + reg.register("leaf", ({ node }) => leaf-{node.intent}); + const node: GraphNode = { + intent: "parent", + slots: { main: [{ intent: "leaf" }, { intent: "leaf" }] }, + }; + setup(node, reg); + expect(screen.getByTestId("parent")).toBeInTheDocument(); + expect(screen.getAllByText(/leaf-leaf/)).toHaveLength(2); + }); +}); + +describe("IntentRegistry", () => { + it("rejects double registration", () => { + const reg = new IntentRegistry(); + reg.register("x", () => null); + expect(() => reg.register("x", () => null)).toThrow(/already registered/); + }); +}); From 6c25b43f7cad8772144d8927a2d3f6968d679702 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sun, 10 May 2026 00:31:41 -0500 Subject: [PATCH 61/89] feat(dashboard/contract/shell): page.shell and metric.counter intent components --- .../shell/src/intents/metric.counter.tsx | 29 +++++++++++++++++++ .../contract/shell/src/intents/page.shell.tsx | 21 ++++++++++++++ .../contract/shell/src/intents/register.ts | 10 +++++++ .../contract/shell/test/renderer.test.tsx | 10 +++++++ 4 files changed, 70 insertions(+) create mode 100644 extensions/dashboard/contract/shell/src/intents/metric.counter.tsx create mode 100644 extensions/dashboard/contract/shell/src/intents/page.shell.tsx create mode 100644 extensions/dashboard/contract/shell/src/intents/register.ts diff --git a/extensions/dashboard/contract/shell/src/intents/metric.counter.tsx b/extensions/dashboard/contract/shell/src/intents/metric.counter.tsx new file mode 100644 index 00000000..203ef13c --- /dev/null +++ b/extensions/dashboard/contract/shell/src/intents/metric.counter.tsx @@ -0,0 +1,29 @@ +import { useSubscription } from "../contract/hooks"; +import type { IntentComponentProps } from "../runtime/registry"; + +interface CounterProps { + title?: string; +} + +interface CounterPayload { + totalMetrics?: number; + cpuPercent?: number; + [k: string]: unknown; +} + +const CONTRIBUTOR = "core-contract"; + +export function MetricCounter({ node, props }: IntentComponentProps) { + const subscriptionIntent = node.data?.intent; + const ev = useSubscription(CONTRIBUTOR, subscriptionIntent ?? "metrics.summary"); + + const value = ev?.payload.totalMetrics ?? ev?.payload.cpuPercent ?? "—"; + const title = props.title ?? node.title ?? subscriptionIntent ?? "Metric"; + + return ( +
+
{title}
+
{value}
+
+ ); +} diff --git a/extensions/dashboard/contract/shell/src/intents/page.shell.tsx b/extensions/dashboard/contract/shell/src/intents/page.shell.tsx new file mode 100644 index 00000000..c2ab32ed --- /dev/null +++ b/extensions/dashboard/contract/shell/src/intents/page.shell.tsx @@ -0,0 +1,21 @@ +import { SlotRenderer } from "../runtime/slots"; +import { usePrincipalStore } from "../auth/principal"; +import type { IntentComponentProps } from "../runtime/registry"; + +export function PageShell({ node, slots }: IntentComponentProps) { + const principal = usePrincipalStore((s) => s.principal); + const title = node.title ?? "Dashboard"; + return ( +
+
+

{title}

+
+ {principal ? {principal.displayName} : Loading…} +
+
+
+ +
+
+ ); +} diff --git a/extensions/dashboard/contract/shell/src/intents/register.ts b/extensions/dashboard/contract/shell/src/intents/register.ts new file mode 100644 index 00000000..e6a001e8 --- /dev/null +++ b/extensions/dashboard/contract/shell/src/intents/register.ts @@ -0,0 +1,10 @@ +import { IntentRegistry, type IntentComponent } from "../runtime/registry"; +import { PageShell } from "./page.shell"; +import { MetricCounter } from "./metric.counter"; + +export function buildIntentRegistry(): IntentRegistry { + const reg = new IntentRegistry(); + reg.register("page.shell", PageShell as unknown as IntentComponent); + reg.register("metric.counter", MetricCounter as unknown as IntentComponent); + return reg; +} diff --git a/extensions/dashboard/contract/shell/test/renderer.test.tsx b/extensions/dashboard/contract/shell/test/renderer.test.tsx index d1fdcb44..aa0313ae 100644 --- a/extensions/dashboard/contract/shell/test/renderer.test.tsx +++ b/extensions/dashboard/contract/shell/test/renderer.test.tsx @@ -54,3 +54,13 @@ describe("IntentRegistry", () => { expect(() => reg.register("x", () => null)).toThrow(/already registered/); }); }); + +import { buildIntentRegistry } from "../src/intents/register"; + +describe("buildIntentRegistry", () => { + it("registers page.shell and metric.counter", () => { + const reg = buildIntentRegistry(); + expect(reg.has("page.shell")).toBe(true); + expect(reg.has("metric.counter")).toBe(true); + }); +}); From a80cccbaa0c674c673341a0a41e53270e1432120 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sun, 10 May 2026 00:31:51 -0500 Subject: [PATCH 62/89] feat(dashboard/contract/shell): App routing + end-to-end smoke test App.tsx wires QueryClientProvider, IntentRegistryProvider, BrowserRouter (basename /dashboard/contract/app) with a catch-all that fetches the graph and renders via GraphRenderer. Principal store loads on mount. React Query retry disabled for predictable test timing. Test-infrastructure improvements that landed alongside the smoke test: - ContractClient and SubscriptionMux now read globalThis.fetch / globalThis.EventSource lazily so test-time interception (MSW patching fetch in beforeAll) is honored even when the shared instances were constructed at module load. - ContractClient.resolveURL joins relative paths against window.location.origin so Node's undici-backed fetch (which rejects relative URLs) gets an absolute URL. - vitest config sets jsdom url to http://localhost:3000 so window.location.origin is populated. --- .../dashboard/contract/shell/src/App.tsx | 51 +++++++++++++-- .../contract/shell/src/contract/client.ts | 28 ++++++-- .../contract/shell/src/contract/sse.ts | 17 +++-- .../contract/shell/test/smoke.test.tsx | 64 +++++++++++++++++++ .../dashboard/contract/shell/vitest.config.ts | 5 ++ 5 files changed, 153 insertions(+), 12 deletions(-) create mode 100644 extensions/dashboard/contract/shell/test/smoke.test.tsx diff --git a/extensions/dashboard/contract/shell/src/App.tsx b/extensions/dashboard/contract/shell/src/App.tsx index 4503cf68..577c377d 100644 --- a/extensions/dashboard/contract/shell/src/App.tsx +++ b/extensions/dashboard/contract/shell/src/App.tsx @@ -1,8 +1,51 @@ +import { useEffect } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { BrowserRouter, Route, Routes, useParams } from "react-router-dom"; +import { IntentRegistryProvider } from "./runtime/context"; +import { buildIntentRegistry } from "./intents/register"; +import { GraphRenderer } from "./runtime/renderer"; +import { useContractGraph } from "./contract/hooks"; +import { LoadingNode, ErrorNode } from "./runtime/fallbacks"; +import { usePrincipalStore } from "./auth/principal"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5_000, + refetchOnWindowFocus: false, + retry: false, + }, + }, +}); + +const registry = buildIntentRegistry(); + +function PageRoute() { + const params = useParams(); + const route = `/${params["*"] ?? ""}`; + const { data, isLoading, error } = useContractGraph("core-contract", route); + + if (isLoading) return ; + if (error) return ; + if (!data) return ; + return ; +} + export function App() { + const loadPrincipal = usePrincipalStore((s) => s.load); + useEffect(() => { + void loadPrincipal(); + }, [loadPrincipal]); + return ( -
-

Forge Dashboard Shell

-

Runtime scaffolded; routes will be wired in Phase 6.

-
+ + + + + } /> + + + + ); } diff --git a/extensions/dashboard/contract/shell/src/contract/client.ts b/extensions/dashboard/contract/shell/src/contract/client.ts index ce333d8c..aa20759b 100644 --- a/extensions/dashboard/contract/shell/src/contract/client.ts +++ b/extensions/dashboard/contract/shell/src/contract/client.ts @@ -28,12 +28,30 @@ export interface ClientOptions { export class ContractClient { private readonly baseURL: string; - private readonly fetcher: typeof fetch; + private readonly explicitFetcher: typeof fetch | undefined; private csrfToken: string | null = null; constructor(opts: ClientOptions = {}) { this.baseURL = opts.baseURL ?? "/api/dashboard/v1"; - this.fetcher = opts.fetcher ?? fetch; + this.explicitFetcher = opts.fetcher; + } + + // fetch reads globalThis.fetch at call time so test-time interception (e.g. + // MSW patching globalThis.fetch after this module loaded) is honored. + private get fetcher(): typeof fetch { + return this.explicitFetcher ?? globalThis.fetch.bind(globalThis); + } + + // resolveURL turns the (possibly relative) baseURL into an absolute URL + // by joining it against window.location.origin when running in the browser + // (or jsdom). Node's undici-backed fetch rejects relative URLs, so this + // matters for tests; in production it's a no-op for already-absolute URLs. + private resolveURL(path: string): string { + if (/^https?:\/\//.test(path)) return path; + if (typeof window !== "undefined" && window.location?.origin) { + return new URL(path, window.location.origin).toString(); + } + return path; } async query( @@ -98,7 +116,7 @@ export class ContractClient { idempotencyKey: input.idempotencyKey, }; - const res = await this.fetcher(this.baseURL, { + const res = await this.fetcher(this.resolveURL(this.baseURL), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(req), @@ -128,7 +146,9 @@ export class ContractClient { } private async refreshCSRF(): Promise { - const res = await this.fetcher(`${this.baseURL}/csrf`, { credentials: "include" }); + const res = await this.fetcher(this.resolveURL(`${this.baseURL}/csrf`), { + credentials: "include", + }); if (!res.ok) { this.csrfToken = null; return; diff --git a/extensions/dashboard/contract/shell/src/contract/sse.ts b/extensions/dashboard/contract/shell/src/contract/sse.ts index 0b2229f2..535e6974 100644 --- a/extensions/dashboard/contract/shell/src/contract/sse.ts +++ b/extensions/dashboard/contract/shell/src/contract/sse.ts @@ -16,8 +16,8 @@ interface PendingSub { export class SubscriptionMux { private readonly baseURL: string; - private readonly EventSourceCtor: typeof EventSource; - private readonly fetcher: typeof fetch; + private readonly explicitEventSource: typeof EventSource | undefined; + private readonly explicitFetcher: typeof fetch | undefined; private es: EventSource | null = null; private streamID: string | null = null; private pending: PendingSub[] = []; @@ -25,8 +25,17 @@ export class SubscriptionMux { constructor(opts: SubscriptionMuxOptions = {}) { this.baseURL = opts.baseURL ?? "/api/dashboard/v1"; - this.EventSourceCtor = opts.eventSource ?? globalThis.EventSource; - this.fetcher = opts.fetcher ?? globalThis.fetch; + this.explicitEventSource = opts.eventSource; + this.explicitFetcher = opts.fetcher; + } + + // Lazy lookups so test-time globals (jsdom + MSW + a fake EventSource set in beforeAll) + // are honored even though the shared mux was created at module load. + private get EventSourceCtor(): typeof EventSource { + return this.explicitEventSource ?? globalThis.EventSource; + } + private get fetcher(): typeof fetch { + return this.explicitFetcher ?? globalThis.fetch.bind(globalThis); } async subscribe( diff --git a/extensions/dashboard/contract/shell/test/smoke.test.tsx b/extensions/dashboard/contract/shell/test/smoke.test.tsx new file mode 100644 index 00000000..019b2bb2 --- /dev/null +++ b/extensions/dashboard/contract/shell/test/smoke.test.tsx @@ -0,0 +1,64 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { App } from "../src/App"; + +const server = setupServer( + http.get("/api/dashboard/v1/principal", () => + HttpResponse.json({ subject: "alice", displayName: "Alice", roles: [], scopes: [] }), + ), + http.post("/api/dashboard/v1", () => + HttpResponse.json({ + ok: true, + envelope: "v1", + kind: "graph", + data: { + intent: "page.shell", + title: "Live Metrics", + slots: { + main: [ + { + intent: "metric.counter", + title: "Total Metrics", + data: { intent: "metrics.summary" }, + }, + ], + }, + }, + meta: {}, + }), + ), +); + +beforeAll(() => { + // jsdom has no EventSource; provide a noop class so SubscriptionMux doesn't crash + // when metric.counter subscribes. + (globalThis as unknown as { EventSource: unknown }).EventSource = class { + constructor(public url: string) {} + addEventListener() {} + removeEventListener() {} + close() {} + onopen: (() => void) | null = null; + onerror: (() => void) | null = null; + }; + history.pushState({}, "", "/dashboard/contract/app/metrics/live"); + server.listen({ onUnhandledRequest: "error" }); +}); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe("App smoke", () => { + it("renders page.shell with metric.counter from a fetched graph", async () => { + render(); + expect( + await screen.findByText("Live Metrics", undefined, { timeout: 5000 }), + ).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText("Total Metrics")).toBeInTheDocument(); + }); + await waitFor(() => { + expect(screen.getByText("Alice")).toBeInTheDocument(); + }); + }, 10000); +}); diff --git a/extensions/dashboard/contract/shell/vitest.config.ts b/extensions/dashboard/contract/shell/vitest.config.ts index c3fd9ab4..5e2ef659 100644 --- a/extensions/dashboard/contract/shell/vitest.config.ts +++ b/extensions/dashboard/contract/shell/vitest.config.ts @@ -6,6 +6,11 @@ export default defineConfig({ test: { globals: true, environment: "jsdom", + environmentOptions: { + jsdom: { + url: "http://localhost:3000/", + }, + }, setupFiles: ["./test/setup.ts"], include: ["test/**/*.test.{ts,tsx}"], coverage: { provider: "v8" }, From 1bce87d96e528bd226847ecb82abbc9bd5be2c94 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sun, 10 May 2026 00:34:45 -0500 Subject: [PATCH 63/89] feat(dashboard): principal endpoint, shell embed, and SPA route registration Adds GET /api/dashboard/v1/principal returning the current UserInfo (subject, displayName, roles, scopes) for the React shell's topbar. Returns 401 when no user is in context. Embeds the production-built React shell into the dashboard binary via //go:embed dist/* on the new shell sub-package, and serves it under two new route groups guarded by the contract registration block: /dashboard/contract/static/* -> hashed-asset static handler with cache-friendly headers for /assets/* and no-cache for everything else /dashboard/contract/app[/*] -> SPA fallback returning index.html so React Router handles client-side navigation The shell's dist/ must be built (pnpm build) before go build for the embed to include real assets; a placeholder index.html is the minimum the embed directive needs to compile. --- extensions/dashboard/contract/shell/embed.go | 19 +++++ extensions/dashboard/extension.go | 72 +++++++++++++++++++ extensions/dashboard/handlers/principal.go | 39 ++++++++++ .../dashboard/handlers/principal_test.go | 59 +++++++++++++++ 4 files changed, 189 insertions(+) create mode 100644 extensions/dashboard/contract/shell/embed.go create mode 100644 extensions/dashboard/handlers/principal.go create mode 100644 extensions/dashboard/handlers/principal_test.go diff --git a/extensions/dashboard/contract/shell/embed.go b/extensions/dashboard/contract/shell/embed.go new file mode 100644 index 00000000..82150bd0 --- /dev/null +++ b/extensions/dashboard/contract/shell/embed.go @@ -0,0 +1,19 @@ +package shell + +import ( + "embed" + "io/fs" +) + +//go:embed all:dist +var distFS embed.FS + +// FS returns the production-built shell's static files. Files live under "dist/" +// within the embedded FS; the returned fs.FS strips that prefix so the static +// handler sees a flat root. +// +// The dist/ directory must exist at build time. Run `pnpm build` inside this +// directory before `go build` from the repo root. +func FS() (fs.FS, error) { + return fs.Sub(distFS, "dist") +} diff --git a/extensions/dashboard/extension.go b/extensions/dashboard/extension.go index aaf87df8..3fd36b14 100644 --- a/extensions/dashboard/extension.go +++ b/extensions/dashboard/extension.go @@ -4,7 +4,10 @@ import ( "context" "errors" "fmt" + "io" + "io/fs" "net/http" + "net/url" "os" "strings" "sync" @@ -25,6 +28,7 @@ import ( "github.com/xraph/forge/extensions/dashboard/contract/idempotency" "github.com/xraph/forge/extensions/dashboard/contract/loader" "github.com/xraph/forge/extensions/dashboard/contract/pilot" + contractshell "github.com/xraph/forge/extensions/dashboard/contract/shell" "github.com/xraph/forge/extensions/dashboard/contract/transport" "github.com/xraph/forge/extensions/dashboard/contributor" dashboarddiscovery "github.com/xraph/forge/extensions/dashboard/discovery" @@ -1374,6 +1378,23 @@ func (e *Extension) registerRoutes() { must(router.GET(base+"/api/dashboard/v1/csrf", transport.NewCSRFTokenHandler(e.csrfMgr, 12*time.Hour).ServeHTTP)) } + + // Slice (d) Phase 7: principal endpoint surfaces the current user to + // the React shell's topbar. Reads from dashauth.UserFromContext, so it + // honors whatever auth middleware the deployment has wired upstream. + must(router.GET(base+"/api/dashboard/v1/principal", handlers.HandleAPIPrincipalHTTP)) + + // Slice (d) Phase 7: static + SPA serving for the embedded React shell. + // Static assets at /dashboard/contract/static/* are served from the + // embedded dist/. Any path under /dashboard/contract/app/* serves + // index.html; React Router handles client-side routing. + shellFS, shellErr := contractshell.FS() + if shellErr == nil { + staticPrefix := base + "/contract/static" + must(router.GET(staticPrefix+"/*filepath", e.makeShellStaticHandler(shellFS, staticPrefix))) + must(router.GET(base+"/contract/app", e.makeShellSPAHandler(shellFS))) + must(router.GET(base+"/contract/app/*filepath", e.makeShellSPAHandler(shellFS))) + } } // 4. Export endpoints (stay on forge.Router) @@ -1648,3 +1669,54 @@ func (a *idempotencyAdapter) Store(ctx context.Context, key, identity string, c TTL: c.TTL, }) } + +// makeShellStaticHandler serves files from the embedded React shell at +// /{base}/contract/static/*. Hashed asset paths (under /assets/) are cached +// aggressively; everything else uses a no-cache header so deploys land +// immediately. The stripPrefix is the URL prefix the static handler is +// mounted at — request paths beneath it are resolved against the embedded FS. +func (e *Extension) makeShellStaticHandler(shellFS fs.FS, stripPrefix string) http.HandlerFunc { + fileServer := http.FileServer(http.FS(shellFS)) + return func(w http.ResponseWriter, r *http.Request) { + trimmed := strings.TrimPrefix(r.URL.Path, stripPrefix) + if trimmed == "" { + trimmed = "/" + } + if strings.Contains(trimmed, "/assets/") { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } else { + w.Header().Set("Cache-Control", "no-cache") + } + r2 := r.Clone(r.Context()) + r2.URL = cloneURLWithPath(r.URL, trimmed) + fileServer.ServeHTTP(w, r2) + } +} + +// makeShellSPAHandler returns the SPA index.html for any path under +// /{base}/contract/app/*. React Router handles the client-side routing. +func (e *Extension) makeShellSPAHandler(shellFS fs.FS) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + f, err := shellFS.Open("index.html") + if err != nil { + http.Error(w, "shell index missing — has `pnpm build` been run inside extensions/dashboard/contract/shell?", http.StatusInternalServerError) + return + } + defer f.Close() + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + _, _ = io.Copy(w, f) + } +} + +// cloneURLWithPath returns a copy of u with Path replaced. Used by the static +// handler so it doesn't mutate the original request's URL. +func cloneURLWithPath(u *url.URL, path string) *url.URL { + if u == nil { + return &url.URL{Path: path} + } + clone := *u + clone.Path = path + clone.RawPath = "" + return &clone +} diff --git a/extensions/dashboard/handlers/principal.go b/extensions/dashboard/handlers/principal.go new file mode 100644 index 00000000..8e28e761 --- /dev/null +++ b/extensions/dashboard/handlers/principal.go @@ -0,0 +1,39 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + dashauth "github.com/xraph/forge/extensions/dashboard/auth" +) + +// principalResponse is the wire shape for GET /api/dashboard/v1/principal. +type principalResponse struct { + Subject string `json:"subject"` + DisplayName string `json:"displayName"` + Email string `json:"email,omitempty"` + Roles []string `json:"roles"` + Scopes []string `json:"scopes"` +} + +// HandleAPIPrincipalHTTP returns the current user's principal info as JSON. +// 401 when no user is in context. +func HandleAPIPrincipalHTTP(w http.ResponseWriter, r *http.Request) { + user := dashauth.UserFromContext(r.Context()) + if user == nil { + http.Error(w, "unauthenticated", http.StatusUnauthorized) + return + } + resp := principalResponse{ + Subject: user.Subject, + DisplayName: user.DisplayName, + Email: user.Email, + Roles: append([]string{}, user.Roles...), + Scopes: append([]string{}, user.Scopes...), + } + if resp.DisplayName == "" { + resp.DisplayName = resp.Subject + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} diff --git a/extensions/dashboard/handlers/principal_test.go b/extensions/dashboard/handlers/principal_test.go new file mode 100644 index 00000000..aa393cdf --- /dev/null +++ b/extensions/dashboard/handlers/principal_test.go @@ -0,0 +1,59 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + dashauth "github.com/xraph/forge/extensions/dashboard/auth" +) + +func TestHandleAPIPrincipal_OK(t *testing.T) { + user := &dashauth.UserInfo{ + Subject: "alice", + DisplayName: "Alice", + Roles: []string{"admin"}, + Scopes: []string{"users.read"}, + } + ctx := dashauth.WithUser(context.Background(), user) + req := httptest.NewRequest(http.MethodGet, "/api/dashboard/v1/principal", nil).WithContext(ctx) + w := httptest.NewRecorder() + HandleAPIPrincipalHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", w.Code, w.Body) + } + var body map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if body["subject"] != "alice" || body["displayName"] != "Alice" { + t.Errorf("unexpected body: %v", body) + } +} + +func TestHandleAPIPrincipal_DisplayNameFallsBackToSubject(t *testing.T) { + user := &dashauth.UserInfo{Subject: "u_42"} + ctx := dashauth.WithUser(context.Background(), user) + req := httptest.NewRequest(http.MethodGet, "/api/dashboard/v1/principal", nil).WithContext(ctx) + w := httptest.NewRecorder() + HandleAPIPrincipalHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", w.Code, w.Body) + } + var body map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &body) + if body["displayName"] != "u_42" { + t.Errorf("expected displayName fallback to subject, got %q", body["displayName"]) + } +} + +func TestHandleAPIPrincipal_Unauthenticated(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/dashboard/v1/principal", nil) + w := httptest.NewRecorder() + HandleAPIPrincipalHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", w.Code) + } +} From 1410c051800178f19f8ceec71fefffa05f4df995 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sun, 10 May 2026 00:38:52 -0500 Subject: [PATCH 64/89] docs(dashboard/contract): add slice-e built-in vocabulary design (shadcn-based) --- .../dashboard/contract/SLICE_E_DESIGN.md | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 extensions/dashboard/contract/SLICE_E_DESIGN.md diff --git a/extensions/dashboard/contract/SLICE_E_DESIGN.md b/extensions/dashboard/contract/SLICE_E_DESIGN.md new file mode 100644 index 00000000..1e018125 --- /dev/null +++ b/extensions/dashboard/contract/SLICE_E_DESIGN.md @@ -0,0 +1,146 @@ +# Slice (e) — Built-in Intent Vocabulary v1 + +> Companion design doc to [SLICE_D_DESIGN.md](SLICE_D_DESIGN.md). Slice (d) shipped the renderer and one example component; slice (e) ships the actual vocabulary the dashboard contract YAML will reach for. + +## Context + +Slice (d) shipped the React shell with a single concrete intent (`metric.counter`) that proved the pipeline works. Slice (e) builds out the rest of the v1 vocabulary so the pilot's three pages — and any contributor's manifest — can render real admin UI without falling back to `UnknownIntent`. **Every component is built on shadcn/ui** (Radix primitives + Tailwind), giving the dashboard accessible, themed, polished UI without bespoke styling work. + +Slice (e) also retroactively refactors the components written in slice (d) (`PageShell`, `MetricCounter`, fallbacks) onto shadcn — keeping the codebase consistent and avoiding a mixed primitive/non-primitive split. + +## Architecture Decisions (locked in) + +| Decision | Choice | Rationale | +|---|---|---| +| UI primitive layer | **shadcn/ui** (vendored — copied into `src/components/ui/`, NOT npm dependency) | Industry default for React+Tailwind admin tools; full control over the components since they live in our tree; no upstream dependency churn. | +| Icon set | **lucide-react** | shadcn's standard icon companion; tree-shakable; comprehensive. | +| Path alias | `@/*` → `src/*` | shadcn convention; clean imports across the runtime. | +| Theme tokens | CSS variables (HSL) for `background`, `foreground`, `primary`, `card`, `border`, etc.; `.dark` class flips them. | shadcn convention; supports light + dark out of the box; deployment can override colors via CSS without rebuilding. | +| Dark mode | Class-based (`.dark` on ``); user toggle persisted via Zustand + localStorage | Standard pattern; respects `prefers-color-scheme` on first load. | +| Form library | **react-hook-form** + **zod** for validation, wrapped in shadcn's `Form` primitive | Standard shadcn/ui form pattern; type-safe schemas. | +| Table | shadcn `Table` for `resource.list`; sorting/filtering done client-side for v1 (server-side query params are slice (f)+) | Keeps the v1 component tractable; the contract already supports server-side filters via query params. | +| Drawer / sheet | shadcn `Sheet` for `resource.list.detailDrawer` | Slides in from the right; matches admin-tool conventions. | + +## Vocabulary scope (v1) + +### Refactored from slice (d) +| Intent | shadcn primitives | Behavior | +|---|---|---| +| `page.shell` | `header`, lucide icon, `Avatar`, `DropdownMenu` for the user menu, `Separator`, theme toggle | Topbar + main slot; principal info + dark mode toggle live in the topbar. | +| `metric.counter` | `Card`, `CardHeader`, `CardTitle`, `CardContent` | Subscribed counter; renders a numeric value with a label. | +| `UnknownIntent` (fallback) | `Alert` (warning variant) | Graceful degradation when the registry doesn't have an intent. | +| `LoadingNode` (fallback) | `Skeleton` | Replaces the bare "Loading…" string. | +| `ErrorNode` (fallback) | `Alert` (destructive variant) | Replaces the bare red box. | + +### New in slice (e) +| Intent | shadcn primitives | Behavior | +|---|---|---| +| `resource.list` | `Table`, `TableHead/Body/Row/Cell`, `Sheet` for detailDrawer slot, `Skeleton` while loading | Renders rows from a query intent; columns from `node.props.columns`; row click opens the `detailDrawer` slot in a Sheet; renders the `rowActions` slot per row. | +| `resource.detail` | `Card`, `dl/dt/dd` typography, `Skeleton` | Renders a fetched record's fields. | +| `dashboard.grid` | CSS grid (Tailwind `grid grid-cols-*`), no shadcn-specific primitive | Lays out widget children from the `widgets` slot in a responsive grid. | +| `form.edit` | `Form` (shadcn wrapper around react-hook-form), `Button` for submit | Wraps fields, runs the `op` command on submit. Pre-populates from `node.data` (a query intent). | +| `form.field` | `FormField`, `FormLabel`, `FormControl`, `FormDescription`, `Input`, `Select`, `Checkbox`, `Textarea` | Branches by `node.props.kind`. | +| `action.button` | `Button` (variants: default, destructive, outline) | Issues a `command` envelope when clicked; `confirm` prop opens shadcn `AlertDialog` first. | +| `action.menu` | `DropdownMenu`, `DropdownMenuItem` | Renders a list of action.button-shaped items in a popover. | +| `action.divider` | `DropdownMenuSeparator` (or `Separator` outside menus) | Visual separator. | +| `audit.tail` | `ScrollArea`, monospace `` rows | Append-mode subscription; new entries push onto the bottom; auto-scroll until user scrolls up. | + +## Theme tokens + +```css +:root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --radius: 0.5rem; +} +.dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; +} +``` + +These mirror shadcn's "slate" defaults. Deployments override via a CSS file shipped alongside the embedded bundle. + +## File Structure (additions to slice d) + +``` +extensions/dashboard/contract/shell/ + components.json # shadcn config + src/lib/utils.ts # cn() helper + src/lib/theme.ts # Zustand theme store with localStorage persistence + src/components/ui/ # vendored shadcn primitives (one file per component) + button.tsx + card.tsx + alert.tsx + skeleton.tsx + separator.tsx + avatar.tsx + dropdown-menu.tsx + sheet.tsx + table.tsx + scroll-area.tsx + form.tsx + input.tsx + label.tsx + select.tsx + checkbox.tsx + textarea.tsx + alert-dialog.tsx + src/components/theme-toggle.tsx # Sun/Moon button + src/intents/ + page.shell.tsx # REFACTORED on shadcn + metric.counter.tsx # REFACTORED on shadcn + resource.list.tsx # NEW + resource.detail.tsx # NEW + dashboard.grid.tsx # NEW + form.edit.tsx # NEW + form.field.tsx # NEW + action.button.tsx # NEW + action.menu.tsx # NEW + action.divider.tsx # NEW + audit.tail.tsx # NEW + register.ts # UPDATED to register all of the above + src/runtime/fallbacks.tsx # REFACTORED on shadcn (Alert / Skeleton) + +extensions/dashboard/contract/shell/ARCHITECTURE.md # NEW: how to author intent components +extensions/dashboard/contract/shell/README.md # EXPANDED +``` + +## Verification + +- Existing 13 tests stay green after the shadcn refactor. +- 1 smoke test per new intent (~9 new tests): renders given a representative `GraphNode` + props. +- `pnpm build` clean, bundle stays under 350KB gzipped (shadcn pulls Radix primitives — budget bumps from 250KB). +- `pnpm lint` clean. +- `go build ./...` and `go test ./extensions/dashboard/...` clean. + +## Out of Scope (still future) + +- Slice (f): contributor migration + templ retirement. +- Server-side filtering/sorting/pagination for `resource.list` (client-side covers the v1 cases). +- Custom column rendering via the `customCell.` slot (designed in slice (a), but no concrete component yet). +- Iframe escape-hatch component. +- Browser E2E (Playwright). +- Internationalization. From 1b365e8510bd3031945e9444fb90a760f5e7c777 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sun, 10 May 2026 00:40:51 -0500 Subject: [PATCH 65/89] feat(dashboard/contract/shell): shadcn infrastructure (Radix deps, theme tokens, @ alias) Adds shadcn/ui foundation: class-variance-authority + clsx + tailwind-merge for the cn() helper, tailwindcss-animate for Radix animations, lucide-react for icons, plus the Radix primitives slice (e) builds on (Slot, Separator, DropdownMenu, Dialog, Avatar, ScrollArea, Select, Checkbox, Label, AlertDialog) and the form stack (react-hook-form + zod + @hookform/resolvers). @/* is set up in tsconfig.json + vite + vitest configs so shadcn's convention (@/components/ui/*, @/lib/utils) works. Tailwind config gains the shadcn-standard color tokens (background, foreground, primary, muted, destructive, etc.) wired to CSS variables in index.css, with a .dark class flipping them. components.json declares the shadcn config shape so future shadcn CLI additions land in the right place. A Zustand-backed theme store at src/lib/theme.ts handles light/dark/system with localStorage persistence and reacts to prefers-color-scheme changes when the user's preference is system. --- .../dashboard/contract/shell/components.json | 20 + .../dashboard/contract/shell/package.json | 18 + .../dashboard/contract/shell/pnpm-lock.yaml | 1280 +++++++++++++++-- .../dashboard/contract/shell/src/index.css | 57 + .../dashboard/contract/shell/src/lib/theme.ts | 68 + .../dashboard/contract/shell/src/lib/utils.ts | 7 + .../contract/shell/tailwind.config.ts | 68 +- .../dashboard/contract/shell/tsconfig.json | 6 +- .../dashboard/contract/shell/vite.config.ts | 9 + .../dashboard/contract/shell/vitest.config.ts | 9 + 10 files changed, 1458 insertions(+), 84 deletions(-) create mode 100644 extensions/dashboard/contract/shell/components.json create mode 100644 extensions/dashboard/contract/shell/src/lib/theme.ts create mode 100644 extensions/dashboard/contract/shell/src/lib/utils.ts diff --git a/extensions/dashboard/contract/shell/components.json b/extensions/dashboard/contract/shell/components.json new file mode 100644 index 00000000..66fa9e6a --- /dev/null +++ b/extensions/dashboard/contract/shell/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/index.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/extensions/dashboard/contract/shell/package.json b/extensions/dashboard/contract/shell/package.json index f07e6247..4978e3b0 100644 --- a/extensions/dashboard/contract/shell/package.json +++ b/extensions/dashboard/contract/shell/package.json @@ -13,10 +13,28 @@ "format": "prettier --write src test" }, "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", "@tanstack/react-query": "^5.40.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.14.0", "react": "^18.3.0", "react-dom": "^18.3.0", + "react-hook-form": "^7.75.0", "react-router-dom": "^6.26.0", + "tailwind-merge": "^3.5.0", + "tailwindcss-animate": "^1.0.7", + "zod": "^4.4.3", "zustand": "^4.5.0" }, "devDependencies": { diff --git a/extensions/dashboard/contract/shell/pnpm-lock.yaml b/extensions/dashboard/contract/shell/pnpm-lock.yaml index 2d3a661f..8815d851 100644 --- a/extensions/dashboard/contract/shell/pnpm-lock.yaml +++ b/extensions/dashboard/contract/shell/pnpm-lock.yaml @@ -8,18 +8,72 @@ importers: .: dependencies: + '@hookform/resolvers': + specifier: ^5.2.2 + version: 5.2.2(react-hook-form@7.75.0(react@18.3.1)) + '@radix-ui/react-alert-dialog': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-avatar': + specifier: ^1.1.11 + version: 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-checkbox': + specifier: ^1.3.3 + version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dialog': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.16 + version: 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-label': + specifier: ^2.1.8 + version: 2.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-scroll-area': + specifier: ^1.2.10 + version: 1.2.10(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-select': + specifier: ^2.2.6 + version: 2.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-separator': + specifier: ^1.1.8 + version: 1.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': + specifier: ^1.2.4 + version: 1.2.4(@types/react@18.3.28)(react@18.3.1) '@tanstack/react-query': specifier: ^5.40.0 version: 5.100.9(react@18.3.1) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + lucide-react: + specifier: ^1.14.0 + version: 1.14.0(react@18.3.1) react: specifier: ^18.3.0 version: 18.3.1 react-dom: specifier: ^18.3.0 version: 18.3.1(react@18.3.1) + react-hook-form: + specifier: ^7.75.0 + version: 7.75.0(react@18.3.1) react-router-dom: specifier: ^6.26.0 version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + tailwind-merge: + specifier: ^3.5.0 + version: 3.5.0 + tailwindcss-animate: + specifier: ^1.0.7 + version: 1.0.7(tailwindcss@3.4.19) + zod: + specifier: ^4.4.3 + version: 4.4.3 zustand: specifier: ^4.5.0 version: 4.5.7(@types/react@18.3.28)(react@18.3.1) @@ -332,6 +386,26 @@ packages: cpu: [x64] os: [win32] + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@hookform/resolvers@5.2.2': + resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==} + peerDependencies: + react-hook-form: ^7.55.0 + '@inquirer/ansi@2.0.5': resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} @@ -411,6 +485,441 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-alert-dialog@1.1.15': + resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-avatar@1.1.11': + resolution: {integrity: sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-checkbox@1.3.3': + resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.3': + resolution: {integrity: sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.8': + resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.4': + resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-scroll-area@1.2.10': + resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.2.6': + resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.1.8': + resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.0': + resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@remix-run/router@1.23.2': resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==} engines: {node: '>=14.0.0'} @@ -543,6 +1052,9 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@tanstack/query-core@5.100.9': resolution: {integrity: sha512-SJSFw1S8+kQ0+knv/XGfrbocWoAlT7vDKsSImtLx3ZPQmEcR46hkDjLSvynSy25N8Ms4tIEini1FuBd5k7IscQ==} @@ -676,6 +1188,10 @@ packages: arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} @@ -742,6 +1258,9 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} @@ -750,6 +1269,10 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -815,6 +1338,9 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} @@ -932,6 +1458,10 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -1063,6 +1593,11 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucide-react@1.14.0: + resolution: {integrity: sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -1243,6 +1778,12 @@ packages: peerDependencies: react: ^18.3.1 + react-hook-form@7.75.0: + resolution: {integrity: sha512-Ovv94H+0p3sJ7B9B5QxPuCP1u8V/cHuVGyH55cSwodYDtoJwK+fqk3vjfIgSX59I2U/bU4z0nRJ9HMLpNiWEmw==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} @@ -1250,6 +1791,26 @@ packages: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react-router-dom@6.30.3: resolution: {integrity: sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==} engines: {node: '>=14.0.0'} @@ -1263,6 +1824,16 @@ packages: peerDependencies: react: '>=16.8' + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -1377,6 +1948,14 @@ packages: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + + tailwindcss-animate@1.0.7: + resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders' + tailwindcss@3.4.19: resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} engines: {node: '>=14.0.0'} @@ -1444,6 +2023,9 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-fest@5.6.0: resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} engines: {node: '>=20'} @@ -1465,6 +2047,26 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -1598,6 +2200,9 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zustand@4.5.7: resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} engines: {node: '>=12.7.0'} @@ -1785,128 +2390,565 @@ snapshots: '@esbuild/freebsd-x64@0.21.5': optional: true - '@esbuild/linux-arm64@0.21.5': - optional: true + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@floating-ui/utils@0.2.11': {} + + '@hookform/resolvers@5.2.2(react-hook-form@7.75.0(react@18.3.1))': + dependencies: + '@standard-schema/utils': 0.3.0 + react-hook-form: 7.75.0(react@18.3.1) + + '@inquirer/ansi@2.0.5': {} + + '@inquirer/confirm@6.0.12(@types/node@25.6.2)': + dependencies: + '@inquirer/core': 11.1.9(@types/node@25.6.2) + '@inquirer/type': 4.0.5(@types/node@25.6.2) + optionalDependencies: + '@types/node': 25.6.2 + + '@inquirer/core@11.1.9(@types/node@25.6.2)': + dependencies: + '@inquirer/ansi': 2.0.5 + '@inquirer/figures': 2.0.5 + '@inquirer/type': 4.0.5(@types/node@25.6.2) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.0 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 25.6.2 + + '@inquirer/figures@2.0.5': {} + + '@inquirer/type@4.0.5(@types/node@25.6.2)': + optionalDependencies: + '@types/node': 25.6.2 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@mswjs/interceptors@0.41.8': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/deferred-promise@3.0.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + + '@radix-ui/number@1.1.1': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-avatar@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-context': 1.1.3(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.28)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-context@1.1.2(@types/react@18.3.28)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-context@1.1.3(@types/react@18.3.28)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-dialog@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.28)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-direction@1.1.1(@types/react@18.3.28)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 - '@esbuild/linux-arm@0.21.5': - optional: true + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@esbuild/linux-ia32@0.21.5': - optional: true + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@esbuild/linux-loong64@0.21.5': - optional: true + '@radix-ui/react-focus-guards@1.1.3(@types/react@18.3.28)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 - '@esbuild/linux-mips64el@0.21.5': - optional: true + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@esbuild/linux-ppc64@0.21.5': - optional: true + '@radix-ui/react-id@1.1.1(@types/react@18.3.28)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 - '@esbuild/linux-riscv64@0.21.5': - optional: true + '@radix-ui/react-label@2.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@esbuild/linux-s390x@0.21.5': - optional: true + '@radix-ui/react-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.28)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@esbuild/linux-x64@0.21.5': - optional: true + '@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-rect': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/rect': 1.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@esbuild/netbsd-x64@0.21.5': - optional: true + '@radix-ui/react-portal@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@esbuild/openbsd-x64@0.21.5': - optional: true + '@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@esbuild/sunos-x64@0.21.5': - optional: true + '@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@esbuild/win32-arm64@0.21.5': - optional: true + '@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.2.4(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@esbuild/win32-ia32@0.21.5': - optional: true + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@esbuild/win32-x64@0.21.5': - optional: true + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@inquirer/ansi@2.0.5': {} + '@radix-ui/react-select@2.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.28)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@inquirer/confirm@6.0.12(@types/node@25.6.2)': + '@radix-ui/react-separator@1.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@inquirer/core': 11.1.9(@types/node@25.6.2) - '@inquirer/type': 4.0.5(@types/node@25.6.2) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) optionalDependencies: - '@types/node': 25.6.2 + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@inquirer/core@11.1.9(@types/node@25.6.2)': + '@radix-ui/react-slot@1.2.3(@types/react@18.3.28)(react@18.3.1)': dependencies: - '@inquirer/ansi': 2.0.5 - '@inquirer/figures': 2.0.5 - '@inquirer/type': 4.0.5(@types/node@25.6.2) - cli-width: 4.1.0 - fast-wrap-ansi: 0.2.0 - mute-stream: 3.0.0 - signal-exit: 4.1.0 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 optionalDependencies: - '@types/node': 25.6.2 - - '@inquirer/figures@2.0.5': {} + '@types/react': 18.3.28 - '@inquirer/type@4.0.5(@types/node@25.6.2)': + '@radix-ui/react-slot@1.2.4(@types/react@18.3.28)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 optionalDependencies: - '@types/node': 25.6.2 + '@types/react': 18.3.28 - '@jridgewell/gen-mapping@0.3.13': + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.28)(react@18.3.1)': dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 - '@jridgewell/remapping@2.3.5': + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@18.3.28)(react@18.3.1)': dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 - '@jridgewell/trace-mapping@0.3.31': + '@radix-ui/react-use-effect-event@0.0.2(@types/react@18.3.28)(react@18.3.1)': dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 - '@mswjs/interceptors@0.41.8': + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@18.3.28)(react@18.3.1)': dependencies: - '@open-draft/deferred-promise': 2.2.0 - '@open-draft/logger': 0.3.0 - '@open-draft/until': 2.1.0 - is-node-process: 1.2.0 - outvariant: 1.4.3 - strict-event-emitter: 0.5.1 + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 - '@nodelib/fs.scandir@2.1.5': + '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@18.3.28)(react@18.3.1)': dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 + react: 18.3.1 + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 - '@nodelib/fs.stat@2.0.5': {} + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.3.28)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 - '@nodelib/fs.walk@1.2.8': + '@radix-ui/react-use-previous@1.1.1(@types/react@18.3.28)(react@18.3.1)': dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 - '@open-draft/deferred-promise@2.2.0': {} + '@radix-ui/react-use-rect@1.1.1(@types/react@18.3.28)(react@18.3.1)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 - '@open-draft/deferred-promise@3.0.0': {} + '@radix-ui/react-use-size@1.1.1(@types/react@18.3.28)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 - '@open-draft/logger@0.3.0': + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - is-node-process: 1.2.0 - outvariant: 1.4.3 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@open-draft/until@2.1.0': {} + '@radix-ui/rect@1.1.1': {} '@remix-run/router@1.23.2': {} @@ -1987,6 +3029,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.3': optional: true + '@standard-schema/utils@0.3.0': {} + '@tanstack/query-core@5.100.9': {} '@tanstack/react-query@5.100.9(react@18.3.1)': @@ -2144,6 +3188,10 @@ snapshots: arg@5.0.2: {} + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + aria-query@5.3.0: dependencies: dequal: 2.0.3 @@ -2212,6 +3260,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + cli-width@4.1.0: {} cliui@8.0.1: @@ -2220,6 +3272,8 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + clsx@2.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -2264,6 +3318,8 @@ snapshots: dequal@2.0.3: {} + detect-node-es@1.1.0: {} + didyoumean@1.2.2: {} dlv@1.1.3: {} @@ -2397,6 +3453,8 @@ snapshots: hasown: 2.0.3 math-intrinsics: 1.1.0 + get-nonce@1.0.1: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -2527,6 +3585,10 @@ snapshots: dependencies: yallist: 3.1.1 + lucide-react@1.14.0(react@18.3.1): + dependencies: + react: 18.3.1 + lz-string@1.5.0: {} magic-string@0.30.21: @@ -2676,10 +3738,33 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-hook-form@7.75.0(react@18.3.1): + dependencies: + react: 18.3.1 + react-is@17.0.2: {} react-refresh@0.17.0: {} + react-remove-scroll-bar@2.3.8(@types/react@18.3.28)(react@18.3.1): + dependencies: + react: 18.3.1 + react-style-singleton: 2.2.3(@types/react@18.3.28)(react@18.3.1) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.28 + + react-remove-scroll@2.7.2(@types/react@18.3.28)(react@18.3.1): + dependencies: + react: 18.3.1 + react-remove-scroll-bar: 2.3.8(@types/react@18.3.28)(react@18.3.1) + react-style-singleton: 2.2.3(@types/react@18.3.28)(react@18.3.1) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@18.3.28)(react@18.3.1) + use-sidecar: 1.1.3(@types/react@18.3.28)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + react-router-dom@6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@remix-run/router': 1.23.2 @@ -2692,6 +3777,14 @@ snapshots: '@remix-run/router': 1.23.2 react: 18.3.1 + react-style-singleton@2.2.3(@types/react@18.3.28)(react@18.3.1): + dependencies: + get-nonce: 1.0.1 + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.28 + react@18.3.1: dependencies: loose-envify: 1.4.0 @@ -2819,6 +3912,12 @@ snapshots: tagged-tag@1.0.0: {} + tailwind-merge@3.5.0: {} + + tailwindcss-animate@1.0.7(tailwindcss@3.4.19): + dependencies: + tailwindcss: 3.4.19 + tailwindcss@3.4.19: dependencies: '@alloc/quick-lru': 5.2.0 @@ -2900,6 +3999,8 @@ snapshots: ts-interface-checker@0.1.13: {} + tslib@2.8.1: {} + type-fest@5.6.0: dependencies: tagged-tag: 1.0.0 @@ -2916,6 +4017,21 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + use-callback-ref@1.3.3(@types/react@18.3.28)(react@18.3.1): + dependencies: + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.28 + + use-sidecar@1.1.3(@types/react@18.3.28)(react@18.3.1): + dependencies: + detect-node-es: 1.1.0 + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.28 + use-sync-external-store@1.6.0(react@18.3.1): dependencies: react: 18.3.1 @@ -3035,6 +4151,8 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + zod@4.4.3: {} + zustand@4.5.7(@types/react@18.3.28)(react@18.3.1): dependencies: use-sync-external-store: 1.6.0(react@18.3.1) diff --git a/extensions/dashboard/contract/shell/src/index.css b/extensions/dashboard/contract/shell/src/index.css index 8a143165..0959e6be 100644 --- a/extensions/dashboard/contract/shell/src/index.css +++ b/extensions/dashboard/contract/shell/src/index.css @@ -2,6 +2,63 @@ @tailwind components; @tailwind utilities; +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + font-feature-settings: "rlig" 1, "calt" 1; + } +} + html, body, #root { height: 100%; } diff --git a/extensions/dashboard/contract/shell/src/lib/theme.ts b/extensions/dashboard/contract/shell/src/lib/theme.ts new file mode 100644 index 00000000..4f3223a0 --- /dev/null +++ b/extensions/dashboard/contract/shell/src/lib/theme.ts @@ -0,0 +1,68 @@ +import { create } from "zustand"; + +export type Theme = "light" | "dark" | "system"; + +interface ThemeState { + theme: Theme; + /** resolved is the actual mode applied to the DOM (light or dark, never "system"). */ + resolved: "light" | "dark"; + setTheme: (t: Theme) => void; + /** Reads from localStorage + system preference on first run; safe to call repeatedly. */ + init: () => void; +} + +const STORAGE_KEY = "forge.dashboard.theme"; + +function readStoredTheme(): Theme { + if (typeof window === "undefined") return "system"; + const v = window.localStorage.getItem(STORAGE_KEY); + if (v === "light" || v === "dark" || v === "system") return v; + return "system"; +} + +function systemPrefersDark(): boolean { + if (typeof window === "undefined" || !window.matchMedia) return false; + return window.matchMedia("(prefers-color-scheme: dark)").matches; +} + +function resolve(theme: Theme): "light" | "dark" { + if (theme === "system") return systemPrefersDark() ? "dark" : "light"; + return theme; +} + +function applyToDocument(resolved: "light" | "dark"): void { + if (typeof document === "undefined") return; + const html = document.documentElement; + if (resolved === "dark") html.classList.add("dark"); + else html.classList.remove("dark"); +} + +export const useThemeStore = create((set, get) => ({ + theme: "system", + resolved: "light", + setTheme(t) { + if (typeof window !== "undefined") { + window.localStorage.setItem(STORAGE_KEY, t); + } + const resolved = resolve(t); + applyToDocument(resolved); + set({ theme: t, resolved }); + }, + init() { + const stored = readStoredTheme(); + const resolved = resolve(stored); + applyToDocument(resolved); + set({ theme: stored, resolved }); + + // Listen for system preference changes when user is on "system". + if (typeof window !== "undefined" && window.matchMedia) { + const mql = window.matchMedia("(prefers-color-scheme: dark)"); + mql.addEventListener("change", () => { + if (get().theme !== "system") return; + const r = systemPrefersDark() ? "dark" : "light"; + applyToDocument(r); + set({ resolved: r }); + }); + } + }, +})); diff --git a/extensions/dashboard/contract/shell/src/lib/utils.ts b/extensions/dashboard/contract/shell/src/lib/utils.ts new file mode 100644 index 00000000..caf18272 --- /dev/null +++ b/extensions/dashboard/contract/shell/src/lib/utils.ts @@ -0,0 +1,7 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +/** cn merges Tailwind class strings, deduplicating conflicts via tailwind-merge. */ +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)); +} diff --git a/extensions/dashboard/contract/shell/tailwind.config.ts b/extensions/dashboard/contract/shell/tailwind.config.ts index 9fb0e282..50d59cfa 100644 --- a/extensions/dashboard/contract/shell/tailwind.config.ts +++ b/extensions/dashboard/contract/shell/tailwind.config.ts @@ -1,9 +1,73 @@ import type { Config } from "tailwindcss"; +import animate from "tailwindcss-animate"; const config: Config = { + darkMode: "class", content: ["./index.html", "./src/**/*.{ts,tsx}"], - theme: { extend: {} }, - plugins: [], + theme: { + container: { + center: true, + padding: "2rem", + screens: { "2xl": "1400px" }, + }, + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + keyframes: { + "accordion-down": { + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + }, + }, + }, + plugins: [animate], }; export default config; diff --git a/extensions/dashboard/contract/shell/tsconfig.json b/extensions/dashboard/contract/shell/tsconfig.json index 19d92698..857e2dfc 100644 --- a/extensions/dashboard/contract/shell/tsconfig.json +++ b/extensions/dashboard/contract/shell/tsconfig.json @@ -16,7 +16,11 @@ "allowImportingTsExtensions": false, "resolveJsonModule": true, "useDefineForClassFields": true, - "types": ["vitest/globals", "@testing-library/jest-dom"] + "types": ["vitest/globals", "@testing-library/jest-dom"], + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } }, "include": ["src", "test"], "references": [{ "path": "./tsconfig.node.json" }] diff --git a/extensions/dashboard/contract/shell/vite.config.ts b/extensions/dashboard/contract/shell/vite.config.ts index ce5ef780..c9b854ec 100644 --- a/extensions/dashboard/contract/shell/vite.config.ts +++ b/extensions/dashboard/contract/shell/vite.config.ts @@ -1,8 +1,17 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); export default defineConfig({ plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, base: "/dashboard/contract/static/", build: { outDir: "dist", diff --git a/extensions/dashboard/contract/shell/vitest.config.ts b/extensions/dashboard/contract/shell/vitest.config.ts index 5e2ef659..0232ebb2 100644 --- a/extensions/dashboard/contract/shell/vitest.config.ts +++ b/extensions/dashboard/contract/shell/vitest.config.ts @@ -1,8 +1,17 @@ import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); export default defineConfig({ plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, test: { globals: true, environment: "jsdom", From 03e982dfee6e84c6d13e4635d46fdaea3cc479df Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sun, 10 May 2026 00:43:58 -0500 Subject: [PATCH 66/89] feat(dashboard/contract/shell): refactor existing components onto shadcn (Card, Alert, Skeleton, DropdownMenu, Avatar) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vendors the shadcn primitives needed for the slice (d) refactor and the slice (e) vocabulary that follows: Button, Card, Alert, Skeleton, Separator, Avatar, DropdownMenu — all standard shadcn templates dropped into src/components/ui/, plus a ThemeToggle component using the new DropdownMenu + lucide-react icons. Refactored slice (d) components: - PageShell: shadcn-styled topbar with Avatar (initials fallback), Separator, ThemeToggle, theme-aware bg-card/border-border tokens, LayoutDashboard icon for branding. - MetricCounter: now a Card with CardHeader+CardTitle+CardContent; reads from theme tokens for foreground colors. - UnknownIntent / LoadingNode / ErrorNode: shadcn Alert (warning, default, destructive variants) with lucide AlertTriangle/AlertCircle icons. LoadingNode now renders a 3-line Skeleton. App.tsx initializes the theme store on mount so dark/light/system preference is applied before first render. --- .../dashboard/contract/shell/src/App.tsx | 5 +- .../shell/src/components/theme-toggle.tsx | 39 +++++ .../shell/src/components/ui/alert.tsx | 43 +++++ .../shell/src/components/ui/avatar.tsx | 38 +++++ .../shell/src/components/ui/button.tsx | 45 ++++++ .../contract/shell/src/components/ui/card.tsx | 56 +++++++ .../shell/src/components/ui/dropdown-menu.tsx | 153 ++++++++++++++++++ .../shell/src/components/ui/separator.tsx | 21 +++ .../shell/src/components/ui/skeleton.tsx | 7 + .../shell/src/intents/metric.counter.tsx | 15 +- .../contract/shell/src/intents/page.shell.tsx | 35 +++- .../contract/shell/src/runtime/fallbacks.tsx | 28 +++- 12 files changed, 467 insertions(+), 18 deletions(-) create mode 100644 extensions/dashboard/contract/shell/src/components/theme-toggle.tsx create mode 100644 extensions/dashboard/contract/shell/src/components/ui/alert.tsx create mode 100644 extensions/dashboard/contract/shell/src/components/ui/avatar.tsx create mode 100644 extensions/dashboard/contract/shell/src/components/ui/button.tsx create mode 100644 extensions/dashboard/contract/shell/src/components/ui/card.tsx create mode 100644 extensions/dashboard/contract/shell/src/components/ui/dropdown-menu.tsx create mode 100644 extensions/dashboard/contract/shell/src/components/ui/separator.tsx create mode 100644 extensions/dashboard/contract/shell/src/components/ui/skeleton.tsx diff --git a/extensions/dashboard/contract/shell/src/App.tsx b/extensions/dashboard/contract/shell/src/App.tsx index 577c377d..1db138a9 100644 --- a/extensions/dashboard/contract/shell/src/App.tsx +++ b/extensions/dashboard/contract/shell/src/App.tsx @@ -7,6 +7,7 @@ import { GraphRenderer } from "./runtime/renderer"; import { useContractGraph } from "./contract/hooks"; import { LoadingNode, ErrorNode } from "./runtime/fallbacks"; import { usePrincipalStore } from "./auth/principal"; +import { useThemeStore } from "@/lib/theme"; const queryClient = new QueryClient({ defaultOptions: { @@ -33,9 +34,11 @@ function PageRoute() { export function App() { const loadPrincipal = usePrincipalStore((s) => s.load); + const initTheme = useThemeStore((s) => s.init); useEffect(() => { + initTheme(); void loadPrincipal(); - }, [loadPrincipal]); + }, [loadPrincipal, initTheme]); return ( diff --git a/extensions/dashboard/contract/shell/src/components/theme-toggle.tsx b/extensions/dashboard/contract/shell/src/components/theme-toggle.tsx new file mode 100644 index 00000000..eedef8a3 --- /dev/null +++ b/extensions/dashboard/contract/shell/src/components/theme-toggle.tsx @@ -0,0 +1,39 @@ +import { Monitor, Moon, Sun } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useThemeStore } from "@/lib/theme"; + +export function ThemeToggle() { + const setTheme = useThemeStore((s) => s.setTheme); + const resolved = useThemeStore((s) => s.resolved); + const Icon = resolved === "dark" ? Moon : Sun; + + return ( + + + + + + setTheme("light")}> + + Light + + setTheme("dark")}> + + Dark + + setTheme("system")}> + + System + + + + ); +} diff --git a/extensions/dashboard/contract/shell/src/components/ui/alert.tsx b/extensions/dashboard/contract/shell/src/components/ui/alert.tsx new file mode 100644 index 00000000..b9d2bd49 --- /dev/null +++ b/extensions/dashboard/contract/shell/src/components/ui/alert.tsx @@ -0,0 +1,43 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + warning: + "border-amber-500/50 text-amber-900 bg-amber-50 dark:text-amber-100 dark:bg-amber-950/30 [&>svg]:text-amber-600", + }, + }, + defaultVariants: { variant: "default" }, + }, +); + +export const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = "Alert"; + +export const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = "AlertTitle"; + +export const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = "AlertDescription"; diff --git a/extensions/dashboard/contract/shell/src/components/ui/avatar.tsx b/extensions/dashboard/contract/shell/src/components/ui/avatar.tsx new file mode 100644 index 00000000..80688d5c --- /dev/null +++ b/extensions/dashboard/contract/shell/src/components/ui/avatar.tsx @@ -0,0 +1,38 @@ +import * as React from "react"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; +import { cn } from "@/lib/utils"; + +export const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +export const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +export const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; diff --git a/extensions/dashboard/contract/shell/src/components/ui/button.tsx b/extensions/dashboard/contract/shell/src/components/ui/button.tsx new file mode 100644 index 00000000..62d4c681 --- /dev/null +++ b/extensions/dashboard/contract/shell/src/components/ui/button.tsx @@ -0,0 +1,45 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { variant: "default", size: "default" }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +export const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); +Button.displayName = "Button"; + +export { buttonVariants }; diff --git a/extensions/dashboard/contract/shell/src/components/ui/card.tsx b/extensions/dashboard/contract/shell/src/components/ui/card.tsx new file mode 100644 index 00000000..6cf54b9f --- /dev/null +++ b/extensions/dashboard/contract/shell/src/components/ui/card.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +export const Card = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +Card.displayName = "Card"; + +export const CardHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardHeader.displayName = "CardHeader"; + +export const CardTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardTitle.displayName = "CardTitle"; + +export const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardDescription.displayName = "CardDescription"; + +export const CardContent = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ), +); +CardContent.displayName = "CardContent"; + +export const CardFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardFooter.displayName = "CardFooter"; diff --git a/extensions/dashboard/contract/shell/src/components/ui/dropdown-menu.tsx b/extensions/dashboard/contract/shell/src/components/ui/dropdown-menu.tsx new file mode 100644 index 00000000..2ef3cdd0 --- /dev/null +++ b/extensions/dashboard/contract/shell/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,153 @@ +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export const DropdownMenu = DropdownMenuPrimitive.Root; +export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; +export const DropdownMenuGroup = DropdownMenuPrimitive.Group; +export const DropdownMenuPortal = DropdownMenuPrimitive.Portal; +export const DropdownMenuSub = DropdownMenuPrimitive.Sub; +export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +export const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { inset?: boolean } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; + +export const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; + +export const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +export const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { inset?: boolean } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +export const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; + +export const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +export const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { inset?: boolean } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +export const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +export function DropdownMenuShortcut({ className, ...props }: React.HTMLAttributes) { + return ; +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; diff --git a/extensions/dashboard/contract/shell/src/components/ui/separator.tsx b/extensions/dashboard/contract/shell/src/components/ui/separator.tsx new file mode 100644 index 00000000..87de4a71 --- /dev/null +++ b/extensions/dashboard/contract/shell/src/components/ui/separator.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; +import { cn } from "@/lib/utils"; + +export const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => ( + +)); +Separator.displayName = SeparatorPrimitive.Root.displayName; diff --git a/extensions/dashboard/contract/shell/src/components/ui/skeleton.tsx b/extensions/dashboard/contract/shell/src/components/ui/skeleton.tsx new file mode 100644 index 00000000..185bc9db --- /dev/null +++ b/extensions/dashboard/contract/shell/src/components/ui/skeleton.tsx @@ -0,0 +1,7 @@ +import { cn } from "@/lib/utils"; + +export function Skeleton({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ); +} diff --git a/extensions/dashboard/contract/shell/src/intents/metric.counter.tsx b/extensions/dashboard/contract/shell/src/intents/metric.counter.tsx index 203ef13c..6394eec9 100644 --- a/extensions/dashboard/contract/shell/src/intents/metric.counter.tsx +++ b/extensions/dashboard/contract/shell/src/intents/metric.counter.tsx @@ -1,3 +1,4 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { useSubscription } from "../contract/hooks"; import type { IntentComponentProps } from "../runtime/registry"; @@ -21,9 +22,15 @@ export function MetricCounter({ node, props }: IntentComponentProps -
{title}
-
{value}
-
+ + + + {title} + + + +
{value}
+
+
); } diff --git a/extensions/dashboard/contract/shell/src/intents/page.shell.tsx b/extensions/dashboard/contract/shell/src/intents/page.shell.tsx index c2ab32ed..7281701d 100644 --- a/extensions/dashboard/contract/shell/src/intents/page.shell.tsx +++ b/extensions/dashboard/contract/shell/src/intents/page.shell.tsx @@ -1,16 +1,41 @@ +import { LayoutDashboard } from "lucide-react"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Separator } from "@/components/ui/separator"; +import { ThemeToggle } from "@/components/theme-toggle"; import { SlotRenderer } from "../runtime/slots"; import { usePrincipalStore } from "../auth/principal"; import type { IntentComponentProps } from "../runtime/registry"; +function initialsOf(name: string): string { + const parts = name.trim().split(/\s+/); + if (parts.length === 0) return ""; + if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase(); + return (parts[0]![0]! + parts[parts.length - 1]![0]!).toUpperCase(); +} + export function PageShell({ node, slots }: IntentComponentProps) { const principal = usePrincipalStore((s) => s.principal); const title = node.title ?? "Dashboard"; return ( -
-
-

{title}

-
- {principal ? {principal.displayName} : Loading…} +
+
+
+ +

{title}

+
+
+ + + {principal ? ( +
+ + {initialsOf(principal.displayName)} + + {principal.displayName} +
+ ) : ( + Loading… + )}
diff --git a/extensions/dashboard/contract/shell/src/runtime/fallbacks.tsx b/extensions/dashboard/contract/shell/src/runtime/fallbacks.tsx index 4bc5849d..163c6b4b 100644 --- a/extensions/dashboard/contract/shell/src/runtime/fallbacks.tsx +++ b/extensions/dashboard/contract/shell/src/runtime/fallbacks.tsx @@ -1,23 +1,35 @@ +import { AlertCircle, AlertTriangle } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Skeleton } from "@/components/ui/skeleton"; + export function UnknownIntent({ intent }: { intent: string }) { return ( -
- Unknown intent: {intent} -
+ + + Unknown intent + + The shell has no registered renderer for {intent}. + + ); } export function LoadingNode() { return ( -
- Loading… +
+ + +
); } export function ErrorNode({ message }: { message: string }) { return ( -
- Error: {message} -
+ + + Something went wrong + {message} + ); } From 32679ec761b93671bbf5ff1377d5ce6d0092018c Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sun, 10 May 2026 00:50:32 -0500 Subject: [PATCH 67/89] feat(dashboard/contract/shell): action vocabulary (button, menu, divider) Adds the action.button, action.menu, action.divider intents plus the runtime plumbing they need to know which contributor to address and how to resolve payload bindings from parent context. - runtime/context.tsx: ContributorProvider + ParentProvider with matching useContributor / useParent hooks. App.tsx wraps PageRoute in ContributorProvider so all leaf intents inherit the contributor owning the current graph. - runtime/bindings.ts: resolvePayload turns a ParamSource-shaped map ({ from: 'parent.id' } / { value: 42 } / literal) into resolved JS values against parent / session / route contexts. - intents/action.button.tsx: shadcn Button + AlertDialog confirmation; issues a kind=command via useContractCommand against the current contributor (or override via props.contributor) when clicked. - intents/action.menu.tsx: DropdownMenu wrapper that renders its 'items' slot as DropdownMenuItem (or DropdownMenuSeparator for intent='action.divider'). - intents/action.divider.tsx: standalone Separator for non-menu uses. Also includes infrastructure fixes that surfaced during execution: - Pinned lucide-react to ^0.460 (pnpm had picked the legacy 1.x line). - Switched to postcss.config.cjs to survive aggressive .js cleanups. - Build script now uses 'tsc --noEmit && vite build' so type-checking doesn't emit stray .js artifacts that confuse Vitest's resolver. 4 new tests cover ActionButton (label, payload binding to parent.id through a real CSRF + idempotency round trip, and the confirmation dialog flow). Total 17 tests across 5 test files. --- .../dashboard/contract/shell/package.json | 4 +- .../dashboard/contract/shell/pnpm-lock.yaml | 12 +- .../contract/shell/postcss.config.cjs | 6 + .../dashboard/contract/shell/src/App.tsx | 12 +- .../shell/src/components/ui/alert-dialog.tsx | 105 ++++++++++++++ .../shell/src/intents/action.button.tsx | 88 ++++++++++++ .../shell/src/intents/action.divider.tsx | 10 ++ .../shell/src/intents/action.menu.tsx | 82 +++++++++++ .../contract/shell/src/intents/register.ts | 6 + .../contract/shell/src/runtime/bindings.ts | 69 ++++++++++ .../contract/shell/src/runtime/context.tsx | 43 ++++++ .../contract/shell/test/actions.test.tsx | 130 ++++++++++++++++++ 12 files changed, 556 insertions(+), 11 deletions(-) create mode 100644 extensions/dashboard/contract/shell/postcss.config.cjs create mode 100644 extensions/dashboard/contract/shell/src/components/ui/alert-dialog.tsx create mode 100644 extensions/dashboard/contract/shell/src/intents/action.button.tsx create mode 100644 extensions/dashboard/contract/shell/src/intents/action.divider.tsx create mode 100644 extensions/dashboard/contract/shell/src/intents/action.menu.tsx create mode 100644 extensions/dashboard/contract/shell/src/runtime/bindings.ts create mode 100644 extensions/dashboard/contract/shell/test/actions.test.tsx diff --git a/extensions/dashboard/contract/shell/package.json b/extensions/dashboard/contract/shell/package.json index 4978e3b0..02b84eac 100644 --- a/extensions/dashboard/contract/shell/package.json +++ b/extensions/dashboard/contract/shell/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", + "build": "tsc --noEmit && vite build", "preview": "vite preview", "test": "vitest run --passWithNoTests", "test:watch": "vitest", @@ -27,7 +27,7 @@ "@tanstack/react-query": "^5.40.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "lucide-react": "^1.14.0", + "lucide-react": "^0.460.0", "react": "^18.3.0", "react-dom": "^18.3.0", "react-hook-form": "^7.75.0", diff --git a/extensions/dashboard/contract/shell/pnpm-lock.yaml b/extensions/dashboard/contract/shell/pnpm-lock.yaml index 8815d851..135ef8a0 100644 --- a/extensions/dashboard/contract/shell/pnpm-lock.yaml +++ b/extensions/dashboard/contract/shell/pnpm-lock.yaml @@ -51,8 +51,8 @@ importers: specifier: ^2.1.1 version: 2.1.1 lucide-react: - specifier: ^1.14.0 - version: 1.14.0(react@18.3.1) + specifier: ^0.460.0 + version: 0.460.0(react@18.3.1) react: specifier: ^18.3.0 version: 18.3.1 @@ -1593,10 +1593,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lucide-react@1.14.0: - resolution: {integrity: sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==} + lucide-react@0.460.0: + resolution: {integrity: sha512-BVtq/DykVeIvRTJvRAgCsOwaGL8Un3Bxh8MbDxMhEWlZay3T4IpEKDEpwt5KZ0KJMHzgm6jrltxlT5eXOWXDHg==} peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} @@ -3585,7 +3585,7 @@ snapshots: dependencies: yallist: 3.1.1 - lucide-react@1.14.0(react@18.3.1): + lucide-react@0.460.0(react@18.3.1): dependencies: react: 18.3.1 diff --git a/extensions/dashboard/contract/shell/postcss.config.cjs b/extensions/dashboard/contract/shell/postcss.config.cjs new file mode 100644 index 00000000..12a703d9 --- /dev/null +++ b/extensions/dashboard/contract/shell/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/extensions/dashboard/contract/shell/src/App.tsx b/extensions/dashboard/contract/shell/src/App.tsx index 1db138a9..ab58cf97 100644 --- a/extensions/dashboard/contract/shell/src/App.tsx +++ b/extensions/dashboard/contract/shell/src/App.tsx @@ -1,7 +1,7 @@ import { useEffect } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { BrowserRouter, Route, Routes, useParams } from "react-router-dom"; -import { IntentRegistryProvider } from "./runtime/context"; +import { ContributorProvider, IntentRegistryProvider } from "./runtime/context"; import { buildIntentRegistry } from "./intents/register"; import { GraphRenderer } from "./runtime/renderer"; import { useContractGraph } from "./contract/hooks"; @@ -9,6 +9,8 @@ import { LoadingNode, ErrorNode } from "./runtime/fallbacks"; import { usePrincipalStore } from "./auth/principal"; import { useThemeStore } from "@/lib/theme"; +const DEFAULT_CONTRIBUTOR = "core-contract"; + const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -24,12 +26,16 @@ const registry = buildIntentRegistry(); function PageRoute() { const params = useParams(); const route = `/${params["*"] ?? ""}`; - const { data, isLoading, error } = useContractGraph("core-contract", route); + const { data, isLoading, error } = useContractGraph(DEFAULT_CONTRIBUTOR, route); if (isLoading) return ; if (error) return ; if (!data) return ; - return ; + return ( + + + + ); } export function App() { diff --git a/extensions/dashboard/contract/shell/src/components/ui/alert-dialog.tsx b/extensions/dashboard/contract/shell/src/components/ui/alert-dialog.tsx new file mode 100644 index 00000000..3cf3b9cd --- /dev/null +++ b/extensions/dashboard/contract/shell/src/components/ui/alert-dialog.tsx @@ -0,0 +1,105 @@ +import * as React from "react"; +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; +import { cn } from "@/lib/utils"; +import { buttonVariants } from "./button"; + +export const AlertDialog = AlertDialogPrimitive.Root; +export const AlertDialogTrigger = AlertDialogPrimitive.Trigger; +export const AlertDialogPortal = AlertDialogPrimitive.Portal; + +export const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +export const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +export function AlertDialogHeader({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ); +} +AlertDialogHeader.displayName = "AlertDialogHeader"; + +export function AlertDialogFooter({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ); +} +AlertDialogFooter.displayName = "AlertDialogFooter"; + +export const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +export const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName; + +export const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +export const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; diff --git a/extensions/dashboard/contract/shell/src/intents/action.button.tsx b/extensions/dashboard/contract/shell/src/intents/action.button.tsx new file mode 100644 index 00000000..be971e20 --- /dev/null +++ b/extensions/dashboard/contract/shell/src/intents/action.button.tsx @@ -0,0 +1,88 @@ +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { useContractCommand } from "../contract/hooks"; +import { useContributor, useParent } from "../runtime/context"; +import { usePrincipalStore } from "../auth/principal"; +import { resolvePayload } from "../runtime/bindings"; +import type { IntentComponentProps } from "../runtime/registry"; + +interface ActionButtonProps { + label?: string; + variant?: "default" | "destructive" | "outline" | "secondary" | "ghost"; + size?: "default" | "sm" | "lg"; + /** When set, opens a confirmation dialog before issuing the command. */ + confirm?: string; + /** Override the contributor for the op. Defaults to the current ContributorContext. */ + contributor?: string; +} + +export function ActionButton({ + node, + props, +}: IntentComponentProps) { + const fallbackContributor = useContributor(); + const parent = useParent(); + const principal = usePrincipalStore((s) => s.principal); + + const op = node.op; + const label = props.label ?? node.title ?? op ?? "Action"; + const targetContributor = props.contributor ?? fallbackContributor; + + const command = useContractCommand(targetContributor ?? "core-contract", op ?? ""); + const [open, setOpen] = React.useState(false); + + const run = () => { + const resolved = resolvePayload(node.payload, { + parent, + session: { user: principal }, + }); + command.mutate(resolved); + }; + + const buttonEl = ( + + ); + + if (!props.confirm) return buttonEl; + + return ( + + {buttonEl} + + + Confirm + {props.confirm} + + + Cancel + {label} + + + + ); +} diff --git a/extensions/dashboard/contract/shell/src/intents/action.divider.tsx b/extensions/dashboard/contract/shell/src/intents/action.divider.tsx new file mode 100644 index 00000000..d7dbf559 --- /dev/null +++ b/extensions/dashboard/contract/shell/src/intents/action.divider.tsx @@ -0,0 +1,10 @@ +import { Separator } from "@/components/ui/separator"; + +/** + * action.divider renders a thin horizontal separator. When used inside an + * action.menu, the parent renders a DropdownMenuSeparator instead and this + * component is bypassed; standalone uses get a regular Separator. + */ +export function ActionDivider() { + return ; +} diff --git a/extensions/dashboard/contract/shell/src/intents/action.menu.tsx b/extensions/dashboard/contract/shell/src/intents/action.menu.tsx new file mode 100644 index 00000000..744287f4 --- /dev/null +++ b/extensions/dashboard/contract/shell/src/intents/action.menu.tsx @@ -0,0 +1,82 @@ +import { MoreHorizontal } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useContractCommand } from "../contract/hooks"; +import { useContributor, useParent } from "../runtime/context"; +import { usePrincipalStore } from "../auth/principal"; +import { resolvePayload } from "../runtime/bindings"; +import type { GraphNode } from "@/contract/types"; +import type { IntentComponentProps } from "../runtime/registry"; + +interface ActionMenuProps { + label?: string; +} + +/** + * action.menu groups action.button-shaped children into a dropdown. Children + * with intent="action.divider" render as separators; everything else renders + * as a clickable item that issues the command. + */ +export function ActionMenu({ node, props, slots }: IntentComponentProps) { + const items = slots["items"] ?? []; + const label = props.label ?? node.title ?? "Actions"; + + return ( + + + + + e.stopPropagation()}> + {items.map((child, i) => ( + + ))} + + + ); +} + +function MenuChild({ node }: { node: GraphNode }) { + const fallbackContributor = useContributor(); + const parent = useParent(); + const principal = usePrincipalStore((s) => s.principal); + + if (node.intent === "action.divider") { + return ; + } + + const targetContributor = + (typeof node.props?.contributor === "string" ? node.props.contributor : undefined) ?? + fallbackContributor; + const op = node.op ?? ""; + const label = + (typeof node.props?.label === "string" ? node.props.label : undefined) ?? + node.title ?? + op ?? + "Action"; + + const command = useContractCommand(targetContributor ?? "core-contract", op); + + return ( + { + if (!op) return; + const payload = resolvePayload(node.payload, { + parent, + session: { user: principal }, + }); + command.mutate(payload); + }} + disabled={!op || command.isPending} + > + {label} + + ); +} diff --git a/extensions/dashboard/contract/shell/src/intents/register.ts b/extensions/dashboard/contract/shell/src/intents/register.ts index e6a001e8..6ed89995 100644 --- a/extensions/dashboard/contract/shell/src/intents/register.ts +++ b/extensions/dashboard/contract/shell/src/intents/register.ts @@ -1,10 +1,16 @@ import { IntentRegistry, type IntentComponent } from "../runtime/registry"; import { PageShell } from "./page.shell"; import { MetricCounter } from "./metric.counter"; +import { ActionButton } from "./action.button"; +import { ActionMenu } from "./action.menu"; +import { ActionDivider } from "./action.divider"; export function buildIntentRegistry(): IntentRegistry { const reg = new IntentRegistry(); reg.register("page.shell", PageShell as unknown as IntentComponent); reg.register("metric.counter", MetricCounter as unknown as IntentComponent); + reg.register("action.button", ActionButton as unknown as IntentComponent); + reg.register("action.menu", ActionMenu as unknown as IntentComponent); + reg.register("action.divider", ActionDivider as unknown as IntentComponent); return reg; } diff --git a/extensions/dashboard/contract/shell/src/runtime/bindings.ts b/extensions/dashboard/contract/shell/src/runtime/bindings.ts new file mode 100644 index 00000000..ceda7fc7 --- /dev/null +++ b/extensions/dashboard/contract/shell/src/runtime/bindings.ts @@ -0,0 +1,69 @@ +import type { Principal } from "@/contract/types"; + +export interface BindingContext { + parent?: Record | null; + session?: { user?: Principal | null } | null; + route?: Record; +} + +/** + * resolvePayload turns a manifest-style payload map (whose values may be + * literal primitives, { value } objects, or { from: "scope.path" } references) + * into a flat record of resolved JS values. Unresolvable references become + * undefined and are dropped. + */ +export function resolvePayload( + payload: Record | undefined, + ctx: BindingContext, +): Record { + const out: Record = {}; + if (!payload) return out; + for (const [k, raw] of Object.entries(payload)) { + const v = resolveValue(raw, ctx); + if (v !== undefined) out[k] = v; + } + return out; +} + +/** resolveValue resolves a single ParamSource-like value. */ +export function resolveValue(raw: unknown, ctx: BindingContext): unknown { + if (raw === null) return null; + if (typeof raw !== "object") return raw; + // object: either { value } or { from } or a generic record (treated as literal) + const obj = raw as Record; + if ("from" in obj && typeof obj.from === "string") { + return resolvePath(obj.from, ctx); + } + if ("value" in obj) { + return obj.value; + } + return raw; // generic record passes through as a literal +} + +function resolvePath(path: string, ctx: BindingContext): unknown { + // path is like "parent.id" or "session.user.tenantID" or "route.tenant" + const segments = path.split("."); + if (segments.length === 0) return undefined; + const root = segments[0]; + const rest = segments.slice(1); + + let cursor: unknown; + switch (root) { + case "parent": + cursor = ctx.parent ?? undefined; + break; + case "session": + cursor = ctx.session ?? undefined; + break; + case "route": + cursor = ctx.route ?? undefined; + break; + default: + return undefined; + } + for (const seg of rest) { + if (cursor == null || typeof cursor !== "object") return undefined; + cursor = (cursor as Record)[seg]; + } + return cursor; +} diff --git a/extensions/dashboard/contract/shell/src/runtime/context.tsx b/extensions/dashboard/contract/shell/src/runtime/context.tsx index e6bdfdc5..47b906a7 100644 --- a/extensions/dashboard/contract/shell/src/runtime/context.tsx +++ b/extensions/dashboard/contract/shell/src/runtime/context.tsx @@ -19,3 +19,46 @@ export function useIntentRegistry(): IntentRegistry { if (!reg) throw new Error("useIntentRegistry called outside IntentRegistryProvider"); return reg; } + +// ContributorContext threads the contributor name through the renderer so leaf +// intents (action.button, form.edit, etc.) know which contributor to address +// when issuing commands or queries from a relative `op` reference. +const ContributorContext = createContext(null); + +export function ContributorProvider({ + value, + children, +}: { + value: string; + children: ReactNode; +}) { + return {children}; +} + +/** useContributor returns the contributor name owning the current graph subtree. */ +export function useContributor(): string { + const c = useContext(ContributorContext); + if (!c) throw new Error("useContributor called outside ContributorProvider"); + return c; +} + +// ParentContext exposes the nearest enclosing data row's value to children, so +// that nodes referencing { from: parent. } can resolve those values +// without a custom binding system. resource.list sets parent.* to the row +// object when rendering rowActions and detailDrawer slots. +const ParentContext = createContext | null>(null); + +export function ParentProvider({ + value, + children, +}: { + value: Record; + children: ReactNode; +}) { + return {children}; +} + +/** useParent returns the nearest enclosing parent record, or null when not within one. */ +export function useParent(): Record | null { + return useContext(ParentContext); +} diff --git a/extensions/dashboard/contract/shell/test/actions.test.tsx b/extensions/dashboard/contract/shell/test/actions.test.tsx new file mode 100644 index 00000000..e8a26605 --- /dev/null +++ b/extensions/dashboard/contract/shell/test/actions.test.tsx @@ -0,0 +1,130 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { ContributorProvider, ParentProvider } from "../src/runtime/context"; +import { ActionButton } from "../src/intents/action.button"; +import { ActionDivider } from "../src/intents/action.divider"; +import type { GraphNode } from "../src/contract/types"; + +const server = setupServer( + http.get("/api/dashboard/v1/csrf", () => + HttpResponse.json({ + token: "tok", + expiresAt: new Date(Date.now() + 3600_000).toISOString(), + }), + ), +); +beforeAll(() => server.listen({ onUnhandledRequest: "bypass" })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +function withProviders(ui: React.ReactNode, parent?: Record) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + {parent ? {ui} : ui} + + , + ); +} + +describe("ActionButton", () => { + it("renders label and is enabled when op is set", () => { + const node: GraphNode = { intent: "action.button", op: "user.disable" }; + withProviders( + , + ); + const btn = screen.getByRole("button", { name: "Disable" }); + expect(btn).toBeInTheDocument(); + expect(btn).not.toBeDisabled(); + }); + + it("issues a command on click and resolves parent. in payload", async () => { + type Captured = { intent: string; payload: unknown; csrf?: string }; + const captured: { value?: Captured } = {}; + server.use( + http.post("/api/dashboard/v1", async ({ request }) => { + captured.value = (await request.json()) as Captured; + return HttpResponse.json({ + ok: true, + envelope: "v1", + kind: "command", + data: null, + meta: {}, + }); + }), + ); + const node: GraphNode = { + intent: "action.button", + op: "user.disable", + payload: { id: { from: "parent.id" } }, + }; + withProviders( + , + { id: "u_42", name: "Alice" }, + ); + fireEvent.click(screen.getByRole("button", { name: "Disable" })); + await waitFor(() => { + expect(captured.value?.intent).toBe("user.disable"); + }); + expect(captured.value?.payload).toEqual({ id: "u_42" }); + expect(captured.value?.csrf).toBe("tok"); + }); + + it("opens a confirmation dialog when confirm is set; only fires after confirming", async () => { + let calls = 0; + server.use( + http.post("/api/dashboard/v1", () => { + calls++; + return HttpResponse.json({ + ok: true, + envelope: "v1", + kind: "command", + data: null, + meta: {}, + }); + }), + ); + const node: GraphNode = { intent: "action.button", op: "user.delete" }; + withProviders( + , + ); + // First click opens the dialog; doesn't fire the command yet. + fireEvent.click(screen.getByRole("button", { name: "Delete" })); + await screen.findByText("Are you sure?"); + expect(calls).toBe(0); + + // Confirming fires the command. + const dialogActions = screen.getAllByRole("button", { name: "Delete" }); + // The trigger and the action both have name "Delete"; the action is the + // one inside the AlertDialog. + fireEvent.click(dialogActions[dialogActions.length - 1]!); + await waitFor(() => expect(calls).toBe(1)); + }); +}); + +describe("ActionDivider", () => { + it("renders a horizontal separator", () => { + const { container } = render(); + const sep = container.querySelector('[role="none"], [data-orientation="horizontal"]'); + expect(sep).toBeTruthy(); + }); +}); From 8b7014800e3bc361baf842eec6bc493252f6ee55 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Sun, 10 May 2026 00:53:06 -0500 Subject: [PATCH 68/89] feat(dashboard/contract/shell): form vocabulary (form.edit, form.field) Adds form.edit and form.field intents plus the shadcn primitives they need (Input, Label, Textarea, Checkbox). - form.edit: optional data-binding to a query intent for prefill, fields rendered through the 'fields' slot, gathers field values into a record and submits via node.op as a kind=command. Manifest payload bindings (e.g., id from parent.id) merge with field values on submit. FormStateContext threads values+setter+submitting state to children. - form.field: branches by props.kind (text/email/number/password/ textarea/checkbox), labelled with shadcn Label, controlled inputs. Reads values and writes back via the form context. Test setup gains ResizeObserver + pointer-capture stubs that Radix primitives require under jsdom. 2 new tests cover field-render-and-submit (text + checkbox) and data-binding prefill from a query intent. Total 19 tests. --- .../shell/src/components/ui/checkbox.tsx | 23 ++++ .../shell/src/components/ui/input.tsx | 17 +++ .../shell/src/components/ui/label.tsx | 16 +++ .../shell/src/components/ui/textarea.tsx | 17 +++ .../contract/shell/src/intents/form.edit.tsx | 112 ++++++++++++++++ .../contract/shell/src/intents/form.field.tsx | 72 ++++++++++ .../contract/shell/src/intents/register.ts | 4 + .../contract/shell/test/form.test.tsx | 126 ++++++++++++++++++ .../dashboard/contract/shell/test/setup.ts | 20 +++ 9 files changed, 407 insertions(+) create mode 100644 extensions/dashboard/contract/shell/src/components/ui/checkbox.tsx create mode 100644 extensions/dashboard/contract/shell/src/components/ui/input.tsx create mode 100644 extensions/dashboard/contract/shell/src/components/ui/label.tsx create mode 100644 extensions/dashboard/contract/shell/src/components/ui/textarea.tsx create mode 100644 extensions/dashboard/contract/shell/src/intents/form.edit.tsx create mode 100644 extensions/dashboard/contract/shell/src/intents/form.field.tsx create mode 100644 extensions/dashboard/contract/shell/test/form.test.tsx diff --git a/extensions/dashboard/contract/shell/src/components/ui/checkbox.tsx b/extensions/dashboard/contract/shell/src/components/ui/checkbox.tsx new file mode 100644 index 00000000..7a043e35 --- /dev/null +++ b/extensions/dashboard/contract/shell/src/components/ui/checkbox.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { Check } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; diff --git a/extensions/dashboard/contract/shell/src/components/ui/input.tsx b/extensions/dashboard/contract/shell/src/components/ui/input.tsx new file mode 100644 index 00000000..78eb7ab6 --- /dev/null +++ b/extensions/dashboard/contract/shell/src/components/ui/input.tsx @@ -0,0 +1,17 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +export const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => ( + + ), +); +Input.displayName = "Input"; diff --git a/extensions/dashboard/contract/shell/src/components/ui/label.tsx b/extensions/dashboard/contract/shell/src/components/ui/label.tsx new file mode 100644 index 00000000..8350d964 --- /dev/null +++ b/extensions/dashboard/contract/shell/src/components/ui/label.tsx @@ -0,0 +1,16 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", +); + +export const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & VariantProps +>(({ className, ...props }, ref) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; diff --git a/extensions/dashboard/contract/shell/src/components/ui/textarea.tsx b/extensions/dashboard/contract/shell/src/components/ui/textarea.tsx new file mode 100644 index 00000000..018a98da --- /dev/null +++ b/extensions/dashboard/contract/shell/src/components/ui/textarea.tsx @@ -0,0 +1,17 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +export const Textarea = React.forwardRef< + HTMLTextAreaElement, + React.TextareaHTMLAttributes +>(({ className, ...props }, ref) => ( +